# 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=" -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=[,...]` | | `-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-подією ще не написаний.