Compare commits

..
Author SHA1 Message Date
jakman85andClaude Opus 5 16894b7b73 feat(keycloak): declare passkey (WebAuthn passwordless) login for the homelab realm
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
Adds the realm's WebAuthn Passwordless policy and restructures the browser-mfa
flow so a passkey is an ALTERNATIVE beside the password form. Additive and
optional by design: this IdP gates every SSO app plus kubectl, so a mandatory
passkey flow that misfires is a total lock-out. Enrolment is not forced.

Policy: RP ID is ${BASE_DOMAIN} (not the keycloak host) because a passkey's RP ID
is fixed at enrolment and must not be bound to a name that can move; resident key
required (a non-discoverable credential is a second factor, not a passkey); user
verification required (without it, possession alone is a complete login).

Flow: auth-username-password-form is split into auth-username-form +
auth-password-form, because the server must know who is logging in before it can
decide whether a passkey exists. Conditional OTP for platform-admins is relocated
intact onto the password branch only — a UV-required passkey is already two
phishing-resistant factors.

No requiredActions block is declared, deliberately: Keycloak 26.0.6 treats
requiredActions as REPLACE not merge, so declaring one would delete the default
set including CONFIGURE_TOTP and lock every platform-admin out of the OTP step
this flow still enforces. webauthn-register-passwordless is already registered
enabled-but-not-default by Keycloak's own defaults.

