Netpulse_SasS/server/README.md
zotac 8a92ef8a45 Етап 2: серверна сторона AgentService
Автентифікація зондів за токеном, побудова TaskPlan із детермінованим
schedule_offset, видача розшифрованих креденшелів із TTL, запис телеметрії
в гіпертаблиці, резолвер сусідів LLDP/CDP у topo.links, прийом конфігів.

Ізоляція тенантів робиться двічі — RLS плюс явний предикат tenant_id, бо
RLS не працює на гіпертаблях, а саме туди йде вся телеметрія.

Агент: -token і передача його в метаданих; MarkAllPending() перереєстровує
серії на початку сесії замість обнуляти нумерацію й губити буфер.

Перевірено на Debian 13 / PG 17.11 / TimescaleDB 2.29.1: 11 інтеграційних
тестів проти живої БД (-race), плюс живий прогін справжнього агента проти
справжнього сервера — телеметрія, статус пристрою, heartbeat у базі.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 04:13:40 +03:00

11 KiB
Raw Blame History

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
    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.

Автовиявлення інтерпретує сервер. Агент доповідає лише «на порту 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 збіг хеша → сервер не шле план

Живий наскрізний прогін

Справжній 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 мс

Знайдено під час перевірки

Приведення типу на місці ($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-подією ще не написаний.