#!/usr/bin/env python3 """fg_items.py — the VERDICT's exit items 1–5 again, now THROUGH THE PRODUCT on 9202 (controller 0.287.0, no hand-made traefik files), plus the build's own rules (reset/remove end access, controller down = closed) and the cost.""" import hashlib, json, os, re, statistics, subprocess, sys, time from fg import * SC = w.SC log = open(f"{w.EV}/items.txt", "a", buffering=1) def say(*a): w.say(*a); log.write(" ".join(map(str, a)) + "\n") w.login() fam = json.load(open(f"{SC}/fam.json")); gm = json.load(open(f"{SC}/gm.json")) GMH, MTH = f"library.{DOM}", f"video.{DOM}" say(f"##### exit items through the product — {time.strftime('%FT%TZ', time.gmtime())}") WS = ("Connection: Upgrade", "Upgrade: websocket", "Sec-WebSocket-Version: 13", "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==") cases = [(GMH, "GET", "/", ("Accept: text/html",), None), (GMH, "GET", "/", (), None), (GMH, "GET", "/api/v1/books", (), None), (GMH, "GET", "/api/v1/healthcheck", (), None), (GMH, "POST", "/api/v1/setup", ("Content-Type: application/json",), '{"username":"x","password":"Stranger-1234"}'), (GMH, "POST", "/api/v1/auth/login", ("Content-Type: application/json",), '{"username":"admin","password":"guess"}'), (GMH, "GET", "/ws/websocket", WS, None), (GMH, "GET", "/api/v1/opdsx", (), None), (GMH, "GET", "/api/v1/opds-evil", (), None), (GMH, "GET", "/api/v1/opds/../../api/v1/books", ("X-Path: as-is",), None), (GMH, "GET", "/no-such-page", ("Accept: text/html",), None), (MTH, "GET", "/", ("Accept: text/html",), None), (MTH, "GET", "/", (), None), (MTH, "GET", "/history", (), None), (MTH, "POST", "/add", ("Content-Type: application/json",), '{"url":"https://example.com/v","quality":"best"}'), (MTH, "GET", "/socket.io/?EIO=4&transport=polling", (), None), (MTH, "GET", "/socket.io/?EIO=4&transport=websocket", WS, None), (MTH, "GET", "/download/x.mp4", (), None)] say("## item 1 — a STRANGER (no cookie), LAN and simulated tunnel") apps = 0 for host, m, p, h, d in cases: extra = ["--path-as-is"] if "as-is" in " ".join(h) else [] c, hh, b = lan(host, p, *extra, method=m, data=d, hdrs=h) tc, th, tb = tun(host, p, "198.51.100.66", None, *extra, "-X", m, *sum([["-H", x] for x in h], []), *(["--data", d] if d else [])) g1, g2 = is_gate(c, hh, b), is_gate(tc, th, tb) apps += (not g1) + (not g2) say(f" {host.split('.')[0]:8s} {m:4s} {p:40s} LAN {c} {'GATE' if g1 else 'APP!'} | tunnel {tc} {'GATE' if g2 else 'APP!'}") say(f"item 1: {apps} app answers of {2*len(cases)} -> {'PASS' if apps == 0 else 'FAIL'}") say("## item 2 — family members, their OWN logins, through the product's sign-in page") c, sess, h, b = family_login("anna", fam["anna"]) ma = re.search(r"(?i)max-age=(\d+)", h); path = re.search(r"(?i)path=([^;\n]+)", h) say(f" anna signs in on {CTL}/__family/login: {c}; felhom_family {'set' if sess else 'NONE'}; Max-Age {int(ma.group(1))//86400 if ma else '?'} d; Path={path.group(1).strip() if path else '?'}; dashboard cookie set: {'felhom_session=' in h}") ga = app_cookie("library", sess)[0]; mb_s = family_login("bela", fam["bela"])[1]; mt = app_cookie("video", mb_s)[0] for host, ck, p, hh in ((GMH, ga, "/", ("Accept: text/html",)), (GMH, ga, "/api/v1/healthcheck", ()), (MTH, mt, "/", ()), (MTH, mt, "/socket.io/?EIO=4&transport=polling", ()), (MTH, mt, "/history", ())): c, h2, b2 = lan(host, p, cookie=ck, hdrs=hh) say(f" member GET {host.split('.')[0]}{p} -> {c} {'GATE' if is_gate(c, h2, b2) else 'APP'}") c, h2, b2 = lan(MTH, "/socket.io/?EIO=4&transport=websocket", "--http1.1", cookie=mt, hdrs=WS, timeout=4) say(f" bela MeTube websocket upgrade -> {c} {'GATE' if is_gate(c, h2, b2) else 'APP'} {[l for l in h2.splitlines() if l.lower().startswith('upgrade')]}") c, h2, b2 = lan(GMH, "/", cookie=mt, hdrs=("Accept: text/html",)) say(f" bela's MeTube cookie at Grimmory -> {c} {'GATE' if is_gate(c, h2, b2) else 'APP!'}") w.guest("docker restart felhom-controller >/dev/null; sleep 25") c, h2, b2 = lan(GMH, "/", cookie=ga, hdrs=("Accept: text/html",)) say(f" after the CONTROLLER restarted, anna's Grimmory cookie -> {c} {'GATE' if is_gate(c, h2, b2) else 'APP (survived)'}") w.login() c, h2, b2 = lan(CTL, "/__family/logout", method="POST", cookie=sess) c2, h3, b3 = lan(GMH, "/", cookie=ga, hdrs=("Accept: text/html",)) say(f" anna logs out ({c}) -> her Grimmory cookie {c2} {'GATE (refused)' if is_gate(c2, h3, b3) else 'APP! (still in)'}") say("## item 3 — a stranger's guesses at the family sign-in lock only the stranger (simulated tunnel)") seq = [family_login("anna", f"guess-{i}", visitor_tunnel="198.51.100.66")[0] for i in range(1, 8)] say(f" stranger 198.51.100.66, 7 wrong for anna: {seq}") say(f" stranger with anna's RIGHT password while locked: {family_login('anna', fam['anna'], visitor_tunnel='198.51.100.66')[0]}") c, s2, h, b = family_login("anna", fam["anna"], visitor_tunnel="203.0.113.10") say(f" anna herself from 203.0.113.10 (tunnel), at once: {c}, session {'set' if s2 else 'NONE'}") c, s3, h, b = family_login("bela", fam["bela"]) say(f" bela from the LAN, at once: {c}, session {'set' if s3 else 'NONE'}") say(" controller log:", w.guest("docker logs --since 2m felhom-controller 2>&1 | grep -E 'family sign-in' | tail -6")) say("## item 4 — Grimmory's e-reader exceptions keep Grimmory's OWN login (no family cookie)") ha = app_cookie("library", w.dash_cookie() if hasattr(w, "dash_cookie") else dash_session())[0] c, h, b = lan(GMH, "/api/v1/auth/login", method="POST", cookie=ha, hdrs=("Content-Type: application/json",), data=json.dumps({"username": "admin", "password": gm["admin_pw"]})) tok = json.loads(b).get("accessToken"); A = ("Content-Type: application/json", f"Authorization: Bearer {tok}") opw, kpw = __import__("secrets").token_hex(8), __import__("secrets").token_hex(8) lan(GMH, "/api/v1/settings", method="PUT", cookie=ha, hdrs=A, data=json.dumps([{"name": "OPDS_SERVER_ENABLED", "value": True}])) lan(GMH, "/api/v2/opds-users", method="POST", cookie=ha, hdrs=A, data=json.dumps({"username": "olvaso", "password": opw})) c, h, b = lan(GMH, "/api/v1/kobo-settings/token", method="PUT", cookie=ha, hdrs=A); ktok = json.loads(b).get("token", "") lan(GMH, "/api/v1/koreader-users/me", method="PUT", cookie=ha, hdrs=A, data=json.dumps({"username": "korolvaso", "password": kpw})) lan(GMH, "/api/v1/koreader-users/me/sync?enabled=true", method="PATCH", cookie=ha, hdrs=A) say(" (setup as the household through the gate: OPDS on + an OPDS user, a Kobo token, a KOReader user — values not printed)") md5 = hashlib.md5(kpw.encode()).hexdigest() def row(label, path, *a, via="LAN", hd=(), timeout=40): c, h, b = lan(GMH, path, *a, hdrs=hd, timeout=timeout) if via == "LAN" else tun(GMH, path, "198.51.100.66", None, *a, *sum([["-H", x] for x in hd], []), timeout=timeout) say(f" {label:52s} {via:6s} -> {c} {'GATE' if is_gate(c, h, b) else 'app'} {b[:60].strip()!r}") row("OPDS, the OPDS user's own login", "/api/v1/opds", "-u", f"olvaso:{opw}") row("OPDS, the OPDS user's own login", "/api/v1/opds", "-u", f"olvaso:{opw}", via="tunnel") row("OPDS, a stranger with a wrong password", "/api/v1/opds", "-u", "olvaso:wrong", via="tunnel") row("OPDS, a stranger with nothing", "/api/v1/opds", via="tunnel") row("Kobo initialization, the device token", f"/api/kobo/{ktok}/v1/initialization") row("Kobo, a made-up token", "/api/kobo/0123456789abcdef/v1/library/sync", via="tunnel") row("KOReader sign-in, its own user + md5 key", "/api/koreader/users/auth", hd=("x-auth-user: korolvaso", f"x-auth-key: {md5}", "Accept: application/vnd.koreader.v1+json")) row("KOReader, a stranger's wrong key", "/api/koreader/users/auth", hd=("x-auth-user: korolvaso", "x-auth-key: 00000000000000000000000000000000", "Accept: application/vnd.koreader.v1+json"), via="tunnel") row("Komga API, a stranger with nothing", "/komga/api/v1/libraries", via="tunnel") row("look-alike /api/v1/opdsx (F1)", "/api/v1/opdsx", via="tunnel") row("look-alike /api/koreaderx", "/api/koreaderx/users/auth", via="tunnel") row("../ out of an exception", "/api/v1/opds/../../api/v1/books", "--path-as-is", via="tunnel") say("## R-775 re-measured — a stranger cannot reach Grimmory's web sign-in at all, so its 15-minute lock cannot be aimed from outside") seq = [tun(GMH, "/api/v1/auth/login", "198.51.100.66", None, "-X", "POST", "-H", "Content-Type: application/json", "--data", '{"username":"admin","password":"guess"}')[0] for _ in range(6)] c, h, b = lan(GMH, "/api/v1/auth/login", method="POST", cookie=ha, hdrs=("Content-Type: application/json",), data=json.dumps({"username": "admin", "password": gm["admin_pw"]})) say(f" stranger x6 at /api/v1/auth/login (tunnel): {seq} -> the household then signs in: {c}") say("## item 5 — the family login and the box dashboard") c, s4, h, b = family_login("anna", fam["anna"]) c1, h1, b1 = lan(CTL, "/launcher", cookie=f"{s4}; {ga}") c2, h2, b2 = lan(CTL, "/api/stacks", cookie=s4) c3, h3, b3 = lan(CTL, "/login", method="POST", data=f"password={fam['anna']}", hdrs=("Content-Type: application/x-www-form-urlencoded",)) say(f" family cookies at /launcher -> {c1} {loc(h1)} | at /api/stacks -> {c2} | anna's family password at the dashboard login -> {c3}, session {'SET!' if 'felhom_session=' in h3 else 'none'}") say("## reset and removal end access at the next request (every gated app)") c, s5, h, b = family_login("bela", fam["bela"]); g5 = app_cookie("library", s5)[0]; m5 = app_cookie("video", s5)[0] sess = dash_session(); csrf = open(f"{SC}/csrf{os.getpid()}.txt").read().strip() def card(action, name): r = subprocess.run(["curl", "-sk", "-H", f"Host: {CTL}", "-H", f"Cookie: {sess}", "-H", f"X-CSRF-Token: {csrf}", "-X", "POST", "--data", f"name={name}", f"{w.BASE}/family/members/{action}"], capture_output=True, text=True) return json.loads(r.stdout) d = card("reset", "bela"); fam["bela"] = d["data"]["password"] say(f" card: new password for bela -> ok={d['ok']}; bela's Grimmory cookie -> {lan(GMH, '/', cookie=g5, hdrs=('Accept: text/html',))[0]}, MeTube cookie -> {lan(MTH, '/', cookie=m5, hdrs=('Accept: text/html',))[0]} (302 = sent to sign in)") c, s6, h, b = family_login("bela", fam["bela"]); g6 = app_cookie("video", s6)[0] d = card("remove", "bela") say(f" card: remove bela -> ok={d['ok']} members={d['data']['members']}; bela's MeTube cookie -> {lan(MTH, '/', cookie=g6)[0]}; bela signs in again -> {family_login('bela', fam['bela'])[0]}") json.dump(fam, open(f"{SC}/fam.json", "w")) say("## cost — the same request through the gated name, and straight at the container (60 pairs)") def t(cmd): r = subprocess.run(cmd, capture_output=True, text=True).stdout.split(); return r[0], float(r[1]) * 1000 g, d = [], [] for _ in range(60): c1, t1 = t(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code} %{time_total}", "-H", f"Host: {MTH}", "-H", f"Cookie: {mt}", f"{w.BASE}/version"]) g.append(t1) ip = w.guest("docker inspect metube --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}'").split()[0] dd = w.guest(f"for i in $(seq 1 60); do curl -s -o /dev/null -w '%{{time_total}}\\n' http://{ip}:8081/version; done").split() say(f" gated (LAN→traefik→forwardAuth→app) median {statistics.median(g):.1f} ms, p90 {sorted(g)[54]:.1f} ms; the app straight (guest→container) median {statistics.median([float(x)*1000 for x in dd]):.1f} ms — the honest number is the gate's own: see the controller's forwardAuth timing below") say("## the controller DOWN — a gated app answers an error, never the app; an exception still reaches the app's own login") w.guest("docker stop felhom-controller >/dev/null") c1, h1, b1 = lan(MTH, "/", cookie=mt); c2, h2, b2 = lan(GMH, "/api/v1/books", cookie=ga); c3, h3, b3 = lan(GMH, "/api/v1/opds", "-u", "olvaso:wrong") say(f" controller stopped: MeTube with a member cookie -> {c1} {b1[:40]!r}; Grimmory API -> {c2}; Grimmory OPDS exception (wrong password) -> {c3} (the app's own refusal)") w.guest("docker start felhom-controller >/dev/null; sleep 25") w.login() c1, h1, b1 = lan(MTH, "/", cookie=mt) say(f" controller back: MeTube with bela's earlier cookie (bela was removed) -> {c1} {'GATE' if is_gate(c1,h1,b1) else 'APP'}")