# 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"} )