From f0dfa689fe459cf870e8a30be73b7dfa7abb0a7e Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 17:27:52 +0100 Subject: [PATCH] P3 follow-up: editable target dir, conditional --no-ownership, UK lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small follow-ups from review: 1. Restore target is now operator-editable. Default value is the literal '\$HOME/rm-restore//' (agent expands \$HOME at run time using os.UserHomeDir(); also handles \${HOME} and ~/ prefixes). Operator can replace with any absolute path. - ui_restore.go validates the input is either absolute or starts with one of the recognised prefixes; other env-var refs (\$PATH etc.) are deliberately rejected so operator paths can't pick up arbitrary agent env values. - host_restore.html replaces the read-only mono-text display with a real ; help text spells out that \$HOME resolves agent-side and is substituted on dispatch. - install.sh + the systemd unit prep /root/rm-restore so the default works under the sandbox: ReadWritePaths gains a soft '-/root/rm-restore' entry (the '-' makes the bind-mount soft-fail if missing, but install.sh pre-creates it root-owned 0700). 2. --no-ownership flag now gated on restic version. The flag was added in restic 0.17 and 0.16 rejects it. Previously dropped it wholesale — that meant new-dir restores silently preserved ownership against design intent on 0.17+. Now the agent threads its detected restic version (sysinfo already collects it) through runner.Config -> restic.Env, and RunRestore appends --no-ownership only when AtLeastVersion(0, 17) returns true. 0.16 hosts still restore with original uid/gid; help text in the wizard explicitly notes this. The previous 'Original ownership is preserved' copy was wrong for new-dir mode and is corrected. 3. golangci-lint misspell locale switched US -> UK and the codebase swept (73 corrections, mostly behaviour/serialise/recognise/honour). Wire-format ErrorCode 'unauthorized' -> 'unauthorised' is a tiny contract change but the agent doesn't parse those codes today and no external API consumers exist yet. Tests passed before + after. Tests: - internal/restic/version_test.go covers Env.AtLeastVersion across edge cases (empty, exact match, patch above, minor below, non- numeric) and expandHome on \$HOME / \${HOME} / ~/, plus pass-through for absolute paths and refusal of other env vars. - ui_restore_test updated: TargetDir now starts '\$HOME/rm-restore/' with the job_id substituted into the placeholder. Live verified on the smoke env: default target restored to /root/rm-restore// as the agent's expanded \$HOME (2 files, 14 bytes); custom override '/tmp/custom-restore//' restored into the agent's PrivateTmp namespace (1 file, 6 bytes); both jobs 'succeeded', exit 0. --- .golangci.yml | 2 +- cmd/agent/main.go | 6 +- deploy/install/install.sh | 7 +++ deploy/install/restic-manager-agent.service | 7 ++- internal/agent/runner/runner.go | 10 +-- internal/agent/runner/runner_test.go | 2 +- internal/agent/scheduler/scheduler.go | 2 +- internal/agent/secrets/secrets.go | 2 +- internal/agent/sysinfo/sysinfo.go | 2 +- internal/agent/wsclient/client.go | 2 +- internal/api/messages.go | 2 +- internal/api/wire.go | 2 +- internal/auth/passwords.go | 2 +- internal/crypto/aead.go | 2 +- internal/restic/ls.go | 2 +- internal/restic/restore.go | 61 ++++++++++++++++--- internal/restic/runner.go | 56 ++++++++++++++--- internal/restic/version_test.go | 64 ++++++++++++++++++++ internal/server/http/agent_assets.go | 2 +- internal/server/http/announce.go | 4 +- internal/server/http/auth.go | 2 +- internal/server/http/cancel.go | 2 +- internal/server/http/diff.go | 2 +- internal/server/http/enrollment.go | 2 +- internal/server/http/host_bandwidth.go | 2 +- internal/server/http/host_bandwidth_push.go | 2 +- internal/server/http/host_credentials.go | 12 ++-- internal/server/http/hosts.go | 4 +- internal/server/http/job_download.go | 4 +- internal/server/http/jobs.go | 2 +- internal/server/http/p2r01_ws_test.go | 2 +- internal/server/http/pending_drain_test.go | 4 +- internal/server/http/pending_ws.go | 4 +- internal/server/http/repo_maintenance.go | 4 +- internal/server/http/repo_ops.go | 6 +- internal/server/http/run_group.go | 2 +- internal/server/http/schedules.go | 8 +-- internal/server/http/server.go | 4 +- internal/server/http/snapshots.go | 2 +- internal/server/http/source_groups.go | 10 +-- internal/server/http/ui_handlers.go | 6 +- internal/server/http/ui_repo_reinit.go | 4 +- internal/server/http/ui_restore.go | 67 ++++++++++++++------- internal/server/http/ui_restore_test.go | 8 ++- internal/server/ws/handler.go | 4 +- internal/server/ws/hub.go | 4 +- internal/store/host_repo_stats.go | 2 +- web/static/css/styles.css | 2 +- web/templates/pages/host_restore.html | 19 ++++-- 49 files changed, 315 insertions(+), 120 deletions(-) create mode 100644 internal/restic/version_test.go diff --git a/.golangci.yml b/.golangci.yml index 787123f..791caac 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,7 +26,7 @@ linters: - name: exported arguments: ["disableStutteringCheck"] misspell: - locale: US + locale: UK exclusions: rules: - path: _test\.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 34f8946..123cb50 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -136,6 +136,7 @@ func run() error { d := &dispatcher{ resticBin: resticBin, + resticVer: snap.ResticVersion, secrets: sec, scheduler: scheduler.New(), } @@ -200,6 +201,7 @@ func openSecretsStore(cfg *config.Config) (*secrets.Store, error) { // so a job dispatched in the same session sees the latest values. type dispatcher struct { resticBin string + resticVer string // e.g. "0.17.1"; empty if restic isn't installed yet secrets *secrets.Store scheduler *scheduler.Scheduler @@ -276,7 +278,7 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S case api.MsgTreeList: // Synchronous RPC for the restore wizard's tree browser. The - // server has serialized access; we just run restic ls and reply + // server has serialised access; we just run restic ls and reply // with the same envelope ID. Run in a goroutine so the WS read // loop keeps draining. var p api.TreeListRequestPayload @@ -493,6 +495,7 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc r := runner.New(runner.Config{ ResticBin: d.resticBin, + ResticVersion: d.resticVer, RepoURL: creds.URL, RepoUsername: creds.Username, RepoPassword: creds.Password, @@ -588,6 +591,7 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc } prr := runner.New(runner.Config{ ResticBin: d.resticBin, + ResticVersion: d.resticVer, RepoURL: runCreds.URL, RepoUsername: runCreds.Username, RepoPassword: runCreds.Password, diff --git a/deploy/install/install.sh b/deploy/install/install.sh index f35319b..a0ff7ac 100755 --- a/deploy/install/install.sh +++ b/deploy/install/install.sh @@ -49,6 +49,13 @@ detect_arch() { ensure_dirs() { install -d -m 0700 -o root -g root "$RM_CONFIG_DIR" install -d -m 0700 -o root -g root "$RM_STATE_DIR" + # Default new-directory restore target: $HOME/rm-restore. Pre-create + # so the systemd unit's ReadWritePaths bind-mount applies cleanly + # (paths that don't exist when systemd starts get a soft-fail + # because of the '-' prefix, but the agent then can't mkdir into + # the read-only /root). Mode 0700 + root-owned matches the threat + # model — files restored here are operator-readable as root. + install -d -m 0700 -o root -g root /root/rm-restore } detect_existing_schedulers() { diff --git a/deploy/install/restic-manager-agent.service b/deploy/install/restic-manager-agent.service index 01931e1..5faf370 100644 --- a/deploy/install/restic-manager-agent.service +++ b/deploy/install/restic-manager-agent.service @@ -37,7 +37,12 @@ AmbientCapabilities=CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_CHOWN # needs. Filesystem reads stay open: that's the whole job. NoNewPrivileges=true ProtectSystem=strict -ReadWritePaths=/etc/restic-manager /var/lib/restic-manager +# /etc/restic-manager: agent.yaml + secrets.enc. +# /var/lib/restic-manager: agent state (currently unused but reserved). +# /root/rm-restore: default target for new-directory restores +# ($HOME/rm-restore// resolves here for User=root). +# ReadWritePaths overrides ProtectHome=read-only on this subdir only. +ReadWritePaths=/etc/restic-manager /var/lib/restic-manager -/root/rm-restore ProtectHome=read-only ProtectHostname=true ProtectKernelTunables=true diff --git a/internal/agent/runner/runner.go b/internal/agent/runner/runner.go index 05c078a..91264aa 100644 --- a/internal/agent/runner/runner.go +++ b/internal/agent/runner/runner.go @@ -26,10 +26,11 @@ type Sender interface { // from the agent's config file (server-pushed config.update payloads // override these in memory). type Config struct { - ResticBin string - RepoURL string - RepoUsername string - RepoPassword string + ResticBin string + ResticVersion string // e.g. "0.17.1" — empty if unknown + RepoURL string + RepoUsername string + RepoPassword string // Bandwidth caps in KB/s applied to every restic invocation. // <=0 means "no cap". Per-job override: callers that build a @@ -61,6 +62,7 @@ func New(cfg Config, tx Sender, progressMinPeriod time.Duration) *Runner { func (r *Runner) resticEnv() restic.Env { return restic.Env{ Bin: r.cfg.ResticBin, + Version: r.cfg.ResticVersion, RepoURL: r.cfg.RepoURL, RepoUsername: r.cfg.RepoUsername, RepoPassword: r.cfg.RepoPassword, diff --git a/internal/agent/runner/runner_test.go b/internal/agent/runner/runner_test.go index c9fb042..77ec8e8 100644 --- a/internal/agent/runner/runner_test.go +++ b/internal/agent/runner/runner_test.go @@ -320,7 +320,7 @@ esac // still produces job.started and job.finished envelopes. func TestRunInitShipsStartedAndFinished(t *testing.T) { t.Parallel() - bin := setupScript(t, `echo "initialized repository"`) + bin := setupScript(t, `echo "initialised repository"`) tx := &fakeSender{} r := New(Config{ResticBin: bin}, tx, 0) if err := r.RunInit(context.Background(), "job-init"); err != nil { diff --git a/internal/agent/scheduler/scheduler.go b/internal/agent/scheduler/scheduler.go index e9576ba..b3f236d 100644 --- a/internal/agent/scheduler/scheduler.go +++ b/internal/agent/scheduler/scheduler.go @@ -110,7 +110,7 @@ func (s *Scheduler) Apply(payload api.ScheduleSetPayload, tx Sender) { "received", len(payload.Schedules), "active", added) // Ack outside the lock — Send() shouldn't take long, but holding - // s.mu across an external call would needlessly serialize other + // s.mu across an external call would needlessly serialise other // callers (e.g. a future Status() inspection from the UI). ackEnv, err := api.Marshal(api.MsgScheduleAck, "", api.ScheduleAckPayload{ Version: payload.Version, diff --git a/internal/agent/secrets/secrets.go b/internal/agent/secrets/secrets.go index ff285e9..e6ac2a0 100644 --- a/internal/agent/secrets/secrets.go +++ b/internal/agent/secrets/secrets.go @@ -21,7 +21,7 @@ import ( // additionalData binds ciphertexts to the agent-secrets context, so a // blob lifted from one role's file can't be replayed into another's -// row in some unrelated table that uses the same key. (Defense in +// row in some unrelated table that uses the same key. (Defence in // depth — the key is per-host today, but cheap to be careful.) const additionalData = "rm-agent-repo-creds-v1" diff --git a/internal/agent/sysinfo/sysinfo.go b/internal/agent/sysinfo/sysinfo.go index e0c369e..0380952 100644 --- a/internal/agent/sysinfo/sysinfo.go +++ b/internal/agent/sysinfo/sysinfo.go @@ -76,5 +76,5 @@ func detectResticVersion(ctx context.Context, override string) (string, error) { if len(parts) >= 2 && parts[0] == "restic" { return parts[1], nil } - return "", fmt.Errorf("sysinfo: unrecognized restic version output: %q", first) + return "", fmt.Errorf("sysinfo: unrecognised restic version output: %q", first) } diff --git a/internal/agent/wsclient/client.go b/internal/agent/wsclient/client.go index 4e5d0b0..77f3ee1 100644 --- a/internal/agent/wsclient/client.go +++ b/internal/agent/wsclient/client.go @@ -40,7 +40,7 @@ type Config struct { // Sender is what handlers use to push agent → server messages // (job.progress, job.finished, log.stream, command.result, …). // Returned by the WS client to the dispatch handler. Write operations -// serialize behind a single mutex on the conn; concurrent calls are +// serialise behind a single mutex on the conn; concurrent calls are // safe. type Sender interface { Send(env api.Envelope) error diff --git a/internal/api/messages.go b/internal/api/messages.go index dbfb4e7..8ea18f2 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -394,7 +394,7 @@ type TreeListEntry struct { } // TreeListResultPayload is the reply to a tree.list. Error is set -// when the agent couldn't fulfill the request (missing snapshot, +// when the agent couldn't fulfil the request (missing snapshot, // path doesn't exist, restic invocation failed); Entries is empty in // that case. A successful empty directory has Error="" + nil Entries. type TreeListResultPayload struct { diff --git a/internal/api/wire.go b/internal/api/wire.go index a52a58b..005827f 100644 --- a/internal/api/wire.go +++ b/internal/api/wire.go @@ -78,7 +78,7 @@ type ErrorCode string const ( ErrProtocolTooOld ErrorCode = "protocol_too_old" ErrProtocolTooNew ErrorCode = "protocol_too_new" - ErrUnauthorized ErrorCode = "unauthorized" + ErrUnauthorized ErrorCode = "unauthorised" ErrBadRequest ErrorCode = "bad_request" ErrInternal ErrorCode = "internal" ) diff --git a/internal/auth/passwords.go b/internal/auth/passwords.go index d245ace..6e35321 100644 --- a/internal/auth/passwords.go +++ b/internal/auth/passwords.go @@ -56,7 +56,7 @@ func VerifyPassword(encoded, password string) error { parts := strings.Split(encoded, "$") // "$argon2id$v=...$m=...,t=...,p=...$$" → 6 parts (leading empty) if len(parts) != 6 || parts[1] != "argon2id" { - return errors.New("auth: unrecognized hash format") + return errors.New("auth: unrecognised hash format") } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { diff --git a/internal/crypto/aead.go b/internal/crypto/aead.go index 7a68264..388e97b 100644 --- a/internal/crypto/aead.go +++ b/internal/crypto/aead.go @@ -2,7 +2,7 @@ // passwords, REST-server credentials, hook bodies, and any other // secret that lands in the SQLite store. // -// The threat model is "defense in depth against a stolen DB file" — +// The threat model is "defence in depth against a stolen DB file" — // not "an attacker with code execution can't read secrets at runtime." // We need the encryption key at runtime to do any actual work, so // anyone with a memory dump of the running server can extract it. diff --git a/internal/restic/ls.go b/internal/restic/ls.go index 5625238..0371c4d 100644 --- a/internal/restic/ls.go +++ b/internal/restic/ls.go @@ -32,7 +32,7 @@ type LsEntry struct { // // The first emitted line is restic's "snapshot" preamble (struct_type // = "snapshot") which we discard. Subsequent lines are nodes; we -// match on path equal to dirPath + "/" + name (with normalization so +// match on path equal to dirPath + "/" + name (with normalisation so // trailing slashes don't break the comparison). // // dirPath="" or "/" lists the snapshot root. diff --git a/internal/restic/restore.go b/internal/restic/restore.go index a8d9cdb..548226d 100644 --- a/internal/restic/restore.go +++ b/internal/restic/restore.go @@ -7,7 +7,9 @@ import ( "errors" "fmt" "io" + "os" "os/exec" + "path/filepath" "strings" ) @@ -63,17 +65,26 @@ func (e Env) RunRestore(ctx context.Context, snapshotID string, paths []string, target := targetDir if inPlace { target = "/" + } else { + // Expand $HOME / ${HOME} / leading ~/ in the operator-supplied + // path, using the agent's own HOME (which under the systemd + // unit is the agent user's home — typically /root for the + // User=root unit). The expansion runs agent-side so the + // operator can specify a portable default like + // $HOME/rm-restore// in the wizard without the server + // needing to know which user the agent runs as. + target = expandHome(target) } args = append(args, "--target", target) - // NOTE: restic added --no-ownership in 0.17. Older versions reject - // the flag with "unknown flag: --no-ownership" before doing any - // work. Since the agent runs as root in the systemd unit, files - // land under /var/restic-restore with their original uid/gid - // either way — the original "cp without sudo" rationale doesn't - // hold (operators copying from /var/restic-restore need sudo - // regardless because the parent dir is root-owned). Drop the flag - // entirely until we drop 0.16 support; revisit if a non-root - // agent deployment requirement comes back. + // --no-ownership was added in restic 0.17. Older versions reject + // the flag with "unknown flag: --no-ownership". For new-dir + // restores we want the files owned by the agent user (operator + // can cp them without juggling chown), so pass the flag iff the + // running restic supports it. In-place restores always preserve + // ownership — that's the whole point of in-place. + if !inPlace && e.AtLeastVersion(0, 17) { + args = append(args, "--no-ownership") + } for _, p := range paths { args = append(args, "--include", p) } @@ -119,7 +130,7 @@ func (e Env) RunRestore(ctx context.Context, snapshotID string, paths []string, // stdout — but unlike backup we include the raw status JSON in // log.stream too because restore is short and the live log audience // genuinely benefits from the per-file traffic. Actually — we mirror -// backup's behavior and DROP raw status lines from log.stream +// backup's behaviour and DROP raw status lines from log.stream // (they'd drown the log on a fast restore); the progress envelope // covers them. func pumpRestoreStdout(r io.Reader, handle LineHandler, summary **RestoreSummary) error { @@ -168,6 +179,36 @@ func pumpRestoreStdout(r io.Reader, handle LineHandler, summary **RestoreSummary return scanner.Err() } +// expandHome rewrites $HOME, ${HOME}, or a leading ~/ in p to the +// agent process's home directory. Other env-var references are left +// untouched on purpose (operator-supplied paths shouldn't be able to +// pick up arbitrary agent env values like $PATH or $RESTIC_PASSWORD). +// Returns p unchanged if HOME can't be resolved. +func expandHome(p string) string { + if p == "" { + return p + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return p + } + switch { + case strings.HasPrefix(p, "$HOME/"): + return filepath.Join(home, p[len("$HOME/"):]) + case p == "$HOME": + return home + case strings.HasPrefix(p, "${HOME}/"): + return filepath.Join(home, p[len("${HOME}/"):]) + case p == "${HOME}": + return home + case strings.HasPrefix(p, "~/"): + return filepath.Join(home, p[2:]) + case p == "~": + return home + } + return p +} + // RunDiff executes `restic diff --json ` and forwards every // line to handle as stdout. Restic emits per-line "change" objects // plus a final "statistics" object; we don't parse them server-side — diff --git a/internal/restic/runner.go b/internal/restic/runner.go index 0ba9bb9..2c01233 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -15,7 +15,7 @@ import ( "time" ) -// Locate resolves the path to the restic binary. Honor an explicit +// Locate resolves the path to the restic binary. Honour an explicit // override if provided, else fall back to PATH. func Locate(override string) (string, error) { if override != "" { @@ -42,6 +42,7 @@ func Locate(override string) (string, error) { // in this package ever needs to *log* a URL, use RedactURL. type Env struct { Bin string // path to restic binary + Version string // e.g. "0.17.1"; empty if unknown 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 @@ -55,6 +56,45 @@ type Env struct { LimitDownloadKBps int } +// AtLeastVersion reports whether e.Version >= the given major/minor. +// Comparison is best-effort: empty / unparseable versions return false +// (callers stay on the conservative path). Patch level is ignored. +func (e Env) AtLeastVersion(major, minor int) bool { + v := strings.TrimSpace(e.Version) + if v == "" { + return false + } + parts := strings.SplitN(v, ".", 3) + if len(parts) < 2 { + return false + } + maj, err1 := atoi(parts[0]) + min, err2 := atoi(parts[1]) + if err1 != nil || err2 != nil { + return false + } + if maj != major { + return maj > major + } + return min >= minor +} + +// atoi is strconv.Atoi without dragging the import into a file that +// only needs it for one helper. +func atoi(s string) (int, error) { + n := 0 + if len(s) == 0 { + return 0, fmt.Errorf("empty") + } + for _, r := range s { + if r < '0' || r > '9' { + return 0, fmt.Errorf("not a digit: %q", r) + } + n = n*10 + int(r-'0') + } + return n, nil +} + // globalArgs returns restic's pre-subcommand global flags derived // from the Env. Currently just bandwidth caps. func (e Env) globalArgs() []string { @@ -69,8 +109,8 @@ func (e Env) globalArgs() []string { } // resticCmd builds an exec.Cmd with bandwidth-limit globals prefixed -// before the supplied subcommand args. Centralizing this so every -// command (backup/forget/prune/check/unlock/init/stats) honors +// before the supplied subcommand args. Centralising this so every +// command (backup/forget/prune/check/unlock/init/stats) honours // the caps without each call site having to remember. // // Cancellation: by default exec.CommandContext sends SIGKILL when @@ -142,7 +182,7 @@ type BackupSummary struct { } // LineHandler receives every stdout/stderr line. event is non-nil -// when the line is a recognized JSON status; raw always carries the +// when the line is a recognised JSON status; raw always carries the // original text (so we can also tee to job_logs as `stdout`). type LineHandler func(stream string, raw string, event any) @@ -282,7 +322,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { // Sniff for "config file already exists" on stderr; if we see it // we'll treat the non-zero exit as a soft success — running init - // against an already-initialized repo is a no-op semantically, + // against an already-initialised repo is a no-op semantically, // not a failure. Wraps the caller's handle so the line still // gets streamed verbatim to the operator-facing log. alreadyInited := false @@ -298,7 +338,7 @@ func (e Env) RunInit(ctx context.Context, handle LineHandler) error { if err := runWithPump(cmd, sniff); err != nil { if alreadyInited { if handle != nil { - handle("event", "repo already initialized — treating as success", nil) + handle("event", "repo already initialised — treating as success", nil) } return nil } @@ -394,7 +434,7 @@ func (e Env) RunStats(ctx context.Context, handle LineHandler) (*RepoStats, erro return out, nil } -// CheckResult summarizes a `restic check` invocation. LockPresent is +// CheckResult summarises a `restic check` invocation. LockPresent is // true if the stderr stream contained a stale-lock signal (caller is // expected to surface this in the UI so the operator can run unlock). // ErrorsFound is true if check exited with a non-zero status (errors @@ -406,7 +446,7 @@ type CheckResult struct { // RunCheck executes `restic check` with optional --read-data-subset. // subsetPct of 0 omits the flag (full data check); >0 passes -// --read-data-subset N%. Returns a CheckResult summarizing what was +// --read-data-subset N%. Returns a CheckResult summarising what was // sniffed from stderr; the result is set even if check itself // returns an error (so the caller can persist last_check_status). func (e Env) RunCheck(ctx context.Context, subsetPct int, handle LineHandler) (CheckResult, error) { diff --git a/internal/restic/version_test.go b/internal/restic/version_test.go new file mode 100644 index 0000000..d4a934f --- /dev/null +++ b/internal/restic/version_test.go @@ -0,0 +1,64 @@ +package restic + +import ( + "path/filepath" + "testing" +) + +func TestEnvAtLeastVersion(t *testing.T) { + t.Parallel() + cases := []struct { + ver string + major int + minor int + want bool + shortDesc string + }{ + {"0.17.0", 0, 17, true, "exact match"}, + {"0.17.1", 0, 17, true, "patch above"}, + {"0.18.0", 0, 17, true, "minor above"}, + {"1.0.0", 0, 17, true, "major above"}, + {"0.16.4", 0, 17, false, "minor below"}, + {"0.16", 0, 17, false, "two-part minor below"}, + {"", 0, 17, false, "empty"}, + {"v0.17", 0, 17, false, "prefixed v rejected"}, + {"unknown", 0, 17, false, "non-numeric rejected"}, + } + for _, c := range cases { + got := Env{Version: c.ver}.AtLeastVersion(c.major, c.minor) + if got != c.want { + t.Errorf("AtLeastVersion(%q, %d, %d): got %v want %v · %s", + c.ver, c.major, c.minor, got, c.want, c.shortDesc) + } + } +} + +func TestExpandHome(t *testing.T) { + // Not parallel — t.Setenv on HOME would race with sibling tests. + tmp := t.TempDir() + t.Setenv("HOME", tmp) + + cases := []struct { + in, want string + }{ + {"$HOME/rm-restore/job-1/", filepath.Join(tmp, "rm-restore/job-1")}, + {"${HOME}/rm-restore/job-2/", filepath.Join(tmp, "rm-restore/job-2")}, + {"~/rm-restore/job-3/", filepath.Join(tmp, "rm-restore/job-3")}, + {"$HOME", tmp}, + {"~", tmp}, + {"/var/lib/x/y", "/var/lib/x/y"}, // absolute path passes through + {"", ""}, + {"$PATH/foo", "$PATH/foo"}, // other env vars not expanded + } + for _, c := range cases { + got := expandHome(c.in) + if got != c.want { + t.Errorf("expandHome(%q): got %q want %q", c.in, got, c.want) + } + } + + // Sanity: an absolute path always passes through regardless of HOME. + if got := expandHome("/abs"); got != "/abs" { + t.Errorf("expandHome(/abs): got %q", got) + } +} diff --git a/internal/server/http/agent_assets.go b/internal/server/http/agent_assets.go index 4808504..2efbd8e 100644 --- a/internal/server/http/agent_assets.go +++ b/internal/server/http/agent_assets.go @@ -57,7 +57,7 @@ func (s *Server) handleAgentBinary(w stdhttp.ResponseWriter, r *stdhttp.Request) } func (s *Server) handleInstallAsset(w stdhttp.ResponseWriter, r *stdhttp.Request) { - // chi's TrimPrefix-like behavior: r.URL.Path is "/install/". + // chi's TrimPrefix-like behaviour: r.URL.Path is "/install/". rel := strings.TrimPrefix(r.URL.Path, "/install/") // Reject any path traversal — must be a flat filename. if rel == "" || strings.ContainsAny(rel, "/\\") { diff --git a/internal/server/http/announce.go b/internal/server/http/announce.go index 8635cb8..82377fe 100644 --- a/internal/server/http/announce.go +++ b/internal/server/http/announce.go @@ -133,7 +133,7 @@ func (s *Server) handleAnnounce(w stdhttp.ResponseWriter, r *stdhttp.Request) { keyBytes, err := base64.StdEncoding.DecodeString(req.PublicKey) if err != nil { - // Try URL-safe / no-padding flavors before giving up. + // Try URL-safe / no-padding flavours before giving up. if k2, e2 := base64.RawStdEncoding.DecodeString(req.PublicKey); e2 == nil { keyBytes = k2 } else { @@ -195,7 +195,7 @@ func (s *Server) handleAnnounce(w stdhttp.ResponseWriter, r *stdhttp.Request) { // remoteIP returns r.RemoteAddr stripped of any :port suffix, plus // the X-Forwarded-For chain's first hop when behind a trusted proxy // (RM_TRUSTED_PROXY in the deployment doc). Trust-proxy lookup -// matches the framework's existing behavior elsewhere. +// matches the framework's existing behaviour elsewhere. func remoteIP(r *stdhttp.Request) string { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { // Take the first IP in the chain (closest to the original diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index cb25f71..6c0fc2e 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -137,7 +137,7 @@ func (s *Server) handleBootstrap(w stdhttp.ResponseWriter, r *stdhttp.Request) { return } if n > 0 { - writeJSONError(w, stdhttp.StatusConflict, "already_initialized", + writeJSONError(w, stdhttp.StatusConflict, "already_initialised", "a user already exists; bootstrap is disabled") return } diff --git a/internal/server/http/cancel.go b/internal/server/http/cancel.go index ffb26ed..4e24227 100644 --- a/internal/server/http/cancel.go +++ b/internal/server/http/cancel.go @@ -27,7 +27,7 @@ import ( func (s *Server) handleCancelJob(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } jobID := chi.URLParam(r, "id") diff --git a/internal/server/http/diff.go b/internal/server/http/diff.go index f5f143f..863602f 100644 --- a/internal/server/http/diff.go +++ b/internal/server/http/diff.go @@ -30,7 +30,7 @@ type snapshotDiffRequest struct { func (s *Server) handleSnapshotDiff(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/enrollment.go b/internal/server/http/enrollment.go index f1615e0..5a8203f 100644 --- a/internal/server/http/enrollment.go +++ b/internal/server/http/enrollment.go @@ -213,7 +213,7 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request) // session cookie and trust it, validating the cookie via store. func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } diff --git a/internal/server/http/host_bandwidth.go b/internal/server/http/host_bandwidth.go index 8165a09..d7ced96 100644 --- a/internal/server/http/host_bandwidth.go +++ b/internal/server/http/host_bandwidth.go @@ -27,7 +27,7 @@ type hostBandwidthView struct { func (s *Server) handleUpdateHostBandwidth(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/host_bandwidth_push.go b/internal/server/http/host_bandwidth_push.go index 2cfa7fd..f8512f6 100644 --- a/internal/server/http/host_bandwidth_push.go +++ b/internal/server/http/host_bandwidth_push.go @@ -58,7 +58,7 @@ func (s *Server) pushBandwidthToAgent(ctx context.Context, hostID string, up, do // bandwidthPayload builds a ConfigUpdatePayload with only the // bandwidth fields populated. Pointers are passed through verbatim; // callers wanting to clear a cap should pass a non-nil pointer to 0. -// On the on-hello path we materialize zero-valued pointers when the +// On the on-hello path we materialise zero-valued pointers when the // host record has no cap set, so the agent's stored state is always // in sync (rather than retaining whatever value it last received). func bandwidthPayload(up, down *int) api.ConfigUpdatePayload { diff --git a/internal/server/http/host_credentials.go b/internal/server/http/host_credentials.go index c414eba..4d033a4 100644 --- a/internal/server/http/host_credentials.go +++ b/internal/server/http/host_credentials.go @@ -32,7 +32,7 @@ type hostRepoCredsView struct { // creds for UI display. 404 if no credential has ever been set. func (s *Server) handleGetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -88,7 +88,7 @@ type hostRepoCredsRequest struct { func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -165,7 +165,7 @@ func (s *Server) handleSetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.R w.WriteHeader(stdhttp.StatusNoContent) } -// pushRepoCredsToAgent serializes blob into a config.update envelope +// pushRepoCredsToAgent serialises blob into a config.update envelope // and ships it down the agent's WS. Returns an error from the hub // (no-op if not connected — caller is expected to check first when it // matters). @@ -192,7 +192,7 @@ func (s *Server) pushRepoCredsToAgent(ctx context.Context, hostID string, blob r // uses this to pre-fill the edit form. func (s *Server) handleGetAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -234,7 +234,7 @@ func (s *Server) handleGetAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp. func (s *Server) handleSetAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -319,7 +319,7 @@ func (s *Server) handleSetAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp. func (s *Server) handleDeleteAdminCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/hosts.go b/internal/server/http/hosts.go index e1d5ca3..59f913b 100644 --- a/internal/server/http/hosts.go +++ b/internal/server/http/hosts.go @@ -34,7 +34,7 @@ type hostView struct { // see the same projection. func (s *Server) handleListHosts(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hosts, err := s.deps.Store.ListHosts(r.Context()) @@ -55,7 +55,7 @@ func (s *Server) handleListHosts(w stdhttp.ResponseWriter, r *stdhttp.Request) { // handleFleetSummary returns the dashboard tile aggregate. func (s *Server) handleFleetSummary(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } fs, err := s.deps.Store.FleetSummary(r.Context()) diff --git a/internal/server/http/job_download.go b/internal/server/http/job_download.go index d268cec..03ad7a7 100644 --- a/internal/server/http/job_download.go +++ b/internal/server/http/job_download.go @@ -25,7 +25,7 @@ import ( // REST callers. Default is txt. func (s *Server) handleJobLogDownload(w stdhttp.ResponseWriter, r *stdhttp.Request) { if _, ok := s.requireUser(r); !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } jobID := chi.URLParam(r, "id") @@ -79,7 +79,7 @@ func (s *Server) handleJobLogDownload(w stdhttp.ResponseWriter, r *stdhttp.Reque // writeLogsText renders the logs in the same shape the live page shows: // "HH:MM:SS.mmm TAG payload". Adds a small header so the file is -// useful as a standalone artifact (operator pastes it into a ticket). +// useful as a standalone artefact (operator pastes it into a ticket). func writeLogsText(w stdhttp.ResponseWriter, job *store.Job, logs []store.JobLogLine) { bw := bufio.NewWriter(w) defer func() { _ = bw.Flush() }() diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index 23b874d..8740abe 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -31,7 +31,7 @@ type runNowResponse struct { func (s *Server) handleRunNow(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/p2r01_ws_test.go b/internal/server/http/p2r01_ws_test.go index 23bb9a0..1cbe810 100644 --- a/internal/server/http/p2r01_ws_test.go +++ b/internal/server/http/p2r01_ws_test.go @@ -81,7 +81,7 @@ func drainUntil(t *testing.T, c *websocket.Conn, wantType api.MessageType) api.E return api.Envelope{} } -// enrolHostForWS pre-enrolls a host with bound repo creds so the server +// enrolHostForWS pre-enrols a host with bound repo creds so the server // will treat it as ready to receive command.run. func enrolHostForWS(t *testing.T, srv *Server, st *store.Store, name string) (hostID, token string) { t.Helper() diff --git a/internal/server/http/pending_drain_test.go b/internal/server/http/pending_drain_test.go index 0cec822..a1714a9 100644 --- a/internal/server/http/pending_drain_test.go +++ b/internal/server/http/pending_drain_test.go @@ -506,12 +506,12 @@ func TestEnqueueOnDispatchFailure(t *testing.T) { func TestDrainPendingSerializesPerHost(t *testing.T) { t.Parallel() srv, ts, st := rawTestServer(t) - hostID, token := enrolHostForWS(t, srv, st, "serialize-host") + hostID, token := enrolHostForWS(t, srv, st, "serialise-host") gid, sid := seedSchedAndGroup(t, st, hostID, 10) // Connect the agent so DrainPending can dispatch. c := agentDial(t, srv, ts, hostID, token) - sendHello(t, c, "serialize-host") + sendHello(t, c, "serialise-host") // Drain the on-hello goroutine's pass first (no pending rows yet), // then wait for the schedule.set so the connection is fully settled. _ = drainUntil(t, c, api.MsgScheduleSet) diff --git a/internal/server/http/pending_ws.go b/internal/server/http/pending_ws.go index 9373928..edd0abb 100644 --- a/internal/server/http/pending_ws.go +++ b/internal/server/http/pending_ws.go @@ -214,7 +214,7 @@ type acceptForm struct { func (s *Server) handleAcceptPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } pendingID := chi.URLParam(r, "id") @@ -315,7 +315,7 @@ func (s *Server) handleAcceptPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Re func (s *Server) handleRejectPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) { user, ok := s.requireUser(r) if !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } pendingID := chi.URLParam(r, "id") diff --git a/internal/server/http/repo_maintenance.go b/internal/server/http/repo_maintenance.go index 364024b..6122352 100644 --- a/internal/server/http/repo_maintenance.go +++ b/internal/server/http/repo_maintenance.go @@ -41,7 +41,7 @@ func toRepoMaintenanceView(m store.HostRepoMaintenance) repoMaintenanceView { func (s *Server) handleGetRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -84,7 +84,7 @@ type repoMaintenanceWriteRequest struct { func (s *Server) handleUpdateRepoMaintenance(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/repo_ops.go b/internal/server/http/repo_ops.go index 920677d..00c8078 100644 --- a/internal/server/http/repo_ops.go +++ b/internal/server/http/repo_ops.go @@ -26,7 +26,7 @@ func (s *Server) handleRunRepoPrune(w stdhttp.ResponseWriter, r *stdhttp.Request stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) return } - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -72,7 +72,7 @@ func (s *Server) handleRunRepoCheck(w stdhttp.ResponseWriter, r *stdhttp.Request stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) return } - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -125,7 +125,7 @@ func (s *Server) handleRunRepoUnlock(w stdhttp.ResponseWriter, r *stdhttp.Reques stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) return } - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/run_group.go b/internal/server/http/run_group.go index 1ac33db..d47ecf3 100644 --- a/internal/server/http/run_group.go +++ b/internal/server/http/run_group.go @@ -53,7 +53,7 @@ func (s *Server) handleRunSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Reque stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) return } - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/schedules.go b/internal/server/http/schedules.go index 1e33e9d..6e7d3a1 100644 --- a/internal/server/http/schedules.go +++ b/internal/server/http/schedules.go @@ -61,7 +61,7 @@ var cronParser = cron.NewParser( func (s *Server) handleListSchedules(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -89,7 +89,7 @@ func (s *Server) handleListSchedules(w stdhttp.ResponseWriter, r *stdhttp.Reques func (s *Server) handleCreateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -126,7 +126,7 @@ func (s *Server) handleCreateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Reque func (s *Server) handleUpdateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -173,7 +173,7 @@ func (s *Server) handleUpdateSchedule(w stdhttp.ResponseWriter, r *stdhttp.Reque func (s *Server) handleDeleteSchedule(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/server.go b/internal/server/http/server.go index aa1313f..3a20733 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -43,7 +43,7 @@ type Server struct { srv *stdhttp.Server deps Deps - // drainLocks serializes DrainPending per host. The on-hello + // drainLocks serialises DrainPending per host. The on-hello // goroutine and the 30s ticker can otherwise race for the same // host, double-dispatching every pending row. Map of hostID → // sync.Mutex; checked-and-locked atomically via drainLocksMu. @@ -257,7 +257,7 @@ func (s *Server) routes(r chi.Router) { // Durable post-Add-host page (operator can refresh / come // back; password decrypted from the token row each render). // Polled fragment under /awaiting flips to "connected" once - // the agent enrolls. + // the agent enrols. r.Get("/hosts/pending/{token}", s.handleUIPendingHost) r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) // Host detail (Snapshots tab is the default). diff --git a/internal/server/http/snapshots.go b/internal/server/http/snapshots.go index 138ce84..d88e18b 100644 --- a/internal/server/http/snapshots.go +++ b/internal/server/http/snapshots.go @@ -35,7 +35,7 @@ type listSnapshotsResponse struct { // onto whatever the server most recently received. func (s *Server) handleListHostSnapshots(w stdhttp.ResponseWriter, r *stdhttp.Request) { if _, ok := s.requireUser(r); !ok { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } diff --git a/internal/server/http/source_groups.go b/internal/server/http/source_groups.go index 7fcb5a5..88c2688 100644 --- a/internal/server/http/source_groups.go +++ b/internal/server/http/source_groups.go @@ -66,7 +66,7 @@ type sourceGroupWriteRequest struct { func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -90,7 +90,7 @@ func (s *Server) handleListSourceGroups(w stdhttp.ResponseWriter, r *stdhttp.Req func (s *Server) handleGetSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -109,7 +109,7 @@ func (s *Server) handleGetSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Reque func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -152,7 +152,7 @@ func (s *Server) handleCreateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") @@ -207,7 +207,7 @@ func (s *Server) handleUpdateSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Re // the UI can offer "remove from these schedules first." func (s *Server) handleDeleteSourceGroup(w stdhttp.ResponseWriter, r *stdhttp.Request) { if !s.authedUser(r) { - writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") return } hostID := chi.URLParam(r, "id") diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 37e71f9..87895f9 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -276,7 +276,7 @@ type addHostPage struct { } // pendingHostPage is the GET /hosts/pending/{token} view. Lives -// for as long as the token does (1h ttl); once the agent enrolls, +// 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 @@ -377,7 +377,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques // handleUIPendingHost serves the durable Add-host result page — // shown after a successful POST /hosts/new and reachable until the -// agent enrolls (the page redirects to /hosts/{id} once that +// 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. @@ -730,7 +730,7 @@ func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request) // 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, "unauthorized", stdhttp.StatusUnauthorized) + stdhttp.Error(w, "unauthorised", stdhttp.StatusUnauthorized) return } jobID := chi.URLParam(r, "id") diff --git a/internal/server/http/ui_repo_reinit.go b/internal/server/http/ui_repo_reinit.go index c817df0..ab71bdf 100644 --- a/internal/server/http/ui_repo_reinit.go +++ b/internal/server/http/ui_repo_reinit.go @@ -49,7 +49,7 @@ func (s *Server) handleUIRepoReinit(w stdhttp.ResponseWriter, r *stdhttp.Request } if !s.deps.Hub.Connected(host.ID) { s.renderRepoPage(w, r, u, host, - "Host is offline — bring the agent back up before re-initializing.", + "Host is offline — bring the agent back up before re-initialising.", "", "", "") return } @@ -58,7 +58,7 @@ func (s *Server) handleUIRepoReinit(w stdhttp.ResponseWriter, r *stdhttp.Request if _, err := s.deps.Store.GetHostCredentials(r.Context(), host.ID, store.CredKindRepo); err != nil { if errors.Is(err, store.ErrNotFound) { s.renderRepoPage(w, r, u, host, - "Bind repo credentials before re-initializing.", + "Bind repo credentials before re-initialising.", "", "", "") return } diff --git a/internal/server/http/ui_restore.go b/internal/server/http/ui_restore.go index 10dd863..c43fa31 100644 --- a/internal/server/http/ui_restore.go +++ b/internal/server/http/ui_restore.go @@ -5,7 +5,6 @@ import ( "errors" "log/slog" stdhttp "net/http" - "path" "sort" "strings" "time" @@ -197,10 +196,18 @@ func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Reques return } } else { - // New-directory mode: server picks the path so the operator - // can't escape /var/restic-restore. Operator-supplied - // target_dir is intentionally ignored. - targetDir = "" + // New-directory mode: trust the operator's chosen target. + // Empty falls back to the default. Validate it's either + // absolute or starts with $HOME / ~/ (the agent expands + // these at run time). + if targetDir == "" { + targetDir = defaultRestoreTargetDir() + } + if !looksLikeRestoreTarget(targetDir) { + rerender("Target must be an absolute path, or start with $HOME or ~/.", + stdhttp.StatusUnprocessableEntity) + return + } } if !s.deps.Hub.Connected(host.ID) { @@ -210,13 +217,12 @@ func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Reques } // Build a new job id up-front so we can substitute it into the - // new-directory target path. The dispatch helper will use this - // same id (mint=now → reuse via dispatchJobWithPayload's - // signature requires the id, so do it here and pass on). + // new-directory target path. The agent will additionally expand + // $HOME / ~/ before invoking restic. jobID := ulid.Make().String() finalTarget := "" if !inPlace { - finalTarget = path.Join(defaultRestoreTargetRoot(), jobID) + finalTarget = strings.ReplaceAll(targetDir, "", jobID) } now := time.Now().UTC() @@ -383,22 +389,37 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques } } -// defaultRestoreTargetRoot is the parent of the per-job restore -// directory. The agent's systemd unit pins ReadWritePaths to -// /etc/restic-manager + /var/lib/restic-manager (with ProtectSystem= -// strict making the rest of /var read-only); restore writes have to -// land inside one of those, so we keep them under -// /var/lib/restic-manager/restore where the agent is already allowed -// to write. The /restore subdir is created by the agent on demand. -func defaultRestoreTargetRoot() string { - return "/var/lib/restic-manager/restore" +// defaultRestoreTargetDir is the placeholder shown on the step-3 +// New-directory radio card and the value used when the operator +// leaves the field blank. $HOME resolves agent-side (typically /root +// for the systemd-as-root unit); is substituted at dispatch. +// The systemd unit pins ReadWritePaths to include the agent user's +// home/rm-restore subdir so this default actually works under the +// sandbox. +func defaultRestoreTargetDir() string { + return "$HOME/rm-restore//" } -// defaultRestoreTargetDir surfaces the placeholder path shown on the -// step-3 New-directory radio card. The "" is not substituted -// here — that happens at dispatch time. -func defaultRestoreTargetDir() string { - return defaultRestoreTargetRoot() + "//" +// looksLikeRestoreTarget validates the operator-supplied target dir +// is a shape the agent can sensibly resolve. We accept absolute +// paths and a couple of agent-side expansions ($HOME, ~/). Other env +// vars are deliberately rejected — operator-supplied paths shouldn't +// be able to pick up arbitrary agent env values. +func looksLikeRestoreTarget(p string) bool { + if p == "" { + return false + } + switch { + case strings.HasPrefix(p, "/"): + return true + case strings.HasPrefix(p, "$HOME/"), p == "$HOME": + return true + case strings.HasPrefix(p, "${HOME}/"), p == "${HOME}": + return true + case strings.HasPrefix(p, "~/"), p == "~": + return true + } + return false } // sessionIDFromCookie returns the operator's session cookie value, diff --git a/internal/server/http/ui_restore_test.go b/internal/server/http/ui_restore_test.go index d77858e..16a04b8 100644 --- a/internal/server/http/ui_restore_test.go +++ b/internal/server/http/ui_restore_test.go @@ -302,8 +302,12 @@ func TestRestorePostHappyPathDispatches(t *testing.T) { if cp.Restore.InPlace { t.Fatal("expected new-directory mode (in_place=false)") } - if !strings.HasPrefix(cp.Restore.TargetDir, "/var/lib/restic-manager/restore/") { - t.Fatalf("target_dir: got %q, want prefix /var/lib/restic-manager/restore/", cp.Restore.TargetDir) + if !strings.HasPrefix(cp.Restore.TargetDir, "$HOME/rm-restore/") { + t.Fatalf("target_dir: got %q, want prefix $HOME/rm-restore/", cp.Restore.TargetDir) + } + // placeholder substituted with the dispatched job_id. + if !strings.Contains(cp.Restore.TargetDir, "/01") { + t.Errorf("target_dir: expected job_id substituted into the path; got %q", cp.Restore.TargetDir) } if len(cp.Restore.Paths) != 2 { t.Fatalf("paths: got %d, want 2", len(cp.Restore.Paths)) diff --git a/internal/server/ws/handler.go b/internal/server/ws/handler.go index 27bed4f..b488095 100644 --- a/internal/server/ws/handler.go +++ b/internal/server/ws/handler.go @@ -54,7 +54,7 @@ func AgentHandler(deps HandlerDeps) stdhttp.Handler { return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { host, ok := authenticateAgent(r, deps.Store) if !ok { - stdhttp.Error(w, "unauthorized", stdhttp.StatusUnauthorized) + stdhttp.Error(w, "unauthorised", stdhttp.StatusUnauthorized) return } @@ -204,7 +204,7 @@ 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) } - // repo_initialized_at projection has been removed — auto-init + // repo_initialised_at projection has been removed — auto-init // at host enrolment makes "is the repo init'd" derivable from // the latest init job's status, no separate column needed. if deps.JobHub != nil { diff --git a/internal/server/ws/hub.go b/internal/server/ws/hub.go index e69cf9b..9085fc5 100644 --- a/internal/server/ws/hub.go +++ b/internal/server/ws/hub.go @@ -23,7 +23,7 @@ type Hub struct { conns map[string]*Conn // hostID → conn // rpcs tracks in-flight synchronous RPC calls (e.g. tree.list). - // See rpc.go for details. Lazy-initialized via the registry's + // See rpc.go for details. Lazy-initialised via the registry's // own register() so callers don't have to juggle a constructor. rpcs rpcRegistry } @@ -105,7 +105,7 @@ func NewConn(hostID string, c *websocket.Conn) *Conn { } // Send writes an envelope as a JSON text message. Concurrent calls -// are serialized; the underlying socket is not safe for parallel +// are serialised; the underlying socket is not safe for parallel // writers. func (c *Conn) Send(ctx context.Context, env api.Envelope) error { c.writeMu.Lock() diff --git a/internal/store/host_repo_stats.go b/internal/store/host_repo_stats.go index 1952f68..9889b29 100644 --- a/internal/store/host_repo_stats.go +++ b/internal/store/host_repo_stats.go @@ -38,7 +38,7 @@ func (s *Store) GetHostRepoStats(ctx context.Context, hostID string) (*HostRepoS // getHostRepoStatsTx is identical to GetHostRepoStats but runs on an // existing transaction so the fetch-merge-upsert in UpsertHostRepoStats -// is fully serialized. +// is fully serialised. func getHostRepoStatsTx(ctx context.Context, tx *sql.Tx, hostID string) (*HostRepoStats, error) { row := tx.QueryRowContext(ctx, `SELECT host_id, total_size_bytes, raw_size_bytes, unique_files, diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 739303d..489f323 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%!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}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;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-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.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}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.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}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.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-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,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-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.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}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.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}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-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)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.p-\[18px\]{padding:18px}.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-3\.5{padding-left:.875rem;padding-right:.875rem}.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}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.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}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.py-\[9px\]{padding-bottom:9px;padding-top:9px}.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}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.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-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.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}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.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}.leading-none{line-height:1}.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-40{opacity:.4}.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)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)} +/*! 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}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;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-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.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}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.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}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.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-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,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-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.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}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.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}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-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)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.p-\[18px\]{padding:18px}.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-3\.5{padding-left:.875rem;padding-right:.875rem}.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}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.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}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.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}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.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-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.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}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.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}.leading-none{line-height:1}.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-40{opacity:.4}.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)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)} diff --git a/web/templates/pages/host_restore.html b/web/templates/pages/host_restore.html index 9558320..0f6fed6 100644 --- a/web/templates/pages/host_restore.html +++ b/web/templates/pages/host_restore.html @@ -167,14 +167,21 @@
New directory
Files restore into a fresh path on the host. Original files untouched. - Original ownership (uid/gid/mode) is preserved. + Restored as the agent user — original uid/gid is dropped (restic ≥ 0.17; + older versions preserve it).
-
- - {{$page.DefaultTargetDir}} +
+ + +
+
+ $HOME resolves to the agent user's home; + <job-id> is substituted on dispatch. + Edit if you want a specific directory.
-
Final job-id slug substituted on dispatch.