From 9c94a9c6c00422f4fa7f9e7d3cfaeaa2b9219905 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 7 Oct 2026 10:18:50 +0200 Subject: [PATCH] R-366 slice 2 (decision 168): one operator line when the box's whole-guest archives include another key's (edge-triggered) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- .../architecture/07-backup-architecture.md | 10 +++ hub/CHANGELOG.md | 1 + hub/internal/api/handler.go | 75 ++++++++++++++++++- .../api/r366_foreign_key_archives_test.go | 73 ++++++++++++++++++ hub/internal/store/store.go | 11 +++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 hub/internal/api/r366_foreign_key_archives_test.go diff --git a/documentation/architecture/07-backup-architecture.md b/documentation/architecture/07-backup-architecture.md index 1ff8fda7..596b4c1f 100644 --- a/documentation/architecture/07-backup-architecture.md +++ b/documentation/architecture/07-backup-architecture.md @@ -199,6 +199,16 @@ success, and oldest-proven ordering, which is now the tie-break **between due ti remains, but only as the **evaluation interval** (6 h by default, chosen from a measured cost: one due-check is 18 ms on a local dir storage and 392 ms on the PBS tier over the WAN). +**[FACT, built 2026-10-07 — R-727 + R-366 slice 2, `09` §3 decisions 158 and 168] Archives written with another key.** +On an encrypted (PBS) tier the pick takes only archives written with THIS box's key (R-727, agent v0.138.0): after a +reinstall the namespace still holds the earlier install's archives, which this box cannot open. Until 2026-10-07 that +skip was one INFO log line per archive and nothing else. **Now the host report carries, per tier, the count and the date +range of such archives** (`foreign_key_archives.tiers`; the stanza is absent until a tier was evaluated since the agent started, `tiers: []` +when none), and the hub records **one `restore_test_foreign_key_archives` info line per CHANGE of that set** on the operator's +timeline — not per archive, not per report, never mailed, never the household's. It names the retained escrow count: +the hub keeps the old key's escrow since hub 0.141.0 (R-366 slice 1). Tests `TestR366_*` (agent `internal/backup`, +hub `internal/api`). + **The hub's half is not optional.** `restoreProvenStaleAfter` was a flat 7 days derived from the very cadence this replaced, and a weekly tier proved weekly reaches a proof age of **exactly** one interval just before its next proof — 168 h against a 168 h window. It sat ON the line, so any ordinary delay diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index a1768d8c..1a0787aa 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -11,6 +11,7 @@ `documentation/audits/day-2026-10-07/B/red-hub-pve-mutations.txt`. ## Unreleased (2026-10-07) +- hub (R-366 slice 2, `09` §3 decision 168): the host report's new `foreign_key_archives` (per tier: the count and date range of whole-guest archives the restore-test skipped as written with another key) becomes ONE `restore_test_foreign_key_archives` info line on the operator's timeline per change of the set — not per archive, not per report; an absent stanza keeps the state, `tiers: []` clears it. Never mailed. `TestR366_ForeignKeyArchivesOneEventPerChange` (red-proved twice, `documentation/audits/day-2026-10-07/E/`). `07` §6 updated. - hub (R-32 option A, `09` §3 decision 167): RESET's off-site leg on the shared pool box purges the household's folder — the repository and every `.orphaned-*` copy, nothing else — through the sub-account's OWN login (`offsitekeys.Registrar.PurgeRepos`, the `DeleteSetAside` route) BEFORE it deletes the sub-account; a failed purge or no purge route keeps the sub-account and fails the leg (re-run resumes). It used to delete only the sub-account (a login), leaving the folder for the next lifecycle. The false "repo DATA dies with the sub-account" comment is corrected. Tests `TestDeprovision_R32_*`, `TestPurgeRepos_R32_*` (red-proved, `documentation/audits/day-2026-10-07/D/`). `07` §6 (RESET) updated. - hub (R-105 option A, `09` §3 decision 169): the two never-built "slim DR record" fields are retired. `hosts.dr_record_json` is no longer scanned (it had no writer and no reader); the escrow PUT no longer stores a `directive` (only a by-hand selftest flag ever sent one, and every wizard escrow overwrote it with `{}`); the re-enroll and restore-directive routes still serve the two opaque blobs and a `directive` of `{}`. The columns stay in the schema, unread. `TestR105_IdentitySaveLeavesDirectiveColumnAlone`, `TestR105_RetiredColumnsHaveNoReader` (red-proved, `documentation/audits/day-2026-10-07/F/`). `05` §9/§11 and `06` §3.5 corrected. diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 196223bb..8393c14e 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -11,6 +11,7 @@ import ( "io" "log" "net/http" + "sort" "strings" "sync" "time" @@ -688,7 +689,13 @@ type hostReportPayload struct { Backups []hostBackup `json:"backups"` // slice 6 RestoreTests []hostRestoreTest `json:"restore_tests"` // slice 6 PBSSnapshots []hostPBSSnapshot `json:"pbs_snapshots"` // slice 6 Phase B - Cloudflared struct { + // ForeignKeyArchives (R-366 slice 2, agent >= the 2026-10-07 release): per tier, the whole-guest archives the + // restore-test skipped as written with another key. Absent = not evaluated / an older agent → the hub keeps its + // state; `tiers: []` = evaluated, none. + ForeignKeyArchives *struct { + Tiers []hostForeignKeyArchives `json:"tiers"` + } `json:"foreign_key_archives"` + Cloudflared struct { Status string `json:"status"` // agent >= 0.141.0: running | not_running | unknown (older: active | inactive | …) Detail string `json:"detail,omitempty"` } `json:"cloudflared"` @@ -703,6 +710,14 @@ type hostReportPayload struct { } `json:"log_tail"` } +// hostForeignKeyArchives mirrors the agent's hub.ForeignKeyArchives (R-366 slice 2). +type hostForeignKeyArchives struct { + Target string `json:"target"` + Count int `json:"count"` + Oldest string `json:"oldest"` + Newest string `json:"newest"` +} + // drRecipeVersionOnly extracts just recipe_version from a half's JSON (ignore-unknown). 0 if absent. type drRecipeVersionOnly struct { RecipeVersion int `json:"recipe_version"` @@ -926,6 +941,9 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) { hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings) } } + if rep.ForeignKeyArchives != nil { + h.noteForeignKeyArchives(custID, hostID, rep.ForeignKeyArchives.Tiers) + } for _, bk := range rep.Backups { if !bk.Success { h.logger.Printf("[WARN] host %s backup FAILED: target=%s vmid=%d err=%q", @@ -1316,6 +1334,61 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa // registered operator-only in notify.operatorOnlyEvents. const eventRepoKeyChanged = "offsite_repo_key_changed" +// eventForeignKeyArchives (R-366 slice 2, `09` §3 decision 168) — the box's whole-guest copies include archives an +// earlier install wrote with another key, which this box cannot open. Hub-internal, severity info: ONE line on the +// operator's timeline per change of the set (not per archive, not per report); never mailed, never the household's. +const eventForeignKeyArchives = "restore_test_foreign_key_archives" + +// noteForeignKeyArchives turns a CHANGE of the reported set into one operator event (the caller skips an absent +// stanza, which keeps the state); an empty set clears it without an event. Pinned by TestR366_ForeignKeyArchives*. +func (h *Handler) noteForeignKeyArchives(customerID, hostID string, set []hostForeignKeyArchives) { + parts := make([]string, 0, len(set)) + for _, f := range set { + if f.Count > 0 { + parts = append(parts, fmt.Sprintf("%s:%d:%s:%s", f.Target, f.Count, f.Oldest, f.Newest)) + } + } + sort.Strings(parts) + sig := strings.Join(parts, ";") + if sig == h.store.ForeignKeyArchiveSignature(hostID) { + return + } + if err := h.store.SetForeignKeyArchiveSignature(hostID, sig); err != nil { + h.logger.Printf("[WARN] host %s: storing the foreign-key archive state failed: %v", hostID, err) + return + } + if sig == "" { + h.logger.Printf("[INFO] host %s: no whole-guest archive written with another key any more (R-366)", hostID) + return + } + var lines []string + total := 0 + for _, f := range set { + if f.Count > 0 { + total += f.Count + lines = append(lines, fmt.Sprintf("%d on %s (%s … %s)", f.Count, f.Target, dayOf(f.Oldest), dayOf(f.Newest))) + } + } + retained, _ := h.store.CountSupersededEscrow(hostID) + msg := fmt.Sprintf("Host %s: %d whole-guest backup archive(s) were written with ANOTHER key (an earlier install of this box) — %s. "+ + "This box cannot open them, so its restore test skips them. The hub holds %d retained escrow blob(s) for this host; "+ + "the server-side prune removes the old archives as new copies land (R-366).", hostID, total, strings.Join(lines, "; "), retained) + details, _ := json.Marshal(map[string]any{"host_id": hostID, "archives": set, "retained_escrow": retained}) + if _, err := h.store.SaveEvent(customerID, eventForeignKeyArchives, "info", msg, string(details), "hub"); err != nil { + h.logger.Printf("[WARN] %s event save failed for %s: %v", eventForeignKeyArchives, hostID, err) + return + } + h.logger.Printf("[INFO] host %s: %d whole-guest archive(s) written with another key — operator event recorded (R-366)", hostID, total) +} + +// dayOf returns the date part of an RFC3339 time ("" stays ""). +func dayOf(ts string) string { + if len(ts) >= 10 { + return ts[:10] + } + return ts +} + // eventEscrowBlobServed (R-199) — a host retrieved its own sealed identity blob. Hub-internal, // operator-only. See handleHostEscrowGet for why every retrieval is loud. const eventEscrowBlobServed = "escrow_blob_served" diff --git a/hub/internal/api/r366_foreign_key_archives_test.go b/hub/internal/api/r366_foreign_key_archives_test.go new file mode 100644 index 00000000..73e289ca --- /dev/null +++ b/hub/internal/api/r366_foreign_key_archives_test.go @@ -0,0 +1,73 @@ +package api + +import ( + "net/http" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-366 slice 2 (`09` §3 decision 168): archives the box's restore-test skipped as written with ANOTHER key reach the +// operator as ONE line per change of the set — not per archive, not per report; never mailed (info), never the +// household's. + +func reportWithForeign(foreign string) string { + body := validReportBody("h1") + return strings.TrimSuffix(body, "}") + `,"foreign_key_archives":{"tiers":` + foreign + `}}` +} + +func foreignEvents(t *testing.T, st *store.Store) []store.Event { + t.Helper() + ev, err := st.GetEventsByType("c1", eventForeignKeyArchives, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + return ev +} + +// COMPANION RED-PROOF (observed): remove the noteForeignKeyArchives call from handleHostReport → this fails with +// "a new set of other-key archives must record exactly one operator event; got 0". Restored. +func TestR366_ForeignKeyArchivesOneEventPerChange(t *testing.T) { + h, st, _ := newTestHandler(t) + st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}) + st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) + set := `[{"target":"felhom-pbs","count":2,"oldest":"2026-08-18T03:58:43Z","newest":"2026-08-19T03:58:43Z"}]` + + for i := 0; i < 3; i++ { // the same set on three reports → one event + if rr := do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(set)); rr.Code != http.StatusOK { + t.Fatalf("report %d: %d %s", i, rr.Code, rr.Body.String()) + } + } + ev := foreignEvents(t, st) + if len(ev) != 1 { + t.Fatalf("a new set of other-key archives must record exactly one operator event; got %d", len(ev)) + } + if ev[0].Severity != "info" || !strings.Contains(ev[0].Message, "2 whole-guest backup archive(s)") || + !strings.Contains(ev[0].Message, "felhom-pbs (2026-08-18 … 2026-08-19)") { + t.Fatalf("the line must name the count, the tier and the date range; got %s %q", ev[0].Severity, ev[0].Message) + } + + // An older agent or a fresh restart (the stanza absent) keeps the state: no new event. + do(h, http.MethodPost, "/host-report", "HKEY", validReportBody("h1")) + if n := len(foreignEvents(t, st)); n != 1 { + t.Fatalf("an absent stanza must keep the state; got %d events", n) + } + + // The set changes (a third archive) → one more line. + do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(`[{"target":"felhom-pbs","count":3,"oldest":"2026-08-18T03:58:43Z","newest":"2026-08-25T03:58:43Z"}]`)) + if n := len(foreignEvents(t, st)); n != 2 { + t.Fatalf("a changed set must record one more line; got %d events", n) + } + + // Evaluated, none left → the state clears silently; the same set coming back later is news again. + do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(`[]`)) + if n := len(foreignEvents(t, st)); n != 2 { + t.Fatalf("an empty list clears without an event; got %d", n) + } + do(h, http.MethodPost, "/host-report", "HKEY", reportWithForeign(set)) + if n := len(foreignEvents(t, st)); n != 3 { + t.Fatalf("after a clear, a set is news again; got %d", n) + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 4f4868e4..564c62d9 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -2286,6 +2286,17 @@ func (s *Store) setSetting(key, value string) error { return err } +// ForeignKeyArchiveSignature / SetForeignKeyArchiveSignature (R-366 slice 2) hold, per host, the last reported set of +// whole-guest archives written with another key, so the operator event fires once per CHANGE, not every report. +func (s *Store) ForeignKeyArchiveSignature(hostID string) string { + return s.getSetting("foreign_key_archives:" + hostID) +} + +// SetForeignKeyArchiveSignature stores the host's current signature ("" = none). +func (s *Store) SetForeignKeyArchiveSignature(hostID, sig string) error { + return s.setSetting("foreign_key_archives:"+hostID, sig) +} + // GetArtifactManifest returns the operator-recorded current artifact set. All-empty when nothing // has been published yet. func (s *Store) GetArtifactManifest() ArtifactManifest {