Files
home-network-k3s-impl/decisions/2026-10-04-plex-advertise-plexdirect.md
T
jakman85andClaude Opus 5 1f4552d409
gate / gate (pull_request) Successful in 1m48s
gate / conformance (pull_request) Has been skipped
fix(plex): advertise the plex.direct name, not the bare VIP (server vanished from the plex.tv account)
customConnections held two hand-typed URLs and neither could ever complete, so
Plex had nothing publishable and the server disappeared from the account:

  - https://192.168.2.24:32400 -- the server's cert is
    *.130072f20c334d6c8f63d0dfef87309b.plex.direct with NO IP SAN, so bare-IP
    https fails TLS for every client. Measured: a continuous storm of
    "CERT: incomplete TLS handshake ... certificate unknown" from real LAN
    clients arriving through the plex-l4-tcp VIP.
  - https://plex.home.keithjackson.dev:32400 -- nothing listens; the Gateway
    serves that host on 443 (measured http=000 against 192.168.2.20:32400).

With no completable connection plex.tv published nothing ("Mapped - Not
Published"), Plex fell back to Relay, and clients reported "not authorized".

Fix: advertise <dashed-ip>.<serverhash>.plex.direct -- public DNS that resolves
to the embedded address, so it points at the LAN VIP and matches the wildcard
cert. Verified with full TLS validation (no curl -k): 200 + verify OK, where
the bare-IP form fails to connect.

Declared as ADVERTISE_IP so git owns the value instead of a web form: the
image's 40-plex-first-run writes it into customConnections on every pod start,
outside the claim guard.

Does NOT fix off-LAN access -- the advertised address is RFC1918 and the WAN
tcp/32400 forward remains unverified. ManualPortMappingMode=1 with an absent
port is left as-is (no GitOps path exists for it).

Touches clusters/** -> sensitive paths -> needs the operator's
/approve-override, deliberately not self-posted (CLAUDE.md 1).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-04 02:53:01 -05:00

11 KiB

date, branch, title
date branch title
2026-10-04 fix/plex-advertise-plexdirect Plex vanished from the plex.tv account — every advertised connection was uncompletable; advertise the plex.direct name instead of the bare VIP

What changed

Reported symptom: the server no longer appears on the operator's plex.tv account; plex.home.keithjackson.dev shows "not authorized"; connecting straight to the VIP + port works.

Root cause, measured: customConnections held two hand-typed URLs and NEITHER could ever complete, so Plex had nothing publishable. Because customConnections OVERRIDES Plex's own connection publishing, the server ended up advertising only dead endpoints. Live Preferences.xml, 2026-10-04:

customConnections="https://plex.home.keithjackson.dev:32400/,https://192.168.2.24:32400/"
ManualPortMappingMode="1"   ManualPortMappingPort=<ABSENT>
  • https://192.168.2.24:32400 — TLS can never validate. Plex serves 32400 with CN=*.130072f20c334d6c8f63d0dfef87309b.plex.direct and no IP SAN, so a bare-IP https URL fails for every client. Not theoretical: Plex's log carries a continuous storm of CERT: incomplete TLS handshake from <node> ... sslv3 alert certificate unknown, every few seconds, from real LAN clients arriving through the new plex-l4-tcp VIP.
  • https://plex.home.keithjackson.dev:32400 — nothing listens. The shared Gateway serves that host on 443; measured http=000 (connect failed) against 192.168.2.20:32400.

Consequence chain, each link measured: no completable connection -> plex.tv could publish nothing (MyPlex: mapping state set to 'Mapped - Not Published', repeatedly) -> Plex fell back to Plex Relay (startRelay, remote forward to 127.0.0.1:32401) -> the server disappeared from the account, and clients reported "not authorized".

The fix advertises Plex's own <dashed-ip>.<serverhash>.plex.direct form, which is public DNS resolving to the embedded address — so it points at the LAN VIP and matches the wildcard cert.

  • catalog/plex.yaml: ADVERTISE_IP: ${PLEX_ADVERTISE_URL} added, replacing the long-standing DEFERRAL note. The deferral had weighed only the L7 Gateway URL; the operator chose the native L4 path on 2026-10-03, so the open question was never "Gateway or not" but "which URL has a valid cert".
  • clusters/base/cluster-vars.yaml: new PLEX_ADVERTISE_URL knob (+ substitution-inventory comment updated to name plex: PLEX_ADVERTISE_URL). The base default is an explicit non-functional placeholder — dev is decommissioned and has no claimed server, and StrictPostBuildSubstitutions makes an undefined var a hard failure, so the default only has to exist and be correctly shaped.
  • clusters/prod/kustomization.yaml: prod value https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400.
  • apps/wave-0/plex.yaml: re-rendered with the canonical command (python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation). Two added lines, nothing else.

Why a whole-URL cluster-var and not something built from ${PLEX_L4_VIP}: postBuild substitution is plain envsubst and cannot turn 192.168.2.24 into 192-168-2-24, and <serverhash> is the server's certificate identity, derivable from nothing in this tree. The dashed IP and ${PLEX_L4_VIP} are two halves of one address and nothing checks that they agree — noted in both files.

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". Deliberately not posted here: CLAUDE.md §1 forbids an agent clearing this on its own PR, and the agent account holds owner, so the prohibition is a rule rather than a limit.

What I verified

All measurements against live prod, 2026-10-04, read-only. Secrets handled per CLAUDE.md §6: preference attributes were read by name-scoped grep, never by dumping Preferences.xml (it holds PlexOnlineToken), and the token was reported only as <PRESENT,len=22>.

  • THE FIX ITSELF, with full TLS verification (no curl -k) — the one measurement the whole change rests on:
    • https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400/identity -> http=200, TLS verify OK
    • https://192.168.2.24:32400/identity -> http=000, cert validation FAILED
    • that name resolves to 192.168.2.24 (getent hosts), confirming the dashed-IP mechanism works from inside this network and is not blocked by DNS-rebinding protection on the resolver path tested.
  • The server is still claimed — this was never a lost claim. /identity -> claimed="1" with the real machineIdentifier; PlexOnlineToken present, PlexOnlineUsername="jakman85".
  • The certificate, read off the live listener: exactly one SAN, DNS:*.130072f20c334d6c8f63d0dfef87309b.plex.direct. No IP SAN. This is why the bare-IP URL fails.
  • The publish payload Plex actually sent to plex.tv, which is what proves the connection list was the problem rather than a guess: PUT https://servers.plex.tv/devices/<id>?Connection[][uri]=https://plex.home.keithjackson.dev:32400&Connection[][uri]=https://192.168.2.24:32400&Connection[][uri]=http://136.51.115.252:32400&Connection[][uri]=http://10.0.2.136:32400&httpsEnabled=1... — all four unreachable from a LAN client or from plex.tv.
  • Mapped - Not Published at every reachability check across the whole log, and an active Plex Relay session — the documented fallback when nothing is directly publishable.
  • The certificate unknown TLS failures come from real client traffic, not a probe. The source 10.0.1.78 resolves via cilium-dbg bpf ipcache get to identity 1 = reserved:host, i.e. LAN clients SNAT'd through plex-l4-tcp (externalTrafficPolicy: Cluster) on prod-3.
  • The Gateway is NOT broken, and my first reading of it was wrong. A 403 Access denied from server: envoy on the Gateway path turned out to be an artifact of probing from inside the plex pod: all 16 Gateway hostnames return 403 from there, because Cilium's Gateway preserves the original source identity and enforces the destination's ingress policy. Plex's own log shows a concurrent, working, signed-in browser session through the Gateway returning 200s. The HTTPRoute is Accepted=True/ResolvedRefs=True with no filters, and it is the only route claiming that hostname.
  • Cilium is not denying the Gateway->Plex hop: cilium-dbg bpf policy get 3796 shows Allow Ingress reserved:ingress 32400/TCP, PROXY PORT NONE, with 141k packets counted. No L7/HTTP rules and no CCNPs exist anywhere in the cluster.
  • In-cluster consumers were never affected: Seerr's recently-added scans and watchlist syncs complete cleanly with no auth errors; there are zero HTTP 401s in Plex's log (the five 401 hits are transcoder segment numbers).
  • ADVERTISE_IP's mechanism, in the running image rather than from docs: /etc/cont-init.d/40-plex-first-run line 127 does setPref "customConnections" "${ADVERTISE_IP}", outside the claim guard, and /.firstRunComplete (lines 9/139) is on the container overlay, not the /config PVC — so it re-applies on every pod start. Editing the env rolls the Deployment, which applies it.
  • 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 the two known environmental skips — gitea-settings (no local SETTINGS_API_TOKEN) and conformance L2/L3 (CLUSTER_AVAILABLE unset). Not green. Reported as rc 3.
  • render-golden passing is the specific proof that the catalog edit and the committed golden agree; the rendered diff is exactly two lines and no other service's manifest moved.

What I did NOT verify

  • THE OUTCOME. Nothing here has been observed to put the server back on the plex.tv account. The change is unreconciled: ADVERTISE_IP is absent from the live Deployment, the live customConnections still holds the two broken URLs, and Plex has not been restarted. What is proven is that the new URL answers 200 with a valid certificate and that every currently advertised URL cannot complete — a necessary-and-plausibly-sufficient argument, not a demonstrated fix.
  • Whether plex.tv will publish and list the server once this lands. plex.tv's decision is made off-network and cannot be measured from here. A LAN-only connection may still leave the account showing the server as remotely unreachable even when local clients work.
  • REMOTE (off-LAN) ACCESS IS NOT FIXED BY THIS, and may still be fully broken. The advertised URL resolves to an RFC1918 address, so it serves LAN clients only. The off-repo WAN tcp/32400 port-forward (runbooks/plex-remote-access.md) is still unverified — http://136.51.115.252:32400 was unreachable from inside the LAN, but 2026-10-03 established there is no NAT hairpin here, so that is inconclusive, not proof of absence. This repo has no off-LAN vantage point.
  • ManualPortMappingMode="1" with ManualPortMappingPort ABSENT is left as-is. Manual public-port mapping is enabled with no port set — a half-configured state I did not change and whose exact effect on publishing I did not isolate. There is no GitOps path to it (2026-10-03: the image supports no PLEX_PREFERENCE* of any kind), so it is an operator UI step.
  • Whether the operator intended the hand-typed URLs to override the 2026-10-03 native-L4 design, or typed them while debugging. I did not alter live preferences; the env var will overwrite customConnections on the next pod start, which is a deliberate transfer of ownership from the web form to git and will silently discard anything typed there since.
  • The plex.direct hash's stability. Treated as server identity and read once from the live cert. I did not establish what reissues it, so this URL could go stale with no alert; nothing in CI or the conformance suite checks that the dashed IP still matches ${PLEX_L4_VIP} or that the hash is current.
  • Resolver behaviour for the whole LAN. plex.direct resolved correctly from a pod (cluster DNS path). LAN clients resolve via PiHole, and dnsmasq's stop-dns-rebind would reject a public answer carrying a private address. I did not test resolution from a LAN client, and this is the most likely way the fix still fails.
  • 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 pending the operator's override.
  • Why PMS restarted. The process came up at 05:26 UTC while the pod is 2d2h old with 0 restarts, so something restarted Plex inside the container. Unexplained and not investigated.