# 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 -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": "", "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` лишається без живої перевірки.