diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 9ceb117..42d25ae 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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 diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index f496dcb..4ab0976 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -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() +}