feat(hybrid-gateway): integrate muse-cli with Cloudflare netns isolation, symmetric sidechat routing, and 646-pip sync unblock
This commit is contained in:
Executable
+714
@@ -0,0 +1,714 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
exec-constrained.py — Constrained HTTPS script-execution endpoint for bl.
|
||||
|
||||
SSH-fallback: when SSH to bl is down, operators can still run a constrained
|
||||
set of scripts (dm.py, job-dispatch, chromebox control) over HTTPS.
|
||||
|
||||
This is the hardened replacement for exec-server.py's /exec endpoint, which
|
||||
executes ARBITRARY shell commands (subprocess.run(cmd, shell=True)). This
|
||||
server NEVER takes a command string. Clients request a named OPERATION with
|
||||
validated ARGUMENTS; the server maps op -> fixed argv. No shell. No
|
||||
interpolation. No RCE.
|
||||
|
||||
Usage:
|
||||
python3 exec-constrained.py --port 8444
|
||||
|
||||
Client:
|
||||
curl -sk -X POST https://127.0.0.1:8444/exec \\
|
||||
-H 'Authorization: Bearer <token>' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d '{"op": "dm.send", "args": {"agent": "opm", "to": "646",
|
||||
"target": "main", "message": "hello"}}'
|
||||
|
||||
Or signature auth (no secret in transit):
|
||||
payload=$(python3 -c "import json,time,secrets; print(json.dumps({
|
||||
'op': 'dm.send',
|
||||
'args': {'agent':'opm','to':'646','target':'main','message':'hi'},
|
||||
'ts': time.time(), 'nonce': secrets.token_hex(16)}))")
|
||||
sig=$(printf '%s' "$payload" | ssh-keygen -Y sign -f ~/.ssh/id_frontdoor -n exec-constrained)
|
||||
curl -sk -X POST https://127.0.0.1:8444/exec \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
-d "$(python3 -c "import json,sys; print(json.dumps({
|
||||
'identity': 'operator-main',
|
||||
'payload': sys.argv[1], 'signature': sys.argv[2]}))" "$payload" "$sig")"
|
||||
|
||||
Auth model: master bearer token + per-agent bearer tokens (same files as
|
||||
exec-server.py, so migration is drop-in) OR ssh-keygen -Y signatures over
|
||||
the op envelope (namespace 'exec-constrained'). See SECURITY.md.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import ssl
|
||||
|
||||
# ---------------------------------------------------------------- config
|
||||
|
||||
BIN_DIR = '/home/super/Projects/NetVM/bin'
|
||||
JOBS_DIR = '/home/super/Projects/NetVM/jobs'
|
||||
TOKEN_FILE = '/home/super/.exec-server-token'
|
||||
TOKEN_DIR = '/home/super/.exec-tokens'
|
||||
SIGNERS_FILE = '/home/super/.exec-signers'
|
||||
NONCE_FILE = '/home/super/.exec-constrained-nonces'
|
||||
AUDIT_LOG = '/home/super/.exec-constrained-audit.jsonl'
|
||||
SIG_NAMESPACE = 'exec-constrained'
|
||||
SIG_MAX_SKEW = 300
|
||||
|
||||
AGENTS = ('muse', 'pip', '646', 'opm')
|
||||
TARGET_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9/_.-]{0,63}$')
|
||||
JOB_RE = re.compile(r'^[a-z0-9][a-z0-9-]{0,63}$')
|
||||
IDENT_RE = re.compile(r'^[a-z0-9-]+$')
|
||||
NONCE_RE = re.compile(r'^[0-9a-fA-F]{16,128}$')
|
||||
HEX_RE = re.compile(r'^[0-9a-f]{8,128}$')
|
||||
|
||||
MAX_BODY = 64 * 1024 # 64KB request cap
|
||||
MAX_MESSAGE = 2000 # dm.py message cap
|
||||
MAX_OUTPUT = 256 * 1024 # per-stream output cap
|
||||
WORK_DIR = '/home/super'
|
||||
|
||||
|
||||
# ------------------------------------------------------- rate limiting
|
||||
|
||||
class RateLimiter:
|
||||
"""Token bucket per identity. Thread-safe."""
|
||||
def __init__(self, rate_per_min=20, burst=5):
|
||||
self.rate = rate_per_min / 60.0
|
||||
self.burst = burst
|
||||
self._buckets = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def allow(self, ident):
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
tokens, last = self._buckets.get(ident, (self.burst, now))
|
||||
tokens = min(self.burst, tokens + (now - last) * self.rate)
|
||||
if tokens >= 1.0:
|
||||
self._buckets[ident] = (tokens - 1.0, now)
|
||||
return True
|
||||
self._buckets[ident] = (tokens, now)
|
||||
return False
|
||||
|
||||
|
||||
LIMITER = RateLimiter()
|
||||
|
||||
|
||||
# ------------------------------------------------------------- auth
|
||||
|
||||
def get_token():
|
||||
try:
|
||||
with open(TOKEN_FILE) as f:
|
||||
return f.read().strip()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def check_token(token):
|
||||
"""Bearer token -> identity label, or None. Never logs the token."""
|
||||
master = get_token()
|
||||
if token and master and hmac.compare_digest(token, master):
|
||||
return 'master'
|
||||
try:
|
||||
names = os.listdir(TOKEN_DIR)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
for name in names:
|
||||
if not IDENT_RE.fullmatch(name):
|
||||
continue
|
||||
p = os.path.join(TOKEN_DIR, name)
|
||||
if not os.path.isfile(p):
|
||||
continue
|
||||
try:
|
||||
with open(p) as f:
|
||||
t = f.read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if t and hmac.compare_digest(token, t):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def check_nonce(nonce):
|
||||
now = time.time()
|
||||
fresh = []
|
||||
try:
|
||||
with open(NONCE_FILE) as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
n, t = parts
|
||||
try:
|
||||
if now - float(t) < 2 * SIG_MAX_SKEW:
|
||||
fresh.append((n, t))
|
||||
except ValueError:
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if any(n == nonce for n, _ in fresh):
|
||||
return False
|
||||
fresh.append((nonce, str(now)))
|
||||
try:
|
||||
with open(NONCE_FILE, 'w') as f:
|
||||
for n, t in fresh:
|
||||
f.write(f'{n} {t}\n')
|
||||
os.chmod(NONCE_FILE, 0o600)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_signature(identity, payload, signature):
|
||||
"""ssh-keygen -Y signature over {"op","args","ts","nonce"} -> identity/None."""
|
||||
if not IDENT_RE.fullmatch(identity or ''):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
op = data.get('op')
|
||||
args = data.get('args')
|
||||
ts = data.get('ts')
|
||||
nonce = data.get('nonce')
|
||||
if not isinstance(op, str) or op not in OPS:
|
||||
return None
|
||||
if not isinstance(args, dict):
|
||||
return None
|
||||
if not isinstance(nonce, str) or not NONCE_RE.fullmatch(nonce):
|
||||
return None
|
||||
try:
|
||||
ts = float(ts)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if abs(time.time() - ts) > SIG_MAX_SKEW:
|
||||
return None
|
||||
if not check_nonce(nonce):
|
||||
return None
|
||||
sig_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile('w', delete=False, suffix='.sig') as f:
|
||||
f.write(signature if signature.endswith('\n') else signature + '\n')
|
||||
sig_path = f.name
|
||||
p = subprocess.run(
|
||||
['ssh-keygen', '-Y', 'verify', '-f', SIGNERS_FILE, '-I', identity,
|
||||
'-n', SIG_NAMESPACE, '-s', sig_path],
|
||||
input=payload.encode(), capture_output=True, timeout=15)
|
||||
return identity if p.returncode == 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
if sig_path:
|
||||
try:
|
||||
os.unlink(sig_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------- allowlist
|
||||
#
|
||||
# Each op maps to a FIXED script with VALIDATED arguments. The client can
|
||||
# never influence: the executable path, the subcommand, or any flag name.
|
||||
# Only whitelisted argument VALUES flow through, each checked below.
|
||||
#
|
||||
# To add an op: add an entry here with a build() function. build() receives
|
||||
# the validated args dict and returns an argv list (no shell). Arg spec is
|
||||
# enforced by validate() before build() runs.
|
||||
|
||||
class OpError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _clean_message(s):
|
||||
if not isinstance(s, str) or not s.strip():
|
||||
raise OpError('message must be a non-empty string')
|
||||
if len(s) > MAX_MESSAGE:
|
||||
raise OpError(f'message too long (max {MAX_MESSAGE})')
|
||||
if any(ord(c) < 32 and c not in '\n\t' for c in s):
|
||||
raise OpError('message contains control characters')
|
||||
return s
|
||||
|
||||
|
||||
def _agent(v):
|
||||
if v not in AGENTS:
|
||||
raise OpError(f'agent must be one of {AGENTS}')
|
||||
return v
|
||||
|
||||
|
||||
def _target(v):
|
||||
if not isinstance(v, str) or not TARGET_RE.fullmatch(v):
|
||||
raise OpError('target must match ^[A-Za-z0-9][A-Za-z0-9/_.-]{0,63}$')
|
||||
return v
|
||||
|
||||
|
||||
def _opt_int(v, lo, hi, name):
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, bool) or not isinstance(v, int):
|
||||
raise OpError(f'{name} must be an integer')
|
||||
if not (lo <= v <= hi):
|
||||
raise OpError(f'{name} must be {lo}..{hi}')
|
||||
return v
|
||||
|
||||
|
||||
def _job_name(v):
|
||||
# Must exist in JOBS_DIR and match the safe pattern (no path traversal).
|
||||
if not isinstance(v, str) or not JOB_RE.fullmatch(v):
|
||||
raise OpError('job must match ^[a-z0-9][a-z0-9-]{0,63}$')
|
||||
path = os.path.join(JOBS_DIR, v + '.json')
|
||||
if not os.path.isfile(path):
|
||||
raise OpError('unknown job')
|
||||
return v
|
||||
|
||||
|
||||
def _dm_send_build(a):
|
||||
argv = [sys.executable, os.path.join(BIN_DIR, 'dm.py'), 'send',
|
||||
'--agent', a['agent'], '--target', a['target']]
|
||||
if a.get('to'):
|
||||
argv += ['--to', a['to']]
|
||||
if a.get('expect_reply'):
|
||||
argv += ['--expect-reply']
|
||||
if a.get('reply_timeout') is not None:
|
||||
argv += ['--reply-timeout', str(a['reply_timeout'])]
|
||||
if a.get('reply_nudges') is not None:
|
||||
argv += ['--reply-nudges', str(a['reply_nudges'])]
|
||||
if a.get('reply_escalate'):
|
||||
argv += ['--reply-escalate', a['reply_escalate']]
|
||||
if a.get('route'):
|
||||
argv += ['--route', a['route']]
|
||||
for t in a.get('tags', []):
|
||||
argv += ['--tag', t]
|
||||
argv.append(a['message'])
|
||||
return argv
|
||||
|
||||
|
||||
def _dm_send_validate(raw):
|
||||
if not isinstance(raw, dict):
|
||||
raise OpError('args must be an object')
|
||||
allowed = {'agent', 'to', 'target', 'message', 'expect_reply',
|
||||
'reply_timeout', 'reply_nudges', 'reply_escalate',
|
||||
'route', 'tags'}
|
||||
for k in raw:
|
||||
if k not in allowed:
|
||||
raise OpError(f'unknown arg: {k}')
|
||||
a = {}
|
||||
a['agent'] = _agent(raw.get('agent'))
|
||||
if 'to' in raw and raw['to'] is not None:
|
||||
a['to'] = _agent(raw['to'])
|
||||
a['target'] = _target(raw.get('target'))
|
||||
a['message'] = _clean_message(raw.get('message'))
|
||||
a['expect_reply'] = bool(raw.get('expect_reply', False))
|
||||
a['reply_timeout'] = _opt_int(raw.get('reply_timeout'), 60, 604800,
|
||||
'reply_timeout')
|
||||
a['reply_nudges'] = _opt_int(raw.get('reply_nudges'), 0, 10,
|
||||
'reply_nudges')
|
||||
esc = raw.get('reply_escalate')
|
||||
if esc is not None:
|
||||
a['reply_escalate'] = _agent(esc)
|
||||
route = raw.get('route')
|
||||
if route is not None:
|
||||
if not isinstance(route, str) or not TARGET_RE.fullmatch(route):
|
||||
raise OpError('route must match target pattern')
|
||||
a['route'] = route
|
||||
tags = raw.get('tags', [])
|
||||
if not isinstance(tags, list) or len(tags) > 8:
|
||||
raise OpError('tags must be a list of at most 8 strings')
|
||||
clean_tags = []
|
||||
for t in tags:
|
||||
if not isinstance(t, str) or len(t) > 120:
|
||||
raise OpError('tag must be a string <= 120 chars')
|
||||
# Canonical tag vocabulary only (see DEPLOY-DECISIONS.md).
|
||||
if not re.fullmatch(r'[a-z0-9_:-]+=[a-zA-Z0-9_.:/-]*', t) and \
|
||||
not re.fullmatch(r'[a-z0-9_:-]+', t):
|
||||
raise OpError(f'malformed tag: {t}')
|
||||
clean_tags.append(t)
|
||||
a['tags'] = clean_tags
|
||||
return a
|
||||
|
||||
|
||||
def _dm_thread_validate(raw):
|
||||
# Same shape as dm.send but uses the thread subcommand.
|
||||
return _dm_send_validate(raw)
|
||||
|
||||
|
||||
def _dm_thread_build(a):
|
||||
argv = _dm_send_build(a)
|
||||
argv[2] = 'thread'
|
||||
return argv
|
||||
|
||||
|
||||
def _dm_read_validate(raw):
|
||||
if not isinstance(raw, dict):
|
||||
raise OpError('args must be an object')
|
||||
allowed = {'agent', 'target', 'limit'}
|
||||
for k in raw:
|
||||
if k not in allowed:
|
||||
raise OpError(f'unknown arg: {k}')
|
||||
return {
|
||||
'agent': _agent(raw.get('agent')),
|
||||
'target': _target(raw.get('target')),
|
||||
'limit': _opt_int(raw.get('limit', 20), 1, 100, 'limit') or 20,
|
||||
}
|
||||
|
||||
|
||||
def _dm_read_build(a):
|
||||
return [sys.executable, os.path.join(BIN_DIR, 'dm.py'), 'read',
|
||||
'--agent', a['agent'], '--target', a['target'],
|
||||
'--limit', str(a['limit'])]
|
||||
|
||||
|
||||
def _job_run_validate(raw):
|
||||
if not isinstance(raw, dict):
|
||||
raise OpError('args must be an object')
|
||||
allowed = {'job'}
|
||||
for k in raw:
|
||||
if k not in allowed:
|
||||
raise OpError(f'unknown arg: {k}')
|
||||
return {'job': _job_name(raw.get('job'))}
|
||||
|
||||
|
||||
def _job_run_build(a):
|
||||
return [sys.executable, os.path.join(BIN_DIR, 'job-dispatch.py'), a['job']]
|
||||
|
||||
|
||||
def _chat_messages_validate(raw):
|
||||
if not isinstance(raw, dict):
|
||||
raise OpError('args must be an object')
|
||||
allowed = {'account', 'limit'}
|
||||
for k in raw:
|
||||
if k not in allowed:
|
||||
raise OpError(f'unknown arg: {k}')
|
||||
return {
|
||||
'account': _agent(raw.get('account')),
|
||||
'limit': _opt_int(raw.get('limit', 20), 1, 100, 'limit') or 20,
|
||||
}
|
||||
|
||||
|
||||
def _chat_messages_build(a):
|
||||
return [sys.executable, os.path.join(BIN_DIR, 'muse-chat-api.py'),
|
||||
'--account', a['account'], 'messages', '--limit', str(a['limit'])]
|
||||
|
||||
|
||||
def _chat_send_validate(raw):
|
||||
if not isinstance(raw, dict):
|
||||
raise OpError('args must be an object')
|
||||
allowed = {'account', 'message', 'thread'}
|
||||
for k in raw:
|
||||
if k not in allowed:
|
||||
raise OpError(f'unknown arg: {k}')
|
||||
a = {
|
||||
'account': _agent(raw.get('account')),
|
||||
'message': _clean_message(raw.get('message')),
|
||||
}
|
||||
if raw.get('thread') is not None:
|
||||
th = raw['thread']
|
||||
if not isinstance(th, str) or not HEX_RE.fullmatch(th):
|
||||
raise OpError('thread must be a hex uuid')
|
||||
a['thread'] = th
|
||||
return a
|
||||
|
||||
|
||||
def _chat_send_build(a):
|
||||
argv = [sys.executable, os.path.join(BIN_DIR, 'muse-chat-api.py'),
|
||||
'--account', a['account'], 'send', '--message', a['message']]
|
||||
if a.get('thread'):
|
||||
argv += ['--thread', a['thread']]
|
||||
return argv
|
||||
|
||||
|
||||
def _health_validate(raw):
|
||||
if raw not in ({}, None):
|
||||
raise OpError('health.check takes no args')
|
||||
return {}
|
||||
|
||||
|
||||
def _health_build(a):
|
||||
return ['/bin/bash', os.path.join(BIN_DIR, 'agent-health.sh'), '--check']
|
||||
|
||||
|
||||
# op -> {validate, build, timeout, side_effecting, description}
|
||||
OPS = {
|
||||
'dm.send': {
|
||||
'validate': _dm_send_validate, 'build': _dm_send_build,
|
||||
'timeout': 120, 'side_effecting': True,
|
||||
'desc': 'Send a DM via dm.py (verified delivery)',
|
||||
},
|
||||
'dm.thread': {
|
||||
'validate': _dm_thread_validate, 'build': _dm_thread_build,
|
||||
'timeout': 120, 'side_effecting': True,
|
||||
'desc': 'Send a threaded DM via dm.py',
|
||||
},
|
||||
'dm.read': {
|
||||
'validate': _dm_read_validate, 'build': _dm_read_build,
|
||||
'timeout': 60, 'side_effecting': False,
|
||||
'desc': 'Read recent DMs (read-only)',
|
||||
},
|
||||
'job.run': {
|
||||
'validate': _job_run_validate, 'build': _job_run_build,
|
||||
'timeout': 300, 'side_effecting': True,
|
||||
'desc': 'Run a job from the jobs directory',
|
||||
},
|
||||
'chat.messages': {
|
||||
'validate': _chat_messages_validate, 'build': _chat_messages_build,
|
||||
'timeout': 60, 'side_effecting': False,
|
||||
'desc': 'Read recent chat messages (read-only)',
|
||||
},
|
||||
'chat.send': {
|
||||
'validate': _chat_send_validate, 'build': _chat_send_build,
|
||||
'timeout': 120, 'side_effecting': True,
|
||||
'desc': 'Send a chat message via muse-chat-api.py',
|
||||
},
|
||||
'health.check': {
|
||||
'validate': _health_validate, 'build': _health_build,
|
||||
'timeout': 120, 'side_effecting': False,
|
||||
'desc': 'Run agent-health.sh --check (read-only)',
|
||||
},
|
||||
'exec.ping': {
|
||||
'validate': _health_validate,
|
||||
'build': lambda a: ['/bin/echo', 'PONG'],
|
||||
'timeout': 10, 'side_effecting': False,
|
||||
'desc': 'Canary no-op for watchdogs',
|
||||
},
|
||||
}
|
||||
|
||||
# identity -> set of ops. 'master' may invoke everything. Unknown identities
|
||||
# get the read-only subset. Per-agent tokens inherit their agent name as the
|
||||
# identity; tighten per agent here as needed.
|
||||
PERMISSIONS = {
|
||||
'master': set(OPS),
|
||||
'operator-main': set(OPS),
|
||||
'operator-646': {'dm.send', 'dm.thread', 'dm.read', 'job.run',
|
||||
'chat.messages', 'health.check', 'exec.ping'},
|
||||
'operator-muse': {'dm.send', 'dm.read', 'chat.messages', 'exec.ping'},
|
||||
'operator-pip': {'dm.send', 'dm.read', 'chat.messages', 'exec.ping'},
|
||||
'exec-canary': {'exec.ping'},
|
||||
}
|
||||
DEFAULT_PERMS = {'dm.read', 'chat.messages', 'health.check', 'exec.ping'}
|
||||
|
||||
|
||||
def permitted(ident, op):
|
||||
perms = PERMISSIONS.get(ident, DEFAULT_PERMS)
|
||||
return op in perms
|
||||
|
||||
# ------------------------------------------------------- audit log
|
||||
|
||||
_audit_lock = threading.Lock()
|
||||
|
||||
|
||||
def audit(entry):
|
||||
"""Append-only JSONL audit. Never includes tokens or signatures."""
|
||||
entry = dict(entry)
|
||||
entry['ts'] = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())
|
||||
line = json.dumps(entry, separators=(',', ':')) + '\n'
|
||||
with _audit_lock:
|
||||
try:
|
||||
with open(AUDIT_LOG, 'a') as f:
|
||||
f.write(line)
|
||||
os.chmod(AUDIT_LOG, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------- HTTP handler
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = 'exec-constrained/1.0'
|
||||
|
||||
def log_message(self, format, *args):
|
||||
sys.stderr.write(f'{self.client_address[0]} - {format % args}\n')
|
||||
|
||||
def _send(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 self.path == '/health':
|
||||
self._send(200, {'status': 'ok', 'mode': 'constrained'})
|
||||
elif self.path == '/ops':
|
||||
# List ops (names + descriptions only; no arg details needed).
|
||||
self._send(200, {'ops': [
|
||||
{'op': k, 'desc': v['desc'],
|
||||
'side_effecting': v['side_effecting']}
|
||||
for k, v in sorted(OPS.items())]})
|
||||
else:
|
||||
self._send(404, {'error': 'not found'})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == '/exec':
|
||||
self._handle_exec()
|
||||
else:
|
||||
self._send(404, {'error': 'not found'})
|
||||
|
||||
def _handle_exec(self):
|
||||
peer = self.client_address[0]
|
||||
try:
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
except ValueError:
|
||||
length = 0
|
||||
if length <= 0 or length > MAX_BODY:
|
||||
self._send(413 if length > MAX_BODY else 400,
|
||||
{'error': 'bad content length'})
|
||||
return
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length))
|
||||
except Exception:
|
||||
self._send(400, {'error': 'invalid json'})
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
self._send(400, {'error': 'body must be an object'})
|
||||
return
|
||||
|
||||
# --- auth: Bearer <redacted> header (preferred) or legacy body token,
|
||||
# --- or ssh-keygen signature envelope.
|
||||
ident = None
|
||||
op = data.get('op')
|
||||
args = data.get('args', {})
|
||||
authz = self.headers.get('Authorization', '')
|
||||
if authz.startswith('Bearer '):
|
||||
token = authz[7:].strip()
|
||||
ident = check_token(token)
|
||||
elif data.get('token'):
|
||||
ident = check_token(data['token'])
|
||||
elif data.get('identity') and data.get('payload') \
|
||||
and data.get('signature'):
|
||||
# Signature envelope carries op/args; ignore body's op/args.
|
||||
ident = check_signature(data['identity'], data['payload'],
|
||||
data['signature'])
|
||||
if ident:
|
||||
try:
|
||||
env = json.loads(data['payload'])
|
||||
op, args = env.get('op'), env.get('args', {})
|
||||
except Exception:
|
||||
op, args = None, {}
|
||||
if not ident:
|
||||
audit({'event': 'auth_failed', 'peer': peer,
|
||||
'op': str(op)[:64]})
|
||||
self._send(401, {'error': 'unauthorized'})
|
||||
return
|
||||
|
||||
# --- rate limit
|
||||
if not LIMITER.allow(ident):
|
||||
audit({'event': 'rate_limited', 'ident': ident, 'peer': peer,
|
||||
'op': str(op)[:64]})
|
||||
self._send(429, {'error': 'rate limited'})
|
||||
return
|
||||
|
||||
# --- op + permission + args
|
||||
if not isinstance(op, str) or op not in OPS:
|
||||
self._send(400, {'error': 'unknown op',
|
||||
'hint': 'GET /ops for the allowlist'})
|
||||
return
|
||||
if not permitted(ident, op):
|
||||
audit({'event': 'forbidden', 'ident': ident, 'peer': peer,
|
||||
'op': op})
|
||||
self._send(403, {'error': 'forbidden for this identity'})
|
||||
return
|
||||
try:
|
||||
spec = OPS[op]
|
||||
clean = spec['validate'](args)
|
||||
argv = spec['build'](clean)
|
||||
except OpError as e:
|
||||
self._send(400, {'error': f'bad args: {e}'})
|
||||
return
|
||||
except Exception as e:
|
||||
self._send(500, {'error': 'internal'})
|
||||
return
|
||||
|
||||
# --- execute (NO shell, argv only)
|
||||
audit({'event': 'exec_start', 'ident': ident, 'peer': peer,
|
||||
'op': op,
|
||||
'args_sha': hashlib.sha256(
|
||||
json.dumps(clean, sort_keys=True).encode()
|
||||
).hexdigest()[:16]})
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
p = subprocess.run(argv, capture_output=True, text=True,
|
||||
timeout=spec['timeout'], cwd=WORK_DIR)
|
||||
rc = p.returncode
|
||||
out = p.stdout[-MAX_OUTPUT:]
|
||||
err = p.stderr[-MAX_OUTPUT:]
|
||||
ok = True
|
||||
except subprocess.TimeoutExpired:
|
||||
rc, out, err, ok = -1, '', 'timeout', False
|
||||
except Exception as e:
|
||||
rc, out, err, ok = -1, '', str(e)[:500], False
|
||||
dt = round(time.monotonic() - t0, 2)
|
||||
audit({'event': 'exec_done', 'ident': ident, 'peer': peer,
|
||||
'op': op, 'rc': rc, 'duration_s': dt, 'ok': ok})
|
||||
if ok:
|
||||
self._send(200, {'rc': rc, 'stdout': out, 'stderr': err,
|
||||
'duration_s': dt})
|
||||
else:
|
||||
self._send(500, {'error': err, 'rc': rc})
|
||||
|
||||
|
||||
# ------------------------------------------------------- main
|
||||
|
||||
def main():
|
||||
global TOKEN_FILE, TOKEN_DIR, SIGNERS_FILE, NONCE_FILE, AUDIT_LOG
|
||||
global BIN_DIR, JOBS_DIR, WORK_DIR
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--port', type=int, default=8444)
|
||||
ap.add_argument('--host', default='127.0.0.1')
|
||||
ap.add_argument('--token-file', default=TOKEN_FILE)
|
||||
ap.add_argument('--token-dir', default=TOKEN_DIR)
|
||||
ap.add_argument('--signers-file', default=SIGNERS_FILE)
|
||||
ap.add_argument('--nonce-file', default=NONCE_FILE)
|
||||
ap.add_argument('--audit-log', default=AUDIT_LOG)
|
||||
ap.add_argument('--bin-dir', default=BIN_DIR)
|
||||
ap.add_argument('--jobs-dir', default=JOBS_DIR)
|
||||
ap.add_argument('--cert-file',
|
||||
default='/home/super/.exec-constrained-cert.pem')
|
||||
ap.add_argument('--key-file',
|
||||
default='/home/super/.exec-constrained-key.pem')
|
||||
ap.add_argument('--work-dir', default='/home/super')
|
||||
args = ap.parse_args()
|
||||
|
||||
TOKEN_FILE, TOKEN_DIR = args.token_file, args.token_dir
|
||||
SIGNERS_FILE, NONCE_FILE = args.signers_file, args.nonce_file
|
||||
AUDIT_LOG = args.audit_log
|
||||
BIN_DIR, JOBS_DIR = args.bin_dir, args.jobs_dir
|
||||
WORK_DIR = args.work_dir
|
||||
|
||||
server = HTTPServer((args.host, args.port), Handler)
|
||||
|
||||
cert_file = args.cert_file
|
||||
key_file = args.key_file
|
||||
if not os.path.exists(cert_file):
|
||||
print('Generating self-signed cert...', file=sys.stderr)
|
||||
subprocess.run([
|
||||
'openssl', 'req', '-x509', '-newkey', 'rsa:2048',
|
||||
'-keyout', key_file, '-out', cert_file,
|
||||
'-days', '3650', '-nodes',
|
||||
'-subj', '/CN=bl-exec-constrained',
|
||||
], check=True, capture_output=True)
|
||||
os.chmod(key_file, 0o600)
|
||||
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(cert_file, key_file)
|
||||
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
||||
|
||||
print(f'Constrained exec listening on https://{args.host}:{args.port}/exec',
|
||||
file=sys.stderr)
|
||||
print(f'Allowlist: {", ".join(sorted(OPS))}', file=sys.stderr)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print('\nShutting down', file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user