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
gates / gates (push) Successful in 13m23s
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user