hub v0.159.0 (R-1154, after security review): the enroll reuse path re-issues nothing, it warns the operator

The first draft (5121536f, unpushed) re-issued the claim code and the off-site login on a
reuse whose old box was stale or switched off. A security review flagged it: the caller
holds only the household's retrieval passphrase, and both re-issues are operator
session-only actions (05 section 17.5, 09 decision 252, R-1055/R-1059). Now the reuse
path classifies the old box and re-issues nothing: gone -> host_reinstall_over_old_host
(warning, mailed to the operator: delete the old host and re-run, or re-issue by hand);
live -> host_reenroll_while_live (info). The mint path after the operator's delete is
unchanged and still re-issues. The once-per-silence record and the claim engine comment
edit are reverted. 05 section 8, README and CHANGELOG updated; tests red-proved.
This commit is contained in:
admin committed 2026-10-19 03:29:44 +02:00
1 parent 5121536f10
commit b505ce4ba0
9 files changed
+265 -268

No files matched your search

@@ -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 The existing `configgen` deep-merge + Hungarian-passphrase machinery is the base; it grows the
agent-first + key-pinning + restore-mode steps. 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 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 customer's retrieval passphrase; an existing host record is REUSED (same id and key — the reinstalled box takes
The clean-slate re-issues (the claim code, the off-site login — both live in the CONTROLLER) used to run only when a it over). The clean-slate re-issues (a new claim code mailed to the household, the off-site provider password reset)
record was minted, so a reinstall that kept the old record skipped both in silence. The reuse path now runs them run only on the MINT path, i.e. after the operator deleted the old host — **the operator's delete is their gate,
when, and only when, the old box is gone: **no controller wait connection open now**, and either the household and stays so.** A reinstall that kept the old record used to skip them in silence. Now the reuse path classifies the
switched the box off on purpose (`deliberate_off_at` inside its hold) or **both** the agent's host report and the old box and re-issues NOTHING: **gone** (no controller wait connection open now, and either `deliberate_off_at`
controller's report are older than `alerting.stale_threshold` (or absent) — the threshold and comparison inside its hold or BOTH the host report and the controller report older than `alerting.stale_threshold` or absent —
`hostStatus` uses, no second definition. **Once per silence:** not again when it already ran since the host's last the `hostStatus` threshold and comparison) → the operator-only WARNING `host_reinstall_over_old_host`, mailed to the
report (an installer `--resume` before the new agent's first report). **Why this rule and not "re-issue on every operator: delete the old host (Hosts page) and run the installer again, or re-issue the claim code and the off-site
reuse":** a live box re-enrolls legitimately (a `--resume` after its agent started, a repair re-run), and re-issuing login from the customer page; **live** → the operator-only INFO `host_reenroll_while_live`, recorded, never mailed.
there rotates the household's claim code and resets its off-site login under a running box; not re-issuing costs a **Why not re-issue automatically** (a security review of the first draft, the same day): the passphrase is a
reinstall two operator presses, which the skip record now names. The controller report counts because the codes household-held secret typed into the installer, and both re-issues are operator SESSION-ONLY actions — not even an
live there: a box whose agent is broken but whose controller reports is live. **Known residual:** a reinstall done 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
less than the threshold after the old box's last report is still treated as live — it is recorded while"). Re-issuing on the reuse path would give those powers to anyone holding the passphrase whenever the box is
(`host_reenroll_not_reissued`, operator-only, info) instead of silent, and the operator presses the two buttons or switched off or silent. The cost of the loud shape is two operator acts per reinstall that kept its old record. Both
deletes the old host first. The re-issue writes `host_reenroll_reissued` (also the once-per-silence record); both event types are hub-origin only. `hub/internal/api/reenroll.go`, pinned by `hub/internal/api/r1154_reenroll_test.go`.
types are hub-origin only. `hub/internal/api/reenroll.go`, pinned by `hub/internal/api/r1154_reenroll_test.go`.
## 9. DR model ## 9. DR model
+23 -17
View File
@@ -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 - **`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 `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 `TestR1150_OtherCapStillRaisesCapabilityDegraded`, `TestR1150_BornRefusedAlertsOnce` (red-proved: the pre-fix
shape emits `agent_capability_degraded` instead), `TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer` shape emits `agent_capability_degraded` instead), `TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer`
(red-proved: without the register lines the household is mailed). (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 - **A reinstall over the old host record is LOUD, never acted on** (R-1154; `05` §8 [DESIGN]). `handleHostEnroll`
existing host record and returned at once, so `reissueOnReenroll` (claim code + off-site login) ran only on a mint — reuses an existing host record (same host id and key, unchanged) and the clean-slate re-issues (claim code, off-site
a reinstall that kept the old record skipped both in silence (demo-hp 2026-08-21; Tester4's standing reinstalls). provider password) run only on the mint path — after the operator deleted the old host — so a reinstall that kept
The reuse path (same host id and key, unchanged) now re-issues when the old box is gone: no controller wait the old record skipped them in silence (demo-hp 2026-08-21; Tester4's standing reinstalls). The reuse path now
connection open, and either `deliberate_off_at` inside its hold or BOTH the host report and the controller report classifies the old box and re-issues NOTHING: gone (no controller wait connection open, and either
older than `alerting.stale_threshold` (or absent) — the `hostStatus` threshold, wired from main `deliberate_off_at` inside its hold or BOTH the host report and the controller report older than
(`SetStaleThreshold`; unwired = never re-issues). Once per silence (an installer `--resume` before the new agent `alerting.stale_threshold` or absent — the `hostStatus` threshold, wired from main via `SetStaleThreshold`) →
reports does not re-issue again): the record is the info event `host_reenroll_reissued`, written by the mint path `host_reinstall_over_old_host` (warning, mailed to the operator) „A reinstall of <customer> enrolled while the old
too. A live box re-enrolling gets nothing re-issued and the skip is recorded (`host_reenroll_not_reissued`), so a host record <host> still exists (<why>); nothing was re-issued — delete the old host (Hosts page) and run the
reinstall within the threshold is visible instead of silent. Both types operator-only, hub-origin only. installer again, or re-issue the claim code and the off-site login from the customer page."; live →
`internal/api/reenroll.go`. Tests `TestR1154_StaleOldHostIsReissued`, `TestR1154_DeliberatelyOffOldHostIsReissued`, `host_reenroll_while_live` (info, recorded). Both operator-only, hub-origin only. **Why no automatic re-issue:** the
`TestR1154_LiveHostIsNotReissued` (host report / controller report / wait connection / connected-while-off), first draft (commit 5121536f, never pushed) re-issued on a stale or off old host; a security review flagged it —
`TestR1154_ResumeDoesNotReissueTwice`, `TestR1154_NeverReportedAndUnwiredEdges`, `TestR1154_EventsAreOperatorOnlyAndHubOrigin`, the passphrase is household-held, and both re-issues are operator SESSION-ONLY (`05` §17.5, `09` §3 decision 252,
`TestR1154_MainWiresTheStaleThresholdIntoTheAPI` — each red-proved (the pre-fix early return: „claim generation 1→1, R-1055/R-1059), so it would have let anyone with the passphrase reset the off-site login and rotate the claim code
claim mails 0, off-site re-issues 0"; an unconditional re-issue: „claim generation 1→2 … off-site re-issues 1" on a of a box on holiday. `internal/api/reenroll.go`. Tests `TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing`,
live box; no once-per-silence check: „claim generation 1→3, off-site re-issues 2"). `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) ## 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)
+5 -4
View File
@@ -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 - `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 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). 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 - `host_reinstall_over_old_host` (warning) / `host_reenroll_while_live` (info) — `POST /host-enroll` reused an
record and re-issued the claim code and the off-site login (the old box is gone), or did not (the box is live) existing host record while the old box looks gone (the operator is mailed: delete the old host and re-run, or
(R-1154, v0.159.0). Operator-only; hub-origin only. 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 - `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 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). 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}` | | `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. 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.
+10 -12
View File
@@ -113,9 +113,9 @@ type Handler struct {
poker Poker poker Poker
// staleThreshold (R-1154, v0.159.0) is the hub's report-liveness threshold (alerting.stale_threshold — the // 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 // same value hostStatus and both staleness checkers read). The host-enroll REUSE path judges by it whether
// clean-slate credentials only when the old box has been silent past it (reenroll.go). 0 = not wired: the // the old box is gone (a reinstall over the old record → the operator's warning mail) or live (reenroll.go).
// reuse path then never re-issues (the pre-v0.159.0 behaviour — the fail-safe direction). main wires it. // 0 = not wired: no verdict, a log line only. main wires it.
staleThreshold time.Duration 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 // 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 // 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 // record. The clean-slate re-issues do NOT run here: they are operator session-only powers and the
// enroll is a reinstall and the clean-slate re-issues run here too — reenroll.go decides. // 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) existing, err := h.store.GetHostByCustomer(req.CustomerID)
if err != nil { if err != nil {
h.logger.Printf("[ERROR] host-enroll: host lookup failed for %s: %v", req.CustomerID, err) 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) http.Error(w, "Internal error", http.StatusInternalServerError)
return return
} }
h.reissueOnReuseIfReinstall(cc, existing, time.Now()) h.noteReenrollOverExistingHost(cc, existing, time.Now())
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey}) 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) 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 // 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 // once per fresh host record (the operator deleted the stale host, so re-enroll mints), so it is
// single-shot re-enroll hooks — the other is the reuse path above for a silent old host (R-1154). // the single-shot re-enroll hook — and the operator's delete is its gate (R-1154: the reuse path
// For a CLAIMED customer the fresh box has no // 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 // 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 // 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 // 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. // one-time offsite password only ever reached the OLD controller, so the fresh one has no target.
h.reissueOnReenroll(cc) 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.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
+124 -112
View File
@@ -5,18 +5,25 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
"testing" "testing"
"time" "time"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent" "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/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store" "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 // R-1154 (v0.159.0) — a reinstall while the customer's OLD host record still exists. The reuse path NEVER
// the off-site login when the old box is gone (silent past the stale threshold on BOTH report streams, or switched // re-issues the claim code or the off-site login (operator session-only powers — `05` §17.5; the passphrase must
// off on purpose), and NEVER while the box is live. reenroll.go carries the rule; `05` §8 the design. // 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" const r1154Host = "c1-abc123"
@@ -25,12 +32,13 @@ type r1154Fixture struct {
st *store.Store st *store.Store
db *sql.DB db *sql.DB
mail *nullMailer mail *nullMailer
offsite int // offsite re-issuer calls offsite int // off-site re-issuer calls
gen0 int // the claim generation before the enroll 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 // 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 { func newR1154(t *testing.T) *r1154Fixture {
t.Helper() t.Helper()
h, st, path := newTestHandler(t) h, st, path := newTestHandler(t)
@@ -54,6 +62,13 @@ func newR1154(t *testing.T) *r1154Fixture {
return nil return nil
}) })
h.SetStaleThreshold(45 * time.Minute) 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 { if err := st.UpsertHost(&store.Host{HostID: r1154Host, CustomerID: "c1", APIKey: "old-host-key-0123456789"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -66,7 +81,6 @@ func newR1154(t *testing.T) *r1154Fixture {
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
f.db = db f.db = db
f.exec(t, `UPDATE hosts SET created_at = datetime('now', '-30 days') WHERE host_id = ?`, r1154Host)
f.mail.sends = 0 f.mail.sends = 0
f.gen0 = f.generation(t) f.gen0 = f.generation(t)
return f return f
@@ -100,8 +114,8 @@ func (f *r1154Fixture) generation(t *testing.T) int {
return cs.Generation return cs.Generation
} }
// enroll runs the installer's call and checks the reuse contract that must hold on EVERY branch: 200, the same // enroll runs the installer's call and checks the reuse contract that holds on EVERY branch: 200, the same host id
// host id and key. // 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) { func (f *r1154Fixture) enroll(t *testing.T) {
t.Helper() t.Helper()
rr := doEnroll(f.h, "c1", "pass-phrase") 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 { if n := countHostsForCustomer(t, f.st, "c1"); n != 1 {
t.Fatalf("host rows = %d, want 1 (the record is reused, never duplicated)", n) t.Fatalf("host rows = %d, want 1 (the record is reused, never duplicated)", n)
} }
} 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",
// reissued reports what the enroll did: claim generation moved, mails sent, off-site calls. f.gen0, gen, f.mail.sends, f.offsite)
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)
} }
} }
@@ -144,37 +147,75 @@ func (f *r1154Fixture) eventAt(t *testing.T, et string) time.Time {
return at 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 // 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. // Restored.
func TestR1154_StaleOldHostIsReissued(t *testing.T) { func TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing(t *testing.T) {
f := newR1154(t) f := newR1154(t)
f.hostReportedAgo(t, 2*time.Hour) f.hostReportedAgo(t, 2*time.Hour)
f.controllerReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 2*time.Hour)
f.enroll(t) f.enroll(t)
f.assertReissued(t, true) if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
if f.eventAt(t, eventHostReenrollReissued).IsZero() { t.Fatal("no host_reinstall_over_old_host event — the reinstall is silent again")
t.Error("host_reenroll_reissued not recorded")
} }
if !f.eventAt(t, "claim_reissued_reenroll").After(time.Time{}) { got := f.eventMessage(t, eventHostReinstallOverOldHost)
t.Error("claim_reissued_reenroll (the claim leg's own event) not recorded") 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 // COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed this fails with
// 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. // Restored.
func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) { func TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing(t *testing.T) {
f := newR1154(t) f := newR1154(t)
f.hostReportedAgo(t, 3*time.Minute) f.hostReportedAgo(t, 3*time.Minute)
f.controllerReportedAgo(t, 3*time.Minute) f.controllerReportedAgo(t, 3*time.Minute)
@@ -183,20 +224,24 @@ func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
f.enroll(t) 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 // A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) is recorded as info,
// credential: nothing re-issued, and the skip is recorded so a too-early reinstall is visible. Three ways to be // NOT mailed, and nothing is re-issued. Three ways to be live, each alone, plus connected-while-off.
// live, each alone: the host report, the controller report (the codes live in the controller), the wait connection.
// //
// COMPANION RED-PROOF (observed 2026-10-19): with decideReenroll replaced by an unconditional re-issue on the reuse // COMPANION RED-PROOF (observed 2026-10-19): with classifyReenroll replaced by an unconditional "gone" this fails
// path this fails with // 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. // (and the same for the other three cases). Restored.
func TestR1154_LiveHostIsNotReissued(t *testing.T) { func TestR1154_LiveHostIsRecordedNotWarned(t *testing.T) {
cases := []struct { cases := []struct {
name string name string
setup func(t *testing.T, f *r1154Fixture) setup func(t *testing.T, f *r1154Fixture)
@@ -231,80 +276,33 @@ func TestR1154_LiveHostIsNotReissued(t *testing.T) {
f := newR1154(t) f := newR1154(t)
c.setup(t, f) c.setup(t, f)
f.enroll(t) f.enroll(t)
if gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 { if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
t.Fatalf("%s: want NOT re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d", t.Fatalf("%s: a live box raised the reinstall warning", c.name)
c.name, f.gen0, gen, f.mail.sends, f.offsite)
} }
if f.eventAt(t, eventHostReenrollNotReissued).IsZero() { if f.eventAt(t, eventHostReenrollWhileLive).IsZero() {
t.Errorf("%s: the live skip was not recorded (host_reenroll_not_reissued)", c.name) t.Errorf("%s: the live re-enroll was not recorded (host_reenroll_while_live)", c.name)
} }
if !f.eventAt(t, eventHostReenrollReissued).IsZero() { if !strings.HasPrefix(f.eventMessage(t, eventHostReenrollWhileLive), "info|") {
t.Errorf("%s: a re-issue record was written for a live box", c.name) 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 // Edges the code shows. A host record that never reported (an operator /admin/hosts mint) with no controller
// — the old box is still "silent", and the re-issues must not run twice (the claim engine's single-shot rule). // report → the warning; not wired (no threshold) → no event at all, nothing re-issued; and the MINT path (the old
// Then the new agent reports, goes silent again past the threshold (the next reinstall): re-issued again. // 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 // COMPANION RED-PROOF (observed 2026-10-19): with the call removed from the reuse path „never reported" fails with
// 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.
// r1154_reenroll_test.go: the --resume re-issued again: claim generation 1→3, off-site re-issues 2 func TestR1154_NeverReportedUnwiredAndMintEdges(t *testing.T) {
// t.Run("never reported", func(t *testing.T) {
// 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) {
f := newR1154(t) f := newR1154(t)
f.exec(t, `UPDATE hosts SET last_report_at = NULL WHERE host_id = ?`, r1154Host) f.exec(t, `UPDATE hosts SET last_report_at = NULL WHERE host_id = ?`, r1154Host)
f.enroll(t) f.enroll(t)
f.assertReissued(t, true) if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
}) t.Fatal("never reported: no host_reinstall_over_old_host event")
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)
} }
}) })
t.Run("threshold not wired", func(t *testing.T) { 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.hostReportedAgo(t, 2*time.Hour)
f.controllerReportedAgo(t, 2*time.Hour) f.controllerReportedAgo(t, 2*time.Hour)
f.enroll(t) 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 // Both R-1154 event types: operator-only, and hub-origin only (no box can POST them).
// plant the once-per-silence record and block the re-issue of its own reinstall.
func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) { func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) {
for _, et := range []string{eventHostReenrollReissued, eventHostReenrollNotReissued} { for _, et := range []string{eventHostReinstallOverOldHost, eventHostReenrollWhileLive} {
if !notify.IsOperatorOnly(et) { if !notify.IsOperatorOnly(et) {
t.Errorf("%s is not operator-only", et) t.Errorf("%s is not operator-only", et)
} }
+76 -95
View File
@@ -13,130 +13,111 @@ import (
// //
// POST /host-enroll is called by the installer (felhom-host-install.sh step 4) on EVERY pass, a --resume // 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 // 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 // key — a reinstalled box takes over the record). The clean-slate re-issues (reissueOnReenroll: a new claim
// clean-slate re-issues (reissueOnReenroll: the claim code, the off-site login) ran ONLY when the old host // code mailed to the household, the off-site provider password reset) run ONLY on the mint path, i.e. after
// had been deleted first, and a plain reinstall skipped both in silence: the household stuck at the claim // the OPERATOR deleted the old host. Until v0.159.0 a reinstall that kept the old record skipped them IN
// page, the off-site tier stuck (proven on demo-hp 2026-08-21). // 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"). // - The old box looks gone (no controller wait connection open now, AND either the household switched it
// 2. The household switched the box off on purpose (deliberate_off_at inside its hold) — the report clocks // off on purpose — deliberate_off_at inside its hold — or BOTH the agent's host report and the
// are not consulted: the box said it was going down. // controller's report are older than staleThreshold or absent; the hostStatus threshold and comparison):
// 3. Otherwise BOTH the agent's host report AND the controller's report are older than staleThreshold (or // the operator-only WARNING host_reinstall_over_old_host, mailed to the operator, saying what to do.
// absent). The claim code and the off-site login live in the CONTROLLER, so a box whose agent is // - The box is live (a --resume after its agent started, a repair re-run, or a reinstall done within the
// silent but whose controller still reports is live for this purpose (an installer re-run to repair a // threshold of the old box's last report): the operator-only INFO host_reenroll_while_live, recorded on
// broken agent must not cost the household its codes). Same threshold, same comparison as hostStatus: // the event list, never mailed.
// 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.
// //
// A live box that enrolls again (a --resume after its agent started, a repair re-run) gets its credentials // WHY NOT RE-ISSUE AUTOMATICALLY (security review of the first draft, 2026-10-19): the passphrase is a
// and NOTHING re-issued; that skip is recorded as the info event host_reenroll_not_reissued, so a reinstall // household-held secret typed into the installer. Re-issuing a household's off-site credential and
// done within the threshold of the old box's last report is visible on the event list instead of silent. // (re)sending its claim code are operator SESSION-ONLY actions — not even an operator token may do them
// Not wired (staleThreshold 0) or a failed lookup: nothing is re-issued — the pre-v0.159.0 behaviour. // (`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 // Not wired (staleThreshold 0) or a failed lookup: no verdict, a WARN log line only. Both event types are
// once-per-silence record) and operator-only. Pinned by r1154_reenroll_test.go. // hub-origin only (not in allowedEventTypes) and operator-only. Pinned by r1154_reenroll_test.go.
const ( const (
eventHostReenrollReissued = "host_reenroll_reissued" eventHostReinstallOverOldHost = "host_reinstall_over_old_host"
eventHostReenrollNotReissued = "host_reenroll_not_reissued" eventHostReenrollWhileLive = "host_reenroll_while_live"
) )
// reenrollDecision is the verdict on a reuse-path enroll. // reenrollVerdict classifies a reuse-path enroll.
type reenrollDecision struct { type reenrollVerdict int
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
}
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 { if h.staleThreshold <= 0 {
return reenrollDecision{why: "no stale threshold wired"} return reenrollUnknown, "no stale threshold wired"
} }
if h.intentHub != nil { if h.intentHub != nil {
if p := h.intentHub.Presence(host.CustomerID, now); p.State == intent.PresenceConnected { 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) { if store.DeliberateOffHeld(host.DeliberateOffAt, now) {
why = "the household switched the box off at " + host.DeliberateOffAt.UTC().Format("2006-01-02 15:04 UTC") return reenrollGone, "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() + ")"
} }
// Once per silence. hostAge := "never reported"
since := host.CreatedAt if host.LastReportAt != nil {
if host.LastReportAt != nil && host.LastReportAt.After(since) { age := now.Sub(*host.LastReportAt)
since = *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 { if err != nil {
h.logger.Printf("[WARN] host-enroll: re-issue record lookup failed for %s: %v — nothing re-issued", host.CustomerID, err) return reenrollUnknown, "the controller report lookup failed: " + err.Error()
return reenrollDecision{why: "the re-issue record lookup failed"}
} }
if !last.IsZero() && !last.Before(since) { ctrlAge := "the controller never reported"
return reenrollDecision{why: "already re-issued at " + last.UTC().Format("2006-01-02 15:04:05 UTC") + " for this install"} 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 // noteReenrollOverExistingHost records (and for a reinstall, mails the operator about) an enroll that reused
// old box is gone, and records the outcome. Best-effort, like reissueOnReenroll: it never fails the enroll. // the old host record. It re-issues NOTHING and never fails the enroll.
func (h *Handler) reissueOnReuseIfReinstall(cc *store.CustomerConfig, host *store.Host, now time.Time) { func (h *Handler) noteReenrollOverExistingHost(cc *store.CustomerConfig, host *store.Host, now time.Time) {
d := h.decideReenroll(host, now) v, why := h.classifyReenroll(host, now)
switch { switch v {
case d.reissue: case reenrollGone:
h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — re-issuing the claim code and the off-site login: %s", 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, d.why) host.HostID, cc.CustomerID, why)
h.reissueOnReenroll(cc) msg := "A reinstall of " + cc.CustomerID + " enrolled while the old host record " + host.HostID + " still exists (" +
h.recordReenrollReissued(cc.CustomerID, host.HostID, d.why) why + "); nothing was re-issued — delete the old host (Hosts page) and run the installer again, or " +
case d.live: "re-issue the claim code and the off-site login from the customer page."
h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — nothing re-issued, the box is live: %s", h.saveReenrollEvent(cc.CustomerID, eventHostReinstallOverOldHost, "warning", msg, host.HostID, why)
host.HostID, cc.CustomerID, d.why) case reenrollLive:
msg := "Host " + host.HostID + " enrolled again while the box is live (" + d.why + ") — the claim code and " + h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — the box is live: %s", host.HostID, cc.CustomerID, why)
"the off-site login were NOT re-issued. If this box was reinstalled, send a new claim code and re-issue " + msg := "Host " + host.HostID + " enrolled again while the box is live (" + why + "); nothing was re-issued. " +
"the off-site login from the customer page." "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, eventHostReenrollNotReissued, msg, host.HostID, d.why) h.saveReenrollEvent(cc.CustomerID, eventHostReenrollWhileLive, "info", msg, host.HostID, why)
default: 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) saveReenrollEvent(customerID, eventType, severity, msg, hostID, why string) {
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) {
details, _ := json.Marshal(map[string]string{"host_id": hostID, "reason": why}) 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) 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")
} }
} }
+2 -3
View File
@@ -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 // 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 // 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 // single-shot by invoking this only on a genuinely fresh host record (the host-enroll mint path,
// reuse path once per silence of the old box (api/reenroll.go). Pinned by api/r1154_reenroll_test.go // which fires exactly once per reinstall). Returns (generation, reissued, error).
// TestR1154_ResumeDoesNotReissueTwice. Returns (generation, reissued, error).
func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) { func (e *Engine) ReissueForReenroll(cc *store.CustomerConfig) (gen int, reissued bool, err error) {
cs, err := e.Store.GetClaim(cc.CustomerID) cs, err := e.Store.GetClaim(cc.CustomerID)
if err != nil { if err != nil {
+6 -6
View File
@@ -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. // since v0.44.0, and only configuration (enabled_events) made that true — the R-97c shape. Now structural.
"agent_capability_degraded": true, "agent_capability_degraded": true,
"agent_capability_recovered": 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 // R-1154 (v0.159.0): a host-enroll that reused the old host record — a reinstall over it (warning: the operator
// did NOT because the box is live. Info (recorded, never mailed), host ids and report ages: the operator's // deletes the old host or re-issues by hand) or a live box enrolling again (info). Host ids, report ages, and an
// record. Hub-origin only (api/reenroll.go; not in api.allowedEventTypes — a box must not plant the // instruction only the operator can carry out. Hub-origin only (api/reenroll.go; not in api.allowedEventTypes).
// once-per-silence record). Listed in the SAME commit that mints them. // Listed in the SAME commit that mints them.
"host_reenroll_reissued": true, "host_reinstall_over_old_host": true,
"host_reenroll_not_reissued": true, "host_reenroll_while_live": true,
} }
// IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the // IsOperatorOnly reports whether an event type is barred from customer dispatch. Exported so the
@@ -15,7 +15,8 @@ import "testing"
// //
// Restored. // Restored.
func TestR1150_RefusedBackupAndCapabilityEventsNeverReachTheCustomer(t *testing.T) { 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) st := opOnlyStore(t)
if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{et}, 6); err != nil { if err := st.SaveNotificationPrefs("c1", "customer@example.com", []string{et}, 6); err != nil {
t.Fatalf("SaveNotificationPrefs: %v", err) 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) 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] { if !operatorOnlyEvents[et] {
t.Errorf("%s must be operator-only (the pair's other edge)", et) t.Errorf("%s must be operator-only (the pair's other edge)", et)
} }