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
@@ -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/.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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."
|
||||
WG_SERVER_PUBKEY="$(tr -d ' \r\n' < "$WG_SERVER_KEY_FILE" | wg pubkey)"
|
||||
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
|
||||
mkdir -p "$OUT_DIR"
|
||||
fi
|
||||
[ -n "$OUT_DIR" ] || OUT_DIR="${REPO_ROOT}/.cache/wg-peers"
|
||||
mkdir -p "$OUT_DIR"
|
||||
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:
|
||||
# umask 077
|
||||
# mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
|
||||
# wg genkey > ~/.config/wireguard-home/server.key
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -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