Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5 989649e64a feat(cilium): export Plex's pre-SNAT client address via Hubble, scoped to tcp/32400
gate / gate (pull_request) Successful in 1m49s
gate / conformance (pull_request) Has been skipped
The requirement is log visibility only -- which address connected to Plex.

Plex's own log cannot show it. Every LoadBalancer Service here is
externalTrafficPolicy: Cluster, so the node holding the VIP's L2 lease SNATs
the client before forwarding to the backend pod. Measured: Plex had logged
3,214 requests from 10.0.1.x (lease holder k3s-prod-3's own address, Cilium
identity 1 = reserved:host) and ZERO from any 192.168.x address, ever. No
X-Forwarded-For either -- it serves raw tcp/32400, not HTTP via the Gateway.
Nothing on the router does this; there is no hairpin NAT. It is Cilium.

The address is not lost, only unrecorded. Cilium observes the flow at ingress,
BEFORE its own translation, so Hubble already has it:

  192.168.2.133 -> 10.0.0.135   real LAN client -> the Plex pod
  10.0.1.78     -> 10.0.0.135   same traffic post-SNAT, what Plex sees

What was missing is RETENTION: 4095/4095 flows at ~303/s is about thirteen
seconds of history, metrics disabled, no exporter configured.

So enable the static exporter, port-scoped, to the agent's stdout -- Alloy
already tails every pod into Loki, so this needs no new mount, no rotation
handling and no Alloy change.

  {namespace="kube-system", app="cilium"} |= "32400"

Scoped BY PORT, not namespace, on evidence: --to-namespace plex returned only
the post-SNAT flows; the record carrying the real client address did not match
it. A namespace filter would drop exactly what this exists to capture.

