Netpulse_SasS/server/API.md
byrsapty f162dcab19 Імпорт профілів збору конфігу з NOC Project
147 профілів на 67 вендорів у ncm.profiles: Cisco, Huawei, Juniper,
MikroTik, Eltex, D-Link, HP, Brocade, Extreme, Alcatel, Allied Telesis,
Qtech, ZTE, BDCOM та інші.

Розбір через ast, без виконання: NOC-скрипт тягне половину свого
фреймворку, і імпортувати його означало б або принести весь NOC, або
підсунути заглушки, які мовчки змінюють поведінку.

З кожного get_config.py береться перша команда збору, гілка startup,
strip_first_lines і регекси платформ. Альтернативи лишаються в
коментарі. Шум (exit, sh, changeto system, terminal width 200)
відсіюється явним списком: Cisco.ASA після фільтра дає рівно
more system:running-config, Juniper.JUNOSe — рівно
show running-configuration замість чотирьох команд із сусідніх гілок.

Пейджер додається лише мережевому CLI: Eltex SMG і TAU знімають конфіг
через cat, і terminal datadump у bash просто впав би.

Важливо: команди — з NOC, а промпти й вимкнення пейджера — ні. У дампі
всі __init__.py порожні (359 із 364), а NOC тримає pattern_prompt саме
там. Вони проставлені за родиною вендора з типових значень і потребують
перевірки на живому залізі.

Не витягнулись 23 з 170, і не через помилку: 21 знімає конфіг по HTTP
(камери, відеокодери, MikroTik SwOS, HP iLO2), 2 — порожні заглушки в
самому NOC.

Виконувати профілі поки нікому: агентського модуля ncm (SSH/Telnet)
немає, це Етап 7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:34:55 +03:00

47 KiB
Raw Blame History

NetPulse API — REST і WebSocket для фронтенду

go build -o netpulse-api ./cmd/netpulse-api
./netpulse-api -listen :8080 -dsn "postgres://..." -cert api.pem -key api.key

Окремий процес від netpulse-server (AgentService) навмисно: зонди й браузери мають різні профілі навантаження, різні мережеві периметри й різні цикли релізів. Спільним лишається лише шар store, тому дані обидва бачать однакові.

Автентифікація

Два рівноправні способи довести, хто ти. Обидва приходять одним заголовком:

Authorization: Bearer <токен>

Сервер розрізняє їх за формою: рівно дві крапки — це JWT людини, інакше машинний токен. Це навмисно не залежить від префікса, бо префікс може змінитись, а формат JWT — ні.

Люди входять за логіном (core.users.username), а не за поштою: у мережевій інсталяції половина облікових записів технічні (noc, monitoring, oncall) і скриньки не мають узагалі. Пошта лишається прийнятною — сервер шукає за обома полями, — але вона більше не обов'язкова.

