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
+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,