#!/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": ""}' 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/ contains that agent's token TOKEN_DIR = '/home/super/.exec-tokens' # ssh-keygen signature auth: public keys of fleet identities, one per line # (" ssh-ed25519 AAAA..."), synced from the VM's allowed_signers. SIGNERS_FILE = '/home/super/.exec-signers' # Seen nonces for replay protection (" " 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": ""}. 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://:/health', file=sys.stderr) try: server.serve_forever() except KeyboardInterrupt: print('\nShutting down', file=sys.stderr) if __name__ == '__main__': main()