Files
home-network-k3s-impl/apps/wave-0/plex-cert.yaml
T
jakman85andClaude Opus 5 0aa1a0a78a
gate / gate (pull_request) Successful in 1m49s
gate / conformance (pull_request) Has been skipped
feat(plex): one advertised hostname for local+remote, its own LE cert, and a LAN definition
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]>
2026-10-04 03:37:57 -05:00

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