2633dc1654
gates / gates (push) Successful in 26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
163 lines
11 KiB
Python
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())
|