Netpulse_SasS/server/internal/httpapi/server.go
byrsapty 95a19b074d
Some checks failed
CI / agent (push) Waiting to run
CI / hygiene (push) Successful in 9s
CI / web (push) Successful in 1m11s
CI / server (push) Successful in 1m35s
CI / dbtest (push) Has been cancelled
Історія входів: 93 записи були в базі й ніде не показувались
core.login_attempts наповнювалась на кожній спробі входу й читалась
рівно в одному місці — для стримування підбору. Ні ендпойнта, ні
сторінки. На питання «хто заходив у мій моніторинг» продукт мав
відповідь у базі й не мав способу її показати.

ГОЛОВНЕ БУЛА НЕ ВЕРСТКА. У таблиці немає tenant_id — саме тому ці
події й не в аудиті. Показати «як є» означало б віддати одному
кабінету спроби входу чужих людей. Прив'язка непряма: спроба ->
користувач -> членство, трьома шляхами одночасно (user_id, username,
email) в одному JOIN LATERAL з tenant_id усередині. Саме JOIN, а не
LEFT JOIN: без збігу рядок ПРИБИРАЄТЬСЯ, а не лишається без імені.

На бойових даних це не теорія: у власника немає пошти, тож за email
не прив'язується ЖОДНА з 94 спроб. Шлях через username дає 93.

Невдала спроба з неіснуючим логіном не належить нікому: рядком не
показується, але рахується числом — сигнал «логіни перебирають»
лишається, чужа людина в чужий кабінет не потрапляє.

Ізоляцію перевірено проти справжньої бази (TestLoginAttemptsTenantIsolation).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:06:42 +03:00

