109 lines
5.2 KiB
Python
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")
|