297 lines
12 KiB
Go
297 lines
12 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)
|
|
r.Get("/setup", s.handleUISetupGet)
|
|
r.Post("/setup", s.handleUISetupPost)
|
|
}
|
|
|
|
// 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.Get("/api/users", s.handleAPIUsersList)
|
|
r.Post("/api/users", s.handleAPIUserCreate)
|
|
r.Get("/api/users/{id}", s.handleAPIUserGet)
|
|
r.Patch("/api/users/{id}", s.handleAPIUserPatch)
|
|
r.Post("/api/users/{id}/disable", s.handleAPIUserDisable)
|
|
r.Post("/api/users/{id}/enable", s.handleAPIUserEnable)
|
|
r.Post("/api/users/{id}/regenerate-setup", s.handleAPIUserRegenerateSetup)
|
|
r.Post("/api/users/{id}/force-logout", s.handleAPIUserForceLogout)
|
|
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 }
|