Files
www/webroot/api/cluster/index.py
T

229 lines
9.8 KiB
Python

# 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://<host>/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 <token> 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 <token> 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"}
)