feat(cilium): export Plex's pre-SNAT client address via Hubble, scoped to tcp/32400 #50

Merged
jakman85 merged 1 commits from feat/hubble-flow-export-plex into main 2026-10-04 21:47:21 +00:00
Owner

Log visibility only — which address connected to Plex. Not metering.

Why Plex's own log can't show it

Every LoadBalancer Service in this cluster is externalTrafficPolicy: Cluster, so the node holding
the VIP's L2 lease SNATs the client before forwarding to the backend pod.

Measured 2026-10-04
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, ever 0
X-Forwarded-For 0 — raw tcp/32400, not HTTP via the Gateway
10.0.1.78 identity identity=1 → reserved:host = prod-3 itself

Nothing on the router does this — there's no hairpin NAT. It's Cilium's BPF load-balancer on the
lease holder, because the backend pod is on a different node.

The address isn't 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 flows/s ≈ thirteen seconds of
history, Metrics: Disabled, and no hubble-export-* key in cilium-config at all.

The change

hubble.export.static enabled, port-scoped, to the agent's stdout — Alloy already tails every
pod into Loki (platform/monitoring/alloy.yaml), so this needs no new hostPath mount, no
rotation handling, and no Alloy change
:

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

Applied to all three value files so the [parity] contract holds: the HelmRelease (authoritative),
values.yaml (mirror), and the Ansible bootstrap template.

Scoped by PORT, not namespace — on evidence. A --to-namespace plex query returned only the
post-SNAT flows; the record carrying the real client address did not match it, because at that
observation point the destination identity isn't yet resolved to the plex pod. A namespace filter
would have dropped exactly what this exists to capture.

Alternatives rejected, with reasons

  • externalTrafficPolicy: Local would put the real address in Plex's own log, but cilium 1.20's
    L2 announcer is explicitly incompatible — upstream docs for this version: "may cause service IPs
    to be announced on nodes without pods causing traffic drops"
    , "L2 announcements isn't currently
    aware of this setting"
    . Not theoretical: the VIP is on prod-3 and the pod on prod-1, so the flip
    would black-hole 32400 immediately. Making it safe costs Plex its ability to reschedule.
  • DSR also preserves the address, but routing-mode is vxlan, not native/geneve, and DSR
    returns bypass the ingress node — breaking the router's stateful NAT on the remote path.
    Cluster-wide blast radius for one service's logging.

Corrects the repo's own record

decisions/2026-10-03-plex-remote-access-l4.md says the SNAT lands in 192.168.0.0/16 (the node's
LAN address), and I repeated that. It's 10.0.1.x — the cilium_host inside the pod CIDR. That
matters: LanNetworksBandwidth can never separate local from remote while eTP is Cluster, because
10.0.0.0/8 must be in the LAN set for Seerr and every external client arrives in that same range.
#48 therefore fixes the Remote badge by making everything local — fine here only because metering
is explicitly not a goal.

Gate

rc 3 — spec-immutability, decisions-log, lint, l0, render-golden, l1,
approval-policy all pass; rc 3 is the two known environmental skips (gitea-settings,
conformance L2/L3). Not green — reported as rc 3. No sensitive paths, so no override needed.

⚠️ Read before merging

  • THIS ROLLS CILIUM. The agent DaemonSet restarts on 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
    — a lease move during the rollout could briefly interrupt LAN DNS for the
    whole house. Worth doing at a quiet moment.
  • filePath: stdout is the largest risk. Taken from a verbatim upstream sentence ("stdout can
    be defined as hubble-export-file-path"
    ) but not confirmed against 1.20.1 source, and the chart
    default is a real path. If the literal isn't special-cased, the agent may create a file named
    stdout and nothing reaches Loki — a silent no-op. Fallback documented: keep the default path and
    add a loki.source.file target to Alloy with a hostPath mount.
  • The port FlowFilter has no upstream example. {"destination_port":["32400"]} matches the proto
    and the documented shape, but a malformed filter could export nothing silently.
  • The Loki label set is unconfirmed — app="cilium" depends on the cilium pods carrying
    app.kubernetes.io/name; may need pod=~"cilium-.*".
  • Nothing is reconciled and no flow has ever been exported.

🤖 Generated with Claude Code

