diff --git a/dimos/cli/commands/lifecycle.py b/dimos/cli/commands/lifecycle.py index c44189f82d..63abf44a09 100644 --- a/dimos/cli/commands/lifecycle.py +++ b/dimos/cli/commands/lifecycle.py @@ -96,6 +96,11 @@ def run( help="Bridge this robot to a relay started elsewhere (its HTTP URL, e.g. " "http://localhost:7780)", ), + relay_ca: str | None = typer.Option( + None, + "--relay-ca", + help="PEM CA bundle that signed the relay's certificate (mkcert, a private CA)", + ), show_help: bool = typer.Option(False, "--help"), ) -> None: """Start a robot blueprint""" @@ -138,7 +143,11 @@ def run( # These flags are accepted on `run` itself, not just as global options. run_overrides = { name: value - for name, value in (("local_relay", local_relay), ("relay_url", relay_url)) + for name, value in ( + ("local_relay", local_relay), + ("relay_url", relay_url), + ("relay_ca", relay_ca), + ) if value is not None } if run_overrides: diff --git a/dimos/cli/test_dimos.py b/dimos/cli/test_dimos.py index 4de6aba020..095c2d4aea 100644 --- a/dimos/cli/test_dimos.py +++ b/dimos/cli/test_dimos.py @@ -364,6 +364,24 @@ def compose(blueprint: Any) -> Any: assert stubbed_run["parsed_config"].global_config["local_relay"] is True +def test_run_relay_ca_flag_is_applied_before_composition( + stubbed_run: dict[str, Any], + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: list[str | None] = [] + + def compose(blueprint: Any) -> Any: + observed.append(global_config.relay_ca) + return blueprint + + monkeypatch.setattr(lifecycle, "_with_relay_bridge", compose) + + result = CliRunner().invoke(main, ["run", "alpha", "--relay-ca", "/ca.pem"]) + + assert result.exit_code == 0, result.output + assert observed == ["/ca.pem"] + + def test_run_rejects_ambiguous_short_config_flag(stubbed_run: dict[str, Any]) -> None: result = CliRunner().invoke(main, ["run", "alpha", "beta", "--map-file", "office"]) diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index 32247b521f..f71e9f68e5 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -129,6 +129,10 @@ class GlobalConfig(BaseSettings): relay_url: str | None = None """HTTP URL of a relay started elsewhere (e.g. http://localhost:7780); the bridge discovers its WebTransport endpoint through /api/info.""" + relay_ca: str | None = None + """PEM CA bundle that signed the relay_url relay's certificate (mkcert, a + private CA); replaces the default trust stores. Unset for a relay with a + public certificate.""" dimos_cloud_url: str = "https://api.dimensional.org" dimos_api_key: str | None = None dimos_upload_codec: str = "lz4" diff --git a/dimos/web/relay_bridge/_wt_session.py b/dimos/web/relay_bridge/_wt_session.py index 0437e07804..18804413c5 100644 --- a/dimos/web/relay_bridge/_wt_session.py +++ b/dimos/web/relay_bridge/_wt_session.py @@ -127,7 +127,7 @@ async def get(self) -> DataFrame: return frame -def make_quic_configuration(insecure: bool) -> QuicConfiguration: +def make_quic_configuration(insecure: bool, cafile: str | None = None) -> QuicConfiguration: config = QuicConfiguration( is_client=True, alpn_protocols=H3_ALPN, @@ -137,6 +137,10 @@ def make_quic_configuration(insecure: bool) -> QuicConfiguration: ) if insecure: config.verify_mode = ssl.CERT_NONE + if cafile is not None: + # Replaces certifi's bundle (aioquic loads that only when no location + # is given): the relay's private CA, e.g. mkcert's root. + config.load_verify_locations(cafile=cafile) return config diff --git a/dimos/web/relay_bridge/locate.py b/dimos/web/relay_bridge/locate.py index c5b71a2e0d..9046d43ab9 100644 --- a/dimos/web/relay_bridge/locate.py +++ b/dimos/web/relay_bridge/locate.py @@ -87,25 +87,29 @@ def relay_run_cmd( cockpit_dir: Path | None = None, sdk_dir: Path | None = None, serve_dir: Path | None = None, + cert: Path | None = None, + key: Path | None = None, ) -> list[str]: """Build the argv that runs the relay with the pinned config and least permissions.""" # Canonical paths: the relay realpath-checks served files against its # roots, so a symlinked --allow-read scope (macOS /tmp -> /private/tmp) # would deny every read. web_dir = web_dir.resolve() - dirs = [ + paths = [ (flag, path.resolve()) for flag, path in ( ("--cockpit-dir", cockpit_dir), ("--sdk-dir", sdk_dir), ("--serve-dir", serve_dir), + ("--cert", cert), + ("--key", key), ) if path is not None ] # --node-modules-dir=none: the workspace root deno.json says "auto" (for # the cockpit build tooling), which would make this run materialize # node_modules next to the config -- inside site-packages under a wheel. - allow_read = ",".join([str(web_dir), *(str(path) for _, path in dirs)]) + allow_read = ",".join([str(web_dir), *(str(path) for _, path in paths)]) cmd = [ deno, "run", @@ -117,7 +121,7 @@ def relay_run_cmd( str(web_dir / "deno.json"), str(web_dir / "relay" / "main.ts"), ] - for flag, path in dirs: + for flag, path in paths: cmd += [flag, str(path)] return [*cmd, *args] diff --git a/dimos/web/relay_bridge/relay_bridge_module.py b/dimos/web/relay_bridge/relay_bridge_module.py index e2b107b21b..3e94a086a8 100644 --- a/dimos/web/relay_bridge/relay_bridge_module.py +++ b/dimos/web/relay_bridge/relay_bridge_module.py @@ -249,6 +249,10 @@ class RelayBridgeConfig(ModuleConfig): """HTTP URL of a relay started elsewhere (e.g. http://localhost:7780); its WebTransport endpoint is discovered through /api/info on every connect. None: spawn a local one.""" + relay_ca: str | None = None + """PEM CA bundle that signed the relay_url relay's certificate (mkcert, a + private CA). It replaces the default trust stores for both the /api/info + fetch and QUIC, so leave it unset for a relay with a public certificate.""" local_port: int = 7780 """HTTP port of the spawned local relay; 0 picks an ephemeral port (tests).""" open_browser: bool = True @@ -553,6 +557,7 @@ def __init__(self, **kwargs: Any) -> None: self._build_cancel: threading.Event | None = None self._session: _Session | None = None self._url: str | None = None + self._ca: str | None = None # Last /api/info discovery (for logs and tests). self._relay_info: RelayInfo | None = None # Resolved config.serve_dir, kept for relay-child respawns. @@ -669,6 +674,9 @@ async def main(self) -> AsyncIterator[None]: ) self._manifest = manifest.model_dump() self._url = self.config.relay_url or self.config.g.relay_url + self._ca = ( + (self.config.relay_ca or self.config.g.relay_ca) if self._url is not None else None + ) if self._url is not None and self.config.serve_dir is not None: raise RuntimeError( "serve_dir requires the spawned local relay (--local-relay); " @@ -967,10 +975,10 @@ async def _connect_and_hello(self) -> _Session: assert self._url is not None and self._robot_info is not None and self._manifest is not None # Discovery on every connect: a restarted relay has a new QUIC port # and certificate behind the same HTTP URL. - info = await fetch_relay_info(self._url) + info = await fetch_relay_info(self._url, cafile=self._ca) self._relay_info = info client = await RelayClient.connect( - info.wt_url, "robot", insecure=info.cert_hash is not None + info.wt_url, "robot", insecure=info.cert_hash is not None, cafile=self._ca ) try: await client.hello(robot=self._robot_info, manifest=self._manifest) diff --git a/dimos/web/relay_bridge/relay_process.py b/dimos/web/relay_bridge/relay_process.py index 7cb9fba944..533e5a6e68 100644 --- a/dimos/web/relay_bridge/relay_process.py +++ b/dimos/web/relay_bridge/relay_process.py @@ -271,7 +271,8 @@ def _swap_dist(package: Path, new_dist: Path) -> None: class RelayReadyInfo: http_port: int wt_url: str - cert_hash: str + # None when the relay serves a real (--cert/--key) certificate. + cert_hash: str | None v: int # True when the relay serves a built Cockpit at /; set by RelayProcess. cockpit: bool = False @@ -280,7 +281,8 @@ class RelayReadyInfo: def open_url(self) -> str: """What a browser should open (without a cockpit dist the relay answers it with a 404 build hint).""" - return f"http://127.0.0.1:{self.http_port}/" + scheme = "http" if self.cert_hash is not None else "https" + return f"{scheme}://127.0.0.1:{self.http_port}/" class RelayProcess: @@ -295,6 +297,8 @@ def __init__( cockpit_dir: Path | None = None, sdk_dir: Path | None = None, serve_dir: Path | None = None, + cert: Path | None = None, + key: Path | None = None, timeout: float = 20.0, ) -> None: self._port = port @@ -303,6 +307,8 @@ def __init__( self._cockpit_dir = cockpit_dir self._sdk_dir = sdk_dir self._serve_dir = serve_dir + self._cert = cert + self._key = key self._timeout = timeout self._process: subprocess.Popen[str] | None = None self._threads: list[threading.Thread] = [] @@ -328,6 +334,8 @@ def start(self) -> RelayReadyInfo: cockpit_dir=cockpit_dir, sdk_dir=sdk_dir, serve_dir=self._serve_dir, + cert=self._cert, + key=self._key, ) logger.info(f"starting relay: {' '.join(cmd)}") env = os.environ | {"NO_COLOR": "1"} @@ -417,7 +425,7 @@ def _read_stdout(self, stream: IO[str]) -> None: RelayReadyInfo( http_port=int(data["httpPort"]), wt_url=str(data["wtUrl"]), - cert_hash=str(data["certHash"]), + cert_hash=data.get("certHash"), v=int(data["v"]), ) ) diff --git a/dimos/web/relay_bridge/test_relay_bridge_e2e.py b/dimos/web/relay_bridge/test_relay_bridge_e2e.py index 123a48f0b5..d14237a4f8 100644 --- a/dimos/web/relay_bridge/test_relay_bridge_e2e.py +++ b/dimos/web/relay_bridge/test_relay_bridge_e2e.py @@ -28,7 +28,10 @@ import asyncio from collections.abc import Iterator +from datetime import datetime, timedelta, timezone +from ipaddress import IPv4Address import json +from pathlib import Path import subprocess import sys import threading @@ -36,6 +39,10 @@ from typing import Any import zlib +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID import numpy as np import pytest @@ -605,10 +612,11 @@ async def held() -> bool: # --relay-url: a relay started by hand. -def _external_bridge(relay_url: str) -> RelayBridgeModule: +def _external_bridge(relay_url: str, relay_ca: str | None = None) -> RelayBridgeModule: """A bridge attached to a relay it did not spawn (the --relay-url path).""" return RelayBridgeModule( relay_url=relay_url, + relay_ca=relay_ca, open_browser=False, web_build=False, robot_id=ROBOT_ID, @@ -652,6 +660,70 @@ async def flow() -> None: restarted.stop() +def _self_signed_cert(directory: Path) -> tuple[Path, Path]: + """A one-day P-256 certificate for 127.0.0.1, its own trust anchor, as + PEM files (certificate, PKCS#8 key).""" + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "dimos relay e2e")]) + now = datetime.now(timezone.utc) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(hours=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName([x509.IPAddress(IPv4Address("127.0.0.1"))]), + critical=False, + ) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False) + .sign(key, hashes.SHA256()) + ) + cert_path = directory / "relay.pem" + key_path = directory / "relay-key.pem" + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + return cert_path, key_path + + +def test_external_relay_with_real_certificate(tmp_path: Path) -> None: + # A relay given --cert/--key advertises no hash, so both client legs + # verify the certificate: against relay_ca (mkcert, a private CA) or the + # default trust stores. Never insecure=True. + cert, key = _self_signed_cert(tmp_path) + with RelayProcess(cert=cert, key=key) as ready: + assert ready.cert_hash is None + assert ready.open_url == f"https://127.0.0.1:{ready.http_port}/" + wt_url = f"https://127.0.0.1:{ready.http_port}" + + async def unverified() -> None: + # The default stores do not know this certificate. + with pytest.raises(OSError, match="CERTIFICATE_VERIFY_FAILED"): + await fetch_relay_info(ready.open_url) + with pytest.raises(ConnectionError): + await RelayClient.connect(wt_url, "robot", insecure=False) + + asyncio.run(unverified()) + + bridge = _external_bridge(ready.open_url, relay_ca=str(cert)) + try: + bridge.start() # returns only after hello/welcome: registered + info = bridge._relay_info + assert info is not None and info.cert_hash is None and info.wt_url == wt_url + assert _session_live(bridge) + finally: + stop_module(bridge) + + def test_start_waits_out_robot_id_conflict_on_relay(monkeypatch: pytest.MonkeyPatch) -> None: # The same robot id twice on one relay: the second bridge's start waits # until the first lets go (a killed predecessor expires at the relay's diff --git a/dimos/web/relay_bridge/test_relay_bridge_module.py b/dimos/web/relay_bridge/test_relay_bridge_module.py index edcba4766d..845f6f8ee2 100644 --- a/dimos/web/relay_bridge/test_relay_bridge_module.py +++ b/dimos/web/relay_bridge/test_relay_bridge_module.py @@ -952,6 +952,60 @@ async def remote_info(base_url: str, **kwargs: Any) -> RelayInfo: stop_module(module) +def test_relay_ca_reaches_discovery_and_connect(monkeypatch) -> None: + ca = "/ca.pem" + seen: list[tuple[Any, ...]] = [] + client = FakeClient() + + async def fake_fetch(base_url: str, **kwargs: Any) -> RelayInfo: + seen.append(("fetch", kwargs.get("cafile"))) + # A relay with a real certificate advertises no hash. + return RelayInfo(wt_url="https://127.0.0.1:1", cert_hash=None, v=PROTOCOL_VERSION) + + async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: + seen.append(("connect", kwargs.get("cafile"), kwargs.get("insecure"))) + return client + + monkeypatch.setattr(relay_bridge_module, "fetch_relay_info", fake_fetch) + monkeypatch.setattr(relay_bridge_module.RelayClient, "connect", fake_connect) + module = RelayBridgeModule( + relay_url="http://127.0.0.1:7780", relay_ca=ca, open_browser=False, robot_id="unit-bot" + ) + try: + module.start() + assert seen == [("fetch", ca), ("connect", ca, False)] + finally: + stop_module(module) + + +def test_local_relay_ignores_relay_ca(monkeypatch) -> None: + seen: list[tuple[str | None, bool | None]] = [] + client = FakeClient() + + async def fake_connect(url: str, role: str, **kwargs: Any) -> FakeClient: + seen.append((kwargs.get("cafile"), kwargs.get("insecure"))) + return client + + monkeypatch.setattr(relay_bridge_module, "_probe_local_port", lambda _: None) + patch_relay(monkeypatch, fake_connect) + monkeypatch.setattr( + RelayBridgeModule, + "_spawn_relay", + lambda self, open_browser, serve_dir: "http://127.0.0.1:7780", + ) + module = RelayBridgeModule( + relay_ca="/missing/ca.pem", + open_browser=False, + web_build=False, + robot_id="unit-bot", + ) + try: + module.start() + assert seen == [(None, True)] + finally: + stop_module(module) + + def test_start_gives_up_on_robot_id_conflict_after_deadline(monkeypatch) -> None: monkeypatch.setattr(relay_bridge_module, "_RECONNECT_PAUSE_S", 0.01) monkeypatch.setattr(relay_bridge_module, "_CONFLICT_RETRY_S", 0.05) diff --git a/dimos/web/relay_bridge/test_relay_process.py b/dimos/web/relay_bridge/test_relay_process.py index bc6c86d652..2f9019bce4 100644 --- a/dimos/web/relay_bridge/test_relay_process.py +++ b/dimos/web/relay_bridge/test_relay_process.py @@ -82,6 +82,17 @@ def test_relay_run_cmd_dir_flags() -> None: assert cmd[cmd.index("--sdk-dir") + 1] == "/sdk/dist" assert cmd[cmd.index("--serve-dir") + 1] == "/my/ui" + # The relay reads the PEM files itself, so they join the read scope. + cmd = relay_run_cmd( + "deno", + Path("/web"), + cert=Path("/etc/relay/fullchain.pem"), + key=Path("/etc/relay/privkey.pem"), + ) + assert "--allow-read=/web,/etc/relay/fullchain.pem,/etc/relay/privkey.pem" in cmd + assert cmd[cmd.index("--cert") + 1] == "/etc/relay/fullchain.pem" + assert cmd[cmd.index("--key") + 1] == "/etc/relay/privkey.pem" + def test_relay_run_cmd_resolves_symlinked_dirs(tmp_path: Path) -> None: # The relay realpath-checks served files, so --allow-read must be granted @@ -94,6 +105,16 @@ def test_relay_run_cmd_resolves_symlinked_dirs(tmp_path: Path) -> None: assert f"--allow-read={real.resolve()}" in cmd +def test_relay_process_reports_unpaired_tls_flag_before_reading_pem(tmp_path: Path) -> None: + process = RelayProcess(cert=tmp_path / "missing.pem", timeout=2.0) + + try: + with pytest.raises(RuntimeError, match="--cert and --key must be given together"): + process.start() + finally: + process.stop() + + def test_relay_serves_cockpit_dist(tmp_path: Path) -> None: dist = _make_fake_dist(tmp_path) with RelayProcess(port=0, cockpit_dir=dist) as info: diff --git a/dimos/web/relay_bridge/test_wt_client.py b/dimos/web/relay_bridge/test_wt_client.py index 5f5fe4b31e..421265fdeb 100644 --- a/dimos/web/relay_bridge/test_wt_client.py +++ b/dimos/web/relay_bridge/test_wt_client.py @@ -505,6 +505,28 @@ async def __aexit__(self, *args: Any) -> None: await RelayClient.connect("https://127.0.0.1:1", "robot", timeout=0.01) +async def test_connect_defaults_port_to_443_and_loads_relay_ca( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dials: list[tuple[str, int, str | None]] = [] + + class Refused: + async def __aenter__(self) -> None: + raise ConnectionRefusedError + + async def __aexit__(self, *args: Any) -> None: + pass + + def fake_connect(host: str, port: int, *, configuration: Any, **kwargs: Any) -> Refused: + dials.append((host, port, configuration.cafile)) + return Refused() + + monkeypatch.setattr(wt_client, "aioquic_connect", fake_connect) + with pytest.raises(ConnectionRefusedError): + await RelayClient.connect("https://relay.example", "robot", cafile="/ca.pem") + assert dials == [("relay.example", 443, "/ca.pem")] + + # /api/info discovery against an in-process HTTP server. diff --git a/dimos/web/relay_bridge/wt_client.py b/dimos/web/relay_bridge/wt_client.py index 199c0b754c..b3da8b75ce 100644 --- a/dimos/web/relay_bridge/wt_client.py +++ b/dimos/web/relay_bridge/wt_client.py @@ -23,6 +23,7 @@ from dataclasses import dataclass import itertools import json +import ssl import time from types import TracebackType from typing import Any, cast @@ -90,8 +91,9 @@ def resolve_info_url(base_url: str) -> str: return urljoin(base_url if base_url.endswith("/") else base_url + "/", "api/info") -def _get_json(url: str, timeout: float) -> Any: - with urllib.request.urlopen(url, timeout=timeout) as response: +def _get_json(url: str, timeout: float, cafile: str | None) -> Any: + context = ssl.create_default_context(cafile=cafile) if cafile is not None else None + with urllib.request.urlopen(url, timeout=timeout, context=context) as response: body = response.read() try: return json.loads(body) @@ -99,16 +101,20 @@ def _get_json(url: str, timeout: float) -> Any: return None # reported as a shape problem by the caller -async def fetch_relay_info(base_url: str, *, timeout: float = 5.0) -> RelayInfo: +async def fetch_relay_info( + base_url: str, *, timeout: float = 5.0, cafile: str | None = None +) -> RelayInfo: """Discover the relay's WebTransport endpoint through GET /api/info (the mirror of the SDK's fetchRelayInfo). A relay restart means a new QUIC port and certificate behind the same HTTP URL, so callers fetch on every - connect. Raises OSError when the relay is unreachable or answers an HTTP - error (transient), ProtocolError for a non-relay answer or a protocol - version mismatch. + connect. For an https base, `cafile` (a PEM CA bundle: mkcert, a private + CA) replaces the system trust store. Raises OSError when the relay is + unreachable, answers an HTTP error, or fails certificate verification + (transient), ProtocolError for a non-relay answer or a protocol version + mismatch. """ url = resolve_info_url(base_url) - data = await asyncio.to_thread(_get_json, url, timeout) + data = await asyncio.to_thread(_get_json, url, timeout, cafile) if not isinstance(data, dict): raise ProtocolError(f"{url} returned an unexpected shape") wt_url, cert_hash, v = data.get("wtUrl"), data.get("certHash"), data.get("v") @@ -149,20 +155,24 @@ async def connect( *, insecure: bool | None = None, timeout: float = 10.0, + cafile: str | None = None, ) -> RelayClient: - """Connect to `url` (the relay's wtUrl, e.g. https://127.0.0.1:4433). + """Connect to `url` (the relay's wtUrl, e.g. https://127.0.0.1:4433; + the port defaults to 443). `insecure` skips certificate verification and defaults to True for loopback hosts only (the local relay uses an ephemeral self-signed cert). Passing insecure=True for a non-loopback host is refused. - `timeout` bounds the QUIC handshake and the WebTransport session - setup separately. + Otherwise aioquic checks the certificate against the URL host (DNS or + IP SANs) and its chain against `cafile` (a PEM CA bundle: mkcert, a + private CA), or certifi's bundle without one. `timeout` bounds the + QUIC handshake and the WebTransport session setup separately. """ parsed = urlparse(url) host = parsed.hostname - port = parsed.port - if parsed.scheme != "https" or host is None or port is None: - raise ValueError(f"relay URL must look like https://host:port, got {url!r}") + if parsed.scheme != "https" or host is None: + raise ValueError(f"relay URL must look like https://host[:port], got {url!r}") + port = parsed.port if parsed.port is not None else 443 is_loopback = host in _LOOPBACK_HOSTS if insecure is None: insecure = is_loopback @@ -185,7 +195,7 @@ async def connect( ctx = aioquic_connect( host, port, - configuration=make_quic_configuration(insecure), + configuration=make_quic_configuration(insecure, cafile), create_protocol=SessionProtocol, ) # Bounded: aioquic gives up on an endpoint nobody listens on only at diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index b0bcbc91aa..5ba546d3d8 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -134,6 +134,7 @@ Config( dimsim_headless=True, local_relay=False, relay_url=None, + relay_ca=None, dimos_cloud_url='https://api.dimensional.org', dimos_api_key=None ), diff --git a/docs/usage/web_sdk.md b/docs/usage/web_sdk.md index 35944f3c52..436ba72768 100644 --- a/docs/usage/web_sdk.md +++ b/docs/usage/web_sdk.md @@ -47,7 +47,7 @@ The bridge discovers the WebTransport endpoint (an ephemeral QUIC port and certi - `--serve-dir` belongs to the relay here (`deno task dev --serve-dir DIR`). `dimos run --serve-dir` is rejected together with `--relay-url`. - A second robot on the same relay needs its own `--robot-id`. A synthetic one: `uv run python -m dimos.web.relay_bridge.demo_smoke --url http://localhost:7780`. - With several robots on the relay the cockpit lists them; pick one to watch it. "switch robot" in the status bar reopens the list. -- Another machine cannot use this relay yet: its certificate is ephemeral and self-signed, which only loopback may trust. +- Another machine requires a relay started with `--cert PEM --key PEM`; pass `--relay-ca` to the robot for a private CA. Non-loopback binding still requires `--unsafe-non-loopback` until relay auth lands. ## Your first page diff --git a/web/README.md b/web/README.md index 78d18500a1..bb37747da6 100644 --- a/web/README.md +++ b/web/README.md @@ -11,7 +11,8 @@ and `dimos --local-relay` auto-downloads Deno via `ensure_deno()`. ```bash deno task dev # relay on http://127.0.0.1:7780 (add --cockpit-dir cockpit/dist for the UI, - # --sdk-dir sdk/dist for /sdk.js, --serve-dir DIR for a custom page at /) + # --sdk-dir sdk/dist for /sdk.js, --serve-dir DIR for a custom page at /, + # --cert PEM --key PEM for real TLS) deno task test # relay + shared tests (unit + loopback e2e) deno task check # type-check relay + shared; deno fmt + deno lint for style (all of web/) ``` @@ -97,6 +98,10 @@ relay's HTTP URL (`http://127.0.0.1:7780`): the bridge fetches `/api/info` on ev like the SDK, so a relay restart (new QUIC port, new ephemeral certificate) is transparent to it. `docs/usage/web_sdk.md` has the recipe. +With `--cert PEM --key PEM`, HTTPS and QUIC share `--port` and clients verify the certificate +normally. A private CA reaches the robot as `--relay-ca`; non-loopback binding still needs +`--unsafe-non-loopback` until relay auth lands. + ## Cockpit ```bash diff --git a/web/relay/main.ts b/web/relay/main.ts index 21806bf686..466838d2ce 100644 --- a/web/relay/main.ts +++ b/web/relay/main.ts @@ -3,20 +3,25 @@ // everything else logs to stderr-adjacent console lines prefixed [relay]. import { parseArgs } from "@std/cli"; import { PROTOCOL_VERSION } from "@dimos/shared"; -import { startRelay } from "./server.ts"; +import { CERT_KEY_PAIR_ERROR, startRelay } from "./server.ts"; const args = parseArgs(Deno.args, { - string: ["host", "cockpit-dir", "sdk-dir", "serve-dir"], + string: ["host", "cockpit-dir", "sdk-dir", "serve-dir", "cert", "key"], // Non-loopback binds need this explicit acknowledgment: the local relay // trusts every origin that can reach it (see RelayOptions.unsafeNonLoopback). boolean: ["unsafe-non-loopback"], default: { port: 7780, host: "127.0.0.1" }, }); +if ((args.cert === undefined) !== (args.key === undefined)) { + throw new Error(CERT_KEY_PAIR_ERROR); +} const host = args.host as string; -if (host !== "127.0.0.1" && host !== "localhost") { +const tls = args.cert !== undefined; +if (host !== "127.0.0.1" && host !== "localhost" && !tls) { // serverCertificateHashes only works from secure contexts; http:// - // pages are not one. Remote access needs the cloud relay + real TLS (T12). + // pages are not one. A real certificate (--cert/--key) makes https:// + // one, and the browser then verifies it instead of pinning a hash. console.log( `[relay] warning: binding ${host} - browsers will not treat http://${host} as a ` + "secure context, so WebTransport will be unavailable there; this is only useful " + @@ -31,6 +36,8 @@ const relay = await startRelay({ sdkDir: args["sdk-dir"], serveDir: args["serve-dir"], unsafeNonLoopback: args["unsafe-non-loopback"], + cert: args.cert === undefined ? undefined : await Deno.readTextFile(args.cert), + key: args.key === undefined ? undefined : await Deno.readTextFile(args.key), }); console.log(JSON.stringify({ @@ -41,15 +48,16 @@ console.log(JSON.stringify({ v: PROTOCOL_VERSION, })); const pageHost = host === "0.0.0.0" ? "127.0.0.1" : host; +const pageBase = `${tls ? "https" : "http"}://${pageHost}:${relay.httpPort}/`; if (args["serve-dir"] !== undefined) { - console.log(`[relay] serving ${args["serve-dir"]}: http://${pageHost}:${relay.httpPort}/`); + console.log(`[relay] serving ${args["serve-dir"]}: ${pageBase}`); } else if (args["cockpit-dir"] !== undefined) { - console.log(`[relay] cockpit: http://${pageHost}:${relay.httpPort}/`); + console.log(`[relay] cockpit: ${pageBase}`); } else { console.log("[relay] no cockpit dist configured; serving /api only"); } if (args["sdk-dir"] !== undefined) { - console.log(`[relay] sdk: http://${pageHost}:${relay.httpPort}/sdk.js`); + console.log(`[relay] sdk: ${pageBase}sdk.js`); } for (const signal of ["SIGINT", "SIGTERM"] as const) { diff --git a/web/relay/server.ts b/web/relay/server.ts index 7509a60981..df82897bb4 100644 --- a/web/relay/server.ts +++ b/web/relay/server.ts @@ -11,7 +11,10 @@ import { Registry } from "./registry.ts"; import { RobotSession, ViewerSession } from "./session.ts"; export interface RelayOptions { - /** TCP port for the HTTP side. Default 7780; 0 picks an ephemeral port. */ + /** + * TCP port for the HTTP side (and, with cert/key, the QUIC port too). + * Default 7780; 0 picks an ephemeral port. + */ port?: number; /** Bind host for both listeners. The default is the only secure-context-friendly choice. */ host?: string; @@ -24,6 +27,15 @@ export interface RelayOptions { sdkDir?: string; /** User static root served at / instead of the cockpit (--serve-dir). */ serveDir?: string; + /** + * PEM certificate (chain) and private key (--cert/--key), both or neither. + * With them the relay terminates real TLS itself: HTTPS on `port`, QUIC on + * the same port, and no certificate hash advertised (clients verify the + * certificate normally). Without them: an ephemeral self-signed certificate + * pinned by hash, QUIC on an ephemeral port. + */ + cert?: string; + key?: string; /** * Explicit acknowledgment for binding a non-loopback host. This local * relay trusts every origin that can reach it (wildcard CORS on the @@ -40,10 +52,21 @@ export interface RelayHandle { quicPort: number; /** Base WebTransport URL (no path); clients append /robot or /viewer. */ wtUrl: string; - certHash: string; + /** base64 SHA-256 of the ephemeral certificate; absent with cert/key. */ + certHash?: string; shutdown(): Promise; } +export const CERT_KEY_PAIR_ERROR = "--cert and --key must be given together"; + +/** What the listeners serve: the ephemeral certificate or the operator's. */ +interface ServedCert { + certPem: string; + keyPem: string; + /** Hash pinned through /api/info; absent for a real certificate. */ + certHashB64?: string; +} + const MIME: Record = { ".html": "text/html; charset=utf-8", // Browsers enforce a JavaScript MIME type for module scripts, so .js and @@ -162,11 +185,46 @@ export async function startRelay(options: RelayOptions = {}): Promise {}, + cert: options.cert, + key: options.key, + }, + handleHttp, + ); + const httpPort = (httpServer.addr as Deno.NetAddr).port; + let endpoint: Deno.QuicEndpoint; + try { + endpoint = new Deno.QuicEndpoint({ hostname: host, port: tls ? httpPort : 0 }); + } catch (e) { + await httpServer.shutdown(); + throw new Error( + `QUIC cannot bind UDP port ${httpPort} (with --cert/--key it shares --port): ` + + ((e as Error)?.message ?? e), + ); + } const listener = endpoint.listen({ cert: cert.certPem, key: cert.keyPem, @@ -220,11 +278,12 @@ export async function startRelay(options: RelayOptions = {}): Promise {} }, - handleHttp, - ); - const httpPort = (httpServer.addr as Deno.NetAddr).port; - return { httpPort, quicPort, diff --git a/web/relay/server_test.ts b/web/relay/server_test.ts index 26be18ac1b..f7f21265a1 100644 --- a/web/relay/server_test.ts +++ b/web/relay/server_test.ts @@ -19,6 +19,7 @@ import { type RobotInfo, type RobotManifest, } from "@dimos/shared"; +import { makeEphemeralCert } from "./cert.ts"; import { startRelay } from "./server.ts"; const ROBOT: RobotInfo = { id: "deno-bot", name: "Deno Bot", model: "test" }; @@ -42,7 +43,8 @@ const MANIFEST: RobotManifest = { layout: "color_image", }; -function certOpts(hashB64: string): WebTransportOptions { +function certOpts(hashB64: string | undefined): WebTransportOptions { + if (hashB64 === undefined) throw new Error("the relay advertises no certificate hash"); return { serverCertificateHashes: [{ algorithm: "sha-256", @@ -1226,3 +1228,52 @@ Deno.test("startRelay rejects a bad served dir with a labeled error", async () = "sdkDir does not exist: /no/such/dir", ); }); + +Deno.test("startRelay refuses a certificate without its key (and vice versa)", async () => { + const cert = await makeEphemeralCert(); + await assertRejects( + () => startRelay({ port: 0, cert: cert.certPem }), + Error, + "--cert and --key must be given together", + ); + await assertRejects( + () => startRelay({ port: 0, key: cert.keyPem }), + Error, + "--cert and --key must be given together", + ); +}); + +Deno.test({ + name: "a relay with --cert/--key serves HTTPS and QUIC on one port and advertises no hash", + sanitizeOps: false, + sanitizeResources: false, +}, async () => { + // The ephemeral generator stands in for a CA-issued certificate: the relay + // serves whatever PEM it is given, and the client trusts it as a root. + const cert = await makeEphemeralCert(); + const relay = await startRelay({ port: 0, cert: cert.certPem, key: cert.keyPem }); + // h2 on purpose: over TLS Deno.serve negotiates HTTP/2, where the request + // host comes from :authority rather than a Host header. + const client = Deno.createHttpClient({ caCerts: [cert.certPem], http1: false, http2: true }); + try { + assertEquals(relay.certHash, undefined); + assertEquals(relay.quicPort, relay.httpPort); + const res = await fetch(`https://127.0.0.1:${relay.httpPort}/api/info`, { client }); + const info = await res.json(); + assertEquals(info, { wtUrl: `https://127.0.0.1:${relay.httpPort}`, v: PROTOCOL_VERSION }); + + // The QUIC listener serves the given certificate: pin its locally + // computed hash (the relay never advertised one) and complete a hello. + const viewer = new WebTransport(`${info.wtUrl}/viewer`, certOpts(cert.certHashB64)); + await within(viewer.ready, "viewer connect"); + const control = await within(viewer.createBidirectionalStream(), "control stream"); + const writer = control.writable.getWriter(); + const nextControl = controlQueue(control.readable); + await writer.write(encodeControlFrame({ t: "hello", v: PROTOCOL_VERSION, role: "viewer" })); + assertEquals(await within(nextControl(), "welcome"), { t: "welcome", v: PROTOCOL_VERSION }); + viewer.close(); + } finally { + client.close(); + await relay.shutdown(); + } +}); diff --git a/web/sdk/src/transport.test.ts b/web/sdk/src/transport.test.ts index 75b089a087..5de7d219b1 100644 --- a/web/sdk/src/transport.test.ts +++ b/web/sdk/src/transport.test.ts @@ -4,6 +4,7 @@ import { backoffDelayMs, CONNECT_TIMEOUT_MS, connectWebTransport, + fetchRelayInfo, ReconnectingTransport, type RelayInfo, resolveInfoUrl, @@ -78,6 +79,54 @@ describe("connectWebTransport", () => { vi.unstubAllGlobals(); } }); + + it("omits serverCertificateHashes when the relay advertises no hash", () => { + const calls: [string, WebTransportOptions][] = []; + vi.stubGlobal( + "WebTransport", + class { + constructor(url: string, options: WebTransportOptions) { + calls.push([url, options]); + } + }, + ); + try { + connectWebTransport({ wtUrl: "https://relay.example", v: PROTOCOL_VERSION }); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe("https://relay.example/viewer"); + expect(calls[0][1]).toEqual({}); + } finally { + vi.unstubAllGlobals(); + } + }); +}); + +describe("fetchRelayInfo", () => { + function stubFetch(body: unknown): void { + vi.stubGlobal("fetch", vi.fn(() => Promise.resolve(new Response(JSON.stringify(body))))); + } + + it("accepts a body without certHash (a relay with a real certificate)", async () => { + stubFetch({ wtUrl: "https://relay.example", v: PROTOCOL_VERSION }); + try { + const info = await fetchRelayInfo("/api/info", new AbortController().signal); + expect(info).toEqual({ wtUrl: "https://relay.example", v: PROTOCOL_VERSION }); + expect(info.certHash).toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("rejects a certHash that is present but not a string", async () => { + stubFetch({ wtUrl: "https://relay.example", certHash: 7, v: PROTOCOL_VERSION }); + try { + await expect(fetchRelayInfo("/api/info", new AbortController().signal)).rejects.toThrow( + "unexpected shape", + ); + } finally { + vi.unstubAllGlobals(); + } + }); }); describe("resolveInfoUrl", () => { diff --git a/web/sdk/src/transport.ts b/web/sdk/src/transport.ts index 107444656b..404a189b81 100644 --- a/web/sdk/src/transport.ts +++ b/web/sdk/src/transport.ts @@ -1,14 +1,17 @@ -// Reconnecting WebTransport wrapper: fetch /api/info, connect with the pinned -// cert hash, hand the session off, and retry forever with capped backoff when -// anything dies. /api/info is re-fetched on every attempt because a relay -// restart means a new QUIC port and a new ephemeral certificate. +// Reconnecting WebTransport wrapper: fetch /api/info, connect (pinning the +// relay's certificate hash when it advertises one; a relay with a real +// certificate advertises none and the browser verifies it normally), hand the +// session off, and retry forever with capped backoff when anything dies. +// /api/info is re-fetched on every attempt because a relay restart means a +// new QUIC port and a new ephemeral certificate. import { PROTOCOL_VERSION } from "@dimos/shared"; export interface RelayInfo { /** WebTransport base URL (no path); connectWebTransport appends /viewer. */ wtUrl: string; - certHash: string; + /** base64 SHA-256 of the relay's ephemeral certificate; absent with a real one. */ + certHash?: string; v: number; } @@ -93,7 +96,8 @@ export async function fetchRelayInfo(url: string, signal: AbortSignal): Promise< if ( typeof data !== "object" || data === null || typeof (data as Record).wtUrl !== "string" || - typeof (data as Record).certHash !== "string" || + ((data as Record).certHash !== undefined && + typeof (data as Record).certHash !== "string") || typeof (data as Record).v !== "number" ) { throw new Error(`${url} returned an unexpected shape`); @@ -102,10 +106,12 @@ export async function fetchRelayInfo(url: string, signal: AbortSignal): Promise< } export function connectWebTransport(info: RelayInfo): WebTransportLike { - const hash = Uint8Array.from(atob(info.certHash), (c) => c.charCodeAt(0)); - return new WebTransport(`${info.wtUrl}/viewer`, { - serverCertificateHashes: [{ algorithm: "sha-256", value: hash }], - }); + const options: WebTransportOptions = {}; + if (info.certHash !== undefined) { + const hash = Uint8Array.from(atob(info.certHash), (c) => c.charCodeAt(0)); + options.serverCertificateHashes = [{ algorithm: "sha-256", value: hash }]; + } + return new WebTransport(`${info.wtUrl}/viewer`, options); } export class ReconnectingTransport {