--- # ================================================================================================= # PLEX REMOTE ACCESS — raw tcp/32400 north-south exposure, and the policy that admits it. # ================================================================================================= # HAND-WRITTEN, NOT RENDERED. A non-catalog app-tier unit anchored to the `plex` ServiceDefinition # and placed into wave 0 by `catalog/render.py --emit-waves` via its EXTRA_UNITS table — the same # mechanism gitea-actions and pihole-dns use. It is hand-written for the SAME reason those are: # schemas/servicedefinition.schema.json is `additionalProperties: false` and has NO field for a # type=LoadBalancer Service, for a pinned lbipam VIP, or for raw-TCP north-south exposure at all. # `ingressRoutes` renders HTTPRoutes on the shared Gateway, which is an L7 HTTPS terminator. # # WHY THE L7 GATEWAY IS NOT ENOUGH HERE — and it is NOT because the Gateway fails to serve Plex. # Measured 2026-10-03 through the Gateway VIP: `GET https://plex.home.keithjackson.dev/identity` # returned HTTP 200 with `claimed="1"` and the real machineIdentifier, and the protected endpoints # (/library/sections, /status/sessions, /:/prefs) all returned 401. The L7 path WORKS. # # The problem is DISCOVERY, not proxying. Plex apps (phone, TV, Roku) do not resolve # `plex.${BASE_DOMAIN}`; they ask plex.tv for the server's connection list. A server gets onto that # list one of two ways: it advertises a custom URL (`customConnections`, the ADVERTISE_IP env), or it # publishes a reachable public IP:port for its own tcp/32400. Measured in the live Preferences.xml on # 2026-10-03: `customConnections` ABSENT, `ManualPortMappingMode` ABSENT, while # `PublishServerOnPlexOnlineKey="1"` — the server was trying to publish and had nothing reachable to # publish, so remote clients saw NO connection at all. The operator chose the NATIVE path (this # file) over advertising the Gateway URL: Plex then terminates its own TLS with its # `*.plex.direct` certificate and video streams bypass the Envoy L7 hop entirely. # # VIP: ${PLEX_L4_VIP} (cluster-vars, R-ENV-02 — dev 192.168.1.57, prod 192.168.2.24), PINNED via # lbipam.cilium.io/ips out of the CiliumLoadBalancerIPPool `default-pool` # (${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP}) and ARP-announced on the flat LAN by the existing # CiliumL2AnnouncementPolicy `default-l2` (platform-config/cilium/*). No new Cilium config is needed; # this Service just consumes the pool. PINNING IS NOT OPTIONAL: the address is typed into a WAN # port-forward on the SOHO router, which is hand-maintained and off-repo, so it can never move. # 192.168.2.24 verified FREE live 2026-10-03 — `default-pool` reported IPsTotal 11 / IPsUsed 4 / # IPsAvailable 7, and the only four allocations were .20 (cilium-gateway-shared), .21 (unifi-l4-*), # .22 (crafty-l4-*) and .23 (pihole-dns-*). # # ONE SERVICE, TCP ONLY. Unlike unifi/crafty/pihole-dns this needs no second Service and no # lbipam.cilium.io/sharing-key: Plex's remote access is tcp/32400 and nothing else. (Plex's # DLNA/GDM discovery uses UDP, but that is LAN-only multicast and is not part of remote access.) # # NO `loadBalancerSourceRanges`, DELIBERATELY — and this is the one place this file diverges from # its closest sibling. apps/wave-0/pihole-dns.yaml pins its source range to ${LAN_CIDR} because a # resolver must never answer the internet. This Service is the OPPOSITE case: admitting remote # clients from arbitrary internet addresses is its entire purpose, so there is no range to narrow # to. Plex authenticates every caller itself (measured above: 401 on every protected endpoint # without a token), which is what makes an unfiltered port defensible here. # # NO external-dns ANNOTATION, DELIBERATELY. unifi and crafty both carry # `external-dns.alpha.kubernetes.io/hostname` so external-dns publishes their VIP as a public # Cloudflare record. This Service must not, for pihole-dns's reason: ${PLEX_L4_VIP} is an RFC1918 # address that is useless in public DNS. Remote clients get this server's address from plex.tv, not # from the zone. The existing public `plex.${BASE_DOMAIN}` record is published from the rendered # HTTPRoute via the gateway source and is NOT affected by this file — the web UI keeps working # through the Gateway exactly as before. # # ------------------------------------------------------------------------------------------------- # externalTrafficPolicy: Cluster — inherited from crafty's 8-DAY LAN OUTAGE, not re-derived. # ------------------------------------------------------------------------------------------------- # `Local` is the tempting choice because it preserves the client source IP, which this service # actually has a use for (see the allowedNetworks note below). It is still WRONG here, for the # reason platform/crafty/service-l4.yaml documents at length: Cilium's L2 announcer elects ONE lease # holder per Service and does NOT follow the backend pod, so with `Local` the VIP can be announced # by a node that has no Plex pod, which black-holes the port while every object still reports # healthy. Plex is a single-replica Deployment with `strategy: Recreate` pinned to wherever the # scheduler put it, so the mismatch is the normal case, not a rare one. Take the SNAT. # # KNOWN CONSEQUENCE OF THAT CHOICE, stated rather than discovered later: `Cluster` SNATs the client # to the ingress NODE's address (192.168.2.1x), which falls inside the `192.168.0.0/16` entry of # catalog/plex.yaml's ALLOWED_NETWORKS. Plex will therefore classify every REMOTE client as LAN. # Measured 2026-10-03 on the Gateway path, which SNATs the same way: Plex logged the request as # `[10.0.2.214:36040 (Allowed Network (WAN))]`. This is a METERING effect, not a security one — # authorization is unchanged (401s above), but Plex's remote-quality/bandwidth limits will not bind # on these streams because it believes they are local. Narrowing ALLOWED_NETWORKS cannot fix it # without also de-classifying genuine LAN clients, so it is accepted. apiVersion: v1 kind: Service metadata: name: plex-l4-tcp namespace: plex labels: app.kubernetes.io/name: plex annotations: lbipam.cilium.io/ips: "${PLEX_L4_VIP}" spec: type: LoadBalancer externalTrafficPolicy: Cluster selector: app.kubernetes.io/name: plex ports: - name: plex-remote port: 32400 targetPort: 32400 protocol: TCP --- # INGRESS allow for the raw port (R-NET-01/02). # # The rendered plex.yaml already carries `plex-allow-gateway-ingress` (fromEntities: ingress -> # 32400) and three reciprocal allows for sonarr/radarr/seerr. CiliumNetworkPolicies are ADDITIVE, so # this file adds the off-cluster case without touching those. # # `world` is Cilium's entity for anything outside the cluster, which is what a remote Plex client is. # `host` and `cluster` mirror platform/crafty/networkpolicy.yaml's game-port rule and are what make # this work under `externalTrafficPolicy: Cluster`: after SNAT the packet's source is the ingress # NODE, which Cilium classifies as `host` (same node) or `remote-node` (a different one) — NOT # `world`. Listing `world` alone would compile cleanly and then drop every remote stream, so all # four entities are named. apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: plex-allow-remote-32400 namespace: plex labels: app.kubernetes.io/name: plex spec: endpointSelector: matchLabels: app.kubernetes.io/name: plex ingress: - fromEntities: - world - cluster - host - remote-node toPorts: - ports: - { port: "32400", protocol: TCP }