378 lines
13 KiB
Python
Executable File
378 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
HTTPS exec server for operator remote command execution.
|
|
Runs on bl (stable), accepts authenticated POST /exec, returns command output.
|
|
|
|
Usage:
|
|
python3 exec-server.py --port 8443 --token-file /home/super/.exec-token
|
|
|
|
646 (or any operator) can then:
|
|
curl -k -X POST https://100.123.153.75:8443/exec \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"cmd": "dm.py send --agent 646 --to opm --target main \"hello\"", "token": "..."}'
|
|
|
|
Via the VM Caddy (no tailnet needed from the agent's container):
|
|
curl -k -X POST https://34-139-37-135.sslip.io/exec/exec \
|
|
-H "Content-Type: application/json" \
|
|
-d '{"cmd": "...", "token": "<per-agent token>"}'
|
|
|
|
Signature auth (preferred for agents): no secret crosses the wire at all.
|
|
The agent signs {"cmd","ts","nonce"} with their registered SSH key
|
|
(`ssh-keygen -Y sign -n exec-server`) and posts
|
|
{"identity","payload","signature"}. Verified against the signers file
|
|
with `ssh-keygen -Y verify`; ts must be within 300s and the nonce unused.
|
|
This is the same identity primitive as signed board posts, and it never
|
|
trips secret-handling guardrails because a signature is not a secret.
|
|
|
|
Auth: the master token (TOKEN_FILE) plus per-agent tokens, one file per
|
|
agent under TOKEN_DIR (0600). Each agent's token is individually revocable
|
|
by deleting its file. The using identity is logged (never the token).
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
import subprocess
|
|
import sys
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
import ssl
|
|
|
|
# Token file - generated on first run if not exists
|
|
TOKEN_FILE = '/home/super/.exec-server-token'
|
|
# Per-agent tokens: TOKEN_DIR/<agent> contains that agent's token
|
|
TOKEN_DIR = '/home/super/.exec-tokens'
|
|
# ssh-keygen signature auth: public keys of fleet identities, one per line
|
|
# ("<identity> ssh-ed25519 AAAA..."), synced from the VM's allowed_signers.
|
|
SIGNERS_FILE = '/home/super/.exec-signers'
|
|
# Seen nonces for replay protection ("<nonce> <ts>" per line).
|
|
NONCE_FILE = '/home/super/.exec-nonces'
|
|
SIG_NAMESPACE = 'exec-server'
|
|
SIG_MAX_SKEW = 300 # seconds; nonces remembered for 2x this
|
|
|
|
def get_token():
|
|
"""Load or generate the auth token."""
|
|
try:
|
|
with open(TOKEN_FILE, 'r') as f:
|
|
return f.read().strip()
|
|
except FileNotFoundError:
|
|
token = secrets.token_hex(32)
|
|
with open(TOKEN_FILE, 'w') as f:
|
|
f.write(token)
|
|
# Secure permissions
|
|
import os
|
|
os.chmod(TOKEN_FILE, 0o600)
|
|
print(f'Generated new token in {TOKEN_FILE}', file=sys.stderr)
|
|
return token
|
|
|
|
def check_token(token):
|
|
"""Check token against the master token and per-agent tokens.
|
|
Returns the identity label ('master' or the agent filename), or None."""
|
|
if token and hmac.compare_digest(token, get_token()):
|
|
return 'master'
|
|
import os
|
|
try:
|
|
names = os.listdir(TOKEN_DIR)
|
|
except FileNotFoundError:
|
|
return None
|
|
for name in names:
|
|
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):
|
|
"""True if the nonce was never used; records it. Prunes expired entries."""
|
|
import os
|
|
import time
|
|
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):
|
|
"""Verify an `ssh-keygen -Y` signature over the payload envelope.
|
|
Returns the identity on success, None on failure. The envelope must be
|
|
JSON {"cmd","ts","nonce"} with a fresh ts and an unused nonce."""
|
|
import json as _json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
if not re.fullmatch(r'[a-z0-9-]+', identity or ''):
|
|
return None
|
|
try:
|
|
data = _json.loads(payload)
|
|
except Exception:
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
cmd = data.get('cmd')
|
|
ts = data.get('ts')
|
|
nonce = data.get('nonce')
|
|
if not isinstance(cmd, str) or not cmd:
|
|
return None
|
|
if not isinstance(nonce, str) or not re.fullmatch(r'[0-9a-fA-F]{16,128}', 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
|
|
|
|
|
|
class ExecHandler(BaseHTTPRequestHandler):
|
|
def log_message(self, format, *args):
|
|
# Quiet logging, just to stderr
|
|
sys.stderr.write(f'{self.client_address[0]} - {format % args}\n')
|
|
|
|
def do_POST(self):
|
|
# Read body
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
if content_length > 1024 * 1024: # 1MB max
|
|
self.send_response(413)
|
|
self.end_headers()
|
|
return
|
|
|
|
body = self.rfile.read(content_length)
|
|
try:
|
|
data = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
self.send_response(400)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"error": "invalid json"}')
|
|
return
|
|
|
|
if self.path == '/exec/rotate':
|
|
self.handle_rotate(data)
|
|
return
|
|
|
|
if self.path != '/exec':
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
return
|
|
|
|
# Auth: bearer token OR ssh-keygen -Y signature (no secret in transit).
|
|
# Bearer: {"token": "...", "cmd": "..."}.
|
|
# Signature: {"identity": "...", "payload": "{\"cmd\":...,\"ts\":...,\"nonce\":...}",
|
|
# "signature": "<ssh-keygen -Y armor>"}. check_signature
|
|
# validates the envelope; cmd comes from the signed payload.
|
|
ident = None
|
|
cmd = ''
|
|
token = data.get('token', '')
|
|
if token:
|
|
ident = check_token(token)
|
|
cmd = data.get('cmd', '')
|
|
elif data.get('identity') and data.get('payload') and data.get('signature'):
|
|
ident = check_signature(data['identity'], data['payload'], data['signature'])
|
|
if ident:
|
|
try:
|
|
cmd = json.loads(data['payload']).get('cmd', '')
|
|
except Exception:
|
|
cmd = ''
|
|
if not ident:
|
|
self.send_response(401)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"error": "unauthorized"}')
|
|
return
|
|
sys.stderr.write(f'exec as {ident} from {self.client_address[0]}\n')
|
|
# Get command
|
|
if not cmd or not isinstance(cmd, str):
|
|
self.send_response(400)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"error": "missing cmd"}')
|
|
return
|
|
|
|
# Execute (with timeout)
|
|
timeout = min(data.get('timeout', 60), 300) # max 5 min
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
shell=True,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
cwd='/home/super'
|
|
)
|
|
response = {
|
|
'stdout': result.stdout,
|
|
'stderr': result.stderr,
|
|
'rc': result.returncode,
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
response = {'error': 'timeout', 'rc': -1}
|
|
except Exception as e:
|
|
response = {'error': str(e), 'rc': -1}
|
|
|
|
# Send response
|
|
resp_body = json.dumps(response).encode()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(resp_body)))
|
|
self.end_headers()
|
|
self.wfile.write(resp_body)
|
|
|
|
def handle_rotate(self, data):
|
|
"""POST /exec/rotate - replace an agent's token with a fresh one.
|
|
The old token dies immediately; the new one is returned ONLY in the
|
|
response body (never logged). Lets an agent bootstrap from a
|
|
trust-root-delivered token and end up with one nobody else knows.
|
|
Agents may rotate only their own token; master may rotate any
|
|
agent's (not its own - that stays manual on bl)."""
|
|
import os
|
|
import re
|
|
token = data.get('token', '')
|
|
ident = check_token(token)
|
|
if not ident:
|
|
self.send_response(401)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"error": "unauthorized"}')
|
|
return
|
|
target = data.get('agent', ident)
|
|
if not re.fullmatch(r'[a-z0-9-]+', target or ''):
|
|
self.send_response(400)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"error": "bad agent name"}')
|
|
return
|
|
if target == 'master' or (ident != 'master' and target != ident):
|
|
self.send_response(403)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"error": "forbidden"}')
|
|
return
|
|
path = os.path.join(TOKEN_DIR, target)
|
|
if not os.path.isfile(path):
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
self.wfile.write(b'{"error": "no such agent token"}')
|
|
return
|
|
new_token = secrets.token_hex(32)
|
|
tmp = path + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
f.write(new_token + '\n')
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, path)
|
|
sys.stderr.write(f'token rotated for {target} by {ident}\n')
|
|
resp_body = json.dumps({'token': new_token}).encode()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(resp_body)))
|
|
self.end_headers()
|
|
self.wfile.write(resp_body)
|
|
|
|
def do_GET(self):
|
|
if self.path == '/health':
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.end_headers()
|
|
self.wfile.write(b'{"status": "ok"}')
|
|
else:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
|
|
def main():
|
|
global TOKEN_FILE, TOKEN_DIR
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--port', type=int, default=8443)
|
|
parser.add_argument('--host', default='0.0.0.0')
|
|
parser.add_argument('--token-file', default='/home/super/.exec-server-token')
|
|
parser.add_argument('--token-dir', default='/home/super/.exec-tokens')
|
|
parser.add_argument('--signers-file', default='/home/super/.exec-signers')
|
|
parser.add_argument('--nonce-file', default='/home/super/.exec-nonces')
|
|
args = parser.parse_args()
|
|
|
|
TOKEN_FILE = args.token_file
|
|
TOKEN_DIR = args.token_dir
|
|
global SIGNERS_FILE, NONCE_FILE
|
|
SIGNERS_FILE = args.signers_file
|
|
NONCE_FILE = args.nonce_file
|
|
|
|
# Ensure token exists
|
|
token = get_token()
|
|
print(f'Token: {token[:8]}... (full in {TOKEN_FILE})', file=sys.stderr)
|
|
|
|
server = HTTPServer((args.host, args.port), ExecHandler)
|
|
|
|
# Wrap with TLS (self-signed is fine for our use, we use -k)
|
|
# Generate self-signed cert if not exists
|
|
import os
|
|
cert_file = '/home/super/.exec-server-cert.pem'
|
|
key_file = '/home/super/.exec-server-key.pem'
|
|
|
|
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-server'
|
|
], check=True, capture_output=True)
|
|
os.chmod(key_file, 0o600)
|
|
|
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
context.load_cert_chain(cert_file, key_file)
|
|
server.socket = context.wrap_socket(server.socket, server_side=True)
|
|
|
|
print(f'Exec server listening on https://{args.host}:{args.port}/exec', file=sys.stderr)
|
|
print('Health check: https://<host>:<port>/health', file=sys.stderr)
|
|
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print('\nShutting down', file=sys.stderr)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|