diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3ef6d..35e4722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## v0.65.0 — S3.1 offsite-tunnel client MTU 1420 → 1280 (resolve the CGNAT-smoke MTU open decision) (2026-07-04) + +One-constant fix closing `06 §4.3`'s OPEN DECISION. The 2026-07-04 CGNAT smoke test found the +shipped interface MTU 1420 **silently black-holes bulk TCP** on any path below ~1480 B (mobile +~1400, DS-Lite ~1452): the WG handshake and ping stay healthy (small packets) while the PBS TLS +page — and, at S4, the backup itself — drops. "Looks green, loses backups." Must be safe before +S4 flows bulk TCP over the tunnel. + +- **`internal/wgtunnel/manager.go`**: new `const clientMTU = 1280` (the RFC 8200 IPv6-minimum link + MTU — every path carries ≥1280; outer = 1280+60 v4 / +80 v6, fits every realistic path); + `renderConf` emits `MTU = %d` from it. **Fleet-wide, family-agnostic, permanent** — decouples + the fix from the v4/v6 endpoint-resolution question (§4.2). **Client-only by construction:** the + interface MTU caps box→PBS and the advertised MSS (=MTU−40) caps PBS→box, so the endpoint's `wg0` + is deliberately untouched (zero live-endpoint risk). Rejected: auto-probe / per-connection-type + (fragile moving part optimizing throughput, a non-metric here) and MSS-clamp (no forwarded flows). +- **`internal/wgtunnel/manager_test.go`**: golden pins exact `MTU = 1280`; red-proofed (flip const + → 1420 fails the golden on the MTU line — non-vacuous). +- **`internal/hub/report.go`**: stale "MTU 1420" comment → 1280 (still NOT a wire field). +- No wire/JSON-golden change (MTU is a client-derived constant, never on the wire); no endpoint, + hub, controller, key, or desired-state change. + ## v0.64.0 — S3 offsite WG tunnel: keygen + registration + agent-managed wg-quick@wg-felhom + escrow join (2026-07-04) The agent half of doc 06 §3.3 (felhom.eu S1/S2 built the endpoint + hub half). **DEFAULT OFF — diff --git a/internal/hub/report.go b/internal/hub/report.go index 0050457..ed3d409 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -316,7 +316,7 @@ type WireDesiredState struct { // WireWireguard is the hub-owned offsite-tunnel assignment (S3) — field-exact with the S2 golden // (testdata/desired-state-wireguard.golden.json, byte-identical hub copy). Client-side -// AllowedIPs, PersistentKeepalive=25 and MTU 1420 are deliberately NOT wire fields — wgtunnel +// AllowedIPs, PersistentKeepalive=25 and MTU 1280 are deliberately NOT wire fields — wgtunnel // constants derived from endpoint.pbs_tunnel_ip + doc 06 §4. type WireWireguard struct { Endpoint WireWireguardEndpoint `json:"endpoint"` diff --git a/internal/wgtunnel/manager.go b/internal/wgtunnel/manager.go index c3ef138..3b4ce60 100644 --- a/internal/wgtunnel/manager.go +++ b/internal/wgtunnel/manager.go @@ -140,8 +140,16 @@ func validKeyB64(s string) error { return nil } +// clientMTU is the offsite tunnel's interface MTU — the IPv6-minimum floor (RFC 8200 guarantees +// every path carries ≥1280). Chosen fleet-wide + family-agnostic so bulk TCP never black-holes on +// a constrained path: outer = 1280+60 (v4) / 1280+80 (v6), both fit the mobile ~1400, DS-Lite +// ~1452, PPPoE 1492 and clean 1500 cases. Client-only bounds both directions (interface MTU caps +// box→PBS; advertised MSS = MTU−40 caps PBS→box), so the endpoint's wg0 stays untouched. Was 1420 +// (silently black-holed sub-~1480 paths — CGNAT smoke test 2026-07-04). See doc 06 §4.3. +const clientMTU = 1280 + // renderConf builds the wg-felhom.conf content from the hub block + the local private key. -// Client-side constants per doc 06 §4: MTU 1420, AllowedIPs = pbs_tunnel_ip/32 (the tunnel +// Client-side constants per doc 06 §4: MTU 1280, AllowedIPs = pbs_tunnel_ip/32 (the tunnel // carries ONLY box→PBS traffic), PersistentKeepalive 25. All inputs validated — nothing // user-controlled is interpolatable (strict charsets, netip parses). func renderConf(block *hub.WireWireguard, privB64 string) (string, error) { @@ -171,7 +179,7 @@ func renderConf(block *hub.WireWireguard, privB64 string) (string, error) { b.WriteString("[Interface]\n") fmt.Fprintf(&b, "PrivateKey = %s\n", privB64) fmt.Fprintf(&b, "Address = %s\n", block.AssignedIP) - b.WriteString("MTU = 1420\n\n") + fmt.Fprintf(&b, "MTU = %d\n\n", clientMTU) b.WriteString("[Peer]\n") fmt.Fprintf(&b, "PublicKey = %s\n", block.Endpoint.ServerPubkey) fmt.Fprintf(&b, "Endpoint = %s:%d\n", block.Endpoint.DNSName, block.Endpoint.WGPort) diff --git a/internal/wgtunnel/manager_test.go b/internal/wgtunnel/manager_test.go index 8ccde7d..f26cec4 100644 --- a/internal/wgtunnel/manager_test.go +++ b/internal/wgtunnel/manager_test.go @@ -130,6 +130,9 @@ func localPub(t *testing.T, m *Manager) string { 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) @@ -140,7 +143,7 @@ func TestRenderConf_Golden(t *testing.T) { [Interface] PrivateKey = ` + vectorPrivB64 + ` Address = 10.77.0.2/32 -MTU = 1420 +MTU = 1280 [Peer] PublicKey = CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=