// Package mgmtplane is the agent-side OBSERVER for the management-plane break-glass system (TASK G1). // // It does NOT heal anything — the healing is done by the dumb, agent-independent // felhom-mgmt-watchdog timer (configs/felhom-mgmt-watchdog.{sh,service,timer}), precisely so the // heal works when the agent is down. This package only READS host state each heartbeat and produces // the report stanza the hub surfaces: // // - /run/sshd present? — OpenSSH's SHARED privsep dir; its absence is the KEXINIT-reset // lockout (SPIKE-felhom-sshd-2026-07-05 §8). // - stock sshd listener answers? — a TCP connect to the sshd port (22 for G1; H1 passes the // discovered felhom-sshd port later). // - did the watchdog auto-heal? — the watchdog writes an RFC3339 marker to /run when it heals; // its presence (+ timestamp) tells the hub a clobber recurred, so // the operator learns of a recurring cause BEFORE a lockout. // // Read-only and dependency-light: os.Stat + os.ReadFile + a short net.Dial, no exec, no sudo. Safe to // run every heartbeat. package mgmtplane import ( "context" "net" "os" "strings" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) const ( // DefaultPrivsepDir is OpenSSH's compiled-in privilege-separation directory (shared by every sshd // on the host). Its absence is the exact lockout G1 closes. DefaultPrivsepDir = "/run/sshd" // DefaultHealMarker is where felhom-mgmt-watchdog records a heal (RFC3339 UTC). On tmpfs, so it // clears on reboot — "healed since boot" is the intended semantics. DefaultHealMarker = "/run/felhom-mgmt-watchdog.healed" // DefaultSshdPort is the stock sshd port G1 observes (H1 will pass the felhom-sshd port). DefaultSshdPort = 22 // dialTimeout bounds the sshd reachability probe (a local TCP connect is fast; never block a report). dialTimeout = 2 * time.Second ) // Reporter observes the host management plane. All fields are injectable for tests. type Reporter struct { privsepDir string marker string sshdAddr string // host:port dialed for the reachability probe statDir func(string) bool // dir-exists check (os.Stat wrapper; test seam) readMarker func(string) (string, bool) // marker read → (timestamp, present) dialSSHD func(ctx context.Context, addr string) bool } // NewReporter builds the production reporter over the real filesystem + a real TCP dial. sshdPort<=0 // falls back to DefaultSshdPort. func NewReporter(privsepDir, marker string, sshdPort int) *Reporter { if privsepDir == "" { privsepDir = DefaultPrivsepDir } if marker == "" { marker = DefaultHealMarker } if sshdPort <= 0 { sshdPort = DefaultSshdPort } return &Reporter{ privsepDir: privsepDir, marker: marker, sshdAddr: net.JoinHostPort("127.0.0.1", itoa(sshdPort)), statDir: statIsDir, readMarker: readMarkerFile, dialSSHD: dialTCP, } } // MgmtPlaneStatus builds the heartbeat stanza. Never errors — every probe degrades to a boolean; a // read failure means "not ok", never a crash. Implements hub.MgmtPlaneReporter. func (r *Reporter) MgmtPlaneStatus(ctx context.Context) *hub.MgmtPlaneStatus { st := &hub.MgmtPlaneStatus{ PrivsepDirOK: r.statDir(r.privsepDir), SshdReachable: r.dialSSHD(ctx, r.sshdAddr), } if ts, present := r.readMarker(r.marker); present { st.HealedRecently = true st.PrivsepHealedAt = ts } return st } // --- production probe impls (all replaceable in tests) --- func statIsDir(path string) bool { fi, err := os.Stat(path) return err == nil && fi.IsDir() } func readMarkerFile(path string) (string, bool) { raw, err := os.ReadFile(path) if err != nil { return "", false } ts := strings.TrimSpace(string(raw)) if ts == "" { return "", false // an empty marker is treated as absent (never report a heal we can't timestamp) } return ts, true } func dialTCP(ctx context.Context, addr string) bool { d := net.Dialer{Timeout: dialTimeout} conn, err := d.DialContext(ctx, "tcp", addr) if err != nil { return false } _ = conn.Close() return true } // itoa avoids importing strconv for one call. func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) }