The operator enrolled a phone, merged the peer, and `wg show` listed none. Measured on prod 2026-10-06 with the peer already on main: live wg-peers ConfigMap, active AllowedIPs .. 0 DaemonSet volume reference ................. configMap.name: wg-peers (FIXED) DaemonSet generation/observedGeneration .... 1 / 1 (never rolled) init container, all three pods ............. "0 peer(s)" Part of it was just the 10m reconcile interval. The defect is the fixed name: Flux updates a fixed-name ConfigMap IN PLACE, so the pod template is byte-identical, so no rollout is triggered -- and the init container assembles wg0.conf ONCE at pod start into a tmpfs, so the running tunnel keeps the old peer list indefinitely. Adding or revoking a peer had no effect until something unrelated restarted the pods. For a REVOCATION that is a security defect, not an inconvenience: delete a stanza, see a green PR, device keeps working. The runbook and README both claimed "PR -> merge -> Flux rolls the DaemonSet". Never true. And this repo already documents the fix, in a file I had read -- 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: peers.yaml (inline ConfigMap) -> peers.conf (plain file) + a configMapGenerator. The operator's enrolled peer is carried across verbatim. Every line is WireGuard config and `#` is a legal comment there, so the documentation now lives inside the file that is actually mounted. `namespace: vpn-ingress` on the kustomization is REQUIRED, not tidiness: a generated ConfigMap inherits no namespace, everything else here declares vpn-ingress, and kustomize's nameReference will not rewrite across a namespace boundary -- without it the build mounts the unhashed name and the pods wedge on a missing volume. Caught only by building the output and reading the volumes. new-peer.sh: PEERS_FILE repointed, and a missing register is now FATAL rather than skipped -- the old `[ -r "$PEERS_FILE" ] && grep ...` guard meant that after the rename the only symptom would have been duplicate detection quietly never firing again. Stale peers.yaml references swept from six files including the script's own user-facing output. Verified: the DaemonSet now mounts wg-peers-m9kcckcb5t; the hash tracks content (edit -> dgg7ht9c74 -> restore -> m9kcckcb5t); the enrolled peer is byte-identical to main's; and `new-peer.sh dupe 2` is now correctly refused by the operator's real allocation. No sensitive paths; merges on a green gate. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
6.5 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-06 | fix/peers-configmap-content-hash | 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 aconfigMapGeneratorinkustomization.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-ingressadded to the kustomization — REQUIRED, not tidiness. A generated ConfigMap inherits no namespace; every other object here declaresvpn-ingress; and kustomize'snameReferencetransformer will not rewrite a reference across a namespace boundary. Without it the build emittedConfigMap wg-peers-m9kcckcb5twhile the DaemonSet still mounted the unhashedwg-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_FILErepointed topeers.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.yamlreferences 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
generationmust increase after the merge — and a troubleshooting row for0 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-m9kcckcb5tin namespacevpn-ingress, and the DaemonSet mountswg-peers-m9kcckcb5t. Before addingnamespace:the same build mounted the unhashedwg-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 onmain, compared directly againstgit show origin/main:platform/vpn-ingress/peers.yaml. - The build still emits all five objects: Namespace, ConfigMap, Secret, DaemonSet,
NetworkPolicy — and the
Namespaceobject's own name is stillvpn-ingress(the namespace transformer did not rewrite it). new-peer.shworks against the new register:spare-laptop 7renders a profile and prints "append this to platform/vpn-ingress/peers.conf";dupe 2is refused because the operator's real.2now 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,l1andapproval-policyall 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
generationincreasing andwg0.conf assembled: 1 peer(s)in the init log, followed bywg showlisting 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-peersConfigMap is actually pruned. Reasoned fromprune: trueon the Kustomization, not observed.