Merge pull request 'fix(vpn-ingress): keep the server key in source through SOPS, not as an out-of-band Secret' (#60) from fix/vpn-server-key-in-sops into main
Reviewed-on: #60 Reviewed-by: Keith Jackson <[email protected]>
This commit was merged in pull request #60.
This commit is contained in:
commit
4791653f2b
14 files changed
+584
-90
No files matched your search
@@ -62,53 +62,78 @@ lsmod | grep '^wireguard'
|
||||
grep -w wireguard /etc/modules-load.d/k3s-cilium-longhorn.conf
|
||||
```
|
||||
|
||||
## Step 2 — create the server key (once)
|
||||
## Step 2 — fill in the server key (once)
|
||||
|
||||
This is the **only** secret in the component, and the one thing that must never be generated in an
|
||||
agent session or pasted into a transcript. Generate it on your own machine:
|
||||
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.
|
||||
|
||||
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. 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
|
||||
mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
|
||||
umask 077
|
||||
wg genkey > ~/.config/wireguard-home/server.key # keep this; see "Back it up" below
|
||||
./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)\""
|
||||
'
|
||||
```
|
||||
|
||||
Load it into the cluster. **Do not echo it, do not pass it on an interactive command line that
|
||||
lands in shell history, and never print the resulting Secret's `.data`** (CLAUDE.md §6):
|
||||
**2. Record it in your PASSWORD MANAGER.** Derive both halves from the file you just wrote:
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n vpn-ingress create secret generic wg-server-key \
|
||||
--from-file=privatekey=/work/.cache/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'
|
||||
```
|
||||
|
||||
…where `/work/.cache/server.key` is a path you copied the key to (`.cache/` is not tracked; create
|
||||
it if absent). Delete that copy as soon as the Secret exists: `shred -u .cache/server.key`.
|
||||
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.
|
||||
|
||||
> **KEEP THE `.key` SUFFIX.** That filename is what keeps the key out of git: `.gitignore` line 11
|
||||
> is `*.key`, and it is the **only** rule covering this path — `.cache/` itself is NOT ignored
|
||||
> (`git check-ignore -v .cache/serverkey.txt` matches nothing). So a copy named `server.pem`,
|
||||
> `wg-server` or `privatekey` sitting in that directory is **committable**, and the gate's
|
||||
> `no-plaintext-secrets` stage does not know a WireGuard private key when it sees one. Verify before
|
||||
> you trust it: `git check-ignore -v .cache/<your-filename>` must print a rule, and
|
||||
> `git status --short` must not list the file.
|
||||
|
||||
Confirm it exists **by key name only**:
|
||||
**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
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n vpn-ingress get secret wg-server-key -o jsonpath="{.data}" | tr "," "\n" | cut -d: -f1'
|
||||
# expected: {"privatekey -- a NAME. If you see base64 payload, you printed the key. Rotate it.
|
||||
./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"'
|
||||
```
|
||||
|
||||
**Why out of band and not SOPS.** An agent must not mint a long-lived private key, and a key that
|
||||
passed through a session transcript would have to be treated as compromised. The trade-off is that
|
||||
this Secret is **not reconstructible from git** — so:
|
||||
That is the same check the pod's init container runs, so a pass here predicts a pass there.
|
||||
|
||||
**Back it up.** Keep `server.key` in your password manager. Velero's `daily` schedule excludes only
|
||||
`monitoring` and `unifi` (`platform/velero/helmrelease.yaml`), so this namespace *is* captured
|
||||
off-cluster to R2 as well. Restoring this one key is what makes every already-enrolled device work
|
||||
again after a cluster rebuild — **losing it means re-enrolling every device.**
|
||||
**4. 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
|
||||
|
||||
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)
|
||||
|
||||
@@ -149,12 +174,19 @@ 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,
|
||||
prints a QR code if `qrencode` is installed, and prints the `[Peer]` stanza to add. Then:
|
||||
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.
|
||||
|
||||
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:
|
||||
|
||||
1. Paste the stanza into `platform/vpn-ingress/peers.yaml` under `data.peers.conf`, keeping the
|
||||
`# <name> — <owner>, enrolled <date>` comment. `wg show` can only ever print the key, so that
|
||||
|
||||
Reference in new issue
Block a user