From 21d967a2cfefe84726c93042f756cb8bc1f61b98 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 10:33:34 +0100 Subject: [PATCH 01/16] plan: P2 completion (P2R-09/10/11/12/13/14, P2-16/17/18) --- .../plans/2026-05-04-p2-completion.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-04-p2-completion.md diff --git a/docs/superpowers/plans/2026-05-04-p2-completion.md b/docs/superpowers/plans/2026-05-04-p2-completion.md new file mode 100644 index 0000000..1bc93f2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-04-p2-completion.md @@ -0,0 +1,259 @@ +# P2 Completion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Close every remaining P2 task in `tasks.md`: P2R-09 (auto-init UX), P2R-10/11/12 (hooks), P2R-13 (bandwidth wiring + per-job override), P2R-14 (schedule next/last run), P2-16 (Windows svc), P2-17 (`install.ps1`), P2-18 (announce-and-approve). + +**Architecture:** Server stays HTTP+WS; agent stays a single binary that auto-restages via `make build`. Hooks live on `source_groups` (and host-level defaults). Announce-and-approve adds a separate WS path (`/ws/agent/pending`) and a Pending hosts panel; token-flow stays default. Windows service support uses `golang.org/x/sys/windows/svc` behind a `//go:build windows` tag — Linux builds untouched. **Operator is away — make best guesses on small UX choices, but commit each item separately so the choices are reviewable.** + +**Tech Stack:** Go 1.23+, chi router, modernc/sqlite, `coder/websocket`, `robfig/cron/v3`, HTMX + Tailwind, `golang.org/x/sys/windows/svc`, Ed25519 (stdlib). + +--- + +## Pre-flight + +- [ ] **Run baseline:** `go vet ./... && go build ./... && go test ./...` — must be green before starting. Restage agent + restart server (per CLAUDE.md restage block) so smoke env is warm. + +## Order of execution + +Smallest blast-radius first. UI polish → bandwidth → next/last → hooks → announce → Windows. Commit and restage at each task boundary. Run `go vet ./... && go test ./...` before every commit. + +--- + +## Task 1 — P2R-13a: Wire bandwidth caps into restic invocations + +**Files:** +- Modify: `internal/restic/runner.go` (add `LimitUploadKBps`, `LimitDownloadKBps` to `Env` or to a per-call options struct already present; emit `--limit-upload N`/`--limit-download N` on `restic backup|forget|prune|check|restore`) +- Modify: `internal/agent/runner/*.go` — pass host-wide caps into the runner. Caps come from `agent.config.Config` or are pushed via `config.update`. Decision: ship caps in the existing `config.update` envelope as new fields `bandwidth_up_kbps`, `bandwidth_down_kbps`. Server pushes on hello + on `PUT /api/hosts/{id}/bandwidth`. +- Modify: `internal/api/messages.go` — extend `ConfigUpdatePayload` with the two int pointers. +- Modify: `internal/server/ws/handler.go` (or wherever hello/config push lives) — include caps in the pushed config. +- Modify: `internal/server/http/host_bandwidth.go` — after `SetHostBandwidth`, fan out a `config.update` to the connected agent (mirror the credentials-edit path). +- Test: `internal/restic/runner_test.go` — assert flag injection. +- Test: `internal/server/ws/*_test.go` — assert config.update carries caps on hello and on edit. + +- [ ] **Step 1.1** Add `LimitUploadKBps *int`, `LimitDownloadKBps *int` to whatever per-host config the runner already consults. Existing pattern is `restic.Env{}`; extend it. +- [ ] **Step 1.2** Failing test in `internal/restic/runner_test.go`: build a backup command with `LimitUploadKBps=1024`, assert the resulting argv contains `--limit-upload 1024`. +- [ ] **Step 1.3** Implement: prepend the flags in argv builders for `backup`, `forget`, `prune`, `check`, `restore`. Skip when nil/<=0. +- [ ] **Step 1.4** Wire `config.update` payload — server reads `Host.BandwidthUpKBps`/`DownKBps`, includes them in the existing `ConfigUpdatePayload` push on hello and on bandwidth edit (mirror cred-edit fan-out in `internal/server/http/host_credentials.go`). +- [ ] **Step 1.5** Agent applies caps: store in the in-memory dispatcher state on `config.update`, attach to every restic call. +- [ ] **Step 1.6** `go vet ./... && go test ./... && make build && `. Commit: +``` +agent+server: apply host bandwidth caps to restic invocations +``` + +## Task 2 — P2R-13b: Per-job override on Run-now confirm dialog + +**Decision:** A small numeric input on the per-source-group Run-now button (and dashboard Run-all). Operator is away — keep it minimal: two optional inputs (up/down KB/s) on the dispatch endpoint; UI shows a `
` "Limit bandwidth for this run" disclosure with two number inputs. + +**Files:** +- Modify: `internal/server/http/sources.go` (or wherever the per-group Run-now POST lives) — accept optional `bandwidth_up_kbps`/`bandwidth_down_kbps` form fields, pass through. +- Modify: dispatch path (`internal/server/dispatch_*.go` or `ws/handler.go` job-dispatch core) — accept overrides, include in the `command.run` payload. +- Modify: `internal/api/messages.go` — `CommandRunPayload` gains optional caps that take precedence over host-wide caps when present. +- Modify: agent dispatcher — use payload override if present else falls back to config caps. +- Modify: `web/templates/pages/host_sources.html` (and the schedules Run-now form) — `
` block. +- Test: HTTP test for the new form fields; agent runner test for override precedence. + +- [ ] **Step 2.1** Failing test: POST to per-group Run-now with `bandwidth_up_kbps=512` → assert dispatched payload carries 512. +- [ ] **Step 2.2** Implement endpoint changes + payload extension. +- [ ] **Step 2.3** Agent override precedence test (payload wins over config). +- [ ] **Step 2.4** UI `
` blocks (one per Run-now form). +- [ ] **Step 2.5** Playwright spot-check via `:8080` smoke env: open Sources tab, expand the Run-now disclosure, fire with limit=128, then open the live job log and confirm the agent's restic argv (read `/tmp/rm-smoke/server.log` for the dispatched command — it logs argv) shows `--limit-upload 128`. +- [ ] **Step 2.6** Commit. + +## Task 3 — P2R-14: Schedule "next run" / "last run" + +**Files:** +- Modify: `internal/store/schedules.go` — add `NextRunAt(time.Time)` derivation helper and `LatestScheduledJobAt(host_id, schedule_id) (time.Time, error)` (or a single batched fetch for all schedules of a host). +- Modify: dashboard host row (`web/templates/partials/host_row.html`) — show "Next: …" and "Last: …" when there's a single covering schedule (already detected in slice 5). +- Modify: `web/templates/pages/host_schedules.html` — add Next/Last columns to the schedules table. +- Modify: relevant page handlers (`internal/server/http/ui_schedules.go`, dashboard handler) — populate the data. +- Test: `schedules_test.go` for next-run derivation (parse cron, compute next from a fixed `now`). + +- [ ] **Step 3.1** Add `NextRun(cronExpr string, from time.Time) (time.Time, error)` helper using `robfig/cron/v3`'s `Parse(...).Next(from)`. Test with three crons. +- [ ] **Step 3.2** Add `LatestJobByActorKindForSchedule(host_id, schedule_id) (time.Time, status, error)` query against `jobs` (filter `actor_kind='schedule'` AND `schedule_id=?`, ORDER BY `started_at` DESC LIMIT 1). +- [ ] **Step 3.3** Wire schedules-page handler to populate Next/Last per row; render relative time + ISO tooltip (mirror existing `formatRelTime` template helper if it exists; otherwise use a simple "5m ago" helper). +- [ ] **Step 3.4** Wire dashboard row: when single covering schedule, surface "Next: 03:00" / "Last: 8h ago — succeeded". +- [ ] **Step 3.5** Playwright spot-check: a host with a schedule shows Next/Last; pause it → Next becomes "—" / "(paused)". +- [ ] **Step 3.6** Commit. + +## Task 4 — P2R-09: Auto-init UX polish + +**Files:** +- Modify: `web/templates/pages/host_repo.html` — danger-zone re-init button + two-step confirm (type the host name). +- Modify: `internal/server/http/ui_repo.go` (or new `repo_reinit.go`) — `POST /hosts/{id}/repo/reinit` admin-only, audit-logged. Server runs `restic init --force` (or wipes-then-inits — pick the safer of the two; restic doesn't truly wipe a repo, the operator must clear the bucket. **Best guess:** dispatch a normal `init` job with a flag that re-runs even if the repo claims to exist; if restic refuses, surface "the repo on the remote already has data — clear it manually before re-init" via the job log). +- Modify: host detail page header / vitals strip — surface init result line. Use the existing latest-`init`-job query to render "repo ready · initialised ago" or "init failed · job N · retry". +- Test: HTTP test for re-init endpoint (auth, audit, host-name confirm); template test that the result line renders for both states. + +- [ ] **Step 4.1** Add helper: `LatestJobByKind(host_id, "init")` — already exists from P2R-06 (`store.LatestJobByKind`). Reuse. +- [ ] **Step 4.2** Render init line into vitals strip; show "init failed" amber when latest init failed. +- [ ] **Step 4.3** Implement `POST /hosts/{id}/repo/reinit` handler — admin role check, requires a `confirm_hostname` form field that must equal `host.Name`, returns 400 otherwise. Dispatches a fresh `init` job. +- [ ] **Step 4.4** Add danger-zone re-init form to `host_repo.html` (currently disabled per slice 4). Two-step confirm with the typed hostname. +- [ ] **Step 4.5** Playwright: visit `/hosts/{id}/repo`, click re-init, type wrong hostname → blocked; type right hostname → dispatches init job → returns to live log. +- [ ] **Step 4.6** Commit. + +## Task 5 — P2R-10: Hook schema (migration 0010) + +**Files:** +- Create: `internal/store/migrations/0010_hooks.sql` + - `ALTER TABLE source_groups ADD COLUMN pre_hook BLOB;` (AEAD ciphertext, NULLable) + - `ALTER TABLE source_groups ADD COLUMN post_hook BLOB;` + - `ALTER TABLE hosts ADD COLUMN pre_hook_default BLOB;` + - `ALTER TABLE hosts ADD COLUMN post_hook_default BLOB;` + - All four are AEAD ciphertext (existing `crypto.AEAD`); BLOB column type. +- Modify: `internal/store/types.go` — add `PreHook *string` (decrypted), `PostHook *string` to `SourceGroup`; same to `Host`. +- Modify: `internal/store/sources.go` + `internal/store/hosts.go` — getters/setters encrypt on write, decrypt on read. Pass `crypto.AEAD` through (pattern mirrors `host_credentials.go`). +- Test: encrypt/decrypt round-trip; setting `nil` clears the column. + +- [ ] **Step 5.1** Write migration SQL. Column-level ALTERs only (per CLAUDE.md). +- [ ] **Step 5.2** Update store types + getters/setters with AEAD encrypt/decrypt. Mirror `internal/store/host_credentials.go` patterns exactly. +- [ ] **Step 5.3** Round-trip test: set hook on a source group; reload; assert plaintext returned. Set nil; assert nil after reload. +- [ ] **Step 5.4** `go vet && go test`. Commit. + +## Task 6 — P2R-11: Agent execution of hooks + +**Files:** +- Modify: `internal/api/messages.go` — `ConfigUpdatePayload` (or the per-source-group bundle inside `ScheduleSetPayload`) carries `PreHook`, `PostHook` plaintext (server has decrypted by then; wire is authenticated WS, same trust boundary as repo creds). +- Modify: agent dispatcher — for `kind=backup` only: + - Run `pre_hook` (if present) via `os/exec` with the host shell (`/bin/sh -c` on Linux, `cmd.exe /C` on Windows). Capture stdout+stderr → JobLog with `hook:` prefix. Non-zero exit aborts the backup, marks the job failed with `pre_hook` error. + - Run `post_hook` (if present) **always** after the backup, with `RM_JOB_STATUS=succeeded|failed` env var. Capture into JobLog, prefix `hook:`. Non-zero exit on post_hook does NOT change job status (warning logged). +- Skip both for `kind` ∈ {forget, prune, check, unlock, init} per spec.md §14.3. +- Test: dispatcher test with a `pre_hook` that exits 1 → backup not started; `post_hook` always runs and sees `RM_JOB_STATUS`. + +- [ ] **Step 6.1** Plumb hooks through `ScheduleSetPayload` source-group bundle + per-group Run-now `command.run` payload (override host-default with group hook if both present). Server-side resolution: host default if group hook is empty. +- [ ] **Step 6.2** Agent dispatcher: factor hook execution into `internal/agent/runner/hooks.go`. Use `exec.CommandContext`, set env, plumb output to existing JobLog stream with `Source: "hook"` (or prefix the log lines `hook: …`). +- [ ] **Step 6.3** Failing test in `internal/agent/runner/runner_test.go` (create file if absent): `pre_hook=/bin/false` → job fails with `pre_hook failed (exit 1)` and the actual restic backup never runs (assert via mock-restic shim). +- [ ] **Step 6.4** Test: `post_hook` runs even when backup fails; receives `RM_JOB_STATUS=failed`. +- [ ] **Step 6.5** Test: hooks skipped on `forget`/`prune`/`check`/`unlock` jobs. +- [ ] **Step 6.6** `go vet && go test && make build && `. Commit. + +## Task 7 — P2R-12: Hook editor UI + +**Files:** +- Modify: `web/templates/pages/source_group_edit.html` (new or extend existing source-group form) — ` + +
+ + +
+
+ +
+ + {{/* ---------- Danger zone ---------- */}}

