Спільний 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>
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
# 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` лишається без живої перевірки.
|