#!/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://), CTL_DOMAIN (Host: felhom.), 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' 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]}