Reported symptom: users cannot reach Plex from outside the LAN. Two independent causes, both measured against live prod on 2026-10-03.
What was actually wrong
Plex advertised no remote connection at all. Live Preferences.xml: customConnectionsABSENT, ManualPortMappingModeABSENT, while PublishServerOnPlexOnlineKey="1" — publishing was on, with nothing reachable to publish. Plex's automatic UPnP/NAT-PMP path cannot work from a pod on the Cilium overlay.
Nothing from the WAN reached the cluster.136.51.115.252:32400 → refused. 136.51.115.252:443 → a self-signed router cert (C=ZZ, CN=%D), not the Gateway's LE wildcard. The Plex Service was ClusterIP only, so 32400 had no off-cluster path at all.
The L7 Gateway was NOT the problem. It serves Plex correctly — GET /identity → HTTP 200, claimed="1", and 401 on every protected endpoint. But Plex apps discover servers through plex.tv, not DNS, so an HTTPRoute can never put this server on a client's connection list.
What this changes
A hand-written non-catalog unit, apps/wave-0/plex-remote.yaml: a type=LoadBalancer Service on tcp/32400 pinned to ${PLEX_L4_VIP} plus a CiliumNetworkPolicy admitting world/cluster/host/remote-node. Non-catalog because the SD schema is additionalProperties: false and cannot express a LoadBalancer Service or a pinned lbipam VIP — the same reason pihole-dns.yaml, crafty/service-l4.yaml and unifi/50-service-l4.yaml are hand-written. Registered in render.py's EXTRA_UNITS so --emit-waves places it in plex's own wave.
PLEX_L4_VIP = 192.168.2.24 on prod, verified free: pool default-pool IPsTotal 11 / IPsUsed 4 / IPsAvailable 7, allocations only .20/.21/.22/.23.
ADVERTISE_IP deliberately not set. Advertising the Gateway URL was the alternative and the mechanism was verified to work on an already-claimed server. Operator chose the native path so Plex terminates its own TLS (*.plex.direct) and streams bypass the Envoy L7 hop.
externalTrafficPolicy: Cluster, inheriting crafty's 8-day-outage lesson rather than re-deriving it. Documented consequence: SNAT puts remote clients inside ALLOWED_NETWORKS, so Plex meters them as LAN (confirmed — a probe logged as (Allowed Network (WAN))). Metering effect, not an authorization change.
⚠️ This does not work yet — two manual steps remain
Neither can live in this repo. runbooks/plex-remote-access.md is the procedure.
Router: forward WAN tcp/32400 → 192.168.2.24. Also check whether the router's management UI is bound to WAN:443, which would shadow the Gateway's forward.
Plex UI: Settings → Remote Access → tick "manually specify public port" = 32400. There is no env var for this — grep of the running image's entire init surface for ManualPortMapping/PLEX_PREFERENCE returned NONE.
Gate
./infra/devcontainer/dev.sh run bash -c './ci/gate.sh' → rc 3, not green, reported as rc 3.
spec-immutability, decisions-log, lint, l0, render-golden, l1, approval-policy all pass. The rc 3 comes from two environmental skips only: gitea-settings (no local SETTINGS_API_TOKEN) and conformance L2/L3 (CLUSTER_AVAILABLE unset). render-golden passing is the specific proof that the render.py change, the new unit and the edited wave kustomization are mutually consistent.
🔒 Sensitive path — needs an operator override
Touches clusters/** (2 files), so ci/approval-policy.sh will block. Per CLAUDE.md §1 I have not posted the override myself — the agent account holds owner, so that prohibition is a rule rather than a limit. Please post, as a PR review of type "Comment":
/approve-override <reason>
Not verified — please read
The fix itself is unproven. Both remaining steps are off-repo and manual. This removes two blockers; it does not demonstrate an outcome.
Outside-in reachability, at all. Every probe was from inside the LAN, so the :443 router cert strictly proves only that there is no NAT hairpin — not outside-in failure. There is no off-LAN vantage point here.
192.168.2.24 was never ARP-probed off-cluster (only in-cluster pool accounting). .23 was arping-probed before it was claimed; .24 was not.
The LoadBalancer has never been reconciled — LB-IPAM has not assigned the VIP, L2 announcement is unobserved, and the new policy has never admitted a real packet. The entity set is reasoned from crafty's working game-port rule, not measured.
Pre-existing drift found and deliberately left alone
Running the documented render.py --emit-waves wants to add vm-demo.yaml to wave 0 and keycloak.yaml to wave 1 — both listed as intentionally not onboarded in ci/render-golden.sh. --emit-waves places any file on disk with no awareness of that exclusion, and render-golden.sh uses --wave-layout and never checks kustomization resources, which is why it has survived unnoticed. So the "DO NOT hand-edit: re-run --emit-waves" header is currently unsafe advice — following it onboards an IdP and a KubeVirt VM. I restored both kustomizations and hand-added only my one line. Not fixed here; worth its own issue.
Reported symptom: **users cannot reach Plex from outside the LAN.** Two independent causes, both measured against live prod on 2026-10-03.
### What was actually wrong
1. **Plex advertised no remote connection at all.** Live `Preferences.xml`: `customConnections` **ABSENT**, `ManualPortMappingMode` **ABSENT**, while `PublishServerOnPlexOnlineKey="1"` — publishing was on, with nothing reachable to publish. Plex's automatic UPnP/NAT-PMP path cannot work from a pod on the Cilium overlay.
2. **Nothing from the WAN reached the cluster.** `136.51.115.252:32400` → refused. `136.51.115.252:443` → a **self-signed router cert** (`C=ZZ, CN=%D`), not the Gateway's LE wildcard. The Plex Service was `ClusterIP` only, so 32400 had no off-cluster path at all.
**The L7 Gateway was NOT the problem.** It serves Plex correctly — `GET /identity` → `HTTP 200`, `claimed="1"`, and `401` on every protected endpoint. But Plex apps discover servers through **plex.tv**, not DNS, so an HTTPRoute can never put this server on a client's connection list.
### What this changes
A hand-written non-catalog unit, `apps/wave-0/plex-remote.yaml`: a `type=LoadBalancer` Service on tcp/32400 pinned to `${PLEX_L4_VIP}` plus a `CiliumNetworkPolicy` admitting `world`/`cluster`/`host`/`remote-node`. Non-catalog because the SD schema is `additionalProperties: false` and cannot express a LoadBalancer Service or a pinned lbipam VIP — the same reason `pihole-dns.yaml`, `crafty/service-l4.yaml` and `unifi/50-service-l4.yaml` are hand-written. Registered in `render.py`'s `EXTRA_UNITS` so `--emit-waves` places it in plex's own wave.
`PLEX_L4_VIP` = `192.168.2.24` on prod, verified free: pool `default-pool` IPsTotal 11 / IPsUsed 4 / IPsAvailable 7, allocations only `.20`/`.21`/`.22`/`.23`.
**`ADVERTISE_IP` deliberately not set.** Advertising the Gateway URL was the alternative and the mechanism was verified to work on an already-claimed server. Operator chose the native path so Plex terminates its own TLS (`*.plex.direct`) and streams bypass the Envoy L7 hop.
**`externalTrafficPolicy: Cluster`**, inheriting crafty's 8-day-outage lesson rather than re-deriving it. Documented consequence: SNAT puts remote clients inside `ALLOWED_NETWORKS`, so **Plex meters them as LAN** (confirmed — a probe logged as `(Allowed Network (WAN))`). Metering effect, not an authorization change.
### ⚠️ This does not work yet — two manual steps remain
Neither can live in this repo. `runbooks/plex-remote-access.md` is the procedure.
1. **Router:** forward WAN tcp/32400 → `192.168.2.24`. Also check whether the router's management UI is bound to WAN:443, which would shadow the Gateway's forward.
2. **Plex UI:** Settings → Remote Access → tick **"manually specify public port"** = 32400. There is **no env var for this** — grep of the running image's entire init surface for `ManualPortMapping`/`PLEX_PREFERENCE` returned **NONE**.
### Gate
`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'` → **rc 3**, not green, reported as rc 3.
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy` all **pass**. The rc 3 comes from two environmental skips only: `gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). `render-golden` passing is the specific proof that the `render.py` change, the new unit and the edited wave kustomization are mutually consistent.
### 🔒 Sensitive path — needs an operator override
Touches `clusters/**` (2 files), so `ci/approval-policy.sh` will **block**. Per CLAUDE.md §1 I have **not** posted the override myself — the agent account holds `owner`, so that prohibition is a rule rather than a limit. Please post, as a PR **review** of type "Comment":
```
/approve-override <reason>
```
### Not verified — please read
- **The fix itself is unproven.** Both remaining steps are off-repo and manual. This removes two blockers; it does not demonstrate an outcome.
- **Outside-in reachability, at all.** Every probe was from inside the LAN, so the `:443` router cert strictly proves only that there is **no NAT hairpin** — not outside-in failure. There is no off-LAN vantage point here.
- **`192.168.2.24` was never ARP-probed off-cluster** (only in-cluster pool accounting). `.23` was `arping`-probed before it was claimed; `.24` was not.
- **The LoadBalancer has never been reconciled** — LB-IPAM has not assigned the VIP, L2 announcement is unobserved, and the new policy has never admitted a real packet. The entity set is reasoned from crafty's working game-port rule, not measured.
### Pre-existing drift found and deliberately left alone
Running the documented `render.py --emit-waves` wants to add **`vm-demo.yaml`** to wave 0 and **`keycloak.yaml`** to wave 1 — both listed as intentionally *not* onboarded in `ci/render-golden.sh`. `--emit-waves` places any file on disk with no awareness of that exclusion, and `render-golden.sh` uses `--wave-layout` and never checks kustomization `resources`, which is why it has survived unnoticed. **So the "DO NOT hand-edit: re-run `--emit-waves`" header is currently unsafe advice** — following it onboards an IdP and a KubeVirt VM. I restored both kustomizations and hand-added only my one line. Not fixed here; worth its own issue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Remote Plex clients had no path in, for two measured reasons: Plex advertised
no remote connection at all (customConnections and ManualPortMappingMode both
ABSENT while PublishServerOnPlexOnlineKey=1), and its Service was ClusterIP, so
32400 had no off-cluster path. The L7 Gateway was NOT the problem -- it serves
Plex correctly (200, claimed=1, 401 on protected endpoints) -- but Plex clients
discover servers through plex.tv, not DNS, so an HTTPRoute cannot put this
server on a client's connection list.
Operator chose the native path over ADVERTISE_IP: Plex terminates its own TLS
with *.plex.direct and streams bypass the Envoy L7 hop.
Adds a hand-written non-catalog unit (the SD schema cannot express a
LoadBalancer Service or a pinned lbipam VIP), registered in render.py's
EXTRA_UNITS so --emit-waves places it in plex's own wave.
externalTrafficPolicy: Cluster, inheriting crafty's 8-day-outage lesson rather
than re-deriving it. Known consequence, documented: SNAT puts remote clients
inside ALLOWED_NETWORKS, so Plex meters them as LAN. Not an auth change.
Remote access does NOT work until the operator does two manual steps that
cannot live in this repo -- the WAN tcp/32400 forward and Plex's "manually
specify public port" toggle (no env var exists for it; verified NONE in the
image's init surface). runbooks/plex-remote-access.md is the procedure.
Touches clusters/** -- a sensitive path -- so the approval-policy stage will
block pending an operator /approve-override.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Reported symptom: users cannot reach Plex from outside the LAN. Two independent causes, both measured against live prod on 2026-10-03.
What was actually wrong
Preferences.xml:customConnectionsABSENT,ManualPortMappingModeABSENT, whilePublishServerOnPlexOnlineKey="1"— publishing was on, with nothing reachable to publish. Plex's automatic UPnP/NAT-PMP path cannot work from a pod on the Cilium overlay.136.51.115.252:32400→ refused.136.51.115.252:443→ a self-signed router cert (C=ZZ, CN=%D), not the Gateway's LE wildcard. The Plex Service wasClusterIPonly, so 32400 had no off-cluster path at all.The L7 Gateway was NOT the problem. It serves Plex correctly —
GET /identity→HTTP 200,claimed="1", and401on every protected endpoint. But Plex apps discover servers through plex.tv, not DNS, so an HTTPRoute can never put this server on a client's connection list.What this changes
A hand-written non-catalog unit,
apps/wave-0/plex-remote.yaml: atype=LoadBalancerService on tcp/32400 pinned to${PLEX_L4_VIP}plus aCiliumNetworkPolicyadmittingworld/cluster/host/remote-node. Non-catalog because the SD schema isadditionalProperties: falseand cannot express a LoadBalancer Service or a pinned lbipam VIP — the same reasonpihole-dns.yaml,crafty/service-l4.yamlandunifi/50-service-l4.yamlare hand-written. Registered inrender.py'sEXTRA_UNITSso--emit-wavesplaces it in plex's own wave.PLEX_L4_VIP=192.168.2.24on prod, verified free: pooldefault-poolIPsTotal 11 / IPsUsed 4 / IPsAvailable 7, allocations only.20/.21/.22/.23.ADVERTISE_IPdeliberately not set. Advertising the Gateway URL was the alternative and the mechanism was verified to work on an already-claimed server. Operator chose the native path so Plex terminates its own TLS (*.plex.direct) and streams bypass the Envoy L7 hop.externalTrafficPolicy: Cluster, inheriting crafty's 8-day-outage lesson rather than re-deriving it. Documented consequence: SNAT puts remote clients insideALLOWED_NETWORKS, so Plex meters them as LAN (confirmed — a probe logged as(Allowed Network (WAN))). Metering effect, not an authorization change.⚠️ This does not work yet — two manual steps remain
Neither can live in this repo.
runbooks/plex-remote-access.mdis the procedure.192.168.2.24. Also check whether the router's management UI is bound to WAN:443, which would shadow the Gateway's forward.ManualPortMapping/PLEX_PREFERENCEreturned NONE.Gate
./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'→ rc 3, not green, reported as rc 3.spec-immutability,decisions-log,lint,l0,render-golden,l1,approval-policyall pass. The rc 3 comes from two environmental skips only:gitea-settings(no localSETTINGS_API_TOKEN) andconformanceL2/L3 (CLUSTER_AVAILABLEunset).render-goldenpassing is the specific proof that therender.pychange, the new unit and the edited wave kustomization are mutually consistent.🔒 Sensitive path — needs an operator override
Touches
clusters/**(2 files), soci/approval-policy.shwill block. Per CLAUDE.md §1 I have not posted the override myself — the agent account holdsowner, so that prohibition is a rule rather than a limit. Please post, as a PR review of type "Comment":Not verified — please read
:443router cert strictly proves only that there is no NAT hairpin — not outside-in failure. There is no off-LAN vantage point here.192.168.2.24was never ARP-probed off-cluster (only in-cluster pool accounting)..23wasarping-probed before it was claimed;.24was not.Pre-existing drift found and deliberately left alone
Running the documented
render.py --emit-waveswants to addvm-demo.yamlto wave 0 andkeycloak.yamlto wave 1 — both listed as intentionally not onboarded inci/render-golden.sh.--emit-wavesplaces any file on disk with no awareness of that exclusion, andrender-golden.shuses--wave-layoutand never checks kustomizationresources, which is why it has survived unnoticed. So the "DO NOT hand-edit: re-run--emit-waves" header is currently unsafe advice — following it onboards an IdP and a KubeVirt VM. I restored both kustomizations and hand-added only my one line. Not fixed here; worth its own issue.🤖 Generated with Claude Code