Files
restic-manager/internal/server/http/ui_handlers.go
T
steve 02e4ef7544 testing: bootstrap UI, agent reliability, NS-01..04 + alert username
Smoothes the rough edges that came up exercising a live deployment.

First-run bootstrap UI: /bootstrap renders a username + password form
that uses the in-memory token directly (operator no longer copies it
out of the log); /login redirects there while bootstrap is available.

Agent reliability: failJob synthetic envelopes so command.run early
returns no longer hang the server-side job; runtime probe of restic
restore --help drives --no-ownership instead of version sniffing
(0.18.x had it removed). Server unit re-shaped: ProtectSystem=full
plus ReadWritePaths=/etc/restic-manager, no ProtectHome — restore
can now write anywhere a user might want.

Restore wizard: default target is /root/rm-restore/<job-id>/ with
clearer help text. Re-init confirm input uses .field (was .input,
which doesn't exist — text was invisible).

NS-01 host delete: store DeleteHost, admin-band /hosts/{id}/delete
with hostname-confirm danger zone, audit, FK cascade, live WS close.

NS-02 enrollment-token recovery: outstanding-tokens panel on
/hosts/new, regenerate (preserves attachments) and revoke handlers
+ audit, store-level ListOutstandingEnrollmentTokens and
DeleteEnrollmentToken.

NS-03 repo init / probe surface: migration 0020 adds
hosts.repo_status + repo_status_error; WS handler projects every
init job's outcome onto the host row (idempotent already-initialised
collapses to ready); creds-save resets status and dispatches a fresh
probe; /hosts/{id}/repo/probe retry endpoint with banner.

NS-04 dashboard live + sort + filter: query-string filter
(q/status/repo_status/tag/sort/dir), 5s htmx live poll mirroring the
alerts pattern with a localStorage live toggle, sortable column
headers, filter row + clear.

Alerts page: ack'd-by line resolves user_id ULID to username.

Compose.yaml ignored — host-specific.
2026-05-05 22:03:15 +01:00

