From ea14985d1b13da37feab9caf4197a3db7f36923c Mon Sep 17 00:00:00 2001 From: Logan Joy-Koer Date: Wed, 16 Sep 2026 23:03:16 -0700 Subject: [PATCH] really fix the server this time it's actually reliable I promise --- webroot/api/cluster/index.py | 228 +++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 webroot/api/cluster/index.py diff --git a/webroot/api/cluster/index.py b/webroot/api/cluster/index.py new file mode 100644 index 0000000..a016d52 --- /dev/null +++ b/webroot/api/cluster/index.py @@ -0,0 +1,228 @@ +# webroot/api/cluster/index.py +# +# Fixed-whitelist cluster management API. Executed by server.py's generic +# ".py" exec mechanism, so it inherits the request-scoped local variables +# server.py already built for us: requestType, argumentsDict, +# requestheaders, requestBody, Cookies, sourceIP, sourceIPvisual, +# script_directory, etc. See server.py's handle_connection() for the +# full list if you need something not used here. +# +# Reachable at: https:///api/cluster/ +# GET -> discovery: which hosts/actions this token is allowed to use +# POST -> perform an action. JSON body: +# {"host": "...", "action": "...", "params": {...}, "reason": "..."} +# +# Auth: Authorization: Bearer header (NOT a query param — query +# strings get printed to server.py's stdout log on every request). +# +# Config lives OUTSIDE webroot/ and userdata/, per the site's existing +# convention, so it can never be served even by a routing mistake: +# [script_directory]/secrets/api_tokens.json (hashes only, chmod 600) +# [script_directory]/secrets/cluster_hosts.json +# [script_directory]/secrets/cluster_actions.json +# [script_directory]/secrets/cluster_api_audit.log (append-only) +# +# Security model, read this before editing: +# - Every param value is checked against a fixed enum whitelist defined +# in cluster_actions.json BEFORE it touches a command line. Nothing +# from the request body is ever string-substituted freely — only +# values that already exist in that whitelist. Don't change params +# to accept free-form strings without re-thinking this. +# - The SSH command is built as an argv list (never shell=True), and a +# `--` separates the destination from the remote command so a +# whitelisted value can't be mistaken for an ssh option. +# - Every attempt (denied or not) is written to the audit log. +# - The remote side should ALSO be locked down (see README): a +# dedicated SSH key used only for this, and sudoers entries scoped to +# the exact commands in cluster_actions.json — not blanket sudo. This +# script whitelisting actions is one layer, not the only layer. + +import json +import hmac +import hashlib +import subprocess +import time + +CONFIG_DIR = script_directory + "/secrets" +TOKENS_FILE = CONFIG_DIR + "/api_tokens.json" +HOSTS_FILE = CONFIG_DIR + "/cluster_hosts.json" +ACTIONS_FILE = CONFIG_DIR + "/cluster_actions.json" +AUDIT_LOG = CONFIG_DIR + "/cluster_api_audit.log" + +contentType = "application/json" + + +def make_json(status, body, _json=json): + """Returns (responseType, responsecontent_bytes). Never mutates + outer scope directly -- exec()'d top-level code + helper functions + don't share a normal global/nonlocal relationship, so we pass + values back explicitly instead of relying on `global` inside here. + + _json defaults to the module-level `json` imported above. This looks + redundant but isn't: server.py runs this whole file via + exec(code, globals(), local_vars) with globals() != local_vars, so + `import json` at the top only binds `json` in local_vars. A nested + function body like this one resolves free names via LOAD_GLOBAL + against the *real* globals dict, which never got `json` -- so a + plain `json.dumps(...)` here raises NameError. Default arguments are + evaluated at def-time (top-level, local_vars is visible there), so + capturing it this way sidesteps the issue. Same trick below for + `time`. + """ + return status, _json.dumps(body).encode() + + +def load_json(path, default, _json=json): + try: + with open(path) as f: + return _json.load(f) + except FileNotFoundError: + return default + + +def audit(entry: dict, _json=json, _time=time): + entry["ts"] = _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()) + try: + with open(AUDIT_LOG, "a") as f: + f.write(_json.dumps(entry) + "\n") + except Exception: + pass # logging must never be why a request 500s + + +# --- auth --------------------------------------------------------------- +supplied_token = None +for h in requestheaders: + if h.lower().startswith("authorization: bearer "): + supplied_token = h.split(" ", 2)[2].strip() + break + +token_label = None +if supplied_token: + tokens = load_json(TOKENS_FILE, {"tokens": []}).get("tokens", []) + supplied_hash = hashlib.sha256(supplied_token.encode()).hexdigest() + for t in tokens: + if hmac.compare_digest(t.get("token_sha256", ""), supplied_hash): + token_label = t.get("label", "unknown") + break + +if token_label is None: + audit({"event": "auth_failed", "source_ip": sourceIPvisual}) + responseType, responsecontent = make_json( + "401 Unauthorized", {"error": "missing or invalid Authorization: Bearer header"} + ) + +elif requestType not in ("GET", "POST"): + responseType, responsecontent = make_json( + "405 Method Not Allowed", {"error": "use GET (discovery) or POST (act)"} + ) + +else: + actions = load_json(ACTIONS_FILE, {}) + hosts = load_json(HOSTS_FILE, {}) + + if requestType == "GET": + # Read-only discovery. No secrets (keys/addresses) go in this response. + responseType, responsecontent = make_json("200 OK", { + "hosts": { + name: {"allowed_actions": cfg.get("allowed_actions", [])} + for name, cfg in hosts.items() + }, + "actions": { + name: {"description": cfg.get("description", ""), "params": cfg.get("params", {})} + for name, cfg in actions.items() + }, + }) + + else: # POST + try: + payload = json.loads(requestBody.decode()) + except Exception: + payload = None + + if not isinstance(payload, dict): + responseType, responsecontent = make_json( + "400 Bad Request", {"error": "expected a JSON object body"} + ) + else: + host_name = payload.get("host") + action_name = payload.get("action") + params = payload.get("params", {}) or {} + reason = payload.get("reason", "") + + host_cfg = hosts.get(host_name) + action_cfg = actions.get(action_name) + + if host_cfg is None: + responseType, responsecontent = make_json( + "400 Bad Request", {"error": "unknown host", "known_hosts": list(hosts.keys())} + ) + elif action_cfg is None: + responseType, responsecontent = make_json( + "400 Bad Request", {"error": "unknown action", "known_actions": list(actions.keys())} + ) + elif action_name not in host_cfg.get("allowed_actions", []): + audit({ + "event": "action_denied", "token": token_label, "host": host_name, + "action": action_name, "reason": reason, "source_ip": sourceIPvisual, + }) + responseType, responsecontent = make_json( + "403 Forbidden", {"error": "action not allowed on this host"} + ) + else: + # Every param must match an entry in that action's enum + # whitelist. Nothing free-form ever reaches the command line. + param_specs = action_cfg.get("params", {}) + bad_params = [] + resolved = {} + for pname, spec in param_specs.items(): + val = params.get(pname) + if val not in spec.get("values", []): + bad_params.append(pname) + else: + resolved[pname] = val + + if bad_params: + responseType, responsecontent = make_json( + "400 Bad Request", {"error": "invalid or missing params", "bad_params": bad_params} + ) + else: + remote_cmd = [part.format(**resolved) for part in action_cfg["remote_cmd_template"]] + ssh_cmd = [ + "ssh", "-i", host_cfg["ssh_key"], + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=5", + host_cfg["ssh_user"] + "@" + host_cfg["address"], + "--", + ] + remote_cmd + + audit({ + "event": "action_start", "token": token_label, "host": host_name, + "action": action_name, "params": resolved, "reason": reason, + "source_ip": sourceIPvisual, + }) + + try: + result = subprocess.run(ssh_cmd, capture_output=True, text=True, timeout=30) + ok = result.returncode == 0 + audit({ + "event": "action_done", "token": token_label, "host": host_name, + "action": action_name, "returncode": result.returncode, + }) + responseType, responsecontent = make_json( + "200 OK" if ok else "502 Bad Gateway", + { + "ok": ok, + "returncode": result.returncode, + "stdout": result.stdout[-4000:], + "stderr": result.stderr[-4000:], + }, + ) + except subprocess.TimeoutExpired: + audit({ + "event": "action_timeout", "token": token_label, + "host": host_name, "action": action_name, + }) + responseType, responsecontent = make_json( + "504 Gateway Timeout", {"error": "ssh command timed out after 30s"} + )