02e4ef7544
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.
1271 lines
41 KiB
Go
1271 lines
41 KiB
Go
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 = "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)
|
||
// 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)
|
||
}
|