1271 lines
41 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 http
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"io/fs"
"log/slog"
stdhttp "net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/coder/websocket"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
"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"
"gitea.dcglab.co.uk/steve/restic-manager/web"
)
// ----- static assets (Tailwind CSS, future favicon, etc) -------------
// staticHandler serves files embedded under web/static/ at /static/*.
// Returns 404 for anything missing rather than the fs default 500.
func staticHandler() stdhttp.Handler {
sub, err := fs.Sub(web.FS, "static")
if err != nil {
// Embed.FS panics live at compile time; if Sub fails the binary
// is genuinely broken — surface it loudly.
panic("web: static subtree missing: " + err.Error())
}
return stdhttp.StripPrefix("/static/", stdhttp.FileServer(stdhttp.FS(sub)))
}
// ----- session helpers ------------------------------------------------
// sessionUser resolves the request's session cookie to a User, or
// (nil, nil) if the cookie is missing/expired/invalid. A non-nil
// error means an underlying store failure; treat that as 500.
func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
// Missing or invalid cookie just means the caller isn't logged
// in — that's a normal state, not a server error. Return
// (nil, nil) so callers can decide between "redirect to login"
// and "treat as anonymous".
return nil, nil //nolint:nilerr
}
sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
if err != nil {
// Treat "not found" / "expired" as "no session", not as fatal.
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
return nil, err
}
u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, nil
}
return nil, err
}
if u.DisabledAt != nil {
_ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value))
return nil, nil
}
return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
}
// requireUIUser resolves the session and 303-redirects to /login if
// there isn't one. Returns nil + emits the redirect when unauthed.
// (HTML twin of jobs.go's API-style requireUser, which returns 401.)
func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui.User {
u, err := s.sessionUser(r)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return nil
}
if u == nil {
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
return nil
}
return u
}
// baseView populates the fields the nav partial needs on every
// authenticated page. Every UI page sits under the dashboard primary
// nav today; if a future page lives under a different primary nav
// tab (e.g. Settings, Audit), accept an Active arg again.
//
// OpenAlerts is populated via a quick store count so the nav badge
// stays current on every page load without requiring a page-specific
// store call.
func (s *Server) baseView(r *stdhttp.Request, u *ui.User) ui.ViewData {
view := ui.ViewData{
User: u,
Active: "dashboard",
Version: s.version(),
}
// Populate OpenAlerts from the store so the nav badge shows the
// current count on every page.
if open, err := s.deps.Store.ListAlerts(r.Context(), store.AlertFilter{Status: "open"}); err == nil {
view.OpenAlerts = len(open)
}
return view
}
// version returns the binary's build version — passed in via Deps so
// cmd/server's `var version` ends up here.
func (s *Server) version() string {
if s.deps.Version != "" {
return s.deps.Version
}
return "dev"
}
// ----- handlers -------------------------------------------------------
// dashboardPage is the data the dashboard template renders against.
type dashboardPage struct {
Hosts []dashboardHostRow
HostCount int // unfiltered fleet size
ShownCount int // after every active filter
Summary store.FleetSummary
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
CritOpenCount int
// Tag filter state. ActiveTag is the chip currently selected
// ("" = all). KnownTags is the full set of tags in use across
// the fleet, used to render the chip-row.
ActiveTag string
KnownTags []string
// Filter / sort URL state (NS-04). Round-tripped through query
// string so a bookmarked / shared dashboard URL is durable, and
// passed back to the template so the form inputs and column
// header sort-arrows render with current state.
Filter dashboardFilter
// RefreshURL is the same dashboard URL with all current filters
// pinned, used by the htmx live-poll trigger to refetch the
// table without flashing the surrounding chrome.
RefreshURL string
// SortURL is a per-column URL builder: passing a column key
// returns the URL that sorts by that column (toggling direction
// when it's already active). Pre-computed so the template stays
// dumb.
SortURL map[string]string
}
// dashboardFilter holds the parsed query-string filter state.
type dashboardFilter struct {
Search string // hostname substring match (case-insensitive)
Status string // "" | "online" | "offline" | "never_seen"
RepoStatus string // "" | "unknown" | "ready" | "init_failed"
Tag string // mirrors ActiveTag for round-trip on links
Sort string // column key (see sortDashboard)
Dir string // "asc" | "desc"
}
// dashboardHostRow carries a host plus the per-row Run-now decision
// the host_row partial needs. The decision is computed server-side
// once per render rather than recomputed in the template.
type dashboardHostRow struct {
Host store.Host
// RunAllScheduleID is the ID of the single schedule that covers
// every source group on the host. Empty when zero or 2+ schedules
// match — in that case the row shows "Open →" instead of a Run-now
// button (the operator picks per-group from the host detail).
RunAllScheduleID string
// NextRun is the next-fire time of RunAllScheduleID (when set),
// computed server-side from its cron. nil otherwise.
NextRun *time.Time
}
// pickRunAllSchedule returns the ID of the single schedule whose
// source-group set ⊇ every source group on the host. Returns "" when
// zero or 2+ such "covering" schedules exist (operator-disambiguation
// belongs on the host detail, not the dashboard one-click).
func pickRunAllSchedule(scheds []store.Schedule, groups []store.SourceGroup) string {
if len(groups) == 0 || len(scheds) == 0 {
return ""
}
groupIDs := make(map[string]struct{}, len(groups))
for _, g := range groups {
groupIDs[g.ID] = struct{}{}
}
matched := ""
for _, sc := range scheds {
if !sc.Enabled {
continue
}
// Treat sc.SourceGroupIDs as a set; check it covers every group.
got := make(map[string]struct{}, len(sc.SourceGroupIDs))
for _, gid := range sc.SourceGroupIDs {
got[gid] = struct{}{}
}
covers := true
for gid := range groupIDs {
if _, ok := got[gid]; !ok {
covers = false
break
}
}
if !covers {
continue
}
if matched != "" {
// Two distinct covering schedules — ambiguous, bail out.
return ""
}
matched = sc.ID
}
return matched
}
// handleUIDashboard is the root page. Auth-gated; falls through to
// /login if there is no session.
func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
allHosts, err := s.deps.Store.ListHosts(r.Context())
if err != nil {
slog.Error("ui dashboard: list hosts", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
// Parse query-string filter + sort (NS-04). The tag chip-row is
// kept as ?tag= for backwards compat with existing bookmarks.
filter := parseDashboardFilter(r.URL.Query())
hosts := filterAndSortDashboardHosts(allHosts, filter)
knownTags, _ := s.deps.Store.DistinctHostTags(r.Context())
summary, err := s.deps.Store.FleetSummary(r.Context())
if err != nil {
slog.Error("ui dashboard: fleet summary", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
// Per-host: pick the single covering schedule (if any) so the row
// can render a one-click Run-now where it's unambiguous. Two store
// calls per host — fine at fleet sizes we care about.
rows := make([]dashboardHostRow, 0, len(hosts))
for _, h := range hosts {
row := dashboardHostRow{Host: h}
groups, gerr := s.deps.Store.ListSourceGroupsByHost(r.Context(), h.ID)
if gerr != nil {
slog.Warn("ui dashboard: list source groups", "host_id", h.ID, "err", gerr)
}
scheds, serr := s.deps.Store.ListSchedulesByHost(r.Context(), h.ID)
if serr != nil {
slog.Warn("ui dashboard: list schedules", "host_id", h.ID, "err", serr)
}
row.RunAllScheduleID = pickRunAllSchedule(scheds, groups)
if row.RunAllScheduleID != "" {
for _, sc := range scheds {
if sc.ID == row.RunAllScheduleID {
if parsed, perr := cronParser.Parse(sc.CronExpr); perr == nil {
n := parsed.Next(time.Now().UTC()).UTC()
row.NextRun = &n
}
break
}
}
}
rows = append(rows, row)
}
pending, perr := s.deps.Store.ListPendingHosts(r.Context(), time.Now().UTC())
if perr != nil {
slog.Warn("ui dashboard: list pending hosts", "err", perr)
}
critOpenCount := 0
if crit, err := s.deps.Store.ListAlerts(r.Context(), store.AlertFilter{Status: "open", Severity: "critical"}); err == nil {
critOpenCount = len(crit)
}
view := s.baseView(r, u)
view.Page = dashboardPage{
Hosts: rows,
HostCount: len(allHosts),
ShownCount: len(rows),
Summary: summary,
PendingHosts: pending,
CritOpenCount: critOpenCount,
ActiveTag: filter.Tag,
KnownTags: knownTags,
Filter: filter,
RefreshURL: "/?" + filter.encode(),
SortURL: buildDashboardSortURLs(filter),
}
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
slog.Error("ui: render dashboard", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// parseDashboardFilter reads the query string into a dashboardFilter,
// normalising defaults (sort=name, dir=asc) so the rest of the
// pipeline doesn't have to special-case empty values.
func parseDashboardFilter(q url.Values) dashboardFilter {
f := dashboardFilter{
Search: strings.TrimSpace(q.Get("q")),
Status: q.Get("status"),
RepoStatus: q.Get("repo_status"),
Tag: q.Get("tag"),
Sort: q.Get("sort"),
Dir: q.Get("dir"),
}
if f.Sort == "" {
f.Sort = "name"
}
if f.Dir != "asc" && f.Dir != "desc" {
f.Dir = "asc"
}
return f
}
// encode rebuilds the filter as a URL-safe query string. Used for the
// live-refresh URL and for column-sort link composition.
func (f dashboardFilter) encode() string {
v := url.Values{}
if f.Search != "" {
v.Set("q", f.Search)
}
if f.Status != "" {
v.Set("status", f.Status)
}
if f.RepoStatus != "" {
v.Set("repo_status", f.RepoStatus)
}
if f.Tag != "" {
v.Set("tag", f.Tag)
}
if f.Sort != "" && f.Sort != "name" {
v.Set("sort", f.Sort)
}
if f.Dir != "" && f.Dir != "asc" {
v.Set("dir", f.Dir)
}
return v.Encode()
}
// filterAndSortDashboardHosts narrows a host list by the active
// filter dimensions, then sorts it by the chosen column/direction.
// Filter precedence: search ∧ status ∧ repo_status ∧ tag — every
// active filter has to match. Sort runs after filtering.
func filterAndSortDashboardHosts(hosts []store.Host, f dashboardFilter) []store.Host {
out := make([]store.Host, 0, len(hosts))
q := strings.ToLower(f.Search)
for _, h := range hosts {
if q != "" && !strings.Contains(strings.ToLower(h.Name), q) {
continue
}
if f.Status != "" {
switch f.Status {
case "online", "offline":
if h.Status != f.Status {
continue
}
case "never_seen":
if h.LastSeenAt != nil {
continue
}
}
}
if f.RepoStatus != "" {
// Backward compatibility: rows pre-NS-03 have an empty
// status string in memory if loaded before the migration
// scan added the column; treat that as "unknown".
rs := h.RepoStatus
if rs == "" {
rs = "unknown"
}
if rs != f.RepoStatus {
continue
}
}
if f.Tag != "" {
match := false
for _, t := range h.Tags {
if t == f.Tag {
match = true
break
}
}
if !match {
continue
}
}
out = append(out, h)
}
sortDashboardHosts(out, f.Sort, f.Dir)
return out
}
// sortDashboardHosts applies the column-by-direction sort in place.
// Unknown column key falls back to name asc — defensive default that
// keeps a malformed bookmarked URL from rendering an empty table.
func sortDashboardHosts(hosts []store.Host, col, dir string) {
less := func(i, j int) bool {
a, b := hosts[i], hosts[j]
switch col {
case "os":
if a.OS != b.OS {
return a.OS < b.OS
}
case "status":
if a.Status != b.Status {
return a.Status < b.Status
}
case "repo_status":
if a.RepoStatus != b.RepoStatus {
return a.RepoStatus < b.RepoStatus
}
case "restic":
if a.ResticVersion != b.ResticVersion {
return a.ResticVersion < b.ResticVersion
}
case "snapshot_count":
if a.SnapshotCount != b.SnapshotCount {
return a.SnapshotCount < b.SnapshotCount
}
case "repo_size":
if a.RepoSizeBytes != b.RepoSizeBytes {
return a.RepoSizeBytes < b.RepoSizeBytes
}
case "last_backup":
at, bt := time.Time{}, time.Time{}
if a.LastBackupAt != nil {
at = *a.LastBackupAt
}
if b.LastBackupAt != nil {
bt = *b.LastBackupAt
}
if !at.Equal(bt) {
return at.Before(bt)
}
}
// Stable secondary key: name.
return a.Name < b.Name
}
if dir == "desc" {
sort.Slice(hosts, func(i, j int) bool { return less(j, i) })
} else {
sort.Slice(hosts, less)
}
}
// buildDashboardSortURLs precomputes the link target for every
// sortable column header. Clicking the active column toggles
// direction; clicking a different column starts ascending.
func buildDashboardSortURLs(active dashboardFilter) map[string]string {
cols := []string{"name", "os", "status", "repo_status", "restic", "snapshot_count", "repo_size", "last_backup"}
out := make(map[string]string, len(cols))
for _, c := range cols {
f := active
f.Sort = c
if active.Sort == c && active.Dir == "asc" {
f.Dir = "desc"
} else {
f.Dir = "asc"
}
enc := f.encode()
if enc == "" {
out[c] = "/"
} else {
out[c] = "/?" + enc
}
}
return out
}
// Per-host Run-now and manual Init-repo were retired by the P2 redesign.
// Run-now lives at POST /hosts/{id}/source-groups/{gid}/run; init runs
// automatically on the agent's first WS connect after enrolment. Both
// routes return 410 Gone so any cached browser tab gets a clear error.
func (s *Server) handleUIRunBackupGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w,
"per-host Run-now has moved — use POST /hosts/{id}/source-groups/{gid}/run",
stdhttp.StatusGone)
}
func (s *Server) handleUIInitRepoGone(w stdhttp.ResponseWriter, r *stdhttp.Request) {
stdhttp.Error(w,
"manual init-repo is gone — the server auto-inits on the agent's first connect",
stdhttp.StatusGone)
}
// addHostPage carries the Add-host form state. The result-state
// (showing the install command + htpasswd snippet) lives at
// /hosts/pending/{token} and uses pendingHostPage instead, so the
// operator can refresh / bookmark / come back later — the password
// is decrypted from the still-alive token row on every render
// rather than living only in a one-shot rendered response.
type addHostPage struct {
// Form fields — pre-populate the form on a re-render after a
// validation error.
Hostname string
Tags string
RepoURL string
RepoUsername string
Paths string
ServerURL string
Error string
// Outstanding tokens (NS-02) — every still-valid (un-consumed,
// un-expired) enrolment token, surfaced so an operator who closed
// the install snippet tab can recover via Regenerate or revoke.
OutstandingTokens []addHostOutstandingToken
}
// addHostOutstandingToken is a UI-shaped projection of a row from
// store.ListOutstandingEnrollmentTokens with the repo URL already
// decrypted-and-redacted (no creds reach the browser).
type addHostOutstandingToken struct {
TokenHash string // full hex hash; opaque path param for actions
ShortHash string // first 12 chars of TokenHash for display
CreatedAt time.Time
ExpiresAt time.Time
RepoURL string // redacted (no embedded creds)
InitialPaths []string
}
// pendingHostPage is the GET /hosts/pending/{token} view. Lives
// for as long as the token does (1h ttl); once the agent enrols,
// the handler redirects to /hosts/{host_id} and this page is gone.
type pendingHostPage struct {
Token string
ServerURL string
ExpiresAt time.Time
RepoURL string
RepoUsername string
RepoPassword string
InitialPaths []string
}
// handleUIAddHostGet renders the empty Add host form.
func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
view := s.baseView(r, u)
view.Title = "Add host · restic-manager"
view.Page = addHostPage{
ServerURL: s.publicURL(r),
OutstandingTokens: s.loadOutstandingTokensForUI(r),
}
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// loadOutstandingTokensForUI fetches the still-valid enrolment tokens
// and decrypts each row's repo URL so the Add-host page can show a
// recoverable list. Decryption failures (rotated key etc.) are logged
// and surfaced as "(decrypt failed)" rather than crashing the page.
func (s *Server) loadOutstandingTokensForUI(r *stdhttp.Request) []addHostOutstandingToken {
rows, err := s.deps.Store.ListOutstandingEnrollmentTokens(r.Context())
if err != nil {
slog.Warn("ui add_host: list outstanding tokens", "err", err)
return nil
}
out := make([]addHostOutstandingToken, 0, len(rows))
for _, row := range rows {
short := row.TokenHash
if len(short) > 12 {
short = short[:12]
}
entry := addHostOutstandingToken{
TokenHash: row.TokenHash,
ShortHash: short,
CreatedAt: row.CreatedAt,
ExpiresAt: row.ExpiresAt,
InitialPaths: row.InitialPaths,
}
if row.EncRepoCreds != "" {
plain, derr := s.deps.AEAD.Decrypt(row.EncRepoCreds, []byte("token:"+row.TokenHash))
if derr != nil {
entry.RepoURL = "(decrypt failed — key rotation?)"
} else {
var blob repoCredsBlob
_ = json.Unmarshal(plain, &blob)
entry.RepoURL = restic.RedactURL(blob.RepoURL)
}
}
out = append(out, entry)
}
return out
}
// handleUIAddHostPost validates the form, mints the enrolment token
// (with encrypted repo creds), and 303-redirects to the persistent
// pending-host page. On validation errors we re-render the form
// with the operator's typed input intact and a banner.
func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
page := addHostPage{
Hostname: strings.TrimSpace(r.PostForm.Get("hostname")),
Tags: strings.TrimSpace(r.PostForm.Get("tags")),
RepoURL: strings.TrimSpace(r.PostForm.Get("repo_url")),
RepoUsername: strings.TrimSpace(r.PostForm.Get("repo_username")),
Paths: r.PostForm.Get("paths"),
ServerURL: s.publicURL(r),
}
repoPassword := r.PostForm.Get("repo_password")
if page.Hostname == "" {
page.Error = "Hostname is required."
} else if page.RepoURL == "" {
page.Error = "Repo URL is required so the agent can back up the moment it comes online."
}
if page.Error == "" && repoPassword == "" {
gen, err := generateRepoPassword()
if err != nil {
slog.Error("ui add_host: generate repo password", "err", err)
page.Error = "Couldnt generate a password — see the server log for details."
} else {
repoPassword = gen
}
}
// Default repo username to the hostname when the operator left it
// blank. With rest-server's --private-repos this is what the URL
// path segment is expected to be anyway, and an htpasswd entry
// always needs *some* user — defaulting saves the operator from
// landing on a pending page with a half-formed snippet.
repoUsername := page.RepoUsername
if repoUsername == "" {
repoUsername = page.Hostname
}
if page.Error == "" {
token, _, err := s.mintEnrollmentToken(r.Context(), page.RepoURL, repoUsername, repoPassword, splitPaths(page.Paths))
switch {
case err == nil:
stdhttp.Redirect(w, r, "/hosts/pending/"+token, stdhttp.StatusSeeOther)
return
case errors.Is(err, errMissingRepoCreds):
page.Error = "Repo URL and password are both required."
default:
slog.Error("ui add_host: mint token", "err", err)
page.Error = "Couldnt mint a token — see the server log for details."
}
}
view := s.baseView(r, u)
view.Title = "Add host · restic-manager"
view.Page = page
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
slog.Error("ui: render add_host", "err", err)
}
}
// handleUIPendingHost serves the durable Add-host result page —
// shown after a successful POST /hosts/new and reachable until the
// agent enrols (the page redirects to /hosts/{id} once that
// happens) or the token expires (1h ttl). The password is
// re-decrypted from the encrypted token row on every render so
// the operator can refresh, bookmark, navigate away and come back.
func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
rawToken := chi.URLParam(r, "token")
if rawToken == "" {
stdhttp.NotFound(w, r)
return
}
tokHash := auth.HashToken(rawToken)
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), tokHash)
if err != nil {
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
if status.ConsumedHost != nil {
stdhttp.Redirect(w, r, "/hosts/"+*status.ConsumedHost, stdhttp.StatusSeeOther)
return
}
if time.Now().After(status.ExpiresAt) {
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
att, err := s.deps.Store.GetEnrollmentTokenAttachments(r.Context(), tokHash)
if err != nil {
slog.Warn("ui pending: load attachments", "err", err)
stdhttp.Redirect(w, r, "/hosts/new", stdhttp.StatusSeeOther)
return
}
page := pendingHostPage{
Token: rawToken,
ServerURL: s.publicURL(r),
ExpiresAt: status.ExpiresAt,
InitialPaths: att.InitialPaths,
}
if att.EncRepoCreds != "" {
plain, err := s.deps.AEAD.Decrypt(att.EncRepoCreds, []byte("token:"+tokHash))
if err != nil {
slog.Error("ui pending: decrypt creds", "err", err)
} else {
var blob repoCredsBlob
if err := json.Unmarshal(plain, &blob); err == nil {
page.RepoURL = blob.RepoURL
page.RepoUsername = blob.RepoUsername
page.RepoPassword = blob.RepoPassword
}
}
}
view := s.baseView(r, u)
view.Title = "Pending host · restic-manager"
view.Page = page
if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
slog.Error("ui: render pending_host", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUIPendingAwaiting is the polled fragment that the pending-
// host page swaps in every couple of seconds to detect "agent
// connected". Returns either the still-awaiting partial (with the
// HTMX poll trigger preserved) or the connected partial (no poll —
// includes a meta-refresh to /hosts/{id} so the operator lands on
// the host detail).
func (s *Server) handleUIPendingAwaiting(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if u := s.requireUIUser(w, r); u == nil {
return
}
rawToken := chi.URLParam(r, "token")
if rawToken == "" {
stdhttp.Error(w, "missing token", stdhttp.StatusBadRequest)
return
}
status, err := s.deps.Store.GetEnrollmentTokenStatus(r.Context(), auth.HashToken(rawToken))
page := awaitingFragment{Token: rawToken, ExpiresAt: status.ExpiresAt}
switch {
case errors.Is(err, store.ErrNotFound):
page.State = "expired"
case err != nil:
slog.Warn("ui awaiting: lookup", "err", err)
page.State = "expired"
case status.ConsumedHost != nil:
page.State = "connected"
page.HostID = *status.ConsumedHost
if h, err := s.deps.Store.GetHost(r.Context(), *status.ConsumedHost); err == nil {
page.HostName = h.Name
page.LastSeenAt = h.LastSeenAt
}
case time.Now().After(status.ExpiresAt):
page.State = "expired"
default:
page.State = "awaiting"
}
if err := s.deps.UI.RenderPartial(w, "awaiting_agent", ui.ViewData{Page: page}); err != nil {
slog.Error("ui: render awaiting_agent", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// awaitingFragment carries the state for the polled awaiting-agent
// partial. State == awaiting | connected | expired drives both the
// copy block and whether HTMX keeps polling.
type awaitingFragment struct {
State string
Token string
ExpiresAt time.Time
HostID string
HostName string
LastSeenAt *time.Time
}
// hostChromeData is the field set the host_chrome partial reads from
// every host-detail-tab page's Page struct. Embed it as the first
// (anonymous) field of the page struct so .Page.Host / .Page.SubTab
// resolve via field promotion in the template.
type hostChromeData struct {
Host store.Host
SubTab string // snapshots | sources | schedules | repo
Crumb string // breadcrumb tail ("snapshots" / "sources" / etc)
SourceGroupCount int
ScheduleCount int
ScheduleVersion int64 // host_schedule_version (latest desired)
// KnownTags is the union of tags already in use across the fleet,
// used for autocomplete on the host-tags edit form. Cheap query.
KnownTags []string
// Auto-init status surfaced from the latest 'init' job.
// InitStatus is "succeeded" | "failed" | "running" | "queued" | "" (never run).
InitStatus string
InitAt *time.Time // started_at if non-nil else created_at
InitJobID string
// Latest 'restore' job — surfaced as a small line below the
// init-status one so the operator has at-a-glance visibility into
// recent destructive activity. Empty status means no restore has
// ever run on this host.
RestoreStatus string
RestoreAt *time.Time
RestoreJobID string
}
// loadHostChrome fetches the per-tab counts that every host-detail tab
// renders in the chrome (sub-tab badges + version indicator). On any
// non-fatal store error it logs and degrades to zeros — better to
// render the page with stale counts than 500 the whole tab.
func (s *Server) loadHostChrome(r *stdhttp.Request, host store.Host, subtab, crumb string) hostChromeData {
d := hostChromeData{Host: host, SubTab: subtab, Crumb: crumb}
if groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID); err == nil {
d.SourceGroupCount = len(groups)
} else {
slog.Warn("ui chrome: list source groups", "host_id", host.ID, "err", err)
}
if scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID); err == nil {
d.ScheduleCount = len(scheds)
} else {
slog.Warn("ui chrome: list schedules", "host_id", host.ID, "err", err)
}
if v, err := s.deps.Store.GetHostScheduleVersion(r.Context(), host.ID); err == nil {
d.ScheduleVersion = v
}
if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "init"); err == nil && j != nil {
d.InitStatus = j.Status
d.InitJobID = j.ID
t := j.CreatedAt
if j.StartedAt != nil {
t = *j.StartedAt
}
d.InitAt = &t
}
if j, err := s.deps.Store.LatestJobByKind(r.Context(), host.ID, "restore"); err == nil && j != nil {
d.RestoreStatus = j.Status
d.RestoreJobID = j.ID
t := j.CreatedAt
if j.StartedAt != nil {
t = *j.StartedAt
}
d.RestoreAt = &t
}
if tags, err := s.deps.Store.DistinctHostTags(r.Context()); err == nil {
d.KnownTags = tags
}
return d
}
// handleUIHostTagsSave accepts a comma-separated tag list, normalises,
// dedups, and writes. Operator-band; mounted in server.go.
func (s *Server) handleUIHostTagsSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil {
stdhttp.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
raw := r.PostForm.Get("tags")
tags := normaliseTags(raw)
if err := s.deps.Store.SetHostTags(r.Context(), hostID, tags); err != nil {
slog.Error("ui host tags: save", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "host.tags_updated",
TargetKind: ptr("host"), TargetID: &hostID,
TS: time.Now().UTC(),
})
stdhttp.Redirect(w, r, "/hosts/"+hostID, stdhttp.StatusSeeOther)
}
// normaliseTags splits a comma-separated string, lowercases each token,
// trims whitespace, drops empties, and dedupes. Order is preserved
// from first occurrence (so the user's typing order shows on screen).
func normaliseTags(raw string) []string {
parts := strings.Split(raw, ",")
seen := make(map[string]bool, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
t := strings.ToLower(strings.TrimSpace(p))
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
// hostDetailPage carries everything the host detail template needs.
type hostDetailPage struct {
hostChromeData
Snapshots []store.Snapshot
// SnapshotsShown is the number rendered (we cap at ~50 for the
// first slice; pagination lands when it matters).
SnapshotsShown int
// LegacyRestic is true when the host's restic version predates
// 0.17, in which case `restic snapshots --json` doesn't embed the
// per-snapshot summary block and the Size/Files columns render
// blank. The template uses this to attach a tooltip to those
// column headers explaining the version requirement.
LegacyRestic bool
}
// handleUIHostDetail is the host detail page (snapshots tab by default).
// Auth-gated. 404 if the host id is unknown.
func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
stdhttp.NotFound(w, r)
return
}
host, err := s.deps.Store.GetHost(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui host detail: get host", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
if err != nil {
slog.Error("ui host detail: list snapshots", "host_id", hostID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
const cap = 50
shown := snaps
if len(shown) > cap {
shown = shown[:cap]
}
view := s.baseView(r, u)
view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
Snapshots: shown,
SnapshotsShown: len(shown),
LegacyRestic: !restic.Env{Version: host.ResticVersion}.AtLeastVersion(0, 17),
}
if err := s.deps.UI.Render(w, "host_detail", view); err != nil {
slog.Error("ui: render host_detail", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// generateRepoPassword returns a 24-byte URL-safe random string for
// use as a per-host rest-server password. URL-safe alphabet keeps
// it shell-safe inside single quotes — important since the operator
// pastes it into an `htpasswd -i` invocation on the rest-server.
func generateRepoPassword() (string, error) {
var buf [24]byte
if _, err := rand.Read(buf[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buf[:]), nil
}
// splitPaths parses the textarea content into a clean []string —
// one path per line, leading/trailing whitespace trimmed, blanks
// dropped.
func splitPaths(s string) []string {
out := []string{}
for _, line := range strings.Split(s, "\n") {
if p := strings.TrimSpace(line); p != "" {
out = append(out, p)
}
}
return out
}
// publicURL is what the operator should paste into the install
// command. Prefers RM_BASE_URL (set by the operator's reverse
// proxy config) and falls back to scheme + Host of the inbound
// request — useful for local smoke without a proxy.
func (s *Server) publicURL(r *stdhttp.Request) string {
if s.deps.Cfg.BaseURL != "" {
return strings.TrimRight(s.deps.Cfg.BaseURL, "/")
}
scheme := "http"
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
}
// jobDetailPage carries everything the live-log template renders.
type jobDetailPage struct {
Job store.Job
Host store.Host
Logs []store.JobLogLine
NextSeq int64
IsActive bool // true while status is queued|running
}
// handleUIJobDetail renders the live job log view (snapshot of any
// already-persisted log lines + an empty stream container the JS
// fills via the WS).
func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
jobID := chi.URLParam(r, "id")
if jobID == "" {
stdhttp.NotFound(w, r)
return
}
job, err := s.deps.Store.GetJob(r.Context(), jobID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
host, err := s.deps.Store.GetHost(r.Context(), job.HostID)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
logs, err := s.deps.Store.ListJobLogs(r.Context(), jobID, 0, 0)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
var nextSeq int64
if n := len(logs); n > 0 {
nextSeq = logs[n-1].Seq
}
view := s.baseView(r, u)
view.Title = job.Kind + " · " + host.Name + " · restic-manager"
view.Page = jobDetailPage{
Job: *job,
Host: *host,
Logs: logs,
NextSeq: nextSeq,
IsActive: job.Status == "queued" || job.Status == "running",
}
if err := s.deps.UI.Render(w, "job_detail", view); err != nil {
slog.Error("ui: render job_detail", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleJobStream is the browser-side WS endpoint. Auth is via the
// session cookie (the HTTP layer does the lookup before upgrading).
// On connect we subscribe to JobHub for the given job_id; the
// subscriber goroutine pumps fan-out messages to the client until
// the job finishes or the browser navigates away.
//
// Messages on the wire are the same api.Envelope shape as on the
// agent side, so the client-side JS can switch on env.type the
// same way our Go code does.
func (s *Server) handleJobStream(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if u, _ := s.sessionUser(r); u == nil {
stdhttp.Error(w, "unauthorised", stdhttp.StatusUnauthorized)
return
}
jobID := chi.URLParam(r, "id")
if jobID == "" {
stdhttp.Error(w, "missing job id", stdhttp.StatusBadRequest)
return
}
if _, err := s.deps.Store.GetJob(r.Context(), jobID); err != nil {
stdhttp.NotFound(w, r)
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true, // Origin checks pointless for a same-origin browser hop.
})
if err != nil {
slog.Warn("ws browser accept failed", "job_id", jobID, "err", err)
return
}
defer func() { _ = conn.Close(websocket.StatusNormalClosure, "") }()
// Wrap so we get the same Send semantics as the agent path.
c := ws.NewConn("browser-"+jobID, conn)
// Register first so future broadcasts reach us, then re-fetch the
// job to close the late-subscriber race: a fast-failing job can
// finish (DB write + Broadcast) before the browser's WS hop
// completes, leaving the JS waiting forever for a job.finished
// that already passed. If the job is already terminal here, prime
// the subscriber with a synthetic job.finished so the JS reloads.
sub := s.deps.JobHub.Register(jobID)
if cur, gerr := s.deps.Store.GetJob(r.Context(), jobID); gerr == nil && isTerminalJobStatus(cur.Status) {
if env, ferr := buildSyntheticJobFinished(cur); ferr == nil {
sub.Send(env)
}
}
sub.Run(r.Context(), c)
}
func isTerminalJobStatus(s string) bool {
switch api.JobStatus(s) {
case api.JobSucceeded, api.JobFailed, api.JobCancelled:
return true
}
return false
}
func buildSyntheticJobFinished(job *store.Job) (api.Envelope, error) {
var fin time.Time
if job.FinishedAt != nil {
fin = *job.FinishedAt
}
exit := 0
if job.ExitCode != nil {
exit = *job.ExitCode
}
errMsg := ""
if job.Error != nil {
errMsg = *job.Error
}
return api.Marshal(api.MsgJobFinished, "", api.JobFinishedPayload{
JobID: job.ID,
Status: api.JobStatus(job.Status),
ExitCode: exit,
FinishedAt: fin,
Stats: job.Stats,
Error: errMsg,
})
}
// handleUILoginGet renders the login form. If the user is already
// signed in we redirect them home — login is for the unauthenticated.
func (s *Server) handleUILoginGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if u, _ := s.sessionUser(r); u != nil {
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
return
}
// First-run: no users + token still in memory ⇒ funnel the visitor
// to the bootstrap page so they don't have to know the API exists.
if s.bootstrapAvailable(r) {
stdhttp.Redirect(w, r, "/bootstrap", stdhttp.StatusSeeOther)
return
}
view := ui.ViewData{
Version: s.version(),
OIDCError: r.URL.Query().Get("oidc_error"),
}
if s.deps.OIDC != nil {
view.OIDCEnabled = true
view.OIDCDisplayName = s.deps.OIDC.DisplayName()
}
if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
}
}
// handleUILoginPost consumes the form, validates, mints a session,
// and either redirects to / on success or re-renders the form with
// an error banner on failure.
func (s *Server) handleUILoginPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
username := r.PostForm.Get("username")
password := r.PostForm.Get("password")
if _, err := s.authenticateAndSession(w, r, username, password); err != nil {
// Re-render the form. Single generic message — see
// authenticateAndSession's note on not leaking user existence.
view := ui.ViewData{
Version: s.version(),
Username: username,
Error: "Invalid username or password.",
}
if s.deps.OIDC != nil {
view.OIDCEnabled = true
view.OIDCDisplayName = s.deps.OIDC.DisplayName()
}
w.WriteHeader(stdhttp.StatusUnauthorized)
if err := s.deps.UI.Render(w, "login", view); err != nil {
slog.Error("ui: render login (post-fail)", "err", err)
}
return
}
stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
}
// handleUILogoutPost is the form-submit twin of /api/auth/logout. For
// local sessions it drops the cookie and redirects to /login. For OIDC
// sessions, if the IdP advertised an end_session_endpoint it performs
// RP-initiated logout by redirecting there with id_token_hint and
// post_logout_redirect_uri.
func (s *Server) handleUILogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
return
}
hash := auth.HashToken(c.Value)
sess, _ := s.deps.Store.LookupSession(r.Context(), hash)
_ = s.deps.Store.DeleteSession(r.Context(), hash)
// Default: drop session, go to /login.
dest := "/login"
// OIDC session with a discovered end_session_endpoint? Compose
// the IdP logout URL with id_token_hint + post_logout_redirect_uri.
if sess != nil && sess.IDToken != "" && s.deps.OIDC != nil &&
s.deps.OIDC.EndSessionEndpoint() != "" {
v := url.Values{}
v.Set("id_token_hint", sess.IDToken)
if base := strings.TrimRight(s.deps.Cfg.BaseURL, "/"); base != "" {
v.Set("post_logout_redirect_uri", base+"/login")
}
dest = s.deps.OIDC.EndSessionEndpoint() + "?" + v.Encode()
}
// Clear the cookie.
stdhttp.SetCookie(w, &stdhttp.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.deps.Cfg.CookieSecure,
SameSite: stdhttp.SameSiteLaxMode,
})
stdhttp.Redirect(w, r, dest, stdhttp.StatusSeeOther)
}