summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/src')
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py5
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/ice_filter.py82
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))