Netpulse_SasS/agent/README.md
zotac ad7672c32f Етап 2: модуль topology — LLDP/CDP/ARP/FDB та інвентар портів
Спільний SNMP-транспорт винесено в snmpx: ним користуються два модулі.
Звіти автовиявлення йдуть окремим RPC, не телеметричним стрімом — вони
рідкі, великі й не прив'язані до моменту часу так, як метрики.

Живий прогін проти справжнього snmpd+lldpd знайшов дві помилки:

1. Префікс типу чека не збігався з ключем модуля (topo.discover при
   плагіні topology). Агент маршрутизує задачі саме за префіксом, тож
   зонд відхиляв би їх. Інваріант закріплено обмеженням у БД.
2. Унікальний індекс topo.neighbors схлопував ARP-сусідів: ключ не
   включав MAC, а chassis_id/port_id в ARP немає взагалі.

Перевірено на живих даних: інвентар із ifTable, 2 ARP-сусіди, зіставлення
шлюзу за MAC (впевненість 90), зведений лінк із capacity 10 Гбіт/с.

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

187 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 Agent
Легкий зонд збору телеметрії. Єдиний статичний бінарник, усі з'єднання вихідні —
у мережі клієнта не треба відкривати жодного порту.
```bash
go build -trimpath -ldflags "-s -w" -o netpulse-agent ./cmd/netpulse-agent
```
```bash
./netpulse-agent -server monitor.example.com:443 -agent-id <uuid> -cert agent.pem -key agent.key
```
## Будова
```
cmd/netpulse-agent/ точка входу, збирання компонентів
internal/
config/ прапорці + NETPULSE_*, mTLS
module/ контракт модуля, реєстр, маршрутизація check_type → модуль
telemetry/
interner.go series_ref: серія реєструється раз за сесію
buffer.go обмежений буфер із бюджетом пам'яті
scheduler/ min-heap за часом запуску, семафор паралельності
session/ gRPC-клієнт, рукостискання, реконект, ack
snmpx/ спільний SNMP-транспорт + розбір індексів OID
modules/
icmp/ ping, RTT, jitter, втрати
snmp/ лічильники інтерфейсів (v2c/v3), довільні OID
topology/ LLDP, CDP, ARP, FDB + інвентар портів
```
## Рішення, які варто розуміти
**Модулі вкомпільовані, а не завантажуються динамічно.** Плагінність на агенті
навмисно простіша, ніж на сервері: жодних `.so`. Причина — той самий бінарник має
працювати на Alpine, Windows і роутері з musl, а бюджет RSS не дозволяє тягнути
рантайм плагінів. «Активація» означає, що сервер дозволив модуль цьому зонду
(`ControlDown.ModuleControl`).
**Креденшели беруться на момент виконання, а не з плану.** У них TTL. Якби вони
копіювались у задачу, після ротації паролів агент довбав би пристрої простроченими
даними, доки не приїде новий план — і заблокував би обліковий запис на половині
комутаторів. Прострочені не віддаються взагалі: явна помилка «немає креденшелів»
краща за блокування.
**Розклад вирівняний по сітці інтервалу, а не «зараз + інтервал».** Після рестарту
агента задача повертається у свій слот. `schedule_offset` рахує сервер
детерміновано від `check_id`, тому 5000 чеків з інтервалом 60 с розведені й
переживають перезапуск без збою фази.
**Буфер викидає найстаріші дані, а не найновіші.** Коли зв'язок відновиться,
оператору потрібен передусім поточний стан мережі. Зміни статусу пристрою
викидаються останніми: без них мапа показуватиме пристрій живим, поки він лежить.
Кількість викинутого їде в `AgentHealth.dropped_samples`, щоб діра була видимою.
**Швидкості інтерфейсів рахує агент.** Лише він знає фактичний інтервал між двома
опитуваннями; серверний розрахунок за номінальним інтервалом помиляється рівно на
мережеву затримку й джитер планувальника. При перевороті лічильника виставляється
`counter_reset`, і швидкості не заповнюються — краще діра в графіку, ніж стрибок
на терабіт і хибний алерт.
**Один писар у контрольний стрім.** gRPC не допускає паралельних `Send`, а слати
треба і heartbeat, і статуси задач. Тому окрема горутина-писар і канал. Читання
підтверджень телеметрії — на іншій горутині: `Send` + `Recv` на різних горутинах
це єдине, що gRPC дозволяє робити паралельно на одному стрімі.
**`GOMEMLIMIT` виставляється в коді (48 МБ).** Зонд часто живе на роутері або в
контейнері з 64 МБ. Хай збирач працює агресивніше, ніж OOM killer вб'є процес і
осліпить моніторинг саме тоді, коли він потрібен.
## Параметри
| Прапорець | Змінна | Призначення |
|-----------|--------|-------------|
| `-server` | `NETPULSE_SERVER` | адреса сервера `host:port` |
| `-agent-id` | `NETPULSE_AGENT_ID` | ідентифікатор зонда з `Enroll` |
| `-cert` / `-key` / `-ca` | `NETPULSE_CERT` / `_KEY` / `_CA` | mTLS |
| `-insecure` | `NETPULSE_INSECURE=1` | без TLS, лише локальний стенд |
| `-modules` | `NETPULSE_MODULES` | активні до першої відповіді сервера |
| `-max-concurrency` | `NETPULSE_MAX_CONCURRENCY` | скільки чеків паралельно |
| `-buffer-items` / `-buffer-bytes` | `NETPULSE_BUFFER_*` | ліміти буфера |
Перевірка сертифіката сервера не вимикається прапорцем: зонд ходить через
інтернет, і «тимчасово без перевірки» — це той самий канал, яким їдуть паролі від
усіх комутаторів клієнта.
## Параметри чеків
`icmp.ping`
```json
{"count": 3, "packet_size": 56, "interval_ms": 100}
```
`snmp.if` — перелік інтерфейсів приходить від сервера, бо він уже є в
`inv.interfaces`. Агент не ходить по `ifTable`, щоб з'ясувати, що існує:
виявлення нових інтерфейсів — робота модуля topology, і вона окрема саме тому,
що впирається в ліміт тарифу.
```json
{"use_hc_counters": true,
"interfaces": [{"if_index": 1, "interface_id": "<uuid>", "speed_bps": 1000000000}]}
```
`snmp.get`
```json
{"oids": [{"oid": "1.3.6.1.4.1.9.9.109.1.1.1.1.8.1",
"metric_key": "cpu.util", "unit": "pct", "scale": 1}]}
```
`topology.discover` — сусіди й інвентар портів беруться з одного SNMP-обходу,
тому окремий чек для інтерфейсів був би зайвим трафіком.
```json
{"protos": ["lldp", "cdp", "arp", "fdb"], "collect_interfaces": true}
```
Префікс типу чека **зобов'язаний** дорівнювати ключу модуля: саме за ним агент
обирає виконавця. Реєстр відхиляє модуль, який оголошує чужий тип, а в БД те саме
закріплено обмеженням `check_types_prefix_matches_plugin`.
## Стан перевірки
Стенд Debian 13, Go 1.25.13. `go vet` чисто, тести з `-race`:
| Пакет | Результат |
|-------|-----------|
| `internal/scheduler` | ok |
| `internal/telemetry` | ok |
| `internal/session` | ok |
| `internal/snmpx` | ok |
| `internal/modules/icmp` | ok |
Заміряно на релізному бінарнику (`CGO_ENABLED=0`, `-trimpath -s -w`):
- розмір — **12 МБ**
- базовий RSS у циклі реконекту — **11.6 МБ** (бюджет 30 МБ)
Що саме доводять тести:
| Тест | Що перевіряє |
|------|--------------|
| `TestNextRunSurvivesRestart` | слот задачі не з'їжджає після перезапуску агента |
| `TestOffsetsSpreadLoad` | 60 різних offset дають 60 різних моментів запуску |
| `TestSchedulerSkipsWhenPreviousStillRunning` | повільний чек не множить сам себе; пропуск явний |
| `TestSchedulerRejectsUnknownModule` | задача на неактивний модуль відхиляється зі слідом |
| `TestInternerLabelOrderDoesNotMatter` | недетермінований порядок map не плодить серій |
| `TestBufferDropsOldestOnOverflow` | при переповненні лишається найновіше, викинуте пораховане |
| `TestBufferPrioritisesStatusChanges` | зміна стану пролазить у батч поперед метрик |
| `TestBufferRequeuePreservesData` | розрив між Send і Ack не втрачає ні семпли, ні дескриптори |
| `TestSessionFullCycle` | Hello → план у зустрічному напрямку → виконання з креденшелами → телеметрія на сервері |
| `TestSessionReconnectsAfterDrop` | агент повертається сам після розриву |
| `TestSplitIndex` | розбір індексу OID, включно з пасткою «схожий префікс» (`.1.2.20` не є нащадком `.1.2`) |
| `TestFormatMAC` | сирі байти, `00:11:...`, `0011.2233.4455` і дефіси зводяться до одного вигляду |
| `TestPingLoopback` | реальний ICMP-сокет, не мок |
### Перевірка проти справжнього SNMP-агента
На стенді піднято `snmpd` + `lldpd` (LLDP-MIB через AgentX). Живий прогін
`topology.discover` і `snmp.get` кожні 10 с:
| Що перевірено | Результат |
|---------------|-----------|
| Інвентар портів зі справжнього `ifTable` | `lo` (softwareLoopback) і `eth0` (ethernetCsmacd, MAC `bc:24:11:07:68:67`) |
| `ifHighSpeed` замість 32-бітного `ifSpeed` | 10 Гбіт/с — 32-бітне поле впиралося б у 4.29 Гбіт/с |
| Сусіди зі справжньої ARP-таблиці | 2 записи, обидва з локальним портом `eth0` |
| Зіставлення за MAC | шлюз `192.168.1.1` розпізнано, впевненість 90 |
| Зведення лінка | `snmp-host:eth0 → gateway`, джерело `arp`, `capacity_bps` 10 Гбіт/с |
| Три проходи поспіль | лінк один, дублікатів немає |
| Метрики `snmp.get` | `sys.uptime` збирається, помилок чеків немає |
**Обмеження перевірки:** `TestPingLoopback` пропускається під звичайним
користувачем в unprivileged LXC — ядро не дає ані unprivileged-, ані raw-сокета,
і `sysctl net.ipv4.ping_group_range` там недоступний. Під root на тому ж стенді
тест проходить. У проді агенту треба `CAP_NET_RAW` або дозволений
`ping_group_range`.
**LLDP і CDP не перевірені на живих сусідах:** `lldpd` на стенді зареєстрував
LLDP-MIB, але сусідів у нього немає — поруч немає другого пристрою, який шле LLDP.
Розбір `lldpRemTable` і `cdpCacheTable` покритий лише тестами на індекси OID;
ARP-гілка того самого коду перевірена на живих даних. CDP-гілка чекає на Cisco.
Так само не перевірено `snmp.if`: сервер поки не генерує для нього перелік
інтерфейсів, тому чек нікуди не призначається. Логіка перевороту лічильників і
`util_pct` лишається без живої перевірки.