diff --git a/CONTEXT.md b/CONTEXT.md index 2a62fbb..d0f0d37 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,19 @@ > Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub), > `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`. +- **2026-07-16 — REINSTALL ARC SHIPPED: hub v0.57.0 (F2/F3/2.3).** The clean-slate + reinstall-of-existing-customer path is now first-class (Peti R-1 prerequisite). On host-enroll's + mint path (single-bump-per-re-enroll): claim re-issue for a claimed customer (`ReissueForReenroll`, + reset code — the hub never stores the password, so fork B not A), offsite re-issue to the fresh box + (`ReissueOffsiteForCustomer`), and escrow-honesty — re-issuing offsite marks the escrow STALE + (`MarkEscrowStale`), withholds the mismatched restic-hash from auto-confirm, and shows "stale" not + "ceremony done" (2.3, red-proofed). **Part 0** unblocked the demo live (ACL grant on + `/storage/felhom-offsite` → PBS-DR converged, `escrow.pbs_storage_id` seeded). **Controller + + scripts unchanged** — both spec premises were contradicted by source (the controller reads escrow + prereqs live from the agent; the installer can't know the descriptor-provisioned storage id). **F4** + root fix is agent-side → **ROADMAP R-22** (agent train: the reconcile pre-check 403 must not abort + before the root-run apply grants the ACL). Detail: `hub/CHANGELOG.md` v0.57.0, `REPORT.md`. + - **2026-07-16 — R-21 PHYSICAL RUN on the demo N100 (`tests/VALIDATION-n100-baremetal-2026-07-16.md`).** demo-felhom host reinstalled clean-slate from a pipeline-built ISO; first-boot chain reached **rc-0 on the first attempt on real hardware** (closes slice A's operator-gated boundary), diff --git a/REPORT.md b/REPORT.md index ecdbb82..e18d9f5 100644 --- a/REPORT.md +++ b/REPORT.md @@ -2,36 +2,54 @@ > **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md). -## R-21 physical run — N100 bare-metal reinstall + onboarding rehearsal — 2026-07-16 +## Reinstall-of-existing-customer arc — F2/F3/F4/2.3 (hub v0.57.0) — 2026-07-16 -Supervised run (Viktor at the box, CC on Phase-0 prep + the SSH-side legs). Full detail: -**`documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md`**. No production code changed. +Closes the N100 physical-run findings (`documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md`). +**Only the hub shipped** — controller + scripts unchanged (both spec premises contradicted by source, +below). Baseline: felhom.eu @ `dd961a6`, hub v0.56.0 → **v0.57.0**. -**Core objectives — GREEN.** The demo N100 (felhom-pve, serving demo-felhom.eu) was reinstalled -clean-slate from a pipeline-built ISO (`felhom-pve-9.2-1-v1.16.0-n100-demo.iso`, profile filter -`ID_SERIAL_SHORT=QDF922W009654S30EX`, appliance). The first-boot chain reached **rc-0 on the first -attempt (`NRestarts=0`)** on real hardware — enrolled `demo-felhom`, provisioned guest 9201, agent -0.88.0, WG up, `bootstrap.env` shredded. This **closes slice A's operator-gated rc-0 boundary on -metal.** Serial-filter safety proven on real hardware (external-HDD canary byte-identical). PBS-DR -reconciler self-healed on the reused peer (all four steps done incl. escrow). DMI verdict on real -cheap hardware: all "Default string" → slice-C keys on **MAC + UUID**. +### Fork verdicts (source-verified) +- **2.1 = FORK B.** `hub/internal/claim/engine.go` + `handler.go:413-429` (`MarkClaimed`, set-only): the + hub stores the claim code + a **claimed boolean**, never the password hash (controller-owned by the + arc's design). Fork A (hub delivers the hash) would violate the design → **B** (bump generation + email). +- **3.1 = no controller change (spec premise contradicted).** `controller/internal/agentapi/escrow.go` + `EscrowPreflight` fetches the prereq checklist **live from the agent** (`GET /escrow/preflight`); it + never sources `pbs_storage_id` from hub config. The agent seeds it on PBS-DR convergence. The fresh + box "lacked" it only because F4's 403 blocked convergence — Part 0 + the demo grant restore it. **No + controller v0.138.0.** -**The obstacle was firmware, not the pipeline.** This board's early AMI `AN3PLUS 0.01` UEFI cannot -boot the ISO's GRUB **from USB** (`relocation 0x0 is not implemented yet`); Secure-Boot-off and -shim-bypass did **not** help (it's GRUB, not shim). The identical ISO boots in reference UEFI, and the -box's installed GRUB boots fine from the SSD — so it's the firmware relocating the ISO's USB-loaded -GRUB. **Worked around live** by rebuilding the stick's `BOOTX64.EFI` with `grub-mkimage` from the box's -own working `2.12-9+pmx2` GRUB (all needed modules embedded, config that searches+boots the ISO). This -is the top **slice-B input**: the pipeline should ship a firmware-compatible loader and/or a PXE path. +### Part 0 — live unblock (done, reversible) +Granted `FelhomAgentStore` on `/storage/felhom-offsite` (user + token) on felhom-pve. The agent's next +tick converged: created the storage, ran `felhom-pbs-apply grant`, **seeded `escrow.pbs_storage_id`**, +`pbsdr: converged state=applied`; `pvesm` shows **felhom-offsite pbs active**. The 403s stopped. -**Findings to tackle (7):** F1 firmware boot loader (HIGH, slice-B); F2 no claim-code re-issue on -reinstall (+ verify open-vs-gated); F3 offsite creds need re-issue on reprovision; F4 PBS-DR 403 — -install ACL grants only default `felhom-pbs`, not the config's `felhom-offsite` storage id; F5 guest -RAM 2 GB not configurable; F6 drive-initialize formats but doesn't mount+attach (client-disconnect -aborts post-mkfs, no UI feedback); F7 storage Back-route → /settings. F2/F3/F4 are reused-customer -clean-slate edges feeding R-1/Peti. BookStack deployed + works. +### Part 2 — hub v0.57.0 (shipped) +- **F2 claim continuity** — `claim.ReissueForReenroll` (claimed → bump generation once + reset email; + no-op unclaimed), hooked at the host-enroll **mint path** (single-bump-per-re-enroll). Event + `claim_reissued_reenroll`. +- **F3 offsite continuity** — the re-enroll path calls `web.ReissueOffsiteForCustomer` (same machinery + as the manual button; wired via `SetOffsiteReissuer`). Event `offsite_reissued`. +- **2.3 escrow honesty** — `offsite.ReissueCredentials` marks the escrow stale (`store.MarkEscrowStale`; + cleared by the next ceremony); the ACK **withholds** the mismatched `restic_pw_sha256`; the DR + checklist shows **stale** not "done." Event `escrow_stale`. -**Current state:** box healthy, demo-felhom.eu live, guest 9201 running (controller/traefik/cloudflared/ -filebrowser/bookstack), external HDD reformatted + attached as `hdd_1`. Onboarding not finished (claim -+ escrow pending on F2/F3/F4). Boot stick (secret-bearing, with the GRUB workaround) to be wiped at -teardown. Findings will be worked next per Viktor. +### Scenarios / red-proofs +- **A (claim)**: `TestReissueForReenroll` — claimed rotates + reset email; unclaimed no-op. PASS. +- **C (escrow, red-proof)**: `TestReissue_InvalidatesEscrow` — after a re-issue the escrow is STALE and + the hash is withheld. **Red-proof run-fail-revert:** with `MarkEscrowStale` gutted → FAIL ("escrow + must be STALE… advertising ceremony-done against a key the repo no longer accepts"); restored → PASS. +- Green gate: `go build ./... && go vet ./... && go test ./...` all clean. +- Deployed versions + Scenario E (escrow prereqs green on the live demo): see the deploy section / the + task's verification. + +### F4 — NOT an installer fix (reported) +The DR storage id lives in the agent-domain **pbs_dr descriptor** (`web/pbsdr.go`), provisioned post-WG +— not in the installer-fetched config — so `step_agent_config()` can't derive it. The block is a +bootstrap circularity: the agent's token-auth pre-check `GET /storage/` 403s and aborts before its +own root-run `felhom-pbs-apply grant`. **Root fix is agent-side → ROADMAP R-22 (agent train)**; the demo +was unblocked live (Part 0). Default-storage-id customers (all new/Peti installs) already work — F4 only +bites non-default ids. Per Viktor: do NOT flip the demo's descriptor id. + +### NOT done +The escrow **ceremony itself** — Viktor's S10 step (CC's boundary is prereqs-green; CC never runs the +ceremony, touches R, or mints a recovery code). diff --git a/documentation/backlog/ROADMAP.md b/documentation/backlog/ROADMAP.md index b4164dd..89ed3f4 100644 --- a/documentation/backlog/ROADMAP.md +++ b/documentation/backlog/ROADMAP.md @@ -20,7 +20,7 @@ | ID | Item | Size | Status | Notes / map rows flipped | |----|------|------|--------|--------------------------| -| R-1 | **Peti convergence:** clean-slate proxmox2 reinstall (spec'd 07-15), first live auto-confirm, supervised escrow ceremony, execute parked publish trains (agent 0.81→0.88, controller → 0.137) | L | spec'd | Flips: publish train PARTIAL→PROVEN-LIVE; appliance/BYO/day-0 "real customer" notes; escrow ceremony. The single biggest unproven surface — an alpha where fixes can't ship remotely is dead | +| R-1 | **Peti convergence:** clean-slate proxmox2 reinstall (spec'd 07-15), first live auto-confirm, supervised escrow ceremony, execute parked publish trains (agent 0.81→0.88, controller → 0.137) | L | spec'd | Flips: publish train PARTIAL→PROVEN-LIVE; appliance/BYO/day-0 "real customer" notes; escrow ceremony. The single biggest unproven surface — an alpha where fixes can't ship remotely is dead. **Reinstall arc SHIPPED hub v0.57.0 (2026-07-16):** the clean-slate reinstall-of-existing-customer path is now first-class — claim re-issue (F2), offsite re-issue (F3), escrow-honesty-on-re-issue (2.3) all auto-fire on re-enrollment. Peti's proxmox2 clean-slate now walks a supported path | | R-2 | ~~Resolve ~215 lines of foreign WIP in felhom.eu clone (`hub/internal/notify/`, `store.go`, `hub/internal/claim/`)~~ | S | **killed** (2026-07-16) | Not a real issue: the "foreign WIP" was in-flight code from a concurrent CC session on the customer-claim arc, snapshotted before it committed. All of it landed cleanly — `notify/`+`claim/engine.go` in `6b40eb8` (v0.50.0), `store.go` in `a1d0450` (v0.54.0), plus follow-up `e205a2d`; v0.55.0 shipped. Working tree is clean, no stashes. Lesson already codified: never run two writing sessions on one felhom.eu clone (CLAUDE.md §git add -A) | | R-3 | Friend-alpha onboarding runbook (generalized from `pilot/RUNBOOK-peti-return-2026-07-13`): hardware prep → golden → install → claim → ceremony → "first restore by the customer" scripted step | M | idea | Flips: "customer performs a restore" MISSING row; produces the tester-agreement sibling of `PETI-tester-agreement.md` | | R-4 | Claim-code deliverability: test-send to gmail.com / freemail.hu / citromail.hu / t-online.hu; tighten DMARC `p=none` → `p=quarantine` (pending since email.md 02-04) | S | idea | A claim code in spam bricks onboarding at step 1. Cheap, do before first invite | @@ -37,6 +37,7 @@ | R-10 | T-6E-1: DB-dump dir-fsync asymmetry (LOW, confirmed in 6E) | XS | idea | One-line hardening; batch with the next controller task | | R-11 | Tester-facing one-pager: what the box does, known limitations, how to report (channel decision: Messenger group?) | S | idea | Pairs with R-3 | | R-16 | Operator hygiene: campaign6 autofs orphan (clears on host reboot) + tied-CreatedAt flash duplicates (audiobookshelf/komga/romm) | XS | open | Viktor's own action items from 6D/6E | +| R-22 | **Agent-train: PBS-DR pre-check must not abort before the root-run apply grants the ACL (F4).** On a NON-DEFAULT PBS storage id, the agent's reconcile tick does a token-auth `GET /storage/` that 403s (the token has no ACL there yet) and aborts BEFORE its own root-run `felhom-pbs-apply grant` sets it — a bootstrap circularity. Fix: proceed to the root-run create+grant despite the pre-check 403 (or run the pre-check as root). | S | idea (agent) | From `tests/VALIDATION-n100-baremetal-2026-07-16.md` F4. Default-id customers unaffected (installer grants `felhom-pbs`); the demo's legacy `felhom-offsite` id hit it — unblocked live 2026-07-16 with a one-shot `pveum` grant. An installer fix is NOT viable (the id is descriptor-provisioned post-WG, not in the installer's config) | | R-17 | Old-box archive (u629193-sub1) retirement decision — 9/9 byte-identical restores verified | XS | awaiting-decision | Viktor ruling | | R-19 | Internet-outage customer-experience drill: pull WAN on demo, verify lan_resolver path, document what the customer actually sees/does | S | idea | Flips map row E "LAN access" IMPLEMENTED→PROVEN-LIVE | | R-20 | ~~Verify operator-key pinning is fully in the day-0 install flow~~ | XS | **closed** (2026-07-16) | Confirmed against `scripts/felhom-host-install.sh` source (not changelog): keys resolve at L1181–1219 (script constants `OPERATOR_KEY_*`, populated, `--operator-pubkey-file` override), pinned automatically by `step_agent_config()` "STEP 6/8" (L2044; python builds `authz.signers` L2146–2156, reinstall preserves existing), verified at L2332–2337 ("authz signers: N … operator-signed self-update armed"). No interactive prompt or post-install hand-edit — fully automatic. Doc-drift note: the L193–197 "EMPTY by default" comment is stale vs the now-populated constants (→ R-16 hygiene) | diff --git a/documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md b/documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md index c6da01d..4021861 100644 --- a/documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md +++ b/documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md @@ -88,9 +88,9 @@ boards where USB-grub is broken. | # | Sev | Finding | Root cause | Disposition / fix | |---|-----|---------|-----------|-------------------| | **F1** | **HIGH** | ISO won't UEFI-boot GRUB from USB on this AMI `AN3PLUS 0.01` firmware (`relocation 0x0…`) | firmware can't relocate the ISO's signed GRUB from USB; SB-off and shim-bypass don't help | **worked around live** (self-built `grub-mkimage` loader from the box's own GRUB). R-21 slice-B: bake a firmware-compatible loader into the pipeline, or ship the field-recipe / PXE path. Nested virt could never catch this — the flagged OPEN, now concrete | -| **F2** | MEDIUM | No claim-code email on reinstall of an existing customer | claim state (`claim_code_generation:2`, hash, issued 2026-07-13) is **hub/customer-level** and is delivered to the fresh box; an existing code ⇒ no re-issue/re-email. Fresh box has **no password set** | need a **"re-issue claim code"** operator action (bump generation + email). **⚠ also verify** whether the reinstalled *unclaimed* box is properly gated or accidentally **open** (F-4 class) — dashboard routes were reachable directly | -| **F3** | MEDIUM | Offsite target missing on the fresh controller → escrow blocked | offsite transient password is "delivered to the controller **once**" — it went to the *old* box; the fresh controller never got it. Hub showed provisioned + escrow-done → **hub/controller desync** | **"Re-issue offsite credentials"** in the hub restaged it (done during the run; controller picks up next config refresh). Codify: reinstall must re-issue offsite | -| **F4** | MEDIUM | PBS-DR read 403s every tick | agent token `felhom-agent@pve!agent` has `FelhomAgentStore` on `/storage/**felhom-pbs**` only, but the customer's **PVE STORAGE ID is `felhom-offsite`** (non-default, the demo's adopted manual entry) → `GET /storage/felhom-offsite -> 403 (missing Datastore.Allocate)` | install ACL must grant on the **config's storage id**, not just default `felhom-pbs`. Fix now: `pveum acl modify /storage/felhom-offsite --tokens 'felhom-agent@pve!agent' --roles FelhomAgentStore` (+ user); or set the config's storage id to `felhom-pbs` | +| **F2** | MEDIUM | No claim-code email on reinstall of an existing customer | claim state (`claim_code_generation:2`, hash, issued 2026-07-13) is **hub/customer-level** and is delivered to the fresh box; an existing code ⇒ no re-issue/re-email. Fresh box has **no password set** | need a **"re-issue claim code"** operator action (bump generation + email). ~~**⚠ also verify** whether the reinstalled *unclaimed* box is properly gated or accidentally **open** (F-4 class)~~ **ERRATUM 2026-07-16 (Viktor):** the ⚠ is RETRACTED — the claim gate WAS presented at felhom.demo-felhom.eu; the customer self-served a new code, claimed, and set a password. F2 is a continuity/UX gap, not a gating hole. **SHIPPED hub v0.57.0** — `claim.ReissueForReenroll` auto-issues a reset code on clean-slate re-enroll (host-enroll mint path). | +| **F3** | MEDIUM | Offsite target missing on the fresh controller → escrow blocked | offsite transient password is "delivered to the controller **once**" — it went to the *old* box; the fresh controller never got it. Hub showed provisioned + escrow-done → **hub/controller desync** | **"Re-issue offsite credentials"** in the hub restaged it (done during the run; controller picks up next config refresh). Codify: reinstall must re-issue offsite. **SHIPPED hub v0.57.0** — the re-enroll mint path calls the same machinery (`ReissueOffsiteForCustomer`) automatically. | +| **F4** | MEDIUM | PBS-DR read 403s every tick | agent token `felhom-agent@pve!agent` has `FelhomAgentStore` on `/storage/**felhom-pbs**` only, but the customer's **PVE STORAGE ID is `felhom-offsite`** (non-default, the demo's adopted manual entry) → `GET /storage/felhom-offsite -> 403 (missing Datastore.Allocate)` | ~~install ACL must grant on the **config's storage id**~~ **ERRATUM/DISPOSITION 2026-07-16:** an installer fix is **not feasible** — the DR storage id lives in the agent-domain **pbs_dr descriptor** (`web/pbsdr.go` `StorageID`), provisioned *after* WG registration, so `step_agent_config()` cannot know it at ACL-grant time. The real block is a bootstrap circularity: the agent's reconcile tick does a **token-auth** `GET /storage/` pre-check that 403s and aborts **before** its own root-run `felhom-pbs-apply grant` sets the ACL. Root fix is **agent-side** (proceed to the root-run apply despite the pre-check 403, or run the pre-check as root) — logged as a ROADMAP agent-train item; the demo was unblocked live with a one-shot `pveum` grant on `/storage/felhom-offsite`. Every default-storage-id (all new/Peti installs) already works — F4 only bites non-default ids. | | **F5** | MEDIUM | Guest RAM = 2 GB (too low on a 16 GB host) | **golden default**; no `--memory` passed (appliance), no per-customer/host-aware sizing | make guest RAM/cores configurable (hub config or auto-size at provision). Currently only the install `--memory` flag exists and isn't surfaced | | **F6** | MEDIUM | Drive "initialize" formats but doesn't mount/attach; UI shows nothing | on confirm, the **format client disconnects** ("mkfs continues detached; poll GET /disks/format/status") — mkfs completes (`storage: formatted device /dev/sdb ext4`) but the **post-mkfs mount+register is aborted** and the UI never polls the status | users reasonably expect *initialize* to also mount+register. Fix the confirm→poll flow so the wizard finishes the mount+attach and reports progress/completion. (Viktor recovered by re-running the *attach* flow manually → `hdd_1` at `/mnt/hdd_1`) | | **F7** | LOW | "Vissza" (Back) on `/storage/init` and `/storage/attach` routes to `/settings` | frontend route bug | point Back → `/storage` | diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index df5e006..6a8d173 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,40 @@ # Felhom Hub — Changelog +## v0.57.0 — reinstall-of-existing-customer arc: claim continuity, offsite re-issue, escrow honesty (2026-07-16) + +Closes the N100 physical-run findings F2/F3 and the correctness edge behind F4→2.3 +(`documentation/tests/VALIDATION-n100-baremetal-2026-07-16.md`). When an existing customer's box is +clean-slate reinstalled, hub and box previously disagreed about claim, offsite, and escrow state. +This makes the reinstall path first-class — the Peti (R-1) convergence prerequisite. + +- **F2 — claim continuity.** `claim.Engine.ReissueForReenroll` rides the existing rotation semantics: + for a CLAIMED customer whose box re-enrolls (fresh, passwordless), it bumps the generation ONCE and + emails a RESET code (delivery via the existing report ACK) — the customer no longer has to hunt for + the manual "request a new code" button. No-op for an unclaimed customer (first-provision path owns + the code). Hooked at the host-enroll **mint path** (`handleHostEnroll`), which fires exactly once per + fresh host record — the single-bump-per-re-enroll guarantee. Emits `claim_reissued_reenroll`. + *(Fork verdict, source-verified: the hub stores only the claim code + a claimed boolean — never the + password hash, which is controller-owned by the arc's design. So fork B, not A.)* +- **F3 — offsite continuity.** The re-enroll mint path also calls the same machinery as the manual + "Re-issue offsite credentials" button (`web.Server.ReissueOffsiteForCustomer`, wired to the api + handler via `SetOffsiteReissuer`) — the one-time offsite password only ever reached the OLD + controller, so the fresh box gets a fresh one and a `ConfigVersion` bump. Emits `offsite_reissued`. +- **2.3 — escrow honesty (correctness; red-proofed).** Re-issuing offsite credentials changes the + restic repo password, so any existing key-escrow blob is now STALE (a recovery code minted against + it would decrypt a password that no longer opens the repo). `offsite.ReissueCredentials` now marks + the escrow stale (`store.MarkEscrowStale`, cleared by the next ceremony via `SaveHostEscrow`); the + ACK **withholds** the now-mismatched `restic_pw_sha256` so the controller cannot auto-confirm against + a dead key, and the DR-tier checklist shows **stale** instead of "ceremony done." Emits `escrow_stale`. + Red-proof: with `MarkEscrowStale` gutted, the hub keeps advertising ceremony-done after a re-issue → + `TestReissue_InvalidatesEscrow` FAILS; restored → passes. +- **Out of scope (reported):** F4's general installer fix is NOT feasible — the DR storage id lives in + the agent-domain pbs_dr descriptor (provisioned post-WG), not the installer-fetched config, and the + underlying block is the agent's token-auth pre-check 403ing before its own root-run `felhom-pbs-apply + grant`. Root fix is agent-side (ROADMAP agent-train item); the demo was unblocked live with a + one-shot ACL grant. Controller (Part 3) unchanged: its escrow prereqs are already fetched live from + the agent, so F4/Part-0 alone restore them (3.1 spec premise contradicted by source). scripts + unchanged (v1.16.0). + ## v0.56.0 — PBS-DR self-heal reconciler (re-stage a consumable secret) (2026-07-15) Implements `SPIKE-pbsdr-selfheal-2026-07-15` (`e8f8c44`). **⚠️ ARCHITECTURE IMPACT:** before this, diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 1000745..10c9861 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -302,6 +302,12 @@ func main() { logger.Printf("[INFO] Offsite provisioning enabled (pool_box=%d, location=%s)", poolBoxID, location) } + // v0.57.0 (F3) — clean-slate re-enrollment auto re-issues offsite credentials to the fresh box. + // The API host-enroll path calls this seam; the web server owns the offsite provisioner + config + // bump (and, via ReissueCredentials, the escrow invalidation + events). No-op when offsite is + // unconfigured or the customer has no offsite tier. + apiHandler.SetOffsiteReissuer(webServer.ReissueOffsiteForCustomer) + // Build HTTP mux mux := http.NewServeMux() diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 35a436d..d2cdd05 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -66,6 +66,12 @@ type Handler struct { // desired-state tick). nil = no cascade hook (pre-v0.51.0 behavior). Runs in a detached // goroutine; must never delay or fail the registration response. wgRegisteredHook func(ctx context.Context, customerID string) + + // offsiteReissuer (F3, v0.57.0) re-issues the customer's offsite credentials on clean-slate + // re-enrollment — main.go wires it to the web server's ReissueOffsiteForCustomer (same machinery + // as the manual "Re-issue offsite credentials" button, so escrow invalidation + events ride + // along). nil = no auto re-issue; a no-op when offsite isn't provisioned/enabled for the customer. + offsiteReissuer func(ctx context.Context, customerID string) error } // SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used). @@ -73,6 +79,11 @@ func (h *Handler) SetClaimEngine(e *claim.Engine) { h.claimEngine = e } +// SetOffsiteReissuer wires the clean-slate re-enroll offsite re-issue seam (nil-safe). +func (h *Handler) SetOffsiteReissuer(f func(ctx context.Context, customerID string) error) { + h.offsiteReissuer = f +} + // SetWGRegisteredHook wires the post-WG-registration cascade hook (v0.51.0; nil-safe). func (h *Handler) SetWGRegisteredHook(f func(ctx context.Context, customerID string)) { h.wgRegisteredHook = f @@ -971,11 +982,49 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) { return } h.logger.Printf("[INFO] host enrolled: %s (customer %s)", hostID, req.CustomerID) + + // F2 (v0.57.0) — clean-slate reinstall of an existing customer. This mint path fires exactly + // once per fresh host record (the clean-slate flow deletes the stale host, so re-enroll mints), + // so it is the natural single-shot re-enroll hook. For a CLAIMED customer the fresh box has no + // password; auto-issue a reset code so the customer isn't stranded at the claim page hunting for + // the manual "request a new code" button (delivery rides the report ACK). No-op for an unclaimed + // customer (first provision). Also re-issue offsite credentials to the fresh box (F3) — the + // one-time offsite password only ever reached the OLD controller, so the fresh one has no target. + h.reissueOnReenroll(cc) + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey}) } +// reissueOnReenroll runs the F2+F3 clean-slate re-enrollment side effects for a customer whose box +// was wiped and re-minted a host record: re-issue the claim code (if claimed) and re-issue offsite +// credentials (if provisioned). Every action that fires emits a visible customer event. Best-effort +// and non-fatal — a failure here never fails the enrollment (the box is already minted). +func (h *Handler) reissueOnReenroll(cc *store.CustomerConfig) { + // F2 — claim continuity. + if h.claimEngine != nil { + if gen, reissued, err := h.claimEngine.ReissueForReenroll(cc); err != nil { + h.logger.Printf("[WARN] claim re-issue on re-enroll for %s failed: %v", cc.CustomerID, err) + } else if reissued { + if _, serr := h.store.SaveEvent(cc.CustomerID, "claim_reissued_reenroll", "info", + fmt.Sprintf("Új beállító kódot küldtünk a szerver újratelepítése után (%d. generáció) az ügyfél címére.", gen), + "", "hub"); serr != nil { + h.logger.Printf("[WARN] save claim_reissued_reenroll for %s: %v", cc.CustomerID, serr) + } + } + } + // F3 — offsite continuity: re-stage the one-time offsite password to the fresh controller (the + // one-time password only ever reached the OLD controller). The re-issuer resets the restic repo + // password, which makes the OLD escrow blob stale — the offsite provisioner invalidates the + // escrow (2.3) and emits both events. Skips silently when offsite isn't provisioned/enabled. + if h.offsiteReissuer != nil { + if err := h.offsiteReissuer(context.Background(), cc.CustomerID); err != nil { + h.logger.Printf("[WARN] offsite re-issue on re-enroll for %s failed: %v", cc.CustomerID, err) + } + } +} + // escrowUploadRequest is the agent→hub wire shape for the OPAQUE PBS recovery-code escrow blob // (slice 7, doc 03 §8a). It MUST stay in lockstep with the agent's emit struct // (felhom-agent cmd/felhom-agent escrowUploadRequest). The hub stores the bytes and NEVER decrypts @@ -1463,6 +1512,10 @@ var allowedEventTypes = map[string]bool{ "node_stale": true, "node_down": true, "node_recovered": true, + // v0.57.0 reinstall arc (F2/F3/2.3) — hub-emitted on clean-slate re-enrollment / offsite re-issue + "claim_reissued_reenroll": true, // reset code auto-issued to a reinstalled claimed customer + "offsite_reissued": true, // offsite one-time password re-staged (manual button or re-enroll) + "escrow_stale": true, // key-escrow blob invalidated by an offsite password re-issue // Hub-generated host-domain events (v0.7.0, slice 3) "host_stale": true, "host_down": true, diff --git a/hub/internal/claim/engine.go b/hub/internal/claim/engine.go index 8b817c4..1407834 100644 --- a/hub/internal/claim/engine.go +++ b/hub/internal/claim/engine.go @@ -159,6 +159,33 @@ func (e *Engine) RequestReset(cc *store.CustomerConfig) error { return err } +// ReissueForReenroll handles the clean-slate reinstall of a CLAIMED customer (F2, v0.57.0): the box +// (host + in-guest controller) was wiped and re-enrolls, so the fresh controller has NO password +// while the hub-side claim is set. This rotates + emails a RESET code (rides Resend's rotation +// semantics — a single generation bump, single active code) so the customer gets a fresh code +// automatically instead of hunting for the manual "request new code" button. The new hash reaches +// the fresh controller through the existing report ACK. +// +// No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already +// owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees +// single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path, +// which fires exactly once per reinstall). Returns (generation, reissued, error). +func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) { + cs, err := e.Store.GetClaim(cc.CustomerID) + if err != nil { + return 0, false, fmt.Errorf("claim: reading state: %w", err) + } + if cs == nil || !cs.Claimed() { + return 0, false, nil // unclaimed → first-provision path; nothing to re-issue + } + gen, err = e.rotateAndSend(cc, EmailReset) + if err != nil { + return gen, true, err // reissued=true so the caller records the attempt even on email failure + } + e.logf("[INFO] [claim] reset code re-issued (gen %d) for %s on box re-enrollment (clean-slate reinstall)", gen, cc.CustomerID) + return gen, true, nil +} + // MarkClaimed records a controller-reported successful claim and sends the one-time confirmation // email on the unclaimed→claimed transition (idempotent — repeated reports are no-ops). func (e *Engine) MarkClaimed(cc *store.CustomerConfig) error { diff --git a/hub/internal/claim/engine_test.go b/hub/internal/claim/engine_test.go index 60f2db4..063d49c 100644 --- a/hub/internal/claim/engine_test.go +++ b/hub/internal/claim/engine_test.go @@ -139,6 +139,56 @@ func TestResend_ClaimedGetsResetTemplateAndStaysClaimed(t *testing.T) { } } +// v0.57.0 (F2) — ReissueForReenroll rotates + emails a RESET code for a CLAIMED customer whose box +// was clean-slate reinstalled (fresh box has no password), and is a NO-OP for an unclaimed customer +// (the first-provision path, where EnsureIssued owns the first code — re-enrolling must not rotate). +func TestReissueForReenroll(t *testing.T) { + t.Run("claimed rotates and sends the reset template", func(t *testing.T) { + e, st, m := newTestEngine(t) + if _, err := e.EnsureIssued(cust()); err != nil { + t.Fatalf("EnsureIssued: %v", err) + } + if err := e.MarkClaimed(cust()); err != nil { + t.Fatalf("MarkClaimed: %v", err) + } + sendsBefore := len(m.sends) + gen, reissued, err := e.ReissueForReenroll(cust()) + if err != nil { + t.Fatalf("ReissueForReenroll: %v", err) + } + if !reissued { + t.Fatal("a CLAIMED customer must re-issue a code on box re-enrollment") + } + cs, _ := st.GetClaim("c1") + if gen < 2 || cs.Generation != gen { + t.Fatalf("re-enroll must bump the generation once: gen=%d stored=%d", gen, cs.Generation) + } + if !cs.Claimed() { + t.Fatal("re-issue must NEVER un-claim (reset rides rotation)") + } + if len(m.sends) != sendsBefore+1 || !strings.HasPrefix(m.sends[len(m.sends)-1], "reset:") { + t.Fatalf("claimed re-enroll must send exactly one RESET email, got %v", m.sends) + } + }) + t.Run("unclaimed is a no-op (first-provision path)", func(t *testing.T) { + e, _, m := newTestEngine(t) + if _, err := e.EnsureIssued(cust()); err != nil { // issued but NOT claimed + t.Fatalf("EnsureIssued: %v", err) + } + sendsBefore := len(m.sends) + _, reissued, err := e.ReissueForReenroll(cust()) + if err != nil { + t.Fatalf("ReissueForReenroll: %v", err) + } + if reissued { + t.Fatal("an UNCLAIMED customer must NOT re-issue on re-enroll (first provision owns the code)") + } + if len(m.sends) != sendsBefore { + t.Fatalf("no email may be sent on an unclaimed re-enroll, got %v", m.sends) + } + }) +} + // RequestReset caps at 3/day per customer, hub-side. func TestRequestReset_DailyCap(t *testing.T) { e, _, m := newTestEngine(t) diff --git a/hub/internal/offsite/offsite.go b/hub/internal/offsite/offsite.go index 456e9d6..2ace075 100644 --- a/hub/internal/offsite/offsite.go +++ b/hub/internal/offsite/offsite.go @@ -194,6 +194,36 @@ func (p *Provisioner) ReissueCredentials(ctx context.Context, customerID, typ st if err := p.Store.SaveOneTimeSecret(customerID, pw); err != nil { return fmt.Errorf("offsite: store re-issued one-time password: %w", err) } + + // v0.57.0 (2.3, the escrow-honesty fix): the restic repo password just changed, so any existing + // key-escrow blob — which sealed the OLD password — is now STALE. A recovery code minted against + // it would decrypt a password that no longer opens the repo. Mark the escrow stale so the hub + // stops advertising "ceremony done" and the customer's escrow wizard is offered again; a fresh + // ceremony seals the new password and clears the flag. Every credential change also emits a + // visible customer event (offsite_reissued always; escrow_stale only when a blob was invalidated). + // Best-effort: the password reset already succeeded — a bookkeeping failure here must not fail it. + escrowStaled := false + if host, herr := p.Store.GetHostByCustomer(customerID); herr == nil && host != nil { + if esc, eerr := p.Store.GetHostEscrow(host.HostID); eerr == nil && esc != nil { + if serr := p.Store.MarkEscrowStale(host.HostID); serr != nil { + p.logf("[offsite] WARN mark-escrow-stale for %s: %v", customerID, serr) + } else { + escrowStaled = true + } + } + } + if _, serr := p.Store.SaveEvent(customerID, "offsite_reissued", "info", + "Az offsite (házon kívüli) mentési hozzáférést újra kiadtuk — az új egyszeri jelszót a vezérlő a következő frissítéskor átveszi.", + "", "hub"); serr != nil { + p.logf("[offsite] WARN save offsite_reissued event for %s: %v", customerID, serr) + } + if escrowStaled { + if _, serr := p.Store.SaveEvent(customerID, "escrow_stale", "warning", + "A helyreállítási kulcs-letét elavult az offsite jelszó cseréje miatt — futtasd le újra a helyreállítási szertartást (Biztonsági mentés → Helyreállítás).", + "", "hub"); serr != nil { + p.logf("[offsite] WARN save escrow_stale event for %s: %v", customerID, serr) + } + } return nil } diff --git a/hub/internal/offsite/offsite_test.go b/hub/internal/offsite/offsite_test.go index b3b789e..8316796 100644 --- a/hub/internal/offsite/offsite_test.go +++ b/hub/internal/offsite/offsite_test.go @@ -211,6 +211,60 @@ func TestReissue_RefusesAmbiguousLookup(t *testing.T) { } } +// v0.57.0 (2.3, escrow honesty) — re-issuing offsite credentials INVALIDATES the key-escrow blob: +// the blob sealed the OLD repo password, so a recovery code minted against it would decrypt a +// password that no longer opens the repo. RED-PROOF (Scenario C): on pre-fix code (no MarkEscrowStale +// in ReissueCredentials + no stale plumbing) the hub keeps advertising the escrow as current after a +// re-issue and keeps serving its restic-hash for auto-confirm — this test asserts it does NEITHER. +func TestReissue_InvalidatesEscrow(t *testing.T) { + p, _, st := newTestProvisioner(t) + const cust = "cust-esc" + if _, err := p.ProvisionOffsite(context.Background(), cust, Input{Enabled: true, Type: "shared", QuotaGB: 10}); err != nil { + t.Fatal(err) + } + // A host + a key-escrow blob whose sealed repo-password hash the hub serves for auto-confirm. + if err := st.UpsertHost(&store.Host{HostID: cust + "-01", CustomerID: cust, APIKey: "k"}); err != nil { + t.Fatal(err) + } + if err := st.SaveHostEscrow(cust+"-01", []byte("opaque-blob"), "SHA256:fp", "zero_knowledge", "2026-07-16T00:00:00Z", "OLDHASH"); err != nil { + t.Fatal(err) + } + // Before re-issue: current escrow — the hub serves the sealed hash and is NOT stale. + es, err := st.GetEscrowStatusForCustomer(cust) + if err != nil || es == nil { + t.Fatalf("escrow status (before): %v", err) + } + if es.Stale || es.ResticPwSHA256 != "OLDHASH" { + t.Fatalf("pre-reissue escrow must be current: stale=%v hash=%q", es.Stale, es.ResticPwSHA256) + } + + // Re-issue the offsite credential — the repo password just changed under the sealed blob. + if err := p.ReissueCredentials(context.Background(), cust, "shared"); err != nil { + t.Fatalf("reissue: %v", err) + } + + // After: the escrow is STALE and the restic-hash is WITHHELD (no auto-confirm against a dead key). + es, err = st.GetEscrowStatusForCustomer(cust) + if err != nil || es == nil { + t.Fatalf("escrow status (after): %v", err) + } + if !es.Stale { + t.Fatal("RED-PROOF: escrow must be STALE after an offsite re-issue (the hub was advertising ceremony-done against a key the repo no longer accepts)") + } + if es.ResticPwSHA256 != "" { + t.Fatalf("a stale escrow must WITHHOLD the restic hash to inhibit auto-confirm, got %q", es.ResticPwSHA256) + } + + // A fresh ceremony (new blob sealing the new password) clears stale + serves the new hash. + if err := st.SaveHostEscrow(cust+"-01", []byte("opaque-blob-2"), "SHA256:fp", "zero_knowledge", "2026-07-16T01:00:00Z", "NEWHASH"); err != nil { + t.Fatal(err) + } + es, _ = st.GetEscrowStatusForCustomer(cust) + if es == nil || es.Stale || es.ResticPwSHA256 != "NEWHASH" { + t.Fatalf("a fresh ceremony must clear stale + serve the new hash: %+v", es) + } +} + // Scenario E (SLICE 4) — the freeze lever flips ONLY readonly on the exactly-1 labelled sub-account // (SSH stays on — a freeze must not cut access, just writes); ambiguity refuses; unfreeze reverses. func TestFreeze_SharedTogglesReadonlyOnly(t *testing.T) { diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 0ce2948..0544435 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -364,6 +364,12 @@ func (s *Store) migrate() error { // instead of trusting blob-presence. NULL/'' = a legacy or password-less blob (never auto-confirms). s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN restic_pw_sha256 TEXT`) + // v0.57.0 (2.3, escrow honesty on offsite re-issue) — stale_at is set when the offsite repo + // password is re-issued: the blob then seals a password that no longer opens the repo, so the + // hub must stop advertising "ceremony done" and withhold the (now non-matching) restic_pw_sha256 + // from the auto-confirm ACK. NULL = current; a fresh ceremony (SaveHostEscrow) clears it. + s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN stale_at DATETIME`) + // dr_recipe (SPIKE-dr-recipe-2026-06-16): the secret-free DR reconstruction recipe, stored // PLAINTEXT (it has NO secrets — the clean inverse of the retired infra_backup). Two halves keyed // by customer: the agent's storage/guest/PBS half (host_half_json, from the host-report) and the @@ -2057,6 +2063,9 @@ type HostEscrow struct { // ResticPwSHA256 (SLICE 3) — the non-reversible hash of the offsite repo password the identity blob // covers ("" = legacy/password-less blob). Safe to store/serve; the password itself never reaches the hub. ResticPwSHA256 string + // StaleAt (v0.57.0, 2.3) — non-empty when the offsite password was re-issued after this blob was + // sealed: the blob is stale (seals a password that no longer opens the repo). Cleared by a fresh ceremony. + StaleAt string } // SaveHostEscrow stores (last-write-wins) the OPAQUE escrow blob for a host. The hub keeps the @@ -2072,20 +2081,30 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu posture = excluded.posture, created_at = excluded.created_at, restic_pw_sha256 = excluded.restic_pw_sha256, + stale_at = NULL, updated_at = datetime('now')`, hostID, blob, keyFingerprint, posture, createdAt, resticPwSHA256, ) return err } +// MarkEscrowStale flags a host's escrow blob as stale (v0.57.0, 2.3) — called when the offsite repo +// password is re-issued, because the blob then seals a password that no longer opens the repo. No-op +// when no escrow row exists; idempotent (only stamps the first re-issue since the last ceremony; a +// fresh ceremony clears stale_at via SaveHostEscrow's ON CONFLICT). +func (s *Store) MarkEscrowStale(hostID string) error { + _, err := s.db.Exec(`UPDATE host_escrow SET stale_at = datetime('now') WHERE host_id = ? AND stale_at IS NULL`, hostID) + return err +} + // GetHostEscrow returns the stored opaque escrow for a host (nil if none). Used by tests and // (future, slice 10) restore-mode serving. The hub returns bytes verbatim; it never decrypts. func (s *Store) GetHostEscrow(hostID string) (*HostEscrow, error) { var e HostEscrow err := s.db.QueryRow(` - SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at, COALESCE(restic_pw_sha256, '') + SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at, COALESCE(restic_pw_sha256, ''), COALESCE(stale_at, '') FROM host_escrow WHERE host_id = ?`, hostID). - Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt, &e.ResticPwSHA256) + Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt, &e.ResticPwSHA256, &e.StaleAt) if err == sql.ErrNoRows { return nil, nil } @@ -2101,6 +2120,10 @@ type EscrowStatus struct { IdentityBlobPresent bool `json:"identity_blob_present"` ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"` CreatedAt string `json:"created_at,omitempty"` + // Stale (v0.57.0, 2.3) — true when the offsite password was re-issued after the blob was sealed. + // When stale the ResticPwSHA256 is WITHHELD (emptied) so the controller cannot auto-confirm against + // a hash that no longer matches the live repo password — the ceremony must run again. + Stale bool `json:"escrow_stale,omitempty"` } // GetEscrowStatusForCustomer returns the escrow status of the customer's host (nil if the customer has no @@ -2108,12 +2131,13 @@ type EscrowStatus struct { func (s *Store) GetEscrowStatusForCustomer(customerID string) (*EscrowStatus, error) { var st EscrowStatus var identityPresent int + var staleAt string err := s.db.QueryRow(` - SELECT (e.identity_blob IS NOT NULL), COALESCE(e.restic_pw_sha256, ''), e.created_at + SELECT (e.identity_blob IS NOT NULL), COALESCE(e.restic_pw_sha256, ''), e.created_at, COALESCE(e.stale_at, '') FROM host_escrow e JOIN hosts h ON h.host_id = e.host_id WHERE h.customer_id = ? ORDER BY e.updated_at DESC LIMIT 1`, customerID). - Scan(&identityPresent, &st.ResticPwSHA256, &st.CreatedAt) + Scan(&identityPresent, &st.ResticPwSHA256, &st.CreatedAt, &staleAt) if err == sql.ErrNoRows { return nil, nil } @@ -2121,6 +2145,12 @@ func (s *Store) GetEscrowStatusForCustomer(customerID string) (*EscrowStatus, er return nil, err } st.IdentityBlobPresent = identityPresent == 1 + // v0.57.0 (2.3): a stale blob must NOT auto-confirm — withhold the hash and flag it so the + // controller stays pending and the escrow wizard is offered again. + if staleAt != "" { + st.Stale = true + st.ResticPwSHA256 = "" + } return &st, nil } diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 557723d..7188628 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -699,6 +699,45 @@ func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, cu http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued#tab=edit", http.StatusSeeOther) } +// ReissueOffsiteForCustomer is the programmatic form of handleOffsiteReissue — the seam the API +// host-enroll path calls on a clean-slate re-enrollment (F3, v0.57.0): the offsite one-time password +// only ever reached the OLD controller, so the fresh box has no target. It re-issues (and, via +// ReissueCredentials, invalidates the now-stale escrow + emits events), then bumps ConfigVersion so +// the controller re-pulls and the bridge consumes the fresh password. Silent NO-OP (nil) when the +// customer has no provisioned/enabled offsite tier — that is the common non-DR case, not an error. +func (s *Server) ReissueOffsiteForCustomer(ctx context.Context, customerID string) error { + if s.offsite == nil { + return nil // offsite not configured on this hub + } + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil { + return fmt.Errorf("offsite re-issue: customer lookup: %w", err) + } + if cfg == nil { + return nil + } + var overrides struct { + Offsite struct { + Enabled bool `json:"enabled"` + Type string `json:"type"` + } `json:"offsite"` + } + _ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) + if !overrides.Offsite.Enabled || overrides.Offsite.Type == "" { + return nil // no provisioned offsite tier — nothing to re-issue + } + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Minute) + defer cancel() + if err := s.offsite.ReissueCredentials(rctx, customerID, overrides.Offsite.Type); err != nil { + return fmt.Errorf("offsite re-issue: %w", err) + } + if err := s.store.SaveCustomerConfig(cfg); err != nil { + return fmt.Errorf("offsite re-issue: config bump: %w", err) + } + s.logger.Printf("[INFO] offsite credentials re-issued for %s on re-enroll (fresh one-time password; ConfigVersion bumped)", customerID) + return nil +} + // handleOffsiteFreeze (SLICE 4) freezes/unfreezes the customer's shared sub-account (readonly) — an // OPERATOR lever, never automatic (freezing also blocks prune, the customer's only way down from // over-quota). Shared model only; the exactly-1 label guard lives in the provisioner. Action logged, diff --git a/hub/internal/web/pbsdr.go b/hub/internal/web/pbsdr.go index f0c5466..574c753 100644 --- a/hub/internal/web/pbsdr.go +++ b/hub/internal/web/pbsdr.go @@ -417,6 +417,7 @@ type pbsDRView struct { TokenID string WGPeer bool // cascade stage 2: the host has registered its tunnel peer EscrowPresent bool // cascade stage 4: the ceremony ran (blob in custody) + EscrowStale bool // v0.57.0 (2.3): the blob is stale (offsite password re-issued since) — re-run the ceremony } // pbsDRViewFor loads the section state for the form. Read-only; every error degrades to a @@ -439,6 +440,7 @@ func (s *Server) pbsDRViewFor(customerID string, drTier bool) pbsDRView { } if escrow, err := s.store.GetHostEscrow(host.HostID); err == nil && escrow != nil { v.EscrowPresent = true + v.EscrowStale = escrow.StaleAt != "" } if d := readPBSDR(host.DesiredJSON); d != nil { v.Enabled = d.Enabled diff --git a/hub/internal/web/templates/config_form_body.html b/hub/internal/web/templates/config_form_body.html index b597dca..a6a4aab 100644 --- a/hub/internal/web/templates/config_form_body.html +++ b/hub/internal/web/templates/config_form_body.html @@ -166,7 +166,7 @@
  • {{if not .PBSDR.NoHost}}done host enrolled ({{.PBSDR.HostID}}){{else}}waiting no host enrolled yet — the Day-0 install enrolls it{{end}}
  • {{if .PBSDR.WGPeer}}done WG tunnel peer registered{{else}}waiting the host has not reported a WG key yet — the tunnel peer must exist before the PBS DR tier (registration is hands-free once the agent starts){{end}}
  • {{if .PBSDR.Provisioned}}done descriptor provisioned (namespace {{.PBSDR.Namespace}}, token {{.PBSDR.TokenID}}){{else}}waiting provisions automatically when the WG peer registers{{end}}
  • -
  • {{if .PBSDR.EscrowPresent}}done key escrow present (ceremony done){{else}}waiting ceremony possible once the descriptor is applied on the box (run it with the customer — R-moment){{end}}
  • +
  • {{if .PBSDR.EscrowStale}}stale key escrow STALE — offsite credentials were re-issued since the ceremony; the sealed repo password no longer matches. Re-run the ceremony with the customer (R-moment){{else if .PBSDR.EscrowPresent}}done key escrow present (ceremony done){{else}}waiting ceremony possible once the descriptor is applied on the box (run it with the customer — R-moment){{end}}
  • {{end}} {{if and .PBSDR.Provisioned (not .PBSDR.DRTier)}}