From 503c93fada55ded8c3fd845289185c09d977c20b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 19 Oct 2026 10:34:41 +0200 Subject: [PATCH] hub v0.160.0: a live re-enroll mails the operator (R-1156); further refused backups counted, alarm once a day (R-1157); the reinstall's off-site re-issue says what it still does (R-1158) --- hub/CHANGELOG.md | 32 +++++++++ hub/cmd/hub/main.go | 3 +- hub/internal/api/handler.go | 17 +++-- hub/internal/api/r1154_reenroll_test.go | 27 +++++-- hub/internal/api/reenroll.go | 17 +++-- hub/internal/monitor/host_capability.go | 67 ++++++++++++++++-- hub/internal/monitor/host_capability_test.go | 74 ++++++++++++++++++++ hub/internal/offsite/offsite.go | 2 +- hub/internal/offsite/offsite_test.go | 32 +++++++++ hub/internal/web/configs.go | 8 +-- 10 files changed, 251 insertions(+), 28 deletions(-) diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 2676ccea..f34c8f5e 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,35 @@ +## v0.160.0 — a reinstall within 45 minutes mails the operator too (R-1156); further refused backups are counted and the alarm repeats once a day (R-1157); the off-site re-issue on a reinstall says what it still does (R-1158); hub gofmt gate (R-1160) (2026-10-19) + +- **A live re-enroll is a WARNING mailed to the operator** (R-1156). `host_reenroll_while_live` was info (recorded, + never mailed), so a reinstall done within the stale threshold (45 min) of the old box's last report — which reads as + live — told nobody. It is now a warning with the same instruction as the stale case: „Host of + enrolled again while the box looks live (); nothing was re-issued. If this box was reinstalled: delete the old + host (Hosts page) and run the installer again, or re-issue the claim code and the off-site login from the customer + page. If it was an installer --resume or a repair re-run, nothing needs doing." The cost: a `--resume` or a repair + re-run mails too (the text says so). Still operator-only and hub-origin only; still re-issues nothing. Test renamed + `TestR1154_LiveHostWarnsButIsNotAReinstall` (all four live shapes: warning, the text, the operator mail path) — + red-proved: with the severity back at info it fails „the live re-enroll must be a warning (mailed)". +- **One refusal episode, counted** (R-1157). The agent holds its cadence cap degraded for 24 h after its NEWEST refusal, + so a guest refused every day read as one long episode and one mail. The agent's reason names the newest refusal's + time, so a changed reason while degraded is a further refusal: `HostCapabilityChecker` counts it; the alarm repeats + (`whole_guest_backup_refused` again: „… still being refused — N refusals since the first one in this episode …") at + most once per 24 h; the details carry `refusals_in_episode` and `first_refusal`; the cleared record carries the + total; a steady report adds nothing. No agent change. In memory, like the state itself (a hub restart starts the + count at 1 and alarms once, as before). `TestR1157_FurtherRefusalsAreCountedAndRepeatDaily` — red-proved: without the + further-refusal branch the day-later refusal raises nothing. +- **The off-site leg of a clean-slate re-enroll: kept, and its words corrected** (R-1158). Measured from the code: + the only reader of the Storage Box sub-account password is the hub's own key registrar (`offsitekeys.TargetFor`); + `ConsumeOneTimeSecret` has no production caller and `consume-password` answers 410 since v0.127.0 — no box receives + the password. So `reissueOnReenroll`'s off-site leg changes nothing a box uses. Not removed: it still hands the hub a + fresh, working login at every clean reinstall, which repairs a broken stored one. Comments in `handler.go`, + `main.go`, `web/configs.go` no longer say the fresh controller consumes it, and the `offsite_reissued` record says + the password is the hub's and the backups do not change (it said the controller takes it at its next refresh). + `TestReissue_R1158_RecordSaysTheHubOwnsThePassword` — red-proved with the old sentence. +- **gofmt** (R-1160). `d5e3c8b0` formats the 22 hub files that were not gofmt-clean (formatting only). The repo runner + gains `hub-gofmt` (`scripts/hub_gofmt_gate.py`, the controller's R-454 shape, fast set), with decoys on a scratch + module dir (convicted by name / the formatted twin passes / CI without Go passes out loud / a dev machine without Go + is INCONCLUSIVE); red-proved with the conviction switched off. + ## v0.159.0 — a refused whole-guest backup has its own alarm text (R-1150); a reinstall over the old host record warns the operator instead of staying silent (R-1154) (2026-10-19) — DEPLOYED 2026-10-19 04:14 local on the operator's yes (given in advance in the day brief) - **`whole_guest_backup_refused`** (R-1150). The agent's R-973 cadence cap reports a CRITICAL capability diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 1177e653..61bd093a 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -453,7 +453,8 @@ func main() { } } - // v0.57.0 (F3) — clean-slate re-enrollment auto re-issues offsite credentials to the fresh box. + // v0.57.0 (F3) — clean-slate re-enrollment auto re-issues the off-site login. Since hub v0.127.0 that + // login is the hub's own (its key registrar); no box receives it (R-1158, `05` §8). // The API host-enroll path calls this seam; the web server owns the offsite provisioner + config // bump (and, via ReissueCredentials, the escrow invalidation + events). No-op when offsite is // unconfigured or the customer has no offsite tier. diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index d1ec4830..6d48165c 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -97,8 +97,12 @@ type Handler struct { // offsiteReissuer (F3, v0.57.0) re-issues the customer's offsite credentials on clean-slate // re-enrollment — main.go wires it to the web server's ReissueOffsiteForCustomer (same machinery - // as the manual "Re-issue offsite credentials" button, so escrow invalidation + events ride - // along). nil = no auto re-issue; a no-op when offsite isn't provisioned/enabled for the customer. + // as the manual "Re-issue offsite credentials" button). nil = no auto re-issue; a no-op when + // offsite isn't provisioned/enabled for the customer. WHAT IT STILL DOES (R-1158, 2026-10-19): it + // resets the Storage Box sub-account password that only the HUB uses (offsitekeys.TargetFor — the + // registrar's login); no box reads it since hub v0.127.0 (consume-password answers 410, every box + // reaches the store with its own registered key). Kept because it hands the hub a fresh, working + // login at every clean reinstall — which repairs a broken stored one — not because the box needs it. offsiteReissuer func(ctx context.Context, customerID string) error // intentHub (v0.58.0, Direction-2 immediate-sync) is the in-memory per-customer generation @@ -1266,8 +1270,8 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) { // above never re-issues; it tells the operator to delete). For a CLAIMED customer the fresh box has no // password; auto-issue a reset code so the customer isn't stranded at the claim page hunting for // the manual "request a new code" button (delivery rides the report ACK). No-op for an unclaimed - // customer (first provision). Also re-issue offsite credentials to the fresh box (F3) — the - // one-time offsite password only ever reached the OLD controller, so the fresh one has no target. + // customer (first provision). Also re-issue the off-site login (F3) — since hub v0.127.0 that is the + // HUB's own Storage Box login, not something the fresh box receives (R-1158; see offsiteReissuer). h.reissueOnReenroll(cc) w.Header().Set("Content-Type", "application/json") @@ -1292,8 +1296,9 @@ func (h *Handler) reissueOnReenroll(cc *store.CustomerConfig) { } } } - // F3 — offsite continuity: re-stage the one-time offsite password to the fresh controller (the - // one-time password only ever reached the OLD controller). ⚠ CORRECTED 2026-08-04 (R-196): this + // F3 — the off-site login: a fresh Storage Box sub-account password, stored for the HUB's registrar + // (R-1158: no box receives it since hub v0.127.0; the fresh box registers its own key with the + // controller API key). ⚠ CORRECTED 2026-08-04 (R-196): this // used to claim "the re-issuer resets the restic repo password, which makes the OLD escrow blob // stale". It does not and cannot — the re-issuer resets the PROVIDER account password; the // repository password is generated on the box and never leaves it except sealed under R. The diff --git a/hub/internal/api/r1154_reenroll_test.go b/hub/internal/api/r1154_reenroll_test.go index cfcd8643..c01f498e 100644 --- a/hub/internal/api/r1154_reenroll_test.go +++ b/hub/internal/api/r1154_reenroll_test.go @@ -232,8 +232,16 @@ func TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing(t *testing.T) { } } -// A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) is recorded as info, -// NOT mailed, and nothing is re-issued. Three ways to be live, each alone, plus connected-while-off. +// A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run, or a reinstall within the +// stale threshold) never raises the reinstall-over-old-host event and re-issues nothing; since hub v0.160.0 (R-1156) it +// is a WARNING mailed to the operator with the same instruction. Three ways to be live, each alone, plus +// connected-while-off. +// +// COMPANION RED-PROOF R-1156 (observed 2026-10-19): with the live branch's severity back at "info" this fails with +// +// r1154_reenroll_test.go: host report fresh: the live re-enroll must be a warning (mailed): info|Host … +// +// Restored. // // COMPANION RED-PROOF (observed 2026-10-19): with classifyReenroll replaced by an unconditional "gone" this fails // with @@ -241,7 +249,7 @@ func TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing(t *testing.T) { // r1154_reenroll_test.go: host report fresh: a live box raised the reinstall warning // // (and the same for the other three cases). Restored. -func TestR1154_LiveHostIsRecordedNotWarned(t *testing.T) { +func TestR1154_LiveHostWarnsButIsNotAReinstall(t *testing.T) { cases := []struct { name string setup func(t *testing.T, f *r1154Fixture) @@ -282,8 +290,17 @@ func TestR1154_LiveHostIsRecordedNotWarned(t *testing.T) { if f.eventAt(t, eventHostReenrollWhileLive).IsZero() { t.Errorf("%s: the live re-enroll was not recorded (host_reenroll_while_live)", c.name) } - if !strings.HasPrefix(f.eventMessage(t, eventHostReenrollWhileLive), "info|") { - t.Errorf("%s: the live record must be info (recorded, never mailed): %s", c.name, f.eventMessage(t, eventHostReenrollWhileLive)) + got := f.eventMessage(t, eventHostReenrollWhileLive) + if !strings.HasPrefix(got, "warning|") { + t.Fatalf("%s: the live re-enroll must be a warning (mailed): %s", c.name, got) + } + for _, want := range []string{r1154Host, "nothing was re-issued", "delete the old host (Hosts page) and run the installer again", "nothing needs doing"} { + if !strings.Contains(got, want) { + t.Errorf("%s: the live warning lacks %q: %s", c.name, want, got) + } + } + if !f.operatorMailed(t, eventHostReenrollWhileLive) { + t.Errorf("%s: the operator mail path was not reached for the live re-enroll", c.name) } }) } diff --git a/hub/internal/api/reenroll.go b/hub/internal/api/reenroll.go index 0f89e305..43c8b379 100644 --- a/hub/internal/api/reenroll.go +++ b/hub/internal/api/reenroll.go @@ -25,8 +25,10 @@ import ( // controller's report are older than staleThreshold or absent; the hostStatus threshold and comparison): // the operator-only WARNING host_reinstall_over_old_host, mailed to the operator, saying what to do. // - The box is live (a --resume after its agent started, a repair re-run, or a reinstall done within the -// threshold of the old box's last report): the operator-only INFO host_reenroll_while_live, recorded on -// the event list, never mailed. +// threshold of the old box's last report): the operator-only WARNING host_reenroll_while_live, mailed to the +// operator with the same instruction (R-1156, hub v0.160.0: a reinstall done within 45 minutes of the old +// box's last report reads as live, and was recorded without a mail until then). The cost: a --resume or a +// repair re-run mails too — the text says that case needs nothing. // // WHY NOT RE-ISSUE AUTOMATICALLY (security review of the first draft, 2026-10-19): the passphrase is a // household-held secret typed into the installer. Re-issuing a household's off-site credential and @@ -101,10 +103,13 @@ func (h *Handler) noteReenrollOverExistingHost(cc *store.CustomerConfig, host *s "re-issue the claim code and the off-site login from the customer page." h.saveReenrollEvent(cc.CustomerID, eventHostReinstallOverOldHost, "warning", msg, host.HostID, why) case reenrollLive: - h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — the box is live: %s", host.HostID, cc.CustomerID, why) - msg := "Host " + host.HostID + " enrolled again while the box is live (" + why + "); nothing was re-issued. " + - "If this box was reinstalled, delete the old host and run the installer again, or re-issue from the customer page." - h.saveReenrollEvent(cc.CustomerID, eventHostReenrollWhileLive, "info", msg, host.HostID, why) + h.logger.Printf("[WARN] host re-enrolled: %s (customer %s) while the box looks live: %s; nothing re-issued, the operator is told", + host.HostID, cc.CustomerID, why) + msg := "Host " + host.HostID + " of " + cc.CustomerID + " enrolled again while the box looks live (" + why + + "); nothing was re-issued. If this box was reinstalled: delete the old host (Hosts page) and run the installer " + + "again, or re-issue the claim code and the off-site login from the customer page. If it was an installer " + + "--resume or a repair re-run, nothing needs doing." + h.saveReenrollEvent(cc.CustomerID, eventHostReenrollWhileLive, "warning", msg, host.HostID, why) default: h.logger.Printf("[WARN] host re-enrolled: %s (customer %s) — not classified: %s", host.HostID, cc.CustomerID, why) } diff --git a/hub/internal/monitor/host_capability.go b/hub/internal/monitor/host_capability.go index 60a79aba..560bf0ad 100644 --- a/hub/internal/monitor/host_capability.go +++ b/hub/internal/monitor/host_capability.go @@ -4,8 +4,10 @@ import ( "encoding/json" "log" "sort" + "strconv" "strings" "sync" + "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) @@ -40,6 +42,12 @@ import ( // stands): each fires on its own edge, so one report flip is at most one event per state — never // two events for one state, and never a "grants restored" line when only the refusal window ended. // Pinned by host_capability_test.go TestR1150_*. +// +// R-1157 (hub v0.160.0) — ONE EPISODE, COUNTED. The agent holds the cap degraded for 24 h after its NEWEST refusal, +// so a guest refused every day reads as one long degraded state. The agent's reason names the newest refusal's +// time, so a CHANGED reason while degraded is a further refusal: it adds to the episode's count, and the alarm +// repeats (whole_guest_backup_refused again, the count and the first refusal in its text and details) at most +// once per refusalRepeatEvery. The cleared record carries the episode's total. Pinned by TestR1157_*. type HostCapabilityChecker struct { store *store.Store logger *log.Logger @@ -48,9 +56,21 @@ type HostCapabilityChecker struct { mu sync.Mutex states map[string]string // hostID → "ok" | "degraded" (the grants) refusals map[string]string // hostID → "ok" | "degraded" (the backup cadence cap, R-1150) + episodes map[string]*refusalEpisode customerOf map[string]string // hostID → customerID + now func() time.Time } +// refusalEpisode is one degraded stretch of the cadence cap on a host (R-1157). +type refusalEpisode struct { + firstReason, lastReason string + count int + lastAlarm time.Time +} + +// refusalRepeatEvery bounds the repeat of whole_guest_backup_refused inside one episode (R-1157). +const refusalRepeatEvery = 24 * time.Hour + // backupCadenceCapName is the agent's R-973 cadence-cap status name. It must equal felhom-agent's // backupCapStatusName (cmd/felhom-agent/main.go) — the agent's status name stays (R-1150). const backupCadenceCapName = "localapi:backup-cadence-cap" @@ -72,7 +92,9 @@ func NewHostCapabilityChecker(s *store.Store, onEvent EventNotifyFunc, logger *l onEvent: onEvent, states: make(map[string]string), refusals: make(map[string]string), + episodes: make(map[string]*refusalEpisode), customerOf: make(map[string]string), + now: time.Now, } rows, err := s.GetHostCapabilities() if err != nil { @@ -123,6 +145,7 @@ func (cc *HostCapabilityChecker) Check() { if cc.store.IsCustomerBlocked(row.CustomerID) { delete(cc.states, row.HostID) delete(cc.refusals, row.HostID) + delete(cc.episodes, row.HostID) continue } cc.customerOf[row.HostID] = row.CustomerID @@ -132,8 +155,26 @@ func (cc *HostCapabilityChecker) Check() { cc.emitTransition(row.HostID, row.CustomerID, old, newState, names, features) } refState, reason := backupRefusalState(row.Capabilities) - if old, changed := step(cc.refusals, row.HostID, refState); changed { - cc.emitRefusal(row.HostID, row.CustomerID, old, refState, reason) + old, changed := step(cc.refusals, row.HostID, refState) + ep := cc.episodes[row.HostID] + switch { + case changed && refState == "degraded": + ep = &refusalEpisode{firstReason: reason, lastReason: reason, count: 1, lastAlarm: cc.now()} + cc.episodes[row.HostID] = ep + cc.emitRefusal(row.HostID, row.CustomerID, old, refState, reason, ep) + case changed: + cc.emitRefusal(row.HostID, row.CustomerID, old, refState, reason, ep) + delete(cc.episodes, row.HostID) + case refState == "degraded" && ep != nil && reason != ep.lastReason: + // A further refusal inside the episode (R-1157): counted always, alarmed at most once a day. + ep.count++ + ep.lastReason = reason + if cc.now().Sub(ep.lastAlarm) >= refusalRepeatEvery { + ep.lastAlarm = cc.now() + cc.emitRefusal(row.HostID, row.CustomerID, refState, refState, reason, ep) + } else { + cc.logger.Printf("[INFO] Host capability: %s refused another whole-guest backup (%d in this episode); the alarm repeats at most once a day", row.HostID, ep.count) + } } } @@ -144,6 +185,11 @@ func (cc *HostCapabilityChecker) Check() { } } } + for id := range cc.episodes { + if !seen[id] { + delete(cc.episodes, id) + } + } } // step records newState for hostID in m and reports whether an event is due, with the state it left @@ -251,9 +297,15 @@ func backupRefusalState(caps []store.CapabilityStatus) (state, reason string) { // emitRefusal raises the operator's alarm for a refused whole-guest backup (R-1150) — the agent turned // down a backup the guest asked for beyond its schedule — and records its end. -func (cc *HostCapabilityChecker) emitRefusal(hostID, customerID, oldState, newState, reason string) { +func (cc *HostCapabilityChecker) emitRefusal(hostID, customerID, oldState, newState, reason string, ep *refusalEpisode) { var eventType, severity, message string switch { + case newState == "degraded" && oldState == "degraded" && ep != nil: + eventType = EventWholeGuestBackupRefused + severity = "warning" + message = "Host " + hostID + ": whole-guest backups are still being refused — " + strconv.Itoa(ep.count) + + " refusals since the first one in this episode. The guest keeps asking for more whole-guest backups than " + + "its schedule allows. Its controller may be misbehaving or compromised: check the guest." case newState == "degraded": eventType = EventWholeGuestBackupRefused severity = "warning" @@ -266,11 +318,16 @@ func (cc *HostCapabilityChecker) emitRefusal(hostID, customerID, oldState, newSt default: return } - details, _ := json.Marshal(map[string]any{ + d := map[string]any{ "host_id": hostID, "capability": backupCadenceCapName, "agent_reason": reason, - }) + } + if ep != nil { + d["refusals_in_episode"] = ep.count + d["first_refusal"] = ep.firstReason + } + details, _ := json.Marshal(d) cc.logger.Printf("[INFO] Host capability: %s backup refusal %s → %s (%s)", hostID, oldState, newState, eventType) if _, err := cc.store.SaveEvent(customerID, eventType, severity, message, string(details), "hub"); err != nil { cc.logger.Printf("[WARN] Failed to save backup refusal event for %s: %v", hostID, err) diff --git a/hub/internal/monitor/host_capability_test.go b/hub/internal/monitor/host_capability_test.go index da244cad..918ba07c 100644 --- a/hub/internal/monitor/host_capability_test.go +++ b/hub/internal/monitor/host_capability_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" _ "modernc.org/sqlite" @@ -298,3 +299,76 @@ func TestR1150_BornRefusedAlertsOnce(t *testing.T) { t.Fatalf("born-refused → want exactly [whole_guest_backup_refused], got %v", got) } } + +func capCadenceRefusedAt(at string) string { + return `[{"name":"guest-init-pid","feature":"drive-gate","critical":true,"status":"ok"},` + + `{"name":"localapi:backup-cadence-cap","feature":"whole-guest restore points","critical":true,"status":"degraded",` + + `"reason":"refused a whole-guest backup of guest 9202 on target \"local\" at ` + at + ` — more than the schedule allows"}]` +} + +// R-1157: a guest refused again and again inside one degraded stretch. Each FURTHER refusal (the agent's reason names +// a new time) adds to the count; the alarm repeats with the count at most once a day; a steady report (same reason) +// adds nothing; the cleared record carries the episode's total. +// +// COMPANION RED-PROOF (observed 2026-10-19): with the further-refusal branch removed from Check this fails with +// +// host_capability_test.go: a refusal a day later → want a second whole_guest_backup_refused, got [whole_guest_backup_refused] +// +// Restored. +func TestR1157_FurtherRefusalsAreCountedAndRepeatDaily(t *testing.T) { + st := newCapStore(t) + st.SaveHostReport("h1", "c1", reportWith(capCadenceOK), store.HostReportDenorm{}) + var evs []capEvent + cc := NewHostCapabilityChecker(st, recordCapEvents(&evs), log.New(io.Discard, "", 0)) + now := time.Date(2026, 10, 19, 8, 0, 0, 0, time.UTC) + cc.now = func() time.Time { return now } + report := func(caps string) { + st.SaveHostReport("h1", "c1", reportWith(caps), store.HostReportDenorm{}) + cc.Check() + } + + report(capCadenceRefusedAt("2026-10-19T08:00:00Z")) + if got := capTypes(evs); len(got) != 1 || got[0] != "whole_guest_backup_refused" { + t.Fatalf("first refusal → want [whole_guest_backup_refused], got %v", got) + } + if !strings.Contains(evs[0].details, `"refusals_in_episode":1`) { + t.Errorf("first alarm details lack the count 1: %s", evs[0].details) + } + + now = now.Add(2 * time.Hour) + report(capCadenceRefusedAt("2026-10-19T08:00:00Z")) // the same refusal reported again + now = now.Add(time.Hour) + report(capCadenceRefusedAt("2026-10-19T11:00:00Z")) // a second refusal, 3 h after the alarm + if len(evs) != 1 { + t.Fatalf("a second refusal within the day must be counted, not alarmed: got %v", capTypes(evs)) + } + + now = now.Add(22 * time.Hour) + report(capCadenceRefusedAt("2026-10-20T09:00:00Z")) // a third refusal, a day after the first alarm + if got := capTypes(evs); len(got) != 2 || got[1] != "whole_guest_backup_refused" { + t.Fatalf("a refusal a day later → want a second whole_guest_backup_refused, got %v", got) + } + e := evs[1] + if e.severity != "warning" || !strings.Contains(e.message, "still being refused — 3 refusals") { + t.Errorf("the repeat must say the count in plain words: %s %q", e.severity, e.message) + } + if !strings.Contains(e.details, `"refusals_in_episode":3`) || !strings.Contains(e.details, "2026-10-19T08:00:00Z") || !strings.Contains(e.details, "2026-10-20T09:00:00Z") { + t.Errorf("the repeat's details must carry the count, the first and the newest refusal: %s", e.details) + } + + now = now.Add(time.Hour) + report(capCadenceRefusedAt("2026-10-20T10:00:00Z")) + report(capCadenceOK) + if got := capTypes(evs); len(got) != 3 || got[2] != "whole_guest_backup_refusal_cleared" { + t.Fatalf("window ended → want [.. whole_guest_backup_refusal_cleared], got %v", got) + } + if !strings.Contains(evs[2].details, `"refusals_in_episode":4`) { + t.Errorf("the cleared record must carry the episode's total 4: %s", evs[2].details) + } + + // A new episode starts at 1 again. + report(capCadenceRefusedAt("2026-10-21T08:00:00Z")) + if len(evs) != 4 || !strings.Contains(evs[3].details, `"refusals_in_episode":1`) { + t.Fatalf("a new episode must start at 1: %v %s", capTypes(evs), evs[len(evs)-1].details) + } +} diff --git a/hub/internal/offsite/offsite.go b/hub/internal/offsite/offsite.go index f9844c29..c6170e7c 100644 --- a/hub/internal/offsite/offsite.go +++ b/hub/internal/offsite/offsite.go @@ -296,7 +296,7 @@ func (p *Provisioner) ReissueCredentials(ctx context.Context, customerID, typ st // credential moved. Best-effort: the password reset already succeeded, so a bookkeeping failure // here must not fail it. if _, serr := p.Store.SaveEvent(customerID, "offsite_reissued", "info", - "Az offsite (házon kívüli) mentési hozzáférést újra kiadtuk — az új egyszeri jelszót a vezérlő a következő frissítéskor átveszi.", + "Az offsite (házon kívüli) tárhely jelszavát újra kiadtuk. Ezt a jelszót csak a hub használja; a szerver a saját kulcsával éri el a tárhelyet, a mentéseken nem változik semmi.", "", "hub"); serr != nil { p.logf("[offsite] WARN save offsite_reissued event for %s: %v", customerID, serr) } diff --git a/hub/internal/offsite/offsite_test.go b/hub/internal/offsite/offsite_test.go index 9729119c..364e19df 100644 --- a/hub/internal/offsite/offsite_test.go +++ b/hub/internal/offsite/offsite_test.go @@ -458,3 +458,35 @@ func TestClearProvisionedDescriptor(t *testing.T) { t.Errorf("no-offsite clear mutated config: %s", got) } } + +// R-1158: the re-issue's record must not tell anyone that the box takes the new password — since hub v0.127.0 no box +// does (consume-password answers 410; the box reaches the store with its own registered key). The text says the +// password is the hub's and that the backups do not change. +// +// COMPANION RED-PROOF (observed 2026-10-19): with the old sentence restored this fails with +// +// offsite_test.go: offsite_reissued still claims the controller takes the password: … a vezérlő a következő frissítéskor átveszi. +// +// Restored. +func TestReissue_R1158_RecordSaysTheHubOwnsThePassword(t *testing.T) { + p, _, st := newTestProvisioner(t) + if _, err := p.ProvisionOffsite(context.Background(), "cust-t", Input{Enabled: true, Type: "shared", QuotaGB: 10}); err != nil { + t.Fatal(err) + } + if err := p.ReissueCredentials(context.Background(), "cust-t", "shared"); err != nil { + t.Fatalf("reissue: %v", err) + } + evs, err := st.GetEventsByType("cust-t", "offsite_reissued", time.Time{}) + if err != nil || len(evs) != 1 { + t.Fatalf("want one offsite_reissued record, got %d (%v)", len(evs), err) + } + msg := evs[0].Message + if strings.Contains(msg, "átveszi") { + t.Fatalf("offsite_reissued still claims the controller takes the password: %s", msg) + } + for _, want := range []string{"csak a hub használja", "a mentéseken nem változik semmi"} { + if !strings.Contains(msg, want) { + t.Errorf("offsite_reissued lacks %q: %s", want, msg) + } + } +} diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index f6c62cba..2b46ad4e 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -1020,10 +1020,10 @@ func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, cu } // ReissueOffsiteForCustomer is the programmatic form of handleOffsiteReissue — the seam the API -// host-enroll path calls on a clean-slate re-enrollment (F3, v0.57.0): the offsite one-time password -// only ever reached the OLD controller, so the fresh box has no target. It re-issues (and, via -// ReissueCredentials, invalidates the now-stale escrow + emits events), then bumps ConfigVersion so -// the controller re-pulls and the bridge consumes the fresh password. Silent NO-OP (nil) when the +// host-enroll path calls on a clean-slate re-enrollment (F3, v0.57.0) and the delivery self-heal uses. +// It resets the Storage Box sub-account password and stores the new one, then bumps ConfigVersion. +// Since hub v0.127.0 that password is the HUB's own login (its key registrar); no box consumes it +// (R-1158 — the old "the fresh controller consumes it" reason no longer holds). Silent NO-OP (nil) when the // customer has no provisioned/enabled offsite tier — that is the common non-DR case, not an error. func (s *Server) ReissueOffsiteForCustomer(ctx context.Context, customerID string) error { if s.offsite == nil {