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]>
127 lines
7.5 KiB
YAML
127 lines
7.5 KiB
YAML
---
|
|
# =================================================================================================
|
|
# 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 }
|