UNAPPLIED: the realm import is a one-shot resting at Done=True/Started=False, so
this reaches the live realm never. See the handoff entry and the README section.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 23:23:36 -05:00
jakman85 1a0b20857a Merge pull request 'fix(oauth2-proxy): gate startup on Keycloak discovery, or a cold boot CrashLoops the proxy' (#70) from fix/oauth2-proxy-oidc-startup-race into main
gate / gate (push) Successful in 1m52s
gate / conformance (push) Has been skipped
Reviewed-on: #70
2026-10-18 04:14:52 +00:00
jakman85andClaude Opus 5 c150327a30 fix(oauth2-proxy): gate startup on Keycloak discovery, or a cold boot CrashLoops the proxy
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
Exposed by the 2026-10-17 whole-cluster shutdown. oauth2-proxy performs OIDC
discovery ONCE at startup and exits 1 if it fails. On a cold boot Keycloak is
not serving yet, the shared Gateway answers 503, and the proxy dies:

  [provider.go:55] Performing OIDC Discovery...
  [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: ... failed to discover
               OIDC configuration: unexpected status "503": no healthy upstream

It then sits in exponential CrashLoopBackOff long after Keycloak is healthy --
one replica was still down minutes later and only a manual pod delete cleared
it. There is no retry flag, a readinessProbe cannot help (the container is
already dead), and CrashLoopBackOff backoff is not tunable per-pod.

Add an extraInitContainers gate (wait-for-idp) that polls the discovery doc
until it answers. A pod in Init accrues no CrashLoopBackOff backoff and retries
promptly. BOUNDED and FAILS OPEN: after ~5m it starts the proxy anyway, so the
worst case is exactly today's behaviour and never something new.

extraInitContainers is the chart's OWN hook for this -- its commented example is
literally `- name: wait-for-idp` -- and the key was CONFIRMED against the pinned
chart (helm show values, 10.7.1) rather than assumed, because a wrong Helm values
key is silently ignored. No new base image: the chart already runs an alpine init
container for its built-in waitForRedis gate; pinned to 3.22 (verified upstream)
rather than the chart's `latest`.

Rejected: Flux postRenderers (no precedent in this tree, and the chart has a
hook); --skip-oidc-discovery (removes the fetch entirely, but needs four
hardcoded endpoints and I could not verify lazy-JWKS offline -- this component
gates SSO for seven apps, so the additive option wins).

Shell vars are written BARE with no braced form: platform declares
postBuild.substituteFrom, and envsubst would blank a braced shell variable and
silently break the loop. Same convention as the gitea/nextcloud OIDC hooks.

UNAPPLIED; the init container has never run, and the race was not re-created to
prove the fix. Check the init container's log on first apply. See the handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 22:47:30 -05:00
jakman85 9a41a8e1e5 Merge pull request 'Expand plex volume' (#68) from plex/volume-expamsion into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #68
2026-10-10 16:13:46 +00:00
jakman85 0132c680b3 Update apps/wave-0/plex.yaml
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
2026-10-10 16:09:53 +00:00
jakman85 439b35a3f0 Expand plex volume
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
2026-10-10 13:55:15 +00:00
jakman85 e89df78210 Merge pull request 'fix(bulk-nfs): repoint at the TrueNAS (192.168.2.30); the PVs must be recreated, not reconciled' (#67) from fix/bulk-nfs-truenas into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #67
Reviewed-by: Keith Jackson <[email protected]>
2026-10-07 06:26:44 +00:00
jakman85andClaude Opus 5 0747849837 fix(bulk-nfs): repoint at the TrueNAS (192.168.2.30); the PVs must be recreated, not reconciled
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
Both ZFS pools moved off the Proxmox host onto a standalone TrueNAS on
192.168.2.30 -- prod's OWN /24, so the pfSense crossing that made this knob
load-bearing is gone, along with the DNAT at 192.168.2.102 it mounted through.
Measured 2026-10-07: 192.168.2.102:2049 no longer answers; .30:2049 and :111 do.

BULK_NFS_SERVER -> 192.168.2.30 in prod, base and dev. The dev value is marked
inert (dev is decommissioned); it carries the real address only so nothing in
the tree renders a host that no longer exists.

THE OPERATIONAL POINT, and why this is not just a value change: a bound
PersistentVolume's NFS source is immutable. Server dry-run, 2026-10-07:

  spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is
  immutable after creation

so Flux's apply FAILS rather than updating, and the pods keep their old, dead
mount. The five PVs must be deleted and recreated -- safe, because Retain never
touches data and render.py emits a pre-set claimRef so each PV rebinds its own
PVC. The sequence is docs/BULK-NFS-STORAGE.md section 10 and is an OPERATOR
action; this commit does not perform it.

Pairs with the hazard already on the page: spec.nfs.server is unvalidated at
create (any string binds) and immutable at update. A wrong value is accepted
silently and then cannot be corrected in place.

docs/BULK-NFS-STORAGE.md: sections 1 and 5 rewritten for TrueNAS -- the measured
exports, the service settings that decide whether uid 1000 survives the wire,
the three stale ZFS sharenfs properties that had to be cleared, and the ACL
hazard (acltype=nfsv4 + aclinherit=passthrough means one click in the ACL editor
propagates to the whole future library). Section 7 rewritten now that the
placeholder is gone, section 9 retargeted from dev to prod, section 10 new.
Sections 2/3/4/6 are unchanged by the move.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-07 01:15:04 -05:00
jakman85 ad37acdf53 Merge pull request 'fix(vpn-ingress): content-hash the peer register, or a merged peer never reaches the tunnel' (#66) from fix/peers-configmap-content-hash into main
gate / gate (push) Successful in 1m51s
gate / conformance (push) Has been skipped
Reviewed-on: #66
2026-10-06 07:58:49 +00:00
jakman85andClaude Opus 5 2acfed460a fix(vpn-ingress): content-hash the peer register, or a merged peer never reaches the tunnel
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
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]>
2026-10-06 02:55:59 -05:00
jakman85 e1bebb857e Merge pull request 'Added my phone to peers' (#65) from vpn/add-keith-phone into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #65
2026-10-06 07:42:04 +00:00
jakman85 23db97b958 Added my phone to peers
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
2026-10-06 02:39:23 -05:00
jakman85 5928d8153c Merge pull request 'fix(vpn-ingress): new-peer.sh counted its own commented-out example as an allocated address' (#64) from fix/new-peer-ignores-template-comments into main
gate / gate (push) Successful in 1m50s
gate / conformance (push) Has been skipped
Reviewed-on: #64
2026-10-06 07:31:03 +00:00
jakman85andClaude Opus 5 146dd9a906 fix(vpn-ingress): new-peer.sh counted its own commented-out example as an allocated address
gate / gate (pull_request) Successful in 1m51s
gate / conformance (pull_request) Has been skipped
The first real enrolment failed:

    $ ./infra/vpn-ingress/new-peer.sh keith-phone 2
    new-peer.sh: 10.90.0.2/32 is already allocated ... Pick another octet.

Nothing was allocated -- peers.yaml had zero peers, as the pod's init
container reported (`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 anyone would ask for, and the one my own runbook uses as its example
-- was permanently un-allocatable, with a message asserting the opposite of
the truth.

Fixed on both sides, because either alone leaves the trap half-set:

  * the check strips comment lines before matching, which is the correct
    predicate rather than a patch: a real stanza's AllowedIPs line is never
    commented, while the template and every peer's `# <name> — <owner>` label
    always are.
  * the example now uses placeholders that cannot match anything real
    (`10.90.0.<octet>/32`, `<device-name>`), so 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. This is its third
instance 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 a kustomization named it in a comment.
Make examples structurally unmatchable, not merely commented out.

Verified by running it: .2 now renders a profile (via the real
`sops -d | wg pubkey` path, which this also proves against the operator's
actual key for the first time), and an uncommented duplicate stanza is still
correctly refused.

No sensitive paths; merges on a green gate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 02:28:37 -05:00
jakman85 d1c07fe021 Merge pull request 'fix(vpn-ingress): stop trying to set net.ipv4.ip_forward — a hostNetwork pod cannot, and wg-quick dies on a failed PostUp' (#63) from fix/vpn-ingress-ip-forward into main
gate / gate (push) Successful in 1m51s
gate / conformance (push) Has been skipped
Reviewed-on: #63
2026-10-06 07:09:34 +00:00
jakman85andClaude Opus 5 5c43fbac65 fix(vpn-ingress): stop trying to set net.ipv4.ip_forward — a hostNetwork pod cannot, and wg-quick dies on a failed PostUp
gate / gate (pull_request) Successful in 1m51s
gate / conformance (pull_request) Has been skipped
The tunnel never came up on its first reconcile with a real key. One line of my
own manifest was the whole cause. Measured on prod 2026-10-06:

    [#] ip link add dev wg0 type wireguard        <- fine
    [#] wg addconf wg0 /dev/fd/63                 <- fine
    [#] ip -4 address add 10.90.0.1/24 dev wg0    <- fine
    [#] ip link set mtu 1420 up dev wg0           <- fine
    [#] sysctl -q -w net.ipv4.ip_forward=1
    sysctl: permission denied on key "net.ipv4.ip_forward"
    [#] ip link delete dev wg0
    **** Tunnel failed, will stop all others! ****

Not a missing capability: with hostNetwork the runtime mounts /proc/sys
READ-ONLY (confirmed in the pod's /proc/mounts), because those sysctls are the
HOST's globals. CAP_NET_ADMIN cannot lift a read-only mount, so the write can
never succeed here -- and wg-quick treats a failed PostUp as fatal, so it
deleted the interface it had just built.

The line was never needed. node_baseline already sets ip_forward=1, and reading
it from the pod returns 1. My header comment said exactly that ("so no pod
sysctl is needed") and then emitted the sysctl anyway, justified as "re-asserted
so this component does not silently depend on another role's sysctl file". That
defensive re-assertion is the entire outage. In a hostNetwork pod: VERIFY host
state, never write it.

Replaced with a read in the init container -- `grep -qx 1
/proc/sys/net/ipv4/ip_forward` -- failing closed with a message naming the
node_baseline role. Still fail-closed on purpose: a tunnel that comes up unable
to forward satisfies every probe here and silently drops all client traffic.

Also documents, in the manifest and the runbook, that the image's `chown:
Operation not permitted` run and its "we will not provide support for it"
warning are expected and harmless -- capabilities are dropped to NET_ADMIN so
its s6 init has no CAP_CHOWN, /config is a writable tmpfs, everything runs as
root, and wg-quick proceeds (it did; the failure was 30 lines later).

Three other unknowns closed by the same reconcile, now recorded in the README:
the kernel module reaches the pod (ip link add type wireguard works, no
CAP_SYS_MODULE needed); allowPrivilegeEscalation:false does NOT break the s6
init; and the image's iptables is nf_tables (xtables-nft-multi) matching Debian
13, which closes the worst documented fear -- rules landing in a table the
kernel never consults.

No sensitive paths; merges on a green gate with no override.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 01:30:08 -05:00
jakman85 cf7d5e4316 Merge pull request 'Changed secret' (#62) from feature/add-private-key-for-wireguard into main
gate / gate (push) Successful in 1m50s
gate / conformance (push) Has been skipped
Reviewed-on: #62
2026-10-06 06:18:13 +00:00
jakman85 be23cf3e26 Changed secret
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
2026-10-06 01:11:56 -05:00
jakman85 4791653f2b Merge pull request 'fix(vpn-ingress): keep the server key in source through SOPS, not as an out-of-band Secret' (#60) from fix/vpn-server-key-in-sops into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #60
Reviewed-by: Keith Jackson <[email protected]>
2026-10-06 06:09:04 +00:00
jakman85 523f482957 Merge pull request 'fix(node): pin gitea to the Gateway VIP — the failing certificate was the router's, not the Gateway's' (#59) from docs/pm-agent-registry-pull-prereq into main
gate / gate (push) Successful in 1m51s
gate / conformance (push) Has been skipped
Reviewed-on: #59
2026-10-06 05:51:22 +00:00
jakman85andClaude Opus 5 90a79d29d3 docs(node): name the exact Ansible command that applies the pin, not 'run the play'
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
Node configuration in this project is Ansible's — declared in the inventory, reviewed,
version-controlled — and for prod it is the ONLY surface: infra/PROD-PROVISIONING.md
records prod as 3x bare metal with the dev OpenTofu -> Proxmox -> cloud-init path
explicitly not applying, so there is no tofu/cloud-init half to keep in parity for an
/etc/hosts pin.

The pin was already in the right place; what the docs lacked was the command. All four
places now carry the scoped, idempotent one:

    cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
      -e k3s_hosts=k3s_prod --tags hosts

--tags hosts runs ONLY the /etc/hosts task, so applying this pin cannot drift any other
baseline setting.

Also verified the mechanism is not inert, which is the failure mode this repo keeps
meeting: roles/node_baseline/tasks/main.yml renders node_baseline_hosts_pins into a marked
blockinfile with backup: true, and state: absent when the list empties — so a removed
entry really does leave the node.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 00:44:26 -05:00
jakman85andClaude Opus 5 523a463fd7 fix(node): pin gitea to the Gateway VIP — containerd was resolving it to the non-hairpinned WAN
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
The pm-agent image pull failed TLS verification, and the previous entry on this branch
recorded it honestly as not diagnosed. Diagnosed now, and it is not about the image.

containerd pulls as a NODE process, so it resolves through node_baseline_dns_servers (the
LAN gateway, deliberately not PiHole) and gets the public answer: a CNAME to
wan.home.keithjackson.dev -> 136.51.115.252, measured via both 1.1.1.1 and 8.8.8.8. This
router does not hairpin, so WAN:443 answers with its own placeholder certificate —
measured with the right SNI from the workstation:

    subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
    No extensions in certificate

No extensions means no subjectAltName, which is exactly 'certificate is not valid for any
names'. Meanwhile 192.168.2.20 IS the shared Gateway's VIP and its https listener serves
the real Let's Encrypt *.home.keithjackson.dev. The Gateway, the wildcard cert and the
digest were never implicated — only the node's answer.

This file already documents the identical failure for the Keycloak issuer, measured from
k3s-prod-2/-3 on 2026-10-03 (curl exit 60, ssl_verify_result=18), and already carries the
mechanism that fixes it. gitea is simply the second node process to need it; containerd is
a node process and nobody had added it. An operator must run the node_baseline play.

Also records, where an operator reads them, that the agent authenticates to Gitea with a
local account and a scoped PAT by decision — and why the federated alternative is not
merely unchosen but unavailable: Gitea implements only the Authorization Code Grant and
validates no third-party OIDC token, so a Keycloak service-account token cannot
authenticate to its API or to git-over-HTTP.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 00:35:18 -05:00
jakman85andClaude Opus 5 a209a618fd fix(pm-agent): the agent is IN the cluster — one git host, the cluster-local service name
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
An in-cluster consumer reaches a service by its *.svc.cluster.local name, which is by
design a different path from how a client outside the cluster reaches the same service.
pm-worker's host allowlist accepted TWO values: the cluster-local name and the external
hostname gitea.home.keithjackson.dev. The second is removed.

It was never needed — nothing sets it — it would route in-cluster traffic out through the
Gateway and back for no reason, and as an ACCEPTED value it widened the allowlist to a
host reachable over the namespace's world:443 egress rule, which is precisely the
injection shape the allowlist exists to refuse.

probe-validation.sh now pins that: 20 refusals, including the external hostname, the
cluster-local name over https, and the cluster-local name on the wrong port.

Also corrects a remark in the handoff entry: I cited cluster-vars' GATEWAY_VIP
(192.168.1.53) as suspicious next to the nodes' 192.168.2.x. That was noise — those base
cluster-vars values are dev-era leftovers for a decommissioned cluster and say nothing
about prod. None of the measured facts depended on it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:40:55 -05:00
jakman85andClaude Opus 5 6253d546f8 docs(pm-agent): the registry cannot be pulled from yet — name both prerequisites where the operator reads them
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
pm-agent reconciled exactly as designed after #57: Flux applied main@sha1:95597bd to
platform-config, pm-agent and apps-wave-0 (all Ready), the ClusterPolicy
pm-agent-job-shape is live with Enforce + failurePolicy Fail and all six rules, SOPS
decryption works through the new overlay patch (checked by key names and value lengths,
never values), and pm-agent-worker really is bound to nothing.

Measuring it turned up two prerequisites for the NEXT operator step that nothing in this
repository declares, and that #57's handoff could not have known — it listed the image
pull as unverified, and that is exactly where it bit:

 - the nodes cannot verify the registry's TLS certificate (containerd: 'x509: certificate
   is not valid for any names'), while the same name serves a healthy Let's Encrypt
   wildcard from the LAN. A correct digest fails identically;
 - an unauthenticated client gets nothing from this Gitea's registry (its anonymous token
   carries no access scope) and no imagePullSecret exists anywhere in the tree.

Not specific to pm-agent: conformance-runner shares the registry path and has never been
pulled either. The dispatcher is the first workload in the cluster ever to reference it.

Documentation only — no manifest, no policy, no code. Both fixes need an operator action
and one of them is a posture decision, so the docs present both rather than pick.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:22:52 -05:00
35 changed files with 2087 additions and 269 deletions

No files matched your search

+1 -1
View File
@@ -77,7 +77,7 @@ spec:
storageClassName: longhorn
resources:
requests:
storage: 8Gi
storage: 16Gi
---
# PVC plex-media (R-STATE)
apiVersion: v1
+1 -1
View File
@@ -351,7 +351,7 @@ exposure: public # edge port-forward + public DNS; clients + Seerr
egress: internet # normal WAN egress (docs/03; R-NET-05)
storage:
- name: config
size: 8Gi
size: 16Gi
class: longhorn # Plex DB/metadata: replicated app state on the 3-replica tier (docs/04; R-STATE-01/02)
mountPath: /config # plexinc/pms-docker writes its identity/claim + "Library" DB under /config, NOT the
# renderer default /data/config. Without this the PVC sat unused and Plex wrote to the
+2 -2
View File
@@ -359,7 +359,7 @@ storage:
# The live /config contents were copied onto each PVC before this landed,
# so API keys and credentials other apps reference were preserved.
# THE DOWNLOADS AREA — ONE mount of the SAME export sonarr/radarr/plex already consume
# (192.168.2.102:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at
# (192.168.2.30:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at
# /downloads, which is precisely where this app is ALREADY configured to write.
#
# WHY subPath: downloads AND NOT THE EXPORT ROOT AT /media (the shape sonarr/radarr use):
@@ -434,7 +434,7 @@ dependencies: [] # default-deny: reaches only DNS; arr reach it n
# for weeks and neither has any CNP naming the NFS server or port 2049
# (grep apps/wave-1/sonarr.yaml: none). The SAME fact is what keeps the VPN
# egress gateway off this traffic (its selector is a podSelector), and
# 192.168.2.102 is inside the 192.168.0.0/16 excludedCIDR besides — see
# 192.168.2.30 is inside the 192.168.0.0/16 excludedCIDR besides — see
# platform/vpn-egress/egress-steering.yaml, "BULK NFS".
ingressRoutes:
- host: qbittorrent.${BASE_DOMAIN}
+17 -15
View File
@@ -234,26 +234,28 @@ data:
WG_CLIENT_CIDR: "10.90.0.0/24" # remote VPN clients; .1 = server. NOT a LAN range
# --- Bulk (external NFS) storage host (docs/04 bulk tier) ---------------------------------------
# THE address of the Proxmox host whose ZFS datasets back every `class: bulk` volume: the media
# library (Storage/plex, 8.33 T — plex, sonarr, radarr) and the legacy Nextcloud archive
# THE address of the NAS whose ZFS datasets back every `class: bulk` volume: the media library
# (Storage/plex, 8.33 T — plex, sonarr, radarr, qbittorrent) and the legacy Nextcloud archive
# (Storage/nextcloud, 270 G). The renderer emits it into each static NFS PersistentVolume's
# `spec.nfs.server` (catalog/render.py, BULK_NFS_SERVER); the export PATH is per-dataset and is
# declared in the ServiceDefinition, because the path is identical in every environment and only
# the ADDRESS is not.
#
# WHY THIS IS A KNOB AND NOT A LITERAL — the two clusters are on OPPOSITE SIDES of pfSense.
# The storage host has exactly one interface, vmbr0 192.168.1.9/24, on pfSense's LAN.
# dev nodes are 192.168.1.40/.41/.42 — the SAME L2 segment. They reach it directly, and one
# NFS export to them already exists on that host (/mnt/Storage/k8s-volumes in
# /etc/exports), so this path is proven, not theoretical. Base default = the dev value,
# per the convention of the LAN block above.
# prod nodes are 192.168.2.10/.11/.12 — pfSense's WAN. tcp/2049 does NOT traverse that today;
# 22 and 8006 answer only because pfSense port-forwards them via 192.168.2.102. Opening
# the storage path is a PENDING OPERATOR DECISION with two candidate outcomes that give
# DIFFERENT values here: 192.168.2.102 (another port-forward) or 192.168.1.9 (a route).
# So prod holds a loud TBD-PROD-* placeholder — see clusters/prod/kustomization.yaml for the
# blast radius of shipping it unset, which is NOT the usual "fails to apply".
BULK_NFS_SERVER: "192.168.1.9"
# IT IS NO LONGER A CROSS-NETWORK KNOB (2026-10-07). This used to read "the two clusters are on
# OPPOSITE SIDES of pfSense": the datasets lived on a Proxmox host whose only interface was
# vmbr0 192.168.1.9/24 on pfSense's LAN, which prod (192.168.2.0/24, pfSense's WAN) could reach
# only through a DNAT at 192.168.2.102. Both pools were physically moved onto a standalone
# TrueNAS at 192.168.2.30 — prod's OWN /24 — so there is no crossing to arrange and no candidate
# values to choose between. Measured 2026-10-07: 192.168.2.102:2049 no longer answers;
# 192.168.2.30:2049 and :111 do. There is now exactly ONE storage host and one correct value;
# this stays a knob only because the renderer reads it per-environment.
#
# *** CHANGING THIS VALUE IS NOT A PLAIN RECONCILE. *** A bound PersistentVolume's spec.nfs is
# immutable — measured by server dry-run 2026-10-07:
# spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation
# so Flux's apply FAILS rather than updating, and the pods keep their old mount. The PVs must be
# deleted and recreated; the operator sequence is docs/BULK-NFS-STORAGE.md §10.
BULK_NFS_SERVER: "192.168.2.30"
# Off-cluster S3 backup target (R-STATE-03, R-IDP-05) — the on-Proxmox MinIO, plain HTTP, path-style.
# Consumed by BOTH backup paths: every CNPG Cluster's barmanObjectStore.endpointURL (keycloak-db,
+7 -7
View File
@@ -279,15 +279,15 @@ patches:
- op: replace
path: /data/CRAFTY_L4_VIP
value: "192.168.1.55"
# Bulk (external NFS) storage host: the Proxmox host itself, on the SAME LAN as dev's nodes
# (192.168.1.40/.41/.42), which already hold an NFS export from it (/mnt/Storage/k8s-volumes).
# Restated verbatim rather than inherited, per this overlay's "supply every knob" convention.
# Dev is therefore the environment in which the bulk manifests can actually be VALIDATED
# end-to-end before prod's network path exists — provided the additive `zfs set sharenfs=`
# change described in docs/BULK-NFS-STORAGE.md is applied for the dev node addresses.
# Bulk (external NFS) storage host. THIS VALUE IS INERT — dev is decommissioned, its nodes
# (192.168.1.40/.41/.42) no longer exist, and the Proxmox host this used to name (192.168.1.9)
# went with the pools it held. It is restated per this overlay's "supply every knob"
# convention, not because anything consumes it. It carries the real address so that no file
# in the tree renders a host that is gone; it has NOT been proven from a dev node, because
# there is no dev node to prove it from.
- op: replace
path: /data/BULK_NFS_SERVER
value: "192.168.1.9"
value: "192.168.2.30"
# Off-cluster backup target: the on-Proxmox MinIO (plain HTTP, path-style) that every CNPG
# barmanObjectStore and Velero's backupStorageLocation write to.
#
+35 -47
View File
@@ -540,60 +540,48 @@ patches:
path: /data/LAN_CIDR
value: "192.168.2.0/24"
# ------------------------------------------------------------------------------------------
# BULK (EXTERNAL NFS) STORAGE HOST — *** NOT YET DECIDED. THIS PLACEHOLDER IS LOAD-BEARING. ***
# BULK (EXTERNAL NFS) STORAGE HOST — the TrueNAS at 192.168.2.30.
# ------------------------------------------------------------------------------------------
# Backs every `class: bulk` volume: the 8.33 T media library (Storage/plex — plex, sonarr,
# radarr) and the 270 G legacy Nextcloud archive (Storage/nextcloud).
# radarr, qbittorrent) and the 270 G legacy Nextcloud archive (Storage/nextcloud).
#
# THE STORAGE HOST IS NOT REACHABLE FROM PROD AT ALL RIGHT NOW. It has one interface,
# vmbr0 192.168.1.9/24, on pfSense's LAN; prod's nodes are on 192.168.2.0/24, pfSense's WAN.
# 22 and 8006 answer from prod only because pfSense port-forwards them via 192.168.2.102;
# 2049 and 111 do not. Opening that path is an OPERATOR decision, and the two candidate
# outcomes give DIFFERENT values here:
# port-forward 192.168.2.102:2049 -> 192.168.1.9:2049 => "192.168.2.102"
# a WAN->LAN route to 192.168.1.0/24 => "192.168.1.9"
# Guessing either one authors a prod cluster that mounts nothing, or — worse, if the guess
# ever became reachable — mounts something unintended. So it stays a placeholder.
# THIS WAS THE REPO'S ONE LOAD-BEARING PLACEHOLDER, AND THE REASON IT COULD BE IS NOW GONE.
# The datasets lived on a Proxmox host with a single interface on pfSense's LAN
# (192.168.1.9), which prod could reach only through a DNAT at 192.168.2.102 — so the correct
# value here depended on an undecided operator choice, and guessing it would have authored a
# cluster that mounts nothing. Both pools were physically moved onto a standalone TrueNAS at
# 192.168.2.30, prod's OWN /24. No crossing to arrange, no candidates to choose between.
# Measured 2026-10-07: 192.168.2.102:2049 no longer answers; 192.168.2.30:2049 and :111 do.
#
# WHICH PORTS THE DECISION MUST OPEN: exactly ONE, tcp/2049. The PVs mount nfsvers=4.1, and
# NFSv4 carries mount and lock in-protocol — no rpcbind/111, no mountd, no statd. Choosing v4
# over v3 was in part this decision's cost: v3 would have needed four ports and three of them
# on ephemeral numbers.
#
# WHAT THE EXPORT MUST ALLOW IS ALREADY SETTLED, and it is the same under BOTH outcomes.
# Measured 2026-09-07 from this workstation (192.168.2.132) through the existing port-forward:
# the storage host logged the connection as coming FROM 192.168.2.132, not from a pfSense LAN
# address (`ss -tn` peer 192.168.2.132, and the host's only route off-LAN is its default via
# 192.168.1.1). pfSense DNATs WITHOUT source NAT, so the NFS server will see prod's REAL node
# addresses either way. The additive `zfs set sharenfs=` values are in docs/BULK-NFS-STORAGE.md.
#
# *** BLAST RADIUS — READ BEFORE MERGING THIS TO A BRANCH PROD RECONCILES. ***
# A PersistentVolume's spec.nfs.server is an unvalidated string: the API server ACCEPTS this
# placeholder, the PV is created and the PVC BINDS to it (binding is pure control-plane). The
# failure surfaces only when a pod mounts — MountVolume.SetUp "Failed to resolve server
# TBD-PROD-BULK-NFS-SERVER" — so plex, sonarr, radarr and nextcloud would sit in
# ContainerCreating on prod. Unlike the LAN knobs above, this placeholder does NOT fail at
# apply time and there is no CRD validation to lean on. Set this value BEFORE prod reconciles
# a revision containing the bulk volumes; the dev overlay renders a working value, which is
# where these manifests are meant to be proven first.
# ONE PORT IS STILL ALL IT NEEDS: tcp/2049. The PVs mount nfsvers=4.1 and NFSv4 carries mount
# and lock in-protocol — no rpcbind/111, no mountd, no statd. The NAS advertises
# `+3 +4 +4.1 +4.2` (/proc/fs/nfsd/versions, measured), so 4.1 negotiates.
- op: replace
path: /data/BULK_NFS_SERVER
# 192.168.2.102 is pfSense's WAN address, which DNATs tcp/2049 through to the Proxmox
# host's REAL address 192.168.1.9 on pfSense's LAN. Prod sits on the WAN side and cannot
# reach 192.168.1.9 directly, so the cluster mounts the NAT address. Dev is on the LAN and
# keeps the base value 192.168.1.9.
# VERIFIED ON THE NAS 2026-10-07, before any cluster touched it (`exportfs -v`):
# /mnt/Storage/plex 192.168.2.10/.11/.12 rw,sec=sys,secure,root_squash,no_all_squash
# /mnt/Storage/nextcloud 192.168.2.10/.11/.12 ro,sec=sys,secure,root_squash,no_all_squash
#
# PROVEN from k3s-prod-1 on 2026-09-12, not inferred:
# - mount of 192.168.2.102:/mnt/Storage/plex negotiated nfs4 vers=4.1 proto=tcp
# - a write as uid 1000 succeeded. Tested as 1000 and NOT root deliberately: the export
# carries no_root_squash, so a root write would have passed and proved nothing, while
# LSIO images default to PUID 911 and would have gone Ready then failed every import
# with EACCES. The tree is 0755 1000:3000, so uid 1000 is the only writer.
# - ln() across downloads/ -> tv/ returned link count 2 (same inode), so the arrs get
# real atomic moves instead of silently degrading to copy-then-delete on EXDEV.
# pfSense DNATs WITHOUT source NAT (the host logs prod's own node addresses as the peer),
# so the ZFS export ACL names 192.168.2.10/.11/.12 directly rather than one translated IP.
value: "192.168.2.102"
# no_all_squash is the flag the entire permission model rests on: client uids pass through
# untouched, so the arrs' PUID=1000 arrives as the owner of a 0755 1000:3000 tree. sec=sys
# keeps that numeric rather than principal-mapped. The export deliberately does NOT carry
# no_root_squash, which the Proxmox one did: nothing here needs it, because every write to
# the tree happens after the LSIO drop to uid 1000 and the images only chown their Longhorn
# /config. Both subPath directories (plex/downloads, nextcloud/nextcloud) already exist at
# 0755 1000:3000, so a squashed root can still traverse them for subPath resolution.
#
# The ro= posture on the archive is now SERVER-enforced, which the old export also managed.
# The media tree's read-only half (plex) stays a CLIENT-side guarantee — all four media pods
# can land on any node, so the server sees the same three addresses for reader and writers
# alike and cannot tell them apart. Unchanged by the move; see docs/BULK-NFS-STORAGE.md §5.
#
# *** CHANGING THIS VALUE REQUIRES DELETING AND RECREATING THE PVs. *** A bound PV's
# spec.nfs is immutable — measured by server dry-run 2026-10-07:
# spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation
# so Flux's apply FAILS rather than updating and the pods keep their old, dead mount. The
# operator sequence is docs/BULK-NFS-STORAGE.md §10. Retain + the renderer's pre-set
# claimRef make it safe: no data is touched and each recreated PV rebinds its own PVC.
value: "192.168.2.30"
# ==========================================================================================
# OFF-CLUSTER DR TARGET — Cloudflare R2 (operator, 2026-09-05; bucket + token 2026-09-07).
# ==========================================================================================
@@ -0,0 +1,127 @@
---
date: 2026-10-05
branch: docs/pm-agent-registry-pull-prereq
title: pm-agent reconciled exactly as designed — and is the first workload ever to pull from the in-cluster registry, which does not work yet
---
## What changed
Documentation only. No manifest, no policy, no code.
PR #57 merged and `pm-agent` reconciled. I measured what landed, and the measurement turned
up **two prerequisites for the next operator step that nothing in this repository
declares** — and that the handoff entry for #57 could not have known, because it listed the
image pull as unverified and that is exactly where it bit.
So this entry records the post-merge measurement, and
`platform/pm-agent/README.md`, `docs/plans/pm-agent-issue-to-pr.md` and
`runbooks/pm-agent.md` now name both prerequisites where they say "build, push, pin the
digest". Before this, that step read as one action; it is three, and the two new ones are
not an agent's to perform.
**The finding is not specific to pm-agent.** `conformance-runner` has the identical
registry path (`gitea.home.keithjackson.dev/home-network/...`), the identical "operator
pushes, a digest goes in a PR" model, and has never been pulled either — so the same two
blockers sit in front of the L2/L3 conformance wiring. Measured: **no pod anywhere in the
cluster references that registry** except the pm-agent dispatcher, which is therefore the
first workload ever to try it.
## What I verified
Read-only, through the pinned toolbox, against the live prod cluster.
**The merge reconciled, completely and exactly as designed.**
- `GitRepository/flux-system` artifact revision: `main@sha1:95597bd6883637…` — the merge
commit. `platform-config`, `pm-agent` and `apps-wave-0` all report that same
`lastAppliedRevision` with `Ready=True`. Compared field to field, per CLAUDE.md §2b,
rather than assumed from the merge.
- **The security boundary is live.** `ClusterPolicy/pm-agent-job-shape`:
`validationFailureAction=Enforce`, `failurePolicy=Fail`, `Ready=True`, and all six rules
present (`worker-must-be-the-committed-shape`,
`worker-must-be-one-container-with-no-args`,
`worker-volumes-must-be-workspace-or-tmp-emptydirs`,
`worker-may-not-declare-init-or-ephemeral-containers`,
`worker-env-names-must-be-the-committed-set`, `worker-may-not-use-envfrom`). This is the
check that mattered most: a ClusterPolicy added to the wrong kustomization list ships
green and never reaches the cluster, which is what happened to the conformance boundary
on 2026-09-29.
- Namespace contents: both ServiceAccounts, `pm-agent-config` (17 keys), the
`pm-agent-credentials` Secret, the `default-deny` NetworkPolicy, and both
CiliumNetworkPolicies with `VALID=True`.
- **SOPS decryption works through the new overlay patch.** Checked by KEY NAMES and VALUE
LENGTHS only, never values (CLAUDE.md §6): keys `gitea-token`, `claude-code-oauth-token`,
lengths 76 and 52 bytes — which are the base64 lengths of the committed placeholder
strings, so the Secret holds the decrypted placeholders and not ciphertext. A
ciphertext apply succeeds silently and would have left `ENC[AES256_GCM,…]` values an
order of magnitude longer.
- **`pm-agent-worker` really is bound to nothing.** The namespace has exactly one
RoleBinding (`pm-agent` → Role `pm-agent`, subject `ServiceAccount/pm-agent`), and no
ClusterRoleBinding anywhere names either pm-agent ServiceAccount.
- `gitea-allow-pm-agent-ingress` is applied in the `gitea` namespace, matching
`io.kubernetes.pod.namespace: pm-agent`.
- The dispatcher is `ImagePullBackOff` on the all-zero placeholder digest — the designed,
visible failure.
**The two prerequisites, measured.**
1. **The nodes cannot verify the registry's TLS certificate.** containerd's error on the
pull is not about the digest:
failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:000…":
tls: failed to verify certificate: x509: certificate is not valid for any names,
but wanted to match gitea.home.keithjackson.dev
"not valid for **any** names" means the certificate presented to the node carries no
matching SAN at all. From the operator's workstation the same name is healthy: it
resolves to `192.168.2.20` and serves a Let's Encrypt certificate
`CN=*.home.keithjackson.dev` with SANs `*.home.keithjackson.dev, home.keithjackson.dev`
— and `Certificate gateway/wildcard-home` is `Ready=True` for exactly those names. So
the certificate that *should* be served is fine, and what the NODE reaches is not what
the workstation reaches. **Correcting the digest will not clear this.**
2. **Anonymous pulls from this registry are refused.** Not inferred from a bare 401 — that
would have been wrong, because this Gitea answers 401 for *everything* anonymously:
`/v2/home-network/agent-runner/manifests/latest`, `/v2/home-network/conformance-runner/…`,
a randomly-named nonexistent repository, and `nosuchowner/nosuchimage` all return 401,
and `/api/v1/packages/Home-Network` returns `{"message":"token is required"}`. The
registry *does* issue a token to an anonymous client, but its `access` claim is `null` —
no pull scope. So the conclusion that holds is the simple one: **an unauthenticated
client gets nothing from this registry**, so a pull with no credential cannot succeed.
And `grep` over the tree finds **no `imagePullSecrets` anywhere** — not in
`platform/pm-agent/deployment.yaml`, not in `infra/agent/worker-job-template.yaml`, not
in `conformance/incluster/`.
## What I did NOT verify
- **Why the node sees a nameless certificate.** I measured that it does, and that the LAN
does not. I did not diagnose it: I did not read the nodes' resolver or
`/etc/rancher/k3s/registries.yaml` (node files, not reachable read-only through the API),
and I did not check whether the node resolves the name to the same `192.168.2.20` the
workstation does or to something else.
**A correction to a remark I first wrote here:** I cited
`cluster-vars`'s `GATEWAY_VIP: 192.168.1.53` as suspicious next to the nodes'
`192.168.2.x`. That was noise — the operator confirms those base `cluster-vars` values are
**dev-era leftovers for a cluster that is decommissioned**, so the 192.168.1.x addresses in
that file say nothing about prod and should not be read as evidence about anything. The
measured facts stand on their own and none of them depended on that knob.
- **Whether the package would be pullable once authenticated.** Nothing has ever been
pushed to that path, so "a credential is sufficient" is untested. The only thing
established is that no credential is insufficient.
- **Which fix the operator wants.** Two routes, and they are not equivalent:
(a) an `imagePullSecret` — a `dockerconfigjson` holding a Gitea token, SOPS-encrypted
like `pm-agent-credentials`, referenced from the Deployment *and* from the committed
worker Job template; note the Job template's shape is pinned by admission, and
`imagePullSecrets` is a field that policy does not currently constrain, so adding one is
a template change that needs a deliberate look at whether the policy should pin it too.
(b) allowing anonymous reads of that package in Gitea — no manifest change at all, but it
makes the image world-readable to anything that can reach the host.
I have implemented neither: both need an operator action I must not take, and (b) is a
posture decision. The docs now present both.
- **Everything the #57 entry listed as unverified about RUNTIME remains unverified.** The
image has still never been built or pushed. No dispatcher has authenticated to Gitea, no
issue has been triaged, no worker Job has been created — so the admission policy, though
now provably applied and Enforce, has still never refused or admitted a real Job. The
network policies have not carried traffic.
- **I did not re-run the merge gate against `main` after the merge.** This branch's own gate
run is the evidence for this change; the merged state's is CI run on PR #57.
@@ -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,126 @@
---
date: 2026-10-06
branch: docs/pm-agent-registry-pull-prereq
title: The nodes resolve gitea to the WAN, so the failing certificate is the router's — the second node process to need the issuer pin
---
## What changed
The image pull for `pm-agent` failed TLS verification, and the previous entry on this branch
honestly recorded it as **not diagnosed**. The operator asked for the diagnosis before any
registry design. This is it, and the fix turns out to be a pattern this repository already
has.
`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` now pins
`gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `node_baseline_hosts_pins`, beside the
Keycloak issuer pin that is there for the identical reason.
**It reaches the nodes the way every other node setting in this project does — through
Ansible, from the inventory.** Node configuration is Ansible's (and, for the dev VMs,
OpenTofu's); nothing here touches a node by hand. For prod specifically, Ansible is the
*only* surface: `infra/PROD-PROVISIONING.md` records that prod is 3× bare metal and that the
dev path (OpenTofu → Proxmox VM → cloud-init) does not apply, so there is no
cloud-init/tofu half to keep in parity for this. The one scoped, idempotent command:
cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
-e k3s_hosts=k3s_prod --tags hosts
`--tags hosts` runs ONLY the `/etc/hosts` task, so applying this pin cannot drift any other
baseline setting. Running the play is an operator action (it has become/root on prod), but
the configuration itself is declared, reviewed and version-controlled — not typed on a node.
The docs that described this as an undiagnosed blocker now describe the cause and the fix:
`platform/pm-agent/README.md`, `runbooks/pm-agent.md` (its symptom row now names the pin and
the play), and `docs/plans/pm-agent-issue-to-pr.md`.
Also recorded, in the two places an operator reads when creating the credential: **the agent
authenticates to Gitea with a local account and a scoped PAT, by decision** (operator,
2026-10-06), not with a federated identity. Written down because the alternative looks
obviously better until you check whether it is possible — see below.
## What I verified
Every step measured, read-only, in this order.
**The failing certificate is the router's, not the Gateway's.**
- containerd's error names TLS, not the digest: `tls: failed to verify certificate: x509:
certificate is not valid for any names, but wanted to match gitea.home.keithjackson.dev`.
"not valid for **any** names" means the presented certificate carries no SANs at all.
- `192.168.2.20` is the shared Gateway's own LoadBalancer VIP
(`gateway/cilium-gateway-shared`, from the Cilium pool `192.168.2.20-30`), and its `https`
listener serves `*.home.keithjackson.dev` with `wildcard-home-tls`. From the operator's
workstation the name resolves there and serves the real Let's Encrypt
`CN=*.home.keithjackson.dev` with correct SANs. **So the certificate and the Gateway are
healthy.**
- **The node asks a different resolver and gets a different answer.** Prod's
`node_baseline_dns_servers` is the LAN gateway, not Pi-hole — deliberately, as that file
explains. Public DNS for this zone is a CNAME to `wan.home.keithjackson.dev`: measured via
both `1.1.1.1` and `8.8.8.8`, `gitea.home.keithjackson.dev` →
`wan.home.keithjackson.dev.` → `136.51.115.252`, and the apex answers the same.
- **That address serves a certificate with no names.** Measured from the workstation against
the WAN address with the right SNI:
subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
issuer=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
No extensions in certificate
No extensions means no `subjectAltName`, which is exactly "not valid for any names". The
`CN=%D` is the router's placeholder.
**This repository had already measured the same thing, for Keycloak, and I did not need to
re-derive it.** `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` says, above the pin:
"Since platform/gateway/gateway.yaml set an external-dns `target`, every public name is a
CNAME to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from
the LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18)." Same cause, same symptom,
one name later. The pin exists precisely for node processes that must reach the Gateway
directly; containerd is one, and nobody had added it.
**The parity the pin has to satisfy:** `192.168.2.20` equals the live
`GATEWAY_VIP`, and `k3s_base_domain` equals the live `BASE_DOMAIN` — both read from the
cluster, not from the comment that claims prod serves `prod.home.keithjackson.dev` (it does
not; `flux-system/cluster-vars` reports `BASE_DOMAIN=home.keithjackson.dev` and
`GATEWAY_VIP=192.168.2.20`, and Ansible's `k3s_base_domain` is `home.keithjackson.dev`).
That comment is stale history.
**The credential decision, and why the obvious alternative is not available.** Gitea's API
accepts basic auth, PATs, SSH signatures and its own OAuth2 bearer tokens; as an OAuth2
provider Gitea implements **only the Authorization Code Grant** (plus PKCE/OIDC), so there
is no client-credentials grant, and nothing in Gitea validates a token issued by a
third-party OIDC provider. A Keycloak service-account token therefore cannot authenticate to
Gitea's API or to git-over-HTTP. Read from Gitea's own documentation
(`docs.gitea.com/development/api-usage`, `/development/oauth2-provider`), not assumed.
**Gate:** `./ci/gate.sh` in the pinned toolbox from a §0 worktree — see the branch's run; rc 3
with `gitea-settings` the only stage that did not run.
## What I did NOT verify
- **That the pin fixes the pull.** It is IaC, and nothing has applied it: `/etc/hosts` on the
nodes is written by the `node_baseline` play's `hosts`-tagged task, which is an operator
action. I did confirm the mechanism is not inert — `roles/node_baseline/tasks/main.yml`
renders `node_baseline_hosts_pins` into a marked `blockinfile` with `backup: true`, and
`state: absent` when the list empties, so a removed entry really leaves the node. Until that runs
and an image exists, "the node now reaches the Gateway" is a design claim. The evidence
that it *will* is the Keycloak pin working for the same cause — which I also did not
re-measure.
- **Why public DNS answers at all for an `internal` service.** `catalog/gitea.yaml` sets no
`exposure`, so it inherits `internal`, and the renderer contract says an internal service's
HTTPRoute carries `external-dns.alpha.kubernetes.io/controller: none` and publishes no
public record. Yet `gitea.home.keithjackson.dev` resolves publicly to the WAN CNAME. I did
not chase that: it may be a zone-level wildcard rather than a published record, and it did
not change the diagnosis. It is worth someone's attention, because "internal services
publish no public DNS" is an L0-checked claim and this looks like a case where the *name*
resolves regardless of what the renderer withheld.
- **Registry authentication.** Unchanged and still open: an unauthenticated client gets
nothing from this registry, and no `imagePullSecret` exists anywhere in the tree. With the
credential model now decided (local Gitea accounts with PATs) the shape is a
`dockerconfigjson` for an account whose token carries `read:package`, referenced from the
Deployment and the committed worker Job template — and the worker Job template's shape is
pinned by admission, so whether the policy should also pin `imagePullSecrets` deserves a
deliberate look rather than a quiet addition. Not built: minting that token is an operator
action, and I did not want to guess at a second credential's scope.
- **Anything about runtime.** The image still does not exist. No dispatcher has
authenticated, no issue has been triaged, no worker Job has been created, and the admission
policy — live, `Enforce`, six rules — has still never admitted or refused a real Job.
@@ -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.
+85
View File
@@ -0,0 +1,85 @@
---
date: 2026-10-07
branch: fix/bulk-nfs-truenas
title: Repoint bulk NFS at the TrueNAS (192.168.2.30); the PVs must be recreated, not reconciled
---
## What changed
Both ZFS pools were physically moved off the Proxmox host onto a standalone TrueNAS Community
Edition at **192.168.2.30**, which sits on prod's own `192.168.2.0/24`. `BULK_NFS_SERVER` moves from
`192.168.2.102` (the pfSense DNAT prod used to mount through) to `192.168.2.30`.
- `clusters/prod/kustomization.yaml` — `BULK_NFS_SERVER` → `192.168.2.30`. The comment block that
justified the DNAT, and the "NOT YET DECIDED / THIS PLACEHOLDER IS LOAD-BEARING" banner, are
replaced with the measured export configuration and the recreate requirement below.
- `clusters/base/cluster-vars.yaml` — base default `192.168.1.9` → `192.168.2.30`. That host no
longer exists.
- `clusters/dev/kustomization.yaml` — same; marked explicitly **inert**, since dev is decommissioned
and its nodes are gone. It carries the real address only so nothing in the tree renders a dead one.
- `docs/BULK-NFS-STORAGE.md` — §1 and §5 rewritten for TrueNAS (exports, service settings, the ZFS
`sharenfs` properties that had to be cleared, and the ACL hazard); §7 rewritten now that the
placeholder is gone; §9 retargeted from dev to prod; **new §10** documents the PV
delete-and-recreate. §2/§3/§4/§6 are unchanged by the move.
- `catalog/qbittorrent.yaml`, `platform/monitoring/node-pressure-dashboard.yaml` — two comment/prose
references to `192.168.2.102` as the live export address.
**The headline operational fact: repointing this knob is NOT a plain reconcile.** A bound PV's NFS
source is immutable, so Flux's apply fails rather than updating and the pods keep their old, dead
mount. The five PVs must be deleted and recreated. That sequence is §10 and is an operator action;
it is **not** performed by this PR.
## What I verified
**On the NAS (192.168.2.30), measured 2026-10-07 by the operator at my request:**
- `zfs list` — `Storage` → `/mnt/Storage`, `Storage/plex` → `/mnt/Storage/plex`,
`Storage/nextcloud` → `/mnt/Storage/nextcloud`. The PV paths resolve unchanged.
- Ownership intact: `/mnt/Storage/plex`, `tv/`, `downloads/`, `movies/`,
`downloads/complete/`, `nextcloud/nextcloud/` all `drwxr-xr-x 1000:3000`. `movies/` 1171 entries
and `tv/` 107, against 1165 / 107 measured on Proxmox.
- `acltype=nfsv4` (SCALE's built-in default, `SOURCE: default`), `aclinherit=passthrough`, and no
non-trivial ACLs — mode bits still govern.
- Three stale `sharenfs` dataset properties (`plex`, `nextcloud`, `docker-volumes`) carried over from
Proxmox; cleared with `zfs inherit`, re-checked empty.
- `exportfs -v`: both shares to `192.168.2.10/.11/.12` with `sec=sys,secure,root_squash,no_all_squash`,
`rw` on plex and `ro` on nextcloud.
- `/proc/fs/nfsd/versions` → `+3 +4 +4.1 +4.2`, so the PVs' `nfsvers=4.1` negotiates.
- **A defect was found and fixed during setup:** the plex share listed `192.168.2.13` — an address no
node holds — and so omitted `192.168.2.12` (k3s-prod-3). Corrected and re-verified.
**From the cluster:**
- Node InternalIPs are `192.168.2.10/.11/.12` (`kubectl get nodes`), confirming `.13` was wrong.
- All five NFS PVs currently name `192.168.2.102`.
- `192.168.2.30:2049` and `:111` accept connections; `192.168.2.102:2049` does not.
- **`spec.nfs` immutability, by server-side dry-run** — this is the finding that shapes the change:
`spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation`.
- The pods report `Ready` while their mounts are dead: a `stat /media` answers from attribute cache,
but a `readdir` blocks indefinitely (the PVs mount `hard`). Hence "scale to zero first" in §10.
**Gate:** see the PR; run in the pinned toolbox from this worktree.
## What I did NOT verify
- **Nothing has mounted 192.168.2.30.** No pod has touched the new server — the PVs still point at
the old address and cannot be updated in place. Every §9 probe (numeric-id passthrough, the uid-1000
write, the hardlink/EXDEV check, Plex's read-only posture) is **outstanding**, not passing. The
equivalents passed against the old host on 2026-09-12; that is not evidence about this one.
- **The §10 sequence is reasoned, not executed.** Retain + the renderer's pre-set `claimRef` are read
from the manifests and from `catalog/render.py`; I have not watched a PVC rebind after a PV delete
in this cluster.
- **`root_squash` is a behaviour change from the Proxmox export**, which carried `no_root_squash`. The
argument that nothing needs root on the tree (LSIO drops to 1000; the images chown only their
Longhorn `/config`; both subPath directories already exist at 0755) is reasoned from the images and
the measured permissions. It has not been exercised. If a mount or subPath resolution fails with
EACCES, Maproot `root:root` reproduces the old export and is the first thing to try.
- **Whether NFSv3 can be disabled.** Enabled Protocols is `NFSv3, NFSv4`; the PVs only need v4.1, so
narrowing would close the rpcbind/mountd/statd surface. Not done — it is a change to make after the
cutover is proven, not during it.
- **Whether pfSense / the legacy stack is actually retired.** I measured only that
`192.168.2.102:2049` stopped answering. The other legacy consumers of these datasets
(`192.168.1.6`, `10.123.1.10`, `192.168.1.18`) were dropped from the new exports; if any is still
live, it has lost access. Not checked.
- **L2/L3 conformance.** `vars.CLUSTER_AVAILABLE` is unset, so CI proves the L0/L1 offline subset
only. Nothing in the gate exercises an NFS mount.
+311
View File
@@ -0,0 +1,311 @@
---
date: 2026-10-17
branch: feat/keycloak-passkeys
title: Passkeys declared for the homelab realm — and the realm import will not apply them
---
## What changed
`platform/keycloak/keycloak-realm-import.yaml` now declares passkey (WebAuthn Passwordless)
login for the `homelab` realm, in two parts:
1. **The WebAuthn Passwordless policy.** Ten `webAuthnPolicyPasswordless*` fields on the realm:
RP ID `${BASE_DOMAIN}`, RP entity name `Homelab SSO`, signature algorithms `[ES256, RS256]`,
`requireResidentKey: "Yes"`, `userVerificationRequirement: required`,
`authenticatorAttachment: "not specified"`, `attestationConveyancePreference: none`,
`acceptableAaguids: []`, `createTimeout: 300`, `avoidSameAuthenticatorRegister: true`.
Deliberately the **passwordless** family only; the two-factor `webAuthnPolicy*` family is left
at Keycloak's defaults because nothing in this realm uses `webauthn-authenticator`.
2. **The browser flow.** `browser-mfa` keeps its alias and stays bound as `browserFlow`; its
interior is restructured so a passkey is an **ALTERNATIVE** beside the password form:
```
browser-mfa (top level, unchanged)
browser-mfa-forms ALTERNATIVE
auth-username-form REQUIRED <- was auth-username-password-form
browser-mfa-passkey-or-password REQUIRED <- NEW sub-flow
webauthn-authenticator-passwordless ALTERNATIVE <- NEW: the passkey
browser-mfa-password-and-otp ALTERNATIVE <- NEW sub-flow
auth-password-form REQUIRED <- was half of the combined form
browser-mfa-conditional-otp CONDITIONAL (unchanged, relocated intact)
```
The design decisions, with their reasoning, are written into the manifest comments in this repo's
usual density. The ones that matter:
* **Additive, not replacing. Enrolment optional, not forced.** This IdP gates Grafana, Gitea,
Nextcloud, Headlamp, Semaphore, PiHole, Crafty, the \*arr stack *and* `kubectl` via the
`kubernetes` OIDC client. A mandatory passkey flow that misfires is a total lock-out from the
platform and the cluster API at once, leaving only the Keycloak bootstrap admin and the
break-glass cert kubeconfig. The gain from forcing it (one human stops typing a password) does
not price that risk.
* **No `requiredActions` block was added, on purpose.** Declaring one would have been the obvious
way to add `webauthn-register-passwordless`, and it would have been actively dangerous — see
"What I verified".
* **RP ID is `${BASE_DOMAIN}`, not `keycloak.${BASE_DOMAIN}`.** A passkey's RP ID is fixed at
enrolment and can never be re-keyed, so it must not be bound to a name that can move. This realm
already declares an `auth.${BASE_DOMAIN}` redirect URI, and this repo already owns a reconciler
built because a hostname move silently failed to reach the realm. Residual risk, unavoidable:
changing `BASE_DOMAIN` itself invalidates every enrolled passkey.
* **A passkey login is not also asked for TOTP.** `browser-mfa-conditional-otp` hangs off the
password branch only. The justification is `userVerificationRequirement: required` — the
ceremony already proves possession plus a verified human, phishing-resistantly and origin-bound.
Stacking a replayable shared secret on top of that buys friction. The manifest names the exact
one-line edit to reverse this decision.
* **Login becomes two screens** (username, then password) for every user, passkey or not, because
the server must know who is logging in before it can decide whether a passkey exists. Accepted
cost; it is the enabling change.
* **Usernameless login deliberately not used**, although Keycloak 26.0.6 supports it — see "What I
verified" for the measured reason it would have made every login open on a passkey prompt.
`platform/keycloak/README.md` gains a `## Passkeys` section: the decision table, the fact that
this does not reach the live realm, the two operator paths that would apply it, and the four-step
browser verification nobody has run.
No secret, key, credential or AAGUID was minted or committed. No file under `clusters/**`,
`conformance/**`, `ci/**`, `platform/kyverno/**`, `**/rbac.yaml` or `**/*.sops.yaml` was touched,
so this should not trip `ci/approval-policy.sh`'s sensitive-path review requirement.
## What I verified
**The live realm import has already completed and will never re-run.** Measured on prod,
2026-10-17:
```
$ kubectl --context k3s-prod -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
{"conditions":[{"message":"","status":"True","type":"Done"},
{"message":"","status":"False","type":"Started"},
{"message":"","status":"False","type":"HasErrors"}]}
```
`Done=True` / `Started=False`, exactly the one-shot state `platform/keycloak/README.md` already
documents for client secrets and client URLs. **So the change in this PR has zero effect on how
anyone logs in today**, and the client-secret/client-URL reconciler does not and cannot close the
gap: its hard endpoint allow-list makes `/authentication` unreachable by design, and realm-level
policy fields are not client fields.
**The CRD accepts every field I used.** Measured against the live CRD (not the upstream docs):
`kubectl --context k3s-prod get crd keycloakrealmimports.k8s.keycloak.org` lists all twelve
`webAuthnPolicyPasswordless*` properties with the types I wrote (`RequireResidentKey`,
`RpEntityName`, `RpId`, `UserVerificationRequirement`, `AttestationConveyancePreference`,
`AuthenticatorAttachment` as `string`; `SignatureAlgorithms`, `AcceptableAaguids`, `ExtraOrigins`
as `array/string`; `CreateTimeout` as `integer`; `AvoidSameAuthenticatorRegister` as `boolean`),
plus `authenticationFlows`, `authenticatorConfig` and `requiredActions`. The `spec.realm` schema
has **no** `x-kubernetes-preserve-unknown-fields`, so a misspelled field would have been pruned
silently rather than rejected — which is why this was checked rather than assumed.
**Running Keycloak is 26.0.6.** `quay.io/keycloak/keycloak:26.0.6` on `keycloak-0`/`keycloak-1`,
operator `quay.io/keycloak/keycloak-operator:26.0.6`. Every source claim below is read from the
`26.0.6` tag, not from memory or from docs.
**Every provider ID is quoted from upstream source, not guessed.**
| String I used | Where it is defined at tag 26.0.6 |
|---|---|
| `webauthn-authenticator-passwordless` | `WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID`, display "WebAuthn Passwordless Authenticator" |
| `auth-username-form` | `UsernameFormFactory.PROVIDER_ID`, display "Username Form" |
| `auth-password-form` | `PasswordFormFactory.PROVIDER_ID`, display "Password Form" |
| `webauthn-register-passwordless` | `WebAuthnPasswordlessRegisterFactory.PROVIDER_ID` (referenced in comments only) |
**`requiredActions` is REPLACE, not merge — measured, and it is why I did not declare one.**
`model/storage-private/.../DefaultExportImportManager.java` lines 327-338:
```java
if (rep.getRequiredActions() != null) {
for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) { ... }
DefaultRequiredActions.addDeleteAccountAction(newRealm);
} else {
DefaultRequiredActions.addActions(newRealm);
}
```
Declaring one required action therefore skips the entire default set. `RealmManager` is no
backstop: `setupRequiredActions` is `if (realm.getRequiredActionProvidersStream().count() == 0)`,
and the count is already non-zero by then (`RepresentationToModel.importRealm` runs at
`RealmManager.java:585`, the backstop at `:612`). A realm built that way would have had the passkey
action plus `delete_account` and nothing else — **no `CONFIGURE_TOTP`**, which would leave
`auth-otp-form` demanding a TOTP that no platform-admin could ever enrol: a hard lock-out
delivered by a change whose purpose was to *add* a login method.
**And declaring it would have bought nothing.** `DefaultRequiredActions.addWebAuthnPasswordlessRegisterAction`
registers `webauthn-register-passwordless` with `setEnabled(true)`, `setDefaultAction(false)`,
priority 80, gated on `Profile.Feature.WEB_AUTHN` — which is `Type.DEFAULT` in
`common/.../Profile.java:74`, i.e. on unless explicitly disabled. `enabled=true` /
`defaultAction=false` *is* the additive-and-optional posture this change wants.
**`authenticationFlows` has the opposite semantics, which is why declaring flows is safe.**
`importAuthenticationFlows` adds the declared flows, then backfills each unbound binding
(browser, registration, direct grant, reset credentials, client auth, first broker login, docker
auth) from the built-in defaults individually (`DefaultExportImportManager.java` ~1320-1410).
**The graceful degradation to password is measured from the flow engine, not assumed.** For a user
with no passkey: `WebAuthnPasswordlessAuthenticator.requiresUser()` returns `false`, so
`DefaultAuthenticationFlow.processSingleFlowExecutionModel` takes the `else` branch (`daf:456-462`),
whose four conditions all hold — a user is set (from `auth-username-form`), `configuredFor` is
false, `WebAuthnPasswordlessAuthenticatorFactory.isUserSetupAllowed()` is `false`, and
`WebAuthnAuthenticator implements Authenticator, CredentialValidator<...>` — so it raises
`CREDENTIAL_SETUP_REQUIRED`. The ALTERNATIVE loop (`daf:289-302`) catches exactly that, marks the
execution `ATTEMPTED` and proceeds to the next alternative, with the upstream comment *"the user,
since he has alternatives, should be able to go to another alternative and continue"*. Hence: no
passkey, no passkey screen, straight to the password form.
**Why usernameless was rejected, measured.** The `configuredFor` guard sits inside the
`requiresUser()` branch. With `requiresUser()` false and no user yet in context, that guard is
skipped and the engine just calls `authenticate(context)` — so a passkey execution placed before
the username form is offered to *everyone*, including users who will never own one.
**The policy sentinels are read from the shipped code, and one of my initial assumptions was
wrong.** `Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED = "not specified"` is the
realm-representation sentinel — but `WebAuthnConstants.OPTION_NOT_SPECIFIED = ""` (empty string) is
the WebAuthn-API-level one. I checked both because they disagree. The login theme's
`webauthnRegister.js` at 26.0.6 settles the behaviour:
```js
if (input.requireResidentKey !== 'not specified') {
if (input.requireResidentKey === 'Yes') { authenticatorSelection.requireResidentKey = true; }
else { authenticatorSelection.requireResidentKey = false; }
}
...
if (input.createTimeout !== 0) { publicKey.timeout = input.createTimeout * 1000; }
rp: {id: input.rpId, name: input.rpEntityName}
```
So `"Yes"` is a literal string compare (any other non-sentinel value means *false*), `"not
specified"` is compared literally for four fields, and `createTimeout` is **seconds** with `0`
meaning "send no timeout". It also shows 26.0.6 emits only the legacy `requireResidentKey` member,
never `residentKey: "required"` — the client maps that per WebAuthn L2, but the policy cannot
express `residentKey: "preferred"` at all.
`[ES256, RS256]` is exactly `WebAuthnPolicy.DEFAULT_POLICY` /
`Constants.DEFAULT_WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS` — declared explicitly, not changed.
`getWebAuthnPolicyPasswordless` was read field by field to confirm `[]` for `acceptableAaguids`
means "no restriction" and that `RpEntityName` must be non-empty or the **update** path
(`DefaultExportImportManager.java:842`) drops the whole policy silently.
**The manifest parses and renders.** `yaml.safe_load` round-trips the file and the flow graph reads
back exactly as designed (five flows, the executions and priorities above, `browserFlow:
browser-mfa`, `requiredActions` absent). `python3 ci/render-keycloak-client-urls.py --check` →
`client-urls.yaml is in sync`, rc 0 (no client URL changed, so no regeneration was needed).
`yamllint -c ci/yamllint.yaml platform/keycloak/keycloak-realm-import.yaml` → rc 0; no line exceeds
the 160-column ceiling.
**`browser-mfa` keeps its alias deliberately.** `conformance/tests/l2-behavioral/keycloak-config-as-code`
asserts `kubectl get keycloakrealmimport homelab -o jsonpath={.spec.realm.browserFlow} | grep -q
'browser-mfa'`. Renaming the top-level flow would have failed that test.
**Envsubst safety.** `platform-config`'s Flux Kustomization declares
`postBuild.substituteFrom: cluster-vars` (`clusters/base/platform-config.yaml:26-29`), so this file
is substituted. The only brace token I added is `${BASE_DOMAIN}`, which is declared at
`clusters/base/cluster-vars.yaml:42` (`home.keithjackson.dev`). Every comment I wrote avoids braces
entirely — the RP-ID discussion says `keycloak.<BASE_DOMAIN>` with angle brackets precisely so an
unparseable token cannot fail the whole Kustomization.
**Gate result: rc 3, zero failures, `gitea-settings` the only stage the gate counts as not-run** —
the documented honest best local outcome. Run in the pinned toolbox from this worktree with all
three §3 workarounds (`DEV_KUBECONFIG=/dev/null`, plus an `ENGINE` shim bind-mounting the main
checkout's `.git` and the sibling spec repo with `SPEC_REPO=/home-network-k3s`):
```
[ ok ] stage spec-immutability: pass <- NOT skipped; both shim mounts worked, R-TEST-06 covered
[ ok ] stage decisions-log: pass <- archive frozen, 48 entries well-formed
[ ok ] stage lint: pass
[ ok ] stage l0: pass <- incl. flux-postbuild-substitution + keycloak-client-secret-reconciler
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[ ok ] stage approval-policy: pass <- no sensitive path touched, so no non-author review needed
[skip] stage gitea-settings: SKIPPED <- needs SETTINGS_API_TOKEN, CI-only; expected
[skip] stage conformance: SKIPPED <- cluster-gated L2/L3; expected
MERGE GATE: UNDETERMINED — Stages that did not run: gitea-settings
```
No lint finding and no check failure names either file I changed. Touched requirements resolved to
`R-IDP-01 R-IDP-02 R-IDP-03 R-IDP-04 R-IDP-05 R-IDP-06`; coverage 51/51, 0 gaps.
**A note on how that number was obtained, because the first attempt was worthless.** My first gate
run returned the same rc 3 and the same seven passes — against a tree that did not contain this
change at all. The gate's `tracked_files()` reads **git**, and my edits were still uncommitted, so
every stage gated `origin/main`. The tell was in the log and I nearly missed it: the
touched-requirement subset reported UNDETERMINED with *"check out the feature branch you mean to
gate (not the base branch)"*, and `decisions-log` counted **47** entries rather than 48. **A green
gate run on an uncommitted worktree is a gate run on the base branch.** The numbers above are from
the second run, after committing and rebasing.
**The base moved under me mid-run.** `origin/main` advanced from `9a41a8e` to `1a0b208` (PR #70,
oauth2-proxy OIDC startup race) while I was working. This branch is rebased onto `1a0b208`, so the
gate above ran on the base CI will actually merge into. The rebase was clean — PR #70 touched
`platform/oauth2-proxy/helmrelease.yaml` and its own decisions entry, disjoint from these files.
## What I did NOT verify
**This change is unapplied. It changes nothing about the live realm, and I did not attempt to make
it.** `Done=True` / `Started=False` was measured, not inferred, and I ran no mutating `kubectl`,
no `flux reconcile`, no admin-API call and no `kcadm`. Whether the operator's eventual
admin-console reconstruction of this flow matches what is declared here is, by construction,
unverifiable from git.
**I never enrolled a passkey and never pointed a browser at any of this.** Nothing here has been
exercised by a real WebAuthn ceremony on any device. Specifically unverified:
* **That the RP ID works.** `home.keithjackson.dev` is a registrable-domain suffix of
`keycloak.home.keithjackson.dev`, so it *should* satisfy the browser's check — but I verified
that by reading the WebAuthn rule, not by watching a browser accept it. A wrong RP ID fails
**silently, in the browser, before Keycloak is involved**, with no server-side log line. If
enrolment produces no OS prompt and no error, this is the first suspect.
* **That `"not specified"` is the right literal for `authenticatorAttachment` in a realm import.**
I traced it to `Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED` and to the theme's string
compare, but I have not seen a realm import accept it. Omitting the field would default to the
same value; if an import ever rejects the literal, drop the line.
* **That `createTimeout: 300` behaves.** Seconds-vs-milliseconds is read from the theme JS. The
claim that 300 is long enough for the cross-device QR/hybrid flow, and that Chrome clamps rather
than rejects it, is reasoning about browser behaviour, not a measurement.
* **Whether a passkey user can still reach the password form.** I argued it from
`createAuthenticationSelectionList` producing a "Try another way" option, and I noted that for a
user holding both credentials the *first* screen is chosen by the user's own credential ordering
rather than by my priorities. Neither was observed.
* **The two-screen login regression for ordinary users.** `auth-username-form` +
`auth-password-form` replacing `auth-username-password-form` is a visible change for every user
including non-passkey family accounts, and nobody has logged in through it.
* **That conditional OTP for platform-admins still fires on the password branch.** The sub-flow and
its `conditional-otp-platform-admin` config are byte-identical and merely relocated one level
deeper, but "identical YAML" is not "still enforced at runtime". This is the highest-value
regression check and it is untested. The previous breakage of this exact step
(`condUserRole` vs a non-existent property key) 403'd every platform-admin login.
* **That a platform-admin authenticating by passkey is not challenged for TOTP.** That is the
intended behaviour of the shape I wrote; it is unobserved, and it is also a deliberate
relaxation of R-IDP-04 as previously implemented, which the operator may want to reverse.
**Whether the realm import reconciles into an existing realm: measured as NO for the completed
one-shot, but I did not test a re-import.** I did not delete and recreate the
`KeycloakRealmImport`, so I cannot say what a re-run would do to the existing `homelab` realm —
whether it would merge the new policy and flows, duplicate flow aliases, or fail. The 2026-09-06
total SSO outage documented in `platform/keycloak/README.md` is the reason I did not experiment.
**Treat the re-import path in the README as untested advice, not a runbook.**
**Nothing was proved about the live realm's current state.** I did not read the realm's existing
required actions, its existing WebAuthn policies, or its live flow executions — doing so needs the
admin credential and I did not decrypt or use it. So I cannot confirm that the live realm's
required-action set actually contains `webauthn-register-passwordless` today; I can only show that
Keycloak 26.0.6's defaults register it and that this repo's import never declared any required
actions to suppress them. **That inference has not been checked against the realm.**
**L2/L3 conformance did not run** (no cluster from the gate container; `vars.CLUSTER_AVAILABLE` is
unset in CI anyway), so the existing `keycloak-config-as-code` behavioural test did not re-execute
against this change. I reasoned about its assertion by reading it.
**No new conformance test was added.** Nothing in `conformance/` asserts the passkey policy or the
new flow shape, so a future edit can silently remove either and no gate stage will notice. That is
a real gap this PR leaves open; closing it would touch `conformance/**`, which is a sensitive path
needing non-author review.
**The quoted gate run predates this file's own gate-result paragraph.** It gated commit `207f5db`;
the gate numbers and the rebase note above were then written into this entry, so the pushed tree
differs from the gated one by this handoff's prose only — no manifest, no check, no generated file.
`decisions-log` is the only stage that reads this file, and it validates structure (frontmatter,
the three headings, non-stub bodies), which is unchanged. I did not re-run the full gate for a
prose edit; CI will gate the pushed commit.
**`gitea-settings` did not run locally** — it needs `SETTINGS_API_TOKEN`, which exists only in CI.
I did not re-measure branch protection, the runner, or `required_approvals` against the Gitea API
this session; the claims in `CLAUDE.md` §2 are taken on trust here and are explicitly flagged there
as needing re-measurement.
@@ -0,0 +1,99 @@
---
date: 2026-10-17
branch: fix/oauth2-proxy-oidc-startup-race
title: "oauth2-proxy dies on a cold start because OIDC discovery runs once, before Keycloak serves"
---
## What changed
Exposed by the 2026-10-17 whole-cluster shutdown for the UPS move. oauth2-proxy performs OIDC
discovery **once, at startup**, and `os.Exit(1)`s if it fails. On a cold boot Keycloak is not
serving yet, so the shared Gateway answers 503 and the proxy dies:
```
[provider.go:55] Performing OIDC Discovery...
[main.go:73] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: ...
failed to discover OIDC configuration: unexpected status "503": no healthy upstream
```
It then sits in **exponential CrashLoopBackOff long after Keycloak is healthy** — one of the two
replicas was still down minutes later and only a manual `kubectl delete pod` cleared it. There is no
retry flag; the process does not survive a cold start. A readinessProbe cannot help (the container
is already dead), and CrashLoopBackOff backoff is not tunable per-pod.
`platform/oauth2-proxy/helmrelease.yaml` gains an `extraInitContainers` gate (`wait-for-idp`) that
polls Keycloak's discovery doc until it answers, then lets the proxy start. A pod in `Init` does not
accrue CrashLoopBackOff backoff and retries promptly, which is the point.
**Bounded, and fails open.** After ~5 minutes it gives up and starts the proxy anyway, so the worst
case is exactly today's behaviour and never something new. An unbounded wait would trade a CrashLoop
for a pod wedged in `Init` forever, which is not an improvement.
## Why this shape and not the alternatives
* **`extraInitContainers` is the chart's own documented hook for precisely this.** Its commented
example in `values.yaml` is literally `- name: wait-for-idp`. The key was **confirmed against the
pinned chart** (`helm show values oauth2-proxy/oauth2-proxy --version 10.7.1` → `extraInitContainers: []`
at line 428), not assumed — a wrong Helm values key is silently ignored, which is the
"reviewed, merged, apparently done, still broken" shape this repo keeps getting bitten by.
* **No new base image.** The chart already runs an `alpine` init container for its built-in
`initContainers.waitForRedis` gate, so alpine is a base this chart uses regardless. Pinned to
`3.22` (verified to exist upstream) rather than the chart's own `latest`.
* **Flux `postRenderers` rejected:** it would work independently of the chart schema, but nothing in
this tree uses postRenderers, so it would introduce a mechanism with no precedent for a problem
the chart already has a hook for.
* **`--skip-oidc-discovery` rejected:** it removes the startup fetch entirely, which is arguably
more elegant, but it means hardcoding four Keycloak endpoints and I could not verify offline that
oauth2-proxy then fetches JWKS lazily rather than at startup. oauth2-proxy gates SSO for PiHole,
the \*arr apps, qBittorrent, Homepage, Prometheus, Alertmanager and Hubble — a wrong guess locks
the operator out of all of them, so the reversible, additive option wins.
The `$$`-free convention matters here: shell variables are written bare (`$i`, `$url`, `$(( ))`)
with **no braced form anywhere**, because `platform` declares `postBuild.substituteFrom cluster-vars`
and envsubst consumes braced expansions — a braced shell variable renders to the empty string and
silently breaks the loop. `${BASE_DOMAIN}` is braced on purpose. Same hazard and same convention as
the gitea and nextcloud OIDC bootstrap hooks, and `ci: flux-postbuild-substitution` enforces it.
## What I verified
* **The failure is measured, not theorised.** The log above is from the live pod
(`oauth2-proxy-5d6c74876b-bzb2p`, `lastState.terminated: {reason: Error, exitCode: 1}`), captured
with `--previous` after the cold boot. The surviving replica's log shows the same startup
succeeding once Keycloak was up, which is what identifies this as a race rather than a config
fault.
* **Deleting the pod is a sufficient workaround** — done on prod 2026-10-17, the replacement came up
clean and both replicas have been healthy since.
* **The chart key is real**, at the pinned version range: `helm search repo` resolves
`>=10.0.0 <11.0.0` to chart `10.7.1` (app `7.15.5`), whose values carry `extraInitContainers: []`,
`extraContainers: []` and the built-in `initContainers.waitForRedis` (which uses `alpine`).
* **`docker.io/library/alpine:3.22` exists** — confirmed against the registry tag list rather than
assumed from memory.
* The edited HelmRelease parses as YAML.
## What I did NOT verify
* **UNAPPLIED. The init container has never run.** I did not reconcile the HelmRelease, so I have not
seen `wait-for-idp` execute, exit 0, or gate the proxy. Applying it rolls both oauth2-proxy
replicas, which is a mutating action (CLAUDE.md §5) — and it rolls the component that gates SSO for
seven apps, so **do it when you can watch it**, not unattended.
* **I did not re-create the cold-start race to prove the fix.** That needs Keycloak down while
oauth2-proxy starts; the honest test is the next whole-cluster boot. Until then this is reasoned
from the measured failure, not demonstrated against it.
* **busybox `wget`'s TLS behaviour here is assumed, not tested.** The claim is that alpine's
`ca-certificates-bundle` lets it validate the Gateway's LE-prod certificate, so a genuine TLS
failure exits non-zero rather than passing falsely. If that is wrong in either direction the gate
degrades safely — a never-succeeding probe just burns the 5-minute budget and then starts the proxy
anyway — but the fast path would be silently dead and this would be doing nothing. **Check the
init container's log on first apply**: it should print "Keycloak discovery reachable" within
seconds on a warm cluster, not the give-up message.
* **Whether a pod can reach the Gateway hostname at all from this namespace is inherited, not
re-proven.** The main container does exactly this fetch today and succeeds, so the path is known
good — but note the standing trap that an arbitrary pod gets 403 from every Gateway hostname
(`decisions/2026-10-04-plex-advertise-plexdirect.md`); oauth2-proxy is exempt only because
Keycloak's ingress policy admits it. An init container in the same pod inherits that identity, so
this should hold — unverified.
* **60 attempts x 5s is not tuned.** It is "comfortably longer than Keycloak's observed cold start",
not a measured bound.
* **No runtime conformance.** `vars.CLUSTER_AVAILABLE` is unset, so CI proves the L0/L1 offline
subset only.
* I did not re-measure branch protection or runner state (CLAUDE.md §2b).
+222 -116
View File
@@ -1,61 +1,43 @@
# Bulk NFS storage — the media library and the legacy Nextcloud archive
The operator-facing companion to the `class: bulk` storage entries in `catalog/{plex,sonarr,radarr,nextcloud}.yaml`.
Everything here was **measured** on the storage host on 2026-09-07, over read-only SSH; nothing is
assumed. It resolves the long-standing **OQ-2** stub (`platform/longhorn/storageclasses.yaml`) for the
The operator-facing companion to the `class: bulk` storage entries in
`catalog/{plex,sonarr,radarr,qbittorrent,nextcloud}.yaml`.
**Rewritten 2026-10-07 for the move off Proxmox onto a standalone TrueNAS at 192.168.2.30.** The
layout (§2), the mount design (§3, §4) and the uid model (§6) are unchanged by the move and were
measured on the old host on 2026-09-07. Everything about the storage host itself — §1, §5, §10 —
was re-measured on the NAS on 2026-10-07. Nothing here is assumed. It resolves the long-standing
**OQ-2** stub (`platform/longhorn/storageclasses.yaml`) for the
case that actually exists here — attaching datasets that already hold data — and leaves dynamic bulk
provisioning unimplemented, because nothing needs it.
---
## 1. The storage host, and why prod cannot reach it
## 1. The storage host
The bulk data lives on the **Proxmox host's ZFS pool**. That host has exactly one interface:
The bulk data lives on a **standalone TrueNAS** (Community Edition) at **192.168.2.30** — the same
`192.168.2.0/24` as prod's nodes (`192.168.2.10/.11/.12`), so it is reached directly, with no NAT
and no routing decision.
```
vmbr0 192.168.1.9/24 default via 192.168.1.1 (pfSense LAN)
```
| | nodes | reaches 192.168.1.9? |
|---|---|---|
| **dev** | 192.168.1.40 / .41 / .42 | **Yes** — same L2 segment, and it already holds an NFS export from this host (`/mnt/Storage/k8s-volumes`, in `/etc/exports`) |
| **prod** | 192.168.2.10 / .11 / .12 | **No** — 192.168.2.0/24 is pfSense's **WAN**. Ports 22 and 8006 answer only because pfSense port-forwards them via 192.168.2.102. 2049 and 111 do not. |
Opening the storage path for prod is a **pending operator decision**. Its two candidate outcomes give
different values for the `BULK_NFS_SERVER` cluster-var:
| outcome | `BULK_NFS_SERVER` for prod |
|---|---|
| port-forward `192.168.2.102:2049` → `192.168.1.9:2049` | `192.168.2.102` |
| a WAN→LAN route to 192.168.1.0/24 | `192.168.1.9` |
Until it lands, `clusters/prod` holds `TBD-PROD-BULK-NFS-SERVER`. **See §7 for why that placeholder
behaves differently from the repo's other ones.**
**This section used to be the hard part of this page.** The datasets were on the Proxmox host, whose
only interface was `vmbr0 192.168.1.9/24` on pfSense's **LAN**, while prod sits on pfSense's **WAN**.
tcp/2049 did not traverse that, so prod mounted through a DNAT at `192.168.2.102`, and which address
belonged in `BULK_NFS_SERVER` depended on an undecided operator choice. Both pools were physically
moved on 2026-10-07 (`docs/plans/proxmox-to-n5-air-migration.md`, Phase C/D) and all of that is
simply gone. Measured 2026-10-07: **192.168.2.102:2049 no longer answers; 192.168.2.30:2049 and
:111 do.**
### It only needs ONE port
The PVs mount `nfsvers=4.1`. **NFSv4 carries mount and lock in-protocol**, so the only thing that
must be reachable is **tcp/2049** — no rpcbind/111, no mountd, no statd. NFSv3 would have needed
four ports, three of them on ephemeral numbers, which is a materially worse firewall story. The
server advertises `+3 +4 +4.1 +4.2` (`/proc/fs/nfsd/versions`), so v4.1 is available and is the
conservative floor.
four ports, three of them on ephemeral numbers. The NAS advertises `+3 +4 +4.1 +4.2`
(`/proc/fs/nfsd/versions`, measured 2026-10-07), so 4.1 is available and is the conservative floor.
### The export's client list is the SAME under both outcomes — measured, not assumed
**pfSense DNATs the existing port-forward WITHOUT source NAT.** Connecting from this workstation
(192.168.2.132) to `192.168.2.102:22`, the storage host logged the peer as `192.168.2.132`:
```
SSH_CONNECTION=192.168.2.132 39768 192.168.1.9 22
ss -tn -> 192.168.1.9:22 192.168.2.132:39768
```
The original WAN-side source address survives the translation (return traffic finds its way back via
the host's default route, 192.168.1.1). So under **either** outcome the NFS server will see prod's
**real** node addresses — 192.168.2.10/.11/.12 — and the `sharenfs` change in §5 is the same either
way. **It does not have to wait for the routing decision.**
---
**NFSv4 is not on by default and its absence does not look like a storage problem.** TrueNAS ships
NFSv3-only until NFSv4 is added to *Services → NFS → Enabled Protocols*. Without it the mount never
completes and the pods sit in `ContainerCreating` — no permission error, nothing in the app logs,
just no volume.
## 2. The layout, as found
@@ -107,16 +89,18 @@ why it is mounted read-only; see §6.
| plex | `/mnt/Storage/plex` | `/media` | — | **ro** | ReadOnlyMany |
| sonarr | `/mnt/Storage/plex` | `/media` | — | rw | ReadWriteMany |
| radarr | `/mnt/Storage/plex` | `/media` | — | rw | ReadWriteMany |
| qbittorrent | `/mnt/Storage/plex` | `/downloads` | `downloads` | rw | ReadWriteMany |
| nextcloud | `/mnt/Storage/nextcloud` | `/mnt/legacy-nextcloud` | `nextcloud` | **ro** | ReadOnlyMany |
In-app paths that follow: Plex libraries at `/media/movies`, `/media/tv`, `/media/photos`; Sonarr
Root Folder `/media/tv`; Radarr Root Folder `/media/movies`; both importing from
`/media/downloads/complete/{tv-sonarr,radarr}`.
**All three media consumers use the identical mountPath on purpose.** When qbittorrent is eventually
given the same export (it is not in this change), the arrs and the download client will already agree
on absolute paths, which is the second precondition for hardlinked imports — otherwise every import
needs a Remote Path Mapping and quietly falls back to copying when one is missing.
**The three library consumers use the identical mountPath on purpose**, and qbittorrent has since
been given the same export (narrowed by `subPath: downloads`, presented at `/downloads` to match its
existing `Session\DefaultSavePath`). The arrs and the download client therefore agree on absolute
paths, which is the second precondition for hardlinked imports — otherwise every import needs a
Remote Path Mapping and quietly falls back to copying when one is missing.
**`/media` and not `/data`, and that is not cosmetic.** The arrs' Longhorn config PVC renders at the
renderer default `/data/config`. A media mount at `/data` would be mounted **over** it and mask the
@@ -147,62 +131,123 @@ Narrowing that would cost hardlinks, which is the worse trade.
---
## 5. The export change the operator must apply — STRICTLY ADDITIVE
## 5. The exports, as configured on TrueNAS — and the settings that decide permissions
**Do not apply this from the repo.** It is an operator step on the storage host, recorded here so the
exact value is reviewable. The existing consumers **must keep working**: 192.168.1.6, 10.123.1.10 and
192.168.1.18 on the media tree, and 192.168.1.6 and 192.168.1.5 on the Nextcloud dataset, are the
live non-k3s prod stack.
**Do not apply this from the repo.** It is operator configuration on the NAS, recorded here so the
exact values are reviewable. Everything below was **measured on 192.168.2.30 on 2026-10-07**, after
the move.
Current values (`zfs get sharenfs Storage`):
### The exports
```
Storage/plex rw=@192.168.1.6:@10.123.1.10:@192.168.1.18,no_subtree_check,no_root_squash
Storage/nextcloud [email protected]:@192.168.1.5,no_subtree_check,no_root_squash
/mnt/Storage/plex 192.168.2.10 192.168.2.11 192.168.2.12
(sync,wdelay,hide,no_subtree_check,sec=sys,rw,secure,root_squash,no_all_squash)
/mnt/Storage/nextcloud 192.168.2.10 192.168.2.11 192.168.2.12
(sync,wdelay,hide,no_subtree_check,sec=sys,ro,secure,root_squash,no_all_squash)
```
### `Storage/plex` — append the six k3s node addresses to the existing `rw=` list
Per-node addresses rather than `@192.168.2.0/24`, following the precedent of the old host. A `/24`
would work and would survive adding a node; it also grants the whole subnet.
**All three nodes must appear on BOTH shares.** Any media pod can be scheduled on any node, so a
missing node is a latent failure, not an immediate one: it surfaces the first time a pod reschedules
there, as `access denied by server while mounting`, with the cause weeks stale. One was caught
exactly this way during setup — the plex share listed `192.168.2.13`, an address no node holds, and
so was missing `192.168.2.12` (k3s-prod-3). Nothing was failing at the time.
### The option flags ARE the permission contract
| flag | why it matters here |
|---|---|
| **`no_all_squash`** | The one the whole model rests on. Client uids pass through untouched, so the arrs' `PUID=1000` arrives as the owner of a `0755 1000:3000` tree. In the UI this is **Mapall User/Group left EMPTY** — setting either squashes every client to one identity and erases the reader/writer distinction entirely. |
| **`sec=sys`** | AUTH_SYS. Numeric uid/gid, not Kerberos principals. §6 is only true because of this. |
| **`root_squash`** | Maproot left empty. The Proxmox export carried `no_root_squash`; **nothing here needs it** — every write to the tree happens after the LSIO drop to uid 1000, and the images only chown their Longhorn `/config`. This is a deliberate tightening over the old export, not parity. |
| **`rw` / `ro`** | The archive's read-only posture is **server-enforced**, as it was before. The media tree cannot get the same treatment — see below. |
| **`secure`** | Reserved source ports required; "Allow non-root mount" off. The kubelet mounts as root from a reserved port and does not need the exception. |
**Plex's read-only posture is still a CLIENT-side guarantee.** Plex is ro and the arrs are rw, but
all four can land on any node, so the server sees the same three source addresses for all of them
and cannot distinguish the reader from the writers. `spec.nfs.readOnly: true` on `plex-media` plus
the volumeMount flag is the whole enforcement. Unchanged by the move; an honest limitation, not an
oversight. If it ever needs to be server-enforced, the lever is a dedicated node pool for Plex.
### Service settings that are NOT obvious from the export list
- **Enabled Protocols must include NFSv4** (above).
- **Require Kerberos for NFSv4: off.** The numeric AUTH_SYS pass-through is the model.
- **Manage Groups Server-side: off.** This is `rpc.mountd --manage-gids`: the server *discards the
client's supplied group list* and looks that uid's groups up in TrueNAS's own user database
instead. With no local uid 1000 defined, the arrs' `PGID=3000` would simply vanish. Writes would
survive anyway — uid 1000 **owns** the tree, so the owner bits grant access without the group —
but it is a free way to lose group semantics.
- **NFSv4 DNS Domain: blank.** It only feeds `idmapd.conf`, which numeric mapping bypasses.
- **Bind IP 192.168.2.30** means loopback does not answer: check with `showmount -e 192.168.2.30`,
not `localhost`.
There is **no** "NFSv3 ownership model for NFSv4" control on current TrueNAS — it was an older
TrueNAS/CORE option, and no replacement is needed: on a Linux client against a Linux server over
AUTH_SYS, both the `nfs` and `nfsd` modules default to `nfs4_disable_idmapping=Y`, so NFSv4 carries
raw numeric ids rather than `user@domain` principals. §9's `ls -n` probe is how you confirm it.
### The ZFS `sharenfs` properties must be CLEARED, not edited
The pools arrived with the old host's exports still set as dataset properties:
```
Storage/plex [email protected]:@10.123.1.10:@192.168.1.18:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash
Storage/nextcloud [email protected]:@192.168.1.5,[email protected]:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash
Storage/docker-volumes [email protected]:@192.168.1.5:@192.168.1.10:@192.168.1.18,no_subtree_check,no_root_squash
```
TrueNAS generates `/etc/exports` from its own middleware database and will fight a property it did
not set — the failure mode is two differently-configured exports of one path, with no indication
which is in force. Cleared 2026-10-07:
```sh
zfs set sharenfs='[email protected]:@10.123.1.10:@192.168.1.18:@192.168.1.40:@192.168.1.41:@192.168.1.42:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash' Storage/plex
zfs inherit sharenfs Storage/plex
zfs inherit sharenfs Storage/nextcloud
zfs inherit sharenfs Storage/docker-volumes
```
Every pre-existing host is preserved verbatim and no option changes; only clients are added.
`Storage` is `off`, so each resolves to `off`.
### `Storage/nextcloud` — add the k3s nodes to a **`ro=`** list, not to `rw=`
### Do NOT touch the dataset's permissions or ACLs
```sh
zfs set sharenfs='[email protected]:@192.168.1.5,[email protected]:@192.168.1.41:@192.168.1.42:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash' Storage/nextcloud
```
The tree's ownership **is** the access story (§6), and TrueNAS offers two ways to destroy it
irreversibly across 8.33 T.
This is the one place read-only can be enforced **server-side**, and it is worth taking: only
Nextcloud consumes this dataset from k3s, so the export itself can refuse writes. Even a
misconfigured client cannot then damage the archive.
`Storage/plex` carries `acltype=nfsv4` (SCALE's built-in default, `SOURCE: default`) and
`aclinherit=passthrough` (inherited). Measured 2026-10-07: the directories carry **trivial ACLs
only** — `nfs4xdr_getfacl` shows just `owner@`/`group@`/`everyone@` matching the mode — so `0755
1000:3000` governs exactly as it did under Proxmox.
**The media tree cannot get the same treatment.** Plex is read-only and the arrs are read-write, but
all three may be scheduled on any node, so the server sees the same source addresses for all of
them and cannot distinguish the reader from the writers. **Plex's read-only posture is therefore a
client-side guarantee** (`spec.nfs.readOnly: true` on `plex-media`, plus the volumeMount flag) — an
honest limitation, not an oversight. If it ever needs to be server-enforced, the lever is a
dedicated node pool for Plex, which is a much larger change.
- **Stay out of the dataset's Edit Permissions / Edit ACL screen.** On an `acltype=nfsv4` dataset
that is a full ACL editor, and its presets write *real* NFSv4 ACLs, recursively if permitted.
There is no undo.
- `aclinherit=passthrough` means a single ACL written at `/mnt/Storage/plex` would be **inherited by
everything created under it afterwards** — the blast radius is the whole future library, not one
directory.
- Note GNU `ls`'s `+` marker is **not** a reliable ACL indicator here: it looks for a POSIX ACL
xattr, and this dataset stores NFSv4 ACLs elsewhere. Use `nfs4xdr_getfacl`.
Per-node addresses rather than `@192.168.2.0/24` follows the precedent already on this host
(`/etc/exports` lists the dev nodes individually, as `/32`). A `/24` would work and would survive
adding a node; it also grants the whole subnet. Per-node is the tighter default.
`xattr=on` rather than `sa` is a performance detail, not a permissions one, and changing it would
not affect existing files.
### Verifying, without changing anything
```sh
exportfs -v # the new client lines appear, existing ones unchanged
zfs get sharenfs Storage/plex Storage/nextcloud
cat /proc/fs/nfsd/versions # +4.1 must be present
exportfs -v # the two shares, three nodes each, flags as tabulated above
showmount -e 192.168.2.30 # NOT localhost -- the service is bound to one address
zfs get -r -o name,property,value,source sharenfs Storage # every row off or -
```
---
## 6. uid / gid — the failure this design exists to avoid
The export uses `sec=sys` and `no_root_squash`, so **the client's numeric uid is passed through
verbatim** and the server's on-disk ownership is the entire access story. The media tree is
The export uses `sec=sys` and `no_all_squash`, so **the client's numeric uid is passed through
verbatim** and the server's on-disk ownership is the entire access story. (It no longer carries
`no_root_squash`, which the Proxmox export did — see §5. Nothing here needs it, and the uid that
matters is 1000, not 0.) The media tree is
`0755 1000:3000` throughout (some directories `1000:1000`), so **only uid 1000 can write it**.
**`fsGroup` cannot fix this, and reaching for it is how this gets misdiagnosed.** The in-tree NFS
@@ -233,24 +278,27 @@ Nextcloud needs no uid work at all, which is one more reason its mount is read-o
---
## 7. The prod placeholder does NOT fail at apply time — read this before merging
## 7. Why this knob is unvalidated at create AND immutable at update
**The `TBD-PROD-BULK-NFS-SERVER` placeholder is gone** (2026-10-07) — prod now carries a real
address, because the NAS is on prod's own subnet and there is no longer a decision to defer. The
hazard that made it load-bearing is worth keeping on the page, because it is the half that explains
why §10 exists.
Every other `TBD-PROD-*` in `clusters/prod/kustomization.yaml` is rejected loudly by CRD validation
(Cilium, LB-IPAM, CNPG, Velero) because it is not a valid IP or URL. **This one is not.**
(Cilium, LB-IPAM, CNPG, Velero) because it is not a valid IP or URL. **This one was not.**
A PersistentVolume's `spec.nfs.server` is an **unvalidated string**. The API server accepts
`TBD-PROD-BULK-NFS-SERVER`, the PV is created, and the PVC **binds** to it — binding is pure
control-plane and never touches the server. The failure appears only when a pod mounts:
A PersistentVolume's `spec.nfs.server` is an **unvalidated string**. The API server accepted
`TBD-PROD-BULK-NFS-SERVER`, the PV was created, and the PVC **bound** to it — binding is pure
control-plane and never touches the server. The failure appeared only when a pod mounted:
```
MountVolume.SetUp failed ... Failed to resolve server TBD-PROD-BULK-NFS-SERVER
```
so **plex, sonarr, radarr and nextcloud would sit in ContainerCreating on prod.**
**Consequently: set `BULK_NFS_SERVER` in `clusters/prod/kustomization.yaml` BEFORE prod reconciles a
revision that carries these bulk volumes.** Dev renders a working value and is where these manifests
are meant to be proven first.
**The same field is immutable on UPDATE** (§10), which is the other half of the same awkwardness: a
wrong value is accepted silently at create, and then cannot be corrected in place. Together they are
why repointing this knob is a delete-and-recreate rather than a reconcile.
### The static guard this convention leans on is missing from `main` — but the gate now refuses the value anyway
@@ -287,10 +335,9 @@ Without the arm, HALF A is what holds the line, by design.
EPHEMERAL layer"). Plex was fixed; sonarr, radarr, qbittorrent and jackett were not. Fixing it
means those apps start from an empty config volume, so it belongs in its own change with the
operator present. **This change does not make it worse** — that is why the media mount is `/media`.
2. **qbittorrent has no bulk mount.** Today the arrs' import source is written by the **legacy
non-k3s** download client, which is coherent while the legacy stack stays live. Giving the k3s
qbittorrent the same export at the same `/media` path is the natural follow-up, and the paths were
chosen so it needs no Remote Path Mapping.
2. **RESOLVED — qbittorrent now has the bulk mount** (`subPath: downloads` at `/downloads`), so the
import source is written by the k3s download client rather than the legacy non-k3s one. See
`catalog/qbittorrent.yaml`.
3. **`/mnt/Storage/plex/downloads/downloads` is a symlink to `/shared/downloads`**, which is outside
the export. Over NFS it is a dangling symlink on every client. Harmless, but expect Sonarr to log
it while scanning `downloads/`.
@@ -300,10 +347,10 @@ Without the arm, HALF A is what holds the line, by design.
5. **`nconnect` was not set.** It would likely help throughput on the media tree, but it is a tuning
change that should be measured, not guessed, and it changes client behaviour under server
failure.
6. **Nothing was verified against a live cluster.** No `kubectl` context is usable from this
workstation (browser-OIDC only), and this change is manifest-authoring under an explicit
do-not-apply constraint. Every claim about the *host* is measured; every claim about *binding and
mounting* is reasoned from the manifests.
6. **Every claim about the NAS in §5 is measured (2026-10-07); the post-move MOUNT is not.** The PVs
still have to be recreated (§10) before any pod mounts 192.168.2.30, so the §9 probes are the
outstanding verification, not a formality. The pre-move equivalents did pass against the old host
on 2026-09-12.
7. **The bulk-tier requirement ID is deliberately not typed on any non-`.md` line of this change.**
`ci/touched-requirements.sh` attributes requirements from R-IDs appearing in added lines, and the
in-cluster conformance test for the bulk tier is still a gap, so typing it would select a
@@ -312,36 +359,95 @@ Without the arm, HALF A is what holds the line, by design.
---
## 9. Validating on dev, once the export is extended
## 9. Validating, after the PVs are recreated
Dev can prove all of this before prod's network path exists. Read-only checks first:
These run against **prod**; dev is decommissioned. They are the outstanding verification for the
move — nothing below has been run against 192.168.2.30. Read-only checks first:
```sh
infra/devcontainer/dev.sh run kubectl --context k3s-dev get pv
infra/devcontainer/dev.sh run kubectl --context k3s-dev get pvc -A | grep -E 'media|legacy-archive'
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr get pod -o wide
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get pv -o custom-columns=\
NAME:.metadata.name,SERVER:.spec.nfs.server,PATH:.spec.nfs.path,STATUS:.status.phase'
```
Expect **four** PVs — `plex-media`, `sonarr-media`, `radarr-media`, `nextcloud-legacy-archive` (three
of them naming the *same* export, one per namespace, which is the cross-namespace model) — each
`Bound` to the same-named PVC, with `RECLAIM POLICY Retain` and an empty `STORAGECLASS`.
Expect **five** NFS PVs — `plex-media`, `sonarr-media`, `radarr-media`, `qbittorrent-downloads`,
`nextcloud-legacy-archive` (four of them naming the *same* export, one per namespace, which is the
cross-namespace model) — each `Bound`, `RECLAIM POLICY Retain`, empty `STORAGECLASS`, and
**`SERVER 192.168.2.30`**. A PV still showing `192.168.2.102` means the recreate in §10 did not
happen; Flux will NOT have updated it in place.
Then the two things that actually matter, from inside a pod:
Then the four things that actually matter, from inside a pod:
```sh
# (a) the writer can write — this is the uid check, and it is the one that fails in practice
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr exec deploy/sonarr -- \
sh -c 'id; touch /media/tv/.k3s-write-probe && rm /media/tv/.k3s-write-probe && echo WRITE-OK'
# (a) numeric ids survived the wire -- the idmapping check, new since the TrueNAS move
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- sh -c 'ls -n /media | head -3'
# expect uid 1000 / gid 3000 on the entries. 65534 / nobody means ids are being mapped, not
# passed through, and every import will fail EACCES while the pod still reports Ready.
# (b) hardlinks work BETWEEN the library and the download path — the root-mount check
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr exec deploy/sonarr -- \
# (b) the writer can write -- the uid check, and the one that fails in practice
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- \
sh -c 'touch /media/tv/.k3s-write-probe && rm /media/tv/.k3s-write-probe && echo WRITE-OK'
# (c) hardlinks work BETWEEN the library and the download path -- the root-mount check
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- \
sh -c 'set -e; d=/media/downloads/complete/tv-sonarr/.probe; t=/media/tv/.probe-link;
echo x > "$d"; ln "$d" "$t"; ls -i "$d" "$t"; rm -f "$d" "$t"; echo HARDLINK-OK'
# (c) the reader really is read-only
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n plex exec deploy/plex -- \
sh -c 'touch /media/.probe 2>&1 | head -1' # expect "Read-only file system"
# (d) the reader really is read-only
kubectl --context k3s-prod -n plex exec deploy/plex -- sh -c 'touch /media/.probe 2>&1 | head -1'
# expect "Read-only file system"
```
`(b)` printing two identical inode numbers is the proof that §4's argument holds in practice. If it
`(c)` printing two identical inode numbers is the proof that §4's argument holds in practice. If it
prints `EXDEV`, something has split the single mount back into two.
Reading the failures together is what localises the fault: **(b) EACCES with (a) showing `nobody`**
is idmapping (§5 service settings). **(b) EACCES with (a) showing `1000`** is on-disk ownership —
stop and check `ls -ldn` on the NAS before changing anything else.
---
## 10. Repointing BULK_NFS_SERVER means DELETING AND RECREATING THE PVs
**A bound PersistentVolume's NFS source is immutable.** Measured by server-side dry-run on
2026-10-07:
```
$ kubectl patch pv sonarr-media --type=json --dry-run=server \
-p '[{"op":"replace","path":"/spec/nfs/server","value":"192.168.2.30"}]'
The PersistentVolume "sonarr-media" is invalid: spec.persistentvolumesource: Forbidden:
spec.persistentvolumesource is immutable after creation
```
Note the pairing with §7: `spec.nfs.server` is **unvalidated at create** (any string is accepted and
the PVC binds) and **immutable at update**. So editing `BULK_NFS_SERVER` alone does not fail loudly
and does not work either — Flux's apply errors on the five PVs and the pods keep their previous,
now-dead mount.
This is safe to do because `persistentVolumeReclaimPolicy: Retain` means deleting a PV never touches
the data, and `catalog/render.py` emits a **pre-set `claimRef`** on each one, so a recreated PV
rebinds its own PVC with no manual step.
**Operator sequence** (mutating `kubectl` is an operator action, not an agent action):
```sh
# 1. Stop the consumers. This also releases the hung hard-mounts against the old server:
# the pods report Ready, but any real I/O on /media blocks indefinitely once 2049 stops
# answering, because the PVs mount `hard`.
for ns in plex sonarr radarr qbittorrent nextcloud; do
kubectl --context k3s-prod -n $ns scale deploy --all --replicas=0
done
# 2. Delete the five PVs. Retain + no live claim means nothing is reclaimed or erased.
kubectl --context k3s-prod delete pv plex-media sonarr-media radarr-media \
qbittorrent-downloads nextcloud-legacy-archive
# 3. Let Flux reconcile the revision carrying BULK_NFS_SERVER=192.168.2.30, which recreates them.
# Confirm .status.lastAppliedRevision matches the GitRepository artifact before continuing.
# 4. Bring the consumers back, then run every probe in §9.
```
The PVCs pass through `Lost` between steps 2 and 3; that is expected and resolves when the PV with
the matching `claimRef` reappears. If a PVC is still `Lost` after Flux reconciles, the PV name or
its `claimRef` namespace does not match — do **not** delete the PVC to "fix" it.
+15 -3
View File
@@ -233,9 +233,21 @@ about runtime behaviour in this document is a design claim, not a measurement.
## Follow-ups, in the order they are worth doing
1. **Build, push, pin.** Operator runs `infra/agent/build-image.sh`, pushes, and one PR
replaces both placeholder digests. Expect the build to need iteration: the nodejs module
stream and the npm install have never run against this base.
1. **Make the registry pullable, THEN build, push, pin.** Measured after the merge, and
neither half is declared anywhere in this repository
(`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`):
(i) the nodes resolved the registry hostname to the WAN IP and failed TLS against the
router's `CN=%D` placeholder certificate — **diagnosed and fixed** by pinning
`gitea.{{ k3s_base_domain }}` to the Gateway VIP in `node_baseline_hosts_pins`, the same
pin the Keycloak issuer already carries, applied by
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`;
(ii) the pull is still unauthenticated and no `imagePullSecret` exists in the tree — with
the credential decision now being local Gitea accounts with PATs, that means a
`dockerconfigjson` for an account whose token carries `read:package`. Only then does the
digest matter. Operator runs `infra/agent/build-image.sh`, pushes, and one PR replaces both
placeholder digests. Expect the build itself to need iteration too: the nodejs module
stream and the npm install have never run against this base. The same two blockers sit in
front of `conformance-runner`, which shares the registry path and has never been pulled.
2. **Create the credentials** per `pm-agent-credentials.sops.yaml.example`, and **verify the
bot's permission is `write`, not `admin`** — that measurement is the control that keeps
the agent out of its own approval gate.
+12 -2
View File
@@ -90,9 +90,19 @@ esac
# pointing at an external host would be REACHABLE: it could clone a repository that is not
# this one and push this repository's work somewhere else. The allowlist is baked in here,
# where changing it requires rebuilding the image whose digest admission pins.
#
# ONE ENTRY, AND IT IS THE CLUSTER-LOCAL SERVICE NAME. This agent runs INSIDE the cluster, so
# it reaches Gitea the way every other in-cluster consumer does — `*.svc.cluster.local` —
# which is by design a different path from how a client outside the cluster reaches the same
# service. The external hostname `gitea.home.keithjackson.dev` used to be a second accepted
# value here; it is removed deliberately. It was never needed (nothing sets it), it routes
# in-cluster traffic out through the Gateway and back for no reason, and as an ACCEPTED value
# it widened this allowlist to a host reachable over the `world:443` egress rule — which is
# the exact shape of injection the allowlist exists to refuse. The cluster-local name also
# depends on none of public DNS, Cloudflare, the Gateway or the certificate.
case "${PM_GITEA_URL}" in
http://gitea.gitea.svc.cluster.local:3000|https://gitea.home.keithjackson.dev) ;;
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not an allowed git host (see this script's header)" ;;
http://gitea.gitea.svc.cluster.local:3000) ;;
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not the allowed in-cluster git host (see this script's header)" ;;
esac
[[ "${PM_GITEA_OWNER}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]] || die "PM_GITEA_OWNER is malformed"
[[ "${PM_GITEA_REPO}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] || die "PM_GITEA_REPO is malformed"
+13 -3
View File
@@ -86,12 +86,22 @@ refuses "branch equal to the base branch" "must not be the base branch" \
# --- where code comes FROM and goes TO -------------------------------------
# The namespace's egress policy permits tcp/443 to the world (the model needs it), so an
# injected host would be REACHABLE. These are the cases that matter most.
refuses "a redirected git host" "is not an allowed git host" \
refuses "a redirected git host" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://evil.invalid
refuses "a real, reachable, WRONG git host" "is not an allowed git host" \
refuses "a real, reachable, WRONG git host" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://github.com
refuses "the right host on a smuggled path" "is not an allowed git host" \
refuses "the right host on a smuggled path" "is not the allowed in-cluster git host" \
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000/../..
# This agent is IN the cluster, so it reaches Gitea by its cluster-local service name. The
# EXTERNAL hostname is refused too — it used to be a second accepted value, and as an
# accepted value it widened this allowlist to a host reachable over the `world:443` egress
# rule, which is the injection shape the allowlist exists to refuse.
refuses "this Gitea's EXTERNAL hostname" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://gitea.home.keithjackson.dev
refuses "the cluster-local name over https" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://gitea.gitea.svc.cluster.local:3000
refuses "the cluster-local name, wrong port" "is not the allowed in-cluster git host" \
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:8080
refuses "a malformed owner" "PM_GITEA_OWNER is malformed" \
'PM_GITEA_OWNER=../other-org'
refuses "a malformed repository" "PM_GITEA_REPO is malformed" \
@@ -327,6 +327,29 @@ node_baseline_hosts_pins:
- ip: "192.168.2.20"
names:
- "keycloak.{{ k3s_base_domain }}"
# gitea: the SECOND node process to need this pin, for the identical reason, found
# 2026-10-06. containerd pulls images as a NODE process, so it resolves through the
# public upstreams above and gets the CNAME to wan.home.keithjackson.dev — the WAN IP,
# which this router does not hairpin. The pull then fails on the router's own
# placeholder certificate rather than on anything to do with the image:
#
# failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:…":
# tls: failed to verify certificate: x509: certificate is not valid for any names,
# but wanted to match gitea.home.keithjackson.dev
#
# Measured from the workstation the same day, against the WAN address with that SNI:
# subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
# No extensions in certificate <- no SANs at all, hence "not valid for ANY names"
# while the same name via PiHole answers 192.168.2.20 and serves the real
# Let's Encrypt *.home.keithjackson.dev. So the certificate and the Gateway are fine;
# only the node's ANSWER is wrong. Same root cause, same fix, as the issuer pin above.
#
# WHO NEEDS IT: any workload whose image lives in the in-cluster Gitea registry —
# today platform/pm-agent (agent-runner) and conformance/incluster
# (conformance-runner), neither of which has ever been pulled. A pull is still
# UNAUTHENTICATED after this pin, which is a separate thing to settle; this only makes
# the node reach the right endpoint.
- "gitea.{{ k3s_base_domain }}"
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
+19 -8
View File
@@ -3,7 +3,7 @@
# new-peer.sh — enrol one device on the remote-access VPN (platform/vpn-ingress).
#
# Generates a WireGuard keypair, renders the device's client profile, prints a QR code for a phone,
# and prints the one `[Peer]` stanza to paste into platform/vpn-ingress/peers.yaml. The device's
# and prints the one `[Peer]` stanza to paste into platform/vpn-ingress/peers.conf. The device's
# private half is written to ONE 0600 file and nowhere else: not sent to the cluster, not committed,
# never logged.
#
@@ -62,7 +62,7 @@ set -euo pipefail
die() { printf 'new-peer.sh: %s\n' "$*" >&2; exit 1; }
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 -------------------
# These are duplicated here rather than parsed out of the YAML on purpose: this script is meant to
@@ -133,11 +133,22 @@ CLIENT_ADDR="${CLIENT_BASE}.${OCTET}"
# --- refuse a collision, because the failure is otherwise invisible -----------------------------
# 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.
if [ -r "$PEERS_FILE" ] && grep -q "AllowedIPs *= *${CLIENT_ADDR}/32" "$PEERS_FILE"; then
die "${CLIENT_ADDR}/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet."
# COMMENT LINES ARE NOT ALLOCATIONS. This used to grep the whole file and so matched the
# 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
if [ -r "$PEERS_FILE" ] && grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
printf 'new-peer.sh: WARNING a peer comment already mentions "%s" in peers.yaml.\n' "$NAME" >&2
if grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
printf 'new-peer.sh: WARNING a peer comment already mentions "%s" in peers.conf.\n' "$NAME" >&2
fi
# --- output location: 0700, and never inside the repo -------------------------------------------
@@ -204,7 +215,7 @@ cat <<EOF
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)
[Peer]
@@ -223,7 +234,7 @@ cat <<EOF
STEP 3 — delete the profile from this machine once the device has it:
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.
------------------------------------------------------------------------------------------
EOF
+68
View File
@@ -154,6 +154,74 @@ NEW client, a new protocol mapper or a changed scope still needs the realm impor
(it will not on its own) or a deliberate admin-API/`kcadm` change. `Done=True` is never evidence that
a realm edit landed.
## Passkeys (WebAuthn Passwordless) — DECLARED IN GIT, NOT YET IN THE REALM (2026-10-17)
`keycloak-realm-import.yaml` now declares passkey login: the realm's **WebAuthn Passwordless**
policy (`webAuthnPolicyPasswordless*`), and a `browser-mfa` flow that offers a passkey as an
**ALTERNATIVE** beside the password form. Design decisions and their reasoning are in the manifest
comments, not repeated here. The headline choices:
| Decision | Value | One-line reason |
|---|---|---|
| Additive or replacing? | **Additive** — passkey *or* password | A misfiring mandatory flow locks the single operator out of every SSO app *and* kubectl |
| Enrolment | **Optional**, never a default action | Same reason; `webauthn-register-passwordless` is already enabled-not-forced by default |
| Relying-party ID | `${BASE_DOMAIN}` (not `keycloak.${BASE_DOMAIN}`) | An unre-keyable credential must not be bound to a hostname that can move |
| User verification | `required` | Without it, mere possession of an unlocked device is a complete login |
| Resident key | `Yes` | A non-discoverable credential is a second factor, not a passkey |
| OTP after a passkey? | **No** — conditional OTP stays on the password branch | A UV-required passkey is already two factors, and a phishing-resistant pair at that |
### This change does NOT reach the live realm. At all.
Same one-shot failure this file already documents twice above, third field over. Measured on prod
2026-10-17:
```
$ kubectl --context k3s-prod -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
{"conditions":[{"status":"True","type":"Done"},{"status":"False","type":"Started"},
{"status":"False","type":"HasErrors"}]}
```
`Done=True` / `Started=False` — the import has completed and **will not re-run**, so a policy block
and three new sub-flows added to this file change nothing about how anyone logs in. The
client-secret/client-URL reconciler does **not** close this gap and was never meant to: its hard
endpoint allow-list makes `/authentication` unreachable **by design** (see the table above), and
realm-level policy fields are not client fields. This is exactly the "a NEW client, a new protocol
mapper or a changed scope still needs the realm import to actually re-run" case named above.
**So the manifest is the declared source of truth and a DR-rebuild input, and nothing more, until
an operator applies it by hand.** Two paths, both operator actions:
1. **Admin console (expected path).** *Authentication → Policies → WebAuthn Passwordless Policy*
for the policy block, then *Authentication → Flows → browser-mfa* to rebuild the executions to
match the manifest. The flow edit is the fiddly half: split the existing Username Password Form
into Username Form + Password Form and add the two sub-flows exactly as declared.
2. **A deliberate re-import.** Deleting and recreating the `KeycloakRealmImport` re-runs it against
the **existing** realm. Do not reach for this casually — it is the operation whose failure mode
is the 2026-09-06 total SSO outage documented above, and it has not been rehearsed here.
Either way the realm is then configured by hand and git is only the record of intent, which is a
live exception to R-IDP-03 (config as code) — not a new one, but one more instance of it.
### Verifying it actually works (nobody has, as of 2026-10-17)
No passkey has been enrolled and no browser has been pointed at this. The checks that would prove
it, in order:
```sh
# 1. the policy is live (RP ID must read as the base domain, NOT the keycloak host, NOT a URL)
# Authentication -> Policies -> WebAuthn Passwordless Policy, in the admin console.
# 2. enrol: account console (https://keycloak.${BASE_DOMAIN}/realms/homelab/account) -> Signing in
# -> Passwordless -> set up. A browser/OS passkey prompt must appear; if it does not, the RP ID
# is wrong for the origin and NOTHING will say so.
# 3. log in: a fresh private window to any SSO-gated app. Expect username screen, then a passkey
# prompt. A platform-admin must NOT be asked for TOTP on this path.
# 4. regression (the important one): a user with NO passkey must still get the password screen,
# and a platform-admin must still be asked for TOTP there.
```
A failed passkey ceremony is usually silent in the server log — the browser refuses before Keycloak
is involved — so step 2 failing with no log entry is the expected signature of a wrong RP ID.
## Exceptions that keep native auth (do not force SSO) — ADR-0014
Plex/Seerr (Plex auth), Minecraft (Mojang/Microsoft), UniFi (native).
+285 -8
View File
@@ -74,6 +74,142 @@ spec:
bruteForceProtected: true
permanentLockout: false
sslRequired: external
# ===========================================================================================
# PASSKEYS — the WebAuthn PASSWORDLESS policy (R-IDP-04). 2026-10-17.
# ===========================================================================================
# Read this together with the browser-mfa flow at the bottom of the file: THIS block decides
# what a passkey must BE, the FLOW decides when one is OFFERED. Keycloak keeps TWO independent
# WebAuthn policies per realm and they are not interchangeable:
#
# webAuthnPolicy* -> the TWO-FACTOR policy, consumed by the `webauthn-authenticator`
# authenticator (a security key presented AFTER a password).
# webAuthnPolicyPasswordless* -> the PASSWORDLESS policy, consumed by
# `webauthn-authenticator-passwordless` (a passkey presented
# INSTEAD of a password). This is the one passkeys use.
#
# Setting the wrong family is a silent no-op: the passwordless authenticator reads
# getWebAuthnPolicyPasswordless() only (WebAuthnPasswordlessAuthenticator.getWebAuthnPolicy),
# so a policy written into the two-factor fields is simply never consulted by a passkey login.
# Only the passwordless family is declared here; the two-factor family is deliberately left at
# Keycloak's defaults because nothing in this realm uses `webauthn-authenticator` — TOTP
# (auth-otp-form) is the second factor for platform-admins, see the flow section.
#
# ADDITIVE AND OPTIONAL — THE DECISION THIS BLOCK EXISTS TO MAKE
# -------------------------------------------------------------
# Passkeys are offered ALONGSIDE the password, never instead of it, and enrolment is NOT forced.
# This is a single-operator homelab whose IdP gates Grafana, Gitea, Nextcloud, Headlamp,
# Semaphore, PiHole, Crafty, the *arr stack AND kubectl itself (the `kubernetes` client below).
# A mandatory passkey flow that misfires is therefore not an inconvenience, it is a total
# lock-out from the whole platform AND from the cluster API in one step — and the only repair
# paths left would be the Keycloak bootstrap admin and the break-glass cert kubeconfig. The
# expected gain from forcing enrolment (one human stops typing a password) is small; the
# expected loss is the platform. So: additive, optional, and password login keeps working for
# every user who never enrols.
#
# Concretely that means: no `requiredActions` block is declared anywhere in this file (see the
# long note in the flow section for why declaring one would be actively DANGEROUS), and the
# passkey step sits as an ALTERNATIVE beside the password form rather than ahead of it.
#
# RELYING-PARTY ID — THE FIELD THAT SILENTLY BREAKS EVERY PASSKEY IF IT IS WRONG
# ------------------------------------------------------------------------------
# The RP ID is baked into every credential at enrolment and can never be changed afterwards:
# a passkey created under one RP ID is unusable under another, with no error the user can act
# on — the browser simply reports that no credential is available. It is also browser-enforced,
# so it cannot be "fixed" server-side: per WebAuthn, the RP ID must be equal to, or a
# registrable-domain suffix of, the origin's effective domain. Keycloak is served at
# keycloak.<BASE_DOMAIN> (httproute.yaml, and the Keycloak CR's hostname), so the legal choices
# are keycloak.<BASE_DOMAIN>, <BASE_DOMAIN>, or the registrable domain one level up. It is NOT
# the full URL and NOT a scheme — "https://..." here would fail every ceremony.
#
# We choose BASE_DOMAIN, the shared platform suffix, NOT the keycloak host, for one concrete
# reason drawn from this repo's own history: a credential that cannot be re-keyed must not be
# bound to a name that can move. The oauth2-proxy client below ALREADY declares a redirect URI
# on auth.<BASE_DOMAIN>, i.e. this platform already contemplates serving auth on a second
# hostname; and the client-URL reconciler exists precisely because moving a hostname in git
# silently fails to reach the realm (see README, "Client URLs: the same one-shot failure").
# Binding passkeys to the keycloak subdomain would mean that relocating Keycloak invalidates
# every enrolled passkey; binding them to BASE_DOMAIN survives it. The cost is scope: any
# future WebAuthn relying party under this base domain could assert the same RP ID. In a
# homelab where every such party is already behind THIS IdP that is not a trust boundary worth
# buying. Note the residual, unavoidable risk: changing BASE_DOMAIN itself invalidates every
# passkey, which is a re-enrolment, not a repair.
#
# Written as the cluster-var so it is substituted by the same Flux postBuild pass, with the same
# value, as every hostname in this file — there is no second place the domain is decided.
webAuthnPolicyPasswordlessRpId: ${BASE_DOMAIN}
# The human-readable name the browser/OS shows in the passkey prompt and stores beside the
# credential ("Save a passkey for Homelab SSO?"). Matches the realm displayName above so the
# OS keychain entry is recognisable. It is also load-bearing on the UPDATE path: Keycloak's
# updateRealm applies the passwordless policy ONLY when this field is non-empty
# (DefaultExportImportManager line ~842), so an admin-API realm update that omits it drops the
# whole policy silently.
webAuthnPolicyPasswordlessRpEntityName: "Homelab SSO"
# ES256 then RS256 — exactly Keycloak's own DEFAULT_POLICY, declared explicitly rather than
# inherited so the realm's crypto is auditable from git. ES256 is what every platform
# authenticator (Touch ID/Face ID, Windows Hello, Android) and every FIDO2 key produces;
# RS256 is the fallback for older Windows Hello TPM-backed credentials. EdDSA/Ed25519 is
# deliberately NOT offered: authenticator and browser support is uneven, and an authenticator
# that picks an algorithm the verifier handles badly fails at enrolment, which is the worst
# moment to discover it. Order is a preference list, not an allow-list ordering requirement.
webAuthnPolicyPasswordlessSignatureAlgorithms:
- ES256
- RS256
# DISCOVERABLE CREDENTIAL REQUIRED. "Yes" is a Keycloak sentinel, not a boolean, and the login
# theme compares it as the literal string: webauthnRegister.js maps 'Yes' to
# authenticatorSelection.requireResidentKey=true and ANY other non-sentinel value to false.
# This is not optional for passkeys — a non-discoverable credential cannot be found by the
# browser without the server first naming it, so it is a second factor, not a passkey.
# (Keycloak 26.0.6 emits only the LEGACY requireResidentKey member, not the newer
# residentKey:"required"; per WebAuthn L2 the client maps requireResidentKey=true onto
# residentKey="required", so discoverable credentials are genuinely requested — but it also
# means this policy cannot express residentKey:"preferred" at all.)
webAuthnPolicyPasswordlessRequireResidentKey: "Yes"
# USER VERIFICATION REQUIRED. The authenticator must verify the human (biometric or PIN) and
# say so in the assertion. This is the field that makes a passkey login at least as strong as
# the password it replaces: with verification merely "preferred", possession of an unlocked
# phone or an unattended security key would be a COMPLETE login to every SSO-gated app — a
# single-factor possession check, strictly weaker than the password+TOTP path beside it.
# Required makes the passkey branch inherently two-factor (something you have + something you
# are/know), which is the premise the flow section relies on when it does NOT ask a
# passkey-authenticated platform-admin for TOTP as well.
webAuthnPolicyPasswordlessUserVerificationRequirement: required
# "not specified" is Keycloak's sentinel for "either kind of authenticator"
# (Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED; the theme JS omits the WebAuthn member
# entirely when it sees this exact string). Deliberately NOT narrowed: "platform" would reject
# every YubiKey and every phone used as a cross-device passkey, and "cross-platform" would
# reject Touch ID and Windows Hello — i.e. either choice silently halves the usable hardware.
# The operator should be able to enrol a laptop AND a phone AND a hardware key.
webAuthnPolicyPasswordlessAuthenticatorAttachment: "not specified"
# ATTESTATION: none. Requesting "direct" would hand Keycloak an attestation statement it has
# no trust anchors to validate here (no metadata service is configured) and nothing to compare
# it against, since the AAGUID allow-list below is deliberately empty — so the statement would
# be collected, unverifiable, and would add an extra "share model information?" consent prompt
# on Safari/iOS for no security gain. Attestation is how you ENFORCE an approved-hardware list;
# we do not have one, so asking for it is cost without benefit.
webAuthnPolicyPasswordlessAttestationConveyancePreference: none
# Empty = accept any authenticator model. An AAGUID allow-list is a hardware whitelist: it
# would have to be maintained by hand, and every new phone, laptop or key the operator buys
# would be rejected at enrolment with a message that does not explain itself. It is also moot
# while attestation is "none" (above) — with no attestation statement there is no trustworthy
# AAGUID to match against in the first place.
webAuthnPolicyPasswordlessAcceptableAaguids: []
# Ceremony timeout in SECONDS (the login theme multiplies by 1000; 0 means "send no timeout and
# let the browser decide"). 300s is chosen over a tighter value because the slowest LEGITIMATE
# path is the cross-device hybrid flow — Keycloak renders a QR code, the operator unlocks a
# phone, scans it, and completes a biometric check — which routinely takes over a minute. A
# 60-120s timeout looks tidy and converts that flow into an unexplained failure. Browsers clamp
# this value (Chrome to at most 10 minutes), so 300 is honoured rather than reinterpreted.
webAuthnPolicyPasswordlessCreateTimeout: 300
# Send the user's existing passkeys as excludeCredentials at enrolment, so re-enrolling an
# authenticator the user already registered is refused by the authenticator itself instead of
# producing a second, indistinguishable credential in the account console. Cheap, and it keeps
# "which passkey is which" answerable — which matters when the recovery story is "delete the
# lost one".
webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister: true
# webAuthnPolicyPasswordlessExtraOrigins is intentionally NOT set. It exists to admit non-web
# origins (native app facet IDs, e.g. android:apk-key-hash:...). Every ceremony here happens in
# a browser on Keycloak's own origin, which is already covered by the RP ID above; adding
# origins is only ever a widening of who may ask for these credentials.
# --- Authorization model (R-IDP-06): groups drive access; platform-admins carry a realm role. ---
roles:
realm:
@@ -528,14 +664,105 @@ spec:
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the
# platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP.
# Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails
# the whole import — if so, the fallback is to comment this block + browserFlow and configure
# the conditional-OTP-by-role flow via the admin console. ---
# ===========================================================================================
# MFA for platform-admins (R-IDP-04) + PASSKEY login (2026-10-17). The realm browserFlow is
# bound to browser-mfa at the bottom of this file.
# ===========================================================================================
# Non-admins log in without OTP; platform-admins must enroll/present TOTP. VERIFY at bring-up
# (see runbook): a malformed flow fails the whole import — if so, the fallback is to comment
# this block + browserFlow and configure the flow via the admin console.
#
# THE SHAPE, AND WHY IT IS THIS SHAPE
# -----------------------------------
# browser-mfa (top level, bound as browserFlow)
# auth-cookie ALTERNATIVE already-authenticated SSO cookie
# identity-provider-redirector ALTERNATIVE (no IdPs configured; kept for parity)
# browser-mfa-forms ALTERNATIVE the interactive path:
# auth-username-form REQUIRED WHO are you (no password yet)
# browser-mfa-passkey-or-password REQUIRED ...and PROVE it, one of two ways:
# webauthn-authenticator-passwordless ALTERNATIVE a passkey, or
# browser-mfa-password-and-otp ALTERNATIVE the password path:
# auth-password-form REQUIRED password, then
# browser-mfa-conditional-otp CONDITIONAL TOTP iff platform-admin
#
# WHY THE USERNAME/PASSWORD SCREEN HAD TO BE SPLIT IN TWO. The previous flow used the combined
# `auth-username-password-form`, which collects both on one page. A passkey branch cannot live
# behind that: the server must know WHICH user is logging in before it can decide whether a
# passkey is even available, and the combined form has already taken the password by the time
# it knows. So it is replaced by the `auth-username-form` / `auth-password-form` pair — the
# same decomposition Keycloak's own passwordless recipe uses. THIS IS A VISIBLE UX CHANGE FOR
# EVERY USER, passkey or not: login becomes two screens (username, then password) instead of
# one. That cost is accepted deliberately — it is the enabling change, it is what Google and
# Microsoft logins already do, and the alternative (below) is worse.
#
# WHY NOT USERNAMELESS ("just tap your passkey", no username screen at all). Keycloak 26.0.6
# does support it: WebAuthnPasswordlessAuthenticator.requiresUser() returns FALSE, so the
# authenticator can legally run as a first step with no user in context. We deliberately do not
# do that, because of how the flow engine then behaves. In DefaultAuthenticationFlow the
# "does this user actually have this credential?" guard sits INSIDE the requiresUser() branch;
# with requiresUser() false and no user yet set, the engine skips that guard and simply invokes
# the authenticator. A passkey execution placed before the username form is therefore offered
# to EVERYONE, so every login — including every password-only user and every family member who
# will never own a passkey — would open on a passkey prompt they must dismiss. That is the
# opposite of additive. Keeping the passkey step AFTER auth-username-form means a user is in
# context, which is what makes the graceful degradation below actually work.
#
# WHY THE PASSKEY BRANCH DEGRADES CLEANLY TO A PASSWORD (the property the whole "additive"
# claim rests on). With a user in context, the engine takes the non-requiresUser branch and
# raises CREDENTIAL_SETUP_REQUIRED when the user has no credential of this type and the
# authenticator's factory does not allow self-setup — and WebAuthnPasswordlessAuthenticatorFactory
# .isUserSetupAllowed() is false, and the authenticator is a CredentialValidator, so both
# conditions hold. That exception is raised INSIDE the ALTERNATIVE loop, which catches it,
# marks the execution ATTEMPTED and proceeds to the next alternative. Net effect: a user with
# no passkey never sees a passkey screen and lands on the password form; a user WITH one gets
# the passkey screen and keeps the password as a "Try another way" option. Note that for a
# user holding BOTH, which screen appears FIRST is decided by Keycloak's authentication
# selection list (the user's own credential ordering), not by the priorities below.
#
# WHY A PASSKEY LOGIN IS NOT ALSO ASKED FOR TOTP. browser-mfa-conditional-otp hangs off the
# PASSWORD branch only, so a platform-admin who authenticates with a passkey is not challenged
# for TOTP. That is intentional, and it rests on the policy above: userVerification is
# `required`, so the authenticator has already proved possession of hardware AND verified the
# human (biometric or PIN) in one phishing-resistant, origin-bound ceremony. Stacking TOTP on
# top would add a replayable, phishable shared secret to a credential that is neither, for
# friction on every login. If a future operator disagrees, the change is small and local: move
# the browser-mfa-conditional-otp reference out of browser-mfa-password-and-otp and into
# browser-mfa-passkey-or-password's parent as a REQUIRED step, so it applies to both branches.
#
# WHY THERE IS NO `requiredActions` BLOCK IN THIS FILE — DO NOT ADD ONE
# --------------------------------------------------------------------
# Enrolment uses Keycloak's `webauthn-register-passwordless` required action, and the obvious
# move is to declare it here. DO NOT. In Keycloak 26.0.6 the realm import treats
# requiredActions as REPLACE, NOT MERGE: DefaultExportImportManager.importRealm branches on
# `if (rep.getRequiredActions() != null)` and, when the representation declares ANY, imports
# exactly those plus delete_account — the `else` branch that installs the full default set
# (DefaultRequiredActions.addActions) never runs. RealmManager.setupRequiredActions is no
# backstop either: it fires only when the realm has ZERO required-action providers.
#
# So declaring one action would create this realm with that action and delete_account and
# NOTHING ELSE — no CONFIGURE_TOTP, no UPDATE_PASSWORD, no VERIFY_EMAIL, no delete_credential,
# no UPDATE_PROFILE. The first casualty would be the MFA requirement this very flow enforces:
# with CONFIGURE_TOTP unregistered, a platform-admin who does not yet have TOTP could never
# enrol one, while auth-otp-form below still demands it — a hard lock-out of every
# platform-admin from every SSO-gated app, including Headlamp and kubectl, delivered by a
# change whose stated purpose was to ADD a login method.
#
# And the declaration buys nothing, because the default set ALREADY CONTAINS IT.
# DefaultRequiredActions registers `webauthn-register-passwordless` with enabled=true,
# defaultAction=false, priority 80, gated on Profile.Feature.WEB_AUTHN — which is Type.DEFAULT
# in 26.0.6, i.e. on unless explicitly disabled. enabled=true / defaultAction=false IS the
# additive-and-optional posture this change wants: the action exists and can be triggered for
# a user on demand, but it is not forced on anyone at next login. Declaring it would restate
# the default while deleting ten siblings.
#
# (The symmetric question for authenticationFlows has the opposite answer, which is why THIS
# block is safe: importAuthenticationFlows adds the declared flows and then backfills each
# unbound binding — browser, registration, direct grant, reset credentials, client auth, first
# broker login, docker auth — from the built-in defaults individually. Declaring flows does not
# suppress the built-ins; declaring required actions does suppress them.)
authenticationFlows:
- alias: browser-mfa
description: "Browser flow with conditional OTP required for platform-admins."
description: "Browser flow: passkey or password, with conditional OTP for platform-admins."
providerId: basic-flow
topLevel: true
builtIn: false
@@ -555,13 +782,63 @@ spec:
priority: 30
flowAlias: browser-mfa-forms
userSetupAllowed: false
# Identify the user FIRST (username only), then prove it. Both steps are REQUIRED: the
# username is never optional, and neither is proving possession of the account.
- alias: browser-mfa-forms
description: "Username/password then conditional OTP."
description: "Username, then passkey or password (+ conditional OTP)."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
- authenticator: auth-username-password-form
# auth-username-form = Keycloak's "Username Form" (UsernameFormFactory) — resolves the
# user WITHOUT taking a password, which is the prerequisite for offering a passkey.
# Replaces the previous auth-username-password-form; see the header for the UX cost.
- authenticator: auth-username-form
authenticatorFlow: false
requirement: REQUIRED
priority: 10
userSetupAllowed: false
- authenticatorFlow: true
requirement: REQUIRED
priority: 20
flowAlias: browser-mfa-passkey-or-password
userSetupAllowed: false
# THE FORK. Two ALTERNATIVEs, so satisfying either one completes the flow. Priority puts the
# passkey first for users who have one; users who do not fall through to the password branch
# automatically (CREDENTIAL_SETUP_REQUIRED is caught by the ALTERNATIVE loop — see header).
- alias: browser-mfa-passkey-or-password
description: "A passkey (WebAuthn passwordless), or password with conditional OTP."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
# webauthn-authenticator-passwordless = "WebAuthn Passwordless Authenticator"
# (WebAuthnPasswordlessAuthenticatorFactory). It reads the realm's PASSWORDLESS policy
# declared above — NOT the two-factor webAuthnPolicy* family. Keep it ALTERNATIVE: as
# REQUIRED it would force a passkey on every user, which is the lock-out this change is
# explicitly designed not to create.
- authenticator: webauthn-authenticator-passwordless
authenticatorFlow: false
requirement: ALTERNATIVE
priority: 10
userSetupAllowed: false
- authenticatorFlow: true
requirement: ALTERNATIVE
priority: 20
flowAlias: browser-mfa-password-and-otp
userSetupAllowed: false
# The unchanged pre-passkey behaviour, relocated intact into the password branch: password,
# then TOTP iff the user holds the platform-admin role. Nothing about the OTP condition
# changed; it simply no longer sits on the path a passkey login takes.
- alias: browser-mfa-password-and-otp
description: "Password, then conditional OTP for platform-admins."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
# auth-password-form = Keycloak's "Password Form" (PasswordFormFactory) — the password
# half of the old combined form, now that the username has already been collected.
- authenticator: auth-password-form
authenticatorFlow: false
requirement: REQUIRED
priority: 10
@@ -199,7 +199,7 @@ data:
"id": 15,
"type": "timeseries",
"title": "CAUSE 4 — NFS RPC retransmission ratio, per node",
"description": "node_nfs_rpc_retransmissions_total / node_nfs_rpcs_total, from /proc/net/rpc/nfs. Alert line at 1% (NodeNFSRetransmissionRateHigh). MEASURED BASELINE IS EXACTLY ZERO on all three nodes over 48h, including throughout the 2026-09-14 spike — so any visible line here means the export at 192.168.2.102, or the path to it, is dropping requests. A flat zero does NOT clear NFS: it only clears the TRANSPORT. A merely SLOW export needs per-mount RPC round-trip time, which arrives with the mountstats collector (see kube-prometheus-stack.yaml) and is the known gap recorded in runbooks/node-io.md.",
"description": "node_nfs_rpc_retransmissions_total / node_nfs_rpcs_total, from /proc/net/rpc/nfs. Alert line at 1% (NodeNFSRetransmissionRateHigh). MEASURED BASELINE IS EXACTLY ZERO on all three nodes over 48h, including throughout the 2026-09-14 spike — so any visible line here means the export at 192.168.2.30, or the path to it, is dropping requests. A flat zero does NOT clear NFS: it only clears the TRANSPORT. A merely SLOW export needs per-mount RPC round-trip time, which arrives with the mountstats collector (see kube-prometheus-stack.yaml) and is the known gap recorded in runbooks/node-io.md.",
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 39},
"fieldConfig": {"defaults": {"unit": "percentunit", "min": 0, "custom": {"drawStyle": "line", "fillOpacity": 5, "thresholdsStyle": {"mode": "line"}}, "thresholds": {"mode": "absolute", "steps": [
{"color": "green", "value": null}, {"color": "red", "value": 0.01}
+69
View File
@@ -63,6 +63,75 @@ spec:
# gets oauth2-proxy's HTML sign-in page (HTTP 403) instead of an immediate redirect (docs/15 UX).
skip-provider-button: "true"
scope: "openid email profile"
# ── Cold-start gate: do not start the proxy until Keycloak's discovery doc answers ──────────
# MEASURED on prod 2026-10-17, during the UPS move: oauth2-proxy performs OIDC discovery ONCE at
# startup and EXITS 1 if it fails. On a whole-cluster cold boot Keycloak is not serving yet, so
# the Gateway answers 503 and the proxy dies:
#
# [provider.go:55] Performing OIDC Discovery...
# [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: ... failed to discover OIDC
# configuration: unexpected status "503": no healthy upstream
#
# It then sits in exponential CrashLoopBackOff long after Keycloak is healthy -- one replica was
# still down minutes later and only a manual pod delete cleared it. There is no retry flag; the
# process simply does not survive a cold start. A readinessProbe cannot help (the container is
# already dead), and CrashLoopBackOff backoff is not tunable per-pod.
#
# `extraInitContainers` is the chart's OWN documented hook for this -- its commented example in
# values.yaml is literally `- name: wait-for-idp` -- and the chart already uses an alpine init
# container for its built-in waitForRedis gate, so this adds no new base image to the platform.
# A pod in Init does not accrue CrashLoopBackOff backoff and retries promptly, which is the whole
# point.
#
# BOUNDED, AND FAILS OPEN. After ~5 minutes it gives up and starts the proxy anyway: the worst
# case is then exactly today's behaviour, never something new. An unbounded wait would trade a
# CrashLoop for a pod wedged in Init forever, which is not an improvement.
#
# SHELL VARIABLES ARE WRITTEN BARE ($i, $url, $(( ))), WITH NO BRACED FORM ANYWHERE. This file is
# rendered through Flux envsubst (platform declares postBuild.substituteFrom cluster-vars), which
# CONSUMES braced expansions -- so a braced shell variable renders to the empty string and
# silently breaks the loop. BASE_DOMAIN below is braced ON PURPOSE: it is a real cluster-vars
# knob and must be substituted. Same hazard, and the same convention, as the gitea and nextcloud
# OIDC bootstrap hooks. (ci: flux-postbuild-substitution enforces it.)
extraInitContainers:
- name: wait-for-idp
image: docker.io/library/alpine:3.22
imagePullPolicy: IfNotPresent
command: ["/bin/sh", "-c"]
args:
- |
set -u
url=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration
i=0
while [ "$i" -lt 60 ]; do
# busybox wget validates against alpine's ca-certificates-bundle, so a real TLS
# failure is a non-zero exit here rather than a false pass.
if wget -q -O /dev/null --timeout=5 "$url" 2>/dev/null; then
echo "[wait-for-idp] Keycloak discovery reachable; starting oauth2-proxy"
exit 0
fi
i=$(( i + 1 ))
sleep 5
done
echo "[wait-for-idp] discovery still unreachable after ~5m; starting anyway so this" >&2
echo "[wait-for-idp] gate can never be worse than the un-gated behaviour it replaces" >&2
exit 0
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 65534
runAsGroup: 65534
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
# Metrics for the ServiceMonitor (../oauth2-proxy servicemonitor in platform-config).
metrics:
enabled: true
+50 -1
View File
@@ -22,6 +22,25 @@ requires.
---
## Why the agent authenticates to Gitea with a local account and a PAT
Not Keycloak, and that is a decision rather than an omission (operator, 2026-10-06): the
agents only ever talk to Gitea, so a local Gitea account with a scoped personal access token
is the whole requirement, and it is drastically simpler than federating a machine identity.
It is also what Gitea can actually do. Keycloak service accounts were considered and are not
available for this: Gitea's API accepts basic auth, PATs, SSH signatures and **its own**
OAuth2 bearer tokens, and as an OAuth2 provider Gitea implements **only the Authorization
Code Grant** — there is no client-credentials grant, and nothing in Gitea validates a token
issued by a third-party OIDC provider. So a Keycloak service-account token cannot
authenticate to Gitea's API or to git-over-HTTP at all. Keycloak remains what it is for human
access: the browser login source registered on Gitea (`catalog/gitea.yaml`).
What still matters, and is enforced by the hand-off rather than by Keycloak: **the account
must not be an administrator** — `infra/gitea/approval-policy.json` authorises
`/approve-override` on live repository permission alone, so an admin-permission bot could
waive the sensitive-path human review on its own pull request.
## It does nothing at all until an operator does two things
Both are deliberate, and both fail **visibly** rather than silently:
@@ -31,6 +50,36 @@ Both are deliberate, and both fail **visibly** rather than silently:
`infra/agent/build-image.sh` (which verifies it and refuses to push), push it as the
operator, then update **both** places with the digest `podman push --digestfile` returned,
in one pull request. Until then: `ImagePullBackOff`.
**Two things have to be true before any digest will pull, and neither is declared in this
repository.** Both were measured after PR #57 merged — this service is the first workload
in the cluster ever to reference the in-cluster registry, so nothing had exercised the
path. Details and the exact commands:
[`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`](../../decisions/2026-10-05-pm-agent-registry-pull-prereq.md).
- **The nodes resolve the registry hostname to the WAN, so the TLS they fail to verify is
the router's — DIAGNOSED 2026-10-06, and fixed in this change.** containerd pulls as a
*node* process, so it resolves through the node's public upstreams
(`node_baseline_dns_servers` = the LAN gateway) rather than through Pi-hole. The public
answer for every name in this zone is a CNAME to `wan.home.keithjackson.dev` — the WAN
IP — which this router does not hairpin; it answers :443 with its own placeholder
certificate `CN=%D` carrying **no extensions, therefore no SANs**, which is precisely
`x509: certificate is not valid for any names`. The Gateway, the wildcard certificate
and the digest were never implicated. The fix is the pin this repository already uses
for the Keycloak issuer, for the identical reason: `gitea.{{ k3s_base_domain }}` is now
in `node_baseline_hosts_pins` — node configuration is Ansible's, declared in the
inventory, never typed on a node. Apply it scoped to that one task:
`cd infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod
--tags hosts`.
- **The pull is still unauthenticated** once the node reaches the right endpoint. An
unauthenticated client gets nothing from this Gitea's registry, and there is no
`imagePullSecret` anywhere in the tree. Consistent with the agent credential decision
(local Gitea accounts with PATs), the fix is a `dockerconfigjson` for a local Gitea
account whose token carries `read:package`, referenced from the Deployment and the
committed worker Job template — not built yet, because minting that token is yours.
The same two apply to `conformance-runner`: same registry path, same push-then-pin model,
never pulled either.
2. **The credentials are placeholders.** `pm-agent-credentials.sops.yaml` is encrypted to the
repository's age recipient but holds `REPLACE-ME-…` strings. The hand-off —
which Gitea account to create, which scopes, and why it must *not* be an administrator —
@@ -96,7 +145,7 @@ Job from a committed template. **The model's output is data, never a command.**
| **cannot** | merge anything — the gate is the approver, and the service makes no merge call |
| **cannot** | reach the Kubernetes API: no ServiceAccount token is mounted, the `pm-agent-worker` identity is bound to nothing, and the egress policy does not include the API server. `kubectl` is on its `PATH` (the gate's toolchain needs it) and is inert |
| **cannot** | reach anything except this Gitea and tcp/443 — no package registry, no GitHub |
| **cannot** | clone from or push to any host but this Gitea: `pm-worker` validates `PM_GITEA_URL` against an allowlist baked into the image |
| **cannot** | clone from or push to anywhere but `gitea.gitea.svc.cluster.local:3000`: `pm-worker` validates `PM_GITEA_URL` against a one-entry allowlist baked into the image. In-cluster consumers reach a service by its cluster-local name — a different path by design from how a client outside the cluster reaches it — so the external hostname is not an accepted value |
| **cannot** | authenticate a `git push` **during the model session**: `GIT_ASKPASS` is unset for its duration, so the restriction is structural rather than a request in a prompt |
| **cannot** | be created in any shape but the committed one — see the admission policy |
@@ -51,6 +51,15 @@
# (2) MINT THE GITEA TOKEN as that user (log in as `pm-agent`, or use the admin API to
# create a token for it): Settings -> Applications -> Generate New Token.
#
# A LOCAL GITEA ACCOUNT WITH A PAT IS THE DECIDED MODEL (operator, 2026-10-06), not
# Keycloak. The agents only ever talk to Gitea, so this is the whole requirement and it
# is far simpler than federating a machine identity. It is also the only thing that
# works: Gitea's API accepts basic auth, PATs, SSH signatures and its OWN OAuth2 bearer
# tokens, and as a provider Gitea implements only the Authorization Code Grant — no
# client-credentials grant, and nothing that validates a third-party OIDC token. A
# Keycloak service-account token therefore cannot authenticate to Gitea's API or to
# git-over-HTTP. Do not "upgrade" this to Keycloak without re-reading that.
#
# Scopes: `write:repository` and `write:issue`. Nothing else — in particular NOT
# `write:admin`, NOT `write:organization`, NOT `write:user`.
#
+18 -5
View File
@@ -13,7 +13,7 @@ egress out the house WAN.
| Transport | WireGuard, **udp/51820**, kernel implementation |
| 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 |
| 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 — **SOPS-encrypted in this tree**, `wg-server-key.sops.yaml` |
| 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) |
@@ -129,7 +129,7 @@ translations win here.
| 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 | `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** —
@@ -166,13 +166,26 @@ NetworkPolicy applies.
## Operating it
* **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`.
* **Revoke a device:** delete its stanza → PR → merge. No other step; the key stops working.
stanza into `peers.conf` → PR → merge. Phones scan a QR code; desktops import a `.conf`.
* **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.
* **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.
* **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
1. **The `wireguard` kernel module on every node.** Added to `node_baseline_kernel_modules`
@@ -199,7 +212,7 @@ NetworkPolicy applies.
covered generically by `KubeDeploymentReplicasMismatch`/`KubePodNotReady` from
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
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
`wg-server-key` Secret.
* **No IPv6.** The server offers no IPv6 tunnel address, so a full-tunnel client with native IPv6
+46 -6
View File
@@ -37,6 +37,10 @@
# /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
# 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
# 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
@@ -104,7 +108,7 @@ spec:
dnsPolicy: ClusterFirstWithHostNet
automountServiceAccountToken: false
# ── 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
# key is therefore never written to any node's disk, never committed, and never logged.
initContainers:
@@ -171,6 +175,19 @@ spec:
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
fi
{
echo '[Interface]'
echo "Address = $base.1/$mask"
@@ -184,9 +201,20 @@ spec:
echo 'MTU = 1420'
printf 'PrivateKey = %s\n' "$key"
# ── 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
# silently depend on another role's sysctl file surviving.
echo 'PostUp = sysctl -q -w net.ipv4.ip_forward=1'
# NO `sysctl -w net.ipv4.ip_forward=1` HERE. It is IMPOSSIBLE from this pod and it was
# the only thing stopping the tunnel coming up — MEASURED on prod 2026-10-06:
# [#] 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
# 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
@@ -246,8 +274,10 @@ spec:
# `kubectl get pod -o wide` shows what this listener owns.
- { name: wireguard, containerPort: 51820, hostPort: 51820, protocol: UDP }
securityContext:
# NET_ADMIN is the whole privilege budget: create the wg0 link, set the host sysctl, and
# write the two netfilter rules above. Everything else is dropped, and the container is
# NET_ADMIN is the whole privilege budget: create the wg0 link and write the netfilter
# 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.
# 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
@@ -261,6 +291,16 @@ spec:
- NET_ADMIN
# readOnlyRootFilesystem intentionally UNSET: the image's s6 init writes under /run and
# /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:
requests:
cpu: 25m
+31 -2
View File
@@ -11,10 +11,16 @@
# NOT in platform/kustomization.yaml — see clusters/base/vpn-ingress.yaml.
apiVersion: kustomize.config.k8s.io/v1beta1
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:
- 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.
- daemonset.yaml
# Namespace default-deny baseline. NOTE: does NOT constrain the hostNetwork DaemonSet — the file
@@ -38,3 +44,26 @@ resources:
# hands Flux the raw ciphertext, the Secret APPLIES CLEANLY full of ENC[...], and nothing in any
# Flux condition looks wrong. The init container's `wg pubkey` guard exists partly to turn exactly
# that into a named error rather than a silently non-working tunnel.
# ── 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
+1 -1
View File
@@ -19,7 +19,7 @@
# 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:
# 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.
# 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
@@ -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.
# ============================================================================================
@@ -41,33 +53,40 @@
# 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
# belongs in the `wg-server-key` Secret, not here. See README "Hardening not taken".
apiVersion: v1
kind: ConfigMap
metadata:
name: wg-peers
namespace: vpn-ingress
labels:
app.kubernetes.io/name: vpn-ingress
data:
# Appended VERBATIM to the generated [Interface] block, in this order, at pod start.
#
# *** THERE ARE NO PEERS YET. THAT IS THE CORRECT INITIAL STATE, NOT AN OVERSIGHT. ***
# 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
# 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
# (`wg show` reports an interface with no peers).
#
# COPY THIS TEMPLATE, uncomment, one block per device:
#
# # pixel-8 — Keith's phone, enrolled 2026-10-05
# [Peer]
# PublicKey = <the public key new-peer.sh printed>
# AllowedIPs = 10.90.0.2/32
#
# Keep the `# <name> — <owner>, enrolled <date>` comment line. It is the only place a key is
# tied to a human-recognisable device; `wg show` can only ever print the key itself, so without
# it an incident responder cannot tell which stanza to delete.
peers.conf: |
# No peers enrolled yet. See the template in this file's comments and
# ./infra/vpn-ingress/new-peer.sh. One [Peer] block per device.
#
# name: wg-peers
# namespace: vpn-ingress
# labels:
# app.kubernetes.io/name: vpn-ingress
# Appended VERBATIM to the generated [Interface] block, in this order, at pod start.
#
# *** THERE ARE NO PEERS YET. THAT IS THE CORRECT INITIAL STATE, NOT AN OVERSIGHT. ***
# 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
# 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
# (`wg show` reports an interface with no peers).
#
# COPY THIS TEMPLATE, uncomment, one block per device:
#
# # <device-name> — <owner>, enrolled <YYYY-MM-DD>
# [Peer]
# PublicKey = <the public key new-peer.sh printed>
# AllowedIPs = 10.90.0.<octet>/32
#
# The placeholders above are deliberately NOT valid values. An earlier version of this template
# 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
# already taken. The check now ignores comments; keeping the example unmatchable too means a
# future grep cannot be fooled either.
#
# Keep the `# <name> — <owner>, enrolled <date>` comment line. It is the only place a key is
# tied to a human-recognisable device; `wg show` can only ever print the key itself, so without
# 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
+3 -3
View File
@@ -11,7 +11,7 @@ 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:irLENWa1Z3IJSQ==,iv:Qu2Yr6F5+M6P8cQRuhB5J7joxCcudUhwoEWDiL941Lo=,tag:ZNwcBZEkswtnkX0vGKfMAQ==,type:str]
privatekey: ENC[AES256_GCM,data:ML8CPNm4JwhQHUTUUxZO/I+9X15BCIGqOb1Uw9EmX5RTAsxQ9N97YvkYlbY=,iv:5ZUsFdMMmeGsy4fNbk6qbJkzweMVWXZ5g6E7NciAGUg=,tag:pAHv3ieLjoEyFbqbWXsVgg==,type:str]
sops:
kms: []
gcp_kms: []
@@ -27,8 +27,8 @@ sops:
SUUzN1FnMlI4bkkzbVNlMWhUVnJZODAKQ+Iy86CIv/Oj9E9j4E/0y5hDbcctRuqB
OR825KRPRaTdF/G2hudE1R1JbM4wGbPLMzv078R4ZXHDPGvDttSEkA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-06T04:20:58Z"
mac: ENC[AES256_GCM,data:GpP/zTX4FtJyrlaCS/R0/sAtFbkM0W8A5sqNxb5ZziSgLUXI+oUKYB09igN6vx5cjRNH7rSmXduS/actaT5jhP8kuF1n/iQfG56PsCJHL2vneefzcEOKXeGquAwDOY+iGmNsZ5R8ySrM/Fq7HYLMJasWosyTh3YlBs8GRYiHmU4=,iv:438+pDzHLGyh+OMQCMzGBEBrZUPNp5BbjrrMdiEiqJ8=,tag:JqLu6zZWKtcoB8mp4HSZig==,type:str]
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
@@ -5,7 +5,7 @@
# WHAT THIS IS: the WireGuard SERVER's long-term private key for the remote-access VPN
# (platform/vpn-ingress). It is the ONE secret in the whole component. The init container in
# daemonset.yaml reads it at `/run/wg-server-key/privatekey` and joins it to the PLAINTEXT peer
# register (peers.yaml) in a tmpfs, so the assembled wg0.conf never touches a node's disk.
# 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`
+3 -1
View File
@@ -67,7 +67,9 @@ Work through these in order; the first three are by far the most likely.
| Symptom in the log | Cause | Fix |
|---|---|---|
| `GET /user returned no login — is GITEA_TOKEN valid?` and a CrashLoop | the credentials are still the committed **placeholders**, or the token was revoked | fill them in per `platform/pm-agent/pm-agent-credentials.sops.yaml.example` |
| `ImagePullBackOff`, digest `sha256:0000…` | the image has never been pushed; the committed digest is the deliberate placeholder | `infra/agent/build-image.sh`, operator pushes, then one PR updates the digest in **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) |
| `ImagePullBackOff`, digest `sha256:0000…` | the image has never been pushed; the committed digest is the deliberate placeholder | `infra/agent/build-image.sh`, operator pushes, then one PR updates the digest in **both** `deployment.yaml` and `configmap.yaml` (`PM_WORKER_IMAGE`) — but read the row below first |
| `ImagePullBackOff` with `x509: certificate is not valid for any names` | **diagnosed.** containerd pulls as a node process, resolves through the node's public upstreams, and gets the CNAME to the WAN IP — which this router does not hairpin, so it answers :443 with its own `CN=%D` placeholder certificate (no SANs at all). Nothing to do with the digest, the Gateway or the wildcard cert | the `/etc/hosts` pin for `gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` fixes it — the same pin the Keycloak issuer already has. Apply it with the project's own tooling, scoped to just that task: `cd infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`. Full evidence: `decisions/2026-10-06-pm-agent-node-resolves-gitea-to-wan.md` |
| `ImagePullBackOff` with `401 Unauthorized` once TLS works | the pull is unauthenticated. An anonymous client gets nothing from this Gitea's registry, and no `imagePullSecret` exists anywhere in the tree | add one (a `dockerconfigjson`, SOPS-encrypted like `pm-agent-credentials`, referenced from the Deployment **and** the committed worker Job template) or allow anonymous reads of the package in Gitea — a posture decision, so yours |
| `PM_WORKER_IMAGE must be digest-pinned` | someone put a tag in the ConfigMap | use the digest from `podman push --digestfile` |
| `scope: ONLY issues labelled 'agent'` | `PM_REQUIRE_LABEL` is set; unlabelled issues are out of scope by configuration | label the issue, or clear the knob |
| `cycle: 2 worker(s) active` and nothing new | at `PM_MAX_ACTIVE_WORKERS` | wait, or raise it (RAM is the binding constraint: each worker's limit is 2Gi) |
+48 -4
View File
@@ -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.
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
> k3s nodes (`192.168.2.10/.11/.12`: apiserver 6443, kubelet 10250, SSH 22). That is what "reach
@@ -134,6 +134,37 @@ specific failure in its log — so you never get a server that silently cannot c
| `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)
@@ -188,10 +219,23 @@ It generates the device's keypair, writes a `0600` client profile, prints a QR c
Then:
1. Paste the stanza into `platform/vpn-ingress/peers.yaml` under `data.peers.conf`, keeping the
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
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
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
@@ -231,7 +275,7 @@ if the router is not delivering the packets, and a `handshake` with no completed
## 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
again as a different device.