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

12 KiB
Raw Blame History

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