Netpulse_SasS/server/internal/httpapi/server.go
byrsapty 48848bd3a5 Опитування хоста з форми й повне редагування користувачів
Форма створення хоста збирала назву, адресу, тип і групи — і не
збирала головного. Хост, доданий через UI, не опитувався взагалі:
жодного рядка в core.checks. Наявні хости на стенді працювали лише
тому, що їхні перевірки засіяні через SQL.

Опитування:
- POST /api/v1/devices приймає перевірки одразу; новий хост у формі
  починає з icmp.ping — єдиної перевірки, яка працює будь-де без
  налаштування
- поля параметрів будуються з params_schema, яку віддає сервер, а не
  з захардкодженого списку: інакше кожен новий тип від плагіна вимагав
  би перезбирання фронтенду
- правка йде за id, а не перестворенням: унікальний індекс включає
  md5(params), тож зміна параметрів створила б другу перевірку того
  самого типу
- доступи (SNMP-community, SSH) шифруються тим самим кільцем, що й
  секрети каналів, і прив'язуються до хоста

Користувачі:
- PATCH /api/v1/team/{id} міняє роль, логін, пошту, імʼя й пароль одним
  запитом; зміна пароля відкликає всі сесії
- профіль редагується лише в того, хто працює тільки в цій організації:
  core.users глобальна, і адмін філії не має міняти пароль тому, хто
  тим самим акаунтом заходить у сусідню — той навіть не дізнався б.
  Спроба дає 409 shared_user

Знайдено при написанні: core.plugins не має колонки enabled — активація
на тенанта живе в core.plugin_installs, і вона порожня, тому базові
плагіни доступні без явного встановлення.

Живий прогін: 8 типів із 9, три перевірки записались, правка інтервалу
не задвоїла, зміна пароля пустила новим і відхилила старий, зміна ролі
собі — 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 02:22:56 +03:00

319 lines
13 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/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("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},
})
}