#!/usr/bin/env python3 """fab_gate_proof.py — (fixes 2026-10-21: the data-loss hunt's C3, rerun on controller 0.337.0-rc.1 as the R-1191 live proof) C3 for R-1187 (e) on scratch 9202: does a `.fab` export → remove → import drop the family gate? Glance has no login of its own; the catalog puts it ONLY behind the family gate (decision 221). Observables, each read from a DIFFERENT channel than the act: (1) the gate file `family-gate-glance.yml` on the guest's disk; (2) what a stranger's request to the app's host gets back from traefik (302 to the family page vs 200 from Glance). Acts are the dashboard's own endpoints: deploy, /api/export/start, stop+remove, /api/export/import. Env: CTL_BASE, CTL_DOMAIN, CTL_PW_ENV (tools/ctl.py). """ import json, os, subprocess, sys, time sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/fixes-2026-10-21/tools") import ctl # noqa: E402 APP, SUB = "glance", "r1187glance" DOM = os.environ["CTL_DOMAIN"] def say(*a): print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True) def guest(cmd): r = subprocess.run(["ssh", "demo-hp", f"export LC_ALL=C; pct exec 9202 -- bash -c {json.dumps(cmd)}"], capture_output=True, text=True, timeout=300) return (r.stdout + r.stderr).strip() def observe(tag): say(f"[{tag}] gate file:", guest("find / -xdev -name 'family-gate-glance.yml' 2>/dev/null | head -3; true") or "(none)") hdr = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-D", "-", "-H", f"Host: {SUB}.{DOM}", ctl.BASE + "/"], capture_output=True, text=True).stdout.splitlines() out = " ".join([hdr[0].split()[1] if hdr else "000"] + [l.split(" ", 1)[1].strip() for l in hdr if l.lower().startswith("location")]) say(f"[{tag}] a stranger's GET https://{SUB}.{DOM}/ ->", out) def wait_stack(pred, n=60): for _ in range(n): code, d = ctl.call("GET", f"/api/stacks/{APP}") st = (d.get("data") or {}) if isinstance(d, dict) else {} if pred(st): return st time.sleep(5) return st def poll(path, n=120): last = None for _ in range(n): code, d = ctl.call("GET", path) dd = (d.get("data") or d) if isinstance(d, dict) else {} s = json.dumps(dd, ensure_ascii=False)[:400] if s != last: say(" ", path, s) last = s if isinstance(dd, dict) and dd.get("status") in ("completed", "failed", "done", "error") or ( isinstance(dd, dict) and dd.get("running") is False and dd.get("status") not in ("running", "pending")): return dd time.sleep(3) return dd say("== 1 deploy glance") code, d = ctl.call("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": DOM, "SUBDOMAIN": SUB}}) say("deploy ->", code, str(d)[:150]) st = wait_stack(lambda s: s.get("deployed") and s.get("state") in ("running", "unhealthy", "degraded")) say("state:", st.get("state"), "family_gate in app_config:", bool((st.get("app_config") or {}).get("family_gate"))) time.sleep(20) observe("1 POSITIVE CONTROL after install") say("== 2 export to the data drive") drive = "/mnt/felhom-drives/scratch_hdd" code, d = ctl.call("POST", "/api/export/start", {"stack_name": APP, "dest_drive": drive, "password": "", "stop_app": True}) say("export start ->", code, str(d)[:200]) est = poll("/api/export/status") code, d = ctl.call("GET", "/api/export/bundles") bundles = [b for b in ((d.get("data") or {}).get("bundles") or d.get("data") or []) if isinstance(b, dict) and APP in json.dumps(b)] say("bundles for glance:", json.dumps(bundles, ensure_ascii=False)[:500]) path = (bundles[-1].get("path") if bundles else None) or (est.get("output_path") if isinstance(est, dict) else None) # the export's own status (the hunt's harness slip) say("bundle path:", path) say("== 3 remove glance (keep nothing)") ctl.call("POST", f"/api/stacks/{APP}/stop") time.sleep(5) code, d = ctl.call("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": True, "remove_backups": True}) say("remove ->", code, str(d)[:160]) time.sleep(10) observe("3 after remove") say("== 4 import the bundle") code, d = ctl.call("POST", "/api/export/import", {"path": path, "password": ""}) say("import start ->", code, str(d)[:200]) poll("/api/export/import/status") st = wait_stack(lambda s: s.get("deployed") and s.get("state") in ("running", "unhealthy", "degraded"), n=40) say("state after import:", st.get("state"), "family_gate in app_config:", bool((st.get("app_config") or {}).get("family_gate")), "app_config keys:", sorted((st.get("app_config") or {}).keys())) time.sleep(30) observe("4 AFTER import (fixed: must be 401, gate file present)") say("controller log:", guest("docker logs --since 15m felhom-controller 2>&1 | grep -iE 'family|import' | grep -v 'GET /api' | tail -15")) say("== 5 teardown: remove glance through the product, delete the bundle") ctl.call("POST", f"/api/stacks/{APP}/stop") time.sleep(5) code, d = ctl.call("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": True, "remove_backups": True}) say("remove ->", code, str(d)[:160]) if path: say("bundle delete:", guest(f"rm -f {json.dumps(path)} && echo deleted {json.dumps(path)}")) time.sleep(10) observe("5 after teardown")