92 lines
3.6 KiB
Python
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]}
|