Files
felhom.eu/documentation/audits/dataloss-hunt-2026-10-20/C3-R1187e-fab-family-gate/fab_gate_proof.py
T

97 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""fab_gate_proof.py — 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/rehearsal-2026-10-20/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)")
out = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-w", "%{http_code} %{redirect_url}",
"-H", f"Host: {SUB}.{DOM}", ctl.BASE + "/"], capture_output=True, text=True).stdout
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])
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)
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 FAULT after import")
say("controller log:", guest("docker logs --since 15m felhom-controller 2>&1 | grep -iE 'family|import' | grep -v 'GET /api' | tail -15"))