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]>
19 KiB
platform/vpn-ingress — WireGuard remote access (north-south INGRESS)
Lets the operator's own devices — Android, Linux, Windows, macOS, iOS — dial in from anywhere on
the internet and behave as though plugged into the home LAN: full reach to 192.168.2.0/24 and
egress out the house WAN.
This is not
platform/vpn-egress. They are opposite directions and share nothing but the word "VPN".vpn-egresspushes pods out to the internet through a PIA tunnel (ADR-0015, R-NET-05/06/07). This component lets remote clients in. Changing one never affects the other.
| Transport | WireGuard, udp/51820, kernel implementation |
| Where it runs | hostNetwork DaemonSet — one warm listener on every node |
| Client subnet | ${WG_CLIENT_CIDR} = 10.90.0.0/24; .1 is the server, .2–.254 devices |
| Authorisation | a device's public key is in peers.conf, or it cannot connect |
| The one secret | the server's private key — SOPS-encrypted in this tree, wg-server-key.sops.yaml |
| Exposure | one hand-made WAN port-forward on the SOHO router, off-repo |
| Enrolling a device | infra/vpn-ingress/new-peer.sh |
| Procedure / bring-up | runbooks/vpn-remote-access.md |
Why WireGuard
The requirement names four client platforms. WireGuard is the only option that has a first-party client on all of them (official apps for Android, iOS, Windows and macOS; in-kernel on Linux since 5.6, so a Linux client needs no userspace daemon at all). It is also the smallest thing to expose: one UDP port, no cipher negotiation, no PKI, no user database, no TLS stack.
OpenVPN would have needed a CA, per-device certificates and a revocation list — three pieces of long-lived state this repo would then have to protect and rotate. IKEv2/IPsec has native clients but is painful to run in a container and brittle behind double NAT. Neither buys anything here.
Why this is safe to expose to the whole internet
The honest answer is not "because it is encrypted" — it is the handshake's failure mode:
An unauthenticated packet gets no reply at all. WireGuard's first handshake message carries a MAC keyed with the responder's own public key. If the sender does not already know it, the packet is dropped silently — no error, no reset, no banner, nothing a scanner can fingerprint. From the outside, udp/51820 is indistinguishable from a closed port. There is no login prompt to brute-force, no version string to match against a CVE, and no amplification to abuse.
That is the whole security argument, and it is why the exposed port carries no IP allowlist (which
would also have made roaming clients unusable — a phone's address changes with every cell tower).
Compare apps/wave-0/pihole-dns.yaml, which does pin loadBalancerSourceRanges to the LAN and
this tunnel's client subnet: a resolver answers anybody, so it must be scoped by address. WireGuard
answers nobody.
What it does not protect against is a stolen device — anyone holding a device's private key is that device. That is what revocation is for: delete the stanza, merge, done (~1 min).
Architecture, and the one decision that drives everything
phone on 5G SOHO router k3s-prod-N (host netns)
┌──────────────┐ udp/51820 ┌───────────────┐ forward ┌────────────────────────────┐
│ WireGuard app│─────────────▶│ WAN 51820 ──▶ │───────────▶│ wg0 10.90.0.1/24 │
│ 10.90.0.2/32 │ over the │ node IP:51820 │ │ decrypt, then FORWARD │
└──────────────┘ internet └───────────────┘ │ SNAT (LAN), eBPF LB (VIPs)│
└─────────────┬──────────────┘
│ translated per destination (below)
┌─────────────────────────────────┼──────────────────────┐
▼ ▼ ▼
LAN 192.168.2.0/24 Gateway VIP .20 (16 web UIs) WAN / internet
(NAS, router, printers) PiHole DNS VIP .23
hostNetwork: true is required, not convenient
This is the only genuinely interesting decision in the component, and it was forced by a measured property of this cluster rather than chosen for convenience.
In Cilium, what a packet may reach is decided by the security identity of its source. A
WireGuard server running as an ordinary pod must SNAT client traffic to its own pod IP (Cilium drops
a packet whose source is not the endpoint's own address), so every remote client would have arrived
everywhere wearing that pod's identity. On 2026-10-04 this cluster measured what that costs
(decisions/2026-10-04-plex-advertise-plexdirect.md): a request to the shared Gateway VIP from
inside a pod returns 403 Access denied from Envoy for all 16 Gateway hostnames, because Cilium's
Gateway preserves the original source identity and enforces the destination's ingress policy —
and every app admits reserved:ingress plus named peers, never an arbitrary pod. LAN browsers work
only because they arrive as reserved:world / reserved:host.
So the pod-networked version of this component would have produced a tunnel that came up healthy,
reported connected peers, routed packets — and 403'd every single cluster web UI: plex, gitea,
keycloak, nextcloud, grafana, headlamp, the apex. *.home.keithjackson.dev would not even have
resolved, because apps/wave-0/pihole-dns.yaml's ingress policy deliberately omits the cluster
entity. A VPN that looks up and is useless is the worst available outcome, and nothing in the
manifests would have hinted at it.
In the host netns there is no pod identity to carry, which is what avoids the 403s. Clients do
not, however, all arrive SNATed to the node. Measured 2026-11-08: Cilium counts wg0 among its
kube-proxy-replacement devices (cilium-dbg status: eno1, wg0, tun0), so what a packet looks
like depends on where it is going:
| destination | handled by | source the destination sees |
|---|---|---|
| LAN hosts, the internet | kernel FORWARD, then the iptables MASQUERADE in daemonset.yaml |
the node's address |
LoadBalancer VIPs (Gateway .20, PiHole .23, ...) |
Cilium's eBPF load balancer, as the packet enters from wg0, before MASQUERADE |
10.90.0.x at the VIP's source-range check, identity world |
The second row is why PiHole's loadBalancerSourceRanges has to list ${WG_CLIENT_CIDR} as well as
the LAN: left LAN-only, tunnel DNS is predicted to die with DROP_NOT_IN_SRC_RANGE. That prediction
and the row's source column come from reading Cilium's datapath source; no peer has ever completed
a handshake, so neither has been observed live. The Gateway VIP carries no source ranges and needs
no change. Three further problems vanish with the host netns:
net.ipv4.ip_forwardis already 1 on these nodes (node_baselinewrites/etc/sysctl.d/99-k3s-cilium.conf). It is not in the kubelet's safe-sysctl allowlist, so a pod-netns build would have needed--allowed-unsafe-sysctlson all three kubelets — a node-restart change to the k3s service, to run a VPN.- No LoadBalancer VIP, so no
CiliumL2AnnouncementPolicylease lottery (the backend-blind announcer that cost this LAN an 8-day Minecraft outage — seeconformance/tests/l0-static/l2-announced-vips-serve-from-any-node) and noexternalTrafficPolicytrade-off to make at all. - No double encapsulation. Client UDP lands on the node's own NIC, so the underlay is a flat
1500-byte path, not Cilium's 1450-byte VXLAN. That is what makes
MTU = 1420correct on the server'swg0. It says nothing about a client's own path:new-peer.shdefaults client profiles to1280(see "Troubleshooting" inrunbooks/vpn-remote-access.md).
hostNetwork is permitted here, and not by oversight:
platform/kyverno/policies/restrict-host-namespaces.yaml matches namespaceSelector: app-namespace=true only, and names this exact carve-out in its own comment — "platform components
that do … e.g. Cilium, node-exporter … live in platform namespaces that do NOT carry the
app-namespace label". namespace.yaml is deliberately unlabelled.
The cost, stated rather than buried: a hostNetwork pod has no Cilium endpoint, so no
NetworkPolicy constrains this workload — not networkpolicy.yaml, not
anything. That file says so at length instead of implying a protection it cannot provide. The
boundary around this port is WireGuard's peer authentication, full stop.
Why a DaemonSet
The WAN forward names one node address, by hand, off-repo (same shape as the plex tcp/32400 forward). A single-replica Deployment would float on every reschedule and silently invalidate it. A DaemonSet instead puts an identical listener on all three nodes — every node answers the same keys — so losing the forwarded node is a one-line router change, with no cluster work and no re-issued client profiles. The two idle listeners cost ~15 MiB each.
A pinned LoadBalancer VIP was the considered alternative (it would make the forward immune to node
replacement). Rejected: it reintroduces the L2-announcement lease, SNAT, and an extra node-to-node
hop into the one path whose failure mode is "I am away from home and cannot get in". Fewer
translations win here.
The split that keeps authorisation reviewable
| half | where | why |
|---|---|---|
| peer public keys + addresses | peers.conf, plaintext in git, as a content-hashed configMapGenerator so an edit rolls the pods |
a public key grants nothing by itself, and this makes "who can get into my house" a reviewable PR diff with git log as its audit trail |
| the server's private key | wg-server-key.sops.yaml, SOPS-encrypted in git |
the only real secret (R-SEC-01, spec docs/07). Ships with a REPLACE-ME placeholder: an agent must not mint a long-lived private key (CLAUDE.md §6), so the operator sops sets it in |
The two are joined only at pod start, by the init container, into a medium: Memory emptyDir —
so the assembled config holding the private key never touches any node's disk and dies with the pod.
Both halves are therefore in git, which is the point: a cluster rebuild restores the server key with
everything else. An out-of-band kubectl create secret — the first shape this component shipped in,
corrected after review — would have put the one credential every enrolled device depends on outside
the GitOps tree, and losing it means re-enrolling every device, because each client profile pins this
key's public half.
Encrypting the peer register would have hidden exactly the change a reviewer most needs to see: a new line granting a new device access to the whole LAN.
What a client can reach — and how to narrow it
By design, everything a LAN machine can reach: all of 192.168.2.0/24, the shared Gateway's 16
hostnames, PiHole, the NAS, the router's admin UI, and the internet via the house WAN.
That includes the three k3s nodes themselves — 192.168.2.10/.11/.12, and therefore the
kube-apiserver on 6443, kubelet on 10250 and SSH on 22. An enrolled device is as privileged as a
machine sitting in the house, which is what "reach the entire local network" asks for; it is
recorded here because it is the largest consequence of saying yes to that, and because nobody should
discover it later. It is also why a lost device must be revoked promptly (delete its stanza,
merge; ~1 min to take effect).
To grant less, narrow it at the client profile first — new-peer.sh --split emits a
split-tunnel profile routing only the LAN and the tunnel subnet, so the device keeps its own
internet path. To enforce less server-side, add filtering to the PostUp chain in
daemonset.yaml (e.g. iptables -A FORWARD -i wg0 -d 192.168.2.10 -j DROP
before the accept rules) — the FORWARD chain there is the only enforcement point, since no
NetworkPolicy applies.
Phone profiles: one tunnel for home, one for away
A phone that needs the LAN-only apps runs full tunnel (AllowedIPs = 0.0.0.0/0, DNS = 192.168.2.23)
everywhere, so it needs two tunnels, each its own peer with its own key and address:
| tunnel | peer | address | Endpoint | use when |
|---|---|---|---|---|
away |
keith-phone |
10.90.0.2 |
wan.home.keithjackson.dev:51820 |
off the home network |
home |
keith-phone-home |
10.90.0.3 |
192.168.2.10:51820 |
on the home Wi-Fi |
wan. cannot work from inside the house. It is an unproxied A record holding the house's WAN
address (platform/externaldns/README.md), and the router has no hairpin NAT, so a LAN client that
sends to it never comes back in through the port-forward. The Cloudflare Tunnel is no way round: it
carries HTTP(S) to the Gateway and WireGuard is UDP to a node, so wan. has to stay DNS-only and
away has to keep using it.
home depends on k3s-prod-1. Its Endpoint is that node's address, with no failover: if
prod-1 is down, home cannot handshake even though the other two listeners are healthy. The fix
is client-side: edit the tunnel's Endpoint to 192.168.2.11 or .12, since every node answers the
same keys. No server change and no new key is needed. The same dependency applies to away if the
WAN forward points at prod-1.
Android allows one active VPN at a time, so switch tunnels by hand when leaving or arriving.
Operating it
- Enrol a device:
./infra/vpn-ingress/new-peer.sh <name> <octet>→ paste the printed[Peer]stanza intopeers.conf→ PR → merge. Phones scan a QR code; desktops import a.conf. - Revoke a device: delete its stanza from
peers.conf→ PR → merge. No other step; the key stops working as soon as the pods roll (the generator's content hash guarantees they do). - Is it up?
kubectl --context k3s-prod -n vpn-ingress get ds wireguard— three Ready. - Who is connected?
kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show(latest handshakeunder ~3 min means live). Never dumpwg showconf— it prints the private key. - Full bring-up and verification:
runbooks/vpn-remote-access.md.
Measured on prod, 2026-10-06 — what is now known rather than assumed
The first real reconcile settled four things this component's design rested on. Three went the right way; one was a defect in my own manifest.
| question | answer |
|---|---|
| Does the kernel module reach the pod? | Yes. ip link add dev wg0 type wireguard succeeds, and /proc/modules lists wireguard, after the node_baseline run. CAP_SYS_MODULE is correctly not needed. |
Does allowPrivilegeEscalation: false break the image's s6 init? |
No. The container reaches wg-quick normally. The flag stays. |
| Is the image's iptables backend compatible with the host's? | Yes — both nft. The image ships iptables v1.8.13 (nf_tables) via xtables-nft-multi, and Debian 13 is nf_tables. This was the documented worst fear (rules silently landing in a table the kernel does not consult); it is closed. |
Can the pod set net.ipv4.ip_forward? |
No, and it must not try. /proc/sys is mounted read-only under hostNetwork (proc /proc/sys proc ro,…), because those are the host's globals; sysctl -w fails with EPERM whatever the capabilities. A failed PostUp is fatal to wg-quick, so this one line took the whole tunnel down. The node already has it set to 1; the init container now verifies rather than writes. |
Prerequisites outside this directory
- The
wireguardkernel module on every node. Added tonode_baseline_kernel_modules(infra/ansible/roles/node_baseline/defaults/main.yml); the operator runs the role. The pod hasNET_ADMINbut deliberately notSYS_MODULE, so it cannot load the module itself — it CrashLoops with a clearwg-quickerror instead of quietly degrading to userspace WireGuard. - The server private key.
wg-server-key.sops.yamlis committed, encrypted, with aREPLACE-MEplaceholder, and both overlays already carry its decryption patch — so the wiring is complete and the only outstanding act is replacing the value (runbook step 2). Until then the DaemonSet CrashLoops with an explicit FATAL naming the file: the init container validates the value withwg pubkey, which rejects the placeholder, an empty value, a public key pasted by mistake, and a Secret that arrived as undecryptedENC[...]ciphertext. That last case is the one worth having a guard for — a missing overlay decryption patch applies cleanly and leaves every Flux condition green. - The WAN port-forward, udp/51820 → one node address. Hand-made on the SOHO router, off-repo, and the half CI can never check.
Not built (deliberate, with reasons)
- No metrics / no peer-level alerting. A WireGuard exporter would expose
wireguard_latest_handshake_secondsper peer — genuinely useful for spotting unexpected activity — but it needs a secondNET_ADMINcontainer in the pod that terminates the internet-facing tunnel, and that trade was not worth making unverified. Pod-down is already covered generically byKubeDeploymentReplicasMismatch/KubePodNotReadyfrom kube-prometheus-stack. This is the top follow-up. - No preshared keys. They would add a symmetric post-quantum hedge on top of the Noise
handshake — and would be a real secret, forcing
peers.confinto SOPS and costing the reviewability above. Revisit only if the threat model changes; if it does, the PSK belongs in thewg-server-keySecret. - No IPv6. The server offers no IPv6 tunnel address and
new-peer.shemits only0.0.0.0/0. What happens to a client's IPv6 then depends on the client. The Android app does not leak it: itsGoBackendskipsallowFamily()when the config is one peer with a default route, so Android blocks IPv6 for the VPN's duration and apps fall back to IPv4. Other clients are not covered by that code; expectwg-quickon a dual-stack Linux host to leave IPv6 outside the tunnel. Fix is anfd..ULA onwg0plus::/0in the client'sAllowedIPs; not done because nothing here needs it. - No in-cluster-service reach beyond what the LAN has. Clients reach cluster apps the way a LAN
browser does — through the Gateway VIP. Pod IPs and
10.43.0.0/16service IPs are not routed into the tunnel.