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]>
11 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-04 | feat/hubble-flow-export-plex | 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,allowListof{"destination_port":["32400"]}and{"source_port":["32400"]}.platform/cilium/values.yaml(reference mirror) andinfra/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: Localwould 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 theexternalTrafficPolicy: Localon 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 trapplatform/crafty/service-l4.yamldocuments from an 8-day Minecraft outage.- DSR also preserves the client address, but
routing-modehere 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-announceon 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 32400on the lease holder returned192.168.2.133 -> 10.0.0.135and its return flow, alongside the post-SNAT10.0.1.78 -> 10.0.0.135. - The retention gap is real:
cilium-dbg statusreportedHubble: Ok Current/Max Flows: 4095/4095 (100.00%), Flows/s: 303.62 Metrics: Disabled, and thecilium-configConfigMap contains nohubble-export-*key at all. - The namespace-filter trap, directly:
--to-namespace plexreturned only10.0.1.78and10.0.1.71; the192.168.2.133flow 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: tunnelandbpf-lb-mode-annotation: falsein the livecilium-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}, withfilePathdefaulting to/var/run/cilium/hubble/events.log. The keys used here exist. - Alloy's pipeline shape, read from
platform/monitoring/alloy.yaml: a DaemonSet withloki.source.kubernetes "pods"→loki.process→loki.writeathttp://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_portoccurrence each) and both YAML files parse, withexport.staticresolving to the intended object. - Toolbox gate: rc 3, with
spec-immutability,decisions-log,lint,l0,render-golden,l1andapproval-policyall pass; rc 3 is the two known environmental skips (gitea-settings— no localSETTINGS_API_TOKEN;conformanceL2/L3 —CLUSTER_AVAILABLEunset). Not green. Reported as rc 3. - No sensitive paths touched —
platform/cilium/**andinfra/ansible/**are not ininfra/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-configstill has nohubble-export-*key. Every claim is about inputs. - Whether
filePath: stdoutis accepted as written. This is the single largest risk. The value comes from a verbatim upstream doc sentence ("stdoutcan be defined ashubble-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 namedstdoutin its working directory and nothing reaches Loki — a silent no-op, not a crash. Fallback if so: leavefilePathat the chart default and add alocal.file_match+loki.source.filetarget to Alloy with a hostPath mount for/var/run/cilium/hubble. - Whether the FlowFilter JSON is accepted.
{"destination_port":["32400"]}follows the proto (destination_portis 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
fieldMaskis set, so each record is a full flow object. If it is noisy, narrow withfieldMaskrather 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 carryingapp.kubernetes.io/name, which Alloy maps toapp— I did not confirm that label exists on the cilium DaemonSet pods). The query in the comment may need adjusting topod=~"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/orconformance/verifies cilium Helm↔Ansible parity, so a future edit can still drift them silently.