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:
2026-10-19 03:29:44 +02:00
parent 5121536f10
commit b505ce4ba0
9 changed files with 265 additions and 268 deletions
+10 -12
View File
@@ -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)
+124 -112
View File
@@ -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)
}
+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
// 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")
}
}