Люди входять через /api/v1/auth/login і отримують короткий access-токен (HS256, 15 хвилин, у пам'яті вкладки) плюс refresh-сесію в httpOnly-кукі np_refresh (30 днів, SameSite=Strict, Path=/api/v1/auth). Access-токен свідомо не кладеться в localStorage: будь-який XSS звідти його забирає, а з замикання модуля — ні.

Машини (NOC-екрани, скрипти, кіоски) користуються токенами з core.api_tokens; у БД лежить лише sha256, сам токен показується один раз при створенні. Порожній scopes означає повний доступ — так поводяться токени, створені власником тенанта для себе.

Права в обох випадках зводяться до одного набору рядків (maps:write, users:write, billing:manage, …) і перевіряються однаково. Для людини набір щоразу читається з БД за членством, а не береться з токена: інакше відкликана роль жила б до кінця TTL.

GET /healthz свідомо відкритий: його опитує балансувальник.

Перший користувач

Порожня база не має кого впустити, тому власник заводиться CLI-утилітою, а не через API:

./netpulse-user -dsn "postgres://..." -tenant acme -login admin -role owner

-list показує учасників, -reset-password міняє пароль і відкликає всі сесії.

Ендпоїнти

Метод Шлях Призначення
GET /healthz стан процесу й кількість WebSocket-підписників
POST /api/v1/auth/login вхід за логіном і паролем
POST /api/v1/auth/refresh обмін refresh-кукі на новий access-токен
POST /api/v1/auth/logout відкликати поточну сесію
POST /api/v1/auth/password змінити свій пароль
GET /api/v1/me хто я і що мені можна
GET /api/v1/team учасники організації
POST /api/v1/team завести користувача (users:write)
PATCH /api/v1/team/{id} змінити роль (users:write)
DELETE /api/v1/team/{id} прибрати з організації (users:write)
GET /api/v1/roles доступні ролі з правами
GET /api/v1/maps перелік мап із лічильниками вузлів і ребер
POST /api/v1/maps створити мапу
GET /api/v1/maps/{id} повний стан полотна разом із живими статусами
PATCH /api/v1/maps/{id} редактор полотна з оптимістичним блокуванням
DELETE /api/v1/maps/{id} м'яко видалити мапу (топологія лишається)
POST /api/v1/maps/{id}/build добудувати мапу з виявленої топології
POST /api/v1/maps/{id}/undo відкотити останню зміну полотна
GET /api/v1/devices інвентар (звужений групами доступу)
POST /api/v1/devices додати хост (devices:write)
PATCH /api/v1/devices/{id} змінити хост і його групи
DELETE /api/v1/devices/{id} мʼяко видалити хост
GET /api/v1/check-types що система вміє опитувати
GET /api/v1/devices/{id}/checks перевірки хоста
PUT /api/v1/devices/{id}/checks замінити набір перевірок
GET /api/v1/credentials доступи до обладнання (без секретів)
POST /api/v1/credentials створити доступ (devices:write)
GET /api/v1/device-groups групи хостів із лічильниками
POST /api/v1/device-groups створити групу хостів
DELETE /api/v1/device-groups/{id} видалити групу
GET /api/v1/user-groups групи доступу з правами
POST /api/v1/user-groups створити групу доступу
PATCH /api/v1/user-groups/{id} склад і права одним запитом
DELETE /api/v1/user-groups/{id} видалити групу доступу
GET /api/v1/agents зонди, версії, самометрики
GET /api/v1/alerts активні алерти + лічильники
POST /api/v1/alerts/{id}/ack підтвердити (alerts:ack)
POST /api/v1/alerts/{id}/close закрити вручну (alerts:ack)
POST /api/v1/mutes заглушити пристрій (alerts:ack)
GET /api/v1/alert-rules правила з лічильником активних
POST /api/v1/alert-rules створити правило (alerts:write)
PATCH /api/v1/alert-rules/{id} увімкнути/вимкнути (alerts:write)
DELETE /api/v1/alert-rules/{id} видалити правило (alerts:write)
GET /api/v1/channels канали доставки (без секретів)
POST /api/v1/channels створити канал (alerts:write)
POST /api/v1/channels/{id}/test пробне повідомлення (alerts:write)
DELETE /api/v1/channels/{id} видалити канал (alerts:write)
GET /api/v1/ws WebSocket: події та завантаження каналів

POST /api/v1/auth/login — вхід

{"login": "admin", "password": "…", "tenant_id": "…"}  // tenant_id — опційно

Поле login приймає і логін, і пошту. Старе email теж лишається робочим: інтеграції, написані до перейменування, не мають ламатися через назву ключа.

Успіх:

{
  "access_token": "eyJ…",
  "expires_in": 900,
  "user": {"id": "…", "username": "admin", "email": "admin@acme.io", "full_name": "Admin"},
  "tenant": {"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"},
  "permissions": ["maps:read", "maps:write", "users:write", ]
}

Якщо людина працює в кількох організаціях і tenant_id не вказано, токен не видається — приходить перелік на вибір, і клієнт повторює вхід із tenant_id:

{"tenants": [{"tenant_id": "…", "tenant_name": "Acme", "role_key": "owner", "role_name": "Власник"}, ]}

Помилки навмисно нерозрізненні: невідомий логін і невірний пароль дають однаковий 401 bad_credentials, і на неіснуючому користувачі сервер спалює стільки ж часу на фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які акаунти існують.

Після 10 невдач з одного логіна або IP за 15 хвилин — 429 too_many_attempts. Лічильник живе в core.login_attempts (гіпертаблиця), тому переживає рестарт процесу й працює на кількох інстансах одразу.

POST /api/v1/auth/refresh — продовження сесії

Тіла немає — сервер читає кукі np_refresh. Відповідь така сама, як у login. Токен ротується: старий рядок сесії відкликається, видається новий. Це перетворює викрадену кукі на видиму подію — злодій і власник не можуть користуватись однією сесією паралельно, другий отримає 401 no_session.

Членство перевіряється при кожній ротації, тож прибраний з організації користувач випадає щонайбільше за 15 хвилин, а не за 30 днів.

GET /api/v1/me

{"tenant_id": "…", "user_id": "…", "email": "admin@acme.io", "permissions": [...]}

Для машинного токена замість user_id/email приходить token_name.

Формат логіна: 364 символи з латиниці, цифр, крапки, дефіса й підкреслення; перший і останній символ — літера або цифра. Перевіряється і в БД (CHECK), і в API — відмова БД виглядає як «violates check constraint», і людині з неї нічого не зрозуміло.

GET|POST|PATCH|DELETE /api/v1/team

Керування учасниками; усе, крім читання, потребує users:write.

PATCH міняє роль, профіль і пароль одним запитом:

{"role_id": "…", "username": "noc-a", "email": "noc@acme.io",
 "full_name": "Черговий", "password": "…"}

Порожнє поле означає «не чіпати» — форма не стирає того, чого не показувала. Порожній email прибирає адресу (сигнал -). Зміна пароля відкликає всі сесії цієї людини: інакше той, хто знав старий, лишається всередині до місяця, доки не протермінується refresh.

Профіль редагується лише в того, хто працює тільки в цій організації. core.users глобальна: одна людина може мати доступ до кількох тенантів (типово для MSP). Логін, пошта й пароль — її власність, а не власність організації, тому адмін філії не може змінити їх тому, хто заходить тим самим акаунтом ще кудись — інакше той навіть не дізнався б. Спроба дає 409 shared_user. Роль і членство в групах локальні й редагуються завжди.

Три обмеження вшиті навмисно й не обходяться параметрами: роль owner не видається через API (лише CLI), не можна змінити роль самому собі й не можна прибрати себе з організації. Кожне з них рятує від одного й того самого — організації без жодного власника.

DELETE відкликає всі сесії цієї людини в цьому тенанті, не чіпаючи інші організації. Зміна власного пароля відкликає всі сесії скрізь.

GET /api/v1/maps/{id} — головний запит продукту

Одним викликом повертає готове до рендеру полотно: мапу, підкладки, вузли, ребраі живий стан. Це не оптимізація: без підмішаного статусу мапа малювалася б сірою й лише потім доганяла кольори сотнею дозапитів.

{
  "id": "…", "name": "Автомапа", "layout_algo": "manual",
  "viewport": {"x":0,"y":0,"zoom":1},
  "grid": {"enabled":true,"size":16,"snap":true},
  "clustering": {"enabled":true,"zoom_threshold":0.4},

  "backgrounds": [
    {"kind":"image","storage_key":"s3://…/floorplan.svg","opacity":0.6,
     "x":0,"y":0,"width":2400,"height":1600,"locked":true}
  ],

  "nodes": [
    {"id":"…","kind":"device","label":"core-sw","x":100,"y":200,
     "device_id":"…","style":{"icon":"switch"},
     "status":"up","rtt_ms":0.113,"loss_pct":0}      // ← фарбує вузол
  ],

  "edges": [
    {"id":"…","source_node_id":"…","target_node_id":"…",
     "source_port":"Gi0/1","target_port":"ether1","label":"Gi0/1 → ether1",
     "style":"smoothstep","waypoints":[],
     "animation":{"enabled":true,"speed_source":"utilization"},
     "thresholds":{"warn_pct":70,"crit_pct":90},
     "link_status":"up","util_pct":7.2,"capacity_bps":10000000000}
  ]
}

link_status виводиться з кінців лінка, а не читається з колонки. У схемі є topo.links.status, але її ніхто не підтримує: писати туди означало б оновлювати всі лінки пристрою на кожну зміну його статусу й тримати це узгодженим. Стан лінка — похідна величина, тож рахується на читанні. Порядок гілок важливий: обрив перекриває все інше, а «невідомо» стоїть перед «up», щоб мапа не малювала зеленим те, чого ще жодного разу не опитували.

rtt_ms і loss_pct беруться лише за останні 15 хвилин (ts.device_last_icmp). Старіші дані не характеризують поточний стан, і показувати їх означало б брехати про живість пристрою.

PATCH /api/v1/maps/{id} — редактор

{
  "revision": 7,                    // ревізія, яку бачив клієнт
  "viewport": {"x":0,"y":0,"zoom":1.2},
  "nodes": {
    "upsert": [
      {"id": "…", "x": 1500, "y": 640},                   // перетягування
      {"client_id": "tmp-1", "kind": "cloud",             // новий вузол
       "label": "Інтернет", "x": 900, "y": 100}
    ],
    "remove": ["…"]
  },
  "edges": {
    "upsert": [{"client_id": "tmp-e", "source_node_id": "…",
                "target_node_id": "tmp-1", "style": "bezier"}]
  },
  "comment": "додав аплінк"
}

Відповідь повертає нову ревізію й мапу client_id → id:

{"revision": 8, "node_ids": {"tmp-1": "…"}, "edge_ids": {"tmp-e": "…"}}

Усі скалярні поля — необов'язкові, і відсутнє означає «не чіпати». Це не формальність: перетягування шле лише x/y, і якби відсутні поля трактувались як порожні, кожен рух миші стирав би стиль, розмір і прив'язку до пристрою.

Ребро може посилатися на вузол, створений у цьому ж патчі, за client_id — інакше намалювати зв'язок до нового вузла вимагало б двох запитів і проміжного стану.

Оптимістичне блокування. Клієнт надсилає ревізію, яку бачив; якщо мапу встиг змінити хтось інший — 409 revision_conflict із поточним номером, а не тихе затирання. У NOC над однією мапою часто працюють кілька людей, і мовчазна втрата чужих правок гірша за помилку. Пропустити revision можна, але це вимикає перевірку — так робить лише серверний код.

Кожна правка лишає знімок у topo.map_revisions (зберігаються останні 50) — це основа undo. Знімок пишеться тією ж транзакцією, що й зміна: інакше після збою в історії лишався б крок, якого в мапі немає, і відкат ламав би її.

Невідоме поле в тілі — 400. Мовчки проковтнути друкарську помилку клієнта означає, що правка «збереглася», але не застосувалась.

Код Коли
409 revision_conflict мапу змінив хтось інший
402 plan_limit упёрлись у ліміт тарифу (тригер у БД) — UI має показати пропозицію змінити тариф
400 invalid чужий вузол, невідоме поле, некоректний uuid

POST /api/v1/maps/{id}/build — автопобудова

Додає на мапу пристрої, що беруть участь у виявлених лінках, і ребра між ними. Ідемпотентна: наявні вузли не дублюються, а координати не чіпаються — інакше кожен запуск скидав би розкладку, яку оператор робив руками.

Нові вузли розставляються сіткою. Осмислену розкладку дає лише клієнт (він знає розміри полотна й алгоритм), а сервер має покласти їх хоч кудись, але не в одну точку.

{"nodes_added": 2, "edges_added": 1, "revision": 2}

POST /api/v1/maps/{id}/undo — відкат

Повертає полотно до попереднього знімка з topo.map_revisions.

{"revision": 11, "restored_from": 9}

Відкат оформлюється як НОВА ревізія, а не як відмотування лічильника. Інакше клієнти, що тримають номер 10, після повернення до 9 отримали б «свою» ревізію знову актуальною й тихо перезаписали б відкочене. Undo — така сама зміна, як будь-яка інша, і має рухати історію вперед.

Ідентифікатори вузлів зберігаються. Відновлений вузол повертається з тим самим id, тому ребра, що на нього спираються, знову працюють, а виділення в UI й зовнішні посилання не ламаються.

Відновлення йде в порядку «ребра геть → вузли геть → вузли назад → ребра назад»: зовнішні ключі не дозволяють інакше.

409 nothing_to_undoу щойно створеної мапи історії ще немає. Sec-WebSocket-Protocol: netpulse.token.<токен>


Браузерний WebSocket API не дозволяє довільні заголовки, тому токен їде підпротоколом.
Сам токен при цьому не потрапляє в URL, а отже і в логи проксі.

**Клієнт → сервер**

```json
{"type": "subscribe", "map_id": "…"}

Мапа перевіряється на належність тенанту: вгаданий id не відкриє чужу топологію.

Сервер → клієнт

type Коли Вміст
hello одразу після рукостискання tenant_id
subscribed підтвердження підписки map_id
error напр. чужа мапа code, map_id
device.status зміна статусу пристрою device_id, status, previous_status, reason
link.load кожні 5 с для підписаної мапи map_id, links: [{link_id, status, util_pct}]
map.updated мапу змінив інший клієнт map_id, revision

map.updated несе лише номер ревізії, а не сам патч: клієнт сам вирішує, чи перечитувати полотно. Розсилати зміни дельтами означало б тримати на сервері модель того, що бачить кожен клієнт, — а це вже спільне редагування з CRDT, окрема задача.

Дві частоти навмисно різні. Зміна статусу — подія: рідка, але має дійти майже миттєво, інакше мапа бреше про стан мережі. Завантаження каналу — величина: вона змінюється весь час, і слати її частіше, ніж оновлюються лічильники (60 с), означає слати ту саму цифру по колу.

Звідки беруться події

core.event_outbox наповнюється тією ж транзакцією, що й зміна, яку описує. Інакше WebSocket міг би розповісти про перехід, якого в базі ще (або вже) немає.

Транспорт — опитування таблиці раз на секунду, а не LISTEN/NOTIFY. NOTIFY не переживає падіння підписника й обмежений 8 КБ на повідомлення, а тут потрібна гарантія, що жодна зміна статусу не загубиться між перезапусками API. Ціна — один дешевий запит за індексом.

Нова сесія починає з кінця журналу: клієнт щойно завантажив повний стан мапи, і все старіше в ньому вже враховано.

Підписник, який не встигає читати, відключається, а не сповільнює решту: тримати його чергу означало б віддавати пам'ять і затримувати всіх інших.

Стан перевірки

Тести працюють проти справжньої БД, справжнього HTTP і справжнього WebSocket (NETPULSE_TEST_DSN; без змінної пропускаються). go test -raceусі проходять.

Тест Що доводить
TestAuthRequired без токена й з чужим токеном — 401; healthz відкритий
TestMapStateIsRenderReady одним викликом приходять підкладка, координати, статус вузла, RTT, порти ребра, util_pct, capacity_bps, налаштування анімації
TestLinkStatusFollowsEndpoints лінк червоніє, коли впав кінець або порт, — без жодних змін у topo.links
TestTenantIsolation чужа мапа за точним id → 404; у переліку пристроїв немає чужого тенанта
TestBadMapID некоректний uuid → 400, а не 500
TestWebSocketRequiresToken без токена з'єднання не відкривається
TestWebSocketDeliversStatusChange справжній батч телеметрії → applyDeviceStatus → outbox → hub → браузер отримав device.status із previous_status
TestWebSocketRejectsForeignMap підписка на чужу мапу відхилена
TestWebSocketPushesLinkLoads періодичний link.load із реальним util_pct
TestPatchMoveNodeKeepsOtherFields драг шле лише x/y — підпис, прив'язка до пристрою й статус не затираються
TestPatchRevisionConflict друга вкладка зі старою ревізією отримує 409, перша правка ціла
TestPatchCreatesNodeAndEdgeTogether ребро прив'язується до вузла, створеного тим же патчем, за client_id
TestPatchDeleteNodeRemovesEdges видалення вузла не лишає ребер у нікуди
TestPatchStoresRevisionSnapshot знімок із коментарем і повним складом вузлів
TestPatchRejectsForeignNode вузол чужої мапи не редагується через свою
TestPatchRejectsUnknownField друкарська помилка в клієнті — 400, а не тиха втрата
TestPatchRequiresWriteScope токен maps:read не пише, але читає
TestBuildFromTopologyIsIdempotent повторна побудова нічого не додає й не скидає ручну розкладку
TestPatchBroadcastsToOtherViewers правка долітає до інших відкритих полотен як map.updated
TestUndoRestoresNodePosition відкат повертає координати й створює нову ревізію, а не відмотує лічильник
TestUndoRestoresDeletedNodeWithEdges видалений вузол повертається з тим самим id, ребра прив'язуються назад, живий стан лінка на місці
TestUndoWithoutHistory у мапи без історії — 409 nothing_to_undo
TestUndoRequiresWriteScope токен maps:read не відкочує

Живий прогін

Агент, netpulse-server і netpulse-api запущені разом проти справжнього snmpd:

мапа: Автомапа | вузлів: 2 | ребер: 1
вузол  snmp-host   статус=up  rtt=0.113 мс  loss=0
вузол  gateway     статус=up  rtt=0.827 мс  loss=0
ребро  eth0 → ?    порти=eth0->None  лінк=up  util=7.0e-06%  capacity=10000000000
зонд   probe-snmp: online, linux/amd64, пристроїв=2,
       health={rss_bytes: 11624464, dropped_samples: 0, clock_skew_ms: 0}

target_port порожній — і це правильно: лінк знайдено через ARP, а ARP не повідомляє порт віддаленої сторони. Заповнить його LLDP, коли поруч буде обладнання, що його шле.

Живий прогін редактора

Проти справжніх даних, зібраних агентом:

1) створюємо порожню мапу              map_id=a4eb2025…
2) будуємо з виявленої топології       +2 вузлів, +1 ребер, ревізія 2
3) читаємо полотно                     вузли snmp-host(380,120) і gateway(120,120), обидва up
4) пересуваємо вузол                   нова ревізія 3
5) той самий патч зі старою ревізією   409 revision_conflict
6) повторна автопобудова               +0 вузлів, +0 ребер
7) координати після побудови           x=1500 y=640 — ручна розкладка збережена
                                       знімків в історії: 2

Опитування хоста

Хост сам по собі нічого не робить. Опитує його core.checks — рядок «пристрій X, тип перевірки Y, кожні N секунд, з такими параметрами». Тому POST /api/v1/devices приймає перевірки одразу:

{
  "name": "sw-core-1",
  "address": "10.0.0.1",
  "kind": "switch",
  "credential_ids": ["…"],
  "checks": [
    {"check_type": "icmp.ping", "params": {"count": 3}, "interval_sec": 30},
    {"check_type": "snmp.if",   "params": {"use_hc_counters": true}, "interval_sec": 300}
  ]
}

Створювати хост без перевірок можна, але це свідомий вибір: такий хост лежить у списку й не опитується ніколи. Форма в UI попереджає про це прямим текстом і починає новий хост із icmp.ping — єдиної перевірки, яка працює будь-де без налаштування.

GET /api/v1/check-types віддає перелік із params_schema (JSON Schema) для кожного типу. Форма будує поля з неї, а не зі свого списку: інакше кожен новий тип перевірки, доданий плагіном, вимагав би перезбирання фронтенду. Поле available каже, чи плагін увімкнений цьому тенанту — базові (is_core) доступні завжди, решта потребує запису в core.plugin_installs.

Правка перевірок іде за id, а не перестворенням. Унікальний індекс checks_uniq включає md5(params), тому «видалити й вставити» на зміні параметрів створило б ДРУГУ перевірку того самого типу. Плюс перестворення скидає next_run_at і збиває рівномірність опитування по всьому парку.

PUT /api/v1/devices/{id}/checks замінює набір цілком: форма показує повний список, і зняту перевірку треба вміти зняти. Перевірки на інтерфейсах (interface_id IS NOT NULL) не чіпаються — їх заводить автовиявлення, і форма хоста про них не знає.

Доступи до обладнання

SNMP-community, паролі SSH і Telnet живуть в inv.credentials, зашифровані тим самим кільцем, що й секрети каналів. Прив'язка до хоста — credential_ids у тілі хоста. Без доступу працює лише icmp.ping.

Профілі збору конфігу

ncm.profiles описує, як зняти конфіг із конкретної платформи: які команди виконати, за яким промптом ловити кінець виводу, що вирізати з diff (scrub_patterns) і що замаскувати перед записом у Git (redact_patterns).

Вбудовано 148 профілів на 67 вендорів, імпортованих із NOC Project — Cisco, Huawei, Juniper, MikroTik, Eltex, D-Link, HP, Brocade, Extreme, Alcatel, Allied Telesis, Qtech, ZTE, BDCOM та інші. Інструмент імпорту й межі його застосовності — db/import/README.md.

Команди взято з NOC. Промпти й вимкнення пейджера — ні: у дампі профілів NOC вони не містились, тому проставлені за родиною вендора з типових значень і потребують перевірки на живому залізі.

Виконувати ці профілі поки нікому. Агентського модуля ncm (SSH/Telnet) ще немає — це Етап 7. Профілі лежать готовими даними, і щойно модуль з'явиться, збір конфігу запрацює на всьому парку без дописування коду під кожен вендор.

Групи й доступ до хостів

Два незалежні виміри, які не можна змішувати в одному списку прав:

  • Роль відповідає на питання «що людині вільно робити» — maps:write, alerts:ack, users:write.
  • Група доступу відповідає на «над якими хостами». Інженер над однією філією та інженер над усією мережею мають однакову роль і різний доступ.

Група доступу (core.user_groups) містить людей і видає права на групи хостів (inv.device_groups) одним із трьох рівнів: read, write, deny.

Три правила обчислення

  1. Хто не входить у жодну групу — не обмежений групами взагалі. Це свідомо не по-заббіксівськи: там користувач без груп не бачить нічого, і кожна нова інсталяція починається з питання «чому порожньо». Тут звуження вмикається тоді, коли його справді налаштували, а до того доступ визначає роль.
  2. Заборона перемагає дозвіл. Хост у двох групах, де одна дає читання, а друга забороняє, лишається невидимим — інакше заборону можна обійти, додавши об'єкт у будь-яку іншу групу.
  3. Серед дозволів виграє найширший. Читання в одній групі й запис у другій дають запис.

Обчислення живе в БД (core.accessible_devices) і читається раз на запит: питати про кожен хост окремо означало б перетворити список на N запитів рівно тоді, коли хостів багато.

Звуження діє на /api/v1/devices, /api/v1/alerts, лічильники алертів і операції запису (редагування хоста, заглушення). Алерт без пристрою (наприклад, про сам зонд) видно всім: сховати його від обмеженого користувача означало б приховати аварію, до якої групи не мають стосунку.

Живий прогін груп

1) вхід за логіном 'admin'          200, роль owner
2) вхід тим самим, але поштою       200 — сумісність збережена
3) створено дві групи хостів        201/201
4) хости розкладено                 gateway→Магістраль, snmp-host→Доступ
5) новий хост через API             201
6) лічильники груп                  Доступ=2, Магістраль=1
7) група доступу для 'eng'          лише «Доступ», рівень read
8) що бачить eng                    snmp-host, test-host — обидва writable=false
                                    gateway зник із вибірки
9) алерти під eng                   1 замість 3
10) eng редагує чужий хост          403
11) видалення хоста власником       204

Алерти

Движок правил живе всередині netpulse-api і обчислюється раз на -alert-interval (за замовчуванням 30 с). Кілька екземплярів API за балансувальником безпечні: тік бере pg_try_advisory_lock, тож правила рахує рівно один — дедуплікацію алертів захищає індекс, а от сповіщення пішли б у кількох копіях.

Движок не тримає стану між тіками. Вікно for_seconds — це запит по часу до TSDB, а не лічильник у пам'яті, тому перезапуск процесу нічого не збиває.

Умови правил

// Усі виміри вікна мають задовольняти умову — це і є антифлап.
{"metric": "loss_pct", "op": ">", "value": 20}

// Агрегат за вікно порівнюється один раз.
{"metric": "rtt_avg_ms", "op": ">", "value": 150, "agg": "avg"}

// Даних немає взагалі — моніторинг, що мовчить про мертвий зонд,
// показує зелену мапу мертвої мережі.
{"metric": "no_data"}

// Довільна серія з ts.samples.
{"metric_key": "cpu.util", "op": ">", "value": 85, "agg": "avg"}

op: > >= < <= == !=. agg: avg min max sum count last. Метрики: для icmprtt_avg_ms, rtt_min_ms, rtt_max_ms, jitter_ms, loss_pct, reachable; для interfacein_bps, out_bps, in_pps, out_pps, util_in_pct, util_out_pct, in_errors, out_errors, in_discards, out_discards, oper_up.

Списки закриті навмисно: значення з condition потрапляє в текст запиту, і будь-яке послаблення тут перетворюється на SQL-ін'єкцію через JSON у таблиці правил.

selector обмежує область: device_ids, group_ids, site_ids, kinds, vendors, tags. Порожній означає «до всього» — найчастіший випадок, і вимагати для нього переліку означало б ламати правило щоразу, коли додається пристрій.

Кореляція за топологією

Правило з depends_on_topology (типово увімкнено) не піднімає алерти на пристроях, що стоять за іншим недоступним пристроєм. Причина аварії — на межі зони недоступності: у кого лишився хоч один живий сусід, той упав сам; хто оточений виключно мертвими — наслідок.

Пристрій без жодного відомого зв'язку завжди лишається причиною: топологія про нього нічого не знає, тож списати його на чужу аварію було б вигадкою. Повністю мертвий острів алертує весь — краще зайвий шум, ніж мовчазне ковтання цілого сегмента.

Придушення

  • Вікна обслуговування (alr.maintenance_windows) — за розкладом; вікно без селектора накриває весь тенант.
  • Ручне заглушення (alr.mutes) — кнопка в UI, стеля 7 днів. Безстрокове «не турбувати» — найпоширеніший спосіб тихо вимкнути моніторинг назавжди.

Придушений алерт лишається видимим у списку (окремим фільтром), але не надсилає сповіщень.

Канали й маршрути

Канал зберігає несекретну частину в config, а токен — у core.secrets, зашифрований тим самим кільцем, що й паролі від обладнання. Перелік каналів секрети не розшифровує взагалі.

Тенант без жодного маршруту отримує сповіщення в усі придатні канали: підключили Telegram — має працювати без додаткових налаштувань. Маршрути з'являються тоді, коли треба розділити потоки.

Тихі години маршруту глушать усе, крім disaster: сенс чергування в тому, щоб його підняли.

Вебхуки на внутрішні адреси заблоковані (SSRF), бо адресу задає користувач тенанта, а запит іде з сервера. У self-hosted це знімається прапорцем -allow-private-webhooks — там внутрішня мережа належить тому самому, хто налаштовує вебхук.

Живий прогін алертів

1) вхід власника                  200
2) активні алерти                 200, 1 алерт: gateway 0.98 мс > 0.5
                                  snmp-host (0.08 мс) не спрацював — поріг посередині
3) правила                        200, 3 правила, «no_data» і «помилки на порту» мовчать
4) канал webhook створено         201
5) перевірка каналу               200, ok=true — повідомлення дійшло
6) нове правило                   201
7) після тіку                     3 алерти, сповіщень по 1
8) підтвердження                  200, стан=acknowledged
9) глядач пробує підтвердити      403
10) глядач читає алерти           200

дедуплікація: за три тіки алертів 3→3, сповіщень 2→2
вимкнення правила                 його алерти закрито, чужий ack не зачеплено
повернення правила                алерти піднялись знову
заглушення пристрою               рівно його алерт → suppressed (mute)
видалення правила                 його алерти закрито, сиріт не лишилось
доставок за прогін                5 на 5 подій — жодного повтору

Живий прогін входу

1) вхід власника                       200, роль owner, прав 20, кукі: ['np_refresh']
2) хто я                               200, admin@netpulse.local
3) власник читає команду               200, учасників 2
4) вхід інженера                       maps:write=True, users:write=False
5) інженер пробує читати команду        403 forbidden
6) інженер читає мапи                  200, мап 1
7) невірний пароль                     401 bad_credentials
8) вихід                               204; refresh після виходу — 401 no_session

Права так само доводяться до UI: під глядачем (devices:read, maps:read, alerts:read) у шапці лишається сам вибір мапи й вихід — кнопки «Добудувати» та «Відкотити» зникають, полотно стає нередагованим, а блок зондів у бічній панелі не показується взагалі.

Чого ще немає

  • Історія метрик: GET /api/v1/metrics для графіків із ts.samples_5m не написано.
  • Ескалації: alr.escalation_policies і повторні сповіщення не реалізовані — сповіщення надсилається один раз при піднятті алерту.
  • Web Push і Telegram-кнопки: alr.push_subscriptions порожня, а callback_data кнопок Ack/Mute у Telegram нікуди не приходить — бот-приймач не написано.
  • Правила з джерел syslog, trap, ncm, compliance: движок їх свідомо пропускає, бо вони обробляються подіями, а не опитуванням.
  • Редагування правил: PATCH міняє лише enabled; умову правила змінюють перестворенням.
  • Завантаження підкладок: map_backgrounds.storage_key можна задати патчем, але самого прийому файлів (S3/MinIO) ще немає.
  • Спільне редагування обмежене оптимістичним блокуванням: одночасний драг двома людьми закінчиться 409 для того, хто спізнився. Для повноцінної співпраці потрібен CRDT — свідомо відкладено.