gates / gates (push) Successful in 6m18s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
114 lines
7.2 KiB
Python
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"))
|