hub v0.163.0: a rebuilt box gets the household's sealed package back (R-1176); one run per daily job on clock-change days (R-1183); a scoped abandonment purge (R-1182); the new-link page says the old box is still linked (R-1203)
gates / gates (push) Successful in 13m23s

This commit is contained in:
2026-10-21 13:36:39 +02:00
parent e07b077ac2
commit 9688e76efd
16 changed files with 1018 additions and 29 deletions
+69
View File
@@ -0,0 +1,69 @@
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")
}
}
}
+164
View File
@@ -0,0 +1,164 @@
package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1176 scenario A through the hub's own front doors: the reports a rebuilt box sends, the ACK it reads, and the
// escrow GET its agent calls during the recovery.
func carryRebuiltBox(t *testing.T, st *store.Store, identity []byte) {
t.Helper()
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "Tester4", APIKey: "ckey", RetrievalPassword: "p"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "Tester4-old", CustomerID: "Tester4", APIKey: "OLDKEY"}); err != nil {
t.Fatal(err)
}
if _, _, err := st.SaveHostEscrow("Tester4-old", []byte("k-blob"), "fp", "zero_knowledge", "2026-10-18T00:00:00Z", "SHA_HOUSEHOLD"); err != nil {
t.Fatal(err)
}
if err := st.SaveHostDRBundle("Tester4-old", identity); err != nil {
t.Fatal(err)
}
if err := st.DeleteHost("Tester4-old", true); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "Tester4-new", CustomerID: "Tester4", APIKey: "NEWKEY"}); err != nil {
t.Fatal(err)
}
}
func TestR1176_TheRebuiltBoxsFirstACKCarriesTheHouseholdsPackage(t *testing.T) {
h, st, _ := newTestHandler(t)
identity := []byte("sealed-under-the-household-recovery-code")
carryRebuiltBox(t, st, identity)
rr := do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"Tester4"}`)
if rr.Code != http.StatusOK {
t.Fatalf("report = %d %s", rr.Code, rr.Body.String())
}
var ack struct {
Escrow *struct {
IdentityBlobPresent bool `json:"identity_blob_present"`
ResticPwSHA256 string `json:"restic_pw_sha256"`
} `json:"escrow"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
t.Fatal(err)
}
if ack.Escrow == nil || !ack.Escrow.IdentityBlobPresent || ack.Escrow.ResticPwSHA256 != "SHA_HOUSEHOLD" {
t.Fatalf("first ACK escrow = %+v — the rebuilt box would mint a fresh key (R-1176)", ack.Escrow)
}
// The new host's agent fetches its OWN current package (self-scoped) — the household's bytes.
g := do(h, http.MethodGet, "/hosts/Tester4-new/escrow", "NEWKEY", "")
var got struct {
Present bool `json:"present"`
B64 string `json:"identity_escrow_b64"`
}
if err := json.Unmarshal(g.Body.Bytes(), &got); err != nil || g.Code != http.StatusOK || !got.Present {
t.Fatalf("escrow GET = %d %s", g.Code, g.Body.String())
}
if b, _ := base64.StdEncoding.DecodeString(got.B64); string(b) != string(identity) {
t.Fatal("the served package is not byte-identical to the household's")
}
// The deleted host's key is dead; nothing else of the old host is reachable by the new one.
if r := do(h, http.MethodGet, "/hosts/Tester4-old/escrow/retained", "NEWKEY", ""); r.Code != http.StatusForbidden {
t.Fatalf("new host reading the old host's retained packages = %d, want 403 (self-scope)", r.Code)
}
evs, _ := st.GetEventsByType("Tester4", eventEscrowCarried, time.Time{})
if len(evs) != 1 {
t.Fatalf("escrow_carried events = %d, want 1", len(evs))
}
if strings.Contains(evs[0].DetailsJSON, string(identity)) || strings.Contains(evs[0].Message, string(identity)) {
t.Fatal("the audit event carries the package bytes")
}
}
func TestR1176_TheAgentsHostReportCarriesFirst(t *testing.T) {
h, st, _ := newTestHandler(t)
carryRebuiltBox(t, st, []byte("id"))
if rr := do(h, http.MethodPost, "/host-report", "NEWKEY", validReportBody("Tester4-new")); rr.Code != http.StatusOK {
t.Fatalf("host-report = %d %s", rr.Code, rr.Body.String())
}
if e, _ := st.GetHostEscrow("Tester4-new"); e == nil || e.ResticPwSHA256 != "SHA_HOUSEHOLD" {
t.Fatalf("after the agent's first report the new host holds %+v, want the household's package", e)
}
}
func TestR1176_RefusalMailsTheOperatorOnce(t *testing.T) {
h, st, _ := newTestHandler(t)
carryRebuiltBox(t, st, []byte("id"))
if err := st.UpsertHost(&store.Host{HostID: "Tester4-second", CustomerID: "Tester4", APIKey: "K2"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"Tester4"}`)
}
evs, _ := st.GetEventsByType("Tester4", eventEscrowCarryRefused, time.Time{})
if len(evs) != 1 || evs[0].Severity != "warning" {
t.Fatalf("refusal events = %+v, want exactly one warning", evs)
}
for _, hid := range []string{"Tester4-new", "Tester4-second"} {
if e, _ := st.GetHostEscrow(hid); e != nil {
t.Fatalf("%s got the package while two hosts are live", hid)
}
}
}
// R-1182 at the ACK: a box that declared its abandonment is told the purge is done, although another set-aside
// copy's package is (rightly) still kept and `superseded_present` stays true.
func TestR1182_TheDeclaringBoxIsToldThePurgeIsDone(t *testing.T) {
h, st, _ := newTestHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "C", APIKey: "ckey", RetrievalPassword: "p"}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "C-h", CustomerID: "C", APIKey: "HK"}); err != nil {
t.Fatal(err)
}
for _, sha := range []string{"SHA_1", "SHA_2", "SHA_3"} {
if _, _, err := st.SaveHostEscrow("C-h", []byte("k-"+sha), "fp", "zk", "2026-10-01T00:00:00Z", sha); err != nil {
t.Fatal(err)
}
if err := st.SaveHostDRBundle("C-h", []byte("id-"+sha)); err != nil {
t.Fatal(err)
}
if sha != "SHA_3" {
if _, err := st.TagRetainedEscrowSetAside("C", "/r.orphaned-"+sha); err != nil {
t.Fatal(err)
}
}
}
// SHA_1's package now points at /r.orphaned-SHA_2 (the copy set aside after SHA_2 replaced it); SHA_2's package is
// retained 'live'. The household abandons that one copy and the hub deletes it:
id, _ := st.CreateOffsiteAbandon("C", "/r.orphaned-SHA_2", time.Now())
if err := st.MarkOffsiteAbandonDeleted(id); err != nil {
t.Fatal(err)
}
rr := do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"C","offsite":{"abandon_purge_requested":true}}`)
var ack struct {
Escrow struct {
SupersededPresent bool `json:"superseded_present"`
AbandonPurgeDone bool `json:"abandon_purge_done"`
} `json:"escrow"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &ack); err != nil {
t.Fatal(err)
}
if !ack.Escrow.AbandonPurgeDone || !ack.Escrow.SupersededPresent {
t.Fatalf("ACK escrow = %+v, want done=true while another copy's package is kept", ack.Escrow)
}
// A report that did not declare never carries the flag.
rr = do(h, http.MethodPost, "/report", "ckey", `{"customer_id":"C"}`)
if strings.Contains(rr.Body.String(), "abandon_purge_done") {
t.Fatal("a non-declaring report's ACK carries abandon_purge_done")
}
}
+18 -1
View File
@@ -622,9 +622,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
AbandonPurgeRequested bool `json:"abandon_purge_requested"`
} `json:"offsite"`
}
abandonDeclared := false
if err := json.Unmarshal(body, &abandonPayload); err == nil &&
abandonPayload.Offsite != nil && abandonPayload.Offsite.AbandonPurgeRequested {
n, perr := h.store.PurgeSupersededEscrowForCustomer(payload.CustomerID)
abandonDeclared = true
// R-1182: only the packages that open a set-aside copy the hub DELETED — never every retained package.
n, perr := h.store.PurgeAbandonedEscrowForCustomer(payload.CustomerID)
switch {
case perr != nil:
// Never swallowed: the box keeps declaring, so this retries next cycle — but an operator
@@ -649,7 +652,16 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
// 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
// non-reversible (256-bit random secret) — safe to serve; omitted entirely when no escrow row exists.
// R-1176: a rebuilt box's FIRST ACK must already carry the household's package, or the box mints a fresh key.
h.carryEscrow(payload.CustomerID)
if es, err := h.store.GetEscrowStatusForCustomer(payload.CustomerID); err == nil && es != nil {
// R-1182: other retained packages are now rightly KEPT after an abandonment, so `superseded_present` can stay
// true for good; this field tells the box its own declaration is finished. Only on a declaring report.
if abandonDeclared {
if left, lerr := h.store.AbandonedEscrowLeft(payload.CustomerID); lerr == nil && left == 0 {
es.AbandonPurgeDone = true
}
}
resp["escrow"] = es
}
@@ -969,6 +981,11 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
return
}
// R-1176: the agent's report is the first a new box sends — carry the household's package before its
// controller exists, so the controller's first ACK finds it.
if custID != "" {
h.carryEscrow(custID)
}
h.checkTunnel(hostID, custID, rep.Cloudflared.Status, rep.Cloudflared.Detail)
h.checkCrash(hostID, custID, body)
h.checkDeliberateOff(hostID, custID, &rep)