"""Expose this browser over a ServiceLink HTTP /rpc endpoint. This lets the other nodes (picoshare, website) drive the browser through the shared servicelink envelope, reusing browser-cli's own wire commands verbatim: a link method like `tabs.list` forwards straight to `send_command_async`. It is separate from `serve` (the Ed25519/TCP remote-control daemon); use this when you want a node in the mesh to call the browser with a bearer token. servicelink (and its httpx dependency) is imported lazily inside the command so that a missing optional dependency never breaks the rest of the CLI. """ import asyncio import hmac import sys from pathlib import Path import click from browser_cli.client.core import send_command_async from browser_cli.errors import BrowserNotConnected # Curated set of browser commands exposed over the mesh. Method name == command. EXPOSED_COMMANDS = [ "tabs.list", "tabs.open", "tabs.close", "tabs.active", "tabs.query", "nav.open", "nav.reload", "nav.back", "nav.forward", "dom.query", "dom.text", "dom.attr", "dom.exists", "dom.click", "extract.links", "extract.images", "extract.text", "extract.markdown", "extract.html", "page.info", "session.save", "session.load", "session.list", ] def _import_servicelink(): """Import servicelink lazily; the flat submodule sits at the repo root.""" repo_root = Path(__file__).resolve().parents[2] if str(repo_root) not in sys.path: sys.path.insert(0, str(repo_root)) try: import servicelink return servicelink except ImportError as exc: raise click.ClickException( "servicelink could not be imported. Initialise the submodule " "(`git submodule update --init`) and ensure httpx is installed." ) from exc def _build_router(sl, profile): router = sl.Router("browser-cli") def make_handler(command): async def handler(params, ctx): try: return await send_command_async(command, params or None, profile=profile) except BrowserNotConnected as exc: raise sl.Unavailable(f"browser not connected: {exc}") except (RuntimeError, ConnectionError) as exc: raise sl.LinkError(str(exc)) return handler for command in EXPOSED_COMMANDS: router.register(command, make_handler(command)) return router def _make_verifier(sl, token): if not token: return None async def verify(authorization, request): presented = (authorization or "").split(" ", 1)[-1].strip() # Constant-time compare so a wrong token can't be timed out character by character. if not presented or not hmac.compare_digest(presented, token): raise sl.Unauthorized("invalid or missing token") return sl.Principal(subject="mesh", scopes=frozenset({"all", "mesh"})) return verify def _http_response(status, body, content_type): head = ( f"HTTP/1.1 {status}\r\n" f"Content-Type: {content_type}\r\n" f"Content-Length: {len(body)}\r\n" "Connection: close\r\n\r\n" ).encode("latin-1") return head + body async def _handle_connection(sl, router, verify, reader, writer): try: request_line = await reader.readline() if not request_line: return headers = {} while True: line = await reader.readline() if line in (b"\r\n", b"\n", b""): break key, _, value = line.decode("latin-1").partition(":") headers[key.strip().lower()] = value.strip() length = int(headers.get("content-length", "0") or "0") body = await reader.readexactly(length) if length else b"" if not request_line.upper().startswith(b"POST"): writer.write(_http_response(405, b'{"error":"only POST /rpc is supported"}', "application/json")) else: status, payload, content_type = await sl.handle_envelope( router, body, authorization=headers.get("authorization"), verify=verify, content_type=headers.get("content-type", "application/json"), ) writer.write(_http_response(status, payload, content_type)) await writer.drain() except Exception: # noqa: BLE001 - never let one connection kill the server pass finally: writer.close() async def _serve(sl, host, port, profile, token): router = _build_router(sl, profile) verify = _make_verifier(sl, token) server = await asyncio.start_server( lambda r, w: _handle_connection(sl, router, verify, r, w), host, port ) click.echo(f"servicelink browser node listening on http://{host}:{port}/rpc") async with server: await server.serve_forever() _LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} @click.command("link-serve") @click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.") @click.option("--port", default=8770, show_default=True, type=int, help="HTTP port for /rpc.") @click.option("--token", default=None, metavar="SECRET", help="Shared bearer token required from callers (sent as 'Authorization: Bearer ...').") @click.option("--insecure", is_flag=True, default=False, help="Run with NO token. Grants full browser control (cookies, pages) to anyone who can reach the port.") @click.pass_context def cmd_link_serve(ctx, host, port, token, insecure): """Serve this browser to the ServiceLink mesh over HTTP /rpc. Exposes the running browser (open/scrape pages, read cookies and storage), so a token is required by default. Bind to loopback and keep the port off the public network. """ if not token and not insecure: raise click.ClickException( "Refusing to start without --token (this endpoint can control your browser " "and read its cookies). Pass --insecure to override on a trusted host." ) if host not in _LOOPBACK_HOSTS: click.echo( f"WARNING: binding to {host} (not loopback). Anyone who can reach this " "address may control the browser; ensure it is firewalled.", err=True, ) if insecure and not token: click.echo("WARNING: --insecure set; the endpoint is UNAUTHENTICATED.", err=True) sl = _import_servicelink() profile = ctx.obj.get("browser") if ctx.obj else None try: asyncio.run(_serve(sl, host, port, profile, token)) except KeyboardInterrupt: pass