Files
felhom.eu/hub/internal/api/reenroll.go
T

135 lines
6.9 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). 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: the reuse path NEVER re-issues anything; it makes the reinstall LOUD instead.
//
// - 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 WARNING host_reenroll_while_live, mailed to the
// operator with the same instruction (R-1156, hub v0.160.0: a reinstall done within 45 minutes of the old
// box's last report reads as live, and was recorded without a mail until then). The cost: a --resume or a
// repair re-run mails too — the text says that case needs nothing.
//
// 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.
//
// 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 (
eventHostReinstallOverOldHost = "host_reinstall_over_old_host"
eventHostReenrollWhileLive = "host_reenroll_while_live"
)
// reenrollVerdict classifies a reuse-path enroll.
type reenrollVerdict int
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 reenrollUnknown, "no stale threshold wired"
}
if h.intentHub != nil {
if p := h.intentHub.Presence(host.CustomerID, now); p.State == intent.PresenceConnected {
return reenrollLive, "the box's controller is connected to the hub right now"
}
}
if store.DeliberateOffHeld(host.DeliberateOffAt, now) {
return reenrollGone, "the household switched the box off at " + host.DeliberateOffAt.UTC().Format("2006-01-02 15:04 UTC")
}
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"
}
found, ctrlAt, _, err := h.store.LatestReportOffsitePresence(host.CustomerID)
if err != nil {
return reenrollUnknown, "the controller report lookup failed: " + err.Error()
}
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 reenrollGone, "the host " + hostAge + ", " + ctrlAge + " (stale threshold " + h.staleThreshold.String() + ")"
}
// 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("[WARN] host re-enrolled: %s (customer %s) while the box looks live: %s; nothing re-issued, the operator is told",
host.HostID, cc.CustomerID, why)
msg := "Host " + host.HostID + " of " + cc.CustomerID + " enrolled again while the box looks live (" + why +
"); nothing was re-issued. If this box was reinstalled: 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. If it was an installer " +
"--resume or a repair re-run, nothing needs doing."
h.saveReenrollEvent(cc.CustomerID, eventHostReenrollWhileLive, "warning", msg, host.HostID, why)
default:
h.logger.Printf("[WARN] host re-enrolled: %s (customer %s) — not classified: %s", host.HostID, cc.CustomerID, why)
}
}
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, 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")
}
}
func roundAge(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
return d.Round(time.Minute).String()
}