mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-21 22:56:47 +00:00
feat: remove network_mode host for standalone WebRTC (#864)
* 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 <tito@titos-Mac-Studio.local>
This commit is contained in:
@@ -23,6 +23,7 @@ services:
|
|||||||
context: server
|
context: server
|
||||||
ports:
|
ports:
|
||||||
- "1250:1250"
|
- "1250:1250"
|
||||||
|
- "50000-50100:50000-50100/udp"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,6 +49,9 @@ services:
|
|||||||
DIARIZATION_URL: http://cpu:8000
|
DIARIZATION_URL: http://cpu:8000
|
||||||
# Caddy reverse proxy prefix
|
# Caddy reverse proxy prefix
|
||||||
ROOT_PATH: /server-api
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -104,6 +104,29 @@ rebuild_images() {
|
|||||||
done
|
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() {
|
wait_for_url() {
|
||||||
local url="$1" label="$2" retries="${3:-30}" interval="${4:-2}"
|
local url="$1" label="$2" retries="${3:-30}" interval="${4:-2}"
|
||||||
for i in $(seq 1 "$retries"); do
|
for i in $(seq 1 "$retries"); do
|
||||||
@@ -262,6 +285,17 @@ ENVEOF
|
|||||||
env_set "$SERVER_ENV" "LLM_MODEL" "$MODEL"
|
env_set "$SERVER_ENV" "LLM_MODEL" "$MODEL"
|
||||||
env_set "$SERVER_ENV" "LLM_API_KEY" "not-needed"
|
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=<your-lan-ip> in server/.env manually"
|
||||||
|
fi
|
||||||
|
|
||||||
ok "Standalone vars set (LLM_URL=$LLM_URL_VALUE)"
|
ok "Standalone vars set (LLM_URL=$LLM_URL_VALUE)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
sentry_sdk = None
|
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
|
# lifespan events
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
ROOT_PATH: str = "/"
|
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
|
# CORS
|
||||||
UI_BASE_URL: str = "http://localhost:3000"
|
UI_BASE_URL: str = "http://localhost:3000"
|
||||||
CORS_ORIGIN: str = "*"
|
CORS_ORIGIN: str = "*"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pydantic import BaseModel
|
|||||||
from reflector.events import subscribers_shutdown
|
from reflector.events import subscribers_shutdown
|
||||||
from reflector.logger import logger
|
from reflector.logger import logger
|
||||||
from reflector.pipelines.runner import PipelineRunner
|
from reflector.pipelines.runner import PipelineRunner
|
||||||
|
from reflector.settings import settings
|
||||||
|
|
||||||
sessions = []
|
sessions = []
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -123,7 +124,16 @@ async def rtc_offer_base(
|
|||||||
# update metrics
|
# update metrics
|
||||||
m_rtc_sessions.inc()
|
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
|
@subscribers_shutdown.append
|
||||||
|
|||||||
111
server/reflector/webrtc_ports.py
Normal file
111
server/reflector/webrtc_ports.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user