go-git, чистий Go без cgo. Голий репозиторій на тенанта, гілка на пристрій за ідентифікатором (ім'я змінюють, історія не має від цього розсипатись), файл за іменем. Однаковий вміст нового коміту не створює. netpulse-gitsync переливає накопичену історію й відтворює втрачений репозиторій із бази — тіла конфігів там і так лежать зашифрованими. У вебі з'явився вибір версії, з якою порівнювати: сервер це вмів (?from=), інтерфейс — ні. Дорогою виправлено чотири тести, які CI запустив уперше з базою. Серед них справжня помилка: машинний токен не міг читати мапи — ACL мап отримував порожній рядок замість uuid і давав 500. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| cmd | ||
| internal | ||
| migrations | ||
| webui | ||
| API.md | ||
| go.mod | ||
| go.sum | ||
| README.md | ||
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-подією ще не написаний.