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 fetched bool // a desired-state document has been delivered (false = never a teardown signal) 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.fetched = true l.block = resp.DesiredState.Wireguard l.mu.Unlock() select { case l.nudge <- struct{}{}: default: } } func (l *Loop) snapshot() (bool, *hub.WireWireguard) { l.mu.Lock() defer l.mu.Unlock() return l.fetched, 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. The `fetched` gate mirrors // wgtunnel: until a desired-state document arrives, the operator inputs (authorized_keys, @operator_ips) // are LEFT UNTOUCHED — an unfetched block must never empty the belt (operator lockout) or wipe the key. func (l *Loop) reconcile(ctx context.Context) { fetched, block := l.snapshot() // Pass the block to Apply only when fetched, so authorized_keys is applied only from real desired // state (a nil block on Apply skips the authorized_keys write, leaving the existing file). applyBlock := block if !fetched { applyBlock = nil } port, err := l.mgr.Apply(ctx, applyBlock) if err != nil { return // Apply logged; a claim/exhaustion or install error — retry next tick } if l.belt != nil { l.belt.SyncPort(ctx, port) // always — the port is the agent's own claim if fetched { l.belt.SyncOperator(ctx, oobPeerIP(block)) // only from real desired state } } 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 { _, block := l.snapshot() return l.mgr.Status(ctx, block) }