Netpulse_SasS/server
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00
..
cmd Дві сесії роботи: 0058–0068, розгортання однією командою, тести 2026-08-27 17:32:49 +03:00
internal Дві сесії роботи: 0058–0068, розгортання однією командою, тести 2026-08-27 17:32:49 +03:00
migrations Дві сесії роботи: 0058–0068, розгортання однією командою, тести 2026-08-27 17:32:49 +03:00
webui Пакування: runner міграцій і вшитий у бінарник фронтенд 2026-08-25 01:01:55 +03:00
API.md Дві сесії роботи: 0058–0068, розгортання однією командою, тести 2026-08-27 17:32:49 +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-подією ще не написаний.