feat(felhomsshd): dedicated OOB sshd instance + port-adaptive belt (H1 Parts 2-4 agent)

internal/felhomsshd: agent-managed felhom-sshd (claim port [8822,2222,8022,62222]
loud-fail-on-exhaustion; render config→sshd -t→reload never-restart-on-change
[SF-2]; operator authorized_keys from the hub block outside ~/.ssh [SF-3]); the
static-table nft belt mutating ONLY @operator_ips + @ssh_port [trap 4]; health/heal
(reset-failed-then-restart with 10min cooldown, NEVER restart onto an invalid
config) + the oob heartbeat stanza. configs/felhom-sshd.service (SAFE, no
RuntimeDirectory [SF-1]). FELHOM_SSHD + FELHOM_OOB sudoers (set-elements only).
oob.enabled config DEFAULT FALSE. Wired into main like wgtunnel.

Non-hollow tests: claim clean/contention/idempotent/exhaustion; config
safe+byte-stable+refuses-:22; belt mutate-then-idempotent + never-touches-rules;
heal no-restart-on-invalid-config + cooldown; status reflects block.

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:27:02 +02:00
parent effff53f99
commit c983a25609
14 changed files with 1140 additions and 6 deletions
+16
View File
@@ -70,6 +70,7 @@ type Collector struct {
wg WireguardReporter // S3: offsite-tunnel status (nil → stanza omitted)
selfUpdate SelfUpdateReporter // D1: agent self-update pending status (nil → false)
mgmtPlane MgmtPlaneReporter // G1: management-plane health (nil → stanza omitted)
oob OOBReporter // H1: operator-access health (nil → stanza omitted)
hostID string
agentVersion string
logger *slog.Logger
@@ -154,6 +155,17 @@ func (c *Collector) SetMgmtPlaneReporter(m MgmtPlaneReporter) *Collector {
return c
}
// OOBReporter is the H1 seam the felhom-sshd loop plugs into (nil → no oob stanza).
type OOBReporter interface {
OOBStatus(ctx context.Context) *OOBStatus
}
// SetOOBReporter wires the operator-access health source (H1; nil-safe → stanza omitted).
func (c *Collector) SetOOBReporter(o OOBReporter) *Collector {
c.oob = o
return c
}
// Collect builds the report. Best-effort liveness: a failed NodeStatus is a hard
// error (no useful report — the cycle skips the POST); a failed per-guest
// GuestConfig degrades that guest to status="unknown" without spec but still sends;
@@ -200,6 +212,10 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) {
if c.mgmtPlane != nil {
report.MgmtPlane = c.mgmtPlane.MgmtPlaneStatus(ctx)
}
// H1: operator-access (OOB) health (nil reporter = feature not wired → stanza omitted).
if c.oob != nil {
report.OOB = c.oob.OOBStatus(ctx)
}
return report, nil
}
+29 -3
View File
@@ -76,6 +76,26 @@ type HostReport struct {
// SelfUpdatePending precedent): stored opaquely hub-side, so these additive fields need no
// hub-schema change and are absent when the reporter is not wired.
MgmtPlane *MgmtPlaneStatus `json:"mgmt_plane,omitempty"`
// OOB is the operator-access health stanza (TASK H1). It answers the operator's question — "can I
// get into this box right now, and if not, why" — from the hub: felhom-sshd up + on which port,
// locally reachable, the tunnel handshake age (the OOB path rides wg-felhom), whether the operator
// peer/key is configured, and whether the config is invalid. The heartbeat reaches the hub over
// HTTPS even when felhom-sshd or the tunnel is DOWN (channel independence). `omitempty`: absent
// when the feature is not wired (pre-H1) — additive, no hub-schema change.
OOB *OOBStatus `json:"oob,omitempty"`
}
// OOBStatus is the per-heartbeat operator-access health (TASK H1). Carries no secret.
type OOBStatus struct {
FelhomSshdActive bool `json:"felhom_sshd_active"` // the felhom-sshd unit is active
FelhomSshdPort int `json:"felhom_sshd_port"` // the claimed OOB port (0 = unclaimed)
Reachable bool `json:"reachable"` // a local TCP dial to the OOB port succeeds
ConfigInvalid bool `json:"config_invalid"` // `sshd -t` on the OOB config fails
OperatorPeerConfigured bool `json:"operator_peer_configured"` // oob_peer_ip present in desired-state
OperatorKeyConfigured bool `json:"operator_key_configured"` // operator authorized_key installed
WGHandshakeAgeS *int64 `json:"wg_handshake_age_s,omitempty"` // wg-felhom last handshake age (nil = unknown)
HealedAt string `json:"healed_at,omitempty"` // last felhom-sshd auto-heal (RFC3339)
}
// MgmtPlaneStatus is the per-heartbeat management-plane health (TASK G1). Carries no secret.
@@ -84,9 +104,9 @@ type HostReport struct {
// hub raises a warning event on a PrivsepHealedAt it has not alerted on — a recurring auto-heal means
// a persistent clobber cause worth investigating before the box locks out.
type MgmtPlaneStatus struct {
PrivsepDirOK bool `json:"privsep_dir_ok"` // /run/sshd exists (the KEXINIT-reset detector)
SshdReachable bool `json:"sshd_reachable"` // the stock sshd listener accepts TCP
HealedRecently bool `json:"healed_recently"` // the watchdog heal-marker is present (this boot)
PrivsepDirOK bool `json:"privsep_dir_ok"` // /run/sshd exists (the KEXINIT-reset detector)
SshdReachable bool `json:"sshd_reachable"` // the stock sshd listener accepts TCP
HealedRecently bool `json:"healed_recently"` // the watchdog heal-marker is present (this boot)
PrivsepHealedAt string `json:"privsep_healed_at,omitempty"` // marker timestamp; hub warns on a NEW value
}
@@ -364,6 +384,12 @@ type WireWireguard struct {
// set`) so it survives self-heal/restart/reboot ([OF-1]: a runtime widening was wiped by the
// agent's own self-heal). `omitempty`: absent = OOB off, byte-identical conf for pre-H1 hosts.
OOBPeerIP string `json:"oob_peer_ip,omitempty"`
// OOBOperatorSSHKey is the operator's SSH PUBLIC key (an authorized_keys line, TASK H1). The agent
// writes it to felhom-sshd's dedicated AuthorizedKeysFile (/etc/felhom-sshd/authorized_keys/{root,
// felhom-op}) — OUTSIDE ~/.ssh, so the customer's sshd never honours it [SF-3]. Hub-driven so it
// rotates fleet-wide. `omitempty`: absent = no operator login installed. NOT a secret (public key).
OOBOperatorSSHKey string `json:"oob_operator_ssh_key,omitempty"`
}
// WireWireguardEndpoint is the endpoint half of the wireguard block.