Files
felhom.eu/hub/internal/monitor/host_mgmtplane.go
T
admin 012e5f3ecc fix(hub): mgmt_plane_healed alerts on the FIRST auto-heal (TASK G1) — v0.34.1
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
2026-07-05 19:12:50 +02:00

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")
}
}