Спільний 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>
12 KiB
NetPulse Agent
Легкий зонд збору телеметрії. Єдиний статичний бінарник, усі з'єднання вихідні — у мережі клієнта не треба відкривати жодного порту.
go build -trimpath -ldflags "-s -w" -o netpulse-agent ./cmd/netpulse-agent
./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
{"count": 3, "packet_size": 56, "interval_ms": 100}
snmp.if — перелік інтерфейсів приходить від сервера, бо він уже є в
inv.interfaces. Агент не ходить по ifTable, щоб з'ясувати, що існує:
виявлення нових інтерфейсів — робота модуля topology, і вона окрема саме тому,
що впирається в ліміт тарифу.
{"use_hc_counters": true,
"interfaces": [{"if_index": 1, "interface_id": "<uuid>", "speed_bps": 1000000000}]}
snmp.get
{"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-обходу,
тому окремий чек для інтерфейсів був би зайвим трафіком.
{"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 лишається без живої перевірки.