diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-30 22:17:30 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-30 22:17:30 +0200 |
| commit | 07e6e4271ea3a45e9cd364eb6ec05801a653d9e8 (patch) | |
| tree | 5d2dea4251ee2ecea32aeef80d09167d08085dd0 /packages/meshbay-node/src | |
| parent | bf56b60b95c5413d592f0199320d637978792756 (diff) | |
| download | meshbay-07e6e4271ea3a45e9cd364eb6ec05801a653d9e8.tar.gz | |
feat: configurable STUN server fallbacks for WebRTC ICE
The WebRTC transport relied on a single Google STUN server — if it was
unreachable, ICE gathering waited the full 4s timeout. Now four public
servers are used by default (Google ×2, Cloudflare, Mozilla), configurable
via node.toml, the Node page UI, and the CLI (meshbay-node stun list|add|
remove|reset). Changes are hot-swapped on the live transport.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/src')
6 files changed, 150 insertions, 15 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 5d9282d..b94d03a 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -18,6 +18,13 @@ except ImportError: log = logging.getLogger(__name__) +DEFAULT_STUN_SERVERS: list[str] = [ + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + "stun:stun.cloudflare.com:3478", + "stun:stun.services.mozilla.com:3478", +] + DEFAULT_CONFIG_PATH = Path.home() / ".config" / "meshbay" / "node.toml" EXAMPLE_CONFIG = """\ @@ -57,6 +64,10 @@ transcode_incompatible_video = true # gathering to specific interfaces (by OS adapter name). # ice_interfaces = ["wlp0s20f3", "eth0"] +# STUN servers for WebRTC ICE candidate gathering (NAT traversal). By default +# four public servers are used; set this to override. +# stun_servers = ["stun:stun.l.google.com:19302", "stun:stun.cloudflare.com:3478"] + # Browser and native clients reach this node over WebRTC DataChannel via hub # signaling — no inbound port to open. QUIC is the optional direct path. @@ -145,6 +156,7 @@ class NodeConfig: # 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 + stun_servers: list[str] = field(default_factory=list) # empty = DEFAULT_STUN_SERVERS @dataclass @@ -305,6 +317,9 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: ice_if = nd.get("ice_interfaces") if isinstance(ice_if, list): cfg.node.ice_interfaces = [str(s) for s in ice_if] + stun = nd.get("stun_servers") + if isinstance(stun, list): + cfg.node.stun_servers = [str(s) for s in stun] # 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 e1e4dee..51f78b7 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -269,6 +269,7 @@ class NodeDaemon: # Apply any roster overrides to node config (panel-edited values # take precedence over node.toml defaults). + from meshbay_node.config import DEFAULT_STUN_SERVERS nd = self._config.node defaults = { "invite_ttl_hours": nd.invite_ttl_hours, @@ -276,6 +277,7 @@ class NodeDaemon: "device_request_ttl_minutes": nd.device_request_ttl_minutes, "max_concurrent_streams": nd.max_concurrent_streams, "transcode_incompatible_video": nd.transcode_incompatible_video, + "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), } effective = await self._roster.node_settings(defaults) for k, v in effective.items(): @@ -486,6 +488,7 @@ class NodeDaemon: denylist=denylist, max_concurrent_streams=self._config.node.max_concurrent_streams, transcode_incompatible_video=self._config.node.transcode_incompatible_video, + stun_servers=self._config.node.stun_servers or None, ) # No global chat_store here: each group's store lives in # groups_ctx[gid]["chat_store"] and is resolved per session via @@ -1604,22 +1607,25 @@ def main() -> None: parser.add_argument("command", nargs="?", choices=["init", "reset", "status", "ui", "gek-init", "gek", "operator", "member", "group", "file", - "video", "denylist", "reload", "restart-daemon", - "calibrate-argon2"], + "video", "denylist", "stun", "reload", + "restart-daemon", "calibrate-argon2"], help="init: provision config + keystore | reset: erase all " "node state | status: node state and keys " "| ui: print the admin UI URL | operator pair: pair a " "browser with this node | member list|invite|revoke|unpin " "| group list|add|remove | gek init|rotate | file list|rm " "| video rematch: re-resolve TMDB matches for a group's " - "videos | denylist show|clear | reload: re-read node.toml " + "videos | denylist show|clear " + "| stun list|add|remove|reset " + "| reload: re-read node.toml " "(systemctl --user reload) | restart-daemon: restart " "the systemd unit (systemctl --user restart) " "| calibrate-argon2: benchmark") parser.add_argument("subcommand", nargs="?", help="'pair' for operator; list|invite|revoke|unpin for " "member; list|add|remove for group; init|rotate for gek; " - "list|rm for file; rematch for video; show|clear for denylist") + "list|rm for file; rematch for video; show|clear for " + "denylist; list|add|remove|reset for stun") parser.add_argument("target", nargs="?", help="username for member invite|revoke|unpin; group name " "for group add; file id for file rm; identifier for " @@ -1645,7 +1651,7 @@ def main() -> None: # Query commands print a report; library logging would interleave with it. quiet = args.command in ("status", "ui", "gek-init", "gek", "operator", "member", "group", "file", "video", "denylist", - "reload", "restart-daemon", "reset") + "stun", "reload", "restart-daemon", "reset") logging.basicConfig( level=logging.ERROR if quiet else getattr(logging, args.log_level), format="%(asctime)s %(levelname)-8s %(name)s: %(message)s", @@ -2087,6 +2093,67 @@ def main() -> None: print("usage: meshbay-node denylist show|clear [identifier] [--yes]") sys.exit(1) + if args.command == "stun": + cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + sub = args.subcommand or "list" + + if sub == "list": + out = _daemon_api(cfg, "/api/node-settings") + servers = out.get("stun_servers", []) + if not servers: + print("stun servers (none configured)") + return + for i, s in enumerate(servers, 1): + print(f" {i}. {s}") + return + + if sub == "add": + url = args.target + if not url: + print("usage: meshbay-node stun add <stun:host:port>") + sys.exit(1) + if not url.startswith("stun:"): + print(f"error: STUN URL must start with stun: — got {url!r}") + sys.exit(1) + out = _daemon_api(cfg, "/api/node-settings") + servers = out.get("stun_servers", []) + if url in servers: + print(f"already present: {url}") + return + servers.append(url) + _daemon_api(cfg, "/api/node-settings", method="PUT", + body={"stun_servers": servers}) + print(f"added {url} ({len(servers)} servers total)") + return + + if sub == "remove": + url = args.target + if not url: + print("usage: meshbay-node stun remove <stun:host:port>") + sys.exit(1) + out = _daemon_api(cfg, "/api/node-settings") + servers = out.get("stun_servers", []) + if url not in servers: + print(f"not found: {url}") + sys.exit(1) + servers.remove(url) + _daemon_api(cfg, "/api/node-settings", method="PUT", + body={"stun_servers": servers}) + print(f"removed {url} ({len(servers)} servers remaining)") + return + + if sub == "reset": + from meshbay_node.config import DEFAULT_STUN_SERVERS + _daemon_api(cfg, "/api/node-settings", method="PUT", + body={"stun_servers": list(DEFAULT_STUN_SERVERS)}) + print("STUN servers reset to defaults:") + for s in DEFAULT_STUN_SERVERS: + print(f" {s}") + return + + print("usage: meshbay-node stun list|add|remove|reset [url]") + sys.exit(1) + if args.command == "file": cfg = load_config(args.config or DEFAULT_CONFIG_PATH) sub = args.subcommand or "list" diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index d1314f0..2ab6753 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -332,6 +332,7 @@ async def list_groups(state: dict) -> dict: members = await roster.list_members() has_operator = any(m["role"] == "operator" and m["status"] == "active" for m in members) + from meshbay_node.config import DEFAULT_STUN_SERVERS nd = config.node if config else None defaults = { "invite_ttl_hours": nd.invite_ttl_hours if nd else 168, @@ -339,6 +340,7 @@ async def list_groups(state: dict) -> dict: "device_request_ttl_minutes": nd.device_request_ttl_minutes if nd else 60, "max_concurrent_streams": nd.max_concurrent_streams if nd else 8, "transcode_incompatible_video": nd.transcode_incompatible_video if nd else True, + "stun_servers": nd.stun_servers if nd and nd.stun_servers else list(DEFAULT_STUN_SERVERS), } if roster: settings = await roster.node_settings(defaults) @@ -511,6 +513,14 @@ def _update_node_toml(conf_path: Path, updates: dict) -> None: node_start = len(lines) - 1 node_end = len(lines) + def _format_value(key, value): + if isinstance(value, bool): + return f"{key} = {'true' if value else 'false'}" + if isinstance(value, list): + items = ", ".join(f'"{v}"' for v in value) + return f"{key} = [{items}]" + return f"{key} = {value}" + remaining = dict(updates) for i in range(node_start + 1, node_end): for key in list(remaining): @@ -518,18 +528,11 @@ def _update_node_toml(conf_path: Path, updates: dict) -> None: r'^(\s*#?\s*)' + re.escape(key) + r'\s*=\s*.*$') if pattern.match(lines[i]): value = remaining.pop(key) - if isinstance(value, bool): - lines[i] = f"{key} = {'true' if value else 'false'}" - else: - lines[i] = f"{key} = {value}" + lines[i] = _format_value(key, value) break for key, value in remaining.items(): - if isinstance(value, bool): - insert_line = f"{key} = {'true' if value else 'false'}" - else: - insert_line = f"{key} = {value}" - lines.insert(node_end, insert_line) + lines.insert(node_end, _format_value(key, value)) node_end += 1 conf_path.write_text("\n".join(lines)) @@ -783,6 +786,24 @@ async def set_member_upload(state: dict, group_id: str, allowed: bool) -> dict: # ── Node settings ──────────────────────────────────────────────────────────── +async def get_node_settings(state: dict) -> dict: + """Return current effective node settings.""" + from meshbay_node.config import DEFAULT_STUN_SERVERS + roster = _roster(state) + config = _config(state) + nd = config.node + defaults = { + "invite_ttl_hours": nd.invite_ttl_hours, + "pair_ttl_hours": nd.pair_ttl_hours, + "device_request_ttl_minutes": nd.device_request_ttl_minutes, + "max_concurrent_streams": nd.max_concurrent_streams, + "transcode_incompatible_video": nd.transcode_incompatible_video, + "stun_servers": nd.stun_servers if nd.stun_servers else list(DEFAULT_STUN_SERVERS), + } + if roster: + return await roster.node_settings(defaults) + return defaults + async def set_node_settings(state: dict, settings: dict) -> dict: """Update node-level daemon settings. Writes to both roster.db and node.toml.""" roster = _roster(state) @@ -796,6 +817,7 @@ async def set_node_settings(state: dict, settings: dict) -> dict: "device_request_ttl_minutes": ("int", roster.SETTING_DEVICE_TTL), "max_concurrent_streams": ("int", roster.SETTING_MAX_STREAMS), "transcode_incompatible_video": ("bool", roster.SETTING_TRANSCODE), + "stun_servers": ("list", roster.SETTING_STUN_SERVERS), } set_by = state.get("node_user_id", "") @@ -819,6 +841,17 @@ async def set_node_settings(state: dict, settings: dict) -> dict: setattr(nd, key, v) await roster.set_node_setting(setting_key, "1" if v else "0", set_by) updated[key] = v + elif kind == "list": + import json as _json + if not isinstance(value, list): + raise OpError(f"{key} must be a list") + v = [str(s) for s in value] + for s in v: + if not s.startswith("stun:"): + raise OpError(f"Invalid STUN server: {s} (must start with stun:)") + setattr(nd, key, v) + await roster.set_node_setting(setting_key, _json.dumps(v), set_by) + updated[key] = v if updated: _update_node_toml(conf_path, updated) @@ -826,6 +859,10 @@ async def set_node_settings(state: dict, settings: dict) -> dict: webrtc = state.get("webrtc") if webrtc and hasattr(webrtc, '_stream_sem'): webrtc._stream_sem = asyncio.Semaphore(updated["max_concurrent_streams"]) + if "stun_servers" in updated: + webrtc = state.get("webrtc") + if webrtc and hasattr(webrtc, '_stun'): + webrtc._stun = updated["stun_servers"] log.info("Node settings updated: %s", updated) return {"updated": updated} diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py index cf0d421..5bdb5ee 100644 --- a/packages/meshbay-node/src/meshbay_node/roster.py +++ b/packages/meshbay-node/src/meshbay_node/roster.py @@ -764,9 +764,11 @@ class Roster: SETTING_DEVICE_TTL = "device_request_ttl_minutes" SETTING_MAX_STREAMS = "max_concurrent_streams" SETTING_TRANSCODE = "transcode_incompatible_video" + SETTING_STUN_SERVERS = "stun_servers" async def node_settings(self, defaults: dict) -> dict: """Current effective settings: roster override if present, else config default.""" + import json as _json result = {} for key, setting in [ ("invite_ttl_hours", self.SETTING_INVITE_TTL), @@ -783,6 +785,15 @@ class Roster: result[key] = int(stored) else: result[key] = defaults.get(key) + stored = await self.get_setting(self.NODE_WIDE_GROUP_ID, + self.SETTING_STUN_SERVERS) + if stored is not None: + try: + result["stun_servers"] = _json.loads(stored) + except (ValueError, TypeError): + result["stun_servers"] = defaults.get("stun_servers", []) + else: + result["stun_servers"] = defaults.get("stun_servers", []) return result async def set_node_setting(self, key: str, value: str, diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index a934418..15309cf 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -4604,7 +4604,8 @@ class WebRTCTransport: self._ctx["groups"] = groups if denylist: self._ctx["denylist"] = denylist - self._stun = stun_servers or ["stun:stun.l.google.com:19302"] + from meshbay_node.config import DEFAULT_STUN_SERVERS + self._stun = stun_servers or list(DEFAULT_STUN_SERVERS) self._sessions: dict[str, WebRTCPeerSession] = {} async def handle_offer( diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index b25b36a..671597f 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -447,6 +447,10 @@ def create_ui_app(state: dict) -> FastAPI: # ── Node settings (operator only, localhost) ─────────────────────────── + @app.get("/api/node-settings") + async def get_node_settings(): + return await _op(lambda: ops.get_node_settings(state)) + @app.put("/api/node-settings") async def update_node_settings(payload: dict): return await _op(lambda: ops.set_node_settings(state, payload)) |