package api import ( "encoding/json" "fmt" "strings" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-1176 (operator ruling 2026-10-21): the hub re-attaches a household's sealed package to its rebuilt box. The // store does the move (store.ReattachRetainedEscrow — conditions and reasons there); this half records it. // // Called on BOTH report paths, before anything reads the escrow: the agent's host report (the first thing a new // box sends after enrolling, long before its controller exists) and the controller's report (before the ACK's // escrow status is built, so the very first ACK already says „identity present" and the box never mints a fresh // repository password). Idempotent and cheap: one query once the box holds a package. // // The audit names host ids and the non-reversible hash only — never the blob (03 §8a: the hub never opens, re-seals // or logs the package). const ( eventEscrowCarried = "escrow_carried" eventEscrowCarryRefused = "escrow_carry_refused" escrowCarryHashLogPrefix = 12 ) func (h *Handler) carryEscrow(customerID string) { res, err := h.store.ReattachRetainedEscrow(customerID) if err != nil { h.logger.Printf("[ERROR] escrow carry for %s failed (the rebuilt box sees no package until this succeeds; retried on the next report): %v", customerID, err) return } switch res.Outcome { case store.CarryDone: short := res.ResticPwSHA256 if len(short) > escrowCarryHashLogPrefix { short = short[:escrowCarryHashLogPrefix] } h.logger.Printf("[WARN] escrow CARRIED for %s: the sealed package of deleted host %s is now the current package of %s (repo-key hash %s…); the box will ask for the recovery code", customerID, res.FromHost, res.ToHost, short) msg := fmt.Sprintf("Recovery package carried: the sealed package of the deleted host %s now belongs to the household's new host %s. "+ "The box will offer the household its earlier off-site backups and ask for the recovery code. The hub did not open the package.", res.FromHost, res.ToHost) details, _ := json.Marshal(map[string]any{"from_host": res.FromHost, "to_host": res.ToHost, "restic_pw_sha256": res.ResticPwSHA256}) if _, eerr := h.store.SaveEvent(customerID, eventEscrowCarried, "info", msg, string(details), "hub"); eerr != nil { h.logger.Printf("[WARN] %s event save failed for %s: %v", eventEscrowCarried, customerID, eerr) } case store.CarryRefusedOtherLiveHost: // Once per deleted host: the report repeats every few minutes, the operator needs one mail. if prev, perr := h.store.GetLatestEventByType(customerID, eventEscrowCarryRefused); perr == nil && prev != nil && strings.Contains(prev.DetailsJSON, `"from_host":"`+res.FromHost+`"`) { return } live := strings.Join(res.LiveHosts, ", ") h.logger.Printf("[WARN] escrow carry REFUSED for %s: the package of deleted host %s waits, but the customer has %d live hosts (%s) — the operator must delete the one that is not the household's", customerID, res.FromHost, len(res.LiveHosts), live) msg := fmt.Sprintf("Recovery package NOT carried: customer %s has %d live hosts (%s), so the hub cannot tell which box is the household's. "+ "The sealed package of the deleted host %s is kept. Delete the host that is not in use on the Hosts page; the package then goes to the remaining box on its next report.", customerID, len(res.LiveHosts), live, res.FromHost) details, _ := json.Marshal(map[string]any{"from_host": res.FromHost, "live_hosts": res.LiveHosts}) if _, eerr := h.store.SaveEvent(customerID, eventEscrowCarryRefused, "warning", msg, string(details), "hub"); eerr != nil { h.logger.Printf("[WARN] %s event save failed for %s: %v", eventEscrowCarryRefused, customerID, eerr) return // audit row first: a mail without its event row lies } if h.dispatcher != nil { go h.dispatcher.ProcessEvent(customerID, eventEscrowCarryRefused, "warning", msg, string(details), "hub") } } }