fix(devcontainer): bake wg + qrencode into the image instead of splitting the VPN procedure across host and container
gate / conformance (pull_request) Has been skipped
gate / gate (pull_request) Successful in 1m53s

Operator correction: "sop, wg, and any other tool requirement is supposed to be
baked into the devcontainer."

My previous commit discovered that `wg` existed only on the host, `sops` only in
the container, and `qrencode` on neither -- and fixed it the wrong way, by
splitting every step of the key procedure across the boundary and telling the
operator to `dnf install qrencode` on their workstation. That breaks CLAUDE.md §5
and the devcontainer README's "Don't install the toolchain on the host", produces
a procedure that cannot be reproduced in CI, and is miserable to follow.

wireguard-tools + qrencode are now distro packages in the Containerfile. Both are
in Fedora 44's repos (wireguard-tools 1.0.20260223 -- the same upstream version as
the image this component runs -- and qrencode 4.1.1), unpinned like every other
dnf package there: the pinned thing is the Fedora base, and neither is a gate tool
whose version changes what green means.

Three things got SIMPLER rather than merely relocated, which is the tell that the
rule was right:

  * Step 2 is one pipeline in one shell -- `sops set ... "$(wg genkey)"` writes the
    key straight into the already-encrypted document. No staged copy in .cache, no
    `*.key`-suffix warning, no sha256 comparison to prove the right bytes landed.
  * new-peer.sh derives the server public key from wg-server-key.sops.yaml itself
    (`sops -d --extract | wg pubkey`), so there is no local key copy to keep in
    step with the encrypted one, and no magic host path it must live at.
  * `.cache/` is gitignored at the repo root (mirroring infra/ansible/.gitignore),
    giving the container a persistent non-committable scratch dir. new-peer.sh
    writes profiles to .cache/wg-peers/ -- necessary because the repo bind-mount is
    the container's only persistent read-write mount, so a profile in /tmp there is
    gone before it can reach a desktop. Its blanket "refuse to write inside the
    checkout" guard is now a `git check-ignore` test of the real property.

Verified by execution, not reasoning: the image rebuilds; wg/qrencode/sops all
resolve in it; `sops -d | wg pubkey` runs in one shell; new-peer.sh refuses the
REPLACE-ME placeholder with an actionable message; and a full run with a throwaway
server key renders a correct profile at 0600 under a 0700 gitignored dir, prints
the QR, and refuses a non-ignored destination inside the repo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-10-06 00:48:32 -05:00
1 parent e35612dc7e
commit 8059297036
7 files changed
+212 -123

No files matched your search

+41 -54
View File
@@ -73,67 +73,55 @@ Why in git rather than `kubectl create secret`: an out-of-band Secret puts the o
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):
> `wg` is on the **HOST** only (`/usr/bin/wg`, absent from the dev container); `sops` is in the
> **CONTAINER** only (`/usr/local/bin/sops`, absent from the host). You cannot pipe
> `sops -d … | wg pubkey` in one shell. The steps below are split accordingly and never put the key
> on a terminal.
Everything below runs **inside the dev container** — `wg`, `sops` and `qrencode` are all baked into
the image (CLAUDE.md §5: the toolchain lives only in the pinned container; don't install it on the
host). One shell, one toolchain.
**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.
**1. Generate the key straight into the encrypted file.** One pipeline: `wg genkey` makes it,
`sops set` writes it into the already-encrypted document in place. The plaintext never lands on
disk and never reaches a terminal.
```bash
umask 077
mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
wg genkey > ~/.config/wireguard-home/server.key
./infra/devcontainer/dev.sh run bash -c '
cd /work
umask 077
sops set platform/vpn-ingress/wg-server-key.sops.yaml \
"[\"stringData\"][\"privatekey\"]" "\"$(wg genkey)\""
'
```
**2. Store it in your PASSWORD MANAGER**, together with its public half (safe to display):
**2. Record it in your PASSWORD MANAGER.** Derive both halves from the file you just wrote:
```bash
wg pubkey < ~/.config/wireguard-home/server.key
# the PUBLIC half — safe to display, and what every client profile carries
./infra/devcontainer/dev.sh run bash -c 'cd /work && sops -d --extract "[\"stringData\"][\"privatekey\"]" \
platform/vpn-ingress/wg-server-key.sops.yaml | wg pubkey'
# the PRIVATE half — paste into the password manager, then clear your scrollback
./infra/devcontainer/dev.sh run bash -c 'cd /work && sops -d --extract "[\"stringData\"][\"privatekey\"]" \
platform/vpn-ingress/wg-server-key.sops.yaml'
```
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.
The second command is the one place this key is deliberately shown. The encrypted file is in git and
Velero captures this namespace to R2, so the password-manager copy is belt-and-braces — but it is
the only copy that survives losing the cluster *and* the backups, and the key cannot be regenerated
without invalidating every client profile.
**3. Write it into the encrypted file in place.**
**3. Confirm the value is a real key**, without printing it — if `wg pubkey` prints a key, the
value is valid; if it errors, it is still wrong:
```bash
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
./infra/devcontainer/dev.sh run bash -c 'cd /work && sops -d --extract "[\"stringData\"][\"privatekey\"]" \
platform/vpn-ingress/wg-server-key.sops.yaml | wg pubkey >/dev/null && echo "valid WireGuard 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.)
That is the same check the pod's init container runs, so a pass here predicts a pass there.
> **KEEP THE `.key` SUFFIX** on the staged copy. `.gitignore` line 11 is `*.key`, and it is the
> **only** rule covering that path — `.cache/` itself is NOT ignored
> (`git check-ignore -v .cache/serverkey.txt` matches nothing). A copy named `server.pem` or
> `privatekey` in that directory is **committable**, and the gate's `no-plaintext-secrets` stage
> does not recognise a bare WireGuard key.
**4. PR it, and merge.** Flux rolls the DaemonSet and the pods leave CrashLoop.
**4. Verify you wrote the right bytes, without printing them** — compare digests, not values:
```bash
./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.
If you would rather use an editor than `sops set`:
`./infra/devcontainer/dev.sh run sops platform/vpn-ingress/wg-server-key.sops.yaml` (replace
`REPLACE-ME`, save, exit — it re-encrypts on write).
### What the pod tells you if this went wrong
@@ -186,18 +174,17 @@ unauthenticated traffic either way. If the pod is Ready, the interface exists.
## Step 5 — enrol a device
```bash
./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)
# in the dev container, like everything else
./infra/devcontainer/dev.sh run bash -c './infra/vpn-ingress/new-peer.sh pixel-8 2' # full tunnel
./infra/devcontainer/dev.sh run bash -c './infra/vpn-ingress/new-peer.sh thinkpad 3 --split' # 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.
It derives the server's public key from `wg-server-key.sops.yaml` itself, so there is no local key
copy to keep in step with anything. Profiles land in `.cache/wg-peers/` — gitignored, and persistent
because the repo is the container's only read-write mount.
> **`qrencode` is 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` (or `apt install
> qrencode`). Without it the script says so plainly and leaves you to move the `.conf` to the
> device by another route — fine for a desktop, awkward for a phone.
It generates the device's keypair, writes a `0600` client profile, prints a QR code, and prints the
`[Peer]` stanza to add. It refuses to write a profile anywhere git does not ignore.
Then: