package monitor import ( "encoding/json" "log" "sync" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // HostLeafChecker raises an operator alert when a host's agent local-API LEAF FINGERPRINT changes — a // proactive, fleet-wide agent-re-key detector (the last self-health leg). It is a sibling of // HostCapabilityChecker and independent of any controller's channel-health check: it catches a re-key // even if a controller is down / hasn't probed, and gives the operator one fleet view. // // Design (Option 1 — trust-on-first-report): the first leaf fp seen per host is the baseline; any later // change alerts and advances the baseline. KNOWN LIMITATION (v1, acceptable): if the agent re-keyed // BEFORE the hub's first report, the hub trusts the wrong baseline — but the controller channel-check // still catches the downstream pin mismatch, so this is defense-in-depth, not the sole guard. (The // authoritative served-fp-vs-pinned-fp cross-check is a deliberate future enhancement.) // // An empty reported fp (pre-v0.48.0 agent, or the local API disabled) is "unknown" — never an alert and // never overwrites a baseline. type HostLeafChecker struct { store *store.Store logger *log.Logger onEvent EventNotifyFunc mu sync.Mutex states map[string]string // hostID → last-seen leaf fp (the baseline) customerOf map[string]string // hostID → customerID (event attribution) } // NewHostLeafChecker seeds the per-host baseline from the latest reported fps. No events on init. func NewHostLeafChecker(s *store.Store, onEvent EventNotifyFunc, logger *log.Logger) *HostLeafChecker { lc := &HostLeafChecker{ store: s, logger: logger, onEvent: onEvent, states: make(map[string]string), customerOf: make(map[string]string), } rows, err := s.GetHostLeafFingerprints() if err != nil { logger.Printf("[WARN] Host leaf checker: failed to seed: %v", err) return lc } seeded := 0 for _, row := range rows { if s.IsCustomerBlocked(row.CustomerID) || row.LeafFP == "" { continue } lc.customerOf[row.HostID] = row.CustomerID lc.states[row.HostID] = row.LeafFP seeded++ } logger.Printf("[INFO] Host leaf checker initialized: %d host fingerprint(s) seeded", seeded) return lc } // Check evaluates all hosts and emits host_leaf_changed on a fingerprint change. Call on the same 60s // sweep as the staleness/capability checkers. func (lc *HostLeafChecker) Check() { rows, err := lc.store.GetHostLeafFingerprints() if err != nil { lc.logger.Printf("[WARN] Host leaf check failed: %v", err) return } lc.mu.Lock() defer lc.mu.Unlock() seen := make(map[string]bool, len(rows)) for _, row := range rows { if lc.store.IsCustomerBlocked(row.CustomerID) { delete(lc.states, row.HostID) continue } seen[row.HostID] = true // the host exists; keep its baseline even if this report's fp is empty if row.LeafFP == "" { continue // unknown fp → no alert, no baseline change } lc.customerOf[row.HostID] = row.CustomerID old := lc.states[row.HostID] if old == "" { lc.states[row.HostID] = row.LeafFP // first observation → baseline, no event continue } if old == row.LeafFP { continue } lc.states[row.HostID] = row.LeafFP // advance the baseline (a change-back later re-alerts) lc.emit(row.HostID, row.CustomerID, old, row.LeafFP) } for id := range lc.states { if !seen[id] { delete(lc.states, id) } } } // GetState returns the current baseline fp for a host ("" if unseen). func (lc *HostLeafChecker) GetState(hostID string) string { lc.mu.Lock() defer lc.mu.Unlock() return lc.states[hostID] } func (lc *HostLeafChecker) emit(hostID, customerID, oldFP, newFP string) { msg := "Host " + hostID + ": agent local-API leaf fingerprint CHANGED (agent re-keyed) — controllers will fail the pin check until re-bootstrapped / re-pinned" details, _ := json.Marshal(map[string]string{ "host_id": hostID, "old_fingerprint": oldFP, "new_fingerprint": newFP, }) lc.logger.Printf("[WARN] Host leaf: %s fp %s… → %s… (host_leaf_changed)", hostID, shortFP(oldFP), shortFP(newFP)) if _, err := lc.store.SaveEvent(customerID, "host_leaf_changed", "warning", msg, string(details), "hub"); err != nil { lc.logger.Printf("[WARN] save host_leaf_changed for %s: %v", hostID, err) return } if lc.onEvent != nil { lc.onEvent(customerID, "host_leaf_changed", "warning", msg, string(details), "hub") } } func shortFP(fp string) string { if len(fp) > 12 { return fp[:12] } return fp }