Netpulse_SasS/server/internal/httpapi/server.go
byrsapty 444447d86b
Some checks are pending
CI / web (push) Waiting to run
CI / server (push) Waiting to run
CI / agent (push) Waiting to run
Профілі збору конфігів: перегляд, редагування, створення з прикладів
147 вбудованих профілів досі жили лише в базі — виправити команди під
свою прошивку було ніяк. Тепер є сторінка з пошуком і формою.

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

Профіль — це три регулярні вирази й перелік команд, і порожня форма з
такими полями не підказує нічого. Тому три робочі заготовки, кнопка
«За зразок» на кожному вбудованому й приклад у кожному полі.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 16:40:26 +03:00

406 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Package httpapi — REST і WebSocket для фронтенду.
//
// Окремий процес від AgentService навмисно: зонди й браузери мають різні
// профілі навантаження, різні мережеві периметри й різні цикли релізів.
// Спільним лишається лише шар store, тому обидва бачать однакові дані.
package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/netpulse/netpulse/server/internal/alerting"
"github.com/netpulse/netpulse/server/internal/auth"
"github.com/netpulse/netpulse/server/internal/crypto"
"github.com/netpulse/netpulse/server/internal/store"
)
type Server struct {
store *store.Store
log *slog.Logger
hub *Hub
signer *auth.Signer
// Потрібні лише для каналів сповіщень: без них решта API працює,
// а /api/v1/channels відповідає 503 із поясненням.
keyring *crypto.Keyring
notifier *alerting.Notifier
}
// 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
}
// 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))
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("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}/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("PATCH /api/v1/devices/{id}", s.authenticated(s.handleUpdateDevice))
mux.Handle("DELETE /api/v1/devices/{id}", s.authenticated(s.handleDeleteDevice))
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("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/backup-defaults", s.authenticated(s.handleGetBackupDefaults))
mux.Handle("PUT /api/v1/ncm/backup-defaults", s.authenticated(s.handleSetBackupDefaults))
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("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/{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))
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))
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
}
devices, err := s.store.ListDevices(r.Context(), p.TenantID, p.Scope())
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},
})
}