really fix the server this time it's actually reliable I promise
This commit is contained in:
@@ -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://<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"}
|
||||
)
|
||||
Reference in New Issue
Block a user