feat(hub): OOB access health ingest + degraded alert (H1 Part 4)

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
This commit is contained in:
2026-07-05 22:30:03 +02:00
parent 0ec7555126
commit f8fc09e5cc
6 changed files with 362 additions and 0 deletions
+38
View File
@@ -15,6 +15,7 @@ import (
"io"
"net/http"
"net/netip"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -48,6 +49,29 @@ func validateWGPubkey(pk string) error {
return nil
}
// validSSHAuthorizedKey does a conservative shape check on an SSH public key (an authorized_keys
// line): a known key type, a base64 blob, no newlines/control chars (it is written verbatim into a
// per-user authorized_keys file, so a hostile value must not inject options or extra lines).
func validSSHAuthorizedKey(line string) bool {
line = strings.TrimSpace(line)
if line == "" || strings.ContainsAny(line, "\n\r\x00") {
return false
}
fields := strings.Fields(line)
if len(fields) < 2 {
return false
}
switch fields[0] {
case "ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "sk-ssh-ed25519@openssh.com":
default:
return false
}
if _, err := base64.StdEncoding.DecodeString(fields[1]); err != nil {
return false
}
return true
}
// syncAfterMutation runs an inline sync after a peer mutation. The DB write already happened —
// it is the source of truth — so a push failure is REPORTED, not rolled back: the reconciler's
// next tick converges the endpoint (Scenario D).
@@ -327,6 +351,10 @@ func (h *Handler) mergeWireguard(hostID, desired string) string {
h.logger.Printf("[WARN] wg merge %s: operator OOB peer lookup failed: %v (serving without oob_peer_ip)", hostID, oerr)
} else if op != nil {
wgBlock["oob_peer_ip"] = op.AssignedIP // bare IPv4, e.g. "10.77.0.250"
// The operator SSH pubkey rides alongside (agent writes felhom-sshd's authorized_keys from it).
if k := h.store.GetOOBOperatorSSHKey(); k != "" {
wgBlock["oob_operator_ssh_key"] = k
}
}
doc["wireguard"] = wgBlock
out, err := json.Marshal(doc)
@@ -438,6 +466,7 @@ func (h *Handler) handleAdminSetOperatorPeer(w http.ResponseWriter, r *http.Requ
var req struct {
Pubkey string `json:"pubkey"`
AssignedIP string `json:"assigned_ip"` // bare IPv4, e.g. "10.77.0.250"
SSHPubkey string `json:"ssh_pubkey"` // optional: the operator's SSH authorized_keys line
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
@@ -447,6 +476,10 @@ func (h *Handler) handleAdminSetOperatorPeer(w http.ResponseWriter, r *http.Requ
http.Error(w, "Invalid payload: "+err.Error(), http.StatusBadRequest)
return
}
if req.SSHPubkey != "" && !validSSHAuthorizedKey(req.SSHPubkey) {
http.Error(w, "Invalid payload: ssh_pubkey must be an ssh-ed25519/ssh-rsa/ecdsa authorized_keys line", http.StatusBadRequest)
return
}
if err := h.store.SetOperatorOOBPeer(req.Pubkey, req.AssignedIP); err != nil {
if err == store.ErrWGEndpointUnset {
http.Error(w, "wg endpoint not configured", http.StatusConflict)
@@ -455,6 +488,11 @@ func (h *Handler) handleAdminSetOperatorPeer(w http.ResponseWriter, r *http.Requ
http.Error(w, "Invalid operator peer: "+err.Error(), http.StatusBadRequest)
return
}
if req.SSHPubkey != "" {
if err := h.store.SetOOBOperatorSSHKey(req.SSHPubkey); err != nil {
h.logger.Printf("[WARN] operator peer set but ssh_pubkey store failed: %v", err)
}
}
syncStatus := h.syncAfterMutation(r.Context())
bumped, berr := h.store.BumpAllHostGenerations()
if berr != nil {