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