312fd5ee29
The 2026-07-04 CGNAT smoke test found MTU 1420 silently black-holes bulk TCP on sub-~1480 paths (mobile ~1400, DS-Lite ~1452): handshake+ping stay healthy, PBS TLS page (and at S4 the backup itself) drops. Set a fleet-wide, permanent, family-agnostic client MTU of 1280 (RFC 8200 IPv6-minimum floor; outer 1340 v4 / 1360 v6 fits every realistic path). Client-only by construction — interface MTU caps box→PBS, advertised MSS caps PBS→box; the endpoint's wg0 is untouched (zero live-endpoint risk). New const clientMTU=1280 as the single home; golden pins exact "MTU = 1280" (red-proofed against a 1420 flip). Stale report.go comment updated. No wire/JSON change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
453 lines
14 KiB
Go
453 lines
14 KiB
Go
package wgtunnel
|
|
|
|
// Group B — the manager state machine (Scenarios A-D). Non-hollow: every test asserts EXEC
|
|
// COUNTS on the recording runner (the no-exec negatives are the load-bearing ones), single
|
|
// registration, marker semantics, and the absence of key material in the captured log buffer.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
|
|
)
|
|
|
|
// recordingRunner records every privileged exec; scripted errors per command name.
|
|
type recordingRunner struct {
|
|
mu sync.Mutex
|
|
calls [][]string
|
|
fail map[string]error // command name → error
|
|
}
|
|
|
|
func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
|
|
r.mu.Lock()
|
|
r.calls = append(r.calls, append([]string{name}, args...))
|
|
err := r.fail[name]
|
|
r.mu.Unlock()
|
|
if err != nil {
|
|
return nil, []byte("scripted failure"), err
|
|
}
|
|
if name == "wg" {
|
|
return []byte("SERVERPUB\t1783107115\n"), nil, nil
|
|
}
|
|
return nil, nil, nil
|
|
}
|
|
|
|
func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
|
|
return r.Run(ctx, name, args...)
|
|
}
|
|
|
|
func (r *recordingRunner) count(name string) int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
n := 0
|
|
for _, c := range r.calls {
|
|
if c[0] == name {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (r *recordingRunner) total() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.calls)
|
|
}
|
|
|
|
func (r *recordingRunner) lastSystemctl() []string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
for i := len(r.calls) - 1; i >= 0; i-- {
|
|
if r.calls[i][0] == "systemctl" {
|
|
return r.calls[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fakeHub scripts RegisterWG.
|
|
type fakeHub struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
resp *hub.WGRegisterResponse
|
|
err error
|
|
}
|
|
|
|
func (f *fakeHub) RegisterWG(_ context.Context, pubkey string) (*hub.WGRegisterResponse, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls++
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
if f.resp != nil {
|
|
return f.resp, nil
|
|
}
|
|
return &hub.WGRegisterResponse{Pubkey: pubkey, AssignedIP: "10.77.0.2/32", Generation: 3, Sync: "ok"}, nil
|
|
}
|
|
|
|
func (f *fakeHub) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls }
|
|
|
|
// testManager builds a Manager over temp state with all external probes faked.
|
|
// active is a pointer so tests flip service state mid-scenario.
|
|
func testManager(t *testing.T, fh *fakeHub) (*Manager, *recordingRunner, *bool, *bytes.Buffer) {
|
|
t.Helper()
|
|
rr := &recordingRunner{fail: map[string]error{}}
|
|
active := false
|
|
logBuf := &bytes.Buffer{}
|
|
logger := slog.New(slog.NewTextHandler(logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
|
m := NewManager(rr, fh, t.TempDir(), logger)
|
|
m.isActive = func(ctx context.Context) bool { return active }
|
|
m.wgPresent = func() bool { return true }
|
|
return m, rr, &active, logBuf
|
|
}
|
|
|
|
func testBlock(pub string) *hub.WireWireguard {
|
|
return &hub.WireWireguard{
|
|
Endpoint: hub.WireWireguardEndpoint{
|
|
DNSName: "ep0.felhom.eu", WGPort: 443,
|
|
ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=", PBSTunnelIP: "10.77.0.1",
|
|
},
|
|
Pubkey: pub,
|
|
AssignedIP: "10.77.0.2/32",
|
|
}
|
|
}
|
|
|
|
func localPub(t *testing.T, m *Manager) string {
|
|
t.Helper()
|
|
pub, _, err := EnsureKey(m.stateDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pub
|
|
}
|
|
|
|
// 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.
|
|
func TestRenderConf_Golden(t *testing.T) {
|
|
block := testBlock("ignored")
|
|
conf, err := renderConf(block, vectorPrivB64)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := `# felhom offsite tunnel — agent-managed (S3); DO NOT EDIT
|
|
[Interface]
|
|
PrivateKey = ` + vectorPrivB64 + `
|
|
Address = 10.77.0.2/32
|
|
MTU = 1280
|
|
|
|
[Peer]
|
|
PublicKey = CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=
|
|
Endpoint = ep0.felhom.eu: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.
|
|
bad := *block
|
|
bad.Endpoint.ServerPubkey = "short"
|
|
if _, err := renderConf(&bad, vectorPrivB64); 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 {
|
|
t.Error("assigned_ip without /32 accepted")
|
|
}
|
|
bad = *block
|
|
bad.Endpoint.DNSName = "evil host\ninjected"
|
|
if _, err := renderConf(&bad, vectorPrivB64); err == nil {
|
|
t.Error("hostile dns_name accepted")
|
|
}
|
|
}
|
|
|
|
func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, rr, active, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
|
|
// Tick 1: no desired data yet → keygen + ONE registration, NO conf/systemctl.
|
|
m.Apply(ctx, false, nil)
|
|
if fh.count() != 1 {
|
|
t.Fatalf("register calls = %d, want 1", fh.count())
|
|
}
|
|
if rr.total() != 0 {
|
|
t.Fatalf("privileged execs before the block arrived: %v", rr.calls)
|
|
}
|
|
if m.loadMarker() == nil {
|
|
t.Fatal("marker not written after successful registration")
|
|
}
|
|
|
|
// Tick 2 (still nothing fetched): NO second registration (marker gate).
|
|
m.Apply(ctx, false, nil)
|
|
if fh.count() != 1 {
|
|
t.Fatalf("register calls after marker = %d, want still 1 (marker gate)", fh.count())
|
|
}
|
|
|
|
// Block arrives → conf staged + installed + enabled.
|
|
pub := localPub(t, m)
|
|
m.Apply(ctx, true, testBlock(pub))
|
|
if rr.count("install") != 1 || rr.count("systemctl") != 1 {
|
|
t.Fatalf("apply execs = %v", rr.calls)
|
|
}
|
|
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
|
|
t.Errorf("systemctl argv = %v", got)
|
|
}
|
|
if _, err := os.Stat(m.stagedConfPath()); err != nil {
|
|
t.Error("staged conf missing")
|
|
}
|
|
// Steady state with service now active: NO further execs.
|
|
*active = true
|
|
before := rr.total()
|
|
m.Apply(ctx, true, testBlock(pub))
|
|
if rr.total() != before {
|
|
t.Errorf("steady-state tick ran execs: %v", rr.calls[before:])
|
|
}
|
|
}
|
|
|
|
func TestScenarioB_ConfChangeRestartsAndSelfHeal(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, rr, active, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
m.Apply(ctx, false, nil) // register
|
|
pub := localPub(t, m)
|
|
m.Apply(ctx, true, testBlock(pub)) // first apply (enable)
|
|
*active = true
|
|
|
|
// Endpoint re-key: server_pubkey changes → re-render + install + RESTART (not reload/enable).
|
|
changed := testBlock(pub)
|
|
changed.Endpoint.ServerPubkey = "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="
|
|
m.Apply(ctx, true, changed)
|
|
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl restart wg-quick@wg-felhom" {
|
|
t.Errorf("conf change systemctl = %v, want restart", got)
|
|
}
|
|
|
|
// Crash/manual stop with unchanged conf → self-heal enable --now.
|
|
*active = false
|
|
m.Apply(ctx, true, changed)
|
|
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
|
|
t.Errorf("self-heal systemctl = %v, want enable --now", got)
|
|
}
|
|
}
|
|
|
|
func TestScenarioC_RevocationDisablesAndNeverReregisters(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, rr, active, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
m.Apply(ctx, false, nil)
|
|
pub := localPub(t, m)
|
|
m.Apply(ctx, true, testBlock(pub))
|
|
*active = true
|
|
|
|
// The hub removes the peer: PRESENT desired-state, absent block.
|
|
m.Apply(ctx, true, nil)
|
|
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl disable --now wg-quick@wg-felhom" {
|
|
t.Fatalf("revocation systemctl = %v, want disable --now", got)
|
|
}
|
|
if m.loadMarker() == nil {
|
|
t.Fatal("marker removed on revocation — it must be KEPT (revoked stays revoked)")
|
|
}
|
|
*active = false
|
|
|
|
// Multiple later ticks: NO re-register, NO install, NO enable (the load-bearing negatives).
|
|
regBefore, execBefore := fh.count(), rr.total()
|
|
for i := 0; i < 5; i++ {
|
|
m.Apply(ctx, true, nil)
|
|
}
|
|
if fh.count() != regBefore {
|
|
t.Errorf("re-registration after revocation: %d calls", fh.count()-regBefore)
|
|
}
|
|
if rr.total() != execBefore {
|
|
t.Errorf("execs after teardown: %v", rr.calls[execBefore:])
|
|
}
|
|
|
|
// Operator re-adds the peer → block returns → tunnel back WITHOUT registration.
|
|
m.Apply(ctx, true, testBlock(pub))
|
|
if fh.count() != regBefore {
|
|
t.Errorf("re-add triggered a registration: %d", fh.count()-regBefore)
|
|
}
|
|
if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
|
|
t.Errorf("re-add systemctl = %v, want enable --now", got)
|
|
}
|
|
}
|
|
|
|
func TestScenarioD_MismatchReregisterWithBackoff(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, _, _, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
now := time.Unix(1_800_000_000, 0)
|
|
m.now = func() time.Time { return now }
|
|
|
|
m.Apply(ctx, false, nil) // initial registration
|
|
pub := localPub(t, m)
|
|
|
|
// The hub serves a block for ANOTHER pubkey → exactly one re-register per backoff window.
|
|
other := testBlock("hHwNLDdSNPNl5mCVUYejc1oPdhPRYJ06ak2MU66qWiI=")
|
|
fh.err = errors.New("hub 500")
|
|
m.Apply(ctx, true, other)
|
|
if fh.count() != 2 {
|
|
t.Fatalf("mismatch register calls = %d, want 2 (initial + one retry)", fh.count())
|
|
}
|
|
// Same instant: backoff gates the hot loop.
|
|
m.Apply(ctx, true, other)
|
|
m.Apply(ctx, true, other)
|
|
if fh.count() != 2 {
|
|
t.Fatalf("backoff not applied: %d calls", fh.count())
|
|
}
|
|
// After the backoff window: one more attempt, this time succeeding → marker updated.
|
|
fh.err = nil
|
|
now = now.Add(2 * time.Minute)
|
|
m.Apply(ctx, true, other)
|
|
if fh.count() != 3 {
|
|
t.Fatalf("post-backoff register calls = %d, want 3", fh.count())
|
|
}
|
|
if mk := m.loadMarker(); mk == nil || mk.Pubkey != pub {
|
|
t.Errorf("marker after re-key = %+v, want local pubkey %s", m.loadMarker(), pub)
|
|
}
|
|
}
|
|
|
|
func TestAdoptLostMarkerWithKnownKey(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, rr, _, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
pub := localPub(t, m) // key exists, NO marker (lost state), hub already has the block
|
|
|
|
m.Apply(ctx, true, testBlock(pub))
|
|
if fh.count() != 0 {
|
|
t.Fatalf("adopt path re-registered: %d calls", fh.count())
|
|
}
|
|
mk := m.loadMarker()
|
|
if mk == nil || mk.Pubkey != pub || mk.AssignedIP != "10.77.0.2/32" {
|
|
t.Fatalf("adoption marker = %+v", mk)
|
|
}
|
|
if rr.count("install") != 1 {
|
|
t.Errorf("adopt did not proceed to conf apply: %v", rr.calls)
|
|
}
|
|
}
|
|
|
|
func TestNoTeardownOnAbsentData(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, rr, active, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
m.Apply(ctx, false, nil)
|
|
pub := localPub(t, m)
|
|
m.Apply(ctx, true, testBlock(pub))
|
|
*active = true
|
|
before := rr.total()
|
|
|
|
// Desired fetch stale/unavailable (fetched=false): keep last-applied state — NO teardown,
|
|
// NO execs (red-proof (d) anchor).
|
|
for i := 0; i < 3; i++ {
|
|
m.Apply(ctx, false, nil)
|
|
}
|
|
if rr.total() != before {
|
|
t.Errorf("absent-data tick ran execs (teardown-on-absent-data bug): %v", rr.calls[before:])
|
|
}
|
|
}
|
|
|
|
func TestPartialStateAndCorruptKeyIdle(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, rr, _, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
|
|
// Marker without key file → idle, no registration, no execs, key NOT created.
|
|
if err := m.writeMarker(marker{Pubkey: "X", AssignedIP: "10.77.0.9/32"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.Apply(ctx, false, nil)
|
|
if fh.count() != 0 || rr.total() != 0 {
|
|
t.Fatalf("partial state acted: reg=%d execs=%d", fh.count(), rr.total())
|
|
}
|
|
if _, err := os.Stat(KeyFilePath(m.stateDir)); !os.IsNotExist(err) {
|
|
t.Fatal("partial state minted a fresh key — never-guess violated")
|
|
}
|
|
|
|
// Corrupt key file → idle likewise.
|
|
os.Remove(m.markerPath())
|
|
os.MkdirAll(m.wgDir(), 0o700)
|
|
os.WriteFile(KeyFilePath(m.stateDir), []byte("garbage"), 0o600)
|
|
m.Apply(ctx, false, nil)
|
|
if fh.count() != 0 || rr.total() != 0 {
|
|
t.Fatalf("corrupt key acted: reg=%d execs=%d", fh.count(), rr.total())
|
|
}
|
|
}
|
|
|
|
func TestStatusStanzaAndHandshakeParse(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, rr, active, _ := testManager(t, fh)
|
|
ctx := context.Background()
|
|
m.now = func() time.Time { return time.Unix(1783107215, 0) } // 100s after the scripted handshake
|
|
|
|
m.Apply(ctx, false, nil) // keygen + register
|
|
*active = true
|
|
st := m.Status(ctx)
|
|
if st.Pubkey == "" || !st.Registered || !st.Active || st.AssignedIP != "10.77.0.2/32" {
|
|
t.Fatalf("status = %+v", st)
|
|
}
|
|
if st.LastHandshakeAgeS == nil || *st.LastHandshakeAgeS != 100 {
|
|
t.Fatalf("handshake age = %v, want 100", st.LastHandshakeAgeS)
|
|
}
|
|
// The ONLY wg invocation is latest-handshakes — never `dump`.
|
|
rr.mu.Lock()
|
|
for _, c := range rr.calls {
|
|
if c[0] == "wg" && strings.Join(c, " ") != "wg show wg-felhom latest-handshakes" {
|
|
t.Errorf("unexpected wg invocation: %v", c)
|
|
}
|
|
}
|
|
rr.mu.Unlock()
|
|
|
|
// Inactive service → no wg exec, nil age.
|
|
*active = false
|
|
wgBefore := rr.count("wg")
|
|
st = m.Status(ctx)
|
|
if st.Active || st.LastHandshakeAgeS != nil {
|
|
t.Errorf("inactive status = %+v", st)
|
|
}
|
|
if rr.count("wg") != wgBefore {
|
|
t.Error("handshake read ran against an inactive service")
|
|
}
|
|
}
|
|
|
|
func TestNoKeyMaterialInLogs(t *testing.T) {
|
|
fh := &fakeHub{}
|
|
m, _, active, logBuf := testManager(t, fh)
|
|
ctx := context.Background()
|
|
m.Apply(ctx, false, nil)
|
|
pub := localPub(t, m)
|
|
m.Apply(ctx, true, testBlock(pub))
|
|
*active = true
|
|
m.Apply(ctx, true, nil) // revocation path too
|
|
m.Status(ctx)
|
|
|
|
raw, err := os.ReadFile(KeyFilePath(m.stateDir))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
privB64 := strings.TrimSpace(string(raw))
|
|
if privB64 == "" || len(privB64) != 44 {
|
|
t.Fatalf("unexpected key file %q", privB64)
|
|
}
|
|
logs := logBuf.String()
|
|
if strings.Contains(logs, privB64) {
|
|
t.Fatal("PRIVATE KEY found in the log buffer")
|
|
}
|
|
// base64 of the raw key without padding variants too (defense against partial logging)
|
|
if trimmed := strings.TrimRight(privB64, "="); len(trimmed) > 20 && strings.Contains(logs, trimmed) {
|
|
t.Fatal("private key material (unpadded) found in the log buffer")
|
|
}
|
|
if !strings.Contains(logs, pub) {
|
|
t.Error("pubkey absent from logs — expected (pubkeys are fine to log)")
|
|
}
|
|
}
|