Files
felhom.eu/documentation/audits/catalog-sweep-2026-10-24/tools/ctl.py
T
admin 67053335d1
gates / gates (push) Successful in 13m17s
Catalog sweep 2026-10-24: all 65 catalog apps on a fresh box (52 work, 10 with a problem, 0 broken); register 208 -> 217 (R-1269..R-1277 opened; notes on R-739, R-1267); STATUS + D46; map B1 re-checked; evidence
Every catalog app installed, used, backed up, removed keeping data, reinstalled, restored and removed with data on a
fresh box from the public ISO (demo-hp VM 9360, Tester4), endpoint-level. Worst: a reinstall's capture rewrites the
kept unit so its restore breaks the app (R-1269, P2), the restore still reports success (R-1270, P2), and „use my kept
data" loaded another box's future-dated database (R-1271, P2). Drill catalog fixes for the morning list (n8n usage
data; OpenGist 256M, proven by a cgroup probe). VM 9360 and LXC 9361 destroyed; hub host Tester4-aa9466 left for the
operator. REPORT-catalog-sweep-2026-10-24.md.
2026-10-25 00:13:05 +02:00

92 lines
3.4 KiB
Python

#!/usr/bin/env python3
"""ctl.py — a minimal headless client for a Felhom controller dashboard (rehearsal 2026-10-20).
The same requests the dashboard's pages send (endpoint-level validation): POST /login, then JSON calls with the
session cookie and the X-CSRF-Token header. Configuration by env: CTL_BASE (https://<ip>), CTL_DOMAIN (Host:
felhom.<domain>), CTL_PW_ENV (the NAME of the env var holding the dashboard password — run under `sec run`).
Session files live in a 0700 temp dir per process; nothing secret is printed.
usage as a CLI: ctl.py GET /api/stacks
ctl.py POST /api/stacks/x/stop '{"a":1}'
"""
import json, os, re, subprocess, sys, tempfile
BASE = os.environ["CTL_BASE"]
HOST = "Host: felhom." + os.environ["CTL_DOMAIN"]
_T = tempfile.mkdtemp(prefix="ctl-")
os.chmod(_T, 0o700)
_SESS = _CSRF = None
def _curl(args, inp=None, timeout=600):
r = subprocess.run(["curl", "-sk", "-H", HOST] + args, capture_output=True, text=True, timeout=timeout, input=inp)
return r.stdout
def login():
global _SESS, _CSRF
pw = os.environ[os.environ["CTL_PW_ENV"]]
hdr = os.path.join(_T, "h")
# the password goes on stdin (curl --data-urlencode password@-), never on a command line
_curl(["-D", hdr, "-o", "/dev/null", "-X", "POST", "--data-urlencode", "password@-", BASE + "/login"], inp=pw)
m = re.search(r"felhom_session=[A-Za-z0-9._-]+", open(hdr).read(), re.I)
if not m:
sys.exit("ctl: login failed (no session cookie)")
_SESS = m.group(0)
body = _curl(["-L", "-H", "Cookie: " + _SESS, BASE + "/"])
c = re.search(r'<meta name="csrf-token" content="([^"]+)"', body)
if not c:
sys.exit("ctl: login failed (no csrf token)")
_CSRF = c.group(1)
def call(method, path, data=None, raw=False, timeout=600):
if _SESS is None:
login()
for attempt in (0, 1):
args = ["-H", "Cookie: " + _SESS, "-w", "\n%{http_code}"]
if method != "GET":
args += ["-H", "X-CSRF-Token: " + _CSRF, "-H", "Content-Type: application/json", "-X", method]
if data is not None:
args += ["--data", json.dumps(data)]
out = _curl(args + [BASE + path], timeout=timeout)
body, _, code = out.rpartition("\n")
if code in ("302", "401") and attempt == 0:
login()
continue
if raw:
return code, body
try:
return code, json.loads(body)
except ValueError:
return code, {"_raw": body[:800]}
def page(path):
if _SESS is None:
login()
return _curl(["-H", "Cookie: " + _SESS, BASE + path])
if __name__ == "__main__":
m, p = sys.argv[1], sys.argv[2]
d = json.loads(sys.argv[3]) if len(sys.argv) > 3 else None
code, body = call(m, p, d)
print(code)
print(json.dumps(body, ensure_ascii=False, indent=1)[:6000])
def form(path, fields, timeout=600):
"""A form POST (application/x-www-form-urlencoded) with the session and the X-CSRF-Token header."""
import urllib.parse
if _SESS is None:
login()
out = _curl(["-H", "Cookie: " + _SESS, "-H", "X-CSRF-Token: " + _CSRF, "-w", "\n%{http_code}", "-X", "POST",
"--data-binary", "@-", "-H", "Content-Type: application/x-www-form-urlencoded", BASE + path],
inp=urllib.parse.urlencode(fields), timeout=timeout)
body, _, code = out.rpartition("\n")
try:
return code, json.loads(body)
except ValueError:
return code, {"_raw": body[:800]}