Log visibility only — which address connected to Plex. Not metering. ## Why Plex's own log can't show it Every LoadBalancer Service in this cluster is `externalTrafficPolicy: Cluster`, so the node holding the VIP's L2 lease SNATs the client before forwarding to the backend pod. | Measured 2026-10-04 | | |---|---| | 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, ever | **0** | | `X-Forwarded-For` | **0** — raw tcp/32400, not HTTP via the Gateway | | `10.0.1.78` identity | `identity=1` → `reserved:host` = prod-3 itself | **Nothing on the router does this** — there's no hairpin NAT. It's Cilium's BPF load-balancer on the lease holder, because the backend pod is on a different node. ## The address isn't 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 flows/s` ≈ **thirteen seconds** of history, `Metrics: Disabled`, and no `hubble-export-*` key in `cilium-config` at all. ## The change `hubble.export.static` enabled, port-scoped, to the agent's **stdout** — Alloy already tails every pod into Loki (`platform/monitoring/alloy.yaml`), so this needs **no new hostPath mount, no rotation handling, and no Alloy change**: ``` {namespace="kube-system", app="cilium"} |= "32400" ``` Applied to all three value files so the `[parity]` contract holds: the HelmRelease (authoritative), `values.yaml` (mirror), and the Ansible bootstrap template. **Scoped by PORT, not namespace — on evidence.** A `--to-namespace plex` query returned *only* the post-SNAT flows; the record carrying the real client address did **not** match it, because at that observation point the destination identity isn't yet resolved to the plex pod. A namespace filter would have dropped exactly what this exists to capture. ## Alternatives rejected, with reasons - **`externalTrafficPolicy: Local`** would put the real address in Plex's own log, but cilium 1.20's L2 announcer is explicitly incompatible — upstream docs for this version: *"may cause service IPs to be announced on nodes without pods causing traffic drops"*, *"L2 announcements isn't currently aware of this setting"*. Not theoretical: the VIP is on prod-3 and the pod on prod-1, so the flip would black-hole 32400 **immediately**. Making it safe costs Plex its ability to reschedule. - **DSR** also preserves the address, but `routing-mode` is **vxlan**, not native/geneve, and DSR returns bypass the ingress node — breaking the router's stateful NAT on the remote path. Cluster-wide blast radius for one service's logging. ## Corrects the repo's own record `decisions/2026-10-03-plex-remote-access-l4.md` says the SNAT lands in `192.168.0.0/16` (the node's LAN address), and I repeated that. **It's `10.0.1.x`** — the `cilium_host` inside the pod CIDR. That matters: `LanNetworksBandwidth` can never separate local from remote while eTP is `Cluster`, because `10.0.0.0/8` must be in the LAN set for Seerr and every external client arrives in that same range. #48 therefore fixes the Remote badge by making *everything* local — fine here only because metering is explicitly not a goal. ## Gate **rc 3** — `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy` all **pass**; rc 3 is the two known environmental skips (`gitea-settings`, `conformance` L2/L3). **Not green — reported as rc 3.** No sensitive paths, so no override needed. ## ⚠️ Read before merging - **THIS ROLLS CILIUM.** The agent DaemonSet restarts on 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** — a lease move during the rollout could briefly interrupt LAN DNS for the whole house. Worth doing at a quiet moment. - **`filePath: stdout` is the largest risk.** Taken from a verbatim upstream sentence (*"`stdout` can be defined as `hubble-export-file-path`"*) but not confirmed against 1.20.1 source, and the chart default is a real path. If the literal isn't special-cased, the agent may create a *file* named `stdout` and nothing reaches Loki — a silent no-op. Fallback documented: keep the default path and add a `loki.source.file` target to Alloy with a hostPath mount. - **The port FlowFilter has no upstream example.** `{"destination_port":["32400"]}` matches the proto and the documented shape, but a malformed filter could export nothing silently. - **The Loki label set is unconfirmed** — `app="cilium"` depends on the cilium pods carrying `app.kubernetes.io/name`; may need `pod=~"cilium-.*"`. - Nothing is reconciled and no flow has ever been exported. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gitea-admin added 1 commit 2026-10-04 21:36:58 +00:00
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
989649e64a
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]>
jakman85 merged commit 642a7752d8 into main 2026-10-04 21:47:21 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Home-Network/home-network-k3s-impl#50