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
+29 -2
View File
@@ -31,6 +31,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/desired"
"gitea.dooplex.hu/admin/felhom-agent/internal/dr"
"gitea.dooplex.hu/admin/felhom-agent/internal/escrow"
"gitea.dooplex.hu/admin/felhom-agent/internal/felhomsshd"
"gitea.dooplex.hu/admin/felhom-agent/internal/guesthook"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/lanresolver"
@@ -706,6 +707,28 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
}
}
// OOB operator access (H1): the dedicated felhom-sshd instance + port-adaptive belt + health.
// DEFAULT OFF (oob.enabled) until the operator endpoint + static belt table exist. Consumes the
// SAME wireguard desired-state block (oob_peer_ip → belt, oob_operator_ssh_key → authorized_keys).
oobServers := 0
var oobLoop *felhomsshd.Loop
{
oc := cfg.OOB.WithDefaults()
if oc.Enabled {
oobMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if oobMode == "" {
oobMode = proxmox.RunnerSudo
}
oobRunner := &proxmox.ExecRunner{Mode: oobMode, SudoPath: cfg.Privileged.SudoPath}
oobMgr := felhomsshd.NewManager(oobRunner, oc.StateDir, logger)
oobBelt := felhomsshd.NewBelt(oobRunner, logger)
oobLoop = felhomsshd.NewLoop(oobMgr, oobBelt, time.Duration(oc.IntervalSeconds)*time.Second, logger)
desiredSyncer.AddConsumer(oobLoop) // raw desired-state → oob_peer_ip + operator key
collector.SetOOBReporter(oobLoop) // heartbeat oob health stanza
logger.Info("felhomsshd (OOB): enabled", "interval_s", oc.IntervalSeconds, "state_dir", oc.StateDir)
}
}
// Run reconcile, the hub loop, the storage watchdog, the restore-test scheduler, the PBS
// verify loop, (optionally) the local-API server, and (optionally) the LAN resolver loop
// concurrently; any one returning ends the daemon (ctx cancel tears down the rest).
@@ -768,6 +791,10 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
wgServers = 1
go func() { errc <- wgLoop.Run(ctx) }()
}
if oobLoop != nil {
oobServers = 1
go func() { errc <- oobLoop.Run(ctx) }()
}
// TASK D1 Scenario D: if this process is a JUST-FLIPPED self-update (a pending marker names THIS
// version), commit it after a clean dwell — but only now that core init is done (config parsed,
@@ -777,8 +804,8 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
go selfUpdateMgr.MaybeCommit(ctx)
err = <-errc
stop() // tear down the siblings on the first exit
for i := 0; i < 4+localServers+lanServers+wgServers; i++ { // wait for the other goroutines
stop() // tear down the siblings on the first exit
for i := 0; i < 4+localServers+lanServers+wgServers+oobServers; i++ { // wait for the other goroutines
<-errc
}
if err != nil && err != context.Canceled {