Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
989649e64a | ||
|
|
422834d4e2 | ||
|
|
1f4552d409 | ||
|
|
c95b1c7121 | ||
|
|
ed6afc3d3f | ||
|
|
9a69f6906a |
No files matched your search
@@ -137,6 +137,8 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: plex-claim
|
name: plex-claim
|
||||||
key: PLEX_CLAIM
|
key: PLEX_CLAIM
|
||||||
|
- name: ADVERTISE_IP
|
||||||
|
value: ${PLEX_ADVERTISE_URL}
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
gpu.intel.com/i915: 1
|
gpu.intel.com/i915: 1
|
||||||
|
|||||||
+48
-8
@@ -72,14 +72,54 @@ env:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: plex-claim
|
name: plex-claim
|
||||||
key: PLEX_CLAIM
|
key: PLEX_CLAIM
|
||||||
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
|
# ADVERTISE_IP ("Custom server access URLs") — NOW SET. This replaces a DEFERRAL note that stood
|
||||||
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
|
# here until 2026-10-04, and the deferral's premise has changed: it weighed only ONE candidate,
|
||||||
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
|
# `https://plex.home.keithjackson.dev` through the L7 Gateway. The operator chose the NATIVE L4
|
||||||
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
|
# path instead on 2026-10-03 (apps/wave-0/plex-remote.yaml) so streams go straight to
|
||||||
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
|
# ${PLEX_L4_VIP}:32400 and bypass the Envoy hop. What was still missing was a URL whose TLS
|
||||||
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
|
# CERTIFICATE validates — and getting that wrong is what took the service down.
|
||||||
# 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".
|
# 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).
|
# 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
|
# 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.
|
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ data:
|
|||||||
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
|
# 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
|
# 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;
|
# 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 (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
||||||
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
|
# 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.
|
# 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)
|
# 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_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
|
# 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
|
# 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
|
# from `world` is an open resolver and an amplification reflector, so the allow is scoped to this
|
||||||
|
|||||||
@@ -496,6 +496,16 @@ patches:
|
|||||||
- op: replace
|
- op: replace
|
||||||
path: /data/PLEX_L4_VIP
|
path: /data/PLEX_L4_VIP
|
||||||
value: "192.168.2.24"
|
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 —
|
# 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.
|
# 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.
|
# 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
|
enabled: true
|
||||||
ui:
|
ui:
|
||||||
enabled: true
|
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
|
# 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.
|
# (from the reserved 10.73.0.240-.250 range) is authored by WS-NET in Phase 2.
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
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:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
|
|
||||||
# Roll the cilium agent DaemonSet ONE node at a time. [parity] must match the
|
# 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
|
# platform/cilium HelmRelease. The cilium 1.20.1 chart defaults this to 2, which on a
|
||||||
|
|||||||
@@ -101,19 +101,92 @@ spec:
|
|||||||
enabled: true
|
enabled: true
|
||||||
ui:
|
ui:
|
||||||
enabled: true # exposure gated behind oauth2-proxy forward-auth later (ADR-0014)
|
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) ---
|
# --- L2 announcement for the ingress VIP on the flat LAN (docs/09, OQ-1 resolved) ---
|
||||||
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
|
# [parity] MUST equal Ansible: l2announcements.enabled: true. IP pool +
|
||||||
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
# CiliumL2AnnouncementPolicy (reserved 10.73.0.240-.250) authored by WS-NET in Phase 2.
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
enabled: true
|
||||||
# --- cgroup: k3s ships its own cgroup mount; point Cilium at it, don't auto-mount ---
|
# --- cgroup: point Cilium at the HOST's cgroup2 root, which systemd mounts at /sys/fs/cgroup ---
|
||||||
# [parity] MUST equal Ansible cgroup block. Not in the deliverable's headline list, but it IS
|
# [parity] MUST equal Ansible cgroup block (roles/cilium/templates/cilium-values.yaml.j2).
|
||||||
# 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.
|
# 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:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
enabled: false
|
||||||
hostRoot: /run/cilium/cgroupv2
|
hostRoot: /sys/fs/cgroup
|
||||||
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
# --- Agent rollout: ONE node at a time (blast-radius bound) — 2026-09-01 incident ---
|
||||||
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
# [parity] MUST equal Ansible: updateStrategy.rollingUpdate.maxUnavailable: 1.
|
||||||
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
# The chart's own default is 2 (cilium 1.20.1 values.yaml: updateStrategy.rollingUpdate.
|
||||||
|
|||||||
@@ -38,14 +38,28 @@ hubble:
|
|||||||
enabled: true
|
enabled: true
|
||||||
ui:
|
ui:
|
||||||
enabled: true
|
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)
|
# [parity] ingress VIP announcement on the flat LAN (docs/09, OQ-1)
|
||||||
l2announcements:
|
l2announcements:
|
||||||
enabled: true
|
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:
|
cgroup:
|
||||||
autoMount:
|
autoMount:
|
||||||
enabled: false
|
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
|
# [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
|
# 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
|
# 2026-09-01 incident did (k8sServiceHost: "" → the `config` init container fell back to the
|
||||||
|
|||||||
Reference in new issue
Block a user