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
This commit is contained in:
2026-07-04 15:47:17 +02:00
parent c618fc69f7
commit 734f45c422
8 changed files with 436 additions and 23 deletions
+204 -8
View File
@@ -10,6 +10,7 @@ import (
"errors"
"io"
"log/slog"
"net/netip"
"os"
"strings"
"sync"
@@ -19,6 +20,33 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)
// fakeResolver drives endpoint resolution deterministically + counts lookups (the "no DNS while
// healthy" negative asserts count stays flat).
type fakeResolver struct {
mu sync.Mutex
addrs []netip.Addr
err error
calls int
}
func (f *fakeResolver) LookupIPv4(_ context.Context, _ string) ([]netip.Addr, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
if f.err != nil {
return nil, f.err
}
return append([]netip.Addr(nil), f.addrs...), nil
}
func (f *fakeResolver) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls }
func (f *fakeResolver) set(a ...netip.Addr) {
f.mu.Lock()
f.addrs, f.err = a, nil
f.mu.Unlock()
}
func (f *fakeResolver) fail(e error) { f.mu.Lock(); f.err = e; f.mu.Unlock() }
// recordingRunner records every privileged exec; scripted errors per command name.
type recordingRunner struct {
mu sync.Mutex
@@ -107,6 +135,8 @@ func testManager(t *testing.T, fh *fakeHub) (*Manager, *recordingRunner, *bool,
m := NewManager(rr, fh, t.TempDir(), logger)
m.isActive = func(ctx context.Context) bool { return active }
m.wgPresent = func() bool { return true }
// Default resolver: one A record, no real DNS. Tests exercising the watchdog swap in their own.
m.resolver = &fakeResolver{addrs: []netip.Addr{netip.MustParseAddr("167.233.158.164")}}
return m, rr, &active, logBuf
}
@@ -131,11 +161,12 @@ func localPub(t *testing.T, m *Manager) string {
}
// TestRenderConf_Golden pins the EXACT conf bytes, including "MTU = 1280" (the IPv6-minimum
// floor, doc 06 §4.3). Red-proofed 2026-07-04: flipping clientMTU back to 1420 fails this test
// on the MTU line mismatch — the golden is non-vacuous, not a "contains MTU" check.
// floor, doc 06 §4.3) and the v4-pinned "Endpoint = 167.233.158.164:443" (doc 06 §4.2 — the A
// LITERAL, never the DNS name, never an AAAA). Red-proofed: flipping clientMTU back to 1420, or
// rendering the dns_name instead of the resolved literal, fails this on the mismatch — non-vacuous.
func TestRenderConf_Golden(t *testing.T) {
block := testBlock("ignored")
conf, err := renderConf(block, vectorPrivB64)
conf, err := renderConf(block, vectorPrivB64, "167.233.158.164")
if err != nil {
t.Fatal(err)
}
@@ -147,29 +178,36 @@ MTU = 1280
[Peer]
PublicKey = CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=
Endpoint = ep0.felhom.eu:443
Endpoint = 167.233.158.164:443
AllowedIPs = 10.77.0.1/32
PersistentKeepalive = 25
`
if conf != want {
t.Errorf("conf mismatch:\n--- got ---\n%s\n--- want ---\n%s", conf, want)
}
// Refusals: bad server key, bad ip, hostile dns_name.
// Refusals: bad server key, bad ip, hostile dns_name, non-v4 endpoint literal.
bad := *block
bad.Endpoint.ServerPubkey = "short"
if _, err := renderConf(&bad, vectorPrivB64); err == nil {
if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
t.Error("bad server pubkey accepted")
}
bad = *block
bad.AssignedIP = "10.77.0.2" // no /32
if _, err := renderConf(&bad, vectorPrivB64); err == nil {
if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
t.Error("assigned_ip without /32 accepted")
}
bad = *block
bad.Endpoint.DNSName = "evil host\ninjected"
if _, err := renderConf(&bad, vectorPrivB64); err == nil {
if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
t.Error("hostile dns_name accepted")
}
// The endpoint literal must be IPv4 — a v6 (or the DNS name) is refused.
if _, err := renderConf(block, vectorPrivB64, "2a01:4f8:1c16:7aa1::1"); err == nil {
t.Error("IPv6 endpoint literal accepted (v4-pin violated)")
}
if _, err := renderConf(block, vectorPrivB64, "ep0.felhom.eu"); err == nil {
t.Error("DNS name accepted as endpoint literal")
}
}
func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) {
@@ -450,3 +488,161 @@ func TestNoKeyMaterialInLogs(t *testing.T) {
t.Error("pubkey absent from logs — expected (pubkeys are fine to log)")
}
}
// --- S4: v4-pin + re-resolve watchdog (doc 06 §4.2) ---
// TestV4Pin_LowestARecordInConf: Apply renders the DETERMINISTIC-lowest A record as the Endpoint
// literal (never the DNS name), resolves exactly once, and a steady-state re-Apply hits the cache
// (no second DNS call — the "no DNS while healthy" negative at the Apply level).
func TestV4Pin_LowestARecordInConf(t *testing.T) {
fh := &fakeHub{}
m, _, active, _ := testManager(t, fh)
fr := &fakeResolver{}
fr.set(netip.MustParseAddr("167.233.158.164"), netip.MustParseAddr("10.0.0.5")) // lowest = 10.0.0.5
m.resolver = fr
ctx := context.Background()
m.Apply(ctx, false, nil)
pub := localPub(t, m)
m.Apply(ctx, true, testBlock(pub))
*active = true
staged, err := os.ReadFile(m.stagedConfPath())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(staged), "Endpoint = 10.0.0.5:443") {
t.Errorf("conf did not pin the lowest A record:\n%s", staged)
}
if strings.Contains(string(staged), "ep0.felhom.eu") {
t.Error("DNS name leaked into the conf (v4-pin violated)")
}
m.Apply(ctx, true, testBlock(pub)) // steady state
if fr.count() != 1 {
t.Errorf("resolver calls = %d, want exactly 1 (cached after first apply — no DNS while healthy)", fr.count())
}
}
// establishWG brings a manager to applied+active over the given resolver; returns the local pubkey.
func establishWG(t *testing.T, m *Manager, active *bool) string {
t.Helper()
ctx := context.Background()
m.Apply(ctx, false, nil)
pub := localPub(t, m)
m.Apply(ctx, true, testBlock(pub))
*active = true
return pub
}
func TestWatchdog_HealthyNoReResolve(t *testing.T) {
fh := &fakeHub{}
m, rr, active, _ := testManager(t, fh)
fr := &fakeResolver{}
fr.set(netip.MustParseAddr("167.233.158.164"))
m.resolver = fr
pub := establishWG(t, m, active)
ctx := context.Background()
m.now = func() time.Time { return time.Unix(1783107115+30, 0) } // handshake age 30s < 180 → fresh
dnsBefore, scBefore := fr.count(), rr.count("systemctl")
m.Watchdog(ctx, true, testBlock(pub))
if fr.count() != dnsBefore {
t.Errorf("watchdog re-resolved a HEALTHY tunnel: %d extra DNS calls", fr.count()-dnsBefore)
}
if rr.count("systemctl") != scBefore {
t.Errorf("watchdog took mutating action on a healthy tunnel: %v", rr.calls)
}
}
func TestWatchdog_StaleReIPRestarts(t *testing.T) {
fh := &fakeHub{}
m, rr, active, _ := testManager(t, fh)
fr := &fakeResolver{}
fr.set(netip.MustParseAddr("167.233.158.164"))
m.resolver = fr
pub := establishWG(t, m, active)
ctx := context.Background()
m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // age 300s > 180 → stale
fr.set(netip.MustParseAddr("192.0.2.9")) // endpoint re-IP
m.Watchdog(ctx, true, testBlock(pub))
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl restart wg-quick@wg-felhom" {
t.Errorf("re-IP watchdog systemctl = %v, want restart", got)
}
staged, err := os.ReadFile(m.stagedConfPath())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(staged), "Endpoint = 192.0.2.9:443") {
t.Errorf("conf not re-pinned to the new endpoint IP:\n%s", staged)
}
}
func TestWatchdog_StaleSameIPNoChurn(t *testing.T) {
fh := &fakeHub{}
m, rr, active, logBuf := testManager(t, fh)
fr := &fakeResolver{}
fr.set(netip.MustParseAddr("167.233.158.164"))
m.resolver = fr
pub := establishWG(t, m, active)
ctx := context.Background()
m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // stale, but resolver returns SAME IP
scBefore := rr.count("systemctl")
m.Watchdog(ctx, true, testBlock(pub))
m.Watchdog(ctx, true, testBlock(pub)) // second stale tick
if rr.count("systemctl") != scBefore {
t.Errorf("watchdog restarted on stale-but-unchanged IP (churn): %v", rr.calls)
}
if n := strings.Count(logBuf.String(), "endpoint IP unchanged"); n != 1 {
t.Errorf("stale-same-IP warning logged %d times, want throttled to 1", n)
}
}
func TestWatchdog_ResolverFailureKeepsConf(t *testing.T) {
fh := &fakeHub{}
m, rr, active, _ := testManager(t, fh)
fr := &fakeResolver{}
fr.set(netip.MustParseAddr("167.233.158.164"))
m.resolver = fr
pub := establishWG(t, m, active)
ctx := context.Background()
orig, _ := os.ReadFile(m.stagedConfPath())
m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // stale
fr.fail(errors.New("resolver down"))
scBefore := rr.count("systemctl")
m.Watchdog(ctx, true, testBlock(pub))
if rr.count("systemctl") != scBefore {
t.Errorf("watchdog acted despite a resolver failure: %v", rr.calls)
}
after, _ := os.ReadFile(m.stagedConfPath())
if !bytes.Equal(orig, after) {
t.Error("conf changed on resolver failure — must keep the last-applied conf")
}
}
// TestInitialResolveFailureNoTeardown: first apply with a failing resolver cannot render — so it
// applies NOTHING and NEVER tears down (DNS failure ≠ revocation); it recovers when DNS returns.
func TestInitialResolveFailureNoTeardown(t *testing.T) {
fh := &fakeHub{}
m, rr, _, _ := testManager(t, fh)
fr := &fakeResolver{}
fr.fail(errors.New("resolver down"))
m.resolver = fr
ctx := context.Background()
m.Apply(ctx, false, nil) // register (no DNS)
pub := localPub(t, m)
m.Apply(ctx, true, testBlock(pub)) // resolve fails → cannot apply
if rr.count("install") != 0 || rr.count("systemctl") != 0 {
t.Errorf("applied/tore-down despite a resolve failure: %v", rr.calls)
}
// DNS returns → the tunnel comes up on the next tick.
fr.set(netip.MustParseAddr("167.233.158.164"))
m.Apply(ctx, true, testBlock(pub))
if rr.count("install") != 1 {
t.Errorf("did not recover after DNS returned: %v", rr.calls)
}
}