hub v0.159.0: a refused whole-guest backup has its own alarm (R-1150); a reinstall re-issues the claim code and the off-site login when the old host is stale or off (R-1154)

R-1150: the agent's localapi:backup-cadence-cap is a separate per-host state in
HostCapabilityChecker: alone it raises whole_guest_backup_refused (warning, plain text,
agent reason in details) and whole_guest_backup_refusal_cleared (info); other critical
caps keep agent_capability_degraded/_recovered unchanged. New types operator-only and
hub-origin only (not in allowedEventTypes: the suspected controller must not post them).
agent_capability_degraded/_recovered join operatorOnlyEvents (the checker's "never the
customer" claim was configuration only).

R-1154: handleHostEnroll's reuse path re-issues (claim code + off-site login) when no
controller wait is open and either deliberate_off_at is held or both the host report and
the controller report are older than alerting.stale_threshold (wired from main); once per
silence (host_reenroll_reissued record, also written at mint); a live box gets nothing
re-issued and the skip is recorded (host_reenroll_not_reissued). 05 section 8 [DESIGN].

Tests red-proved; go build/vet/test green.
This commit is contained in:
2026-10-19 03:18:34 +02:00
parent e4f022a0d9
commit 5121536f10
14 changed files with 995 additions and 33 deletions
+22 -3
View File
@@ -111,6 +111,17 @@ type Handler struct {
// box ticks in seconds instead of ≤15 min. Shared with the web server (same *poke.Notifier). nil
// = poke disabled (a no-op; the report cycle still reconciles).
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.
staleThreshold time.Duration
}
// SetStaleThreshold wires the report-liveness threshold the host-enroll reuse path judges by (R-1154).
func (h *Handler) SetStaleThreshold(d time.Duration) {
h.staleThreshold = d
}
// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
@@ -1205,7 +1216,10 @@ func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
return
}
// Mint-once-reuse: an existing host for this customer is returned as-is (idempotent).
// 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.
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)
@@ -1219,6 +1233,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())
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})
@@ -1245,13 +1260,17 @@ 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 (the clean-slate flow deletes the stale host, so re-enroll mints),
// so it is the natural single-shot re-enroll hook. For a CLAIMED customer the fresh box has no
// 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
// 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)