Files
home-network-k3s-impl/decisions/2026-10-03-plex-remote-access-l4.md
jakman85andClaude Opus 5 fbaeb3e038
gate / gate (pull_request) Successful in 1m49s
gate / conformance (pull_request) Has been skipped
feat(plex): expose tcp/32400 for remote access via a pinned LoadBalancer VIP
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]>
2026-10-03 23:30:46 -05:00

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:

  1. Plex advertised no remote connection at all. Live Preferences.xml on prod: customConnections ABSENT, ManualPortMappingMode ABSENT, while PublishServerOnPlexOnlineKey="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.
  2. Nothing from the WAN reached the cluster. 136.51.115.252:32400 refused; 136.51.115.252:443 answered with a self-signed router cert (C=ZZ, CN=%D), not the Gateway's LE wildcard. The Plex Service was ClusterIP only — 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). A type=LoadBalancer Service on tcp/32400 pinned to ${PLEX_L4_VIP} via lbipam.cilium.io/ips, plus a CiliumNetworkPolicy (plex-allow-remote-32400) admitting world/cluster/host/remote-node to 32400. Non-catalog because schemas/servicedefinition.schema.json is additionalProperties: false and has no field for a LoadBalancer Service or a pinned VIP — the same reason apps/wave-0/pihole-dns.yaml, platform/crafty/service-l4.yaml and apps/vms/unifi/50-service-l4.yaml are hand-written.
  • catalog/render.py: an EXTRA_UNITS entry (plex-remote, anchored to plex, above: False) so --emit-waves places the unit in plex's own wave.
  • clusters/base/cluster-vars.yaml: new PLEX_L4_VIP (dev default 192.168.1.57), plus the substitution inventory comment updated to name plex-remote: PLEX_L4_VIP.
  • clusters/prod/kustomization.yaml: prod override PLEX_L4_VIP: 192.168.2.24.
  • apps/wave-0/kustomization.yaml: plex-remote.yaml added to resources.
  • 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 dumping Preferences.xml (it holds PlexOnlineToken).
  • 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", real machineIdentifier. HTTPRoute plex-32400: Accepted=True, ResolvedRefs=True.
  • Plex enforces its own auth, which is what makes an unfiltered WAN port defensible: /library/sections, /status/sessions, /:/prefs all → 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 cert C=ZZ, ST=Somewhere, O=%D342e7e6b, CN=%D. Public DNS resolves correctly: plex.home.keithjackson.dev → CNAME wan.home.keithjackson.dev → 136.51.115.252.
  • 192.168.2.24 is 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) for ManualPortMapping and PLEX_PREFERENCE → NONE.
  • ADVERTISE_IP would have worked on a claimed server (recorded because the runbook's "why not" rests on it): setPref "customConnections" at line 126 of 40-plex-first-run sits outside the PLEX_CLAIM/PlexOnlineToken guard, and the /.firstRunComplete early-exit marker is on the overlay FS, not the /config PVC, so the script re-runs every pod start.
  • Toolbox gate (./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'): rc 3, with spec-immutability, decisions-log, lint, l0, render-golden, l1 and approval-policy all pass. The rc 3 is two environmental skips — gitea-settings (no local SETTINGS_API_TOKEN) and conformance L2/L3 (CLUSTER_AVAILABLE unset). Not green. Reported as rc 3, not as pass.
  • render-golden passing is the specific proof that the render.py EXTRA_UNITS change, 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 :443 self-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.24 was never ARP-probed off-cluster. Only in-cluster pool accounting was checked. .23 was arping-probed before it was claimed; .24 was not, so an off-cluster squatter would not have been caught.
  • The LoadBalancer has never been reconciled. plex-l4-tcp does 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. Whether world/host/cluster/ remote-node is the correct entity set under this SNAT path is reasoned from platform/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-waves regenerates the wave kustomizations and wants to add vm-demo.yaml to wave 0 and keycloak.yaml to wave 1. Both are listed as intentionally not onboarded in ci/render-golden.sh (RENDER_GOLDEN_NOT_ONBOARDED="keycloak vm-demo"); their manifests are tracked in apps/ but deliberately absent from the resources lists so Flux does not apply them. --emit-waves places any file present on disk and has no awareness of that exclusion, while render-golden.sh uses --wave-layout and compares rendered manifests only — it never checks kustomization resources, which is why this has survived unnoticed. So the header "DO NOT hand-edit: re-run render.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.