docs: exec-server -> exec-constrained stale references (bl:8444)
- docs/TOKEN_POLICY.md: rewritten for exec-constrained.py (named ops,
-n exec-constrained, {op,args,ts,nonce} envelope; rotate endpoint gone)
- bin/chromebox-gateway.py: exec-server naming -> shared exec token files
- docs/DM-HTTPS-DESIGN-646.md + docs/DM-OVER-HTTPS-DESIGN.md: port
8443->8444, namespace exec-server->exec-constrained, envelope updated,
cloudflared port fix marked done 2026-10-04
This commit is contained in:
Executable
+364
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
chromebox-gateway.py — HTTPS fallback for chromebox/DM control when SSH is down.
|
||||
|
||||
The chromeboxes (headless Chromium per NetVM node) are normally driven over
|
||||
SSH via muse-chat-api.py / dm.py, which speak CDP through the per-node
|
||||
netns relay. When SSH to bl breaks, this gateway provides a constrained
|
||||
HTTPS path to the same high-level operations.
|
||||
|
||||
CRITICAL: this does NOT expose raw CDP. Raw CDP (Runtime.evaluate,
|
||||
Page.navigate, etc.) is arbitrary code execution inside the browser with
|
||||
the agent's live session. This gateway exposes ONLY the allowlisted
|
||||
high-level operations below, each mapped to an existing audited script.
|
||||
|
||||
Usage:
|
||||
python3 chromebox-gateway.py --port 8444
|
||||
|
||||
Auth: Bearer token, reusing the shared exec per-agent token files
|
||||
(~/.exec-tokens/<agent>). The master token (~/.exec-server-token) also
|
||||
works. Identity = token filename; tokens are never logged.
|
||||
|
||||
Endpoints:
|
||||
GET /health {"status":"ok"} — no auth (load-balancer friendly)
|
||||
POST /api/v1/op {"op": "<name>", "params": {...}} — bearer auth
|
||||
|
||||
Audit: every call appended to ~/.chromebox-gateway-audit.jsonl (0600).
|
||||
"""
|
||||
import argparse
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# ---------------------------------------------------------------- config
|
||||
|
||||
BIN_DIR = os.path.expanduser("~/Projects/NetVM/bin")
|
||||
TOKEN_FILE = "/home/super/.exec-server-token" # master token (shared exec token file)
|
||||
TOKEN_DIR = "/home/super/.exec-tokens" # per-agent tokens (shared exec token files)
|
||||
AUDIT_FILE = "/home/super/.chromebox-gateway-audit.jsonl"
|
||||
CERT_FILE = "/home/super/.chromebox-gateway-cert.pem"
|
||||
KEY_FILE = "/home/super/.chromebox-gateway-key.pem"
|
||||
|
||||
NODES = ("muse", "pip", "646", "opm")
|
||||
MAX_MSG = 2000 # gateway-level cap; downstream scripts enforce their own
|
||||
BACKEND_TIMEOUT = 120 # seconds per backend call
|
||||
|
||||
# Rate limit: token bucket per identity — 20 req/min sustained, burst 5.
|
||||
RATE_PER_SEC = 20.0 / 60.0
|
||||
RATE_BURST = 5
|
||||
|
||||
# ---------------------------------------------------------------- ops
|
||||
# Each op maps to an argv builder for an existing script. No shell=True,
|
||||
# ever. Params are validated before building argv.
|
||||
|
||||
def _node(params):
|
||||
node = params.get("account") or params.get("agent")
|
||||
if node not in NODES:
|
||||
raise ValueError("account/agent must be one of %s" % (",".join(NODES)))
|
||||
return node
|
||||
|
||||
def _msg(params):
|
||||
m = params.get("message", "")
|
||||
if not isinstance(m, str) or not m.strip():
|
||||
raise ValueError("message must be a non-empty string")
|
||||
if len(m) > MAX_MSG:
|
||||
raise ValueError("message exceeds %d chars" % MAX_MSG)
|
||||
return m
|
||||
|
||||
def _target(params):
|
||||
t = params.get("target", "main")
|
||||
if not isinstance(t, str) or not t or len(t) > 128:
|
||||
raise ValueError("target must be a short string")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_./:-]+", t):
|
||||
raise ValueError("target has invalid characters")
|
||||
if ".." in t:
|
||||
raise ValueError("target must not contain '..'")
|
||||
return t
|
||||
|
||||
def _n(params, default=5, cap=50):
|
||||
n = params.get("n", default)
|
||||
try:
|
||||
n = int(n)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("n must be an integer")
|
||||
if not 1 <= n <= cap:
|
||||
raise ValueError("n must be 1..%d" % cap)
|
||||
return n
|
||||
|
||||
def _tags(params):
|
||||
tags = params.get("tags", [])
|
||||
if not isinstance(tags, list):
|
||||
raise ValueError("tags must be a list")
|
||||
out = []
|
||||
for t in tags:
|
||||
if not isinstance(t, str) or len(t) > 128:
|
||||
raise ValueError("bad tag")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_:=\-./]+", t):
|
||||
raise ValueError("tag has invalid characters: %r" % t[:40])
|
||||
out.append(t)
|
||||
if len(out) > 10:
|
||||
raise ValueError("too many tags (max 10)")
|
||||
return out
|
||||
|
||||
CHAT = os.path.join(BIN_DIR, "muse-chat-api.py")
|
||||
DM = os.path.join(BIN_DIR, "dm.py")
|
||||
|
||||
def op_chat_send(p):
|
||||
return [CHAT, "--account", _node(p), "send", _msg(p)]
|
||||
|
||||
def op_chat_messages(p):
|
||||
return [CHAT, "--account", _node(p), "messages", "--n", str(_n(p))]
|
||||
|
||||
def op_chat_sidechats(p):
|
||||
return [CHAT, "--account", _node(p), "sidechat", "list"]
|
||||
|
||||
def op_chat_sidechat_create(p):
|
||||
argv = [CHAT, "--account", _node(p), "sidechat", "create"]
|
||||
name = p.get("name")
|
||||
if name:
|
||||
if not isinstance(name, str) or len(name) > 80 or not re.fullmatch(r"[A-Za-z0-9 _-]+", name):
|
||||
raise ValueError("bad sidechat name")
|
||||
argv += ["--name", name]
|
||||
return argv
|
||||
|
||||
def op_chat_approvals(p):
|
||||
return [CHAT, "--account", _node(p), "approvals"]
|
||||
|
||||
def op_chat_url(p):
|
||||
return [CHAT, "--account", _node(p), "url"]
|
||||
|
||||
def op_dm_send(p):
|
||||
node = _node(p)
|
||||
to = p.get("to", node)
|
||||
if to not in NODES:
|
||||
raise ValueError("to must be one of %s" % (",".join(NODES)))
|
||||
argv = [DM, "send", "--agent", node, "--to", to,
|
||||
"--target", _target(p), _msg(p)]
|
||||
for t in _tags(p):
|
||||
argv += ["--tag", t]
|
||||
return argv
|
||||
|
||||
def op_dm_read(p):
|
||||
return [DM, "read", "--agent", _node(p),
|
||||
"--target", _target(p), "--n", str(_n(p, default=5, cap=20))]
|
||||
|
||||
# The allowlist. Adding an op here is a security decision — review accordingly.
|
||||
# Deliberately absent: wait (long-poll), upload (file ingress),
|
||||
# sidechat use (raw navigation), anything raw-CDP.
|
||||
ALLOWLIST = {
|
||||
"chat.send": ("write", op_chat_send),
|
||||
"chat.messages": ("read", op_chat_messages),
|
||||
"chat.sidechats": ("read", op_chat_sidechats),
|
||||
"chat.sidechat_create": ("write", op_chat_sidechat_create),
|
||||
"chat.approvals": ("read", op_chat_approvals),
|
||||
"chat.url": ("read", op_chat_url),
|
||||
"dm.send": ("write", op_dm_send),
|
||||
"dm.read": ("read", op_dm_read),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- auth
|
||||
|
||||
def _read_token_file(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def check_token(token):
|
||||
"""Return identity label or None. Tokens never leave this function."""
|
||||
if not token:
|
||||
return None
|
||||
if hmac.compare_digest(token, _read_token_file(TOKEN_FILE)):
|
||||
return "master"
|
||||
try:
|
||||
names = os.listdir(TOKEN_DIR)
|
||||
except OSError:
|
||||
return None
|
||||
for name in names:
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", name):
|
||||
continue
|
||||
t = _read_token_file(os.path.join(TOKEN_DIR, name))
|
||||
if t and hmac.compare_digest(token, t):
|
||||
return name
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------- rate limit
|
||||
|
||||
_buckets = {} # identity -> [tokens, last_ts]
|
||||
|
||||
def rate_ok(identity):
|
||||
now = time.monotonic()
|
||||
tokens, last = _buckets.get(identity, (RATE_BURST, now))
|
||||
tokens = min(RATE_BURST, tokens + (now - last) * RATE_PER_SEC)
|
||||
if tokens < 1.0:
|
||||
_buckets[identity] = (tokens, now)
|
||||
return False
|
||||
_buckets[identity] = (tokens - 1.0, now)
|
||||
return True
|
||||
|
||||
# ---------------------------------------------------------------- audit
|
||||
|
||||
def audit(entry):
|
||||
entry = dict(entry)
|
||||
entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
# Defense in depth: never let a full message body reach the audit file,
|
||||
# even if a caller forgets to summarize first.
|
||||
params = entry.get("params")
|
||||
if isinstance(params, dict) and "message" in params:
|
||||
params = dict(params)
|
||||
params["message_len"] = len(params.pop("message"))
|
||||
entry["params"] = params
|
||||
try:
|
||||
fd = os.open(AUDIT_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
with os.fdopen(fd, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
except OSError as e:
|
||||
print("audit write failed: %s" % e, file=sys.stderr)
|
||||
|
||||
# ---------------------------------------------------------------- handler
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "chromebox-gateway/1.0"
|
||||
|
||||
def log_message(self, fmt, *args): # quiet; audit log is the record
|
||||
pass
|
||||
|
||||
def _json(self, code, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
if urlparse(self.path).path == "/health":
|
||||
self._json(200, {"status": "ok", "ops": sorted(ALLOWLIST)})
|
||||
return
|
||||
self._json(404, {"error": "not_found"})
|
||||
|
||||
def do_POST(self):
|
||||
if urlparse(self.path).path != "/api/v1/op":
|
||||
self._json(404, {"error": "not_found"})
|
||||
return
|
||||
|
||||
auth = self.headers.get("Authorization", "")
|
||||
token = auth[7:] if auth.startswith("Bearer ") else ""
|
||||
identity = check_token(token)
|
||||
if not identity:
|
||||
self._json(401, {"error": "unauthorized"})
|
||||
return
|
||||
if not rate_ok(identity):
|
||||
audit({"identity": identity, "op": None, "result": "rate_limited"})
|
||||
self._json(429, {"error": "rate_limited"})
|
||||
return
|
||||
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length > 65536:
|
||||
self._json(413, {"error": "body_too_large"})
|
||||
return
|
||||
try:
|
||||
req = json.loads(self.rfile.read(length) or b"{}")
|
||||
except (ValueError, OSError):
|
||||
self._json(400, {"error": "bad_json"})
|
||||
return
|
||||
|
||||
op = req.get("op")
|
||||
params = req.get("params") or {}
|
||||
if not isinstance(params, dict):
|
||||
self._json(400, {"error": "params_must_be_object"})
|
||||
return
|
||||
entry = ALLOWLIST.get(op)
|
||||
if not entry:
|
||||
audit({"identity": identity, "op": op, "result": "unknown_op"})
|
||||
self._json(400, {"error": "unknown_op", "allowed": sorted(ALLOWLIST)})
|
||||
return
|
||||
cls, builder = entry
|
||||
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
argv = builder(params)
|
||||
except ValueError as e:
|
||||
audit({"identity": identity, "op": op, "class": cls, "result": "bad_params",
|
||||
"detail": str(e)[:120]})
|
||||
self._json(400, {"error": "bad_params", "detail": str(e)})
|
||||
return
|
||||
|
||||
# Redacted summary for the audit log — never the full message.
|
||||
summary = {k: (v[:80] + "…" if isinstance(v, str) and len(v) > 80 else v)
|
||||
for k, v in params.items() if k != "message"}
|
||||
if "message" in params:
|
||||
summary["message_len"] = len(params["message"])
|
||||
|
||||
try:
|
||||
proc = subprocess.run(argv, capture_output=True, text=True,
|
||||
timeout=BACKEND_TIMEOUT)
|
||||
ok = proc.returncode == 0
|
||||
result = "ok" if ok else "backend_error"
|
||||
self._json(200 if ok else 502, {
|
||||
"ok": ok,
|
||||
"op": op,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": proc.stdout[-8000:],
|
||||
"stderr": proc.stderr[-2000:],
|
||||
})
|
||||
except subprocess.TimeoutExpired:
|
||||
result = "timeout"
|
||||
self._json(504, {"ok": False, "op": op, "error": "backend_timeout"})
|
||||
except OSError as e:
|
||||
result = "exec_failed"
|
||||
self._json(500, {"ok": False, "op": op, "error": "exec_failed"})
|
||||
finally:
|
||||
audit({"identity": identity, "op": op, "class": cls,
|
||||
"node": params.get("account") or params.get("agent"),
|
||||
"params": summary, "result": result,
|
||||
"latency_ms": int((time.monotonic() - t0) * 1000)})
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
|
||||
def ensure_cert():
|
||||
if os.path.exists(CERT_FILE) and os.path.exists(KEY_FILE):
|
||||
return
|
||||
print("generating self-signed cert...", file=sys.stderr)
|
||||
subprocess.run([
|
||||
"openssl", "req", "-x509", "-newkey", "rsa:2048",
|
||||
"-keyout", KEY_FILE, "-out", CERT_FILE,
|
||||
"-days", "825", "-nodes", "-subj", "/CN=chromebox-gateway",
|
||||
], check=True)
|
||||
os.chmod(KEY_FILE, 0o600)
|
||||
os.chmod(CERT_FILE, 0o600)
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=8444)
|
||||
ap.add_argument("--bind", default="100.123.153.75",
|
||||
help="tailnet IP; use 127.0.0.1 for local-only")
|
||||
args = ap.parse_args()
|
||||
|
||||
ensure_cert()
|
||||
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
context.load_cert_chain(CERT_FILE, KEY_FILE)
|
||||
|
||||
srv = HTTPServer((args.bind, args.port), Handler)
|
||||
srv.socket = context.wrap_socket(srv.socket, server_side=True)
|
||||
print("chromebox-gateway listening on https://%s:%d" % (args.bind, args.port),
|
||||
file=sys.stderr)
|
||||
print("ops: %s" % ", ".join(sorted(ALLOWLIST)), file=sys.stderr)
|
||||
try:
|
||||
srv.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user