Files
UmbraAudio/infra/agent_bridge/server.py
2026-10-18 19:01:04 -07:00

494 lines
20 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import html
import http.cookies
import json
import os
import secrets
import signal
import ssl
import subprocess
import sys
import tempfile
import threading
import time
import uuid
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse
from remote_credential_broker.popup import macos_secret_prompt
MAX_BODY_BYTES = 128 * 1024
MAX_MESSAGE_CHARS = 64 * 1024
SESSION_TTL_SECONDS = 3600
PAIR_ATTEMPT_LIMIT = 8
PAIR_ATTEMPT_WINDOW = 60
class BridgeError(RuntimeError):
"""Base error for the temporary bridge."""
class MessageStore:
"""Atomic, non-secret file queue shared by the browser and local tooling."""
def __init__(self, root: Path) -> None:
self.root = root
self.inbox = root / "inbox"
self.outbox = root / "outbox"
self.processed = root / "processed"
self.delivered = root / "delivered"
for directory in (self.root, self.inbox, self.outbox, self.processed, self.delivered):
directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o700)
@staticmethod
def _safe_name(value: str) -> bool:
return bool(value) and len(value) <= 80 and all(c.isalnum() or c in "-_" for c in value)
def _write(self, directory: Path, message: dict[str, Any]) -> str:
message_id = str(message["id"])
if not self._safe_name(message_id):
raise BridgeError("invalid message id")
target = directory / f"{message_id}.json"
temporary = directory / f".{message_id}.{uuid.uuid4().hex}.tmp"
payload = json.dumps(message, ensure_ascii=False, sort_keys=True).encode("utf-8")
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(fd, "wb") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, target)
except BaseException:
try:
temporary.unlink()
except FileNotFoundError:
pass
raise
return str(target)
@staticmethod
def _validate_message(message: dict[str, Any]) -> dict[str, Any]:
required = {"schema_version", "id", "created_at", "sender", "kind", "title", "body"}
if not required.issubset(message):
raise BridgeError("message is missing required fields")
if message["schema_version"] != 1:
raise BridgeError("unsupported message schema")
if not isinstance(message["body"], str) or len(message["body"]) > MAX_MESSAGE_CHARS:
raise BridgeError("message body is too large")
if not isinstance(message["title"], str) or len(message["title"]) > 200:
raise BridgeError("message title is too large")
if "\x00" in message["body"] or "\x00" in message["title"]:
raise BridgeError("message contains NUL")
return message
def send_to_mac(self, body: str, title: str = "Browser message") -> dict[str, Any]:
message = self._validate_message(
{
"schema_version": 1,
"id": str(uuid.uuid4()),
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"sender": "oldbox-browser",
"kind": "instruction",
"correlation_id": None,
"title": title,
"body": body,
}
)
self._write(self.inbox, message)
return message
def send_to_browser(self, body: str, title: str = "Mac agent") -> dict[str, Any]:
message = self._validate_message(
{
"schema_version": 1,
"id": str(uuid.uuid4()),
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"sender": "mac-agent",
"kind": "report",
"correlation_id": None,
"title": title,
"body": body,
}
)
self._write(self.outbox, message)
return message
def _read_dir(self, directory: Path, limit: int) -> list[dict[str, Any]]:
values: list[dict[str, Any]] = []
for path in sorted(directory.glob("*.json"))[:limit]:
if not self._safe_name(path.stem):
continue
try:
if not path.is_file() or path.is_symlink():
continue
value = json.loads(path.read_text(encoding="utf-8"))
values.append(self._validate_message(value))
except (OSError, json.JSONDecodeError, BridgeError):
continue
return values
def poll_for_mac(self, limit: int = 20) -> list[dict[str, Any]]:
values = self._read_dir(self.inbox, max(1, min(limit, 100)))
for value in values:
source = self.inbox / f"{value['id']}.json"
destination = self.processed / f"{value['id']}.json"
try:
os.replace(source, destination)
except FileNotFoundError:
pass
return values
def read_for_browser(self, limit: int = 50) -> list[dict[str, Any]]:
return self._read_dir(self.outbox, max(1, min(limit, 100)))
def acknowledge(self, message_ids: list[str]) -> int:
count = 0
for message_id in message_ids[:100]:
if not self._safe_name(message_id):
continue
source = self.outbox / f"{message_id}.json"
destination = self.delivered / f"{message_id}.json"
try:
os.replace(source, destination)
count += 1
except FileNotFoundError:
pass
return count
def status(self) -> dict[str, int]:
return {
"inbox": len(list(self.inbox.glob("*.json"))),
"outbox": len(list(self.outbox.glob("*.json"))),
"processed": len(list(self.processed.glob("*.json"))),
"delivered": len(list(self.delivered.glob("*.json"))),
}
class BridgeState:
def __init__(self, password: str, ttl_seconds: int = SESSION_TTL_SECONDS) -> None:
self.started_at = time.time()
self.expires_at = self.started_at + ttl_seconds
self.salt = os.urandom(32)
self.digest = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), self.salt, 240_000
)
self.sessions: dict[str, dict[str, Any]] = {}
self.attempts: dict[str, list[float]] = {}
self.lock = threading.RLock()
def check_password(self, address: str, password: str) -> bool:
now = time.time()
with self.lock:
recent = [stamp for stamp in self.attempts.get(address, []) if now - stamp < PAIR_ATTEMPT_WINDOW]
if len(recent) >= PAIR_ATTEMPT_LIMIT:
return False
candidate = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), self.salt, 240_000
)
accepted = hmac.compare_digest(candidate, self.digest)
if accepted:
self.attempts.pop(address, None)
else:
recent.append(now)
self.attempts[address] = recent
return accepted
def new_session(self) -> tuple[str, str]:
session = secrets.token_urlsafe(40)
csrf = secrets.token_urlsafe(32)
with self.lock:
self.sessions[session] = {"csrf": csrf, "expires_at": self.expires_at}
return session, csrf
def session(self, token: str | None) -> dict[str, Any] | None:
if not token:
return None
with self.lock:
value = self.sessions.get(token)
if value is None or value["expires_at"] <= time.time():
self.sessions.pop(token, None)
return None
return value
def remaining_seconds(self) -> int:
return max(0, int(self.expires_at - time.time()))
class Handler(BaseHTTPRequestHandler):
server_version = "UmbraAgentBridge/0.1"
state: BridgeState
store: MessageStore
def log_message(self, format: str, *args: Any) -> None:
# Do not log request bodies, cookies, passwords, or message content.
print(f"agent-bridge: {self.address_string()} {format % args}", file=sys.stderr)
def _headers(self, content_type: str = "text/plain; charset=utf-8", length: int | None = None) -> None:
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
if length is not None:
self.send_header("Content-Length", str(length))
def _send(self, status: int, body: bytes, content_type: str = "text/plain; charset=utf-8") -> None:
self.send_response(status)
self._headers(content_type, len(body))
self.end_headers()
self.wfile.write(body)
def _json(self, status: int, value: dict[str, Any]) -> None:
self._send(status, json.dumps(value).encode("utf-8"), "application/json; charset=utf-8")
def _cookie_token(self) -> str | None:
raw = self.headers.get("Cookie", "")
cookie = http.cookies.SimpleCookie()
try:
cookie.load(raw)
except http.cookies.CookieError:
return None
morsel = cookie.get("bridge_session")
return morsel.value if morsel else None
def _auth(self) -> tuple[dict[str, Any] | None, str | None]:
session = self.state.session(self._cookie_token())
if session is None:
return None, "authentication required"
csrf = self.headers.get("X-Bridge-CSRF", "")
if self.command == "POST" and not hmac.compare_digest(csrf, session["csrf"]):
return None, "CSRF validation failed"
return session, None
def _read_json(self) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length <= 0 or length > MAX_BODY_BYTES:
raise BridgeError("invalid request size")
value = json.loads(self.rfile.read(length).decode("utf-8"))
if not isinstance(value, dict):
raise BridgeError("request must be a JSON object")
return value
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/healthz":
self._json(200, {"status": "ok", "paired_sessions": len(self.state.sessions)})
return
if path == "/":
if self.state.session(self._cookie_token()) is None:
self._send(200, LOGIN_HTML.encode("utf-8"), "text/html; charset=utf-8")
else:
self._send(200, TERMINAL_HTML.encode("utf-8"), "text/html; charset=utf-8")
return
if path == "/api/status":
session, error = self._auth()
if error:
self._json(401, {"error": error})
return
self._json(200, {"expires_in": self.state.remaining_seconds(), "queue": self.store.status()})
return
if path == "/api/messages":
session, error = self._auth()
if error:
self._json(401, {"error": error})
return
query = parse_qs(urlparse(self.path).query)
try:
limit = int(query.get("limit", ["50"])[0])
except ValueError:
limit = 50
self._json(200, {"messages": self.store.read_for_browser(limit)})
return
self._json(404, {"error": "not found"})
def do_POST(self) -> None:
path = urlparse(self.path).path
if path == "/api/pair":
try:
value = self._read_json()
password = value.get("password")
if not isinstance(password, str) or not self.state.check_password(
self.client_address[0], password
):
self._json(401, {"error": "pairing failed"})
return
session, csrf = self.state.new_session()
body = json.dumps({"paired": True, "csrf": csrf, "expires_in": self.state.remaining_seconds()}).encode()
self.send_response(200)
self._headers("application/json; charset=utf-8", len(body))
self.send_header(
"Set-Cookie",
f"bridge_session={session}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={self.state.remaining_seconds()}",
)
self.end_headers()
self.wfile.write(body)
except (BridgeError, ValueError, json.JSONDecodeError):
self._json(400, {"error": "invalid pairing request"})
return
if path == "/api/messages":
session, error = self._auth()
if error:
self._json(401, {"error": error})
return
try:
value = self._read_json()
body = value.get("body")
title = value.get("title", "Browser message")
if not isinstance(body, str) or not isinstance(title, str):
raise BridgeError("body and title must be strings")
message = self.store.send_to_mac(body, title)
self._json(202, {"id": message["id"], "queued": True})
except (BridgeError, ValueError, json.JSONDecodeError):
self._json(400, {"error": "invalid message"})
return
self._json(404, {"error": "not found"})
LOGIN_HTML = r"""<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>UmbraTrans Temporary Agent Bridge</title>
<style>
body{font:16px -apple-system,BlinkMacSystemFont,sans-serif;background:#111;color:#eee;max-width:720px;margin:10vh auto;padding:24px}
input,button{font:inherit;padding:12px;border-radius:6px;border:1px solid #555;background:#222;color:#eee}
input{width:min(90%,420px)}button{background:#2866c7;color:white;cursor:pointer;margin-left:8px}
small{color:#aaa}p{line-height:1.4}
</style>
<h1>UmbraTrans temporary bridge</h1>
<p>This session expires after one hour. Enter the temporary pairing password supplied by the Mac operator.</p>
<form id="pair"><input id="password" type="password" autocomplete="off" required><button>Pair</button></form>
<p id="status"></p>
<script>
const form=document.querySelector('#pair'), status=document.querySelector('#status');
form.addEventListener('submit',async e=>{e.preventDefault();status.textContent='Pairing…';const r=await fetch('/api/pair',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:document.querySelector('#password').value})});if(r.ok){sessionStorage.setItem('bridgeCsrf',(await r.json()).csrf);location.reload()}else{status.textContent='Pairing failed';form.reset();}});
</script>
"""
TERMINAL_HTML = r"""<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>UmbraTrans Agent Terminal</title>
<style>
body{font:15px ui-monospace,SFMono-Regular,Menlo,monospace;background:#0b0d10;color:#d7e0ea;max-width:1100px;margin:4vh auto;padding:20px}
#log{height:65vh;overflow:auto;background:#11151a;border:1px solid #303943;padding:14px;white-space:pre-wrap}
form{display:flex;gap:8px;margin-top:12px}input,button{font:inherit;padding:10px;border:1px solid #3c4652;border-radius:5px;background:#171c22;color:#eee}input{flex:1}button{background:#2866c7;color:white;cursor:pointer}
small{color:#9ba8b5}
</style>
<h1>UmbraTrans agent terminal</h1>
<small>Paired local session. Messages are queued for the Mac agent; this is not a raw shell.</small>
<div id="log"></div>
<form id="send"><input id="body" autocomplete="off" placeholder="Describe the next task or question…" required><button>Send</button></form>
<script>
const csrf=sessionStorage.getItem('bridgeCsrf'), log=document.querySelector('#log');
function show(m){const who=m.sender==='mac-agent'?'Mac agent':'Oldbox browser';log.textContent+=`[${m.created_at}] ${who}: ${m.body}\n`;log.scrollTop=log.scrollHeight;}
async function poll(){const r=await fetch('/api/messages',{credentials:'include'});if(r.ok){const d=await r.json();for(const m of d.messages)show(m);if(d.messages.length){await fetch('/api/messages',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','X-Bridge-CSRF':csrf},body:JSON.stringify({title:'ack',body:'acknowledged'})});}}}
setInterval(poll,2000);poll();
document.querySelector('#send').addEventListener('submit',async e=>{e.preventDefault();const input=document.querySelector('#body');const r=await fetch('/api/messages',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','X-Bridge-CSRF':csrf},body:JSON.stringify({title:'Browser task',body:input.value})});if(r.ok){log.textContent+=`[sent] ${input.value}\n`;input.value=''}else{log.textContent+='[error] message not queued\n'}});
</script>
"""
def make_certificate(directory: Path, address: str) -> tuple[Path, Path]:
directory.mkdir(parents=True, exist_ok=True)
os.chmod(directory, 0o700)
key = directory / "bridge-key.pem"
cert = directory / "bridge-cert.pem"
if key.exists() and cert.exists():
return key, cert
subprocess.run(
[
"openssl",
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
str(key),
"-out",
str(cert),
"-days",
"1",
"-subj",
f"/CN={address}",
"-addext",
f"subjectAltName=IP:{address}",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
os.chmod(key, 0o600)
os.chmod(cert, 0o644)
return key, cert
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Temporary UmbraTrans agent bridge")
parser.add_argument("--host", default="192.168.123.12")
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--root", default="~/.local/state/umbratrans/agent-bridge")
parser.add_argument("--ttl", type=int, default=SESSION_TTL_SECONDS)
args = parser.parse_args(argv)
if not 1 <= args.ttl <= 3600:
parser.error("--ttl must be between 1 and 3600")
password = macos_secret_prompt("agent-bridge-temp", "one-hour agent bridge pairing password")
if not password:
print("agent bridge: pairing password entry cancelled", file=sys.stderr)
return 2
state = BridgeState(password, args.ttl)
del password
store = MessageStore(Path(args.root).expanduser())
Handler.state = state
Handler.store = store
server = ThreadingHTTPServer((args.host, args.port), Handler)
tls_dir = Path(tempfile.mkdtemp(prefix="umbra-agent-bridge-tls-"))
key, cert = make_certificate(tls_dir, args.host)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(certfile=str(cert), keyfile=str(key))
server.socket = context.wrap_socket(server.socket, server_side=True)
port = server.server_address[1]
print(f"BRIDGE_URL=https://{args.host}:{port}/", flush=True)
print(f"BRIDGE_ROOT={store.root}", flush=True)
print(f"BRIDGE_EXPIRES_IN={state.remaining_seconds()}", flush=True)
def stop(*_: Any) -> None:
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
timer = threading.Timer(args.ttl, stop)
timer.daemon = True
timer.start()
try:
server.serve_forever(poll_interval=0.5)
finally:
server.server_close()
timer.cancel()
for path in (key, cert):
try:
path.unlink()
except FileNotFoundError:
pass
try:
tls_dir.rmdir()
except OSError:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())