Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f741d744 | ||
|
|
3ee663dbcc | ||
|
|
ad3f56eee8 | ||
|
|
2cb891c05d | ||
|
|
f92707f38c | ||
|
|
22c94effff | ||
|
|
d0db63911a | ||
|
|
81f627167d | ||
|
|
4386d1453b | ||
|
|
874c6703f0 | ||
|
|
bc7095677b | ||
|
|
a0e6ef145f | ||
|
|
c163deaef9 | ||
|
|
642a7752d8 | ||
|
|
21c88b0dc7 | ||
|
|
b156ee6cfa | ||
|
|
0aa1a0a78a |
No files matched your search
@@ -4,6 +4,125 @@ Read this before doing anything durable. It is the delivery contract for AI codi
|
||||
sessions and agents. The newest entry in `decisions/` is the current PM handoff and
|
||||
takes precedence on *what* to work on; this file governs *how* it lands.
|
||||
|
||||
**Start with §0.** It is first because it is the rule you can break before you have read
|
||||
anything else: the very first `git checkout -b` in the wrong directory has already done the
|
||||
damage.
|
||||
|
||||
## 0. Work in your OWN workspace. Never in the shared checkout.
|
||||
|
||||
Before you create a branch, edit a file, or run `git` anything: **make yourself a git
|
||||
worktree outside the repository root, and do all of your work there.** One workspace per
|
||||
agent run, no exceptions.
|
||||
|
||||
# from the shared checkout, ONCE, at the start of your run
|
||||
git fetch origin main
|
||||
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
|
||||
cd /path/outside/the/repo/wt-<slug> # everything after this happens HERE
|
||||
|
||||
`/path/outside/the/repo` means outside this repository's tree — your session scratchpad,
|
||||
or a sibling like `/var/home/keith/source/wt-<slug>`. Branch from **`origin/main`**, never
|
||||
from `main` and never from `HEAD`; see "stale base" below.
|
||||
|
||||
**The shared checkout is `/var/home/keith/source/home-network-k3s-impl`** — the operator's
|
||||
working copy, the one you are probably started in. In it, do **not**:
|
||||
|
||||
- **`git checkout` / `git switch` / `git checkout -b`.** Its HEAD is shared state; moving
|
||||
it moves the tree out from under whoever else is using it.
|
||||
- **edit, create or delete a file.** Including "just one doc", including a file you intend
|
||||
to move later.
|
||||
- **`git add` / `git commit` / `git stash` / `git restore`.** `git add -A` there stages
|
||||
whatever another agent left lying around; `git stash` *removes* their uncommitted work
|
||||
into a place they will not look.
|
||||
- **`git worktree remove` anything you did not create.**
|
||||
|
||||
Read-only commands there are fine, and the one write you do need is creating your own
|
||||
worktree.
|
||||
|
||||
### Why — measured in the shared checkout, 2026-10-05
|
||||
|
||||
| What was measured | Value |
|
||||
|---|---|
|
||||
| `checkout: moving` entries in its reflog | **107** — the tree has been yanked between branches 107 times |
|
||||
| the branch it was sitting on | `feat/pm-agent-issue-to-pr` — **not `main`** |
|
||||
| uncommitted trees left in it by other runs | **2** (`infra/agent/`, `platform/pm-agent/`), mtimes five minutes apart the same evening |
|
||||
| its local `main` vs `origin/main` | **two commits behind** (`4386d14` vs `22c94ef`) |
|
||||
| registered worktrees | **42**, of which **31** are on branches already merged to `main` |
|
||||
| gate scripts carrying an explicit `.claude/` exclusion | **9** |
|
||||
|
||||
Each row is a distinct way the shared checkout damages work:
|
||||
|
||||
- **Dirty tree, foreign files.** Two other runs' uncommitted directories were sitting in
|
||||
it. An agent that edits there and runs `git add -A` ships someone else's half-finished
|
||||
work inside its own PR, and the PR's author has no idea what those files are. This is not
|
||||
hypothetical and it is not historical: **eleven minutes after those two directories were
|
||||
measured, while this very section was being written, another run committed them** —
|
||||
`5334abe feat(pm-agent): ...`, 1,000+ lines across `infra/agent/`, `clusters/`, `catalog/`
|
||||
and `apps/` — onto the shared checkout's checked-out branch, moving its HEAD under anyone
|
||||
reading it. Had this rule's author been working in that tree rather than in a worktree,
|
||||
that commit and this one would have collided.
|
||||
- **Stale base.** Its `main` was two commits behind `origin/main`, so
|
||||
`git checkout -b x main` there branches off a tree that is already not what CI will merge
|
||||
into — the gate you run locally proves a base nobody else has. Worse, it was not even *on*
|
||||
`main`: a bare `git checkout -b x` would have branched off `feat/pm-agent-issue-to-pr` and
|
||||
silently inherited that work into your PR. `git fetch` first, and name `origin/main`
|
||||
explicitly as the start point.
|
||||
- **Shared HEAD.** 107 branch switches in one tree is 107 chances to switch it while
|
||||
another agent is mid-edit. The loser's changes are either carried onto the wrong branch
|
||||
or lost to a checkout conflict — and the symptom surfaces in *their* session, not yours.
|
||||
|
||||
### Put the workspace OUTSIDE the repo root
|
||||
|
||||
A worktree *inside* the tree (`.claude/worktrees/agent-*/` — which is where Claude Code's
|
||||
own default puts it) is a full second copy of the repo that every tree-walking check then
|
||||
finds. Measured 2026-09-12: `find`-based discovery reported **the same manifest under test
|
||||
once per worktree, up to 18 times**, and the L0 check comparing that listing against the
|
||||
tree failed on paths that are not authored surface at all
|
||||
(`platform/monitoring/tests/run.sh:115-123`). Nine gate scripts now carry an explicit
|
||||
`.claude/` prune for this reason — which means the hazard is *worked around*, not fixed,
|
||||
and the next check that walks the tree will hit it again. Keep the copy out of the tree and
|
||||
none of that applies.
|
||||
|
||||
### Clean up — and expect not to be able to
|
||||
|
||||
31 of the 42 registered worktrees are on branches already merged to `main`. A registered
|
||||
worktree **pins its branch**: while it exists, that branch cannot be deleted or checked out
|
||||
anywhere else. So a left-behind workspace is a lock on a branch name, and the cost lands on
|
||||
whoever next needs it.
|
||||
|
||||
Remove yours when you are done:
|
||||
|
||||
git worktree remove /path/outside/the/repo/wt-<slug>
|
||||
|
||||
Measured 2026-10-05: on a worktree **you created yourself, in the session scratchpad**, that
|
||||
command **succeeds** — rc 0, no prompt, with or without a branch checked out. The "worktree
|
||||
removal is sandbox-blocked" note in circulation is about the `.claude/worktrees/agent-*` ones
|
||||
belonging to *other* sessions, some of which are additionally `locked` on git's own terms. So:
|
||||
remove yours, and only yours. If removal is refused, say so in your handoff and leave the
|
||||
worktree registered — do **not** `rm -rf` the directory, which leaves a registered worktree
|
||||
pointing at nothing and still holding the branch.
|
||||
|
||||
If someone *else's* stale worktree is holding the branch you need, do not try to reclaim the
|
||||
branch name. Push a `rework/<pr-number>` branch to the PR's refspec instead.
|
||||
|
||||
### If you are already standing in the shared checkout with edits
|
||||
|
||||
Stop. Do not `git stash` (that moves other agents' work too). Create your worktree as
|
||||
above, redo the edits there, and leave the shared checkout's HEAD and working tree exactly
|
||||
as you found them.
|
||||
|
||||
### Gating from a worktree
|
||||
|
||||
Three known breakages, all with workarounds, all required every run: see §3, "Running the
|
||||
gate from a worktree".
|
||||
|
||||
### If you are writing a brief for another agent
|
||||
|
||||
The brief must name **its own explicit branch** and tell it to create **its own worktree**
|
||||
from `origin/main`. "Work on the current branch" and "continue in the checkout" are how
|
||||
branch sprawl and cross-contamination got here: an agent given no workspace uses the one it
|
||||
is started in, which is the shared one. Also carry §3's three gate workarounds into the
|
||||
brief — an agent that cannot gate will report rc 3 as green.
|
||||
|
||||
## 1. Never push to `main`. Ever.
|
||||
|
||||
Every durable change reaches `main` exactly one way:
|
||||
@@ -147,15 +266,21 @@ Exit codes are a contract — do not flatten them:
|
||||
| 3 | UNDETERMINED — a stage did not run. **Never report this as green.** |
|
||||
|
||||
`spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out
|
||||
beside this one; that single skip is expected and tolerated. Any *other* skip is a
|
||||
real gap in your run.
|
||||
beside this one; that single skip is expected and tolerated — and from a worktree it is
|
||||
avoidable outright (§3's worktree list, item 3). `gitea-settings` is the other expected
|
||||
skip: it needs `SETTINGS_API_TOKEN`, which exists only in CI, so **a local run cannot
|
||||
return 0** — the honest best local result is rc 3 with `gitea-settings` as the only stage
|
||||
that did not run. Any *further* skip is a real gap in your run. Report the rc and name the
|
||||
skipped stages; never round rc 3 up.
|
||||
|
||||
**Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`,
|
||||
so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts.
|
||||
|
||||
### Running the gate from a git worktree
|
||||
### Running the gate from a worktree
|
||||
|
||||
Two unrelated failures, both with known workarounds — put both in any agent brief:
|
||||
This is the **normal** path, not an edge case — §0 requires you to be in a worktree, so
|
||||
every gate run you do hits all of these. Three known breakages, all with
|
||||
workarounds, all of them yours to apply every run — put them in any agent brief:
|
||||
|
||||
1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked
|
||||
and exists only in the main checkout. Do **not** copy it (it is a credential).
|
||||
@@ -166,10 +291,17 @@ Two unrelated failures, both with known workarounds — put both in any agent br
|
||||
the `ENGINE` hook — a shim that intercepts `run` and re-execs
|
||||
`podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in
|
||||
`tracked_files()` is this bug, not a broken tree.
|
||||
3. **`spec-immutability` skips** — the skip called tolerated above is also *avoidable*. `dev.sh`
|
||||
derives the sibling spec path from its own `REPO_ROOT`, which in a worktree is your
|
||||
workspace, so `../home-network-k3s` does not resolve and it adds no mount. Mount the spec
|
||||
repo read-only in the same `ENGINE` shim and pass `-e SPEC_REPO=/home-network-k3s`: with
|
||||
both mounts the stage reports `pass`, not SKIPPED, and R-TEST-06 is actually covered
|
||||
(measured — `decisions/2026-10-01-render-selftest-runs-in-gate.md`).
|
||||
|
||||
## 4. Branch naming
|
||||
|
||||
Use `feat/`, `fix/`, `docs/`, `chore/`. Note that `.gitea/workflows/gate.yml`'s **push**
|
||||
Use `feat/`, `fix/`, `docs/`, `chore/`. **One branch, one workspace, one PR** — if you
|
||||
need a second branch, make a second worktree (§0); do not re-point an existing one. Note that `.gitea/workflows/gate.yml`'s **push**
|
||||
trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run.
|
||||
The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected.
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ specification** at [`../home-network-k3s`](../home-network-k3s).
|
||||
- **Done = a green, reproducible, requirement-tagged conformance test** — never "merged," never a
|
||||
manual demo (spec `docs/14`, `agents/definition-of-done.md`).
|
||||
- **No work on `main`.** Every change is a feature branch merged into `main` (spec `CONTRIBUTING.md`).
|
||||
- **No work in a shared checkout.** Every agent run gets its own git worktree, outside the repo
|
||||
root, branched from `origin/main`. This checkout is shared mutable state — see
|
||||
[`CLAUDE.md` §0](CLAUDE.md).
|
||||
- **No plaintext secrets** — SOPS-encrypted only (spec `docs/07`).
|
||||
- **Phase discipline** — only build what the active roadmap phase authorizes (spec `roadmap/phases.md`).
|
||||
|
||||
@@ -21,7 +24,8 @@ infra/ node provisioning: OpenTofu (Proxmox VE) + cloud-init + Ansible,
|
||||
bootstrap/ one-time cluster bootstrap: Flux install, SOPS/age key, host Gitea
|
||||
platform/ platform CONTROLLERS (Flux HelmReleases / operator bases): kyverno, cilium,
|
||||
longhorn, external-snapshotter, cert-manager, externaldns, gateway, coredns,
|
||||
vpn-egress, monitoring, cnpg, keycloak, oauth2-proxy, kubevirt
|
||||
vpn-egress, vpn-ingress, monitoring, cnpg, keycloak, oauth2-proxy, kubevirt
|
||||
(vpn-egress = pods OUT via PIA; vpn-ingress = remote clients IN via WireGuard)
|
||||
platform-config/ platform CONFIGS — the custom resources of the CRDs installed above
|
||||
(ClusterIssuers, Gateway, Kyverno policies, KubeVirt/CDI CRs, Keycloak CRs).
|
||||
Split out because CRs and the CRDs they use cannot apply in one pass.
|
||||
@@ -94,6 +98,20 @@ The running narrative — decisions, root causes, incidents, follow-ups — is
|
||||
|
||||
## Working on this repo
|
||||
|
||||
**First, get out of this checkout.** It is the operator's working copy and several agents
|
||||
share it; concurrent runs here have overwritten each other's branches and left each other's
|
||||
uncommitted files behind. One workspace per change:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
|
||||
cd /path/outside/the/repo/wt-<slug>
|
||||
```
|
||||
|
||||
Outside the repo root specifically — a worktree under `.claude/worktrees/` is a second full
|
||||
copy that tree-walking gate checks then count twice. The rule, the measurements behind it, and
|
||||
the three gate workarounds a worktree needs are in [`CLAUDE.md` §0](CLAUDE.md) and §3.
|
||||
|
||||
Everything the gate needs is baked into the pinned dev container — **do not install the
|
||||
toolchain on the host**:
|
||||
|
||||
|
||||
@@ -244,6 +244,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): jackett-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: jackett-allow-monitoring-scrape
|
||||
namespace: jackett
|
||||
labels:
|
||||
app.kubernetes.io/name: jackett
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: jackett
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): jackett-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+22
-1
@@ -73,7 +73,7 @@ spec:
|
||||
- name: FTLCONF_dns_rateLimit_count
|
||||
value: '0'
|
||||
- name: FTLCONF_misc_dnsmasq_lines
|
||||
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP}
|
||||
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
|
||||
- name: FTLCONF_webserver_api_password
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
@@ -232,6 +232,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): pihole-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: pihole-allow-monitoring-scrape
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): pihole-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+21
-1
@@ -138,7 +138,19 @@ spec:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
- name: ADVERTISE_IP
|
||||
value: ${PLEX_ADVERTISE_URL}
|
||||
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
|
||||
- name: PLEX_TLS_CRT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.crt
|
||||
optional: true
|
||||
- name: PLEX_TLS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.key
|
||||
optional: true
|
||||
resources:
|
||||
limits:
|
||||
gpu.intel.com/i915: 1
|
||||
@@ -148,6 +160,14 @@ spec:
|
||||
- name: media
|
||||
mountPath: /media
|
||||
readOnly: true
|
||||
lifecycle:
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- "# NOTE \u2014 LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.\n# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a\n# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD\n# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be\n# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` \u2014 bare. Only\n# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be\n# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell\n# variable written with braces would be blanked or would fail the whole Kustomization \u2014\n# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and\n# catalog/render.py now refuses to emit it (which is how this very comment got caught).\n# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the\n# certificate has not issued yet they are genuinely UNSET, not empty \u2014 and under `set -u`\n# the first `[ -n \"$PLEX_TLS_CRT\" ]` would abort the script, which for a postStart hook\n# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists\n# to prevent, so turning it back on here would defeat the whole design. (The alternative,\n# a brace-wrapped `:-` default, would need envsubst escaping \u2014 the trap the note above\n# describes.) Every value below is explicitly guarded instead.\nPX=\"/config/Library/Application Support/Plex Media Server/Preferences.xml\"\nCERTDIR=/config/certs\nP12=$CERTDIR/plex-vip.p12\n# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it\n# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a\n# container-format requirement, not an access control.\nP12PASS=plexvip\nLANNETS='10.0.0.0/8,192.168.0.0/16'\nCERTDOMAIN=plex-vip.${BASE_DOMAIN}\nAPI=http://127.0.0.1:32400\n\n# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so\n# a cert-manager RENEWAL is picked up with no extra moving part.\nif [ -n \"$PLEX_TLS_CRT\" ] && [ -n \"$PLEX_TLS_KEY\" ]; then\n mkdir -p $CERTDIR && chmod 700 $CERTDIR\n printf '%s' \"$PLEX_TLS_CRT\" > $CERTDIR/tls.crt\n printf '%s' \"$PLEX_TLS_KEY\" > $CERTDIR/tls.key\n chmod 600 $CERTDIR/tls.key\n if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \\\n -in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then\n mv -f $P12.tmp $P12\n chown 1000:1000 $P12 2>/dev/null || true\n chmod 600 $P12\n echo \"[plex-settings] PKCS#12 bundle written\"\n else\n rm -f $P12.tmp\n echo \"[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle\" >&2\n fi\n rm -f $CERTDIR/tls.key\nelse\n echo \"[plex-settings] WARN: TLS env empty; certificate not installed this start\" >&2\nfi\n\n# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.\ni=0\nwhile [ \"$i\" -lt 45 ]; do\n if [ \"$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)\" = \"200\" ]; then\n break\n fi\n i=$(expr $i + 1); sleep 2\ndone\n\n# (3) The prefs API needs the server token. It already lives in Preferences.xml on this\n# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An\n# UNCLAIMED server has none \u2014 a normal state on a fresh /config \u2014 so skip, do not fail.\nTOKEN=$(grep -o 'PlexOnlineToken=\"[^\"]*\"' \"$PX\" 2>/dev/null | head -1 | cut -d'\"' -f2)\nif [ -z \"$TOKEN\" ]; then\n echo \"[plex-settings] not claimed yet \u2014 skipping prefs, will retry next start\" >&2\n exit 0\nfi\n\n# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on\n# a PUT, so a raw '/' or ',' would be read as structure rather than data.\nenc() {\n printf '%s' \"$1\" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \\\n -e 's|:|%3A|g' -e 's| |%20|g'\n}\nsetpref() {\n code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT\
|
||||
\ \\\n -H \"X-Plex-Token: $TOKEN\" \"$API/:/prefs?$1=$(enc \"$2\")\" 2>/dev/null)\n if [ \"$code\" = \"200\" ]; then\n echo \"[plex-settings] set $1\"\n else\n echo \"[plex-settings] WARN: $1 -> HTTP $code\" >&2\n fi\n}\n\n# (4) THE LAN DEFINITION \u2014 the reason this hook was written. Without it Plex has NO notion\n# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests\n# logged \"Allowed Network (WAN)\" and ZERO \"(LAN)\", including Seerr from inside the\n# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks\n# (the ALLOWED_NETWORKS env) only waives AUTH \u2014 it does NOT make a client local. Two\n# different settings; only one of them has an env hook.\nsetpref LanNetworksBandwidth \"$LANNETS\"\n\n# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only\n# if the bundle exists \u2014 pointing Plex at a missing file would break its TLS listener.\nif [ -s \"$P12\" ]; then\n setpref customCertificatePath \"$P12\"\n setpref customCertificateKey \"$P12PASS\"\n setpref customCertificateDomain \"$CERTDOMAIN\"\nelse\n echo \"[plex-settings] WARN: no bundle; leaving certificate prefs untouched\" >&2\nfi\nexit 0\n"
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
|
||||
@@ -319,6 +319,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): qbittorrent-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: qbittorrent-allow-monitoring-scrape
|
||||
namespace: qbittorrent
|
||||
labels:
|
||||
app.kubernetes.io/name: qbittorrent
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: qbittorrent
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -347,6 +347,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): radarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: radarr-allow-monitoring-scrape
|
||||
namespace: radarr
|
||||
labels:
|
||||
app.kubernetes.io/name: radarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: radarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): radarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -345,6 +345,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: sonarr-allow-monitoring-scrape
|
||||
namespace: sonarr
|
||||
labels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+10
-1
@@ -134,8 +134,17 @@ env:
|
||||
# then silently wrong: FTL stores `["a","b"]` as ONE array element containing those literal
|
||||
# characters, writes that single bogus line into /etc/pihole/dnsmasq.conf and reports "0 invalid"
|
||||
# while resolution for the zone does not work. Confirmed both ways against the live image.
|
||||
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the LAN HALF of Plex's single advertised
|
||||
# hostname. Plex publishes ONE URL to plex.tv for both local and remote clients
|
||||
# (catalog/plex.yaml ADVERTISE_IP): on the LAN this answer points it at the tcp/32400
|
||||
# LoadBalancer, while Cloudflare answers the same name publicly with the WAN address, NATed to
|
||||
# the same VIP. WITHOUT THIS LINE the entry above answers it with ${GATEWAY_VIP} via the zone
|
||||
# wildcard, where NOTHING listens on 32400 — measured 2026-10-04, plex-vip.${BASE_DOMAIN}
|
||||
# resolved to the Gateway VIP and a connect to :32400 there returned http=000. That is a silent
|
||||
# failure: the Plex web UI on plex.${BASE_DOMAIN} keeps working through the Gateway on 443 while
|
||||
# every client that takes the ADVERTISED connection cannot reach the server at all.
|
||||
- name: FTLCONF_misc_dnsmasq_lines
|
||||
value: "address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP}"
|
||||
value: "address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
|
||||
# THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from
|
||||
# apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which
|
||||
# carries the full rationale and the measurement that found it.
|
||||
|
||||
+215
-42
@@ -72,54 +72,227 @@ env:
|
||||
secretKeyRef:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
# ADVERTISE_IP ("Custom server access URLs") — NOW SET. This replaces a DEFERRAL note that stood
|
||||
# here until 2026-10-04, and the deferral's premise has changed: it weighed only ONE candidate,
|
||||
# `https://plex.home.keithjackson.dev` through the L7 Gateway. The operator chose the NATIVE L4
|
||||
# path instead on 2026-10-03 (apps/wave-0/plex-remote.yaml) so streams go straight to
|
||||
# ${PLEX_L4_VIP}:32400 and bypass the Envoy hop. What was still missing was a URL whose TLS
|
||||
# CERTIFICATE validates — and getting that wrong is what took the service down.
|
||||
# ADVERTISE_IP ("Custom server access URLs") — the connection Plex publishes to plex.tv.
|
||||
#
|
||||
# THE VALUE IS A `plex.direct` NAME, NOT THE RAW VIP, AND THAT IS THE WHOLE POINT. Plex serves
|
||||
# 32400 with its own certificate; measured live 2026-10-04 it carries exactly one name:
|
||||
# CN = *.<serverhash>.plex.direct (one SAN, no IP SANs at all)
|
||||
# so `https://<ip>:32400` can NEVER validate. `<dashed-ip>.<serverhash>.plex.direct` is Plex's own
|
||||
# mechanism for this: it is PUBLIC DNS that resolves to the embedded address, so the name points at
|
||||
# the LAN VIP while the wildcard cert above matches it. Measured 2026-10-04 with FULL verification
|
||||
# (no `curl -k`):
|
||||
# https://192-168-2-24.<hash>.plex.direct:32400/identity -> 200, TLS verify OK
|
||||
# https://192.168.2.24:32400/identity -> 000, cert validation FAILED
|
||||
# THE VALUE MUST BE RESOLVABLE BY EVERY CLIENT, NOT JUST BY CLIENTS THAT USE OUR DNS. That single
|
||||
# property is what this field keeps getting wrong, so it is the first thing to check before
|
||||
# changing it. A Plex client resolves the advertised host ITSELF, with whatever resolver it
|
||||
# happens to use — and on a real LAN that is frequently NOT PiHole: browsers ship DoH on by
|
||||
# default, phones have "private DNS", guest and IoT devices often take the router's DNS, and some
|
||||
# clients hard-code a public resolver.
|
||||
#
|
||||
# THIS FIELD EXISTS BECAUSE AUTO-DISCOVERY CANNOT WORK HERE. Left empty, Plex publishes the
|
||||
# addresses it can see itself — which from a pod is the overlay IP (`10.0.2.136`), useless to a LAN
|
||||
# client. The VIP has to be advertised explicitly; there is no "just clear it" fix.
|
||||
# THAT IS NOT THEORETICAL — IT TOOK PLEX DOWN FOR THE WHOLE LAN ON 2026-10-05. This field briefly
|
||||
# advertised `plex-vip.${BASE_DOMAIN}`, a name that exists ONLY in PiHole's split-horizon answers
|
||||
# (catalog/pihole.yaml) and in no public zone. Clients that did not use PiHole could not resolve
|
||||
# it; the SOHO router answered the failed lookup with ITSELF, and every one of them landed on the
|
||||
# router's own web UI and reported a certificate error for `CN = %D`. Measured 2026-10-05: the
|
||||
# router serves that self-signed cert (`C = ZZ, O = %D342e7e6b, CN = %D`) on BOTH `192.168.2.1:443`
|
||||
# and the WAN address, while PiHole itself was answering perfectly — both replicas returned
|
||||
# `plex-vip -> ${PLEX_L4_VIP}` and `plex -> ${GATEWAY_VIP}`. The DNS was right; the DEPENDENCY on
|
||||
# it was the bug. Nothing Plex advertises listens on 443, which is how we know the clients had left
|
||||
# the Plex path entirely and were talking to the router.
|
||||
#
|
||||
# WHAT WENT WRONG, recorded so it is not re-introduced by hand. customConnections OVERRIDES Plex's
|
||||
# own publishing, and on 2026-10-04 it held two hand-typed URLs, both uncompletable:
|
||||
# - `https://192.168.2.24:32400` -> cert cannot cover an IP. Measured: a storm of
|
||||
# `CERT: incomplete TLS handshake ... sslv3 alert certificate unknown` in Plex's log every few
|
||||
# seconds, from REAL LAN clients arriving through the VIP.
|
||||
# - `https://plex.home.keithjackson.dev:32400` -> nothing listens. The Gateway serves this host on
|
||||
# 443; measured `http=000` against 192.168.2.20:32400.
|
||||
# With every advertised connection dead, plex.tv had nothing publishable, sat at
|
||||
# `mapping state 'Mapped - Not Published'`, fell back to Plex Relay, and THE SERVER DISAPPEARED
|
||||
# FROM THE ACCOUNT — clients reported "not authorized".
|
||||
# SO: `<dashed-ip>.<serverhash>.plex.direct`, WHICH IS RESOLVER-INDEPENDENT. This is Plex's own
|
||||
# mechanism and the reason it exists: `plex.direct` is a PUBLIC zone that returns the private
|
||||
# address embedded in the label, so EVERY resolver on earth — PiHole, the router, 1.1.1.1, a DoH
|
||||
# browser — answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no
|
||||
# split-horizon entry, no Cloudflare record, and no certificate work, because the wildcard cert
|
||||
# Plex already serves on 32400 covers exactly this name. Re-verified live 2026-10-05 with FULL
|
||||
# validation (no `curl -k`): resolves to 192.168.2.24 and `/identity` returns **200, TLS verified**.
|
||||
#
|
||||
# DECLARING IT HERE rather than in the web UI is the durable half of the fix. The image's
|
||||
# /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections" "${ADVERTISE_IP}"` (line 127)
|
||||
# OUTSIDE the PLEX_CLAIM/PlexOnlineToken guard, and its `/.firstRunComplete` marker sits on the
|
||||
# container's OVERLAY fs, not the /config PVC — so this is re-applied on EVERY pod start and git,
|
||||
# not a form field, owns the value. Editing this env rolls the Deployment, which is what applies it.
|
||||
# WHY NOT THE NICER HOSTNAME, since a readable name in our own zone is obviously more pleasant:
|
||||
# it cannot be made to work for clients that bypass PiHole. A public record for it would resolve
|
||||
# to the WAN address, and with no NAT hairpin on this router a LAN client sent there fails too —
|
||||
# so the hostname is correct ONLY for clients using PiHole, and wrong for the rest. There is no
|
||||
# configuration of that name that is right for both. `plex.direct` is right for both.
|
||||
# (The PiHole `plex-vip` answer is KEPT — it is harmless, costs nothing, and is genuinely useful
|
||||
# for reaching the VIP by name from a LAN shell. It is simply not what Plex advertises.)
|
||||
#
|
||||
# TWO LIMITS, stated rather than discovered later:
|
||||
# - THE HASH IS SERVER IDENTITY, which is why the whole URL is a cluster-var and not built from
|
||||
# ${PLEX_L4_VIP}: envsubst cannot dash an IP, and the hash is not derivable from anything in
|
||||
# this tree. It is not a secret (it ships in the cert and in the published connection list), but
|
||||
# if Plex reissues under a new hash this URL goes stale and must be re-read from the live cert.
|
||||
# - THIS RESTORES LAN CLIENTS AND THE ACCOUNT LISTING, NOT WAN. Remote clients still need the
|
||||
# off-repo tcp/32400 port-forward (runbooks/plex-remote-access.md). Once that exists, the WAN
|
||||
# twin of this URL can be appended comma-separated — customConnections takes a list.
|
||||
# THE TWO EMBEDDED VALUES, and the honest cost of this choice. The dashed IP must track
|
||||
# ${PLEX_L4_VIP} and `<serverhash>` is this server's certificate identity — read it from the live
|
||||
# listener with
|
||||
# openssl s_client -connect <vip>:32400 </dev/null | openssl x509 -noout -subject
|
||||
# Nothing in this tree can derive either, and nothing checks that they still agree: envsubst
|
||||
# cannot dash an IP, so this is a literal. That fragility is real and was the reason the hostname
|
||||
# was tried in the first place — it is accepted here because an unresolvable name is a WORSE
|
||||
# failure (silent, LAN-wide, and it points users at the router) than a stale literal, which at
|
||||
# least fails loudly and only after someone changes the VIP.
|
||||
#
|
||||
# The image's /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections"
|
||||
# "${ADVERTISE_IP}"` (line 127, verified in the running image), OUTSIDE the
|
||||
# PLEX_CLAIM/PlexOnlineToken guard, with its `/.firstRunComplete` marker on the container OVERLAY
|
||||
# fs rather than the /config PVC — so this is re-applied on EVERY pod start and git, not a web
|
||||
# form, owns the value.
|
||||
- name: ADVERTISE_IP
|
||||
value: ${PLEX_ADVERTISE_URL}
|
||||
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
|
||||
# THE TLS MATERIAL FOR THE NAME ABOVE, as PEM text in env rather than a mounted volume.
|
||||
# `plex-vip-tls` is issued by cert-manager from ClusterIssuer `letsencrypt-prod` for exactly
|
||||
# `plex-vip.${BASE_DOMAIN}` (apps/wave-0/plex-cert.yaml, same wave => same reconcile as this
|
||||
# workload). The postStart hook converts it to the PKCS#12 bundle Plex requires.
|
||||
#
|
||||
# WHY ENV AND NOT A VOLUME, which would be the conventional choice. MEASURED 2026-10-04:
|
||||
# `schemas/servicedefinition.schema.json` `storage` accepts only
|
||||
# [class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`,
|
||||
# `catalog/render.py` emits NO secret volumes, and no rendered app mounts a Secret as a volume.
|
||||
# Mounting one therefore needs a schema + renderer change; `env.valueFrom.secretKeyRef` is
|
||||
# already in the schema and already used by PLEX_CLAIM above. PEM is text, so it survives an env
|
||||
# var intact (a raw PKCS#12 would not — it is binary and carries NUL bytes).
|
||||
#
|
||||
# COST, STATED PLAINLY: the private key is readable from this container's environment
|
||||
# (/proc/1/environ, `kubectl exec env`) by anything that can already exec into the pod. That is
|
||||
# the same audience that can read the converted bundle under /config, so the marginal exposure is
|
||||
# small — but a secret VOLUME would still be the better shape, and adding secret-volume support
|
||||
# to the schema is the clean follow-up that removes this note.
|
||||
#
|
||||
# `optional: true` IS LOAD-BEARING, NOT DEFENSIVE CLUTTER (schema 1.11.0, added for this). The
|
||||
# Certificate is issued INTO THIS NAMESPACE, and the namespace is created by the rendered
|
||||
# apps/wave-0/plex.yaml itself — so the cert CANNOT be issued in an earlier tier, and the
|
||||
# Certificate and this Deployment land in the SAME reconcile. On a cold bootstrap the Secret is
|
||||
# therefore briefly absent while cert-manager completes a DNS01 challenge. With a HARD ref, Plex
|
||||
# would sit in CreateContainerConfigError until a certificate issued — and because `apps-wave-0`
|
||||
# is `wait: true` with `apps-wave-1` dependsOn it, a failed or slow issuance would STALL THE WHOLE
|
||||
# APP TIER (the same hazard the `hardware` block below documents for the GPU resource). Optional
|
||||
# instead means: no cert -> Plex still starts and serves, the postStart hook logs that the
|
||||
# certificate was not installed, and the next start after issuance converges. Plex falls back to
|
||||
# its own `*.plex.direct` certificate in the meantime, so the degraded state is "advertised URL
|
||||
# not yet servable over TLS", not "media server down".
|
||||
- name: PLEX_TLS_CRT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.crt
|
||||
optional: true
|
||||
- name: PLEX_TLS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.key
|
||||
optional: true
|
||||
lifecycle:
|
||||
# LEVEL-TRIGGERED PLEX SETTINGS, re-asserted on EVERY container start — the same pattern and the
|
||||
# same reasoning as catalog/pihole.yaml's and catalog/qbittorrent.yaml's postStart: desired state
|
||||
# lives in git and is re-asserted unconditionally rather than typed into a web form once. Typing
|
||||
# settings into the web form is precisely what took this service down on 2026-10-04.
|
||||
#
|
||||
# IT EXISTS BECAUSE THE IMAGE HAS NO ENV FOR THESE SETTINGS. Measured 2026-10-04 across the whole
|
||||
# init surface (/etc/cont-init.d, /etc/services.d, /plex-common.sh, /usr/local/bin): the image
|
||||
# calls setPref for exactly SIX prefs — MachineIdentifier, ProcessedMachineIdentifier,
|
||||
# PlexOnlineToken, customConnections, allowedNetworks, TranscoderTempDirectory — and supports NO
|
||||
# generic PLEX_PREFERENCE* hook of any kind. The two things we need (a LAN definition and a custom
|
||||
# certificate) are reachable ONLY through Plex's own API, so this hook is the only GitOps path.
|
||||
#
|
||||
# IT MUST NEVER FAIL. A postStart hook that exits non-zero makes kubelet KILL the container, so
|
||||
# every step is bounded and the script ends with an unconditional `exit 0`. A missing token, an
|
||||
# unissued certificate or an unreachable API degrades to "not applied this start" and is retried
|
||||
# on the next one; it never crashloops Plex.
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
# NOTE — LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.
|
||||
# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a
|
||||
# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD
|
||||
# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be
|
||||
# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` — bare. Only
|
||||
# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be
|
||||
# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell
|
||||
# variable written with braces would be blanked or would fail the whole Kustomization —
|
||||
# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and
|
||||
# catalog/render.py now refuses to emit it (which is how this very comment got caught).
|
||||
# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the
|
||||
# certificate has not issued yet they are genuinely UNSET, not empty — and under `set -u`
|
||||
# the first `[ -n "$PLEX_TLS_CRT" ]` would abort the script, which for a postStart hook
|
||||
# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists
|
||||
# to prevent, so turning it back on here would defeat the whole design. (The alternative,
|
||||
# a brace-wrapped `:-` default, would need envsubst escaping — the trap the note above
|
||||
# describes.) Every value below is explicitly guarded instead.
|
||||
PX="/config/Library/Application Support/Plex Media Server/Preferences.xml"
|
||||
CERTDIR=/config/certs
|
||||
P12=$CERTDIR/plex-vip.p12
|
||||
# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it
|
||||
# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a
|
||||
# container-format requirement, not an access control.
|
||||
P12PASS=plexvip
|
||||
LANNETS='10.0.0.0/8,192.168.0.0/16'
|
||||
CERTDOMAIN=plex-vip.${BASE_DOMAIN}
|
||||
API=http://127.0.0.1:32400
|
||||
|
||||
# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so
|
||||
# a cert-manager RENEWAL is picked up with no extra moving part.
|
||||
if [ -n "$PLEX_TLS_CRT" ] && [ -n "$PLEX_TLS_KEY" ]; then
|
||||
mkdir -p $CERTDIR && chmod 700 $CERTDIR
|
||||
printf '%s' "$PLEX_TLS_CRT" > $CERTDIR/tls.crt
|
||||
printf '%s' "$PLEX_TLS_KEY" > $CERTDIR/tls.key
|
||||
chmod 600 $CERTDIR/tls.key
|
||||
if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \
|
||||
-in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then
|
||||
mv -f $P12.tmp $P12
|
||||
chown 1000:1000 $P12 2>/dev/null || true
|
||||
chmod 600 $P12
|
||||
echo "[plex-settings] PKCS#12 bundle written"
|
||||
else
|
||||
rm -f $P12.tmp
|
||||
echo "[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle" >&2
|
||||
fi
|
||||
rm -f $CERTDIR/tls.key
|
||||
else
|
||||
echo "[plex-settings] WARN: TLS env empty; certificate not installed this start" >&2
|
||||
fi
|
||||
|
||||
# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.
|
||||
i=0
|
||||
while [ "$i" -lt 45 ]; do
|
||||
if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)" = "200" ]; then
|
||||
break
|
||||
fi
|
||||
i=$(expr $i + 1); sleep 2
|
||||
done
|
||||
|
||||
# (3) The prefs API needs the server token. It already lives in Preferences.xml on this
|
||||
# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An
|
||||
# UNCLAIMED server has none — a normal state on a fresh /config — so skip, do not fail.
|
||||
TOKEN=$(grep -o 'PlexOnlineToken="[^"]*"' "$PX" 2>/dev/null | head -1 | cut -d'"' -f2)
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "[plex-settings] not claimed yet — skipping prefs, will retry next start" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on
|
||||
# a PUT, so a raw '/' or ',' would be read as structure rather than data.
|
||||
enc() {
|
||||
printf '%s' "$1" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \
|
||||
-e 's|:|%3A|g' -e 's| |%20|g'
|
||||
}
|
||||
setpref() {
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT \
|
||||
-H "X-Plex-Token: $TOKEN" "$API/:/prefs?$1=$(enc "$2")" 2>/dev/null)
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "[plex-settings] set $1"
|
||||
else
|
||||
echo "[plex-settings] WARN: $1 -> HTTP $code" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# (4) THE LAN DEFINITION — the reason this hook was written. Without it Plex has NO notion
|
||||
# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests
|
||||
# logged "Allowed Network (WAN)" and ZERO "(LAN)", including Seerr from inside the
|
||||
# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks
|
||||
# (the ALLOWED_NETWORKS env) only waives AUTH — it does NOT make a client local. Two
|
||||
# different settings; only one of them has an env hook.
|
||||
setpref LanNetworksBandwidth "$LANNETS"
|
||||
|
||||
# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only
|
||||
# if the bundle exists — pointing Plex at a missing file would break its TLS listener.
|
||||
if [ -s "$P12" ]; then
|
||||
setpref customCertificatePath "$P12"
|
||||
setpref customCertificateKey "$P12PASS"
|
||||
setpref customCertificateDomain "$CERTDOMAIN"
|
||||
else
|
||||
echo "[plex-settings] WARN: no bundle; leaving certificate prefs untouched" >&2
|
||||
fi
|
||||
exit 0
|
||||
# HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02).
|
||||
# INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an
|
||||
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
|
||||
|
||||
+61
-12
@@ -156,11 +156,17 @@ OAUTH2_COOKIE_REFRESH = "2m30s"
|
||||
# ready endpoints. The flag is confirmed present in the PINNED v7.8.1 binary (`--help`),
|
||||
# which matters because the shared proxy is on v7.15.3 and could not have vouched for it.
|
||||
#
|
||||
# THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED. A container port is not a
|
||||
# Service port: these sidecars' Services expose the app port and :4180, and each namespace's
|
||||
# default-deny baseline has `ingress: []`, so Prometheus can neither resolve nor reach this
|
||||
# port until a Service port (or PodMonitor), a monitoring-ingress allow and a ServiceMonitor
|
||||
# are added. Do not read this flag as "metrics are working".
|
||||
# THE SCRAPE WORKS AS OF 2026-10-05 — but it took THREE independent pieces, and this flag is
|
||||
# only the first. A container port is not a Service port, and each namespace's default-deny
|
||||
# baseline denies all ingress, so the flag alone bought nothing for 63.7h:
|
||||
# 1. this flag + a named `metrics` container port (PR #28)
|
||||
# 2. a monitor that targets POD ports, not Service ports — platform/oauth2-proxy/podmonitor.yaml
|
||||
# is a PodMonitor for exactly that reason (no Service exposes 44180)
|
||||
# 3. an ingress allowance from ns monitoring (PR #49, r_monitoring_ingress_policy below)
|
||||
# Verified on prod 2026-10-05: all 6 targets up == 1, scraping in 1-6ms.
|
||||
# STILL do not read this flag alone as "metrics are working" — 1 without 3 is precisely the state
|
||||
# that produced 5 x TargetDown for 63.7h. If you add a sidecar metrics port anywhere else, check
|
||||
# that r_monitoring_ingress_policy covers it.
|
||||
OAUTH2_PROXY_METRICS_PORT = 44180
|
||||
|
||||
# --- Hardware capability: `hardware.gpu: intel-quicksync` ------------------------------
|
||||
@@ -1362,8 +1368,9 @@ def _oauth2_sidecar_container(s: dict) -> dict:
|
||||
f"--cookie-refresh={OAUTH2_COOKIE_REFRESH}",
|
||||
"--pass-access-token=true",
|
||||
"--set-xauthrequest=true",
|
||||
# Start the /metrics listener. Prerequisite only — not yet reachable by Prometheus;
|
||||
# see OAUTH2_PROXY_METRICS_PORT above for exactly what is still missing.
|
||||
# Start the /metrics listener. Scraped as of 2026-10-05, but only because
|
||||
# r_monitoring_ingress_policy also opens this port to ns monitoring — the flag alone was
|
||||
# inert for 63.7h. See OAUTH2_PROXY_METRICS_PORT above for the three pieces required.
|
||||
f"--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}",
|
||||
]
|
||||
secret = f"{s['name']}-oauth2-proxy"
|
||||
@@ -1541,12 +1548,54 @@ def r_monitoring_ingress_policy(s: dict):
|
||||
trap Longhorn's own NetworkPolicies set (platform/longhorn/networkpolicy-metrics.yaml exists
|
||||
for exactly this reason), and it is a THIRD independent way for a metrics SLI to be inert.
|
||||
|
||||
Rendered only alongside a declared metricsEndpoint, and it opens ONLY that port to ONLY the
|
||||
monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
||||
Rendered only alongside a port something actually serves, and it opens ONLY that port to ONLY
|
||||
the monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
||||
benefit.
|
||||
|
||||
TWO INDEPENDENT SOURCES OF A SERVED METRICS PORT, and this used to cover only the first:
|
||||
|
||||
1. `observability.metricsEndpoint` — the APP's own exporter. NOTE, measured 2026-10-04: no
|
||||
ServiceDefinition in this catalog declares one, so before the sidecar clause below this
|
||||
function had NEVER emitted a single object on any branch. It is correct but, so far,
|
||||
entirely latent — do not read its presence as evidence that scrape ingress is solved.
|
||||
|
||||
2. THE forward-auth GATE SIDECAR's own `/metrics` on 44180 (added 2026-10-04). Every
|
||||
`_needs_oauth2_sidecar` app gets `--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}`
|
||||
and a named `metrics` container port from `_oauth2_sidecar_container`, so the port is
|
||||
served whenever the sidecar exists — unconditionally, which is what makes it safe to
|
||||
open without a per-app declaration.
|
||||
|
||||
WHY CLAUSE 2 KEYS ON `_needs_oauth2_sidecar` AND NOT ON `observability.metrics`: it must match
|
||||
the condition the SCRAPE keys on, exactly. platform/oauth2-proxy/podmonitor.yaml selects pods
|
||||
by the `homelab.keithjackson.dev/auth-mode: forward-auth` POD LABEL and nothing else, so a
|
||||
target exists iff the sidecar exists. Keying the allowance on `observability.metrics` instead
|
||||
would let `metrics: false` produce a discovered-but-blocked target — the same "two halves
|
||||
authored apart" failure this function exists to prevent, just moved one field over.
|
||||
|
||||
THIS CLAUSE IS A BUG FIX, and the bug was live for 63.7h. Measured on prod 2026-10-04: all 6
|
||||
sidecar targets across jackett/radarr/sonarr/qbittorrent/pihole read `health=down`,
|
||||
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly 10.0s — the scrapeTimeout,
|
||||
i.e. packets DROPPED, not refused. Each app namespace ships a `default-deny` NetworkPolicy
|
||||
with `policyTypes: [Ingress, Egress]` and NO `ingress` key at all (deny-all-ingress), and the
|
||||
only ingress allowance is `<app>-allow-gateway-ingress`: `fromEntities: [ingress]` -> 4180.
|
||||
Prometheus matched no clause. 5 x `TargetDown` + 6 x `OAuth2ProxyGateUnobserved` had been
|
||||
re-notifying at ~22 emails/day since the PodMonitor landed on 2026-10-02.
|
||||
|
||||
WHY THE POLICY BELONGS HERE AND NOT IN platform/oauth2-proxy: podmonitor.yaml's header records
|
||||
the objection — a policy in that tier would name app namespaces created by the SIBLING
|
||||
apps-wave-0/1 tiers (measured Flux graph: `oauth2-proxy` and `apps-wave-0` both
|
||||
`dependsOn=[platform-config]`, no ordering between them), so it would apply on a running
|
||||
cluster and fail on a cold bootstrap. Rendered here it ships in the same tier that creates the
|
||||
namespace, which dissolves that ordering problem entirely.
|
||||
"""
|
||||
ep = s["observability"].get("metricsEndpoint")
|
||||
if not ep or not s["observability"]["metrics"]:
|
||||
obs = s["observability"]
|
||||
ep = obs.get("metricsEndpoint")
|
||||
ports: list[str] = []
|
||||
if ep and obs["metrics"]:
|
||||
ports.append(str(ep["port"]))
|
||||
if _needs_oauth2_sidecar(s):
|
||||
ports.append(str(OAUTH2_PROXY_METRICS_PORT))
|
||||
if not ports:
|
||||
return None
|
||||
return {
|
||||
"apiVersion": "cilium.io/v2",
|
||||
@@ -1564,7 +1613,7 @@ def r_monitoring_ingress_policy(s: dict):
|
||||
{"matchLabels": {"k8s:io.kubernetes.pod.namespace": MONITORING_NS}}
|
||||
],
|
||||
"toPorts": [
|
||||
{"ports": [{"port": str(ep["port"]), "protocol": "TCP"}]}
|
||||
{"ports": [{"port": p, "protocol": "TCP"} for p in ports]}
|
||||
],
|
||||
}
|
||||
],
|
||||
|
||||
@@ -101,7 +101,8 @@ data:
|
||||
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
|
||||
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH; pihole + pihole-dns: PIHOLE_DNS_VIP, LAN_CIDR, plus
|
||||
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers;
|
||||
# plex-remote: PLEX_L4_VIP; plex: PLEX_ADVERTISE_URL),
|
||||
# plex-remote + pihole: PLEX_L4_VIP),
|
||||
# vpn-ingress (WG_CLIENT_CIDR),
|
||||
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
||||
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
|
||||
# UNSUBSTITUTED path would ship the literal string into the cluster.
|
||||
@@ -189,32 +190,13 @@ data:
|
||||
# dev -> 192.168.1.57 (base default; next free after pihole-dns .56 in dev's .53-.60 pool)
|
||||
# prod -> 192.168.2.24 (next free after pihole-dns .23 in prod's .20-.30 pool; see clusters/prod)
|
||||
PLEX_L4_VIP: "192.168.1.57" # Plex: tcp/32400 remote access (NOT the web UI, which is L7)
|
||||
# SECOND CONSUMER since 2026-10-04: catalog/pihole.yaml reads this knob to answer
|
||||
# plex-vip.${BASE_DOMAIN} on the LAN, which is the local half of the single hostname Plex
|
||||
# advertises to plex.tv. Without that per-host answer the zone wildcard sends the name to
|
||||
# ${GATEWAY_VIP}, where nothing listens on 32400. So this value is now typed into THREE places
|
||||
# that must agree: this knob, the WAN port-forward on the SOHO router (off-repo), and PiHole's
|
||||
# answer -- and only the first two were true before.
|
||||
|
||||
# PLEX_ADVERTISE_URL — the connection URL Plex publishes to plex.tv ("Custom server access URLs",
|
||||
# the ADVERTISE_IP env in catalog/plex.yaml, which the image writes into the `customConnections`
|
||||
# preference on every pod start). Read catalog/plex.yaml's ADVERTISE_IP block for the full why;
|
||||
# the short version is the only thing that matters when editing this line:
|
||||
#
|
||||
# IT MUST BE A `<dashed-ip>.<serverhash>.plex.direct` NAME, NEVER A BARE IP. Plex serves 32400
|
||||
# with a `*.<serverhash>.plex.direct` wildcard certificate and NO IP SAN, so `https://<ip>:32400`
|
||||
# fails TLS validation for every client. That exact mistake is what broke this service on
|
||||
# 2026-10-04: with no completable connection, plex.tv published nothing, the server vanished from
|
||||
# the account and clients reported "not authorized". The dashed name is public DNS that resolves
|
||||
# to the embedded address, so it points at the LAN VIP AND matches the cert.
|
||||
#
|
||||
# WHY IT IS A WHOLE URL AND NOT BUILT FROM ${PLEX_L4_VIP}: postBuild substitution is plain
|
||||
# envsubst — it cannot turn 192.168.2.24 into 192-168-2-24 — and `<serverhash>` is this server's
|
||||
# certificate identity, derivable from nothing in this tree. Re-read it from the live cert
|
||||
# (`openssl s_client -connect <vip>:32400 | openssl x509 -noout -subject`) if Plex ever reissues.
|
||||
#
|
||||
# The dashed IP here MUST track ${PLEX_L4_VIP} above. They are two halves of one address and
|
||||
# nothing checks that they agree.
|
||||
#
|
||||
# dev -> PLACEHOLDER, non-functional. dev is decommissioned and has no claimed Plex server, so
|
||||
# there is no real hash to put here; the base default only has to be a defined, correctly
|
||||
# shaped string (StrictPostBuildSubstitutions makes an UNDEFINED var a hard failure).
|
||||
# prod -> the real 192.168.2.24 + live server hash; see clusters/prod/kustomization.yaml.
|
||||
PLEX_ADVERTISE_URL: "https://192-168-1-57.0000000000000000000000000000000f.plex.direct:32400"
|
||||
|
||||
# The FLAT LAN this cluster's nodes and LB VIPs live on, in CIDR form. Consumed as the ingress
|
||||
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
|
||||
@@ -226,6 +208,31 @@ data:
|
||||
# prod -> 192.168.2.0/24 (prod LAN; nodes .10/.11/.12)
|
||||
LAN_CIDR: "192.168.1.0/24" # ingress scope for the PiHole DNS listener
|
||||
|
||||
# --- Remote-access VPN client subnet (platform/vpn-ingress, WireGuard) --------------------------
|
||||
# The tunnel subnet handed to REMOTE VPN clients. `.1` is the server (the hostNetwork DaemonSet,
|
||||
# on every node); `.2`-`.254` are devices, allocated one /32 each in
|
||||
# platform/vpn-ingress/peers.yaml. Consumed twice in the pod's config-assembly script: to derive
|
||||
# the server's own address, and as the `-s` match of the MASQUERADE rule that makes LAN replies
|
||||
# routable.
|
||||
#
|
||||
# IT IS A KNOB RATHER THAN A LITERAL FOR ONE REASON: it is the only address range in this cluster
|
||||
# that must be checked against EVERY OTHER range at once, so it belongs where those are declared.
|
||||
# 10.90.0.0/24 was chosen to collide with none of them:
|
||||
# 10.0.0.0/16 Cilium cluster-pool pod CIDR (${CLUSTER_POD_CIDR})
|
||||
# 10.43.0.0/16 k3s service CIDR (measured live 2026-10-05: kubernetes svc
|
||||
# clusterIP 10.43.0.1)
|
||||
# 10.72.192.0/18 the PIA tunnel's pushed addresses (platform/vpn-egress)
|
||||
# 192.168.1.0/24 dev LAN / pfSense LAN (${LAN_CIDR} base default)
|
||||
# 192.168.2.0/24 prod LAN (${LAN_CIDR} on prod)
|
||||
#
|
||||
# NO PER-ENVIRONMENT OVERRIDE, and that is deliberate rather than an omission: unlike every VIP
|
||||
# above, this range is NOT drawn from the LAN. It is private to the tunnel, so the same value is
|
||||
# correct in both environments and a prod patch would be a no-op that implies otherwise.
|
||||
#
|
||||
# IF YOU CHANGE IT, every already-issued client profile breaks (its `Address` is from this range)
|
||||
# and must be re-rendered — so treat it as fixed once the first device is enrolled.
|
||||
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
|
||||
|
||||
@@ -71,4 +71,11 @@ resources:
|
||||
# controller must NEVER gate the platform chain; same non-gating rationale as headlamp/oauth2-proxy.
|
||||
# Carries velero-cloud.sops.yaml, so clusters/dev adds a SOPS decryption patch for it (by name).
|
||||
- backup.yaml # Flux Kustomization -> ./platform/velero (dependsOn platform-config)
|
||||
# WireGuard remote-access VPN (north-south INGRESS, udp/51820) — POST-platform tier
|
||||
# (dependsOn platform-config, wait:false). NOT the same thing as platform/vpn-egress, which sends
|
||||
# pods OUT through PIA; this lets remote CLIENTS IN. Non-gating is load-bearing rather than
|
||||
# conventional here: its DaemonSet cannot start until the operator creates the out-of-band
|
||||
# `wg-server-key` Secret, so in the gating `platform` tier an unfinished manual step would have
|
||||
# stalled every apps wave. Carries no SOPS secret, so neither overlay adds a decryption patch.
|
||||
- vpn-ingress.yaml # Flux Kustomization -> ./platform/vpn-ingress (dependsOn platform-config)
|
||||
- cluster-vars.yaml # overlay knob surface (base defaults)
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# Flux Kustomization: WireGuard remote-access VPN — reconciles ./platform/vpn-ingress.
|
||||
#
|
||||
# POST-PLATFORM TIER (dependsOn platform-config), NON-GATING (wait:false). Not a line in
|
||||
# platform/kustomization.yaml, for the standard reason plus one specific to this component:
|
||||
#
|
||||
# * STANDARD: a leaf must never sit in the gating `platform` tier. While `platform` is not-Ready
|
||||
# NOTHING downstream reconciles — the incident behind the headlamp/oauth2-proxy/homepage moves
|
||||
# (DECISIONS-LOG: "Headlamp gating stall").
|
||||
# * SPECIFIC, AND THE REASON THIS IS NOT A JUDGEMENT CALL: this DaemonSet cannot start until the
|
||||
# operator creates the out-of-band `wg-server-key` Secret (runbooks/vpn-remote-access.md
|
||||
# step 1). That is the correct fail-closed state — an unenrolled VPN must not come up — but in
|
||||
# the gating tier it would have been a self-inflicted platform-wide outage ON MERGE, with the
|
||||
# cause (a missing manual step) invisible from any Flux condition.
|
||||
#
|
||||
# WHY dependsOn platform-config AND NOT cilium. This component needs no Gateway, no TLS cert, no
|
||||
# Keycloak and no LB-IPAM pool — it has no HTTPRoute and no LoadBalancer Service, and hostNetwork
|
||||
# means it does not even need a Cilium endpoint. It could legally reconcile much earlier. It is
|
||||
# pinned behind platform-config anyway so that the ONE thing remote clients actually consume
|
||||
# through it — split-horizon DNS and the shared Gateway — exists by the time the tunnel does.
|
||||
# A tunnel that comes up before the things on the far side of it is a support call, not a feature.
|
||||
#
|
||||
# wait:false: nothing in the graph dependsOn this, and its pods are legitimately not-Ready whenever
|
||||
# the server key is absent or the `wireguard` kernel module has not been loaded on a node. Neither
|
||||
# condition should be able to gate anything else, even if a future resource is mistakenly pointed
|
||||
# at it.
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vpn-ingress
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
prune: true
|
||||
wait: false
|
||||
dependsOn:
|
||||
- name: platform-config
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./platform/vpn-ingress
|
||||
# REQUIRED, not boilerplate: platform/vpn-ingress/daemonset.yaml renders ${WG_CLIENT_CIDR} into
|
||||
# the config-assembly script. Without this block that token becomes the EMPTY STRING and the
|
||||
# Kustomization still reports Ready (Flux's substitution fails OPEN — ci:
|
||||
# flux-postbuild-substitution), leaving an initContainer that derives a server address of ".1/"
|
||||
# and a MASQUERADE rule with no source. Both fail loudly at pod start, but the Kustomization
|
||||
# would look healthy.
|
||||
postBuild:
|
||||
substituteFrom:
|
||||
- kind: ConfigMap
|
||||
name: cluster-vars
|
||||
# NO `decryption:` stanza is needed in the overlays: this path carries no *.sops.yaml. The server
|
||||
# key is an out-of-band Secret (platform/vpn-ingress/kustomization.yaml says what to do if that
|
||||
# ever changes).
|
||||
@@ -496,16 +496,6 @@ patches:
|
||||
- op: replace
|
||||
path: /data/PLEX_L4_VIP
|
||||
value: "192.168.2.24"
|
||||
# PLEX_ADVERTISE_URL — the prod VIP above in Plex's `plex.direct` form, which is the ONLY form
|
||||
# that validates: the server's cert is `*.130072f20c334d6c8f63d0dfef87309b.plex.direct` with no
|
||||
# IP SAN (read off the live listener 2026-10-04), so a bare `https://192.168.2.24:32400` fails
|
||||
# TLS for every client — the 2026-10-04 outage. Measured with full verification, no `curl -k`:
|
||||
# this URL answers /identity with 200 and TLS verify OK; the bare-IP form fails to connect.
|
||||
# THE DASHED IP MUST MATCH /data/PLEX_L4_VIP ABOVE (192.168.2.24 -> 192-168-2-24); nothing
|
||||
# enforces that. The hash is server identity, not a secret, and changes only if Plex reissues.
|
||||
- op: replace
|
||||
path: /data/PLEX_ADVERTISE_URL
|
||||
value: "https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400"
|
||||
# The prod flat LAN. This is the 192.168.2.0/24 segment the SOHO ROUTER at 192.168.2.1 owns —
|
||||
# NOT pfSense's 192.168.1.0/24, which is a different network and not in the LAN-DNS path.
|
||||
# It scopes the ingress allow for PiHole's DNS listener to LAN clients only.
|
||||
|
||||
@@ -171,6 +171,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): paas-demo-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paas-demo-allow-monitoring-scrape
|
||||
namespace: paas-demo
|
||||
labels:
|
||||
app.kubernetes.io/name: paas-demo
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paas-demo
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): paas-demo-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+21
@@ -198,6 +198,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: sonarr-allow-monitoring-scrape
|
||||
namespace: sonarr
|
||||
labels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -198,6 +198,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: sonarr-allow-monitoring-scrape
|
||||
namespace: sonarr
|
||||
labels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: fix/oauth2-sidecar-metrics-scrape-netpol
|
||||
title: The forward-auth gate sidecars' /metrics was NetworkPolicy-blocked — the renderer now opens 44180 to ns monitoring
|
||||
---
|
||||
|
||||
> **Added 2026-10-05, after merge:** the top item under *What I did NOT verify* below — "that the
|
||||
> scrape now succeeds" — **has since been verified on prod**. All 6 targets read `up == 1`. The
|
||||
> original text is left exactly as written; the measurements are in
|
||||
> [[2026-10-05-oauth2-sidecar-scrape-verified]].
|
||||
|
||||
## What changed
|
||||
|
||||
`catalog/render.py`'s `r_monitoring_ingress_policy` now emits its CiliumNetworkPolicy for a
|
||||
**second** reason: the forward-auth gate sidecar's own `/metrics` port (44180), keyed on
|
||||
`_needs_oauth2_sidecar(s)`. Previously it fired only from `observability.metricsEndpoint`.
|
||||
|
||||
That matters more than it sounds, because **no ServiceDefinition in this catalog declares a
|
||||
`metricsEndpoint`** (measured: zero grep hits across `catalog/*.yaml`). So the function was wired
|
||||
in at the call site and had **never emitted a single object on any branch** — correct, and
|
||||
entirely latent. Its presence in the tree read as "scrape ingress is handled". It was not.
|
||||
|
||||
Re-rendered `apps/` from that change: five new CNPs, one per forward-auth app
|
||||
(`jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`), each opening **only** 44180 to **only**
|
||||
`fromEndpoints: [k8s:io.kubernetes.pod.namespace: monitoring]`.
|
||||
|
||||
The bug being fixed: `platform/oauth2-proxy/podmonitor.yaml` landed 2026-10-02T04:58:44Z and
|
||||
discovered 6 targets that have **never once scraped successfully**. Each app namespace ships a
|
||||
`default-deny` NetworkPolicy with `policyTypes: [Ingress, Egress]` and **no `ingress` key at all**
|
||||
— deny-all-ingress in NetworkPolicy semantics — and the only ingress allowance is
|
||||
`<app>-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180. Prometheus matched no
|
||||
clause, so its packets were dropped.
|
||||
|
||||
Two deliberate choices, both recorded in the function's docstring:
|
||||
|
||||
- **Clause 2 keys on `_needs_oauth2_sidecar`, not on `observability.metrics`.** It has to match
|
||||
the condition the *scrape* keys on: `podmonitor.yaml` selects pods by the
|
||||
`homelab.keithjackson.dev/auth-mode: forward-auth` pod label and nothing else, so a target
|
||||
exists iff the sidecar exists. Keying on `observability.metrics` would let `metrics: false`
|
||||
produce a discovered-but-blocked target — the same "two halves authored apart" failure this
|
||||
function exists to prevent, moved one field over.
|
||||
- **The policy is rendered here, not added to `platform/oauth2-proxy/`.** podmonitor.yaml's own
|
||||
header records why that tier cannot host it: it would name app namespaces created by the
|
||||
*sibling* `apps-wave-0/1` tiers, so it would apply on a running cluster and fail on a cold
|
||||
bootstrap. Rendered per-app it ships in the same tier that creates the namespace.
|
||||
|
||||
## What I verified
|
||||
|
||||
Measured on prod 2026-10-04, read-only, via the pinned toolbox:
|
||||
|
||||
- **The cause, not a guess.** All 6 sidecar targets in pool
|
||||
`podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0`: `health=down`,
|
||||
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly `10.0s` — the configured
|
||||
`scrapeTimeout`. Hitting the timeout precisely means packets **dropped, not refused**.
|
||||
- **The listener is fine.** jackett's `oauth2-proxy` container carries
|
||||
`--metrics-address=0.0.0.0:44180` and a named port `metrics: 44180`.
|
||||
- **The deny is universal.** All six namespaces (`jackett radarr sonarr qbittorrent pihole
|
||||
crafty`): `default-deny` with `ingress` key absent, `nIngress=0`; the only ingress CNP allowance
|
||||
is 4180 `fromEntities: [ingress]`. **Zero** namespaces had any `-allow-monitoring-scrape` object.
|
||||
- **Frequency.** Single continuous episode, no flapping: `ALERTS_FOR_STATE` = 229343s (63.7h),
|
||||
firing since 2026-10-02 05:09 UTC — 11 minutes after the PodMonitor was created, i.e. TargetDown's
|
||||
`for: 10m`. A 15-day `query_range` shows **1 episode** per namespace, not many. Alertmanager:
|
||||
`group_by: [alertname, namespace]`, `repeat_interval: 12h` on `email-warning` → 5 TargetDown
|
||||
groups = 10 emails/day, plus 6 `OAuth2ProxyGateUnobserved` groups = 12/day, ~22/day total.
|
||||
0 silences.
|
||||
- **The re-render is deterministic and the diff is exactly the intended one.** `apps/` is
|
||||
byte-identical to `render.py --all catalog -o apps --wave-layout --no-image-automation`
|
||||
(12/12 SAME before my edit; the 11-file "drift" against a default render is solely the
|
||||
ImageRepository/ImagePolicy pair, which `apps/` is generated without). After the edit:
|
||||
5 files changed, +21 lines each, nothing else touched.
|
||||
- `render.py --selftest`: **PASS**.
|
||||
- **Three L1 goldens needed regenerating, and the diff is +63 lines / -0.** The first gate run
|
||||
caught `container-workload-render` (R-PLAT-02) and `egress-class-render` (R-NET-05/06/07) as
|
||||
FAIL with exactly the new CNP as the drift. Regenerated with the same invocations the checks
|
||||
use (`render.py <fixture>`, no flags). Evidence the regen is faithful rather than a rubber
|
||||
stamp: `default-deny-baseline-render`'s golden came back **byte-identical** (its fixture is not
|
||||
forward-auth), and the other three gained only the one CNP, port 44180, ns monitoring.
|
||||
- Toolbox gate `./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`: **rc 3 — UNDETERMINED,
|
||||
NOT rc 0, and I am not reporting it as green.** Every stage that ran is green
|
||||
(spec-immutability, approval-policy, decisions-log, lint, l0, render-golden, l1); `l1` went
|
||||
FAIL -> pass once the goldens were regenerated. The gate's own words: "Stages that did not run:
|
||||
gitea-settings" — skipped for want of `GITEA_SETTINGS_TOKEN`, which is an environment gap on
|
||||
every local run and unrelated to this change. L2/L3 conformance is a `[warn]`, not a not-run
|
||||
stage: UNVERIFIED for all 8 touched requirements (R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01
|
||||
R-NET-05 R-NET-06 R-NET-07 R-PLAT-02), no cluster.
|
||||
- `--emit-waves` was **not** run (it re-onboards `vm-demo` + `keycloak`); only the per-service
|
||||
render path was used, so no wave kustomization changed.
|
||||
|
||||
## This PR needs a human approval (sensitive path) — MEASURED, not predicted
|
||||
|
||||
CI run #230 on PR #49, head `f9446be3`: **`MERGE GATE: BLOCKED`, and `approval-policy` is the ONLY
|
||||
failing stage.** Read from the job log, not from the status tick:
|
||||
|
||||
[skip] stage spec-immutability: SKIPPED (not run) <- sibling spec repo absent in CI; expected
|
||||
[ ok ] stage decisions-log: pass
|
||||
[ ok ] stage lint: pass
|
||||
[ ok ] stage l0: pass
|
||||
[ ok ] stage render-golden: pass
|
||||
[ ok ] stage l1: pass
|
||||
[fail] stage approval-policy: FAIL
|
||||
[ ok ] stage gitea-settings: pass <- could NOT run locally; green in CI
|
||||
[skip] stage conformance: SKIPPED (not run)
|
||||
|
||||
sensitive paths touched:
|
||||
conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml
|
||||
conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml
|
||||
conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml
|
||||
|
||||
Exactly the three regenerated goldens, nothing else. `l1` and `render-golden` both pass in CI,
|
||||
which is the independent confirmation that the regenerated goldens are right rather than merely
|
||||
self-consistent with my local run.
|
||||
|
||||
One local-reproduction note worth recording, because it wasted a step: running
|
||||
`PR_NUMBER=49 SETTINGS_API_TOKEN=... ci/approval-policy.sh` with the credential from
|
||||
`git credential fill` reports `could not determine PR author — BLOCKING`. That is an **artifact**,
|
||||
not a finding — `~/.git-credentials` holds a *password*, and `Authorization: token <password>`
|
||||
returns 401 `user does not exist [uid: 0]`. The stage needs a real API token. I nearly reported
|
||||
that 401 as the CI failure; the job log is what settled it.
|
||||
|
||||
Regenerating the L1 goldens means this branch touches `conformance/**`, which is in
|
||||
`sensitive_paths` in `infra/gitea/approval-policy.json` ("the tests and the harness; changing them
|
||||
changes what green MEANS"). So `ci/approval-policy.sh` will require an approving review from a
|
||||
non-author, and the local gate **cannot** see that: it reports `approval-policy: green` because
|
||||
offline it evaluates N/A.
|
||||
|
||||
The golden update is not optional — a renderer change that adds an object necessarily drifts the
|
||||
goldens that pin the renderer's output — but it does mean the gate correctly refuses to
|
||||
self-approve this one. **An agent must not clear this with `/approve-override`**, even though the
|
||||
`gitea-admin` account holds `owner`: that is precisely the self-approval the stage exists to
|
||||
prevent. The operator posts the override as a PR *review* of type "Comment", or approves normally.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the scrape now succeeds.** This is a git-only change and nothing is applied until Flux
|
||||
reconciles `main`. The 5 CNPs have never existed on the cluster, so `up == 1` for those targets
|
||||
is **UNVERIFIED** — it is a prediction from the measured drop signature, not an observation.
|
||||
After merge, confirm `up{container="oauth2-proxy"}` goes to 1 for all 6 targets and that the 11
|
||||
alerts resolve. `send_resolved: true` is set, so expect ~11 resolved emails.
|
||||
- **That the CNP `endpointSelector` matches pihole's pods.** It selects
|
||||
`app.kubernetes.io/name: pihole`, identical to the shape of `pihole-allow-gateway-ingress`
|
||||
which demonstrably works for 4180 — so this is inference from a working sibling, not a
|
||||
measurement of the new object.
|
||||
- **`crafty` is still unobserved and will keep firing `OAuth2ProxyGateUnobserved`.** It is
|
||||
hand-managed under `platform/crafty`, outside the renderer, and has **no `--metrics-address`
|
||||
at all** — so there is no port to open and this change cannot help it. Separate work.
|
||||
- **Whether 44180 is reachable in the Cilium data path once allowed.** Only the policy was
|
||||
reasoned about; no L2 probe was run (CI has `vars.CLUSTER_AVAILABLE` unset, so L2/L3 did not
|
||||
run here either — the gate proves the L0/L1 offline subset only).
|
||||
- **Why `OAuth2ProxyGateUnobserved` started at 2026-10-04 06:44** rather than 10-02 with
|
||||
TargetDown. Alertmanager did not restart (TargetDown's `startsAt` is still 10-02) and the rule's
|
||||
`for` is 1h, so neither explains it. Something changed on the monitoring side that morning which
|
||||
I did not identify. It does not affect this fix.
|
||||
- I did not re-run a port-forward to 44180 to prove the endpoint serves *today*; I confirmed the
|
||||
flag and named port on the live pod spec. The 200/10208-byte measurement is podmonitor.yaml's,
|
||||
from 2026-10-01.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: fix/plex-unblock-app-tier
|
||||
title: Unblock the app tier — my Certificate stalled apps-wave-0, and the advertised https URL had no servable certificate
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**I broke the app tier with PR #48 and this reverts the part that did it.** Two faults, both mine,
|
||||
both measured after the merge:
|
||||
|
||||
1. **Kyverno blocks cert-manager in app namespaces.** The temporary private-key Secrets
|
||||
(`plex-vip-<random>`) were denied by `secret-provenance/secrets-must-be-flux-managed`:
|
||||
|
||||
resource Secret/plex/plex-vip-rp4qj was blocked due to the following policies
|
||||
secret-provenance:
|
||||
secrets-must-be-flux-managed: Secrets in application namespaces must be delivered by
|
||||
Flux from SOPS-encrypted git ... or be exempt controller-generated Secrets.
|
||||
|
||||
The policy matches every Secret in a namespace labelled
|
||||
`homelab.keithjackson.dev/app-namespace: true` and excludes only Secrets labelled
|
||||
`homelab.keithjackson.dev/secret-managed-by: controller` or carrying a Flux label. **The `plex`
|
||||
namespace has that label; the `gateway` namespace — where the working wildcard cert lives — does
|
||||
NOT.** That asymmetry is why the existing Certificate was never affected and mine was, and I did
|
||||
not check it. cert-manager's temp key Secrets carry neither exempting label, and `secretTemplate`
|
||||
cannot label them (the keymanager controller creates them, not the Certificate template). Result:
|
||||
**no CertificateRequest, Order or Challenge was ever created.**
|
||||
|
||||
2. **Flux health-checks the Certificate — guarding the pod was not enough.** `optional: true` did
|
||||
exactly what it was designed to do: Plex rolled and ran fine without the Secret (verified). But
|
||||
`apps-wave-0` is `wait: true`, so Flux waits on every applied object with a known health type:
|
||||
|
||||
health check failed after 5m0.045564329s: timeout waiting for:
|
||||
[Certificate/plex/plex-vip status: 'InProgress']
|
||||
|
||||
apps-wave-0 never went Ready, and apps-wave-1/-2 plus everything downstream of platform-config
|
||||
sat in `DependencyNotReady` behind it. I reasoned about the pod and missed the object.
|
||||
|
||||
A third, independent problem the same PR introduced: **the advertised URL was `https://` with no
|
||||
servable certificate.** Plex answers 32400 with its `*.<serverhash>.plex.direct` cert, which cannot
|
||||
cover `plex-vip.${BASE_DOMAIN}` — the precise failure mode of the outage earlier that day.
|
||||
|
||||
- **`apps/wave-0/plex-cert.yaml` DELETED**, and `plex-cert.yaml` removed from
|
||||
`apps/wave-0/kustomization.yaml` `resources`, so Flux prunes the stuck Certificate and the health
|
||||
gate clears. **Deleted rather than merely unregistered** — my first attempt kept the file as
|
||||
"tracked but not onboarded" (the shape `keycloak.yaml` and `vm-demo.yaml` use) and **the repo's own
|
||||
gate rejected it**: `tls-lifecycle-automated` (R-TLS-02) fails any Certificate in the tree that no
|
||||
Flux path reconciles — *"nothing reconciles it, so a deleted Certificate/issuer/controller would
|
||||
stay deleted until a human re-applied it."* That pattern is legal for a Deployment and not for a
|
||||
TLS object, which is a distinction I had not known.
|
||||
- **`catalog/render.py`:** the now-dangling `plex-cert` EXTRA_UNITS entry removed.
|
||||
- **The re-enable recipe is recorded at the call site** — in `catalog/plex.yaml` beside the scheme
|
||||
it is coupled to — rather than in the deleted file: the Kyverno exemption, the non-gating
|
||||
Kustomization, and the `https` flip, as one change.
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → **`http://`**`plex-vip.${BASE_DOMAIN}:32400`, explicitly
|
||||
temporary, with the restore condition written at the call site.
|
||||
|
||||
**Everything else from PR #48 is kept and working** — the hostname design, the PiHole split-horizon
|
||||
answer, the `LanNetworksBandwidth` hook, the `optional` schema field, and the TLS env (inert without
|
||||
the Secret, so re-enabling is additive).
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-04, read-only, after the #48/#50 merges.
|
||||
|
||||
- **The stall and its exact cause**: the health-check timeout message above, and the full dependency
|
||||
cascade (`cilium` → "dependency 'flux-system/flux-system' revision is not up to date", everything
|
||||
else `DependencyNotReady`). The root `cilium` wait was a normal revision-gated cascade; the
|
||||
*Certificate* was the real blocker.
|
||||
- **The Kyverno denial text, verbatim**, from the cert-manager controller log, repeating every ~30s
|
||||
across at least 15 distinct Secret names.
|
||||
- **The policy's own structure**, read from `platform/kyverno/admission/secret-provenance.yaml`:
|
||||
match = Secrets in `app-namespace: true` namespaces; exclude = `secret-managed-by: controller` OR
|
||||
a `kustomize.toolkit.fluxcd.io/name` label; validate = unconditional `deny`.
|
||||
- **The namespace-label asymmetry**: `plex` carries
|
||||
`homelab.keithjackson.dev/app-namespace: "true"`; `gateway` does not.
|
||||
- **cert-manager's controller ServiceAccount is `cert-manager`** in namespace `cert-manager` — the
|
||||
identity a future exemption should name.
|
||||
- **`optional: true` worked**: the new pod (`plex-c4f87c84d-zgjq5`) is `1/1 Running` with no Secret
|
||||
present, and `/config/certs` was never created.
|
||||
- **The postStart hook RAN and took effect** — the thing the whole hook was written for:
|
||||
`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is now set in the live `Preferences.xml`, and
|
||||
`customConnections="https://plex-vip.home.keithjackson.dev:32400"` was applied from the env. The
|
||||
certificate prefs are correctly **ABSENT**, i.e. the hook's "no bundle → leave them alone" guard
|
||||
behaved as designed.
|
||||
- **PiHole's split-horizon answer is correct**, asked of `pihole-0` directly:
|
||||
`plex-vip.home.keithjackson.dev -> 192.168.2.24`, alongside `plex -> .20` and `crafty-mc -> .22`,
|
||||
and the live `/etc/pihole/dnsmasq.conf` carries
|
||||
`address=/plex-vip.home.keithjackson.dev/192.168.2.24`. **This corrects my own earlier check**,
|
||||
which read `.20` and which I nearly reported as a failure — that test resolved via CLUSTER DNS
|
||||
(CoreDNS) from inside a pod and never consulted PiHole at all.
|
||||
- **The gate caught my first attempt and I fixed it rather than working around it.** Keeping the
|
||||
file unregistered produced `MERGE GATE: BLOCKED` (rc 1) with
|
||||
`CHECK FAIL (tls-lifecycle-automated): 1 of 612 assertions failed`. After deleting the file and
|
||||
the dangling EXTRA_UNITS entry: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1` and `approval-policy` all **pass** — the rc 3 being only the two known
|
||||
environmental skips (`gitea-settings`, `conformance` L2/L3). **Not green. Reported as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Whether this actually unblocks the tier.** Unreconciled at time of writing. The reasoning is
|
||||
that pruning the Certificate removes the only failing health check, but I have not seen
|
||||
apps-wave-0 go Ready.
|
||||
- **Whether `http://` restores client connectivity.** PiHole resolves the name and Plex serves 32400
|
||||
(both measured), but I have not watched a Plex client complete a connection over the advertised
|
||||
URL, and I did not re-check whether plex.tv publishes it.
|
||||
- **THE REMOTE BADGE — the symptom that started this.** `LanNetworksBandwidth` is set, but the log
|
||||
still tags traffic `Allowed Network (WAN)`: **82 WAN, 0 LAN**, with entries continuing after the
|
||||
pref was written. So either Plex needs a restart for it to take effect, or that log tag is not
|
||||
driven by this pref at all. **I do not know which, and the user-visible badge is the only real
|
||||
test** — it is in the Plex client, which I cannot see.
|
||||
- **The Hubble flow export is NOT active.** The cilium agents are still the pre-merge pods, so the
|
||||
`filePath: stdout` question is still open and no flow has been exported. The Cilium rollout — and
|
||||
its LAN-DNS risk, since prod-3 holds the Gateway, PiHole-DNS and Plex VIPs — is still AHEAD.
|
||||
- **Whether a cert-manager exemption is the right shape**, or whether it widens
|
||||
`secret-provenance` more than the operator wants. It is `platform/kyverno/**`, a sensitive path,
|
||||
and deliberately not attempted here.
|
||||
- **Whether the pruned Certificate leaves residue** — I did not check for orphaned
|
||||
`plex-vip-<random>` Secrets or CertificateRequests after the prune. Note Kyverno DENIED every one
|
||||
of those Secrets, so there may be nothing to clean, but that is an inference, not a check.
|
||||
- **Whether `tls-lifecycle-automated` would also have objected to the Certificate living in a
|
||||
`wait: false` Kustomization** (the recommended re-enable shape). It demands a reconciling Flux
|
||||
path, which that provides, but I did not test it.
|
||||
- **The unencrypted advertised URL.** `http://` is tolerable only while remote access does not work
|
||||
(no WAN forward). Nothing enforces that assumption, and nothing will complain if `http` is still
|
||||
there the day the port-forward appears.
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: feat/plex-vip-hostname-and-lan-networks
|
||||
title: Plex — one advertised hostname for local and remote, its own LE certificate, and a LAN definition so LAN clients stop being reported as remote
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Two operator-reported symptoms, after the 2026-10-04 "server missing from the account" fix
|
||||
(decisions/2026-10-04-plex-advertise-plexdirect.md) had restored publishing:
|
||||
|
||||
1. **LAN clients were shown as Remote.** Root cause, measured: `LanNetworksBandwidth` was **ABSENT**,
|
||||
so Plex had no definition of a local network and classified *everything* as WAN — **468 requests
|
||||
logged `Allowed Network (WAN)` and ZERO `(LAN)`**, including Seerr dialling from inside the
|
||||
cluster. `allowedNetworks` (the `ALLOWED_NETWORKS` env) only waives **auth**; it does not make a
|
||||
client local. Two different settings, and only one of them has an env hook.
|
||||
2. **The advertised URL was a single-purpose, fragile value.** The previous fix advertised
|
||||
`https://<dashed-ip>.<serverhash>.plex.direct:32400`, which works, but hard-codes the VIP *and*
|
||||
the server's certificate hash into a cluster-var — two values nothing could keep in sync.
|
||||
|
||||
**Operator design adopted (2026-10-04): ONE hostname, `plex-vip.${BASE_DOMAIN}`, serving both
|
||||
audiences by split-horizon DNS** — PiHole answers it with `${PLEX_L4_VIP}` on the LAN, Cloudflare
|
||||
answers it publicly with the WAN address, NATed to the same VIP.
|
||||
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → `https://plex-vip.${BASE_DOMAIN}:32400`. New
|
||||
`lifecycle.postStart` hook (level-triggered, same idiom as pihole's and qbittorrent's) that
|
||||
re-asserts on **every** start: `LanNetworksBandwidth`, and the three `customCertificate*` prefs
|
||||
after converting the PEM to the PKCS#12 bundle Plex requires. New `PLEX_TLS_CRT`/`PLEX_TLS_KEY`
|
||||
env from the `plex-vip-tls` Secret, both `optional: true`.
|
||||
- **`apps/wave-0/plex-cert.yaml` (new, hand-written):** a cert-manager `Certificate` for exactly
|
||||
`plex-vip.${BASE_DOMAIN}` from ClusterIssuer `letsencrypt-prod`. Non-catalog because the schema has
|
||||
no field for a Certificate. A single name, not the wildcard: the wildcard's Secret lives in
|
||||
`gateway` and Secrets are not readable across namespaces, and re-requesting `*.${BASE_DOMAIN}`
|
||||
would burn a duplicate-certificate slot for no benefit.
|
||||
- **`schemas/servicedefinition.schema.json` → 1.11.0:** adds `optional` to
|
||||
`env.valueFrom.secretKeyRef` / `configMapKeyRef`. **This is load-bearing, not tidiness.** The
|
||||
Certificate must be issued into the `plex` namespace, and that namespace is created by the
|
||||
rendered `apps/wave-0/plex.yaml` itself — so it cannot be issued in an earlier tier and lands in
|
||||
the *same* reconcile as the workload. With hard refs, Plex would sit in
|
||||
`CreateContainerConfigError` until a certificate issued, and `apps-wave-0` is `wait: true` with
|
||||
`apps-wave-1` dependsOn it, so a slow or failed DNS01 challenge would have **stalled the whole app
|
||||
tier** — the hazard `catalog/plex.yaml` already documents for its GPU resource.
|
||||
- **`catalog/pihole.yaml`:** `address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}` added to
|
||||
`FTLCONF_misc_dnsmasq_lines`. Required, not cosmetic: measured 2026-10-04,
|
||||
`plex-vip.${BASE_DOMAIN}` resolved to **`192.168.2.20`** (the Gateway VIP) via the zone wildcard,
|
||||
and a connect to `:32400` there returned **`http=000`**.
|
||||
- **`clusters/base/cluster-vars.yaml` + `clusters/prod/kustomization.yaml`:** `PLEX_ADVERTISE_URL`
|
||||
**deleted** — the hostname makes it unnecessary, which removes the cert-hash fragility outright.
|
||||
`PLEX_L4_VIP`'s note now records its second consumer (pihole).
|
||||
- **`catalog/render.py`:** `plex-cert` EXTRA_UNITS entry; **`apps/wave-0/kustomization.yaml`:**
|
||||
`plex-cert.yaml` hand-added (NOT via `--emit-waves`, which would also onboard `keycloak` and
|
||||
`vm-demo` — the pre-existing drift recorded on 2026-10-03).
|
||||
|
||||
**A correction to my own earlier reasoning, recorded because it was wrong in the handoff log.** The
|
||||
previous entry asserted a bare-IP URL "can NEVER validate" and treated a SAN match as a
|
||||
*requirement*. The operator pointed out it is not: `httpsRequired=0` and `secureConnections` default
|
||||
to Preferred, so clients can fall back to cleartext. The measurements still stand (a validating
|
||||
client did fail, and real clients did send `certificate unknown`) — but the correct framing is that a
|
||||
matching certificate keeps the remote leg **encrypted**, not that it is required for Plex to
|
||||
function. That is why this branch obtains a real certificate for the advertised name rather than
|
||||
choosing the name to suit Plex's built-in one.
|
||||
|
||||
**SENSITIVE PATHS — touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK until an
|
||||
approving review (or the operator's `/approve-override <reason>` posted as a PR *review* of type
|
||||
"Comment"). Deliberately not self-posted: CLAUDE.md §1.**
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-04, read-only. Secrets per CLAUDE.md §6: prefs were read by name-scoped grep,
|
||||
the server token was used inside the pod and never printed, and no `Preferences.xml` was dumped.
|
||||
|
||||
- **The LAN-classification root cause, directly:** `LanNetworksBandwidth=<ABSENT>`, and request
|
||||
tagging of **468 × `Allowed Network (WAN)` vs 0 × `(LAN)`** — including `10.0.2.33` (Seerr), an
|
||||
in-cluster pod. This is the measurement the whole hook rests on.
|
||||
- **All four prefs the hook sets EXIST on this build**, read from the server's own `/:/prefs`
|
||||
(token-authenticated, output filtered to ids): `LanNetworksBandwidth`, `customCertificatePath`,
|
||||
`customCertificateKey`, `customCertificateDomain`, all `type="text"`. I had earlier been unable to
|
||||
confirm this (an unauthenticated `GET /:/prefs` returns **401**) and said so rather than assuming.
|
||||
- **There is NO env path for these settings** — the reason the hook exists. Grep of the image's
|
||||
entire init surface: `setPref` is called for exactly six prefs (`MachineIdentifier`,
|
||||
`ProcessedMachineIdentifier`, `PlexOnlineToken`, `customConnections`, `allowedNetworks`,
|
||||
`TranscoderTempDirectory`) and the only `PLEX_*` env it reads are CLAIM/CLAIM_FILE/UID/GID/
|
||||
UPDATE_CHANNEL and five INFO/HOME vars. No `PLEX_PREFERENCE*` of any kind; zero files mention
|
||||
`LanNetworks`.
|
||||
- **The DNS gap was real:** `plex-vip.${BASE_DOMAIN}` resolved to `192.168.2.20` (Gateway VIP), and
|
||||
`https://192.168.2.20:32400` returned `http=000` (nothing listening). PiHole's wildcard is why.
|
||||
- **`openssl` is present in the image** (`/usr/bin/openssl`, 3.0.13) — the hook's PEM→PKCS#12
|
||||
conversion has a tool to run.
|
||||
- **The issuer exists and works:** ClusterIssuer `letsencrypt-prod` `READY=True` (28d), and the
|
||||
existing `gateway/wildcard-home` Certificate is `Ready`/not-expired, which is what makes a DNS01
|
||||
issuance for a new name in this zone a reasonable expectation.
|
||||
- **The renderer's substitution guard caught a real defect in my own work**, which is recorded
|
||||
because it is the second time this class of bug has appeared here: my hook's explanatory comment
|
||||
contained brace-wrapped names, and `assert_substitution_safe` refused to emit
|
||||
(`'PX' is not a declared cluster-vars key — envsubst will replace it with the EMPTY STRING`).
|
||||
Reworded; the rendered hook now contains exactly one brace-wrapped name, `BASE_DOMAIN`, verified
|
||||
by parsing the emitted manifest.
|
||||
- **A `set -u` bug I introduced and then removed.** With `optional: true` the TLS env vars are
|
||||
genuinely UNSET when no certificate has issued; under `set -u` the first `[ -n ... ]` would abort
|
||||
the hook, and a postStart hook exiting non-zero makes kubelet KILL the container — the exact
|
||||
failure `optional` exists to prevent. Verified by parsing the rendered manifest that no `set -u`
|
||||
**command** remains (only two mentions in comments).
|
||||
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||
**pass**; the rc 3 is the two known environmental skips (`gitea-settings` — no local
|
||||
`SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE` unset). **Not green. Reported as
|
||||
rc 3.** `l0` passing is the specific proof the schema edit admits `optional` and still validates
|
||||
every catalog file; `render-golden` passing is the proof catalog and committed goldens agree.
|
||||
- **Only plex and pihole goldens moved.** No other service's manifest changed, and no service was
|
||||
silently onboarded.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME, ENTIRELY. Nothing in this branch has been observed working.** It is unreconciled:
|
||||
no Certificate exists in prod, `plex-vip` still resolves to the Gateway VIP, `LanNetworksBandwidth`
|
||||
is still absent, and the hook has NEVER RUN. Every claim above is about the inputs, not the result.
|
||||
- **Whether the certificate will actually ISSUE.** DNS01 for a new name in this zone is inferred from
|
||||
the wildcard's success, not demonstrated. If the Cloudflare credential or solver is scoped in a way
|
||||
that fails for this name, Plex will start (by design) with no TLS and the advertised `https://` URL
|
||||
will be unservable — i.e. **this branch could leave the advertised URL broken in a new way**. First
|
||||
reconcile must be watched.
|
||||
- **Whether `openssl pkcs12 -export` produces a bundle PLEX ACCEPTS.** OpenSSL 3 defaults to AES-256
|
||||
+ SHA-256, which some consumers reject. Untested against Plex. If it is rejected, Plex may fail to
|
||||
load its TLS listener — the hook guards against a *missing* file, NOT against a present-but-
|
||||
unreadable one. **This is the single largest unverified risk in the change.**
|
||||
- **Whether the certificate prefs take effect without a restart.** Plex may need to re-initialise its
|
||||
listener after `customCertificatePath` changes. If so, the first reconcile applies the prefs and
|
||||
the SECOND start serves the cert. Converges either way, but the first start may look wrong.
|
||||
- **Whether `LanNetworksBandwidth` actually flips the Remote badge.** It is the correct setting by
|
||||
Plex's own documentation and the measured absence explains the symptom exactly, but I did not set it
|
||||
live and observe a client reclassify.
|
||||
- **SNAT makes the LAN definition coarser than it looks.** With `externalTrafficPolicy: Cluster`, Plex
|
||||
sees the ingress NODE's address for everything arriving via the VIP, so once `10.0.0.0/8` and
|
||||
`192.168.0.0/16` are LAN, GENUINE REMOTE clients arriving that way will ALSO be classified local
|
||||
and remote bandwidth limits will not bind on them. This is the metering consequence already
|
||||
accepted on 2026-10-03, now made stronger rather than weaker. Not measured.
|
||||
- **The public Cloudflare record for `plex-vip` is off-repo and absent from this branch**, as is the
|
||||
WAN tcp/32400 port-forward. Remote access cannot work until the operator creates both; nothing here
|
||||
can verify either, and this repo has no off-LAN vantage point.
|
||||
- **The private key is exposed in the container's environment** (`/proc/1/environ`) because the schema
|
||||
and renderer support no secret VOLUME — measured: `storage` accepts only
|
||||
[class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`, and
|
||||
no rendered app mounts a Secret. A volume would be the better shape; adding secret-volume support
|
||||
is the clean follow-up and I did not do it.
|
||||
- **The PKCS#12 passphrase is a hardcoded literal** (`plexvip`) in the rendered manifest. It protects
|
||||
nothing — Plex needs it in cleartext in `customCertificateKey` and it sits beside the bundle on the
|
||||
same PVC — but it is worth knowing it is not a secret and was never treated as one.
|
||||
- **Four places must agree and nothing enforces it:** `ADVERTISE_IP`, the hook's
|
||||
`customCertificateDomain`, `plex-cert.yaml`'s `dnsNames`, and PiHole's answer. No gate stage or
|
||||
conformance test checks that they match, or that PiHole's `plex-vip` answer still equals
|
||||
`${PLEX_L4_VIP}`.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: docs/agents-work-in-their-own-workspace
|
||||
title: One workspace per agent run — the shared checkout is now documented as off-limits
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Agents have been doing their work in the operator's checkout at
|
||||
`/var/home/keith/source/home-network-k3s-impl` and clobbering each other. Nothing in the tree
|
||||
told them not to: worktrees appeared in CLAUDE.md only as §3's *gate workarounds*, i.e. as an
|
||||
awkward edge case, and `decisions/README.md` asserted worktree isolation as a fact about how
|
||||
agents already behave rather than as a rule they must follow. Nowhere was the rule stated.
|
||||
|
||||
Documentation only — no code, no manifests, no pipeline logic.
|
||||
|
||||
- **`CLAUDE.md`: new `## 0. Work in your OWN workspace. Never in the shared checkout.`**
|
||||
Placed before §1 so the section numbers every other file and handoff entry cites (§1, §2b,
|
||||
§3, §7) all keep their meaning. It gives the three commands (`git fetch origin main`;
|
||||
`git worktree add -b <type>/<slug> <path outside the repo> origin/main`; `cd`), an explicit
|
||||
do-not list for the shared checkout (no `checkout`/`switch`, no edits, no
|
||||
`add`/`commit`/`stash`/`restore`, no removing others' worktrees), a measurement table, the
|
||||
"put it outside the repo root" rule, cleanup, what to do if you are already standing in the
|
||||
shared checkout with edits, and what a PM must put in a brief it writes for another agent.
|
||||
- **`CLAUDE.md` intro** now says "Start with §0", with the reason: it is the rule you can
|
||||
break before you have read anything else.
|
||||
- **`CLAUDE.md` §3** retitled "Running the gate from a worktree" and reframed as the normal
|
||||
path rather than an edge case, since §0 now makes every gate run a worktree run. Its list
|
||||
grew from two breakages to three: `spec-immutability`'s skip is *avoidable*, not merely
|
||||
tolerated, if the `ENGINE` shim also mounts the sibling spec repo read-only and passes
|
||||
`-e SPEC_REPO=/home-network-k3s`.
|
||||
- **`CLAUDE.md` §4** gained "One branch, one workspace, one PR".
|
||||
- **`README.md`**: a fourth non-negotiable rule ("No work in a shared checkout"), and
|
||||
"Working on this repo" now opens with how to get out of the checkout before describing the
|
||||
dev container that runs in it.
|
||||
- **`decisions/README.md`**: the worktree-isolation sentence now cites §0 as the requirement
|
||||
instead of describing it as something agents happen to do.
|
||||
|
||||
I did this work the way the new §0 requires — a worktree at
|
||||
`<session scratchpad>/wt-workspace-rule`, outside the repo root, branched from `origin/main`,
|
||||
with the shared checkout left on the branch and with the untracked files I found in it.
|
||||
|
||||
## What I verified
|
||||
|
||||
Every number in the new §0 table was measured in the shared checkout on 2026-10-05, not
|
||||
recalled:
|
||||
|
||||
| Claim | Command | Value |
|
||||
|---|---|---|
|
||||
| shared HEAD is yanked between branches | `git reflog \| grep -c 'checkout: moving'` | **107** |
|
||||
| it is not sitting on `main` | `git worktree list` | `feat/pm-agent-issue-to-pr` |
|
||||
| other runs' uncommitted work is in it | `git status --porcelain` | **2** trees: `infra/agent/` (22:04), `platform/pm-agent/` (22:09) — `stat -c %y` |
|
||||
| its `main` is stale | `git rev-parse main` vs `origin/main` | `4386d14` vs `22c94ef`, **2 behind** |
|
||||
| worktrees registered / already merged | `git worktree list --porcelain` + `git merge-base --is-ancestor <b> main` | **42** branches, **31** merged |
|
||||
| nested worktrees are a gate hazard | `grep -rl '\.claude' conformance/tests platform/monitoring/tests` | **9** scripts carry an explicit exclusion |
|
||||
| the 18× double-count | read `platform/monitoring/tests/run.sh:115-123` | in-tree comment, observed 2026-09-12 |
|
||||
|
||||
**The failure mode was observed live, mid-session, not just reconstructed.** The two untracked
|
||||
trees I measured at 22:04 and 22:09 were committed at 22:20 by another run — `5334abe`
|
||||
`feat(pm-agent): an in-cluster AI Program Manager...`, 1,000+ lines across `infra/agent/`,
|
||||
`clusters/`, `catalog/`, `apps/`, `docs/plans/` and its own `decisions/` entry — onto the shared
|
||||
checkout's checked-out branch. Measured after the fact: the shared checkout's HEAD moved
|
||||
`22c94ef` → `5334abe` and its working tree went clean between my first `git status` and my last,
|
||||
with `git log -1` naming a commit that is not mine. That is §0's "shared HEAD" row happening
|
||||
during the session that wrote §0, and it is now cited in the doc.
|
||||
|
||||
**A claim of mine was wrong and is corrected in the doc.** I had it recorded that
|
||||
`git worktree remove` is blocked by the agent sandbox. Provoked rather than assumed: created
|
||||
a throwaway detached worktree in the scratchpad and removed it — **rc 0**, and
|
||||
`git worktree list` no longer shows it. §0 therefore tells agents to remove their own, and
|
||||
scopes the "blocked" note to what it actually covers: *other* sessions'
|
||||
`.claude/worktrees/agent-*`, some of which are additionally `locked` on git's own terms. The
|
||||
`rework/<pr-number>` escape hatch is now scoped to someone else's stale worktree holding a
|
||||
branch, which is a real and separate problem (31 of them). Both of my own worktrees in this
|
||||
session were removed cleanly at rc 0 after pushing.
|
||||
|
||||
`spec-immutability` is avoidable-not-tolerated: taken from
|
||||
`decisions/2026-10-01-render-selftest-runs-in-gate.md`, which records
|
||||
`stage spec-immutability: pass` under the two-mount shim plus `-e SPEC_REPO=/home-network-k3s`.
|
||||
|
||||
**Gate — `rc 3`, UNDETERMINED, and I am not calling it green.** Pinned toolbox, from this
|
||||
worktree, `DEV_KUBECONFIG=/dev/null` and an `ENGINE` shim bind-mounting both the main
|
||||
checkout's `.git` (at its own host path) and the sibling spec repo read-only with
|
||||
`-e SPEC_REPO=/home-network-k3s`:
|
||||
|
||||
ENGINE=<two-mount shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh' # rc 3
|
||||
|
||||
| stage | result |
|
||||
|---|---|
|
||||
| spec-immutability | **pass** — it genuinely RAN; the second mount is what makes it run from a worktree |
|
||||
| approval-policy | pass, but **N/A**: `event='none'`. It cannot see a PR locally, so this green says nothing. See below |
|
||||
| decisions-log | pass — the three required headings and the frozen-archive pin |
|
||||
| lint · l0 · render-golden · l1 | pass (L1: 3 offline checks, 0 failures, 0 skips, 0 unwired) |
|
||||
| gitea-settings | **SKIPPED** — `SETTINGS_API_TOKEN` unset; it exists only in CI |
|
||||
| conformance (L2/L3) | SKIPPED — `CLUSTER_AVAILABLE!=true`; the decision notes no cluster-needing requirement was touched |
|
||||
|
||||
`gitea-settings` is the *only* stage that did not run, and it cannot be made to run from a
|
||||
workstation, so rc 3 is the ceiling for any local run — not a defect in this branch. The
|
||||
first gate attempt returned rc 3 for a different and real reason (`HEAD is not ahead of
|
||||
origin/main`: I had not committed yet, so the touched-requirement set was empty by default
|
||||
rather than by verification); it was re-run after committing, and the table above is the
|
||||
post-commit run. §3 now records both expected skips, because it previously named only
|
||||
`spec-immutability`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **GATE: UNVERIFIED at the time of writing.** The pinned-toolbox result for this branch and
|
||||
its exit code are reported in the PR, not predicted here. **A local rc 3 is not green** and
|
||||
will not be described as one.
|
||||
- **CI gate: RED, measured — `MERGE GATE: BLOCKED`, one failing stage.** Run 245 on PR #56,
|
||||
head `b988da8b`, 1m47s. The only failure is `approval-policy`: *"a sensitive change lacks a
|
||||
non-author approval"*, `sensitive paths touched: CLAUDE.md`, author `gitea-admin`. Every other
|
||||
stage passed — `decisions-log`, `lint`, `l0`, `render-golden`, `l1`, and `gitea-settings`
|
||||
(which can only run in CI, and reported `required_approvals = 0`,
|
||||
`dismiss_stale_approvals = true`). `spec-immutability` SKIPPED in CI (no sibling spec repo in
|
||||
the CI workspace — the inverse of the local run, where the two-mount shim made it pass) and
|
||||
`conformance` SKIPPED. **Note `gate / conformance (pull_request)` reports status `success`
|
||||
with description "Has been skipped"** — CLAUDE.md §2b's trap, live on this very PR. Nothing
|
||||
in this branch is broken; the red is the structural human gate, below. *Measured at
|
||||
`b988da8b`; this entry was then amended to record it, so the head SHA in the PR is newer than
|
||||
the run quoted here and CI re-ran on the amended commit.*
|
||||
- **This PR is a sensitive-path PR and is CI-RED on `approval-policy` until a human acts.**
|
||||
`CLAUDE.md` is in `infra/gitea/approval-policy.json`'s `sensitive_paths` ("the delivery
|
||||
contract agents follow"), so the stage requires an approving review from someone who is not
|
||||
the author, or an `/approve-override <reason>` posted by an administrator **as a PR review
|
||||
of type "Comment"**, not in the plain comment box. `ci/approval-policy.sh` run locally
|
||||
reports N/A (it cannot see a PR), so the local gate cannot catch this and a green local run
|
||||
says nothing about it. **I have not posted an override and must not** — CLAUDE.md §1 forbids
|
||||
an agent clearing this on its own PR even though `gitea-admin` holds `owner` and could.
|
||||
- **Nothing here is enforced.** §0 is prose. There is no gate stage that detects work done in
|
||||
the shared checkout, no check that a worktree is outside the repo root, and no check that a
|
||||
branch was cut from `origin/main`. Every other rule in CLAUDE.md that agents kept breaking
|
||||
(`DECISIONS-LOG.md` appends, the three handoff headings) ended up as a gate stage, and that
|
||||
is the honest follow-up here. I did not write one: the ask was documentation, and an
|
||||
enforcement stage lands in `ci/**`, which is itself a sensitive path and deserves its own PR.
|
||||
- **I did not clean up the 31 stale merged worktrees or the 35 nested under
|
||||
`.claude/worktrees/`.** They are other sessions' workspaces; removing them is an operator
|
||||
action, and §0 explicitly tells agents not to remove worktrees they did not create.
|
||||
- **I did not touch the untracked `infra/agent/` and `platform/pm-agent/` trees** sitting in the
|
||||
shared checkout. They are another run's in-flight work. Whose, and whether they are wanted,
|
||||
is unknown to me.
|
||||
- **`docs/plans/ssd-etcd-execution-runbook.md:18` still points at a `.claude/worktrees/` path**
|
||||
inside the repo. Left as-is deliberately: that runbook is marked COMPLETED/historical and
|
||||
describes the now-decommissioned dev cluster, so the path is a record of what was done, not
|
||||
an instruction. It does mean a grep for in-repo worktree paths is not yet clean.
|
||||
- **No runtime, cluster or Flux behaviour was touched or checked.** This branch changes three
|
||||
Markdown files; `kubectl` was not run.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: feat/cilium-roll-out-on-config-change
|
||||
title: Cilium config changes were silently inert — roll the agents when cilium-config changes, which also finally activates the Hubble exporter
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**A merged Cilium config change did nothing, and everything reported success.** The Hubble flow
|
||||
exporter (PR #50, merged 2026-10-04) wrote its settings into `cilium-config` and never took effect.
|
||||
|
||||
| Measured 2026-10-05, over a day after the merge | |
|
||||
|---|---|
|
||||
| HelmRelease `cilium` | `Ready=True`, `UpgradeSucceeded`, *"Helm upgrade succeeded for release kube-system/cilium.v6"* |
|
||||
| `cilium-config` keys | `hubble-export-file-path: stdout`, `hubble-export-allowlist: {"destination_port":["32400"]} {"source_port":["32400"]}` — **all present** |
|
||||
| cilium agent pods | **all three still the PRE-MERGE pods**, `startTime 2026-10-04T07:11Z` |
|
||||
| exported flow lines in agent stdout | **0, 0, 0** |
|
||||
| Flux Kustomizations | all `Ready=True` |
|
||||
|
||||
Cause: the chart's `rollOutCiliumPods` defaults to **`false`** (cilium 1.20.1 `values.yaml`: *"Roll
|
||||
out cilium agent pods automatically when configmap is updated"*), so Helm rewrites the ConfigMap and
|
||||
leaves the DaemonSet's pod template untouched — and **cilium-agent reads that ConfigMap only at
|
||||
startup**. The feature was merged, believed delivered, green everywhere, and doing nothing at all.
|
||||
|
||||
That is the same class of failure this repo already guards against elsewhere: a green signal sitting
|
||||
over something that never ran.
|
||||
|
||||
- **`platform/cilium/helmrelease.yaml`** (authoritative): `rollOutCiliumPods: true`.
|
||||
- **`platform/cilium/values.yaml`** (reference mirror) and
|
||||
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
|
||||
the same, so a bootstrap render does not differ from what Flux reconciles.
|
||||
|
||||
**The cost is real and is the point.** Every future Cilium config change now triggers a cluster-wide
|
||||
agent rollout instead of waiting for someone to remember a manual restart. That is strictly more
|
||||
honest — the change applies when merged, or it fails visibly — but it makes config edits disruptive
|
||||
by default, so they want a quiet moment. Mitigation already in place:
|
||||
`updateStrategy.rollingUpdate.maxUnavailable: 1` means the DaemonSet will not proceed past an unready
|
||||
agent, so a bad render costs one node rather than the cluster.
|
||||
|
||||
**Merging this is itself one rollout**, because adding the ConfigMap checksum annotation changes the
|
||||
pod template — and that rollout is what finally activates the Hubble exporter.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Every row of the table above**, read from the live HelmRelease status, the live `cilium-config`
|
||||
ConfigMap, the agent pods' `startTime`, and a grep for `"flow"` in each agent's stdout.
|
||||
- **The chart value name, default and semantics**, quoted from cilium **v1.20.1**'s own
|
||||
`values.yaml` — the version actually running here (`quay.io/cilium/cilium:v1.20.1`), not a guess
|
||||
from another release: `rollOutCiliumPods: false` with the comment *"Roll out cilium agent pods
|
||||
automatically when configmap is updated."*
|
||||
- **All three value files carry the key** and both YAML files still parse, with
|
||||
`spec.values.rollOutCiliumPods` resolving to `true`.
|
||||
- **The blast-radius mitigation is genuinely present**, not assumed:
|
||||
`updateStrategy.rollingUpdate.maxUnavailable: 1` is already set in the HelmRelease, with a long
|
||||
existing comment explaining that a CNI rollout has no healthy-peer fallback.
|
||||
- **THE MECHANISM IS CONFIRMED BY CONTRAST, not just reasoned.** The cgroup fix (PR #42,
|
||||
2026-10-03) DID take effect: both the DaemonSet and the running pod mount
|
||||
`cilium-cgroup hostPath=/sys/fs/cgroup`, matching what the HelmRelease declares. The difference is
|
||||
exactly the one claimed above — #42 changed the POD TEMPLATE (a volume hostPath), which rolls the
|
||||
DaemonSet on its own, while #50 changed ONLY the ConfigMap, which does not. It also explains the
|
||||
agents' `startTime 2026-10-04T07:11Z`: that was #42's rollout, and nothing has rolled them since.
|
||||
So the inertness is specific to ConfigMap-only changes, which is precisely what this flag fixes.
|
||||
- **Flux is healthy before this change** — every Kustomization `Ready=True`, so this lands on a clean
|
||||
graph rather than into an existing cascade.
|
||||
- **Toolbox gate**: recorded in the PR.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME, ENTIRELY. Unreconciled.** No agent has rolled, no flow has been exported. This
|
||||
change is an argument that the rollout will happen, not evidence that it did.
|
||||
- **Whether `filePath: stdout` actually works** — still the open question from #50, and it cannot be
|
||||
answered until the agents restart. If the literal is not special-cased, the agent may create a
|
||||
FILE named `stdout` and nothing reaches Loki: a silent no-op, not a crash. Fallback documented in
|
||||
#50: keep the chart's default path and add a `loki.source.file` target to Alloy with a hostPath
|
||||
mount. **Check for flow lines in the agent logs immediately after the rollout.**
|
||||
- **THE ROLLOUT'S IMPACT ON LAN DNS IS UNMEASURED AND IS THE MAIN RISK.** The node holding the L2
|
||||
lease for the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP loses them briefly as it cycles.
|
||||
`maxUnavailable: 1` bounds it to one node at a time, and BPF maps persist across an agent restart,
|
||||
but I have not measured how long a VIP is unreachable during a lease move, nor whether the two
|
||||
PiHole replicas cover the gap. Merge it at a quiet moment.
|
||||
- **Whether enabling this is the operator's preferred trade.** The alternative — leaving it `false`
|
||||
and restarting by hand — keeps config edits non-disruptive at the cost of them being silently
|
||||
inert. I judged silent inertness the worse failure, but it is a judgement, not a measurement.
|
||||
- **Whether anything ELSE in `cilium-config` is currently inert.** I checked the one other recent
|
||||
Cilium change and it is fine (see below), but I did not audit every key against the running
|
||||
agents' actual state.
|
||||
- **`hubble.relay.rollOutPods` / `hubble.ui.rollOutPods`** are left at their `false` defaults. Both
|
||||
have the same silently-inert property for their own ConfigMaps; not addressed here.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: docs/oauth2-sidecar-scrape-verified
|
||||
title: The gate-sidecar scrape is verified working on prod — and four places in the tree still said it wasn't
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Documentation only. No manifest, no rendered output, no behaviour.
|
||||
|
||||
PR #49 merged as `21c88b0` and the fix works, but four places in the tree still asserted the
|
||||
*pre-fix* state as current — in the present tense, in capitals, in the files a reader would consult
|
||||
first. This is the stale-documentation failure mode this repo has been burned by twice already
|
||||
(`git push origin main` in the runbooks, "append to DECISIONS-LOG.md" in the old handoffs), so:
|
||||
|
||||
- **`platform/oauth2-proxy/podmonitor.yaml`** — the banner read *"READ THIS BEFORE BELIEVING THE
|
||||
TARGETS ARE UP: THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY"* and *"SO THE TARGETS BELOW WILL
|
||||
READ `up == 0` UNTIL AN INGRESS ALLOWANCE LANDS"*. Both now false, and the first one actively
|
||||
instructs the reader to disbelieve a correct `up == 1`. Replaced with a `STATUS: THE SCRAPE WORKS`
|
||||
block carrying the post-fix numbers; the diagnosis is kept below it under an explicit `HISTORY`
|
||||
heading, because it is why the fix is shaped the way it is. Added the drop fingerprint to
|
||||
recognise if it regresses — `lastScrapeDuration` pinned at *exactly* the `scrapeTimeout` with
|
||||
`context deadline exceeded`, versus 1–6 ms when healthy.
|
||||
- **`catalog/render.py:159`** — *"THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED … Do not
|
||||
read this flag as 'metrics are working'."* Rewritten to enumerate the three pieces a working
|
||||
scrape needs and which PR landed each. The warning is **kept, deliberately**: the flag alone
|
||||
really is insufficient, and "1 without 3" is exactly what caused the 63.7h outage. Only the
|
||||
"not yet scraped" claim was false.
|
||||
- **`catalog/render.py:1365`** — the sidecar arg comment, same correction, one line.
|
||||
- **`platform/oauth2-proxy/README.md`** — its two observability subsections are *dated*
|
||||
("measured 2026-09-30", "Update 2026-10-01"), so they are honest records rather than lies. But a
|
||||
reader hits the stale body long before any correction, so a `> SUPERSEDED` pointer now sits
|
||||
directly under the heading, and an `### Update 2026-10-05` section at the end carries the
|
||||
before/after table, the TSDB series counts and the alert outcome.
|
||||
|
||||
Two substantive errors fixed while there, not just tense:
|
||||
|
||||
1. The README's *"To close the gap for an app, three things are needed"* listed **a Service port
|
||||
and a ServiceMonitor**. Wrong — a PodMonitor targets pod ports directly, which is the whole
|
||||
reason no Service exposes 44180 and the renderer needed no Service change. Following that
|
||||
paragraph would have sent someone to edit `catalog/render.py`'s Service rendering for no reason.
|
||||
2. `podmonitor.yaml` ended by deferring the fix to *"the catalog/renderer, which is in flight on
|
||||
another branch"* — that branch does not exist and never did; the work happened in PR #49. It now
|
||||
names `r_monitoring_ingress_policy` directly, and a new `CRAFTY` section explains why crafty is
|
||||
the one namespace this file cannot help.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The fix itself, on prod 2026-10-05, read-only** (this is the evidence the docs now quote):
|
||||
Flux applied `21c88b0` through the real path — `apps-wave-0` `lastAppliedRevision` at 21:37:01Z,
|
||||
`apps-wave-1` at 21:37:46Z, in dependency order — and all five `<app>-allow-monitoring-scrape`
|
||||
CNPs exist, each `ports=['44180']`, `from=[['monitoring']]`.
|
||||
- **All 6 targets `up == 1`**, `health=up`, `lastError=''`, `lastScrapeDuration` 0.001–0.006s —
|
||||
against 10.0s and `context deadline exceeded` before.
|
||||
- **`oauth2_proxy_*` is in the TSDB for these namespaces for the first time:** 5 metric names,
|
||||
148 series; `sum by (namespace) (oauth2_proxy_requests_total)` = qbittorrent 35072, radarr 23560,
|
||||
sonarr 1284. (Queried with `count by (__name__)` plus a matcher, not label-values.)
|
||||
- **Alerts:** all 5 `TargetDown` and 5 of 6 `OAuth2ProxyGateUnobserved` resolved; one series
|
||||
remains, `OAuth2ProxyGateUnobserved{namespace="crafty"}`, firing.
|
||||
- **The rendered output is byte-identical.** `catalog/render.py` changed only in comments, so this
|
||||
branch touches no `apps/` file and no golden. Confirmed by `render-golden` and `l1` both passing
|
||||
with no regeneration, and by `git status` showing nothing under `apps/`.
|
||||
- **This branch touches no sensitive path**, unlike PR #49: `platform/oauth2-proxy/**`,
|
||||
`catalog/**` and `decisions/**` are none of them in `infra/gitea/approval-policy.json`. So it
|
||||
should merge on a green gate with no human approval — the opposite of the PR it documents, and
|
||||
the reason the two were kept apart.
|
||||
- `render.py --selftest`: PASS.
|
||||
- Toolbox gate: see the handoff's own rule — reported below rather than claimed here.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **I did not re-measure the 63.7h figure or the ~22 emails/day rate**; both are carried forward
|
||||
from the 2026-10-04 measurements, now quoted inside the docs as historical. The 15-day
|
||||
`query_range` that established "one episode, not flapping" was run once, on 2026-10-04.
|
||||
- **`oauth2_proxy_requests_total` is still absent for jackett and pihole.** I assert in the README
|
||||
that this is because the counter is not created until the gate serves its first request. That is
|
||||
the documented behaviour of a Prometheus counter with no initialisation, and it is consistent
|
||||
with the other three namespaces having traffic — but I did not force a request through jackett's
|
||||
gate and watch the series appear. If it is still absent after real use, that is a finding.
|
||||
- **The auth-churn rule is still unwritten.** The README now says it is "writable rather than
|
||||
decorative"; that is a claim about the metrics being present, not a rule anyone has authored or
|
||||
unit-tested. The thing the 2026-09-30 incident actually needed does not exist yet.
|
||||
- **crafty remains unobserved.** I read its container spec on 2026-10-05 and confirmed no
|
||||
`--metrics-address`; I did not attempt the fix, and nothing here changes its alert.
|
||||
- **I did not audit the whole tree for other stale "metrics are not scraped" claims** beyond the
|
||||
four sites a targeted grep found (`not yet reachable`, `UNTIL AN INGRESS`, `up == 0`,
|
||||
`not reachable by Prometheus` and neighbours). `runbooks/oauth2-proxy-auth-failures.md` is
|
||||
described in the README as "the by-hand substitute meanwhile" and I left it alone — the by-hand
|
||||
log procedure still works and is not wrong, but whether it should now be rewritten against the
|
||||
new metrics is unexamined.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: fix/plex-resolver-independent-advertise
|
||||
title: Plex down LAN-wide — the advertised name existed only in PiHole, so clients that bypass it landed on the router's %D cert
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**I took Plex down for the whole LAN.** Reported symptom: every internal client showed a certificate
|
||||
error with `%D` as the subject name and could not connect.
|
||||
|
||||
**Root cause: the advertised connection depended on our own resolver.** `ADVERTISE_IP` was
|
||||
`http://plex-vip.${BASE_DOMAIN}:32400` — a name that exists ONLY in PiHole's split-horizon answers
|
||||
and in no public zone. A Plex client resolves the advertised host *itself*, with whatever resolver it
|
||||
happens to use, and on a real LAN that is frequently not PiHole: browsers ship DoH on by default,
|
||||
phones have "private DNS", guest/IoT devices take the router's DNS. Those clients could not resolve
|
||||
the name, the SOHO router answered the failed lookup with **itself**, and they all landed on the
|
||||
router's web UI.
|
||||
|
||||
| Measured 2026-10-05 | Result |
|
||||
|---|---|
|
||||
| `192.168.2.1:443` (router, LAN side) | `C = ZZ, ST = Somewhere, O = %D342e7e6b, CN = %D` |
|
||||
| `136.51.115.252:443` (router, WAN side) | the same `%D` cert |
|
||||
| PiHole **pihole-0** | `plex -> 192.168.2.20`, `plex-vip -> 192.168.2.24` ✅ |
|
||||
| PiHole **pihole-1** | identical ✅ (both behind the `.23` VIP) |
|
||||
| Gateway `.20:443` cert | `CN = *.home.keithjackson.dev`, valid to 2026-12-12 ✅ |
|
||||
| `http://192.168.2.24:32400/identity` | `200` ✅ |
|
||||
| Plex's published connections | all port **32400** — nothing on 443 |
|
||||
|
||||
**The DNS was right; the dependency on it was the bug.** PiHole answered correctly from both
|
||||
replicas the entire time. And because nothing Plex advertises listens on 443, the clients had plainly
|
||||
left the Plex path altogether and were talking to the router.
|
||||
|
||||
**The fix: go back to `<dashed-ip>.<serverhash>.plex.direct`, which is RESOLVER-INDEPENDENT.** This
|
||||
is Plex's own mechanism and the reason it exists — `plex.direct` is a public zone that returns the
|
||||
private address embedded in the label, so *every* resolver (PiHole, the router, 1.1.1.1, a DoH
|
||||
browser) answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no split-horizon
|
||||
entry, no Cloudflare record and no certificate work, because the wildcard cert Plex already serves on
|
||||
32400 covers exactly that name.
|
||||
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP` →
|
||||
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`, with the
|
||||
resolver-independence requirement written at the top of the block as the first thing to check
|
||||
before anyone changes this field again.
|
||||
- **PiHole's `plex-vip` answer is KEPT.** It is harmless, costs nothing, and is useful for reaching
|
||||
the VIP by name from a LAN shell. It is simply not what Plex advertises.
|
||||
|
||||
**Why not keep the nicer hostname.** It cannot be made correct for clients that bypass PiHole: a
|
||||
public record for it resolves to the WAN address, and with no NAT hairpin on this router a LAN client
|
||||
sent there fails too. The name is right only for PiHole users and wrong for everyone else, and there
|
||||
is no configuration of it that is right for both. `plex.direct` is right for both.
|
||||
|
||||
**The honest cost, restated rather than buried:** the value embeds both the VIP and the server's
|
||||
certificate hash as a literal — envsubst cannot dash an IP and nothing in the tree can derive the
|
||||
hash, so nothing checks that they still agree. That fragility is exactly why the hostname was tried.
|
||||
It is accepted because an unresolvable name is a *worse* failure: silent, LAN-wide, and it points
|
||||
users at their router.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-05, read-only.
|
||||
|
||||
- **Every row of the table above**, including the router's `%D` cert on both its LAN and WAN sides —
|
||||
which is what identifies the router as what the clients were reaching.
|
||||
- **THE FIX ITSELF, with full TLS validation (no `curl -k`)**: the plex.direct name resolves to
|
||||
`192.168.2.24` and `/identity` returns **200, TLS verified**. Re-checked today, not carried over
|
||||
from yesterday's measurement.
|
||||
- **Both PiHole replicas are identical and correct**, with the same five `address=` lines live in
|
||||
`/etc/pihole/dnsmasq.conf`, and both IPs present behind the `pihole-dns-tcp`/`-udp` endpoints. I
|
||||
checked only pihole-0 at first and deliberately went back for pihole-1, because a split-brain pair
|
||||
would have produced exactly this intermittent-looking symptom.
|
||||
- **The cluster was never the problem:** all eight LoadBalancer VIPs still assigned, L2
|
||||
announcements intact (prod-3 holds `.20`/`.23`/`.24`), `wildcard-home` Certificate `Ready=True`,
|
||||
and PR #51 had reconciled (`customConnections="http://plex-vip.home.keithjackson.dev:32400"` was
|
||||
live, confirming the bad value was in effect).
|
||||
- **`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is still set**, so the postStart hook survived
|
||||
the roll and keeps re-asserting.
|
||||
- **The Cilium rollout from #50 STILL has not happened** — agents are unchanged since
|
||||
2026-10-04T07:11, so the Hubble export is inactive and `filePath: stdout` remains unproven.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME. Unreconciled.** No client has been observed connecting after this change. The
|
||||
argument is that the advertised name now resolves for every resolver and serves a matching
|
||||
certificate — both measured — but I have not seen a Plex client complete a connection, and I
|
||||
cannot see the user's clients at all.
|
||||
- **THE CLIENT-SIDE RESOLVER PATH IS INFERRED, NOT OBSERVED.** I could not query a public resolver
|
||||
from here — my attempt returned NXDOMAIN even for a `plex.direct` name that certainly resolves, so
|
||||
the probe was broken (egress policy or a missing tool) and I discarded it rather than report it.
|
||||
The chain "client bypasses PiHole → NXDOMAIN → router answers with itself → `%D`" is the only
|
||||
explanation consistent with all the measurements, but the NXDOMAIN-hijack step in particular is
|
||||
deduced from the router serving `%D` on its LAN address, not from watching a lookup.
|
||||
- **Which clients bypass PiHole, or how many.** If in fact every client uses PiHole, then my
|
||||
explanation is wrong and something else sent them to the router — in which case the next thing to
|
||||
check is whether the clients were using `plex.home.keithjackson.dev` (port 443, the Gateway) rather
|
||||
than an advertised connection at all.
|
||||
- **Whether plex.tv will re-publish promptly** after the value changes, and whether stale connection
|
||||
lists are cached on clients. A client holding the old list may keep failing until it refreshes.
|
||||
- **Mixed content.** The previous `http://` value would also have been blocked by the browser for
|
||||
anyone using `https://app.plex.tv`; that is fixed incidentally by returning to `https`, but I did
|
||||
not test it.
|
||||
- **The Remote badge — the symptom that began this whole sequence — remains unconfirmed.**
|
||||
`LanNetworksBandwidth` is set, but Plex still tags traffic `Allowed Network (WAN)`. Unchanged and
|
||||
still unexplained.
|
||||
- **`ManualPortMappingMode="1"` with an absent port** is still there, untouched.
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: feat/vpn-remote-access-wireguard
|
||||
title: Remote-access VPN — WireGuard on udp/51820, hostNetwork because a pod identity would 403 every Gateway hostname
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
New platform component `platform/vpn-ingress/`: a **WireGuard** remote-access VPN so the operator's
|
||||
Android / Linux / Windows / macOS devices can dial in from anywhere on the internet and behave like
|
||||
machines on the LAN — full reach to `192.168.2.0/24` plus internet egress out the house WAN.
|
||||
|
||||
This is the **opposite direction** to the existing `platform/vpn-egress` (pods *out* through PIA,
|
||||
ADR-0015). They share nothing but the word "VPN"; both READMEs now say so, and `README.md`'s layout
|
||||
block labels the two.
|
||||
|
||||
**Transport: WireGuard, and the security argument is the handshake's failure mode, not "it is
|
||||
encrypted".** WireGuard's first handshake message carries a MAC keyed with the responder's own
|
||||
public key, so a sender that does not already know it gets **no reply at all** — no error, no reset,
|
||||
no banner. From the internet, udp/51820 is indistinguishable from a closed port: nothing to
|
||||
brute-force, no version string, no amplification. That is what makes an unfiltered WAN port
|
||||
defensible here, and it is why there is deliberately no `loadBalancerSourceRanges`-style allowlist
|
||||
(which would also break roaming clients). It also has first-party clients on all four named
|
||||
platforms, which OpenVPN (needs a CA + per-device certs + a CRL) and IKEv2 do not.
|
||||
|
||||
**`hostNetwork: true`, and this is THE decision — forced by a measured property of this cluster.**
|
||||
A pod-networked WireGuard server must SNAT client traffic to its own pod IP (Cilium drops a packet
|
||||
whose source is not the endpoint's address), so every remote client would have arrived wearing *that
|
||||
pod's* security identity. `decisions/2026-10-04-plex-advertise-plexdirect.md` measured what that
|
||||
costs: a request to the shared Gateway VIP **from inside a pod returns `403 Access denied` from
|
||||
Envoy for all 16 Gateway hostnames**, because Cilium's Gateway preserves the original source
|
||||
identity and enforces the *destination's* ingress policy, and every app admits `reserved:ingress`
|
||||
plus named peers — never an arbitrary pod. So the obvious build would have produced a tunnel that
|
||||
came up healthy, showed connected peers, routed packets, and 403'd every cluster web UI — and
|
||||
`*.home.keithjackson.dev` would not even have resolved, because `apps/wave-0/pihole-dns.yaml`'s
|
||||
ingress policy deliberately omits the `cluster` entity. In the host netns there is no pod identity
|
||||
to carry: forwarded traffic is SNATed to the node address and arrives as `host`/`remote-node`,
|
||||
byte-for-byte what a LAN client looks like. Three further problems vanish with it: `ip_forward` is
|
||||
already 1 on these nodes (so no `--allowed-unsafe-sysctls` kubelet change — it is *not* a safe
|
||||
sysctl); no LoadBalancer VIP, so no L2-announcement lease lottery and no `externalTrafficPolicy`
|
||||
trade-off; and no double encapsulation, which is what makes `MTU = 1420` simply correct.
|
||||
|
||||
`hostNetwork` is permitted by `restrict-host-namespaces`' own stated carve-out (it matches
|
||||
`app-namespace=true` only, and names Cilium/node-exporter as the precedent); `namespace.yaml` is
|
||||
deliberately unlabelled. **The cost is stated, not buried:** a hostNetwork pod has no Cilium
|
||||
endpoint, so **no NetworkPolicy constrains this workload**. `networkpolicy.yaml` says that at
|
||||
length rather than shipping an `allow world → 51820` rule that would read as the control and
|
||||
enforce nothing; the real boundary is WireGuard's peer authentication.
|
||||
|
||||
**A DaemonSet, not a Deployment.** The WAN forward names one node address by hand, off-repo (same
|
||||
shape as the plex tcp/32400 forward). A floating single replica would silently invalidate it on
|
||||
every reschedule; an identical listener on all three nodes makes **failover a one-line router
|
||||
change** with no cluster work and no re-issued client profiles. A pinned LoadBalancer VIP was the
|
||||
considered alternative and was rejected: it reintroduces the L2 lease, SNAT and an extra node hop
|
||||
into the one path whose failure mode is "I am away from home and cannot get in".
|
||||
|
||||
**Authorisation is split so that it stays reviewable.** Peer **public** keys live in
|
||||
`platform/vpn-ingress/peers.yaml` in **plaintext** — a public key grants nothing by itself, and this
|
||||
makes "who can get into my house" a PR diff with `git log` as its audit trail. The only real secret,
|
||||
the server's private key, is the `wg-server-key` Secret, **created out of band by the operator**: an
|
||||
agent must not mint a long-lived private key (CLAUDE.md §6), and a key that passed through a session
|
||||
transcript would have to be treated as compromised. The two halves are joined only at pod start, by
|
||||
an init container, into a `medium: Memory` emptyDir — so the assembled config holding the key never
|
||||
touches a node's disk and dies with the pod.
|
||||
|
||||
**There are no peers yet, and that is the correct initial state**, for the same reason. The VPN ships
|
||||
INERT-BUT-HEALTHY: a zero-peer interface listens and refuses every handshake.
|
||||
|
||||
Files:
|
||||
|
||||
- `platform/vpn-ingress/` (new): `namespace.yaml`, `peers.yaml` (the plaintext peer register),
|
||||
`daemonset.yaml` (hostNetwork listener + config assembly), `networkpolicy.yaml` (namespace
|
||||
baseline, with the honest caveat), `kustomization.yaml`, `README.md` (the design record).
|
||||
- `clusters/base/vpn-ingress.yaml` (new): its own **non-gating post-platform** Flux Kustomization
|
||||
(`dependsOn platform-config`, `wait:false`). Non-gating is load-bearing rather than conventional
|
||||
here: the DaemonSet *cannot* start until the operator creates the server key, so in the gating
|
||||
`platform` tier an unfinished manual step would have stalled every apps wave on merge.
|
||||
- `clusters/base/kustomization.yaml`: the new tier added.
|
||||
- `clusters/base/cluster-vars.yaml`: new `WG_CLIENT_CIDR` (`10.90.0.0/24`), declared next to
|
||||
`LAN_CIDR` because it is the one range that must be collision-checked against every other; the
|
||||
comment enumerates the five it avoids. **No prod override, deliberately** — unlike every VIP, this
|
||||
range is private to the tunnel, so the same value is correct in both environments and a prod patch
|
||||
would imply otherwise. Substitution inventory updated.
|
||||
- `infra/ansible/roles/node_baseline/defaults/main.yml`: `wireguard` added to
|
||||
`node_baseline_kernel_modules`. The pod has `NET_ADMIN` but deliberately **not** `SYS_MODULE`, so
|
||||
it cannot autoload the module — it CrashLoops with a clear error rather than quietly degrading to
|
||||
userspace wireguard-go (which would need `/dev/net/tun`, a device a hostPath mount cannot actually
|
||||
grant, as the `/dev/dri` attempt proved).
|
||||
- `infra/vpn-ingress/new-peer.sh` (new): enrols a device. Generates the keypair on the operator's
|
||||
machine, writes a 0600 profile **outside** the checkout (it refuses to write into the repo),
|
||||
prints a QR code for phones, and prints only the public `[Peer]` stanza. It also refuses a
|
||||
duplicate address, because two devices on one address does not error anywhere — the server routes
|
||||
to whichever handshook last, so both appear to work intermittently.
|
||||
- `runbooks/vpn-remote-access.md` (new) + its `kustomization.yaml` generator entry + `README.md`
|
||||
index line: the three manual steps, the four off-LAN verification checks, revocation, and why no
|
||||
connection alert exists.
|
||||
- `README.md`: `vpn-ingress` added to the layout, with the egress/ingress distinction spelled out.
|
||||
|
||||
**THREE MANUAL STEPS REMAIN AND THE VPN DOES NOT WORK UNTIL ALL THREE ARE DONE** (all off-repo by
|
||||
necessity; `runbooks/vpn-remote-access.md`): run `node_baseline` to load the kernel module, create
|
||||
the `wg-server-key` Secret, and forward WAN udp/51820 to one node address on the SOHO router.
|
||||
|
||||
**SENSITIVE PATHS — this PR touches `clusters/**` (three files), so `ci/approval-policy.sh` will
|
||||
BLOCK it.** It needs the operator's `/approve-override <reason>` posted as a PR **review** of type
|
||||
"Comment". I have deliberately not posted it: CLAUDE.md §1 forbids an agent clearing this on its own
|
||||
PR, and the agent account holds `owner`, so the prohibition is a rule, not a limit.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, read-only, 2026-10-05, plus the offline gate.
|
||||
|
||||
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||
**pass**. The rc 3 is two environmental skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`)
|
||||
and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3, not as pass.**
|
||||
Run from an isolated git worktree with both CLAUDE.md §3 workarounds applied
|
||||
(`DEV_KUBECONFIG=/dev/null` plus an `ENGINE` shim bind-mounting the main checkout's `.git` and
|
||||
the sibling spec repo) — which is why `spec-immutability` is **green here and skipped in CI**,
|
||||
where the runner has no spec checkout. `approval-policy` passing locally is an offline N/A, not a
|
||||
verdict; the real verdict is the CI run below.
|
||||
- **The CI gate ran on PR #55 (run 244, head `1a67fc1b`) and `approval-policy` is the ONLY failing
|
||||
stage** — read from the job log, not from the red tick. Verbatim: "This PR changes the pipeline or
|
||||
the security posture it rests on, and has NO approving review from anyone other than its author
|
||||
('gitea-admin')", naming exactly the three files I predicted:
|
||||
`clusters/base/cluster-vars.yaml`, `clusters/base/kustomization.yaml`,
|
||||
`clusters/base/vpn-ingress.yaml`. Every other stage that ran reported green —
|
||||
`gitea-settings`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1`. So the branch is blocked
|
||||
on the operator's `/approve-override <reason>` (posted as a PR **review** of type "Comment") and
|
||||
on nothing else. **I have not posted it** — CLAUDE.md §1.
|
||||
- **Branch protection is live, measured by CI's own `gitea-settings` stage** (it has the token this
|
||||
workstation lacks): `required_approvals = 0`, `enable_status_check = true`,
|
||||
`status_check_contexts = ["gate / gate (pull_request)"]`, `block_admin_merge_override = true`,
|
||||
`enable_push = false`, `dismiss_stale_approvals = true`, and **1 runner online**. That is the §2b
|
||||
re-measurement of CLAUDE.md §2's table for this branch, not a restatement of it.
|
||||
- **`flux-postbuild-substitution` (L0) actually exercised the new wiring, and caught a real defect
|
||||
in it.** On the first run it FAILED, naming `platform/vpn-ingress/daemonset.yaml:118` and
|
||||
attributing it to "Flux Kustomization 'vpn-ingress' (clusters/base/vpn-ingress.yaml, path
|
||||
./platform/vpn-ingress)". Two things are proven by that message: the new tier's
|
||||
`postBuild.substituteFrom` declaration is correctly detected (so `${WG_CLIENT_CIDR}` will be
|
||||
substituted, not shipped literally), and the check reads comments as well as values — the hazard
|
||||
was a comment that spelled out the forbidden braced form literally while claiming not to use it.
|
||||
Fixed; the check now passes 444/444 with 0 hazards.
|
||||
- **The `wireguard` kernel module is available on these nodes by construction**: all three run
|
||||
Debian 13 (trixie) on kernel `7.1.8+deb13-amd64` (measured), and WireGuard has been in-tree since
|
||||
5.6, so it is present in the distro kernel and only needs loading.
|
||||
- **`net.ipv4.ip_forward = 1` is already written by `node_baseline`**
|
||||
(`roles/node_baseline/tasks/main.yml`, `/etc/sysctl.d/99-k3s-cilium.conf`), and **no kubelet
|
||||
carries `--allowed-unsafe-sysctls`** (grepped across `roles/k3s_server`, `roles/node_baseline` and
|
||||
`group_vars`: zero hits). That pair is what makes the host netns the only option that does not
|
||||
require a node-restart change.
|
||||
- **Cilium's datapath, measured from `cilium-config` rather than from the values file**:
|
||||
`enable-ipv4-masquerade=true`, `enable-bpf-masquerade=true`, `routing-mode=tunnel`,
|
||||
`tunnel-protocol=vxlan`, `ipv4-native-routing-cidr` EMPTY, Cilium `v1.20.1`. The vxlan underlay is
|
||||
where `MTU = 1420` comes from, and the empty native-routing CIDR is why pod→LAN is masqueraded
|
||||
today.
|
||||
- **`WG_CLIENT_CIDR` collides with nothing.** Pod CIDR `10.0.0.0/16` (`CLUSTER_POD_CIDR`), service
|
||||
CIDR `10.43.0.0/16` (measured: `kubernetes` Service clusterIP is `10.43.0.1`), the PIA tunnel's
|
||||
`10.72.192.0/18` (`platform/vpn-egress/README.md`), and both LANs `192.168.1.0/24` /
|
||||
`192.168.2.0/24`. `10.90.0.0/24` is outside all five.
|
||||
- **The egress-class label matters and is set to `internet`.** Read live:
|
||||
`CiliumEgressGatewayPolicy vpn-egress-steering` selects `podSelector:
|
||||
homelab.keithjackson.dev/egress-class: vpn` with **no namespace scope**, so `vpn` here would have
|
||||
steered every remote client's internet traffic out PIA.
|
||||
- **All three nodes are untainted** (`spec.taints: None` on each), so the DaemonSet covers the
|
||||
cluster with no tolerations.
|
||||
- **`new-peer.sh` was run end to end** against a throwaway server key in the scratchpad (deleted
|
||||
immediately after; it never existed in the repo). It rendered a correct profile — `Address
|
||||
10.90.0.7/32`, `DNS 192.168.2.23`, `Endpoint wan.home.keithjackson.dev:51820`, `MTU 1420`,
|
||||
`PersistentKeepalive 25`, `AllowedIPs 0.0.0.0/0` — at mode 0600, printed the matching public-key
|
||||
stanza, and both argument-validation paths (missing name, out-of-range octet) refuse correctly.
|
||||
The private key was redacted from every line I displayed.
|
||||
- **The endpoint name is resolver-independent, which is the specific trap this avoids.**
|
||||
`wan.${BASE_DOMAIN}` is an unproxied public Cloudflare A record kept current by
|
||||
`platform/externaldns/ddns.yaml` (checked every 5 min), so it resolves from a cellular connection
|
||||
— unlike any name PiHole answers, which a remote client cannot resolve until it is already
|
||||
connected. (This is the `plex.direct` lesson applied.)
|
||||
- **Velero does cover this namespace.** `platform/velero/helmrelease.yaml`'s `daily` schedule
|
||||
excludes only `monitoring` and `unifi`, so `vpn-ingress` and the `wg-server-key` Secret are
|
||||
captured off-cluster to R2. Read from the manifest, not from a backup listing.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE ACTUAL FIX. Nothing in this branch has been proven to carry a single packet.** The
|
||||
DaemonSet has never been reconciled, no `wg0` has ever existed, and no handshake has ever been
|
||||
attempted. Three blockers are off-repo and manual (kernel module, server key, WAN forward), and
|
||||
this branch removes none of them — it only makes them the *only* remaining work.
|
||||
- **Outside-in reachability, at all.** This repo has **no off-LAN vantage point**, which is exactly
|
||||
the gap the plex remote-access work hit. Step 6 of the runbook (a phone on cellular) is the only
|
||||
honest test and it has not been run.
|
||||
- **The in-pod `iptables` → host-netfilter interaction, which is my biggest single doubt.** Debian 13
|
||||
uses nf_tables; the container writes MASQUERADE/FORWARD rules into the **host's** tables via the
|
||||
image's own `iptables`. If that binary is the legacy backend while the host is nft, the rules can
|
||||
apply to a table the kernel does not consult — and the symptom would be the nastiest available:
|
||||
handshakes succeed, `wg show` looks perfect, and every forwarded connection hangs. **Check this
|
||||
first if the tunnel comes up but nothing is reachable:** `iptables-save -t nat | grep MASQUERADE`
|
||||
and `nft list table ip nat` on the node, from the host, not from the pod.
|
||||
- **Whether `allowPrivilegeEscalation: false` is compatible with the image's s6 init.** It is the
|
||||
correct posture and I set it, but this repo has already been bitten by `no-new-privileges`
|
||||
misbehaving (`platform/externaldns/ddns.yaml`'s README note). If the pod fails before
|
||||
`wg-quick` runs, that flag is the first thing to try flipping.
|
||||
- **Whether `NET_ADMIN` alone is sufficient.** Reasoned from what `wg-quick` does (create the link,
|
||||
set a sysctl, write two netfilter rules), not measured. If `iptables` complains, `NET_RAW` is the
|
||||
likely addition — deliberately not granted pre-emptively.
|
||||
- **The image's custom-config path.** `lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls124` (tag
|
||||
confirmed to exist and to be the current one, published 2026-10-01) is expected to bring up
|
||||
whatever is in `/config/wg_confs/` when `PEERS` is unset. That is its documented behaviour; it has
|
||||
not been run here, and the init container's assembly of that file has never executed.
|
||||
- **Whether the probe is right.** `wg show wg0` is asserted to exit non-zero when the interface is
|
||||
absent. Not executed.
|
||||
- **Cilium's reaction to a new `wg0` in the host netns.** `platform/cilium/values.yaml` does not pin
|
||||
`devices`, so Cilium auto-detects; whether it attaches to `wg0` — and whether that matters — is
|
||||
unknown. `wg` interfaces are already filtered out by `node_health_collect.py`, so something in
|
||||
the estate anticipates them, but that is not evidence about Cilium.
|
||||
- **Zero peers means the end-to-end authorisation path is untested**, including revocation. The
|
||||
first enrolment is also the first test of `peers.yaml`'s format being what `wg-quick` accepts.
|
||||
- **No peer-level observability exists.** An exporter publishing
|
||||
`wireguard_latest_handshake_seconds` is the top follow-up; until then "who is connected" is an
|
||||
on-demand `wg show` and nothing alerts on an unexpected session. Pod-down is covered only by
|
||||
kube-prometheus-stack's generic `KubePodNotReady` / `KubeDeploymentReplicasMismatch`, which I did
|
||||
not confirm fire for a DaemonSet in this namespace.
|
||||
- **The security consequence I am most keen for a human to review rather than take from me:** an
|
||||
enrolled device reaches the three k3s nodes themselves — apiserver 6443, kubelet 10250, SSH 22 —
|
||||
because "reach the entire local network" includes them. That is stated in the README and the
|
||||
runbook with the narrowing recipe, but it is a posture decision, not a verified fact, and it is
|
||||
the right thing to argue about on this PR.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: fix/vpn-runbook-ansible-command
|
||||
title: vpn-remote-access runbook — step 1 named a script that does not exist, and step 2 cited the wrong reason the key is gitignored
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Two defects in `runbooks/vpn-remote-access.md` (merged in #55), both authored by me, both reported
|
||||
by the operator the first time they tried to follow it.
|
||||
|
||||
**1. Step 1 named a command that does not exist.** It said:
|
||||
|
||||
cd infra/ansible && ./site.sh --tags system --limit k3s_prod
|
||||
|
||||
There is **no `site.sh` anywhere in this repo** (`find . -name site.sh` → nothing). The invocation
|
||||
was wrong in four separate ways at once, which is what makes it worth recording rather than just
|
||||
patching: wrong kind of thing (this repo drives Ansible with `ansible-playbook <playbook>`, never a
|
||||
wrapper script — `playbooks/site.yml` exists and is the full bring-up chain, not this); wrong
|
||||
playbook (the module work lives in `playbooks/node-baseline.yml`); wrong targeting flag (`--limit`
|
||||
instead of this repo's `-e k3s_hosts=...` convention, which `infra/ansible/README.md` "Targeting
|
||||
prod" documents for every playbook); and no toolbox wrapper, though Ansible exists only inside the
|
||||
pinned container and `ansible.cfg` is only read from `infra/ansible`.
|
||||
|
||||
Replaced with the real command, plus the three things about it that are load-bearing:
|
||||
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'cd /work/infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags system'
|
||||
|
||||
- `-e k3s_hosts=k3s_prod` — the playbook defaults to `k3s_servers`, which is the **dev** group;
|
||||
dev is decommissioned, so an unqualified run does nothing useful.
|
||||
- `--tags system`, never untagged — the two kernel-module tasks carry that tag, and an untagged run
|
||||
would also hit the `disks` tasks, which assert on block-device variables prod deliberately leaves
|
||||
as placeholders (the playbook's own header warns about this). `--tags system` is broader than the
|
||||
modules alone (sysctls, iscsid, qemu-guest-agent, swap masking) but each is idempotent and
|
||||
already in its desired state on a live node.
|
||||
|
||||
**2. Step 2 gave the wrong reason the server key is safe from git — and the right reason is
|
||||
fragile.** It told the operator to stage the key at `/work/.cache/server.key` "inside the repo's
|
||||
gitignored cache". `.cache/` is **not** gitignored: `git check-ignore -v .cache/serverkey.txt`
|
||||
matches nothing. The file was ignored only incidentally, by `.gitignore` line 11 `*.key` — so the
|
||||
path happened to be safe for a reason I had not identified, and **any operator who renamed it
|
||||
(`server.pem`, `wg-server`, `privatekey`) would have had a committable WireGuard private key sitting
|
||||
in the checkout.** The gate would not have caught it either; `no-plaintext-secrets` does not
|
||||
recognise a bare WireGuard key.
|
||||
|
||||
The step now states the real rule, marks the `.key` suffix as the thing doing the work, and gives
|
||||
the operator a verification they can run before trusting it (`git check-ignore -v` must print a
|
||||
rule, and `git status --short` must not list the file).
|
||||
|
||||
Nothing outside `runbooks/vpn-remote-access.md` changed; no manifest, no behaviour.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **`site.sh` does not exist**: `find . -name 'site.sh' -not -path './.git/*'` → no output.
|
||||
- **The real surface**: `infra/ansible/playbooks/` contains `node-baseline.yml` and `site.yml` (and
|
||||
11 others); `node-baseline.yml`'s own header documents both
|
||||
`-e k3s_hosts=k3s_prod` and the tag-scoping hazard. The `-e <playbook>_hosts=` convention is
|
||||
confirmed by `infra/ansible/README.md` §"Targeting prod" and by the existing
|
||||
`runbooks/node-memory-swap.md`, which invokes `ansible-playbook playbooks/node-health.yml -e
|
||||
node_health_hosts=...`.
|
||||
- **`k3s_prod` is a real inventory group**: `[k3s_prod:children]` in `inventory/hosts.ini`, with
|
||||
`[k3s_prod_primary]`/`[k3s_prod_secondary]`.
|
||||
- **`--tags system` covers the kernel-module tasks**: both "Persist kernel modules…" and "Load
|
||||
kernel modules now" carry `tags: [system]` in `roles/node_baseline/tasks/main.yml`.
|
||||
- **The gitignore claim, measured both directions**:
|
||||
`git check-ignore -v .cache/server.key` → `.gitignore:11:*.key`;
|
||||
`git check-ignore -v .cache/serverkey.txt` → no match. So the suffix is the only thing ignoring it.
|
||||
- **Every path the component's docs reference resolves**: all six relative markdown links in
|
||||
`platform/vpn-ingress/README.md` (checked from that file's own directory), and both Ansible
|
||||
playbook paths.
|
||||
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
|
||||
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; the rc 3 is the two
|
||||
environmental skips (`gitea-settings` — no local token; conformance L2/L3 — no `CLUSTER_AVAILABLE`).
|
||||
**Not green. Reported as rc 3, not as pass.**
|
||||
- **This branch touches NO sensitive path** (`runbooks/**` and `decisions/**` are not in
|
||||
`infra/gitea/approval-policy.json`), so unlike #55 it needs no `/approve-override`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The corrected command has not been RUN.** Ansible is an operator action, and this is the
|
||||
decisive gap: I have verified the playbook, the group, the tag and the flag all exist and are
|
||||
the documented convention, but not that the run succeeds end to end or that
|
||||
`lsmod | grep '^wireguard'` then reports the module. It is a better-evidenced command, not a
|
||||
proven one.
|
||||
- **Whether `--tags system` is entirely side-effect-free on a live prod node.** Each task it runs is
|
||||
idempotent by construction, but I did not dry-run it (`--check`) to confirm it reports zero
|
||||
changes beyond the module load.
|
||||
- **The rest of the runbook's commands past step 2** are unchanged and still unexercised — in
|
||||
particular `kubectl exec ds/wireguard -- wg show`, which cannot be reached while the pods sit in
|
||||
`Init` waiting on the server key.
|
||||
- **Whether any OTHER document I wrote in #55 contains an invented command.** I re-checked every
|
||||
file path and markdown link in the component (all resolve) and every script the docs tell an
|
||||
operator to run, but "the referenced file exists" is weaker than "the command is correct" — which
|
||||
is exactly the gap that produced `site.sh`.
|
||||
|
||||
## Post-merge state of #55, recorded here since its own entry predates the merge
|
||||
|
||||
Measured on live prod after #55 merged as `ad3f56e`:
|
||||
|
||||
- Flux applied it by the real path: Kustomization `vpn-ingress` `lastAppliedRevision:
|
||||
main@sha1:ad3f56e`, equal to the GitRepository's artifact revision, `Ready=True`.
|
||||
- All **18** Flux Kustomizations `Ready=True` — the new tier disturbed nothing, and `wait: false`
|
||||
behaved as intended (`vpn-ingress` is Ready while its own pods are not, so nothing downstream
|
||||
stalled on it).
|
||||
- **`hostNetwork` is confirmed in effect, not merely configured**: the three pods report pod IPs
|
||||
`192.168.2.10/.11/.12` — the node addresses, one per node.
|
||||
- **It fails closed exactly as designed**, with a legible cause: all three pods `Init:0/1`,
|
||||
`MountVolume.SetUp failed for volume "wg-server-key" : secret "wg-server-key" not found`, and no
|
||||
Secret in the namespace. The #55 entry's "the DaemonSet has never been reconciled" is superseded
|
||||
by this paragraph; its remaining unverified claims (the tunnel carrying a packet, the
|
||||
iptables/nftables backend question) still stand.
|
||||
+2
-1
@@ -26,7 +26,8 @@ The fix is the obvious one for concurrent appenders: **stop sharing a file**. Ea
|
||||
handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is
|
||||
nothing for git to merge and nothing to conflict. This scales to any number of parallel
|
||||
branches with zero coordination — which is the point, since the agents are deliberately
|
||||
isolated from each other in separate worktrees and cannot coordinate.
|
||||
isolated from each other in separate worktrees (required by `CLAUDE.md` §0) and cannot
|
||||
coordinate.
|
||||
|
||||
There is deliberately **no index file** and no counter. An index would be a shared
|
||||
mutable file — exactly the thing that just failed — and a sequence number would have
|
||||
|
||||
@@ -39,6 +39,12 @@ bpf:
|
||||
masquerade: true
|
||||
|
||||
# Flow observability / isolation audit (ADR-0002).
|
||||
# Roll the agents when cilium-config changes. Kept EQUAL to the Flux values
|
||||
# (platform/cilium/helmrelease.yaml), which carry the full rationale: the chart default of false
|
||||
# leaves the pod template untouched on a config change, and cilium-agent reads that ConfigMap only
|
||||
# at startup, so the change is silently inert until someone restarts the DaemonSet by hand.
|
||||
rollOutCiliumPods: true
|
||||
|
||||
hubble:
|
||||
enabled: true
|
||||
relay:
|
||||
|
||||
@@ -74,12 +74,23 @@ node_baseline_backports_kernel_packages:
|
||||
# kernel, and how do I undo it?" from the machine itself.
|
||||
node_baseline_backports_kernel_stamp: /etc/node-baseline-backports-kernel
|
||||
|
||||
# Modules written to /etc/modules-load.d/k3s-cilium-longhorn.conf (persistent) AND modprobe'd now.
|
||||
node_baseline_kernel_modules:
|
||||
- overlay
|
||||
- br_netfilter
|
||||
- iscsi_tcp
|
||||
- dm_crypt
|
||||
- nfs
|
||||
# Remote-access VPN (platform/vpn-ingress). The WireGuard DaemonSet creates its wg0 link in the
|
||||
# HOST netns with CAP_NET_ADMIN only -- it is deliberately NOT granted CAP_SYS_MODULE, so it
|
||||
# cannot autoload this itself: `ip link add wg0 type wireguard` fails outright when the module is
|
||||
# absent, and the pod CrashLoops. Loading it here is therefore a PREREQUISITE of that component,
|
||||
# not a nicety. In-tree since kernel 5.6, so Debian 13's 7.1.8 ships it and there is nothing to
|
||||
# install. Naming it here also means the kernel does the crypto: without the module a WireGuard
|
||||
# container's only fallback is slow userspace wireguard-go, which we do not use (and which would
|
||||
# need /dev/net/tun -- a device a hostPath mount cannot actually grant, as the /dev/dri attempt
|
||||
# proved: visible but EPERM from the device cgroup).
|
||||
- wireguard
|
||||
|
||||
# --- DATA DISKS (tasks/disks.yml, tag `disks`) ----------------------------------------
|
||||
# The SHAPE of a node's storage is DECLARED per environment, never inferred from whether
|
||||
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bash
|
||||
# =================================================================================================
|
||||
# new-peer.sh — enrol one device on the remote-access VPN (platform/vpn-ingress).
|
||||
#
|
||||
# Generates a WireGuard keypair ON THIS MACHINE, renders the device's client profile, optionally
|
||||
# prints a QR code for a phone, and prints the one `[Peer]` stanza to paste into
|
||||
# platform/vpn-ingress/peers.yaml. The private half NEVER leaves this machine: it is not sent to the
|
||||
# cluster, not committed, and not written to any log.
|
||||
#
|
||||
# WHY A SCRIPT AND NOT A RUNBOOK STEP. Three of these fields are easy to get wrong in ways that are
|
||||
# either silently insecure or silently broken, and all three are mechanical:
|
||||
# * AllowedIPs on the SERVER side must be a /32. On the server, AllowedIPs is simultaneously the
|
||||
# cryptokey routing table AND the inbound source filter, so a /24 here lets one device spoof
|
||||
# every other device's tunnel address.
|
||||
# * PersistentKeepalive must be set on the CLIENT. Without it the NAT mapping at the client's end
|
||||
# expires after ~30s of idle and nothing can reach the device until it speaks first — which
|
||||
# presents as "the VPN works for a minute and then half-works".
|
||||
# * The endpoint must be the DDNS name, never the current WAN IP (see --endpoint below).
|
||||
#
|
||||
# USAGE
|
||||
# ./infra/vpn-ingress/new-peer.sh <device-name> <last-octet> [--split] [--out DIR]
|
||||
#
|
||||
# <device-name> short, lowercase, e.g. pixel-8, thinkpad, work-laptop. Used for the filename
|
||||
# and the comment that ties the key to a human-recognisable device.
|
||||
# <last-octet> 2-254, the device's address inside WG_CLIENT_CIDR. Must be unused.
|
||||
# --split split tunnel: route ONLY the LAN and the tunnel subnet. Default is a FULL
|
||||
# tunnel (0.0.0.0/0) so the device also egresses the house WAN.
|
||||
# --out DIR where to write the profile (default: a 0700 dir under $TMPDIR).
|
||||
#
|
||||
# ENVIRONMENT
|
||||
# WG_SERVER_KEY_FILE server PRIVATE key (default ~/.config/wireguard-home/server.key); its public
|
||||
# half is derived from it. Alternatively set WG_SERVER_PUBKEY directly.
|
||||
# WG_ENDPOINT_HOST default wan.home.keithjackson.dev
|
||||
# WG_CLIENT_CIDR default 10.90.0.0/24 (must match clusters/base/cluster-vars.yaml)
|
||||
# WG_LAN_CIDR default 192.168.2.0/24 (prod LAN_CIDR)
|
||||
# WG_DNS default 192.168.2.23 (prod PIHOLE_DNS_VIP)
|
||||
#
|
||||
# REQUIRES: wireguard-tools (`wg`). `qrencode` is optional and only used for the QR code.
|
||||
# =================================================================================================
|
||||
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"
|
||||
|
||||
# --- 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
|
||||
# run on an operator's laptop with no checkout-shaped assumptions beyond its own location, and a
|
||||
# silent mis-parse would be worse than a visible default. If you change the knob, change it here.
|
||||
: "${WG_CLIENT_CIDR:=10.90.0.0/24}" # clusters/base/cluster-vars.yaml
|
||||
: "${WG_LAN_CIDR:=192.168.2.0/24}" # clusters/prod LAN_CIDR
|
||||
: "${WG_DNS:=192.168.2.23}" # clusters/prod PIHOLE_DNS_VIP
|
||||
: "${WG_SERVER_KEY_FILE:=${HOME}/.config/wireguard-home/server.key}"
|
||||
# wan.<base-domain> is an UNPROXIED A record that platform/externaldns/ddns.yaml keeps pointed at
|
||||
# the current WAN IP (checked every 5 min). Use it, never a literal address: this house has a
|
||||
# DYNAMIC WAN IP, and a profile pinned to today's address stops working without warning and with no
|
||||
# symptom on the server side. It is a PUBLIC Cloudflare record, so it also resolves from a cellular
|
||||
# connection — unlike any `*.home.keithjackson.dev` name PiHole answers, which a remote client
|
||||
# cannot resolve until it is already connected.
|
||||
: "${WG_ENDPOINT_HOST:=wan.home.keithjackson.dev}"
|
||||
: "${WG_LISTEN_PORT:=51820}"
|
||||
: "${WG_MTU:=1420}"
|
||||
|
||||
SPLIT=0
|
||||
OUT_DIR=""
|
||||
NAME=""
|
||||
OCTET=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--split) SPLIT=1; shift ;;
|
||||
--out) OUT_DIR="${2:-}"; [ -n "$OUT_DIR" ] || die "--out needs a directory"; shift 2 ;;
|
||||
-h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;;
|
||||
-*) die "unknown option: $1" ;;
|
||||
*) if [ -z "$NAME" ]; then NAME="$1"; elif [ -z "$OCTET" ]; then OCTET="$1"; else die "unexpected argument: $1"; fi; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$NAME" ] || die "need a device name. See --help"
|
||||
[ -n "$OCTET" ] || die "need a last octet (2-254). See --help"
|
||||
printf '%s' "$NAME" | grep -Eq '^[a-z0-9][a-z0-9-]{0,30}$' \
|
||||
|| die "device name must be lowercase letters/digits/hyphens: got '$NAME'"
|
||||
printf '%s' "$OCTET" | grep -Eq '^[0-9]+$' || die "octet must be a number: got '$OCTET'"
|
||||
[ "$OCTET" -ge 2 ] && [ "$OCTET" -le 254 ] || die "octet must be 2-254 (.1 is the server): got '$OCTET'"
|
||||
|
||||
command -v wg >/dev/null || die "wireguard-tools not installed ('wg' not on PATH). Debian/Ubuntu: apt install wireguard-tools"
|
||||
|
||||
# --- the server's public key --------------------------------------------------------------------
|
||||
if [ -z "${WG_SERVER_PUBKEY:-}" ]; then
|
||||
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read server key '$WG_SERVER_KEY_FILE'.
|
||||
Either point WG_SERVER_KEY_FILE at it, or set WG_SERVER_PUBKEY to the public half.
|
||||
If the server key does not exist yet, see runbooks/vpn-remote-access.md step 1."
|
||||
WG_SERVER_PUBKEY="$(tr -d ' \r\n' < "$WG_SERVER_KEY_FILE" | wg pubkey)"
|
||||
fi
|
||||
|
||||
CLIENT_BASE="${WG_CLIENT_CIDR%/*}"; CLIENT_BASE="${CLIENT_BASE%.*}"
|
||||
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."
|
||||
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
|
||||
fi
|
||||
|
||||
# --- output location: 0700, and never inside the repo -------------------------------------------
|
||||
if [ -z "$OUT_DIR" ]; then
|
||||
OUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wg-peer-XXXXXX")"
|
||||
else
|
||||
mkdir -p "$OUT_DIR"
|
||||
fi
|
||||
chmod 700 "$OUT_DIR"
|
||||
case "$(cd "$OUT_DIR" && pwd)" in
|
||||
"$REPO_ROOT"|"$REPO_ROOT"/*) die "refusing to write a private key inside the git checkout ($OUT_DIR)" ;;
|
||||
esac
|
||||
PROFILE="${OUT_DIR}/${NAME}.conf"
|
||||
|
||||
umask 077
|
||||
PRIVKEY="$(wg genkey)"
|
||||
PUBKEY="$(printf '%s' "$PRIVKEY" | wg pubkey)"
|
||||
|
||||
if [ "$SPLIT" -eq 1 ]; then
|
||||
ALLOWED="${WG_LAN_CIDR}, ${WG_CLIENT_CIDR}"
|
||||
TUNNEL_KIND="split tunnel (LAN only; the device keeps using its own internet)"
|
||||
else
|
||||
ALLOWED="0.0.0.0/0"
|
||||
TUNNEL_KIND="FULL tunnel (all IPv4 traffic egresses the house WAN)"
|
||||
fi
|
||||
|
||||
cat > "$PROFILE" <<EOF
|
||||
# ${NAME} — remote-access VPN profile for home.keithjackson.dev
|
||||
# Generated $(date -u +%Y-%m-%dT%H:%M:%SZ) by infra/vpn-ingress/new-peer.sh
|
||||
# ${TUNNEL_KIND}
|
||||
#
|
||||
# THIS FILE CONTAINS A PRIVATE KEY. Move it to the device, then delete it from here.
|
||||
# It is the ONLY copy: the server never sees it and it cannot be recovered or re-derived.
|
||||
[Interface]
|
||||
PrivateKey = ${PRIVKEY}
|
||||
Address = ${CLIENT_ADDR}/32
|
||||
DNS = ${WG_DNS}
|
||||
MTU = ${WG_MTU}
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${WG_SERVER_PUBKEY}
|
||||
Endpoint = ${WG_ENDPOINT_HOST}:${WG_LISTEN_PORT}
|
||||
AllowedIPs = ${ALLOWED}
|
||||
# Hold the NAT mapping open from the client side. Required for the server to be able to reach the
|
||||
# device at all once it goes idle; harmless otherwise (one small packet every 25s).
|
||||
PersistentKeepalive = 25
|
||||
EOF
|
||||
chmod 600 "$PROFILE"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Device : ${NAME}
|
||||
Address : ${CLIENT_ADDR}/32
|
||||
Routing : ${TUNNEL_KIND}
|
||||
DNS : ${WG_DNS} (PiHole — gives the device LAN answers for *.home.keithjackson.dev)
|
||||
Profile : ${PROFILE} (0600; contains the private key)
|
||||
|
||||
------------------------------------------------------------------------------------------
|
||||
STEP 1 — add this to platform/vpn-ingress/peers.yaml, under data.peers.conf, then PR it:
|
||||
|
||||
# ${NAME} — <owner>, enrolled $(date -u +%Y-%m-%d)
|
||||
[Peer]
|
||||
PublicKey = ${PUBKEY}
|
||||
AllowedIPs = ${CLIENT_ADDR}/32
|
||||
|
||||
Keep the comment line: \`wg show\` can only print the key, so it is the only record of which
|
||||
stanza belongs to which device when one has to be revoked.
|
||||
|
||||
STEP 2 — get the profile onto the device:
|
||||
Android / iOS : scan the QR code below in the WireGuard app ("+" -> Scan from QR code)
|
||||
Windows / macOS: WireGuard app -> "Import tunnel(s) from file" -> ${NAME}.conf
|
||||
Linux : install as /etc/wireguard/${NAME}.conf, then
|
||||
sudo systemctl enable --now wg-quick@${NAME}
|
||||
|
||||
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
|
||||
(~1 min). Until then the device handshakes and gets silence, exactly like an unknown key.
|
||||
------------------------------------------------------------------------------------------
|
||||
EOF
|
||||
|
||||
if command -v qrencode >/dev/null; then
|
||||
printf ' QR code (this IS the private key — your terminal scrollback now holds a credential):\n\n'
|
||||
qrencode -t ansiutf8 < "$PROFILE"
|
||||
else
|
||||
printf ' qrencode not installed, so no QR code was printed (Debian/Ubuntu: apt install qrencode).\n'
|
||||
printf ' Transfer %s to the device by another route instead.\n' "$PROFILE"
|
||||
fi
|
||||
@@ -188,6 +188,37 @@ spec:
|
||||
enabled: false
|
||||
hostRoot: /sys/fs/cgroup
|
||||
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
||||
# --- Roll the agents when their ConfigMap changes (2026-10-05) ---
|
||||
# [parity] MUST equal Ansible: rollOutCiliumPods: true
|
||||
#
|
||||
# WHY: a merged Cilium config change DID NOTHING. The chart default is `false`
|
||||
# (cilium 1.20.1 values.yaml: "Roll out cilium agent pods automatically when configmap is
|
||||
# updated. rollOutCiliumPods: false"), so Helm rewrites `cilium-config` and leaves the
|
||||
# DaemonSet's pod template untouched — and cilium-agent reads that ConfigMap only at STARTUP.
|
||||
# The result is a silently inert change: Flux green, HelmRelease `UpgradeSucceeded`, the new
|
||||
# keys visibly present in the ConfigMap, and the running agents still on the old settings
|
||||
# until a human remembers to restart them.
|
||||
#
|
||||
# MEASURED, and it is why this exists: the Hubble flow exporter merged 2026-10-04 wrote
|
||||
# `hubble-export-file-path: stdout` and its allowlist into `cilium-config`, the HelmRelease
|
||||
# reported `Helm upgrade succeeded ... cilium.v6`, and over a DAY later all three agents were
|
||||
# still the pre-merge pods (startTime 2026-10-04T07:11Z) with ZERO exported flow lines. The
|
||||
# feature was merged, believed delivered, and doing nothing at all. That is the same class of
|
||||
# failure this repo already documents elsewhere — a green signal over something that never ran.
|
||||
#
|
||||
# THE COST IS REAL AND IS THE POINT: every future Cilium config change now triggers a
|
||||
# cluster-wide agent rollout instead of waiting for a manual restart. That is strictly more
|
||||
# honest — the change applies when it is merged, or it fails visibly — but it means config
|
||||
# edits are disruptive by default, so they want a quiet moment. One node at a time:
|
||||
# `updateStrategy.rollingUpdate.maxUnavailable: 1` below means the DaemonSet will not proceed
|
||||
# past an unready agent, so a bad render costs one node rather than the cluster. Note that the
|
||||
# node holding the L2 lease for the Gateway, PiHole-DNS and Plex VIPs loses them briefly as it
|
||||
# cycles, so a rollout can blip LAN DNS.
|
||||
#
|
||||
# ENABLING THIS IS ITSELF ONE ROLLOUT: adding the ConfigMap checksum annotation changes the pod
|
||||
# template, so merging this rolls the agents once — which is what finally activates the Hubble
|
||||
# exporter that has been inert since 2026-10-04.
|
||||
rollOutCiliumPods: true
|
||||
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
||||
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
||||
# maxUnavailable: 2) — NOT a local choice, and on a 3-node cluster that lets a single bad
|
||||
|
||||
@@ -32,6 +32,15 @@ enableIPv4Masquerade: true
|
||||
bpf:
|
||||
masquerade: true
|
||||
# [parity] flow observability / isolation audit
|
||||
# [parity] roll the agents when cilium-config changes. The chart default (false) rewrites the
|
||||
# ConfigMap but leaves the pod template alone, and cilium-agent only reads it at STARTUP — so a
|
||||
# merged config change is SILENTLY INERT. Measured 2026-10-04..05: the Hubble exporter keys were
|
||||
# live in cilium-config and the HelmRelease said UpgradeSucceeded, while all three agents were
|
||||
# still pre-merge pods exporting nothing for over a day. Cost: config edits now roll the agents
|
||||
# (one node at a time via maxUnavailable: 1), which can briefly blip the VIPs on the lease-holding
|
||||
# node. Full rationale in helmrelease.yaml.
|
||||
rollOutCiliumPods: true
|
||||
|
||||
hubble:
|
||||
enabled: true
|
||||
relay:
|
||||
|
||||
@@ -91,6 +91,10 @@ This proves the R-IDP-02 property (unauthenticated cannot reach the upstream) on
|
||||
|
||||
## Observability: what this component can and cannot see (measured 2026-09-30)
|
||||
|
||||
> **SUPERSEDED — read "Update 2026-10-05" at the end of this section first.** The gate sidecars
|
||||
> **are** scraped now and the TSDB **does** hold `oauth2_proxy_*` for them. The two subsections
|
||||
> below are kept as the dated record of how this was diagnosed, not as current state.
|
||||
|
||||
`servicemonitor.yaml` scrapes **this namespace's** deployment and nothing else. On prod that is
|
||||
two targets, `job="oauth2-proxy"`, both `up=1`. The complete set of oauth2-proxy-specific series
|
||||
in the TSDB is:
|
||||
@@ -157,3 +161,55 @@ present `up` series. A discovered-but-failing target publishes `up` with value 0
|
||||
would have cleared `OAuth2ProxyGateUnobserved` the moment the PodMonitor landed — reporting the gates
|
||||
as observed while nothing was being scraped. The unit test pins that scenario; deleting the `== 1`
|
||||
fails it and nothing else.
|
||||
|
||||
### Update 2026-10-05: the scrape works
|
||||
|
||||
PR #49 (merged `21c88b0`) added the missing third piece. `catalog/render.py`'s
|
||||
`r_monitoring_ingress_policy` now also fires for the gate sidecar's metrics port, keyed on
|
||||
`_needs_oauth2_sidecar`, rendering `<app>-allow-monitoring-scrape` per app: ns `monitoring` → that
|
||||
app's `endpointSelector` → TCP 44180 only. Five objects, one each for jackett, pihole, qbittorrent,
|
||||
radarr, sonarr.
|
||||
|
||||
Measured on prod 2026-10-05 after Flux applied it (`apps-wave-0` 21:37:01Z, `apps-wave-1`
|
||||
21:37:46Z):
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| target health (6) | `down` | `up` |
|
||||
| `lastError` | `context deadline exceeded` | `''` |
|
||||
| `lastScrapeDuration` | **10.0s** — exactly the `scrapeTimeout` | **0.001–0.006s** |
|
||||
| `up` | `0` × 6 | `1` × 6 |
|
||||
|
||||
count by (__name__) ({__name__=~"oauth2_proxy_.*",
|
||||
namespace=~"jackett|radarr|sonarr|qbittorrent|pihole"})
|
||||
oauth2_proxy_requests_in_flight => 6
|
||||
oauth2_proxy_requests_total => 16
|
||||
oauth2_proxy_response_duration_seconds_bucket => 108
|
||||
oauth2_proxy_response_duration_seconds_sum => 9
|
||||
oauth2_proxy_response_duration_seconds_count => 9
|
||||
|
||||
sum by (namespace) (oauth2_proxy_requests_total)
|
||||
qbittorrent => 35072 radarr => 23560 sonarr => 1284
|
||||
|
||||
`oauth2_proxy_requests_total` is absent for jackett and pihole simply because that counter is not
|
||||
created until the gate has served its first request.
|
||||
|
||||
**So the auth-churn rule is now writable rather than decorative** — sustained `code="302"` as a
|
||||
share of `oauth2_proxy_requests_total`, held across a window longer than the session lifetime. That
|
||||
is the rule the 2026-09-30 incident above could not be caught by. It is still unwritten.
|
||||
|
||||
**Alert outcome:** all 5 `TargetDown` resolved, and 5 of 6 `OAuth2ProxyGateUnobserved` with them.
|
||||
At `repeat_interval: 12h` with `group_by: [alertname, namespace]`, those 11 groups had been
|
||||
re-notifying at roughly **22 emails/day** since 2026-10-02; that drops to 2/day.
|
||||
|
||||
**`crafty` still fires, and that is correct.** It is a hand-maintained Deployment under
|
||||
`platform/crafty`, outside the renderer, whose `oauth2-proxy` container has **no
|
||||
`--metrics-address` at all** — no listener, so no port to open and nothing for the PodMonitor's
|
||||
pod-label selector to match. Closing it needs the flag and a `metrics` containerPort on crafty's
|
||||
own manifest first, then a hand-written allowance beside it. Open follow-up work.
|
||||
|
||||
**The paragraph above headed "To close the gap for an app" is superseded**: a *Service* port and a
|
||||
ServiceMonitor are **not** required. A PodMonitor targets pod ports directly, which is why no
|
||||
Service exposes 44180 and why the renderer needed no Service change. The three things actually
|
||||
needed are `--metrics-address` + named container port, a PodMonitor, and the monitoring-ingress
|
||||
allowance.
|
||||
@@ -27,10 +27,28 @@
|
||||
# created by the apps-wave-0/1 tiers, which this tier does not and must not depend on.
|
||||
#
|
||||
# ============================================================================================
|
||||
# READ THIS BEFORE BELIEVING THE TARGETS ARE UP: THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY
|
||||
# STATUS: THE SCRAPE WORKS. Verified on prod 2026-10-05 (PR #49 merged as 21c88b0).
|
||||
# ============================================================================================
|
||||
# This PodMonitor is NECESSARY AND NOT SUFFICIENT. The endpoint serves, and the path from ns
|
||||
# monitoring is blocked. Both halves measured on prod 2026-10-01, read-only:
|
||||
# All 6 targets in pool podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0 read `up == 1`,
|
||||
# health=up, lastError='', lastScrapeDuration 0.001-0.006s. The TSDB holds oauth2_proxy_* for
|
||||
# these namespaces for the first time: 5 metric names / 148 series, with live counters
|
||||
# (oauth2_proxy_requests_total 35072 qbittorrent, 23560 radarr, 1284 sonarr). All 5 TargetDown
|
||||
# and 5 of 6 OAuth2ProxyGateUnobserved resolved; only namespace="crafty" still fires, and that
|
||||
# is correct — see CRAFTY below.
|
||||
#
|
||||
# The missing piece was an ingress allowance, and it now ships WITH EACH APP, rendered by
|
||||
# catalog/render.py's r_monitoring_ingress_policy as `<app>-allow-monitoring-scrape`:
|
||||
# ns monitoring -> the app's endpointSelector -> TCP 44180 only. See WHY NOT IN THIS DIRECTORY
|
||||
# below for why it is rendered per-app instead of shipped from this tier.
|
||||
#
|
||||
# Keep the history below. It is why the fix is shaped the way it is, and `up == 1` in
|
||||
# auth-visibility-alerts.yaml is load-bearing precisely because of it.
|
||||
#
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# HISTORY: 2026-10-02 -> 2026-10-05, THE SCRAPE WAS DENIED BY NETWORKPOLICY FOR 63.7h
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# This PodMonitor was NECESSARY AND NOT SUFFICIENT: the endpoint served, and the path from ns
|
||||
# monitoring was blocked. Both halves measured on prod 2026-10-01, read-only:
|
||||
#
|
||||
# THE ENDPOINT IS HEALTHY — port-forward straight to the jackett pod's 44180:
|
||||
# GET /metrics -> http=200 bytes=10208
|
||||
@@ -54,19 +72,24 @@
|
||||
# `jackett-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180 — the Gateway's Envoy
|
||||
# and nothing else. Prometheus matches no clause and its packets are dropped, not refused.
|
||||
#
|
||||
# SO THE TARGETS BELOW WILL READ `up == 0` UNTIL AN INGRESS ALLOWANCE LANDS. That is a known,
|
||||
# measured, documented state — NOT a regression introduced here, and NOT something to silence.
|
||||
# It is deliberately left visible: `OAuth2ProxyGateUnobserved` in auth-visibility-alerts.yaml
|
||||
# THE TARGETS READ `up == 0` FROM 2026-10-02 05:09 UTC UNTIL THE ALLOWANCE LANDED — one
|
||||
# continuous 63.7h episode (a 15d query_range shows 1 episode per namespace, not flapping),
|
||||
# starting 11 minutes after this file was created, i.e. TargetDown's `for: 10m`. It was never
|
||||
# silenced, and that was the right call: `OAuth2ProxyGateUnobserved` in auth-visibility-alerts.yaml
|
||||
# requires `up == 1`, precisely so a discovered-but-failing target does not read as "observed"
|
||||
# (see that file's header — this exact trap is why the `== 1` is there).
|
||||
#
|
||||
# The signature to recognise if it ever returns: lastScrapeDuration pinned at EXACTLY the
|
||||
# scrapeTimeout (10.0s) with `context deadline exceeded`. That is a DROP, not a refusal — a
|
||||
# refused connection fails in milliseconds. Post-fix the same targets scrape in 1-6ms.
|
||||
#
|
||||
# THIS IS THE SAME BUG LONGHORN HAD, and the platform has a documented fix idiom for it:
|
||||
# platform/longhorn/networkpolicy-metrics.yaml, whose header records the identical fingerprint
|
||||
# measured on 2026-09-13 — ServiceMonitor in place, 3 targets DISCOVERED, `up` 0/0/0, TSDB empty,
|
||||
# `lastError: context deadline exceeded` (a drop, not a refusal). The fix there is a narrow
|
||||
# additive `networking.k8s.io/v1` NetworkPolicy: ns monitoring -> the pod label, that one port.
|
||||
#
|
||||
# WHY THAT POLICY IS NOT IN THIS DIRECTORY. It would have to be five objects, one per app
|
||||
# WHY NOT IN THIS DIRECTORY (the reasoning the fix followed). It would have to be five objects, one per app
|
||||
# namespace, and those namespaces are created by apps-wave-0/1. Measured Flux graph, 2026-10-01:
|
||||
# oauth2-proxy dependsOn=['platform-config'] <- this tier
|
||||
# apps-wave-0 dependsOn=['platform-config'] <- SIBLING, no ordering between them
|
||||
@@ -76,8 +99,19 @@
|
||||
# running cluster and break on a rebuild — the "works now, broken on restore" shape this repo
|
||||
# keeps paying for. Longhorn's precedent is safe only because longhorn-system is created by the
|
||||
# same tier that ships its policy. The allowance therefore belongs WITH THE APPS, rendered next to
|
||||
# the rest of each app's network policy — i.e. in the catalog/renderer, which is in flight on
|
||||
# another branch. Tracked in the handoff; it is one narrow rule per app, not a redesign.
|
||||
# the rest of each app's network policy — i.e. in the catalog/renderer. THAT IS WHERE IT LANDED:
|
||||
# `r_monitoring_ingress_policy`, keyed on `_needs_oauth2_sidecar`, one narrow rule per app.
|
||||
#
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# CRAFTY: STILL UNOBSERVED, AND THIS FILE CANNOT HELP IT
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# `OAuth2ProxyGateUnobserved{namespace="crafty"}` still fires after the fix, correctly. crafty is
|
||||
# a hand-maintained Deployment under platform/crafty, OUTSIDE the renderer, and its oauth2-proxy
|
||||
# container has NO `--metrics-address` at all (measured 2026-10-05) — so its metrics listener
|
||||
# never starts and there is no port to open. The selector below therefore does not match it, and
|
||||
# the per-app allowance is never rendered for it. Closing crafty's gap needs the flag plus a
|
||||
# `metrics` containerPort added to its own manifest FIRST; the allowance is the second half, and
|
||||
# it would have to be written by hand there rather than rendered. Open follow-up work.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PodMonitor
|
||||
metadata:
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# platform/vpn-ingress — WireGuard remote access (north-south INGRESS)
|
||||
|
||||
Lets the operator's own devices — Android, Linux, Windows, macOS, iOS — dial in from anywhere on
|
||||
the internet and behave as though plugged into the home LAN: full reach to `192.168.2.0/24` and
|
||||
egress out the house WAN.
|
||||
|
||||
> **This is not `platform/vpn-egress`.** They are opposite directions and share nothing but the
|
||||
> word "VPN". `vpn-egress` pushes *pods out* to the internet through a PIA tunnel (ADR-0015,
|
||||
> R-NET-05/06/07). This component lets *remote clients in*. Changing one never affects the other.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| The one secret | the server's private key, Secret `wg-server-key`, **created out of band** |
|
||||
| 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) |
|
||||
| Procedure / bring-up | [`runbooks/vpn-remote-access.md`](../../runbooks/vpn-remote-access.md) |
|
||||
|
||||
## Why WireGuard
|
||||
|
||||
The requirement names four client platforms. WireGuard is the only option that has a *first-party*
|
||||
client on all of them (official apps for Android, iOS, Windows and macOS; in-kernel on Linux since
|
||||
5.6, so a Linux client needs no userspace daemon at all). It is also the smallest thing to expose:
|
||||
one UDP port, no cipher negotiation, no PKI, no user database, no TLS stack.
|
||||
|
||||
OpenVPN would have needed a CA, per-device certificates and a revocation list — three pieces of
|
||||
long-lived state this repo would then have to protect and rotate. IKEv2/IPsec has native clients but
|
||||
is painful to run in a container and brittle behind double NAT. Neither buys anything here.
|
||||
|
||||
### Why this is safe to expose to the whole internet
|
||||
|
||||
The honest answer is not "because it is encrypted" — it is the handshake's failure mode:
|
||||
|
||||
**An unauthenticated packet gets no reply at all.** WireGuard's first handshake message carries a
|
||||
MAC keyed with the *responder's own public key*. If the sender does not already know it, the packet
|
||||
is dropped silently — no error, no reset, no banner, nothing a scanner can fingerprint. From the
|
||||
outside, udp/51820 is indistinguishable from a closed port. There is no login prompt to brute-force,
|
||||
no version string to match against a CVE, and no amplification to abuse.
|
||||
|
||||
That is the whole security argument, and it is why the exposed port carries no IP allowlist (which
|
||||
would also have made roaming clients unusable — a phone's address changes with every cell tower).
|
||||
Compare `apps/wave-0/pihole-dns.yaml`, which *does* pin `loadBalancerSourceRanges` to the LAN: a
|
||||
resolver answers anybody, so it must be scoped by address. WireGuard answers nobody.
|
||||
|
||||
What it does **not** protect against is a stolen device — anyone holding a device's private key
|
||||
*is* that device. That is what revocation is for: delete the stanza, merge, done (~1 min).
|
||||
|
||||
## Architecture, and the one decision that drives everything
|
||||
|
||||
```
|
||||
phone on 5G SOHO router k3s-prod-N (host netns)
|
||||
┌──────────────┐ udp/51820 ┌───────────────┐ forward ┌────────────────────────────┐
|
||||
│ WireGuard app│─────────────▶│ WAN 51820 ──▶ │───────────▶│ wg0 10.90.0.1/24 │
|
||||
│ 10.90.0.2/32 │ over the │ node IP:51820 │ │ decrypt, then FORWARD │
|
||||
└──────────────┘ internet └───────────────┘ │ SNAT to the node's addr │
|
||||
└─────────────┬──────────────┘
|
||||
│ as `host`/`remote-node`
|
||||
┌─────────────────────────────────┼──────────────────────┐
|
||||
▼ ▼ ▼
|
||||
LAN 192.168.2.0/24 Gateway VIP .20 (16 web UIs) WAN / internet
|
||||
(NAS, router, printers) PiHole DNS VIP .23
|
||||
```
|
||||
|
||||
### `hostNetwork: true` is required, not convenient
|
||||
|
||||
This is the only genuinely interesting decision in the component, and it was forced by a *measured*
|
||||
property of this cluster rather than chosen for convenience.
|
||||
|
||||
In Cilium, what a packet may reach is decided by the **security identity of its source**. A
|
||||
WireGuard server running as an ordinary pod must SNAT client traffic to its own pod IP (Cilium drops
|
||||
a packet whose source is not the endpoint's own address), so every remote client would have arrived
|
||||
everywhere wearing *that pod's* identity. On 2026-10-04 this cluster measured what that costs
|
||||
(`decisions/2026-10-04-plex-advertise-plexdirect.md`): a request to the shared Gateway VIP **from
|
||||
inside a pod returns `403 Access denied` from Envoy for all 16 Gateway hostnames**, because Cilium's
|
||||
Gateway preserves the original source identity and enforces the *destination's* ingress policy —
|
||||
and every app admits `reserved:ingress` plus named peers, never an arbitrary pod. LAN browsers work
|
||||
only because they arrive as `reserved:world` / `reserved:host`.
|
||||
|
||||
So the pod-networked version of this component would have produced a tunnel that came up healthy,
|
||||
reported connected peers, routed packets — and 403'd **every single cluster web UI**: plex, gitea,
|
||||
keycloak, nextcloud, grafana, headlamp, the apex. `*.home.keithjackson.dev` would not even have
|
||||
resolved, because `apps/wave-0/pihole-dns.yaml`'s ingress policy deliberately omits the `cluster`
|
||||
entity. A VPN that looks up and is useless is the worst available outcome, and nothing in the
|
||||
manifests would have hinted at it.
|
||||
|
||||
In the **host** netns there is no pod identity to carry. Forwarded client traffic is SNATed to the
|
||||
node's own address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
|
||||
byte-for-byte what a LAN client looks like. Three further problems vanish with it:
|
||||
|
||||
* `net.ipv4.ip_forward` is **already 1** on these nodes (`node_baseline` writes
|
||||
`/etc/sysctl.d/99-k3s-cilium.conf`). It is *not* in the kubelet's safe-sysctl allowlist, so a
|
||||
pod-netns build would have needed `--allowed-unsafe-sysctls` on all three kubelets — a
|
||||
node-restart change to the k3s service, to run a VPN.
|
||||
* **No LoadBalancer VIP**, so no `CiliumL2AnnouncementPolicy` lease lottery (the backend-blind
|
||||
announcer that cost this LAN an 8-day Minecraft outage — see
|
||||
`conformance/tests/l0-static/l2-announced-vips-serve-from-any-node`) and no
|
||||
`externalTrafficPolicy` trade-off to make at all.
|
||||
* **No double encapsulation.** Client UDP lands on the node's own NIC, so the underlay is a flat
|
||||
1500-byte path, not Cilium's 1450-byte VXLAN. That is what makes `MTU = 1420` simply correct.
|
||||
|
||||
`hostNetwork` is *permitted* here, and not by oversight:
|
||||
`platform/kyverno/policies/restrict-host-namespaces.yaml` matches `namespaceSelector:
|
||||
app-namespace=true` only, and names this exact carve-out in its own comment — "platform components
|
||||
that do … e.g. Cilium, node-exporter … live in platform namespaces that do NOT carry the
|
||||
app-namespace label". [`namespace.yaml`](./namespace.yaml) is deliberately unlabelled.
|
||||
|
||||
**The cost, stated rather than buried:** a `hostNetwork` pod has no Cilium endpoint, so **no
|
||||
NetworkPolicy constrains this workload** — not [`networkpolicy.yaml`](./networkpolicy.yaml), not
|
||||
anything. That file says so at length instead of implying a protection it cannot provide. The
|
||||
boundary around this port is WireGuard's peer authentication, full stop.
|
||||
|
||||
### Why a DaemonSet
|
||||
|
||||
The WAN forward names **one node address**, by hand, off-repo (same shape as the plex tcp/32400
|
||||
forward). A single-replica Deployment would float on every reschedule and silently invalidate it.
|
||||
A DaemonSet instead puts an identical listener on all three nodes — every node answers the same
|
||||
keys — so **losing the forwarded node is a one-line router change**, with no cluster work and no
|
||||
re-issued client profiles. The two idle listeners cost ~15 MiB each.
|
||||
|
||||
A pinned `LoadBalancer` VIP was the considered alternative (it would make the forward immune to node
|
||||
replacement). Rejected: it reintroduces the L2-announcement lease, SNAT, and an extra node-to-node
|
||||
hop into the one path whose failure mode is *"I am away from home and cannot get in"*. Fewer
|
||||
translations win here.
|
||||
|
||||
### The split that keeps authorisation reviewable
|
||||
|
||||
| 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 |
|
||||
| the server's **private** key | Secret `wg-server-key`, **out of band** | the only real secret; an agent must not mint a long-lived private key (CLAUDE.md §6) |
|
||||
|
||||
The two are joined only at pod start, by the init container, into a **`medium: Memory` emptyDir** —
|
||||
so the assembled config holding the private key never touches any node's disk and dies with the pod.
|
||||
|
||||
Encrypting the peer register would have hidden exactly the change a reviewer most needs to see: a
|
||||
new line granting a new device access to the whole LAN.
|
||||
|
||||
## What a client can reach — and how to narrow it
|
||||
|
||||
By design, **everything a LAN machine can reach**: all of `192.168.2.0/24`, the shared Gateway's 16
|
||||
hostnames, PiHole, the NAS, the router's admin UI, and the internet via the house WAN.
|
||||
|
||||
**That includes the three k3s nodes themselves** — `192.168.2.10/.11/.12`, and therefore the
|
||||
kube-apiserver on 6443, kubelet on 10250 and SSH on 22. An enrolled device is as privileged as a
|
||||
machine sitting in the house, which is what "reach the entire local network" asks for; it is
|
||||
recorded here because it is the largest consequence of saying yes to that, and because nobody should
|
||||
discover it later. **It is also why a lost device must be revoked promptly** (delete its stanza,
|
||||
merge; ~1 min to take effect).
|
||||
|
||||
To grant less, narrow it at the **client** profile first — `new-peer.sh --split` emits a
|
||||
split-tunnel profile routing only the LAN and the tunnel subnet, so the device keeps its own
|
||||
internet path. To enforce less *server-side*, add filtering to the `PostUp` chain in
|
||||
[`daemonset.yaml`](./daemonset.yaml) (e.g. `iptables -A FORWARD -i wg0 -d 192.168.2.10 -j DROP`
|
||||
before the accept rules) — the FORWARD chain there is the only enforcement point, since no
|
||||
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.
|
||||
* **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`.
|
||||
|
||||
## Prerequisites outside this directory
|
||||
|
||||
1. **The `wireguard` kernel module on every node.** Added to `node_baseline_kernel_modules`
|
||||
(`infra/ansible/roles/node_baseline/defaults/main.yml`); the operator runs the role. The pod has
|
||||
`NET_ADMIN` but deliberately **not** `SYS_MODULE`, so it cannot load the module itself — it
|
||||
CrashLoops with a clear `wg-quick` error instead of quietly degrading to userspace WireGuard.
|
||||
2. **The `wg-server-key` Secret.** Runbook step 1. Until it exists the DaemonSet cannot start —
|
||||
the correct fail-closed state for an unenrolled VPN.
|
||||
3. **The WAN port-forward**, udp/51820 → one node address. Hand-made on the SOHO router, off-repo,
|
||||
and the half CI can never check.
|
||||
|
||||
## Not built (deliberate, with reasons)
|
||||
|
||||
* **No metrics / no peer-level alerting.** A WireGuard exporter would expose
|
||||
`wireguard_latest_handshake_seconds` per peer — genuinely useful for spotting unexpected
|
||||
activity — but it needs a second `NET_ADMIN` container in the pod that terminates the
|
||||
internet-facing tunnel, and that trade was not worth making unverified. Pod-down is already
|
||||
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
|
||||
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
|
||||
still reaches IPv6 sites *outside* the tunnel. Functionally harmless, a privacy leak if the point
|
||||
was to hide the client's network. Fix is an `fd..` ULA on `wg0` plus `::/0` in the client's
|
||||
`AllowedIPs`; not done because nothing here needs it.
|
||||
* **No in-cluster-service reach beyond what the LAN has.** Clients reach cluster apps the way a LAN
|
||||
browser does — through the Gateway VIP. Pod IPs and `10.43.0.0/16` service IPs are not routed
|
||||
into the tunnel.
|
||||
@@ -0,0 +1,295 @@
|
||||
---
|
||||
# =================================================================================================
|
||||
# WireGuard remote-access listener — udp/51820 in the HOST network namespace, on every node.
|
||||
# =================================================================================================
|
||||
# WHY WireGuard AND NOT OpenVPN/IPsec. The requirement is "Android, Linux, Windows and other
|
||||
# computing clients", from anywhere on the internet, securely. WireGuard is the only option here
|
||||
# with a first-party client on all four platforms (official apps for Android/iOS/Windows/macOS, and
|
||||
# in-kernel on Linux since 5.6 — so Linux clients need no userspace daemon at all). It is also the
|
||||
# smallest attack surface of the three: one UDP port, ~4k lines of kernel code, no cipher
|
||||
# negotiation, no PKI, no user database, and — the property that actually matters for a port
|
||||
# exposed to the whole internet — it NEVER REPLIES to an unauthenticated packet. See README.
|
||||
#
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# hostNetwork: true — REQUIRED, not convenient. This is THE decision in this file.
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# A VPN client must behave like a machine plugged into the LAN. In Cilium, what a packet is ALLOWED
|
||||
# to reach is decided by the SECURITY IDENTITY of its source, and after any in-pod SNAT that
|
||||
# identity is the VPN pod's — not `world`. That distinction is not academic; it was MEASURED on
|
||||
# this cluster on 2026-10-04 (decisions/2026-10-04-plex-advertise-plexdirect.md): a curl of the
|
||||
# shared Gateway VIP from inside a pod returns `403 Access denied` from Envoy for **all 16**
|
||||
# Gateway hostnames, because Cilium's Gateway preserves the original source identity and enforces
|
||||
# the DESTINATION's ingress CiliumNetworkPolicy, and every app admits `reserved:ingress` plus named
|
||||
# peers — never an arbitrary pod. LAN browsers are allowed only because they arrive as
|
||||
# `reserved:world` / `reserved:host`.
|
||||
#
|
||||
# So a pod-networked WireGuard server would have handed remote clients a tunnel on which every
|
||||
# single cluster web UI returned 403 — plex, gitea, keycloak, nextcloud, grafana, headlamp, the
|
||||
# apex, all of them — while `wg show` reported a perfectly healthy tunnel. PiHole would have failed
|
||||
# the same way and worse: apps/wave-0/pihole-dns.yaml's ingress policy deliberately OMITS the
|
||||
# `cluster` entity, so VPN clients would not have been able to resolve `*.${BASE_DOMAIN}` either.
|
||||
# The VPN would have looked up and been useless, which is the worst failure shape available.
|
||||
#
|
||||
# In the host netns there is no pod identity to carry: forwarded client traffic is SNATed to the
|
||||
# NODE's address, so it reaches the Gateway, PiHole and every LAN host as `host`/`remote-node` —
|
||||
# exactly what a LAN client looks like. Three further problems disappear with it:
|
||||
# * net.ipv4.ip_forward is ALREADY 1 on these nodes (node_baseline writes it to
|
||||
# /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).
|
||||
# * 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
|
||||
# flat 1500-byte LAN path, not Cilium's 1450-byte VXLAN.
|
||||
#
|
||||
# hostNetwork is PERMITTED here, and not by accident: platform/kyverno/policies/
|
||||
# restrict-host-namespaces.yaml matches `namespaceSelector: app-namespace=true` only, and names
|
||||
# this exact carve-out in its own comment ("platform components that do -- e.g. Cilium,
|
||||
# node-exporter -- live in platform namespaces that do NOT carry the app-namespace label").
|
||||
# namespace.yaml is deliberately unlabelled. THE COST IS REAL AND IS STATED: a hostNetwork pod
|
||||
# bypasses the Cilium datapath, so NO NetworkPolicy constrains this workload (see
|
||||
# networkpolicy.yaml, which says so rather than pretending otherwise). The boundary protecting
|
||||
# this port is WireGuard's own cryptographic peer authentication, not the CNI.
|
||||
#
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# A DaemonSet, not a single-replica Deployment.
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# The WAN port-forward on the SOHO router points at ONE node address, by hand, off-repo (same shape
|
||||
# as the plex tcp/32400 forward — runbooks/plex-remote-access.md). A Deployment would float to
|
||||
# whichever node the scheduler picked and silently invalidate that forward on every reschedule. A
|
||||
# DaemonSet instead puts an identical warm listener on ALL THREE nodes: the forward names one of
|
||||
# them, and if that node is lost, FAILOVER IS A ONE-LINE ROUTER CHANGE with no cluster work and no
|
||||
# new client profiles — every node answers the same keys. Each node binds udp/51820 in its own
|
||||
# netns, so there is no conflict, and the two idle listeners cost ~15 MiB each.
|
||||
#
|
||||
# All three nodes carry no taints (measured 2026-10-05), so no tolerations are needed for the
|
||||
# DaemonSet to cover the cluster; add them if a node is ever tainted.
|
||||
apiVersion: apps/v1
|
||||
kind: DaemonSet
|
||||
metadata:
|
||||
name: wireguard
|
||||
namespace: vpn-ingress
|
||||
labels:
|
||||
app.kubernetes.io/name: vpn-ingress
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vpn-ingress
|
||||
# One node at a time, so a rollout never takes every listener down at once. The node named by the
|
||||
# router's forward does drop for a few seconds during its own replacement; clients re-handshake
|
||||
# automatically (WireGuard retries every 5s, and rekeys every 2 minutes regardless).
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: vpn-ingress
|
||||
# Declared for documentation and for symmetry with the app tier. It MUST be `internet`, and
|
||||
# the value is load-bearing rather than cosmetic: platform/vpn-egress/egress-steering.yaml's
|
||||
# CiliumEgressGatewayPolicy selects `egress-class: vpn` pods CLUSTER-WIDE, with no namespace
|
||||
# scope. Setting `vpn` here would steer every remote client's internet traffic out the PIA
|
||||
# tunnel instead of the house WAN — the opposite of the requirement, and it would look like
|
||||
# a working VPN with a mysterious foreign exit IP. (The selector matches on the pod label,
|
||||
# so hostNetwork does not exempt this pod from being selected.)
|
||||
homelab.keithjackson.dev/egress-class: internet
|
||||
# WireGuard authenticates its own peers by public key; there is no UI and no IdP hop.
|
||||
homelab.keithjackson.dev/auth-mode: native
|
||||
spec:
|
||||
# See the long rationale at the head of this file. ClusterFirstWithHostNet keeps in-cluster
|
||||
# DNS working for the pod itself despite hostNetwork (without it the pod inherits the NODE's
|
||||
# resolv.conf, which points at public resolvers).
|
||||
hostNetwork: true
|
||||
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
|
||||
# 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:
|
||||
- name: assemble-config
|
||||
image: lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls124
|
||||
imagePullPolicy: IfNotPresent
|
||||
# POSIX sh. Note on $ handling: this script is rendered through Flux's envsubst (the
|
||||
# `vpn-ingress` Kustomization declares postBuild.substituteFrom), so `${WG_CLIENT_CIDR}`
|
||||
# below IS substituted on purpose — it is a declared cluster-vars knob. Every SHELL
|
||||
# variable is written bare (`$net`, `$(cmd)`), which envsubst leaves untouched. There is
|
||||
# deliberately NO braced shell-variable form anywhere in here: braces are what envsubst
|
||||
# consumes, so a shell variable written that way silently renders to the empty string.
|
||||
# (ci: flux-postbuild-substitution enforces this, and caught this very comment when it
|
||||
# first spelled the hazard out literally — the check reads comments too, correctly.)
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -eu
|
||||
umask 077
|
||||
mkdir -p /config/wg_confs
|
||||
|
||||
# Derive the server's own tunnel address from the single CIDR knob, so there is no
|
||||
# second knob to keep in agreement: 10.90.0.0/24 -> 10.90.0.1/24.
|
||||
net=${WG_CLIENT_CIDR}
|
||||
mask=$(echo "$net" | cut -d/ -f2)
|
||||
base=$(echo "$net" | cut -d/ -f1 | cut -d. -f1-3)
|
||||
|
||||
# The key arrives as a Secret file; strip any trailing newline the operator's shell
|
||||
# added, or wg-quick rejects the config with "Key is not the correct length or format".
|
||||
key=$(tr -d ' \r\n' < /run/wg-server-key/privatekey)
|
||||
if [ -z "$key" ]; then
|
||||
echo "FATAL: /run/wg-server-key/privatekey is empty." >&2
|
||||
echo " See runbooks/vpn-remote-access.md step 1 (create the server key)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo '[Interface]'
|
||||
echo "Address = $base.1/$mask"
|
||||
echo 'ListenPort = 51820'
|
||||
# 1420 = 1500 (flat LAN/WAN path to the node's own NIC) - 60 bytes of WireGuard
|
||||
# encapsulation (20 IPv4 + 8 UDP + 32 WireGuard). This is also what wg-quick would
|
||||
# auto-derive, but it is pinned because the symptom of getting it wrong is the
|
||||
# nastiest kind: handshakes and SSH work while large packets (HTTPS, file copies)
|
||||
# hang. Inner packets stay under Cilium's 1450-byte VXLAN MTU too, so reaching a
|
||||
# cluster pod does not fragment either.
|
||||
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'
|
||||
# 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
|
||||
# `! -o wg0` rather than a named `-o eth0` keeps this independent of the node's NIC
|
||||
# name. Each rule is add-if-absent so a pod killed without PostDown does not
|
||||
# accumulate duplicates in the host's tables across restarts.
|
||||
echo "PostUp = iptables -t nat -C POSTROUTING -s $net ! -o wg0 -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s $net ! -o wg0 -j MASQUERADE"
|
||||
echo 'PostUp = iptables -C FORWARD -i wg0 -j ACCEPT 2>/dev/null || iptables -A FORWARD -i wg0 -j ACCEPT'
|
||||
echo 'PostUp = iptables -C FORWARD -o wg0 -j ACCEPT 2>/dev/null || iptables -A FORWARD -o wg0 -j ACCEPT'
|
||||
echo "PostDown = iptables -t nat -D POSTROUTING -s $net ! -o wg0 -j MASQUERADE 2>/dev/null || true"
|
||||
echo 'PostDown = iptables -D FORWARD -i wg0 -j ACCEPT 2>/dev/null || true'
|
||||
echo 'PostDown = iptables -D FORWARD -o wg0 -j ACCEPT 2>/dev/null || true'
|
||||
echo ''
|
||||
} > /config/wg_confs/wg0.conf
|
||||
|
||||
cat /run/wg-peers/peers.conf >> /config/wg_confs/wg0.conf
|
||||
chmod 600 /config/wg_confs/wg0.conf
|
||||
|
||||
# Report the SHAPE of the result, never its content — the file now holds the server's
|
||||
# private key, and a log line is a transcript (CLAUDE.md §6).
|
||||
echo "wg0.conf assembled: $(grep -c '^\[Peer\]' /config/wg_confs/wg0.conf || true) peer(s), server $base.1/$mask, udp/51820."
|
||||
securityContext:
|
||||
runAsUser: 0
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
memory: 64Mi
|
||||
volumeMounts:
|
||||
- { name: wg-config, mountPath: /config }
|
||||
- { name: wg-server-key, mountPath: /run/wg-server-key, readOnly: true }
|
||||
- { name: wg-peers, mountPath: /run/wg-peers, readOnly: true }
|
||||
containers:
|
||||
- name: wireguard
|
||||
# linuxserver/wireguard — provides wg, wg-quick and iptables on a maintained, multi-arch
|
||||
# base. Pinned to the full upstream+build tag (1.0.20260223, published 2026-10-01) rather
|
||||
# than `latest`, per the crafty/oauth2-proxy convention; Renovate maintains it.
|
||||
#
|
||||
# NO `PEERS` ENV IS SET, DELIBERATELY. With PEERS the image runs in "server mode" and
|
||||
# GENERATES keypairs at runtime — which would put the authoritative peer list on a
|
||||
# container filesystem instead of in git, and (worse) print client private keys and QR
|
||||
# codes into the POD LOG, where they would be readable by anyone with log access and
|
||||
# shipped to Loki. Supplying our own /config/wg_confs/wg0.conf selects the image's
|
||||
# bring-up-what-you-are-given path instead.
|
||||
image: lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls124
|
||||
imagePullPolicy: IfNotPresent
|
||||
env:
|
||||
- name: TZ
|
||||
value: "America/Chicago"
|
||||
ports:
|
||||
# hostNetwork, so containerPort IS the node port. Declared for documentation and so
|
||||
# `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
|
||||
# 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
|
||||
# never needs to load a kernel module. If the module is missing the pod CrashLoops with
|
||||
# a clear wg-quick error rather than quietly falling back to slow userspace WireGuard.
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- ALL
|
||||
add:
|
||||
- NET_ADMIN
|
||||
# readOnlyRootFilesystem intentionally UNSET: the image's s6 init writes under /run and
|
||||
# /var during startup. Same deferral as crafty/headlamp.
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
# Kernel WireGuard does its crypto in the KERNEL, not in this container, so throughput
|
||||
# does not consume the container's budget. The limit covers s6 plus the tmpfs holding
|
||||
# wg0.conf (a memory-medium emptyDir counts against this).
|
||||
memory: 128Mi
|
||||
# `wg show wg0` exits non-zero when the interface is absent, which is the one failure that
|
||||
# matters and the one that a port probe cannot see: a crashed or never-created wg0 leaves
|
||||
# udp/51820 silent, and WireGuard is silent to unauthenticated traffic ANYWAY, so "nothing
|
||||
# answers" is indistinguishable from "working" from the outside. This probe is the only
|
||||
# thing that tells the difference.
|
||||
startupProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "wg show wg0 >/dev/null"]
|
||||
periodSeconds: 5
|
||||
failureThreshold: 24
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["sh", "-c", "wg show wg0 >/dev/null"]
|
||||
periodSeconds: 30
|
||||
failureThreshold: 3
|
||||
volumeMounts:
|
||||
- { name: wg-config, mountPath: /config }
|
||||
# Read-only so the image can SEE whether the kernel module is present and fail with a
|
||||
# real message. It cannot load one (no SYS_MODULE) — that is the node's job.
|
||||
- { name: lib-modules, mountPath: /lib/modules, readOnly: true }
|
||||
volumes:
|
||||
# tmpfs. The assembled wg0.conf contains the server's private key, so it must never land on
|
||||
# a node's disk — `medium: Memory` guarantees that and guarantees it is gone when the pod is.
|
||||
- name: wg-config
|
||||
emptyDir:
|
||||
medium: Memory
|
||||
# 8Mi, not 1Mi: /config is this image's whole config ROOT, not just our one file — its
|
||||
# s6 init also writes templates and state there, and a tmpfs that small would ENOSPC
|
||||
# during startup rather than at a point anyone would connect to the volume size. Still
|
||||
# counted against the container's 128Mi limit, and still far more than wg0.conf needs.
|
||||
sizeLimit: 8Mi
|
||||
# The ONLY secret in this component. Created OUT OF BAND by the operator and referenced by
|
||||
# name (R-IDP-04) — runbooks/vpn-remote-access.md step 1. It is NOT a *.sops.yaml in this
|
||||
# tree on purpose: an agent must not mint a long-lived private key (CLAUDE.md §6), and a key
|
||||
# that passed through a session transcript would have to be treated as compromised.
|
||||
# DR: Velero's `daily` schedule excludes only `monitoring` and `unifi`, so this namespace
|
||||
# and this Secret ARE captured off-cluster (platform/velero/helmrelease.yaml). Keep a copy
|
||||
# in the operator's password manager as well — restoring it is what makes every already
|
||||
# enrolled client work again without re-enrolling.
|
||||
- name: wg-server-key
|
||||
secret:
|
||||
secretName: wg-server-key
|
||||
defaultMode: 0400
|
||||
items:
|
||||
- { key: privatekey, path: privatekey }
|
||||
- name: wg-peers
|
||||
configMap:
|
||||
name: wg-peers
|
||||
- name: lib-modules
|
||||
hostPath:
|
||||
path: /lib/modules
|
||||
type: Directory
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
# WireGuard remote-access VPN (north-south INGRESS). Reconciled by its OWN non-gating post-platform
|
||||
# Flux Kustomization (clusters/base/vpn-ingress.yaml, dependsOn platform-config, wait:false) — the
|
||||
# same placement as crafty/headlamp/homepage/runbooks, for the same reason: a leaf whose pod can be
|
||||
# legitimately unhealthy must never sit in the gating `platform` tier, where one component's
|
||||
# unhealth stalls platform-config and every apps wave behind it (DECISIONS-LOG: "Headlamp gating
|
||||
# stall"). That rationale is unusually concrete here: until the operator creates the `wg-server-key`
|
||||
# Secret (runbooks/vpn-remote-access.md step 1) this DaemonSet CANNOT start, so putting it in the
|
||||
# gating tier would have taken the whole platform down on merge.
|
||||
#
|
||||
# NOT in platform/kustomization.yaml — see clusters/base/vpn-ingress.yaml.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
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
|
||||
# says so at length rather than implying a protection it cannot provide.
|
||||
- networkpolicy.yaml
|
||||
#
|
||||
# DELIBERATELY ABSENT: a Secret. The server's private key (`wg-server-key`) is created out of band
|
||||
# by the operator and referenced BY NAME from daemonset.yaml. There is no `*.sops.yaml` here, so
|
||||
# this component needs NO SOPS decryption stanza in clusters/dev or clusters/prod — unlike
|
||||
# crafty/headlamp/velero. If that key is ever migrated into SOPS, BOTH halves must land together:
|
||||
# the file here, and a `decryption:` patch naming the `vpn-ingress` Kustomization in each overlay.
|
||||
# Without the patch, kustomize hands Flux the raw ciphertext and the pod mounts a key full of
|
||||
# ENC[...] — which applies cleanly and fails only at handshake time.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
# WireGuard REMOTE-ACCESS VPN (north-south INGRESS) — the counterpart to platform/vpn-egress.
|
||||
#
|
||||
# Do not confuse the two. They are opposite directions and share nothing but the word "VPN":
|
||||
# * platform/vpn-egress — pods OUT to the internet through a PIA tunnel (ADR-0015, R-NET-05/06/07).
|
||||
# * platform/vpn-ingress — remote CLIENTS IN from the internet to this LAN. THIS directory.
|
||||
#
|
||||
# NOTE: intentionally NOT homelab.keithjackson.dev/app-namespace=true. This is PLATFORM network
|
||||
# infrastructure (like cilium / vpn-egress), not an application, so Kyverno does NOT generate the app
|
||||
# default-deny baseline here and the app-scoped auth-mode / egress-class / host-namespace policies do
|
||||
# not apply — every Kyverno policy in platform/kyverno/policies matches namespaceSelector
|
||||
# app-namespace=true. THAT EXEMPTION IS LOAD-BEARING HERE, not incidental: this workload runs with
|
||||
# `hostNetwork: true`, which `restrict-host-namespaces` forbids in an app namespace. It is permitted
|
||||
# here by that policy's OWN stated carve-out — "platform components that do [need host namespaces] --
|
||||
# e.g. Cilium, node-exporter -- live in platform namespaces that do NOT carry the app-namespace
|
||||
# label, so they are out of scope". daemonset.yaml documents why hostNetwork is REQUIRED rather than
|
||||
# convenient; it is the single most consequential decision in this component.
|
||||
#
|
||||
# NO `gateway-access` label. This component serves no HTTP and attaches no HTTPRoute to the shared
|
||||
# Gateway — it is a raw UDP listener. There is deliberately nothing here for the L7 Gateway to route.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: vpn-ingress
|
||||
labels:
|
||||
app.kubernetes.io/name: vpn-ingress
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
# =================================================================================================
|
||||
# READ THIS BEFORE TRUSTING THIS FILE: it does NOT constrain the WireGuard listener.
|
||||
# =================================================================================================
|
||||
# Every other platform namespace in this repo (crafty, headlamp, homepage, runbooks) ships an
|
||||
# explicit default-deny baseline as defense in depth, because Kyverno's generator only covers
|
||||
# `app-namespace=true` namespaces. This file keeps that convention — but it would be dishonest to
|
||||
# leave it at that, because the convention does not actually bite here:
|
||||
#
|
||||
# THE `wireguard` DaemonSet RUNS WITH hostNetwork: true, SO IT HAS NO CILIUM ENDPOINT, AND NO
|
||||
# NetworkPolicy OR CiliumNetworkPolicy APPLIES TO IT — NEITHER THIS ONE NOR ANY OTHER.
|
||||
#
|
||||
# That is the documented reason restrict-host-namespaces forbids hostNetwork in app namespaces at
|
||||
# all ("a pod on hostNetwork bypasses the CNI (Cilium) datapath and therefore ESCAPES the
|
||||
# default-deny NetworkPolicy fabric -- it is the most direct way to defeat R-NET-01"). Here that
|
||||
# escape is the deliberate, required design (daemonset.yaml explains why: without it, remote
|
||||
# clients carry a pod identity and are 403'd by every Gateway hostname). A CiliumNetworkPolicy
|
||||
# admitting `world` to udp/51820 would therefore be theatre: it would read as the control that
|
||||
# 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
|
||||
# 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
|
||||
# ("clients should reach the entire local network"). README "What a client can reach" states
|
||||
# the consequence in full, including reach to the nodes' own control plane, and gives the
|
||||
# narrowing recipe for an operator who wants less than that.
|
||||
#
|
||||
# WHAT THIS FILE IS STILL FOR: it is the namespace's standing posture for any FUTURE pod-networked
|
||||
# workload added here (a metrics exporter is the likely one — README "Not built"). Without it, such
|
||||
# a pod would be born unrestricted, because Kyverno will not generate a baseline in a namespace
|
||||
# that is not labelled `app-namespace=true`, and this one deliberately is not.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: vpn-ingress
|
||||
labels:
|
||||
homelab.keithjackson.dev/baseline: default-deny
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress: []
|
||||
egress:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
# ============================================================================================
|
||||
# THE PEER REGISTER — every device allowed to dial in, in PLAINTEXT, in git, on purpose.
|
||||
# ============================================================================================
|
||||
# WireGuard has no usernames, no passwords and no PKI. Authorisation is exactly one thing: a
|
||||
# device's PUBLIC key appears in this file, or it cannot complete a handshake. An unlisted key
|
||||
# gets no reply at all — not a rejection, silence (see README "Why this is safe to expose").
|
||||
# Revoking a device is therefore deleting its stanza, and nothing else.
|
||||
#
|
||||
# WHY PLAINTEXT, AND WHY THIS IS NOT A SECRET LEAK. A WireGuard peer's PUBLIC key is public by
|
||||
# construction — it is the half you are supposed to hand to the server, and holding it grants
|
||||
# nothing. The only secret in the whole scheme is the SERVER's private key, which is NOT here:
|
||||
# it lives in the `wg-server-key` Secret and is assembled into the running config at pod start
|
||||
# (daemonset.yaml). Keeping the register in cleartext buys the property that matters most for an
|
||||
# access-control list: **"who can get into my house" is reviewable in a pull-request diff**, and
|
||||
# its history is `git log` on this file. A SOPS-encrypted register would hide exactly the change
|
||||
# a reviewer most needs to see — a new line granting a new device entry to the whole LAN.
|
||||
#
|
||||
# This is the same division of labour the repo already uses for the opposite case: a Secret's KEY
|
||||
# NAMES are cleartext while values are encrypted (.sops.yaml `encrypted_regex`), so diffs stay
|
||||
# readable. Here the whole file is the key-name half.
|
||||
#
|
||||
# ADDING A DEVICE — never hand-roll this, and never generate a key in a chat transcript.
|
||||
# ./infra/vpn-ingress/new-peer.sh <device-name> <last-octet>
|
||||
# It generates the keypair ON THE OPERATOR'S MACHINE, writes/renders the client profile (and a
|
||||
# QR code for phones), prints ONLY the public half for pasting below, and never transmits or
|
||||
# logs the private half. Then: add the stanza, PR, merge; Flux rolls the DaemonSet.
|
||||
#
|
||||
# ADDRESS ALLOCATION. ${WG_CLIENT_CIDR} is the tunnel subnet; .1 is the server (every node).
|
||||
# Give each device ONE address as a /32 in AllowedIPs. A /32 is not a formality — on the SERVER
|
||||
# side AllowedIPs is the cryptokey ROUTING table AND the inbound source filter: a peer may only
|
||||
# send from, and receive traffic for, the addresses listed in its own stanza. A sloppy /24 here
|
||||
# would let any one device impersonate every other device's tunnel address.
|
||||
#
|
||||
# .1 the server (this DaemonSet, on every node)
|
||||
# .2 - .254 devices, one each, allocated below. Keep this list dense and in order so the
|
||||
# next free octet is obvious; never re-use an octet after revoking a device
|
||||
# (an old client profile would silently start working again as someone else).
|
||||
#
|
||||
# PRESHARED KEYS are deliberately NOT used. They add a symmetric post-quantum hedge on top of
|
||||
# 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.
|
||||
@@ -120,6 +120,9 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
|
||||
- **`vpn-egress-stale`** — the tunnel is UP but `egress-class=vpn` pods have no egress: Cilium's cached
|
||||
ifindex outlived a reconnect. Also covers the watchdog for that rule going inert.
|
||||
- **`denied-flows`** — policy-denied flow spike. The Cilium hairpin/identity regression class.
|
||||
- **`vpn-remote-access`** — the INBOUND remote-access VPN (WireGuard, udp/51820): standing it up,
|
||||
enrolling and revoking devices, and the off-LAN checks that are the only honest proof it works.
|
||||
The opposite direction to the two pages above — do not confuse them.
|
||||
|
||||
### Backups and DR
|
||||
- **`backup-failure`** — a Velero backup failed. The R2 object-tagging incompatibility is documented here.
|
||||
|
||||
@@ -113,3 +113,8 @@ configMapGenerator:
|
||||
- update-rollback=update-rollback.md
|
||||
- vpn-down=vpn-down.md
|
||||
- vpn-egress-stale=vpn-egress-stale.md
|
||||
# Operator procedure, not an alert page. Served because the component it completes is INERT
|
||||
# until three off-repo manual steps are done (kernel module, server key, WAN port-forward) —
|
||||
# and because the one check that actually proves it cannot be run from inside the house.
|
||||
# Peer-level alerting does not exist yet (it needs an exporter); this page is the substitute.
|
||||
- vpn-remote-access=vpn-remote-access.md
|
||||
@@ -0,0 +1,226 @@
|
||||
# vpn-remote-access — standing up the WireGuard remote-access VPN, and proving it
|
||||
|
||||
**Status: the in-cluster half is shipped; the VPN is INERT until three manual steps are done.**
|
||||
All three are off-repo by necessity — a kernel module, a private key, and a router port-forward.
|
||||
**Alert:** pod-down is covered generically (`KubePodNotReady` / `KubeDeploymentReplicasMismatch`).
|
||||
There is **no peer-level alerting** — see "Why there is no connection alert". This is an **operator
|
||||
procedure**, not an incident page.
|
||||
**Files:** `platform/vpn-ingress/` (namespace, peer register, DaemonSet, baseline policy),
|
||||
`clusters/base/vpn-ingress.yaml` (its Flux tier), `clusters/base/cluster-vars.yaml`
|
||||
(`WG_CLIENT_CIDR`), `infra/ansible/roles/node_baseline/defaults/main.yml` (the `wireguard` module),
|
||||
`infra/vpn-ingress/new-peer.sh` (device enrolment).
|
||||
**Design rationale:** `platform/vpn-ingress/README.md` — read "Why `hostNetwork` is required"
|
||||
before changing anything about how this is wired.
|
||||
**Not to be confused with** `vpn-down` / `vpn-egress-stale`, which are about `platform/vpn-egress`
|
||||
(pods *out* through PIA). This page is remote clients *in*.
|
||||
|
||||
## What this gives you
|
||||
|
||||
A device — Android, Linux, Windows, macOS, iOS — dials in over **udp/51820** from anywhere and
|
||||
behaves like a machine on the LAN: full reach to `192.168.2.0/24`, the Gateway's web UIs, PiHole
|
||||
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.
|
||||
|
||||
> **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
|
||||
> the entire local network" means. Revoke a lost device promptly: delete its stanza, PR, merge.
|
||||
|
||||
## Step 1 — load the `wireguard` module on every node (once)
|
||||
|
||||
The DaemonSet creates its `wg0` link in the host netns with `CAP_NET_ADMIN` only; it is
|
||||
deliberately **not** granted `CAP_SYS_MODULE`, so it cannot autoload the module. Without it
|
||||
`wg-quick` fails outright and the pod CrashLoops.
|
||||
|
||||
```bash
|
||||
# operator action (Ansible is not an agent action). Ansible lives ONLY in the pinned toolbox,
|
||||
# and ansible.cfg is only picked up from infra/ansible, so cd there inside the container.
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'cd /work/infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags system'
|
||||
```
|
||||
|
||||
Three details in that command are load-bearing, not style:
|
||||
|
||||
- **`-e k3s_hosts=k3s_prod`.** `node-baseline.yml` targets `k3s_servers` by default, which is the
|
||||
**dev** group — and dev was decommissioned, so an unqualified run does nothing useful. This is
|
||||
the targeting pattern `infra/ansible/README.md` "Targeting prod" documents for every playbook.
|
||||
- **`--tags system`, and never an untagged run.** The two kernel-module tasks carry the `system`
|
||||
tag. An untagged run would also execute the `disks` tasks, which assert on block-device variables
|
||||
prod has deliberately left as placeholders (`infra/PROD-PROVISIONING.md` §2) — the playbook's own
|
||||
header warns about this. `--tags system` is broader than the modules alone (it also re-asserts
|
||||
sysctls, iscsid, qemu-guest-agent and the swap masking) but every one of those is idempotent and
|
||||
already in its desired state on a live node.
|
||||
- **It is a playbook, not a wrapper script.** There is no `site.sh` in this repo; `playbooks/site.yml`
|
||||
is the full bring-up chain and is the wrong tool here.
|
||||
|
||||
Verify on each node — the module is in-tree since kernel 5.6, so nothing is installed, only loaded:
|
||||
|
||||
```bash
|
||||
# expected: a `wireguard` line, and the modules-load.d file naming it for the next boot
|
||||
lsmod | grep '^wireguard'
|
||||
grep -w wireguard /etc/modules-load.d/k3s-cilium-longhorn.conf
|
||||
```
|
||||
|
||||
## Step 2 — create the server key (once)
|
||||
|
||||
This is the **only** secret in the component, and the one thing that must never be generated in an
|
||||
agent session or pasted into a transcript. Generate it on your own machine:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/wireguard-home && chmod 700 ~/.config/wireguard-home
|
||||
umask 077
|
||||
wg genkey > ~/.config/wireguard-home/server.key # keep this; see "Back it up" below
|
||||
```
|
||||
|
||||
Load it into the cluster. **Do not echo it, do not pass it on an interactive command line that
|
||||
lands in shell history, and never print the resulting Secret's `.data`** (CLAUDE.md §6):
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n vpn-ingress create secret generic wg-server-key \
|
||||
--from-file=privatekey=/work/.cache/server.key'
|
||||
```
|
||||
|
||||
…where `/work/.cache/server.key` is a path you copied the key to (`.cache/` is not tracked; create
|
||||
it if absent). Delete that copy as soon as the Secret exists: `shred -u .cache/server.key`.
|
||||
|
||||
> **KEEP THE `.key` SUFFIX.** That filename is what keeps the key out of git: `.gitignore` line 11
|
||||
> is `*.key`, and it is the **only** rule covering this path — `.cache/` itself is NOT ignored
|
||||
> (`git check-ignore -v .cache/serverkey.txt` matches nothing). So a copy named `server.pem`,
|
||||
> `wg-server` or `privatekey` sitting in that directory is **committable**, and the gate's
|
||||
> `no-plaintext-secrets` stage does not know a WireGuard private key when it sees one. Verify before
|
||||
> you trust it: `git check-ignore -v .cache/<your-filename>` must print a rule, and
|
||||
> `git status --short` must not list the file.
|
||||
|
||||
Confirm it exists **by key name only**:
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n vpn-ingress get secret wg-server-key -o jsonpath="{.data}" | tr "," "\n" | cut -d: -f1'
|
||||
# expected: {"privatekey -- a NAME. If you see base64 payload, you printed the key. Rotate it.
|
||||
```
|
||||
|
||||
**Why out of band and not SOPS.** An agent must not mint a long-lived private key, and a key that
|
||||
passed through a session transcript would have to be treated as compromised. The trade-off is that
|
||||
this Secret is **not reconstructible from git** — so:
|
||||
|
||||
**Back it up.** Keep `server.key` in your password manager. Velero's `daily` schedule excludes only
|
||||
`monitoring` and `unifi` (`platform/velero/helmrelease.yaml`), so this namespace *is* captured
|
||||
off-cluster to R2 as well. Restoring this one key is what makes every already-enrolled device work
|
||||
again after a cluster rebuild — **losing it means re-enrolling every device.**
|
||||
|
||||
## Step 3 — forward udp/51820 on the router (once)
|
||||
|
||||
On the SOHO router at `192.168.2.1`, forward **WAN udp/51820** to **one node address**, e.g.
|
||||
`192.168.2.10:51820`. This is hand-made and off-repo; it is the half CI can never check.
|
||||
|
||||
Pick any node — all three run an identical listener. Note which one you chose here:
|
||||
|
||||
```
|
||||
# WAN udp/51820 -> 192.168.2.__ (k3s-prod-_) set <date>
|
||||
```
|
||||
|
||||
**Node loss = failover is this one line.** Re-point the forward at another node address; every
|
||||
device keeps working with the same profile, because all three nodes answer the same keys.
|
||||
|
||||
Do **not** also expose this on tcp, and do not change the port hoping for obscurity — WireGuard is
|
||||
already silent to unauthenticated probes (README, "Why this is safe to expose").
|
||||
|
||||
## Step 4 — confirm the listener is healthy
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n vpn-ingress get ds wireguard -o wide'
|
||||
# expected: DESIRED 3 CURRENT 3 READY 3
|
||||
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show'
|
||||
# expected: `interface: wg0`, `listening port: 51820`, and (before step 5) NO peers.
|
||||
```
|
||||
|
||||
A zero-peer interface that is Ready is the **correct** state before any device is enrolled: it
|
||||
listens, and refuses every handshake because no key is trusted.
|
||||
|
||||
The readiness/liveness probe is `wg show wg0`, and that is load-bearing rather than cosmetic: a
|
||||
port probe cannot distinguish "wg0 never came up" from "working", because WireGuard is silent to
|
||||
unauthenticated traffic either way. If the pod is Ready, the interface exists.
|
||||
|
||||
## Step 5 — enrol a device
|
||||
|
||||
```bash
|
||||
./infra/vpn-ingress/new-peer.sh pixel-8 2 # full tunnel (LAN + WAN egress)
|
||||
./infra/vpn-ingress/new-peer.sh thinkpad 3 --split # split tunnel (LAN only)
|
||||
```
|
||||
|
||||
It generates the keypair **on your machine**, writes a `0600` client profile outside the checkout,
|
||||
prints a QR code if `qrencode` is installed, and prints the `[Peer]` stanza to add. Then:
|
||||
|
||||
1. Paste the stanza into `platform/vpn-ingress/peers.yaml` under `data.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).
|
||||
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
|
||||
device's private key — the server never sees it and it cannot be re-derived.
|
||||
|
||||
Until the PR is merged the device handshakes and gets silence, exactly like an unknown key.
|
||||
|
||||
## Step 6 — verify it end to end (from OFF the LAN)
|
||||
|
||||
**This is the only honest test, and it cannot be run from inside the house.** Put the phone on
|
||||
cellular, with Wi-Fi off, and check all four — each proves a different hop:
|
||||
|
||||
| # | Check | Proves |
|
||||
|---|---|---|
|
||||
| 1 | tunnel shows a recent handshake in the WireGuard app | the WAN forward + the server key + the peer stanza |
|
||||
| 2 | `ping 192.168.2.1` (the router) | decrypt → FORWARD → LAN, and the SNAT return path |
|
||||
| 3 | open `https://plex.home.keithjackson.dev` | PiHole DNS over the tunnel **and** the Gateway accepting a `host`-identity source — the specific thing a pod-networked VPN would have 403'd |
|
||||
| 4 | visit an IP-echo site; it should show `136.51.115.252` | full-tunnel WAN egress out the house, not the carrier |
|
||||
|
||||
Then confirm server-side:
|
||||
|
||||
```bash
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'kubectl --context k3s-prod -n vpn-ingress exec ds/wireguard -- wg show'
|
||||
# a `latest handshake` under ~3 min, and non-zero transfer, on the expected peer.
|
||||
```
|
||||
|
||||
**If check 3 fails while 2 passes**, the tunnel is fine and the problem is DNS or the Gateway, not
|
||||
WireGuard. Check the client's `DNS = 192.168.2.23` and that PiHole is up.
|
||||
|
||||
**If 1 and 2 pass but large transfers hang**, it is MTU. The profile ships `MTU = 1420` (1500 − 60
|
||||
bytes of WireGuard encapsulation); drop the client to `1280` and re-test.
|
||||
|
||||
**If nothing handshakes at all**, you cannot tell a wrong forward from a wrong key from outside —
|
||||
by design. Work inwards: `wg show` on the node holding the forward will show *zero* received bytes
|
||||
if the router is not delivering the packets, and a `handshake` with no completed session if it is.
|
||||
|
||||
## Revoking a device
|
||||
|
||||
Delete its `[Peer]` stanza from `peers.yaml`, 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.
|
||||
|
||||
## Why there is no connection alert
|
||||
|
||||
A WireGuard exporter would publish `wireguard_latest_handshake_seconds` per peer and make
|
||||
"something connected at 03:00" alertable. It needs a second `NET_ADMIN` container inside the pod
|
||||
that terminates the internet-facing tunnel, and that was not worth shipping unverified. **It is the
|
||||
top follow-up for this component.** Until then:
|
||||
|
||||
* pod-down is covered generically by kube-prometheus-stack's `KubePodNotReady` /
|
||||
`KubeDeploymentReplicasMismatch`;
|
||||
* "who is connected" is an on-demand question — `wg show`, above;
|
||||
* **never** `wg showconf` — it prints the server's private key.
|
||||
|
||||
## Known limitations
|
||||
|
||||
* **No IPv6 in the tunnel.** A full-tunnel client with native IPv6 still reaches IPv6 sites outside
|
||||
the tunnel. Functionally harmless; a privacy leak if hiding the client's network was the point.
|
||||
* **Client IPs are not preserved** on the LAN side: everything arrives SNATed as the node's
|
||||
address. A static route for `10.90.0.0/24` → the forwarded node, added on the router, would
|
||||
preserve them — at the cost of another hand-maintained off-repo route.
|
||||
* **Rollouts briefly drop the forwarded node.** `maxUnavailable: 1`, so one node at a time; clients
|
||||
re-handshake automatically (retry every 5s, rekey every 2 min).
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://home.keithjackson.dev/schemas/servicedefinition.schema.json",
|
||||
"title": "ServiceDefinition",
|
||||
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.10.0 (adds optional `availability` for the container kind, one member: `availability.replicas`. It is AVAILABILITY INTENT, not a scheduling primitive -- it names no Kubernetes object, no topology key and no budget. 1 (the default) renders a Deployment exactly as before, so every pre-existing app stays byte-identical; >1 means the service must survive the loss of a single node, and the renderer satisfies that with a StatefulSet whose longhorn volumes become per-replica volumeClaimTemplates (a longhorn PVC is ReadWriteOnce and cannot attach on two nodes, so a multi-replica Deployment is UNSCHEDULABLE, not merely suboptimal), REQUIRED podAntiAffinity on kubernetes.io/hostname (preferred would let every replica land on one node and deliver none of the intended tolerance while reporting healthy), podManagementPolicy Parallel, a PodDisruptionBudget of minAvailable 1, and an `up` SLI that follows the rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever and can never fire). Added for pihole, where LAN DNS -- a critical network service -- was one pod on one node: measured 2026-10-04, losing the node running it meant roughly 6-10 minutes with no LAN resolution (tolerationSeconds 300 plus node-NotReady detection plus a Longhorn RWO force-detach). Deliberately NOT usable until a service's state is reproducible from git, because a replica with a fresh volume otherwise comes up serving but unconfigured -- for pihole that prerequisite was done first (decisions/2026-10-04-pihole-declare-state.md). Capped at 5: a count above the node count can never schedule under the anti-affinity this implies. Mechanism documented in schemas/renderer-contract.md `### availability`). 1.9.0 (adds optional `auth.apiRoutes` -- a per-service list of anchored path regexes the forward-auth oauth2-proxy sidecar answers with 401 instead of a 302 to Keycloak, rendered as one repeatable `--api-route` per entry. The `auth` block stays `additionalProperties: false`; this is one named, documented key, not an opened door. Without it there was NO per-app override hook at all, so the only way to answer an app's XHRs with 401 was a renderer-wide constant -- and the path shape is a property of the app: jackett's is `/api/` (33 x 302 vs 2 x 401 measured 2026-09-30, 4 of 6 POSTs answered by the proxy and never reaching the app), radarr's is `/signalr/` (232 x 302 measured 2026-10-01, every one a POST from a left-open browser tab that is never re-prompted), and pihole serves no `/api/` at all. Each entry must be anchored at `^/` and continue with literal path characters, because a too-broad regex turns a plain page load into a 401 and the service looks permanently broken to a logged-out user. Enforced on every gate run by ci/l0.sh's schema stage over catalog/*.yaml, by catalog/render.py refusing to render an unanchored entry, and by conformance/tests/l0-static/oauth2-proxy-api-routes-per-app)). 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with just two things -- a /dev/dri hostPath volume (`DirectoryOrCreate`) and a read-write /dev/dri volumeMount on the app container -- and a later move to the Intel GPU device plugin is then a render.py change with no catalog edit. It sets NO podSecurityContext.supplementalGroups: measured in-container on prod 2026-09-30, the pms-docker cont-init hook adds the app user to the device's group inside the container and `s6-setuidgid` then REPLACES the process group list from /etc/group, discarding anything Kubernetes supplied, so such an entry would be inert and misleading. Renders NOTHING when absent, so every existing app stays byte-identical. Emits no privileged securityContext (Kyverno restrict-host-namespaces would reject it and it is not needed), no nodeSelector (node placement is a separate concern with its own justification) and no resource requests). 1.7.0 (no field changes; a MAPPING change to `exposure`, which had never had an effect on DNS. `exposure: internal` now renders ExternalDNS's own controller annotation onto every HTTPRoute it emits, set to a value other than `dns-controller`, so external-dns (v0.21.0, source/gateway.go -> annotations.IsControllerMismatch) skips the route before resolving any hostname and the service is INCAPABLE of producing a public record. Both docs/09 R-DNS-03 and schemas/renderer-contract.mdLine truncated
|
||||
"description": "Operator-facing onboarding contract for the home k3s + Longhorn platform. One ServiceDefinition per file under catalog/. Captures INTENT; the platform compiles it into concrete resources (see schemas/renderer-contract.md). Canonical source of truth: spec docs/13-service-catalog.md. Serves R-GITOPS-02; ties into R-NET-*, R-STATE-*, R-TLS-*, R-DNS-*, R-OBS-*, R-UPD-*, R-IDP-*. Schema version: 1.11.0 (adds optional `optional` to env.valueFrom.secretKeyRef and env.valueFrom.configMapKeyRef -- the k8s EnvVarSource field, emitted verbatim. Without it a secretKeyRef was ALWAYS hard: a missing Secret meant the pod would not start at all. That is the right default and stays the default, but it cannot express a value the workload is designed to run without. Added for plex, whose TLS material is issued by cert-manager INTO ITS OWN NAMESPACE and therefore cannot exist before the app wave that creates that namespace -- the Certificate and the Deployment land in the same reconcile, so on a cold bootstrap the Secret is briefly absent while a DNS01 challenge completes. Hard refs there would have made the media server unstartable until a certificate issued, and `apps-wave-0` is `wait: true`, so a failed or slow issuance would have STALLED THE WHOLE APP TIER -- the same hazard catalog/plex.yaml already documents for its GPU resource. With `optional: true` Plex starts regardless, its postStart hook logs that the certificate is not installed, and the next start after issuance converges. Renders nothing when absent, so every existing app stays byte-identical). 1.10.0 (adds optional `availability` for the container kind, one member: `availability.replicas`. It is AVAILABILITY INTENT, not a scheduling primitive -- it names no Kubernetes object, no topology key and no budget. 1 (the default) renders a Deployment exactly as before, so every pre-existing app stays byte-identical; >1 means the service must survive the loss of a single node, and the renderer satisfies that with a StatefulSet whose longhorn volumes become per-replica volumeClaimTemplates (a longhorn PVC is ReadWriteOnce and cannot attach on two nodes, so a multi-replica Deployment is UNSCHEDULABLE, not merely suboptimal), REQUIRED podAntiAffinity on kubernetes.io/hostname (preferred would let every replica land on one node and deliver none of the intended tolerance while reporting healthy), podManagementPolicy Parallel, a PodDisruptionBudget of minAvailable 1, and an `up` SLI that follows the rendered kind (a Deployment-shaped rule against a StatefulSet absent()s forever and can never fire). Added for pihole, where LAN DNS -- a critical network service -- was one pod on one node: measured 2026-10-04, losing the node running it meant roughly 6-10 minutes with no LAN resolution (tolerationSeconds 300 plus node-NotReady detection plus a Longhorn RWO force-detach). Deliberately NOT usable until a service's state is reproducible from git, because a replica with a fresh volume otherwise comes up serving but unconfigured -- for pihole that prerequisite was done first (decisions/2026-10-04-pihole-declare-state.md). Capped at 5: a count above the node count can never schedule under the anti-affinity this implies. Mechanism documented in schemas/renderer-contract.md `### availability`). 1.9.0 (adds optional `auth.apiRoutes` -- a per-service list of anchored path regexes the forward-auth oauth2-proxy sidecar answers with 401 instead of a 302 to Keycloak, rendered as one repeatable `--api-route` per entry. The `auth` block stays `additionalProperties: false`; this is one named, documented key, not an opened door. Without it there was NO per-app override hook at all, so the only way to answer an app's XHRs with 401 was a renderer-wide constant -- and the path shape is a property of the app: jackett's is `/api/` (33 x 302 vs 2 x 401 measured 2026-09-30, 4 of 6 POSTs answered by the proxy and never reaching the app), radarr's is `/signalr/` (232 x 302 measured 2026-10-01, every one a POST from a left-open browser tab that is never re-prompted), and pihole serves no `/api/` at all. Each entry must be anchored at `^/` and continue with literal path characters, because a too-broad regex turns a plain page load into a 401 and the service looks permanently broken to a logged-out user. Enforced on every gate run by ci/l0.sh's schema stage over catalog/*.yaml, by catalog/render.py refusing to render an unanchored entry, and by conformance/tests/l0-static/oauth2-proxy-api-routes-per-app)). 1.8.0 (adds optional `hardware` - a host HARDWARE CAPABILITY declaration for the container kind, currently one member: `hardware.gpu: intel-quicksync`, meaning the workload needs the host iGPU render device for hardware video transcode. It is an ENUM OF INTENT, not a device path: the renderer satisfies it today with just two things -- a /dev/dri hostPath volume (`DirectoryOrCreate`) and a read-write /dev/dri volumeMount on the app container -- and a later move to the Intel GPU device pluLine truncated
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "source"],
|
||||
@@ -69,7 +69,8 @@
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"key": { "type": "string", "minLength": 1 }
|
||||
"key": { "type": "string", "minLength": 1 },
|
||||
"optional": { "type": "boolean", "description": "Tolerate the key being ABSENT: the env var is simply unset instead of the pod refusing to start (kubelet CreateContainerConfigError). Default false, which is the pre-1.11.0 behaviour. Use ONLY where the workload degrades gracefully without the value, and say so at the call site -- a silently-empty credential is usually worse than a pod that will not start." }
|
||||
}
|
||||
},
|
||||
"configMapKeyRef": {
|
||||
@@ -78,7 +79,8 @@
|
||||
"required": ["name", "key"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "minLength": 1 },
|
||||
"key": { "type": "string", "minLength": 1 }
|
||||
"key": { "type": "string", "minLength": 1 },
|
||||
"optional": { "type": "boolean", "description": "Tolerate the key being ABSENT: the env var is simply unset instead of the pod refusing to start (kubelet CreateContainerConfigError). Default false, which is the pre-1.11.0 behaviour. Use ONLY where the workload degrades gracefully without the value, and say so at the call site -- a silently-empty credential is usually worse than a pod that will not start." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user