Правило «процесор вище 85% — це проблема» описує клас пристроїв, а не окремий хост. Тепер воно живе поруч із перевірками, які дають йому дані, а не окремою сторінкою, де його доводилось повторювати руками для кожного комутатора. Тригер розгортається в ОДНЕ правило alr.rules із селектором за шаблоном, а не в правило на кожен хост: призначили шаблон новому пристрою — він одразу під правилом, без перегенерації. Форма шаблону розкладена на вкладки Загальне/Перевірки/Графіки/Тригери з лічильниками; смуга вкладок винесена у спільний компонент. Умова тригера редагується полями, JSON лишився запасним виходом. Правила з шаблону в списку правил помічені й не редагуються там. Клон копіює тригери вимкненими, щоб копія не подвоїла сповіщення. 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-подією ще не написаний.