f8fc09e5cc
store.GetHostOOBStates parses the agent oob heartbeat stanza. monitor/host_oob: transition-based oob_degraded/oob_recovered warning (felhom-sshd down while the operator peer is configured, OR config invalid) — proactive "can the operator get in right now" signal; unconfigured OOB never alerts. Wired into the 60s sweep. Non-hollow tests + transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
149 lines
4.7 KiB
Go
149 lines
4.7 KiB
Go
package monitor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"sync"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// HostOOBChecker raises an operator WARNING when a host's OOB access path is DEGRADED — felhom-sshd
|
|
// down (while the operator peer is configured, i.e. OOB is meant to work) OR its config is invalid.
|
|
// It answers "can the operator get into this box right now, and if not, why" proactively, from the
|
|
// hub. Transition-based (ok↔degraded, one event per transition — the HostCapabilityChecker shape), so
|
|
// a persistent problem alerts ONCE, not every 60s sweep, and a recovery is noted.
|
|
//
|
|
// A host with no oob stanza (pre-H1 / feature off) is never evaluated. A degraded state requires the
|
|
// operator peer to be configured — a box where OOB was never set up is not "broken".
|
|
type HostOOBChecker struct {
|
|
store *store.Store
|
|
logger *log.Logger
|
|
onEvent EventNotifyFunc
|
|
|
|
mu sync.Mutex
|
|
degraded map[string]bool // hostID → currently-degraded
|
|
customerOf map[string]string
|
|
}
|
|
|
|
// NewHostOOBChecker seeds per-host degraded state from the latest reports WITHOUT alerting (a problem
|
|
// present at startup alerts on the first transition-in evaluated after seed = never re-alerts a
|
|
// steady bad state; matches HostCapabilityChecker). Actually seeds silent, then Check transitions.
|
|
func NewHostOOBChecker(s *store.Store, onEvent EventNotifyFunc, logger *log.Logger) *HostOOBChecker {
|
|
c := &HostOOBChecker{
|
|
store: s,
|
|
logger: logger,
|
|
onEvent: onEvent,
|
|
degraded: make(map[string]bool),
|
|
customerOf: make(map[string]string),
|
|
}
|
|
rows, err := s.GetHostOOBStates()
|
|
if err != nil {
|
|
logger.Printf("[WARN] Host OOB checker: failed to seed: %v", err)
|
|
return c
|
|
}
|
|
seeded := 0
|
|
for _, row := range rows {
|
|
if s.IsCustomerBlocked(row.CustomerID) || !row.Present {
|
|
continue
|
|
}
|
|
c.customerOf[row.HostID] = row.CustomerID
|
|
if oobDegraded(row) {
|
|
c.degraded[row.HostID] = true // seed the bad state so we don't re-alert it on cycle 1
|
|
seeded++
|
|
}
|
|
}
|
|
logger.Printf("[INFO] Host OOB checker initialized: %d host(s) seeded degraded", seeded)
|
|
return c
|
|
}
|
|
|
|
// oobDegraded is the degraded predicate: config invalid, OR (OOB meant to work — operator peer
|
|
// configured — AND felhom-sshd is not active/reachable).
|
|
func oobDegraded(r store.HostOOBRow) bool {
|
|
if !r.Present {
|
|
return false
|
|
}
|
|
if r.ConfigInvalid {
|
|
return true
|
|
}
|
|
if r.OperatorPeerConfigured && (!r.FelhomSshdActive || !r.Reachable) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Check evaluates all hosts and emits oob_degraded / oob_recovered on transitions.
|
|
func (c *HostOOBChecker) Check() {
|
|
rows, err := c.store.GetHostOOBStates()
|
|
if err != nil {
|
|
c.logger.Printf("[WARN] Host OOB check failed: %v", err)
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
seen := make(map[string]bool, len(rows))
|
|
for _, row := range rows {
|
|
if c.store.IsCustomerBlocked(row.CustomerID) {
|
|
delete(c.degraded, row.HostID)
|
|
continue
|
|
}
|
|
if !row.Present {
|
|
continue // no oob stanza → not evaluated
|
|
}
|
|
seen[row.HostID] = true
|
|
c.customerOf[row.HostID] = row.CustomerID
|
|
bad := oobDegraded(row)
|
|
was := c.degraded[row.HostID]
|
|
switch {
|
|
case bad && !was:
|
|
c.degraded[row.HostID] = true
|
|
c.emit(row, "oob_degraded", "warning")
|
|
case !bad && was:
|
|
delete(c.degraded, row.HostID)
|
|
c.emit(row, "oob_recovered", "info")
|
|
}
|
|
}
|
|
for id := range c.degraded {
|
|
if !seen[id] {
|
|
delete(c.degraded, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// IsDegraded reports the current tracked state for a host (test/UI helper).
|
|
func (c *HostOOBChecker) IsDegraded(hostID string) bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.degraded[hostID]
|
|
}
|
|
|
|
func (c *HostOOBChecker) emit(row store.HostOOBRow, eventType, severity string) {
|
|
var msg string
|
|
if eventType == "oob_degraded" {
|
|
reason := "felhom-sshd unreachable"
|
|
if row.ConfigInvalid {
|
|
reason = "felhom-sshd config invalid (sshd -t fails)"
|
|
}
|
|
msg = "Host " + row.HostID + ": OPERATOR ACCESS DEGRADED — " + reason +
|
|
". The break-glass net (auto-heal + vaulted root@pam console) is still under the box."
|
|
} else {
|
|
msg = "Host " + row.HostID + ": operator access recovered (felhom-sshd reachable again)."
|
|
}
|
|
details, _ := json.Marshal(map[string]any{
|
|
"host_id": row.HostID,
|
|
"felhom_sshd_port": row.FelhomSshdPort,
|
|
"active": row.FelhomSshdActive,
|
|
"reachable": row.Reachable,
|
|
"config_invalid": row.ConfigInvalid,
|
|
})
|
|
c.logger.Printf("[%s] Host OOB: %s (%s)", map[string]string{"warning": "WARN", "info": "INFO"}[severity], row.HostID, eventType)
|
|
if _, err := c.store.SaveEvent(row.CustomerID, eventType, severity, msg, string(details), "hub"); err != nil {
|
|
c.logger.Printf("[WARN] save %s for %s: %v", eventType, row.HostID, err)
|
|
return
|
|
}
|
|
if c.onEvent != nil {
|
|
c.onEvent(row.CustomerID, eventType, severity, msg, string(details), "hub")
|
|
}
|
|
}
|