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:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}}
|
||||
|
||||
Reference in New Issue
Block a user