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
+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)}}