Досі доступ давав машинний токен зі змінної збірки — одні права на всіх і жодного способу відрізнити, хто що зробив. Тепер продукт уміє впустити людину. Сервер: - argon2id для паролів, власний HS256 JWT (15 хв) + refresh-сесія в httpOnly-кукі на 30 днів з ротацією при кожному обміні - Principal зводить людину й машинний токен до одного набору прав; права читаються з БД на кожному запиті, а не з claims, щоб відкликана роль не жила до кінця TTL - 9 ендпоїнтів: auth/login|refresh|logout|password, me, team CRUD, roles - netpulse-user — CLI для першого власника: публічна реєстрація в B2B це дірка, а «перший через веб, поки нікого немає» — нечесна гонка - міграція 0012: три RLS-політики винятку для шляху входу (без них вхід неможливий за побудовою — щоб знайти користувача за email, треба знати тенант, який відомий лише після пошуку) і login_attempts для тротлінгу Фронтенд: - сторінка входу з вибором організації, access-токен у замиканні модуля замість localStorage, тихе відновлення сесії по кукі - один refresh на всі паралельні запити: інакше ротація зробила б усі, крім першого, недійсними й викинула б людину на вхід - дії без права не показуються; полотно нередаговане для глядача - мобільний адаптив: висувна бічна панель, інспектор нижнім аркушем 11 нових тестів (37 у httpapi), go vet і tsc чисто, живий прогін з 8 кроків проти netpulse_it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
26 KiB
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 — ні.
Люди входять через /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 -email admin@acme.io -role owner
-list показує учасників, -reset-password міняє пароль і відкликає всі сесії.
Ендпоїнти
| Метод | Шлях | Призначення |
|---|---|---|
GET |
/healthz |
стан процесу й кількість WebSocket-підписників |
POST |
/api/v1/auth/login |
вхід за email і паролем |
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 |
інвентар |
GET |
/api/v1/agents |
зонди, версії, самометрики |
GET |
/api/v1/ws |
WebSocket: події та завантаження каналів |
POST /api/v1/auth/login — вхід
{"email": "admin@acme.io", "password": "…", "tenant_id": "…"} // tenant_id — опційно
Успіх:
{
"access_token": "eyJ…",
"expires_in": 900,
"user": {"id": "…", "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": "Власник"}, …]}
Помилки навмисно нерозрізненні: невідомий email і невірний пароль дають однаковий
401 bad_credentials, і на неіснуючому користувачі сервер спалює стільки ж часу на
фіктивній перевірці argon2id. Інакше час відповіді сам би розказував, які адреси
зареєстровані.
Після 10 невдач з одного email або 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.
GET|POST|PATCH|DELETE /api/v1/team
Керування учасниками; усе, крім читання, потребує users:write.
Три обмеження вшиті навмисно й не обходяться параметрами: роль 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
Живий прогін входу
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.alertsне віддаються, хоча схема готова. - Завантаження підкладок:
map_backgrounds.storage_keyможна задати патчем, але самого прийому файлів (S3/MinIO) ще немає. - Спільне редагування обмежене оптимістичним блокуванням: одночасний драг двома людьми закінчиться 409 для того, хто спізнився. Для повноцінної співпраці потрібен CRDT — свідомо відкладено.