Збір конфігів перестав залежати від того, чи згадає людина натиснути кнопку. Свій парсер cron (internal/cronx) замість залежності: бітові маски uint64 на поле, пошук наступного запуску покроково по хвилинах із запобіжником у чотири роки. Правило dom/dow — об'єднання, як у справжнього cron. Неможливий розклад (30 лютого) чесно відмовляє замість зациклення. Планувальник тікає раз на хвилину під advisory-блокуванням, тож у кластері розклад розкручує рівно один екземпляр. Спершу переноситься next_backup_at, потім ставиться завдання: падіння між кроками коштує одного пропущеного бекапу, зворотний порядок дав би нескінченну чергу. Форма розкладу: чотири пресети плюс довільний cron. Виправлено помилку, через яку пункт «свій розклад…» нічого не робив — обробник select відсікав порожнє значення, тобто саме той випадок, заради якого пункт існує. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
328 lines
14 KiB
Go
328 lines
14 KiB
Go
// Package httpapi — REST і WebSocket для фронтенду.
|
||
//
|
||
// Окремий процес від AgentService навмисно: зонди й браузери мають різні
|
||
// профілі навантаження, різні мережеві периметри й різні цикли релізів.
|
||
// Спільним лишається лише шар store, тому обидва бачать однакові дані.
|
||
package httpapi
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/netpulse/netpulse/server/internal/alerting"
|
||
"github.com/netpulse/netpulse/server/internal/auth"
|
||
"github.com/netpulse/netpulse/server/internal/crypto"
|
||
"github.com/netpulse/netpulse/server/internal/store"
|
||
)
|
||
|
||
type Server struct {
|
||
store *store.Store
|
||
log *slog.Logger
|
||
hub *Hub
|
||
signer *auth.Signer
|
||
|
||
// Потрібні лише для каналів сповіщень: без них решта API працює,
|
||
// а /api/v1/channels відповідає 503 із поясненням.
|
||
keyring *crypto.Keyring
|
||
notifier *alerting.Notifier
|
||
}
|
||
|
||
// 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/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/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/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/credentials", s.authenticated(s.handleListCredentials))
|
||
mux.Handle("POST /api/v1/credentials", s.authenticated(s.handleCreateCredential))
|
||
|
||
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("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("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("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))
|
||
|
||
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)
|
||
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
|
||
}
|
||
|
||
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},
|
||
})
|
||
}
|