Operator correction: secrets are kept in source through sops. #55 shipped the WireGuard server private key as a Secret to `kubectl create` by hand. Two real costs, not just a convention break: * R-SEC-01 / spec docs/07 -- secrets live in source, SOPS-encrypted, and reach the cluster only through Flux. * It broke DR for the one credential that cannot be re-derived. A rebuild restores everything in git; it does not restore a hand-made Secret, and losing this key means re-enrolling EVERY device, because each client profile pins its public half. #55 papered over that with "keep a copy in your password manager". (Note secret-provenance did NOT forbid the original shape -- it scopes to app-namespace=true and vpn-ingress is deliberately a platform namespace. The rule broken was the written requirement, not an enforced control.) An agent still must not mint a long-lived private key (CLAUDE.md §6), so the file is committed ALREADY ENCRYPTED with `privatekey: REPLACE-ME`. That makes the wiring complete and green from the first commit -- resource line plus both overlay decryption patches -- leaving the operator exactly one act: replace the value. A commented-out resource to uncomment later is the "in git, no generator entry, reviewed, merged, still broken" shape this repo keeps hitting. The init guard is now a validation rather than an emptiness check: the value is piped through `wg pubkey`, which accepts only a real curve25519 private key, and the pod names which case it hit -- placeholder, empty, mangled, or (the silent one) still `ENC[` ciphertext because an overlay is missing its decryption patch, which otherwise applies cleanly with every Flux condition green. Second, independent finding that also fixes #55's step 2: `wg` is on the HOST only and `sops` is in the CONTAINER only, so `sops -d | wg pubkey` cannot run in one shell -- the command #55 documented could not have worked. Every step is now split by where it runs, and the written key is verified by comparing sha256 digests rather than printing anything. `qrencode` is on neither, so the QR step says so and gives the install command. Touches clusters/** and adds a **/*.sops.yaml, so approval-policy will block pending the operator's /approve-override. Deliberately not posted by me. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
14 KiB
vpn-remote-access — standing up the WireGuard remote-access VPN, and proving it
Status: the in-cluster half is shipped; the VPN is INERT until three manual steps are done.
All three are off-repo by necessity — a kernel module, a private key, and a router port-forward.
Alert: pod-down is covered generically (KubePodNotReady / KubeDeploymentReplicasMismatch).
There is no peer-level alerting — see "Why there is no connection alert". This is an operator
procedure, not an incident page.
Files: platform/vpn-ingress/ (namespace, peer register, DaemonSet, baseline policy),
clusters/base/vpn-ingress.yaml (its Flux tier), clusters/base/cluster-vars.yaml
(WG_CLIENT_CIDR), infra/ansible/roles/node_baseline/defaults/main.yml (the wireguard module),
infra/vpn-ingress/new-peer.sh (device enrolment).
Design rationale: platform/vpn-ingress/README.md — read "Why hostNetwork is required"
before changing anything about how this is wired.
Not to be confused with vpn-down / vpn-egress-stale, which are about platform/vpn-egress
(pods out through PIA). This page is remote clients in.
What this gives you
A device — Android, Linux, Windows, macOS, iOS — dials in over udp/51820 from anywhere and
behaves like a machine on the LAN: full reach to 192.168.2.0/24, the Gateway's web UIs, PiHole
DNS, and internet egress out the house WAN.
Authorisation is one thing only: the device's public key is in
platform/vpn-ingress/peers.yaml. An unlisted key gets no reply at all.
An enrolled device is as privileged as a machine sitting in the house — including the three k3s nodes (
192.168.2.10/.11/.12: apiserver 6443, kubelet 10250, SSH 22). That is what "reach the entire local network" means. Revoke a lost device promptly: delete its stanza, PR, merge.
Step 1 — load the wireguard module on every node (once)
The DaemonSet creates its wg0 link in the host netns with CAP_NET_ADMIN only; it is
deliberately not granted CAP_SYS_MODULE, so it cannot autoload the module. Without it
wg-quick fails outright and the pod CrashLoops.
# operator action (Ansible is not an agent action). Ansible lives ONLY in the pinned toolbox,
# and ansible.cfg is only picked up from infra/ansible, so cd there inside the container.
./infra/devcontainer/dev.sh run bash -c \
'cd /work/infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags system'
Three details in that command are load-bearing, not style:
-e k3s_hosts=k3s_prod.node-baseline.ymltargetsk3s_serversby default, which is the dev group — and dev was decommissioned, so an unqualified run does nothing useful. This is the targeting patterninfra/ansible/README.md"Targeting prod" documents for every playbook.--tags system, and never an untagged run. The two kernel-module tasks carry thesystemtag. An untagged run would also execute thediskstasks, which assert on block-device variables prod has deliberately left as placeholders (infra/PROD-PROVISIONING.md§2) — the playbook's own header warns about this.--tags systemis broader than the modules alone (it also re-asserts sysctls, iscsid, qemu-guest-agent and the swap masking) but every one of those is idempotent and already in its desired state on a live node.- It is a playbook, not a wrapper script. There is no
site.shin this repo;playbooks/site.ymlis the full bring-up chain and is the wrong tool here.
Verify on each node — the module is in-tree since kernel 5.6, so nothing is installed, only loaded:
# expected: a `wireguard` line, and the modules-load.d file naming it for the next boot
lsmod | grep '^wireguard'
grep -w wireguard /etc/modules-load.d/k3s-cilium-longhorn.conf
Step 2 — fill in the server key (once)
The key is SOPS-encrypted in this tree (R-SEC-01, spec docs/07), not created by hand:
platform/vpn-ingress/wg-server-key.sops.yaml. It is already committed — encrypted, with a
REPLACE-ME placeholder — and it is already listed in the component's resources: with a
decryption: patch in both overlays. Nothing needs wiring. You are only replacing one value.
Why in git rather than kubectl create secret: an out-of-band Secret puts the one credential every
enrolled device depends on outside the GitOps tree, where a cluster rebuild silently loses it — and
losing it means re-enrolling every device, because each client profile pins this key's public half.
MIND WHERE EACH COMMAND RUNS. The two tools are not in the same place (measured 2026-10-05):
wgis on the HOST only (/usr/bin/wg, absent from the dev container);sopsis in the CONTAINER only (/usr/local/bin/sops, absent from the host). You cannot pipesops -d … | wg pubkeyin one shell. The steps below are split accordingly and never put the key on a terminal.
1. HOST — generate it. Keep it at this path: it is where new-peer.sh looks by default to
derive the public half for each client profile.
umask 077
mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
wg genkey > ~/.config/wireguard-home/server.key
2. Store it in your PASSWORD MANAGER, together with its public half (safe to display):
wg pubkey < ~/.config/wireguard-home/server.key
This copy is the one that survives losing the cluster and the backups. Velero does capture this
namespace to R2 (its daily schedule excludes only monitoring and unifi), and the encrypted
file is in git — but the key itself cannot be regenerated without invalidating every client profile.
3. Write it into the encrypted file in place.
mkdir -p .cache && cp ~/.config/wireguard-home/server.key .cache/server.key
git check-ignore -v .cache/server.key # MUST print `.gitignore:11:*.key` — see the warning below
./infra/devcontainer/dev.sh run bash -c \
'cd /work && sops set platform/vpn-ingress/wg-server-key.sops.yaml \
"[\"stringData\"][\"privatekey\"]" "\"$(tr -d "\r\n" < /work/.cache/server.key)\""'
shred -u .cache/server.key
sops set edits the encrypted document directly, so no plaintext copy of it is ever written.
(sops platform/vpn-ingress/wg-server-key.sops.yaml opens an editor instead, if you prefer.)
KEEP THE
.keySUFFIX on the staged copy..gitignoreline 11 is*.key, and it is the only rule covering that path —.cache/itself is NOT ignored (git check-ignore -v .cache/serverkey.txtmatches nothing). A copy namedserver.pemorprivatekeyin that directory is committable, and the gate'sno-plaintext-secretsstage does not recognise a bare WireGuard key.
4. Verify you wrote the right bytes, without printing them — compare digests, not values:
./infra/devcontainer/dev.sh run bash -c \
'cd /work && sops -d --extract "[\"stringData\"][\"privatekey\"]" \
platform/vpn-ingress/wg-server-key.sops.yaml | tr -d "\r\n" | sha256sum'
tr -d '\r\n' < ~/.config/wireguard-home/server.key | sha256sum
The two digests must match. This is the only sanctioned decrypt of this file, and its output goes
straight into sha256sum — never onto a terminal (CLAUDE.md §6).
5. PR it, and merge. Flux rolls the DaemonSet and the pods leave CrashLoop.
What the pod tells you if this went wrong
The init container validates the value with wg pubkey before writing wg0.conf, and names the
specific failure in its log — so you never get a server that silently cannot complete a handshake:
| pod log says | meaning |
|---|---|
still the committed REPLACE-ME PLACEHOLDER |
step 3 not done (the expected fresh-install state) |
It is EMPTY |
the value was blanked |
still SOPS CIPHERTEXT (starts with ENC[) |
the Secret reached the cluster undecrypted — this environment's overlay is missing its vpn-ingress decryption patch. This is the dangerous one: the Secret applies cleanly and every Flux condition stays green |
wg pubkey rejected it with none of the above |
a mangled value, or a public key pasted by mistake |
Step 3 — forward udp/51820 on the router (once)
On the SOHO router at 192.168.2.1, forward WAN udp/51820 to one node address, e.g.
192.168.2.10:51820. This is hand-made and off-repo; it is the half CI can never check.
Pick any node — all three run an identical listener. Note which one you chose here:
# WAN udp/51820 -> 192.168.2.__ (k3s-prod-_) set <date>
Node loss = failover is this one line. Re-point the forward at another node address; every device keeps working with the same profile, because all three nodes answer the same keys.
Do not also expose this on tcp, and do not change the port hoping for obscurity — WireGuard is already silent to unauthenticated probes (README, "Why this is safe to expose").
Step 4 — confirm the listener is healthy
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n vpn-ingress get ds wireguard -o wide'
# expected: DESIRED 3 CURRENT 3 READY 3
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show'
# expected: `interface: wg0`, `listening port: 51820`, and (before step 5) NO peers.
A zero-peer interface that is Ready is the correct state before any device is enrolled: it listens, and refuses every handshake because no key is trusted.
The readiness/liveness probe is wg show wg0, and that is load-bearing rather than cosmetic: a
port probe cannot distinguish "wg0 never came up" from "working", because WireGuard is silent to
unauthenticated traffic either way. If the pod is Ready, the interface exists.
Step 5 — enrol a device
./infra/vpn-ingress/new-peer.sh pixel-8 2 # full tunnel (LAN + WAN egress)
./infra/vpn-ingress/new-peer.sh thinkpad 3 --split # split tunnel (LAN only)
It generates the keypair on your machine, writes a 0600 client profile outside the checkout
(it refuses to write inside the git checkout), prints a QR code, and prints the [Peer] stanza to
add.
qrencodeis installed neither on this workstation nor in the dev container (measured 2026-10-05), so the QR path needs it first:sudo dnf install qrencode(orapt install qrencode). Without it the script says so plainly and leaves you to move the.confto the device by another route — fine for a desktop, awkward for a phone.
Then:
- Paste the stanza into
platform/vpn-ingress/peers.yamlunderdata.peers.conf, keeping the# <name> — <owner>, enrolled <date>comment.wg showcan only ever print the key, so that comment is the only record of which stanza belongs to which device. - PR → merge. Flux rolls the DaemonSet (~1 min).
- Get the profile onto the device (QR scan for phones; "Import tunnel from file" on
Windows/macOS;
/etc/wireguard/<name>.conf+wg-quick@<name>on Linux). - Delete the profile from your machine:
shred -u <path>. It is the only copy of that device's private key — the server never sees it and it cannot be re-derived.
Until the PR is merged the device handshakes and gets silence, exactly like an unknown key.
Step 6 — verify it end to end (from OFF the LAN)
This is the only honest test, and it cannot be run from inside the house. Put the phone on cellular, with Wi-Fi off, and check all four — each proves a different hop:
| # | Check | Proves |
|---|---|---|
| 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 |
| 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:
./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.
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 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.
Revoking a device
Delete its [Peer] stanza from peers.yaml, PR, merge. Nothing else — the key stops working on the
next reconcile. Never re-use that address octet: an old profile would silently start working
again as a different device.
Why there is no connection alert
A WireGuard exporter would publish wireguard_latest_handshake_seconds per peer and make
"something connected at 03:00" alertable. It needs a second NET_ADMIN container inside the pod
that terminates the internet-facing tunnel, and that was not worth shipping unverified. It is the
top follow-up for this component. Until then:
- pod-down is covered generically by kube-prometheus-stack's
KubePodNotReady/KubeDeploymentReplicasMismatch; - "who is connected" is an on-demand question —
wg show, above; - never
wg showconf— it prints the server's private key.
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. - 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).