feat(hub): v0.57.0 reinstall-of-existing-customer arc — claim/offsite/escrow continuity

F2 claim re-issue on clean-slate re-enroll (ReissueForReenroll, host-enroll mint path,
single-bump, reset code; hub never stores the password so fork B). F3 offsite re-issue on
re-enroll (ReissueOffsiteForCustomer, same machinery as the manual button). 2.3 escrow honesty
(red-proofed): re-issuing offsite marks the escrow stale (MarkEscrowStale), withholds the
mismatched restic hash from auto-confirm, DR checklist shows stale not done. Events:
claim_reissued_reenroll / offsite_reissued / escrow_stale.

Controller + scripts unchanged (source contradicted both premises): 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; demo unblocked live (Part 0 ACL grant). VALIDATION doc
F2 erratum + F3/F4 dispositions. Green gate + Scenario-C red-proof pass.
This commit is contained in:
2026-07-16 18:00:13 +02:00
parent dd961a66bb
commit 7747a16ff1
15 changed files with 395 additions and 37 deletions
+13
View File
@@ -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),
+46 -28
View File
@@ -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/<id>` 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).
+2 -1
View File
@@ -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/<id>` 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 L11811219 (script constants `OPERATOR_KEY_*`, populated, `--operator-pubkey-file` override), pinned automatically by `step_agent_config()` "STEP 6/8" (L2044; python builds `authz.signers` L21462156, reinstall preserves existing), verified at L23322337 ("authz signers: N … operator-signed self-update armed"). No interactive prompt or post-install hand-edit — fully automatic. Doc-drift note: the L193197 "EMPTY by default" comment is stale vs the now-populated constants (→ R-16 hygiene) |
@@ -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/<id>` 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` |
+35
View File
@@ -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,
+6
View File
@@ -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()
+53
View File
@@ -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,
+27
View File
@@ -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 {
+50
View File
@@ -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)
+30
View File
@@ -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
}
+54
View File
@@ -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) {
+34 -4
View File
@@ -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
}
+39
View File
@@ -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,
+2
View File
@@ -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
@@ -166,7 +166,7 @@
<li>{{if not .PBSDR.NoHost}}<span class="badge badge-ok">done</span> host enrolled ({{.PBSDR.HostID}}){{else}}<span class="badge badge-neutral">waiting</span> no host enrolled yet — the Day-0 install enrolls it{{end}}</li>
<li>{{if .PBSDR.WGPeer}}<span class="badge badge-ok">done</span> WG tunnel peer registered{{else}}<span class="badge badge-neutral">waiting</span> 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}}</li>
<li>{{if .PBSDR.Provisioned}}<span class="badge badge-ok">done</span> descriptor provisioned (namespace {{.PBSDR.Namespace}}, token {{.PBSDR.TokenID}}){{else}}<span class="badge badge-neutral">waiting</span> provisions automatically when the WG peer registers{{end}}</li>
<li>{{if .PBSDR.EscrowPresent}}<span class="badge badge-ok">done</span> key escrow present (ceremony done){{else}}<span class="badge badge-neutral">waiting</span> ceremony possible once the descriptor is applied on the box (run it with the customer — R-moment){{end}}</li>
<li>{{if .PBSDR.EscrowStale}}<span class="badge badge-warn">stale</span> 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}}<span class="badge badge-ok">done</span> key escrow present (ceremony done){{else}}<span class="badge badge-neutral">waiting</span> ceremony possible once the descriptor is applied on the box (run it with the customer — R-moment){{end}}</li>
</ul>
{{end}}
{{if and .PBSDR.Provisioned (not .PBSDR.DRTier)}}