Автовиявлення наповнювало inv.interfaces, але їх ніхто не опитував: на мапі були лінки й не було трафіку. Тепер сервер формує snmp.if-чек зі складу портів і штовхає його живій сесії як TaskDelta — без цього після кожного нового комутатора були б години порожніх графіків. Чек оновлюється, а не задвоюється: унікальний індекс core.checks включає md5(params). Склад портів порівнюється як множина, бо порядок ключів у jsonb не гарантований. Без SNMP-креденшела чек не створюється. Живий прогін знайшов помилку: OID у запиті йшов без провідної крапки, а pdu.Name повертається з нею — пошук у мапі мовчки не знаходив нічого, і чек виглядав як "жоден інтерфейс не відповів". Канонізація тепер у snmpx.Normalize, застосована з обох боків. Перевірено наскрізь: виявлення -> автостворення чека -> справжні HC-лічильники -> ts.if_counters, без жодного ручного кроку. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
177 lines
14 KiB
Markdown
177 lines
14 KiB
Markdown
# NetPulse Server — AgentService
|
||
|
||
Приймальна сторона: зонди підключаються сюди самі, сервер до них не ходить.
|
||
|
||
```bash
|
||
go build -trimpath -ldflags "-s -w" -o netpulse-server ./cmd/netpulse-server
|
||
```
|
||
|
||
```bash
|
||
./netpulse-server -listen :9443 -dsn "postgres://..." -dek "k1=<hex32>" -cert srv.pem -key srv.key -client-ca agents-ca.pem
|
||
```
|
||
|
||
## Будова
|
||
|
||
```
|
||
cmd/netpulse-server/ точка входу, TLS, keepalive, m'яка зупинка
|
||
internal/
|
||
crypto/ AES-GCM-256 для core.secrets, keyring із ротацією
|
||
store/
|
||
store.go пул pgx, InTenantTx із SET LOCAL app.tenant_id
|
||
agents.go автентифікація зонда за токеном, heartbeat, статуси задач
|
||
plan.go побудова TaskPlan, детермінований schedule_offset, хеш плану
|
||
credentials.go розшифровка секретів і видача комплекту з TTL
|
||
telemetry.go резолвер серій + запис у гіпертаблиці
|
||
discovery.go сусіди → зіставлення з інвентарем → topo.links
|
||
autochecks.go snmp.if-чеки з виявлених інтерфейсів
|
||
ncm.go прийом конфігів, дедуплікація, шифроване тіло
|
||
grpcapi/ AgentService: Control, StreamTelemetry, StreamLogs,
|
||
ReportDiscovery, UploadConfig + перехоплювачі автентифікації
|
||
```
|
||
|
||
## Рішення, які варто розуміти
|
||
|
||
**Ізоляція тенантів робиться двічі.** RLS у БД (через `SET LOCAL app.tenant_id`) плюс
|
||
явний предикат `tenant_id` у кожному запиті. Дублювання не зайве: RLS **не працює на
|
||
гіпертаблях** — TimescaleDB не поєднує row level security зі стисненням, а саме туди
|
||
йде вся телеметрія. Для неї другий механізм єдиний, тому він не «про всяк випадок».
|
||
|
||
**`SET LOCAL`, а не `SET`.** Значення живе до кінця транзакції й не протікає на
|
||
наступний запит, який візьме те саме з'єднання з пулу. Протікання тут означало б показ
|
||
чужих даних.
|
||
|
||
**`schedule_offset` рахує сервер, детерміновано від `check_id`** (`fnv64 % interval`).
|
||
Функція чиста, тому будь-який вузол сервера дає те саме значення, а зонд після
|
||
перезапуску повертається у свій слот. Інакше 5000 чеків з інтервалом 60 с раз на
|
||
хвилину били б сплеском.
|
||
|
||
**Хеш плану рахується з полів, що впливають на поведінку** — id, тип, параметри,
|
||
інтервали. Зміна опису пристрою не змушує переливати 50 000 задач після кожного обриву
|
||
зв'язку. Агент шле хеш у `Hello`, сервер відповідає `task_plan_follows=false`, якщо він
|
||
збігся.
|
||
|
||
**Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити.** Обидва потрібні.
|
||
Зонд, який назвався чужим `agent_id` при валідному токені, отримує `PermissionDenied`:
|
||
інакше він забрав би чужий план і чужі креденшели.
|
||
|
||
**Креденшели розшифровуються на сервері.** DEK не покидає сервер; у БД лише `key_id`,
|
||
тому дамп бази без доступу до ключів не дає жодного пароля від обладнання клієнта.
|
||
Комплект їде з TTL в годину — після відкликання доступу зонд перестане ним
|
||
користуватись сам, навіть якщо зв'язок із ним втрачено.
|
||
|
||
**Один нечитабельний секрет не рушить весь комплект.** Решта пристроїв мусить
|
||
опитуватись далі.
|
||
|
||
**Запис телеметрії — `ON CONFLICT DO NOTHING`.** Семантика доставки at-least-once, тож
|
||
повторний батч після реконекту не має ані падати, ані дублювати рядки. Первинні ключі
|
||
`(ts, device_id)`, `(ts, interface_id)`, `(ts, series_id)` роблять це безпечним.
|
||
|
||
**Статус пристрою змінюється одним запитом із умовним записом в історію.** Не
|
||
read-modify-write: два воркери, що обробляють сусідні батчі, наввипередки писали б
|
||
неіснуючі переходи `up→up`.
|
||
|
||
**Виявлені інтерфейси одразу отримують snmp.if-чек.** Без цього кроку
|
||
автовиявлення наповнює `inv.interfaces`, але ніхто їх не опитує: на мапі є лінки й
|
||
немає трафіку. Чекати наступного перепідключення агента, щоб він забрав новий
|
||
план, — це години порожніх графіків після кожного нового комутатора, тому зміна
|
||
штовхається живій сесії як `TaskDelta`. Чек не створюється для пристрою без
|
||
SNMP-креденшела: він лише щохвилини писав би помилку автентифікації.
|
||
|
||
**Склад портів порівнюється як множина, а не як рядок JSON.** Postgres не
|
||
гарантує порядок ключів у `jsonb`, тому пряме порівняння давало б хибну зміну на
|
||
кожному обході автовиявлення — і агент отримував би новий план щоразу.
|
||
|
||
**Автовиявлення інтерпретує сервер.** Агент доповідає лише «на порту X бачу chassis Y».
|
||
Впевненість зіставлення спадає за надійністю ознаки: chassis-id (95) → MAC (90) →
|
||
IP керування (80) → sysName (60). Останнє низьке навмисно: `sysName` вводить людина, і
|
||
на двох комутаторах цілком може бути `switch`. Лінк із `is_pinned` автовиявлення не
|
||
чіпає — інакше кожен запуск затирав би ручні правки.
|
||
|
||
## Параметри
|
||
|
||
| Прапорець | Змінна | Призначення |
|
||
|-----------|--------|-------------|
|
||
| `-listen` | `NETPULSE_LISTEN` | адреса gRPC, типово `:9443` |
|
||
| `-dsn` | `NETPULSE_DSN` | PostgreSQL |
|
||
| `-dek` | `NETPULSE_DEK` | ключі шифрування `id=<hex\|base64>[,...]` |
|
||
| `-cert` / `-key` | `NETPULSE_CERT` / `_KEY` | сертифікат сервера |
|
||
| `-client-ca` | `NETPULSE_CLIENT_CA` | CA зондів; вмикає mTLS |
|
||
| `-insecure` | `NETPULSE_INSECURE=1` | без TLS, лише локальний стенд |
|
||
|
||
## Стан перевірки
|
||
|
||
Стенд Debian 13 / PostgreSQL 17.11 / TimescaleDB 2.29.1 / Go 1.25.
|
||
`go vet` чисто, `go test -race` — усі тести проходять.
|
||
|
||
Інтеграційні тести працюють проти **справжньої БД зі схемою Етапу 1** і справжнього
|
||
gRPC (`NETPULSE_TEST_DSN`; без змінної пропускаються):
|
||
|
||
| Тест | Що доводить |
|
||
|------|-------------|
|
||
| `TestControlHandshake` | план зібрано з `core.checks`, offset детермінований і в межах інтервалу, креденшели розшифрувались тим самим ключем і AAD, зонд позначений online |
|
||
| `TestControlRejectsMismatchedAgentID` | чужий `agent_id` при валідному токені відхилено |
|
||
| `TestUnauthenticatedRejected` | без токена сесії немає |
|
||
| `TestTelemetryPersisted` | семпл, ICMP і `util_out_pct` у гіпертаблицях; статус пристрою піднявся; **повтор батчу не продублював ані рядки, ані переходи в історії** |
|
||
| `TestTelemetryUnknownSeriesRef` | невідомий ref → `reset_series_table`, а не тихе відкидання |
|
||
| `TestDiscoveryResolvesLink` | сусід зіставлений за chassis-id, лінк створено з портами й `capacity_bps`; **зустрічний звіт B→A не створив дублікат** |
|
||
| `TestDiscoveryRespectsPinnedLink` | ручний лінк не затерто |
|
||
| `TestConfigUploadAndDedup` | чанки склеєні, тіло збережено **зашифрованим** і читається назад; повторний збір не створює версію |
|
||
| `TestConfigUploadRejectsBadChecksum` | зіпсований конфіг не потрапляє в базу |
|
||
| `TestHeartbeatPersisted` | самометрики в `ts.agent_health`, `dropped_samples` видно у зведенні зонда |
|
||
| `TestPlanHashSkipsResend` | збіг хеша → сервер не шле план |
|
||
| `TestDiscoveryCreatesInterfaceChecks` | виявлені порти дають `snmp.if`-чек із `interface_id` і `speed_bps`; loopback відсіяно; зміна складу портів **оновлює** чек, а не задвоює |
|
||
| `TestNoInterfaceCheckWithoutSnmpCredential` | без SNMP-креденшела чек не створюється, але інтерфейси все одно збережені |
|
||
|
||
### Живий наскрізний прогін
|
||
|
||
Справжній `netpulse-agent` проти справжнього `netpulse-server` і живої БД,
|
||
40 секунд, чек `icmp.ping` кожні 5 с проти `127.0.0.1`:
|
||
|
||
| Що перевірено | Результат |
|
||
|---------------|-----------|
|
||
| Зонд підключився, отримав план | `tasks=1, plan_unchanged=false` |
|
||
| ICMP-семпли в `ts.icmp_samples` | 5 за 25 с — рівно за розкладом |
|
||
| Узагальнені метрики | `icmp.rtt_avg` 0.108 мс, `icmp.jitter`, `icmp.loss_pct` |
|
||
| Статус пристрою | `unknown → up`, причина `icmp`, **рівно один перехід в історії** |
|
||
| Heartbeat | записано, RSS зонда 11.6 МБ, `dropped_samples=0` |
|
||
| Версія/ОС/архітектура зонда | зафіксовані в `core.agents` |
|
||
| Розрив сесії | зонд позначений `offline` |
|
||
| Розсинхронізація годинника | −0.9 мс |
|
||
|
||
### Наскрізний ланцюг проти справжнього SNMP
|
||
|
||
Стенд із `snmpd` + `lldpd`. Агент і сервер запущені як є, без жодного ручного
|
||
кроку між ними:
|
||
|
||
| Крок | Результат |
|
||
|------|-----------|
|
||
| `topology.discover` знайшов порти | `lo`, `eth0` у `inv.interfaces` |
|
||
| Сервер створив `snmp.if`-чек | 1 порт (loopback відсіяно), 10 Гбіт/с |
|
||
| Дельта доїхала до живої сесії | `надіслано_наживо: true` |
|
||
| Агент опитав справжні HC-лічильники | `in_octets` 625 246 266, `in_bps` 11 938 |
|
||
| `util_out_pct` пораховано | 0.000001 % — знаменник 10 Гбіт/с із `ifHighSpeed` |
|
||
| Помилок чеків | немає |
|
||
|
||
Це повний шлях даних для анімації трафіку на мапі: від виявлення порту до
|
||
`ts.if_counters`, без жодного ручного налаштування.
|
||
|
||
### Знайдено під час перевірки
|
||
|
||
Приведення типу на місці (`$1::text`) **не розв'язує** конфлікт виведення типів у
|
||
Postgres, а нав'язує тип обом уживанням параметра. Коли `$1` потрібен і як `uuid` для
|
||
колонки, і як `text` для конкатенації, кастувати треба протилежне уживання:
|
||
`VALUES ($1::uuid, …, '/шлях/' || $1 || '.git')`.
|
||
|
||
## Чого ще немає
|
||
|
||
- **Git-двигун (libgit2) не підключено.** Тіло конфігу зберігається зашифрованим у
|
||
`core.secrets`, а `ncm.configs.commit_sha` тимчасово містить hex контентного хеша.
|
||
Дедуплікація, підрахунок рядків і ланцюжок `prev_config_id` працюють уже зараз, тож
|
||
diff між версіями будується без Git. Коли двигун з'явиться, зміниться лише джерело
|
||
`commit_sha`.
|
||
- **`EnrollmentService` не реалізовано** — зонди поки заводяться вставкою в
|
||
`core.agents` з `sha256` токена.
|
||
- **`TaskDelta` сервер не надсилає**: при зміні плану поки йде повна синхронізація.
|
||
Механізм на боці агента вже є.
|
||
- **`ConfigJob` / `ConfigApplyJob` сервер не ініціює** — планувальник бекапів за cron
|
||
і Syslog-подією ще не написаний.
|