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
@@ -5,18 +5,25 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
|
||||
"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.
|
||||
// R-1154 (v0.159.0) — a reinstall while the customer's OLD host record still exists. The reuse path NEVER
|
||||
// re-issues the claim code or the off-site login (operator session-only powers — `05` §17.5; the passphrase must
|
||||
// not reach them) and makes the reinstall LOUD: an operator-only warning mailed to the operator. reenroll.go
|
||||
// carries the rule; `05` §8 the design.
|
||||
|
||||
const r1154Host = "c1-abc123"
|
||||
|
||||
@@ -25,12 +32,13 @@ type r1154Fixture struct {
|
||||
st *store.Store
|
||||
db *sql.DB
|
||||
mail *nullMailer
|
||||
offsite int // offsite re-issuer calls
|
||||
offsite int // off-site 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.
|
||||
// (the production value), a claim engine, a counting off-site re-issuer, and a dispatcher behind a MAIL-HOLD
|
||||
// marker (so the operator mail path is observable in notification_log as "held" with no network).
|
||||
func newR1154(t *testing.T) *r1154Fixture {
|
||||
t.Helper()
|
||||
h, st, path := newTestHandler(t)
|
||||
@@ -54,6 +62,13 @@ func newR1154(t *testing.T) *r1154Fixture {
|
||||
return nil
|
||||
})
|
||||
h.SetStaleThreshold(45 * time.Minute)
|
||||
holdDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(holdDir, mailhold.FileName), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := notify.NewDispatcher(st, "test-key-not-used", "from@felhom.eu", "operator@felhom.eu", true, log.New(io.Discard, "", 0))
|
||||
d.SetMailHold(mailhold.New(holdDir, log.New(io.Discard, "", 0)))
|
||||
h.SetDispatcher(d)
|
||||
if err := st.UpsertHost(&store.Host{HostID: r1154Host, CustomerID: "c1", APIKey: "old-host-key-0123456789"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -66,7 +81,6 @@ func newR1154(t *testing.T) *r1154Fixture {
|
||||
}
|
||||
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
|
||||
@@ -100,8 +114,8 @@ func (f *r1154Fixture) generation(t *testing.T) int {
|
||||
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.
|
||||
// enroll runs the installer's call and checks the reuse contract that holds on EVERY branch: 200, the same host id
|
||||
// and key, one record — and NOTHING re-issued (no claim rotation, no claim mail, no off-site re-issue).
|
||||
func (f *r1154Fixture) enroll(t *testing.T) {
|
||||
t.Helper()
|
||||
rr := doEnroll(f.h, "c1", "pass-phrase")
|
||||
@@ -118,20 +132,9 @@ func (f *r1154Fixture) enroll(t *testing.T) {
|
||||
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)
|
||||
if gen := f.generation(t); gen != f.gen0 || f.mail.sends != 0 || f.offsite != 0 {
|
||||
t.Fatalf("the reuse path re-issued: claim generation %d→%d, claim mails %d, off-site re-issues %d — a passphrase must not reach these",
|
||||
f.gen0, gen, f.mail.sends, f.offsite)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,37 +147,75 @@ func (f *r1154Fixture) eventAt(t *testing.T, et string) time.Time {
|
||||
return at
|
||||
}
|
||||
|
||||
// The old box silent on both streams past the threshold → the enroll re-issues both, keeps the record, records it.
|
||||
// operatorMailed waits (the dispatch is asynchronous) for an operator-channel notification row of eventType.
|
||||
func (f *r1154Fixture) operatorMailed(t *testing.T, eventType string) bool {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
logs, err := f.st.GetRecentNotifications("c1", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, l := range logs {
|
||||
if l.EventType == eventType && l.Channel == "operator" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *r1154Fixture) eventMessage(t *testing.T, et string) string {
|
||||
t.Helper()
|
||||
var msg, sev string
|
||||
if err := f.db.QueryRow(`SELECT message, severity FROM events WHERE customer_id='c1' AND event_type=? ORDER BY id DESC LIMIT 1`, et).Scan(&msg, &sev); err != nil {
|
||||
t.Fatalf("event %s: %v", et, err)
|
||||
}
|
||||
return sev + "|" + msg
|
||||
}
|
||||
|
||||
// The old box silent on both streams past the threshold → nothing re-issued, the operator WARNED by mail with the
|
||||
// instruction.
|
||||
//
|
||||
// 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
|
||||
// once, noteReenrollOverExistingHost 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
|
||||
// r1154_reenroll_test.go: no host_reinstall_over_old_host event — the reinstall is silent again
|
||||
//
|
||||
// and with the first draft's automatic re-issue (reissueOnReenroll called on this branch) it fails with
|
||||
//
|
||||
// r1154_reenroll_test.go: the reuse path re-issued: claim generation 1→2, claim mails 1, off-site re-issues 1 — a passphrase must not reach these
|
||||
//
|
||||
// Restored.
|
||||
func TestR1154_StaleOldHostIsReissued(t *testing.T) {
|
||||
func TestR1154_StaleOldHostWarnsTheOperatorAndReissuesNothing(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, eventHostReinstallOverOldHost).IsZero() {
|
||||
t.Fatal("no host_reinstall_over_old_host event — the reinstall is silent again")
|
||||
}
|
||||
if !f.eventAt(t, "claim_reissued_reenroll").After(time.Time{}) {
|
||||
t.Error("claim_reissued_reenroll (the claim leg's own event) not recorded")
|
||||
got := f.eventMessage(t, eventHostReinstallOverOldHost)
|
||||
for _, want := range []string{"warning|", "A reinstall of c1", r1154Host, "nothing was re-issued", "delete the old host", "re-issue the claim code and the off-site login from the customer page"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("warning text lacks %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
if !f.operatorMailed(t, eventHostReinstallOverOldHost) {
|
||||
t.Error("the operator mail path was not reached (no operator notification row)")
|
||||
}
|
||||
}
|
||||
|
||||
// Switched off on purpose minutes ago (both report clocks still fresh) → re-issued: the box said it was going down.
|
||||
// Switched off on purpose minutes ago (both report clocks still fresh) → the same warning: the box said it was
|
||||
// going down. Deliberate-off only changes which message the operator reads — it unlocks nothing.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed from decideReenroll this fails
|
||||
// with
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): with the deliberate-off branch removed this fails with
|
||||
//
|
||||
// r1154_reenroll_test.go: want re-issued once: claim generation 1→1, claim mails 0, off-site re-issues 0
|
||||
// r1154_reenroll_test.go: deliberately off: no host_reinstall_over_old_host event
|
||||
//
|
||||
// Restored.
|
||||
func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) {
|
||||
func TestR1154_DeliberatelyOffOldHostWarnsAndReissuesNothing(t *testing.T) {
|
||||
f := newR1154(t)
|
||||
f.hostReportedAgo(t, 3*time.Minute)
|
||||
f.controllerReportedAgo(t, 3*time.Minute)
|
||||
@@ -183,20 +224,24 @@ func TestR1154_DeliberatelyOffOldHostIsReissued(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.enroll(t)
|
||||
f.assertReissued(t, true)
|
||||
if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
|
||||
t.Fatal("deliberately off: no host_reinstall_over_old_host event")
|
||||
}
|
||||
if !strings.Contains(f.eventMessage(t, eventHostReinstallOverOldHost), "switched the box off") {
|
||||
t.Errorf("the warning does not say why: %s", f.eventMessage(t, eventHostReinstallOverOldHost))
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// A LIVE box enrolling again (an installer --resume after its agent started, a repair re-run) is recorded as info,
|
||||
// NOT mailed, and nothing is re-issued. Three ways to be live, each alone, plus connected-while-off.
|
||||
//
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): with decideReenroll replaced by an unconditional re-issue on the reuse
|
||||
// path this fails with
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): with classifyReenroll replaced by an unconditional "gone" 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
|
||||
// r1154_reenroll_test.go: host report fresh: a live box raised the reinstall warning
|
||||
//
|
||||
// (and the same for the other two cases). Restored.
|
||||
func TestR1154_LiveHostIsNotReissued(t *testing.T) {
|
||||
// (and the same for the other three cases). Restored.
|
||||
func TestR1154_LiveHostIsRecordedNotWarned(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, f *r1154Fixture)
|
||||
@@ -231,80 +276,33 @@ func TestR1154_LiveHostIsNotReissued(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, eventHostReinstallOverOldHost).IsZero() {
|
||||
t.Fatalf("%s: a live box raised the reinstall warning", c.name)
|
||||
}
|
||||
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, eventHostReenrollWhileLive).IsZero() {
|
||||
t.Errorf("%s: the live re-enroll was not recorded (host_reenroll_while_live)", c.name)
|
||||
}
|
||||
if !f.eventAt(t, eventHostReenrollReissued).IsZero() {
|
||||
t.Errorf("%s: a re-issue record was written for a live box", c.name)
|
||||
if !strings.HasPrefix(f.eventMessage(t, eventHostReenrollWhileLive), "info|") {
|
||||
t.Errorf("%s: the live record must be info (recorded, never mailed): %s", c.name, f.eventMessage(t, eventHostReenrollWhileLive))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Edges the code shows. A host record that never reported (an operator /admin/hosts mint) with no controller
|
||||
// report → the warning; not wired (no threshold) → no event at all, nothing re-issued; and the MINT path (the old
|
||||
// host deleted by the operator) still re-issues exactly as before — the operator's delete is the gate.
|
||||
//
|
||||
// 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) {
|
||||
// COMPANION RED-PROOF (observed 2026-10-19): with the call removed from the reuse path „never reported" fails with
|
||||
// „never reported: no host_reinstall_over_old_host event"; with an unconditional "gone", „threshold not wired"
|
||||
// fails with „not wired: an event was written". Restored.
|
||||
func TestR1154_NeverReportedUnwiredAndMintEdges(t *testing.T) {
|
||||
t.Run("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)
|
||||
if f.eventAt(t, eventHostReinstallOverOldHost).IsZero() {
|
||||
t.Fatal("never reported: no host_reinstall_over_old_host event")
|
||||
}
|
||||
})
|
||||
t.Run("threshold not wired", func(t *testing.T) {
|
||||
@@ -313,14 +311,28 @@ func TestR1154_NeverReportedAndUnwiredEdges(t *testing.T) {
|
||||
f.hostReportedAgo(t, 2*time.Hour)
|
||||
f.controllerReportedAgo(t, 2*time.Hour)
|
||||
f.enroll(t)
|
||||
f.assertReissued(t, false)
|
||||
if !f.eventAt(t, eventHostReinstallOverOldHost).IsZero() || !f.eventAt(t, eventHostReenrollWhileLive).IsZero() {
|
||||
t.Fatal("not wired: an event was written")
|
||||
}
|
||||
})
|
||||
t.Run("old host deleted: the mint path re-issues", func(t *testing.T) {
|
||||
f := newR1154(t)
|
||||
if err := f.st.DeleteHost(r1154Host, false); err != nil {
|
||||
t.Fatalf("DeleteHost: %v", err)
|
||||
}
|
||||
if rr := doEnroll(f.h, "c1", "pass-phrase"); rr.Code != http.StatusCreated {
|
||||
t.Fatalf("enroll after delete = %d, want 201 (mint)", rr.Code)
|
||||
}
|
||||
if gen := f.generation(t); gen != f.gen0+1 || f.mail.sends != 1 || f.offsite != 1 {
|
||||
t.Fatalf("mint after the operator's delete must re-issue: claim generation %d→%d, claim mails %d, off-site re-issues %d",
|
||||
f.gen0, gen, f.mail.sends, f.offsite)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Both R-1154 event types: operator-only, and hub-origin only (no box can POST them).
|
||||
func TestR1154_EventsAreOperatorOnlyAndHubOrigin(t *testing.T) {
|
||||
for _, et := range []string{eventHostReenrollReissued, eventHostReenrollNotReissued} {
|
||||
for _, et := range []string{eventHostReinstallOverOldHost, eventHostReenrollWhileLive} {
|
||||
if !notify.IsOperatorOnly(et) {
|
||||
t.Errorf("%s is not operator-only", et)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user