494 lines
20 KiB
Python
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())
|