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)

This commit is contained in:
2026-10-19 10:34:41 +02:00
parent d5e3c8b0d9
commit 503c93fada
10 changed files with 251 additions and 28 deletions
+32
View File
@@ -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 <host> of <customer>
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." 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
+2 -1
View File
@@ -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.
+11 -6
View File
@@ -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
+22 -5
View File
@@ -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)
}
})
}
+11 -6
View File
@@ -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)
}
+62 -5
View File
@@ -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)
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
+32
View File
@@ -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)
}
}
}
+4 -4
View File
@@ -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 {