fix(vpn): default client profiles to MTU 1280; correct the VPN runbook
gate / gate (pull_request) Successful in 57s
gate / conformance (pull_request) Skipped

new-peer.sh generated MTU 1420, which stalled traffic on mobile data
despite a healthy handshake (one phone, one carrier, 2026-11-09). Default
to 1280, overridable through WG_MTU.

Correct stale claims in the runbook and the DaemonSet header against
live BPF conntrack: LoadBalancer VIPs see 10.90.0.x as world, not a
SNATed host identity. Add a troubleshooting section.

The DaemonSet render is unchanged, so WireGuard pods do not roll.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5.5 committed 2026-11-08 21:15:20 -06:00
1 parent da0515c8c8
commit 8f60e6103a
5 files changed
+190 -17

No files matched your search

+98 -12
View File
@@ -252,7 +252,7 @@ cellular, with Wi-Fi off, and check all four — each proves a different hop:
|---|---|---|
| 1 | tunnel shows a recent handshake in the WireGuard app | the WAN forward + the server key + the peer stanza |
| 2 | `ping 192.168.2.1` (the router) | decrypt → FORWARD → LAN, and the SNAT return path |
| 3 | open `https://plex.home.keithjackson.dev` | PiHole DNS over the tunnel **and** the Gateway accepting a `host`-identity source — the specific thing a pod-networked VPN would have 403'd |
| 3 | open `https://plex.home.keithjackson.dev` | PiHole DNS over the tunnel **and** the Gateway admitting a tunnel client, which a pod-networked VPN would have 403'd. Expected source identity is `world`, like a LAN browser: measured 2026-11-09 on VIPs `.23` and `.24`, not yet observed on the Gateway's `.20` |
| 4 | visit an IP-echo site; it should show `136.51.115.252` | full-tunnel WAN egress out the house, not the carrier |
Then confirm server-side:
@@ -260,18 +260,97 @@ Then confirm server-side:
```bash
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show'
# a `latest handshake` under ~3 min, and non-zero transfer, on the expected peer.
# a `latest handshake` under ~3 min, and non-zero transfer, on the expected peer. `ds/wireguard`
# picks ONE pod; if it reads 0, ask the pod on the node holding the forward (see Troubleshooting).
```
**If check 3 fails while 2 passes**, the tunnel is fine and the problem is DNS or the Gateway, not
WireGuard. Check the client's `DNS = 192.168.2.23` and that PiHole is up.
**If 1 and 2 pass but large transfers hang**, it is MTU. The profile ships `MTU = 1420` (1500 − 60
bytes of WireGuard encapsulation); drop the client to `1280` and re-test.
**If 1 and 2 pass but pages or large transfers hang**, suspect the client MTU; see
Troubleshooting.
**If nothing handshakes at all**, you cannot tell a wrong forward from a wrong key from outside —
by design. Work inwards: `wg show` on the node holding the forward will show *zero* received bytes
if the router is not delivering the packets, and a `handshake` with no completed session if it is.
**If nothing handshakes at all**, you cannot tell a wrong forward from a wrong key from the
device, by design. `wg show` cannot tell them apart either: it reads the same in both cases. The
node's conntrack table can; see Troubleshooting.
## Troubleshooting
Ask the pod on **the node carrying the traffic**: the node the WAN forward names for `away`,
`k3s-prod-1` for `home`. Sessions are per node, and the other two pods read `0`.
```bash
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n vpn-ingress get pods -o wide'
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n vpn-ingress exec <pod> -c wireguard -- wg show wg0 latest-handshakes'
# likewise: `wg show wg0 endpoints`, `wg show wg0 transfer`
```
**Use only those three subcommands to diagnose.** Never `wg showconf`, `wg show wg0 private-key` or
`wg show wg0 preshared-keys`: they print key material into your terminal and transcript.
| output | reading |
|---|---|
| `latest-handshakes`: `<public key> 0` | no handshake with this peer since the pod started |
| `endpoints`: `<public key> (none)` | the server has not accepted a packet from this peer since the pod started |
| `transfer`: `<public key> <rx> <tx>` | bytes, as the server counts them |
### The app shows TX, no RX and no "Latest handshake"
**Symptom.** The app's transmit counter climbs and receive stays at zero. On the server,
`endpoints` is `(none)` and `latest-handshakes` is `0` for that peer.
**Cause.** A key mismatch: the server's public key in the profile, or the device's public key in
`peers.conf`. WireGuard drops an initiation it cannot authenticate and sends nothing back, so
neither end logs an error. Measured 2026-11-09: a mistyped server public key in the phone's profile.
**Check.** First prove the packets arrive. The pod has no `conntrack` binary, but in `hostNetwork`
it can read the host's table:
```bash
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n vpn-ingress exec <pod> -c wireguard -- grep dport=51820 /proc/net/nf_conntrack'
```
- A line whose first tuple is `src=<the phone's public address> ... dport=51820`, marked
`[UNREPLIED]`: the datagrams reach the node and the server stays silent. It is the keys.
- No such line: the packets are not arriving. Look at the router forward, the DNS name, or the
device's network.
Then compare the keys **by copy and paste, never by eye and never retyped**: the server's public
half (the first command in Step 2's "Record it in your PASSWORD MANAGER") against the profile's
`PublicKey`, and the device's public key from the app against its stanza in `peers.conf`.
### Works on Wi-Fi, stalls on mobile data, handshake healthy
**Symptom.** `latest-handshakes` is recent and some traffic flows, but names or pages fail on
mobile data. The same device is fine on Wi-Fi.
**Cause.** Most likely a client MTU too large for the carrier's path: a tunnel MTU of `1420` means
outer packets of up to 1480 bytes. This is inferred from one before-and-after, on one phone and one
carrier (2026-11-09): `1420` failed and clearing the field fixed it. The path MTU itself was not
measured.
**Check.** In the Android WireGuard app, edit the tunnel, clear the `MTU` field or set it to `1280`,
save, then turn the tunnel off and on. A blank field means `1280` in the app's default userspace
backend (`GoBackend`: `getMtu().orElse(1280)`). On other clients set `MTU = 1280` in the profile.
`new-peer.sh` now generates `MTU = 1280` unless `WG_MTU` overrides it. A profile already on a device
does not change, so edit it by hand. If `1280` does not help, it is not the MTU: go back to
Step 6's check 3.
### The `away` profile cannot connect from home
**Symptom.** On the home network, `away` never handshakes.
**Cause.** Its Endpoint is `wan.home.keithjackson.dev`, the house's WAN address, and the router has
no hairpin NAT. WireGuard cannot use the Cloudflare Tunnel instead.
**Check.** Turn Wi-Fi off. If `away` handshakes on mobile data, the profile is fine: use the `home`
tunnel indoors, as described in
[Phone profiles: one tunnel for home, one for away](../platform/vpn-ingress/README.md#phone-profiles-one-tunnel-for-home-one-for-away).
## Revoking a device
@@ -293,10 +372,17 @@ top follow-up for this component.** Until then:
## Known limitations
* **No IPv6 in the tunnel.** A full-tunnel client with native IPv6 still reaches IPv6 sites outside
the tunnel. Functionally harmless; a privacy leak if hiding the client's network was the point.
* **Client IPs are not preserved** on the LAN side: everything arrives SNATed as the node's
address. A static route for `10.90.0.0/24` → the forwarded node, added on the router, would
preserve them — at the cost of another hand-maintained off-repo route.
* **No IPv6 in the tunnel.** What a client's IPv6 does then depends on the client. In the Android
app's default `GoBackend`, a config with one peer and a default route skips `allowFamily()`, so
Android should block IPv6 while the tunnel is up and apps fall back to IPv4. That is read from
upstream source, not observed on a device. A `--split` profile has no default route, so its IPv6
stays outside the tunnel. Expect `wg-quick` on a dual-stack Linux host to leave IPv6 outside too
(not tested).
* **Client IPs are only partly preserved.** LAN hosts and the internet see the node's address
(MASQUERADE). A static route for `10.90.0.0/24` → the forwarded node, added on the router, would
preserve them there, at the cost of another hand-maintained off-repo route. LoadBalancer VIPs are
different: Cilium handles the packet before MASQUERADE, so the source stays `10.90.0.x`, identity
`world` (BPF conntrack on prod-1, 2026-11-09, VIPs `.23` and `.24`). Any `loadBalancerSourceRanges`
must therefore list the tunnel subnet, as PiHole's do.
* **Rollouts briefly drop the forwarded node.** `maxUnavailable: 1`, so one node at a time; clients
re-handshake automatically (retry every 5s, rekey every 2 min).