diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-29 14:49:11 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-29 14:49:11 +0200 |
| commit | be57cf9c3b499c8e59a94d13059f16f1456fcae1 (patch) | |
| tree | 674e7d32ea6c1def0a4b86e40bad5eda3ba04c1d | |
| parent | c4813d1aaf1f4fdf3eb9dc07909f324f5745544d (diff) | |
| download | meshbay-be57cf9c3b499c8e59a94d13059f16f1456fcae1.tar.gz | |
perf(node): filter ICE interfaces to eliminate 5s STUN timeout on VPN/virtual adapters
aioice sends STUN binding requests from every IPv4 interface and waits up
to 5 seconds for all to complete. On a machine with Tailscale (wt0), the
STUN request never gets a response, adding a fixed 5-second penalty to
every WebRTC connection — measured at 6 s total (vs 1-2 s without it).
Auto-exclude virtual/VPN adapters (tailscale, virbr, docker, veth, podman,
cni) and CGNAT-range IPs (100.64.0.0/10). Operator can override with
ice_interfaces in node.toml [node] section for explicit control.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/config.py | 15 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 5 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/ice_filter.py | 82 |
3 files changed, 102 insertions, 0 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index e7f3116..5d9282d 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -51,6 +51,12 @@ max_concurrent_streams = 8 # Set to false only if every viewer's client is known to decode HEVC itself. transcode_incompatible_video = true +# ICE candidate gathering. By default, virtual/VPN interfaces (Tailscale, +# libvirt, Docker) are auto-excluded — a STUN request that can't reach the +# server holds the WebRTC answer for 5 seconds. Set this to restrict +# gathering to specific interfaces (by OS adapter name). +# ice_interfaces = ["wlp0s20f3", "eth0"] + # Browser and native clients reach this node over WebRTC DataChannel via hub # signaling — no inbound port to open. QUIC is the optional direct path. @@ -133,6 +139,12 @@ class NodeConfig: # that already decode the source codec directly, since transcoding costs # real CPU per concurrent viewer, unlike the copy path. transcode_incompatible_video: bool = True + # ICE candidate gathering: which network interfaces to include or exclude. + # By default, virtual and VPN interfaces (Tailscale, libvirt, Docker) are + # auto-excluded because a STUN request that can't reach the server holds + # the gather for the full 5-second timeout — measured at 6 s total on a + # machine with a Tailscale wt0 interface. + ice_interfaces: list[str] = field(default_factory=list) # include-list overrides auto @dataclass @@ -290,6 +302,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg.node.max_concurrent_streams, "max_concurrent_streams") cfg.node.transcode_incompatible_video = bool( nd.get("transcode_incompatible_video", cfg.node.transcode_incompatible_video)) + ice_if = nd.get("ice_interfaces") + if isinstance(ice_if, list): + cfg.node.ice_interfaces = [str(s) for s in ice_if] # Multi-group: [[groups]] array if "groups" in raw: diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index 779ad91..c708ec6 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -454,6 +454,11 @@ class NodeDaemon: denylist = self._denylist # 6. WebRTC transport (browser clients) + from meshbay_node.transport.ice_filter import install as install_ice_filter + install_ice_filter( + self._config.node.ice_interfaces or None, + ) + first = next(iter(groups_ctx.values()), None) if WEBRTC_AVAILABLE: self._webrtc = WebRTCTransport( diff --git a/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py b/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py new file mode 100644 index 0000000..a91724e --- /dev/null +++ b/packages/meshbay-node/src/meshbay_node/transport/ice_filter.py @@ -0,0 +1,82 @@ +""" +Filter network interfaces for ICE candidate gathering. + +aioice enumerates every interface and sends a STUN binding request from each +IPv4 address. On a machine with a Tailscale (wt0), libvirt (virbr*), or +Docker (docker*, veth*) interface, the STUN request often cannot reach the +server and burns the full 5-second asyncio.wait timeout — measured at 6 s +end-to-end on a Fedora laptop with Tailscale. + +This module patches aioice.ice.get_host_addresses so the gather runs against +the interfaces the operator actually wants. Two modes: + + ice_interfaces = ["wlp0s20f3"] → explicit include-list, nothing else + ice_interfaces = [] → auto-exclude virtual/VPN adapters + +Auto-excluded adapter name patterns: tailscale*, wt*, virbr*, docker*, +veth*, br-*, podman*, cni*. +""" + +import ipaddress +import logging +import re + +import aioice.ice + +log = logging.getLogger(__name__) + +_VIRTUAL_ADAPTER_RE = re.compile( + r"^(tailscale|wt|virbr|docker|veth|br-|podman|cni)", + re.IGNORECASE, +) + +_original_get_host_addresses = aioice.ice.get_host_addresses + + +def _is_cgnat(addr: str) -> bool: + """Tailscale uses 100.64.0.0/10 (RFC 6598 CGNAT).""" + try: + ip = ipaddress.ip_address(addr) + return ip in ipaddress.ip_network("100.64.0.0/10") + except ValueError: + return False + + +def install(include: list[str] | None = None) -> None: + """Monkey-patch aioice.ice.get_host_addresses with a filtered version.""" + import ifaddr + + include_set = set(include) if include else None + + def filtered_get_host_addresses( + use_ipv4: bool = True, use_ipv6: bool = True, + ) -> list[str]: + addresses: list[str] = [] + for adapter in ifaddr.get_adapters(): + if include_set is not None: + if adapter.name not in include_set: + continue + elif _VIRTUAL_ADAPTER_RE.match(adapter.name): + continue + + for ip in adapter.ips: + if isinstance(ip.ip, str) and use_ipv4 and ip.ip != "127.0.0.1": + if include_set is None and _is_cgnat(ip.ip): + continue + addresses.append(ip.ip) + elif use_ipv6 and ip.ip[0] != "::1" and ip.ip[2] == 0: + addresses.append(ip.ip[0]) + return addresses + + aioice.ice.get_host_addresses = filtered_get_host_addresses + + if include_set: + log.info("ICE interfaces (explicit): %s", ", ".join(sorted(include_set))) + else: + all_adapters = [a.name for a in ifaddr.get_adapters()] + excluded = [ + n for n in all_adapters + if _VIRTUAL_ADAPTER_RE.match(n) + ] + if excluded: + log.info("ICE auto-excluded interfaces: %s", ", ".join(excluded)) |