Files
felhom.eu/documentation/audits/fixes-2026-10-21/tools/fab_gate_proof.py
T

109 lines
5.2 KiB
Python

#!/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")