Netpulse_SasS/server/README.md
zotac aaf067a46b Етап 2: сервер сам заводить snmp.if-чеки з виявлених інтерфейсів
Автовиявлення наповнювало 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>
2026-08-14 15:10:02 +03:00

177 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-подією ще не написаний.