c1e974aad9
Routes are now structured into Public / Viewer / Operator / Admin bands using requireRole middleware. Job log stream and download moved into the Viewer band. healthz moved from New() into routes() with the other public endpoints.
287 lines
11 KiB
Go
287 lines
11 KiB
Go
// Package http hosts the chi-based REST handlers for the control
|
|
// plane. The Server type owns the router, the handlers, and the
|
|
// graceful-shutdown lifecycle.
|
|
package http
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
stdhttp "net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
|
|
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
|
|
)
|
|
|
|
// Deps bundles every collaborator the HTTP server depends on. Wired up
|
|
// in cmd/server; tests pass a pared-down Deps with fakes.
|
|
type Deps struct {
|
|
Cfg config.Config
|
|
Store *store.Store
|
|
AEAD *crypto.AEAD
|
|
Hub *ws.Hub
|
|
JobHub *ws.JobHub
|
|
UI *ui.Renderer
|
|
// AlertEngine (optional, wired in G1) receives job-finished and
|
|
// host-online events from the WS handler. Nil until G1 constructs
|
|
// the engine at boot.
|
|
AlertEngine *alert.Engine
|
|
// NotificationHub (optional, wired in G1) is used by the test-fire
|
|
// endpoint to dispatch a single synthetic payload through a channel.
|
|
NotificationHub *notification.Hub
|
|
// Version is the binary's build version, surfaced in the chrome.
|
|
// Empty falls back to "dev".
|
|
Version string
|
|
// BootstrapToken (optional, populated only on first run) is the raw
|
|
// admin-bootstrap token printed in the server logs. While set, the
|
|
// /bootstrap endpoint accepts it to create the first admin user.
|
|
BootstrapToken string
|
|
}
|
|
|
|
// Server is the running HTTP server.
|
|
type Server struct {
|
|
srv *stdhttp.Server
|
|
deps Deps
|
|
|
|
// drainLocks serialises DrainPending per host. The on-hello
|
|
// goroutine and the 30s ticker can otherwise race for the same
|
|
// host, double-dispatching every pending row. Map of hostID →
|
|
// sync.Mutex; checked-and-locked atomically via drainLocksMu.
|
|
drainLocksMu sync.Mutex
|
|
drainLocks map[string]*sync.Mutex
|
|
|
|
// announceRL is the per-source-IP token-bucket guarding
|
|
// POST /api/agents/announce (P2-18). One process-local map.
|
|
announceRL *announceLimiter
|
|
|
|
// pendingHub holds live /ws/agent/pending sockets keyed by
|
|
// pending_id so the accept/reject handlers can push the bearer
|
|
// or close cleanly (P2-18b).
|
|
pendingHub *pendingHub
|
|
|
|
// treeCache holds per-wizard-session listings of snapshot
|
|
// directories (P3-X2). Pre-allocated in New so the lazy-init
|
|
// race is impossible.
|
|
treeCache *treeCache
|
|
}
|
|
|
|
// New builds a configured but not-yet-started server.
|
|
func New(deps Deps) *Server {
|
|
r := chi.NewRouter()
|
|
|
|
// Built-in middleware: request ID for log correlation, recovery
|
|
// (don't crash the process on a panic in a handler), realIP iff a
|
|
// trusted proxy is configured.
|
|
r.Use(middleware.RequestID)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(requestLogger)
|
|
|
|
s := &Server{
|
|
deps: deps,
|
|
drainLocks: make(map[string]*sync.Mutex),
|
|
announceRL: newAnnounceLimiter(),
|
|
pendingHub: newPendingHub(),
|
|
treeCache: newTreeCache(),
|
|
}
|
|
s.routes(r)
|
|
|
|
s.srv = &stdhttp.Server{
|
|
Addr: deps.Cfg.Listen,
|
|
Handler: r,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
// Long write timeout — WS upgrades and live log streams need it.
|
|
WriteTimeout: 0,
|
|
}
|
|
return s
|
|
}
|
|
|
|
// routes wires the API tree. Subtrees live in this file by area so a
|
|
// reader can scan one place and see the surface.
|
|
func (s *Server) routes(r chi.Router) {
|
|
// Public, unauthenticated.
|
|
r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
|
|
w.WriteHeader(stdhttp.StatusNoContent)
|
|
})
|
|
r.Post("/api/auth/login", s.handleLogin)
|
|
r.Post("/api/auth/logout", s.handleLogout)
|
|
r.Post("/api/bootstrap", s.handleBootstrap)
|
|
r.Post("/api/agents/enroll", s.handleAgentEnroll)
|
|
r.Post("/api/agents/announce", s.handleAnnounce)
|
|
r.Get("/agent/binary", s.handleAgentBinary)
|
|
r.Get("/install/*", s.handleInstallAsset)
|
|
if s.deps.Hub != nil {
|
|
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
|
|
Hub: s.deps.Hub,
|
|
Store: s.deps.Store,
|
|
JobHub: s.deps.JobHub,
|
|
AlertEngine: s.deps.AlertEngine,
|
|
OnHello: s.onAgentHello,
|
|
OnScheduleAck: s.applyScheduleAck,
|
|
OnScheduleFire: s.dispatchScheduledJob,
|
|
}))
|
|
}
|
|
r.Get("/ws/agent/pending", s.handlePendingWS)
|
|
r.Mount("/static/", staticHandler())
|
|
|
|
if s.deps.UI != nil {
|
|
r.Get("/login", s.handleUILoginGet)
|
|
r.Post("/login", s.handleUILoginPost)
|
|
r.Post("/logout", s.handleUILogoutPost)
|
|
}
|
|
|
|
// Viewer band — anyone authenticated can read.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireRole(store.RoleViewer))
|
|
|
|
// Read APIs.
|
|
r.Get("/api/hosts", s.handleListHosts)
|
|
r.Get("/api/fleet/summary", s.handleFleetSummary)
|
|
r.Get("/api/hosts/{id}/snapshots", s.handleListHostSnapshots)
|
|
r.Get("/api/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
|
|
r.Get("/api/hosts/{id}/admin-credentials", s.handleGetAdminCredentials)
|
|
r.Get("/api/hosts/{id}/schedules", s.handleListSchedules)
|
|
r.Get("/api/hosts/{id}/source-groups", s.handleListSourceGroups)
|
|
r.Get("/api/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
|
|
r.Get("/api/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
|
|
r.Get("/api/alerts", s.handleAPIAlerts)
|
|
r.Get("/api/audit", s.handleAPIAudit)
|
|
|
|
// Job log stream + download (read-only; any authenticated user).
|
|
if s.deps.JobHub != nil {
|
|
r.Get("/api/jobs/{id}/stream", s.handleJobStream)
|
|
}
|
|
r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload)
|
|
|
|
if s.deps.UI != nil {
|
|
r.Get("/", s.handleUIDashboard)
|
|
r.Get("/hosts/{id}", s.handleUIHostDetail)
|
|
r.Get("/hosts/{id}/sources", s.handleUIHostSources)
|
|
r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
|
|
r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
|
|
r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
|
|
r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
|
|
r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
|
|
r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
|
|
r.Get("/jobs/{id}", s.handleUIJobDetail)
|
|
r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
|
|
r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
|
|
r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
|
|
r.Get("/alerts", s.handleUIAlerts)
|
|
r.Get("/audit", s.handleUIAudit)
|
|
r.Get("/audit.csv", s.handleUIAuditCSV)
|
|
}
|
|
})
|
|
|
|
// Operator band — mutating endpoints up to backup ops.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireRole(store.RoleOperator))
|
|
|
|
// Pending hosts approval.
|
|
r.Post("/api/pending-hosts/{id}/accept", s.handleAcceptPendingHost)
|
|
r.Post("/api/pending-hosts/{id}/reject", s.handleRejectPendingHost)
|
|
r.Post("/api/enrollment-tokens", s.handleCreateEnrollmentToken)
|
|
|
|
// Run-now, restore, repo ops (JSON).
|
|
r.Post("/api/hosts/{id}/jobs", s.handleRunNow)
|
|
r.Put("/api/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
|
|
r.Put("/api/hosts/{id}/admin-credentials", s.handleSetAdminCredentials)
|
|
r.Delete("/api/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials)
|
|
r.Post("/api/hosts/{id}/schedules", s.handleCreateSchedule)
|
|
r.Put("/api/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
|
|
r.Delete("/api/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
|
|
r.Post("/api/hosts/{id}/source-groups", s.handleCreateSourceGroup)
|
|
r.Put("/api/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
|
|
r.Delete("/api/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
|
|
r.Put("/api/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
|
|
r.Put("/api/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
|
|
r.Post("/api/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
|
|
r.Post("/api/hosts/{id}/repo/prune", s.handleRunRepoPrune)
|
|
r.Post("/api/hosts/{id}/repo/check", s.handleRunRepoCheck)
|
|
r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
|
|
r.Post("/api/jobs/{id}/cancel", s.handleCancelJob)
|
|
r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
|
|
|
|
// HTMX form variants outside /api.
|
|
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
|
|
r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
|
|
r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
|
|
r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
|
|
r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
|
|
r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
|
|
r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
|
|
|
|
if s.deps.UI != nil {
|
|
r.Get("/hosts/new", s.handleUIAddHostGet)
|
|
r.Post("/hosts/new", s.handleUIAddHostPost)
|
|
r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
|
|
r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
|
|
r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
|
|
r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
|
|
r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
|
|
r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
|
|
r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
|
|
r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
|
|
r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
|
|
r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
|
|
r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
|
|
r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
|
|
r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
|
|
r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave)
|
|
r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete)
|
|
r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
|
|
r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
|
|
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
|
|
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
|
|
}
|
|
})
|
|
|
|
// Admin band — channels, server-shape config.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(s.requireRole(store.RoleAdmin))
|
|
|
|
r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest)
|
|
|
|
if s.deps.UI != nil {
|
|
r.Get("/settings", s.handleUISettings)
|
|
r.Get("/settings/notifications", s.handleUINotificationsList)
|
|
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
|
|
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
|
|
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
|
|
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
|
|
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
|
|
r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Start begins listening. Blocks until ListenAndServe returns
|
|
// (typically only on Shutdown). The server is HTTP-only by design;
|
|
// production deployments terminate TLS at a reverse proxy in front.
|
|
func (s *Server) Start() error {
|
|
err := s.srv.ListenAndServe()
|
|
if errors.Is(err, stdhttp.ErrServerClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Shutdown stops accepting new connections and waits up to ctx.Deadline
|
|
// for in-flight handlers to finish.
|
|
func (s *Server) Shutdown(ctx context.Context) error {
|
|
return s.srv.Shutdown(ctx)
|
|
}
|
|
|
|
// Addr returns the configured listen address. Useful in tests when
|
|
// the caller passes :0 to get a random port.
|
|
func (s *Server) Addr() string { return s.srv.Addr }
|