Files
felhom-agent/internal/wgtunnel/loop.go
T
admin 734f45c422 wgtunnel: v4-pin + re-resolve watchdog; FELHOM_WG Critical flips (S4 agent half)
v4-pin (doc 06 §4.2): renderConf takes a pre-resolved IPv4 literal and writes
Endpoint=<ip>:<port> — never the DNS name, never AAAA. Resolver seam (A records
only, LookupNetIP "ip4"); multiple A → lowest (deterministic fleet-wide);
renderConf stays pure. Resolved IP cached: steady-state Apply = zero DNS + zero
execs. DNS failure keeps the last conf (never a teardown).

Watchdog (loop-only, so Apply's zero-exec steady state is untouched): handshake
age > wg_tunnel.stale_after_seconds (default 180) → re-resolve; IP changed →
re-render + restart (endpoint re-IP recovery); IP same → no churn (throttled
warn). Staleness read reuses wg show latest-handshakes (never dump).

Capability: wg-conf-install/enable/restart/handshake-read flipped Critical=true
(backups ride the tunnel from S4); apt-install + disable stay non-critical.
TestWGCapabilityCriticality pins the set.

Tests + red-proofs a/b/d all fire. No new sudoers grant; no wire/JSON change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-04 15:47:17 +02:00

84 lines
2.1 KiB
Go

package wgtunnel
import (
"context"
"log/slog"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// Loop drives the Manager on its own cadence (the lanresolver shape) AND consumes fetched
// desired-state via the desired.Syncer raw-consumer seam. It distinguishes "no desired data
// seen yet" (fetched=false — never a teardown signal) from "desired-state present without the
// wireguard block" (revocation).
type Loop struct {
mgr *Manager
interval time.Duration
logger *slog.Logger
mu sync.Mutex
fetched bool
block *hub.WireWireguard
nudge chan struct{}
}
// NewLoop builds the loop. interval defaults to 60s.
func NewLoop(mgr *Manager, interval time.Duration, logger *slog.Logger) *Loop {
if interval <= 0 {
interval = 60 * time.Second
}
if logger == nil {
logger = slog.Default()
}
return &Loop{mgr: mgr, interval: interval, logger: logger, nudge: make(chan struct{}, 1)}
}
// OnDesiredState implements desired.RawConsumer: store the latest wireguard block (or its
// absence) and nudge the loop. Non-blocking and panic-free by construction.
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 applies immediately, then on every tick or desired-state nudge, until ctx is cancelled.
func (l *Loop) Run(ctx context.Context) error {
fetched, block := l.snapshot()
l.mgr.Apply(ctx, fetched, block)
t := time.NewTicker(l.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
case <-l.nudge:
}
fetched, block = l.snapshot()
l.mgr.Apply(ctx, fetched, block)
l.mgr.Watchdog(ctx, fetched, block) // re-resolve + recover on endpoint re-IP (doc 06 §4.2)
}
}
// WireguardStatus implements the hub collector's WireguardReporter seam.
func (l *Loop) WireguardStatus(ctx context.Context) *hub.WireguardStatus {
return l.mgr.Status(ctx)
}