Files
felhom.eu/documentation/audits/media-build-2026-10-15/tools/plexgate.py
T

114 lines
7.2 KiB
Python

#!/usr/bin/env python3
"""plexgate.py state|install|probe|remove — Part A (R-1045): the live catalog's Plex setup gate on a box.
Env: TARGET (box_walk row, e.g. 9201), SC (0600 dir with .ctlpw). Uses ONLY the endpoints the dashboard uses.
install POST /api/stacks/plex/deploy with an EXPIRED claim code (a made-up `claim-…`, not a secret: plex.tv refuses it),
HDD_PATH = the box's drive. Refuses if Plex is installed (live-probes rule).
probe four looks, each quoted: (1) a stranger over the PUBLIC path (no cookie, Cloudflare) on /, /identity,
/library/sections, /web; (2) a stranger on the LAN path (traefik :443 on the guest, Host header);
(3) the household: dashboard session → the gate handshake → /identity through traefik;
(4) the server itself on the docker network: claimed? and does it answer /library/sections with no token?
remove POST /api/stacks/plex/remove with remove_hdd_data=false, then read back: no container.
"""
import json, os, re, subprocess, sys, time, urllib.request, urllib.error, ssl
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
APP = "plex"
HOST = f"plex.{w.DOMAIN}"
UA = "Mozilla/5.0 (X11; Linux x86_64) felhom-plexgate-check"
def public(path, accept="text/html"):
"""A stranger over the internet path (DooPlex → Cloudflare → tunnel). No cookie, no redirect following."""
class NR(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k): return None
op = urllib.request.build_opener(NR)
try:
r = op.open(urllib.request.Request(f"https://{HOST}{path}", headers={"User-Agent": UA, "Accept": accept}), timeout=20)
code, loc, body = r.status, r.headers.get("Location"), r.read(300)
except urllib.error.HTTPError as e:
code, loc, body = e.code, e.headers.get("Location"), e.read(300)
except Exception as e:
return f"no answer ({type(e).__name__})"
b = body.decode("utf-8", "replace").replace("\n", " ")[:120]
return f"{code} loc={loc or '-'} body={b!r}"
def lan(path, cookie=None, accept="text/html"):
"""The LAN path: traefik on the guest's own address, Host header, from inside the guest."""
c = f"-H 'Cookie: {cookie}'" if cookie else ""
out = w.guest(f"curl -sk -o /tmp/pg.$$ -D - -H 'Host: {HOST}' -H 'Accept: {accept}' {c} https://127.0.0.1{path} | grep -iE '^HTTP|^location|^set-cookie' | sed 's/=[A-Za-z0-9._-]\\{{20,\\}}/=<redacted>/g'; head -c 120 /tmp/pg.$$ | tr '\\n' ' '; rm -f /tmp/pg.$$")
return out.strip().replace("\r", "")
def server():
return w.guest("IP=$(docker inspect plex -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' | awk '{print $1}'); "
"echo identity: $(curl -s -H 'Accept: application/json' http://$IP:32400/identity); "
"echo sections-no-token: HTTP $(curl -s -o /dev/null -w '%{http_code}' http://$IP:32400/library/sections); "
"echo prefs-no-token: HTTP $(curl -s -o /dev/null -w '%{http_code}' http://$IP:32400/:/prefs)").strip()
def gate_cookie():
"""The household's handshake (memory box-walk-through-setup-gate): app → 302 to the dashboard's /__gate/start →
with the dashboard session → 302 to the app's /__felhom_gate/cb → Set-Cookie felhom_gate."""
sess = open(f"{w.SC}/sess{os.getpid()}.txt").read().strip()
r1 = w.guest(f"curl -sk -o /dev/null -D - -H 'Host: {HOST}' -H 'Accept: text/html' https://127.0.0.1/ | grep -i '^location' | tr -d '\\r'")
loc = r1.split(":", 1)[1].strip() if ":" in r1 else ""
m = re.match(r"https?://([^/]+)(/.*)", loc)
if not m:
return None, f"step 1 gave no redirect: {r1.strip()!r}"
r2 = w.guest(f"curl -sk -o /dev/null -D - -H 'Host: {m.group(1)}' -H 'Cookie: {sess}' 'https://127.0.0.1{m.group(2)}' | grep -i '^location' | tr -d '\\r'")
loc2 = r2.split(":", 1)[1].strip() if ":" in r2 else ""
m2 = re.match(r"https?://([^/]+)(/.*)", loc2)
if not m2:
return None, f"step 2 gave no redirect: {r2.strip()[:120]!r}"
r3 = w.guest(f"curl -sk -o /dev/null -D - -H 'Host: {m2.group(1)}' 'https://127.0.0.1{m2.group(2)}' | grep -i '^set-cookie' | tr -d '\\r'")
c = re.search(r"(felhom_gate=[^;]+)", r3)
return (c.group(1) if c else None), f"step1 → {m.group(1)}{m.group(2).split('?')[0]}; step2 → {m2.group(1)}{m2.group(2).split('?')[0]}; step3 set-cookie felhom_gate: {'yes' if c else 'NO'}"
step = sys.argv[1]
w.login()
st = w.stack(APP)
if step == "state":
w.say(f"plex deployed={st.get('deployed')} state={st.get('state')} setup_gate={json.dumps((st.get('app_config') or {}).get('setup_gate'))}")
meta = st.get("meta") or {}
w.say(f"template on the box: setup_gate={meta.get('setup_gate')} probe={meta.get('setup_done_probe')}")
elif step == "install":
if st.get("deployed"):
sys.exit(w.say("plex is installed — not touched") or 1)
vals = {"DOMAIN": w.DOMAIN, "SUBDOMAIN": "plex", "PLEX_CLAIM": "claim-expired0000000000000", "HDD_PATH": os.environ.get("HDD", "/mnt/felhom-drives/hdd_1")}
c, d = w.ctl("POST", f"/api/stacks/{APP}/deploy", {"values": vals})
w.say(f"POST /api/stacks/plex/deploy {json.dumps(vals)} -> {c} {json.dumps(d, ensure_ascii=False)[:160]}")
t0 = time.time()
for _ in range(120):
time.sleep(5)
st = w.stack(APP)
if st.get("deployed") and (st.get("app_config") or {}).get("pinned_images") and st.get("state") in ("running", "unhealthy", "degraded"):
break
w.say(f"after {time.time()-t0:.0f}s: deployed={st.get('deployed')} state={st.get('state')} setup_gate={json.dumps((st.get('app_config') or {}).get('setup_gate'))}")
print(w.guest("ls -l /opt/docker/stacks/traefik/dynamic/ | grep -i gate; sed -n 1,30p /opt/docker/stacks/traefik/dynamic/setup-gate-plex.yml 2>/dev/null"))
elif step == "probe":
w.say("== (1) a stranger over the PUBLIC path (no cookie)")
for p, a in (("/", "text/html"), ("/web/index.html", "text/html"), ("/identity", "application/json"), ("/library/sections", "application/json"), ("/:/prefs", "*/*")):
w.say(f" GET https://{HOST}{p} (Accept {a}) -> {public(p, a)}")
w.say("== (2) a stranger on the LAN path (traefik :443 on the guest)")
for p, a in (("/", "text/html"), ("/library/sections", "application/json")):
w.say(f" GET {p} (Accept {a}) -> {lan(p, accept=a)}")
w.say("== (3) the household: dashboard session -> the gate handshake")
gc, how = gate_cookie()
w.say(f" {how}")
if gc:
w.say(f" with the gate cookie: GET /identity -> {lan('/identity', cookie=gc, accept='application/json')}")
w.say("== (4) the server itself (docker network, no token)")
w.say(" " + server().replace("\n", "\n "))
elif step == "remove":
c, d = w.ctl("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": False, "remove_backups": False})
w.say(f"POST /api/stacks/plex/remove remove_hdd_data=false -> {c} {json.dumps(d, ensure_ascii=False)[:160]}")
for _ in range(40):
time.sleep(3)
if not w.stack(APP).get("deployed"):
break
print(w.guest("echo containers: $(docker ps -aq -f name=^plex$ | wc -l); echo gate file: $(ls /opt/docker/stacks/traefik/dynamic/setup-gate-plex.yml 2>/dev/null | wc -l); echo volumes: $(docker volume ls -q | grep -c plex); ls -d /opt/felhom/stacks/plex 2>&1 | tail -1"))