Netpulse_SasS/proto/README.md
zotac 15d22a8d8a Етап 1-2: схема БД та protobuf-контракт агент-сервер
Схема PostgreSQL 16+/TimescaleDB: 11 міграцій, 7 схем, топологія
(neighbors -> links -> maps -> nodes/edges), time-series з CAGG,
NCM, alerting, білінг з entitlements, RLS.

Контракт agent<->server: 6 proto-файлів, gRPC, інтернування серій,
at-least-once з ack, чанкування конфігів.

Перевірено на стенді Debian 13 / PG 17.11 / TimescaleDB 2.29.1:
міграції + 8 функціональних перевірок схеми, buf lint + 5 наскрізних
gRPC-тестів контракту.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 03:28:40 +03:00

189 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |