Files
felhom.eu/documentation/audits/family-gate-2026-10-02/tools/fg_items.py
T

137 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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'}")