Netpulse_SasS/server
byrsapty 65b37a28ff
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння
go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на
пристрій за ідентифікатором (ім'я змінюють, історія не має від цього
розсипатись), файл за іменем. Однаковий вміст нового коміту не створює.

netpulse-gitsync переливає накопичену історію й відтворює втрачений
репозиторій із бази — тіла конфігів там і так лежать зашифрованими.

У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів
(?from=), інтерфейс — ні.

Дорогою виправлено чотири тести, які CI запустив уперше з базою.
Серед них справжня помилка: машинний токен не міг читати мапи —
ACL мап отримував порожній рядок замість uuid і давав 500.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:14 +03:00
..
cmd Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння 2026-08-25 13:15:14 +03:00
internal Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння 2026-08-25 13:15:14 +03:00
migrations Пакування: runner міграцій і вшитий у бінарник фронтенд 2026-08-25 01:01:55 +03:00
webui Пакування: runner міграцій і вшитий у бінарник фронтенд 2026-08-25 01:01:55 +03:00
API.md Етап 8: реєстрація зонда одноразовим запрошенням 2026-08-25 00:50:57 +03:00
go.mod Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння 2026-08-25 13:15:14 +03:00
go.sum Git-двигун NCM: версіювання конфігів, переливання історії, довільне порівняння 2026-08-25 13:15:14 +03:00
README.md Етап 2: сервер сам заводить snmp.if-чеки з виявлених інтерфейсів 2026-08-14 15:10:02 +03:00

NetPulse Server — AgentService

Приймальна сторона: зонди підключаються сюди самі, сервер до них не ходить.

go build -trimpath -ldflags "-s -w" -o netpulse-server ./cmd/netpulse-server
./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-подією ще не написаний.