Files
felhom.eu/hub/internal/api/escrow_carry.go
T

70 lines
3.9 KiB
Go

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