965793dd8c
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.
164 lines
6.0 KiB
Python
164 lines
6.0 KiB
Python
"""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
|