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:
@@ -0,0 +1,107 @@
|
||||
"""Client validation and authentication for ``browser-cli serve``."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
from browser_cli.compat import adapt_auth
|
||||
from browser_cli.serve.logging import log_request
|
||||
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, parse_version
|
||||
|
||||
_UA_PATTERN = re.compile(r"^browser-cli/\d")
|
||||
AuthDecodeResult = tuple[bytes | None, bool] | tuple[Literal[False], Literal[False]]
|
||||
|
||||
class ServeAuthMixin:
|
||||
addr: tuple
|
||||
command: str
|
||||
client_ver: str
|
||||
msg_id: object
|
||||
nonce: str
|
||||
pq_private_key: object | None
|
||||
auth_keys: list[str] | None
|
||||
response_secret: bytes | None
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
||||
|
||||
async def validate_client(self, msg: dict) -> bool:
|
||||
self.msg_id = msg.get("id")
|
||||
ua = msg.get("user_agent") or ""
|
||||
if not _UA_PATTERN.match(ua):
|
||||
await self.send_error("forbidden: client required")
|
||||
log_request(self.addr, msg.get("command", "?"), None, "DENIED", f"bad user-agent: {ua!r}")
|
||||
return False
|
||||
try:
|
||||
self.client_ver = ua.split("/", 1)[1]
|
||||
if parse_version(self.client_ver) < parse_version(PROTOCOL_MIN_CLIENT):
|
||||
await self.send_error(f"client version {self.client_ver} is too old; please upgrade to >= {PROTOCOL_MIN_CLIENT}")
|
||||
log_request(self.addr, msg.get("command", "?"), None, "DENIED", f"client {self.client_ver} < min {PROTOCOL_MIN_CLIENT}")
|
||||
return False
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return True
|
||||
|
||||
async def authenticate(self, msg: dict) -> dict | None:
|
||||
if self.auth_keys is None:
|
||||
return msg
|
||||
|
||||
pub = msg.get("pubkey") or ""
|
||||
sig = msg.get("sig") or ""
|
||||
if not pub or not sig:
|
||||
await self.send_error("unauthorized: pubkey auth required — run 'browser-cli auth keygen' on the client")
|
||||
log_request(self.addr, self.command, None, "DENIED", "missing pubkey/sig")
|
||||
return None
|
||||
if pub not in self.auth_keys:
|
||||
await self.send_error("unauthorized: untrusted public key")
|
||||
log_request(self.addr, self.command, None, "DENIED", "untrusted key")
|
||||
return None
|
||||
|
||||
pq_shared_secret, transport_encrypted = await self._decode_pq_transport(msg, pub, sig)
|
||||
if pq_shared_secret is False:
|
||||
return None
|
||||
|
||||
from browser_cli.auth import verify
|
||||
if not verify(pub, bytes.fromhex(self.nonce), msg, sig, pq_shared_secret):
|
||||
await self.send_error("unauthorized: invalid signature")
|
||||
log_request(self.addr, self.command, None, "DENIED", "bad signature")
|
||||
return None
|
||||
self.response_secret = pq_shared_secret if transport_encrypted else None
|
||||
return msg
|
||||
|
||||
async def _decode_pq_transport(self, msg: dict, pub: str, sig: str) -> AuthDecodeResult:
|
||||
pq_shared_secret = None
|
||||
transport_encrypted = False
|
||||
if self.pq_private_key is None:
|
||||
return pq_shared_secret, transport_encrypted
|
||||
|
||||
kex = msg.get("pq_kex") or {}
|
||||
pq_required = parse_version(self.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:
|
||||
await self.send_error("unauthorized: post-quantum key exchange required")
|
||||
log_request(self.addr, self.command, None, "DENIED", "missing pq kex")
|
||||
return False, False
|
||||
return pq_shared_secret, transport_encrypted
|
||||
|
||||
try:
|
||||
from browser_cli.auth import pq_decrypt, pq_kex_server_decapsulate
|
||||
pq_shared_secret = pq_kex_server_decapsulate(self.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.update({"pubkey": pub, "sig": sig, "pq_kex": kex})
|
||||
msg.clear()
|
||||
msg.update(adapt_auth(decrypted_msg, self.client_ver))
|
||||
self.msg_id = msg.get("id", self.msg_id)
|
||||
self.command = msg.get("command", "?")
|
||||
transport_encrypted = True
|
||||
elif pq_required:
|
||||
await self.send_error("unauthorized: post-quantum encrypted transport required")
|
||||
log_request(self.addr, self.command, None, "DENIED", "missing pq transport")
|
||||
return False, False
|
||||
except Exception:
|
||||
await self.send_error("unauthorized: invalid post-quantum encrypted transport")
|
||||
log_request(self.addr, self.command, None, "DENIED", "bad pq transport")
|
||||
return False, False
|
||||
return pq_shared_secret, transport_encrypted
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Built-in control commands handled directly by ``browser-cli serve``."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli.serve.logging import log_request
|
||||
|
||||
class ServeControlMixin:
|
||||
addr: tuple
|
||||
command: str
|
||||
auth_keys_path: Path | None
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
||||
async def send_ok(self, payload, command: str | None = None) -> None: ...
|
||||
|
||||
async def handle_control_command(self, msg: dict) -> bool:
|
||||
if self.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)
|
||||
]
|
||||
await self.send_ok(targets, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
return True
|
||||
|
||||
if self.command == "browser-cli.auth.keys":
|
||||
if self.auth_keys_path is None:
|
||||
await self.send_error("no authorized keys file configured on this server")
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||
return True
|
||||
from browser_cli.auth import load_authorized_keys_with_names
|
||||
entries = [{"pubkey": pk, "name": name} for pk, name in load_authorized_keys_with_names(self.auth_keys_path)]
|
||||
await self.send_ok(entries, self.command)
|
||||
log_request(self.addr, self.command, None, "OK")
|
||||
return True
|
||||
|
||||
if self.command == "browser-cli.auth.trust":
|
||||
return await self._handle_trust(msg)
|
||||
return False
|
||||
|
||||
async def _handle_trust(self, msg: dict) -> bool:
|
||||
if self.auth_keys_path is None:
|
||||
await self.send_error("no authorized keys file configured on this server")
|
||||
log_request(self.addr, self.command, None, "ERROR", "no authorized keys file")
|
||||
return True
|
||||
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):
|
||||
await self.send_error("invalid pubkey: expected 64 lowercase hex characters")
|
||||
log_request(self.addr, self.command, None, "ERROR", "invalid pubkey")
|
||||
return True
|
||||
added = add_authorized_key(self.auth_keys_path, pubkey, name)
|
||||
await self.send_ok({"added": added}, self.command)
|
||||
log_request(self.addr, self.command, None, "OK" if added else "ALREADY_TRUSTED")
|
||||
return True
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Shared logging helpers for ``browser-cli serve``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
|
||||
def log_request(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]")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Proxying from TCP clients to the local browser native-host socket."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.compat import adapt_request, adapt_response
|
||||
from browser_cli.framing import async_recv_frame, async_send_frame
|
||||
from browser_cli.serve.logging import log_request
|
||||
|
||||
_STRIP_PROTOCOL_FIELDS = {"token", "_route", "pubkey", "sig", "user_agent", "pq_kex", "encrypted", "accept_encoding"}
|
||||
|
||||
class ServeProxyMixin:
|
||||
addr: tuple
|
||||
profile: str | None
|
||||
client_ver: str
|
||||
command: str
|
||||
compress: bool
|
||||
accept_encoding: dict | None
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None: ...
|
||||
async def send_payload(self, data: bytes) -> None: ...
|
||||
|
||||
async def forward_to_browser(self, msg: dict) -> None:
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.client.targets import resolve_socket
|
||||
from browser_cli.platform import is_windows
|
||||
|
||||
resolved_profile = msg.get("_route") or self.profile
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in _STRIP_PROTOCOL_FIELDS}
|
||||
clean_payload = json.dumps(adapt_request(clean_msg, self.client_ver)).encode()
|
||||
|
||||
try:
|
||||
sock_path = resolve_socket(resolved_profile)
|
||||
except BrowserNotConnected as e:
|
||||
await self.send_error(str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", "browser not connected")
|
||||
return
|
||||
|
||||
try:
|
||||
if is_windows():
|
||||
resp_payload = await self._windows_roundtrip(sock_path, clean_payload)
|
||||
else:
|
||||
resp_payload = await self._unix_roundtrip(sock_path, clean_payload)
|
||||
await self.send_browser_response(adapt_response(resp_payload, self.command, self.client_ver), resolved_profile)
|
||||
except (OSError, json.JSONDecodeError, ConnectionError) as e:
|
||||
await self.send_error(str(e))
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", str(e))
|
||||
|
||||
async def _windows_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
|
||||
from multiprocessing.connection import Client as PipeClient
|
||||
|
||||
def _pipe_roundtrip():
|
||||
with PipeClient(sock_path, family="AF_PIPE") as pipe:
|
||||
pipe.send_bytes(payload)
|
||||
return pipe.recv_bytes()
|
||||
|
||||
return await asyncio.to_thread(_pipe_roundtrip)
|
||||
|
||||
async def _unix_roundtrip(self, sock_path: str, payload: bytes) -> bytes:
|
||||
local_reader, local_writer = await asyncio.open_unix_connection(sock_path)
|
||||
try:
|
||||
await async_send_frame(local_writer, payload)
|
||||
return await async_recv_frame(local_reader) or b""
|
||||
finally:
|
||||
local_writer.close()
|
||||
await local_writer.wait_closed()
|
||||
|
||||
async def send_browser_response(self, resp_payload: bytes, resolved_profile: str | None) -> None:
|
||||
resp_data = json.loads(resp_payload)
|
||||
if self.compress:
|
||||
await self.send_payload(transport.encode_response(resp_data, self.accept_encoding, self.command))
|
||||
else:
|
||||
await self.send_payload(resp_payload)
|
||||
if resp_data.get("success", True):
|
||||
log_request(self.addr, self.command, resolved_profile, "OK")
|
||||
else:
|
||||
log_request(self.addr, self.command, resolved_profile, "ERROR", resp_data.get("error", ""))
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Runtime implementation for ``browser-cli serve``.
|
||||
|
||||
The Click command lives in ``browser_cli.commands.serve``. This module owns the
|
||||
connection lifecycle; auth, control commands and browser proxying live in small
|
||||
mixins so each piece can be tested/refactored independently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from browser_cli import transport
|
||||
from browser_cli.compat import adapt_auth
|
||||
from browser_cli.framing import async_recv_frame, async_send_frame
|
||||
from browser_cli.serve.auth import ServeAuthMixin
|
||||
from browser_cli.serve.control import ServeControlMixin
|
||||
from browser_cli.serve.logging import console, log_request
|
||||
from browser_cli.serve.proxy import ServeProxyMixin
|
||||
from browser_cli.version_manager import PROTOCOL_MIN_CLIENT, get_installed_version
|
||||
|
||||
async def _async_framed_send(writer: asyncio.StreamWriter, data: bytes) -> None:
|
||||
await async_send_frame(writer, data)
|
||||
|
||||
async def _async_recv_all(reader: asyncio.StreamReader) -> bytes:
|
||||
return await async_recv_frame(reader) or b""
|
||||
|
||||
@dataclass
|
||||
class ServeRequest(ServeAuthMixin, ServeControlMixin, ServeProxyMixin):
|
||||
reader: asyncio.StreamReader
|
||||
writer: asyncio.StreamWriter
|
||||
addr: tuple
|
||||
profile: str | None
|
||||
auth_keys: list[str] | None
|
||||
auth_keys_path: Path | None
|
||||
nonce: str
|
||||
pq_private_key: object | None = None
|
||||
compress: bool = True
|
||||
|
||||
response_secret: bytes | None = None
|
||||
accept_encoding: dict | None = None
|
||||
client_ver: str = "0"
|
||||
msg_id: object = None
|
||||
command: str = "?"
|
||||
|
||||
async def send_payload(self, data: bytes) -> None:
|
||||
if self.response_secret is not None:
|
||||
from browser_cli.auth import pq_encrypt
|
||||
data = json.dumps({"encrypted": pq_encrypt(self.response_secret, "response", data)}).encode()
|
||||
await _async_framed_send(self.writer, data)
|
||||
|
||||
async def send_error(self, msg: str, msg_id=None) -> None:
|
||||
err = json.dumps({"id": self.msg_id if msg_id is None else msg_id, "success": False, "error": msg}).encode()
|
||||
try:
|
||||
await self.send_payload(err)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def send_ok(self, payload, command: str | None = None) -> None:
|
||||
obj = {"id": self.msg_id, "success": True, "data": payload}
|
||||
try:
|
||||
await self.send_payload(transport.encode_response(obj, self.accept_encoding if self.compress else None, command))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def read_message(self) -> dict | None:
|
||||
try:
|
||||
payload = await _async_recv_all(self.reader)
|
||||
except (ConnectionError, OSError) as exc:
|
||||
if "too large" in str(exc):
|
||||
await self.send_error(str(exc), msg_id=None)
|
||||
return None
|
||||
try:
|
||||
msg = json.loads(payload)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
await self.send_error("invalid JSON", msg_id=None)
|
||||
log_request(self.addr, "?", None, "ERROR", "invalid JSON")
|
||||
return None
|
||||
return msg if isinstance(msg, dict) else None
|
||||
|
||||
async def run(self) -> None:
|
||||
msg = await self.read_message()
|
||||
if msg is None or not await self.validate_client(msg):
|
||||
return
|
||||
msg = adapt_auth(msg, self.client_ver)
|
||||
self.command = msg.get("command", "?")
|
||||
msg = await self.authenticate(msg)
|
||||
if msg is None:
|
||||
return
|
||||
self.accept_encoding = msg.get("accept_encoding")
|
||||
if await self.handle_control_command(msg):
|
||||
return
|
||||
await self.forward_to_browser(msg)
|
||||
|
||||
async def _async_proxy_request(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
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:
|
||||
await ServeRequest(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress).run()
|
||||
|
||||
async def _async_handle_client(
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
addr: tuple,
|
||||
profile: str | None,
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool = True,
|
||||
conn_limit: asyncio.Semaphore | None = None,
|
||||
) -> None:
|
||||
if conn_limit is None:
|
||||
conn_limit = asyncio.Semaphore(64)
|
||||
if conn_limit.locked():
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
return
|
||||
await conn_limit.acquire()
|
||||
try:
|
||||
auth_keys = await _load_auth_keys(auth_keys_path)
|
||||
nonce, pq_private_key, challenge_msg = await _build_challenge(auth_keys_path)
|
||||
try:
|
||||
await _async_framed_send(writer, json.dumps(challenge_msg).encode())
|
||||
except OSError:
|
||||
return
|
||||
await _async_proxy_request(reader, writer, addr, profile, auth_keys, auth_keys_path, nonce, pq_private_key, compress)
|
||||
finally:
|
||||
conn_limit.release()
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _load_auth_keys(auth_keys_path: Path | None) -> list[str] | None:
|
||||
if auth_keys_path is None:
|
||||
return None
|
||||
from browser_cli.auth import load_authorized_keys
|
||||
return await asyncio.to_thread(load_authorized_keys, auth_keys_path)
|
||||
|
||||
async def _build_challenge(auth_keys_path: Path | None) -> tuple[str, object | None, dict]:
|
||||
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 = await asyncio.to_thread(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()}
|
||||
return nonce, pq_private_key, challenge_msg
|
||||
|
||||
def _handle_client(
|
||||
client_sock: socket.socket,
|
||||
addr: tuple,
|
||||
profile: str | None,
|
||||
auth_keys_path: Path | None,
|
||||
compress: bool = True,
|
||||
) -> None:
|
||||
"""Run one accepted socket through the async serve pipeline."""
|
||||
|
||||
async def _run() -> None:
|
||||
reader, writer = await asyncio.open_connection(sock=client_sock)
|
||||
await _async_handle_client(reader, writer, addr, profile, auth_keys_path, compress)
|
||||
|
||||
try:
|
||||
asyncio.run(_run())
|
||||
except OSError:
|
||||
try:
|
||||
client_sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def _serve_async(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
|
||||
conn_limit = asyncio.Semaphore(64)
|
||||
|
||||
async def _client_connected(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
||||
peer = writer.get_extra_info("peername") or ("?", 0)
|
||||
await _async_handle_client(reader, writer, peer, profile, auth_keys_path, compress, conn_limit)
|
||||
|
||||
server = await asyncio.start_server(_client_connected, host, port, backlog=16)
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
Reference in New Issue
Block a user