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:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
|
||||
)
|
||||
|
||||
// R-1150 (v0.159.0): the refused whole-guest backup pair is operator-only AND hub-origin only — not in
|
||||
// allowedEventTypes, so no box can POST one to /api/v1/event. The alarm says the guest's controller may be
|
||||
// compromised; that controller holds the /event credential, and a type it could post it could fake, or post
|
||||
// first to spend the operator cooldown and drown the real one (the R-895 reasoning). Both registers in one test.
|
||||
func TestR1150_RefusedBackupEventsAreOperatorOnlyAndHubOrigin(t *testing.T) {
|
||||
for _, et := range []string{monitor.EventWholeGuestBackupRefused, monitor.EventWholeGuestBackupRefusalCleared} {
|
||||
if !notify.IsOperatorOnly(et) {
|
||||
t.Errorf("%s is not operator-only", et)
|
||||
}
|
||||
if allowedEventTypes[et] {
|
||||
t.Errorf("%s is in allowedEventTypes — a box could POST it", et)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"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.
|
||||
|
||||
const r1154Host = "c1-abc123"
|
||||
|
||||
type r1154Fixture struct {
|
||||
h *Handler
|
||||
st *store.Store
|
||||
db *sql.DB
|
||||
mail *nullMailer
|
||||
offsite int // offsite 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.
|
||||
func newR1154(t *testing.T) *r1154Fixture {
|
||||
t.Helper()
|
||||
h, st, path := newTestHandler(t)
|
||||
f := &r1154Fixture{h: h, st: st}
|
||||
cc := &store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase",
|
||||
Email: "owner@example.hu", Domain: "example.hu", ConfigJSON: "{}"}
|
||||
if err := st.SaveCustomerConfig(cc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.mail = withClaimEngine(t, h, st)
|
||||
if _, err := h.claimEngine.EnsureIssued(cc); err != nil {
|
||||
t.Fatalf("EnsureIssued: %v", err)
|
||||
}
|
||||
if _, err := st.MarkClaimed("c1"); err != nil {
|
||||
t.Fatalf("MarkClaimed: %v", err)
|
||||
}
|
||||
h.SetOffsiteReissuer(func(_ context.Context, id string) error {
|
||||
if id == "c1" {
|
||||
f.offsite++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
h.SetStaleThreshold(45 * time.Minute)
|
||||
if err := st.UpsertHost(&store.Host{HostID: r1154Host, CustomerID: "c1", APIKey: "old-host-key-0123456789"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveHostReport(r1154Host, "c1", []byte(`{"host_id":"`+r1154Host+`"}`), store.HostReportDenorm{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (f *r1154Fixture) exec(t *testing.T, q string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := f.db.Exec(q, args...); err != nil {
|
||||
t.Fatalf("%s: %v", q, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *r1154Fixture) hostReportedAgo(t *testing.T, d time.Duration) {
|
||||
f.exec(t, `UPDATE hosts SET last_report_at = datetime('now', ?) WHERE host_id = ?`, fmt.Sprintf("-%d seconds", int(d.Seconds())), r1154Host)
|
||||
}
|
||||
|
||||
func (f *r1154Fixture) controllerReportedAgo(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
if err := f.st.SaveReport("c1", []byte(`{"customer_id":"c1"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.exec(t, `UPDATE reports SET received_at = datetime('now', ?) WHERE customer_id = 'c1'`, fmt.Sprintf("-%d seconds", int(d.Seconds())))
|
||||
}
|
||||
|
||||
func (f *r1154Fixture) generation(t *testing.T) int {
|
||||
t.Helper()
|
||||
cs, err := f.st.GetClaim("c1")
|
||||
if err != nil || cs == nil {
|
||||
t.Fatalf("GetClaim: %+v %v", cs, err)
|
||||
}
|
||||
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.
|
||||
func (f *r1154Fixture) enroll(t *testing.T) {
|
||||
t.Helper()
|
||||
rr := doEnroll(f.h, "c1", "pass-phrase")
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enroll = %d, want 200 (reuse), body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var got enrollResp
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.HostID != r1154Host || got.APIKey != "old-host-key-0123456789" {
|
||||
t.Fatalf("reuse returned another credential: %s", got.HostID)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *r1154Fixture) eventAt(t *testing.T, et string) time.Time {
|
||||
t.Helper()
|
||||
at, err := f.st.LastEventAt("c1", et)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// The old box silent on both streams past the threshold → the enroll re-issues both, keeps the record, records it.
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0
|
||||
//
|
||||
// Restored.
|
||||
func TestR1154_StaleOldHostIsReissued(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, "claim_reissued_reenroll").After(time.Time{}) {
|
||||
t.Error("claim_reissued_reenroll (the claim leg's own event) not recorded")
|
||||
}
|
||||
}
|
||||
|
||||
// Switched off on purpose minutes ago (both report clocks still fresh) → re-issued: the box said it was going down.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed from decideReenroll this fails
|
||||
// with
|
||||
//
|
||||
// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0
|
||||
//
|
||||
// Restored.
|
||||
func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) {
|
||||
f := newR1154(t)
|
||||
f.hostReportedAgo(t, 3*time.Minute)
|
||||
f.controllerReportedAgo(t, 3*time.Minute)
|
||||
off := time.Now().UTC().Add(-5 * time.Minute).Truncate(time.Second)
|
||||
if _, err := f.st.SetHostDeliberateOff(r1154Host, &off); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.enroll(t)
|
||||
f.assertReissued(t, true)
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): with decideReenroll replaced by an unconditional re-issue on the reuse
|
||||
// path 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
|
||||
//
|
||||
// (and the same for the other two cases). Restored.
|
||||
func TestR1154_LiveHostIsNotReissued(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, f *r1154Fixture)
|
||||
}{
|
||||
{"host report fresh", func(t *testing.T, f *r1154Fixture) {
|
||||
f.hostReportedAgo(t, 2*time.Minute)
|
||||
f.controllerReportedAgo(t, 2*time.Hour)
|
||||
}},
|
||||
{"controller report fresh, agent silent", func(t *testing.T, f *r1154Fixture) {
|
||||
f.hostReportedAgo(t, 2*time.Hour)
|
||||
f.controllerReportedAgo(t, 5*time.Minute)
|
||||
}},
|
||||
{"controller connected, both reports old", func(t *testing.T, f *r1154Fixture) {
|
||||
f.hostReportedAgo(t, 2*time.Hour)
|
||||
f.controllerReportedAgo(t, 2*time.Hour)
|
||||
hub := intent.New()
|
||||
f.h.SetIntentHub(hub)
|
||||
hub.MarkWaitStart("c1")
|
||||
}},
|
||||
{"controller connected, deliberately off", func(t *testing.T, f *r1154Fixture) {
|
||||
f.hostReportedAgo(t, 2*time.Minute)
|
||||
f.controllerReportedAgo(t, 2*time.Minute)
|
||||
off := time.Now().UTC().Add(-time.Minute).Truncate(time.Second)
|
||||
f.st.SetHostDeliberateOff(r1154Host, &off)
|
||||
hub := intent.New()
|
||||
f.h.SetIntentHub(hub)
|
||||
hub.MarkWaitStart("c1")
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(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, eventHostReenrollNotReissued).IsZero() {
|
||||
t.Errorf("%s: the live skip was not recorded (host_reenroll_not_reissued)", c.name)
|
||||
}
|
||||
if !f.eventAt(t, eventHostReenrollReissued).IsZero() {
|
||||
t.Errorf("%s: a re-issue record was written for a live box", c.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
t.Run("threshold not wired", func(t *testing.T) {
|
||||
f := newR1154(t)
|
||||
f.h.SetStaleThreshold(0)
|
||||
f.hostReportedAgo(t, 2*time.Hour)
|
||||
f.controllerReportedAgo(t, 2*time.Hour)
|
||||
f.enroll(t)
|
||||
f.assertReissued(t, false)
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) {
|
||||
for _, et := range []string{eventHostReenrollReissued, eventHostReenrollNotReissued} {
|
||||
if !notify.IsOperatorOnly(et) {
|
||||
t.Errorf("%s is not operator-only", et)
|
||||
}
|
||||
if allowedEventTypes[et] {
|
||||
t.Errorf("%s is in allowedEventTypes — a box could POST it", et)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user