From 9dbf155be4de7c059035a75f90c7bf0845344b74 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 13 Feb 2026 14:59:12 -0600 Subject: [PATCH] feat: remove network_mode host for standalone WebRTC (#864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: remove network_mode host for standalone by fixing WebRTC port range and ICE candidates aioice hardcodes bind(addr, 0) for ICE UDP sockets, making port mapping impossible in Docker bridge networking. This adds two env-var-gated mechanisms to replace network_mode: host: 1. WEBRTC_PORT_RANGE (e.g. "50000-50100"): monkey-patches aioice to bind UDP sockets within a known range, so they can be mapped in Docker. 2. WEBRTC_HOST (e.g. "host.docker.internal"): rewrites container-internal IPs in SDP answers with the Docker host's real IP, so LAN clients can reach the ICE candidates. Both default to None — no effect on existing deployments. * fix: do not attempt sidecar to detect host ip, use the standalone script to figure out the external ip and use it * style: reformat --------- Co-authored-by: tito --- docker-compose.standalone.yml | 4 + scripts/setup-standalone.sh | 34 +++++++++ server/reflector/app.py | 7 ++ server/reflector/settings.py | 9 +++ server/reflector/views/rtc_offer.py | 12 ++- server/reflector/webrtc_ports.py | 111 ++++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 server/reflector/webrtc_ports.py diff --git a/docker-compose.standalone.yml b/docker-compose.standalone.yml index dbe96347..b9f7a74d 100644 --- a/docker-compose.standalone.yml +++ b/docker-compose.standalone.yml @@ -23,6 +23,7 @@ services: context: server ports: - "1250:1250" + - "50000-50100:50000-50100/udp" extra_hosts: - "host.docker.internal:host-gateway" volumes: @@ -48,6 +49,9 @@ services: DIARIZATION_URL: http://cpu:8000 # Caddy reverse proxy prefix ROOT_PATH: /server-api + # WebRTC: fixed UDP port range for ICE candidates (mapped above). + # WEBRTC_HOST is set by setup-standalone.sh in server/.env (LAN IP detection). + WEBRTC_PORT_RANGE: "50000-50100" depends_on: postgres: condition: service_healthy diff --git a/scripts/setup-standalone.sh b/scripts/setup-standalone.sh index 35ad0445..48e10f0d 100755 --- a/scripts/setup-standalone.sh +++ b/scripts/setup-standalone.sh @@ -104,6 +104,29 @@ rebuild_images() { done } +detect_lan_ip() { + # Returns the host's LAN IP — used for WebRTC ICE candidate rewriting. + case "$OS" in + Darwin) + # Try common interfaces: en0 (Wi-Fi), en1 (Ethernet) + for iface in en0 en1 en2 en3; do + local ip + ip=$(ipconfig getifaddr "$iface" 2>/dev/null || true) + if [[ -n "$ip" ]]; then + echo "$ip" + return + fi + done + ;; + Linux) + ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*src \([^ ]*\).*/\1/p' + return + ;; + esac + # Fallback — empty means "not detected" + echo "" +} + wait_for_url() { local url="$1" label="$2" retries="${3:-30}" interval="${4:-2}" for i in $(seq 1 "$retries"); do @@ -262,6 +285,17 @@ ENVEOF env_set "$SERVER_ENV" "LLM_MODEL" "$MODEL" env_set "$SERVER_ENV" "LLM_API_KEY" "not-needed" + # WebRTC: detect LAN IP for ICE candidate rewriting (bridge networking) + local lan_ip + lan_ip=$(detect_lan_ip) + if [[ -n "$lan_ip" ]]; then + env_set "$SERVER_ENV" "WEBRTC_HOST" "$lan_ip" + ok "WebRTC host IP: $lan_ip" + else + warn "Could not detect LAN IP — WebRTC recording from other devices may not work" + warn "Set WEBRTC_HOST= in server/.env manually" + fi + ok "Standalone vars set (LLM_URL=$LLM_URL_VALUE)" } diff --git a/server/reflector/app.py b/server/reflector/app.py index fc7bcf8e..bba79908 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -37,6 +37,13 @@ try: except ImportError: sentry_sdk = None +# Patch aioice port range if configured (must happen before any RTCPeerConnection) +if settings.WEBRTC_PORT_RANGE: + from reflector.webrtc_ports import parse_port_range, patch_aioice_port_range + + _min, _max = parse_port_range(settings.WEBRTC_PORT_RANGE) + patch_aioice_port_range(_min, _max) + # lifespan events @asynccontextmanager diff --git a/server/reflector/settings.py b/server/reflector/settings.py index b3acccd9..b8d80386 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -14,6 +14,15 @@ class Settings(BaseSettings): ROOT_PATH: str = "/" + # WebRTC port range for ICE candidates (e.g. "50000-50100"). + # When set, monkey-patches aioice to bind UDP sockets within this range, + # allowing Docker port mapping instead of network_mode: host. + WEBRTC_PORT_RANGE: str | None = None + # Host IP or hostname to advertise in ICE candidates instead of the + # container's internal IP. Use "host.docker.internal" in Docker with + # extra_hosts, or a specific LAN IP. Resolved at connection time. + WEBRTC_HOST: str | None = None + # CORS UI_BASE_URL: str = "http://localhost:3000" CORS_ORIGIN: str = "*" diff --git a/server/reflector/views/rtc_offer.py b/server/reflector/views/rtc_offer.py index 2235aec1..935ac544 100644 --- a/server/reflector/views/rtc_offer.py +++ b/server/reflector/views/rtc_offer.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from reflector.events import subscribers_shutdown from reflector.logger import logger from reflector.pipelines.runner import PipelineRunner +from reflector.settings import settings sessions = [] router = APIRouter() @@ -123,7 +124,16 @@ async def rtc_offer_base( # update metrics m_rtc_sessions.inc() - return RtcOffer(sdp=pc.localDescription.sdp, type=pc.localDescription.type) + sdp = pc.localDescription.sdp + + # Rewrite ICE candidate IPs when running behind Docker bridge networking + if settings.WEBRTC_HOST: + from reflector.webrtc_ports import resolve_webrtc_host, rewrite_sdp_host + + host_ip = resolve_webrtc_host(settings.WEBRTC_HOST) + sdp = rewrite_sdp_host(sdp, host_ip) + + return RtcOffer(sdp=sdp, type=pc.localDescription.type) @subscribers_shutdown.append diff --git a/server/reflector/webrtc_ports.py b/server/reflector/webrtc_ports.py new file mode 100644 index 00000000..5863d1f4 --- /dev/null +++ b/server/reflector/webrtc_ports.py @@ -0,0 +1,111 @@ +""" +Monkey-patch aioice to use a fixed UDP port range for ICE candidates, +and optionally rewrite SDP to advertise a different host IP. + +This allows running the server in Docker with bridge networking +(no network_mode: host) by: + 1. Restricting ICE UDP ports to a known range that can be mapped in Docker + 2. Replacing container-internal IPs with the Docker host IP in SDP answers +""" + +import asyncio +import socket + +from reflector.logger import logger + + +def parse_port_range(range_str: str) -> tuple[int, int]: + """Parse a 'min-max' string into (min_port, max_port).""" + parts = range_str.split("-") + if len(parts) != 2: + raise ValueError(f"WEBRTC_PORT_RANGE must be 'min-max', got: {range_str!r}") + min_port, max_port = int(parts[0]), int(parts[1]) + if not (1024 <= min_port <= max_port <= 65535): + raise ValueError( + f"Invalid port range: {min_port}-{max_port} " + "(must be 1024-65535 with min <= max)" + ) + return min_port, max_port + + +def patch_aioice_port_range(min_port: int, max_port: int) -> None: + """ + Monkey-patch aioice so that ICE candidate UDP sockets bind to ports + within [min_port, max_port] instead of OS-assigned ephemeral ports. + + Works by temporarily wrapping loop.create_datagram_endpoint() during + aioice's get_component_candidates() to intercept bind(addr, 0) calls. + """ + import aioice.ice as _ice + + _original = _ice.Connection.get_component_candidates + _state = {"next_port": min_port} + + async def _patched_get_component_candidates(self, component, addresses, timeout=5): + loop = asyncio.get_event_loop() + _orig_create = loop.create_datagram_endpoint + + async def _create_with_port_range(*args, **kwargs): + local_addr = kwargs.get("local_addr") + if local_addr and local_addr[1] == 0: + addr = local_addr[0] + # Try each port in the range (wrapping around) + attempts = max_port - min_port + 1 + for _ in range(attempts): + port = _state["next_port"] + _state["next_port"] = ( + min_port + if _state["next_port"] >= max_port + else _state["next_port"] + 1 + ) + try: + kwargs["local_addr"] = (addr, port) + return await _orig_create(*args, **kwargs) + except OSError: + continue + # All ports exhausted, fall back to OS assignment + logger.warning( + "All WebRTC ports in range exhausted, falling back to OS", + min_port=min_port, + max_port=max_port, + ) + kwargs["local_addr"] = (addr, 0) + return await _orig_create(*args, **kwargs) + + loop.create_datagram_endpoint = _create_with_port_range + try: + return await _original(self, component, addresses, timeout) + finally: + loop.create_datagram_endpoint = _orig_create + + _ice.Connection.get_component_candidates = _patched_get_component_candidates + logger.info( + "aioice patched for WebRTC port range", + min_port=min_port, + max_port=max_port, + ) + + +def resolve_webrtc_host(host: str) -> str: + """Resolve a hostname or IP to an IP address for ICE candidate rewriting.""" + try: + ip = socket.gethostbyname(host) + logger.info("Resolved WEBRTC_HOST", host=host, ip=ip) + return ip + except socket.gaierror: + logger.warning("Could not resolve WEBRTC_HOST, using as-is", host=host) + return host + + +def rewrite_sdp_host(sdp: str, target_ip: str) -> str: + """ + Replace container-internal IPs in SDP with target_ip so that + ICE candidates advertise a routable address. + """ + import aioice.ice + + container_ips = aioice.ice.get_host_addresses(use_ipv4=True, use_ipv6=False) + for ip in container_ips: + if ip != "127.0.0.1" and ip != target_ip: + sdp = sdp.replace(ip, target_ip) + return sdp