Files
felhom.eu/hub/internal/api/reenroll.go
T
admin 5121536f10 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.
2026-10-19 03:18:34 +02:00

149 lines
7.4 KiB
Go

package api
import (
"encoding/json"
"fmt"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-1154 (hub v0.159.0, brief 2026-10-19) — a reinstall while the customer's OLD host record still exists.
//
// 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).
//
// THE RULE (`05` §8). The reuse path re-issues when the OLD BOX IS GONE, judged by the hub's own clocks:
//
// 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.
//
// 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.
//
// 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.
const (
eventHostReenrollReissued = "host_reenroll_reissued"
eventHostReenrollNotReissued = "host_reenroll_not_reissued"
)
// 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
}
func (h *Handler) decideReenroll(host *store.Host, now time.Time) reenrollDecision {
if h.staleThreshold <= 0 {
return reenrollDecision{why: "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"}
}
}
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() + ")"
}
// Once per silence.
since := host.CreatedAt
if host.LastReportAt != nil && host.LastReportAt.After(since) {
since = *host.LastReportAt
}
last, err := h.store.LastEventAt(host.CustomerID, eventHostReenrollReissued)
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"}
}
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"}
}
return reenrollDecision{reissue: true, why: why}
}
// 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)
default:
h.logger.Printf("[INFO] host re-enrolled: %s (customer %s) — nothing re-issued: %s", host.HostID, cc.CustomerID, d.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) {
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 {
h.logger.Printf("[WARN] save %s for %s: %v", eventType, customerID, err)
}
}
func roundAge(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
return d.Round(time.Minute).String()
}