diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..36e5f81 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "servicelink"] + path = servicelink + url = git@git.yiprawr.dev:submodules/servicelink.git diff --git a/browser_cli/commands/link_serve.py b/browser_cli/commands/link_serve.py new file mode 100644 index 0000000..f6bf0f1 --- /dev/null +++ b/browser_cli/commands/link_serve.py @@ -0,0 +1,163 @@ +"""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 diff --git a/browser_cli/commands/serve.py b/browser_cli/commands/serve.py index 268a1b5..b8ce2ea 100644 --- a/browser_cli/commands/serve.py +++ b/browser_cli/commands/serve.py @@ -45,9 +45,32 @@ __all__ = [ default=False, help="Disable response compression / msgpack even for clients that support it.", ) +@click.option( + "--rpc", + is_flag=True, + default=False, + help="Also expose a ServiceLink HTTP /rpc endpoint (for mesh nodes) in the same process.", +) +@click.option("--rpc-port", default=8770, show_default=True, type=int, help="Port for the /rpc endpoint (with --rpc).") +@click.option( + "--rpc-token", + default=None, + metavar="SECRET", + help="Bearer token required on /rpc (with --rpc).", +) +@click.option( + "--rpc-insecure", + is_flag=True, + default=False, + help="Allow --rpc with no token (DANGEROUS: full browser control to anyone who can reach the port).", +) @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.""" +def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress, rpc, rpc_port, rpc_token, rpc_insecure): + """Expose this browser over TCP so remote hosts can control it. + + With --rpc, additionally serve the ServiceLink mesh over HTTP /rpc on + --rpc-port, so the native TCP protocol and the node mesh share one daemon. + """ profile = ctx.obj.get("browser") if ctx.obj else None compress = not no_compress @@ -61,16 +84,40 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress): if auth_keys_path is False: sys.exit(1) + if rpc and not rpc_token and not rpc_insecure: + console.print( + "[red]Error:[/red] --rpc requires --rpc-token (this endpoint can control your " + "browser and read its cookies). Use --rpc-insecure to override on a trusted host." + ) + sys.exit(1) + _print_startup(host, port, profile, auth_keys_path, compress) + if rpc: + console.print(f" Mesh: [green]ServiceLink HTTP[/green] [cyan]{host}:{rpc_port}/rpc[/cyan]") + if not rpc_token: + console.print("[yellow] /rpc auth disabled (--rpc-insecure)[/yellow]") try: - asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress)) + if rpc: + asyncio.run(_serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token)) + else: + 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]") +async def _serve_with_rpc(host, port, profile, auth_keys_path, compress, rpc_port, rpc_token): + """Run the native TCP server and the ServiceLink HTTP /rpc server together.""" + from browser_cli.commands import link_serve + + sl = link_serve._import_servicelink() + await asyncio.gather( + _serve_async(host, port, profile, auth_keys_path, compress), + link_serve._serve(sl, host, rpc_port, profile, rpc_token), + ) + 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 diff --git a/servicelink b/servicelink new file mode 160000 index 0000000..946d79c --- /dev/null +++ b/servicelink @@ -0,0 +1 @@ +Subproject commit 946d79c95da8762cfab8f2dbcaf10391e0cfbc05