# NetPulse — контракт агент↔сервер (Етап 2) gRPC / protobuf, пакет `netpulse.v1`. Джерело істини — файли в `netpulse/v1/`; `gen/go` згенерований і комітиться, щоб збірка агента не залежала від наявності `buf`. ## Головне обмеження, з якого випливає вся форма контракту **Усі з'єднання ініціює агент.** У мережі клієнта немає ані відкритих портів, ані прокидання NAT — зонд стоїть за фаєрволом і має лише вихідний доступ. Тому «команда з сервера» фізично є повідомленням у зустрічному напрямку вже відкритого агентом bidi-стріму `Control`. Це не деталь реалізації, а причина, чому контракт побудований навколо довгоживучих потоків, а не навколо RPC-викликів у бік агента. ## Файли | Файл | Зміст | |------|-------| | `common.proto` | Спільні типи: `Status`, `Transport`, `DiscoveryProto`, `Error`, `DeviceTarget`, `Credential`, `AgentBuild`, `AgentHealth` | | `agent.proto` | `EnrollmentService`, `AgentService`, `ControlUp`/`ControlDown` і все, що всередині них | | `telemetry.proto` | `SeriesDescriptor`, `MetricSample`, `IcmpResult`, `InterfaceCounters`, `TelemetryBatch`, `TelemetryAck` | | `discovery.proto` | `NeighborRecord`, `InterfaceRecord`, `DiscoveredDevice`, `DiscoveryReport` | | `ncm.proto` | `ConfigJob`, `ConfigUpload` (header/chunk/trailer), `ConfigApplyJob` | | `logs.proto` | `SyslogEntry`, `SnmpTrap`, `LogBatch` | ## Сервіси ``` EnrollmentService Enroll(EnrollRequest) → EnrollResponse одноразово, без клієнтського сертифіката AgentService усе далі — тільки mTLS Control(stream ControlUp) → stream ControlDown керування, довгоживучий StreamTelemetry(stream TelemetryBatch) → stream TelemetryAck StreamLogs(stream LogBatch) → stream LogAck ReportDiscovery(DiscoveryReport) → DiscoveryAck UploadConfig(stream ConfigUpload) → ConfigReceipt ``` Чотири окремі стріми, а не один — навмисно. Пачка на 10 000 семплів не має блокувати heartbeat і затримувати команду з сервера, а сплеск syslog під час аварії не має топити телеметрію, за якою цю аварію й видно. ## Життєвий цикл сесії ```mermaid sequenceDiagram participant A as Агент (мережа клієнта) participant S as Сервер Note over A,S: одноразово A->>S: Enroll(enrollment_token, CSR) S-->>A: сертифікат + CA + control_endpoint Note over A,S: кожна сесія, тільки вихідне з'єднання A->>S: Control: Hello(build, task_plan_hash, last_acked_batch_id) S-->>A: Welcome(session_id, server_time, ліміти батчингу) S-->>A: TaskPlan(tasks, devices) або TaskDelta S-->>A: ModuleControl(які модулі активувати) S-->>A: CredentialBundle(розшифровані креди, з TTL) loop опитування A->>S: StreamTelemetry: TelemetryBatch(new_series?, samples, icmp, interfaces) S-->>A: TelemetryAck(acked_through, max_in_flight) A->>S: Control: Heartbeat(AgentHealth) S-->>A: Control: Ping A->>S: Control: Pong end Note over A,S: за подією або розкладом S-->>A: Control: ConfigJob(commands, prompt_regex) A->>S: UploadConfig: header → chunk* → trailer(sha256) S-->>A: ConfigReceipt(commit_sha | unchanged) ``` ## Рішення, які варто розуміти перед реалізацією агента ### Інтернування серій Повторювати `device_id` (36 байт) + `metric_key` + labels у кожному семплі задорого: при 50 000 пристроїв це десятки байт службових даних на одне число `float64`. Тому агент реєструє серію один раз під локальним номером `series_ref`, далі шле лише номер і значення. Сервер тримає мапу `series_ref → ts.series.id` на час сесії. Заміряно в `test/contract`: **66 байт → 25 байт** на семпл. `series_ref` дійсний рівно в межах сесії. Після реконекту агент нумерує з 1 і реєструє все заново. Якщо сервер втратив стан — відповідає `reset_series_table = true`, а не мовчки викидає дані. ### Хто рахує швидкості Агент шле **і** сирі 64-бітні лічильники, **і** вже пораховані `in_bps`/`out_bps`/ `util_*_pct`. Швидкості рахує агент, бо лише він знає точний інтервал між двома опитуваннями — мережева затримка робить серверний розрахунок неточним. Сирі лічильники потрібні, щоб сервер міг перерахувати заднім числом. Обробка wrap/reset — на агенті: при перезавантаженні пристрою виставляється `counter_reset = true`, і швидкості цього разу недійсні. ### Хто чистить конфіги Агент віддає **сирий** текст. Scrub (прибрати `uptime` та timestamp'и, щоб не шуміти в diff), redact (замаскувати паролі) і коміт у Git — на сервері. Це навмисно: правила очищення живуть у `ncm.profiles` і мають змінюватись без оновлення агентів у полі. ### Хто вирішує топологію Агент доповідає сире: «на порту X я бачу chassis-id Y, port-id Z». Він **не** вирішує, хто з ким з'єднаний. Резолвер на сервері зводить це в `topo.links` і виставляє `confidence`. Так автовиявлення лишається відтворюваним і не залежить від версії агента в полі. ### Непрозорі параметри задач `Task.params_json` — байти, а не типізоване поле. Валідність гарантує `params_schema` з `core.check_types`, розбирає їх сам модуль. Завдяки цьому новий плагін не потребує зміни `.proto` — саме те, заради чого затівалась плагінна архітектура. Модуль-виконавець визначається префіксом `check_type` до крапки: `snmp.if` → модуль `snmp`. ### Семантика доставки **At-least-once.** Дедуплікацію забезпечує сама схема БД: первинні ключі `(ts, device_id)`, `(ts, interface_id)`, `(ts, series_id)` роблять повторний запис безпечним. Агент тримає неacknowledged батчі й після реконекту шле їх із `is_retransmit = true`, продовжуючи від `last_acked_batch_id` з `Hello`. ### Зворотний тиск Розмір батчу, інтервал і `max_in_flight` диктує сервер у `Welcome`, а коригує в кожному `TelemetryAck`. Балакучого агента можна пригальмувати на льоту, не оновлюючи бінарник у полі. `AgentHealth.dropped_samples` показує, коли ліміти затиснуті надто сильно. ## Безпека - **mTLS на всьому, крім `Enroll`.** Агент приходить із одноразовим enrollment-токеном і CSR, іде з власним сертифікатом. Приватний ключ ніколи не залишає агента. - **Креденшели передаються розшифрованими** — сервер дістає їх із `core.secrets` і розшифровує. Агент тримає їх лише в пам'яті, не пише на диск, не логує, і зобов'язаний занулити після `Credential.expires_at`. - **Самооновлення підписане.** `UpdateInfo` несе sha256 бінарника і підпис Ed25519 над ним. Без валідного підпису оновлення не застосовується: інакше компрометація CDN перетворюється на RCE в мережі кожного клієнта. - **Відкат конфігурації** (`ConfigApplyJob`) агент виконує беззастережно — уся логіка погодження (`ncm.rollbacks`: draft → awaiting_approval → approved) лишається на сервері. `confirm_timeout` вмикає confirmed commit там, де пристрій це підтримує: найкращий захист від втрати керування після помилкового правила фаєрвола. ## Версіонування Пакет `netpulse.v1`. Правила: не змінювати номери полів, не перевикористовувати видалені (позначати `reserved`), нові поля — тільки додавати. Ламкі зміни — через `netpulse.v2` поруч, бо агенти в полі оновлюються не одночасно з сервером. `buf breaking` у CI проти головної гілки ловить порушення автоматично. ## Кодогенерація ```bash buf generate ``` Без `buf` (те, чим це перевірялось): ```bash protoc -I proto --go_out=gen/go --go_opt=paths=source_relative --go-grpc_out=gen/go --go-grpc_opt=paths=source_relative proto/netpulse/v1/*.proto ``` ## Стан перевірки Перевірено на стенді Debian 13: protoc 3.21.12, Go 1.24.4, buf 1.72.0. | Крок | Результат | |------|-----------| | `protoc` — валідність усіх 6 файлів і залежностей | OK | | `buf lint` (STANDARD) | без зауважень | | Генерація Go + gRPC, `go build`, `go vet` | OK | | `go test ./test/contract/...` — 5 наскрізних тестів на bufconn | усі PASS | Тести в [test/contract/contract_test.go](../test/contract/contract_test.go) перевіряють не компіляцію, а поведінку: | Тест | Що доводить | |------|-------------| | `TestControlHandshakeAndTaskPush` | Hello → Welcome → TaskPlan доїжджає в зустрічному напрямку стріму; `params_json` розбирається; модуль виводиться з `check_type`; Ping/Pong міряє clock skew | | `TestTelemetrySeriesInterning` | Серія реєструється раз, далі розв'язується з номера; ICMP і `util_out_pct` доїжджають без спотворень; економія 66 → 25 байт | | `TestTelemetryUnknownSeriesRefTriggersReset` | Невідомий `series_ref` дає `reset_series_table` + retryable-помилку, а не тихе відкидання даних | | `TestConfigUploadChunked` | header → chunk × N → trailer збирається байт-у-байт зі звіркою sha256 | | `TestConfigUploadRejectsBadChecksum` | Пошкоджений конфіг відхиляється з `checksum_mismatch`, а не комітиться в Git |