Перший справжній прогін на живій мережі дав 18 порушень із 28: типові
SNMP-community на всіх шести хостах, telnet на керуванні на чотирьох,
паролі відкритим і зворотним текстом. Механізм працює — тому з
результатом тепер треба щось робити.
РЕДАГУВАННЯ. Вбудовані правила замкнені на те, що визначає ПИТАННЯ
(name, kind, pattern, config_type) і відкриті на політику кабінету
(enabled, severity, selector, remediation). Причина замка — доказ:
тест читає зразки з міграції й показує для кожного конфіг, де він
мусить спрацювати і де не мусить. Переписаний руками зразок цього
доказу не має, а значок «вбудоване» лишається — у звіті для аудитора
рядок означав би вже не те, що в довіднику. Для правок є копія.
Перевірка зразка на живому конфізі ДО збереження: віддає рядки з
номерами й окремо розрізняє «конфігу немає» від «нічого не знайшов».
Для правил «не має бути» нуль збігів підсвічується: це те саме, що
показало б правило з опискою.
ЗНАХІДКИ РЕЦЕНЗІЇ — всі пʼять підтверджені:
1. Перше збереження будь-якого вбудованого правила стирало результати.
Селектор порівнювався в базі, але порівнювались різні представлення
одного значення: міграція кладе {}, Go марширує сім ключів із null.
Тепер порівняння за ЗНАЧЕННЯМ у Go, колонка канонізується сама.
2. CSV приймав ін’єкцію формул — у клітинку йде сирий рядок конфігу, а
файл відкриває аудитор. Одне місце екранування на всі три звіти:
дублювати захист у трьох файлах означає забути його в четвертому.
3. Знахідки вимкнених правил і зниклих хостів лишались назавжди й
рахувались як чинні. Три заслони: фільтр у списку, прибирання при
прогоні, і звіт їх не рахує.
4. Лічильники в списку правил рахувались по всіх хостах повз права —
інженер філії бачив «5 з 12», а в знахідках дві. Тепер це одне
число, а не два.
5. Доказ перевірки зразка лишався на екрані після правки зразка — тобто
ручка робила протилежне до задуманого в мить найвищої довіри.
652 lines
38 KiB
Go
652 lines
38 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/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("GET /api/v1/ncm/compliance/probe-devices", s.authenticated(s.handleComplianceProbeDevices))
|
||
mux.Handle("POST /api/v1/ncm/compliance/probe", s.authenticated(s.handleComplianceProbe))
|
||
// Звіт. Два шляхи, бо два типи вмісту: .csv віддає файл, і плутати
|
||
// його з JSON в одному шляху означає, що клієнт має вгадувати.
|
||
mux.Handle("GET /api/v1/ncm/compliance/report", s.authenticated(s.handleComplianceReport))
|
||
mux.Handle("GET /api/v1/ncm/compliance/report.csv", s.authenticated(s.handleComplianceReportCSV))
|
||
|
||
// Масове виконання команд. Перегляд переліку хостів — окремий крок
|
||
// і окремий ендпоїнт: підтвердження без точного переліку не
|
||
// вважається підтвердженням, і клієнт не має змоги його оминути.
|
||
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},
|
||
})
|
||
}
|