feat: expose browser control via ServiceLink RPC
Testing / test (push) Successful in 1m30s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m23s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m12s
Build & Publish Package / publish (push) Successful in 1m2s
Package Extension / package-extension (push) Successful in 1m12s
Testing / test (push) Successful in 1m30s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m23s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m12s
Build & Publish Package / publish (push) Successful in 1m2s
Package Extension / package-extension (push) Successful in 1m12s
- Add the ServiceLink submodule and register it in .gitmodules. - Add a link-serve command that exposes selected browser-cli commands over an HTTP /rpc endpoint. - Require bearer-token authentication by default, with explicit insecure opt-in for trusted loopback/local deployments. - Allow the existing serve daemon to run the ServiceLink RPC endpoint alongside the native TCP remote server.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "servicelink"]
|
||||||
|
path = servicelink
|
||||||
|
url = git@git.yiprawr.dev:submodules/servicelink.git
|
||||||
@@ -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
|
||||||
@@ -45,9 +45,32 @@ __all__ = [
|
|||||||
default=False,
|
default=False,
|
||||||
help="Disable response compression / msgpack even for clients that support it.",
|
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
|
@click.pass_context
|
||||||
def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
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."""
|
"""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
|
profile = ctx.obj.get("browser") if ctx.obj else None
|
||||||
compress = not no_compress
|
compress = not no_compress
|
||||||
|
|
||||||
@@ -61,9 +84,23 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
|||||||
if auth_keys_path is False:
|
if auth_keys_path is False:
|
||||||
sys.exit(1)
|
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)
|
_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:
|
try:
|
||||||
|
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))
|
asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
|
||||||
@@ -71,6 +108,16 @@ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
console.print("[yellow]Stopped.[/yellow]")
|
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:
|
def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
|
||||||
if auth_keys_file:
|
if auth_keys_file:
|
||||||
from browser_cli.auth import load_authorized_keys
|
from browser_cli.auth import load_authorized_keys
|
||||||
|
|||||||
Submodule
+1
Submodule servicelink added at 946d79c95d
Reference in New Issue
Block a user