From c1e974aad9af502d85bb8e42358b82d90cef0ae3 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:19:15 +0100 Subject: [PATCH] http: re-group routes by role band, fail-closed admin default 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. --- internal/server/http/server.go | 349 +++++++++++++-------------------- 1 file changed, 133 insertions(+), 216 deletions(-) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 86cd1ba..72ff021 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -85,11 +85,6 @@ func New(deps Deps) *Server { r.Use(middleware.Recoverer) r.Use(requestLogger) - // Health endpoint — unauthenticated, no audit, deliberately cheap. - r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { - w.WriteHeader(stdhttp.StatusNoContent) - }) - s := &Server{ deps: deps, drainLocks: make(map[string]*sync.Mutex), @@ -113,132 +108,17 @@ func New(deps Deps) *Server { // 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) { - r.Route("/api", func(r chi.Router) { - r.Post("/auth/login", s.handleLogin) - r.Post("/auth/logout", s.handleLogout) - r.Post("/bootstrap", s.handleBootstrap) - - // Agent enrollment (open endpoint — token is the credential). - r.Post("/agents/enroll", s.handleAgentEnroll) - - // Announce-and-approve enrolment (open endpoint — fingerprint - // comparison in the UI is the gate). Per-IP rate-limited and - // globally capped (P2-18). - r.Post("/agents/announce", s.handleAnnounce) - - // Pending host management — admin-only (gated inside the handler). - r.Post("/pending-hosts/{id}/accept", s.handleAcceptPendingHost) - r.Post("/pending-hosts/{id}/reject", s.handleRejectPendingHost) - - // Operator → server (authenticated). Spec.md §6.1's - // /hosts/{id}/enrollment-token (regenerate) lands when the - // host page can call it; for now just the create endpoint. - r.Post("/enrollment-tokens", s.handleCreateEnrollmentToken) - - // Fleet read endpoints — back the dashboard. - r.Get("/hosts", s.handleListHosts) - r.Get("/fleet/summary", s.handleFleetSummary) - - // Run-now: dispatch a job to a host's agent. - r.Post("/hosts/{id}/jobs", s.handleRunNow) - - // Snapshot projection (refreshed by the agent after each backup). - r.Get("/hosts/{id}/snapshots", s.handleListHostSnapshots) - - // Repo credentials — operator can edit after enrollment. The - // initial set is supplied at token-mint time (see enrollment.go). - // GET returns a redacted view (URL, username, has_password). - r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials) - r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials) - - // Admin credentials — the prune-capable slot (separate from the - // everyday repo creds). Optional: hosts that don't prune against - // a rest-server repo with a separate admin user never need this. - r.Get("/hosts/{id}/admin-credentials", s.handleGetAdminCredentials) - r.Put("/hosts/{id}/admin-credentials", s.handleSetAdminCredentials) - r.Delete("/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials) - - // Per-host schedule CRUD. Mutations bump host_schedule_version - // and async-push to a connected agent (see schedule_push.go). - r.Get("/hosts/{id}/schedules", s.handleListSchedules) - r.Post("/hosts/{id}/schedules", s.handleCreateSchedule) - r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule) - r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule) - - // Source-group CRUD. A group is "what gets backed up" — paths, - // excludes, retention, retry. Group name doubles as the - // snapshot tag (restic --tag ). - r.Get("/hosts/{id}/source-groups", s.handleListSourceGroups) - r.Post("/hosts/{id}/source-groups", s.handleCreateSourceGroup) - r.Get("/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup) - r.Put("/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup) - r.Delete("/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup) - - // Repo maintenance cadences (forget / prune / check). Driven - // by the server-side ticker (P2R-06), not the agent's cron. - r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) - r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance) - - // Host-wide bandwidth caps (host.bandwidth_up_kbps / - // bandwidth_down_kbps). Apply to every restic invocation. - r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth) - - // Per-source-group Run-now (JSON variant). HTMX action is - // mounted at the equivalent path outside /api below — both - // resolve to the same handler, which sniffs HX-Request. - r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) - - // Repo-level run-now: prune (needs admin creds), check, unlock. - // HTMX forms are also mounted outside /api below. - r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune) - r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck) - r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) - - // Cancel a running job. Operator-driven, sends command.cancel - // to the agent which kills the restic subprocess; the agent's - // resulting job.finished (status=canceled) is what flips the - // job row. - r.Post("/jobs/{id}/cancel", s.handleCancelJob) - - // Snapshot diff (P3-09). Dispatches a JobDiff against two - // snapshots; output streams to the standard live job page. - r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) - - // Alert list (JSON variant). Same filter shape as the UI page. - r.Get("/alerts", s.handleAPIAlerts) - - // Audit log (JSON variant). - r.Get("/audit", s.handleAPIAudit) - - // Notification channel test-fire. Dispatches a synthetic payload - // through a single named channel; returns JSON result. - r.Post("/notifications/{id}/test", s.handleAPINotificationTest) + // Public, unauthenticated. + r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusNoContent) }) - - // HTMX form variant of diff (mounted outside /api so HTMX forms - // can post against it without the api/ prefix). - r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) - - // Per-source-group Run-now (HTMX form action). Available even - // when the server is started without UI templates so REST callers - // against the non-/api path also work. - r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) - // Repo-level run-now (HTMX form actions). Same handlers as the /api - // variants — wantsHTML sniff distinguishes JSON vs HTMX response. - r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune) - r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck) - r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) - // Retired routes — see ui_handlers.go for the messages. Mounted - // outside the UI gate so cached browser tabs get a clear 410 - // even if the server runs without templates. - r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone) - r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone) - - // Pending-host WebSocket (announce-and-approve, P2-18b). Mounted - // before /ws/agent so the more-specific route matches first. - r.Get("/ws/agent/pending", s.handlePendingWS) - - // Agent ↔ server WebSocket. Bearer-authenticated inside the handler. + 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, @@ -250,101 +130,138 @@ func (s *Server) routes(r chi.Router) { OnScheduleFire: s.dispatchScheduledJob, })) } - - // Agent binaries + install scripts. Open endpoints — content is - // unprivileged on its own, gating happens via the enrollment - // token. See agent_assets.go. - r.Get("/agent/binary", s.handleAgentBinary) - r.Get("/install/*", s.handleInstallAsset) - - // Static assets (Tailwind CSS bundle, future favicon). + r.Get("/ws/agent/pending", s.handlePendingWS) r.Mount("/static/", staticHandler()) - // HTML UI. The renderer is required — fail loud if the binary - // was built without templates (impossible in practice given - // embed, but guards bad test wiring). if s.deps.UI != nil { - r.Get("/", s.handleUIDashboard) r.Get("/login", s.handleUILoginGet) r.Post("/login", s.handleUILoginPost) r.Post("/logout", s.handleUILogoutPost) - // Per-host Run-now and manual Init-repo are mounted at the - // outer router (so they reply 410 even without UI). Per- - // source-group Run-now lives there too — same reason. - // Add host flow. - r.Get("/hosts/new", s.handleUIAddHostGet) - r.Post("/hosts/new", s.handleUIAddHostPost) - // Durable post-Add-host page (operator can refresh / come - // back; password decrypted from the token row each render). - // Polled fragment under /awaiting flips to "connected" once - // the agent enrols. - r.Get("/hosts/pending/{token}", s.handleUIPendingHost) - r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) - // Host detail (Snapshots tab is the default). - r.Get("/hosts/{id}", s.handleUIHostDetail) - // Sources tab + source-group CRUD forms. - r.Get("/hosts/{id}/sources", s.handleUIHostSources) - r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) - r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave) - r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) - r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave) - r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete) - // Repo tab — connection / bandwidth / maintenance. Three - // independent forms so saving one doesn't touch the others. - r.Get("/hosts/{id}/repo", s.handleUIHostRepo) - 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) - // Admin credentials form (separate slot for prune-capable user). - r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) - r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) - // Schedules tab + create/edit/delete forms. - r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) - r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) - r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave) - r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet) - 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) - // Live job log. - r.Get("/jobs/{id}", s.handleUIJobDetail) - // Restore wizard (P3-01/P3-02). Two GET variants land on the - // same handler; the second deep-links a chosen snapshot. - r.Get("/hosts/{id}/restore", s.handleUIRestoreGet) - r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet) - r.Post("/hosts/{id}/restore", s.handleUIRestorePost) - r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree) - // Alerts list + operator actions. - r.Get("/alerts", s.handleUIAlerts) - r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge) - r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve) - // Audit log (read-only). - r.Get("/audit", s.handleUIAudit) - r.Get("/audit.csv", s.handleUIAuditCSV) - // Settings shell + Notifications sub-tab CRUD. - 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) } - // Browser job-log stream (separate from /ws/agent so the auth - // layer is session-cookie not bearer). Mounted regardless of - // whether the UI is up — JSON callers may also subscribe. - if s.deps.JobHub != nil { - r.Get("/api/jobs/{id}/stream", s.handleJobStream) - } + // Viewer band — anyone authenticated can read. + r.Group(func(r chi.Router) { + r.Use(s.requireRole(store.RoleViewer)) - // Job log download (txt + ndjson). Source of truth is the - // persisted job_logs table; safe to call any time, no pause - // needed against the live stream. - r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload) + // 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