package http import ( "crypto/rand" "encoding/base64" "encoding/json" "errors" "html/template" "io/fs" "log/slog" "math" 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/internal/version" "gitea.dcglab.co.uk/steve/restic-manager/internal/web/sparkline" "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 // UpdatesBehind is the count of online hosts whose agent_version // trails the server. Surfaces as the dashboard "N hosts behind" // hero tile and links to ?updates=behind. UpdatesBehind int } // 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" // Updates narrows to hosts whose agent is behind the server's // version. Only valid value today is "behind"; empty means no // filter. Updates string } // 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 // UpdateAvailable is true when the host's agent has connected at // least once AND its agent_version differs from the server's. Used // by the host_row partial to render the update-available chip. UpdateAvailable bool // TargetVersion is the server's build version, surfaced in the // chip's tooltip and label. TargetVersion string // RepoSparklineSVG is a server-rendered inline SVG showing the // 30-day repo-size trend. Empty-state SVG (em-dash) is returned // when no history rows exist for the host. RepoSparklineSVG template.HTML } // 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, TargetVersion: version.Version, UpdateAvailable: h.AgentVersion != "" && h.AgentVersion != version.Version, } 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 } } } since := time.Now().UTC().AddDate(0, 0, -30) pts, herr := s.deps.Store.ListHostRepoStatsHistory(r.Context(), h.ID, since) if herr != nil { slog.Warn("ui dashboard: list repo history", "host_id", h.ID, "err", herr) } sparkPoints := make([]float64, len(pts)) for i, p := range pts { if p.TotalSizeBytes == nil { sparkPoints[i] = math.NaN() } else { sparkPoints[i] = float64(*p.TotalSizeBytes) } } row.RepoSparklineSVG = sparkline.RenderSparkline(sparkPoints, 88, 20) 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) } updatesBehind := 0 for _, h := range allHosts { if h.Status == "online" && h.AgentVersion != "" && h.AgentVersion != version.Version { updatesBehind++ } } 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), UpdatesBehind: updatesBehind, } 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"), Updates: q.Get("updates"), } 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) } if f.Updates != "" { v.Set("updates", f.Updates) } 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 } } if f.Updates == "behind" { if h.AgentVersion == "" || h.AgentVersion == version.Version { 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 = "Couldn’t 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 = "Couldn’t 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) // UpdateAvailable + TargetVersion drive the agent-out-of-date chip // in the host detail header. UpdateAvailable is true iff the host // has connected at least once AND its agent_version != server's. UpdateAvailable bool TargetVersion string // Online + UpdateInProgress drive the per-host "Update agent" // button on host_detail. Online mirrors hub.Connected; pulled here // so the button can disable when the host is unreachable. Online bool UpdateInProgress bool // CanAdmin is true when the viewing user has admin role; used to // gate the "Update agent" button. Kept on the chrome struct so any // page reusing host_chrome already has it for free. CanAdmin bool // 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} d.TargetVersion = version.Version d.UpdateAvailable = host.AgentVersion != "" && host.AgentVersion != version.Version if s.deps.Hub != nil { d.Online = s.deps.Hub.Connected(host.ID) } if existing, _ := s.deps.Store.RunningUpdateJobForHost(r.Context(), host.ID); existing != "" { d.UpdateInProgress = true } 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" chrome := s.loadHostChrome(r, *host, "snapshots", "snapshots") chrome.CanAdmin = u.Role == string(store.RoleAdmin) view.Page = hostDetailPage{ hostChromeData: chrome, 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) }