Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion dimos/cli/commands/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down Expand Up @@ -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:
Expand Down
18 changes: 18 additions & 0 deletions dimos/cli/test_dimos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
4 changes: 4 additions & 0 deletions dimos/core/global_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 5 additions & 1 deletion dimos/web/relay_bridge/_wt_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down
10 changes: 7 additions & 3 deletions dimos/web/relay_bridge/locate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]

Expand Down
12 changes: 10 additions & 2 deletions dimos/web/relay_bridge/relay_bridge_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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); "
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 11 additions & 3 deletions dimos/web/relay_bridge/relay_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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] = []
Expand All @@ -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"}
Expand Down Expand Up @@ -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"]),
)
)
Expand Down
74 changes: 73 additions & 1 deletion dimos/web/relay_bridge/test_relay_bridge_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,21 @@

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
import time
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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions dimos/web/relay_bridge/test_relay_bridge_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading