Merge pull request 'feat(plex): one advertised hostname for local+remote, its own LE cert, and a LAN definition so LAN clients stop reading as remote' (#48) from feat/plex-vip-hostname-and-lan-networks into main
Reviewed-on: #48 Reviewed-by: Keith Jackson <[email protected]>
This commit was merged in pull request #48.
This commit is contained in:
commit
c163deaef9
11 files changed
+479
-85
No files matched your search
@@ -18,6 +18,7 @@ resources:
|
||||
- pihole-dns.yaml
|
||||
- pihole-oauth2-proxy-credentials.sops.yaml
|
||||
- pihole.yaml
|
||||
- plex-cert.yaml
|
||||
- plex-claim-secret.sops.yaml
|
||||
- plex-remote.yaml
|
||||
- plex.yaml
|
||||
|
||||
@@ -73,7 +73,7 @@ spec:
|
||||
- name: FTLCONF_dns_rateLimit_count
|
||||
value: '0'
|
||||
- name: FTLCONF_misc_dnsmasq_lines
|
||||
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP}
|
||||
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
|
||||
- name: FTLCONF_webserver_api_password
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
# =================================================================================================
|
||||
# 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
|
||||
+21
-1
@@ -138,7 +138,19 @@ spec:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
- name: ADVERTISE_IP
|
||||
value: ${PLEX_ADVERTISE_URL}
|
||||
value: https://plex-vip.${BASE_DOMAIN}:32400
|
||||
- name: PLEX_TLS_CRT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.crt
|
||||
optional: true
|
||||
- name: PLEX_TLS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.key
|
||||
optional: true
|
||||
resources:
|
||||
limits:
|
||||
gpu.intel.com/i915: 1
|
||||
@@ -148,6 +160,14 @@ spec:
|
||||
- name: media
|
||||
mountPath: /media
|
||||
readOnly: true
|
||||
lifecycle:
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- "# NOTE \u2014 LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.\n# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a\n# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD\n# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be\n# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` \u2014 bare. Only\n# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be\n# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell\n# variable written with braces would be blanked or would fail the whole Kustomization \u2014\n# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and\n# catalog/render.py now refuses to emit it (which is how this very comment got caught).\n# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the\n# certificate has not issued yet they are genuinely UNSET, not empty \u2014 and under `set -u`\n# the first `[ -n \"$PLEX_TLS_CRT\" ]` would abort the script, which for a postStart hook\n# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists\n# to prevent, so turning it back on here would defeat the whole design. (The alternative,\n# a brace-wrapped `:-` default, would need envsubst escaping \u2014 the trap the note above\n# describes.) Every value below is explicitly guarded instead.\nPX=\"/config/Library/Application Support/Plex Media Server/Preferences.xml\"\nCERTDIR=/config/certs\nP12=$CERTDIR/plex-vip.p12\n# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it\n# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a\n# container-format requirement, not an access control.\nP12PASS=plexvip\nLANNETS='10.0.0.0/8,192.168.0.0/16'\nCERTDOMAIN=plex-vip.${BASE_DOMAIN}\nAPI=http://127.0.0.1:32400\n\n# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so\n# a cert-manager RENEWAL is picked up with no extra moving part.\nif [ -n \"$PLEX_TLS_CRT\" ] && [ -n \"$PLEX_TLS_KEY\" ]; then\n mkdir -p $CERTDIR && chmod 700 $CERTDIR\n printf '%s' \"$PLEX_TLS_CRT\" > $CERTDIR/tls.crt\n printf '%s' \"$PLEX_TLS_KEY\" > $CERTDIR/tls.key\n chmod 600 $CERTDIR/tls.key\n if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \\\n -in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then\n mv -f $P12.tmp $P12\n chown 1000:1000 $P12 2>/dev/null || true\n chmod 600 $P12\n echo \"[plex-settings] PKCS#12 bundle written\"\n else\n rm -f $P12.tmp\n echo \"[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle\" >&2\n fi\n rm -f $CERTDIR/tls.key\nelse\n echo \"[plex-settings] WARN: TLS env empty; certificate not installed this start\" >&2\nfi\n\n# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.\ni=0\nwhile [ \"$i\" -lt 45 ]; do\n if [ \"$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)\" = \"200\" ]; then\n break\n fi\n i=$(expr $i + 1); sleep 2\ndone\n\n# (3) The prefs API needs the server token. It already lives in Preferences.xml on this\n# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An\n# UNCLAIMED server has none \u2014 a normal state on a fresh /config \u2014 so skip, do not fail.\nTOKEN=$(grep -o 'PlexOnlineToken=\"[^\"]*\"' \"$PX\" 2>/dev/null | head -1 | cut -d'\"' -f2)\nif [ -z \"$TOKEN\" ]; then\n echo \"[plex-settings] not claimed yet \u2014 skipping prefs, will retry next start\" >&2\n exit 0\nfi\n\n# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on\n# a PUT, so a raw '/' or ',' would be read as structure rather than data.\nenc() {\n printf '%s' \"$1\" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \\\n -e 's|:|%3A|g' -e 's| |%20|g'\n}\nsetpref() {\n code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT\
|
||||
\ \\\n -H \"X-Plex-Token: $TOKEN\" \"$API/:/prefs?$1=$(enc \"$2\")\" 2>/dev/null)\n if [ \"$code\" = \"200\" ]; then\n echo \"[plex-settings] set $1\"\n else\n echo \"[plex-settings] WARN: $1 -> HTTP $code\" >&2\n fi\n}\n\n# (4) THE LAN DEFINITION \u2014 the reason this hook was written. Without it Plex has NO notion\n# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests\n# logged \"Allowed Network (WAN)\" and ZERO \"(LAN)\", including Seerr from inside the\n# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks\n# (the ALLOWED_NETWORKS env) only waives AUTH \u2014 it does NOT make a client local. Two\n# different settings; only one of them has an env hook.\nsetpref LanNetworksBandwidth \"$LANNETS\"\n\n# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only\n# if the bundle exists \u2014 pointing Plex at a missing file would break its TLS listener.\nif [ -s \"$P12\" ]; then\n setpref customCertificatePath \"$P12\"\n setpref customCertificateKey \"$P12PASS\"\n setpref customCertificateDomain \"$CERTDOMAIN\"\nelse\n echo \"[plex-settings] WARN: no bundle; leaving certificate prefs untouched\" >&2\nfi\nexit 0\n"
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
|
||||
Reference in new issue
Block a user