Netpulse_SasS/server/internal/httpapi/server.go
byrsapty ca143a616b
Some checks failed
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 59s
CI / server (push) Failing after 3m27s
CI / agent (push) Successful in 3m3s
Білінг, SLA, вбудовані правила, пісочниця установника, тести сторінок
П'ять паралельних задач. Найцінніше в них — не можливості, а знайдене.

0069 БІЛІНГ. Аудит 0009 показав, що перевірка ліміту не спрацювала б
жодного разу: isPlanLimit шукала слово «ліміт», а тригер писав
"device limit reached" англійською. Перше ж досягнення стелі дало б
клієнту 500 замість пояснення. Плюс три діри: тригер лише на INSERT
(стеля в 15 обходилась за чотири дії через архів), max_maps/max_agents/
max_users не перевіряло ніщо — тобто рівно те, чим відрізняються плани,
і license_keys була закрита політикою tenant_isolation з 0011, хоча
tenant_id там NULLABLE навмисно: головний сценарій self-hosted був
недосяжний.

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

0070 SLA. Джерелом обрано ts.icmp_1h, а не device_status_history:
остання не вміє сказати «ми не знали» — перехід пишеться лише при
зміні стану, тож доба мовчання зонда виглядає як доба роботи. Час
розкладено на чотири частини, і «немає даних» не додається ні до чого;
замість вибору між двома брехнями звіт каже, яку частку періоду він
бачив. Закритий період тримає тригер, а не домовленість у Go.

0071 ВІДПОВІДНІСТЬ. 20 правил, кожне прив'язане до родини: об'єднаний
вираз, що покриває Cisco й не покриває MikroTik, дав би «0 порушень» і
сховав сліпу пляму. Вендор не входить у перелік, доки для нього немає
зразка конфігу в тесті. TestBuiltinRulesAreNotAlwaysGreen вимагає, щоб
у кожного правила був конфіг, де воно спрацювало, І де ні.

ПІСОЧНИЦЯ УСТАНОВНИКА — та сама установка в ізольованому проєкті
compose. Знайшла дві справжні вади з трьох спроб:
  * healthcheck бази ходив unix-сокетом, а споживачі по TCP. При
    первинній ініціалізації Postgres слухає лише сокет — compose
    вважав базу здоровою, migrate отримував connection refused. На
    створеній базі цієї фази немає, тож вада чекала на першого клієнта;
  * у білому переліку модулів API не було traps і filecfg — зонд із
    приймачем трапів неможливо було зареєструвати взагалі.

ТЕСТИ СТОРІНОК: 137 → 252. Мережевий шар, права доступу, незворотні
дії, фільтри з адресного рядка. Підмінюється лише fetch і WebSocket —
api/client.ts працює справжній.
2026-08-27 21:17:23 +03:00

