Пристрій сам каже, що він таке, і шаблон чіпляється без натискань. Системна група знімається тією ж SNMP-сесією, що й обхід топології: три зайві PDU дешевші за окремий чек із власним розкладом. Збіг за префіксом на межі компонента: моделей у виробника тисячі, і повний збіг означав би рядок на кожну коробку. Довший префікс перемагає. Дванадцять вбудованих правил на основних виробників. Шаблони тільки додаються, ніколи не знімаються: автоматика знає модель пристрою, але не знає, чому цьому хосту дали ще один шаблон руками. DiscoveredDevice отримав device_id: зіставляти за адресою не можна — за одним NAT кілька хостів мають ту саму адресу опитування. Заразом дубль перевірки перестав давати «внутрішню помилку». Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| netpulse/v1 | ||
| README.md | ||
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 під час аварії не має топити телеметрію, за якою цю аварію й видно.
Життєвий цикл сесії
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 проти головної гілки ловить порушення автоматично.
Кодогенерація
buf generate
Без buf (те, чим це перевірялось):
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 перевіряють не компіляцію, а поведінку:
| Тест | Що доводить |
|---|---|
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 |