Автентифікація зондів за токеном, побудова 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>
20 KiB
NetPulse — журнал розробки
Стислий лог: що зроблено, які рішення прийняті, що далі. Мета — щоб наступна сесія не перечитувала весь код.
2026-08-14 — Етап 1: схема БД
Створено
netpulse/
├── HISTORY.md ← цей файл
├── docker-compose.yml TimescaleDB 2.17/pg16 + DragonflyDB
└── db/
├── README.md ERD, ключові рішення, ізоляція тенантів
├── migrate.ps1 накат міграцій + schema_migrations
└── migrations/
├── 0001_core.sql tenants, users, RBAC, secrets, audit
├── 0002_inventory.sql sites, devices, interfaces, credentials
├── 0003_agents_plugins.sql plugins, agents, check_types, checks, outbox
├── 0004_topology.sql neighbors, links, maps, nodes, edges, backgrounds
├── 0005_telemetry_timescale.sql hypertables, CAGG, compression, retention
├── 0006_ncm.sql repos, profiles, jobs, configs, diffs, rollback
├── 0007_alerting.sql rules, alerts, channels, routes, maintenance
├── 0008_dashboards.sql dashboards, widgets, SLA
├── 0009_billing_licensing.sql plans, subscriptions, entitlements, invoices, licenses
├── 0010_seed.sql довідники
└── 0011_rls.sql Row Level Security
Прийняті архітектурні рішення
- Фізична топологія ≠ візуальна.
topo.links(що є в мережі) окремо відtopo.map_edges(як намальовано). Один лінк — на багатьох мапах. - Зв'язки port→port через FK на
inv.interfaces, не текстом. Пара нормалізована черезLEAST/GREATEST, щоб A→B і B→A не дублювались. - Автовиявлення в два кроки: сирі
topo.neighbors(LLDP/CDP/ARP/FDB +confidence) → резолвер →topo.links. Прапорецьis_pinnedзахищає ручні лінки. - Дві моделі метрик: узагальнена
ts.series+ts.samples(плагіни реєструютьmetric_keyбез DDL) і широкіts.icmp_samples/ts.if_countersдля гарячих шляхів мапи. - Анімація трафіку має ланцюг даних:
if_counters.util_out_pct→ viewtopo.link_live→map_edges.animation. - Ліміти тарифу перевіряються двічі:
bill.entitlementsв API + тригери в БД. - Секрети лише шифровані (
core.secrets: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS). - Тіло конфігів у Git, метадані в БД (
ncm.configs.commit_sha+content_hash). - RLS за замовчуванням на кожній таблиці з
tenant_id; порожнійapp.tenant_id→ порожній результат.
Проблеми, на які натрапив (щоб не повторювати)
- Порядок seed ↔ RLS. Спершу RLS був 0010, seed 0011 — це ламається: після
FORCE ROW LEVEL SECURITYнавіть власник схеми не вставить довідники зtenant_id IS NULL. Файли переставлені місцями. - CAGG не працюють у транзакції.
CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous)падає всередині транзакційного блоку.migrate.ps1детектить це по вмісту файлу й вимикає--single-transactionдля 0005.
Помилки, знайдені прогоном на живому Postgres
window— зарезервоване слово.core.sla_targets.window→period_kind.core.check_types.keyбув доменомcore.slug, але ключі мають виглядicmp.ping— крапка не проходить. Замінено наtextз власним CHECK^[a-z0-9]+(\.[a-z0-9_]+)+$; так самоcore.checks.check_type.- Домен
core.slugне пропускав підкреслення, а ключі фіч —http_checks,auto_discovery. Регекс розширено до[a-z0-9_-]. - RLS блокує не CAGG, а стиснення. Початкове припущення «CAGG + RLS несумісні» виявилось хибним. Реальна відмова TimescaleDB 2.29:
operation not supported on hypertables that have columnstore enabled— тобто конфлікт саме з compression. Тому в 0011 виключено всі hypertables (черезtimescaledb_information.hypertables), а не три захардкоджені.
Перевірено на живому стенді
Debian 13 (LXC, 192.168.1.203) / PostgreSQL 17.11 / TimescaleDB 2.29.1. Усі 11 міграцій — на чисту БД без помилок. Створено: 81 таблиця, 12 hypertables, 6 continuous aggregates, 25 фонових job-ів, 63 RLS-політики, 220 індексів.
db/tests/smoke.sql — 8 функціональних перевірок, усі PASS:
нормалізація пари лінків, CHECK на device-ноду, дедуплікація алертів,
ліміт пристроїв тарифу Free, стан полотна мапи одним запитом,
ребро port→port з живим util_pct, роллап CAGG, ізоляція тенантів RLS.
Бази на сервері: netpulse (схема + smoke-дані). Перестворити:
sudo -u postgres dropdb netpulse && createdb -O netpulse netpulse, далі міграції.
2026-08-14 — Етап 2 (частина 1): protobuf-контракт агент↔сервер
Створено
netpulse/
├── buf.yaml, buf.gen.yaml
├── proto/
│ ├── README.md контракт: сервіси, потоки, семантика, безпека
│ └── netpulse/v1/
│ ├── common.proto Status, Transport, Error, DeviceTarget, Credential, AgentHealth
│ ├── agent.proto EnrollmentService, AgentService, ControlUp/ControlDown
│ ├── telemetry.proto SeriesDescriptor, MetricSample, IcmpResult, InterfaceCounters
│ ├── discovery.proto NeighborRecord, InterfaceRecord, DiscoveredDevice
│ ├── ncm.proto ConfigJob, ConfigUpload (header/chunk/trailer), ConfigApplyJob
│ └── logs.proto SyslogEntry, SnmpTrap, LogBatch
├── gen/go/ згенерований код (комітиться)
└── test/contract/ наскрізні gRPC-тести на bufconn
Прийняті рішення
- Форма контракту випливає з одного обмеження: усі з'єднання ініціює агент.
«Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого
агентом bidi-стріму
Control, а не RPC у бік агента. - Чотири окремі стріми (Control / Telemetry / Logs / Config), а не один: пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії не має топити телеметрію.
- Інтернування серій. Агент реєструє серію раз під
series_ref, далі шле лише номер. Заміряно: 66 → 25 байт на семпл.series_refживе в межах сесії. - Швидкості рахує агент (лише він знає точний інтервал опитування), але шле й сирі лічильники — щоб сервер міг перерахувати заднім числом.
- Scrub/redact конфігів — на сервері. Агент віддає сирий текст; правила
живуть у
ncm.profilesі змінюються без оновлення агентів у полі. - Топологію зводить сервер. Агент доповідає лише «на порту X бачу chassis Y».
Task.params_json— непрозорі байти. Новий плагін не потребує зміни .proto. Модуль-виконавець виводиться з префіксаcheck_typeдо крапки.- At-least-once + upsert. Дедуплікацію дають PK схеми БД
(ts, device_id)тощо. - Зворотний тиск диктує сервер у
Welcomeі кожномуTelemetryAck. - Самооновлення підписане Ed25519 — інакше компрометація CDN = RCE в мережі кожного клієнта.
Перевірено на стенді
Debian 13 (192.168.1.203): protoc 3.21.12, Go 1.24.4, buf 1.72.0.
protoc— усі 6 файлів валідні;buf lint(STANDARD) — без зауважень;- генерація Go+gRPC,
go build,go vet— чисто; go test ./test/contract/...— 5/5 PASS: рукостискання й push плану задач, інтернування серій, реакція на невідомийseries_ref, чанкування конфігу зі звіркою sha256, відмова при пошкодженій контрольній сумі.
Дрібниця для наступного разу: buf.yaml довелось звільнити від
RPC_REQUEST_STANDARD_NAME та сусідніх правил — вони припускають пари
запит-відповідь, а ControlUp/ControlDown це незалежні потоки подій.
2026-08-14 — Етап 2 (частина 2): Go-агент
Створено
netpulse/
├── .gitignore, .gitattributes (репозиторій: LF усюди, крім .ps1)
└── agent/
├── README.md будова, рішення, параметри чеків
├── go.mod replace → ../gen/go
├── cmd/netpulse-agent/ точка входу, GOMEMLIMIT, keepalive
└── internal/
├── config/ прапорці + NETPULSE_*, mTLS
├── module/ контракт модуля, реєстр, маршрутизація
├── telemetry/ interner.go (series_ref) + buffer.go
├── scheduler/ min-heap, семафор, schedule_offset
├── session/ gRPC-клієнт, реконект, ack, план задач
└── modules/icmp, /snmp
Прийняті рішення
- Модулі вкомпільовані, без динамічного завантаження. Один бінарник має працювати на Alpine, Windows і роутері з musl. «Активація» = дозвіл сервера.
- Креденшели беруться на момент виконання, не з плану: у них TTL, і прострочені не віддаються взагалі — інакше агент заблокує обліковий запис на половині комутаторів клієнта.
- Розклад вирівняний по сітці інтервалу, тому після рестарту задача повертається у свій слот, а не з'їжджає.
- Буфер викидає найстаріше. Після відновлення зв'язку цінніший поточний стан. Зміни статусу викидаються останніми й пролазять у батч першими.
- Швидкості інтерфейсів рахує агент (знає фактичний інтервал); при
перевороті лічильника —
counter_resetзамість стрибка на терабіт. - Один писар у контрольний стрім — gRPC не допускає паралельних Send.
GOMEMLIMIT48 МБ у коді: хай GC працює агресивніше, ніж OOM killer осліпить моніторинг саме тоді, коли він потрібен.
Перевірено на стенді
Debian 13, Go 1.25.13. go vet чисто, go test ./... -race — усі пакети ok.
Релізний бінарник (CGO_ENABLED=0 -trimpath -s -w): 12 МБ, базовий
RSS 11.6 МБ у циклі реконекту (бюджет 30 МБ).
Не перевірено
TestPingLoopbackпід звичайним користувачем в unprivileged LXC пропускається: ядро не дає ані unprivileged-, ані raw-сокета,sysctl ping_group_rangeнедоступний. Під root на тому ж стенді тест проходить — ICMP-модуль перевірений проти реального сокета. У проді потрібенCAP_NET_RAW.- Модуль snmp не перевірявся проти живого пристрою — на стенді немає
SNMP-агента. Компілюється й проходить vet; логіка перевороту лічильників
і
util_pctчекає на реальне обладнання.
Репозиторій
https://git.zotac.keenetic.link/zotac/Netpulse_SasS.git (Forgejo).
Читання анонімне, push вимагає токена — Forgejo не пускає навіть у
публічний репозиторій без автентифікації (Credentials are incorrect).
Коміти лежать локально в main і чекають на токен.
2026-08-14 — Етап 2 (частина 3): серверна сторона AgentService
Створено
netpulse/server/
├── README.md рішення, параметри, стан перевірки
├── cmd/netpulse-server/ TLS, keepalive, m'яка зупинка, keyring із -dek
└── internal/
├── crypto/ AES-GCM-256, keyring із ротацією ключів
├── store/ agents, plan, credentials, telemetry, discovery, ncm
└── grpcapi/ AgentService + перехоплювачі автентифікації
Плюс правки в агенті: -token і передача його в метаданих кожного виклику;
Interner.MarkAllPending() — перереєстрація серій на початку сесії.
Прийняті рішення
- Ізоляція тенантів робиться двічі: RLS (
SET LOCAL app.tenant_id) плюс явний предикатtenant_id. Не перестраховка: RLS не працює на гіпертаблях, а саме туди йде вся телеметрія. schedule_offset— чиста функція відcheck_id, тож будь-який вузол сервера дає те саме значення.- Хеш плану — лише з полів, що впливають на поведінку. Зміна опису пристрою не змушує переливати 50 000 задач.
- Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити. Чужий
agent_idпри валідному токені →PermissionDenied. - DEK не покидає сервер. Дамп БД без ключів не дає жодного пароля. Комплект креденшелів із TTL 1 год.
- Запис телеметрії —
ON CONFLICT DO NOTHING(at-least-once). - Статус пристрою — один запит із умовним записом в історію, інакше два
воркери наввипередки писали б неіснуючі переходи
up→up. - Впевненість зіставлення спадає за надійністю ознаки: chassis-id 95 → MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина.
- Перереєстрація серій замість обнулення нумерації. Спершу агент мав би скидати interner на реконекті, але це викидало б увесь накопичений за час обриву буфер — саме ті дані, заради яких він накопичувався.
Перевірено на стенді
11 інтеграційних тестів проти живої БД зі схемою Етапу 1 і справжнього gRPC —
усі PASS з -race. Найцінніше: повтор батчу не дублює ані рядки, ані переходи в
історії; зустрічний звіт B→A не створює другий лінк; ручний (is_pinned) лінк не
затирається; тіло конфігу лежить зашифрованим.
Живий наскрізний прогін (справжній агент + справжній сервер + БД, 40 с,
icmp.ping кожні 5 с проти 127.0.0.1): 5 ICMP-семплів, метрики icmp.rtt_avg
0.108 мс / jitter / loss, статус unknown → up з причиною icmp і рівно одним
переходом в історії, heartbeat із RSS 11.6 МБ і dropped_samples=0, зонд
позначений offline після зупинки, clock skew −0.9 мс.
Знайдено під час перевірки
Приведення типу на місці ($1::text) не розв'язує конфлікт виведення типів у
Postgres, а нав'язує тип обом уживанням параметра. Коли $1 потрібен і як uuid
для колонки, і як text для конкатенації, кастувати треба протилежне уживання:
VALUES ($1::uuid, …, '/шлях/' || $1 || '.git'). Та сама пастка двічі: у сіді
тесту (VALUES ($1, $1, …) для core.slug і text) і в ncm.repos.
Чого ще немає
- Git-двигун (libgit2): тіло конфігу шифрується в
core.secrets,commit_shaтимчасово = hex контентного хеша. Дедуплікація йprev_config_idпрацюють, тож diff будується вже зараз. EnrollmentService— зонди заводяться вставкою вcore.agents.- Сервер не надсилає
TaskDelta(лише повний план) і не ініціюєConfigJob.
Далі
- Модуль topology на агенті: LLDP/CDP/ARP/FDB →
topo.neighbors. EnrollmentService+ видача сертифікатів.- Планувальник NCM: бекап за cron і за Syslog-подією.
- REST/WebSocket API для UI поверх тієї ж БД.