2bf9354c0e
Mirrors wgtunnel fetched=false-is-never-a-teardown: until the desired-state is delivered, @operator_ips + felhom-op authorized_keys are left untouched (a nil block on agent restart would otherwise empty @operator_ips and lock the operator out until the next fetch). Belt.Sync split into SyncPort (always) + SyncOperator (fetched only). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
114 lines
3.3 KiB
Go
114 lines
3.3 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
|
|
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)
|
|
}
|