012e5f3ecc
A heal marker is an event, not a baseline: construction seeds pre-existing markers (startup false-alarm guard) but a newly-observed marker now raises the warning, so the first auto-heal surfaces (matches the live drill). Added tests for both halves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
120 lines
4.4 KiB
Go
120 lines
4.4 KiB
Go
package monitor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"sync"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// HostMgmtPlaneChecker raises an operator WARNING when a host's agent-independent break-glass watchdog
|
|
// AUTO-HEALED a missing /run/sshd privsep dir (TASK G1). The heal itself is silent and login-free (the
|
|
// point of the watchdog); this surfaces a RECURRING clobber so the operator can find the cause BEFORE
|
|
// it becomes a full management lockout — complementing HostStalenessChecker (which only catches a box
|
|
// gone silent). Sibling of HostLeafChecker; runs on the same 60s sweep.
|
|
//
|
|
// Design: the state is the host's last-seen privsep_healed_at marker timestamp. The watchdog rewrites
|
|
// the marker on EACH heal, so a new, different timestamp = a new heal event → one warning. UNLIKE
|
|
// HostLeafChecker (where the fp is a persistent STATE), a heal marker is an EVENT, so a newly-observed
|
|
// marker DOES alert — the operator must learn of every auto-heal. To avoid a false alarm at hub
|
|
// startup on a marker that predates it, construction SEEDS the last-seen timestamp from the newest
|
|
// reports WITHOUT alerting; thereafter any change to a NEW non-empty timestamp emits exactly one
|
|
// warning. An empty timestamp (healthy host / old agent) never alerts.
|
|
type HostMgmtPlaneChecker struct {
|
|
store *store.Store
|
|
logger *log.Logger
|
|
onEvent EventNotifyFunc
|
|
|
|
mu sync.Mutex
|
|
states map[string]string // hostID → last-seen privsep_healed_at
|
|
customerOf map[string]string
|
|
}
|
|
|
|
// NewHostMgmtPlaneChecker seeds per-host baselines from the latest reports. No events on init.
|
|
func NewHostMgmtPlaneChecker(s *store.Store, onEvent EventNotifyFunc, logger *log.Logger) *HostMgmtPlaneChecker {
|
|
mc := &HostMgmtPlaneChecker{
|
|
store: s,
|
|
logger: logger,
|
|
onEvent: onEvent,
|
|
states: make(map[string]string),
|
|
customerOf: make(map[string]string),
|
|
}
|
|
rows, err := s.GetHostMgmtPlaneStates()
|
|
if err != nil {
|
|
logger.Printf("[WARN] Host mgmt-plane checker: failed to seed: %v", err)
|
|
return mc
|
|
}
|
|
seeded := 0
|
|
for _, row := range rows {
|
|
if s.IsCustomerBlocked(row.CustomerID) || row.PrivsepHealedAt == "" {
|
|
continue
|
|
}
|
|
mc.customerOf[row.HostID] = row.CustomerID
|
|
mc.states[row.HostID] = row.PrivsepHealedAt
|
|
seeded++
|
|
}
|
|
logger.Printf("[INFO] Host mgmt-plane checker initialized: %d host heal-state(s) seeded", seeded)
|
|
return mc
|
|
}
|
|
|
|
// Check evaluates all hosts and emits mgmt_plane_healed on a NEW heal timestamp.
|
|
func (mc *HostMgmtPlaneChecker) Check() {
|
|
rows, err := mc.store.GetHostMgmtPlaneStates()
|
|
if err != nil {
|
|
mc.logger.Printf("[WARN] Host mgmt-plane check failed: %v", err)
|
|
return
|
|
}
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
|
|
seen := make(map[string]bool, len(rows))
|
|
for _, row := range rows {
|
|
if mc.store.IsCustomerBlocked(row.CustomerID) {
|
|
delete(mc.states, row.HostID)
|
|
continue
|
|
}
|
|
seen[row.HostID] = true
|
|
mc.customerOf[row.HostID] = row.CustomerID
|
|
newHealed := row.PrivsepHealedAt
|
|
if newHealed == mc.states[row.HostID] {
|
|
continue // unchanged from last-seen (incl. both empty) → nothing to do
|
|
}
|
|
mc.states[row.HostID] = newHealed // advance the baseline (incl. back to "" if a reboot cleared it)
|
|
if newHealed != "" {
|
|
// A NEW heal timestamp the hub has not seen (construction seeded any that predate it) → alert.
|
|
mc.emit(row.HostID, row.CustomerID, newHealed)
|
|
}
|
|
}
|
|
|
|
for id := range mc.states {
|
|
if !seen[id] {
|
|
delete(mc.states, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetState returns the last-seen heal timestamp for a host ("" if none).
|
|
func (mc *HostMgmtPlaneChecker) GetState(hostID string) string {
|
|
mc.mu.Lock()
|
|
defer mc.mu.Unlock()
|
|
return mc.states[hostID]
|
|
}
|
|
|
|
func (mc *HostMgmtPlaneChecker) emit(hostID, customerID, healedAt string) {
|
|
msg := "Host " + hostID + ": the management-plane privsep dir (/run/sshd) was missing and was AUTO-HEALED by the watchdog at " + healedAt +
|
|
" — a recurring cause can lead to an SSH lockout; investigate (e.g. a unit declaring RuntimeDirectory=sshd)."
|
|
details, _ := json.Marshal(map[string]string{
|
|
"host_id": hostID,
|
|
"privsep_healed_at": healedAt,
|
|
})
|
|
mc.logger.Printf("[WARN] Host mgmt-plane: %s privsep dir auto-healed at %s (mgmt_plane_healed)", hostID, healedAt)
|
|
if _, err := mc.store.SaveEvent(customerID, "mgmt_plane_healed", "warning", msg, string(details), "hub"); err != nil {
|
|
mc.logger.Printf("[WARN] save mgmt_plane_healed for %s: %v", hostID, err)
|
|
return
|
|
}
|
|
if mc.onEvent != nil {
|
|
mc.onEvent(customerID, "mgmt_plane_healed", "warning", msg, string(details), "hub")
|
|
}
|
|
}
|