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
+208 -119

No files matched your search

+8
View File
@@ -37,6 +37,14 @@ coverage/
infra/tofu/backend.hcl
infra/tofu/.r2.env
# Operator scratch, writable from inside the dev container (the repo is its only persistent
# read-write mount). Holds things that must never be committed: a staged copy of the SOPS age or
# WireGuard key, rendered VPN client profiles (infra/vpn-ingress/new-peer.sh writes here by
# default). `infra/ansible/.gitignore` already does this for its own .cache/; this is the same
# convention at the root. Do NOT rely on `*.key` alone to protect a staged key — that only covers
# one filename shape, which is why this rule exists.
.cache/
# Claude Code local scratch: agent worktrees + session state. Nothing here is tracked;
# ignoring it keeps `git add -A` from staging embedded worktree repos and keeps the lint
# stage (which scans untracked files) from tripping on .claude/worktrees/.
+50 -9
View File
@@ -63,14 +63,41 @@ Files:
- `platform/vpn-ingress/README.md`, `runbooks/vpn-remote-access.md` — step 2 rewritten.
- `infra/vpn-ingress/new-peer.sh` — stale "step 1" pointer.
**A second finding, which changes the procedure independently of SOPS: `wg` and `sops` are not in
the same place.** Measured: `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). So
`sops -d … | wg pubkey` cannot be run in one shell, and #55's step 2 — which assumed it could —
would have failed. The runbook and the `.example` now split every command by where it runs, and
verify the written key by comparing **sha256 digests** of the decrypted value and the operator's
local copy, which never puts key material on a terminal. `qrencode` is on neither, so the QR
instruction now says so and gives the install command.
**A second finding: `wg` and `sops` were not in the same place — and the first fix for that was
also wrong.** Measured: `wg` existed only on the host (`/usr/bin/wg`), `sops` only in the dev
container, `qrencode` on neither. So `sops -d … | wg pubkey` could not run in one shell, and #55's
step 2 — which assumed it could — would have failed.
My first fix was to **split every step by where it runs**, and to tell the operator to
`dnf install qrencode` on their workstation. **Operator correction: "sop, wg, and any other tool
requirement is supposed to be baked into the devcontainer."** That is CLAUDE.md §5 and the
devcontainer README's "Don't install the toolchain on the host", and the split fix broke the
invariant rather than honouring it — it also produced a procedure that could never be reproduced in
CI, and which no one should have to follow.
**The real fix:** `wireguard-tools` and `qrencode` are now distro packages in
`infra/devcontainer/Containerfile`, alongside ansible/openssh/diffutils. Both are in Fedora 44's
repos (`wireguard-tools` 1.0.20260223 — the same upstream version as the container image this
component runs — and `qrencode` 4.1.1), unpinned like every other dnf package there, because 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 operator's rule
was the right one:
1. **Step 2 is now one pipeline in one shell:** `sops set … "\"$(wg genkey)\""` generates the key
straight into the already-encrypted document. The plaintext never lands on disk, never reaches a
terminal, and there is no staged copy to shred. The previous version needed a copy in
`.cache/server.key`, a `git check-ignore` warning about the `*.key` suffix being the only thing
protecting it, and a sha256 comparison to prove the right bytes arrived. All of that is gone.
2. **`new-peer.sh` now derives the server's 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. It previously required the operator to keep the private key at a magic host path.
3. **`.cache/` is now gitignored at the repo root** (mirroring `infra/ansible/.gitignore`), so the
container has a persistent, non-committable scratch directory. `new-peer.sh` writes client
profiles to `.cache/wg-peers/` by default — necessary because the repo bind-mount is the
container's ONLY persistent read-write mount, so a profile written to `/tmp` in there is gone
before it can be moved to a desktop. Its old blanket "refuse to write inside the checkout" guard
is replaced by `git check-ignore`, which tests the actual property instead of a proxy.
**SENSITIVE PATHS — this PR touches `clusters/**` (two overlays) AND adds a `**/*.sops.yaml`, so
`ci/approval-policy.sh` will BLOCK it.** It needs the operator's `/approve-override <reason>` as a
@@ -91,7 +118,21 @@ PR **review** of type "Comment". I have deliberately not posted it (CLAUDE.md §
- **`kubectl kustomize platform/vpn-ingress` builds** and now emits five objects — Namespace,
ConfigMap, **Secret**, DaemonSet, NetworkPolicy — with `${WG_CLIENT_CIDR}` still an unexpanded
token for Flux.
- **`wg` / `sops` / `qrencode` locations**, measured on both host and container, as described above.
- **The tool gap, measured on both host and container before the fix**: `wg` host-only, `sops`
container-only, `qrencode` on neither.
- **Both packages exist in the image's repos**, queried from inside the running container:
`wireguard-tools` 1.0.20260223 (repo `updates`) and `qrencode` 4.1.1 (repo `fedora`).
- **The image REBUILDS with them and both tools resolve inside it** —
`./infra/devcontainer/dev.sh build` succeeded, and `wg --version` / `qrencode --version` /
`sops --version` all answer in the rebuilt container. This is the claim the change rests on, so
it was executed rather than reasoned.
- **`new-peer.sh`'s new SOPS path fails correctly against the placeholder.** Run in the rebuilt
container it refused with "could not derive the server public key … it still holds the REPLACE-ME
placeholder", which is both the expected pre-key behaviour and a live test of the
`sops -d | wg pubkey` pipeline the whole design now depends on.
- **`.cache/` is ignored regardless of filename** now: `git check-ignore -v .cache/anything.conf`
and `.cache/serverkey.txt` both match `.gitignore:46:.cache/`. Before this change only `*.key`
matched, which is the fragility the previous runbook had to warn about.
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two
environmental skips (`gitea-settings` — no local token; conformance L2/L3 — no cluster).
+14
View File
@@ -32,9 +32,23 @@ ARG TARGETARCH=amd64
# Distro packages: libvirt client libs (harmless disk-image utilities), ansible, ssh,
# disk-image + cloud-init seed tooling, python3-pip (for the pip-installed test tools),
# diffutils (provides `diff`, used by L1 golden render checks), and common utilities.
#
# wireguard-tools + qrencode serve the REMOTE-ACCESS VPN (platform/vpn-ingress):
# * wireguard-tools gives `wg genkey` / `wg pubkey`, needed to mint the server key and to derive
# every client profile's `[Peer] PublicKey` (infra/vpn-ingress/new-peer.sh).
# * qrencode renders a client profile as a QR code, which is how a PHONE gets enrolled.
# They are here rather than on the operator's host because this image is THE toolchain
# (CLAUDE.md §5; README "Don't install the toolchain on the host"). That rule was briefly broken
# by a runbook that told the operator to run `wg` on the host and `sops` in here, and then split
# every step across the boundary to work around it — a procedure nobody should have to follow, and
# one that cannot be reproduced in CI. Both tools in one image means `sops -d … | wg pubkey` is
# just a pipeline again.
# Unpinned on purpose, like every other dnf package here: the Fedora base is what is pinned
# (FEDORA_VERSION), and these are not gate tools whose version changes what green means.
RUN dnf -y install --setopt=install_weak_deps=False \
libvirt-client qemu-img cloud-utils genisoimage openssh-clients \
ansible-core python3-libvirt python3-pip diffutils \
wireguard-tools qrencode \
git make jq unzip tar gzip xz which ca-certificates curl \
&& dnf clean all
+1
View File
@@ -14,6 +14,7 @@ baseline, and the *same* binaries CI uses.
| Provisioning | OpenTofu (`bpg/proxmox` provider), `ansible-core`, `openssh-clients` |
| GitOps / cluster | `flux`, `kubectl`, `helm` |
| Secrets | `sops`, `age` + `age-keygen` |
| VPN (`platform/vpn-ingress`) | `wg` (wireguard-tools) — mint the server key, derive client public keys; `qrencode` — render a client profile as a phone-scannable QR |
| **CI gate** | `yamllint`, `check-jsonschema`, `kyverno` CLI, `kyverno-json`, `chainsaw`, `yq`, `diffutils`, `make` |
| Misc | `qemu-img`, `cloud-utils`/`genisoimage`, `libvirt-client` (disk-image utilities only — leftovers from the libvirt era, harmless) |
+65 -21
View File
@@ -2,10 +2,24 @@
# =================================================================================================
# new-peer.sh — enrol one device on the remote-access VPN (platform/vpn-ingress).
#
# Generates a WireGuard keypair ON THIS MACHINE, renders the device's client profile, optionally
# prints a QR code for a phone, and prints the one `[Peer]` stanza to paste into
# platform/vpn-ingress/peers.yaml. The private half NEVER leaves this machine: it is not sent to the
# cluster, not committed, and not written to any log.
# Generates a WireGuard keypair, renders the device's client profile, prints a QR code for a phone,
# and prints the one `[Peer]` stanza to paste into platform/vpn-ingress/peers.yaml. The device's
# private half is written to ONE 0600 file and nowhere else: not sent to the cluster, not committed,
# never logged.
#
# RUN IT IN THE DEV CONTAINER — that is where the toolchain lives (CLAUDE.md §5):
#
# ./infra/devcontainer/dev.sh run bash -c './infra/vpn-ingress/new-peer.sh pixel-8 2'
#
# It needs `wg` (wireguard-tools), `qrencode` and `sops`, all three baked into the image. It will
# run on a host that happens to have them too, but the container is the supported way and the only
# one that is reproducible.
#
# THE SERVER'S PUBLIC KEY IS DERIVED FROM THE ENCRYPTED FILE, not from a copy you keep lying around:
# `sops -d` the private half out of platform/vpn-ingress/wg-server-key.sops.yaml and pipe it
# straight into `wg pubkey`. The plaintext never touches disk and never reaches a terminal. (This
# is why both tools being in ONE image matters — see the Containerfile comment.) Override with
# WG_SERVER_PUBKEY or WG_SERVER_KEY_FILE if you are working off-tree.
#
# WHY A SCRIPT AND NOT A RUNBOOK STEP. Three of these fields are easy to get wrong in ways that are
# either silently insecure or silently broken, and all three are mechanical:
@@ -25,17 +39,23 @@
# <last-octet> 2-254, the device's address inside WG_CLIENT_CIDR. Must be unused.
# --split split tunnel: route ONLY the LAN and the tunnel subnet. Default is a FULL
# tunnel (0.0.0.0/0) so the device also egresses the house WAN.
# --out DIR where to write the profile (default: a 0700 dir under $TMPDIR).
# --out DIR where to write the profile. Default: `.cache/wg-peers/` in the repo, which is
# GITIGNORED (`.gitignore` `.cache/`) and, unlike /tmp inside the container,
# survives the container exiting — the repo is its only persistent read-write
# mount, so a profile written anywhere else is gone before you can move it to a
# desktop. The script refuses any destination git does not ignore.
#
# ENVIRONMENT
# WG_SERVER_KEY_FILE server PRIVATE key (default ~/.config/wireguard-home/server.key); its public
# half is derived from it. Alternatively set WG_SERVER_PUBKEY directly.
# WG_SERVER_PUBKEY the server's public key, if you want to skip the SOPS decrypt entirely.
# WG_SERVER_KEY_FILE a local copy of the server PRIVATE key to derive the public half from,
# instead of reading the SOPS file.
# WG_SERVER_SOPS_FILE default platform/vpn-ingress/wg-server-key.sops.yaml
# WG_ENDPOINT_HOST default wan.home.keithjackson.dev
# WG_CLIENT_CIDR default 10.90.0.0/24 (must match clusters/base/cluster-vars.yaml)
# WG_LAN_CIDR default 192.168.2.0/24 (prod LAN_CIDR)
# WG_DNS default 192.168.2.23 (prod PIHOLE_DNS_VIP)
#
# REQUIRES: wireguard-tools (`wg`). `qrencode` is optional and only used for the QR code.
# REQUIRES: `wg`, `qrencode`, `sops` — all three in the dev container.
# =================================================================================================
set -euo pipefail
@@ -51,7 +71,7 @@ PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.yaml"
: "${WG_CLIENT_CIDR:=10.90.0.0/24}" # clusters/base/cluster-vars.yaml
: "${WG_LAN_CIDR:=192.168.2.0/24}" # clusters/prod LAN_CIDR
: "${WG_DNS:=192.168.2.23}" # clusters/prod PIHOLE_DNS_VIP
: "${WG_SERVER_KEY_FILE:=${HOME}/.config/wireguard-home/server.key}"
: "${WG_SERVER_SOPS_FILE:=${REPO_ROOT}/platform/vpn-ingress/wg-server-key.sops.yaml}"
# wan.<base-domain> is an UNPROXIED A record that platform/externaldns/ddns.yaml keeps pointed at
# the current WAN IP (checked every 5 min). Use it, never a literal address: this house has a
# DYNAMIC WAN IP, and a profile pinned to today's address stops working without warning and with no
@@ -83,14 +103,28 @@ printf '%s' "$NAME" | grep -Eq '^[a-z0-9][a-z0-9-]{0,30}$' \
printf '%s' "$OCTET" | grep -Eq '^[0-9]+$' || die "octet must be a number: got '$OCTET'"
[ "$OCTET" -ge 2 ] && [ "$OCTET" -le 254 ] || die "octet must be 2-254 (.1 is the server): got '$OCTET'"
command -v wg >/dev/null || die "wireguard-tools not installed ('wg' not on PATH). Debian/Ubuntu: apt install wireguard-tools"
command -v wg >/dev/null || die "'wg' not on PATH. Run this inside the dev container:
./infra/devcontainer/dev.sh run bash -c './infra/vpn-ingress/new-peer.sh $NAME $OCTET'"
# --- the server's public key --------------------------------------------------------------------
# Order: an explicit pubkey, then a local private-key file, then the SOPS file in the tree. The
# last is the normal path and needs no local copy of anything.
if [ -z "${WG_SERVER_PUBKEY:-}" ]; then
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read server key '$WG_SERVER_KEY_FILE'.
Either point WG_SERVER_KEY_FILE at it, or set WG_SERVER_PUBKEY to the public half.
If the server key does not exist yet, see runbooks/vpn-remote-access.md step 2."
if [ -n "${WG_SERVER_KEY_FILE:-}" ]; then
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read WG_SERVER_KEY_FILE '$WG_SERVER_KEY_FILE'"
WG_SERVER_PUBKEY="$(tr -d ' \r\n' < "$WG_SERVER_KEY_FILE" | wg pubkey)"
else
command -v sops >/dev/null || die "'sops' not on PATH. Run this inside the dev container (see --help)."
[ -r "$WG_SERVER_SOPS_FILE" ] || die "cannot read '$WG_SERVER_SOPS_FILE'"
# The decrypted private key goes straight into `wg pubkey` — never to a file, never to a tty.
# `wg pubkey` fails on the committed REPLACE-ME placeholder, which is the useful error here.
WG_SERVER_PUBKEY="$(sops -d --extract '["stringData"]["privatekey"]' "$WG_SERVER_SOPS_FILE" \
| tr -d ' \r\n' | wg pubkey 2>/dev/null)" || WG_SERVER_PUBKEY=""
[ -n "$WG_SERVER_PUBKEY" ] || die "could not derive the server public key from
$WG_SERVER_SOPS_FILE
Either it still holds the REPLACE-ME placeholder (see runbooks/vpn-remote-access.md step 2),
or this shell cannot decrypt it (the age keyring is mounted at ~/.config/sops in the container)."
fi
fi
CLIENT_BASE="${WG_CLIENT_CIDR%/*}"; CLIENT_BASE="${CLIENT_BASE%.*}"
@@ -107,14 +141,22 @@ if [ -r "$PEERS_FILE" ] && grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
fi
# --- output location: 0700, and never inside the repo -------------------------------------------
if [ -z "$OUT_DIR" ]; then
OUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wg-peer-XXXXXX")"
else
[ -n "$OUT_DIR" ] || OUT_DIR="${REPO_ROOT}/.cache/wg-peers"
mkdir -p "$OUT_DIR"
fi
chmod 700 "$OUT_DIR"
case "$(cd "$OUT_DIR" && pwd)" in
"$REPO_ROOT"|"$REPO_ROOT"/*) die "refusing to write a private key inside the git checkout ($OUT_DIR)" ;;
OUT_ABS="$(cd "$OUT_DIR" && pwd)"
# A profile holds a private key, so the destination must not be committable. Inside the repo that
# means git must IGNORE it — test the real property rather than a proxy, because the repo is the
# container's only persistent read-write mount and so the only sane place to write. Outside the
# repo, anywhere goes.
case "$OUT_ABS" in
"$REPO_ROOT"|"$REPO_ROOT"/*)
if ! git -C "$REPO_ROOT" check-ignore -q "$OUT_ABS/probe.conf" 2>/dev/null; then
die "refusing to write a private key to '$OUT_ABS': it is inside the git checkout and NOT
gitignored, so the profile would be committable. Use the default (.cache/wg-peers) or add an
ignore rule first. Verify with: git check-ignore -v <path>"
fi
;;
esac
PROFILE="${OUT_DIR}/${NAME}.conf"
@@ -190,6 +232,8 @@ if command -v qrencode >/dev/null; then
printf ' QR code (this IS the private key — your terminal scrollback now holds a credential):\n\n'
qrencode -t ansiutf8 < "$PROFILE"
else
printf ' qrencode not installed, so no QR code was printed (Debian/Ubuntu: apt install qrencode).\n'
printf ' Transfer %s to the device by another route instead.\n' "$PROFILE"
# Should not happen in the dev container: qrencode is baked into the image. Kept as a real
# fallback for a host run, rather than failing after the profile has already been written.
printf ' qrencode not on PATH, so no QR code was printed. It is in the dev container —\n'
printf ' rerun there, or transfer %s to the device by another route.\n' "$PROFILE"
fi
@@ -22,52 +22,46 @@
# producing a server that silently cannot complete a handshake.
#
# ---------------------------------------------------------------------------------------------
# MIND WHERE EACH COMMAND RUNS. The two tools do not live in the same place (measured 2026-10-05):
# wg -> the HOST only (/usr/bin/wg; NOT in the dev container)
# sops -> the dev CONTAINER only (/usr/local/bin/sops; NOT on the host)
# So you cannot pipe `sops -d ... | wg pubkey` in one shell. The steps below are split accordingly,
# and they never put the key on a terminal or in a pipeline that crosses the boundary.
# EVERYTHING RUNS IN THE DEV CONTAINER. `wg`, `sops` and `qrencode` are all baked into the image
# (infra/devcontainer/Containerfile) because that image IS the toolchain — CLAUDE.md §5, and the
# devcontainer README's "Don't install the toolchain on the host". One shell, one toolchain, so
# `wg genkey | sops set` is just a pipeline.
# ---------------------------------------------------------------------------------------------
#
# HAND-OFF STEPS (operator — an agent must not mint a long-lived private key, CLAUDE.md §6).
# Full procedure with verification: runbooks/vpn-remote-access.md step 2.
#
# 1. HOST — generate it. This file is also what infra/vpn-ingress/new-peer.sh reads to derive the
# public half for each client profile, so keep it where the script's default expects it:
# 1. Generate the key STRAIGHT INTO this file. The plaintext never lands on disk and never
# reaches a terminal; `sops set` edits the already-encrypted document in place:
# ./infra/devcontainer/dev.sh run bash -c '
# cd /work
# umask 077
# mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
# wg genkey > ~/.config/wireguard-home/server.key
# sops set platform/vpn-ingress/wg-server-key.sops.yaml \
# "[\"stringData\"][\"privatekey\"]" "\"$(wg genkey)\""
# '
# (Or edit it: `./infra/devcontainer/dev.sh run sops platform/vpn-ingress/wg-server-key.sops.yaml`.)
#
# 2. Put that value in your PASSWORD MANAGER. It cannot be regenerated without invalidating every
# client profile. (Velero backs this namespace up to R2 too, but the password-manager copy is
# the one that survives losing the cluster AND the backups.)
# Record the public half alongside it — safe to display, and useful when debugging a client:
# wg pubkey < ~/.config/wireguard-home/server.key
# 2. Record BOTH halves in your PASSWORD MANAGER. The encrypted file is in git and Velero captures
# this namespace to R2, so this 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.
# # public half (safe to display; it is 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'
#
# 3. Stage it where the CONTAINER can read it, then 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 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 — no plaintext copy of it is ever written.
# 3. Confirm the value is a real key without printing it — `wg pubkey` succeeds only on a valid
# 32-byte curve25519 private key, which is exactly the check the pod's init container runs:
# ./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"'
#
# KEEP THE `.key` SUFFIX on that staged copy. `.gitignore` line 11 is `*.key`, and it is the
# ONLY rule covering that path: `.cache/` itself is NOT ignored. A copy named `server.pem` or
# `privatekey` in the same directory is COMMITTABLE, and the gate's `no-plaintext-secrets` stage
# 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. Nothing else to wire: the resource line and both overlay decryption patches already
# 4. PR it. Nothing else to wire: the resource line and both overlay decryption patches already
# exist. Flux rolls the DaemonSet and the pods leave CrashLoop.
#
# NOTE infra/vpn-ingress/new-peer.sh reads THIS FILE to derive each client profile's
# `[Peer] PublicKey`, so there is no separate copy of the key to keep in step with anything.
# =============================================================================
apiVersion: v1
kind: Secret
+40 -53
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
./infra/devcontainer/dev.sh run bash -c '
cd /work
umask 077
mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
wg genkey > ~/.config/wireguard-home/server.key
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: