Files
felhom-agent/internal/felhomsshd/loop.go
T
admin c983a25609 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
2026-07-05 22:27:02 +02:00

100 lines
2.6 KiB
Go

package felhomsshd
import (
"context"
"log/slog"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// Loop drives the felhom-sshd Manager on its own cadence (the wgtunnel/lanresolver shape) and
// consumes the hub desired-state's wireguard block via the desired.Syncer raw-consumer seam (for
// oob_peer_ip → the belt, and oob_operator_ssh_key → authorized_keys). Each tick: claim/render/reload
// the instance, sync the belt sets, and run the health/heal check.
type Loop struct {
mgr *Manager
belt *Belt // Part 3 (nil-safe: no belt sync when unset)
interval time.Duration
logger *slog.Logger
mu sync.Mutex
block *hub.WireWireguard
nudge chan struct{}
}
// NewLoop builds the loop. interval defaults to 60s. belt may be nil (belt sync skipped).
func NewLoop(mgr *Manager, belt *Belt, interval time.Duration, logger *slog.Logger) *Loop {
if interval <= 0 {
interval = 60 * time.Second
}
if logger == nil {
logger = slog.Default()
}
return &Loop{mgr: mgr, belt: belt, interval: interval, logger: logger, nudge: make(chan struct{}, 1)}
}
// OnDesiredState implements desired.RawConsumer: store the latest wireguard block and nudge.
func (l *Loop) OnDesiredState(_ context.Context, resp *hub.DesiredStateResponse) {
if resp == nil {
return
}
l.mu.Lock()
l.block = resp.DesiredState.Wireguard
l.mu.Unlock()
select {
case l.nudge <- struct{}{}:
default:
}
}
func (l *Loop) snapshot() *hub.WireWireguard {
l.mu.Lock()
defer l.mu.Unlock()
return l.block
}
// Run reconciles immediately, then on every tick or desired-state nudge, until ctx is cancelled.
func (l *Loop) Run(ctx context.Context) error {
l.reconcile(ctx)
t := time.NewTicker(l.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
case <-l.nudge:
}
l.reconcile(ctx)
}
}
// reconcile runs one full pass: instance apply → belt sync → health/heal.
func (l *Loop) reconcile(ctx context.Context) {
block := l.snapshot()
port, err := l.mgr.Apply(ctx, block)
if err != nil {
return // Apply logged; a claim/exhaustion or install error — retry next tick
}
if l.belt != nil {
l.belt.Sync(ctx, port, oobPeerIP(block))
}
l.mgr.HealAndCheck(ctx, port)
}
// oobPeerIP extracts the operator /32 source (bare IP) from the block, or "" when OOB is off.
func oobPeerIP(block *hub.WireWireguard) string {
if block == nil {
return ""
}
return block.OOBPeerIP
}
// OOBStatus implements the hub collector's reporter seam (Part 4).
func (l *Loop) OOBStatus(ctx context.Context) *hub.OOBStatus {
return l.mgr.Status(ctx, l.snapshot())
}