feat: harden remote serve and reuse connections
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 56s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 59s
Testing / test (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 33s
Package Extension / package-extension (push) Successful in 36s
- Gate TCP serve commands with safe-by-default policies, per-key allow tokens, per-key rate limiting, and audit labels. - Reuse authenticated encrypted remote sessions and parallelize/caches multi-browser fanout to reduce repeated handshake roundtrips. - Increase paged native-host batch size with extension-side byte budgeting to speed large tab listings safely. - Point install output at public Chrome Web Store / Firefox AMO listings by default, with --dev preserving unpacked workflows. - Share search-engine metadata between CLI and SDK and bump the package/extension version to 0.16.0. - Cover the new security, pooling, paging, install, and fanout behavior with expanded Python and extension tests.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""Per-process pool of authenticated remote connections for reuse.
|
||||
|
||||
A ``browser-cli serve`` connection stays open after its first (encrypted)
|
||||
command, so the client can send further commands over it without re-running the
|
||||
TCP/TLS/challenge/auth handshake (~hundreds of ms each). Only encrypted (PQ)
|
||||
sessions are pooled — plaintext/legacy sessions stay one-shot, matching the
|
||||
server, which only loops for encrypted sessions.
|
||||
|
||||
Connections are checked out exclusively (never shared between threads at once),
|
||||
returned on success, and dropped on any I/O error or once older than an idle
|
||||
bound (kept below the server's idle timeout so we don't reuse a connection the
|
||||
server has already closed).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from browser_cli.constants import REMOTE_SESSION_IDLE_TIMEOUT
|
||||
from browser_cli.framing import frame
|
||||
|
||||
# Retire a pooled connection a few seconds before the server would, so we never
|
||||
# hand back one the server has just timed out and closed.
|
||||
_MAX_IDLE_SECONDS = max(5, REMOTE_SESSION_IDLE_TIMEOUT - 5)
|
||||
_MAX_PER_ENDPOINT = 8
|
||||
|
||||
class PooledConnection:
|
||||
__slots__ = ("sock", "secret", "last_used")
|
||||
|
||||
def __init__(self, sock: socket.socket, secret: bytes) -> None:
|
||||
self.sock = sock
|
||||
self.secret = secret
|
||||
self.last_used = time.monotonic()
|
||||
|
||||
_POOL: dict[str, list[PooledConnection]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
def _close(sock: socket.socket) -> None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def checkout(endpoint: str) -> PooledConnection | None:
|
||||
"""Take an idle authenticated connection for *endpoint*, or None."""
|
||||
now = time.monotonic()
|
||||
with _LOCK:
|
||||
conns = _POOL.get(endpoint)
|
||||
while conns:
|
||||
conn = conns.pop()
|
||||
if now - conn.last_used <= _MAX_IDLE_SECONDS:
|
||||
return conn
|
||||
_close(conn.sock) # too old — assume the server has dropped it
|
||||
return None
|
||||
|
||||
def checkin(endpoint: str, conn: PooledConnection) -> None:
|
||||
"""Return a still-healthy connection to the pool for reuse."""
|
||||
conn.last_used = time.monotonic()
|
||||
with _LOCK:
|
||||
bucket = _POOL.setdefault(endpoint, [])
|
||||
if len(bucket) >= _MAX_PER_ENDPOINT:
|
||||
_close(conn.sock)
|
||||
return
|
||||
bucket.append(conn)
|
||||
|
||||
def discard(conn: PooledConnection) -> None:
|
||||
"""Drop a connection that errored or is no longer usable."""
|
||||
_close(conn.sock)
|
||||
|
||||
def close_all() -> None:
|
||||
"""Close every pooled connection (process exit / test isolation)."""
|
||||
with _LOCK:
|
||||
for bucket in _POOL.values():
|
||||
for conn in bucket:
|
||||
_close(conn.sock)
|
||||
_POOL.clear()
|
||||
|
||||
def session_inner_message(msg: dict) -> dict:
|
||||
"""Strip auth/transport fields, leaving the command for an established session."""
|
||||
keep = {"id", "command", "args", "user_agent", "accept_encoding", "_route", "_suppress_pq_warning"}
|
||||
return {k: v for k, v in msg.items() if k in keep}
|
||||
|
||||
def send_over(conn: PooledConnection, msg: dict) -> bytes | None:
|
||||
"""Send one command over an existing encrypted session. Raises on I/O error."""
|
||||
from browser_cli.auth import pq_encrypt
|
||||
from browser_cli.remote.socket import recv_all
|
||||
from browser_cli.remote.transport import _decode_pq_response
|
||||
|
||||
inner = json.dumps(session_inner_message(msg)).encode("utf-8")
|
||||
envelope = json.dumps({"encrypted": pq_encrypt(conn.secret, "request", inner)}).encode("utf-8")
|
||||
conn.sock.sendall(frame(envelope))
|
||||
response = recv_all(conn.sock)
|
||||
if not response:
|
||||
# EOF — an older server (no session loop) closed after one command. Treat as
|
||||
# a transport failure so the caller re-handshakes; never as an app error,
|
||||
# which could double-execute a non-idempotent command on retry.
|
||||
raise EOFError("remote closed the pooled connection")
|
||||
return _decode_pq_response(response, conn.secret)
|
||||
|
||||
atexit.register(close_all)
|
||||
@@ -25,8 +25,8 @@ def split_endpoint(endpoint: str) -> tuple[str, int]:
|
||||
host, _, port_str = connect_ep.rpartition(":")
|
||||
return host, int(port_str)
|
||||
|
||||
@contextmanager
|
||||
def open_socket(endpoint: str):
|
||||
def connect_socket(endpoint: str) -> socket.socket:
|
||||
"""Open and (on :443) TLS-wrap a socket. Caller owns closing it."""
|
||||
host, port = split_endpoint(endpoint)
|
||||
raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
raw_sock.settimeout(30)
|
||||
@@ -40,6 +40,11 @@ def open_socket(endpoint: str):
|
||||
except Exception:
|
||||
raw_sock.close()
|
||||
raise
|
||||
return sock
|
||||
|
||||
@contextmanager
|
||||
def open_socket(endpoint: str):
|
||||
sock = connect_socket(endpoint)
|
||||
with sock:
|
||||
yield sock
|
||||
|
||||
|
||||
@@ -20,24 +20,50 @@ from browser_cli.remote.auth import (
|
||||
from browser_cli.remote.socket import (
|
||||
async_recv_all as _async_recv_all,
|
||||
async_recv_exact_bytes as _async_recv_exact,
|
||||
connect_socket as _connect_socket,
|
||||
open_async_connection as _open_async_connection,
|
||||
open_socket as _open_socket,
|
||||
recv_all as _recv_all,
|
||||
recv_exact_bytes as _recv_exact,
|
||||
split_endpoint as _split_endpoint,
|
||||
)
|
||||
from browser_cli.remote import pool as _pool
|
||||
|
||||
def _send_remote(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
# Reuse an already-authenticated connection when one is idle for this endpoint.
|
||||
conn = _pool.checkout(endpoint)
|
||||
if conn is not None:
|
||||
try:
|
||||
response = _pool.send_over(conn, msg)
|
||||
_pool.checkin(endpoint, conn)
|
||||
return response
|
||||
except (OSError, ConnectionError, ValueError, EOFError):
|
||||
_pool.discard(conn) # stale/closed — fall through to a fresh handshake
|
||||
|
||||
return _send_remote_handshake(endpoint, msg, private_key, warn_no_pq=warn_no_pq)
|
||||
|
||||
def _send_remote_handshake(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
warn = _should_warn_no_pq(msg) if warn_no_pq is None else warn_no_pq
|
||||
|
||||
def build_auth(sync_msg: dict, challenge: dict | None, nonce_hex: str | None, key):
|
||||
from browser_cli.auth import pq_kex_client_encapsulate
|
||||
return _build_auth_message(sync_msg, challenge, nonce_hex, key, pq_kex_client_encapsulate, warn_no_pq=warn)
|
||||
|
||||
with _open_socket(endpoint) as sock:
|
||||
sock = _connect_socket(endpoint)
|
||||
try:
|
||||
payload_msg, pq_shared_secret = _with_challenge(_recv_all(sock), msg, private_key, build_auth)
|
||||
sock.sendall(frame(json.dumps(payload_msg).encode("utf-8")))
|
||||
return _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
response = _decode_pq_response(_recv_all(sock), pq_shared_secret)
|
||||
except BaseException:
|
||||
_pool._close(sock)
|
||||
raise
|
||||
# Only encrypted sessions are reusable — the server keeps those open, and a
|
||||
# fresh AEAD nonce per frame keeps reuse of the shared secret safe.
|
||||
if pq_shared_secret is not None:
|
||||
_pool.checkin(endpoint, _pool.PooledConnection(sock, pq_shared_secret))
|
||||
else:
|
||||
_pool._close(sock)
|
||||
return response
|
||||
|
||||
async def _send_remote_async(endpoint: str, msg: dict, private_key=None, *, warn_no_pq: bool | None = None) -> bytes | None:
|
||||
reader, writer = await _open_async_connection(endpoint)
|
||||
|
||||
Reference in New Issue
Block a user