refactor: reorganize client transport and extension internals

- Split client, native, remote, serve, markdown, and SDK internals into focused packages with direct imports.
- Move local and remote transport framing/protocol helpers behind clearer module boundaries.
- Break up the extension injected DOM logic into a separate content dispatch bundle and dedicated content modules.
- Add explicit client handling for passive remote discovery without noisy PQ warnings.
- Keep behavior covered with updated unit, integration, and extension tests.
This commit is contained in:
2026-06-13 23:31:24 +02:00
parent fd5447cbb9
commit 076914e5b7
88 changed files with 7491 additions and 5228 deletions
+95 -316
View File
@@ -1,338 +1,117 @@
import re, threading, secrets, socket, struct, click, json, sys
from datetime import datetime
"""Click command for exposing a browser over TCP."""
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from rich.console import Console
import click
from browser_cli import transport
from browser_cli.client import _recv_exact, _recv_all
from browser_cli.compat import adapt_auth, adapt_request, adapt_response
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, MAX_MSG_BYTES, parse_version, get_installed_version
_UA_PATTERN = re.compile(r"^browser-cli/\d")
_CONN_LIMIT = threading.BoundedSemaphore(64)
console = Console()
def _framed_send(sock: socket.socket, data: bytes) -> None:
sock.sendall(struct.pack("<I", len(data)) + data)
def _log(addr:tuple, command:str, profile:str|None, status:str, error:str|None=None) -> None:
ts = datetime.now().strftime("%H:%M:%S")
addr_str = f"{addr[0]}:{addr[1]}"
profile_str = f"[dim]{profile}[/dim] " if profile else ""
if error:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [red]{status}[/red] {error}")
else:
console.print(f"[dim]{ts}[/dim] {addr_str} {profile_str}[cyan]{command}[/cyan] [green]{status}[/green]")
def _proxy_request(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys:list[str]|None, auth_keys_path:"Path|None", nonce:str, pq_private_key=None, compress:bool=True) -> None:
from browser_cli.client import _resolve_socket, BrowserNotConnected
from browser_cli.platform import is_windows
response_secret = None
accept_encoding = None # set once the (decrypted) request is parsed; None → plain JSON
def _send_payload(data: bytes) -> None:
if response_secret is not None:
from browser_cli.auth import pq_encrypt
data = json.dumps({"encrypted": pq_encrypt(response_secret, "response", data)}).encode()
_framed_send(client_sock, data)
def _send_error(msg_id, msg:str) -> None:
# errors stay plain JSON: tiny, and safe for any client
err = json.dumps({"id": msg_id, "success": False, "error": msg}).encode()
try:
_send_payload(err)
except OSError:
pass
def _send_ok(msg_id, payload, command=None) -> None:
obj = {"id": msg_id, "success": True, "data": payload}
try:
_send_payload(transport.encode_response(obj, accept_encoding if compress else None, command))
except OSError:
pass
try:
header = _recv_exact(client_sock, 4)
msg_len = struct.unpack("<I", header)[0]
if msg_len > MAX_MSG_BYTES:
_send_error(None, f"message too large ({msg_len} bytes)")
return
payload = _recv_exact(client_sock, msg_len)
except (ConnectionError, OSError):
return
try:
msg = json.loads(payload)
except (json.JSONDecodeError, ValueError):
_send_error(None, "invalid JSON")
_log(addr, "?", None, "ERROR", "invalid JSON")
return
# ── user-agent + version check ────────────────────────────────────────────
msg_id = msg.get("id")
ua = msg.get("user_agent") or ""
if not _UA_PATTERN.match(ua):
_send_error(msg_id, "forbidden: client required")
_log(addr, msg.get("command", "?"), None, "DENIED", f"bad user-agent: {ua!r}")
return
client_ver = "0"
try:
client_ver = ua.split("/", 1)[1]
if parse_version(client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
_send_error(msg_id, f"client version {client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
_log(addr, msg.get("command", "?"), None, "DENIED", f"client {client_ver} < min {PROTOCOL_MIN_CLIENT}")
return
except (IndexError, ValueError):
pass
msg = adapt_auth(msg, client_ver)
command = msg.get("command", "?")
# ── auth ──────────────────────────────────────────────────────────────────
if auth_keys is not None:
pub = msg.get("pubkey") or ""
sig = msg.get("sig") or ""
if not pub or not sig:
_send_error(msg_id, "unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
_log(addr, command, None, "DENIED", "missing pubkey/sig")
return
if pub not in auth_keys:
_send_error(msg_id, "unauthorized: untrusted public key")
_log(addr, command, None, "DENIED", "untrusted key")
return
pq_shared_secret = None
transport_encrypted = False
if pq_private_key is not None:
kex = msg.get("pq_kex") or {}
pq_required = parse_version(client_ver) >= parse_version("0.9.5")
if not isinstance(kex, dict) or kex.get("alg") != "ML-KEM-768" or not kex.get("ciphertext"):
if pq_required:
_send_error(msg_id, "unauthorized: post-quantum key exchange required")
_log(addr, command, None, "DENIED", "missing pq kex")
return
else:
try:
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
pq_shared_secret = pq_kex_server_decapsulate(pq_private_key, str(kex["ciphertext"]))
if "encrypted" in msg:
decrypted_msg = json.loads(pq_decrypt(pq_shared_secret, "request", msg["encrypted"]))
if not isinstance(decrypted_msg, dict):
raise ValueError("encrypted request is not a JSON object")
decrypted_msg["pubkey"] = pub
decrypted_msg["sig"] = sig
decrypted_msg["pq_kex"] = kex
msg = adapt_auth(decrypted_msg, client_ver)
msg_id = msg.get("id", msg_id)
command = msg.get("command", "?")
transport_encrypted = True
elif pq_required:
_send_error(msg_id, "unauthorized: post-quantum encrypted transport required")
_log(addr, command, None, "DENIED", "missing pq transport")
return
except Exception:
_send_error(msg_id, "unauthorized: invalid post-quantum encrypted transport")
_log(addr, command, None, "DENIED", "bad pq transport")
return
from browser_cli.auth import verify
if not verify(pub, bytes.fromhex(nonce), msg, sig, pq_shared_secret):
_send_error(msg_id, "unauthorized: invalid signature")
_log(addr, command, None, "DENIED", "bad signature")
return
response_secret = pq_shared_secret if transport_encrypted else None
# client advertises what response encodings it can decode (signed, then stripped)
accept_encoding = msg.get("accept_encoding")
if command == "browser-cli.targets":
from browser_cli.client import active_browser_targets
targets = [
{"profile": target.profile, "displayName": target.display_name}
for target in active_browser_targets(include_remotes=False)
]
_send_ok(msg_id, targets, command)
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.keys":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import load_authorized_keys_with_names
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(auth_keys_path)]
_send_ok(msg_id, entries, command)
_log(addr, command, None, "OK")
return
if command == "browser-cli.auth.trust":
if auth_keys_path is None:
_send_error(msg_id, "no authorized keys file configured on this server")
_log(addr, command, None, "ERROR", "no authorized keys file")
return
from browser_cli.auth import add_authorized_key
args = msg.get("args") or {}
pubkey = str(args.get("pubkey") or "")
name = str(args.get("name") or "")
if not re.fullmatch(r"[0-9a-f]{64}", pubkey):
_send_error(msg_id, "invalid pubkey: expected 64 lowercase hex characters")
_log(addr, command, None, "ERROR", "invalid pubkey")
return
added = add_authorized_key(auth_keys_path, pubkey, name)
_send_ok(msg_id, {"added": added}, command)
_log(addr, command, None, "OK" if added else "ALREADY_TRUSTED")
return
resolved_profile = msg.get("_route") or profile
# ── strip protocol fields, apply request compat shim, forward ─────────────
strip = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
clean_msg = {k: v for k, v in msg.items() if k not in strip}
clean_msg = adapt_request(clean_msg, client_ver)
clean_payload = json.dumps(clean_msg).encode()
clean_header = struct.pack("<I", len(clean_payload))
try:
sock_path = _resolve_socket(resolved_profile)
except BrowserNotConnected as e:
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", "browser not connected")
return
try:
if is_windows():
from multiprocessing.connection import Client as PipeClient
with PipeClient(sock_path, family="AF_PIPE") as pipe:
pipe.send_bytes(clean_payload)
resp_payload = pipe.recv_bytes()
resp_payload = adapt_response(resp_payload, command, client_ver)
else:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as local:
local.connect(sock_path)
local.sendall(clean_header + clean_payload)
resp_payload = _recv_all(local)
resp_payload = adapt_response(resp_payload, command, client_ver)
# parse once: drives both the access log and (re-)encoding for the client
resp_data = json.loads(resp_payload)
if compress:
_send_payload(transport.encode_response(resp_data, accept_encoding, command))
else:
_send_payload(resp_payload)
if resp_data.get("success", True):
_log(addr, command, resolved_profile, "OK")
else:
_log(addr, command, resolved_profile, "ERROR", resp_data.get("error", ""))
except (OSError, json.JSONDecodeError) as e:
_send_error(msg_id, str(e))
_log(addr, command, resolved_profile, "ERROR", str(e))
def _handle_client(client_sock:socket.socket, addr:tuple, profile:str|None, auth_keys_path:"Path|None", compress:bool=True) -> None:
if not _CONN_LIMIT.acquire(blocking=False):
client_sock.close()
return
client_sock.settimeout(30)
try:
with client_sock:
# reload on every connection so auth trust --remote takes effect immediately
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
auth_keys: list[str] | None = load_authorized_keys(auth_keys_path)
else:
auth_keys = None
nonce = secrets.token_hex(32)
pq_private_key = None
challenge_msg = {
"type": "challenge",
"nonce": nonce,
"server_version": get_installed_version(),
"min_client_version": PROTOCOL_MIN_CLIENT,
}
if auth_keys_path is not None:
from browser_cli.auth import PQ_KEX_ALG, pq_kex_server_keypair
pq_keypair = pq_kex_server_keypair()
if pq_keypair is not None:
pq_private_key, pq_public_key = pq_keypair
challenge_msg["pq_kex"] = {"alg": PQ_KEX_ALG, "public_key": pq_public_key.hex()}
challenge = json.dumps(challenge_msg).encode()
try:
_framed_send(client_sock, challenge)
except OSError:
return
_proxy_request(client_sock, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
finally:
_CONN_LIMIT.release()
from browser_cli.serve.runtime import (
_async_framed_send,
_async_handle_client,
_async_recv_all,
_handle_client,
_serve_async,
console,
)
from browser_cli.version_manager import get_installed_version
__all__ = [
"_async_framed_send",
"_async_handle_client",
"_async_recv_all",
"_handle_client",
"_serve_async",
"cmd_serve",
]
@click.command("serve")
@click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
@click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
@click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
@click.option("--authorized-keys", "auth_keys_file", default=None, metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.")
@click.option("--no-compress", "no_compress", is_flag=True, default=False,
help="Disable response compression / msgpack even for clients that support it.")
@click.option(
"--authorized-keys",
"auth_keys_file",
default=None,
metavar="FILE",
help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
)
@click.option(
"--no-compress",
"no_compress",
is_flag=True,
default=False,
help="Disable response compression / msgpack even for clients that support it.",
)
@click.pass_context
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
"""Expose this browser over TCP so remote hosts can control it."""
profile = ctx.obj.get("browser") if ctx.obj else None
compress = not no_compress
if host in ("0.0.0.0", "::"):
console.print("[yellow]Warning:[/yellow] Binding to all interfaces — anyone who can reach this port controls your browser.")
if host in ("0.0.0.0", "::"):
console.print(
"[yellow]Warning:[/yellow] Binding to all interfaces — "
"anyone who can reach this port controls your browser."
)
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
elif no_auth:
auth_keys_path = None
else:
console.print("[red]Error:[/red] --authorized-keys FILE is required. Use --no-auth to explicitly disable auth (dangerous).")
sys.exit(1)
auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
if auth_keys_path is False:
sys.exit(1)
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server.bind((host, port))
except OSError as e:
server.close()
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
server.listen(16)
_print_startup(host, port, profile, auth_keys_path, compress)
current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
try:
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
except OSError as e:
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
sys.exit(1)
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
if auth_keys_file:
from browser_cli.auth import load_authorized_keys
auth_keys_path = Path(auth_keys_file)
if not load_authorized_keys(auth_keys_path):
console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
return auth_keys_path
if no_auth:
return None
console.print(
"[red]Error:[/red] --authorized-keys FILE is required. "
"Use --no-auth to explicitly disable auth (dangerous)."
)
return False
def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
current_ver = get_installed_version()
browser_hint = f" (browser: {profile})" if profile else ""
console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
else:
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
if auth_keys_path is not None:
from browser_cli.auth import load_authorized_keys
n = len(load_authorized_keys(auth_keys_path))
console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
else:
console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
_print_encoding_status(compress)
console.print("Ctrl-C to stop.\n")
if compress:
def _print_encoding_status(compress: bool) -> None:
if not compress:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
return
codecs = "+".join(transport.supported_compression())
sers = "+".join(transport.supported_serialization())
console.print(f" Encode: [green]on[/green] [dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]")
else:
console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
console.print("Ctrl-C to stop.\n")
try:
while True:
conn, addr = server.accept()
threading.Thread(target=_handle_client, args=(conn, addr, profile, auth_keys_path, compress), daemon=True).start()
except KeyboardInterrupt:
console.print("[yellow]Stopped.[/yellow]")
finally:
server.close()
console.print(
" Encode: [green]on[/green] "
f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
)