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:
1 parent
5121536f10
commit
b505ce4ba0
9 files changed
+265
-268
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user