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

163 lines
11 KiB
Python

#!/usr/bin/env python3
"""fg_b4.py — Part B4: Grimmory and MeTube installed on ONE demo box (demo-hp, guest 9201, controller 0.287.0, the LIVE
catalog) through the product, measured, and removed WITH their data (the with-data half of record row 2.6 that 9202 cannot
show — R-442). The stranger and the family member go through the REAL internet path (Cloudflare → the box's tunnel), from
DooPlex, as well as the LAN. Secrets never printed; the test member's password lives in a 0600 scratch file and the member
is removed at the end. Env: SC (0600 scratch with .ctlpw = the demo password), EV.
Evidence: $EV/B4/<app>-demo.txt + $EV/B4/demo-common.txt."""
import json, os, re, secrets, subprocess, sys, time
os.environ.setdefault("GUEST", "9201")
os.environ.setdefault("BASE", "https://192.168.0.155")
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts")
import box_walk as w
import upgrade_fixtures_box as fixtures
w.HP = "hp" # `ssh hp` is the working alias for demo-hp (memory: demo-hp-guest-controller-access)
DOM = w.DOMAIN; CTL = f"felhom.{DOM}"
DRIVE = "/mnt/felhom-drives/hdd_1" # what this box's other apps use as HDD_PATH (read 2026-10-02)
EVD = f"{w.EV}/B4"; os.makedirs(EVD, exist_ok=True)
APPS = {"grimmory": ("library", fixtures.FIXTURES["grimmory"]), "metube": ("video", fixtures.FIXTURES["metube"])}
logs = {a: open(f"{EVD}/{a}-demo.txt", "a", buffering=1) for a in APPS}
common = open(f"{EVD}/demo-common.txt", "a", buffering=1)
def say(app, *a):
w.say(*a); (logs[app] if app else common).write(" ".join(map(str, a)) + "\n")
def internet(host, path="/", cookie=None, data=None, hdrs=()):
"""A request through the REAL public path: DooPlex → Cloudflare → the box's tunnel (no Host trick, real DNS)."""
a = ["curl", "-s", "--max-time", "20", "-o", "/dev/null", "-w", "%{http_code}"]
for h in hdrs: a += ["-H", h]
if cookie: a += ["-H", f"Cookie: {cookie}"]
if data is not None: a += ["--data", data]
return w.sh(a + [f"https://{host}{path}"]).stdout.strip()
def lan(host, path="/"):
return w.sh(["curl", "-sk", "--max-time", "15", "-o", "/dev/null", "-w", "%{http_code}", "-H", f"Host: {host}", f"{w.BASE}{path}"]).stdout.strip()
def gate_files():
return w.guest("ls /opt/docker/stacks/traefik/dynamic/ | grep -E 'family-gate|setup-gate' | tr '\\n' ' '").strip() or "none"
def deploy(app, sub):
code, d = w.ctl("GET", f"/api/stacks/{app}/deploy-fields")
fields = (((d.get("data") or {}).get("metadata") or {}).get("deploy_fields")) or []
values = {"DOMAIN": DOM, "SUBDOMAIN": sub}
for f in fields:
ev, ty = f.get("env_var"), f.get("type")
if ev in values or (not f.get("required") and ty != "password"):
continue
if ty == "path":
values[ev] = DRIVE
elif ty in ("secret", "password"):
values[ev] = "Demo-" + secrets.token_hex(12)
else:
values[ev] = f.get("default") or f"demo-{app}"
code, d = w.ctl("POST", f"/api/stacks/{app}/deploy", {"values": values})
say(app, f"deploy {app} (fields beyond DOMAIN/SUBDOMAIN: {[k for k in values if k not in ('DOMAIN', 'SUBDOMAIN')]}, values not printed) -> {code} {str(d)[:100]}")
say(app, " gate files right after the press:", gate_files())
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", "degraded", "unhealthy"):
break
say(app, f" deployed in {round(time.time()-t0)} s: state={st.get('state')} pinned={(st.get('app_config') or {}).get('pinned_images')}")
say(app, " family_gate record:", " ".join(w.guest(f"grep -A4 '^family_gate:' /opt/docker/stacks/{app}/app.yaml").split()))
w.login()
w.sync_rescan("metube", "ghcr.io/alexta69/metube:2026.09.29")
say(None, f"##### B4 on demo-hp 9201 — controller {w.guest('docker inspect felhom-controller --format {{.Config.Image}}').strip()} | catalog "
+ w.guest("cd /opt/docker/felhom-controller/data/catalog-cache 2>/dev/null || cd /var/lib/docker/volumes/felhom-controller-data/_data/data/catalog-cache; git log --oneline -1").strip()[:90]
+ f" | {time.strftime('%FT%TZ', time.gmtime())}")
say(None, "before: gate files:", gate_files(), "| drive:", " ".join(w.guest(f"df -h {DRIVE} | tail -1").split()))
say(None, "the public names resolve to:", w.sh(["getent", "hosts", f"video.{DOM}"]).stdout.strip(), "(Cloudflare — the REAL internet path)")
# a test family member through the Család card (the card's own call)
sess = open(f"{w.SC}/sess{os.getpid()}.txt").read().strip(); csrf = open(f"{w.SC}/csrf{os.getpid()}.txt").read().strip()
r = subprocess.run(["curl", "-sk", "-H", f"Host: {CTL}", "-H", f"Cookie: {sess}", "-H", f"X-CSRF-Token: {csrf}", "-X", "POST", "--data", "name=b4teszt",
f"{w.BASE}/family/members/add"], capture_output=True, text=True)
dd = json.loads(r.stdout); fpw = dd["data"]["password"]
open(f"{w.SC}/b4fam.json", "w").write(json.dumps({"b4teszt": fpw})); os.chmod(f"{w.SC}/b4fam.json", 0o600)
say(None, f"Család card: add b4teszt -> ok={dd['ok']} (password returned once, len {len(fpw)}, not printed)")
toks = {}
for app, (sub, fx) in APPS.items():
deploy(app, sub)
if app == "grimmory":
w.wait_app(sub, "/api/v1/healthcheck", want=("200",), tries=90)
pw = "Gm-" + secrets.token_hex(10)
rc, code, _ = w.app_curl(sub, "/api/v1/setup", "-H", "Content-Type: application/json",
data=json.dumps({"username": "admin", "password": pw, "email": "admin@felhom.invalid", "name": "Felhom"}), method="POST")
say(app, "the household makes the first admin through the setup gate ->", code)
for i in range(30):
if "setup-gate-grimmory" not in gate_files(): break
time.sleep(5)
say(app, f" setup gate opened by its probe after ~{5*i} s; gate files: {gate_files()}")
w.GATE.pop(sub, None) # the SETUP-gate cookie is useless now; the family gate wants a family cookie (attempt 1)
code, tok = fx._token(w, sub, "admin", pw)
auth = ["-H", f"Authorization: Bearer {tok}"]
rc, c, out = w.app_curl(sub, "/api/v1/libraries", *auth, "-H", "Content-Type: application/json",
data=json.dumps({"name": "Konyvek", "icon": "book", "iconType": "LUCIDE", "paths": [{"path": "/books"}], "watch": True,
"metadataSource": "EMBEDDED", "organizationMode": "BOOK_PER_FILE"}), method="POST")
lib = json.loads(out); title = "Felhom Demo " + secrets.token_hex(4)
import tempfile
fn = tempfile.mktemp(suffix=".epub"); open(fn, "wb").write(fx._epub(title))
rc, c, _ = w.app_curl(sub, "/api/v1/files/upload", *auth, "-F", f"file=@{fn};type=application/epub+zip",
"-F", f"libraryId={lib['id']}", "-F", f"pathId={lib['paths'][0]['id']}", method="POST"); os.unlink(fn)
toks[app] = {"user": "admin", "pw": pw, "title": title}
say(app, "a book uploaded ->", c, "| read back:", fx.verify(w, sub, toks[app], lambda *a: say(app, *a)))
else:
toks[app] = fx.seed(w, sub, lambda *a: say(app, *a))
say(app, "the seed reads back:", fx.verify(w, sub, toks[app], lambda *a: say(app, *a)) if toks[app] else "NO SEED")
say(app, "on the drive:", " | ".join(w.guest(f"find {DRIVE} -path '*{app}*' -type f \\( -name '*.epub' -o -name '*.mp4' \\) 2>/dev/null | head -3").split("\n")).strip())
# a STRANGER through the real internet and the LAN
paths = ["/", "/api/v1/books", "/api/v1/opdsx", "/api/v1/opds"] if app == "grimmory" else ["/", "/history", "/socket.io/?EIO=4&transport=polling"]
for p in paths:
say(app, f"a STRANGER {p:40s} internet {internet(f'{sub}.{DOM}', p)} | LAN {lan(f'{sub}.{DOM}', p)}")
# the family member signs in through the REAL internet (the box's own page), then reaches both apps
import http.cookiejar, urllib.request, urllib.parse, ssl
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
def get(url, data=None):
try:
r = op.open(urllib.request.Request(url, data=data, headers={"Accept": "text/html"}), timeout=30)
return r.status, r.geturl(), r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, url, e.read().decode("utf-8", "replace")
for app, (sub, fx) in APPS.items():
st, url, body = get(f"https://{sub}.{DOM}/")
m = re.search(r'name="_ft" value="([^"]+)"', body)
if m and "/__family/login" in url:
form = {"_ft": m.group(1), "name": "b4teszt", "password": fpw}
for k in re.findall(r'<input type="hidden" name="([^"]+)" value="[^"]*"', body):
if k not in form:
import html as _h
form[k] = _h.unescape(re.search(rf'name="{re.escape(k)}" value="([^"]*)"', body).group(1))
st, url, body = get(url.split("?")[0].replace("/__family/start", "/__family/login"), urllib.parse.urlencode(form).encode())
say(app, f"the family member b4teszt through the REAL internet: the app's address → the family sign-in → back: {st} at {urllib.parse.urlsplit(url).netloc}{urllib.parse.urlsplit(url).path} "
f"(the app's own page: {'Grimmory' in body or 'MeTube' in body})")
for app, (sub, fx) in APPS.items():
w.ctl("POST", f"/api/stacks/{app}/stop")
for _ in range(24):
time.sleep(5)
if w.stack(app).get("state") != "running": break
code, d = w.ctl("POST", f"/api/stacks/{app}/remove", {"remove_hdd_data": True, "remove_backups": True})
say(app, f"2.6 remove WITH data and backups -> {code} {str(d)[:300]}")
time.sleep(8)
left = w.guest(f"docker ps -a --format '{{{{.Names}}}}' | grep -E '^{app}'; docker volume ls -q | grep -i {app}; "
f"find {DRIVE} -path '*{app}*' \\( -name '*.epub' -o -name '*.mp4' -o -name '*.sql' \\) 2>/dev/null | head; "
f"ls -d {DRIVE}/*/{app} {DRIVE}/*/*/{app} 2>/dev/null").strip()
say(app, f" left after: {' | '.join(left.splitlines()) or 'nothing (no container, no volume, no file of the app on the drive)'}")
say(app, " gate files:", gate_files(), f"| a stranger at {sub}/ -> internet {internet(f'{sub}.{DOM}')} | LAN {lan(f'{sub}.{DOM}')}")
r = subprocess.run(["curl", "-sk", "-H", f"Host: {CTL}", "-H", f"Cookie: {open(f'{w.SC}/sess{os.getpid()}.txt').read().strip()}",
"-H", f"X-CSRF-Token: {open(f'{w.SC}/csrf{os.getpid()}.txt').read().strip()}", "-X", "POST", "--data", "name=b4teszt",
f"{w.BASE}/family/members/remove"], capture_output=True, text=True)
say(None, "Család card: remove b4teszt ->", json.loads(r.stdout).get("ok"), "| members now:", json.loads(r.stdout).get("data", {}).get("members"))
os.unlink(f"{w.SC}/b4fam.json")
say(None, "after: gate files:", gate_files(), "| stacks:", w.guest("docker ps --format '{{.Names}}' | grep -c -E '^(grimmory|metube)' || true").strip())