660 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Package httpapi — REST і WebSocket для фронтенду.
//
// Окремий процес від AgentService навмисно: зонди й браузери мають різні
// профілі навантаження, різні мережеві периметри й різні цикли релізів.
// Спільним лишається лише шар store, тому обидва бачать однакові дані.
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/alerting"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/logbuf"
"github.com/netpulse/netpulse/server/internal/store"
)
type Server struct {
store *store.Store
log *slog.Logger
hub *Hub
signer *auth.Signer
// Потрібні лише для каналів сповіщень: без них решта API працює,
// а /api/v1/channels відповідає 503 із поясненням.
keyring *crypto.Keyring
notifier *alerting.Notifier
// Приймач подій для правил джерела `compliance`: прогін
// відповідності запускається саме звідси, і саме він — та мить,
// коли порушення стає подією. nil означає, що движок алертів на
// цій інсталяції вимкнено.
events *alerting.EventSink
// Відкриті ключі, якими перевіряють ліцензії. nil — робочий стан:
// збірка без вшитого ключа працює як була, а сторінка тарифу чесно
// каже, що перевірити ключ нічим. Мовчазна відмова тут була б
// гіршою за відсутність можливості.
license *store.LicenseVerifier
// Кільце останніх рядків власного журналу цього процесу й адреса
// такого самого кільця в колекторі. nil і порожньо — робочий стан:
// сторінка журналу тоді чесно каже, чого саме бракує. Див.
// serverlog.go.
logRing *logbuf.Ring
collectorLogURL string
collectorLogToken string
}
// New створює сервер. signer може бути nil лише в тестах, які не
// перевіряють вхід людей: без нього /api/v1/auth/* віддаватиме 500.
func New(st *store.Store, signer *auth.Signer, log *slog.Logger) *Server {
if log == nil {
log = slog.Default()
}
s := &Server{store: st, log: log, signer: signer}
s.hub = NewHub(st, log)
return s
}
// WithNotifications вмикає керування каналами доставки.
//
// Виділено в окремий метод, а не в аргументи New: більшість тестів і
// значна частина інсталяцій (кіоск, читальний інстанс) каналами не
// керують, і вимагати від них ключ шифрування було б безпідставно.
func (s *Server) WithNotifications(ring *crypto.Keyring, n *alerting.Notifier) *Server {
s.keyring, s.notifier = ring, n
return s
}
// WithEventAlerts вмикає подієві алерти на прогоні відповідності.
//
// Окремо від WithNotifications, бо це різні речі: там — доставка, тут —
// сама поява алерту. Інсталяція без каналів усе одно має бачити
// порушення на дошці.
func (s *Server) WithEventAlerts(sink *alerting.EventSink) *Server {
s.events = sink
return s
}
// WithKeyring дає серверу ключі шифрування без движка алертів.
//
// До появи дзеркала конфігів ключ приходив лише разом із каналами
// сповіщень (WithNotifications), і на інсталяції з вимкненим движком
// алертів його не було зовсім. Секрет дзеркала до алертів стосунку не
// має, тож і залежності такої бути не повинно.
func (s *Server) WithKeyring(ring *crypto.Keyring) *Server {
s.keyring = ring
return s
}
// WithLicense дає серверу відкриті ключі перевірки ліцензій.
//
// Окремим методом, а не аргументом New, з тієї ж причини, що й ключі
// шифрування: інсталяція без ліцензії — це не поламана інсталяція, а
// звичайна. Вимагати ключ від тесту, від кіоску й від клієнта, який
// щойно розгорнув систему й ще нічого не купив, було б безпідставно.
func (s *Server) WithLicense(v *store.LicenseVerifier) *Server {
s.license = v
return s
}
// Hub — доступ до трансляції для зовнішнього коду (тести, метрики).
func (s *Server) Hub() *Hub { return s.hub }
// Handler збирає маршрути.
//
// Роутер стандартної бібліотеки: Go 1.22 вміє шаблони з методом і
// параметрами шляху, і цього тут вистачає. Зовнішній роутер додав би
// залежність заради синтаксису.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
// Вхід — єдині відкриті ендпоїнти: до них особи ще немає.
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh)
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
mux.Handle("GET /api/v1/me", s.authenticated(s.handleMe))
mux.Handle("POST /api/v1/auth/password", s.authenticated(s.handleChangePassword))
// Прив'язка telegram-акаунта. Ендпоїнта, який приймав би натискання
// кнопок, тут немає й не буде: Telegram до цього сервера не
// достукається (самопідписаний TLS на IP), тому оновлення забирає
// довге опитування — див. alerting/telegram_bot.go.
mux.Handle("GET /api/v1/me/telegram", s.authenticated(s.handleGetTelegramLink))
mux.Handle("POST /api/v1/me/telegram/code", s.authenticated(s.handleCreateTelegramLinkCode))
mux.Handle("DELETE /api/v1/me/telegram", s.authenticated(s.handleDeleteTelegramLink))
mux.Handle("GET /api/v1/team", s.authenticated(s.handleListTeam))
mux.Handle("POST /api/v1/team", s.authenticated(s.handleCreateUser))
mux.Handle("PATCH /api/v1/team/{id}", s.authenticated(s.handleSetRole))
mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember))
mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles))
mux.Handle("POST /api/v1/roles", s.authenticated(s.handleCreateRole))
mux.Handle("PATCH /api/v1/roles/{id}", s.authenticated(s.handleUpdateRole))
mux.Handle("DELETE /api/v1/roles/{id}", s.authenticated(s.handleDeleteRole))
mux.Handle("GET /api/v1/permissions", s.authenticated(s.handlePermissionCatalog))
mux.Handle("GET /api/v1/dashboards", s.authenticated(s.handleListDashboards))
mux.Handle("POST /api/v1/dashboards", s.authenticated(s.handleSaveDashboard))
mux.Handle("GET /api/v1/dashboards/{id}", s.authenticated(s.handleGetDashboard))
mux.Handle("PUT /api/v1/dashboards/{id}", s.authenticated(s.handleSaveDashboard))
mux.Handle("DELETE /api/v1/dashboards/{id}", s.authenticated(s.handleDeleteDashboard))
mux.Handle("POST /api/v1/dashboards/{id}/default", s.authenticated(s.handleSetDefaultDashboard))
mux.Handle("POST /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleCreatePublicLink))
mux.Handle("DELETE /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleRevokePublicLink))
// Режим NOC TV. Єдина частина API без входу — доступ дає токен у
// посиланні. Обмеження описані в tv.go.
mux.HandleFunc("GET /api/v1/tv/{token}", s.handleTVDashboard)
mux.HandleFunc("GET /api/v1/tv/{token}/alerts", s.handleTVAlerts)
mux.HandleFunc("GET /api/v1/tv/{token}/devices", s.handleTVDevices)
mux.HandleFunc("GET /api/v1/tv/{token}/devices/{device}/metrics", s.handleTVMetrics)
mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps))
mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap))
mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap))
mux.Handle("PATCH /api/v1/maps/{id}", s.authenticated(s.handlePatchMap))
mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap))
mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap))
mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap))
mux.Handle("GET /api/v1/maps/{id}/interfaces", s.authenticated(s.handleMapInterfaces))
mux.Handle("POST /api/v1/maps/{id}/discover", s.authenticated(s.handleMapDiscover))
mux.Handle("GET /api/v1/maps/{id}/permissions", s.authenticated(s.handleListMapPermissions))
mux.Handle("PUT /api/v1/maps/{id}/permissions", s.authenticated(s.handleSetMapPermissions))
mux.Handle("GET /api/v1/icons", s.authenticated(s.handleListIcons))
mux.Handle("POST /api/v1/icons", s.authenticated(s.handleCreateIcon))
mux.Handle("GET /api/v1/icons/{id}", s.authenticated(s.handleGetIcon))
mux.Handle("DELETE /api/v1/icons/{id}", s.authenticated(s.handleDeleteIcon))
mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices))
mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice))
mux.Handle("GET /api/v1/devices/{id}", s.authenticated(s.handleGetDevice))
mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice))
mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice))
// Картка хоста: паспорт, останні дані з походженням, порти.
mux.Handle("GET /api/v1/devices/{id}/overview", s.authenticated(s.handleDeviceOverview))
mux.Handle("GET /api/v1/devices/{id}/latest", s.authenticated(s.handleDeviceLatest))
mux.Handle("GET /api/v1/devices/{id}/interfaces", s.authenticated(s.handleDeviceInterfaces))
// Масова правка й масове видалення. Перегляд набору — окремий крок
// і окрема ручка, як і в масовому виконанні команд: підтвердження
// без точного переліку не вважається підтвердженням.
mux.Handle("POST /api/v1/devices/bulk-targets", s.authenticated(s.handleBulkDeviceTargets))
mux.Handle("POST /api/v1/devices/bulk-update", s.authenticated(s.handleBulkUpdateDevices))
mux.Handle("POST /api/v1/devices/bulk-delete", s.authenticated(s.handleBulkDeleteDevices))
mux.Handle("POST /api/v1/devices/bulk-restore", s.authenticated(s.handleBulkRestoreDevices))
mux.Handle("POST /api/v1/devices/{id}/identify", s.authenticated(s.handleIdentifyDevice))
mux.Handle("POST /api/v1/devices/{id}/collect-config", s.authenticated(s.handleCollectConfig))
mux.Handle("GET /api/v1/devices/{id}/config-jobs", s.authenticated(s.handleListConfigJobs))
mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs))
// Добір хостів на сторінці конфігів: спільний фільтр плюс стан архіву.
mux.Handle("POST /api/v1/ncm/config-devices", s.authenticated(s.handleConfigDevices))
mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig))
mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs))
mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy))
mux.Handle("PUT /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleSetBackupPolicy))
// Довідник для форми розкладу — вузький, лише для вибору.
mux.Handle("GET /api/v1/ncm-profiles", s.authenticated(s.handleListProfiles))
// Повний перелік і редагування — для сторінки профілів.
mux.Handle("GET /api/v1/ncm/profiles", s.authenticated(s.handleListNcmProfiles))
mux.Handle("POST /api/v1/ncm/profiles", s.authenticated(s.handleSaveNcmProfile))
mux.Handle("PUT /api/v1/ncm/profiles/{id}", s.authenticated(s.handleSaveNcmProfile))
mux.Handle("DELETE /api/v1/ncm/profiles/{id}", s.authenticated(s.handleDeleteNcmProfile))
mux.Handle("GET /api/v1/ncm/compliance/rules", s.authenticated(s.handleListComplianceRules))
mux.Handle("POST /api/v1/ncm/compliance/rules", s.authenticated(s.handleSaveComplianceRule))
mux.Handle("PUT /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleSaveComplianceRule))
mux.Handle("DELETE /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleDeleteComplianceRule))
mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults))
mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance))
// Перевірка зразка на справжньому конфізі — до збереження правила.
// Без неї редактор правил є генератором тихої брехні: зразок, який
// не збігається ніколи, дає «порушень немає».
mux.Handle("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))
// Історія входів — поруч із журналом і під тим самим правом
// (audit:read). Обидві сторінки відповідають на одне питання «хто і
// що робив з нашим доступом», і розслідування — це поєднання їхніх
// рядків. Ізоляція кабінетів тут зроблена руками: у
// core.login_attempts немає tenant_id. Див. logins.go.
mux.Handle("GET /api/v1/logins", s.authenticated(s.handleListLoginAttempts))
mux.Handle("GET /api/v1/logins/meta", s.authenticated(s.handleLoginAttemptsMeta))
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},
})
}