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