Netpulse_SasS/HISTORY.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

20 KiB
Raw Blame History

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

Прийняті архітектурні рішення

  1. Фізична топологія ≠ візуальна. topo.links (що є в мережі) окремо від topo.map_edges (як намальовано). Один лінк — на багатьох мапах.
  2. Зв'язки port→port через FK на inv.interfaces, не текстом. Пара нормалізована через LEAST/GREATEST, щоб A→B і B→A не дублювались.
  3. Автовиявлення в два кроки: сирі topo.neighbors (LLDP/CDP/ARP/FDB + confidence) → резолвер → topo.links. Прапорець is_pinned захищає ручні лінки.
  4. Дві моделі метрик: узагальнена ts.series+ts.samples (плагіни реєструють metric_key без DDL) і широкі ts.icmp_samples/ts.if_counters для гарячих шляхів мапи.
  5. Анімація трафіку має ланцюг даних: if_counters.util_out_pct → view topo.link_livemap_edges.animation.
  6. Ліміти тарифу перевіряються двічі: bill.entitlements в API + тригери в БД.
  7. Секрети лише шифровані (core.secrets: ciphertext/nonce/auth_tag/key_id, AES-GCM-256, DEK у KMS).
  8. Тіло конфігів у Git, метадані в БД (ncm.configs.commit_sha + content_hash).
  9. 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

  1. window — зарезервоване слово. core.sla_targets.windowperiod_kind.
  2. core.check_types.key був доменом core.slug, але ключі мають вигляд icmp.ping — крапка не проходить. Замінено на text з власним CHECK ^[a-z0-9]+(\.[a-z0-9_]+)+$; так само core.checks.check_type.
  3. Домен core.slug не пропускав підкреслення, а ключі фіч — http_checks, auto_discovery. Регекс розширено до [a-z0-9_-].
  4. 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

Прийняті рішення

  1. Форма контракту випливає з одного обмеження: усі з'єднання ініціює агент. «Команда з сервера» — це повідомлення у зустрічному напрямку вже відкритого агентом bidi-стріму Control, а не RPC у бік агента.
  2. Чотири окремі стріми (Control / Telemetry / Logs / Config), а не один: пачка семплів не має блокувати heartbeat, сплеск syslog під час аварії не має топити телеметрію.
  3. Інтернування серій. Агент реєструє серію раз під series_ref, далі шле лише номер. Заміряно: 66 → 25 байт на семпл. series_ref живе в межах сесії.
  4. Швидкості рахує агент (лише він знає точний інтервал опитування), але шле й сирі лічильники — щоб сервер міг перерахувати заднім числом.
  5. Scrub/redact конфігів — на сервері. Агент віддає сирий текст; правила живуть у ncm.profiles і змінюються без оновлення агентів у полі.
  6. Топологію зводить сервер. Агент доповідає лише «на порту X бачу chassis Y».
  7. Task.params_json — непрозорі байти. Новий плагін не потребує зміни .proto. Модуль-виконавець виводиться з префікса check_type до крапки.
  8. At-least-once + upsert. Дедуплікацію дають PK схеми БД (ts, device_id) тощо.
  9. Зворотний тиск диктує сервер у Welcome і кожному TelemetryAck.
  10. Самооновлення підписане 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

Прийняті рішення

  1. Модулі вкомпільовані, без динамічного завантаження. Один бінарник має працювати на Alpine, Windows і роутері з musl. «Активація» = дозвіл сервера.
  2. Креденшели беруться на момент виконання, не з плану: у них TTL, і прострочені не віддаються взагалі — інакше агент заблокує обліковий запис на половині комутаторів клієнта.
  3. Розклад вирівняний по сітці інтервалу, тому після рестарту задача повертається у свій слот, а не з'їжджає.
  4. Буфер викидає найстаріше. Після відновлення зв'язку цінніший поточний стан. Зміни статусу викидаються останніми й пролазять у батч першими.
  5. Швидкості інтерфейсів рахує агент (знає фактичний інтервал); при перевороті лічильника — counter_reset замість стрибка на терабіт.
  6. Один писар у контрольний стрім — gRPC не допускає паралельних Send.
  7. GOMEMLIMIT 48 МБ у коді: хай 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() — перереєстрація серій на початку сесії.

Прийняті рішення

  1. Ізоляція тенантів робиться двічі: RLS (SET LOCAL app.tenant_id) плюс явний предикат tenant_id. Не перестраховка: RLS не працює на гіпертаблях, а саме туди йде вся телеметрія.
  2. schedule_offset — чиста функція від check_id, тож будь-який вузол сервера дає те саме значення.
  3. Хеш плану — лише з полів, що впливають на поведінку. Зміна опису пристрою не змушує переливати 50 000 задач.
  4. Токен каже, ЯКИЙ це зонд; сертифікат — що він має право говорити. Чужий agent_id при валідному токені → PermissionDenied.
  5. DEK не покидає сервер. Дамп БД без ключів не дає жодного пароля. Комплект креденшелів із TTL 1 год.
  6. Запис телеметрії — ON CONFLICT DO NOTHING (at-least-once).
  7. Статус пристрою — один запит із умовним записом в історію, інакше два воркери наввипередки писали б неіснуючі переходи up→up.
  8. Впевненість зіставлення спадає за надійністю ознаки: chassis-id 95 → MAC 90 → IP 80 → sysName 60. Останнє низьке навмисно: sysName вводить людина.
  9. Перереєстрація серій замість обнулення нумерації. Спершу агент мав би скидати 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 поверх тієї ж БД.