hub: HostLeafChecker — proactive agent re-key detection (host_leaf_changed) v0.22.0
Watches each host's reported local-API leaf fp; alerts on change (trust-on-first-report). Sibling of HostCapabilityChecker; store.GetHostLeafFingerprints reads report_json (no migration); hub-generated event (no allowlist change). Change red-proof + first-obs-seed + empty-skip + blocked-drop tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
const (
|
||||
fpA = "60b5974d586f5f3c8ec41eb998d0f07406178219c36bf6d3ff377570279d8245"
|
||||
fpB = "911d703c9cb4cf54d7aba9d9a768e587e9d0757ea638b1db595939623c675738"
|
||||
)
|
||||
|
||||
func reportWithLeaf(fp string) []byte {
|
||||
return []byte(`{"host_id":"h1","leaf_fingerprint":"` + fp + `"}`)
|
||||
}
|
||||
|
||||
func newLeafStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.New(filepath.Join(t.TempDir(), "test.db"), log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ck", RetrievalPassword: "p"})
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "k1"})
|
||||
return st
|
||||
}
|
||||
|
||||
// B.2 change RED-PROOF: seed fp A, then fp B → exactly one host_leaf_changed (A→B), baseline advanced
|
||||
// to B. Companion (TestHostLeafChecker_NoChangeNoEvent): fp stays A → no event — proving the CHANGE,
|
||||
// not the cycle, fires it.
|
||||
func TestHostLeafChecker_ChangeAlertsOnce(t *testing.T) {
|
||||
st := newLeafStore(t)
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
|
||||
var events []string
|
||||
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
||||
if lc.GetState("h1") != fpA {
|
||||
t.Fatalf("seed baseline = %q, want %q", lc.GetState("h1"), fpA)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("construction must not emit, got %v", events)
|
||||
}
|
||||
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpB), store.HostReportDenorm{})
|
||||
lc.Check()
|
||||
if len(events) != 1 || events[0] != "host_leaf_changed" {
|
||||
t.Fatalf("fp change → one host_leaf_changed, got %v", events)
|
||||
}
|
||||
if lc.GetState("h1") != fpB {
|
||||
t.Fatalf("baseline not advanced, got %q want %q", lc.GetState("h1"), fpB)
|
||||
}
|
||||
lc.Check() // steady at B → no duplicate
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("steady fp must not re-emit, got %v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostLeafChecker_NoChangeNoEvent(t *testing.T) {
|
||||
st := newLeafStore(t)
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
|
||||
var events []string
|
||||
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{}) // same fp
|
||||
lc.Check()
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("unchanged fp must not alert, got %v", events)
|
||||
}
|
||||
}
|
||||
|
||||
// First observation seeds the baseline silently.
|
||||
func TestHostLeafChecker_FirstObsSeedsNoEvent(t *testing.T) {
|
||||
st := newLeafStore(t)
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
|
||||
var events []string
|
||||
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
||||
// construction already seeded; a Check with no change stays silent.
|
||||
lc.Check()
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("first-obs/seed must be silent, got %v", events)
|
||||
}
|
||||
if lc.GetState("h1") != fpA {
|
||||
t.Fatalf("baseline = %q, want %q", lc.GetState("h1"), fpA)
|
||||
}
|
||||
}
|
||||
|
||||
// A change-BACK (B→A) is also a change → another event (informative).
|
||||
func TestHostLeafChecker_ChangeBackReAlerts(t *testing.T) {
|
||||
st := newLeafStore(t)
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
|
||||
var events []string
|
||||
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpB), store.HostReportDenorm{})
|
||||
lc.Check() // A→B
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
|
||||
lc.Check() // B→A
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("change-back should re-alert (want 2), got %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
// An empty reported fp (pre-v0.48.0 / local-API disabled) is unknown — never seeded, never an alert,
|
||||
// and never overwrites an existing baseline.
|
||||
func TestHostLeafChecker_EmptyFpSkipped(t *testing.T) {
|
||||
st := newLeafStore(t)
|
||||
st.SaveHostReport("h1", "c1", []byte(`{"host_id":"h1"}`), store.HostReportDenorm{}) // no leaf_fingerprint
|
||||
var events []string
|
||||
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
||||
if lc.GetState("h1") != "" {
|
||||
t.Fatalf("empty fp must not seed a baseline, got %q", lc.GetState("h1"))
|
||||
}
|
||||
lc.Check()
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("empty fp must not alert, got %v", events)
|
||||
}
|
||||
// A real fp later → seeds (no alert, it's the first real baseline).
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
|
||||
lc.Check()
|
||||
if len(events) != 0 || lc.GetState("h1") != fpA {
|
||||
t.Fatalf("first real fp should seed silently, events=%v state=%q", events, lc.GetState("h1"))
|
||||
}
|
||||
}
|
||||
|
||||
// A blocked customer's host is dropped — no event.
|
||||
func TestHostLeafChecker_CustomerBlockedDropped(t *testing.T) {
|
||||
st := newLeafStore(t)
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpA), store.HostReportDenorm{})
|
||||
var events []string
|
||||
lc := NewHostLeafChecker(st, func(_, et, _, _, _, _ string) { events = append(events, et) }, log.New(io.Discard, "", 0))
|
||||
if err := st.SetCustomerConfigStatus("c1", "blocked"); err != nil {
|
||||
t.Fatalf("block customer: %v", err)
|
||||
}
|
||||
st.SaveHostReport("h1", "c1", reportWithLeaf(fpB), store.HostReportDenorm{}) // would-be change
|
||||
lc.Check()
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("blocked customer must not alert, got %v", events)
|
||||
}
|
||||
if lc.GetState("h1") != "" {
|
||||
t.Fatalf("blocked host should be dropped, got %q", lc.GetState("h1"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user