229 lines
9.8 KiB
Python
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"}
|
|
)
|