Files
reflector/server/reflector/webrtc_ports.py
2026-04-08 20:32:47 -05:00

110 lines
4.0 KiB
Python

"""
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
import aioice.ice
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.
"""
_original = aioice.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
aioice.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.
"""
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