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]>
9.4 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-03 | feat/plex-remote-access-l4 | Plex remote access — raw tcp/32400 via a pinned LoadBalancer VIP, not the L7 Gateway |
What changed
Reported symptom: users cannot reach Plex from outside the LAN. Two independent causes, measured rather than inferred:
- Plex advertised no remote connection at all. Live
Preferences.xmlon prod:customConnectionsABSENT,ManualPortMappingModeABSENT, whilePublishServerOnPlexOnlineKey="1"— publishing was on and there was 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:32400refused;136.51.115.252:443answered with a self-signed router cert (C=ZZ, CN=%D), not the Gateway's LE wildcard. The Plex Service wasClusterIPonly — no off-cluster path to 32400 by any route.
This branch fixes the in-cluster half. Remote access does not work until the operator also does
two manual steps (runbooks/plex-remote-access.md): the WAN tcp/32400 port-forward, and ticking
"manually specify public port" in the Plex UI.
apps/wave-0/plex-remote.yaml(new, hand-written). Atype=LoadBalancerService on tcp/32400 pinned to${PLEX_L4_VIP}vialbipam.cilium.io/ips, plus aCiliumNetworkPolicy(plex-allow-remote-32400) admittingworld/cluster/host/remote-nodeto 32400. Non-catalog becauseschemas/servicedefinition.schema.jsonisadditionalProperties: falseand has no field for a LoadBalancer Service or a pinned VIP — the same reasonapps/wave-0/pihole-dns.yaml,platform/crafty/service-l4.yamlandapps/vms/unifi/50-service-l4.yamlare hand-written.catalog/render.py: anEXTRA_UNITSentry (plex-remote, anchored toplex,above: False) so--emit-wavesplaces the unit in plex's own wave.clusters/base/cluster-vars.yaml: newPLEX_L4_VIP(dev default192.168.1.57), plus the substitution inventory comment updated to nameplex-remote: PLEX_L4_VIP.clusters/prod/kustomization.yaml: prod overridePLEX_L4_VIP: 192.168.2.24.apps/wave-0/kustomization.yaml:plex-remote.yamladded toresources.runbooks/plex-remote-access.md(new): the two manual steps, the four-step verification, why no alert is possible, and the two documented warts below.
ADVERTISE_IP was deliberately NOT set. Setting it to https://plex.home.keithjackson.dev:443
was the alternative — it needs no new WAN hole and the mechanism was verified working on an
already-claimed server. The operator chose the native path (2026-10-03) so Plex terminates its own
TLS (*.plex.direct) and streams bypass the Envoy L7 hop. catalog/plex.yaml's deferral note for
ADVERTISE_IP remains accurate and untouched.
externalTrafficPolicy: Cluster, with a known cost. Local would preserve the client IP, but
Cilium's L2 announcer does not follow the backend pod — the wart that cost this LAN an 8-day
Minecraft outage (see platform/crafty/service-l4.yaml). Consequence accepted and documented: SNAT
puts the client in 192.168.0.0/16, inside ALLOWED_NETWORKS, so Plex classifies remote streams
as LAN. That is a metering effect (remote bandwidth limits will not bind), not an authorization one.
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". I have deliberately not posted it: CLAUDE.md §1 forbids an agent clearing this on its own
PR, and the agent account holds owner, so the prohibition is a rule, not a limit.
What I verified
All measurements against live prod, 2026-10-03, read-only.
- Plex's advertised state, the finding this branch rests on:
customConnections=<ABSENT>,ManualPortMappingMode=<ABSENT>,ManualPortMappingPort=<ABSENT>,PublishServerOnPlexOnlineKey="1",allowedNetworks="10.0.0.0/8,192.168.0.0/16". Read by attribute grep, never by dumpingPreferences.xml(it holdsPlexOnlineToken). - The L7 Gateway path works and is NOT the problem. Via the Gateway VIP,
GET https://plex.home.keithjackson.dev/identity→HTTP 200,claimed="1", realmachineIdentifier. HTTPRouteplex-32400:Accepted=True,ResolvedRefs=True. - Plex enforces its own auth, which is what makes an unfiltered WAN port defensible:
/library/sections,/status/sessions,/:/prefsall → 401 unauthenticated through the Gateway. - The SNAT/allowedNetworks wart is real, not theoretical. A tagged probe through the Gateway was
logged by Plex as
[10.0.2.214:36040 (Allowed Network (WAN))]— the Envoy pod IP, classified as an allowed network. - WAN state:
136.51.115.252:32400→ connection refused.136.51.115.252:443→ self-signed certC=ZZ, ST=Somewhere, O=%D342e7e6b, CN=%D. Public DNS resolves correctly:plex.home.keithjackson.dev→ CNAMEwan.home.keithjackson.dev→136.51.115.252. 192.168.2.24is free in-cluster.CiliumLoadBalancerIPPool default-pool: IPsTotal 11 / IPsUsed 4 / IPsAvailable 7; the only allocations are.20(cilium-gateway-shared),.21(unifi-l4-),.22(crafty-l4-),.23(pihole-dns-*). Not a node address (.10/.11/.12) and not the kube-vip control-plane VIP (.9).- The manual-port-mapping step genuinely cannot be automated. Grep of the running image's whole
init surface (
/etc/cont-init.d,/plex-common.sh,/usr/local/bin) forManualPortMappingandPLEX_PREFERENCE→ NONE. ADVERTISE_IPwould have worked on a claimed server (recorded because the runbook's "why not" rests on it):setPref "customConnections"at line 126 of40-plex-first-runsits outside thePLEX_CLAIM/PlexOnlineTokenguard, and the/.firstRunCompleteearly-exit marker is on the overlay FS, not the/configPVC, so the script re-runs every pod start.- Toolbox gate (
./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'): rc 3, withspec-immutability,decisions-log,lint,l0,render-golden,l1andapproval-policyall pass. The rc 3 is two environmental skips —gitea-settings(no localSETTINGS_API_TOKEN) andconformanceL2/L3 (CLUSTER_AVAILABLEunset). Not green. Reported as rc 3, not as pass. render-goldenpassing is the specific proof that therender.pyEXTRA_UNITSchange, the new hand-written unit and the edited wave kustomization are mutually consistent.
What I did NOT verify
- THE ACTUAL FIX. Nothing in this branch has been proven to make remote access work — it cannot be, because both remaining steps are off-repo and manual (router forward, Plex UI toggle). This branch removes two blockers and adds none; it does not demonstrate an outcome.
- Outside-in reachability, at all. Every probe above was taken from inside the LAN, so the
:443self-signed cert strictly proves only that there is no NAT hairpin — it does not prove outside-in failure. A router cert on WAN:443 is suggestive (its management UI would shadow a forward), but unproven. This repo has no off-LAN vantage point. Test 3 in the runbook (app.plex.tv from a cellular connection) is the only honest check. 192.168.2.24was never ARP-probed off-cluster. Only in-cluster pool accounting was checked..23wasarping-probed before it was claimed;.24was not, so an off-cluster squatter would not have been caught.- The LoadBalancer has never been reconciled.
plex-l4-tcpdoes not exist in prod yet, so LB-IPAM has not actually assigned the pinned VIP, L2 announcement has not been observed for it, and the new CiliumNetworkPolicy has never admitted a real packet. Whetherworld/host/cluster/remote-nodeis the correct entity set under this SNAT path is reasoned fromplatform/crafty/networkpolicy.yaml's working game-port rule, not measured. - Whether Plex's remote path needs anything beyond the manual port tick. If plex.tv still lists no remote connection after both manual steps, this branch is necessary-but-insufficient.
- 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 it pending an override. - PRE-EXISTING DRIFT I FOUND AND DELIBERATELY LEFT ALONE — worth its own follow-up. Running the
documented
python3 catalog/render.py --emit-wavesregenerates the wave kustomizations and wants to addvm-demo.yamlto wave 0 andkeycloak.yamlto wave 1. Both are listed as intentionally not onboarded inci/render-golden.sh(RENDER_GOLDEN_NOT_ONBOARDED="keycloak vm-demo"); their manifests are tracked inapps/but deliberately absent from theresourceslists so Flux does not apply them.--emit-wavesplaces any file present on disk and has no awareness of that exclusion, whilerender-golden.shuses--wave-layoutand compares rendered manifests only — it never checks kustomizationresources, which is why this has survived unnoticed. So the header "DO NOT hand-edit: re-runrender.py --emit-waves" is currently unsafe advice: following it onboards an IdP and a KubeVirt VM. I therefore restored both kustomizations and hand-added only my one line. I did not fix--emit-waves, and I have not determined how long the drift has existed or whether either service is meant to be onboarded.