The tunnel never came up on its first reconcile with a real key. One line of my
own manifest was the whole cause. Measured on prod 2026-10-06:
[#] ip link add dev wg0 type wireguard <- fine
[#] wg addconf wg0 /dev/fd/63 <- fine
[#] ip -4 address add 10.90.0.1/24 dev wg0 <- fine
[#] ip link set mtu 1420 up dev wg0 <- fine
[#] sysctl -q -w net.ipv4.ip_forward=1
sysctl: permission denied on key "net.ipv4.ip_forward"
[#] ip link delete dev wg0
**** Tunnel failed, will stop all others! ****
Not a missing capability: with hostNetwork the runtime mounts /proc/sys
READ-ONLY (confirmed in the pod's /proc/mounts), because those sysctls are the
HOST's globals. CAP_NET_ADMIN cannot lift a read-only mount, so the write can
never succeed here -- and wg-quick treats a failed PostUp as fatal, so it
deleted the interface it had just built.
The line was never needed. node_baseline already sets ip_forward=1, and reading
it from the pod returns 1. My header comment said exactly that ("so no pod
sysctl is needed") and then emitted the sysctl anyway, justified as "re-asserted
so this component does not silently depend on another role's sysctl file". That
defensive re-assertion is the entire outage. In a hostNetwork pod: VERIFY host
state, never write it.
Replaced with a read in the init container -- `grep -qx 1
/proc/sys/net/ipv4/ip_forward` -- failing closed with a message naming the
node_baseline role. Still fail-closed on purpose: a tunnel that comes up unable
to forward satisfies every probe here and silently drops all client traffic.
Also documents, in the manifest and the runbook, that the image's `chown:
Operation not permitted` run and its "we will not provide support for it"
warning are expected and harmless -- capabilities are dropped to NET_ADMIN so
its s6 init has no CAP_CHOWN, /config is a writable tmpfs, everything runs as
root, and wg-quick proceeds (it did; the failure was 30 lines later).
Three other unknowns closed by the same reconcile, now recorded in the README:
the kernel module reaches the pod (ip link add type wireguard works, no
CAP_SYS_MODULE needed); allowPrivilegeEscalation:false does NOT break the s6
init; and the image's iptables is nf_tables (xtables-nft-multi) matching Debian
13, which closes the worst documented fear -- rules landing in a table the
kernel never consults.
No sensitive paths; merges on a green gate with no override.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
7.4 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-06 | fix/vpn-ingress-ip-forward | The VPN tunnel never came up — a hostNetwork pod cannot write net.ipv4.ip_forward, and wg-quick treats a failed PostUp as fatal |
What changed
The remote-access VPN reconciled for the first time with a real server key (#60 then #62) and the tunnel did not come up. One line in my own manifest was the whole cause.
Measured in the wireguard container on prod, 2026-10-06:
[#] ip link add dev wg0 type wireguard
[#] wg addconf wg0 /dev/fd/63
[#] ip -4 address add 10.90.0.1/24 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] sysctl -q -w net.ipv4.ip_forward=1
sysctl: permission denied on key "net.ipv4.ip_forward"
[#] ip link delete dev wg0
**** Tunnel /config/wg_confs/wg0.conf failed, will stop all others! ****
Everything up to that point worked. The cause is not a missing capability. With
hostNetwork: true the runtime mounts /proc/sys read-only in the container — confirmed in the
pod's own /proc/mounts: proc /proc/sys proc ro,nosuid,nodev,noexec,relatime — precisely because
those sysctls are the host's globals and a container must not rewrite them. CAP_NET_ADMIN cannot
lift a read-only mount, so this can never succeed from here. And wg-quick treats any failed
PostUp as fatal, so it deleted the interface it had just finished building.
The line was never needed. node_baseline writes net.ipv4.ip_forward=1 to
/etc/sysctl.d/99-k3s-cilium.conf, and reading the value from inside the pod returns 1. I had
written the header comment correctly — "ip_forward is ALREADY 1 on these nodes … so no pod sysctl
is needed" — and then emitted the sysctl anyway, justified in its own comment as "re-asserted so
this component does not silently depend on another role's sysctl file surviving". That defensive
re-assertion is the entire outage. The lesson is narrow and reusable: in a hostNetwork pod,
verify host state, never write it.
Fix:
platform/vpn-ingress/daemonset.yaml: thePostUp = sysctl …line is gone, replaced by a read in the init container —grep -qx 1 /proc/sys/net/ipv4/ip_forward— which fails closed with a message naming thenode_baselinerole. Reading/proc/sysis permitted; only writing is not. Fail-closed still matters: a tunnel that comes up but cannot forward would satisfy every probe in this component and silently drop every client's traffic.- The header bullet that said "no pod sysctl is needed" now also says do not attempt one, with the mechanism, so the next author does not re-add it for the same reason I did.
- The
securityContextcomment claimed NET_ADMIN was for "create the wg0 link, set the host sysctl, and write the netfilter rules". The middle clause was false; corrected. - The image's
chown: … Operation not permittedstartup run — plus its own "Permissions could not be set … we will not provide support for it" — is now documented as expected and harmless, in both the manifest and the runbook, so nobody chases it. Capabilities are dropped toNET_ADMINonly, so its s6 init has noCAP_CHOWN;/configis a writable tmpfs, everything runs as root, andwg-quickproceeds past it (as it did here — the failure was 30 lines later). runbooks/vpn-remote-access.md: a "what a healthy start looks like" block with the real log lines;ip_forward is 0added to the init-failure table; and the note that0/1 RunningwithStartup probe failed: Unable to access interface: No such deviceis the signature ofwg-quicktearing the tunnel down — read the[#]line it died on.platform/vpn-ingress/README.md: a table of what the first reconcile settled (below).
No sensitive paths: platform/vpn-ingress/**, runbooks/** and decisions/** only. This merges on
a green gate with no override.
What I verified
Live prod, read-only, 2026-10-06. Four questions this component's design rested on are now measured rather than assumed — three went the right way, one was the defect above.
- The GitOps path is proven end to end.
Kustomization vpn-ingresslastAppliedRevision: main@sha1:cf7d5e4equals the GitRepository artifact revision. The Secret carrieskustomize.toolkit.fluxcd.io/name: vpn-ingress, so Flux delivered it. - SOPS decryption works, proven by digest rather than by eye. The value in
platform/vpn-ingress/wg-server-key.sops.yamlonmain, decrypted, is 44 bytes, is not the placeholder, andwg pubkeyaccepts it. Its SHA-256 equals the SHA-256 of the live Secret's decodeddata.privatekey(d7c8f8de6f252649…both sides). No key material was printed at any point — lengths, booleans and digests only. - The
wg pubkeyguard fired correctly, and itsENC[branch was never needed. During the kubelet window before the updated Secret propagated, all three pods reported "It is still the committed REPLACE-ME PLACEHOLDER" — the guard distinguishing a stale/unfilled value from ciphertext, exactly as designed. It cleared on its own once the mount refreshed; a transientREPLACE-MECrashLoop for a minute or two after the key PR merges is expected, not a fault. - The kernel module reaches the pod:
ip link add dev wg0 type wireguardsucceeded and/proc/moduleslistswireguard. So thenode_baselineaddition works andCAP_SYS_MODULEis correctly not required. allowPrivilegeEscalation: falsedoes NOT break the image's s6 init — the container reacheswg-quicknormally. The flag stays; the fear recorded in #55 is closed.- The iptables backends MATCH, which was my single biggest documented doubt. The image ships
iptables v1.8.13 (nf_tables)via/usr/sbin/xtables-nft-multi, and Debian 13 is nf_tables. The nightmare case — rules accepted into a table the kernel never consults, so handshakes succeed and every forwarded connection hangs — cannot occur on this pairing. net.ipv4.ip_forwardis1in the host netns the pod shares, and/proc/sysisrothere. Both read directly from the pod.- Toolbox gate from an isolated worktree: rc 3, with
spec-immutability,decisions-log,lint,l0,render-golden,l1andapproval-policyall pass; rc 3 is the two environmental skips (gitea-settings, conformance L2/L3). Not green. Reported as rc 3.
What I did NOT verify
- That the tunnel now comes up. This fix has not reconciled. The next reconcile is the test, and
its success criterion is specific: pods
1/1 Running, andwg showreportinginterface: wg0withlistening port: 51820. - That the MASQUERADE and FORWARD rules actually apply. They are the next statements after the
one that failed, so they have never executed. The backends matching (above) removes the worst
failure mode but proves nothing about the rules themselves. First check after the pods go Ready:
iptables -t nat -S POSTROUTING | grep 10.90.0.0/24on a node. - Anything about client traffic. Still zero peers enrolled, still no WAN port-forward, still no off-LAN vantage point. The VPN has carried no packet.
- Whether the transient
REPLACE-MEwindow has an upper bound. It cleared within ~2 minutes here; I did not determine the kubelet refresh behaviour, so a longer stall after a key change is uncharacterised. - The
sops sethand-off command, still. The operator filled the key in by their own route (#62), so the exact quoting I documented insidedev.sh run bash -cremains unexercised.