Operator correction: secrets are kept in source through sops. #55 shipped the WireGuard server private key as a Secret to `kubectl create` by hand. Two real costs, not just a convention break: * R-SEC-01 / spec docs/07 -- secrets live in source, SOPS-encrypted, and reach the cluster only through Flux. * It broke DR for the one credential that cannot be re-derived. A rebuild restores everything in git; it does not restore a hand-made Secret, and losing this key means re-enrolling EVERY device, because each client profile pins its public half. #55 papered over that with "keep a copy in your password manager". (Note secret-provenance did NOT forbid the original shape -- it scopes to app-namespace=true and vpn-ingress is deliberately a platform namespace. The rule broken was the written requirement, not an enforced control.) An agent still must not mint a long-lived private key (CLAUDE.md §6), so the file is committed ALREADY ENCRYPTED with `privatekey: REPLACE-ME`. That makes the wiring complete and green from the first commit -- resource line plus both overlay decryption patches -- leaving the operator exactly one act: replace the value. A commented-out resource to uncomment later is the "in git, no generator entry, reviewed, merged, still broken" shape this repo keeps hitting. The init guard is now a validation rather than an emptiness check: the value is piped through `wg pubkey`, which accepts only a real curve25519 private key, and the pod names which case it hit -- placeholder, empty, mangled, or (the silent one) still `ENC[` ciphertext because an overlay is missing its decryption patch, which otherwise applies cleanly with every Flux condition green. Second, independent finding that also fixes #55's step 2: `wg` is on the HOST only and `sops` is in the CONTAINER only, so `sops -d | wg pubkey` cannot run in one shell -- the command #55 documented could not have worked. Every step is now split by where it runs, and the written key is verified by comparing sha256 digests rather than printing anything. `qrencode` is on neither, so the QR step says so and gives the install command. Touches clusters/** and adds a **/*.sops.yaml, so approval-policy will block pending the operator's /approve-override. Deliberately not posted by me. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
83 lines
5.4 KiB
Plaintext
83 lines
5.4 KiB
Plaintext
# =============================================================================
|
|
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (.yaml.example is ignored by kustomize and never
|
|
# auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
|
|
#
|
|
# WHAT THIS IS: the WireGuard SERVER's long-term private key for the remote-access VPN
|
|
# (platform/vpn-ingress). It is the ONE secret in the whole component. The init container in
|
|
# daemonset.yaml reads it at `/run/wg-server-key/privatekey` and joins it to the PLAINTEXT peer
|
|
# register (peers.yaml) in a tmpfs, so the assembled wg0.conf never touches a node's disk.
|
|
#
|
|
# WHY IT IS IN GIT, SOPS-ENCRYPTED, AND NOT HAND-CREATED. R-SEC-01 / spec docs/07: secrets live in
|
|
# source, encrypted, and reach the cluster only through Flux. An out-of-band `kubectl create secret`
|
|
# would put the single 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.
|
|
#
|
|
# *** THE REAL FILE IS COMMITTED WITH `privatekey: REPLACE-ME`, ALREADY ENCRYPTED. ***
|
|
# That is deliberate, so the wiring is complete and green before the key exists: the resource is
|
|
# already listed in kustomization.yaml and both overlays already carry the `vpn-ingress` decryption
|
|
# patch. Your job is ONLY to replace the value (step 3 below). Until you do, the DaemonSet
|
|
# CrashLoops with an explicit FATAL naming this file — the init container validates the value with
|
|
# `wg pubkey` before writing wg0.conf, so a placeholder fails CLOSED and LOUDLY rather than
|
|
# 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.
|
|
# ---------------------------------------------------------------------------------------------
|
|
#
|
|
# 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
|
|
#
|
|
# 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
|
|
#
|
|
# 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.
|
|
#
|
|
# 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
|
|
# exist. Flux rolls the DaemonSet and the pods leave CrashLoop.
|
|
# =============================================================================
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: wg-server-key
|
|
namespace: vpn-ingress
|
|
type: Opaque
|
|
stringData:
|
|
# The server's WireGuard private key: `wg genkey` output — 44 chars of base64 ending in '='.
|
|
# NOT a passphrase, and NOT the public key. The init container validates it with `wg pubkey`
|
|
# before writing wg0.conf, so a placeholder or a mangled value fails closed with a clear message.
|
|
privatekey: REPLACE-ME
|