Netpulse_SasS/server/internal/httpapi/server.go
byrsapty 04e1242c52
All checks were successful
CI / hygiene (push) Successful in 8s
CI / web (push) Successful in 1m10s
CI / server (push) Successful in 1m41s
CI / agent (push) Successful in 59s
Журнали файлами, тонший штрих мінікарти
ФАЙЛИ В /var/log/netpulse. rsyslog читає journald і розкладає по файлах
на службу. Драйвер docker лишається journald, а не syslog: syslog
віддав би рядки назовні й нічого не лишив докеру, тож `docker logs`
замовк би назавжди — і мовчання виглядало б як «служба нічого не пише».
Тепер працюють усі три шляхи: docker logs, journalctl, файли.

Дорогою три власні помилки, кожна виглядала як «rsyslog не працює»:

  1. Умова матчила $!CONTAINER_NAME — метадані журналу. journalctl їх
     бачить, а до правил rsyslog вони доходять не завжди. Каталог
     створювався й лишався порожнім.
  2. Тег «netpulse/{{.Name}}» здавався охайнішим, але rsyslog обриває
     programname на скісній: для ВСІХ контейнерів вона ставала просто
     «netpulse». Тег тепер — саме ім'я контейнера, воно й так має
     префікс проєкту.
  3. Шаблон `%msg:::sp-if-no-1st-sp,drop-last-lf%` давав ПОРОЖНІЙ
     текст: файли були, рядки були, слів не було. Журнал виглядав
     робочим і не містив нічого — найгірший різновид поломки.

Конфігурації в deploy/, щоб їхали клієнтам, а не лишались разовим
налаштуванням одного сервера.

МІНІКАРТА. Штрих рядка був 2 px при 3 px на рядок — просвіт в один
піксель. На дробовому масштабі екрана (1.25, 1.5 — тобто на більшості
ноутбуків) він губився при округленні, і рядки злипались у суцільну
пляму: мінікарта показувала не форму конфігу, а сірий прямокутник.

Тепер штрих 1 px, просвіт удвічі товщий за нього й переживає будь-яке
округлення. Малюнок став блідішим — це правильний бік розміну: на
мінікарту дивляться, щоб побачити структуру, а не прочитати текст.
2026-08-27 22:55:29 +03:00

643 lines
37 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/logbuf"
"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
// Кільце останніх рядків власного журналу цього процесу й адреса
// такого самого кільця в колекторі. nil і порожньо — робочий стан:
// сторінка журналу тоді чесно каже, чого саме бракує. Див.
// serverlog.go.
logRing *logbuf.Ring
collectorLogURL string
collectorLogToken string
}
// 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))
// Журнал самого сервера — те, на що лається NetPulse, а не мережа.
// Під settings:write: це системний журнал з адресами, іменами
// хостів і текстами помилок SQL, і бачити його має рівно той, хто й
// так бачить усе. Див. serverlog.go.
mux.Handle("GET /api/v1/server-log", s.authenticated(s.handleServerLog))
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},
})
}