Netpulse_SasS/server/internal/httpapi/server.go
byrsapty ed8fc831bf Дві сесії роботи: 0058–0068, розгортання однією командою, тести
Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (store.go, docker-compose.yml, deploy/README.md), і
розділити їх можна було б лише індексуванням шматків. Коміти, які не
збираються, гірші за один великий — тим паче що це рівно той стан, який
перевірявся разом.

ЩО ПРАЦЮЄ НА СТЕНДІ Й ПЕРЕВІРЕНО ТАМ

  0058  подієві алерти: syslog, ncm, compliance спрацьовують у мить
        події; правило з нереалізованим джерелом більше не зберігається
        мовчки
  0059  snmp.walk і прототипи шаблонів — таблиці з динамічним індексом
        описуються шаблоном, а не Go
  0060  відкат конфігу: план як різниця, маскування паролів із підписом
        плану, обов'язковий контрольний збір, verifying при обриві
  0061  кнопки Telegram: довге опитування, авторизація не з callback_data
  0062  аудит і архів хостів; тест на AST, що падає на ключі без назви
  0063  RLS: три ролі, окремий пул для фонових тактів
  0064  строки зберігання даних і сторінка сховища
  0065  приймач SNMP-трапів; перевірено справжніми пакетами по дроту,
        переклад v1→v2 за RFC 3584 дає правильний OID
  0066  ескалації сповіщень
  0067  алерт про вичерпання диска
  0068  поля заливки конфігу переїхали в каталог профілів

Плюс: 137 тестів вебу з нуля (їх не було взагалі), одинадцять справжніх
вад, знайдених ними й виправлених, і виправлення двох інтеграційних
тестів grpcapi, які мовчки пропускались півтора року.

ЩО ЩЕ НЕ ЗАПУСКАЛОСЬ

  netpulse            установник: одна команда замість 18 змінних і
                      593 рядків інструкції
  RLS з першого запуску  нова інсталяція під політиками одразу;
                      RLS-EXISTING-INSTALL.md лишається тільки для
                      старих інсталяцій
  .forgejo + CI       раннер не зареєстрований

Ці три перевірені компіляцією й міркуванням, але не виконанням.

ГОЛОВНИЙ ВИСНОВОК ДВОХ СЕСІЙ

Зелена перевірка доводить рівно те, що вона перевіряє. Тест ізоляції RLS
був правильний і зелений — і пропустив зламаний вхід, бо перевіряв «чи
не видно чужого», коли зламалось «чи видно своє». Інтеграційні тести
grpcapi були зелені, бо не виконувались. Схема, довідник і протокол
описували те, чого в коді не існувало, і виглядало це як готове.

Тому в кожному завданні цих сесій стояла вимога назвати НЕПОКРИТЕ, а
чотири задачі закінчились не можливістю, а відмовою: правило з
нереалізованим джерелом не зберігається, профіль без команд заливки
каже про це замість мовчазної кнопки, міграція RLS валить сама себе на
таблиці без політики, тест словника аудиту падає на ключі без назви.

Подробиці — HISTORY.md, розділи за 26 і 27 серпня.
2026-08-27 17:32:49 +03:00

586 lines
33 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
}
// 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
}
// 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))
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},
})
}