fixes 2026-10-21: evidence so far (R-1176 live proof, R-1192 freeze measured, R-1191 on 9202); design notes 03 §8a / 07 §7.0, §8.2a (D25) / 05 §16.5 (DST)
gates / gates (push) Successful in 13m34s
gates / gates (push) Successful in 13m34s
This commit is contained in:
1 parent
cd3d05981a
commit
ef4dbcf984
47 files changed
+3432
No files matched your search
@@ -0,0 +1,91 @@
|
||||
#!/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]}
|
||||
Reference in new issue
Block a user