Один коміт, а не десяток тематичних, свідомо: теми переплетені в
спільних файлах (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 серпня.
586 lines
33 KiB
Go
586 lines
33 KiB
Go
// 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},
|
||
})
|
||
}
|