diff --git a/documentation/architecture/05-hub-architecture.md b/documentation/architecture/05-hub-architecture.md index e8aad816..98bace47 100644 --- a/documentation/architecture/05-hub-architecture.md +++ b/documentation/architecture/05-hub-architecture.md @@ -222,24 +222,23 @@ Today: `GET /config/{id}` with an `X-Retrieval-Password` (Hungarian passphrase) The existing `configgen` deep-merge + Hungarian-passphrase machinery is the base; it grows the agent-first + key-pinning + restore-mode steps. -**A reinstall over an existing host record re-issues when the old box is gone [DESIGN, 2026-10-19 — R-1154, brief +**A reinstall over an existing host record is made loud, never acted on [DESIGN, 2026-10-19 — R-1154, brief 2026-10-19; hub v0.159.0].** `POST /host-enroll` is the installer's step 4, run on every pass (a `--resume` too), with -the customer's passphrase; an existing host record is REUSED (same id and key — the reinstalled box takes it over). -The clean-slate re-issues (the claim code, the off-site login — both live in the CONTROLLER) used to run only when a -record was minted, so a reinstall that kept the old record skipped both in silence. The reuse path now runs them -when, and only when, the old box is gone: **no controller wait connection open now**, and either the household -switched the box off on purpose (`deliberate_off_at` inside its hold) or **both** the agent's host report and the -controller's report are older than `alerting.stale_threshold` (or absent) — the threshold and comparison -`hostStatus` uses, no second definition. **Once per silence:** not again when it already ran since the host's last -report (an installer `--resume` before the new agent's first report). **Why this rule and not "re-issue on every -reuse":** a live box re-enrolls legitimately (a `--resume` after its agent started, a repair re-run), and re-issuing -there rotates the household's claim code and resets its off-site login under a running box; not re-issuing costs a -reinstall two operator presses, which the skip record now names. The controller report counts because the codes -live there: a box whose agent is broken but whose controller reports is live. **Known residual:** a reinstall done -less than the threshold after the old box's last report is still treated as live — it is recorded -(`host_reenroll_not_reissued`, operator-only, info) instead of silent, and the operator presses the two buttons or -deletes the old host first. The re-issue writes `host_reenroll_reissued` (also the once-per-silence record); both -types are hub-origin only. `hub/internal/api/reenroll.go`, pinned by `hub/internal/api/r1154_reenroll_test.go`. +the customer's retrieval passphrase; an existing host record is REUSED (same id and key — the reinstalled box takes +it over). The clean-slate re-issues (a new claim code mailed to the household, the off-site provider password reset) +run only on the MINT path, i.e. after the operator deleted the old host — **the operator's delete is their gate, +and stays so.** A reinstall that kept the old record used to skip them in silence. Now the reuse path classifies the +old box and re-issues NOTHING: **gone** (no controller wait connection open now, and either `deliberate_off_at` +inside its hold or BOTH the host report and the controller report older than `alerting.stale_threshold` or absent — +the `hostStatus` threshold and comparison) → the operator-only WARNING `host_reinstall_over_old_host`, mailed to the +operator: 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; **live** → the operator-only INFO `host_reenroll_while_live`, recorded, never mailed. +**Why not re-issue automatically** (a security review of the first draft, the same day): the passphrase is a +household-held secret typed into the installer, and both re-issues are operator SESSION-ONLY actions — not even an +operator token may run them (§17.5; `09` §3 decision 252, R-1055/R-1059: an off-site re-issue „stops its backup for a +while"). Re-issuing on the reuse path would give those powers to anyone holding the passphrase whenever the box is +switched off or silent. The cost of the loud shape is two operator acts per reinstall that kept its old record. Both +event types are hub-origin only. `hub/internal/api/reenroll.go`, pinned by `hub/internal/api/r1154_reenroll_test.go`. ## 9. DR model diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 7c1d380f..db654322 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,4 +1,4 @@ -## v0.159.0 — a refused whole-guest backup has its own alarm text (R-1150); a reinstall re-issues the claim code and the off-site login when the old host is stale or off (R-1154) (2026-10-19) +## 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) - **`whole_guest_backup_refused`** (R-1150). The agent's R-973 cadence cap reports a CRITICAL capability `localapi:backup-cadence-cap` as degraded for 24 h after it refused a whole-guest backup a guest asked for beyond its @@ -19,22 +19,28 @@ `TestR1150_OtherCapStillRaisesCapabilityDegraded`, `TestR1150_BornRefusedAlertsOnce` (red-proved: the pre-fix shape emits `agent_capability_degraded` instead), `TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer` (red-proved: without the register lines the household is mailed). -- **A reinstall over the old host record re-issues** (R-1154; `05` §8 [DESIGN]). `handleHostEnroll` reused an - existing host record and returned at once, so `reissueOnReenroll` (claim code + off-site login) ran only on a mint — - a reinstall that kept the old record skipped both in silence (demo-hp 2026-08-21; Tester4's standing reinstalls). - The reuse path (same host id and key, unchanged) now re-issues when the old box is gone: no controller wait - connection open, and either `deliberate_off_at` inside its hold or BOTH the host report and the controller report - older than `alerting.stale_threshold` (or absent) — the `hostStatus` threshold, wired from main - (`SetStaleThreshold`; unwired = never re-issues). Once per silence (an installer `--resume` before the new agent - reports does not re-issue again): the record is the info event `host_reenroll_reissued`, written by the mint path - too. A live box re-enrolling gets nothing re-issued and the skip is recorded (`host_reenroll_not_reissued`), so a - reinstall within the threshold is visible instead of silent. Both types operator-only, hub-origin only. - `internal/api/reenroll.go`. Tests `TestR1154_StaleOldHostIsReissued`, `TestR1154_DeliberatelyOffOldHostIsReissued`, - `TestR1154_LiveHostIsNotReissued` (host report / controller report / wait connection / connected-while-off), - `TestR1154_ResumeDoesNotReissueTwice`, `TestR1154_NeverReportedAndUnwiredEdges`, `TestR1154_EventsAreOperatorOnlyAndHubOrigin`, - `TestR1154_MainWiresTheStaleThresholdIntoTheAPI` — each red-proved (the pre-fix early return: „claim generation 1→1, - claim mails 0, off-site re-issues 0"; an unconditional re-issue: „claim generation 1→2 … off-site re-issues 1" on a - live box; no once-per-silence check: „claim generation 1→3, off-site re-issues 2"). +- **A reinstall over the old host record is LOUD, never acted on** (R-1154; `05` §8 [DESIGN]). `handleHostEnroll` + reuses an existing host record (same host id and key, unchanged) and the clean-slate re-issues (claim code, off-site + provider password) run only on the mint path — after the operator deleted the old host — so a reinstall that kept + the old record skipped them in silence (demo-hp 2026-08-21; Tester4's standing reinstalls). The reuse path now + classifies the old box and re-issues NOTHING: gone (no controller wait connection open, and either + `deliberate_off_at` inside its hold or BOTH the host report and the controller report older than + `alerting.stale_threshold` or absent — the `hostStatus` threshold, wired from main via `SetStaleThreshold`) → + `host_reinstall_over_old_host` (warning, mailed to the operator) „A reinstall of enrolled while the old + host record still exists (); nothing was re-issued — 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."; live → + `host_reenroll_while_live` (info, recorded). Both operator-only, hub-origin only. **Why no automatic re-issue:** the + first draft (commit 5121536f, never pushed) re-issued on a stale or off old host; a security review flagged it — + the passphrase is household-held, and both re-issues are operator SESSION-ONLY (`05` §17.5, `09` §3 decision 252, + R-1055/R-1059), so it would have let anyone with the passphrase reset the off-site login and rotate the claim code + of a box on holiday. `internal/api/reenroll.go`. Tests `TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing`, + `TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing`, `TestR1154_LiveHostIsRecordedNotWarned` (host report / + controller report / wait connection / connected-while-off), `TestR1154_NeverReportedUnwiredAndMintEdges` (the mint + after the operator's delete still re-issues), `TestR1154_EventsAreOperatorOnlyAndHubOrigin`, + `TestR1154_MainWiresTheStaleThresholdIntoTheAPI` — red-proved: the pre-fix early return („no + host_reinstall_over_old_host event — the reinstall is silent again"), the first draft's automatic re-issue („the + reuse path re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1"), an unconditional „gone" („a live + box raised the reinstall warning"). ## v0.158.0 — agent-only vouch, the hub's own snapshot alarms, the hub's own access log (2026-10-17) — built that night; DEPLOYED 2026-10-18 on the operator's yes (12:16) diff --git a/hub/README.md b/hub/README.md index d597cf21..e7d50465 100644 --- a/hub/README.md +++ b/hub/README.md @@ -152,9 +152,10 @@ Events are the primary monitoring mechanism. Each event has: customer_id, event_ - `whole_guest_backup_refused` (warning) / `whole_guest_backup_refusal_cleared` (info) — the same checker, a SEPARATE state (R-1150, v0.159.0): the agent's `localapi:backup-cadence-cap` reports that it refused a whole-guest backup a guest asked for beyond its schedule (R-973). Operator-only; hub-origin only (not POST-able by a box). -- `host_reenroll_reissued` / `host_reenroll_not_reissued` (info) — `POST /host-enroll` reused an existing host - record and re-issued the claim code and the off-site login (the old box is gone), or did not (the box is live) - (R-1154, v0.159.0). Operator-only; hub-origin only. +- `host_reinstall_over_old_host` (warning) / `host_reenroll_while_live` (info) — `POST /host-enroll` reused an + existing host record while the old box looks gone (the operator is mailed: delete the old host and re-run, or + re-issue by hand) or while it is live (recorded) (R-1154, v0.159.0). Nothing is re-issued on that path. + Operator-only; hub-origin only. - `host_leaf_changed` — `HostLeafChecker` (`monitor/host_leaf.go`, every 60s): an agent's reported local-API **leaf fingerprint** changed (an agent re-key) — proactive, fleet-wide, independent of any controller's channel-health check. Trust-on-first-report baseline; empty fp = unknown (never alerts). @@ -187,7 +188,7 @@ Config retrieval uses a separate per-customer retrieval password (not the API ke |--------|------|-------------| | `POST` | `/api/v1/host-enroll` | Mint-or-reuse a host credential, body `{customer_id}` (auth: `X-Retrieval-Password` header) → `{host_id, api_key}` | -The Day-0 first-boot handshake (`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`) enrolls a Proxmox host's agent **using the customer's retrieval passphrase only** — the operator-tier global key never enters the field deploy path. `host-enroll` mints the per-host credential on the first call and **reuses it byte-for-byte** on every later call (idempotent — re-running the host-bootstrap never orphans a running agent's key), backed by `Store.GetHostByCustomer`. Auth is checked **before** any mint, so a wrong passphrase never creates a host row. Behaviour: first → `201`, reuse → `200`, wrong/missing passphrase → `401`, unknown customer → `404`, missing `customer_id` → `400`. **Reinstall (R-1154, v0.159.0):** on a reuse whose old box is gone — its host report AND its controller report both older than `alerting.stale_threshold` (or absent), or the household switched it off on purpose, and no controller wait connection open — the enroll also runs the clean-slate re-issues (claim code, off-site login), once per silence; a live box re-enrolling gets nothing re-issued (`internal/api/reenroll.go`, `05` §8). +The Day-0 first-boot handshake (`documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md`) enrolls a Proxmox host's agent **using the customer's retrieval passphrase only** — the operator-tier global key never enters the field deploy path. `host-enroll` mints the per-host credential on the first call and **reuses it byte-for-byte** on every later call (idempotent — re-running the host-bootstrap never orphans a running agent's key), backed by `Store.GetHostByCustomer`. Auth is checked **before** any mint, so a wrong passphrase never creates a host row. Behaviour: first → `201`, reuse → `200`, wrong/missing passphrase → `401`, unknown customer → `404`, missing `customer_id` → `400`. **Reinstall (R-1154, v0.159.0):** a reuse never re-issues the claim code or the off-site login (operator session-only powers — the passphrase must not reach them; they run on a mint, after the operator deleted the old host). When the old box looks gone — its host report AND its controller report both older than `alerting.stale_threshold` (or absent), or switched off on purpose, and no controller wait connection open — the operator gets a warning mail saying to delete the old host and re-run, or re-issue by hand (`internal/api/reenroll.go`, `05` §8). The global-key `POST /api/v1/admin/hosts` (operator/HQ pre-mint) remains as the escape hatch pending the enrollment-cutover lock-down; it and `GET /config/{id}` are unchanged by this endpoint. diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 906f6ab6..d1ec4830 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -113,9 +113,9 @@ type Handler struct { poker Poker // staleThreshold (R-1154, v0.159.0) is the hub's report-liveness threshold (alerting.stale_threshold — the - // same value hostStatus and both staleness checkers read). The host-enroll REUSE path re-issues the - // clean-slate credentials only when the old box has been silent past it (reenroll.go). 0 = not wired: the - // reuse path then never re-issues (the pre-v0.159.0 behaviour — the fail-safe direction). main wires it. + // same value hostStatus and both staleness checkers read). The host-enroll REUSE path judges by it whether + // the old box is gone (a reinstall over the old record → the operator's warning mail) or live (reenroll.go). + // 0 = not wired: no verdict, a log line only. main wires it. staleThreshold time.Duration } @@ -1218,8 +1218,9 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) { // Mint-once-reuse: an existing host for this customer is returned as-is (idempotent) — the SAME host id and // key, so re-running the installer never orphans a live agent's key and a reinstalled box takes over the - // record. R-1154 (v0.159.0): when the old box has gone silent (or was switched off on purpose), this - // enroll is a reinstall and the clean-slate re-issues run here too — reenroll.go decides. + // record. The clean-slate re-issues do NOT run here: they are operator session-only powers and the + // passphrase must not reach them (R-1154, reenroll.go). A reinstall over the old record is made LOUD — an + // operator-only warning mail saying to delete the old host and re-run, or re-issue by hand. existing, err := h.store.GetHostByCustomer(req.CustomerID) if err != nil { h.logger.Printf("[ERROR] host-enroll: host lookup failed for %s: %v", req.CustomerID, err) @@ -1233,7 +1234,7 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal error", http.StatusInternalServerError) return } - h.reissueOnReuseIfReinstall(cc, existing, time.Now()) + h.noteReenrollOverExistingHost(cc, existing, time.Now()) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey}) @@ -1260,17 +1261,14 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) { h.logger.Printf("[INFO] host enrolled: %s (customer %s)", hostID, req.CustomerID) // F2 (v0.57.0) — clean-slate reinstall of an existing customer. This mint path fires exactly - // once per fresh host record (a host deleted before the reinstall), so it is one of the two - // single-shot re-enroll hooks — the other is the reuse path above for a silent old host (R-1154). - // For a CLAIMED customer the fresh box has no + // once per fresh host record (the operator deleted the stale host, so re-enroll mints), so it is + // the single-shot re-enroll hook — and the operator's delete is its gate (R-1154: the reuse path + // 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. h.reissueOnReenroll(cc) - // R-1154: the record that the re-issues ran for this host record — an installer --resume that - // enrolls again before the new agent's first report must not re-issue a second time. - h.recordReenrollReissued(cc.CustomerID, hostID, "a new host record") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) diff --git a/hub/internal/api/r1154_reenroll_test.go b/hub/internal/api/r1154_reenroll_test.go index 66f66632..cfcd8643 100644 --- a/hub/internal/api/r1154_reenroll_test.go +++ b/hub/internal/api/r1154_reenroll_test.go @@ -5,18 +5,25 @@ import ( "database/sql" "encoding/json" "fmt" + "io" + "log" "net/http" + "os" + "path/filepath" + "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/intent" + "gitea.dooplex.hu/admin/felhom-hub/internal/mailhold" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) -// R-1154 (v0.159.0) — a reinstall while the customer's OLD host record still exists re-issues the claim code and -// the off-site login when the old box is gone (silent past the stale threshold on BOTH report streams, or switched -// off on purpose), and NEVER while the box is live. reenroll.go carries the rule; `05` §8 the design. +// R-1154 (v0.159.0) — a reinstall while the customer's OLD host record still exists. The reuse path NEVER +// re-issues the claim code or the off-site login (operator session-only powers — `05` §17.5; the passphrase must +// not reach them) and makes the reinstall LOUD: an operator-only warning mailed to the operator. reenroll.go +// carries the rule; `05` §8 the design. const r1154Host = "c1-abc123" @@ -25,12 +32,13 @@ type r1154Fixture struct { st *store.Store db *sql.DB mail *nullMailer - offsite int // offsite re-issuer calls + offsite int // off-site re-issuer calls gen0 int // the claim generation before the enroll } // newR1154 builds a CLAIMED customer c1 with an existing host record that has reported, the hub threshold at 45 m -// (the production value), a claim engine and a counting off-site re-issuer. +// (the production value), a claim engine, a counting off-site re-issuer, and a dispatcher behind a MAIL-HOLD +// marker (so the operator mail path is observable in notification_log as "held" with no network). func newR1154(t *testing.T) *r1154Fixture { t.Helper() h, st, path := newTestHandler(t) @@ -54,6 +62,13 @@ func newR1154(t *testing.T) *r1154Fixture { return nil }) h.SetStaleThreshold(45 * time.Minute) + holdDir := t.TempDir() + if err := os.WriteFile(filepath.Join(holdDir, mailhold.FileName), nil, 0o600); err != nil { + t.Fatal(err) + } + d := notify.NewDispatcher(st, "test-key-not-used", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0)) + d.SetMailHold(mailhold.New(holdDir, log.New(io.Discard, "", 0))) + h.SetDispatcher(d) if err := st.UpsertHost(&store.Host{HostID: r1154Host, CustomerID: "c1", APIKey: "old-host-key-0123456789"}); err != nil { t.Fatal(err) } @@ -66,7 +81,6 @@ func newR1154(t *testing.T) *r1154Fixture { } t.Cleanup(func() { db.Close() }) f.db = db - f.exec(t, `UPDATE hosts SET created_at = datetime('now', '-30 days') WHERE host_id = ?`, r1154Host) f.mail.sends = 0 f.gen0 = f.generation(t) return f @@ -100,8 +114,8 @@ func (f *r1154Fixture) generation(t *testing.T) int { return cs.Generation } -// enroll runs the installer's call and checks the reuse contract that must hold on EVERY branch: 200, the same -// host id and key. +// enroll runs the installer's call and checks the reuse contract that holds on EVERY branch: 200, the same host id +// and key, one record — and NOTHING re-issued (no claim rotation, no claim mail, no off-site re-issue). func (f *r1154Fixture) enroll(t *testing.T) { t.Helper() rr := doEnroll(f.h, "c1", "pass-phrase") @@ -118,20 +132,9 @@ func (f *r1154Fixture) enroll(t *testing.T) { if n := countHostsForCustomer(t, f.st, "c1"); n != 1 { t.Fatalf("host rows = %d, want 1 (the record is reused, never duplicated)", n) } -} - -// reissued reports what the enroll did: claim generation moved, mails sent, off-site calls. -func (f *r1154Fixture) assertReissued(t *testing.T, want bool) { - t.Helper() - gen := f.generation(t) - if want { - if gen != f.gen0+1 || f.mail.sends != 1 || f.offsite != 1 { - t.Fatalf("want re-issued once: claim generation %d→%d, claim mails %d, off-site re-issues %d", f.gen0, gen, f.mail.sends, f.offsite) - } - return - } - if gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 { - t.Fatalf("want NOT re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d", f.gen0, gen, f.mail.sends, f.offsite) + if gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 { + t.Fatalf("the reuse path re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d — a passphrase must not reach these", + f.gen0, gen, f.mail.sends, f.offsite) } } @@ -144,37 +147,75 @@ func (f *r1154Fixture) eventAt(t *testing.T, et string) time.Time { return at } -// The old box silent on both streams past the threshold → the enroll re-issues both, keeps the record, records it. +// operatorMailed waits (the dispatch is asynchronous) for an operator-channel notification row of eventType. +func (f *r1154Fixture) operatorMailed(t *testing.T, eventType string) bool { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + logs, err := f.st.GetRecentNotifications("c1", 50) + if err != nil { + t.Fatal(err) + } + for _, l := range logs { + if l.EventType == eventType && l.Channel == "operator" { + return true + } + } + time.Sleep(20 * time.Millisecond) + } + return false +} + +func (f *r1154Fixture) eventMessage(t *testing.T, et string) string { + t.Helper() + var msg, sev string + if err := f.db.QueryRow(`SELECT message, severity FROM events WHERE customer_id='c1' AND event_type=? ORDER BY id DESC LIMIT 1`, et).Scan(&msg, &sev); err != nil { + t.Fatalf("event %s: %v", et, err) + } + return sev + "|" + msg +} + +// The old box silent on both streams past the threshold → nothing re-issued, the operator WARNED by mail with the +// instruction. // // COMPANION RED-PROOF (observed 2026-10-19): with the pre-fix reuse path restored (the existing host returned at -// once, reissueOnReuseIfReinstall not called) this fails with +// once, noteReenrollOverExistingHost not called) this fails with // -// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0 +// r1154_reenroll_test.go: no host_reinstall_over_old_host event — the reinstall is silent again +// +// and with the first draft's automatic re-issue (reissueOnReenroll called on this branch) it fails with +// +// r1154_reenroll_test.go: the reuse path re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1 — a passphrase must not reach these // // Restored. -func TestR1154_StaleOldHostIsReissued(t *testing.T) { +func TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing(t *testing.T) { f := newR1154(t) f.hostReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 2*time.Hour) f.enroll(t) - f.assertReissued(t, true) - if f.eventAt(t, eventHostReenrollReissued).IsZero() { - t.Error("host_reenroll_reissued not recorded") + if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { + t.Fatal("no host_reinstall_over_old_host event — the reinstall is silent again") } - if !f.eventAt(t, "claim_reissued_reenroll").After(time.Time{}) { - t.Error("claim_reissued_reenroll (the claim leg's own event) not recorded") + got := f.eventMessage(t, eventHostReinstallOverOldHost) + for _, want := range []string{"warning|", "A reinstall of c1", r1154Host, "nothing was re-issued", "delete the old host", "re-issue the claim code and the off-site login from the customer page"} { + if !strings.Contains(got, want) { + t.Errorf("warning text lacks %q: %s", want, got) + } + } + if !f.operatorMailed(t, eventHostReinstallOverOldHost) { + t.Error("the operator mail path was not reached (no operator notification row)") } } -// Switched off on purpose minutes ago (both report clocks still fresh) → re-issued: the box said it was going down. +// Switched off on purpose minutes ago (both report clocks still fresh) → the same warning: the box said it was +// going down. Deliberate-off only changes which message the operator reads — it unlocks nothing. // -// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed from decideReenroll this fails -// with +// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed this fails with // -// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0 +// r1154_reenroll_test.go: deliberately off: no host_reinstall_over_old_host event // // Restored. -func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) { +func TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing(t *testing.T) { f := newR1154(t) f.hostReportedAgo(t, 3*time.Minute) f.controllerReportedAgo(t, 3*time.Minute) @@ -183,20 +224,24 @@ func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) { t.Fatal(err) } f.enroll(t) - f.assertReissued(t, true) + if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { + t.Fatal("deliberately off: no host_reinstall_over_old_host event") + } + if !strings.Contains(f.eventMessage(t, eventHostReinstallOverOldHost), "switched the box off") { + t.Errorf("the warning does not say why: %s", f.eventMessage(t, eventHostReinstallOverOldHost)) + } } -// A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) keeps every -// credential: nothing re-issued, and the skip is recorded so a too-early reinstall is visible. Three ways to be -// live, each alone: the host report, the controller report (the codes live in the controller), the wait connection. +// 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. // -// COMPANION RED-PROOF (observed 2026-10-19): with decideReenroll replaced by an unconditional re-issue on the reuse -// path this fails with +// COMPANION RED-PROOF (observed 2026-10-19): with classifyReenroll replaced by an unconditional "gone" this fails +// with // -// r1154_reenroll_test.go: host report fresh: want NOT re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1 +// r1154_reenroll_test.go: host report fresh: a live box raised the reinstall warning // -// (and the same for the other two cases). Restored. -func TestR1154_LiveHostIsNotReissued(t *testing.T) { +// (and the same for the other three cases). Restored. +func TestR1154_LiveHostIsRecordedNotWarned(t *testing.T) { cases := []struct { name string setup func(t *testing.T, f *r1154Fixture) @@ -231,80 +276,33 @@ func TestR1154_LiveHostIsNotReissued(t *testing.T) { f := newR1154(t) c.setup(t, f) f.enroll(t) - if gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 { - t.Fatalf("%s: want NOT re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d", - c.name, f.gen0, gen, f.mail.sends, f.offsite) + if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { + t.Fatalf("%s: a live box raised the reinstall warning", c.name) } - if f.eventAt(t, eventHostReenrollNotReissued).IsZero() { - t.Errorf("%s: the live skip was not recorded (host_reenroll_not_reissued)", c.name) + if f.eventAt(t, eventHostReenrollWhileLive).IsZero() { + t.Errorf("%s: the live re-enroll was not recorded (host_reenroll_while_live)", c.name) } - if !f.eventAt(t, eventHostReenrollReissued).IsZero() { - t.Errorf("%s: a re-issue record was written for a live box", 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)) } }) } } -// Once per silence: an installer --resume of the same reinstall enrolls again before the new agent's first report -// — the old box is still "silent", and the re-issues must not run twice (the claim engine's single-shot rule). -// Then the new agent reports, goes silent again past the threshold (the next reinstall): re-issued again. +// Edges the code shows. A host record that never reported (an operator /admin/hosts mint) with no controller +// report → the warning; not wired (no threshold) → no event at all, nothing re-issued; and the MINT path (the old +// host deleted by the operator) still re-issues exactly as before — the operator's delete is the gate. // -// COMPANION RED-PROOF (observed 2026-10-19): with the once-per-silence check removed from decideReenroll this fails -// with -// -// r1154_reenroll_test.go: the --resume re-issued again: claim generation 1→3, off-site re-issues 2 -// -// Restored. -func TestR1154_ResumeDoesNotReissueTwice(t *testing.T) { - f := newR1154(t) - f.hostReportedAgo(t, 2*time.Hour) - f.controllerReportedAgo(t, 2*time.Hour) - f.enroll(t) - f.enroll(t) // the --resume - if gen := f.generation(t); gen != f.gen0+1 || f.offsite != 1 { - t.Fatalf("the --resume re-issued again: claim generation %d→%d, off-site re-issues %d", f.gen0, gen, f.offsite) - } - // The record is backdated so the next report is strictly after it (second-granularity clocks). - f.exec(t, `UPDATE events SET created_at = datetime('now', '-3 hours') WHERE event_type = ?`, eventHostReenrollReissued) - f.exec(t, `UPDATE hosts SET last_report_at = datetime('now', '-2 hours', '-30 minutes') WHERE host_id = ?`, r1154Host) - f.enroll(t) - if gen := f.generation(t); gen != f.gen0+2 || f.offsite != 2 { - t.Fatalf("a later reinstall (the box reported after the last re-issue, then went silent) must re-issue: claim generation %d→%d, off-site re-issues %d", - f.gen0, gen, f.offsite) - } -} - -// Edges the code shows. A host record that never reported: a provisional record (operator /admin/hosts, no -// re-issue ran for it) → re-issued, like the mint it stands in for; a record the enroll itself minted (the -// re-issues ran at mint) → not again on the --resume. Not wired (no threshold) → nothing re-issued, the -// pre-v0.159.0 behaviour. -// -// COMPANION RED-PROOF (observed 2026-10-19): with the pre-fix reuse path restored, „provisional never reported" -// fails with „want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0"; with the -// once-per-silence check removed, „minted by the enroll" fails with „off-site re-issues = 2, want 1"; with an -// unconditional re-issue, „threshold not wired" fails with „want NOT re-issued: claim generation 1→2, claim mails -// 1, off-site re-issues 1". Restored. -func TestR1154_NeverReportedAndUnwiredEdges(t *testing.T) { - t.Run("provisional never reported", func(t *testing.T) { +// COMPANION RED-PROOF (observed 2026-10-19): with the call removed from the reuse path „never reported" fails with +// „never reported: no host_reinstall_over_old_host event"; with an unconditional "gone", „threshold not wired" +// fails with „not wired: an event was written". Restored. +func TestR1154_NeverReportedUnwiredAndMintEdges(t *testing.T) { + t.Run("never reported", func(t *testing.T) { f := newR1154(t) f.exec(t, `UPDATE hosts SET last_report_at = NULL WHERE host_id = ?`, r1154Host) f.enroll(t) - f.assertReissued(t, true) - }) - t.Run("minted by the enroll, never reported", func(t *testing.T) { - h, st, _ := newTestHandler(t) - st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"}) - h.SetStaleThreshold(45 * time.Minute) - calls := 0 - h.SetOffsiteReissuer(func(context.Context, string) error { calls++; return nil }) - if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusCreated { - t.Fatalf("first enroll = %d", rr.Code) - } - if rr := doEnroll(h, "c1", "pass-phrase"); rr.Code != http.StatusOK { - t.Fatalf("resume enroll = %d", rr.Code) - } - if calls != 1 { - t.Fatalf("off-site re-issues = %d, want 1 (at the mint only, not again on the --resume)", calls) + if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() { + t.Fatal("never reported: no host_reinstall_over_old_host event") } }) t.Run("threshold not wired", func(t *testing.T) { @@ -313,14 +311,28 @@ func TestR1154_NeverReportedAndUnwiredEdges(t *testing.T) { f.hostReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 2*time.Hour) f.enroll(t) - f.assertReissued(t, false) + if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() || !f.eventAt(t, eventHostReenrollWhileLive).IsZero() { + t.Fatal("not wired: an event was written") + } + }) + t.Run("old host deleted: the mint path re-issues", func(t *testing.T) { + f := newR1154(t) + if err := f.st.DeleteHost(r1154Host, false); err != nil { + t.Fatalf("DeleteHost: %v", err) + } + if rr := doEnroll(f.h, "c1", "pass-phrase"); rr.Code != http.StatusCreated { + t.Fatalf("enroll after delete = %d, want 201 (mint)", rr.Code) + } + if gen := f.generation(t); gen != f.gen0+1 || f.mail.sends != 1 || f.offsite != 1 { + t.Fatalf("mint after the operator's delete must re-issue: claim generation %d→%d, claim mails %d, off-site re-issues %d", + f.gen0, gen, f.mail.sends, f.offsite) + } }) } -// Both R-1154 event types: operator-only, and hub-origin only — a box able to POST host_reenroll_reissued could -// plant the once-per-silence record and block the re-issue of its own reinstall. +// Both R-1154 event types: operator-only, and hub-origin only (no box can POST them). func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) { - for _, et := range []string{eventHostReenrollReissued, eventHostReenrollNotReissued} { + for _, et := range []string{eventHostReinstallOverOldHost, eventHostReenrollWhileLive} { if !notify.IsOperatorOnly(et) { t.Errorf("%s is not operator-only", et) } diff --git a/hub/internal/api/reenroll.go b/hub/internal/api/reenroll.go index bb8b7832..0f89e305 100644 --- a/hub/internal/api/reenroll.go +++ b/hub/internal/api/reenroll.go @@ -13,130 +13,111 @@ import ( // // POST /host-enroll is called by the installer (felhom-host-install.sh step 4) on EVERY pass, a --resume // included, with the customer's retrieval passphrase. An existing host record is reused (same host id and -// key — a reinstalled box takes over the record). Until v0.159.0 the reuse path returned at once, so the -// clean-slate re-issues (reissueOnReenroll: the claim code, the off-site login) ran ONLY when the old host -// had been deleted first, and a plain reinstall skipped both in silence: the household stuck at the claim -// page, the off-site tier stuck (proven on demo-hp 2026-08-21). +// key — a reinstalled box takes over the record). The clean-slate re-issues (reissueOnReenroll: a new claim +// code mailed to the household, the off-site provider password reset) run ONLY on the mint path, i.e. after +// the OPERATOR deleted the old host. Until v0.159.0 a reinstall that kept the old record skipped them IN +// SILENCE (demo-hp 2026-08-21; Tester4's standing reinstalls). // -// THE RULE (`05` §8). The reuse path re-issues when the OLD BOX IS GONE, judged by the hub's own clocks: +// THE RULE: the reuse path NEVER re-issues anything; it makes the reinstall LOUD instead. // -// 1. NEVER while the box's controller holds a wait connection right now (intent presence "connected"). -// 2. The household switched the box off on purpose (deliberate_off_at inside its hold) — the report clocks -// are not consulted: the box said it was going down. -// 3. Otherwise BOTH the agent's host report AND the controller's report are older than staleThreshold (or -// absent). The claim code and the off-site login live in the CONTROLLER, so a box whose agent is -// silent but whose controller still reports is live for this purpose (an installer re-run to repair a -// broken agent must not cost the household its codes). Same threshold, same comparison as hostStatus: -// no second definition of "live". -// 4. Once per silence: not when the re-issues already ran for this host since its last report (or, for a -// never-reported host, since the record was created) — a --resume of the same reinstall enrolls again -// before the new agent's first report. The record is the hub-origin info event host_reenroll_reissued. +// - The old box looks gone (no controller wait connection open now, AND either the household switched it +// off on purpose — deliberate_off_at inside its hold — or BOTH the agent's host report and the +// 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. // -// A live box that enrolls again (a --resume after its agent started, a repair re-run) gets its credentials -// and NOTHING re-issued; that skip is recorded as the info event host_reenroll_not_reissued, so a reinstall -// done within the threshold of the old box's last report is visible on the event list instead of silent. -// Not wired (staleThreshold 0) or a failed lookup: nothing is re-issued — the pre-v0.159.0 behaviour. +// 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 +// (re)sending its claim code are operator SESSION-ONLY actions — not even an operator token may do them +// (`05` §17.5; `09` §3 decision 252, R-1055 / R-1059: a re-issue „stops its backup for a while"). An automatic +// re-issue here would hand those powers to anyone holding the passphrase whenever the box is off or silent +// (a household on holiday). The operator's delete stays the gate: delete the old host, run the installer +// again, and the mint path re-issues exactly as before. // -// Both event types are hub-origin only (not in allowedEventTypes — a box must not be able to plant the -// once-per-silence record) and operator-only. Pinned by r1154_reenroll_test.go. +// Not wired (staleThreshold 0) or a failed lookup: no verdict, a WARN log line only. Both event types are +// hub-origin only (not in allowedEventTypes) and operator-only. Pinned by r1154_reenroll_test.go. const ( - eventHostReenrollReissued = "host_reenroll_reissued" - eventHostReenrollNotReissued = "host_reenroll_not_reissued" + eventHostReinstallOverOldHost = "host_reinstall_over_old_host" + eventHostReenrollWhileLive = "host_reenroll_while_live" ) -// reenrollDecision is the verdict on a reuse-path enroll. -type reenrollDecision struct { - reissue bool // run the clean-slate re-issues - live bool // skipped because the box is live (recorded as an event) - why string // operator English, for the log line and the event -} +// reenrollVerdict classifies a reuse-path enroll. +type reenrollVerdict int -func (h *Handler) decideReenroll(host *store.Host, now time.Time) reenrollDecision { +const ( + reenrollUnknown reenrollVerdict = iota // not wired / lookup failed — log only + reenrollLive // the box is live + reenrollGone // the old box looks gone: a reinstall over the old record +) + +func (h *Handler) classifyReenroll(host *store.Host, now time.Time) (reenrollVerdict, string) { if h.staleThreshold <= 0 { - return reenrollDecision{why: "no stale threshold wired"} + return reenrollUnknown, "no stale threshold wired" } if h.intentHub != nil { if p := h.intentHub.Presence(host.CustomerID, now); p.State == intent.PresenceConnected { - return reenrollDecision{live: true, why: "the box's controller is connected to the hub right now"} + return reenrollLive, "the box's controller is connected to the hub right now" } } - var why string if store.DeliberateOffHeld(host.DeliberateOffAt, now) { - why = "the household switched the box off at " + host.DeliberateOffAt.UTC().Format("2006-01-02 15:04 UTC") - } else { - hostAge := "never reported" - if host.LastReportAt != nil { - age := now.Sub(*host.LastReportAt) - if age <= h.staleThreshold { - return reenrollDecision{live: true, why: "the host reported " + roundAge(age) + " ago"} - } - hostAge = "last reported " + roundAge(age) + " ago" - } - found, ctrlAt, _, err := h.store.LatestReportOffsitePresence(host.CustomerID) - if err != nil { - h.logger.Printf("[WARN] host-enroll: controller report lookup failed for %s: %v — nothing re-issued", host.CustomerID, err) - return reenrollDecision{why: "the controller report lookup failed"} - } - ctrlAge := "the controller never reported" - if found { - age := now.Sub(ctrlAt) - if age <= h.staleThreshold { - return reenrollDecision{live: true, why: "the controller reported " + roundAge(age) + " ago"} - } - ctrlAge = "the controller last reported " + roundAge(age) + " ago" - } - why = "the host " + hostAge + ", " + ctrlAge + " (stale threshold " + h.staleThreshold.String() + ")" + return reenrollGone, "the household switched the box off at " + host.DeliberateOffAt.UTC().Format("2006-01-02 15:04 UTC") } - // Once per silence. - since := host.CreatedAt - if host.LastReportAt != nil && host.LastReportAt.After(since) { - since = *host.LastReportAt + hostAge := "never reported" + if host.LastReportAt != nil { + age := now.Sub(*host.LastReportAt) + if age <= h.staleThreshold { + return reenrollLive, "the host reported " + roundAge(age) + " ago" + } + hostAge = "last reported " + roundAge(age) + " ago" } - last, err := h.store.LastEventAt(host.CustomerID, eventHostReenrollReissued) + found, ctrlAt, _, err := h.store.LatestReportOffsitePresence(host.CustomerID) if err != nil { - h.logger.Printf("[WARN] host-enroll: re-issue record lookup failed for %s: %v — nothing re-issued", host.CustomerID, err) - return reenrollDecision{why: "the re-issue record lookup failed"} + return reenrollUnknown, "the controller report lookup failed: " + err.Error() } - if !last.IsZero() && !last.Before(since) { - return reenrollDecision{why: "already re-issued at " + last.UTC().Format("2006-01-02 15:04:05 UTC") + " for this install"} + ctrlAge := "the controller never reported" + if found { + age := now.Sub(ctrlAt) + if age <= h.staleThreshold { + return reenrollLive, "the controller reported " + roundAge(age) + " ago" + } + ctrlAge = "the controller last reported " + roundAge(age) + " ago" } - return reenrollDecision{reissue: true, why: why} + return reenrollGone, "the host " + hostAge + ", " + ctrlAge + " (stale threshold " + h.staleThreshold.String() + ")" } -// reissueOnReuseIfReinstall runs the clean-slate re-issues on the reuse path when decideReenroll says the -// old box is gone, and records the outcome. Best-effort, like reissueOnReenroll: it never fails the enroll. -func (h *Handler) reissueOnReuseIfReinstall(cc *store.CustomerConfig, host *store.Host, now time.Time) { - d := h.decideReenroll(host, now) - switch { - case d.reissue: - h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — re-issuing the claim code and the off-site login: %s", - host.HostID, cc.CustomerID, d.why) - h.reissueOnReenroll(cc) - h.recordReenrollReissued(cc.CustomerID, host.HostID, d.why) - case d.live: - h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — nothing re-issued, the box is live: %s", - host.HostID, cc.CustomerID, d.why) - msg := "Host " + host.HostID + " enrolled again while the box is live (" + d.why + ") — the claim code and " + - "the off-site login were NOT re-issued. If this box was reinstalled, send a new claim code and re-issue " + - "the off-site login from the customer page." - h.saveReenrollEvent(cc.CustomerID, eventHostReenrollNotReissued, msg, host.HostID, d.why) +// noteReenrollOverExistingHost records (and for a reinstall, mails the operator about) an enroll that reused +// the old host record. It re-issues NOTHING and never fails the enroll. +func (h *Handler) noteReenrollOverExistingHost(cc *store.CustomerConfig, host *store.Host, now time.Time) { + v, why := h.classifyReenroll(host, now) + switch v { + case reenrollGone: + h.logger.Printf("[WARN] host re-enrolled: %s (customer %s) over the old host record — %s; nothing re-issued, the operator is told", + host.HostID, cc.CustomerID, why) + msg := "A reinstall of " + cc.CustomerID + " enrolled while the old host record " + host.HostID + " still exists (" + + why + "); nothing was re-issued — 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." + 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) default: - h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — nothing re-issued: %s", host.HostID, cc.CustomerID, d.why) + h.logger.Printf("[WARN] host re-enrolled: %s (customer %s) — not classified: %s", host.HostID, cc.CustomerID, why) } } -// recordReenrollReissued stores the once-per-silence record (and the operator's line) after the re-issues ran. -func (h *Handler) recordReenrollReissued(customerID, hostID, why string) { - msg := "Host " + hostID + " enrolled — " + why + " — the clean-slate re-issues ran (the claim code if the " + - "customer is claimed, the off-site login if it is provisioned)" - h.saveReenrollEvent(customerID, eventHostReenrollReissued, msg, hostID, why) -} - -func (h *Handler) saveReenrollEvent(customerID, eventType, msg, hostID, why string) { +func (h *Handler) saveReenrollEvent(customerID, eventType, severity, msg, hostID, why string) { details, _ := json.Marshal(map[string]string{"host_id": hostID, "reason": why}) - if _, err := h.store.SaveEvent(customerID, eventType, "info", msg, string(details), "hub"); err != nil { + if _, err := h.store.SaveEvent(customerID, eventType, severity, msg, string(details), "hub"); err != nil { h.logger.Printf("[WARN] save %s for %s: %v", eventType, customerID, err) + return + } + if h.dispatcher != nil { + go h.dispatcher.ProcessEvent(customerID, eventType, severity, msg, string(details), "hub") } } diff --git a/hub/internal/claim/engine.go b/hub/internal/claim/engine.go index 3047b6ee..211e6c2c 100644 --- a/hub/internal/claim/engine.go +++ b/hub/internal/claim/engine.go @@ -191,9 +191,8 @@ func (e *Engine) RequestReset(cc *store.CustomerConfig) error { // // No-op for an UNCLAIMED customer — that is the first-provision path where EnsureIssued already // owns the first code; re-enrolling before the first claim must NOT rotate. The CALLER guarantees -// single-shot: the host-enroll mint path (a fresh host record), and since hub v0.159.0 (R-1154) the -// reuse path once per silence of the old box (api/reenroll.go). Pinned by api/r1154_reenroll_test.go -// TestR1154_ResumeDoesNotReissueTwice. Returns (generation, reissued, error). +// single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path, +// which fires exactly once per reinstall). Returns (generation, reissued, error). func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) { cs, err := e.Store.GetClaim(cc.CustomerID) if err != nil { diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 6c802671..171191c2 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -890,12 +890,12 @@ var operatorOnlyEvents = map[string]bool{ // since v0.44.0, and only configuration (enabled_events) made that true — the R-97c shape. Now structural. "agent_capability_degraded": true, "agent_capability_recovered": true, - // R-1154 (v0.159.0): a host-enroll that reused the old host record — re-issued the clean-slate credentials, or - // did NOT because the box is live. Info (recorded, never mailed), host ids and report ages: the operator's - // record. Hub-origin only (api/reenroll.go; not in api.allowedEventTypes — a box must not plant the - // once-per-silence record). Listed in the SAME commit that mints them. - "host_reenroll_reissued": true, - "host_reenroll_not_reissued": true, + // R-1154 (v0.159.0): a host-enroll that reused the old host record — a reinstall over it (warning: the operator + // deletes the old host or re-issues by hand) or a live box enrolling again (info). Host ids, report ages, and an + // instruction only the operator can carry out. Hub-origin only (api/reenroll.go; not in api.allowedEventTypes). + // Listed in the SAME commit that mints them. + "host_reinstall_over_old_host": true, + "host_reenroll_while_live": true, } // IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the diff --git a/hub/internal/notify/r1150_operator_only_test.go b/hub/internal/notify/r1150_operator_only_test.go index 6922b70e..cfbd44b2 100644 --- a/hub/internal/notify/r1150_operator_only_test.go +++ b/hub/internal/notify/r1150_operator_only_test.go @@ -15,7 +15,8 @@ import "testing" // // Restored. func TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer(t *testing.T) { - for _, et := range []string{"whole_guest_backup_refused", "agent_capability_degraded"} { + // R-1154 (v0.159.0): the reinstall-over-the-old-host warning rides the same register and the same proof. + for _, et := range []string{"whole_guest_backup_refused", "agent_capability_degraded", "host_reinstall_over_old_host"} { st := opOnlyStore(t) if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{et}, 6); err != nil { t.Fatalf("SaveNotificationPrefs: %v", err) @@ -35,7 +36,7 @@ func TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer(t *testing. t.Errorf("%s: the operator was not mailed — the guard must be customer-only", et) } } - for _, et := range []string{"whole_guest_backup_refusal_cleared", "agent_capability_recovered"} { + for _, et := range []string{"whole_guest_backup_refusal_cleared", "agent_capability_recovered", "host_reenroll_while_live"} { if !operatorOnlyEvents[et] { t.Errorf("%s must be operator-only (the pair's other edge)", et) }