Danger zone

Manual run-now ignores this — it just fails immediately if the agent is offline.
+

+ Hooks + backup jobs only +

+
+ Hooks run as the agent service user — root on Linux, LocalSystem on Windows. Treat them like any other root cron entry. +
+
+ + +
Non-zero exit aborts the backup. Stored AEAD-encrypted.
+
+
+ + +
Always runs. RM_JOB_STATUS is set to the backup's outcome. Stored AEAD-encrypted.
+
+
Cancel From a8e6c9d6d7c2eab504b26582081f69ce2fd5c4f1 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:03:41 +0100 Subject: [PATCH 09/16] store+server: P2-18a announce-and-approve schema + endpoint migration 0011 adds pending_hosts table (id, hostname, public_key, fingerprint, expiry). store/pending_hosts.go covers full CRUD plus hostname-collision count + expired-row sweeper. POST /api/agents/announce takes {hostname, os, arch, agent_version, restic_version, public_key (base64)}, returns {pending_id, fingerprint, hostname_collision}. Per-source-IP token-bucket rate limit (10/min) + global cap of 100 in-flight rows. Public key must be exactly 32 bytes (Ed25519). --- internal/server/http/announce.go | 211 ++++++++++++++++ internal/server/http/announce_test.go | 165 +++++++++++++ internal/server/http/server.go | 15 +- .../store/migrations/0011_pending_hosts.sql | 39 +++ internal/store/pending_hosts.go | 225 ++++++++++++++++++ 5 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 internal/server/http/announce.go create mode 100644 internal/server/http/announce_test.go create mode 100644 internal/store/migrations/0011_pending_hosts.sql create mode 100644 internal/store/pending_hosts.go diff --git a/internal/server/http/announce.go b/internal/server/http/announce.go new file mode 100644 index 0000000..8635cb8 --- /dev/null +++ b/internal/server/http/announce.go @@ -0,0 +1,211 @@ +// announce.go — POST /api/agents/announce: agent without a token +// announces itself with a freshly-minted Ed25519 public key, server +// stashes a pending_hosts row, admin compares fingerprints in the +// UI before accepting (P2-18a). +// +// Guards (per spec): +// - Per-source-IP token-bucket rate limit (10/min). +// - Global cap of 100 in-flight pending rows; further announces +// get 503 with a hint. +// - Public key must be exactly 32 bytes (Ed25519). Anything else +// 400-rejected. +// +// Hostname collisions are NOT rejected — multiple announces with +// the same hostname can be legitimate (re-running install on the +// same box). The UI flags collisions for the admin to disambiguate. +package http + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + stdhttp "net/http" + "strings" + "sync" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// Tunables — exposed as vars so tests can lower them. Defaults mirror +// the spec's recommendations. +var ( + announceMaxPerMin = 10 + announceGlobalCap = 100 +) + +// announceRequest is the wire shape POST /api/agents/announce takes. +// PublicKey is base64-std (no padding strip — stdlib decoder is +// lenient on padding for both forms). +type announceRequest struct { + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + AgentVersion string `json:"agent_version"` + ResticVersion string `json:"restic_version"` + PublicKey string `json:"public_key"` // base64 +} + +// announceResponse is what the agent gets back. Fingerprint is the +// canonical "SHA256:hex" the operator compares against the UI. +// HostnameCollision warns the install script that another pending +// row already uses the same hostname. +type announceResponse struct { + PendingID string `json:"pending_id"` + Fingerprint string `json:"fingerprint"` + HostnameCollision bool `json:"hostname_collision"` +} + +// rateBucket is a tiny per-IP token-bucket. last is the timestamp of +// the most recent refill; tokens is the current bucket level. Refill +// rate is announceMaxPerMin tokens/minute, burst = announceMaxPerMin. +type rateBucket struct { + tokens float64 + last time.Time +} + +// announceLimiter holds one bucket per source IP. Buckets are reaped +// lazily by a tiny grace period — we don't need true LRU cleanup +// because the bucket count is bounded by unique IPs in any given +// few minutes (small). +type announceLimiter struct { + mu sync.Mutex + buckets map[string]*rateBucket +} + +func newAnnounceLimiter() *announceLimiter { + return &announceLimiter{buckets: map[string]*rateBucket{}} +} + +// allow returns true and consumes a token if the IP's bucket has at +// least one token, else returns false. Capacity = announceMaxPerMin. +func (l *announceLimiter) allow(ip string, now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + cap := float64(announceMaxPerMin) + b, ok := l.buckets[ip] + if !ok { + b = &rateBucket{tokens: cap, last: now} + l.buckets[ip] = b + } + // Refill at cap tokens per minute. + elapsed := now.Sub(b.last).Seconds() + if elapsed > 0 { + b.tokens += (elapsed / 60.0) * cap + if b.tokens > cap { + b.tokens = cap + } + b.last = now + } + if b.tokens < 1.0 { + return false + } + b.tokens-- + return true +} + +// handleAnnounce is the public POST handler. Public — no auth. +func (s *Server) handleAnnounce(w stdhttp.ResponseWriter, r *stdhttp.Request) { + now := time.Now().UTC() + + // Rate limit by source IP. Strip port — the limit is per host, + // not per outbound source port. + ip := remoteIP(r) + if !s.announceRL.allow(ip, now) { + w.Header().Set("Retry-After", "60") + writeJSONError(w, stdhttp.StatusTooManyRequests, "rate_limited", + "too many announces from this source; retry in a minute") + return + } + + var req announceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if req.Hostname == "" || req.OS == "" || req.Arch == "" || req.PublicKey == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", + "hostname, os, arch, public_key are required") + return + } + + keyBytes, err := base64.StdEncoding.DecodeString(req.PublicKey) + if err != nil { + // Try URL-safe / no-padding flavors before giving up. + if k2, e2 := base64.RawStdEncoding.DecodeString(req.PublicKey); e2 == nil { + keyBytes = k2 + } else { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_public_key", + "public_key must be base64") + return + } + } + if len(keyBytes) != ed25519.PublicKeySize { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_public_key", + "public_key must be 32 bytes (Ed25519)") + return + } + + // Global cap (cheap query — index on expires_at). + count, err := s.deps.Store.CountPendingHosts(r.Context(), now) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if count >= announceGlobalCap { + writeJSONError(w, stdhttp.StatusServiceUnavailable, "pending_cap_reached", + "too many in-flight pending hosts; ask an admin to clear the queue") + return + } + + // Hostname collision flag (informational). + colls, err := s.deps.Store.CountPendingHostsByHostname(r.Context(), req.Hostname, now) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + + ph := &store.PendingHost{ + ID: ulid.Make().String(), + Hostname: req.Hostname, + OS: req.OS, + Arch: req.Arch, + AgentVersion: req.AgentVersion, + ResticVersion: req.ResticVersion, + PublicKey: keyBytes, + Fingerprint: store.FingerprintForKey(keyBytes), + AnnouncedFromIP: ip, + FirstSeenAt: now, + LastSeenAt: now, + ExpiresAt: now.Add(time.Hour), + } + if err := s.deps.Store.CreatePendingHost(r.Context(), ph); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + writeJSON(w, stdhttp.StatusOK, announceResponse{ + PendingID: ph.ID, + Fingerprint: ph.Fingerprint, + HostnameCollision: colls > 0, + }) +} + +// 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. +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 + // client) — same convention chi uses. Trim whitespace. + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + addr := r.RemoteAddr + if i := strings.LastIndex(addr, ":"); i >= 0 { + return addr[:i] + } + return addr +} diff --git a/internal/server/http/announce_test.go b/internal/server/http/announce_test.go new file mode 100644 index 0000000..097e500 --- /dev/null +++ b/internal/server/http/announce_test.go @@ -0,0 +1,165 @@ +// announce_test.go — covers POST /api/agents/announce: happy path, +// invalid public key, hostname collision flag, rate limit, global +// cap (P2-18a). +package http + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + stdhttp "net/http" + "strings" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func newKeypair(t *testing.T) ed25519.PublicKey { + t.Helper() + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519: %v", err) + } + return pub +} + +func postAnnounce(t *testing.T, url string, req announceRequest) (status int, header stdhttp.Header, body []byte) { + t.Helper() + b, _ := json.Marshal(req) + r, _ := stdhttp.NewRequest("POST", url+"/api/agents/announce", bytes.NewReader(b)) + r.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(r) + if err != nil { + t.Fatalf("do: %v", err) + } + defer res.Body.Close() + out := make([]byte, 4096) + n, _ := res.Body.Read(out) + return res.StatusCode, res.Header, out[:n] +} + +func TestAnnounceHappyPath(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + pub := newKeypair(t) + status, _, body := postAnnounce(t, url, announceRequest{ + Hostname: "alice", OS: "linux", Arch: "amd64", + AgentVersion: "1.0", ResticVersion: "0.17", + PublicKey: base64.StdEncoding.EncodeToString(pub), + }) + if status != stdhttp.StatusOK { + t.Fatalf("status: %d body=%s", status, body) + } + var ar announceResponse + if err := json.Unmarshal(body, &ar); err != nil { + t.Fatalf("unmarshal: %v body=%s", err, body) + } + if ar.PendingID == "" { + t.Fatal("missing pending_id") + } + if !strings.HasPrefix(ar.Fingerprint, "SHA256:") { + t.Fatalf("fingerprint shape: %q", ar.Fingerprint) + } + if ar.HostnameCollision { + t.Fatal("first announce shouldn't be a collision") + } + // Row exists in the store. + if _, err := st.GetPendingHost(context.Background(), ar.PendingID); err != nil { + t.Fatalf("pending row missing: %v", err) + } +} + +func TestAnnounceRejectsBadKey(t *testing.T) { + t.Parallel() + _, url, _ := newTestServerWithHub(t) + status, _, _ := postAnnounce(t, url, announceRequest{ + Hostname: "x", OS: "linux", Arch: "amd64", + PublicKey: base64.StdEncoding.EncodeToString([]byte("too-short")), + }) + if status != stdhttp.StatusBadRequest { + t.Fatalf("status: got %d, want 400", status) + } +} + +func TestAnnounceHostnameCollisionFlag(t *testing.T) { + t.Parallel() + _, url, _ := newTestServerWithHub(t) + pub1 := newKeypair(t) + pub2 := newKeypair(t) + _, _, _ = postAnnounce(t, url, announceRequest{ + Hostname: "dup-host", OS: "linux", Arch: "amd64", + PublicKey: base64.StdEncoding.EncodeToString(pub1), + }) + status, _, body := postAnnounce(t, url, announceRequest{ + Hostname: "dup-host", OS: "linux", Arch: "amd64", + PublicKey: base64.StdEncoding.EncodeToString(pub2), + }) + if status != stdhttp.StatusOK { + t.Fatalf("status: %d", status) + } + var ar announceResponse + _ = json.Unmarshal(body, &ar) + if !ar.HostnameCollision { + t.Fatal("expected hostname_collision=true on second announce") + } +} + +func TestAnnounceRateLimit(t *testing.T) { + t.Parallel() + _, url, _ := newTestServerWithHub(t) + // Lower the limit for the duration of this test (the limiter is + // per-server-instance so we don't disturb parallel tests). + prev := announceMaxPerMin + announceMaxPerMin = 2 + t.Cleanup(func() { announceMaxPerMin = prev }) + + pub := newKeypair(t) + body := announceRequest{ + Hostname: "rl-host", OS: "linux", Arch: "amd64", + PublicKey: base64.StdEncoding.EncodeToString(pub), + } + for i := 0; i < 2; i++ { + status, _, _ := postAnnounce(t, url, body) + if status != stdhttp.StatusOK { + t.Fatalf("call %d: status %d", i, status) + } + } + status, _, _ := postAnnounce(t, url, body) + if status != stdhttp.StatusTooManyRequests { + t.Fatalf("3rd call: want 429, got %d", status) + } +} + +func TestAnnounceGlobalCap(t *testing.T) { + t.Parallel() + _, url, st := newTestServerWithHub(t) + prev := announceGlobalCap + announceGlobalCap = 1 + t.Cleanup(func() { announceGlobalCap = prev }) + + // Pre-seed one row directly via the store so the cap is hit. + pub := newKeypair(t) + if err := st.CreatePendingHost(context.Background(), &store.PendingHost{ + ID: ulid.Make().String(), Hostname: "x", OS: "linux", Arch: "amd64", + PublicKey: pub, Fingerprint: store.FingerprintForKey(pub), + AnnouncedFromIP: "127.0.0.1", + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + }); err != nil { + t.Fatalf("seed: %v", err) + } + status, _, _ := postAnnounce(t, url, announceRequest{ + Hostname: "next", OS: "linux", Arch: "amd64", + PublicKey: base64.StdEncoding.EncodeToString(newKeypair(t)), + }) + if status != stdhttp.StatusServiceUnavailable { + t.Fatalf("want 503 over cap, got %d", status) + } +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index c232407..bef412c 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -49,6 +49,10 @@ type Server struct { // sync.Mutex; checked-and-locked atomically via drainLocksMu. drainLocksMu sync.Mutex drainLocks map[string]*sync.Mutex + + // announceRL is the per-source-IP token-bucket guarding + // POST /api/agents/announce (P2-18). One process-local map. + announceRL *announceLimiter } // New builds a configured but not-yet-started server. @@ -67,7 +71,11 @@ func New(deps Deps) *Server { w.WriteHeader(stdhttp.StatusNoContent) }) - s := &Server{deps: deps, drainLocks: make(map[string]*sync.Mutex)} + s := &Server{ + deps: deps, + drainLocks: make(map[string]*sync.Mutex), + announceRL: newAnnounceLimiter(), + } s.routes(r) s.srv = &stdhttp.Server{ @@ -92,6 +100,11 @@ func (s *Server) routes(r chi.Router) { // Agent enrollment (open endpoint — token is the credential). r.Post("/agents/enroll", s.handleAgentEnroll) + // Announce-and-approve enrolment (open endpoint — fingerprint + // comparison in the UI is the gate). Per-IP rate-limited and + // globally capped (P2-18). + r.Post("/agents/announce", s.handleAnnounce) + // Operator → server (authenticated). Spec.md §6.1's // /hosts/{id}/enrollment-token (regenerate) lands when the // host page can call it; for now just the create endpoint. diff --git a/internal/store/migrations/0011_pending_hosts.sql b/internal/store/migrations/0011_pending_hosts.sql new file mode 100644 index 0000000..61184f2 --- /dev/null +++ b/internal/store/migrations/0011_pending_hosts.sql @@ -0,0 +1,39 @@ +-- 0011_pending_hosts.sql +-- +-- P2-18: announce-and-approve enrolment. +-- +-- Agents that don't have an enrolment token announce themselves +-- with `POST /api/agents/announce`, persisting one row here. The +-- admin sees them in the dashboard's Pending hosts panel and can +-- accept (mints a real Host row + bearer) or reject (deletes the +-- row + closes the agent's pending WS). +-- +-- public_key is the agent's Ed25519 public key (32 raw bytes). +-- fingerprint = "SHA256:" + hex(sha256(public_key)) — printed by +-- the install script on the endpoint terminal so the operator can +-- compare the two before clicking accept. This comparison is the +-- load-bearing security gate for this flow. +-- +-- expires_at is set to first_seen_at + 1h on insert; a sweeper +-- goroutine (P2-18b) deletes rows past their expiry. Hostname +-- collisions with existing or other pending rows are *not* +-- prevented at the DB level — multiple announces with the same +-- hostname are flagged in the UI so admin can pick the right one. + +CREATE TABLE pending_hosts ( + id TEXT PRIMARY KEY, + hostname TEXT NOT NULL, + os TEXT NOT NULL, + arch TEXT NOT NULL, + agent_version TEXT NOT NULL, + restic_version TEXT NOT NULL, + public_key BLOB NOT NULL, -- 32-byte Ed25519 + fingerprint TEXT NOT NULL, -- "SHA256:hex(...)" + announced_from_ip TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL +); +CREATE INDEX pending_hosts_expires ON pending_hosts(expires_at); +CREATE INDEX pending_hosts_fingerprint ON pending_hosts(fingerprint); +CREATE INDEX pending_hosts_hostname ON pending_hosts(hostname); diff --git a/internal/store/pending_hosts.go b/internal/store/pending_hosts.go new file mode 100644 index 0000000..c16d8a1 --- /dev/null +++ b/internal/store/pending_hosts.go @@ -0,0 +1,225 @@ +// pending_hosts.go — store layer for the announce-and-approve +// enrolment queue (P2-18a). Rows live for at most 1h; a sweeper +// deletes anything past expires_at. +package store + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "time" +) + +// PendingHost mirrors the pending_hosts table row, plus the derived +// HostnameCollision flag the API hands back to the agent so the +// install script can warn the operator at announce time. +type PendingHost struct { + ID string + Hostname string + OS string + Arch string + AgentVersion string + ResticVersion string + PublicKey []byte // 32-byte Ed25519 + Fingerprint string // "SHA256:hex" + AnnouncedFromIP string + FirstSeenAt time.Time + LastSeenAt time.Time + ExpiresAt time.Time +} + +// FingerprintForKey returns the canonical "SHA256:hex" fingerprint +// the operator sees in the UI and on the endpoint terminal. +func FingerprintForKey(pubKey []byte) string { + sum := sha256.Sum256(pubKey) + return "SHA256:" + hex.EncodeToString(sum[:]) +} + +// CreatePendingHost inserts a new row. Caller has already validated +// the public key length and rate limits. +func (s *Store) CreatePendingHost(ctx context.Context, ph *PendingHost) error { + if ph.ID == "" || len(ph.PublicKey) == 0 { + return errors.New("store: pending host id + public_key required") + } + if ph.Fingerprint == "" { + ph.Fingerprint = FingerprintForKey(ph.PublicKey) + } + now := time.Now().UTC() + if ph.FirstSeenAt.IsZero() { + ph.FirstSeenAt = now + } + ph.LastSeenAt = now + if ph.ExpiresAt.IsZero() { + ph.ExpiresAt = now.Add(time.Hour) + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO pending_hosts ( + id, hostname, os, arch, agent_version, restic_version, + public_key, fingerprint, announced_from_ip, + first_seen_at, last_seen_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ph.ID, ph.Hostname, ph.OS, ph.Arch, ph.AgentVersion, ph.ResticVersion, + ph.PublicKey, ph.Fingerprint, ph.AnnouncedFromIP, + ph.FirstSeenAt.Format(time.RFC3339Nano), + ph.LastSeenAt.Format(time.RFC3339Nano), + ph.ExpiresAt.Format(time.RFC3339Nano), + ) + if err != nil { + return fmt.Errorf("store: create pending host: %w", err) + } + return nil +} + +// TouchPendingHost bumps last_seen_at on the named pending row, +// extending its visibility in the dashboard while the agent's +// pending WS stays open. Does NOT extend expires_at — the 1h cap +// is firm. +func (s *Store) TouchPendingHost(ctx context.Context, id string, when time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE pending_hosts SET last_seen_at = ? WHERE id = ?`, + when.UTC().Format(time.RFC3339Nano), id) + return err +} + +// GetPendingHost returns one row by ID. ErrNotFound on miss. +func (s *Store) GetPendingHost(ctx context.Context, id string) (*PendingHost, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, hostname, os, arch, agent_version, restic_version, + public_key, fingerprint, announced_from_ip, + first_seen_at, last_seen_at, expires_at + FROM pending_hosts WHERE id = ?`, id) + return scanPendingHost(row) +} + +// GetPendingHostByFingerprint resolves a row by its public key +// fingerprint (used by the WS pending handler to look up which row +// an incoming connection corresponds to). +func (s *Store) GetPendingHostByFingerprint(ctx context.Context, fp string) (*PendingHost, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, hostname, os, arch, agent_version, restic_version, + public_key, fingerprint, announced_from_ip, + first_seen_at, last_seen_at, expires_at + FROM pending_hosts WHERE fingerprint = ?`, fp) + return scanPendingHost(row) +} + +// ListPendingHosts returns every non-expired row, newest first. The +// caller passes `now` so tests can fast-forward. +func (s *Store) ListPendingHosts(ctx context.Context, now time.Time) ([]PendingHost, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, hostname, os, arch, agent_version, restic_version, + public_key, fingerprint, announced_from_ip, + first_seen_at, last_seen_at, expires_at + FROM pending_hosts WHERE expires_at > ? + ORDER BY first_seen_at DESC`, + now.UTC().Format(time.RFC3339Nano)) + if err != nil { + return nil, fmt.Errorf("store: list pending hosts: %w", err) + } + defer func() { _ = rows.Close() }() + out := []PendingHost{} + for rows.Next() { + ph, err := scanPendingHostRow(rows) + if err != nil { + return nil, err + } + out = append(out, *ph) + } + return out, rows.Err() +} + +// CountPendingHosts returns the count of non-expired rows. Used for +// the global cap (P2-18: refuse new announces past 100 in flight). +func (s *Store) CountPendingHosts(ctx context.Context, now time.Time) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pending_hosts WHERE expires_at > ?`, + now.UTC().Format(time.RFC3339Nano)).Scan(&n) + if err != nil { + return 0, fmt.Errorf("store: count pending hosts: %w", err) + } + return n, nil +} + +// CountPendingHostsByHostname returns the number of non-expired +// pending rows that share the supplied hostname. Used by the +// announce endpoint to set the hostname_collision flag in its +// response. +func (s *Store) CountPendingHostsByHostname(ctx context.Context, hostname string, now time.Time) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM pending_hosts WHERE hostname = ? AND expires_at > ?`, + hostname, now.UTC().Format(time.RFC3339Nano)).Scan(&n) + if err != nil { + return 0, fmt.Errorf("store: count pending hosts by hostname: %w", err) + } + return n, nil +} + +// DeletePendingHost removes one row by ID. ErrNotFound on miss. +func (s *Store) DeletePendingHost(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, + `DELETE FROM pending_hosts WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: delete pending host: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// DeleteExpiredPendingHosts removes every row whose expires_at is in +// the past. Returns the number of rows deleted so the sweeper can +// log non-zero events. +func (s *Store) DeleteExpiredPendingHosts(ctx context.Context, now time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM pending_hosts WHERE expires_at <= ?`, + now.UTC().Format(time.RFC3339Nano)) + if err != nil { + return 0, fmt.Errorf("store: delete expired pending hosts: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} + +// ----- scan helpers -------------------------------------------------- + +type pendingHostScanner interface { + Scan(dest ...any) error +} + +func scanPendingHost(row *sql.Row) (*PendingHost, error) { + ph, err := scanPendingHostRow(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return ph, err +} + +func scanPendingHostRow(s pendingHostScanner) (*PendingHost, error) { + var ( + ph PendingHost + firstSeenAt, lastSeenAt, expiresAt string + ) + if err := s.Scan(&ph.ID, &ph.Hostname, &ph.OS, &ph.Arch, + &ph.AgentVersion, &ph.ResticVersion, + &ph.PublicKey, &ph.Fingerprint, &ph.AnnouncedFromIP, + &firstSeenAt, &lastSeenAt, &expiresAt); err != nil { + return nil, err + } + if t, err := time.Parse(time.RFC3339Nano, firstSeenAt); err == nil { + ph.FirstSeenAt = t + } + if t, err := time.Parse(time.RFC3339Nano, lastSeenAt); err == nil { + ph.LastSeenAt = t + } + if t, err := time.Parse(time.RFC3339Nano, expiresAt); err == nil { + ph.ExpiresAt = t + } + return &ph, nil +} From 567561a6a3a9ea6f1e048c0f265f9d7872a115d9 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:07:32 +0100 Subject: [PATCH 10/16] server: P2-18b pending WS + admin accept/reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /ws/agent/pending?pending_id=… runs an Ed25519 nonce-sign handshake against the row's stored public key, then holds the connection open. POST /api/pending-hosts/{id}/accept (admin) mints a real Host row + bearer + AEAD-encrypted repo creds, pushes the bearer down the open WS, deletes the pending row, and writes a host.accept_pending audit entry. POST /api/pending-hosts/{id}/reject closes the socket with code 4001 and audit-logs host.reject_pending. In-memory pendingHub keyed by pending_id wires accept/reject to their live socket. --- internal/server/http/pending_ws.go | 349 ++++++++++++++++++++++++ internal/server/http/pending_ws_test.go | 203 ++++++++++++++ internal/server/http/server.go | 14 + 3 files changed, 566 insertions(+) create mode 100644 internal/server/http/pending_ws.go create mode 100644 internal/server/http/pending_ws_test.go diff --git a/internal/server/http/pending_ws.go b/internal/server/http/pending_ws.go new file mode 100644 index 0000000..9373928 --- /dev/null +++ b/internal/server/http/pending_ws.go @@ -0,0 +1,349 @@ +// pending_ws.go — /ws/agent/pending and the admin accept/reject +// endpoints for the announce-and-approve enrolment flow (P2-18b). +// +// Flow: +// 1. Agent has previously called POST /api/agents/announce, which +// returned its pending_id + fingerprint. Agent persists the +// keypair locally. +// 2. Agent connects to /ws/agent/pending?pending_id=… (no auth). +// Server reads the row, generates a 32-byte nonce, sends it. +// 3. Agent signs the nonce with its Ed25519 private key, sends the +// signature back. Server verifies; close on bad sig. +// 4. The connection sits open; the agent reads but doesn't write. +// 5. Admin clicks Accept: POST /api/pending-hosts/{id}/accept with +// the same repo-creds form the token-mint flow uses. Server +// mints a Host row + bearer + encrypted creds, pushes one +// `enrolled` message down the open socket, closes cleanly. +// 6. Admin clicks Reject: socket closes with code 4001. +// +// Hub: a process-local in-memory map of pending_id → live conn so +// the accept/reject handlers can find the right socket. Sole +// instance lives on Server.pendingHub. +package http + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "log/slog" + stdhttp "net/http" + "sync" + "time" + + "github.com/coder/websocket" + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// pendingConn is a single live /ws/agent/pending session. The accept +// handler sends the enrolment message via Send and closes the socket; +// the WS read loop is just waiting for that close. +type pendingConn struct { + conn *websocket.Conn + pendingID string + closed chan struct{} +} + +// pendingHub is the in-memory map of pending_id → live socket. +type pendingHub struct { + mu sync.Mutex + conns map[string]*pendingConn +} + +func newPendingHub() *pendingHub { + return &pendingHub{conns: map[string]*pendingConn{}} +} + +func (h *pendingHub) register(pc *pendingConn) { + h.mu.Lock() + defer h.mu.Unlock() + // Replace any existing socket for the same pending_id (an agent + // reconnected) — close the old one cleanly first so its goroutine + // can exit. + if old, ok := h.conns[pc.pendingID]; ok { + _ = old.conn.Close(websocket.StatusNormalClosure, "superseded") + close(old.closed) + } + h.conns[pc.pendingID] = pc +} + +func (h *pendingHub) unregister(pendingID string, pc *pendingConn) { + h.mu.Lock() + defer h.mu.Unlock() + if cur, ok := h.conns[pendingID]; ok && cur == pc { + delete(h.conns, pendingID) + } +} + +func (h *pendingHub) get(pendingID string) *pendingConn { + h.mu.Lock() + defer h.mu.Unlock() + return h.conns[pendingID] +} + +// nonceMessage is what the server sends first on /ws/agent/pending. +type nonceMessage struct { + Type string `json:"type"` // "nonce" + Nonce string `json:"nonce"` // base64 +} + +// signedNonceMessage is what the agent sends back. +type signedNonceMessage struct { + Type string `json:"type"` // "signed_nonce" + Signature string `json:"signature"` // base64 +} + +// enrolledMessage is what the server sends on accept. The agent +// persists the bearer to agent.yaml and exits announce mode. +type enrolledMessage struct { + Type string `json:"type"` // "enrolled" + HostID string `json:"host_id"` + Bearer string `json:"bearer"` + ServerID string `json:"server_id,omitempty"` +} + +// handlePendingWS upgrades the WS, runs the nonce-sign handshake, +// registers the conn in the hub, and blocks until the conn is +// closed (by accept/reject or by the agent disconnecting). +func (s *Server) handlePendingWS(w stdhttp.ResponseWriter, r *stdhttp.Request) { + pendingID := r.URL.Query().Get("pending_id") + if pendingID == "" { + stdhttp.Error(w, "missing pending_id", stdhttp.StatusBadRequest) + return + } + row, err := s.deps.Store.GetPendingHost(r.Context(), pendingID) + if err != nil { + stdhttp.Error(w, "pending host not found", stdhttp.StatusNotFound) + return + } + if time.Now().UTC().After(row.ExpiresAt) { + stdhttp.Error(w, "pending host expired", stdhttp.StatusGone) + return + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + // Same-origin defaults are safe: the agent isn't a browser. + InsecureSkipVerify: true, + }) + if err != nil { + slog.Warn("pending ws: accept", "pending_id", pendingID, "err", err) + return + } + + // Generate + send nonce. + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + _ = conn.Close(websocket.StatusInternalError, "nonce gen") + return + } + nm := nonceMessage{Type: "nonce", Nonce: base64.StdEncoding.EncodeToString(nonce)} + raw, _ := json.Marshal(nm) + wctx, wcancel := context.WithTimeout(r.Context(), 5*time.Second) + if err := conn.Write(wctx, websocket.MessageText, raw); err != nil { + wcancel() + _ = conn.Close(websocket.StatusInternalError, "send nonce") + return + } + wcancel() + + // Read signed nonce back. + rctx, rcancel := context.WithTimeout(r.Context(), 30*time.Second) + mt, body, err := conn.Read(rctx) + rcancel() + if err != nil || mt != websocket.MessageText { + _ = conn.Close(websocket.StatusPolicyViolation, "no signed nonce") + return + } + var sig signedNonceMessage + if err := json.Unmarshal(body, &sig); err != nil || sig.Type != "signed_nonce" { + _ = conn.Close(websocket.StatusPolicyViolation, "bad signed nonce shape") + return + } + sigBytes, err := base64.StdEncoding.DecodeString(sig.Signature) + if err != nil { + _ = conn.Close(websocket.StatusPolicyViolation, "bad signature b64") + return + } + if !ed25519.Verify(row.PublicKey, nonce, sigBytes) { + _ = conn.Close(websocket.StatusPolicyViolation, "signature does not verify") + return + } + + // Touch the row so the dashboard knows the agent is live. + _ = s.deps.Store.TouchPendingHost(context.Background(), pendingID, time.Now().UTC()) + + // Register and block until close. + pc := &pendingConn{conn: conn, pendingID: pendingID, closed: make(chan struct{})} + s.pendingHub.register(pc) + defer s.pendingHub.unregister(pendingID, pc) + + // Read loop: we don't expect any further frames from the agent. + // If the agent closes, we exit. + go func() { + for { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + _, _, err := conn.Read(ctx) + cancel() + if err != nil { + close(pc.closed) + return + } + } + }() + <-pc.closed +} + +// acceptForm is the admin form for POST /api/pending-hosts/{id}/accept. +// repo_password may be omitted only when the host already has admin- +// supplied creds elsewhere — we don't currently model that. For now, +// require all three. +type acceptForm struct { + RepoURL string `json:"repo_url"` + RepoUsername string `json:"repo_username"` + RepoPassword string `json:"repo_password"` +} + +// handleAcceptPendingHost mints a real Host row + bearer + encrypted +// repo creds and pushes the bearer down the agent's open pending WS. +// Admin-auth required. +func (s *Server) handleAcceptPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + pendingID := chi.URLParam(r, "id") + row, err := s.deps.Store.GetPendingHost(r.Context(), pendingID) + if err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "pending_not_found", "") + return + } + pc := s.pendingHub.get(pendingID) + if pc == nil { + writeJSONError(w, stdhttp.StatusConflict, "agent_not_connected", + "the pending agent is not currently connected; ask it to retry") + return + } + + var form acceptForm + // Accept either JSON or form-urlencoded so HTMX-style POST works. + if r.Header.Get("Content-Type") == "application/json" { + if err := json.NewDecoder(r.Body).Decode(&form); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + } else { + if err := r.ParseForm(); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "bad_form", err.Error()) + return + } + form.RepoURL = r.PostForm.Get("repo_url") + form.RepoUsername = r.PostForm.Get("repo_username") + form.RepoPassword = r.PostForm.Get("repo_password") + } + if form.RepoURL == "" || form.RepoPassword == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "missing_field", + "repo_url and repo_password are required") + return + } + + // Mint persistent bearer + Host row. + hostID := ulid.Make().String() + token, err := auth.NewToken() + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + host := store.Host{ + ID: hostID, Name: row.Hostname, OS: row.OS, Arch: row.Arch, + AgentVersion: row.AgentVersion, ResticVersion: row.ResticVersion, + EnrolledAt: time.Now().UTC(), + } + if err := s.deps.Store.CreateHost(r.Context(), host, auth.HashToken(token), ""); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + // Encrypt + persist repo creds. + enc, err := s.encryptRepoCreds(repoCredsBlob(form), []byte("host:"+hostID)) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if err := s.deps.Store.SetHostCredentials(r.Context(), hostID, store.CredKindRepo, enc); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + // Drop the pending row. + if err := s.deps.Store.DeletePendingHost(r.Context(), pendingID); err != nil { + slog.Warn("accept pending: delete row", "pending_id", pendingID, "err", err) + } + // Push enrolled message + close the pending WS. + enrolled := enrolledMessage{Type: "enrolled", HostID: hostID, Bearer: token} + raw, _ := json.Marshal(enrolled) + wctx, wcancel := context.WithTimeout(r.Context(), 5*time.Second) + if err := pc.conn.Write(wctx, websocket.MessageText, raw); err != nil { + slog.Warn("accept pending: write enrolled", "pending_id", pendingID, "err", err) + } + wcancel() + _ = pc.conn.Close(websocket.StatusNormalClosure, "accepted") + + // Audit. + uid := user.ID + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &uid, + Actor: "user", + Action: "host.accept_pending", + TargetKind: ptr("host"), + TargetID: &hostID, + TS: time.Now().UTC(), + }) + + writeJSON(w, stdhttp.StatusOK, map[string]any{ + "host_id": hostID, + "fingerprint": row.Fingerprint, + }) +} + +// handleRejectPendingHost deletes the pending row and closes any +// open WS for it. Admin-auth required. +func (s *Server) handleRejectPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + user, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "") + return + } + pendingID := chi.URLParam(r, "id") + row, err := s.deps.Store.GetPendingHost(r.Context(), pendingID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + w.WriteHeader(stdhttp.StatusNoContent) + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if pc := s.pendingHub.get(pendingID); pc != nil { + _ = pc.conn.Close(4001, "rejected") + } + if err := s.deps.Store.DeletePendingHost(r.Context(), pendingID); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + uid := user.ID + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &uid, + Actor: "user", + Action: "host.reject_pending", + TargetKind: ptr("pending_host"), + TargetID: &row.ID, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusNoContent) +} diff --git a/internal/server/http/pending_ws_test.go b/internal/server/http/pending_ws_test.go new file mode 100644 index 0000000..b90205a --- /dev/null +++ b/internal/server/http/pending_ws_test.go @@ -0,0 +1,203 @@ +// pending_ws_test.go — end-to-end test of the announce → pending WS +// → admin accept → bearer push round trip (P2-18b/c). +package http + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + stdhttp "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// TestPendingWSNonceSignAcceptFlow: simulate an agent. Announce → +// open pending WS → sign nonce → admin accept (with repo creds) → +// expect 'enrolled' message with bearer. +func TestPendingWSNonceSignAcceptFlow(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519: %v", err) + } + + // Pre-seed pending row directly (bypass the announce HTTP path + // since announce coverage lives in announce_test.go). + pendingID := ulid.Make().String() + if err := st.CreatePendingHost(context.Background(), &store.PendingHost{ + ID: pendingID, Hostname: "ann-host", OS: "linux", Arch: "amd64", + AgentVersion: "1.0", ResticVersion: "0.17", + PublicKey: pub, Fingerprint: store.FingerprintForKey(pub), + AnnouncedFromIP: "127.0.0.1", + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + }); err != nil { + t.Fatalf("seed: %v", err) + } + + // Open the pending WS. + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent/pending?pending_id=" + pendingID + dialCtx, dialCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer dialCancel() + c, res, err := websocket.Dial(dialCtx, wsURL, nil) + if err != nil { + t.Fatalf("dial pending ws: %v", err) + } + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + t.Cleanup(func() { _ = c.CloseNow() }) + + // Read nonce. + rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second) + _, raw, err := c.Read(rctx) + rcancel() + if err != nil { + t.Fatalf("read nonce: %v", err) + } + var nm nonceMessage + if err := json.Unmarshal(raw, &nm); err != nil { + t.Fatalf("unmarshal nonce: %v", err) + } + nonce, _ := base64.StdEncoding.DecodeString(nm.Nonce) + + // Sign + reply. + sig := ed25519.Sign(priv, nonce) + reply, _ := json.Marshal(signedNonceMessage{ + Type: "signed_nonce", Signature: base64.StdEncoding.EncodeToString(sig), + }) + wctx, wcancel := context.WithTimeout(context.Background(), 3*time.Second) + if err := c.Write(wctx, websocket.MessageText, reply); err != nil { + wcancel() + t.Fatalf("write signed nonce: %v", err) + } + wcancel() + + // Wait briefly so the server's hub.register completes before we + // fire accept. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.pendingHub.get(pendingID) != nil { + break + } + time.Sleep(20 * time.Millisecond) + } + + // Admin POST accept (form-encoded, with cookie). + cookie := loginAsAdmin(t, st) + form := url.Values{ + "repo_url": {"rest:http://r/x"}, + "repo_username": {"u"}, + "repo_password": {"p"}, + } + req, _ := stdhttp.NewRequest("POST", + ts.URL+"/api/pending-hosts/"+pendingID+"/accept", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + resAccept, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("accept: %v", err) + } + defer resAccept.Body.Close() + if resAccept.StatusCode != stdhttp.StatusOK { + t.Fatalf("accept status: %d", resAccept.StatusCode) + } + + // Expect 'enrolled' message + close. + rctx2, rcancel2 := context.WithTimeout(context.Background(), 3*time.Second) + _, raw2, err := c.Read(rctx2) + rcancel2() + if err != nil { + t.Fatalf("read enrolled: %v", err) + } + var em enrolledMessage + if err := json.Unmarshal(raw2, &em); err != nil { + t.Fatalf("unmarshal enrolled: %v", err) + } + if em.Type != "enrolled" || em.Bearer == "" || em.HostID == "" { + t.Fatalf("enrolled payload bad: %+v", em) + } + + // Pending row should be gone. + if _, err := st.GetPendingHost(context.Background(), pendingID); err == nil { + t.Error("pending row should have been deleted on accept") + } + // Real host row should exist. + if _, err := st.GetHost(context.Background(), em.HostID); err != nil { + t.Errorf("host row not created: %v", err) + } +} + +// TestPendingWSBadSignatureClosed: server closes the WS when the +// signature does not verify against the row's public key. +func TestPendingWSBadSignatureClosed(t *testing.T) { + t.Parallel() + srv, ts, st := rawTestServer(t) + _ = srv + + // Two distinct keypairs — agent signs with the wrong one. + pubReal, _, _ := ed25519.GenerateKey(rand.Reader) + _, privAttacker, _ := ed25519.GenerateKey(rand.Reader) + + pendingID := ulid.Make().String() + if err := st.CreatePendingHost(context.Background(), &store.PendingHost{ + ID: pendingID, Hostname: "bad-host", OS: "linux", Arch: "amd64", + PublicKey: pubReal, Fingerprint: store.FingerprintForKey(pubReal), + AnnouncedFromIP: "127.0.0.1", + FirstSeenAt: time.Now().UTC(), + LastSeenAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + }); err != nil { + t.Fatalf("seed: %v", err) + } + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/agent/pending?pending_id=" + pendingID + dialCtx, dialCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer dialCancel() + c, res, err := websocket.Dial(dialCtx, wsURL, nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + if res != nil && res.Body != nil { + _ = res.Body.Close() + } + defer func() { _ = c.CloseNow() }() + + // Read nonce. + rctx, rcancel := context.WithTimeout(context.Background(), 3*time.Second) + _, raw, _ := c.Read(rctx) + rcancel() + var nm nonceMessage + _ = json.Unmarshal(raw, &nm) + nonce, _ := base64.StdEncoding.DecodeString(nm.Nonce) + + // Sign with the wrong key. + sig := ed25519.Sign(privAttacker, nonce) + reply, _ := json.Marshal(signedNonceMessage{ + Type: "signed_nonce", Signature: base64.StdEncoding.EncodeToString(sig), + }) + wctx, wcancel := context.WithTimeout(context.Background(), 3*time.Second) + _ = c.Write(wctx, websocket.MessageText, reply) + wcancel() + + // Server should close. Read until error. + rctx2, rcancel2 := context.WithTimeout(context.Background(), 3*time.Second) + _, _, err = c.Read(rctx2) + rcancel2() + if err == nil { + t.Fatal("expected ws to close on bad signature") + } +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index bef412c..5fd6539 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -53,6 +53,11 @@ type Server struct { // announceRL is the per-source-IP token-bucket guarding // POST /api/agents/announce (P2-18). One process-local map. announceRL *announceLimiter + + // pendingHub holds live /ws/agent/pending sockets keyed by + // pending_id so the accept/reject handlers can push the bearer + // or close cleanly (P2-18b). + pendingHub *pendingHub } // New builds a configured but not-yet-started server. @@ -75,6 +80,7 @@ func New(deps Deps) *Server { deps: deps, drainLocks: make(map[string]*sync.Mutex), announceRL: newAnnounceLimiter(), + pendingHub: newPendingHub(), } s.routes(r) @@ -105,6 +111,10 @@ func (s *Server) routes(r chi.Router) { // globally capped (P2-18). r.Post("/agents/announce", s.handleAnnounce) + // Pending host management — admin-only (gated inside the handler). + r.Post("/pending-hosts/{id}/accept", s.handleAcceptPendingHost) + r.Post("/pending-hosts/{id}/reject", s.handleRejectPendingHost) + // Operator → server (authenticated). Spec.md §6.1's // /hosts/{id}/enrollment-token (regenerate) lands when the // host page can call it; for now just the create endpoint. @@ -185,6 +195,10 @@ func (s *Server) routes(r chi.Router) { r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone) r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone) + // Pending-host WebSocket (announce-and-approve, P2-18b). Mounted + // before /ws/agent so the more-specific route matches first. + r.Get("/ws/agent/pending", s.handlePendingWS) + // Agent ↔ server WebSocket. Bearer-authenticated inside the handler. if s.deps.Hub != nil { r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{ From a3a53e3b8714805276aac7ea3bae692fea69af21 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:09:47 +0100 Subject: [PATCH 11/16] agent: P2-18c announce-and-approve enrolment path When -enroll-server is supplied without -enroll-token, the agent mints (and persists) an Ed25519 keypair, POSTs /api/agents/announce, prints the SHA256 fingerprint in a copy-friendly banner, opens /ws/agent/pending, signs the server's nonce, and blocks until the admin clicks Accept (1h ceiling). On accept, persists the bearer + host_id from the 'enrolled' message; on reject (close code 4001) exits with a clear error. Repo creds are pushed via config.update on the first standard WS hello (P1-32 path), not in the enrolled message itself. --- cmd/agent/announce.go | 262 ++++++++++++++++++++++++++++++++ cmd/agent/main.go | 11 +- internal/agent/config/config.go | 7 + 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 cmd/agent/announce.go diff --git a/cmd/agent/announce.go b/cmd/agent/announce.go new file mode 100644 index 0000000..536baba --- /dev/null +++ b/cmd/agent/announce.go @@ -0,0 +1,262 @@ +// announce.go — agent-side announce-and-approve enrolment (P2-18c). +// +// Run path: when the agent has no AgentToken set but RM_SERVER is +// configured (and no -enroll-token was supplied), main() switches +// into announce mode: +// 1. Load (or mint+persist) an Ed25519 keypair in agent.yaml. +// 2. POST {hostname, os, arch, agent_version, restic_version, +// public_key} to /api/agents/announce. +// 3. Print the fingerprint to stderr in a copy-friendly banner so +// the operator can compare it against the dashboard. +// 4. Open /ws/agent/pending?pending_id=…, sign the nonce with our +// private key, wait for an `enrolled` message. +// 5. On enrolled: persist the bearer + repo creds, return; main() +// then drops into the normal WS run loop with the new bearer. +// 6. On reject: server closes the socket with code 4001; we exit +// with a clear message. +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + stdhttp "net/http" + "os" + "strings" + "time" + + "github.com/coder/websocket" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/config" + "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/secrets" + "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/sysinfo" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// announceRequest mirrors the server's announceRequest. Duplicated +// here so cmd/agent stays decoupled from the http package. +type announceRequest struct { + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + AgentVersion string `json:"agent_version"` + ResticVersion string `json:"restic_version"` + PublicKey string `json:"public_key"` +} + +type announceResponse struct { + PendingID string `json:"pending_id"` + Fingerprint string `json:"fingerprint"` + HostnameCollision bool `json:"hostname_collision"` +} + +type pendingNonceMessage struct { + Type string `json:"type"` + Nonce string `json:"nonce"` +} + +type pendingSignedMessage struct { + Type string `json:"type"` + Signature string `json:"signature"` +} + +type pendingEnrolledMessage struct { + Type string `json:"type"` + HostID string `json:"host_id"` + Bearer string `json:"bearer"` +} + +// doAnnounce runs the full announce → wait-for-accept flow. On +// success, persists the bearer + host_id into cfg + writes secrets +// for the repo creds the admin supplied at accept time. Returns +// only after the bearer has landed (or on hard error / reject). +func doAnnounce(serverURL string, cfg *config.Config, agentVersion string) error { + ctx, cancel := context.WithTimeout(context.Background(), 24*time.Hour) + defer cancel() + + // Ensure we have a keypair. + priv, pub, err := loadOrMintAnnounceKey(cfg) + if err != nil { + return fmt.Errorf("announce: keypair: %w", err) + } + fingerprint := store.FingerprintForKey(pub) + + snap, err := sysinfo.Collect(ctx, cfg.ResticPath) + if err != nil { + return fmt.Errorf("announce: sysinfo: %w", err) + } + + // POST /api/agents/announce. + body, _ := json.Marshal(announceRequest{ + Hostname: snap.Hostname, OS: string(snap.OS), Arch: string(snap.Arch), + AgentVersion: agentVersion, ResticVersion: snap.ResticVersion, + PublicKey: base64.StdEncoding.EncodeToString(pub), + }) + req, _ := stdhttp.NewRequestWithContext(ctx, "POST", + strings.TrimRight(serverURL, "/")+"/api/agents/announce", + strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("announce: POST: %w", err) + } + rawBody := readAllShort(res) + _ = res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + return fmt.Errorf("announce: server returned %d: %s", res.StatusCode, rawBody) + } + var ar announceResponse + if err := json.Unmarshal(rawBody, &ar); err != nil { + return fmt.Errorf("announce: parse response: %w", err) + } + + // Print the fingerprint banner. + fmt.Fprintln(os.Stderr, strings.Repeat("=", 64)) + fmt.Fprintln(os.Stderr, " Restic-manager: announce-and-approve enrolment") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, " Hostname : "+snap.Hostname) + fmt.Fprintln(os.Stderr, " Server : "+serverURL) + fmt.Fprintln(os.Stderr, " Pending ID : "+ar.PendingID) + fmt.Fprintln(os.Stderr, " Fingerprint : "+fingerprint) + if ar.HostnameCollision { + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, " WARNING: another pending host already uses this hostname.") + fmt.Fprintln(os.Stderr, " Confirm the fingerprint above matches what you see in the UI.") + } + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, " Compare the fingerprint with the one in the UI before accepting.") + fmt.Fprintln(os.Stderr, " Waiting for an admin to accept (1 hour timeout)…") + fmt.Fprintln(os.Stderr, strings.Repeat("=", 64)) + + // Open /ws/agent/pending and run the nonce-sign handshake. + wsURL := wsURLFromHTTP(serverURL) + "/ws/agent/pending?pending_id=" + ar.PendingID + dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second) + c, dialRes, err := websocket.Dial(dialCtx, wsURL, nil) + dialCancel() + if err != nil { + return fmt.Errorf("announce: dial pending ws: %w", err) + } + if dialRes != nil && dialRes.Body != nil { + _ = dialRes.Body.Close() + } + defer func() { _ = c.CloseNow() }() + + // Read nonce. + rctx, rcancel := context.WithTimeout(ctx, 30*time.Second) + _, raw, err := c.Read(rctx) + rcancel() + if err != nil { + return fmt.Errorf("announce: read nonce: %w", err) + } + var nm pendingNonceMessage + if err := json.Unmarshal(raw, &nm); err != nil { + return fmt.Errorf("announce: parse nonce: %w", err) + } + nonce, err := base64.StdEncoding.DecodeString(nm.Nonce) + if err != nil { + return fmt.Errorf("announce: decode nonce: %w", err) + } + sig := ed25519.Sign(priv, nonce) + reply, _ := json.Marshal(pendingSignedMessage{ + Type: "signed_nonce", Signature: base64.StdEncoding.EncodeToString(sig), + }) + wctx, wcancel := context.WithTimeout(ctx, 10*time.Second) + if err := c.Write(wctx, websocket.MessageText, reply); err != nil { + wcancel() + return fmt.Errorf("announce: write signed nonce: %w", err) + } + wcancel() + + // Block until enrolled (or reject / disconnect). + rctx2, rcancel2 := context.WithTimeout(ctx, 1*time.Hour) + defer rcancel2() + _, raw2, err := c.Read(rctx2) + if err != nil { + // CloseError with our reject code 4001 = admin rejected. + var ce websocket.CloseError + if errors.As(err, &ce) && ce.Code == 4001 { + return errors.New("announce: rejected by admin") + } + return fmt.Errorf("announce: wait for enrolled: %w", err) + } + var em pendingEnrolledMessage + if err := json.Unmarshal(raw2, &em); err != nil { + return fmt.Errorf("announce: parse enrolled: %w", err) + } + if em.Type != "enrolled" || em.Bearer == "" { + return fmt.Errorf("announce: bad enrolled payload: %s", raw2) + } + + // Persist the bearer + host_id. + cfg.ServerURL = serverURL + cfg.HostID = em.HostID + cfg.AgentToken = em.Bearer + if err := cfg.EnsureSecretsKey(); err != nil { + return fmt.Errorf("announce: mint secrets key: %w", err) + } + // Note: repo creds aren't pushed in the enrolled message — the + // server pushes them via `config.update` on first WS hello. The + // secrets store will start empty and fill in then. + if err := cfg.Save(); err != nil { + return fmt.Errorf("announce: save config: %w", err) + } + // Touch the secrets store so it exists with the right perms. + keyBytes, _ := cfg.SecretsKeyBytes() + if _, err := secrets.New(cfg.ResolvedSecretsPath(), keyBytes); err != nil { + return fmt.Errorf("announce: open secrets store: %w", err) + } + fmt.Fprintln(os.Stderr, "Accepted. Bearer persisted; reconnecting via the standard WS.") + return nil +} + +// loadOrMintAnnounceKey returns the (priv, pub) keypair, generating +// + persisting one when AnnounceKey is empty. The private key holds +// the public half in its tail 32 bytes per ed25519 convention. +func loadOrMintAnnounceKey(cfg *config.Config) (ed25519.PrivateKey, ed25519.PublicKey, error) { + if cfg.AnnounceKey != "" { + raw, err := base64.StdEncoding.DecodeString(cfg.AnnounceKey) + if err != nil { + return nil, nil, fmt.Errorf("decode AnnounceKey: %w", err) + } + if len(raw) != ed25519.PrivateKeySize { + return nil, nil, fmt.Errorf("AnnounceKey must be %d bytes, got %d", + ed25519.PrivateKeySize, len(raw)) + } + priv := ed25519.PrivateKey(raw) + pub := priv.Public().(ed25519.PublicKey) + return priv, pub, nil + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate keypair: %w", err) + } + cfg.AnnounceKey = base64.StdEncoding.EncodeToString(priv) + if err := cfg.Save(); err != nil { + return nil, nil, fmt.Errorf("persist AnnounceKey: %w", err) + } + return priv, pub, nil +} + +// wsURLFromHTTP swaps the http(s) scheme for ws(s). +func wsURLFromHTTP(httpURL string) string { + switch { + case strings.HasPrefix(httpURL, "https://"): + return "wss://" + strings.TrimPrefix(httpURL, "https://") + case strings.HasPrefix(httpURL, "http://"): + return "ws://" + strings.TrimPrefix(httpURL, "http://") + default: + return httpURL + } +} + +// readAllShort reads up to 64KB of the response body. The announce +// response is small; we cap to avoid pathological server replies. +func readAllShort(res *stdhttp.Response) []byte { + buf := make([]byte, 64*1024) + n, _ := res.Body.Read(buf) + return buf[:n] +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 0c2b691..1bf1954 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -59,8 +59,17 @@ func run() error { return doEnroll(*enrollServer, *enrollToken, cfg, version) } + // Announce-and-approve: -enroll-server set, no token, agent not + // yet enrolled. Run the announce flow inline; on success the cfg + // has the bearer + host_id and we drop into the normal run loop. + if !cfg.Enrolled() && *enrollServer != "" { + if err := doAnnounce(*enrollServer, cfg, version); err != nil { + return fmt.Errorf("announce: %w", err) + } + } + if !cfg.Enrolled() { - return fmt.Errorf("agent is not enrolled; run with -enroll-server and -enroll-token first (config %q)", *configPath) + return fmt.Errorf("agent is not enrolled; run with -enroll-server (and either -enroll-token or wait for admin to accept the announce) first (config %q)", *configPath) } ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) diff --git a/internal/agent/config/config.go b/internal/agent/config/config.go index c10e20c..1e0cdd1 100644 --- a/internal/agent/config/config.go +++ b/internal/agent/config/config.go @@ -62,6 +62,13 @@ type Config struct { LegacyRepoURL string `yaml:"repo_url,omitempty"` LegacyRepoPassword string `yaml:"repo_password,omitempty"` + // AnnounceKey is the base64-encoded Ed25519 private key used by + // announce-and-approve enrolment (P2-18). Generated on first + // announce, persisted so the agent can re-attach to the same + // pending row across restarts. 64 bytes when decoded. + // Empty for token-flow enrolments. + AnnounceKey string `yaml:"announce_key,omitempty"` + // path is the file we loaded from. Used by Save. path string `yaml:"-"` } From bbdf631a012e1e9eb285dd7bfaf5c038b775a84f Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:11:32 +0100 Subject: [PATCH 12/16] ui+server: P2-18d pending hosts dashboard panel + expiry sweeper Dashboard handler loads ListPendingHosts(now); template renders a warn-bordered panel above the host table with hostname, OS/arch, fingerprint (selectable / copyable), source IP, age, expiry. Each row carries an inline accept form (repo URL/user/password) plus a Reject button. cmd/server adds a 60s ticker calling DeleteExpiredPendingHosts so 1h-stale rows drop off. --- cmd/server/main.go | 8 +++++ internal/server/http/ui_handlers.go | 19 ++++++---- web/templates/pages/dashboard.html | 54 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index c97b39d..a083a6d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -156,6 +156,10 @@ func run() error { // shouldn't, but the queue exists either way). pendingDrainTick := time.NewTicker(30 * time.Second) defer pendingDrainTick.Stop() + // Pending-hosts expiry sweeper: drops announce rows past their 1h + // ceiling so the dashboard panel doesn't accumulate stale entries. + pendingExpiryTick := time.NewTicker(60 * time.Second) + defer pendingExpiryTick.Stop() mt := maintenance.New(st) go func() { for { @@ -176,6 +180,10 @@ func run() error { } case <-pendingDrainTick.C: srv.DrainAllDue(ctx) + case <-pendingExpiryTick.C: + if n, err := st.DeleteExpiredPendingHosts(ctx, time.Now().UTC()); err == nil && n > 0 { + slog.Info("expired pending hosts swept", "n", n) + } case <-maintenanceTick.C: decisions, err := mt.Decide(ctx, time.Now().UTC()) if err != nil { diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index ad23e03..f5d9594 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -109,9 +109,10 @@ func (s *Server) version() string { // dashboardPage is the data the dashboard template renders against. type dashboardPage struct { - Hosts []dashboardHostRow - HostCount int - Summary store.FleetSummary + Hosts []dashboardHostRow + HostCount int + Summary store.FleetSummary + PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d) } // dashboardHostRow carries a host plus the per-row Run-now decision @@ -220,12 +221,18 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request) rows = append(rows, row) } + pending, perr := s.deps.Store.ListPendingHosts(r.Context(), time.Now().UTC()) + if perr != nil { + slog.Warn("ui dashboard: list pending hosts", "err", perr) + } + view := s.baseView(u) view.OpenAlerts = summary.OpenAlerts view.Page = dashboardPage{ - Hosts: rows, - HostCount: len(hosts), - Summary: summary, + Hosts: rows, + HostCount: len(hosts), + Summary: summary, + PendingHosts: pending, } if err := s.deps.UI.Render(w, "dashboard", view); err != nil { slog.Error("ui: render dashboard", "err", err) diff --git a/web/templates/pages/dashboard.html b/web/templates/pages/dashboard.html index d936850..fdcd487 100644 --- a/web/templates/pages/dashboard.html +++ b/web/templates/pages/dashboard.html @@ -65,6 +65,60 @@
+ {{/* ---------- Pending hosts (announce-and-approve queue) ---------- */}} + {{if gt (len $page.PendingHosts) 0}} +
+
+
+

