fix(devcontainer): bake wg + qrencode into the image instead of splitting the VPN procedure across host and container
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:
1 parent
e35612dc7e
commit
8059297036
7 files changed
+212
-123
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user