// Package httpapi — REST і WebSocket для фронтенду. // // Окремий процес від AgentService навмисно: зонди й браузери мають різні // профілі навантаження, різні мережеві периметри й різні цикли релізів. // Спільним лишається лише шар store, тому обидва бачать однакові дані. package httpapi import ( "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "github.com/netpulse/netpulse/server/internal/alerting" "github.com/netpulse/netpulse/server/internal/auth" "github.com/netpulse/netpulse/server/internal/crypto" "github.com/netpulse/netpulse/server/internal/logbuf" "github.com/netpulse/netpulse/server/internal/store" ) type Server struct { store *store.Store log *slog.Logger hub *Hub signer *auth.Signer // Потрібні лише для каналів сповіщень: без них решта API працює, // а /api/v1/channels відповідає 503 із поясненням. keyring *crypto.Keyring notifier *alerting.Notifier // Приймач подій для правил джерела `compliance`: прогін // відповідності запускається саме звідси, і саме він — та мить, // коли порушення стає подією. nil означає, що движок алертів на // цій інсталяції вимкнено. events *alerting.EventSink // Відкриті ключі, якими перевіряють ліцензії. nil — робочий стан: // збірка без вшитого ключа працює як була, а сторінка тарифу чесно // каже, що перевірити ключ нічим. Мовчазна відмова тут була б // гіршою за відсутність можливості. license *store.LicenseVerifier // Кільце останніх рядків власного журналу цього процесу й адреса // такого самого кільця в колекторі. nil і порожньо — робочий стан: // сторінка журналу тоді чесно каже, чого саме бракує. Див. // serverlog.go. logRing *logbuf.Ring collectorLogURL string collectorLogToken string } // New створює сервер. signer може бути nil лише в тестах, які не // перевіряють вхід людей: без нього /api/v1/auth/* віддаватиме 500. func New(st *store.Store, signer *auth.Signer, log *slog.Logger) *Server { if log == nil { log = slog.Default() } s := &Server{store: st, log: log, signer: signer} s.hub = NewHub(st, log) return s } // WithNotifications вмикає керування каналами доставки. // // Виділено в окремий метод, а не в аргументи New: більшість тестів і // значна частина інсталяцій (кіоск, читальний інстанс) каналами не // керують, і вимагати від них ключ шифрування було б безпідставно. func (s *Server) WithNotifications(ring *crypto.Keyring, n *alerting.Notifier) *Server { s.keyring, s.notifier = ring, n return s } // WithEventAlerts вмикає подієві алерти на прогоні відповідності. // // Окремо від WithNotifications, бо це різні речі: там — доставка, тут — // сама поява алерту. Інсталяція без каналів усе одно має бачити // порушення на дошці. func (s *Server) WithEventAlerts(sink *alerting.EventSink) *Server { s.events = sink return s } // WithKeyring дає серверу ключі шифрування без движка алертів. // // До появи дзеркала конфігів ключ приходив лише разом із каналами // сповіщень (WithNotifications), і на інсталяції з вимкненим движком // алертів його не було зовсім. Секрет дзеркала до алертів стосунку не // має, тож і залежності такої бути не повинно. func (s *Server) WithKeyring(ring *crypto.Keyring) *Server { s.keyring = ring return s } // WithLicense дає серверу відкриті ключі перевірки ліцензій. // // Окремим методом, а не аргументом New, з тієї ж причини, що й ключі // шифрування: інсталяція без ліцензії — це не поламана інсталяція, а // звичайна. Вимагати ключ від тесту, від кіоску й від клієнта, який // щойно розгорнув систему й ще нічого не купив, було б безпідставно. func (s *Server) WithLicense(v *store.LicenseVerifier) *Server { s.license = v return s } // Hub — доступ до трансляції для зовнішнього коду (тести, метрики). func (s *Server) Hub() *Hub { return s.hub } // Handler збирає маршрути. // // Роутер стандартної бібліотеки: Go 1.22 вміє шаблони з методом і // параметрами шляху, і цього тут вистачає. Зовнішній роутер додав би // залежність заради синтаксису. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.handleHealth) // Вхід — єдині відкриті ендпоїнти: до них особи ще немає. mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) mux.HandleFunc("POST /api/v1/auth/refresh", s.handleRefresh) mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) mux.Handle("GET /api/v1/me", s.authenticated(s.handleMe)) mux.Handle("POST /api/v1/auth/password", s.authenticated(s.handleChangePassword)) // Прив'язка telegram-акаунта. Ендпоїнта, який приймав би натискання // кнопок, тут немає й не буде: Telegram до цього сервера не // достукається (самопідписаний TLS на IP), тому оновлення забирає // довге опитування — див. alerting/telegram_bot.go. mux.Handle("GET /api/v1/me/telegram", s.authenticated(s.handleGetTelegramLink)) mux.Handle("POST /api/v1/me/telegram/code", s.authenticated(s.handleCreateTelegramLinkCode)) mux.Handle("DELETE /api/v1/me/telegram", s.authenticated(s.handleDeleteTelegramLink)) mux.Handle("GET /api/v1/team", s.authenticated(s.handleListTeam)) mux.Handle("POST /api/v1/team", s.authenticated(s.handleCreateUser)) mux.Handle("PATCH /api/v1/team/{id}", s.authenticated(s.handleSetRole)) mux.Handle("DELETE /api/v1/team/{id}", s.authenticated(s.handleRemoveMember)) mux.Handle("GET /api/v1/roles", s.authenticated(s.handleListRoles)) mux.Handle("POST /api/v1/roles", s.authenticated(s.handleCreateRole)) mux.Handle("PATCH /api/v1/roles/{id}", s.authenticated(s.handleUpdateRole)) mux.Handle("DELETE /api/v1/roles/{id}", s.authenticated(s.handleDeleteRole)) mux.Handle("GET /api/v1/permissions", s.authenticated(s.handlePermissionCatalog)) mux.Handle("GET /api/v1/dashboards", s.authenticated(s.handleListDashboards)) mux.Handle("POST /api/v1/dashboards", s.authenticated(s.handleSaveDashboard)) mux.Handle("GET /api/v1/dashboards/{id}", s.authenticated(s.handleGetDashboard)) mux.Handle("PUT /api/v1/dashboards/{id}", s.authenticated(s.handleSaveDashboard)) mux.Handle("DELETE /api/v1/dashboards/{id}", s.authenticated(s.handleDeleteDashboard)) mux.Handle("POST /api/v1/dashboards/{id}/default", s.authenticated(s.handleSetDefaultDashboard)) mux.Handle("POST /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleCreatePublicLink)) mux.Handle("DELETE /api/v1/dashboards/{id}/public-link", s.authenticated(s.handleRevokePublicLink)) // Режим NOC TV. Єдина частина API без входу — доступ дає токен у // посиланні. Обмеження описані в tv.go. mux.HandleFunc("GET /api/v1/tv/{token}", s.handleTVDashboard) mux.HandleFunc("GET /api/v1/tv/{token}/alerts", s.handleTVAlerts) mux.HandleFunc("GET /api/v1/tv/{token}/devices", s.handleTVDevices) mux.HandleFunc("GET /api/v1/tv/{token}/devices/{device}/metrics", s.handleTVMetrics) mux.Handle("GET /api/v1/maps", s.authenticated(s.handleListMaps)) mux.Handle("POST /api/v1/maps", s.authenticated(s.handleCreateMap)) mux.Handle("GET /api/v1/maps/{id}", s.authenticated(s.handleGetMap)) mux.Handle("PATCH /api/v1/maps/{id}", s.authenticated(s.handlePatchMap)) mux.Handle("DELETE /api/v1/maps/{id}", s.authenticated(s.handleDeleteMap)) mux.Handle("POST /api/v1/maps/{id}/build", s.authenticated(s.handleBuildMap)) mux.Handle("POST /api/v1/maps/{id}/undo", s.authenticated(s.handleUndoMap)) mux.Handle("GET /api/v1/maps/{id}/interfaces", s.authenticated(s.handleMapInterfaces)) mux.Handle("POST /api/v1/maps/{id}/discover", s.authenticated(s.handleMapDiscover)) mux.Handle("GET /api/v1/maps/{id}/permissions", s.authenticated(s.handleListMapPermissions)) mux.Handle("PUT /api/v1/maps/{id}/permissions", s.authenticated(s.handleSetMapPermissions)) mux.Handle("GET /api/v1/icons", s.authenticated(s.handleListIcons)) mux.Handle("POST /api/v1/icons", s.authenticated(s.handleCreateIcon)) mux.Handle("GET /api/v1/icons/{id}", s.authenticated(s.handleGetIcon)) mux.Handle("DELETE /api/v1/icons/{id}", s.authenticated(s.handleDeleteIcon)) mux.Handle("GET /api/v1/devices", s.authenticated(s.handleListDevices)) mux.Handle("POST /api/v1/devices", s.authenticated(s.handleCreateDevice)) mux.Handle("GET /api/v1/devices/{id}", s.authenticated(s.handleGetDevice)) mux.Handle("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice)) mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice)) // Картка хоста: паспорт, останні дані з походженням, порти. mux.Handle("GET /api/v1/devices/{id}/overview", s.authenticated(s.handleDeviceOverview)) mux.Handle("GET /api/v1/devices/{id}/latest", s.authenticated(s.handleDeviceLatest)) mux.Handle("GET /api/v1/devices/{id}/interfaces", s.authenticated(s.handleDeviceInterfaces)) // Масова правка й масове видалення. Перегляд набору — окремий крок // і окрема ручка, як і в масовому виконанні команд: підтвердження // без точного переліку не вважається підтвердженням. mux.Handle("POST /api/v1/devices/bulk-targets", s.authenticated(s.handleBulkDeviceTargets)) mux.Handle("POST /api/v1/devices/bulk-update", s.authenticated(s.handleBulkUpdateDevices)) mux.Handle("POST /api/v1/devices/bulk-delete", s.authenticated(s.handleBulkDeleteDevices)) mux.Handle("POST /api/v1/devices/bulk-restore", s.authenticated(s.handleBulkRestoreDevices)) mux.Handle("POST /api/v1/devices/{id}/identify", s.authenticated(s.handleIdentifyDevice)) mux.Handle("POST /api/v1/devices/{id}/collect-config", s.authenticated(s.handleCollectConfig)) mux.Handle("GET /api/v1/devices/{id}/config-jobs", s.authenticated(s.handleListConfigJobs)) mux.Handle("GET /api/v1/devices/{id}/configs", s.authenticated(s.handleListConfigs)) // Добір хостів на сторінці конфігів: спільний фільтр плюс стан архіву. mux.Handle("POST /api/v1/ncm/config-devices", s.authenticated(s.handleConfigDevices)) mux.Handle("GET /api/v1/configs/{id}", s.authenticated(s.handleGetConfig)) mux.Handle("GET /api/v1/configs/{id}/diff", s.authenticated(s.handleDiffConfigs)) mux.Handle("GET /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleGetBackupPolicy)) mux.Handle("PUT /api/v1/devices/{id}/backup-policy", s.authenticated(s.handleSetBackupPolicy)) // Довідник для форми розкладу — вузький, лише для вибору. mux.Handle("GET /api/v1/ncm-profiles", s.authenticated(s.handleListProfiles)) // Повний перелік і редагування — для сторінки профілів. mux.Handle("GET /api/v1/ncm/profiles", s.authenticated(s.handleListNcmProfiles)) mux.Handle("POST /api/v1/ncm/profiles", s.authenticated(s.handleSaveNcmProfile)) mux.Handle("PUT /api/v1/ncm/profiles/{id}", s.authenticated(s.handleSaveNcmProfile)) mux.Handle("DELETE /api/v1/ncm/profiles/{id}", s.authenticated(s.handleDeleteNcmProfile)) mux.Handle("GET /api/v1/ncm/compliance/rules", s.authenticated(s.handleListComplianceRules)) mux.Handle("POST /api/v1/ncm/compliance/rules", s.authenticated(s.handleSaveComplianceRule)) mux.Handle("PUT /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleSaveComplianceRule)) mux.Handle("DELETE /api/v1/ncm/compliance/rules/{id}", s.authenticated(s.handleDeleteComplianceRule)) mux.Handle("GET /api/v1/ncm/compliance/results", s.authenticated(s.handleListComplianceResults)) mux.Handle("POST /api/v1/ncm/compliance/run", s.authenticated(s.handleRunCompliance)) // Масове виконання команд. Перегляд переліку хостів — окремий крок // і окремий ендпоїнт: підтвердження без точного переліку не // вважається підтвердженням, і клієнт не має змоги його оминути. mux.Handle("POST /api/v1/ncm/command-targets", s.authenticated(s.handlePreviewCommandTargets)) mux.Handle("GET /api/v1/ncm/command-runs", s.authenticated(s.handleListCommandRuns)) mux.Handle("POST /api/v1/ncm/command-runs", s.authenticated(s.handleCreateCommandRun)) mux.Handle("GET /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleGetCommandRun)) mux.Handle("POST /api/v1/ncm/command-runs/{id}/cancel", s.authenticated(s.handleCancelCommandRun)) mux.Handle("DELETE /api/v1/ncm/command-runs/{id}", s.authenticated(s.handleDeleteCommandRun)) // Звіт віддається не тут, а за квитком: файл тягне завантажувач // браузера, який заголовка Authorization не додає. Див. downloads.go. mux.Handle("POST /api/v1/ncm/command-runs/{id}/report-link", s.authenticated(s.handleCommandRunReportLink)) // Відкат конфігу. План — окремий крок і окремий ендпоїнт, з тієї ж // причини, що й перегляд хостів у масових командах: підтвердження // без точного переліку того, що поїде на пристрій, підтвердженням // не є. Погодження й відмова — окремі дії, бо в них інший автор. // // Політика лежить поруч, але змінюється іншим правом // (settings:write): вимикач вимоги «другої людини» не має належати // тому, кого ця вимога стримує. Див. ncm_rollback.go. mux.Handle("POST /api/v1/ncm/rollbacks/plan", s.authenticated(s.handleRollbackPlan)) mux.Handle("GET /api/v1/ncm/rollbacks", s.authenticated(s.handleListRollbacks)) mux.Handle("POST /api/v1/ncm/rollbacks", s.authenticated(s.handleCreateRollback)) mux.Handle("GET /api/v1/ncm/rollbacks/{id}", s.authenticated(s.handleGetRollback)) mux.Handle("POST /api/v1/ncm/rollbacks/{id}/approve", s.authenticated(s.handleApproveRollback)) mux.Handle("POST /api/v1/ncm/rollbacks/{id}/reject", s.authenticated(s.handleRejectRollback)) mux.Handle("GET /api/v1/ncm/rollback-policy", s.authenticated(s.handleGetRollbackPolicy)) mux.Handle("PUT /api/v1/ncm/rollback-policy", s.authenticated(s.handleSetRollbackPolicy)) // Завантаження файлу за короткоживучим квитком. Друга (після режиму // NOC TV) частина API без входу — право лежить у токені шляху. mux.HandleFunc("GET /api/v1/downloads/{token}", s.handleDownload) // Конфіг-файли серверів: хости, які збирають самі себе. Збір і // розклад окремих ручок не мають — і те, і те вже робить черга // ncm.jobs (collect-config вище, ScheduleBackups у grpcapi). mux.Handle("GET /api/v1/ncm/server-files", s.authenticated(s.handleListServerFiles)) mux.Handle("PUT /api/v1/devices/{id}/self-agent", s.authenticated(s.handleSetSelfAgent)) mux.Handle("DELETE /api/v1/devices/{id}/self-agent", s.authenticated(s.handleClearSelfAgent)) mux.Handle("GET /api/v1/ncm/backup-defaults", s.authenticated(s.handleGetBackupDefaults)) mux.Handle("PUT /api/v1/ncm/backup-defaults", s.authenticated(s.handleSetBackupDefaults)) // Очистка сховища версій: політика поруч зі спільним розкладом, // ручне видалення — окремим правом ncm:delete (див. ncm_configs.go). mux.Handle("GET /api/v1/ncm/retention", s.authenticated(s.handleGetRetention)) mux.Handle("PUT /api/v1/ncm/retention", s.authenticated(s.handleSetRetention)) mux.Handle("POST /api/v1/ncm/configs/delete", s.authenticated(s.handleDeleteConfigs)) // Дзеркало архіву на зовнішній Git. Стан видно з ncm:read (помітити // відмову має могти будь-хто, хто дивиться на конфіги), а міняти // налаштування — лише з ncm:mirror. Самого push тут немає: його // робить фоновий такт колектора, див. git_mirror.go. mux.Handle("GET /api/v1/ncm/mirror", s.authenticated(s.handleGetMirror)) mux.Handle("PUT /api/v1/ncm/mirror", s.authenticated(s.handleSetMirror)) mux.Handle("POST /api/v1/ncm/mirror/test", s.authenticated(s.handleTestMirror)) mux.Handle("POST /api/v1/ncm/mirror/push", s.authenticated(s.handlePushMirror)) mux.Handle("POST /api/v1/ncm/mirror/deploy-key", s.authenticated(s.handleMirrorDeployKey)) mux.Handle("DELETE /api/v1/ncm/mirror/secret", s.authenticated(s.handleForgetMirrorSecret)) // Журнал SNMP-трапів. Окремим розділом, а не вкладкою в хості: // трап приходить від АДРЕСИ, і половина цікавого в ньому — це саме // ті адреси, за якими хоста немає. Всередині картки хоста такий // трап не показати ніде. mux.Handle("GET /api/v1/traps", s.authenticated(s.handleListTraps)) mux.Handle("GET /api/v1/traps/meta", s.authenticated(s.handleTrapsMeta)) mux.Handle("PUT /api/v1/traps/names", s.authenticated(s.handleSaveTrapName)) mux.Handle("DELETE /api/v1/traps/names/{oid}", s.authenticated(s.handleDeleteTrapName)) mux.Handle("DELETE /api/v1/traps/unknown/{ip}", s.authenticated(s.handleForgetUnknownTrapSource)) mux.Handle("GET /api/v1/check-types", s.authenticated(s.handleListCheckTypes)) mux.Handle("GET /api/v1/devices/{id}/checks", s.authenticated(s.handleListDeviceChecks)) mux.Handle("PUT /api/v1/devices/{id}/checks", s.authenticated(s.handleSetDeviceChecks)) mux.Handle("GET /api/v1/devices/{id}/series", s.authenticated(s.handleListSeries)) mux.Handle("GET /api/v1/devices/{id}/metrics", s.authenticated(s.handleQueryMetrics)) // Добір хостів для сторінки метрик — тим самим фільтром, що в // «Командах», масових діях і «Конфігах». mux.Handle("POST /api/v1/metrics/devices", s.authenticated(s.handleMetricDevices)) mux.Handle("GET /api/v1/templates", s.authenticated(s.handleListTemplates)) mux.Handle("POST /api/v1/templates", s.authenticated(s.handleSaveTemplate)) mux.Handle("GET /api/v1/templates/export", s.authenticated(s.handleExportTemplates)) mux.Handle("POST /api/v1/templates/import", s.authenticated(s.handleImportTemplates)) // Перевірка фільтра складу портів на справжньому хості — до // збереження шаблону, а не після. mux.Handle("GET /api/v1/templates/interface-devices", s.authenticated(s.handleInterfaceFilterDevices)) mux.Handle("POST /api/v1/templates/interface-preview", s.authenticated(s.handleInterfaceFilterPreview)) mux.Handle("GET /api/v1/templates/{id}", s.authenticated(s.handleGetTemplate)) mux.Handle("PUT /api/v1/templates/{id}", s.authenticated(s.handleSaveTemplate)) mux.Handle("DELETE /api/v1/templates/{id}", s.authenticated(s.handleDeleteTemplate)) mux.Handle("POST /api/v1/templates/{id}/clone", s.authenticated(s.handleCloneTemplate)) mux.Handle("PUT /api/v1/templates/{id}/graphs", s.authenticated(s.handleSaveTemplateGraphs)) mux.Handle("PUT /api/v1/templates/{id}/triggers", s.authenticated(s.handleSaveTemplateTriggers)) mux.Handle("GET /api/v1/devices/{id}/graphs", s.authenticated(s.handleDeviceGraphs)) mux.Handle("GET /api/v1/devices/{id}/templates", s.authenticated(s.handleGetDeviceTemplates)) mux.Handle("PUT /api/v1/devices/{id}/templates", s.authenticated(s.handleSetDeviceTemplates)) mux.Handle("GET /api/v1/devices/{id}/credentials", s.authenticated(s.handleGetDeviceCredentials)) mux.Handle("GET /api/v1/credentials", s.authenticated(s.handleListCredentials)) mux.Handle("POST /api/v1/credentials", s.authenticated(s.handleCreateCredential)) mux.Handle("PATCH /api/v1/credentials/{id}", s.authenticated(s.handleUpdateCredential)) mux.Handle("DELETE /api/v1/credentials/{id}", s.authenticated(s.handleDeleteCredential)) mux.Handle("GET /api/v1/device-groups", s.authenticated(s.handleListDeviceGroups)) mux.Handle("POST /api/v1/device-groups", s.authenticated(s.handleCreateDeviceGroup)) mux.Handle("DELETE /api/v1/device-groups/{id}", s.authenticated(s.handleDeleteDeviceGroup)) mux.Handle("GET /api/v1/user-groups", s.authenticated(s.handleListUserGroups)) mux.Handle("POST /api/v1/user-groups", s.authenticated(s.handleCreateUserGroup)) mux.Handle("PATCH /api/v1/user-groups/{id}", s.authenticated(s.handlePatchUserGroup)) mux.Handle("DELETE /api/v1/user-groups/{id}", s.authenticated(s.handleDeleteUserGroup)) mux.Handle("GET /api/v1/agents", s.authenticated(s.handleListAgents)) mux.Handle("PATCH /api/v1/agents/{id}", s.authenticated(s.handleUpdateAgent)) mux.Handle("DELETE /api/v1/agents/{id}", s.authenticated(s.handleDeleteAgent)) mux.Handle("GET /api/v1/agent-enrollments", s.authenticated(s.handleListEnrollments)) mux.Handle("POST /api/v1/agent-enrollments", s.authenticated(s.handleCreateEnrollment)) mux.Handle("DELETE /api/v1/agent-enrollments/{id}", s.authenticated(s.handleDeleteEnrollment)) // Один ендпоїнт на всі черги: питання «чи все встигає» стосується // системи цілком, і відповідь, зібрана з семи запитів у різні // секунди, суперечила б сама собі. Див. queues.go. mux.Handle("GET /api/v1/queues", s.authenticated(s.handleQueues)) // А деталі — окремо й на вимогу: перелік зачеплених хостів потрібен // після кліку по картці, а знімок їде всім раз на п'ять секунд. mux.Handle("GET /api/v1/queues/{key}", s.authenticated(s.handleQueueDetail)) // Журнал аудиту — лише GET, і це не тимчасово: ручки, яка міняє чи // стирає запис, не буде. Див. audit.go. // Сховище: розміри, приріст і строки зберігання. Дивитись — // devices:read (нахил має бачити той, хто щодня дивиться на // систему), міняти — settings:write. Див. storage.go. mux.Handle("GET /api/v1/storage", s.authenticated(s.handleStorageUsage)) mux.Handle("PUT /api/v1/storage/config", s.authenticated(s.handleSetStorageConfig)) mux.Handle("GET /api/v1/storage/retention", s.authenticated(s.handleGetRetentionSettings)) mux.Handle("POST /api/v1/storage/retention/preview", s.authenticated(s.handleRetentionPreview)) mux.Handle("PUT /api/v1/storage/retention", s.authenticated(s.handleSetRetentionSettings)) // Тариф, стелі й ліцензія. Дивитись — billing:read, і це право // інженера: «чому не заводиться шістнадцятий хост» питає той, хто // заводить хости. Міняти — billing:manage, і воно є лише у власника. // Ліцензійний ключ під тим самим правом, що й тариф: за наслідками // це та сама дія — вона задає стелі всієї інсталяції. mux.Handle("GET /api/v1/billing", s.authenticated(s.handleBillingOverview)) mux.Handle("PUT /api/v1/billing/plan", s.authenticated(s.handleSetBillingPlan)) mux.Handle("GET /api/v1/billing/license", s.authenticated(s.handleGetLicense)) mux.Handle("POST /api/v1/billing/license", s.authenticated(s.handleApplyLicense)) mux.Handle("DELETE /api/v1/billing/license", s.authenticated(s.handleClearLicense)) // Звіти SLA. Дивитись — devices:read (доступність своєї мережі бачить // кожен, хто бачить моніторинг), заводити цілі й закривати періоди — // settings:write. Див. sla.go. // // Закриття — POST, а не PUT, і це не формальність: воно не «зберігає // стан», а виконує незворотну дію — робить пораховане число фактом. mux.Handle("GET /api/v1/sla/targets", s.authenticated(s.handleListSLATargets)) mux.Handle("POST /api/v1/sla/targets", s.authenticated(s.handleSaveSLATarget)) mux.Handle("PUT /api/v1/sla/targets/{id}", s.authenticated(s.handleSaveSLATarget)) mux.Handle("DELETE /api/v1/sla/targets/{id}", s.authenticated(s.handleDeleteSLATarget)) mux.Handle("GET /api/v1/sla/targets/{id}/report", s.authenticated(s.handleSLAReport)) mux.Handle("GET /api/v1/sla/targets/{id}/report.csv", s.authenticated(s.handleSLAReportCSV)) mux.Handle("POST /api/v1/sla/targets/{id}/close", s.authenticated(s.handleCloseSLAPeriod)) // Журнал самого сервера — те, на що лається NetPulse, а не мережа. // Під settings:write: це системний журнал з адресами, іменами // хостів і текстами помилок SQL, і бачити його має рівно той, хто й // так бачить усе. Див. serverlog.go. mux.Handle("GET /api/v1/server-log", s.authenticated(s.handleServerLog)) mux.Handle("GET /api/v1/audit", s.authenticated(s.handleListAudit)) mux.Handle("GET /api/v1/audit/meta", s.authenticated(s.handleAuditMeta)) mux.Handle("GET /api/v1/audit/{id}", s.authenticated(s.handleGetAuditEvent)) mux.Handle("GET /api/v1/alerts", s.authenticated(s.handleListAlerts)) mux.Handle("POST /api/v1/alerts/{id}/ack", s.authenticated(s.handleAckAlert)) mux.Handle("POST /api/v1/alerts/{id}/close", s.authenticated(s.handleCloseAlert)) mux.Handle("POST /api/v1/mutes", s.authenticated(s.handleMuteDevice)) mux.Handle("GET /api/v1/alert-rules", s.authenticated(s.handleListAlertRules)) mux.Handle("POST /api/v1/alert-rules", s.authenticated(s.handleCreateAlertRule)) mux.Handle("PUT /api/v1/alert-rules/{id}", s.authenticated(s.handleCreateAlertRule)) mux.Handle("PATCH /api/v1/alert-rules/{id}", s.authenticated(s.handlePatchAlertRule)) mux.Handle("DELETE /api/v1/alert-rules/{id}", s.authenticated(s.handleDeleteAlertRule)) // Драбини ескалації. Читання під alerts:read разом із рештою // сповіщень: побачити, хто буде розбуджений другим, має право той // самий, хто бачить алерти. Зміна — під alerts:write. mux.Handle("GET /api/v1/escalation-policies", s.authenticated(s.handleListEscalationPolicies)) mux.Handle("POST /api/v1/escalation-policies", s.authenticated(s.handleSaveEscalationPolicy)) mux.Handle("PUT /api/v1/escalation-policies/{id}", s.authenticated(s.handleSaveEscalationPolicy)) mux.Handle("DELETE /api/v1/escalation-policies/{id}", s.authenticated(s.handleDeleteEscalationPolicy)) mux.Handle("GET /api/v1/channels", s.authenticated(s.handleListChannels)) mux.Handle("POST /api/v1/channels", s.authenticated(s.handleCreateChannel)) mux.Handle("PUT /api/v1/channels/{id}", s.authenticated(s.handleCreateChannel)) mux.Handle("DELETE /api/v1/channels/{id}", s.authenticated(s.handleDeleteChannel)) mux.Handle("POST /api/v1/channels/{id}/test", s.authenticated(s.handleTestChannel)) // WebSocket теж під автентифікацією: браузер шле токен у // заголовку через підпротокол — див. ws.go. mux.Handle("GET /api/v1/ws", s.authenticated(s.handleWS)) // Статика останньою: "/" у ServeMux ловить усе, що не збіглося з // конкретнішими маршрутами, тож API лишається головним, а фронтенд // отримує решту. Без цього /api/v1/невідомий-шлях віддавав би // index.html замість 404, і клієнт падав би на розборі HTML як JSON. mux.HandleFunc("/", s.serveStatic) return s.withRecovery(s.withLogging(mux)) } // --------------------------------------------------------------------- // Проміжні шари // --------------------------------------------------------------------- // bearerToken дістає токен із заголовка або з підпротоколу WebSocket. // // Другий шлях потрібен тому, що браузерний WebSocket API не дозволяє // задати довільні заголовки — токен передається як підпротокол // "netpulse.token.". Це загальноприйнятий обхід; сам токен при // цьому не потрапляє в 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}, }) }