Two symptoms after the publishing fix landed:
1. LAN clients were reported as Remote. Measured root cause:
LanNetworksBandwidth was ABSENT, so Plex had no notion of a local
network and classified everything WAN -- 468 requests logged
"Allowed Network (WAN)", ZERO "(LAN)", including Seerr from inside the
cluster. allowedNetworks only waives AUTH; it does not make a client
local. Two settings, and only one has an env hook.
2. The advertised URL hard-coded both the VIP and the server's cert hash
into a cluster-var -- two values nothing kept in sync.
Operator design: ONE hostname, plex-vip.${BASE_DOMAIN}, split-horizon --
PiHole answers it with ${PLEX_L4_VIP} on the LAN, Cloudflare answers it
publicly with the WAN address NATed to the same VIP.
- catalog/plex.yaml: ADVERTISE_IP -> https://plex-vip.${BASE_DOMAIN}:32400,
plus a level-triggered postStart hook (pihole/qbittorrent idiom) that
re-asserts LanNetworksBandwidth and the three customCertificate* prefs
every start, converting PEM -> PKCS#12 so cert renewals need no extra part.
- apps/wave-0/plex-cert.yaml: cert-manager Certificate for exactly that
name from letsencrypt-prod, so the advertised https URL is servable.
- schema 1.11.0: `optional` on secretKeyRef/configMapKeyRef. Load-bearing --
the cert is issued INTO the plex namespace, which the rendered plex.yaml
itself creates, so it lands in the same reconcile; hard refs would have
held Plex in CreateContainerConfigError and, since apps-wave-0 is
wait:true, stalled the whole app tier on a slow DNS01 challenge.
- catalog/pihole.yaml: the plex-vip split-horizon answer. Required --
measured, the name resolved to the Gateway VIP where nothing listens
on 32400 (http=000).
- PLEX_ADVERTISE_URL deleted; the hostname removes the cert-hash fragility.
Corrects my own earlier claim that a SAN match is REQUIRED: it is not
(httpsRequired=0, secureConnections defaults to Preferred, so clients can
fall back to cleartext). A matching cert keeps the remote leg ENCRYPTED.
Hence: obtain a real cert for the advertised name rather than pick the name
to suit Plex's built-in one.
UNVERIFIED, and stated plainly in the handoff: nothing here has run. Cert
issuance for this name is inferred from the wildcard's success, and whether
Plex accepts an OpenSSL-3 PKCS#12 bundle is untested -- the largest risk.
Touches clusters/** -> sensitive paths -> needs a non-author approval or the
operator's /approve-override; deliberately not self-posted (CLAUDE.md 1).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
67 lines
4.4 KiB
YAML
67 lines
4.4 KiB
YAML
---
|
|
# =================================================================================================
|
|
# PLEX TLS — a Let's Encrypt certificate for the ONE hostname Plex advertises to plex.tv.
|
|
# =================================================================================================
|
|
# 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, pihole-dns and plex-remote use. It is hand-written for the same reason:
|
|
# `schemas/servicedefinition.schema.json` is `additionalProperties: false` and has NO field for a
|
|
# cert-manager Certificate. (`ingressRoutes` gets TLS for free from the Gateway's wildcard; this is
|
|
# the opposite case — a certificate for a workload that terminates its OWN TLS on a raw L4 port.)
|
|
#
|
|
# WHY PLEX NEEDS ITS OWN CERTIFICATE AT ALL. Plex serves tcp/32400 directly (not through the L7
|
|
# Gateway — the operator chose the native path 2026-10-03, apps/wave-0/plex-remote.yaml) and it
|
|
# presents its own `*.<serverhash>.plex.direct` certificate there. That name cannot cover
|
|
# `plex-vip.${BASE_DOMAIN}`, the hostname we advertise, so without this the advertised `https://`
|
|
# URL fails hostname verification for every client. That is not hypothetical: it is the shape of
|
|
# the 2026-10-04 outage, where Plex's log carried a continuous storm of
|
|
# `CERT: incomplete TLS handshake ... sslv3 alert certificate unknown` from real LAN clients while
|
|
# plex.tv could publish nothing and the server vanished from the account.
|
|
#
|
|
# A SAN match is not what makes Plex FUNCTION — `httpsRequired=0` and `secureConnections` default
|
|
# to Preferred, so clients can fall back to cleartext. It is what keeps the REMOTE leg encrypted
|
|
# rather than silently downgrading traffic that crosses the internet.
|
|
#
|
|
# HOW IT REACHES PLEX. catalog/plex.yaml takes `tls.crt`/`tls.key` from this Secret as env
|
|
# (`optional: true` — see below), and its `lifecycle.postStart` hook converts the PEM into the
|
|
# PKCS#12 bundle Plex's `customCertificatePath` requires and sets the three `customCertificate*`
|
|
# prefs. The conversion is redone on EVERY pod start, so a cert-manager RENEWAL is picked up with
|
|
# no extra moving part and nothing to remember.
|
|
#
|
|
# ORDERING, AND WHY THE ENV REFS ARE OPTIONAL. This Certificate must live in the `plex` namespace
|
|
# for the Deployment to read its Secret, and that namespace is created by the rendered
|
|
# apps/wave-0/plex.yaml ITSELF — so this cannot be issued in an earlier tier (platform-config runs
|
|
# before the namespace exists) and lands in the SAME reconcile as the workload. On a cold bootstrap
|
|
# the Secret is therefore absent for as long as a DNS01 challenge takes. With hard env refs Plex
|
|
# would sit in CreateContainerConfigError until then, and because `apps-wave-0` is `wait: true`
|
|
# with `apps-wave-1` dependsOn it, a failed or slow issuance would STALL THE WHOLE APP TIER. Hence
|
|
# `optional: true` on both refs (schema 1.11.0, added for exactly this): no certificate means Plex
|
|
# still starts and serves on its own plex.direct cert, the hook logs that TLS was not installed,
|
|
# and the next start after issuance converges.
|
|
#
|
|
# ISSUER: ClusterIssuer `letsencrypt-prod` (platform/cert-manager/clusterissuers.yaml), the same
|
|
# issuer behind the Gateway's wildcard (platform/gateway/wildcard-certificate.yaml, Ready 28d on
|
|
# prod as measured 2026-10-04). A SINGLE name is requested rather than reusing the wildcard,
|
|
# because the wildcard's Secret lives in the `gateway` namespace and a Secret cannot be read across
|
|
# namespaces; requesting `*.${BASE_DOMAIN}` again here would also burn a duplicate-certificate slot
|
|
# against Let's Encrypt's weekly limit for no benefit. A single name is the least-privilege shape:
|
|
# this key signs for `plex-vip.${BASE_DOMAIN}` and nothing else.
|
|
apiVersion: cert-manager.io/v1
|
|
kind: Certificate
|
|
metadata:
|
|
name: plex-vip
|
|
namespace: plex
|
|
labels:
|
|
app.kubernetes.io/name: plex
|
|
spec:
|
|
# The name Plex ADVERTISES and the name on this certificate must stay identical. They are two
|
|
# halves of one decision: catalog/plex.yaml's ADVERTISE_IP, the postStart hook's
|
|
# customCertificateDomain, this dnsNames entry, and catalog/pihole.yaml's split-horizon answer.
|
|
# Nothing enforces that they agree.
|
|
dnsNames:
|
|
- plex-vip.${BASE_DOMAIN}
|
|
secretName: plex-vip-tls
|
|
issuerRef:
|
|
kind: ClusterIssuer
|
|
name: letsencrypt-prod
|