From c8ead66f08027e8b3a14c94416b7cd17004f295a Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sat, 2 May 2026 11:02:12 +0100 Subject: [PATCH] P1 polish: agent-as-root, init-repo flow, rest creds passthrough, UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cohesive batch from a smoke-test session against a real rest-server. Themed bullets: * Agent runs as root, sandboxed via systemd. CapabilityBoundingSet drops to CAP_DAC_READ_SEARCH + restore caps; ProtectSystem=strict with ReadWritePaths confined to /etc + /var/lib/restic-manager; NoNewPrivileges blocks escalation. Install script no longer creates a service user. spec.md §4.2 / §14.1 / §14.3 explain the rationale (matches UrBackup / Veeam / Bareos defaults; trying to back up "everything" as an unprivileged user creates silent skips on /home, /root, /var/lib/* with no upside vs the threat model the agent already implies). * Init-repo end-to-end. New JobKind="init" wired through agent runner, restic.Env.RunInit, server dispatcher, and a UI button (red "Initialise repo" in the run-now panel). hosts.repo_initialised_at flips on init success, on backup success, or on a non-empty snapshots.report. The "Run now" / "Init" / "Retry" branching now drives both the dashboard host row and the host-detail panel. Migrations 0004 (column), 0005 (jobs.kind CHECK widened — using the safe create-new-then-rename pattern; first version corrupted job_logs.job_id FK), 0006 (cleans up job_logs FK on already- affected DBs). * rest-server creds embedded at exec time only. restic.Env gains RepoUsername; mergeRestCreds() builds the user:pass@-prefixed URL inside envSlice() and never assigns it back to the struct, so nothing slog-able ever sees the cleartext form. RedactURL helper for any future surface that needs to log a URL safely. Both helpers tested. * Add-host UX. Repo password is now optional — server mints a 24-byte URL-safe random one and surfaces it once, alongside an htpasswd snippet ("echo PASS | htpasswd -B -i ... USERNAME") so the operator pastes one command on the rest-server host and one on the endpoint. Result page also links the install snippet at /install/install.sh (was /install.sh — 404'd before) and pipes to bash (not sh — script uses set -o pipefail and other bashisms; on Debian/Ubuntu sh is dash). * Late-subscriber race in JobHub. A fast-failing job could finish (DB write + Broadcast) before the browser's HX-Redirect → page load → WS-connect path completed, so the JS sat forever waiting on a job.finished that already passed. JobHub split into Register + Send + Run; handleJobStream now subscribes first, re-fetches the job, and sends a synthetic job.finished if the state is already terminal. * HTMX error visibility. New toast partial listens to htmx:responseError and surfaces the response body as a bottom-right toast — every server-side validation error now becomes visible without per-handler JS wiring. Also handles custom rm:toast events for future server-pushed notifications via the HX-Trigger header. Themed via existing CSS vars. * Dashboard rows are now whole-row clickable to host detail (CSS card-link pattern: absolute-positioned anchor + .row-action z-index restoration so the action button stays clickable). "View →" on a running job links to /jobs/ rather than /hosts/ since the row click already covers the host page. * "Run first" / "Run first backup" → "Run now" everywhere for consistency. * runbook (docs/e2e-smoke.md) updated — live-log streaming step now reflects P1-26; mentions the browser-driven Run-now flow. * _diag/dump-creds — moved out of cmd/ so go build doesn't pick it up; .gitignore now excludes /_diag/ entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 + cmd/agent/main.go | 10 ++ deploy/install/install.sh | 40 ++---- deploy/install/restic-manager-agent.service | 38 +++-- docs/e2e-smoke.md | 8 +- internal/agent/runner/runner.go | 59 ++++++++ internal/api/messages.go | 1 + internal/restic/runner.go | 64 ++++++++- internal/restic/url.go | 85 +++++++++++ internal/restic/url_test.go | 41 ++++++ internal/server/http/jobs.go | 2 +- internal/server/http/server.go | 3 + internal/server/http/ui_handlers.go | 136 +++++++++++++++++- internal/server/ui/ui.go | 1 + internal/server/ws/handler.go | 19 +++ internal/server/ws/jobhub.go | 111 ++++++++------ internal/store/hosts.go | 32 ++++- .../migrations/0004_repo_initialised.sql | 15 ++ .../store/migrations/0005_jobs_init_kind.sql | 47 ++++++ .../store/migrations/0006_fix_job_logs_fk.sql | 33 +++++ internal/store/types.go | 6 + spec.md | 36 +++-- web/static/css/styles.css | 2 +- web/styles/input.css | 11 ++ web/templates/layouts/base.html | 2 + web/templates/pages/add_host.html | 42 ++++-- web/templates/pages/host_detail.html | 39 +++-- web/templates/partials/host_row.html | 18 +-- web/templates/partials/toast.html | 108 ++++++++++++++ 29 files changed, 885 insertions(+), 129 deletions(-) create mode 100644 internal/restic/url.go create mode 100644 internal/restic/url_test.go create mode 100644 internal/store/migrations/0004_repo_initialised.sql create mode 100644 internal/store/migrations/0005_jobs_init_kind.sql create mode 100644 internal/store/migrations/0006_fix_job_logs_fk.sql create mode 100644 web/templates/partials/toast.html diff --git a/.gitignore b/.gitignore index 1b5e793..c9e5566 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ coverage.html .env .env.local *.local + +# Local diagnostic helpers (never shipped). Go's build tooling already +# skips paths beginning with _ or ., but ignore explicitly so nothing +# checked in here can leak into a release tarball. +/_diag/ diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 2e9b71d..8b03b58 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -243,6 +243,7 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc r := runner.New(runner.Config{ ResticBin: d.resticBin, RepoURL: creds.URL, + RepoUsername: creds.Username, RepoPassword: creds.Password, }, tx, time.Second) @@ -259,6 +260,15 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc } slog.Info("agent: backup job complete", "job_id", p.JobID) }() + case api.JobInit: + slog.Info("agent: accepting init job", "job_id", p.JobID) + go func() { + if err := r.RunInit(ctx, p.JobID); err != nil { + slog.Warn("agent: init job failed", "job_id", p.JobID, "err", err) + return + } + slog.Info("agent: init job complete", "job_id", p.JobID) + }() default: return fmt.Errorf("kind %q not implemented yet (Phase 2 lands the rest)", p.Kind) } diff --git a/deploy/install/install.sh b/deploy/install/install.sh index 401f80f..f35319b 100755 --- a/deploy/install/install.sh +++ b/deploy/install/install.sh @@ -2,19 +2,23 @@ # install.sh — Linux installer for the restic-manager agent. # # Usage (paste in shell): -# curl -fsSL https://restic.lab.example/install.sh | \ -# sudo RM_SERVER=https://restic.lab.example RM_TOKEN= sh +# curl -fsSL https://restic.lab.example/install/install.sh | \ +# sudo RM_SERVER=https://restic.lab.example RM_TOKEN= bash # # What it does: # 1. detects arch (amd64 / arm64) # 2. fetches the matching agent binary from the server -# 3. creates the restic-manager-agent service user -# 4. lays down /etc/restic-manager/, /var/lib/restic-manager/ -# 5. enrolls (POST /api/agents/enroll) using RM_TOKEN -# 6. installs the systemd unit, enables, starts -# 7. surfaces (but does NOT disable) any existing restic timers / +# 3. lays down /etc/restic-manager/, /var/lib/restic-manager/ (root:root, 0700) +# 4. enrolls (POST /api/agents/enroll) using RM_TOKEN +# 5. installs the systemd unit, enables, starts +# 6. surfaces (but does NOT disable) any existing restic timers / # cron entries so the operator can decide what to do # +# The agent runs as root. See restic-manager-agent.service for the +# rationale (in short: a fleet-backup tool must read every file on +# the system; trying to do that unprivileged buys very little +# security and creates large UX cliffs). +# # Idempotent — safe to re-run; will refuse if already enrolled # unless RM_FORCE_REENROLL=1 is set. @@ -25,8 +29,6 @@ set -euo pipefail : "${RM_INSTALL_PREFIX:=/usr/local/bin}" : "${RM_CONFIG_DIR:=/etc/restic-manager}" : "${RM_STATE_DIR:=/var/lib/restic-manager}" -: "${RM_USER:=restic-manager-agent}" -: "${RM_GROUP:=restic-manager-agent}" : "${RM_FORCE_REENROLL:=0}" require_root() { @@ -44,21 +46,9 @@ detect_arch() { esac } -ensure_user() { - if ! getent group "$RM_GROUP" >/dev/null; then - groupadd --system "$RM_GROUP" - fi - if ! getent passwd "$RM_USER" >/dev/null; then - useradd --system --gid "$RM_GROUP" \ - --home-dir "$RM_STATE_DIR" --no-create-home \ - --shell /usr/sbin/nologin \ - "$RM_USER" - fi -} - ensure_dirs() { - install -d -m 0750 -o "$RM_USER" -g "$RM_GROUP" "$RM_CONFIG_DIR" - install -d -m 0750 -o "$RM_USER" -g "$RM_GROUP" "$RM_STATE_DIR" + install -d -m 0700 -o root -g root "$RM_CONFIG_DIR" + install -d -m 0700 -o root -g root "$RM_STATE_DIR" } detect_existing_schedulers() { @@ -121,8 +111,7 @@ enroll_agent() { fi echo "==> Enrolling agent with $RM_SERVER" - sudo -u "$RM_USER" \ - "$RM_INSTALL_PREFIX/restic-manager-agent" \ + "$RM_INSTALL_PREFIX/restic-manager-agent" \ -config "$cfg" \ -enroll-server "$RM_SERVER" \ -enroll-token "$RM_TOKEN" @@ -142,7 +131,6 @@ install_unit() { main() { require_root - ensure_user ensure_dirs download_agent detect_existing_schedulers diff --git a/deploy/install/restic-manager-agent.service b/deploy/install/restic-manager-agent.service index 2567559..e253ccc 100644 --- a/deploy/install/restic-manager-agent.service +++ b/deploy/install/restic-manager-agent.service @@ -10,20 +10,34 @@ ExecStart=/usr/local/bin/restic-manager-agent -config /etc/restic-manager/agent. Restart=always RestartSec=5 -# Run as a dedicated unprivileged user; the install script creates it. -User=restic-manager-agent -Group=restic-manager-agent +# The agent runs as root. A fleet-backup tool needs to read every +# file on the system regardless of DAC permissions; running as a +# dedicated unprivileged user means either silent skips on /home, +# /root, /var/lib/, or operators having to add the +# service user to every group whose files they want backed up. Both +# are worse than the threat model already implies (the agent holds +# repo credentials, executes arbitrary restic, and runs operator- +# defined hooks — its blast radius is already large). +# +# The mitigation is aggressive systemd sandboxing of the root +# process: drop all capabilities except the few we need, deny +# writes outside our state dirs, and forbid privilege escalation. +User=root +Group=root -# The agent reads its config and writes a small state file there. -# Anything else is read-only. -ReadWritePaths=/etc/restic-manager /var/lib/restic-manager +# CAP_DAC_READ_SEARCH lets us read any file regardless of DAC perms +# (the "backup everything" capability). CAP_DAC_OVERRIDE is needed +# during restore for chown/chmod to recreate ownership. Drop the +# rest — root in this process means "can read", not "can do". +CapabilityBoundingSet=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN +AmbientCapabilities=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN -# Hardening — restic itself needs filesystem read access to whatever -# paths it's backing up; we don't lock that down here. But everything -# else gets the standard systemd sandboxing toggles. +# Hardening — blocks privilege escalation even from root, and +# confines writes / network / kernel access to what restic actually +# needs. Filesystem reads stay open: that's the whole job. NoNewPrivileges=true -PrivateTmp=true ProtectSystem=strict +ReadWritePaths=/etc/restic-manager /var/lib/restic-manager ProtectHome=read-only ProtectHostname=true ProtectKernelTunables=true @@ -31,12 +45,16 @@ ProtectKernelModules=true ProtectKernelLogs=true ProtectControlGroups=true ProtectClock=true +PrivateTmp=true +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictRealtime=true RestrictSUIDSGID=true RestrictNamespaces=true LockPersonality=true MemoryDenyWriteExecute=true SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources @reboot @swap @module @raw-io [Install] WantedBy=multi-user.target diff --git a/docs/e2e-smoke.md b/docs/e2e-smoke.md index ef187d4..a43e0c2 100644 --- a/docs/e2e-smoke.md +++ b/docs/e2e-smoke.md @@ -224,6 +224,13 @@ The agent terminal will show restic chugging through two tiny files; the server terminal will log the lifecycle (`mark job started` / `mark job finished` / `snapshots refreshed count=1`). +For a browser-driven version of the same flow, log in at +`http://127.0.0.1:8080/` and click **Run now** on the host row — the +button posts to `/hosts/{id}/run-backup` and the response sets +`HX-Redirect` to the live log page, which subscribes to +`/api/jobs/{id}/stream` (P1-26) and tails `job.progress` / `log.stream` +until `job.finished` flips it to the final header. + ## 9. Confirm the snapshot ```sh @@ -272,7 +279,6 @@ relevant tasks land: - TLS termination at a reverse proxy (covered by P5-07 reference deployment) - Append-only restic creds + separate prune credential (P2-06) -- Live job log streaming in a browser (P1-21 remainder; needs the UI) - Cancellation (P2) - Schedule-driven backups (P2-01 onwards) - Windows agent (P2-16/17) diff --git a/internal/agent/runner/runner.go b/internal/agent/runner/runner.go index e6e8bb9..a5462ae 100644 --- a/internal/agent/runner/runner.go +++ b/internal/agent/runner/runner.go @@ -28,6 +28,7 @@ type Sender interface { type Config struct { ResticBin string RepoURL string + RepoUsername string RepoPassword string } @@ -65,6 +66,7 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t env := restic.Env{ Bin: r.cfg.ResticBin, RepoURL: r.cfg.RepoURL, + RepoUsername: r.cfg.RepoUsername, RepoPassword: r.cfg.RepoPassword, } @@ -146,6 +148,63 @@ func (r *Runner) RunBackup(ctx context.Context, jobID string, paths, excludes, t return nil } +// RunInit executes a repo-init job and reports back via the sender. +// Returns nil on success. Same envelope shape as RunBackup so the +// browser-side log viewer just works. +func (r *Runner) RunInit(ctx context.Context, jobID string) error { + startedAt := time.Now().UTC() + startEnv, _ := api.Marshal(api.MsgJobStarted, jobID, api.JobStartedPayload{ + JobID: jobID, Kind: api.JobInit, StartedAt: startedAt, + }) + if err := r.tx.Send(startEnv); err != nil { + slog.Warn("runner: send job.started (init)", "err", err) + } + + env := restic.Env{ + Bin: r.cfg.ResticBin, + RepoURL: r.cfg.RepoURL, + RepoUsername: r.cfg.RepoUsername, + RepoPassword: r.cfg.RepoPassword, + } + + var seq atomic.Int64 + handle := func(stream string, line string, _ any) { + now := time.Now().UTC() + logEnv, _ := api.Marshal(api.MsgLogStream, "", api.LogStreamLine{ + JobID: jobID, + Seq: seq.Add(1), + TS: now, + Stream: api.LogStream(stream), + Payload: line, + }) + _ = r.tx.Send(logEnv) + } + + err := env.RunInit(ctx, handle) + finishedAt := time.Now().UTC() + + status := api.JobSucceeded + exit := 0 + errMsg := "" + if err != nil { + status = api.JobFailed + exit = -1 + errMsg = err.Error() + } + finEnv, _ := api.Marshal(api.MsgJobFinished, jobID, api.JobFinishedPayload{ + JobID: jobID, + Status: status, + ExitCode: exit, + FinishedAt: finishedAt, + Error: errMsg, + }) + _ = r.tx.Send(finEnv) + if err != nil { + return fmt.Errorf("runner init: %w", err) + } + return nil +} + // reportSnapshots calls `restic snapshots --json`, translates the // payload into the wire shape, and ships it as a snapshots.report // envelope. Bounded by a separate timeout so a sluggish repo doesn't diff --git a/internal/api/messages.go b/internal/api/messages.go index c04743e..0a7b648 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -47,6 +47,7 @@ type JobKind string const ( JobBackup JobKind = "backup" + JobInit JobKind = "init" JobForget JobKind = "forget" JobPrune JobKind = "prune" JobCheck JobKind = "check" diff --git a/internal/restic/runner.go b/internal/restic/runner.go index be8dfc2..41b738a 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -33,10 +33,19 @@ func Locate(override string) (string, error) { } // Env is the per-invocation context for a restic command. +// +// RepoURL is the bare URL as the operator typed it — no embedded +// credentials. RepoUsername (optional) carries the HTTP basic-auth +// user for `rest:` repos. The merged URL (with `user:pass@host` +// embedded) is built once inside envSlice() at the moment of exec +// and fed straight to the subprocess via RESTIC_REPOSITORY; we +// never assign it back to Env, never pass it to slog. If anything +// in this package ever needs to *log* a URL, use RedactURL. type Env struct { Bin string // path to restic binary - RepoURL string // RESTIC_REPOSITORY - RepoPassword string // RESTIC_PASSWORD (passed via env, never argv) + RepoURL string // RESTIC_REPOSITORY (no embedded creds) + RepoUsername string // optional HTTP basic-auth user for rest: URLs + RepoPassword string // doubles as RESTIC_PASSWORD and (for rest:) HTTP basic-auth password ExtraEnv map[string]string // any other RESTIC_* / passthrough WorkDir string // CWD; default = current } @@ -140,6 +149,55 @@ func (e Env) RunBackup(ctx context.Context, paths, excludes, tags []string, hand return summary, nil } +// RunInit executes `restic init` against the configured repo. Returns +// nil on success. Restic init's output is small and not JSON-rich; +// we tee stdout/stderr verbatim through handle so the operator sees +// the same lines they'd see at the CLI ("created restic repository +// at " on success, "config file already exists" on a +// re-init attempt, etc.). +func (e Env) RunInit(ctx context.Context, handle LineHandler) error { + cmd := exec.CommandContext(ctx, e.Bin, "init") + cmd.Env = e.envSlice() + cmd.Dir = e.WorkDir + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("restic init: stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("restic init: stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("restic init: start: %w", err) + } + + done := make(chan error, 2) + go func() { done <- pumpPlain(stdout, "stdout", handle) }() + go func() { done <- pumpPlain(stderr, "stderr", handle) }() + for i := 0; i < 2; i++ { + if err := <-done; err != nil && handle != nil { + handle("event", fmt.Sprintf("pump error: %v", err), nil) + } + } + if werr := cmd.Wait(); werr != nil { + return fmt.Errorf("restic init: %w", werr) + } + return nil +} + +func pumpPlain(r io.Reader, stream string, handle LineHandler) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + if handle != nil { + handle(stream, scanner.Text(), nil) + } + } + return scanner.Err() +} + // envSlice converts Env's typed fields into the os/exec env shape. // // Deliberately does NOT inherit the parent process's environment: @@ -164,7 +222,7 @@ func (e Env) envSlice() []string { xdg = x } out := []string{ - "RESTIC_REPOSITORY=" + e.RepoURL, + "RESTIC_REPOSITORY=" + mergeRestCreds(e.RepoURL, e.RepoUsername, e.RepoPassword), "RESTIC_PASSWORD=" + e.RepoPassword, // Feed restic via env-only — keeps creds off ps(1). "PATH=/usr/local/bin:/usr/bin:/bin", diff --git a/internal/restic/url.go b/internal/restic/url.go new file mode 100644 index 0000000..5a1ddc5 --- /dev/null +++ b/internal/restic/url.go @@ -0,0 +1,85 @@ +package restic + +import ( + "net/url" + "strings" +) + +// mergeRestCreds embeds basic-auth user:pass into a `rest:` URL, only +// at the moment we hand it off to the restic subprocess. The result +// is intentionally NOT stored on Env or logged — restic's REST +// backend reads basic-auth from the URL only, so we have nowhere +// else to put them. Callers must treat the return value as +// secret-bearing and feed it straight into exec env. +// +// No-ops when: +// - the URL has no `rest:` prefix (other backends — s3, b2, sftp, +// etc. — get creds via their own env vars); +// - the URL already embeds user:pass (operator typed creds inline); +// - username is empty. +// +// Returns rawURL unchanged if it can't be parsed; restic will then +// reject it and the operator gets a clear error rather than a silent +// "I quietly stripped your URL" surprise. +func mergeRestCreds(rawURL, username, password string) string { + if !strings.HasPrefix(rawURL, "rest:") { + return rawURL + } + if username == "" { + return rawURL + } + inner := strings.TrimPrefix(rawURL, "rest:") + u, err := url.Parse(inner) + if err != nil || u.Host == "" { + // Either unparseable or a relative URL we shouldn't touch — + // pass through and let restic complain with a clear message. + return rawURL + } + if u.User != nil { + // Operator already embedded creds — don't overwrite. + return rawURL + } + u.User = url.UserPassword(username, password) + return "rest:" + u.String() +} + +// RedactURL returns a logging-safe version of u with any password in +// the userinfo replaced by ***. Mirrors restic's own redaction so +// our logs match what restic prints. Use this — never the bare URL — +// whenever a URL might end up in slog output, audit entries, or any +// surface an operator can read. +// +// Non-restic URLs (s3, b2, sftp, …) pass through unchanged unless +// they happen to embed userinfo, in which case we redact the same +// way for consistency. +func RedactURL(u string) string { + prefix := "" + rest := u + if i := strings.Index(u, ":"); i > 0 && i+3 < len(u) && u[i+1:i+3] == "//" { + // scheme://… — keep "scheme:" intact. + prefix = u[:i+1] + rest = u[i+1:] + } else if strings.HasPrefix(u, "rest:") { + prefix = "rest:" + rest = strings.TrimPrefix(u, "rest:") + } + parsed, err := url.Parse(rest) + if err != nil || parsed.User == nil { + return u + } + if _, hasPass := parsed.User.Password(); !hasPass { + return u + } + // Build the redacted form by hand rather than via url.URL.String(), + // which percent-encodes the redaction marker into "%2A%2A%2A". + user := parsed.User.Username() + parsed.User = nil + rebuilt := parsed.String() + // rebuilt is "scheme://host/path…"; splice user:***@ in after "//". + const sep = "//" + idx := strings.Index(rebuilt, sep) + if idx < 0 { + return u + } + return prefix + rebuilt[:idx+len(sep)] + user + ":***@" + rebuilt[idx+len(sep):] +} diff --git a/internal/restic/url_test.go b/internal/restic/url_test.go new file mode 100644 index 0000000..63f9b5d --- /dev/null +++ b/internal/restic/url_test.go @@ -0,0 +1,41 @@ +package restic + +import "testing" + +func TestMergeRestCreds(t *testing.T) { + cases := []struct { + name, url, user, pass, want string + }{ + {"rest with creds", "rest:http://h:8000/p/", "u", "p", "rest:http://u:p@h:8000/p/"}, + {"rest no user — no-op", "rest:http://h:8000/p/", "", "p", "rest:http://h:8000/p/"}, + {"rest creds already inline — no-op", + "rest:http://existing:secret@h:8000/p/", "u", "p", + "rest:http://existing:secret@h:8000/p/"}, + {"non-rest s3 — no-op", "s3:s3.amazonaws.com/bucket", "u", "p", "s3:s3.amazonaws.com/bucket"}, + {"unparseable — pass through", "rest:not a url", "u", "p", "rest:not a url"}, + {"https URL kept intact", "rest:https://h/p/", "u", "p", "rest:https://u:p@h/p/"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := mergeRestCreds(c.url, c.user, c.pass) + if got != c.want { + t.Fatalf("mergeRestCreds(%q,%q,***) = %q; want %q", c.url, c.user, got, c.want) + } + }) + } +} + +func TestRedactURL(t *testing.T) { + cases := []struct{ in, want string }{ + {"rest:http://u:p@h:8000/p/", "rest:http://u:***@h:8000/p/"}, + {"rest:http://h:8000/p/", "rest:http://h:8000/p/"}, + {"https://u:p@example/", "https://u:***@example/"}, + {"s3:s3.amazonaws.com/bucket", "s3:s3.amazonaws.com/bucket"}, + } + for _, c := range cases { + got := RedactURL(c.in) + if got != c.want { + t.Fatalf("RedactURL(%q) = %q; want %q", c.in, got, c.want) + } + } +} diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index db26054..3bef4d5 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -135,7 +135,7 @@ func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) { func validJobKind(k api.JobKind) bool { switch k { - case api.JobBackup, api.JobForget, api.JobPrune, api.JobCheck, api.JobUnlock: + case api.JobBackup, api.JobInit, api.JobForget, api.JobPrune, api.JobCheck, api.JobUnlock: return true } return false diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 6a1035e..fc90d17 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -135,6 +135,9 @@ func (s *Server) routes(r chi.Router) { r.Post("/logout", s.handleUILogoutPost) // HTMX action endpoint for "Run now" buttons on the dashboard. r.Post("/hosts/{id}/run-backup", s.handleUIRunBackup) + // HTMX action endpoint for the red "Initialise repo" button + // shown in the run-now panel until the repo is confirmed init'd. + r.Post("/hosts/{id}/init-repo", s.handleUIInitRepo) // Add host flow. r.Get("/hosts/new", s.handleUIAddHostGet) r.Post("/hosts/new", s.handleUIAddHostPost) diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 94de2cd..f88c011 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -1,6 +1,8 @@ package http import ( + "crypto/rand" + "encoding/base64" "errors" "io/fs" "log/slog" @@ -178,6 +180,12 @@ func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.StatusBadRequest) return } + if host.RepoInitialisedAt == nil { + stdhttp.Error(w, + "this host's repo hasn't been initialised yet — click Initialise repo first", + stdhttp.StatusBadRequest) + return + } res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobBackup, host.DefaultPaths) if code != "" { stdhttp.Error(w, msg, status) @@ -197,6 +205,47 @@ func (s *Server) handleUIRunBackup(w stdhttp.ResponseWriter, r *stdhttp.Request) stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther) } +// handleUIInitRepo dispatches a one-shot `restic init` job for a +// host. Surfaced in the run-now panel as a red "Initialise repo" +// button when host.repo_initialised_at IS NULL. On success it +// redirects to the live log page just like Run-now. +func (s *Server) handleUIInitRepo(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + hostID := chi.URLParam(r, "id") + if hostID == "" { + stdhttp.Error(w, "missing host id", stdhttp.StatusBadRequest) + return + } + storeUser, _, err := s.userByID(r, u.ID) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if _, err := s.deps.Store.GetHost(r.Context(), hostID); err != nil { + if errors.Is(err, store.ErrNotFound) { + stdhttp.NotFound(w, r) + return + } + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + res, status, code, msg := s.dispatchJob(r.Context(), storeUser, hostID, api.JobInit, nil) + if code != "" { + stdhttp.Error(w, msg, status) + return + } + target := "/jobs/" + res.JobID + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Redirect", target) + w.WriteHeader(stdhttp.StatusOK) + return + } + stdhttp.Redirect(w, r, target, stdhttp.StatusSeeOther) +} + // addHostPage carries the form state into the Add host template. // In State A (form), Token is empty. In State B (result), Token is // populated and the template renders the install command. @@ -223,6 +272,16 @@ type addHostPage struct { // install command panel instead of the form. Token string ExpiresAt time.Time + + // RepoPassword is the password the agent will use against the + // rest-server. When the operator left the password field blank + // we generate one server-side; PasswordGenerated tracks which + // path produced it so the result page can label it appropriately. + // Either way it's surfaced on the result page exactly once, + // inside the htpasswd snippet — same one-time-view rule as the + // enrolment token. Reload = gone. + RepoPassword string + PasswordGenerated bool } // handleUIAddHostGet renders the empty Add host form. @@ -264,8 +323,22 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques if page.Hostname == "" { page.Error = "Hostname is required." - } else if page.RepoURL == "" || repoPassword == "" { - page.Error = "Repo URL and password are both required so the agent can back up the moment it comes online." + } else if page.RepoURL == "" { + page.Error = "Repo URL is required so the agent can back up the moment it comes online." + } + + // If the operator didn't type a password, mint one. We surface it + // once on the result page (inside the htpasswd snippet) so they + // can paste it into the rest-server's htpasswd file. + 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 + page.PasswordGenerated = true + } } defaultPaths := splitPaths(page.Paths) @@ -276,6 +349,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques case nil: page.Token = token page.ExpiresAt = expires + page.RepoPassword = repoPassword case errMissingRepoCreds: page.Error = "Repo URL and password are both required." default: @@ -355,6 +429,18 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request } } +// 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. @@ -479,7 +565,51 @@ func (s *Server) handleJobStream(w stdhttp.ResponseWriter, r *stdhttp.Request) { // Wrap so we get the same Send semantics as the agent path. c := ws.NewConn("browser-"+jobID, conn) - s.deps.JobHub.Subscribe(r.Context(), jobID, c) + + // 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, + }) } // userByID fetches the full store.User the UI session represents. diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index b4fe62b..6e3192a 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -89,6 +89,7 @@ func New() (*Renderer, error) { "templates/layouts/chromeless.html", "templates/partials/nav.html", "templates/partials/host_row.html", + "templates/partials/toast.html", } pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html") diff --git a/internal/server/ws/handler.go b/internal/server/ws/handler.go index e309de4..e72e946 100644 --- a/internal/server/ws/handler.go +++ b/internal/server/ws/handler.go @@ -196,6 +196,16 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E string(p.Status), p.ExitCode, p.Stats, errMsg, p.FinishedAt); err != nil { slog.Warn("ws: mark job finished", "job_id", p.JobID, "err", err) } + // A successful backup or init proves the repo exists; flip + // repo_initialised_at on the host (idempotent — set-if-null). + if p.Status == api.JobSucceeded { + if job, err := deps.Store.GetJob(ctx, p.JobID); err == nil && + (job.Kind == string(api.JobBackup) || job.Kind == string(api.JobInit)) { + if _, err := deps.Store.MarkHostRepoInitialised(ctx, hostID, p.FinishedAt); err != nil { + slog.Warn("ws: mark repo initialised", "host_id", hostID, "err", err) + } + } + } if deps.JobHub != nil { deps.JobHub.Broadcast(p.JobID, env) } @@ -235,6 +245,15 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E } else { slog.Info("ws: snapshots refreshed", "host_id", hostID, "count", len(snaps)) } + // A non-empty snapshot list also proves the repo is initialised + // (catches the case where an external job — `restic init` from + // the CLI, or a backup ran outside this control plane — + // initialised it before our first job dispatched). + if len(snaps) > 0 { + if _, err := deps.Store.MarkHostRepoInitialised(ctx, hostID, time.Now().UTC()); err != nil { + slog.Warn("ws: mark repo initialised (snapshots)", "host_id", hostID, "err", err) + } + } case api.MsgRepoStats, api.MsgScheduleAck, api.MsgCommandResult: // TODO(P2): persist these projections. diff --git a/internal/server/ws/jobhub.go b/internal/server/ws/jobhub.go index 19164ad..e509193 100644 --- a/internal/server/ws/jobhub.go +++ b/internal/server/ws/jobhub.go @@ -17,54 +17,66 @@ import ( // read-only, lifecycle tied to the browser WS rather than the agent's. type JobHub struct { mu sync.RWMutex - subs map[string]map[*subscriber]struct{} // job_id → set + subs map[string]map[*Subscriber]struct{} // job_id → set } // NewJobHub returns an empty hub. func NewJobHub() *JobHub { - return &JobHub{subs: make(map[string]map[*subscriber]struct{})} + return &JobHub{subs: make(map[string]map[*Subscriber]struct{})} } -// subscriber is one browser WS subscription. Each gets its own -// buffered channel + writer goroutine so a slow client can't block -// the broadcaster (or, transitively, the agent's read loop). -type subscriber struct { +// Subscriber is one browser WS subscription. Each gets its own +// buffered channel so a slow client can't block the broadcaster (or, +// transitively, the agent's read loop). +// +// Two-phase usage: Register() returns a Subscriber that's already in +// the hub's set (so concurrent Broadcasts will reach it), but no +// pump goroutine runs yet. The caller can prime the channel via Send +// — useful for late-subscriber catch-up — and then call Run to start +// the pump. Run blocks until ctx is cancelled or conn dies, and +// unregisters on return. +type Subscriber struct { + hub *JobHub jobID string ch chan api.Envelope } -// Subscribe registers a new subscriber for jobID. Run pumps messages -// from the subscriber's channel onto conn until ctx is cancelled or -// conn dies; it returns when one of those happens. Caller is -// expected to call this from the goroutine that owns conn. +// Register adds a subscriber for jobID and returns it. The caller +// MUST call Run to pump messages — until then the subscriber's +// channel buffers silently (up to its capacity, then drops). // -// If the subscriber's send channel fills, broadcasts drop messages -// for that subscriber rather than blocking. The browser will see a -// gap; on completion the page can re-fetch persisted log_lines to -// reconcile. -func (h *JobHub) Subscribe(ctx context.Context, jobID string, conn *Conn) { +// Use Register + Send + Run when you need to prime the channel from +// the calling goroutine before the pump starts (e.g. to send a +// synthetic job.finished to a late subscriber whose target job is +// already terminal). For the simple case use Subscribe. +func (h *JobHub) Register(jobID string) *Subscriber { const buf = 64 - s := &subscriber{jobID: jobID, ch: make(chan api.Envelope, buf)} - + s := &Subscriber{hub: h, jobID: jobID, ch: make(chan api.Envelope, buf)} h.mu.Lock() if h.subs[jobID] == nil { - h.subs[jobID] = make(map[*subscriber]struct{}) + h.subs[jobID] = make(map[*Subscriber]struct{}) } h.subs[jobID][s] = struct{}{} h.mu.Unlock() + return s +} - defer func() { - h.mu.Lock() - if set, ok := h.subs[jobID]; ok { - delete(set, s) - if len(set) == 0 { - delete(h.subs, jobID) - } - } - h.mu.Unlock() - }() +// Send pushes env onto the subscriber's channel. Non-blocking: if the +// buffer is full, the message is dropped and a warning is logged. +func (s *Subscriber) Send(env api.Envelope) { + select { + case s.ch <- env: + default: + slog.Warn("ws browser sub: send buffer full, dropping message", + "job_id", s.jobID, "type", env.Type) + } +} - // Drain pump. +// Run pumps messages from the subscriber's channel onto conn until +// ctx is cancelled or conn dies. Unregisters on return. Caller is +// expected to invoke this from the goroutine that owns conn. +func (s *Subscriber) Run(ctx context.Context, conn *Conn) { + defer s.unregister() for { select { case <-ctx.Done(): @@ -77,20 +89,35 @@ func (h *JobHub) Subscribe(ctx context.Context, jobID string, conn *Conn) { err := conn.Send(sendCtx, env) cancel() if err != nil { - slog.Info("ws browser send failed; closing subscriber", "job_id", jobID, "err", err) + slog.Info("ws browser send failed; closing subscriber", + "job_id", s.jobID, "err", err) return } } } } +func (s *Subscriber) unregister() { + s.hub.mu.Lock() + if set, ok := s.hub.subs[s.jobID]; ok { + delete(set, s) + if len(set) == 0 { + delete(s.hub.subs, s.jobID) + } + } + s.hub.mu.Unlock() +} + +// Subscribe is a one-call convenience for callers that don't need to +// prime the channel before the pump. Equivalent to Register + Run. +func (h *JobHub) Subscribe(ctx context.Context, jobID string, conn *Conn) { + s := h.Register(jobID) + s.Run(ctx, conn) +} + // Broadcast sends env to every subscriber for jobID. Non-blocking: // if a subscriber's buffer is full, the message is dropped for that -// subscriber and a warning is logged. Other subscribers are -// unaffected. -// -// Safe to call from any goroutine; holds an RLock briefly to snapshot -// the subscriber set, then releases before sending. +// subscriber. Other subscribers are unaffected. func (h *JobHub) Broadcast(jobID string, env api.Envelope) { h.mu.RLock() set := h.subs[jobID] @@ -98,27 +125,19 @@ func (h *JobHub) Broadcast(jobID string, env api.Envelope) { h.mu.RUnlock() return } - targets := make([]*subscriber, 0, len(set)) + targets := make([]*Subscriber, 0, len(set)) for s := range set { targets = append(targets, s) } h.mu.RUnlock() for _, s := range targets { - select { - case s.ch <- env: - default: - // Buffer full — drop. Logged once per drop; a flood means - // the browser is genuinely stuck, not just slow. - slog.Warn("ws browser sub: send buffer full, dropping message", - "job_id", jobID, "type", env.Type) - } + s.Send(env) } } // SubscriberCount returns the number of browsers currently watching -// jobID. Used for diagnostics / future "this many people are -// watching" counters. +// jobID. func (h *JobHub) SubscriberCount(jobID string) int { h.mu.RLock() defer h.mu.RUnlock() diff --git a/internal/store/hosts.go b/internal/store/hosts.go index cd8fbb9..4d3eea3 100644 --- a/internal/store/hosts.go +++ b/internal/store/hosts.go @@ -50,7 +50,7 @@ func (s *Store) LookupHostByAgentToken(ctx context.Context, tokenHash string) (* enrolled_at, last_seen_at, status, repo_id, tags, current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, - applied_schedule_version, default_paths + applied_schedule_version, default_paths, repo_initialised_at FROM hosts WHERE agent_token_hash = ?`, tokenHash) return scanHost(row) @@ -63,7 +63,7 @@ func (s *Store) GetHost(ctx context.Context, id string) (*Host, error) { enrolled_at, last_seen_at, status, repo_id, tags, current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, - applied_schedule_version, default_paths + applied_schedule_version, default_paths, repo_initialised_at FROM hosts WHERE id = ?`, id) return scanHost(row) } @@ -124,7 +124,7 @@ func (s *Store) ListHosts(ctx context.Context) ([]Host, error) { enrolled_at, last_seen_at, status, repo_id, tags, current_job_id, last_backup_at, last_backup_status, repo_size_bytes, snapshot_count, open_alert_count, - applied_schedule_version, default_paths + applied_schedule_version, default_paths, repo_initialised_at FROM hosts ORDER BY name`) if err != nil { return nil, fmt.Errorf("store: list hosts: %w", err) @@ -163,13 +163,14 @@ func scanHostRow(s hostScanner) (*Host, error) { enrolled string tags string defaultPaths string + repoInitAt sql.NullString ) err := s.Scan(&h.ID, &h.Name, &h.OS, &h.Arch, &h.AgentVersion, &h.ResticVersion, &h.ProtocolVersion, &enrolled, &lastSeen, &h.Status, &repoID, &tags, ¤tJob, &lastBackupAt, &lastBkSt, &h.RepoSizeBytes, &h.SnapshotCount, &h.OpenAlertCount, - &h.AppliedScheduleVersion, &defaultPaths) + &h.AppliedScheduleVersion, &defaultPaths, &repoInitAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound @@ -213,5 +214,28 @@ func scanHostRow(s hostScanner) (*Host, error) { if defaultPaths != "" { _ = json.Unmarshal([]byte(defaultPaths), &h.DefaultPaths) } + if repoInitAt.Valid { + t, err := time.Parse(time.RFC3339Nano, repoInitAt.String) + if err != nil { + return nil, fmt.Errorf("store: parse repo_initialised_at: %w", err) + } + h.RepoInitialisedAt = &t + } return &h, nil } + +// MarkHostRepoInitialised sets repo_initialised_at to `when` if it is +// currently NULL. Idempotent: re-firing for an already-initialised +// host is a no-op (we never want to clobber the original timestamp). +// Returns true if the row was updated, false if it was already set. +func (s *Store) MarkHostRepoInitialised(ctx context.Context, hostID string, when time.Time) (bool, error) { + res, err := s.db.ExecContext(ctx, + `UPDATE hosts SET repo_initialised_at = ? + WHERE id = ? AND repo_initialised_at IS NULL`, + when.UTC().Format(time.RFC3339Nano), hostID) + if err != nil { + return false, fmt.Errorf("store: mark repo initialised: %w", err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} diff --git a/internal/store/migrations/0004_repo_initialised.sql b/internal/store/migrations/0004_repo_initialised.sql new file mode 100644 index 0000000..b55d486 --- /dev/null +++ b/internal/store/migrations/0004_repo_initialised.sql @@ -0,0 +1,15 @@ +-- 0004_repo_initialised.sql +-- +-- Track whether a host's restic repo has been initialised. Set when: +-- 1. a `repo_init` job succeeds, OR +-- 2. any backup job succeeds (proves the repo exists), OR +-- 3. a snapshots.report arrives with at least one snapshot. +-- +-- Once set, never cleared by code — only by the operator deleting the +-- host or wiping the column manually if they re-pointed the agent at +-- a different (empty) repo. The UI keys off NULL/non-NULL to decide +-- whether to surface the red "Initialise repo" affordance in the +-- run-now panel. + +ALTER TABLE hosts + ADD COLUMN repo_initialised_at TEXT; diff --git a/internal/store/migrations/0005_jobs_init_kind.sql b/internal/store/migrations/0005_jobs_init_kind.sql new file mode 100644 index 0000000..a34ec28 --- /dev/null +++ b/internal/store/migrations/0005_jobs_init_kind.sql @@ -0,0 +1,47 @@ +-- 0005_jobs_init_kind.sql +-- +-- Add 'init' to the jobs.kind CHECK constraint so the operator can +-- dispatch a `restic init` job from the UI before the first backup. +-- SQLite can't ALTER a CHECK in place, so we rebuild the table. +-- +-- Rebuild pattern note: we create jobs_new (with the wider CHECK), +-- copy data over, DROP the original jobs table, then ALTER RENAME +-- jobs_new TO jobs. This avoids the trap of renaming the original +-- first — with legacy_alter_table=OFF (the modern default), a rename +-- propagates into FK references in dependent tables (e.g. +-- job_logs.job_id), leaving them pointing at the temporary name even +-- after we drop it. Migration 0006 cleans up the orphan FK left by +-- the first version of this migration on already-affected DBs. + +PRAGMA foreign_keys = OFF; + +CREATE TABLE jobs_new ( + id TEXT PRIMARY KEY, + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('backup','init','forget','prune','check','unlock')), + status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed','cancelled')), + scheduled_id TEXT REFERENCES schedules(id) ON DELETE SET NULL, + actor_kind TEXT NOT NULL CHECK (actor_kind IN ('user','schedule','system')), + actor_id TEXT, + started_at TEXT, + finished_at TEXT, + exit_code INTEGER, + stats TEXT, + error TEXT, + created_at TEXT NOT NULL +); + +INSERT INTO jobs_new + SELECT id, host_id, kind, status, scheduled_id, actor_kind, actor_id, + started_at, finished_at, exit_code, stats, error, created_at + FROM jobs; + +DROP TABLE jobs; + +ALTER TABLE jobs_new RENAME TO jobs; + +CREATE INDEX jobs_host_id ON jobs(host_id); +CREATE INDEX jobs_status ON jobs(status); +CREATE INDEX jobs_created_at ON jobs(created_at); + +PRAGMA foreign_keys = ON; diff --git a/internal/store/migrations/0006_fix_job_logs_fk.sql b/internal/store/migrations/0006_fix_job_logs_fk.sql new file mode 100644 index 0000000..75840a1 --- /dev/null +++ b/internal/store/migrations/0006_fix_job_logs_fk.sql @@ -0,0 +1,33 @@ +-- 0006_fix_job_logs_fk.sql +-- +-- Migration 0005 rebuilt the jobs table via the unsafe pattern of +-- renaming the original to jobs_old before dropping it. SQLite (with +-- legacy_alter_table=OFF, the modern default) propagated that rename +-- into the FK declaration of job_logs.job_id, which is now pointing +-- at jobs_old — a table that no longer exists. INSERTs into job_logs +-- fail with "no such table: main.jobs_old (1)". +-- +-- Rebuild job_logs using the safe pattern: create job_logs_new with +-- a clean FK to jobs, copy rows, drop the broken job_logs, rename +-- job_logs_new to job_logs. Renaming job_logs_new is safe because +-- nothing references it. + +PRAGMA foreign_keys = OFF; + +CREATE TABLE job_logs_new ( + job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + seq INTEGER NOT NULL, + ts TEXT NOT NULL, + stream TEXT NOT NULL CHECK (stream IN ('stdout','stderr','event')), + payload TEXT NOT NULL, + PRIMARY KEY (job_id, seq) +); + +INSERT INTO job_logs_new (job_id, seq, ts, stream, payload) + SELECT job_id, seq, ts, stream, payload FROM job_logs; + +DROP TABLE job_logs; + +ALTER TABLE job_logs_new RENAME TO job_logs; + +PRAGMA foreign_keys = ON; diff --git a/internal/store/types.go b/internal/store/types.go index c7b0970..5b09732 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -62,6 +62,12 @@ type Host struct { // operator hits "Run now" without supplying paths. Phase 1 // interim — schedules (P2-01) supersede this. DefaultPaths []string + // RepoInitialisedAt is non-nil once we've confirmed the host's + // repo has been initialised — either the operator clicked the + // init button, or a backup succeeded, or snapshots.report came + // back non-empty. The host detail run-now panel shows a red + // "Initialise repo" affordance while this is nil. + RepoInitialisedAt *time.Time } // EnrollmentToken is the issuer's view of a one-time token. The diff --git a/spec.md b/spec.md index e71270f..06de2a4 100644 --- a/spec.md +++ b/spec.md @@ -123,14 +123,34 @@ It is built for small-to-medium fleets (initial target: ~12 endpoints) and is in - **Service integration:** systemd unit (Linux). Windows service via `golang.org/x/sys/windows/svc` — Phase 2. - **Footprint goal:** ≤ 15 MB binary, ≤ 50 MB RSS idle +- **Privilege model:** the agent runs as root, sandboxed via systemd. A + fleet-backup tool needs to read every file on the system regardless + of DAC permissions; running as a dedicated unprivileged user means + either silent skips on `/home`, `/root`, `/var/lib/`, + or operators having to add the service user to every group whose + files they want backed up. Both are worse failure modes than the + threat model already implies — the agent holds long-lived repo + credentials, executes arbitrary `restic` commands, and runs + operator-defined hooks; its blast radius is already large. This + matches how every comparable tool ships (UrBackup client, Veeam + Agent, Bareos FD, BackupPC client, borgmatic via systemd). The + mitigation is aggressive systemd sandboxing of the root process: + drop the capability set to `CAP_DAC_READ_SEARCH` (read any file) + + `CAP_DAC_OVERRIDE`/`CAP_FOWNER`/`CAP_CHOWN` (restore ownership); + `NoNewPrivileges=true` blocks escalation; `ProtectSystem=strict` + + a tight `ReadWritePaths=` confines writes to `/etc/restic-manager` + and `/var/lib/restic-manager`; `ProtectHome=read-only` keeps `/home` + readable but immutable; standard `Protect*` / `Restrict*` toggles + cover the rest. Hooks (P2) run as root by default with a per-hook + override knob. - **Persistence:** `agent.yaml` (server URL, host ID, bearer, secrets key) + an AEAD-encrypted secrets blob (`secrets.enc`) holding the - restic repo URL + password. Both files are mode 0600 owned by the - agent service user. Phase 1 ships the encrypted-file form on - Linux; Phase 2 swaps that for OS-keyring storage (DPAPI on Windows, - Secret Service / `pass` on Linux where a session bus is - available — see §7.3). A small state DB (BoltDB or JSON) for - queued reports lands when offline-resilience work does. + restic repo URL + password. Both files are mode 0600 owned by root. + Phase 1 ships the encrypted-file form on Linux; Phase 2 swaps that + for OS-keyring storage (DPAPI on Windows, Secret Service / `pass` + on Linux where a session bus is available — see §7.3). A small + state DB (BoltDB or JSON) for queued reports lands when offline- + resilience work does. - **Restic invocation:** spawns `restic` with `--json`, parses streamed output, forwards to server in real time - **Updates:** distributed via OS package manager — apt repo (Linux) and Chocolatey package (Windows), both pointing at gitea releases. No @@ -517,7 +537,7 @@ Restore a snapshot taken on host A onto host B (e.g. recover a dead box onto a f - **Credential model:** target host's agent receives a temporary, server-issued read credential for the source host's repo, scoped to a single restore job and revoked immediately after - **Path remapping:** UI allows rewriting source paths to target paths (e.g. `/home/alice` → `/home/alice-new`) -- **Permissions:** restore runs as the agent's service user; UI surfaces a warning when source paths require root and target service user is non-root +- **Permissions:** restore runs as root (the agent's process; see §4.2). The agent retains `CAP_DAC_OVERRIDE`/`CAP_FOWNER`/`CAP_CHOWN` precisely so it can recreate ownership on the target. The "service user is non-root" warning that appeared in earlier drafts is moot. - **Phase:** 3 (with the restore wizard) ### 14.2 Bandwidth limiting @@ -535,7 +555,7 @@ Per-host shell commands run before and after a backup job. Use cases: `mysqldump - **Schema:** `Schedule.pre_hook` and `Schedule.post_hook` (string, optional). For more complex cases, `Host.pre_hook_default` / `Host.post_hook_default` apply to all schedules on that host unless overridden - **Applicability:** hooks are only meaningful for `kind = backup` schedules. The API rejects non-null `pre_hook` / `post_hook` on any other schedule kind (`forget`, `prune`, `check`) with a clear validation error rather than silently ignoring them. The same constraint applies to `Host.pre_hook_default` / `Host.post_hook_default`: they only fire for backup schedules on that host -- **Execution:** agent runs hooks via the host's default shell (`/bin/sh` Linux, `cmd.exe` or PowerShell Windows — host-configurable) +- **Execution:** agent runs hooks via the host's default shell (`/bin/sh` Linux, `cmd.exe` or PowerShell Windows — host-configurable). Hooks inherit the agent's process — i.e. **root by default** (see §4.2). A per-hook `run_as` field lets the operator drop privileges for a specific hook (`run_as: postgres` for a `pg_dump` hook, etc.); the agent uses `setuid`/`setgid` before exec rather than shelling out to `sudo`. Hooks running as root is what makes `docker stop`, `mysqldump`, `systemctl reload` etc. work without per-host setup, which is what the user expects when typing them into the UI. - **Failure semantics:** `pre_hook` non-zero exit aborts the backup and marks the job failed. `post_hook` runs on both success and failure (with `RM_JOB_STATUS` env var); its own exit code is recorded but does not change the backup job's final status - **Stdout/stderr:** captured into `JobLog` like restic output, prefixed `pre_hook:` / `post_hook:` - **Security:** hooks are stored encrypted; only admins can edit them; every edit audit-logged diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 51bac67..8de349e 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.absolute{position:absolute}.relative{position:relative}.left-0{left:0}.top-0{top:0}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.flex-1{flex:1 1 0%}.cursor-not-allowed{cursor:not-allowed}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis;white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5} \ No newline at end of file +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-3{grid-column:span 3/span 3}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.table{display:table}.grid{display:grid}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.flex-1{flex:1 1 0%}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-not-allowed{cursor:not-allowed}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis;white-space:nowrap}.text-pretty{text-wrap:pretty}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.p-0{padding:0}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-9{padding-left:2.25rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[18px\]{font-size:18px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)} \ No newline at end of file diff --git a/web/styles/input.css b/web/styles/input.css index cbf4ef3..3c08daf 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -174,6 +174,17 @@ .host-row.failed { border-left-color: color-mix(in oklch, var(--bad), transparent 50%); } .host-row.offline { border-left-color: color-mix(in oklch, var(--off), transparent 70%); } .host-row:hover { background: var(--panel-hi); } + /* Whole-row click → host detail. The action cell sits above via + z-index so its button keeps working. */ + .host-row.clickable { position: relative; } + .host-row.clickable .row-link { + position: absolute; inset: 0; z-index: 0; + text-indent: -9999px; overflow: hidden; + } + .host-row.clickable:hover { cursor: pointer; } + .host-row.clickable > * { position: relative; z-index: 1; pointer-events: none; } + .host-row.clickable > .row-link { pointer-events: auto; } + .host-row.clickable > .row-action { pointer-events: auto; } /* ---------- log viewer ---------- */ .log { diff --git a/web/templates/layouts/base.html b/web/templates/layouts/base.html index 03c91cd..6e3533d 100644 --- a/web/templates/layouts/base.html +++ b/web/templates/layouts/base.html @@ -18,6 +18,8 @@ {{block "content" .}}{{end}} + {{template "toast" .}} + {{end}} diff --git a/web/templates/pages/add_host.html b/web/templates/pages/add_host.html index c6ba5ba..e624c91 100644 --- a/web/templates/pages/add_host.html +++ b/web/templates/pages/add_host.html @@ -68,9 +68,9 @@
For rest-server with htpasswd, this is the per-host user.
- - -
Encrypted at rest using the server’s AEAD key. Pushed to the agent only over the authenticated WebSocket.
+ + +
Encrypted at rest using the server’s AEAD key, pushed to the agent only over the authenticated WebSocket. Leave blank and we’ll mint a 24-byte URL-safe random password and surface it once on the next page (alongside the htpasswd snippet you’ll need to run on the rest-server).
@@ -135,18 +135,42 @@ dashboard within a few seconds of the agent connecting.

-
-
- Install command · paste-and-run + {{if and $page.RepoUsername $page.RepoPassword}} +
+
+ + Run on the rest-server box first + {{if $page.PasswordGenerated}} + password generated + {{end}} + · this is the only time you’ll see the password +
-
curl -fsSL {{$page.ServerURL}}/install.sh | sudo \
+      
echo '{{$page.RepoPassword}}' | sudo htpasswd -B -i /path/to/htpasswd {{$page.RepoUsername}}
+
+ Replace /path/to/htpasswd with whatever your restic/rest-server reads (typically the file passed via --htpasswd-file, or /data/.htpasswd in the official Docker image). The -i flag reads the password from stdin so it never appears in your shell’s process list. Then either send SIGHUP to the rest-server process or restart the container to pick up the new entry. +
+
+ {{end}} + +
+
+ Install command · paste-and-run on the host you’re backing up +
+ +
+
+
curl -fsSL {{$page.ServerURL}}/install/install.sh | sudo \
   RM_SERVER={{$page.ServerURL}} \
-  RM_TOKEN={{$page.Token}} sh
+ RM_TOKEN={{$page.Token}} bash
diff --git a/web/templates/pages/host_detail.html b/web/templates/pages/host_detail.html index 4a3f619..9bccaeb 100644 --- a/web/templates/pages/host_detail.html +++ b/web/templates/pages/host_detail.html @@ -38,13 +38,19 @@
- {{if ne $host.Status "offline"}} + {{if eq $host.Status "offline"}} + + {{else if not $host.RepoInitialisedAt}} + + {{else}} - {{else}} - {{end}} @@ -113,10 +119,17 @@

{{if ne $host.Status "offline"}}
- + {{if not $host.RepoInitialisedAt}} + + {{else}} + + {{end}}
{{end}}
@@ -162,10 +175,18 @@
Run-now
- + {{end}} + + hx-disabled-elt="this" + {{if not $host.RepoInitialisedAt}}title="initialise the repo first"{{end}}>backup diff --git a/web/templates/partials/host_row.html b/web/templates/partials/host_row.html index 69eef23..8007f43 100644 --- a/web/templates/partials/host_row.html +++ b/web/templates/partials/host_row.html @@ -1,5 +1,6 @@ {{define "host_row"}} -
+
+ {{.Name}}
{{- if eq .Status "online" -}} @@ -45,21 +46,22 @@ {{.}} {{- end -}}
-
+
{{- if eq .Status "offline" -}} offline {{- else if .CurrentJobID -}} - View → + View job → + {{- else if not .RepoInitialisedAt -}} + {{- else if eq (deref .LastBackupStatus) "failed" -}} - {{- else if eq .SnapshotCount 0 -}} - {{- else -}}
+ + + + +{{end}}