Схема 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>
189 lines
12 KiB
Markdown
189 lines
12 KiB
Markdown
# 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 |
|