Rejected, with reasons: eTP:Local is incompatible with cilium 1.20's L2
announcer per upstream docs for this version ("may cause service IPs to be
announced on nodes without pods causing traffic drops") -- and the VIP is on
prod-3 while the pod is on prod-1, so it would black-hole 32400 immediately;
making it safe costs Plex its ability to reschedule. DSR: routing-mode is
vxlan, not native/geneve, and DSR returns bypass the ingress node, breaking
the router's stateful NAT on the remote path.

Also corrects the repo's own record: 2026-10-03 says the SNAT lands in
192.168.0.0/16 (the node's LAN address). It is 10.0.1.x, the cilium_host
inside the pod CIDR -- which is why LanNetworksBandwidth can never separate
local from remote while eTP is Cluster.

UNVERIFIED and stated in the handoff: nothing is reconciled; `filePath: stdout`
is taken from a verbatim upstream sentence but not confirmed against 1.20.1
source (a wrong notation is a silent no-op, with the file+Alloy fallback
documented); the port FlowFilter has no upstream example; and this change
ROLLS CILIUM -- prod-3 announces the Gateway, PiHole DNS and Plex VIPs, so a
lease move during the rollout could briefly interrupt LAN DNS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 16:36:12 -05:00
jakman85 422834d4e2 Merge pull request 'fix(plex): advertise the plex.direct name, not the bare VIP (server vanished from the plex.tv account)' (#47) from fix/plex-advertise-plexdirect into main
gate / gate (push) Successful in 1m46s
gate / conformance (push) Has been skipped
Reviewed-on: #47
Reviewed-by: Keith Jackson <[email protected]>
2026-10-04 07:59:14 +00:00
jakman85andClaude Opus 5 1f4552d409 fix(plex): advertise the plex.direct name, not the bare VIP (server vanished from the plex.tv account)
gate / gate (pull_request) Successful in 1m48s
gate / conformance (pull_request) Has been skipped
customConnections held two hand-typed URLs and neither could ever complete, so
Plex had nothing publishable and the server disappeared from the account:

  - https://192.168.2.24:32400 -- the server's cert is
    *.130072f20c334d6c8f63d0dfef87309b.plex.direct with NO IP SAN, so bare-IP
    https fails TLS for every client. Measured: a continuous storm of
    "CERT: incomplete TLS handshake ... certificate unknown" from real LAN
    clients arriving through the plex-l4-tcp VIP.
  - https://plex.home.keithjackson.dev:32400 -- nothing listens; the Gateway
    serves that host on 443 (measured http=000 against 192.168.2.20:32400).

With no completable connection plex.tv published nothing ("Mapped - Not
Published"), Plex fell back to Relay, and clients reported "not authorized".

Fix: advertise <dashed-ip>.<serverhash>.plex.direct -- public DNS that resolves
to the embedded address, so it points at the LAN VIP and matches the wildcard
cert. Verified with full TLS validation (no curl -k): 200 + verify OK, where
the bare-IP form fails to connect.

Declared as ADVERTISE_IP so git owns the value instead of a web form: the
image's 40-plex-first-run writes it into customConnections on every pod start,
outside the claim guard.

Does NOT fix off-LAN access -- the advertised address is RFC1918 and the WAN
tcp/32400 forward remains unverified. ManualPortMappingMode=1 with an absent
port is left as-is (no GitOps path exists for it).

Touches clusters/** -> sensitive paths -> needs the operator's
/approve-override, deliberately not self-posted (CLAUDE.md 1).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 02:53:01 -05:00
jakman85 c95b1c7121 Merge pull request 'fix(cilium): point cgroup.hostRoot at the host cgroup2 root (socket-LB never covered host processes)' (#42) from fix/cilium-cgroup-hostroot into main
gate / gate (push) Failing after 1m45s
gate / conformance (push) Has been skipped
Reviewed-on: #42
2026-10-04 07:10:31 +00:00
jakman85 ed6afc3d3f Merge pull request 'docs(decisions): record that PiHole HA is verified live, failover drill included' (#46) from docs/pihole-ha-verified into main
gate / gate (push) Successful in 1m46s
gate / conformance (push) Has been skipped
Reviewed-on: #46
2026-10-04 07:08:19 +00:00
jakman85andClaude Opus 5.5 9a69f6906a fix(cilium): point cgroup.hostRoot at the host's real cgroup2 root
gate / gate (pull_request) Successful in 1m47s
gate / conformance (pull_request) Has been skipped
hostRoot was /run/cilium/cgroupv2 with autoMount disabled, but nothing
mounts cgroup2 there. The agent mounted one from inside its own private
cgroup namespace, so socket-LB attached to the agent's own cgroup and
host processes got no Service/LB translation. k3s-prod-1 (Gateway L2
lease holder + kube-vip VIP) therefore could not reach the Gateway VIP,
and its apiserver's OIDC authenticator has failed since 2026-09-12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-03 18:07:18 -05:00
10 changed files with 572 additions and 18 deletions

No files matched your search

+2
View File
@@ -137,6 +137,8 @@ spec:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
- name: ADVERTISE_IP
value: ${PLEX_ADVERTISE_URL}
resources:
limits:
gpu.intel.com/i915: 1
+48 -8
View File
@@ -72,14 +72,54 @@ env:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
# clients, so per "prefer safety over completeness" it is left unset. FOLLOW-UP: once remote
# reachability through the Gateway is confirmed, set ADVERTISE_IP: "https://plex.home.keithjackson.dev:443".
# 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.
#
# 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
#
# 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.
#
# 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".
#
# 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.
#
# 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.
- name: ADVERTISE_IP
value: ${PLEX_ADVERTISE_URL}
# 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.
+27 -1
View File
@@ -101,7 +101,7 @@ 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-remote: PLEX_L4_VIP; plex: PLEX_ADVERTISE_URL),
# 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.
@@ -190,6 +190,32 @@ data:
# 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)
# 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
# from `world` is an open resolver and an amplification reflector, so the allow is scoped to this
+10
View File
@@ -496,6 +496,16 @@ 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.
@@ -0,0 +1,76 @@
---
date: 2026-10-03
branch: fix/cilium-cgroup-hostroot
title: Cilium socket-LB never covered host processes — cgroup.hostRoot pointed at an unmounted path
---
## What changed
`cgroup.hostRoot` changes from `/run/cilium/cgroupv2` to `/sys/fs/cgroup` in all three parity
copies: `platform/cilium/helmrelease.yaml` (authoritative), `platform/cilium/values.yaml`
(mirror) and `infra/ansible/roles/cilium/templates/cilium-values.yaml.j2` (bootstrap).
`autoMount` stays `false`.
**The defect.** With `autoMount: false`, the Cilium chart only bind-mounts whatever exists at
`hostRoot`, and expects the operator to have mounted cgroup2 there (cilium 1.20.1 values.yaml).
The repo's rationale was "k3s provides the cgroupv2 mount". That is true only for
`/sys/fs/cgroup`. Nothing mounts cgroup2 at `/run/cilium/cgroupv2` on these nodes; it is an
empty `DirectoryOrCreate` hostPath. So the agent mounted cgroup2 there itself, from inside its
own private cgroup namespace, and that mount's root is the agent container's own leaf cgroup.
The socket-LB programs (`cil_sock4_connect` etc.) were attached to it and covered only the
agent.
**The consequence.** Every other host-netns process got no Service/LB translation:
- Service ClusterIPs were unreachable from every node's host.
- The node holding the Gateway's L2 lease could not reach the Gateway VIP (`no route to host`).
Other nodes reached it only because their packets crossed the wire to the lease holder, where
the tc datapath handles them.
- k3s-prod-1 holds both the Gateway lease and the kube-vip API VIP. Its apiserver could not reach
the OIDC issuer, logging `oidc: authenticator not initialized` since at least 2026-09-12
(899 occurrences by 2026-10-03). kubectl SSO through the VIP therefore failed, and in-cluster
OIDC clients failed on whichever calls landed on that apiserver.
- Webhooks and aggregated APIs were unaffected only because k3s runs
`--enable-aggregator-routing=true` (endpoint IPs, not ClusterIPs).
Cilium's own source (v1.20.1 `bpf/bpf_sock.c`, `__sock4_xlate_fwd`) handles exactly this case
when the hook does run: a host-netns connect to an L7-LB Service (the Gateway) is rewritten to
`127.0.0.1:<l7_lb_proxy_port>`, the local Envoy listener. The fix is to make the hook cover the
host.
## What I verified
- `cilium-dbg status --verbose` on k3s-prod-1: `Socket LB: Enabled`, `Coverage: Hostns-only`.
The live cilium-config shows `bpf-lb-sock=false`, but socket LB is implied by kube-proxy
replacement. The Gateway Service frontends `192.168.2.20:{80,443}` and
`10.43.225.79:{80,443}` map to `127.0.0.1:14337`.
- Host: `findmnt /run/cilium/cgroupv2` is empty on all three nodes, and `/sys/fs/cgroup` is
`cgroup2`.
- In the agent: `/run/cilium/cgroupv2` is a `cgroup2 none` mount, the agent's own cgroup reads
`0::/`, the mount has 0 child cgroups, and `bpftool cgroup show /run/cilium/cgroupv2` lists all
`cil_sock*` programs. k3s runs in `0::/system.slice/k3s.service`, and the agent container in
`/kubepods.slice/…/cri-containerd-3ad04cde….scope`.
- **The decisive test**, run on k3s-prod-1 in the same netns: raw TCP connect to
`192.168.2.20:443`, `10.43.225.79:443` and `10.43.59.141:8080` CONNECTED from inside the agent
container (covered cgroup) and FAILED from the host (`No route to host` / timeout).
- Blast radius: host-network pods are cilium, cilium-envoy, cilium-operator (API via
`192.168.2.9:6443`, not a Service), kube-vip (`127.0.0.1:6443`, `svc_enable=false`) and
node-exporter (no outbound). Nothing depends on host Service translation being absent; the
change only makes Service/LB addresses reachable from the host where they currently fail.
- `helm template cilium/cilium --version 1.20.1` with the new values, in the toolbox: the
DaemonSet bind-mounts hostPath `/sys/fs/cgroup` into `cilium-agent` and `clean-cilium-state`,
the cilium-config shows `cgroup-root: "/sys/fs/cgroup"`, and the init containers are unchanged
(no `mount-cgroup`).
- Toolbox gate: see the PR description.
## What I did NOT verify
- **The rollout itself.** After merge, the agent DaemonSet rolls one node at a time
(`maxUnavailable: 1`). On each node, afterwards: host TCP connects to the three targets above
must succeed. On k3s-prod-1, the apiserver's background OIDC init retry must stop logging
`authenticator not initialized` with no k3s restart.
- That the old programs, attached to the previous agent container's cgroup, disappear with it.
Expected, because cgroup BPF attachments die with the cgroup, but not observed.
- An end-to-end kubectl OIDC login (needs a browser).
- No regression test was added. A natural one is an L2 conformance check that a host-netns
connect to the Gateway VIP succeeds on the lease holder, but `conformance/**` is a sensitive
path. Left as a follow-up.
@@ -0,0 +1,157 @@
---
date: 2026-10-04
branch: feat/hubble-flow-export-plex
title: Record the pre-SNAT client address for Plex via Hubble static flow export, instead of breaking externalTrafficPolicy to get it
---
## What changed
Operator requirement, narrowed explicitly: **this is 100% about log visibility** — knowing which
address connected to Plex. Not bandwidth metering, which does not matter here.
**The problem, measured.** Plex's own log cannot show it. Every LoadBalancer Service in this cluster
is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the VIP SNATs the client
before forwarding to the backend pod:
| Measured 2026-10-04 | Value |
|---|---|
| Node announcing `192.168.2.24` | `k3s-prod-3` (also holds `.20` Gateway, `.23` pihole-dns) |
| Node running the Plex pod | `k3s-prod-1` (`10.0.0.135`) |
| Sources in Plex's log | **3,214 × `10.0.1.x`**, 105 × `10.0.2.x`, 120 × loopback |
| `192.168.x` sources in Plex's log, ever | **0** |
| `X-Forwarded-For` in Plex's log | **0** (it serves raw tcp/32400, not HTTP via the Gateway) |
| `10.0.1.78` identity (`cilium-dbg bpf ipcache`) | `identity=1` → `reserved:host` = prod-3 itself |
**Nothing on the router is involved** — the operator correctly pointed out there is no hairpin NAT
here. The rewrite is entirely in-cluster, done by Cilium's BPF load-balancer on the lease holder,
because the backend pod is on a different node.
**The address is not lost, only unrecorded.** Cilium observes the flow at ingress, *before* its own
translation, so Hubble already holds the true source:
192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
10.0.1.78 -> 10.0.0.135 (the same traffic post-SNAT — what Plex sees)
What was missing is **retention**: the ring buffer was **4095/4095 flows at ~303 flows/s — about
thirteen seconds of history** — with hubble metrics disabled and no exporter configured.
So: enable the static flow exporter, scoped to Plex's port, writing to the agent's stdout.
- **`platform/cilium/helmrelease.yaml`** (authoritative): `hubble.export.static.enabled: true`,
`filePath: stdout`, `allowList` of `{"destination_port":["32400"]}` and
`{"source_port":["32400"]}`.
- **`platform/cilium/values.yaml`** (reference mirror) and
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
the same block, so a bootstrap render does not differ from what Flux reconciles — a mismatch would
be a permanent Helm diff and an avoidable agent rollout.
**`filePath: stdout`, not a file,** because Grafana Alloy already runs as a DaemonSet tailing every
pod's logs into Loki (`platform/monitoring/alloy.yaml`). stdout therefore reaches Loki with **no new
hostPath mount, no file-rotation handling, and no Alloy change at all**. Upstream documents the
value: *"To get Hubble flows directly exported to the logs instead of written to a rotated file,
`stdout` can be defined as `hubble-export-file-path`."* Query:
{namespace="kube-system", app="cilium"} |= "32400"
**SCOPED BY PORT, NOT BY NAMESPACE — a measured decision, not a preference.** A
`--to-namespace plex` query returned **only the post-SNAT flows**; the flow carrying the real client
address did not match it, because at the point of observation the destination identity is not yet
resolved to the plex pod. A namespace filter would have dropped exactly the record this change
exists to capture.
**WHY NOT FIX THE SNAT INSTEAD — both alternatives rejected on evidence.**
- `externalTrafficPolicy: Local` would put the real address in Plex's own log, but cilium 1.20's L2
announcer is **explicitly incompatible** with it. Upstream docs for this exact version:
*"incompatible with the `externalTrafficPolicy: Local` on services as it may cause service IPs to
be announced on nodes without pods causing traffic drops"* and *"L2 announcements isn't currently
aware of this setting"*. Live proof it is not theoretical: the VIP is announced by prod-3 while the
pod runs on prod-1, so the flip would black-hole tcp/32400 **immediately**. Making it safe means
pinning both the pod and the announcement to one node and surrendering Plex's ability to
reschedule — a real availability loss to buy a log field. Same trap
`platform/crafty/service-l4.yaml` documents from an 8-day Minecraft outage.
- **DSR** also preserves the client address, but `routing-mode` here is **vxlan**, not
native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO router's stateful
NAT on the remote path. Cluster-wide blast radius across all eight LB Services for one service's
logging.
**A correction to the repo's own record.** `decisions/2026-10-03-plex-remote-access-l4.md` states the
SNAT puts the client inside `192.168.0.0/16` (the ingress node's LAN address), and I repeated that in
the previous entry. **It is wrong**: measured, the SNAT source is `10.0.1.x` — the node's
`cilium_host` inside the pod CIDR. This matters, because it means `LanNetworksBandwidth` can never
discriminate local from remote while eTP is `Cluster`: `10.0.0.0/8` must be in the LAN set for the
in-cluster consumers (Seerr) to count as local, and every external client arrives in that same range.
PR #48's LAN definition therefore fixes the Remote badge by making *everything* local — acceptable
only because metering is explicitly not a goal here.
## What I verified
Live prod, 2026-10-04, read-only.
- **Every measurement in the table above**, including the VIP-lease-to-node map
(`cilium-dbg shell -- db/show l2-announce` on all three agents) and the pod's node.
- **Hubble already carries the pre-SNAT source** — the single finding this whole approach rests on:
`hubble observe --port 32400` on the lease holder returned `192.168.2.133 -> 10.0.0.135` **and**
its return flow, alongside the post-SNAT `10.0.1.78 -> 10.0.0.135`.
- **The retention gap is real:** `cilium-dbg status` reported `Hubble: Ok Current/Max Flows:
4095/4095 (100.00%), Flows/s: 303.62 Metrics: Disabled`, and the `cilium-config` ConfigMap
contains no `hubble-export-*` key at all.
- **The namespace-filter trap**, directly: `--to-namespace plex` returned only `10.0.1.78` and
`10.0.1.71`; the `192.168.2.133` flow was absent. This is why the allowList is port-scoped.
- **All eight LoadBalancer Services are `eTP=Cluster`** (crafty ×2, gateway, pihole-dns ×2, plex,
unifi ×2) — so this is a cluster-wide pattern, not a Plex-specific choice.
- **The eTP:Local incompatibility is upstream-documented for v1.20 specifically**, quoted above from
the docs for the running version — not inferred from the crafty incident.
- **`routing-mode: tunnel`** and **`bpf-lb-mode-annotation: false`** in the live `cilium-config`,
which is what rules DSR out.
- **The chart's value paths and defaults**, read from cilium v1.20.1's own `values.yaml`:
`hubble.export.static.{enabled,filePath,fieldMask,fieldAggregate,aggregationInterval,allowList,denyList,fileMaxSizeMb,fileMaxBackups,fileCompress}`,
with `filePath` defaulting to `/var/run/cilium/hubble/events.log`. The keys used here exist.
- **Alloy's pipeline shape**, read from `platform/monitoring/alloy.yaml`: a DaemonSet with
`loki.source.kubernetes "pods"` → `loki.process` → `loki.write` at
`http://loki.monitoring.svc.cluster.local:3100`. It already tails kube-system pods, which is why
stdout needs no Alloy change.
- **All three value files agree** (one `destination_port` occurrence each) and both YAML files parse,
with `export.static` resolving to the intended object.
- **Toolbox gate**: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
`render-golden`, `l1` and `approval-policy` all **pass**; 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.**
- **No sensitive paths touched** — `platform/cilium/**` and `infra/ansible/**` are not in
`infra/gitea/approval-policy.json`, so this needs no approval override, unlike PR #48.
## What I did NOT verify
- **THE OUTCOME. Nothing has been reconciled and no flow has ever been exported.** `cilium-config`
still has no `hubble-export-*` key. Every claim is about inputs.
- **Whether `filePath: stdout` is accepted as written.** This is the single largest risk. The value
comes from a verbatim upstream doc sentence (*"`stdout` can be defined as
`hubble-export-file-path`"*), but I did not confirm the exact notation in cilium 1.20.1's source,
and the chart default is a real filesystem path. If the literal string is not special-cased, the
agent may create a FILE named `stdout` in its working directory and nothing reaches Loki — a
silent no-op, not a crash. **Fallback if so:** leave `filePath` at the chart default and add a
`local.file_match` + `loki.source.file` target to Alloy with a hostPath mount for
`/var/run/cilium/hubble`.
- **Whether the FlowFilter JSON is accepted.** `{"destination_port":["32400"]}` follows the proto
(`destination_port` is a repeated string) and the documented shape (`'{"verdict":["DROPPED"]}'`),
but upstream gives **no port example** and I did not validate it against the running agent. A
malformed filter could be rejected — or, worse, be silently treated as "no match" and export
nothing.
- **THIS CHANGE ROLLS CILIUM.** Editing the HelmRelease restarts the agent DaemonSet across all
three nodes. BPF maps are preserved so established traffic should survive, but prod-3 currently
announces the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP, so a lease change during the
rollout could briefly interrupt LAN DNS for the whole house. Not measured, and worth doing at a
quiet moment.
- **Log volume in practice.** Estimated "a few hundred to a few thousand lines a day" from the
observed request counts; never measured as flows. Both directions are exported and no `fieldMask`
is set, so each record is a full flow object. If it is noisy, narrow with `fieldMask` rather than
by dropping a filter.
- **Loki's retention for this data**, and whether the `{namespace="kube-system", app="cilium"}` label
set actually matches (it depends on the cilium pods carrying
`app.kubernetes.io/name`, which Alloy maps to `app` — I did not confirm that label exists on the
cilium DaemonSet pods). The query in the comment may need adjusting to `pod=~"cilium-.*"`.
- **That the exported flow actually contains the pre-SNAT address.** Hubble's *live* stream does
(measured). I am assuming the exporter sees the same flow objects; I did not confirm the exporter
is not fed a post-translation view.
- **The parity convention has no automated check.** I kept all three files in sync by hand because
the comments demand it; nothing in `ci/` or `conformance/` verifies cilium Helm↔Ansible parity, so
a future edit can still drift them silently.
@@ -0,0 +1,139 @@
---
date: 2026-10-04
branch: fix/plex-advertise-plexdirect
title: Plex vanished from the plex.tv account — every advertised connection was uncompletable; advertise the plex.direct name instead of the bare VIP
---
## What changed
Reported symptom: the server no longer appears on the operator's plex.tv account; `plex.home.keithjackson.dev`
shows "not authorized"; connecting straight to the VIP + port works.
**Root cause, measured: `customConnections` held two hand-typed URLs and NEITHER could ever complete,
so Plex had nothing publishable.** Because `customConnections` OVERRIDES Plex's own connection
publishing, the server ended up advertising only dead endpoints. Live `Preferences.xml`, 2026-10-04:
customConnections="https://plex.home.keithjackson.dev:32400/,https://192.168.2.24:32400/"
ManualPortMappingMode="1" ManualPortMappingPort=<ABSENT>
- `https://192.168.2.24:32400` — **TLS can never validate.** Plex serves 32400 with
`CN=*.130072f20c334d6c8f63d0dfef87309b.plex.direct` and **no IP SAN**, so a bare-IP https URL fails
for every client. Not theoretical: Plex's log carries a continuous storm of
`CERT: incomplete TLS handshake from <node> ... sslv3 alert certificate unknown`, every few seconds,
from **real LAN clients** arriving through the new `plex-l4-tcp` VIP.
- `https://plex.home.keithjackson.dev:32400` — **nothing listens.** The shared Gateway serves that host
on 443; measured `http=000` (connect failed) against `192.168.2.20:32400`.
Consequence chain, each link measured: no completable connection -> plex.tv could publish nothing
(`MyPlex: mapping state set to 'Mapped - Not Published'`, repeatedly) -> Plex fell back to **Plex Relay**
(`startRelay`, remote forward to 127.0.0.1:32401) -> **the server disappeared from the account**, and
clients reported "not authorized".
The fix advertises Plex's own `<dashed-ip>.<serverhash>.plex.direct` form, which is public DNS resolving
to the embedded address — so it points at the LAN VIP *and* matches the wildcard cert.
- **`catalog/plex.yaml`:** `ADVERTISE_IP: ${PLEX_ADVERTISE_URL}` added, replacing the long-standing
DEFERRAL note. The deferral had weighed only the L7 Gateway URL; the operator chose the native L4 path
on 2026-10-03, so the open question was never "Gateway or not" but "which URL has a valid cert".
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_ADVERTISE_URL` knob (+ substitution-inventory comment
updated to name `plex: PLEX_ADVERTISE_URL`). The base default is an explicit **non-functional
placeholder** — dev is decommissioned and has no claimed server, and
`StrictPostBuildSubstitutions` makes an undefined var a hard failure, so the default only has to exist
and be correctly shaped.
- **`clusters/prod/kustomization.yaml`:** prod value
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`.
- **`apps/wave-0/plex.yaml`:** re-rendered with the canonical command
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`). Two added
lines, nothing else.
**Why a whole-URL cluster-var and not something built from `${PLEX_L4_VIP}`:** postBuild substitution is
plain envsubst and cannot turn `192.168.2.24` into `192-168-2-24`, and `<serverhash>` is the server's
certificate identity, derivable from nothing in this tree. The dashed IP and `${PLEX_L4_VIP}` are two
halves of one address and **nothing checks that they agree** — noted in both files.
**SENSITIVE PATHS — this PR touches `clusters/**` (two 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".
Deliberately not posted here: CLAUDE.md §1 forbids an agent clearing this on its own PR, and the agent
account holds `owner`, so the prohibition is a rule rather than a limit.
## What I verified
All measurements against live prod, 2026-10-04, read-only. Secrets handled per CLAUDE.md §6: preference
attributes were read by name-scoped grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`),
and the token was reported only as `<PRESENT,len=22>`.
- **THE FIX ITSELF, with full TLS verification (no `curl -k`)** — the one measurement the whole change
rests on:
- `https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400/identity` -> **`http=200`, TLS verify OK**
- `https://192.168.2.24:32400/identity` -> **`http=000`, cert validation FAILED**
- that name resolves to `192.168.2.24` (`getent hosts`), confirming the dashed-IP mechanism works from
inside this network and is not blocked by DNS-rebinding protection on the resolver path tested.
- **The server is still claimed — this was never a lost claim.** `/identity` -> `claimed="1"` with the real
`machineIdentifier`; `PlexOnlineToken` present, `PlexOnlineUsername="jakman85"`.
- **The certificate, read off the live listener:** exactly one SAN,
`DNS:*.130072f20c334d6c8f63d0dfef87309b.plex.direct`. No IP SAN. This is why the bare-IP URL fails.
- **The publish payload Plex actually sent to plex.tv**, which is what proves the connection list was the
problem rather than a guess: `PUT https://servers.plex.tv/devices/<id>?Connection[][uri]=https://plex.home.keithjackson.dev:32400&Connection[][uri]=https://192.168.2.24:32400&Connection[][uri]=http://136.51.115.252:32400&Connection[][uri]=http://10.0.2.136:32400&httpsEnabled=1...`
— all four unreachable from a LAN client or from plex.tv.
- **`Mapped - Not Published`** at every reachability check across the whole log, and an active **Plex Relay**
session — the documented fallback when nothing is directly publishable.
- **The `certificate unknown` TLS failures come from real client traffic, not a probe.** The source
`10.0.1.78` resolves via `cilium-dbg bpf ipcache get` to **identity 1 = `reserved:host`**, i.e. LAN
clients SNAT'd through `plex-l4-tcp` (`externalTrafficPolicy: Cluster`) on prod-3.
- **The Gateway is NOT broken, and my first reading of it was wrong.** A 403 `Access denied` from
`server: envoy` on the Gateway path turned out to be an artifact of probing from inside the plex pod:
**all 16** Gateway hostnames return 403 from there, because Cilium's Gateway preserves the original
source identity and enforces the destination's ingress policy. Plex's own log shows a concurrent,
**working, signed-in** browser session through the Gateway returning 200s. The HTTPRoute is
`Accepted=True`/`ResolvedRefs=True` with no filters, and it is the only route claiming that hostname.
- **Cilium is not denying the Gateway->Plex hop:** `cilium-dbg bpf policy get 3796` shows
`Allow Ingress reserved:ingress 32400/TCP`, PROXY PORT `NONE`, with 141k packets counted. No L7/HTTP
rules and no CCNPs exist anywhere in the cluster.
- **In-cluster consumers were never affected:** Seerr's recently-added scans and watchlist syncs complete
cleanly with no auth errors; there are **zero** HTTP 401s in Plex's log (the five ` 401 ` hits are
transcoder segment numbers).
- **`ADVERTISE_IP`'s mechanism, in the running image rather than from docs:**
`/etc/cont-init.d/40-plex-first-run` line 127 does `setPref "customConnections" "${ADVERTISE_IP}"`,
outside the claim guard, and `/.firstRunComplete` (lines 9/139) is on the container overlay, not the
`/config` PVC — so it re-applies on every pod start. Editing the env rolls the Deployment, which applies it.
- **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`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3.**
- **`render-golden` passing is the specific proof** that the catalog edit and the committed golden agree;
the rendered diff is exactly two lines and no other service's manifest moved.
## What I did NOT verify
- **THE OUTCOME. Nothing here has been observed to put the server back on the plex.tv account.** The
change is unreconciled: `ADVERTISE_IP` is absent from the live Deployment, the live
`customConnections` still holds the two broken URLs, and Plex has not been restarted. What is proven is
that the *new URL answers 200 with a valid certificate* and that *every currently advertised URL cannot
complete* — a necessary-and-plausibly-sufficient argument, not a demonstrated fix.
- **Whether plex.tv will publish and list the server once this lands.** plex.tv's decision is made
off-network and cannot be measured from here. A LAN-only connection may still leave the account showing
the server as remotely unreachable even when local clients work.
- **REMOTE (off-LAN) ACCESS IS NOT FIXED BY THIS, and may still be fully broken.** The advertised URL
resolves to an RFC1918 address, so it serves LAN clients only. The off-repo WAN tcp/32400 port-forward
(`runbooks/plex-remote-access.md`) is still **unverified** — `http://136.51.115.252:32400` was
unreachable from inside the LAN, but 2026-10-03 established there is no NAT hairpin here, so that is
**inconclusive**, not proof of absence. This repo has no off-LAN vantage point.
- **`ManualPortMappingMode="1"` with `ManualPortMappingPort` ABSENT is left as-is.** Manual public-port
mapping is enabled with no port set — a half-configured state I did not change and whose exact effect on
publishing I did not isolate. There is **no GitOps path** to it (2026-10-03: the image supports no
`PLEX_PREFERENCE*` of any kind), so it is an operator UI step.
- **Whether the operator intended the hand-typed URLs** to override the 2026-10-03 native-L4 design, or
typed them while debugging. I did not alter live preferences; the env var will overwrite
`customConnections` on the next pod start, which is a deliberate transfer of ownership from the web form
to git and will **silently discard** anything typed there since.
- **The `plex.direct` hash's stability.** Treated as server identity and read once from the live cert. I
did not establish what reissues it, so this URL could go stale with no alert; nothing in CI or the
conformance suite checks that the dashed IP still matches `${PLEX_L4_VIP}` or that the hash is current.
- **Resolver behaviour for the whole LAN.** `plex.direct` resolved correctly from a *pod* (cluster DNS
path). LAN clients resolve via PiHole, and dnsmasq's `stop-dns-rebind` would reject a public answer
carrying a private address. I did **not** test resolution from a LAN client, and this is the most likely
way the fix still fails.
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not a
verdict: this PR touches `clusters/**`, so CI will block pending the operator's override.
- **Why PMS restarted.** The process came up at 05:26 UTC while the pod is 2d2h old with 0 restarts, so
something restarted Plex inside the container. Unexplained and not investigated.
@@ -45,17 +45,34 @@ hubble:
enabled: true
ui:
enabled: true
# Static flow export -> cilium-agent stdout. Kept EQUAL to the Flux values
# (platform/cilium/helmrelease.yaml), which carry the full rationale: it records the PRE-SNAT
# client address for Plex's raw tcp/32400, which Plex itself cannot log because every
# LoadBalancer Service here is externalTrafficPolicy: Cluster and the VIP lease holder SNATs the
# client. Present here so a BOOTSTRAP render does not differ from what Flux reconciles -- a
# mismatch would show up as a permanent Helm diff and an avoidable agent rollout. Scoped by PORT,
# not namespace: the flow carrying the real client address does not match a namespace filter.
export:
static:
enabled: true
filePath: stdout
allowList:
- '{"destination_port":["32400"]}'
- '{"source_port":["32400"]}'
# Ingress VIP announcement on the NAT LAN. IP pool / CiliumL2AnnouncementPolicy
# (from the reserved 10.73.0.240-.250 range) is authored by WS-NET in Phase 2.
l2announcements:
enabled: true
# k3s ships its own cgroup mount; point Cilium at it instead of auto-mounting.
# Point Cilium at the host's real cgroup2 root (/sys/fs/cgroup, mounted by systemd) instead of
# auto-mounting. NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so the agent mounted its
# OWN namespaced cgroup and socket-LB covered only itself — host processes (the apiserver) lost
# Service/LB translation. [parity] platform/cilium/helmrelease.yaml explains (2026-10-03).
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
+78 -5
View File
@@ -101,19 +101,92 @@ spec:
enabled: true
ui:
enabled: true # exposure gated behind oauth2-proxy forward-auth later (ADR-0014)
# --- STATIC FLOW EXPORT: persist the PRE-SNAT client address for Plex (2026-10-04) ---
# [parity] MUST equal Ansible: hubble.export.static.{enabled,filePath,allowList}
#
# WHY THIS EXISTS. Plex's own log cannot show who connected. Every LoadBalancer Service in
# this cluster is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the
# VIP SNATs the client before forwarding to the backend pod. Measured 2026-10-04: Plex had
# logged 3,214 requests from `10.0.1.x` (the lease holder k3s-prod-3's own address, Cilium
# identity 1 = reserved:host) and ZERO from any 192.168.x address, ever. No X-Forwarded-For
# either -- it serves raw tcp/32400, not HTTP through the Gateway.
#
# THE ADDRESS IS NOT LOST, ONLY UNRECORDED. Cilium observes the flow at ingress, BEFORE its
# own translation, so Hubble already has the true source. Measured on the lease holder:
# 192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
# 10.0.1.78 -> 10.0.0.135 (the same traffic, post-SNAT -- what Plex sees)
# What was missing is RETENTION: the ring buffer was 4095/4095 flows at ~303 flows/s, i.e.
# about THIRTEEN SECONDS of history, with hubble metrics disabled and no exporter configured.
#
# WHY NOT FIX THE SNAT INSTEAD. `externalTrafficPolicy: Local` would preserve the address in
# Plex's own log, but cilium 1.20's L2 announcer is explicitly incompatible with it -- upstream
# docs for this exact version: "incompatible with the externalTrafficPolicy: Local on services
# as it may cause service IPs to be announced on nodes without pods causing traffic drops",
# and "L2 announcements isn't currently aware of this setting". Live proof that this is not
# theoretical: the VIP is announced by k3s-prod-3 while the pod runs on k3s-prod-1, so the
# flip would black-hole tcp/32400 immediately. Making it safe would mean pinning BOTH the pod
# and the announcement to one node and giving up Plex's ability to reschedule -- a real
# availability loss to buy a log field. (It is the same trap platform/crafty/service-l4.yaml
# documents from an 8-day Minecraft outage.) DSR was also rejected: routing-mode here is
# vxlan, not native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO
# router's stateful NAT on the remote path.
#
# `filePath: stdout` RATHER THAN A FILE, deliberately. Grafana Alloy already runs as a
# DaemonSet tailing every pod's logs into Loki (platform/monitoring/alloy.yaml), so stdout
# reaches Loki with NO new hostPath mount, no file-rotation handling and no Alloy change.
# Upstream documents it: "To get Hubble flows directly exported to the logs instead of written
# to a rotated file, `stdout` can be defined as `hubble-export-file-path`." fileMaxSizeMb /
# fileMaxBackups are therefore left at chart defaults and do not apply.
# Query: {namespace="kube-system", app="cilium"} |= "32400"
#
# SCOPED BY PORT, NOT BY NAMESPACE -- and that is a measured decision, not a preference. A
# `--to-namespace plex` query returned ONLY the post-SNAT flows; the flow carrying the real
# client address did NOT match it, because at the point of observation the destination
# identity is not yet resolved to the plex pod. Filtering by namespace would therefore drop
# exactly the record this change exists to capture. Port it is.
#
# Both directions are listed so the return path is captured too. Volume is small (a few
# hundred to a few thousand lines a day); if it ever becomes noisy, narrow it with
# `fieldMask` rather than by removing a filter.
export:
static:
enabled: true
filePath: stdout
allowList:
- '{"destination_port":["32400"]}'
- '{"source_port":["32400"]}'
# --- L2 announcement for the ingress VIP on the flat LAN (docs/09, OQ-1 resolved) ---
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
l2announcements:
enabled: true
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
# part of the working CNI config on k3s: reconciling without it lets Cilium try to auto-mount
# cgroupv2 and break on adopt. Kept in parity for a safe Phase-2 adoption.
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
#
# FIXED 2026-10-03 — hostRoot WAS /run/cilium/cgroupv2, AND THAT SILENTLY BROKE SOCKET-LB FOR
# EVERY HOST PROCESS. With autoMount disabled, the chart only bind-mounts whatever already exists
# at hostRoot ("it's expected that users have mounted cgroup2 filesystem at the specified
# cgroup.hostRoot" — cilium 1.20.1 values.yaml). Nothing ever mounted cgroup2 at
# /run/cilium/cgroupv2 on these nodes (it is an empty DirectoryOrCreate hostPath), so the agent
# mounted one ITSELF from inside its own private cgroup namespace. That mount's root is the
# agent container's OWN leaf cgroup, so cil_sock4_connect & co. were attached there and covered
# only the agent. Every other host-netns process (the k3s apiserver in
# /system.slice/k3s.service, curl over SSH) was untranslated: Service ClusterIPs timed out from
# every node, and the node holding the Gateway's L2 lease could not reach the Gateway VIP at all
# ("no route to host"). That is why k3s-prod-1's apiserver logged `oidc: authenticator not
# initialized` from 2026-09-12 on — kubectl SSO through the kube-vip VIP failed outright.
# Measured on k3s-prod-1 before this change: TCP connect to 192.168.2.20:443, 10.43.225.79:443
# and 10.43.59.141:8080 SUCCEEDED from inside the agent container (the covered cgroup) and
# FAILED from the host, same node, same netns. Full write-up:
# decisions/2026-10-03-cilium-cgroup-hostroot.md.
#
# /sys/fs/cgroup IS a cgroup2 mount in the host's root cgroup namespace on every node (verified
# with findmnt on all three), so bind-mounting it gives the agent the real host root. autoMount
# stays off because the mount it would create already exists.
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
+16 -2
View File
@@ -38,14 +38,28 @@ hubble:
enabled: true
ui:
enabled: true
# [parity] static flow export -> cilium-agent stdout -> Alloy -> Loki. Records the PRE-SNAT
# client address for Plex's raw tcp/32400, which Plex's own log cannot show (every LB Service here
# is externalTrafficPolicy: Cluster, so the VIP lease holder SNATs the client). Scoped BY PORT,
# not namespace: the flow carrying the real client IP does not match a namespace filter. Full
# rationale, measurements and the rejected alternatives are in helmrelease.yaml.
export:
static:
enabled: true
filePath: stdout
allowList:
- '{"destination_port":["32400"]}'
- '{"source_port":["32400"]}'
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
l2announcements:
enabled: true
# [parity] k3s provides the cgroupv2 mount; do not auto-mount
# [parity] the host's cgroup2 root is /sys/fs/cgroup (systemd mounts it); do not auto-mount.
# NOT /run/cilium/cgroupv2: nothing mounts cgroup2 there, so socket-LB attached to the agent's own
# cgroup and host processes lost Service/LB translation (2026-10-03 — see helmrelease.yaml).
cgroup:
autoMount:
enabled: false
hostRoot: /run/cilium/cgroupv2
hostRoot: /sys/fs/cgroup
# [parity] roll the agent DaemonSet ONE node at a time. Overrides the cilium 1.20.1 chart
# default of 2, which on 3 nodes lets a single bad render break 2/3 agents at once — as the
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the