@@ -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.