hub: drop the retained recovery package when the box declares its set-aside history deleted (R-241)
The hub half of the controller's abandonment countdown, and the ONLY reason felhom.eu was touched for R-241 at all. A customer who abandons their old off-site history gets a 14-day countdown. At the end of it the controller deletes the set-aside restic store and then DECLARES offsite.abandon_purge_requested in its report until the retained sealed package that protected that store is gone too. Removing only one half leaves a state that asks a question nobody can answer: a package that opens nothing, or ciphertext nobody can ever decrypt. store.PurgeSupersededEscrowForCustomer is the one place R-198's retention is ever undone, and its doc comment says why that is legitimate here. It NEVER touches host_escrow - the current package covers the key the box is using now and is what makes its live backups recoverable. Only host_escrow_superseded rows go. The handler acts on the box's DECLARATION, never an inference, on the same principle as offsite.state: the hub cannot see that a remote store was deleted and the box can. It is placed immediately BEFORE the ACK is built, deliberately. GetEscrowStatusForCustomer is read after it runs, so the SAME response that carries the request's effect is what closes the box's two-phase commit - no second round-trip, and no window in which the box believes it is still owed. The declaration repeats on every report until that ACK stops reporting a superseded package, so a lost request retries by itself. A purge failure is logged at ERROR and never swallowed: the box keeps declaring, so it retries, but an operator must be able to see that the two halves are apart right now. An idempotent re-declaration (already purged, the box has not yet seen the confirming ACK) logs at DEBUG and is not an error. Audit event offsite_abandon_purged is hub-internal, like the pbsdr_* and offsite_selfheal_* events - allowedEventTypes governs the box-pushed POST /event surface, not this. No agent change. No deletion has been performed against any real store. Green: go build, go vet, go test ./... all clean in hub/; repo gates OK.
This commit is contained in:
@@ -507,6 +507,52 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── R-241 (v0.98.0) — THE ABANDONMENT PURGE, ACTED ON BEFORE THE ACK IS BUILT ───────────────
|
||||
//
|
||||
// The box has deleted its set-aside off-site history at the end of a 14-day countdown the customer
|
||||
// chose and could see, and now asks the hub to drop the sealed package that protected it, so the
|
||||
// two halves go together. Removing only one leaves a state that asks a question nobody can answer
|
||||
// — a package that opens nothing, or ciphertext nobody can decrypt.
|
||||
//
|
||||
// IT ACTS ON A DECLARATION, NEVER AN INFERENCE, on the same principle as `offsite.state`: the hub
|
||||
// cannot see that a remote store was deleted, and the box can. The declaration repeats on every
|
||||
// report until the ACK below stops carrying a superseded package, so a lost request retries by
|
||||
// itself instead of leaving the pair half-removed.
|
||||
//
|
||||
// PLACED IMMEDIATELY BEFORE THE ACK deliberately: `GetEscrowStatusForCustomer` is read after this
|
||||
// runs, so the SAME response that carries the request's effect is what closes the box's
|
||||
// two-phase commit. No second round-trip, and no window in which the box thinks it is still owed.
|
||||
//
|
||||
// Only RETAINED rows go — never `host_escrow`, which covers the key the box is using now and is
|
||||
// what makes its live backups recoverable.
|
||||
var abandonPayload struct {
|
||||
Offsite *struct {
|
||||
AbandonPurgeRequested bool `json:"abandon_purge_requested"`
|
||||
} `json:"offsite"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &abandonPayload); err == nil &&
|
||||
abandonPayload.Offsite != nil && abandonPayload.Offsite.AbandonPurgeRequested {
|
||||
n, perr := h.store.PurgeSupersededEscrowForCustomer(payload.CustomerID)
|
||||
switch {
|
||||
case perr != nil:
|
||||
// Never swallowed: the box keeps declaring, so this retries next cycle — but an operator
|
||||
// must be able to see that the pair is half-removed right now.
|
||||
h.logger.Printf("[ERROR] offsite-abandon: %s DECLARED the set-aside history deleted, but purging the retained recovery package FAILED — the two halves are apart until this succeeds: %v", payload.CustomerID, perr)
|
||||
case n > 0:
|
||||
h.logger.Printf("[INFO] offsite-abandon: %s deleted its set-aside off-site history and the retained recovery package was purged (%d row(s)) — both halves are gone", payload.CustomerID, n)
|
||||
// Hub-internal, like the pbsdr_* / offsite_selfheal_* events — not gated by
|
||||
// allowedEventTypes, which governs the box-pushed POST /event surface.
|
||||
if _, eerr := h.store.SaveEvent(payload.CustomerID, "offsite_abandon_purged", "info",
|
||||
fmt.Sprintf("Az ügyfél korábbi távoli mentései és a hozzájuk tartozó megőrzött helyreállítási csomag is törölve (%d csomag). Az ügyfél döntése alapján, a 14 napos türelmi idő lejárta után.", n),
|
||||
"", "hub"); eerr != nil {
|
||||
h.logger.Printf("[WARN] offsite-abandon: %s purge succeeded but the audit event could not be saved: %v", payload.CustomerID, eerr)
|
||||
}
|
||||
default:
|
||||
// Idempotent: already purged, and the box simply has not seen the confirming ACK yet.
|
||||
h.logger.Printf("[DEBUG] offsite-abandon: %s declared a purge; no retained package remained (already done)", payload.CustomerID)
|
||||
}
|
||||
}
|
||||
|
||||
// SLICE 3 — escrow status for the hub-verified auto-confirm: the controller flips its offbox
|
||||
// EscrowState pending→escrowed ONLY when sha256(its local repo password) matches restic_pw_sha256
|
||||
// (blob-presence alone must never confirm — a stale blob may not cover the current key). The hash is
|
||||
|
||||
@@ -3557,3 +3557,32 @@ func (s *Store) GetHostLeafFingerprints() ([]HostLeafRow, error) {
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PurgeSupersededEscrowForCustomer deletes the RETAINED (superseded) escrow rows for every host of a
|
||||
// customer, and reports how many went. R-241 (v0.98.0), and it is the ONE place the retention added
|
||||
// by R-198 is ever undone.
|
||||
//
|
||||
// ⚠ WHY THIS EXISTS AT ALL, given R-198 was written to STOP a supersession destroying the key that
|
||||
// opens an earlier history. Because the customer has now asked for that history to go. The controller
|
||||
// runs a 14-day countdown after an explicit, twice-confirmed abandonment, deletes the set-aside
|
||||
// restic store at the end of it, and then DECLARES `offsite.abandon_purge_requested` in its report
|
||||
// until this runs. Removing the store while keeping its sealed package leaves a package that opens
|
||||
// nothing; removing the package while keeping the store leaves ciphertext nobody can ever decrypt.
|
||||
// Both are states that ask a question with no answer, which is exactly what R-241 was.
|
||||
//
|
||||
// IT NEVER TOUCHES `host_escrow` — the CURRENT package, which covers the key the box is using now,
|
||||
// is what makes the box's live off-site backups recoverable. Only the retained rows go.
|
||||
//
|
||||
// The caller is the report handler, acting on the box's own declaration: the hub cannot see that a
|
||||
// remote store was deleted and must not infer it.
|
||||
func (s *Store) PurgeSupersededEscrowForCustomer(customerID string) (int64, error) {
|
||||
res, err := s.db.Exec(`
|
||||
DELETE FROM host_escrow_superseded WHERE host_id IN (
|
||||
SELECT host_id FROM hosts WHERE customer_id = ?
|
||||
UNION SELECT host_id FROM host_deletions WHERE customer_id = ?
|
||||
)`, customerID, customerID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge superseded escrow for %s: %w", customerID, err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user