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]>
14 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-05 | fix/vpn-server-key-in-sops | VPN server key moves into SOPS-encrypted git — the out-of-band Secret broke R-SEC-01 and the DR story |
What changed
Operator correction (2026-10-05): "secrets are kept in source through sops". They are right, and
#55 shipped the WireGuard server private key the wrong way: as a Secret the operator was told to
kubectl create out of band, referenced by name only. This branch moves it into the tree as
platform/vpn-ingress/wg-server-key.sops.yaml, where every other secret in this repo lives.
Why the original choice was wrong, not merely unconventional. Two concrete costs, both of which #55's own README half-admitted and then shipped anyway:
- R-SEC-01 / spec docs/07 says secrets live in source, SOPS-encrypted, reaching the cluster only through Flux. An out-of-band Secret is outside the GitOps tree by construction.
- It broke DR for the one credential that cannot be re-derived. A cluster rebuild restores everything in git; it does not restore a hand-made Secret. Losing this key means re-enrolling every device, because each client profile pins its public half. #55 papered over this with "keep a copy in your password manager", which is a workaround for a self-inflicted gap.
Note platform/kyverno/admission/secret-provenance.yaml did not forbid the original shape —
it scopes to app-namespace=true namespaces and vpn-ingress is deliberately a platform namespace,
so admission would have accepted the hand-applied Secret. The rule it violated was the written
requirement, not an enforced control. Recording that because "the gate let it through" was never
evidence it was correct.
How the unfillable-key problem is solved. 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 the gate green from the first commit — the resource line and both
overlay decryption patches are present — and leaves the operator exactly one act: replace the value.
The alternative, a commented-out resource line to uncomment later, is precisely the "file in git with
no generator entry: reviewed, merged, apparently done, still broken" shape this repo keeps getting
bitten by (alert-runbooks-resolvable exists because of it).
The init-container guard is now a validation, not an emptiness check. It was [ -z "$key" ].
Because the committed placeholder is non-empty, the likely failure is now a value that is present
and wrong, so the guard pipes the value through wg pubkey — which accepts only a real 32-byte
curve25519 private key — and names which of four cases it hit:
| case | why it matters |
|---|---|
REPLACE-ME placeholder |
the expected fresh-install state |
| empty | blanked value |
starts with ENC[ |
the Secret reached the cluster UNDECRYPTED. A missing overlay decryption patch applies cleanly and leaves every Flux condition green — this is the silent failure the guard exists for |
| rejected, none of the above | mangled value, or a public key pasted by mistake |
Files:
platform/vpn-ingress/wg-server-key.sops.yaml(new) — SOPS-encrypted to the repo's age recipient,stringData.privatekeyonly, placeholder value.platform/vpn-ingress/wg-server-key.sops.yaml.example(new) — plaintext template + operator hand-off, following thecrafty-oauth2-proxy-credentials.sops.yaml.exampleconvention.platform/vpn-ingress/kustomization.yaml— the resource added; the "DELIBERATELY ABSENT: a Secret" block replaced with the overlay-patch warning.platform/vpn-ingress/daemonset.yaml— thewg pubkeyguard; volume comment rewritten.clusters/dev/kustomization.yaml,clusters/prod/kustomization.yaml— thevpn-ingressdecryption:patch, in both. This is the load-bearing half: #55's own kustomization comment predicted that forgetting it applies ciphertext cleanly.clusters/base/vpn-ingress.yaml— its "NO decryption stanza is needed" comment was now false.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: 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:
- 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, agit check-ignorewarning about the*.keysuffix being the only thing protecting it, and a sha256 comparison to prove the right bytes arrived. All of that is gone. new-peer.shnow derives the server's public key fromwg-server-key.sops.yamlitself (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..cache/is now gitignored at the repo root (mirroringinfra/ansible/.gitignore), so the container has a persistent, non-committable scratch directory.new-peer.shwrites 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/tmpin there is gone before it can be moved to a desktop. Its old blanket "refuse to write inside the checkout" guard is replaced bygit 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
PR review of type "Comment". I have deliberately not posted it (CLAUDE.md §1).
What I verified
- The encrypted file is real and correctly targeted.
sops -e -iin the pinned toolbox producedstringData.privatekey: ENC[AES256_GCM,...]with cleartext keys,encrypted_regex ^(data|stringData)$, and exactly one age recipient:age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg— the recipient in the root.sops.yaml, i.e. the key Flux holds assops-age. - Round-trip, without exposing anything:
sops -dthen a Python read of key names only →stringData keys: ['privatekey'], andvalue is still the placeholder: True. - Both overlay patches are present and name the right Kustomization (
name: vpn-ingress,namespace: flux-system,decryption.secretRef.name: sops-age), inserted after each overlay'svelerostanza, mirroring headlamp/crafty/velero. kubectl kustomize platform/vpn-ingressbuilds and now emits five objects — Namespace, ConfigMap, Secret, DaemonSet, NetworkPolicy — with${WG_CLIENT_CIDR}still an unexpanded token for Flux.- The tool gap, measured on both host and container before the fix:
wghost-only,sopscontainer-only,qrencodeon neither. - Both packages exist in the image's repos, queried from inside the running container:
wireguard-tools1.0.20260223 (repoupdates) andqrencode4.1.1 (repofedora). - The image REBUILDS with them and both tools resolve inside it —
./infra/devcontainer/dev.sh buildsucceeded, andwg --version/qrencode --version/sops --versionall 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 thesops -d | wg pubkeypipeline the whole design now depends on..cache/is ignored regardless of filename now:git check-ignore -v .cache/anything.confand.cache/serverkey.txtboth match.gitignore:46:.cache/. Before this change only*.keymatched, 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,l1andapproval-policyall pass; rc 3 is the two environmental skips (gitea-settings— no local token; conformance L2/L3 — no cluster). Not green. Reported as rc 3, not as pass. - The L0 gate caught THREE real defects in this branch before it could merge — including one
in the fix for another — and I had asserted one of them was fine without running it. Both are recorded because the gate's value here is the
whole point of the exercise:
no-plaintext-secretsFAILED: "wg-server-key.sops.yaml.example: an unencrypted .example template is referenced by a kustomization — it would be applied as a plaintext Secret". My kustomization named the template in a comment, and the check matches comments too (correctly: naming one is how a plaintext Secret gets applied by accident).platform/crafty/kustomization.yamlnever names its own example either — the convention was there to copy and I missed it. Fixed by not naming the file; the hand-off lives inside the example, where it belongs. An earlier draft of this entry claimed this check passed. It did not. The claim was written from expectation rather than from a run.flux-postbuild-substitutionFAILED on${key#ENC[}in my new guard (daemonset.yaml:155), flagged[SILENT-EMPTY]: envsubst consumes every${...}, so my shell prefix-strip would have rendered to the empty string — the ciphertext branch of the guard would never have fired, which is precisely the silent failure the guard was added to catch. Rewritten withcase, which uses no braces and so has nothing to escape.- And then it failed a THIRD time, on the fix's own comment —
[FATAL-PARSE] daemonset.yaml:156 ${...}, the prose explaining the rule, written with the literal form. That class is worse than it looks: aFATAL-PARSEhazard fails the entire Kustomization, every resource in the path, not just the line. Rewritten as "dollar-brace expansion". This is the second time in this component that writing the hazard literally inside a comment about the hazard has been the defect (the first was in #55), so the habit to build is: in any substituted path, describe the construct in words, never type it.
- After both fixes,
no-plaintext-secretsandflux-postbuild-substitutionpass (446 and 198 assertions respectively), andl0is green.
What I did NOT verify
- That Flux decrypts this file on prod. The overlay patches have never reconciled. This is the
whole risk of the change and it has exactly one observable outcome: after merge the pods should
CrashLoop reporting the
REPLACE-MEplaceholder (proving decryption worked), not reportingENC[(which would prove a patch is wrong). I built the guard to tell those apart because I cannot test them. - That
wg pubkeyaccepts the operator's real key through this path. The guard is reasoned fromwg pubkey's behaviour and has never run —wgis absent from both this workstation's container and the gate, so the first execution will be in the pod. - The
sops setcommand in the hand-off. I verifiedsops -e -iandsops -din the toolbox; I did not runsops setwith the quoting shown, and nested quoting insidedev.sh run bash -cis exactly where a documented command tends to break. If it misbehaves, the editor form (sops <file>) is the fallback and does not depend on quoting. - The sha256 comparison in step 2 step 4 is untested end to end (it needs the real key).
- Whether anything else in #55 assumed a tool is available where it is not. I checked
wg,sopsandqrencodebecause step 2 depended on them. I did not audit every command in the runbook the same way — and that class of defect has now produced two corrections in a row (site.sh, thensops | wg), so assume more remain until each step has actually been run. - The live VPN still carries no traffic, and the earlier gaps stand unchanged: the
iptables-vs-nftables question,
allowPrivilegeEscalation: falseversus the image's s6 init, and any off-LAN reachability.