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

92 lines
3.6 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 + "/backups"]) # "/" now lands on /launcher, which has no csrf meta
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-binary", "@-"] # the body on stdin, never on a command line (it may hold a password)
out = _curl(args + [BASE + path], inp=json.dumps(data) if data is not None and method != "GET" else None, 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]}