628 lines
36 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package httpapi — REST і WebSocket для фронтенду.
//
// Окремий процес від AgentService навмисно: зонди й браузери мають різні
// профілі навантаження, різні мережеві периметри й різні цикли релізів.
// Спільним лишається лише шар store, тому обидва бачать однакові дані.
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/alerting"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/store"
)
type Server struct {
store *store.Store
log *slog.Logger
hub *Hub
signer *auth.Signer
// Потрібні лише для каналів сповіщень: без них решта API працює,
// а /api/v1/channels відповідає 503 із поясненням.
keyring *crypto.Keyring
notifier *alerting.Notifier
// Приймач подій для правил джерела `compliance`: прогін
// відповідності запускається саме звідси, і саме він — та мить,
// коли порушення стає подією. nil означає, що движок алертів на
// цій інсталяції вимкнено.
events *alerting.EventSink
// Відкриті ключі, якими перевіряють ліцензії. nil — робочий стан:
// збірка без вшитого ключа працює як була, а сторінка тарифу чесно
// каже, що перевірити ключ нічим. Мовчазна відмова тут була б
// гіршою за відсутність можливості.
license *store.LicenseVerifier
}
// New створює сервер. signer може бути nil лише в тестах, які не
// перевіряють вхід людей: без нього /api/v1/auth/* віддаватиме 500.
func New(st *store.Store, signer *auth.Signer, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
s := &Server{store: st, log: log, signer: signer}
s.hub = NewHub(st, log)
return s
}
// WithNotifications вмикає керування каналами доставки.
//
// Виділено в окремий метод, а не в аргументи New: більшість тестів і
// значна частина інсталяцій (кіоск, читальний інстанс) каналами не
// керують, і вимагати від них ключ шифрування було б безпідставно.
func (s *Server) WithNotifications(ring *crypto.Keyring, n *alerting.Notifier) *Server {
s.keyring, s.notifier = ring, n
return s
}
// WithEventAlerts вмикає подієві алерти на прогоні відповідності.
//
// Окремо від WithNotifications, бо це різні речі: там — доставка, тут —
// сама поява алерту. Інсталяція без каналів усе одно має бачити
// порушення на дошці.
func (s *Server) WithEventAlerts(sink *alerting.EventSink) *Server {
s.events = sink
return s
}
// WithKeyring дає серверу ключі шифрування без движка алертів.
//
// До появи дзеркала конфігів ключ приходив лише разом із каналами
// сповіщень (WithNotifications), і на інсталяції з вимкненим движком
// алертів його не було зовсім. Секрет дзеркала до алертів стосунку не
// має, тож і залежності такої бути не повинно.
func (s *Server) WithKeyring(ring *crypto.Keyring) *Server {
s.keyring = ring
return s
}
// WithLicense дає серверу відкриті ключі перевірки ліцензій.
//
// Окремим методом, а не аргументом New, з тієї ж причини, що й ключі
// шифрування: інсталяція без ліцензії — це не поламана інсталяція, а
// звичайна. Вимагати ключ від тесту, від кіоску й від клієнта, який
// щойно розгорнув систему й ще нічого не купив, було б безпідставно.
func (s *Server) WithLicense(v *store.LicenseVerifier) *Server {
s.license = v
return s
}
// Hub — доступ до трансляції для зовнішнього коду (тести, метрики).
func (s *Server) Hub() *Hub { return s.hub }
// Handler збирає маршрути.
//
// Роутер стандартної бібліотеки: Go 1.22 вміє шаблони з методом і
// параметрами шляху, і цього тут вистачає. Зовнішній роутер додав би
// залежність заради синтаксису.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
// Вхід — єдині відкриті ендпоїнти: до них особи ще немає.
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
mux.Handle("GET /api/v1/me", s.authenticated(s.handleMe))
mux.Handle("POST /api/v1/auth/password", s.authenticated(s.handleChangePassword))
// Прив'язка telegram-акаунта. Ендпоїнта, який приймав би натискання
// кнопок, тут немає й не буде: Telegram до цього сервера не
// достукається (самопідписаний TLS на IP), тому оновлення забирає
// довге опитування — див. alerting/telegram_bot.go.
mux.Handle("GET /api/v1/me/telegram", s.authenticated(s.handleGetTelegramLink))
mux.Handle("POST /api/v1/me/telegram/code", s.authenticated(s.handleCreateTelegramLinkCode))
mux.Handle("DELETE /api/v1/me/telegram", s.authenticated(s.handleDeleteTelegramLink))
mux.Handle("GET /api/v1/team", s.authenticated(s.handleListTeam))
mux.Handle("POST /api/v1/team", s.authenticated(s.handleCreateUser))
mux.Handle("PATCH /api/v1/team/{id}", s.authenticated(s.handleSetRole))
mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember))
mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles))
mux.Handle("POST /api/v1/roles", s.authenticated(s.handleCreateRole))
mux.Handle("PATCH /api/v1/roles/{id}", s.authenticated(s.handleUpdateRole))
mux.Handle("DELETE /api/v1/roles/{id}", s.authenticated(s.handleDeleteRole))
mux.Handle("GET /api/v1/permissions", s.authenticated(s.handlePermissionCatalog))
mux.Handle("GET /api/v1/dashboards", s.authenticated(s.handleListDashboards))
mux.Handle("POST /api/v1/dashboards", s.authenticated(s.handleSaveDashboard))
mux.Handle("GET /api/v1/dashboards/{id}", s.authenticated(s.handleGetDashboard))
mux.Handle("PUT /api/v1/dashboards/{id}", s.authenticated(s.handleSaveDashboard))
mux.Handle("DELETE /api/v1/dashboards/{id}", s.authenticated(s.handleDeleteDashboard))
mux.Handle("POST /api/v1/dashboards/{id}/default", s.authenticated(s.handleSetDefaultDashboard))
mux.Handle("POST /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleCreatePublicLink))
mux.Handle("DELETE /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleRevokePublicLink))
// Режим NOC TV. Єдина частина API без входу — доступ дає токен у
// посиланні. Обмеження описані в tv.go.
mux.HandleFunc("GET /api/v1/tv/{token}", s.handleTVDashboard)
mux.HandleFunc("GET /api/v1/tv/{token}/alerts", s.handleTVAlerts)
mux.HandleFunc("GET /api/v1/tv/{token}/devices", s.handleTVDevices)
mux.HandleFunc("GET /api/v1/tv/{token}/devices/{device}/metrics", s.handleTVMetrics)
mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps))
mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap))
mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap))
mux.Handle("PATCH /api/v1/maps/{id}", s.authenticated(s.handlePatchMap))
mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap))
mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap))
mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
mux.Handle("GET /api/v1/maps/{id}/interfaces", s.authenticated(s.handleMapInterfaces))
mux.Handle("POST /api/v1/maps/{id}/discover", s.authenticated(s.handleMapDiscover))
mux.Handle("GET /api/v1/maps/{id}/permissions", s.authenticated(s.handleListMapPermissions))
mux.Handle("PUT /api/v1/maps/{id}/permissions", s.authenticated(s.handleSetMapPermissions))
mux.Handle("GET /api/v1/icons", s.authenticated(s.handleListIcons))
mux.Handle("POST /api/v1/icons", s.authenticated(s.handleCreateIcon))
mux.Handle("GET /api/v1/icons/{id}", s.authenticated(s.handleGetIcon))
mux.Handle("DELETE /api/v1/icons/{id}", s.authenticated(s.handleDeleteIcon))
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice))
mux.Handle("GET /api/v1/devices/{id}", s.authenticated(s.handleGetDevice))
mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice))
mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice))
// Картка хоста: паспорт, останні дані з походженням, порти.
mux.Handle("GET /api/v1/devices/{id}/overview", s.authenticated(s.handleDeviceOverview))
mux.Handle("GET /api/v1/devices/{id}/latest", s.authenticated(s.handleDeviceLatest))
mux.Handle("GET /api/v1/devices/{id}/interfaces", s.authenticated(s.handleDeviceInterfaces))
// Масова правка й масове видалення. Перегляд набору — окремий крок
// і окрема ручка, як і в масовому виконанні команд: підтвердження
// без точного переліку не вважається підтвердженням.
mux.Handle("POST /api/v1/devices/bulk-targets", s.authenticated(s.handleBulkDeviceTargets))
mux.Handle("POST /api/v1/devices/bulk-update", s.authenticated(s.handleBulkUpdateDevices))
mux.Handle("POST /api/v1/devices/bulk-delete", s.authenticated(s.handleBulkDeleteDevices))
mux.Handle("POST /api/v1/devices/bulk-restore", s.authenticated(s.handleBulkRestoreDevices))
mux.Handle("POST /api/v1/devices/{id}/identify", s.authenticated(s.handleIdentifyDevice))
mux.Handle("POST /api/v1/devices/{id}/collect-config", s.authenticated(s.handleCollectConfig))
mux.Handle("GET /api/v1/devices/{id}/config-jobs", s.authenticated(s.handleListConfigJobs))
mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs))
// Добір хостів на сторінці конфігів: спільний фільтр плюс стан архіву.
mux.Handle("POST /api/v1/ncm/config-devices", s.authenticated(s.handleConfigDevices))
mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig))
mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs))
mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy))
mux.Handle("PUT /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleSetBackupPolicy))
// Довідник для форми розкладу — вузький, лише для вибору.
mux.Handle("GET /api/v1/ncm-profiles", s.authenticated(s.handleListProfiles))
// Повний перелік і редагування — для сторінки профілів.
mux.Handle("GET /api/v1/ncm/profiles", s.authenticated(s.handleListNcmProfiles))
mux.Handle("POST /api/v1/ncm/profiles", s.authenticated(s.handleSaveNcmProfile))
mux.Handle("PUT /api/v1/ncm/profiles/{id}", s.authenticated(s.handleSaveNcmProfile))
mux.Handle("DELETE /api/v1/ncm/profiles/{id}", s.authenticated(s.handleDeleteNcmProfile))
mux.Handle("GET /api/v1/ncm/compliance/rules", s.authenticated(s.handleListComplianceRules))
mux.Handle("POST /api/v1/ncm/compliance/rules", s.authenticated(s.handleSaveComplianceRule))
mux.Handle("PUT /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleSaveComplianceRule))
mux.Handle("DELETE /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleDeleteComplianceRule))
mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults))
mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance))
// Масове виконання команд. Перегляд переліку хостів — окремий крок
// і окремий ендпоїнт: підтвердження без точного переліку не
// вважається підтвердженням, і клієнт не має змоги його оминути.
mux.Handle("POST /api/v1/ncm/command-targets", s.authenticated(s.handlePreviewCommandTargets))
mux.Handle("GET /api/v1/ncm/command-runs", s.authenticated(s.handleListCommandRuns))
mux.Handle("POST /api/v1/ncm/command-runs", s.authenticated(s.handleCreateCommandRun))
mux.Handle("GET /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleGetCommandRun))
mux.Handle("POST /api/v1/ncm/command-runs/{id}/cancel", s.authenticated(s.handleCancelCommandRun))
mux.Handle("DELETE /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleDeleteCommandRun))
// Звіт віддається не тут, а за квитком: файл тягне завантажувач
// браузера, який заголовка Authorization не додає. Див. downloads.go.
mux.Handle("POST /api/v1/ncm/command-runs/{id}/report-link", s.authenticated(s.handleCommandRunReportLink))
// Відкат конфігу. План — окремий крок і окремий ендпоїнт, з тієї ж
// причини, що й перегляд хостів у масових командах: підтвердження
// без точного переліку того, що поїде на пристрій, підтвердженням
// не є. Погодження й відмова — окремі дії, бо в них інший автор.
//
// Політика лежить поруч, але змінюється іншим правом
// (settings:write): вимикач вимоги «другої людини» не має належати
// тому, кого ця вимога стримує. Див. ncm_rollback.go.
mux.Handle("POST /api/v1/ncm/rollbacks/plan", s.authenticated(s.handleRollbackPlan))
mux.Handle("GET /api/v1/ncm/rollbacks", s.authenticated(s.handleListRollbacks))
mux.Handle("POST /api/v1/ncm/rollbacks", s.authenticated(s.handleCreateRollback))
mux.Handle("GET /api/v1/ncm/rollbacks/{id}", s.authenticated(s.handleGetRollback))
mux.Handle("POST /api/v1/ncm/rollbacks/{id}/approve", s.authenticated(s.handleApproveRollback))
mux.Handle("POST /api/v1/ncm/rollbacks/{id}/reject", s.authenticated(s.handleRejectRollback))
mux.Handle("GET /api/v1/ncm/rollback-policy", s.authenticated(s.handleGetRollbackPolicy))
mux.Handle("PUT /api/v1/ncm/rollback-policy", s.authenticated(s.handleSetRollbackPolicy))
// Завантаження файлу за короткоживучим квитком. Друга (після режиму
// NOC TV) частина API без входу — право лежить у токені шляху.
mux.HandleFunc("GET /api/v1/downloads/{token}", s.handleDownload)
// Конфіг-файли серверів: хости, які збирають самі себе. Збір і
// розклад окремих ручок не мають — і те, і те вже робить черга
// ncm.jobs (collect-config вище, ScheduleBackups у grpcapi).
mux.Handle("GET /api/v1/ncm/server-files", s.authenticated(s.handleListServerFiles))
mux.Handle("PUT /api/v1/devices/{id}/self-agent", s.authenticated(s.handleSetSelfAgent))
mux.Handle("DELETE /api/v1/devices/{id}/self-agent", s.authenticated(s.handleClearSelfAgent))
mux.Handle("GET /api/v1/ncm/backup-defaults", s.authenticated(s.handleGetBackupDefaults))
mux.Handle("PUT /api/v1/ncm/backup-defaults", s.authenticated(s.handleSetBackupDefaults))
// Очистка сховища версій: політика поруч зі спільним розкладом,
// ручне видалення — окремим правом ncm:delete (див. ncm_configs.go).
mux.Handle("GET /api/v1/ncm/retention", s.authenticated(s.handleGetRetention))
mux.Handle("PUT /api/v1/ncm/retention", s.authenticated(s.handleSetRetention))
mux.Handle("POST /api/v1/ncm/configs/delete", s.authenticated(s.handleDeleteConfigs))
// Дзеркало архіву на зовнішній Git. Стан видно з ncm:read (помітити
// відмову має могти будь-хто, хто дивиться на конфіги), а міняти
// налаштування — лише з ncm:mirror. Самого push тут немає: його
// робить фоновий такт колектора, див. git_mirror.go.
mux.Handle("GET /api/v1/ncm/mirror", s.authenticated(s.handleGetMirror))
mux.Handle("PUT /api/v1/ncm/mirror", s.authenticated(s.handleSetMirror))
mux.Handle("POST /api/v1/ncm/mirror/test", s.authenticated(s.handleTestMirror))
mux.Handle("POST /api/v1/ncm/mirror/push", s.authenticated(s.handlePushMirror))
mux.Handle("POST /api/v1/ncm/mirror/deploy-key", s.authenticated(s.handleMirrorDeployKey))
mux.Handle("DELETE /api/v1/ncm/mirror/secret", s.authenticated(s.handleForgetMirrorSecret))
// Журнал SNMP-трапів. Окремим розділом, а не вкладкою в хості:
// трап приходить від АДРЕСИ, і половина цікавого в ньому — це саме
// ті адреси, за якими хоста немає. Всередині картки хоста такий
// трап не показати ніде.
mux.Handle("GET /api/v1/traps", s.authenticated(s.handleListTraps))
mux.Handle("GET /api/v1/traps/meta", s.authenticated(s.handleTrapsMeta))
mux.Handle("PUT /api/v1/traps/names", s.authenticated(s.handleSaveTrapName))
mux.Handle("DELETE /api/v1/traps/names/{oid}", s.authenticated(s.handleDeleteTrapName))
mux.Handle("DELETE /api/v1/traps/unknown/{ip}", s.authenticated(s.handleForgetUnknownTrapSource))
mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes))
mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks))
mux.Handle("PUT /api/v1/devices/{id}/checks", s.authenticated(s.handleSetDeviceChecks))
mux.Handle("GET /api/v1/devices/{id}/series", s.authenticated(s.handleListSeries))
mux.Handle("GET /api/v1/devices/{id}/metrics", s.authenticated(s.handleQueryMetrics))
// Добір хостів для сторінки метрик — тим самим фільтром, що в
// «Командах», масових діях і «Конфігах».
mux.Handle("POST /api/v1/metrics/devices", s.authenticated(s.handleMetricDevices))
mux.Handle("GET /api/v1/templates", s.authenticated(s.handleListTemplates))
mux.Handle("POST /api/v1/templates", s.authenticated(s.handleSaveTemplate))
mux.Handle("GET /api/v1/templates/export", s.authenticated(s.handleExportTemplates))
mux.Handle("POST /api/v1/templates/import", s.authenticated(s.handleImportTemplates))
// Перевірка фільтра складу портів на справжньому хості — до
// збереження шаблону, а не після.
mux.Handle("GET /api/v1/templates/interface-devices", s.authenticated(s.handleInterfaceFilterDevices))
mux.Handle("POST /api/v1/templates/interface-preview", s.authenticated(s.handleInterfaceFilterPreview))
mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate))
mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate))
mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate))
mux.Handle("POST /api/v1/templates/{id}/clone", s.authenticated(s.handleCloneTemplate))
mux.Handle("PUT /api/v1/templates/{id}/graphs", s.authenticated(s.handleSaveTemplateGraphs))
mux.Handle("PUT /api/v1/templates/{id}/triggers", s.authenticated(s.handleSaveTemplateTriggers))
mux.Handle("GET /api/v1/devices/{id}/graphs", s.authenticated(s.handleDeviceGraphs))
mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates))
mux.Handle("PUT /api/v1/devices/{id}/templates", s.authenticated(s.handleSetDeviceTemplates))
mux.Handle("GET /api/v1/devices/{id}/credentials", s.authenticated(s.handleGetDeviceCredentials))
mux.Handle("GET /api/v1/credentials", s.authenticated(s.handleListCredentials))
mux.Handle("POST /api/v1/credentials", s.authenticated(s.handleCreateCredential))
mux.Handle("PATCH /api/v1/credentials/{id}", s.authenticated(s.handleUpdateCredential))
mux.Handle("DELETE /api/v1/credentials/{id}", s.authenticated(s.handleDeleteCredential))
mux.Handle("GET /api/v1/device-groups", s.authenticated(s.handleListDeviceGroups))
mux.Handle("POST /api/v1/device-groups", s.authenticated(s.handleCreateDeviceGroup))
mux.Handle("DELETE /api/v1/device-groups/{id}", s.authenticated(s.handleDeleteDeviceGroup))
mux.Handle("GET /api/v1/user-groups", s.authenticated(s.handleListUserGroups))
mux.Handle("POST /api/v1/user-groups", s.authenticated(s.handleCreateUserGroup))
mux.Handle("PATCH /api/v1/user-groups/{id}", s.authenticated(s.handlePatchUserGroup))
mux.Handle("DELETE /api/v1/user-groups/{id}", s.authenticated(s.handleDeleteUserGroup))
mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents))
mux.Handle("PATCH /api/v1/agents/{id}", s.authenticated(s.handleUpdateAgent))
mux.Handle("DELETE /api/v1/agents/{id}", s.authenticated(s.handleDeleteAgent))
mux.Handle("GET /api/v1/agent-enrollments", s.authenticated(s.handleListEnrollments))
mux.Handle("POST /api/v1/agent-enrollments", s.authenticated(s.handleCreateEnrollment))
mux.Handle("DELETE /api/v1/agent-enrollments/{id}", s.authenticated(s.handleDeleteEnrollment))
// Один ендпоїнт на всі черги: питання «чи все встигає» стосується
// системи цілком, і відповідь, зібрана з семи запитів у різні
// секунди, суперечила б сама собі. Див. queues.go.
mux.Handle("GET /api/v1/queues", s.authenticated(s.handleQueues))
// А деталі — окремо й на вимогу: перелік зачеплених хостів потрібен
// після кліку по картці, а знімок їде всім раз на п'ять секунд.
mux.Handle("GET /api/v1/queues/{key}", s.authenticated(s.handleQueueDetail))
// Журнал аудиту — лише GET, і це не тимчасово: ручки, яка міняє чи
// стирає запис, не буде. Див. audit.go.
// Сховище: розміри, приріст і строки зберігання. Дивитись —
// devices:read (нахил має бачити той, хто щодня дивиться на
// систему), міняти — settings:write. Див. storage.go.
mux.Handle("GET /api/v1/storage", s.authenticated(s.handleStorageUsage))
mux.Handle("PUT /api/v1/storage/config", s.authenticated(s.handleSetStorageConfig))
mux.Handle("GET /api/v1/storage/retention", s.authenticated(s.handleGetRetentionSettings))
mux.Handle("POST /api/v1/storage/retention/preview", s.authenticated(s.handleRetentionPreview))
mux.Handle("PUT /api/v1/storage/retention", s.authenticated(s.handleSetRetentionSettings))
// Тариф, стелі й ліцензія. Дивитись — billing:read, і це право
// інженера: «чому не заводиться шістнадцятий хост» питає той, хто
// заводить хости. Міняти — billing:manage, і воно є лише у власника.
// Ліцензійний ключ під тим самим правом, що й тариф: за наслідками
// це та сама дія — вона задає стелі всієї інсталяції.
mux.Handle("GET /api/v1/billing", s.authenticated(s.handleBillingOverview))
mux.Handle("PUT /api/v1/billing/plan", s.authenticated(s.handleSetBillingPlan))
mux.Handle("GET /api/v1/billing/license", s.authenticated(s.handleGetLicense))
mux.Handle("POST /api/v1/billing/license", s.authenticated(s.handleApplyLicense))
mux.Handle("DELETE /api/v1/billing/license", s.authenticated(s.handleClearLicense))
// Звіти SLA. Дивитись — devices:read (доступність своєї мережі бачить
// кожен, хто бачить моніторинг), заводити цілі й закривати періоди —
// settings:write. Див. sla.go.
//
// Закриття — POST, а не PUT, і це не формальність: воно не «зберігає
// стан», а виконує незворотну дію — робить пораховане число фактом.
mux.Handle("GET /api/v1/sla/targets", s.authenticated(s.handleListSLATargets))
mux.Handle("POST /api/v1/sla/targets", s.authenticated(s.handleSaveSLATarget))
mux.Handle("PUT /api/v1/sla/targets/{id}", s.authenticated(s.handleSaveSLATarget))
mux.Handle("DELETE /api/v1/sla/targets/{id}", s.authenticated(s.handleDeleteSLATarget))
mux.Handle("GET /api/v1/sla/targets/{id}/report", s.authenticated(s.handleSLAReport))
mux.Handle("GET /api/v1/sla/targets/{id}/report.csv", s.authenticated(s.handleSLAReportCSV))
mux.Handle("POST /api/v1/sla/targets/{id}/close", s.authenticated(s.handleCloseSLAPeriod))
mux.Handle("GET /api/v1/audit", s.authenticated(s.handleListAudit))
mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta))
mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent))
mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts))
mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert))
mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert))
mux.Handle("POST /api/v1/mutes", s.authenticated(s.handleMuteDevice))
mux.Handle("GET /api/v1/alert-rules", s.authenticated(s.handleListAlertRules))
mux.Handle("POST /api/v1/alert-rules", s.authenticated(s.handleCreateAlertRule))
mux.Handle("PUT /api/v1/alert-rules/{id}", s.authenticated(s.handleCreateAlertRule))
mux.Handle("PATCH /api/v1/alert-rules/{id}", s.authenticated(s.handlePatchAlertRule))
mux.Handle("DELETE /api/v1/alert-rules/{id}", s.authenticated(s.handleDeleteAlertRule))
// Драбини ескалації. Читання під alerts:read разом із рештою
// сповіщень: побачити, хто буде розбуджений другим, має право той
// самий, хто бачить алерти. Зміна — під alerts:write.
mux.Handle("GET /api/v1/escalation-policies", s.authenticated(s.handleListEscalationPolicies))
mux.Handle("POST /api/v1/escalation-policies", s.authenticated(s.handleSaveEscalationPolicy))
mux.Handle("PUT /api/v1/escalation-policies/{id}", s.authenticated(s.handleSaveEscalationPolicy))
mux.Handle("DELETE /api/v1/escalation-policies/{id}", s.authenticated(s.handleDeleteEscalationPolicy))
mux.Handle("GET /api/v1/channels", s.authenticated(s.handleListChannels))
mux.Handle("POST /api/v1/channels", s.authenticated(s.handleCreateChannel))
mux.Handle("PUT /api/v1/channels/{id}", s.authenticated(s.handleCreateChannel))
mux.Handle("DELETE /api/v1/channels/{id}", s.authenticated(s.handleDeleteChannel))
mux.Handle("POST /api/v1/channels/{id}/test", s.authenticated(s.handleTestChannel))
// WebSocket теж під автентифікацією: браузер шле токен у
// заголовку через підпротокол — див. ws.go.
mux.Handle("GET /api/v1/ws", s.authenticated(s.handleWS))
// Статика останньою: "/" у ServeMux ловить усе, що не збіглося з
// конкретнішими маршрутами, тож API лишається головним, а фронтенд
// отримує решту. Без цього /api/v1/невідомий-шлях віддавав би
// index.html замість 404, і клієнт падав би на розборі HTML як JSON.
mux.HandleFunc("/", s.serveStatic)
return s.withRecovery(s.withLogging(mux))
}
// ---------------------------------------------------------------------
// Проміжні шари
// ---------------------------------------------------------------------
// bearerToken дістає токен із заголовка або з підпротоколу WebSocket.
//
// Другий шлях потрібен тому, що браузерний WebSocket API не дозволяє
// задати довільні заголовки — токен передається як підпротокол
// "netpulse.token.<value>". Це загальноприйнятий обхід; сам токен при
// цьому не потрапляє в URL, а отже і в логи проксі.
func bearerToken(r *http.Request) string {
if v := r.Header.Get("Authorization"); v != "" {
if after, ok := strings.CutPrefix(v, "Bearer "); ok {
return strings.TrimSpace(after)
}
}
for _, proto := range websocketProtocols(r) {
if after, ok := strings.CutPrefix(proto, "netpulse.token."); ok {
return after
}
}
return ""
}
func websocketProtocols(r *http.Request) []string {
raw := r.Header.Get("Sec-WebSocket-Protocol")
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
level := slog.LevelDebug
if rec.status >= 500 {
level = slog.LevelError
}
s.log.Log(r.Context(), level, "http",
"method", r.Method, "path", r.URL.Path,
"status", rec.status, "ms", time.Since(start).Milliseconds())
})
}
// withRecovery не дає паніці в одному запиті вбити весь процес разом
// із живими WebSocket-підписниками.
func (s *Server) withRecovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
s.log.Error("паніка в обробнику", "path", r.URL.Path, "panic", v)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
}
}()
next.ServeHTTP(w, r)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
written bool
}
func (r *statusRecorder) WriteHeader(code int) {
if r.written {
return
}
r.written = true
r.status = code
r.ResponseWriter.WriteHeader(code)
}
// Hijack потрібен, бо WebSocket перехоплює з'єднання, а обгортка
// логування стоїть у ланцюжку вище.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// ---------------------------------------------------------------------
// Обробники
// ---------------------------------------------------------------------
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"subscribers": s.hub.SubscriberCount(),
})
}
func (s *Server) handleListMaps(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
return
}
maps, err := s.store.ListMaps(r.Context(), p.TenantID, p.UserID)
if err != nil {
s.log.Error("перелік мап", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if maps == nil {
maps = []store.MapSummary{}
}
writeJSON(w, http.StatusOK, map[string]any{"maps": maps})
}
func (s *Server) handleGetMap(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("maps:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права maps:read")
return
}
// Групи доступу можуть закрити конкретну мапу навіть тому, хто має
// maps:read: право читати мапи взагалі й право читати цю — різні речі.
if level, aerr := s.store.MapAccess(r.Context(), p.TenantID, p.UserID, r.PathValue("id")); aerr == nil &&
level == "deny" {
writeError(w, http.StatusForbidden, "forbidden", "немає доступу до цієї мапи")
return
}
state, err := s.store.GetMapState(r.Context(), p.TenantID, r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "мапу не знайдено")
return
}
if err != nil {
// Невалідний uuid у шляху доходить сюди помилкою розбору —
// для клієнта це 400, а не 500.
if strings.Contains(err.Error(), "invalid input syntax for type uuid") {
writeError(w, http.StatusBadRequest, "bad_id", "некоректний ідентифікатор мапи")
return
}
s.log.Error("стан мапи", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
writeJSON(w, http.StatusOK, state)
}
func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("devices:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права devices:read")
return
}
// ?view=archived — перелік прибраних хостів.
//
// Окреме значення, а не прапорець «і прибраних теж»: перелік або
// про те, що працює, або про те, що прибрано, і третього стану
// («усе разом») тут немає навмисно — дії над живим і прибраним
// хостом різні, і один перелік на обидва означав би кнопку, яка
// для половини рядків не має сенсу.
//
// Невідоме значення читається як «живі»: клієнт старішої збірки,
// який дописав щось своє, має отримати колишню поведінку, а не
// відмову.
view := store.DeviceViewLive
if r.URL.Query().Get("view") == string(store.DeviceViewArchived) {
view = store.DeviceViewArchived
}
devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope(), view)
if err != nil {
s.log.Error("перелік пристроїв", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if devices == nil {
devices = []store.DeviceSummary{}
}
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
}
func (s *Server) handleListAgents(w http.ResponseWriter, r *http.Request, p *Principal) {
if !p.Can("agents:read") {
writeError(w, http.StatusForbidden, "forbidden", "немає права agents:read")
return
}
agents, err := s.store.ListAgents(r.Context(), p.TenantID)
if err != nil {
s.log.Error("перелік зондів", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "внутрішня помилка")
return
}
if agents == nil {
agents = []store.AgentSummary{}
}
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
}
// ---------------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, map[string]any{
"error": map[string]string{"code": code, "message": message},
})
}