Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2acfed460a | ||
|
|
e1bebb857e | ||
|
|
23db97b958 | ||
|
|
5928d8153c | ||
|
|
146dd9a906 | ||
|
|
d1c07fe021 | ||
|
|
5c43fbac65 | ||
|
|
cf7d5e4316 | ||
|
|
be23cf3e26 | ||
|
|
4791653f2b | ||
|
|
523f482957 | ||
|
|
8059297036 | ||
|
|
e35612dc7e |
No files matched your search
@@ -37,6 +37,14 @@ coverage/
|
|||||||
infra/tofu/backend.hcl
|
infra/tofu/backend.hcl
|
||||||
infra/tofu/.r2.env
|
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;
|
# 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
|
# 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/.
|
# stage (which scans untracked files) from tripping on .claude/worktrees/.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
# NOTHING downstream reconciles — the incident behind the headlamp/oauth2-proxy/homepage moves
|
# NOTHING downstream reconciles — the incident behind the headlamp/oauth2-proxy/homepage moves
|
||||||
# (DECISIONS-LOG: "Headlamp gating stall").
|
# (DECISIONS-LOG: "Headlamp gating stall").
|
||||||
# * SPECIFIC, AND THE REASON THIS IS NOT A JUDGEMENT CALL: this DaemonSet cannot start until the
|
# * SPECIFIC, AND THE REASON THIS IS NOT A JUDGEMENT CALL: this DaemonSet cannot start until the
|
||||||
# operator creates the out-of-band `wg-server-key` Secret (runbooks/vpn-remote-access.md
|
# operator replaces the `REPLACE-ME` placeholder in platform/vpn-ingress/wg-server-key.sops.yaml
|
||||||
# step 1). That is the correct fail-closed state — an unenrolled VPN must not come up — but in
|
# with a real key (runbooks/vpn-remote-access.md step 2). That is the correct fail-closed state — an unenrolled VPN must not come up — but in
|
||||||
# the gating tier it would have been a self-inflicted platform-wide outage ON MERGE, with the
|
# the gating tier it would have been a self-inflicted platform-wide outage ON MERGE, with the
|
||||||
# cause (a missing manual step) invisible from any Flux condition.
|
# cause (a missing manual step) invisible from any Flux condition.
|
||||||
#
|
#
|
||||||
@@ -51,6 +51,9 @@ spec:
|
|||||||
substituteFrom:
|
substituteFrom:
|
||||||
- kind: ConfigMap
|
- kind: ConfigMap
|
||||||
name: cluster-vars
|
name: cluster-vars
|
||||||
# NO `decryption:` stanza is needed in the overlays: this path carries no *.sops.yaml. The server
|
# SOPS decryption is layered per-environment by the overlay (clusters/dev and clusters/prod each
|
||||||
# key is an out-of-band Secret (platform/vpn-ingress/kustomization.yaml says what to do if that
|
# patch THIS Kustomization by name), identical to headlamp/crafty/velero — this path carries
|
||||||
# ever changes).
|
# platform/vpn-ingress/wg-server-key.sops.yaml, the WireGuard server private key. The `sops-age`
|
||||||
|
# Secret is created out of band and referenced by NAME only. Without the overlay patch the Secret
|
||||||
|
# applies cleanly full of ENC[...] and only the tunnel is broken, so the patch is as load-bearing
|
||||||
|
# as the encrypted file itself.
|
||||||
@@ -122,6 +122,24 @@ patches:
|
|||||||
provider: sops
|
provider: sops
|
||||||
secretRef:
|
secretRef:
|
||||||
name: sops-age
|
name: sops-age
|
||||||
|
# vpn-ingress (the WireGuard remote-access VPN, clusters/base/vpn-ingress.yaml) carries
|
||||||
|
# platform/vpn-ingress/wg-server-key.sops.yaml — the WireGuard SERVER private key, the one
|
||||||
|
# secret in that component. Without this stanza kustomize hands Flux the raw ciphertext and the
|
||||||
|
# Secret applies full of ENC[...]: the APPLY SUCCEEDS and every Flux condition stays green, and
|
||||||
|
# the only symptom is a VPN that no client can handshake with. The DaemonSet's init container
|
||||||
|
# validates the value with `wg pubkey` specifically so that state is a named CrashLoop instead.
|
||||||
|
# The `sops-age` Secret is out-of-band (name only).
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: vpn-ingress
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
|
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
|
||||||
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
|
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
|
||||||
# account and the Anthropic subscription token from `claude setup-token`. Without this
|
# account and the Anthropic subscription token from `claude setup-token`. Without this
|
||||||
|
|||||||
@@ -113,6 +113,24 @@ patches:
|
|||||||
provider: sops
|
provider: sops
|
||||||
secretRef:
|
secretRef:
|
||||||
name: sops-age
|
name: sops-age
|
||||||
|
# vpn-ingress (the WireGuard remote-access VPN, clusters/base/vpn-ingress.yaml) carries
|
||||||
|
# platform/vpn-ingress/wg-server-key.sops.yaml — the WireGuard SERVER private key, the one
|
||||||
|
# secret in that component. Without this stanza kustomize hands Flux the raw ciphertext and the
|
||||||
|
# Secret applies full of ENC[...]: the APPLY SUCCEEDS and every Flux condition stays green, and
|
||||||
|
# the only symptom is a VPN that no client can handshake with. The DaemonSet's init container
|
||||||
|
# validates the value with `wg pubkey` specifically so that state is a named CrashLoop instead.
|
||||||
|
# The `sops-age` Secret is out-of-band (name only).
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: vpn-ingress
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
|
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
|
||||||
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
|
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
|
||||||
# account and the Anthropic subscription token from `claude setup-token`. Without this
|
# account and the Anthropic subscription token from `claude setup-token`. Without this
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-05
|
||||||
|
branch: fix/vpn-server-key-in-sops
|
||||||
|
title: 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:
|
||||||
|
|
||||||
|
1. **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.
|
||||||
|
2. **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.privatekey` only, placeholder value.
|
||||||
|
- `platform/vpn-ingress/wg-server-key.sops.yaml.example` (new) — plaintext template + operator
|
||||||
|
hand-off, following the `crafty-oauth2-proxy-credentials.sops.yaml.example` convention.
|
||||||
|
- `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` — the `wg pubkey` guard; volume comment rewritten.
|
||||||
|
- `clusters/dev/kustomization.yaml`, `clusters/prod/kustomization.yaml` — **the `vpn-ingress`
|
||||||
|
`decryption:` 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:
|
||||||
|
|
||||||
|
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
|
||||||
|
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 -i` in the pinned toolbox
|
||||||
|
produced `stringData.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 as `sops-age`.
|
||||||
|
- **Round-trip, without exposing anything**: `sops -d` then a Python read of key names only →
|
||||||
|
`stringData keys: ['privatekey']`, and `value 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's
|
||||||
|
`velero` stanza, mirroring headlamp/crafty/velero.
|
||||||
|
- **`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.
|
||||||
|
- **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).
|
||||||
|
**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:
|
||||||
|
1. **`no-plaintext-secrets` FAILED**: "`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.yaml`
|
||||||
|
never 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.**
|
||||||
|
2. **`flux-postbuild-substitution` FAILED** 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 with `case`, which uses no braces and so has nothing to escape.
|
||||||
|
3. **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: a `FATAL-PARSE` hazard 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-secrets` and `flux-postbuild-substitution` pass** (446 and 198
|
||||||
|
assertions respectively), and `l0` is 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-ME` placeholder** (proving decryption worked), *not* reporting
|
||||||
|
**`ENC[`** (which would prove a patch is wrong). I built the guard to tell those apart because I
|
||||||
|
cannot test them.
|
||||||
|
- **That `wg pubkey` accepts the operator's real key through this path.** The guard is reasoned from
|
||||||
|
`wg pubkey`'s behaviour and has never run — `wg` is absent from both this workstation's container
|
||||||
|
and the gate, so the first execution will be in the pod.
|
||||||
|
- **The `sops set` command in the hand-off.** I verified `sops -e -i` and `sops -d` in the toolbox;
|
||||||
|
I did **not** run `sops set` with the quoting shown, and nested quoting inside
|
||||||
|
`dev.sh run bash -c` is 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`,
|
||||||
|
`sops` and `qrencode` because 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`, then `sops | 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: false` versus the image's s6 init, and
|
||||||
|
any off-LAN reachability.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-06
|
||||||
|
branch: fix/new-peer-ignores-template-comments
|
||||||
|
title: new-peer.sh refused the first VPN address because its duplicate check matched its own commented-out example
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The operator's first real enrolment failed:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ./infra/vpn-ingress/new-peer.sh keith-phone 2
|
||||||
|
new-peer.sh: 10.90.0.2/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nothing was allocated.** `peers.yaml` had zero peers — the pod's own init container reported
|
||||||
|
`wg0.conf assembled: 0 peer(s)`. The duplicate-address check grepped the WHOLE file, and
|
||||||
|
`peers.yaml` line 66 is a **commented-out example stanza** I wrote, containing literally
|
||||||
|
`# AllowedIPs = 10.90.0.2/32`. So `.2` — the first address anybody would ask for, and the one my
|
||||||
|
own runbook uses in its example — was permanently un-allocatable, with a message asserting the
|
||||||
|
opposite of the truth.
|
||||||
|
|
||||||
|
Both halves fixed, because either alone leaves the trap half-set:
|
||||||
|
|
||||||
|
- **`infra/vpn-ingress/new-peer.sh`**: the check strips comment lines before matching
|
||||||
|
(`grep -vE '^[[:space:]]*#' | grep -q …`). That is the correct predicate rather than a patch: a
|
||||||
|
real stanza's `AllowedIPs` line is never commented, while both the template and every peer's
|
||||||
|
`# <name> — <owner>` label always are.
|
||||||
|
- **`platform/vpn-ingress/peers.yaml`**: the example now uses placeholders that cannot match
|
||||||
|
anything real — `# <device-name> — <owner>, enrolled <YYYY-MM-DD>` and
|
||||||
|
`AllowedIPs = 10.90.0.<octet>/32`. A future grep cannot be fooled by it either.
|
||||||
|
|
||||||
|
The bug class is worth naming: **a documentation example that is byte-identical to real data will
|
||||||
|
eventually be parsed as real data.** The same shape caused two earlier defects in this component —
|
||||||
|
`flux-postbuild-substitution` flagged a `${...}` written inside a comment *explaining* that hazard,
|
||||||
|
and `no-plaintext-secrets` flagged the `.sops.yaml.example` because the kustomization named it in a
|
||||||
|
comment. Three instances now; the rule is to make examples structurally unmatchable, not merely
|
||||||
|
commented out.
|
||||||
|
|
||||||
|
No sensitive paths (`platform/vpn-ingress/**`, `infra/vpn-ingress/**`, `decisions/**`), so this
|
||||||
|
merges on a green gate with no override.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Run in the pinned toolbox against the live tree, 2026-10-06.
|
||||||
|
|
||||||
|
- **`.2` now works**, and it went through the REAL key path: `new-peer.sh keith-phone 2` rendered a
|
||||||
|
profile with `Address 10.90.0.2/32`, `AllowedIPs = 10.90.0.2/32` in the printed stanza, full
|
||||||
|
tunnel, at `.cache/wg-peers/keith-phone.conf` (0600). The throwaway keypair was deleted after.
|
||||||
|
- **This incidentally closes a gap left open in #60**: the profile could only have been rendered by
|
||||||
|
deriving the server's public key via `sops -d --extract … | wg pubkey` from
|
||||||
|
`wg-server-key.sops.yaml` — so that pipeline is now proven **against the operator's real key**,
|
||||||
|
not just against a placeholder it was expected to reject.
|
||||||
|
- **A real duplicate is still refused.** Appending an uncommented
|
||||||
|
`AllowedIPs = 10.90.0.5/32` stanza and asking for octet 5 produced the correct refusal; the tree
|
||||||
|
was restored afterwards and `git diff --stat` showed only the intended template edit.
|
||||||
|
- **Zero active allocations remain**: `grep -vE '^[[:space:]]*#' peers.yaml | grep -c AllowedIPs`
|
||||||
|
→ `0`.
|
||||||
|
- **Toolbox gate**: 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`,
|
||||||
|
conformance L2/L3). **Not green. Reported as rc 3.**
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **No peer is enrolled by this branch.** The keypair I generated was a throwaway and was deleted;
|
||||||
|
the operator must run the script themselves, since the device's private key must exist only on
|
||||||
|
the device. So the end of the enrolment path — a stanza merged into `peers.yaml`, Flux rolling
|
||||||
|
the DaemonSet, and `wg show` listing a peer — is still unexercised.
|
||||||
|
- **The duplicate-NAME warning** (`grep -qi "^ *# *${NAME} "`) deliberately still inspects
|
||||||
|
comments, because per-peer labels are comments. It is no longer tripped by the template, but I
|
||||||
|
did not test it against a real enrolled peer.
|
||||||
|
- **Nothing about client traffic.** The WAN port-forward is still not configured and there is still
|
||||||
|
no off-LAN vantage point, so the tunnel has carried no packet.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-06
|
||||||
|
branch: fix/peers-configmap-content-hash
|
||||||
|
title: A merged VPN peer never reached the tunnel — the peer register was a fixed-name ConfigMap, so nothing rolled the pods
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The operator enrolled a phone, merged the peer, and **`wg show` listed no peers.** Measured on prod
|
||||||
|
2026-10-06, with the peer already on `main`:
|
||||||
|
|
||||||
|
| observation | value |
|
||||||
|
|---|---|
|
||||||
|
| peer stanza on `main` | present (`AllowedIPs = 10.90.0.2/32`) |
|
||||||
|
| GitRepository artifact | `main@sha1:e1bebb8` — Flux had fetched it |
|
||||||
|
| `vpn-ingress` Kustomization `lastAppliedRevision` | `main@sha1:5928d81` — **behind**, 10m interval |
|
||||||
|
| live `wg-peers` ConfigMap, active `AllowedIPs` lines | **0** |
|
||||||
|
| DaemonSet volume reference | `configMap.name: wg-peers` — a **fixed** name |
|
||||||
|
| DaemonSet `generation` / `observedGeneration` | **1 / 1** — it had **never rolled** |
|
||||||
|
| init container, all three pods | `wg0.conf assembled: 0 peer(s)` |
|
||||||
|
|
||||||
|
The lag was only the reconcile interval. **The defect is the fixed name.** When Flux does update a
|
||||||
|
fixed-name ConfigMap it updates it *in place*: the DaemonSet's pod template is byte-identical, so
|
||||||
|
no rollout is triggered — and because the init container assembles `wg0.conf` **once at pod start**
|
||||||
|
into a tmpfs, the running tunnel keeps the old peer list indefinitely. Adding or revoking a peer
|
||||||
|
would have had **no effect at all** until something unrelated happened to restart the pods. For a
|
||||||
|
*revocation* that is a security defect, not an inconvenience: the operator would delete a stanza,
|
||||||
|
see a green PR, and the device would keep working.
|
||||||
|
|
||||||
|
My runbook and README both asserted "PR → merge → Flux rolls the DaemonSet (~1 min)". That was
|
||||||
|
never true.
|
||||||
|
|
||||||
|
**This repo already documents the fix, and I had read the file.** `runbooks/kustomization.yaml`:
|
||||||
|
*"The generated name carries kustomize's content hash, so editing any runbook rolls the server
|
||||||
|
automatically and a stale projection is impossible."*
|
||||||
|
|
||||||
|
So the register becomes a **content-hashed generator**:
|
||||||
|
|
||||||
|
- `platform/vpn-ingress/peers.yaml` (inline ConfigMap) → **`platform/vpn-ingress/peers.conf`**, a
|
||||||
|
plain file, plus a `configMapGenerator` in `kustomization.yaml`. The operator's enrolled peer was
|
||||||
|
carried across verbatim. Every line is WireGuard config and `#` is a legal comment there, so the
|
||||||
|
documentation now travels *inside the mounted file* instead of in a YAML wrapper that gets
|
||||||
|
stripped.
|
||||||
|
- **`namespace: vpn-ingress` added to the kustomization — REQUIRED, not tidiness.** A generated
|
||||||
|
ConfigMap inherits no namespace; every other object here declares `vpn-ingress`; and kustomize's
|
||||||
|
`nameReference` transformer will not rewrite a reference across a namespace boundary. Without it
|
||||||
|
the build emitted `ConfigMap wg-peers-m9kcckcb5t` while the DaemonSet still mounted the unhashed
|
||||||
|
`wg-peers`, which would not exist — the pods would wedge on a missing volume. I caught this only
|
||||||
|
by building the output and reading the volumes block.
|
||||||
|
- **`infra/vpn-ingress/new-peer.sh`**: `PEERS_FILE` repointed to `peers.conf`, and a missing
|
||||||
|
register is now **fatal** instead of skipped. The old guard was
|
||||||
|
`[ -r "$PEERS_FILE" ] && grep …`, so after the rename the only symptom would have been
|
||||||
|
duplicate-address detection quietly never firing again — the silent-skip shape this repo treats
|
||||||
|
as a defect in its own right.
|
||||||
|
- Stale `peers.yaml` references swept from the README, runbook, `daemonset.yaml`,
|
||||||
|
`networkpolicy.yaml`, the SOPS example, and the script's own **user-facing output** (it told the
|
||||||
|
operator to paste into "peers.yaml, under data.peers.conf", which would no longer exist).
|
||||||
|
- The runbook gained the verification that actually distinguishes this failure — the DaemonSet's
|
||||||
|
`generation` must *increase* after the merge — and a troubleshooting row for `0 peer(s)` after a
|
||||||
|
merge.
|
||||||
|
|
||||||
|
Flux's `prune: true` removes the orphaned fixed-name `wg-peers` ConfigMap on the next reconcile.
|
||||||
|
|
||||||
|
No sensitive paths (`platform/vpn-ingress/**`, `infra/vpn-ingress/**`, `runbooks/**`,
|
||||||
|
`decisions/**`), so this merges on a green gate with no override.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Built and run in the pinned toolbox, 2026-10-06 (kustomize v5.8.1 via `kubectl kustomize`).
|
||||||
|
|
||||||
|
- **The reference is now rewritten end to end**: generated `ConfigMap wg-peers-m9kcckcb5t` in
|
||||||
|
namespace `vpn-ingress`, and the DaemonSet mounts **`wg-peers-m9kcckcb5t`**. Before adding
|
||||||
|
`namespace:` the same build mounted the unhashed `wg-peers` — so the namespace line is load
|
||||||
|
bearing, measured both ways.
|
||||||
|
- **The hash tracks content, which is the whole mechanism.** `wg-peers-m9kcckcb5t` →
|
||||||
|
append a second peer → `wg-peers-dgg7ht9c74` → restore → `wg-peers-m9kcckcb5t`. Deterministic
|
||||||
|
and reversible, so an edit necessarily changes the pod template.
|
||||||
|
- **The operator's enrolled peer survived the file conversion byte-for-byte**: the active stanza in
|
||||||
|
`peers.conf` (`PublicKey = pBRSHrRy…`, `AllowedIPs = 10.90.0.2/32`) is identical to the one on
|
||||||
|
`main`, compared directly against `git show origin/main:platform/vpn-ingress/peers.yaml`.
|
||||||
|
- **The build still emits all five objects**: Namespace, ConfigMap, Secret, DaemonSet,
|
||||||
|
NetworkPolicy — and the `Namespace` object's own name is still `vpn-ingress` (the namespace
|
||||||
|
transformer did not rewrite it).
|
||||||
|
- **`new-peer.sh` works against the new register**: `spare-laptop 7` renders a profile and prints
|
||||||
|
"append this to platform/vpn-ingress/peers.conf"; `dupe 2` is **refused** because the operator's
|
||||||
|
real `.2` now occupies it — the first time the duplicate check has been exercised against real
|
||||||
|
data rather than against its own example.
|
||||||
|
- **Toolbox gate**: rc 3, with `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`,
|
||||||
|
`l1` and `approval-policy` all pass; rc 3 is the two environmental skips. **Not green. Reported
|
||||||
|
as rc 3.**
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **That the roll actually happens on the cluster.** The whole point of the change is unproven
|
||||||
|
until Flux applies it: the success criterion is the DaemonSet's `generation` increasing and
|
||||||
|
`wg0.conf assembled: 1 peer(s)` in the init log, followed by `wg show` listing the peer.
|
||||||
|
- **That the tunnel then carries traffic.** No handshake has ever been observed. The WAN
|
||||||
|
port-forward is reportedly configured but has never been exercised by a packet I can see, and I
|
||||||
|
have no off-LAN vantage point — runbook step 6 from cellular remains the only honest test.
|
||||||
|
- **Revocation**, which is the case where this defect was a security problem. Deleting a stanza
|
||||||
|
should now roll the pods and drop the peer; untested.
|
||||||
|
- **Whether the orphaned fixed-name `wg-peers` ConfigMap is actually pruned.** Reasoned from
|
||||||
|
`prune: true` on the Kustomization, not observed.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
---
|
||||||
|
date: 2026-10-06
|
||||||
|
branch: fix/vpn-ingress-ip-forward
|
||||||
|
title: The VPN tunnel never came up — a hostNetwork pod cannot write net.ipv4.ip_forward, and wg-quick treats a failed PostUp as fatal
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The remote-access VPN reconciled for the first time with a real server key (#60 then #62) and
|
||||||
|
**the tunnel did not come up**. One line in my own manifest was the whole cause.
|
||||||
|
|
||||||
|
Measured in the `wireguard` container on prod, 2026-10-06:
|
||||||
|
|
||||||
|
```
|
||||||
|
[#] ip link add dev wg0 type wireguard
|
||||||
|
[#] wg addconf wg0 /dev/fd/63
|
||||||
|
[#] ip -4 address add 10.90.0.1/24 dev wg0
|
||||||
|
[#] ip link set mtu 1420 up dev wg0
|
||||||
|
[#] sysctl -q -w net.ipv4.ip_forward=1
|
||||||
|
sysctl: permission denied on key "net.ipv4.ip_forward"
|
||||||
|
[#] ip link delete dev wg0
|
||||||
|
**** Tunnel /config/wg_confs/wg0.conf failed, will stop all others! ****
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything up to that point worked. **The cause is not a missing capability.** With
|
||||||
|
`hostNetwork: true` the runtime mounts `/proc/sys` **read-only** in the container — confirmed in the
|
||||||
|
pod's own `/proc/mounts`: `proc /proc/sys proc ro,nosuid,nodev,noexec,relatime` — precisely because
|
||||||
|
those sysctls are the *host's* globals and a container must not rewrite them. `CAP_NET_ADMIN` cannot
|
||||||
|
lift a read-only mount, so this can never succeed from here. And `wg-quick` treats any failed
|
||||||
|
`PostUp` as fatal, so it deleted the interface it had just finished building.
|
||||||
|
|
||||||
|
**The line was never needed.** `node_baseline` writes `net.ipv4.ip_forward=1` to
|
||||||
|
`/etc/sysctl.d/99-k3s-cilium.conf`, and reading the value from inside the pod returns `1`. I had
|
||||||
|
written the header comment *correctly* — "ip_forward is ALREADY 1 on these nodes … so no pod sysctl
|
||||||
|
is needed" — and then emitted the sysctl anyway, justified in its own comment as "re-asserted so
|
||||||
|
this component does not silently depend on another role's sysctl file surviving". That defensive
|
||||||
|
re-assertion is the entire outage. **The lesson is narrow and reusable: in a hostNetwork pod,
|
||||||
|
verify host state, never write it.**
|
||||||
|
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- **`platform/vpn-ingress/daemonset.yaml`**: the `PostUp = sysctl …` line is gone, replaced by a
|
||||||
|
**read** in the init container — `grep -qx 1 /proc/sys/net/ipv4/ip_forward` — which fails closed
|
||||||
|
with a message naming the `node_baseline` role. Reading `/proc/sys` is permitted; only writing is
|
||||||
|
not. Fail-closed still matters: a tunnel that comes up but cannot forward would satisfy every
|
||||||
|
probe in this component and silently drop every client's traffic.
|
||||||
|
- The header bullet that said "no pod sysctl is needed" now also says **do not attempt one**, with
|
||||||
|
the mechanism, so the next author does not re-add it for the same reason I did.
|
||||||
|
- The `securityContext` comment claimed NET_ADMIN was for "create the wg0 link, set the host sysctl,
|
||||||
|
and write the netfilter rules". The middle clause was false; corrected.
|
||||||
|
- The image's `chown: … Operation not permitted` startup run — plus its own "Permissions could not
|
||||||
|
be set … we will not provide support for it" — is now documented as **expected and harmless**, in
|
||||||
|
both the manifest and the runbook, so nobody chases it. Capabilities are dropped to `NET_ADMIN`
|
||||||
|
only, so its s6 init has no `CAP_CHOWN`; `/config` is a writable tmpfs, everything runs as root,
|
||||||
|
and `wg-quick` proceeds past it (as it did here — the failure was 30 lines later).
|
||||||
|
- **`runbooks/vpn-remote-access.md`**: a "what a healthy start looks like" block with the real log
|
||||||
|
lines; `ip_forward is 0` added to the init-failure table; and the note that `0/1 Running` with
|
||||||
|
`Startup probe failed: Unable to access interface: No such device` is the signature of `wg-quick`
|
||||||
|
tearing the tunnel down — read the `[#]` line it died on.
|
||||||
|
- **`platform/vpn-ingress/README.md`**: a table of what the first reconcile settled (below).
|
||||||
|
|
||||||
|
No sensitive paths: `platform/vpn-ingress/**`, `runbooks/**` and `decisions/**` only. This merges on
|
||||||
|
a green gate with no override.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Live prod, read-only, 2026-10-06. **Four questions this component's design rested on are now
|
||||||
|
measured rather than assumed — three went the right way, one was the defect above.**
|
||||||
|
|
||||||
|
- **The GitOps path is proven end to end.** `Kustomization vpn-ingress` `lastAppliedRevision:
|
||||||
|
main@sha1:cf7d5e4` equals the GitRepository artifact revision. The Secret carries
|
||||||
|
`kustomize.toolkit.fluxcd.io/name: vpn-ingress`, so Flux delivered it.
|
||||||
|
- **SOPS decryption works, proven by digest rather than by eye.** The value in
|
||||||
|
`platform/vpn-ingress/wg-server-key.sops.yaml` on `main`, decrypted, is 44 bytes, is **not** the
|
||||||
|
placeholder, and **`wg pubkey` accepts it**. Its SHA-256 equals the SHA-256 of the live Secret's
|
||||||
|
decoded `data.privatekey` (`d7c8f8de6f252649…` both sides). No key material was printed at any
|
||||||
|
point — lengths, booleans and digests only.
|
||||||
|
- **The `wg pubkey` guard fired correctly, and its `ENC[` branch was never needed.** During the
|
||||||
|
kubelet window before the updated Secret propagated, all three pods reported *"It is still the
|
||||||
|
committed REPLACE-ME PLACEHOLDER"* — the guard distinguishing a stale/unfilled value from
|
||||||
|
ciphertext, exactly as designed. It cleared on its own once the mount refreshed; **a transient
|
||||||
|
`REPLACE-ME` CrashLoop for a minute or two after the key PR merges is expected, not a fault.**
|
||||||
|
- **The kernel module reaches the pod**: `ip link add dev wg0 type wireguard` succeeded and
|
||||||
|
`/proc/modules` lists `wireguard`. So the `node_baseline` addition works and `CAP_SYS_MODULE` is
|
||||||
|
correctly not required.
|
||||||
|
- **`allowPrivilegeEscalation: false` does NOT break the image's s6 init** — the container reaches
|
||||||
|
`wg-quick` normally. The flag stays; the fear recorded in #55 is closed.
|
||||||
|
- **The iptables backends MATCH, which was my single biggest documented doubt.** The image ships
|
||||||
|
`iptables v1.8.13 (nf_tables)` via `/usr/sbin/xtables-nft-multi`, and Debian 13 is nf_tables. The
|
||||||
|
nightmare case — rules accepted into a table the kernel never consults, so handshakes succeed and
|
||||||
|
every forwarded connection hangs — cannot occur on this pairing.
|
||||||
|
- **`net.ipv4.ip_forward` is `1`** in the host netns the pod shares, and `/proc/sys` is `ro` there.
|
||||||
|
Both read directly from the pod.
|
||||||
|
- **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`, conformance L2/L3). **Not green. Reported as rc 3.**
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **That the tunnel now comes up.** This fix has not reconciled. The next reconcile is the test, and
|
||||||
|
its success criterion is specific: pods `1/1 Running`, and `wg show` reporting `interface: wg0`
|
||||||
|
with `listening port: 51820`.
|
||||||
|
- **That the MASQUERADE and FORWARD rules actually apply.** They are the next statements after the
|
||||||
|
one that failed, so **they have never executed**. The backends matching (above) removes the worst
|
||||||
|
failure mode but proves nothing about the rules themselves. First check after the pods go Ready:
|
||||||
|
`iptables -t nat -S POSTROUTING | grep 10.90.0.0/24` on a node.
|
||||||
|
- **Anything about client traffic.** Still zero peers enrolled, still no WAN port-forward, still no
|
||||||
|
off-LAN vantage point. The VPN has carried no packet.
|
||||||
|
- **Whether the transient `REPLACE-ME` window has an upper bound.** It cleared within ~2 minutes
|
||||||
|
here; I did not determine the kubelet refresh behaviour, so a longer stall after a key change is
|
||||||
|
uncharacterised.
|
||||||
|
- **The `sops set` hand-off command**, still. The operator filled the key in by their own route
|
||||||
|
(#62), so the exact quoting I documented inside `dev.sh run bash -c` remains unexercised.
|
||||||
@@ -32,9 +32,23 @@ ARG TARGETARCH=amd64
|
|||||||
# Distro packages: libvirt client libs (harmless disk-image utilities), ansible, ssh,
|
# 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),
|
# 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.
|
# 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 \
|
RUN dnf -y install --setopt=install_weak_deps=False \
|
||||||
libvirt-client qemu-img cloud-utils genisoimage openssh-clients \
|
libvirt-client qemu-img cloud-utils genisoimage openssh-clients \
|
||||||
ansible-core python3-libvirt python3-pip diffutils \
|
ansible-core python3-libvirt python3-pip diffutils \
|
||||||
|
wireguard-tools qrencode \
|
||||||
git make jq unzip tar gzip xz which ca-certificates curl \
|
git make jq unzip tar gzip xz which ca-certificates curl \
|
||||||
&& dnf clean all
|
&& dnf clean all
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ baseline, and the *same* binaries CI uses.
|
|||||||
| Provisioning | OpenTofu (`bpg/proxmox` provider), `ansible-core`, `openssh-clients` |
|
| Provisioning | OpenTofu (`bpg/proxmox` provider), `ansible-core`, `openssh-clients` |
|
||||||
| GitOps / cluster | `flux`, `kubectl`, `helm` |
|
| GitOps / cluster | `flux`, `kubectl`, `helm` |
|
||||||
| Secrets | `sops`, `age` + `age-keygen` |
|
| 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` |
|
| **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) |
|
| 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).
|
# 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
|
# Generates a WireGuard keypair, renders the device's client profile, prints a QR code for a phone,
|
||||||
# prints a QR code for a phone, and prints the one `[Peer]` stanza to paste into
|
# and prints the one `[Peer]` stanza to paste into platform/vpn-ingress/peers.conf. The device's
|
||||||
# platform/vpn-ingress/peers.yaml. The private half NEVER leaves this machine: it is not sent to the
|
# private half is written to ONE 0600 file and nowhere else: not sent to the cluster, not committed,
|
||||||
# cluster, not committed, and not written to any log.
|
# 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
|
# 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:
|
# either silently insecure or silently broken, and all three are mechanical:
|
||||||
@@ -25,24 +39,30 @@
|
|||||||
# <last-octet> 2-254, the device's address inside WG_CLIENT_CIDR. Must be unused.
|
# <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
|
# --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.
|
# 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
|
# ENVIRONMENT
|
||||||
# WG_SERVER_KEY_FILE server PRIVATE key (default ~/.config/wireguard-home/server.key); its public
|
# WG_SERVER_PUBKEY the server's public key, if you want to skip the SOPS decrypt entirely.
|
||||||
# half is derived from it. Alternatively set WG_SERVER_PUBKEY directly.
|
# 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_ENDPOINT_HOST default wan.home.keithjackson.dev
|
||||||
# WG_CLIENT_CIDR default 10.90.0.0/24 (must match clusters/base/cluster-vars.yaml)
|
# 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_LAN_CIDR default 192.168.2.0/24 (prod LAN_CIDR)
|
||||||
# WG_DNS default 192.168.2.23 (prod PIHOLE_DNS_VIP)
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
die() { printf 'new-peer.sh: %s\n' "$*" >&2; exit 1; }
|
die() { printf 'new-peer.sh: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.yaml"
|
PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.conf"
|
||||||
|
|
||||||
# --- defaults, each mirroring a value that is DECLARED elsewhere in this repo -------------------
|
# --- defaults, each mirroring a value that is DECLARED elsewhere in this repo -------------------
|
||||||
# These are duplicated here rather than parsed out of the YAML on purpose: this script is meant to
|
# These are duplicated here rather than parsed out of the YAML on purpose: this script is meant to
|
||||||
@@ -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_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_LAN_CIDR:=192.168.2.0/24}" # clusters/prod LAN_CIDR
|
||||||
: "${WG_DNS:=192.168.2.23}" # clusters/prod PIHOLE_DNS_VIP
|
: "${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
|
# 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
|
# 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
|
# 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'"
|
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'"
|
[ "$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 --------------------------------------------------------------------
|
# --- 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
|
if [ -z "${WG_SERVER_PUBKEY:-}" ]; then
|
||||||
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read server key '$WG_SERVER_KEY_FILE'.
|
if [ -n "${WG_SERVER_KEY_FILE:-}" ]; then
|
||||||
Either point WG_SERVER_KEY_FILE at it, or set WG_SERVER_PUBKEY to the public half.
|
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read WG_SERVER_KEY_FILE '$WG_SERVER_KEY_FILE'"
|
||||||
If the server key does not exist yet, see runbooks/vpn-remote-access.md step 1."
|
WG_SERVER_PUBKEY="$(tr -d ' \r\n' < "$WG_SERVER_KEY_FILE" | wg pubkey)"
|
||||||
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
|
fi
|
||||||
|
|
||||||
CLIENT_BASE="${WG_CLIENT_CIDR%/*}"; CLIENT_BASE="${CLIENT_BASE%.*}"
|
CLIENT_BASE="${WG_CLIENT_CIDR%/*}"; CLIENT_BASE="${CLIENT_BASE%.*}"
|
||||||
@@ -99,22 +133,41 @@ CLIENT_ADDR="${CLIENT_BASE}.${OCTET}"
|
|||||||
# --- refuse a collision, because the failure is otherwise invisible -----------------------------
|
# --- refuse a collision, because the failure is otherwise invisible -----------------------------
|
||||||
# Two devices sharing an address does not error anywhere: the server routes that address to
|
# Two devices sharing an address does not error anywhere: the server routes that address to
|
||||||
# whichever peer handshook most recently, so BOTH devices appear to work intermittently.
|
# whichever peer handshook most recently, so BOTH devices appear to work intermittently.
|
||||||
if [ -r "$PEERS_FILE" ] && grep -q "AllowedIPs *= *${CLIENT_ADDR}/32" "$PEERS_FILE"; then
|
# COMMENT LINES ARE NOT ALLOCATIONS. This used to grep the whole file and so matched the
|
||||||
die "${CLIENT_ADDR}/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet."
|
# COMMENTED-OUT EXAMPLE STANZA in the register, which made `.2` — the first address anyone would
|
||||||
|
# ask for — permanently un-allocatable with a message claiming it was already taken. Strip
|
||||||
|
# comments first: a real stanza's `AllowedIPs` line is never commented, while both the template
|
||||||
|
# and every peer's `# <name> — <owner>` label are.
|
||||||
|
# FAIL if the register is missing rather than skipping the check. The path was silently wrong once
|
||||||
|
# (it still said peers.yaml after the file became peers.conf), and because the old guard was
|
||||||
|
# `[ -r "$PEERS_FILE" ] && ...` the only symptom would have been duplicate detection quietly
|
||||||
|
# never firing again — the exact silent-skip shape this repo treats as a defect.
|
||||||
|
[ -r "$PEERS_FILE" ] || die "cannot read the peer register '$PEERS_FILE'.
|
||||||
|
Duplicate-address detection cannot run, so this script refuses to issue a profile."
|
||||||
|
if grep -vE '^[[:space:]]*#' "$PEERS_FILE" | grep -q "AllowedIPs *= *${CLIENT_ADDR}/32"; then
|
||||||
|
die "${CLIENT_ADDR}/32 is already allocated in platform/vpn-ingress/peers.conf. Pick another octet."
|
||||||
fi
|
fi
|
||||||
if [ -r "$PEERS_FILE" ] && grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
|
if grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
|
||||||
printf 'new-peer.sh: WARNING a peer comment already mentions "%s" in peers.yaml.\n' "$NAME" >&2
|
printf 'new-peer.sh: WARNING a peer comment already mentions "%s" in peers.conf.\n' "$NAME" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- output location: 0700, and never inside the repo -------------------------------------------
|
# --- output location: 0700, and never inside the repo -------------------------------------------
|
||||||
if [ -z "$OUT_DIR" ]; then
|
[ -n "$OUT_DIR" ] || OUT_DIR="${REPO_ROOT}/.cache/wg-peers"
|
||||||
OUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wg-peer-XXXXXX")"
|
mkdir -p "$OUT_DIR"
|
||||||
else
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
fi
|
|
||||||
chmod 700 "$OUT_DIR"
|
chmod 700 "$OUT_DIR"
|
||||||
case "$(cd "$OUT_DIR" && pwd)" in
|
OUT_ABS="$(cd "$OUT_DIR" && pwd)"
|
||||||
"$REPO_ROOT"|"$REPO_ROOT"/*) die "refusing to write a private key inside the git checkout ($OUT_DIR)" ;;
|
# 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
|
esac
|
||||||
PROFILE="${OUT_DIR}/${NAME}.conf"
|
PROFILE="${OUT_DIR}/${NAME}.conf"
|
||||||
|
|
||||||
@@ -162,7 +215,7 @@ cat <<EOF
|
|||||||
Profile : ${PROFILE} (0600; contains the private key)
|
Profile : ${PROFILE} (0600; contains the private key)
|
||||||
|
|
||||||
------------------------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------
|
||||||
STEP 1 — add this to platform/vpn-ingress/peers.yaml, under data.peers.conf, then PR it:
|
STEP 1 — append this to platform/vpn-ingress/peers.conf, then PR it:
|
||||||
|
|
||||||
# ${NAME} — <owner>, enrolled $(date -u +%Y-%m-%d)
|
# ${NAME} — <owner>, enrolled $(date -u +%Y-%m-%d)
|
||||||
[Peer]
|
[Peer]
|
||||||
@@ -181,7 +234,7 @@ cat <<EOF
|
|||||||
STEP 3 — delete the profile from this machine once the device has it:
|
STEP 3 — delete the profile from this machine once the device has it:
|
||||||
shred -u ${PROFILE} 2>/dev/null || rm -f ${PROFILE}
|
shred -u ${PROFILE} 2>/dev/null || rm -f ${PROFILE}
|
||||||
|
|
||||||
The tunnel will not work until the peers.yaml PR is MERGED and Flux has rolled the DaemonSet
|
The tunnel will not work until the peers.conf PR is MERGED and Flux has rolled the DaemonSet
|
||||||
(~1 min). Until then the device handshakes and gets silence, exactly like an unknown key.
|
(~1 min). Until then the device handshakes and gets silence, exactly like an unknown key.
|
||||||
------------------------------------------------------------------------------------------
|
------------------------------------------------------------------------------------------
|
||||||
EOF
|
EOF
|
||||||
@@ -190,6 +243,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'
|
printf ' QR code (this IS the private key — your terminal scrollback now holds a credential):\n\n'
|
||||||
qrencode -t ansiutf8 < "$PROFILE"
|
qrencode -t ansiutf8 < "$PROFILE"
|
||||||
else
|
else
|
||||||
printf ' qrencode not installed, so no QR code was printed (Debian/Ubuntu: apt install qrencode).\n'
|
# Should not happen in the dev container: qrencode is baked into the image. Kept as a real
|
||||||
printf ' Transfer %s to the device by another route instead.\n' "$PROFILE"
|
# 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
|
fi
|
||||||
@@ -13,8 +13,8 @@ egress out the house WAN.
|
|||||||
| Transport | WireGuard, **udp/51820**, kernel implementation |
|
| Transport | WireGuard, **udp/51820**, kernel implementation |
|
||||||
| Where it runs | `hostNetwork` **DaemonSet** — one warm listener on every node |
|
| Where it runs | `hostNetwork` **DaemonSet** — one warm listener on every node |
|
||||||
| Client subnet | `${WG_CLIENT_CIDR}` = `10.90.0.0/24`; `.1` is the server, `.2`–`.254` devices |
|
| Client subnet | `${WG_CLIENT_CIDR}` = `10.90.0.0/24`; `.1` is the server, `.2`–`.254` devices |
|
||||||
| Authorisation | a device's **public key** is in [`peers.yaml`](./peers.yaml), or it cannot connect |
|
| Authorisation | a device's **public key** is in [`peers.conf`](./peers.conf), or it cannot connect |
|
||||||
| The one secret | the server's private key, Secret `wg-server-key`, **created out of band** |
|
| The one secret | the server's private key — **SOPS-encrypted in this tree**, `wg-server-key.sops.yaml` |
|
||||||
| Exposure | one hand-made WAN port-forward on the SOHO router, off-repo |
|
| Exposure | one hand-made WAN port-forward on the SOHO router, off-repo |
|
||||||
| Enrolling a device | [`infra/vpn-ingress/new-peer.sh`](../../infra/vpn-ingress/new-peer.sh) |
|
| Enrolling a device | [`infra/vpn-ingress/new-peer.sh`](../../infra/vpn-ingress/new-peer.sh) |
|
||||||
| Procedure / bring-up | [`runbooks/vpn-remote-access.md`](../../runbooks/vpn-remote-access.md) |
|
| Procedure / bring-up | [`runbooks/vpn-remote-access.md`](../../runbooks/vpn-remote-access.md) |
|
||||||
@@ -129,12 +129,18 @@ translations win here.
|
|||||||
|
|
||||||
| half | where | why |
|
| half | where | why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| peer **public** keys + addresses | [`peers.yaml`](./peers.yaml), **plaintext in git** | a public key grants nothing by itself, and this makes *"who can get into my house"* a reviewable PR diff with `git log` as its audit trail |
|
| peer **public** keys + addresses | [`peers.conf`](./peers.conf), **plaintext in git**, as a content-hashed `configMapGenerator` so an edit rolls the pods | a public key grants nothing by itself, and this makes *"who can get into my house"* a reviewable PR diff with `git log` as its audit trail |
|
||||||
| the server's **private** key | Secret `wg-server-key`, **out of band** | the only real secret; an agent must not mint a long-lived private key (CLAUDE.md §6) |
|
| the server's **private** key | `wg-server-key.sops.yaml`, **SOPS-encrypted in git** | the only real secret (R-SEC-01, spec docs/07). Ships with a `REPLACE-ME` placeholder: an agent must not mint a long-lived private key (CLAUDE.md §6), so the operator `sops set`s it in |
|
||||||
|
|
||||||
The two are joined only at pod start, by the init container, into a **`medium: Memory` emptyDir** —
|
The two are joined only at pod start, by the init container, into a **`medium: Memory` emptyDir** —
|
||||||
so the assembled config holding the private key never touches any node's disk and dies with the pod.
|
so the assembled config holding the private key never touches any node's disk and dies with the pod.
|
||||||
|
|
||||||
|
Both halves are therefore in git, which is the point: a cluster rebuild restores the server key with
|
||||||
|
everything else. An out-of-band `kubectl create secret` — the first shape this component shipped in,
|
||||||
|
corrected after review — would have put the one credential every enrolled device depends on *outside*
|
||||||
|
the GitOps tree, and losing it means re-enrolling every device, because each client profile pins this
|
||||||
|
key's public half.
|
||||||
|
|
||||||
Encrypting the peer register would have hidden exactly the change a reviewer most needs to see: a
|
Encrypting the peer register would have hidden exactly the change a reviewer most needs to see: a
|
||||||
new line granting a new device access to the whole LAN.
|
new line granting a new device access to the whole LAN.
|
||||||
|
|
||||||
@@ -160,21 +166,40 @@ NetworkPolicy applies.
|
|||||||
## Operating it
|
## Operating it
|
||||||
|
|
||||||
* **Enrol a device:** `./infra/vpn-ingress/new-peer.sh <name> <octet>` → paste the printed `[Peer]`
|
* **Enrol a device:** `./infra/vpn-ingress/new-peer.sh <name> <octet>` → paste the printed `[Peer]`
|
||||||
stanza into `peers.yaml` → PR → merge. Phones scan a QR code; desktops import a `.conf`.
|
stanza into `peers.conf` → PR → merge. Phones scan a QR code; desktops import a `.conf`.
|
||||||
* **Revoke a device:** delete its stanza → PR → merge. No other step; the key stops working.
|
* **Revoke a device:** delete its stanza from `peers.conf` → PR → merge. No other step; the key
|
||||||
|
stops working as soon as the pods roll (the generator's content hash guarantees they do).
|
||||||
* **Is it up?** `kubectl --context k3s-prod -n vpn-ingress get ds wireguard` — three Ready.
|
* **Is it up?** `kubectl --context k3s-prod -n vpn-ingress get ds wireguard` — three Ready.
|
||||||
* **Who is connected?** `kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show`
|
* **Who is connected?** `kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show`
|
||||||
(`latest handshake` under ~3 min means live). Never dump `wg showconf` — it prints the private key.
|
(`latest handshake` under ~3 min means live). Never dump `wg showconf` — it prints the private key.
|
||||||
* **Full bring-up and verification:** `runbooks/vpn-remote-access.md`.
|
* **Full bring-up and verification:** `runbooks/vpn-remote-access.md`.
|
||||||
|
|
||||||
|
## Measured on prod, 2026-10-06 — what is now known rather than assumed
|
||||||
|
|
||||||
|
The first real reconcile settled four things this component's design rested on. Three went the
|
||||||
|
right way; one was a defect in my own manifest.
|
||||||
|
|
||||||
|
| question | answer |
|
||||||
|
|---|---|
|
||||||
|
| Does the **kernel module** reach the pod? | **Yes.** `ip link add dev wg0 type wireguard` succeeds, and `/proc/modules` lists `wireguard`, after the `node_baseline` run. `CAP_SYS_MODULE` is correctly not needed. |
|
||||||
|
| Does `allowPrivilegeEscalation: false` break the image's **s6 init**? | **No.** The container reaches `wg-quick` normally. The flag stays. |
|
||||||
|
| Is the image's **iptables** backend compatible with the host's? | **Yes — both nft.** The image ships `iptables v1.8.13 (nf_tables)` via `xtables-nft-multi`, and Debian 13 is nf_tables. This was the documented worst fear (rules silently landing in a table the kernel does not consult); it is closed. |
|
||||||
|
| Can the pod set **`net.ipv4.ip_forward`**? | **No, and it must not try.** `/proc/sys` is mounted **read-only** under `hostNetwork` (`proc /proc/sys proc ro,…`), because those are the host's globals; `sysctl -w` fails with EPERM whatever the capabilities. A failed `PostUp` is fatal to `wg-quick`, so this one line took the whole tunnel down. The node already has it set to `1`; the init container now **verifies** rather than writes. |
|
||||||
|
|
||||||
## Prerequisites outside this directory
|
## Prerequisites outside this directory
|
||||||
|
|
||||||
1. **The `wireguard` kernel module on every node.** Added to `node_baseline_kernel_modules`
|
1. **The `wireguard` kernel module on every node.** Added to `node_baseline_kernel_modules`
|
||||||
(`infra/ansible/roles/node_baseline/defaults/main.yml`); the operator runs the role. The pod has
|
(`infra/ansible/roles/node_baseline/defaults/main.yml`); the operator runs the role. The pod has
|
||||||
`NET_ADMIN` but deliberately **not** `SYS_MODULE`, so it cannot load the module itself — it
|
`NET_ADMIN` but deliberately **not** `SYS_MODULE`, so it cannot load the module itself — it
|
||||||
CrashLoops with a clear `wg-quick` error instead of quietly degrading to userspace WireGuard.
|
CrashLoops with a clear `wg-quick` error instead of quietly degrading to userspace WireGuard.
|
||||||
2. **The `wg-server-key` Secret.** Runbook step 1. Until it exists the DaemonSet cannot start —
|
2. **The server private key.** `wg-server-key.sops.yaml` is committed, encrypted, with a
|
||||||
the correct fail-closed state for an unenrolled VPN.
|
`REPLACE-ME` placeholder, and both overlays already carry its decryption patch — so the wiring
|
||||||
|
is complete and the only outstanding act is replacing the value (runbook step 2). Until then the
|
||||||
|
DaemonSet CrashLoops with an explicit FATAL naming the file: the init container validates the
|
||||||
|
value with `wg pubkey`, which rejects the placeholder, an empty value, a public key pasted by
|
||||||
|
mistake, **and** a Secret that arrived as undecrypted `ENC[...]` ciphertext. That last case is
|
||||||
|
the one worth having a guard for — a missing overlay decryption patch applies cleanly and leaves
|
||||||
|
every Flux condition green.
|
||||||
3. **The WAN port-forward**, udp/51820 → one node address. Hand-made on the SOHO router, off-repo,
|
3. **The WAN port-forward**, udp/51820 → one node address. Hand-made on the SOHO router, off-repo,
|
||||||
and the half CI can never check.
|
and the half CI can never check.
|
||||||
|
|
||||||
@@ -187,7 +212,7 @@ NetworkPolicy applies.
|
|||||||
covered generically by `KubeDeploymentReplicasMismatch`/`KubePodNotReady` from
|
covered generically by `KubeDeploymentReplicasMismatch`/`KubePodNotReady` from
|
||||||
kube-prometheus-stack. **This is the top follow-up.**
|
kube-prometheus-stack. **This is the top follow-up.**
|
||||||
* **No preshared keys.** They would add a symmetric post-quantum hedge on top of the Noise
|
* **No preshared keys.** They would add a symmetric post-quantum hedge on top of the Noise
|
||||||
handshake — and would be a real secret, forcing `peers.yaml` into SOPS and costing the
|
handshake — and would be a real secret, forcing `peers.conf` into SOPS and costing the
|
||||||
reviewability above. Revisit only if the threat model changes; if it does, the PSK belongs in the
|
reviewability above. Revisit only if the threat model changes; if it does, the PSK belongs in the
|
||||||
`wg-server-key` Secret.
|
`wg-server-key` Secret.
|
||||||
* **No IPv6.** The server offers no IPv6 tunnel address, so a full-tunnel client with native IPv6
|
* **No IPv6.** The server offers no IPv6 tunnel address, so a full-tunnel client with native IPv6
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
# /etc/sysctl.d/99-k3s-cilium.conf), so no pod sysctl is needed — and `net.ipv4.ip_forward`
|
# /etc/sysctl.d/99-k3s-cilium.conf), so no pod sysctl is needed — and `net.ipv4.ip_forward`
|
||||||
# is NOT in the kubelet's safe-sysctl allowlist, so a pod-netns version of this would have
|
# is NOT in the kubelet's safe-sysctl allowlist, so a pod-netns version of this would have
|
||||||
# required `--allowed-unsafe-sysctls` on all three kubelets (a node-restart change).
|
# required `--allowed-unsafe-sysctls` on all three kubelets (a node-restart change).
|
||||||
|
# COROLLARY LEARNED THE HARD WAY (prod, 2026-10-06): this pod must not merely avoid NEEDING
|
||||||
|
# the sysctl, it must not ATTEMPT it. hostNetwork implies /proc/sys is mounted read-only, so
|
||||||
|
# `sysctl -w` fails with EPERM regardless of capabilities, and wg-quick treats a failed PostUp
|
||||||
|
# as fatal and deletes the interface. Verify the value; never write it.
|
||||||
# * no LoadBalancer VIP, so no CiliumL2AnnouncementPolicy lease lottery (the defect that cost
|
# * no LoadBalancer VIP, so no CiliumL2AnnouncementPolicy lease lottery (the defect that cost
|
||||||
# this LAN an 8-day Minecraft outage) and no `externalTrafficPolicy` trade-off at all.
|
# this LAN an 8-day Minecraft outage) and no `externalTrafficPolicy` trade-off at all.
|
||||||
# * no double encapsulation. WireGuard's UDP lands on the node's own NIC, so the underlay is a
|
# * no double encapsulation. WireGuard's UDP lands on the node's own NIC, so the underlay is a
|
||||||
@@ -104,7 +108,7 @@ spec:
|
|||||||
dnsPolicy: ClusterFirstWithHostNet
|
dnsPolicy: ClusterFirstWithHostNet
|
||||||
automountServiceAccountToken: false
|
automountServiceAccountToken: false
|
||||||
# ── Assemble the running config: PUBLIC peer register (git) + PRIVATE server key (Secret) ──
|
# ── Assemble the running config: PUBLIC peer register (git) + PRIVATE server key (Secret) ──
|
||||||
# The two halves are deliberately stored apart (peers.yaml explains why) and are only ever
|
# The two halves are deliberately stored apart (peers.conf explains why) and are only ever
|
||||||
# joined here, at pod start, into a tmpfs that is destroyed with the pod. The server's private
|
# joined here, at pod start, into a tmpfs that is destroyed with the pod. The server's private
|
||||||
# key is therefore never written to any node's disk, never committed, and never logged.
|
# key is therefore never written to any node's disk, never committed, and never logged.
|
||||||
initContainers:
|
initContainers:
|
||||||
@@ -136,9 +140,51 @@ spec:
|
|||||||
# The key arrives as a Secret file; strip any trailing newline the operator's shell
|
# The key arrives as a Secret file; strip any trailing newline the operator's shell
|
||||||
# added, or wg-quick rejects the config with "Key is not the correct length or format".
|
# added, or wg-quick rejects the config with "Key is not the correct length or format".
|
||||||
key=$(tr -d ' \r\n' < /run/wg-server-key/privatekey)
|
key=$(tr -d ' \r\n' < /run/wg-server-key/privatekey)
|
||||||
if [ -z "$key" ]; then
|
# VALIDATE, don't just check for empty. The Secret ships committed with a REPLACE-ME
|
||||||
echo "FATAL: /run/wg-server-key/privatekey is empty." >&2
|
# placeholder so the GitOps wiring is complete before the key exists, so the common
|
||||||
echo " See runbooks/vpn-remote-access.md step 1 (create the server key)." >&2
|
# failure is a value that is present and WRONG — a placeholder, a public key pasted by
|
||||||
|
# mistake, or a SOPS document that reached the cluster undecrypted (ENC[...], which
|
||||||
|
# happens if an overlay is missing its `vpn-ingress` decryption patch). `wg pubkey`
|
||||||
|
# accepts a value only if it is a real 32-byte curve25519 private key, so it rejects
|
||||||
|
# all four. Without this the bad value would be written into wg0.conf and the pod would
|
||||||
|
# fail later and less legibly, or come up with a server nobody can handshake with.
|
||||||
|
if ! printf '%s' "$key" | wg pubkey >/dev/null 2>&1; then
|
||||||
|
echo "FATAL: the server private key in Secret 'wg-server-key' is not a valid" >&2
|
||||||
|
echo " WireGuard private key (wg pubkey rejected it)." >&2
|
||||||
|
if [ -z "$key" ]; then
|
||||||
|
echo " It is EMPTY." >&2
|
||||||
|
elif [ "$key" = "REPLACE-ME" ]; then
|
||||||
|
echo " It is still the committed REPLACE-ME PLACEHOLDER — the key has not" >&2
|
||||||
|
echo " been generated yet. This is the expected state on a fresh install." >&2
|
||||||
|
else
|
||||||
|
# `case`, NOT a dollar-brace prefix-strip. This manifest is rendered through
|
||||||
|
# envsubst, which consumes every dollar-brace expansion it sees, so a shell
|
||||||
|
# parameter expansion here renders to the empty string and silently disables this
|
||||||
|
# very branch. `case` uses no braces, so there is nothing to escape.
|
||||||
|
# (ci: flux-postbuild-substitution caught both the original expansion AND the
|
||||||
|
# first version of this comment, which spelled the hazard out literally.)
|
||||||
|
case "$key" in
|
||||||
|
ENC\[*)
|
||||||
|
echo " It is still SOPS CIPHERTEXT (starts with ENC[). The Secret" >&2
|
||||||
|
echo " reached the cluster undecrypted — check that this environment's" >&2
|
||||||
|
echo " overlay carries the 'vpn-ingress' decryption patch." >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
echo " Fix: runbooks/vpn-remote-access.md step 2." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The pod shares the HOST netns, so this reads the node's real setting. Reading
|
||||||
|
# /proc/sys is permitted even though writing is not (see the PostUp note below).
|
||||||
|
# Fail closed: a tunnel that comes up but cannot forward would look healthy to every
|
||||||
|
# probe here and simply drop every client's traffic.
|
||||||
|
if ! grep -qx 1 /proc/sys/net/ipv4/ip_forward; then
|
||||||
|
echo "FATAL: net.ipv4.ip_forward is 0 on this node, so forwarded VPN traffic would" >&2
|
||||||
|
echo " be dropped. This pod CANNOT set it: with hostNetwork the runtime mounts" >&2
|
||||||
|
echo " /proc/sys read-only, because it is the host's global setting." >&2
|
||||||
|
echo " Fix on the NODE: run the node_baseline role (it writes" >&2
|
||||||
|
echo " /etc/sysctl.d/99-k3s-cilium.conf) — runbooks/vpn-remote-access.md step 1." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -155,9 +201,20 @@ spec:
|
|||||||
echo 'MTU = 1420'
|
echo 'MTU = 1420'
|
||||||
printf 'PrivateKey = %s\n' "$key"
|
printf 'PrivateKey = %s\n' "$key"
|
||||||
# ── Forwarding + SNAT, applied in the HOST netns when wg0 comes up ──
|
# ── Forwarding + SNAT, applied in the HOST netns when wg0 comes up ──
|
||||||
# ip_forward is already 1 from node_baseline; re-asserted so this component does not
|
# NO `sysctl -w net.ipv4.ip_forward=1` HERE. It is IMPOSSIBLE from this pod and it was
|
||||||
# silently depend on another role's sysctl file surviving.
|
# the only thing stopping the tunnel coming up — MEASURED on prod 2026-10-06:
|
||||||
echo 'PostUp = sysctl -q -w net.ipv4.ip_forward=1'
|
# [#] sysctl -q -w net.ipv4.ip_forward=1
|
||||||
|
# sysctl: permission denied on key "net.ipv4.ip_forward"
|
||||||
|
# [#] ip link delete dev wg0
|
||||||
|
# and wg-quick then tore the whole tunnel down. The cause is not a missing
|
||||||
|
# capability: with `hostNetwork: true` the runtime mounts /proc/sys READ-ONLY in the
|
||||||
|
# container (confirmed in /proc/mounts: `proc /proc/sys proc ro,...`), precisely
|
||||||
|
# because those sysctls are the HOST's globals and a container must not rewrite
|
||||||
|
# them. CAP_NET_ADMIN does not and cannot lift a read-only mount.
|
||||||
|
# It was never needed anyway: node_baseline writes net.ipv4.ip_forward=1 to
|
||||||
|
# /etc/sysctl.d/99-k3s-cilium.conf, and reading it from this pod returns 1. The
|
||||||
|
# init container now VERIFIES that (a read, which /proc/sys does allow) and fails
|
||||||
|
# closed with a pointed message, which is what the write was pretending to achieve.
|
||||||
# SNAT client traffic to the node's address. REQUIRED: without it a LAN host would
|
# SNAT client traffic to the node's address. REQUIRED: without it a LAN host would
|
||||||
# reply to 10.90.0.x, find no route, hand the packet to the router, and the router
|
# reply to 10.90.0.x, find no route, hand the packet to the router, and the router
|
||||||
# would drop it — so every connection would hang rather than fail. Matching on
|
# would drop it — so every connection would hang rather than fail. Matching on
|
||||||
@@ -217,8 +274,10 @@ spec:
|
|||||||
# `kubectl get pod -o wide` shows what this listener owns.
|
# `kubectl get pod -o wide` shows what this listener owns.
|
||||||
- { name: wireguard, containerPort: 51820, hostPort: 51820, protocol: UDP }
|
- { name: wireguard, containerPort: 51820, hostPort: 51820, protocol: UDP }
|
||||||
securityContext:
|
securityContext:
|
||||||
# NET_ADMIN is the whole privilege budget: create the wg0 link, set the host sysctl, and
|
# NET_ADMIN is the whole privilege budget: create the wg0 link and write the netfilter
|
||||||
# write the two netfilter rules above. Everything else is dropped, and the container is
|
# rules above. (It does NOT let the pod set a host sysctl — /proc/sys is read-only under
|
||||||
|
# hostNetwork — which is why nothing here tries.) Everything else is dropped, and the
|
||||||
|
# container is
|
||||||
# NOT privileged — which also keeps it clear of restrict-host-namespaces' second rule.
|
# NOT privileged — which also keeps it clear of restrict-host-namespaces' second rule.
|
||||||
# SYS_MODULE is deliberately NOT granted: the `wireguard` module is loaded by the node
|
# SYS_MODULE is deliberately NOT granted: the `wireguard` module is loaded by the node
|
||||||
# (infra/ansible/roles/node_baseline, node_baseline_kernel_modules), so this container
|
# (infra/ansible/roles/node_baseline, node_baseline_kernel_modules), so this container
|
||||||
@@ -232,6 +291,16 @@ spec:
|
|||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
# readOnlyRootFilesystem intentionally UNSET: the image's s6 init writes under /run and
|
# readOnlyRootFilesystem intentionally UNSET: the image's s6 init writes under /run and
|
||||||
# /var during startup. Same deferral as crafty/headlamp.
|
# /var during startup. Same deferral as crafty/headlamp.
|
||||||
|
#
|
||||||
|
# EXPECTED, HARMLESS STARTUP NOISE — do not "fix" it. Because capabilities are dropped
|
||||||
|
# to NET_ADMIN only, the image's s6 init cannot chown /config (no CAP_CHOWN) and logs a
|
||||||
|
# run of `chown: ... Operation not permitted` plus "Permissions could not be set. This
|
||||||
|
# is probably because your volume mounts are remote or read-only" and a scary "we will
|
||||||
|
# not provide support for it". It is wrong about the cause and it does not matter:
|
||||||
|
# /config is a writable tmpfs emptyDir, everything here runs as root (uid 0) anyway, and
|
||||||
|
# wg-quick proceeds normally — verified on prod 2026-10-06, where the tunnel's only
|
||||||
|
# failure was the sysctl above, well past these warnings. Granting CAP_CHOWN to silence
|
||||||
|
# log noise would widen the privilege set of an internet-facing pod for nothing.
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 25m
|
cpu: 25m
|
||||||
@@ -272,14 +341,22 @@ spec:
|
|||||||
# during startup rather than at a point anyone would connect to the volume size. Still
|
# during startup rather than at a point anyone would connect to the volume size. Still
|
||||||
# counted against the container's 128Mi limit, and still far more than wg0.conf needs.
|
# counted against the container's 128Mi limit, and still far more than wg0.conf needs.
|
||||||
sizeLimit: 8Mi
|
sizeLimit: 8Mi
|
||||||
# The ONLY secret in this component. Created OUT OF BAND by the operator and referenced by
|
# The ONLY secret in this component. SOPS-encrypted IN THIS TREE (R-SEC-01, spec docs/07) as
|
||||||
# name (R-IDP-04) — runbooks/vpn-remote-access.md step 1. It is NOT a *.sops.yaml in this
|
# wg-server-key.sops.yaml, delivered by Flux, referenced here by name only (R-IDP-04).
|
||||||
# tree on purpose: an agent must not mint a long-lived private key (CLAUDE.md §6), and a key
|
#
|
||||||
# that passed through a session transcript would have to be treated as compromised.
|
# It ships committed with a REPLACE-ME placeholder: an agent must not mint a long-lived
|
||||||
# DR: Velero's `daily` schedule excludes only `monitoring` and `unifi`, so this namespace
|
# private key (CLAUDE.md §6), so the operator generates it and `sops set`s it in
|
||||||
# and this Secret ARE captured off-cluster (platform/velero/helmrelease.yaml). Keep a copy
|
# (runbooks/vpn-remote-access.md step 2). Shipping the encrypted placeholder rather than no
|
||||||
# in the operator's password manager as well — restoring it is what makes every already
|
# file at all is what lets the resource line and both overlay decryption patches be present
|
||||||
# enrolled client work again without re-enrolling.
|
# and GREEN from the first commit — the alternative, a commented-out resource to uncomment
|
||||||
|
# later, is exactly the "file in git with no generator entry: reviewed, merged, apparently
|
||||||
|
# done, still broken" shape this repo keeps getting bitten by.
|
||||||
|
#
|
||||||
|
# DR: in git means a cluster rebuild restores it with everything else — the property an
|
||||||
|
# out-of-band `kubectl create secret` would NOT have had, and losing this key means
|
||||||
|
# re-enrolling every device because each client profile pins its public half. Velero also
|
||||||
|
# captures this namespace (its `daily` schedule excludes only `monitoring` and `unifi`).
|
||||||
|
# Keep a password-manager copy too: that is the one that survives losing cluster AND backups.
|
||||||
- name: wg-server-key
|
- name: wg-server-key
|
||||||
secret:
|
secret:
|
||||||
secretName: wg-server-key
|
secretName: wg-server-key
|
||||||
|
|||||||
@@ -4,27 +4,66 @@
|
|||||||
# same placement as crafty/headlamp/homepage/runbooks, for the same reason: a leaf whose pod can be
|
# same placement as crafty/headlamp/homepage/runbooks, for the same reason: a leaf whose pod can be
|
||||||
# legitimately unhealthy must never sit in the gating `platform` tier, where one component's
|
# legitimately unhealthy must never sit in the gating `platform` tier, where one component's
|
||||||
# unhealth stalls platform-config and every apps wave behind it (DECISIONS-LOG: "Headlamp gating
|
# unhealth stalls platform-config and every apps wave behind it (DECISIONS-LOG: "Headlamp gating
|
||||||
# stall"). That rationale is unusually concrete here: until the operator creates the `wg-server-key`
|
# stall"). That rationale is unusually concrete here: until the operator replaces the `REPLACE-ME`
|
||||||
# Secret (runbooks/vpn-remote-access.md step 1) this DaemonSet CANNOT start, so putting it in the
|
# placeholder in wg-server-key.sops.yaml (runbooks/vpn-remote-access.md step 2) this DaemonSet
|
||||||
# gating tier would have taken the whole platform down on merge.
|
# CANNOT start, so putting it in the gating tier would have taken the whole platform down on merge.
|
||||||
#
|
#
|
||||||
# NOT in platform/kustomization.yaml — see clusters/base/vpn-ingress.yaml.
|
# NOT in platform/kustomization.yaml — see clusters/base/vpn-ingress.yaml.
|
||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
|
# REQUIRED for the configMapGenerator below to be wired up at all, not merely tidy. A generated
|
||||||
|
# ConfigMap inherits NO namespace, every other object here declares `vpn-ingress`, and kustomize's
|
||||||
|
# nameReference transformer will not rewrite a reference across a namespace boundary — so without
|
||||||
|
# this the DaemonSet keeps mounting the unhashed name `wg-peers`, which does not exist, and the
|
||||||
|
# pods wedge on a missing volume. Verified by building it both ways (kustomize v5.8.1).
|
||||||
|
# Same placement as runbooks/kustomization.yaml, which uses `namespace: runbooks` for its own
|
||||||
|
# content generator.
|
||||||
|
namespace: vpn-ingress
|
||||||
resources:
|
resources:
|
||||||
- namespace.yaml
|
- namespace.yaml
|
||||||
# The peer register: which devices may dial in. PLAINTEXT and reviewable on purpose (see the file).
|
|
||||||
- peers.yaml
|
|
||||||
# The listener itself: hostNetwork DaemonSet, udp/51820 on every node.
|
# The listener itself: hostNetwork DaemonSet, udp/51820 on every node.
|
||||||
- daemonset.yaml
|
- daemonset.yaml
|
||||||
# Namespace default-deny baseline. NOTE: does NOT constrain the hostNetwork DaemonSet — the file
|
# Namespace default-deny baseline. NOTE: does NOT constrain the hostNetwork DaemonSet — the file
|
||||||
# says so at length rather than implying a protection it cannot provide.
|
# says so at length rather than implying a protection it cannot provide.
|
||||||
- networkpolicy.yaml
|
- networkpolicy.yaml # The server's WireGuard private key — the ONE secret here. SOPS-encrypted (R-SEC-01, spec
|
||||||
|
# docs/07), decrypted per-env by the `vpn-ingress` Kustomization, referenced by name only from
|
||||||
|
# daemonset.yaml (R-IDP-04). Its plaintext `.example` sibling carries the operator hand-off --
|
||||||
|
# deliberately NOT named here: conformance/tests/l0-static/no-plaintext-secrets fails any
|
||||||
|
# kustomization that mentions an unencrypted template, because naming one is how a plaintext
|
||||||
|
# Secret gets applied by accident. (It matches comments too. That is correct, not overzealous.)
|
||||||
|
#
|
||||||
|
# SHIPS WITH A `REPLACE-ME` PLACEHOLDER, already encrypted. An agent must not
|
||||||
|
# mint a long-lived private key (CLAUDE.md §6), and the init container validates the value with
|
||||||
|
# `wg pubkey`, so the unfilled state is a loud CrashLoop naming this file, never a server that
|
||||||
|
# quietly cannot handshake.
|
||||||
|
- wg-server-key.sops.yaml
|
||||||
#
|
#
|
||||||
# DELIBERATELY ABSENT: a Secret. The server's private key (`wg-server-key`) is created out of band
|
# THIS COMPONENT CARRIES AN ENCRYPTED SECRET, so BOTH overlays must patch the `vpn-ingress`
|
||||||
# by the operator and referenced BY NAME from daemonset.yaml. There is no `*.sops.yaml` here, so
|
# Kustomization with a `decryption:` stanza — clusters/dev and clusters/prod both do, added by the
|
||||||
# this component needs NO SOPS decryption stanza in clusters/dev or clusters/prod — unlike
|
# same change as this file. That patch is as load-bearing as the file itself: without it kustomize
|
||||||
# crafty/headlamp/velero. If that key is ever migrated into SOPS, BOTH halves must land together:
|
# hands Flux the raw ciphertext, the Secret APPLIES CLEANLY full of ENC[...], and nothing in any
|
||||||
# the file here, and a `decryption:` patch naming the `vpn-ingress` Kustomization in each overlay.
|
# Flux condition looks wrong. The init container's `wg pubkey` guard exists partly to turn exactly
|
||||||
# Without the patch, kustomize hands Flux the raw ciphertext and the pod mounts a key full of
|
# that into a named error rather than a silently non-working tunnel.
|
||||||
# ENC[...] — which applies cleanly and fails only at handshake time.
|
|
||||||
|
# ── THE PEER REGISTER, as a CONTENT-HASHED ConfigMap ─────────────────────────────────────────
|
||||||
|
# peers.conf (plaintext, reviewable — see the file) becomes the `wg-peers` ConfigMap. It is a
|
||||||
|
# GENERATOR, not a plain resource, and that distinction is load-bearing rather than stylistic:
|
||||||
|
# kustomize appends a hash of the content to the generated name and rewrites the DaemonSet's
|
||||||
|
# volume reference to match, so adding or revoking a peer changes the pod template and ROLLS THE
|
||||||
|
# PODS automatically.
|
||||||
|
#
|
||||||
|
# A fixed-name ConfigMap (which is what this component shipped first) does not do that. Flux
|
||||||
|
# updates it in place, the pod template is byte-identical, no rollout is triggered — and because
|
||||||
|
# the init container assembles wg0.conf ONCE at pod start into a tmpfs, the running tunnel keeps
|
||||||
|
# the old peer list indefinitely. Measured on prod 2026-10-06: a merged peer reached the
|
||||||
|
# ConfigMap while `wg show` listed no peers and the DaemonSet still read generation=1, having
|
||||||
|
# never rolled. The runbook's "PR -> merge -> Flux rolls the DaemonSet" was false until this.
|
||||||
|
#
|
||||||
|
# DO NOT set `disableNameSuffixHash` here. The hash IS the mechanism.
|
||||||
|
configMapGenerator:
|
||||||
|
- name: wg-peers
|
||||||
|
files:
|
||||||
|
- peers.conf
|
||||||
|
options:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: vpn-ingress
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
# protects this port, and it would be enforcing nothing. So there isn't one.
|
# protects this port, and it would be enforcing nothing. So there isn't one.
|
||||||
#
|
#
|
||||||
# WHAT ACTUALLY PROTECTS THE EXPOSED PORT, then, since the CNI does not:
|
# WHAT ACTUALLY PROTECTS THE EXPOSED PORT, then, since the CNI does not:
|
||||||
# 1. WireGuard's cryptographic peer authentication. A packet whose key is not in peers.yaml gets
|
# 1. WireGuard's cryptographic peer authentication. A packet whose key is not in peers.conf gets
|
||||||
# NO REPLY — see README "Why this is safe to expose". This is the real control.
|
# NO REPLY — see README "Why this is safe to expose". This is the real control.
|
||||||
# 2. The SOHO router's forward: only udp/51820 is forwarded inbound, to one node.
|
# 2. The SOHO router's forward: only udp/51820 is forwarded inbound, to one node.
|
||||||
# 3. The blast radius once authenticated is deliberately the whole LAN — that IS the requirement
|
# 3. The blast radius once authenticated is deliberately the whole LAN — that IS the requirement
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
---
|
# THIS FILE IS THE MOUNTED CONTENT. It is turned into the `wg-peers` ConfigMap by
|
||||||
|
# `configMapGenerator` in kustomization.yaml, whose generated name carries a CONTENT HASH — so
|
||||||
|
# editing this file changes the ConfigMap name, which changes the DaemonSet's pod template, which
|
||||||
|
# ROLLS THE PODS. That is the whole reason it is a generator and not an inline ConfigMap manifest:
|
||||||
|
# a fixed-name ConfigMap updates in place, the pod template never changes, no rollout happens, and
|
||||||
|
# the init container's wg0.conf (built once at pod start, in tmpfs) keeps the OLD peer list
|
||||||
|
# forever. Measured 2026-10-06: a merged peer sat in the ConfigMap while `wg show` listed none and
|
||||||
|
# the DaemonSet reported generation=1 — it had never rolled. Same mechanism and same fix as
|
||||||
|
# runbooks/kustomization.yaml.
|
||||||
|
#
|
||||||
|
# Every line here is WireGuard config. `#` is a legal comment in it, so the documentation below
|
||||||
|
# travels inside the file that is actually mounted, rather than in a wrapper that gets stripped.
|
||||||
|
#
|
||||||
# ============================================================================================
|
# ============================================================================================
|
||||||
# THE PEER REGISTER — every device allowed to dial in, in PLAINTEXT, in git, on purpose.
|
# THE PEER REGISTER — every device allowed to dial in, in PLAINTEXT, in git, on purpose.
|
||||||
# ============================================================================================
|
# ============================================================================================
|
||||||
@@ -41,33 +53,40 @@
|
|||||||
# the Noise handshake and would be a genuine secret — which would force this register into SOPS
|
# the Noise handshake and would be a genuine secret — which would force this register into SOPS
|
||||||
# and cost the reviewability above. Revisit only if the threat model changes; if it does, the PSK
|
# and cost the reviewability above. Revisit only if the threat model changes; if it does, the PSK
|
||||||
# belongs in the `wg-server-key` Secret, not here. See README "Hardening not taken".
|
# belongs in the `wg-server-key` Secret, not here. See README "Hardening not taken".
|
||||||
apiVersion: v1
|
#
|
||||||
kind: ConfigMap
|
# name: wg-peers
|
||||||
metadata:
|
# namespace: vpn-ingress
|
||||||
name: wg-peers
|
# labels:
|
||||||
namespace: vpn-ingress
|
# app.kubernetes.io/name: vpn-ingress
|
||||||
labels:
|
# Appended VERBATIM to the generated [Interface] block, in this order, at pod start.
|
||||||
app.kubernetes.io/name: vpn-ingress
|
#
|
||||||
data:
|
# *** THERE ARE NO PEERS YET. THAT IS THE CORRECT INITIAL STATE, NOT AN OVERSIGHT. ***
|
||||||
# Appended VERBATIM to the generated [Interface] block, in this order, at pod start.
|
# An agent must not mint key material (CLAUDE.md §6), so no device could be enrolled as part of
|
||||||
#
|
# authoring this component. A zero-peer WireGuard interface is valid and starts cleanly: the
|
||||||
# *** THERE ARE NO PEERS YET. THAT IS THE CORRECT INITIAL STATE, NOT AN OVERSIGHT. ***
|
# DaemonSet will run and listen, and every handshake will be refused because no key is trusted.
|
||||||
# An agent must not mint key material (CLAUDE.md §6), so no device could be enrolled as part of
|
# The VPN is therefore INERT-BUT-HEALTHY until the first stanza lands — fail-closed, and visible
|
||||||
# authoring this component. A zero-peer WireGuard interface is valid and starts cleanly: the
|
# (`wg show` reports an interface with no peers).
|
||||||
# DaemonSet will run and listen, and every handshake will be refused because no key is trusted.
|
#
|
||||||
# The VPN is therefore INERT-BUT-HEALTHY until the first stanza lands — fail-closed, and visible
|
# COPY THIS TEMPLATE, uncomment, one block per device:
|
||||||
# (`wg show` reports an interface with no peers).
|
#
|
||||||
#
|
# # <device-name> — <owner>, enrolled <YYYY-MM-DD>
|
||||||
# COPY THIS TEMPLATE, uncomment, one block per device:
|
# [Peer]
|
||||||
#
|
# PublicKey = <the public key new-peer.sh printed>
|
||||||
# # pixel-8 — Keith's phone, enrolled 2026-10-05
|
# AllowedIPs = 10.90.0.<octet>/32
|
||||||
# [Peer]
|
#
|
||||||
# PublicKey = <the public key new-peer.sh printed>
|
# The placeholders above are deliberately NOT valid values. An earlier version of this template
|
||||||
# AllowedIPs = 10.90.0.2/32
|
# used a real-looking `10.90.0.2/32`, and new-peer.sh's duplicate-address check — which grepped
|
||||||
#
|
# the whole file — matched it, so `.2` could never be allocated and the script insisted it was
|
||||||
# Keep the `# <name> — <owner>, enrolled <date>` comment line. It is the only place a key is
|
# already taken. The check now ignores comments; keeping the example unmatchable too means a
|
||||||
# tied to a human-recognisable device; `wg show` can only ever print the key itself, so without
|
# future grep cannot be fooled either.
|
||||||
# it an incident responder cannot tell which stanza to delete.
|
#
|
||||||
peers.conf: |
|
# Keep the `# <name> — <owner>, enrolled <date>` comment line. It is the only place a key is
|
||||||
# No peers enrolled yet. See the template in this file's comments and
|
# tied to a human-recognisable device; `wg show` can only ever print the key itself, so without
|
||||||
# ./infra/vpn-ingress/new-peer.sh. One [Peer] block per device.
|
# it an incident responder cannot tell which stanza to delete.
|
||||||
|
#
|
||||||
|
# No peers enrolled yet. See the template in this file's comments and
|
||||||
|
# ./infra/vpn-ingress/new-peer.sh. One [Peer] block per device.
|
||||||
|
# keith-phone — <owner>, enrolled 2026-10-06
|
||||||
|
[Peer]
|
||||||
|
PublicKey = pBRSHrRyG2hnsuZ6FvHfEzF8hOVTF5stHUb/7fW2Km8=
|
||||||
|
AllowedIPs = 10.90.0.2/32
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# WireGuard SERVER private key for platform/vpn-ingress — SOPS-ENCRYPTED (R-SEC-01, spec docs/07).
|
||||||
|
# Keys stay cleartext for reviewable diffs; only `stringData` values are encrypted (.sops.yaml
|
||||||
|
# `encrypted_regex`). Plaintext template + operator hand-off: wg-server-key.sops.yaml.example.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: wg-server-key
|
||||||
|
namespace: vpn-ingress
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
#ENC[AES256_GCM,data:fUoPkfWvasisdTtwdhjeOVOlmdQkTl6Bp9YxYyji8g6k+ClATds3BnHJWOj2iplaGpT2XaNakq1+D4YyOi7QbFvNJui+e4qRpqcY8hWnazvZIHJYxjxfAfzW,iv:xtKd6oMZQumAQJAsNLHqJB+o0wdQJ+vL1kI1mc9jL7w=,tag:aImqLgbSmhsRI36/t5kpyg==,type:comment]
|
||||||
|
#ENC[AES256_GCM,data:npDsCW6rQleolIpur5gFkX2jSRrIQzBlGaIPs2Schv0YU3q8AanG39z4QqT3ASDmskV5YuH29Bo0ujEAgBf52YIjQCp58PDDETofrVKOJzhHMa7bV+HCxUFE,iv:7E7thyFBleyqZpqJRGApKvIq3ubCFOkshF/mxim3YPc=,tag:joIrhMe2I6kUnx1ouOPdZw==,type:comment]
|
||||||
|
#ENC[AES256_GCM,data:wHUFx09FgP/2+LTyw61EFbKA5UaqfjU7CeGk6Xc5av2y4r4AvQJRefDM5EGlahKecDFEFsf7fSQRFRHtUJqrQTtw+6tcj3ubSCPW4iJvhyJJv0xdb/0puRmcgj+2NfgF,iv:xgDOSDLqMQ1wcI9vrkKqfNMgIDFEusFnnO7Rzq/aOa4=,tag:m1m/fgjkBsG91c+3daqOpA==,type:comment]
|
||||||
|
privatekey: ENC[AES256_GCM,data:ML8CPNm4JwhQHUTUUxZO/I+9X15BCIGqOb1Uw9EmX5RTAsxQ9N97YvkYlbY=,iv:5ZUsFdMMmeGsy4fNbk6qbJkzweMVWXZ5g6E7NciAGUg=,tag:pAHv3ieLjoEyFbqbWXsVgg==,type:str]
|
||||||
|
sops:
|
||||||
|
kms: []
|
||||||
|
gcp_kms: []
|
||||||
|
azure_kv: []
|
||||||
|
hc_vault: []
|
||||||
|
age:
|
||||||
|
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMays2OXY0VkpRc2FQQmhU
|
||||||
|
Q0owM3N0VkhYR0lYTUdNZWRqeTFIMjNHOVJ3CnJSMnc5NFEyU0IzWTFtUkw2YVFS
|
||||||
|
MU02VFo1TE14YVpZRzNhVjZwRTZwd0UKLS0tIDFaMWF4NUZjZmhRdXRuZFJYNkg3
|
||||||
|
SUUzN1FnMlI4bkkzbVNlMWhUVnJZODAKQ+Iy86CIv/Oj9E9j4E/0y5hDbcctRuqB
|
||||||
|
OR825KRPRaTdF/G2hudE1R1JbM4wGbPLMzv078R4ZXHDPGvDttSEkA==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
lastmodified: "2026-10-06T06:10:31Z"
|
||||||
|
mac: ENC[AES256_GCM,data:6wAGAwWL7jI/NEeswBnUiWoRty+bb5DEsGEAPXajKNNYCwtm5yuXBrcOI/q4w6H8FG+nqVAECkdpAOI3S9mjDfc2zlWAzfCJJdDic92RKPXOC0tOH/M1BBSGJ1fVdJGAZZT1yAVxmtYy9WohJpawv3kmsVv+JnD+TIqmZ1CJHXo=,iv:srs4o0olSwmraQQgmdPxb+4h/t6fHHeFKJ2z914kGgY=,tag:jjoIKhU8Tu9i5SJkvkfcCg==,type:str]
|
||||||
|
pgp: []
|
||||||
|
encrypted_regex: ^(data|stringData)$
|
||||||
|
version: 3.9.4
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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.conf) 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.
|
||||||
|
#
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# 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. 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. 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. 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"'
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
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
|
||||||
+116
-40
@@ -21,7 +21,7 @@ behaves like a machine on the LAN: full reach to `192.168.2.0/24`, the Gateway's
|
|||||||
DNS, and internet egress out the house WAN.
|
DNS, and internet egress out the house WAN.
|
||||||
|
|
||||||
Authorisation is one thing only: the device's **public key is in
|
Authorisation is one thing only: the device's **public key is in
|
||||||
`platform/vpn-ingress/peers.yaml`**. An unlisted key gets no reply at all.
|
`platform/vpn-ingress/peers.conf`**. An unlisted key gets no reply at all.
|
||||||
|
|
||||||
> **An enrolled device is as privileged as a machine sitting in the house** — including the three
|
> **An enrolled device is as privileged as a machine sitting in the house** — including the three
|
||||||
> k3s nodes (`192.168.2.10/.11/.12`: apiserver 6443, kubelet 10250, SSH 22). That is what "reach
|
> k3s nodes (`192.168.2.10/.11/.12`: apiserver 6443, kubelet 10250, SSH 22). That is what "reach
|
||||||
@@ -62,53 +62,109 @@ lsmod | grep '^wireguard'
|
|||||||
grep -w wireguard /etc/modules-load.d/k3s-cilium-longhorn.conf
|
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
|
The key is **SOPS-encrypted in this tree** (R-SEC-01, spec docs/07), not created by hand:
|
||||||
agent session or pasted into a transcript. Generate it on your own machine:
|
`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
|
```bash
|
||||||
mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
umask 077
|
cd /work
|
||||||
wg genkey > ~/.config/wireguard-home/server.key # keep this; see "Back it up" below
|
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
|
**2. Record it in your PASSWORD MANAGER.** Derive both halves from the file you just wrote:
|
||||||
lands in shell history, and never print the resulting Secret's `.data`** (CLAUDE.md §6):
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./infra/devcontainer/dev.sh run bash -c \
|
# the PUBLIC half — safe to display, and what every client profile carries
|
||||||
'kubectl --context k3s-prod -n vpn-ingress create secret generic wg-server-key \
|
./infra/devcontainer/dev.sh run bash -c 'cd /work && sops -d --extract "[\"stringData\"][\"privatekey\"]" \
|
||||||
--from-file=privatekey=/work/.cache/server.key'
|
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
|
The second command is the one place this key is deliberately shown. The encrypted file is in git and
|
||||||
it if absent). Delete that copy as soon as the Secret exists: `shred -u .cache/server.key`.
|
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
|
**3. Confirm the value is a real key**, without printing it — if `wg pubkey` prints a key, the
|
||||||
> is `*.key`, and it is the **only** rule covering this path — `.cache/` itself is NOT ignored
|
value is valid; if it errors, it is still wrong:
|
||||||
> (`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**:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./infra/devcontainer/dev.sh run bash -c \
|
./infra/devcontainer/dev.sh run bash -c 'cd /work && sops -d --extract "[\"stringData\"][\"privatekey\"]" \
|
||||||
'kubectl --context k3s-prod -n vpn-ingress get secret wg-server-key -o jsonpath="{.data}" | tr "," "\n" | cut -d: -f1'
|
platform/vpn-ingress/wg-server-key.sops.yaml | wg pubkey >/dev/null && echo "valid WireGuard key"'
|
||||||
# expected: {"privatekey -- a NAME. If you see base64 payload, you printed the key. Rotate it.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why out of band and not SOPS.** An agent must not mint a long-lived private key, and a key that
|
That is the same check the pod's init container runs, so a pass here predicts a pass there.
|
||||||
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:
|
|
||||||
|
|
||||||
**Back it up.** Keep `server.key` in your password manager. Velero's `daily` schedule excludes only
|
**4. PR it, and merge.** Flux rolls the DaemonSet and the pods leave CrashLoop.
|
||||||
`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
|
If you would rather use an editor than `sops set`:
|
||||||
again after a cluster rebuild — **losing it means re-enrolling every device.**
|
`./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 |
|
||||||
|
| `net.ipv4.ip_forward is 0 on this node` | the node_baseline role has not run here (step 1). The pod **cannot** set this itself — under `hostNetwork` the runtime mounts `/proc/sys` read-only, because it is the host's global setting |
|
||||||
|
| `0 peer(s)` after you merged a peer | the DaemonSet did not roll. Check its `generation` (above). Pre-2026-10-06 this was guaranteed, because the register was a fixed-name ConfigMap; if it recurs, something has reverted `peers.conf` to a plain resource instead of a `configMapGenerator` |
|
||||||
|
|
||||||
|
### What a healthy start looks like
|
||||||
|
|
||||||
|
Measured on prod 2026-10-06. The init container logs one line, naming the shape and never the key:
|
||||||
|
|
||||||
|
```
|
||||||
|
wg0.conf assembled: 0 peer(s), server 10.90.0.1/24, udp/51820.
|
||||||
|
```
|
||||||
|
|
||||||
|
then the `wireguard` container brings the tunnel up:
|
||||||
|
|
||||||
|
```
|
||||||
|
**** Found WG conf /config/wg_confs/wg0.conf, adding to list ****
|
||||||
|
**** Activating tunnel /config/wg_confs/wg0.conf ****
|
||||||
|
[#] ip link add dev wg0 type wireguard
|
||||||
|
[#] wg addconf wg0 /dev/fd/63
|
||||||
|
[#] ip -4 address add 10.90.0.1/24 dev wg0
|
||||||
|
[#] ip link set mtu 1420 up dev wg0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ignore the `chown: ... Operation not permitted` run before that**, and the image's own
|
||||||
|
"Permissions could not be set ... we will not provide support for it". Capabilities are dropped to
|
||||||
|
`NET_ADMIN` only, so its s6 init has no `CAP_CHOWN`; `/config` is a writable tmpfs, everything runs
|
||||||
|
as root, and `wg-quick` proceeds. It is wrong about the cause and it does not matter.
|
||||||
|
|
||||||
|
**A pod stuck `0/1 Running` with `Startup probe failed: Unable to access interface: No such device`
|
||||||
|
means `wg-quick` tore the tunnel down** — read the `wireguard` container log for the `[#]` line it
|
||||||
|
died on. That is the shape of a real failure; the probe is doing its job of refusing to call a
|
||||||
|
server without a `wg0` "ready".
|
||||||
|
|
||||||
## Step 3 — forward udp/51820 on the router (once)
|
## Step 3 — forward udp/51820 on the router (once)
|
||||||
|
|
||||||
@@ -149,17 +205,37 @@ unauthenticated traffic either way. If the pod is Ready, the interface exists.
|
|||||||
## Step 5 — enrol a device
|
## Step 5 — enrol a device
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./infra/vpn-ingress/new-peer.sh pixel-8 2 # full tunnel (LAN + WAN egress)
|
# in the dev container, like everything else
|
||||||
./infra/vpn-ingress/new-peer.sh thinkpad 3 --split # split tunnel (LAN only)
|
./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 derives the server's public key from `wg-server-key.sops.yaml` itself, so there is no local key
|
||||||
prints a QR code if `qrencode` is installed, and prints the `[Peer]` stanza to add. Then:
|
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.
|
||||||
|
|
||||||
1. Paste the stanza into `platform/vpn-ingress/peers.yaml` under `data.peers.conf`, keeping the
|
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. Append the stanza to `platform/vpn-ingress/peers.conf`, keeping the
|
||||||
`# <name> — <owner>, enrolled <date>` comment. `wg show` can only ever print the key, so that
|
`# <name> — <owner>, enrolled <date>` comment. `wg show` can only ever print the key, so that
|
||||||
comment is the only record of which stanza belongs to which device.
|
comment is the only record of which stanza belongs to which device.
|
||||||
2. PR → merge. Flux rolls the DaemonSet (~1 min).
|
2. PR → merge. Flux rolls the DaemonSet (~1 min), because `peers.conf` is a
|
||||||
|
**content-hashed** `configMapGenerator`: editing it changes the ConfigMap's generated name,
|
||||||
|
which changes the pod template, which forces the roll. (It did **not** work this way at first —
|
||||||
|
a fixed-name ConfigMap updates in place, the pod template never changes, and the init container
|
||||||
|
keeps the old peer list forever. Measured on prod 2026-10-06.) Confirm it actually rolled:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n vpn-ingress get ds wireguard \
|
||||||
|
-o jsonpath="{.metadata.generation}{\"/\"}{.status.observedGeneration}{\"\n\"}"'
|
||||||
|
```
|
||||||
|
|
||||||
|
The generation must have **increased** since before the merge. If it is still `1`, the roll did
|
||||||
|
not happen and `wg show` will list no peer no matter how long you wait.
|
||||||
3. Get the profile onto the device (QR scan for phones; "Import tunnel from file" on
|
3. Get the profile onto the device (QR scan for phones; "Import tunnel from file" on
|
||||||
Windows/macOS; `/etc/wireguard/<name>.conf` + `wg-quick@<name>` on Linux).
|
Windows/macOS; `/etc/wireguard/<name>.conf` + `wg-quick@<name>` on Linux).
|
||||||
4. **Delete the profile from your machine**: `shred -u <path>`. It is the only copy of that
|
4. **Delete the profile from your machine**: `shred -u <path>`. It is the only copy of that
|
||||||
@@ -199,7 +275,7 @@ if the router is not delivering the packets, and a `handshake` with no completed
|
|||||||
|
|
||||||
## Revoking a device
|
## Revoking a device
|
||||||
|
|
||||||
Delete its `[Peer]` stanza from `peers.yaml`, PR, merge. Nothing else — the key stops working on the
|
Delete its `[Peer]` stanza from `peers.conf`, PR, merge. Nothing else — the key stops working on the
|
||||||
next reconcile. **Never re-use that address octet**: an old profile would silently start working
|
next reconcile. **Never re-use that address octet**: an old profile would silently start working
|
||||||
again as a different device.
|
again as a different device.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user