Pending hosts

+
{{len $page.PendingHosts}} waiting for approval
+
+
+
+ {{range $i, $ph := $page.PendingHosts}} +
+
+
+
+ {{$ph.Hostname}} + {{$ph.OS}}/{{$ph.Arch}} + agent {{$ph.AgentVersion}} + restic {{$ph.ResticVersion}} +
+
+ {{$ph.Fingerprint}} +
+
+ from {{$ph.AnnouncedFromIP}} · {{relTime $ph.FirstSeenAt}} + · expires {{relTime $ph.ExpiresAt}} +
+
+
+ + + +
+ + +
+
+
+
+ {{end}} +
+
+ {{end}} + {{/* ---------- hosts table ---------- */}}
From d29475560d46b242e63da88cd0824f90ce9d9539 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:13:56 +0100 Subject: [PATCH 13/16] agent: P2-16 Windows service (SCM) integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/agent/service: build-tagged into service_windows.go (svc.Handler that listens for Stop/Shutdown + delegates to the agent loop) and service_other.go (foreground stub for Linux/macOS). install_windows.go wraps mgr.Connect+CreateService/Delete/Start/Stop for the new 'restic-manager-agent install|uninstall|start|stop' subcommands. Cross-compile verified: GOOS=windows GOARCH=amd64 go build ./cmd/agent succeeds. UNTESTED on Windows itself — the SCM round-trip can't be exercised from Linux CI; treat as a starting point for the first real Windows install. --- cmd/agent/main.go | 22 +++++ internal/agent/service/install_windows.go | 103 ++++++++++++++++++++++ internal/agent/service/service_other.go | 44 +++++++++ internal/agent/service/service_windows.go | 93 +++++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 internal/agent/service/install_windows.go create mode 100644 internal/agent/service/service_other.go create mode 100644 internal/agent/service/service_windows.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 1bf1954..ac43d3c 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -17,6 +17,7 @@ import ( "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/runner" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/scheduler" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/secrets" + "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/service" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/sysinfo" "gitea.dcglab.co.uk/steve/restic-manager/internal/agent/wsclient" "gitea.dcglab.co.uk/steve/restic-manager/internal/api" @@ -33,6 +34,27 @@ func main() { } func run() error { + // Optional first positional verb for SCM control on Windows. + // `restic-manager-agent install|uninstall|start|stop` route into + // the service package; everything else falls through to the + // flag-driven default (which is what systemd / interactive runs + // hit). On non-Windows builds these verbs return a clear error. + if len(os.Args) > 1 { + switch os.Args[1] { + case "install": + return service.Install() + case "uninstall": + return service.Uninstall() + case "start": + return service.Start() + case "stop": + return service.Stop() + case "run": + // Strip the verb so flag.Parse sees the rest unchanged. + os.Args = append([]string{os.Args[0]}, os.Args[2:]...) + } + } + configPath := flag.String("config", config.DefaultPath(), "path to agent.yaml") enrollServer := flag.String("enroll-server", "", "server URL (used with -enroll-token to perform first-run enrollment)") enrollToken := flag.String("enroll-token", "", "one-time enrollment token (operator copies this from the UI)") diff --git a/internal/agent/service/install_windows.go b/internal/agent/service/install_windows.go new file mode 100644 index 0000000..2092b53 --- /dev/null +++ b/internal/agent/service/install_windows.go @@ -0,0 +1,103 @@ +//go:build windows + +// install_windows.go — thin wrappers around the Service Control +// Manager via golang.org/x/sys/windows/svc/mgr. Used by the agent's +// `install` / `uninstall` / `start` / `stop` subcommands. +// +// UNTESTED in CI. Mirrors the canonical example shape; if you need +// to extend this, prefer copying from x/sys/windows/svc/example +// over inventing new patterns. +package service + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/windows/svc/mgr" +) + +// Install registers the service with the SCM, pointing it at the +// currently-running binary. The service starts on every boot and +// runs as LocalSystem (default). +func Install() error { + exe, err := os.Executable() + if err != nil { + return fmt.Errorf("install: locate executable: %w", err) + } + exe, err = filepath.Abs(exe) + if err != nil { + return fmt.Errorf("install: absolutise path: %w", err) + } + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("install: connect SCM: %w", err) + } + defer m.Disconnect() + if existing, err := m.OpenService(ServiceName); err == nil { + _ = existing.Close() + return fmt.Errorf("service %q already installed; uninstall first", ServiceName) + } + s, err := m.CreateService(ServiceName, exe, mgr.Config{ + StartType: mgr.StartAutomatic, + DisplayName: "Restic-manager agent", + Description: "Backs up this host on the schedule the central restic-manager dictates.", + }, "run") + if err != nil { + return fmt.Errorf("install: create service: %w", err) + } + defer s.Close() + return nil +} + +// Uninstall removes the service from the SCM. Caller is expected to +// stop the service first; this returns the SCM's error if it's +// still running. +func Uninstall() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("uninstall: connect SCM: %w", err) + } + defer m.Disconnect() + s, err := m.OpenService(ServiceName) + if err != nil { + return fmt.Errorf("uninstall: open service: %w", err) + } + defer s.Close() + if err := s.Delete(); err != nil { + return fmt.Errorf("uninstall: delete service: %w", err) + } + return nil +} + +// Start asks the SCM to start the installed service. No-op if it's +// already running (the SCM returns an error which we surface). +func Start() error { + m, err := mgr.Connect() + if err != nil { + return err + } + defer m.Disconnect() + s, err := m.OpenService(ServiceName) + if err != nil { + return err + } + defer s.Close() + return s.Start() +} + +// Stop sends a stop control to the service. +func Stop() error { + m, err := mgr.Connect() + if err != nil { + return err + } + defer m.Disconnect() + s, err := m.OpenService(ServiceName) + if err != nil { + return err + } + defer s.Close() + _, err = s.Control(0x00000001) // SERVICE_CONTROL_STOP + return err +} diff --git a/internal/agent/service/service_other.go b/internal/agent/service/service_other.go new file mode 100644 index 0000000..d6d27c2 --- /dev/null +++ b/internal/agent/service/service_other.go @@ -0,0 +1,44 @@ +//go:build !windows + +// service_other.go — non-Windows fallback for the service package. +// Linux uses systemd to wrap the agent; the binary itself just runs +// in the foreground. Run() therefore just executes the agent loop +// and returns. install/uninstall sub-commands return a clear error +// directing the operator at the install.sh + systemd unit shipped +// in deploy/install/. +package service + +import ( + "context" + "errors" +) + +// AgentRun is the function-pointer shape main passes in. Same shape +// as the Windows variant so the call site is portable. +type AgentRun func(ctx context.Context) error + +// Run executes the agent loop in the foreground; on Unix the +// systemd unit (or whatever runs us) supplies the lifecycle. +func Run(agentRun AgentRun) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + return agentRun(ctx) +} + +// Install registers the agent as a service. Windows-only; on Unix +// the systemd unit covers this — returns an error pointing there. +func Install() error { return errUnsupported("install") } + +// Uninstall is the inverse of Install. Windows-only. +func Uninstall() error { return errUnsupported("uninstall") } + +// Start asks the OS service manager to start the installed service. +// Windows-only. +func Start() error { return errUnsupported("start") } + +// Stop sends a stop signal to the installed service. Windows-only. +func Stop() error { return errUnsupported("stop") } + +func errUnsupported(verb string) error { + return errors.New("service " + verb + " is Windows-only; use the systemd unit on Linux") +} diff --git a/internal/agent/service/service_windows.go b/internal/agent/service/service_windows.go new file mode 100644 index 0000000..f045ada --- /dev/null +++ b/internal/agent/service/service_windows.go @@ -0,0 +1,93 @@ +//go:build windows + +// service_windows.go — Service Control Manager integration for the +// agent on Windows (P2-16). Implements the svc.Handler interface so +// `restic-manager-agent run` works under both interactive and SCM +// contexts. install/uninstall live in install_windows.go. +// +// UNTESTED on Windows in this repo's CI (the runners are Linux). +// The shape mirrors the canonical example in +// golang.org/x/sys/windows/svc/example. Treat any deviation from +// that example as suspicious. +package service + +import ( + "context" + "errors" + "log/slog" + + "golang.org/x/sys/windows/svc" +) + +// ServiceName is the SCM identifier for the agent service. +const ServiceName = "restic-manager-agent" + +// AgentRun is the function the service handler calls to start the +// agent's main loop. Pass cmd/agent's run-loop entry point at the +// call site so this package stays free of cross-cmd imports. +type AgentRun func(ctx context.Context) error + +// Run delegates to the SCM dispatcher when running under Windows +// service control, otherwise runs the agent loop in the foreground +// (for `restic-manager-agent run` from a console, e.g. while +// debugging on a developer's box). +func Run(agentRun AgentRun) error { + isService, err := svc.IsWindowsService() + if err != nil { + return err + } + if !isService { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + return agentRun(ctx) + } + return svc.Run(ServiceName, &handler{run: agentRun}) +} + +// handler implements svc.Handler. Execute is called once when the +// service is started. We spawn the agent loop in a goroutine and +// listen for SCM Stop / Shutdown notifications, cancelling the +// context to wind down cleanly. +type handler struct { + run AgentRun +} + +func (h *handler) Execute(_ []string, req <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + const accepted = svc.AcceptStop | svc.AcceptShutdown + status <- svc.Status{State: svc.StartPending} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + doneCh := make(chan error, 1) + go func() { + doneCh <- h.run(ctx) + }() + status <- svc.Status{State: svc.Running, Accepts: accepted} + + for { + select { + case c := <-req: + switch c.Cmd { + case svc.Interrogate: + status <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + slog.Info("svc: stop requested") + cancel() + status <- svc.Status{State: svc.StopPending} + if err := <-doneCh; err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("svc: agent loop exited with error", "err", err) + return false, 1 + } + return false, 0 + } + case err := <-doneCh: + // Agent loop exited on its own — uncommon (only via signal + // or fatal error). Surface as an SCM stop. + if err != nil && !errors.Is(err, context.Canceled) { + slog.Warn("svc: agent loop exited unexpectedly", "err", err) + return false, 1 + } + return false, 0 + } + } +} From 8ceb76c73393b958ff08e6f087515905c1bd1e27 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:15:18 +0100 Subject: [PATCH 14/16] deploy: P2-17 install.ps1 (Windows installer) Pwsh installer that detects arch, downloads $Server/agent/binary?os=windows&arch=amd64 to C:\Program Files\restic-manager\, runs the agent in -enroll-server [+ -enroll-token] mode (token flow OR announce-and-approve), then calls 'restic-manager-agent install' to register the SCM service. Surfaces existing scheduled tasks named *restic* without disabling. CLAUDE.md restage block updated to also stage install.ps1 alongside install.sh. --- CLAUDE.md | 2 + deploy/install/install.ps1 | 133 +++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 deploy/install/install.ps1 diff --git a/CLAUDE.md b/CLAUDE.md index c623059..64136fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,8 @@ cp bin/restic-manager-agent \ /tmp/rm-smoke/data/agent-binaries/restic-manager-agent-linux-amd64 cp deploy/install/install.sh \ /tmp/rm-smoke/data/install/install.sh +cp deploy/install/install.ps1 \ + /tmp/rm-smoke/data/install/install.ps1 cp deploy/install/restic-manager-agent.service \ /tmp/rm-smoke/data/install/restic-manager-agent.service diff --git a/deploy/install/install.ps1 b/deploy/install/install.ps1 new file mode 100644 index 0000000..72b7c9d --- /dev/null +++ b/deploy/install/install.ps1 @@ -0,0 +1,133 @@ +# install.ps1 — Windows installer for the restic-manager agent (P2-17). +# +# Usage (Run as administrator): +# $env:RM_SERVER = "https://restic.lab.example" +# $env:RM_TOKEN = "" # omit for announce-and-approve +# iwr "$env:RM_SERVER/install/install.ps1" -UseBasicParsing | iex +# +# What it does: +# 1. checks for admin elevation +# 2. downloads the matching agent binary from the server +# 3. lays down C:\Program Files\restic-manager\ and +# C:\ProgramData\restic-manager\ (config + state) +# 4. registers the agent as a Windows service via the agent's own +# `install` subcommand (which uses the SCM API) +# 5. enrolls (token flow if RM_TOKEN set, otherwise announce flow) +# by spawning the agent with the right CLI flags and waits +# until config is written +# 6. surfaces (but does NOT disable) any existing scheduled tasks +# whose name contains "restic" so the operator can decide +# +# Idempotent — safe to re-run. + +[CmdletBinding()] +param( + [string]$Server = $env:RM_SERVER, + [string]$Token = $env:RM_TOKEN, + [string]$InstallDir = 'C:\Program Files\restic-manager', + [string]$DataDir = 'C:\ProgramData\restic-manager' +) + +$ErrorActionPreference = 'Stop' + +function Test-Admin { + $id = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $pri = New-Object System.Security.Principal.WindowsPrincipal($id) + return $pri.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Detect-Arch { + switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { return 'amd64' } + 'ARM64' { return 'arm64' } + default { throw "unsupported PROCESSOR_ARCHITECTURE: $($env:PROCESSOR_ARCHITECTURE)" } + } +} + +function Detect-ResticTasks { + Write-Host '' + Write-Host '— Existing restic-named scheduled tasks (review manually) —' + try { + $tasks = Get-ScheduledTask -ErrorAction SilentlyContinue | + Where-Object { $_.TaskName -match 'restic' -or $_.TaskPath -match 'restic' } + if ($tasks) { + foreach ($t in $tasks) { + Write-Host " * $($t.TaskPath)$($t.TaskName) state=$($t.State)" + Write-Host " Disable with: Disable-ScheduledTask -TaskName '$($t.TaskName)' -TaskPath '$($t.TaskPath)'" + } + } else { + Write-Host ' (none found)' + } + } catch { + Write-Host ' (Get-ScheduledTask failed; review the Task Scheduler UI manually)' + } + Write-Host '' +} + +# --- preflight ------------------------------------------------------- + +if (-not (Test-Admin)) { + throw 'install.ps1: must be run from an elevated PowerShell (Run as administrator).' +} +if (-not $Server) { + throw 'install.ps1: -Server (or $env:RM_SERVER) is required, e.g. https://restic.lab.example' +} + +$arch = Detect-Arch +Write-Host "install.ps1: server=$Server arch=$arch" + +# --- directories ----------------------------------------------------- + +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +New-Item -ItemType Directory -Force -Path $DataDir | Out-Null + +# --- download agent -------------------------------------------------- + +$agentExe = Join-Path $InstallDir 'restic-manager-agent.exe' +$tmpExe = "$agentExe.tmp" +$dlURL = "$Server/agent/binary?os=windows&arch=$arch" +Write-Host "install.ps1: downloading $dlURL" +Invoke-WebRequest -UseBasicParsing -Uri $dlURL -OutFile $tmpExe +# Atomic-ish replace: stop service if running so the .exe isn't busy. +try { Stop-Service -Name 'restic-manager-agent' -ErrorAction SilentlyContinue } catch {} +Move-Item -Force -Path $tmpExe -Destination $agentExe + +# --- enroll / announce ----------------------------------------------- + +$cfgPath = Join-Path $DataDir 'agent.yaml' +$args = @('-config', $cfgPath, '-enroll-server', $Server) +if ($Token) { + $args += @('-enroll-token', $Token) + Write-Host 'install.ps1: enrolling with one-time token' +} else { + Write-Host 'install.ps1: no RM_TOKEN — running announce-and-approve flow.' + Write-Host ' The fingerprint will print below. Compare it with the dashboard before clicking Accept.' +} +& $agentExe @args +if ($LASTEXITCODE -ne 0) { + throw "install.ps1: agent enrolment failed (exit $LASTEXITCODE)" +} + +# --- install + start service ---------------------------------------- + +# The 'install' subcommand registers the service via the SCM. If +# already registered, it errors loudly — re-run with -Force only if +# you've manually verified. +try { + & $agentExe install +} catch { + Write-Host "install.ps1: service may already be registered ($_); continuing." +} +try { + Start-Service -Name 'restic-manager-agent' +} catch { + Write-Host "install.ps1: Start-Service failed ($_); check Event Viewer." +} + +Detect-ResticTasks + +Write-Host '' +Write-Host 'install.ps1: done.' +Write-Host " config : $cfgPath" +Write-Host " binary : $agentExe" +Write-Host " service: restic-manager-agent (Get-Service to inspect)" From c691dc8a56ce1702442ee0224cb7fead9f3969c8 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 11:27:09 +0100 Subject: [PATCH 15/16] tasks: tick P2 completion + Playwright sweep screenshots P2R-09/10/11/12/13/14, P2-16/17/18 all marked done. Acceptance line for Windows hosts annotated as 'compile-verified, untested in CI'. _diag/p2-completion-sweep/ holds the dashboard + host-detail + schedules + sources + repo + source-group-edit screenshots from a clean sweep against :8080. Zero console errors throughout. announce_test.go: rate-limit + global-cap subtests dropped t.Parallel to avoid racing on the package-level tunables under -race. --- internal/server/http/announce_test.go | 7 ++--- tasks.md | 42 ++++++++++++++++++--------- web/static/css/styles.css | 2 +- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/internal/server/http/announce_test.go b/internal/server/http/announce_test.go index 097e500..ba4a56a 100644 --- a/internal/server/http/announce_test.go +++ b/internal/server/http/announce_test.go @@ -111,10 +111,9 @@ func TestAnnounceHostnameCollisionFlag(t *testing.T) { } func TestAnnounceRateLimit(t *testing.T) { - t.Parallel() + // Not t.Parallel — mutates the package-level announceMaxPerMin + // var, which would otherwise race other announce tests. _, url, _ := newTestServerWithHub(t) - // Lower the limit for the duration of this test (the limiter is - // per-server-instance so we don't disturb parallel tests). prev := announceMaxPerMin announceMaxPerMin = 2 t.Cleanup(func() { announceMaxPerMin = prev }) @@ -137,7 +136,7 @@ func TestAnnounceRateLimit(t *testing.T) { } func TestAnnounceGlobalCap(t *testing.T) { - t.Parallel() + // Not t.Parallel — mutates the package-level announceGlobalCap. _, url, st := newTestServerWithHub(t) prev := announceGlobalCap announceGlobalCap = 1 diff --git a/tasks.md b/tasks.md index 8b86905..7a65c72 100644 --- a/tasks.md +++ b/tasks.md @@ -178,26 +178,26 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [x] **P2R-07** (S) Repo stats panel on the Repo page: total size, raw size, last-check timestamp + status (color-coded), last-prune timestamp, stale-lock banner. Backed by `restic stats --json --mode raw-data` that the agent ships in a `repo.stats` envelope after every backup / check / prune / unlock; persisted via `Store.UpsertHostRepoStats` into a new `host_repo_stats` projection table. - [x] **P2R-08** (M) Pending-runs queue worker. Scheduled backup fires that race an agent disconnect queue to `pending_runs`. Drained on a 30s server-side tick **and** on agent reconnect (via `onAgentHello`); per-host TryLock mutex prevents the two paths double-dispatching the same row. Exponential backoff capped at 30 minutes; abandons rows that exceed the source-group's `retry_max` (audit-logged) or whose schedule/group has genuinely been deleted. -### P2 redesign — Phase 6 (auto-init follow-up) — TODO +### P2 redesign — Phase 6 (auto-init follow-up) ✅ -- [ ] **P2R-09** (S) Auto-init UX polish. Surface init result on host detail (small "repo ready · initialised by you on …" line; or "init failed — see job N · retry" if init failed). Re-init button on Repo page danger zone wipes then re-runs init (admin only, audit-logged, two-step confirm with the host name typed in). +- [x] **P2R-09** (S) Auto-init UX polish. Latest `init` job status surfaced under the host-detail vitals strip (succeeded/failed/running/queued, with link to the live job log on non-success). Danger-zone `POST /hosts/{id}/repo/reinit` dispatches a fresh init job after the operator types the host name to confirm; audit row records `host.repo_reinit`. -### Pre/post hooks (rehomed onto source groups) — TODO +### Pre/post hooks (rehomed onto source groups) ✅ -- [ ] **P2R-10** (M) Hook schema: `source_group.pre_hook`, `source_group.post_hook`, `host.pre_hook_default`, `host.post_hook_default`. Encrypted at rest (existing `crypto.AEAD`). Admin-only edit. Audit-logged. -- [ ] **P2R-11** (M) Agent execution of hooks: configurable shell per host. `pre_hook` failure aborts the backup. `post_hook` always runs with `RM_JOB_STATUS` env var. Stdout/stderr captured into `JobLog` with a `hook:` prefix. Hooks only run for `kind=backup` jobs (forget/prune/check/unlock skip them, per spec.md §14.3). -- [ ] **P2R-12** (S) Hook editor UI on source-group edit page (per-group override) and host Settings tab (host-wide default). Validation rejects non-backup contexts. Warning banner: "this hook runs as the agent service user (root on Linux; LocalSystem on Windows)". +- [x] **P2R-10** (M) Hook schema: migration 0010 adds `pre_hook`/`post_hook` BLOB columns to `source_groups` and `pre_hook_default`/`post_hook_default` to `hosts`. Bytes stored verbatim — AEAD encrypt/decrypt at the HTTP layer (per-slot AD bytes). Round-trip tests cover set/clear semantics on both tables. +- [x] **P2R-11** (M) Agent execution of hooks: `runner.BackupHooks` + `runHook` helper invoked via `/bin/sh -c` (`cmd.exe /C` on Windows). pre_hook non-zero exit aborts the backup; post_hook always runs with `RM_JOB_STATUS=succeeded|failed` in env. Output streamed as `hook(): …` log.stream lines. Hooks only run for `kind=backup`. Server side resolves group → host default → empty and ships plaintext on the WS payload (decrypt at HTTP layer). +- [x] **P2R-12** (S) Hook editor UI: source-group edit form gains pre/post hook textareas with the service-user warning banner; bodies AEAD-encrypted on save (per-group AD). Repo page adds a host-default Hooks panel with the same shape; saved via `POST /hosts/{id}/repo/hooks`. -### Bandwidth + niceties (rehomed onto host + source groups) — TODO +### Bandwidth + niceties (rehomed onto host + source groups) ✅ -- [ ] **P2R-13** (S) Bandwidth limit fields. Host-wide caps (`Host.BandwidthUpKBps`, `BandwidthDownKBps` — schema is in 0008 already, just needs UI on the Repo page) applied to every restic invocation. Per-job override on Run-now (override field on the Run-now confirm dialog). Maps to `restic --limit-upload` / `--limit-download`. -- [ ] **P2R-14** (S) Schedule "next run" / "last run" surfaced on host card (dashboard row) + on the Schedules tab. "Next run" computed server-side from cron + now; "last run" from the most recent job with `actor_kind=schedule` for any schedule that uses any of the host's source groups. +- [x] **P2R-13** (S) Bandwidth limit fields. `restic.Env` gains `LimitUploadKBps`/`LimitDownloadKBps`, emitted as `--limit-upload`/`--limit-download` global flags before the subcommand on every invocation. Agent dispatcher tracks host-wide caps received via `config.update`; server pushes them on hello and after `PUT /api/hosts/{id}/bandwidth`. Per-job override on the per-source-group Run-now form (collapsed `
` "Limit bandwidth for this run" with two KB/s inputs); override wins over host caps. +- [x] **P2R-14** (S) Schedule "next run" / "last run". New `store.LatestJobBySchedule` query. Schedules tab grows two columns (Next derived from cron via `robfig/cron/v3.Parse(...).Next`, Last from latest `actor_kind=schedule` job). Dashboard host row prepends `next 12h ago/from now` when a single covering schedule is the run-now candidate. -### Cross-platform + alt-enrolment (unchanged by redesign) — TODO +### Cross-platform + alt-enrolment ✅ -- [ ] **P2-16** (M) Windows service integration: agent runs under the Service Control Manager via `golang.org/x/sys/windows/svc`; install/uninstall/start/stop wired up. -- [ ] **P2-17** (M) `install.ps1` (Windows): downloads agent, installs as service, enrolls; detects existing scheduled tasks named `*restic*` and prints them for manual review. -- [ ] **P2-18** (L) Announce-and-approve enrollment (second enrollment mode, alongside the token flow that ships in Phase 1): +- [x] **P2-16** (M) Windows service integration: `internal/agent/service` (build-tagged) implements `svc.Handler`; new `restic-manager-agent install|uninstall|start|stop|run` subcommands wrap the SCM via `golang.org/x/sys/windows/svc/mgr`. Cross-compile verified (`GOOS=windows GOARCH=amd64 go build ./cmd/agent`); **untested on Windows itself** — Linux CI can't exercise the SCM round-trip. +- [x] **P2-17** (M) `install.ps1` (Windows): pwsh installer that detects arch, downloads `$Server/agent/binary?os=windows&arch=amd64`, runs the agent in `-enroll-server` (+ optional `-enroll-token`) mode (token flow OR announce-and-approve), then registers the service via `restic-manager-agent install`. Surfaces existing scheduled tasks named `*restic*` without disabling. Served by the existing `GET /install/*` handler; restage block in CLAUDE.md updated. +- [x] **P2-18** (L) Announce-and-approve enrolment (second enrolment mode): - Agent run with no `RM_TOKEN` generates a local Ed25519 keypair (persisted alongside the encrypted secrets blob), then `POST /api/agents/announce` with `{hostname, os, arch, agent_version, restic_version, public_key}`. Server stores a `pending_hosts` row (`public_key`, `fingerprint = sha256(public_key)`, `announced_from_ip`, `first_seen_at`, `last_seen_at`, `expires_at = now+1h`). Hostname collisions with existing or other pending rows are flagged in the response so the install script can warn loudly on the endpoint terminal. - Agent then opens a long-poll/WS to `/ws/agent/pending` authenticated by signing a server-issued nonce with its private key — proves possession of the key tied to the pending row. Connection stays open; agent waits. - Install script prints the fingerprint on the endpoint's terminal in a copy-friendly form (e.g. `SHA256:ab12…cd34`) and tells the operator to compare it to the one shown in the UI before clicking accept. @@ -205,6 +205,20 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - Server-side guards: per-source-IP rate limit on `/api/agents/announce` (token-bucket, e.g. 10/min); global cap on pending rows (e.g. 100); pending rows auto-expire after 1h; duplicate-hostname pending rows allowed but visually flagged in UI; accepting one does **not** auto-reject the others (admin sees them all and decides — defends against the "attacker announces first, real host second" race). - Token-based enrollment (Phase 1) remains the default and is unchanged; announce-and-approve is opt-in for interactive installs. Docs explicitly call out that the fingerprint comparison step is what makes this flow safe — without it, this is no better than trusting `hostname` over the wire. + > **As shipped:** migration 0011 + `store/pending_hosts.go` cover the table. + > `POST /api/agents/announce` (rate-limited 10/min/IP, global cap 100 in-flight rows) + > returns `{pending_id, fingerprint, hostname_collision}`. `GET /ws/agent/pending` + > runs the Ed25519 nonce-sign handshake. Admin POSTs to + > `/api/pending-hosts/{id}/accept|reject` (audit-logged as + > `host.accept_pending`/`host.reject_pending`). Dashboard panel renders the queue + > with a copyable fingerprint + inline accept form (URL/user/password). 60s + > server ticker sweeps expired rows. Agent: `cmd/agent/announce.go` mints + + > persists an Ed25519 keypair into `agent.yaml`'s `announce_key` field; runs + > automatically when `-enroll-server` is supplied without `-enroll-token`. The + > install scripts haven't been updated to surface the printed fingerprint + > beyond the agent's own banner — the operator reads it from the install + > script's stdout. + ### Phase 2 acceptance - A host can be onboarded end-to-end with no manual REST: enrol → auto-init runs → operator opens host → creates source group(s) → attaches them to one or more schedules → schedule fires on time → backup runs against the right paths with the right retention → snapshots tagged by group name appear in UI. @@ -212,7 +226,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - Server-side maintenance ticker drives forget/prune/check at the configured cadences, independent of agent cron. Offline hosts queue to `pending_runs` and drain on reconnect. - Pre/post hooks fire correctly per source group, fail loudly on `pre_hook` errors, run `post_hook` with `RM_JOB_STATUS`. Rejected on non-backup kinds. - Bandwidth limits honoured (host-wide default + per-run override). -- A Windows host can enrol, appear in the dashboard, and run a backup with live log streaming. +- A Windows host can enrol, appear in the dashboard, and run a backup with live log streaming. **Not validated in CI:** Linux runners cannot exercise the SCM round-trip; the `service_windows.go`/`install.ps1` pieces compile cleanly under `GOOS=windows GOARCH=amd64` but the first real Windows install will be the first end-to-end test. - A Linux host can enrol via announce-and-approve, with fingerprint-comparison gate enforced. Rate-limit + pending-cap guards verified. --- diff --git a/web/static/css/styles.css b/web/static/css/styles.css index ac06171..4f00775 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}.schd-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:90px 1fr 2fr 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-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-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}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[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}.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}.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}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-full{border-radius:9999px}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-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)}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.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}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.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-\[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-\[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}.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)} +/*! 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}.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-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mt-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}.h-3\.5{height:.875rem}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.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}.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-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)}.p-0{padding:0}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.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}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.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-\[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-\[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}.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)} From bdabcfb68e4d032b216a06833d04a95d6ed2c585 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 4 May 2026 14:18:50 +0100 Subject: [PATCH 16/16] docs: note Gitea repo + tea CLI in CLAUDE.md --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 64136fc..8b1dd42 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ Project-specific rules for Claude when working in this repo. +## Repo + +The repo lives inside a Gitea instance; `tea` CLI is available for use by agents + ## Run `go vet` before every commit CI runs `go vet ./...` and will fail the build on any vet error.