Part A proof (R-1045 narrowed): Plex setup gate on demo-hp 9201 — expired code, stranger gets only the gate (public + LAN), household passes, gate file removed with the app
gates / gates (push) Successful in 6m18s
gates / gates (push) Successful in 6m18s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
08:27:26 plex deployed=False state=not_deployed setup_gate=null
|
||||
08:27:26 template on the box: setup_gate=True probe=None
|
||||
@@ -0,0 +1,22 @@
|
||||
08:27:27 POST /api/stacks/plex/deploy {"DOMAIN": "enkisfelhom.hu", "SUBDOMAIN": "plex", "PLEX_CLAIM": "claim-expired0000000000000", "HDD_PATH": "/mnt/felhom-drives/hdd_1"} -> 202 {"ok": true, "message": "The install has started – the card shows how it is going"}
|
||||
08:27:57 after 30s: deployed=True state=running setup_gate={"state": "closed", "since": "2026-10-15T06:27:27Z", "hosts": ["plex.enkisfelhom.hu"]}
|
||||
-rw-r--r-- 1 root root 602 Oct 15 06:27 setup-gate-plex.yml
|
||||
# Setup gate for plex — managed by felhom-controller (`09` §3 decision 46).
|
||||
# The app is closed to everyone but the household until its first setup is done; then this file is removed.
|
||||
http:
|
||||
middlewares:
|
||||
felhom-setup-gate-plex:
|
||||
forwardAuth:
|
||||
address: "http://felhom-controller:8080/__felhom_gate/auth"
|
||||
routers:
|
||||
felhom-setup-gate-plex-plex:
|
||||
rule: "Host(`plex.enkisfelhom.hu`)"
|
||||
priority: 100027
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
middlewares:
|
||||
- felhom-setup-gate-plex@file
|
||||
service: "plex@docker"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
08:28:26 == (1) a stranger over the PUBLIC path (no cookie)
|
||||
08:28:26 GET https://plex.enkisfelhom.hu/ (Accept text/html) -> 302 loc=https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fplex.enkisfelhom.hu%2F body='<a href="https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fplex.enkisfelhom.hu%2F">Found</a>. '
|
||||
08:28:26 GET https://plex.enkisfelhom.hu/web/index.html (Accept text/html) -> 302 loc=https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fplex.enkisfelhom.hu%2Fweb%2Findex.html body='<a href="https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fplex.enkisfelhom.hu%2Fweb%2Findex.html">Found</a>. '
|
||||
08:28:27 GET https://plex.enkisfelhom.hu/identity (Accept application/json) -> 401 loc=- body='{"error":"this app is waiting for its first setup"}'
|
||||
08:28:27 GET https://plex.enkisfelhom.hu/library/sections (Accept application/json) -> 401 loc=- body='{"error":"this app is waiting for its first setup"}'
|
||||
08:28:27 GET https://plex.enkisfelhom.hu/:/prefs (Accept */*) -> 401 loc=- body='{"error":"this app is waiting for its first setup"}'
|
||||
08:28:27 == (2) a stranger on the LAN path (traefik :443 on the guest)
|
||||
08:28:31 GET / (Accept text/html) -> HTTP/2 302
|
||||
location: https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fplex.enkisfelhom.hu%2F
|
||||
<a href="https://felhom.enkisfelhom.hu/__gate/start?rd=https%3A%2F%2Fplex.enkisfelhom.hu%2F">Found</a>.
|
||||
08:28:34 GET /library/sections (Accept application/json) -> HTTP/2 401
|
||||
{"error":"this app is waiting for its first setup"}
|
||||
08:28:34 == (3) the household: dashboard session -> the gate handshake
|
||||
08:28:44 step1 → felhom.enkisfelhom.hu/__gate/start; step2 → plex.enkisfelhom.hu/__felhom_gate/cb; step3 set-cookie felhom_gate: yes
|
||||
08:28:47 with the gate cookie: GET /identity -> HTTP/2 200
|
||||
{"MediaContainer":{"size":0,"apiVersion":"0.1.0","claimed":false,"machineIdentifier":"9103c96d2169eea7012ca35f1bd5379c0c
|
||||
08:28:47 == (4) the server itself (docker network, no token)
|
||||
08:28:50 identity: {"MediaContainer":{"size":0,"apiVersion":"0.1.0","claimed":false,"machineIdentifier":"9103c96d2169eea7012ca35f1bd5379c0ca0c571","version":"1.41.4.9463-630c9f557"}}
|
||||
sections-no-token: HTTP 200
|
||||
prefs-no-token: HTTP 200
|
||||
@@ -0,0 +1,24 @@
|
||||
08:28:57 POST /api/stacks/plex/remove remove_hdd_data=false -> 409 {"ok": false, "error": "stack \"plex\" is still running — stop it first before removing"}
|
||||
containers: 1
|
||||
gate file: 1
|
||||
volumes: 2
|
||||
ls: cannot access '/opt/felhom/stacks/plex': No such file or directory
|
||||
|
||||
08:31:17 POST /api/stacks/plex/stop -> 200 {"ok": true, "message": "Stack plex stop completed"}
|
||||
08:31:47 POST /api/stacks/plex/remove remove_hdd_data=false -> 200 {"ok": true, "data": {"removed": "plex", "volumes_removed": ["plex_plex_config", "plex_plex_transcode"], "hdd_paths_removed": [], "hdd_paths_preserved": [], "us
|
||||
containers: 0
|
||||
gate file: 1
|
||||
volumes:
|
||||
documents
|
||||
downloads
|
||||
import
|
||||
media
|
||||
roms
|
||||
|
||||
08:32:03 gate file present: 0
|
||||
2026/10/15 06:28:27 setup_gate.go:248: [DEBUG] [web] setup gate plex: GET /library/sections without a pass — 401 (visitor 37.191.56.193)
|
||||
2026/10/15 06:28:27 setup_gate.go:248: [DEBUG] [web] setup gate plex: GET /:/prefs without a pass — 401 (visitor 37.191.56.193)
|
||||
2026/10/15 06:28:33 setup_gate.go:248: [DEBUG] [web] setup gate plex: GET /library/sections without a pass — 401 (visitor 172.16.253.1)
|
||||
2026/10/15 06:28:43 setup_gate.go:232: [INFO] [web] setup gate plex: the household passed (a dashboard session vouched for this browser) — visitor 172.16.253.1
|
||||
2026/10/15 06:31:56 setup_gate.go:468: [INFO] [stacks] plex: removed a setup-gate file for an app whose gate is not closed
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
#!/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"))
|
||||
@@ -229,7 +229,7 @@ stopping line that lies.
|
||||
| **R-779** | Security & access | P4 | **[P3-LOW] Part A's "two outside addresses seen as two" is proven through the simulated tunnel only; on the REAL tunnel the second outside address (ep0, one request allowed) was refused by Cloudflare's edge with 403 and never reached the box.** Measured 2026-10-01 19:51 UTC (`audits/visitors-2026-10-01/A/L2-demo-hp-real-tunnel.txt`): no log line on demo-hp; demo-hp's box has no geo restriction in its settings, so a Cloudflare ZONE rule (country or bot, not read) refused a German datacenter address. DooPlex's own address on the real tunnel was seen as itself. **Needs:** one sign-in from a second Hungarian address (the operator's phone off wifi) while DooPlex is locked out — 2 minutes; and say which Cloudflare rule refused ep0. | **WAITING-ON-OPERATOR — rank P3-LOW; owner: operator (a phone), CC reads the logs** **Re-ranked 2026-10-03: P3→P4: a proof gap on the real tunnel; operator-only follow-up.** | — | — | CC + operator |
|
||||
| **R-904** | Security & access | P4 | **Cloudflare can read every household's app traffic; replacing it with our own relay is a later item.** Facts (reviewer discussion 2026-10-08; `01`): app traffic and the dashboard reach the box through the Cloudflare Tunnel (`01` §5 trust table, rows end-user ↔ apps and customer ↔ controller UI; §7), and the tunnel's public end is Cloudflare's edge, where TLS ends — so Cloudflare can technically read that traffic (the FAQ says so since 2026-10-08, R-900; „TLS ends at the edge" is not written in `01` — add it there). What Cloudflare gives today, free: inbound reach with no router setup, the CGNAT answer (`01` §4, §7); certificates (`01` §7, the free tier covers one level below a zone); the geo-WAF the hub enforces (`01` §5 last row, §7); flood protection (not in `01`). The alternative named: our own EU relay over WireGuard with TLS passthrough by SNI, certificates on the box, the geo-block on the relay. Its costs: one more machine the operator keeps up, and a single point of reach for every box; weaker flood protection; about a week of work after a spike. Operator ruling 2026-10-08 09:07 (`09` §3 decision 184): a later item. | **DEFERRED** — **2026-10-10: the asked sentence is in `01` §7** („TLS ends at Cloudflare's edge …”); the spike stays deferred. **DEFERRED — after the first customers (operator ruling 2026-10-08 09:07)** | — | A spike after the first customers (the relay's reach, cost and flood behaviour, measured) | operator |
|
||||
| **R-1040** | Security & access | P4 | **Until the household makes Sonarr's or Radarr's first admin, any container on the box can read the app's API key.** Measured on the bench 2026-10-14 with the R-1030 seed in place: `AuthenticationRequired=Enabled`, `AuthenticationMethod=None` — `/initialize.json` from another container answers 200 with `apiKey`. Outsiders are blocked by the box's setup gate in that window; a compromised other app is not. | **OPEN** — filed 2026-10-14 | — | Seed the API key at install (`SONARR__AUTH__APIKEY`, measured to work) so the box knows it, and close the window by telling the household in the first steps; or seed a Forms user | CC |
|
||||
| **R-1045** | Security & access | P2 | **An unclaimed Plex answers everything without a token to a private address — and through the tunnel every request comes from one.** MEASURED 2026-10-14 night on a throwaway `plex-k6` on Tester 1 (`audits/media-drive-events-2026-10-14/``K6-1-unclaimed-plex.txt`): with no claim, `/library/sections`, `/:/prefs` and `/myplex/account` answer 200 with NO token from the Docker network. Through cloudflared, Plex sees cloudflared's private address, so a publicly routed unclaimed Plex would be open to anyone — server settings and every library. R-1028's late code leaves exactly such a server. NOT measured over the public path (the throwaway had no route). Harm if true: a household's media and Plex settings open to the internet. | **OPEN** — filed 2026-10-14 night | — | Measure on a throwaway with a public route on a Tier-0 box (P1 if it holds); the build: no public route until `PlexOnlineToken` is set | CC |
|
||||
| **R-1045** | Security & access | P2 | **An unclaimed Plex answers everything without a token to a private address — and through the tunnel every request comes from one.** MEASURED 2026-10-14 night on a throwaway `plex-k6` on Tester 1 (`audits/media-drive-events-2026-10-14/``K6-1-unclaimed-plex.txt`): with no claim, `/library/sections`, `/:/prefs` and `/myplex/account` answer 200 with NO token from the Docker network. Through cloudflared, Plex sees cloudflared's private address, so a publicly routed unclaimed Plex would be open to anyone — server settings and every library. R-1028's late code leaves exactly such a server. NOT measured over the public path (the throwaway had no route). Harm if true: a household's media and Plex settings open to the internet. **NARROWED 2026-10-15 (Part A, `audits/media-build-2026-10-15/A-plex-gate/`):** the live catalog's Plex has `setup_gate: true` (app-catalog `9a23ee5`). Proven on demo-hp 9201 with an EXPIRED claim code: the server came up unclaimed and open on the docker network (`/library/sections`, `/:/prefs` 200 with no token), while a stranger over the public path got only the gate (HTML → 302 to the dashboard's `/__gate/start`; `/identity`, `/library/sections`, `/:/prefs` → 401 „waiting for its first setup”), the same on the LAN path; the household passed with its dashboard session and reached Plex. Removing the app removed the gate file 9 s later (the tick). **Left open:** the gate opens on the household's „Kész, beállítottam” even while Plex is still unclaimed (no probe: Plex answers XML to the probe's request) — closed by R-1028's build (the controller claims and opens on a real claim). | **NARROWED** — the public path is closed by the gate; the „Kész” button on an unclaimed server is the rest | R-1028 | With R-1028: the gate opens only when Plex reports `claimed` (the controller asks with `Accept: application/json`) | CC |
|
||||
| **R-1050** | Security & access | P4 | **Raise HSTS to one year on or after 2026-11-15, if no https trouble was reported.** `09` §3 decision 249 (D2 = A, operator 2026-10-15): HSTS stays `max-age=86400` now; after a month with no https trouble it becomes one year (`31536000`); never preload, never `includeSubDomains`. The value is `HSTSMaxAgeSeconds` in `felhom-controller/controller/internal/infra/infra.go`, pinned by `TestRenderForwardedHeaders_HTTPSOnly`. „Trouble” = any report of a name that will not open, or a box answering http after the floor. | **WATCHING** — filed 2026-10-15 | the date | On or after 2026-11-15: read the register and the operator's mail for https trouble since 2026-10-15; none → a controller release with one year (test updated), bake → vouch → floor | CC |
|
||||
|
||||
## Box system & updates — 11 rows (P2 1, P3 7, P4 3)
|
||||
|
||||
Reference in New Issue
Block a user