diff options
Diffstat (limited to 'packages')
20 files changed, 454 insertions, 16 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 86258d6..604be3c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -743,4 +743,18 @@ export default { 'wizard.done_message': 'Ihre Gruppe ist bereit. Ihr Node hostet sie und die Verschlüsselung ist eingerichtet.', 'wizard.go_to_group': 'Zur Gruppe', 'wizard.finish_later': 'Einrichtung später abschließen', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 5d2725c..a58619e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -722,4 +722,18 @@ export default { 'node.setting_unit_minutes': 'min', 'node.setting_on': 'On', 'node.setting_off': 'Off', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 7080677..bcb0b93 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -739,4 +739,18 @@ export default { 'wizard.done_message': 'Su grupo está listo. Su node lo aloja y el cifrado está configurado.', 'wizard.go_to_group': 'Ir al grupo', 'wizard.finish_later': 'Finalizar configuración más tarde', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index eee4292..2e3e4d6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -755,4 +755,18 @@ export default { 'wizard.done_message': 'Votre groupe est prêt. Votre node l\'héberge et le chiffrement est configuré.', 'wizard.go_to_group': 'Aller au groupe', 'wizard.finish_later': 'Terminer la configuration plus tard', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 1031fed..dc1d16f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -753,4 +753,18 @@ export default { 'wizard.done_message': 'Il suo gruppo è pronto. Il suo node lo ospita e la cifratura è configurata.', 'wizard.go_to_group': 'Vai al gruppo', 'wizard.finish_later': 'Completa la configurazione in seguito', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 39863b1..90a5f24 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -737,4 +737,18 @@ export default { 'wizard.done_message': 'グループの準備ができました。node がホストし、暗号化が設定されています。', 'wizard.go_to_group': 'グループを開く', 'wizard.finish_later': 'セットアップを後で完了', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 21d14d7..3347087 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -755,4 +755,18 @@ export default { 'wizard.done_message': 'Uw groep is klaar. Uw node host de groep en de versleuteling is ingesteld.', 'wizard.go_to_group': 'Naar groep', 'wizard.finish_later': 'Later voltooien', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index d6ca6af..499a54f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -782,4 +782,18 @@ export default { 'wizard.done_message': 'Grupa jest gotowa. Node ją hostuje, a szyfrowanie jest skonfigurowane.', 'wizard.go_to_group': 'Przejdź do grupy', 'wizard.finish_later': 'Dokończ konfigurację później', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 5e6dd42..03a9f6e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -740,4 +740,18 @@ export default { 'wizard.done_message': 'Seu grupo está pronto. Seu node o está hospedando e a criptografia está configurada.', 'wizard.go_to_group': 'Ir para o grupo', 'wizard.finish_later': 'Finalizar configuração depois', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index da97162..8004868 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -724,4 +724,18 @@ export default { 'wizard.done_message': '您的群组已就绪。您的 node 正在托管它,加密已设置完成。', 'wizard.go_to_group': '前往群组', 'wizard.finish_later': '稍后完成设置', + 'node.stun_servers': 'STUN Servers', + 'node.stun_hint': 'Servers used for WebRTC NAT traversal (ICE candidate gathering).', + 'node.stun_add': 'Add', + 'node.stun_add_placeholder': 'stun:host:port', + 'node.stun_remove': 'Remove', + 'node.stun_move_up': 'Move up', + 'node.stun_move_down': 'Move down', + 'node.stun_save': 'Save', + 'node.stun_saving': 'Saving…', + 'node.stun_saved': 'STUN servers saved.', + 'node.stun_reset': 'Reset to defaults', + 'node.stun_empty': 'No STUN servers configured. NAT traversal will not work.', + 'node.stun_duplicate': 'This server is already in the list.', + 'node.stun_invalid': 'STUN URL must start with stun:', }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 9a6db7d..87a9815 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -102,6 +102,9 @@ export function NodePage({ groups }) { const [nodeSettings, setNodeSettings] = useState(null); const [editSettings, setEditSettings] = useState(null); const [savingSettings, setSavingSettings] = useState(false); + const [editStun, setEditStun] = useState(null); + const [savingStun, setSavingStun] = useState(false); + const [stunInput, setStunInput] = useState(''); const [operatorPaired, setOperatorPaired] = useState(false); const [pairBusy, setPairBusy] = useState(false); const [pairStatus, setPairStatus] = useState(''); @@ -131,6 +134,7 @@ export function NodePage({ groups }) { useEffect(() => { if (nodeSettings && !editSettings) setEditSettings({ ...nodeSettings }); + if (nodeSettings && !editStun) setEditStun([...(nodeSettings.stun_servers || [])]); }, [nodeSettings]); const refresh = useCallback(async () => { @@ -291,6 +295,61 @@ export function NodePage({ groups }) { } }, [editSettings]); + const saveStun = useCallback(async () => { + if (!editStun) return; + setSavingStun(true); + setActionMsg(''); + try { + await nodeCall('PUT', '/api/node-settings', { stun_servers: editStun }); + setNodeSettings(s => s ? { ...s, stun_servers: [...editStun] } : s); + setActionMsg(t('node.stun_saved')); + } catch (err) { + setActionMsg(platform.bridgeMessage(err)); + } finally { + setSavingStun(false); + } + }, [editStun]); + + const addStunServer = useCallback(() => { + const url = stunInput.trim(); + if (!url) return; + if (!url.startsWith('stun:')) { + setActionMsg(t('node.stun_invalid')); + return; + } + if (editStun && editStun.includes(url)) { + setActionMsg(t('node.stun_duplicate')); + return; + } + setEditStun(s => [...(s || []), url]); + setStunInput(''); + setActionMsg(''); + }, [stunInput, editStun]); + + const removeStunServer = useCallback((idx) => { + setEditStun(s => s.filter((_, i) => i !== idx)); + }, []); + + const moveStunServer = useCallback((idx, dir) => { + setEditStun(s => { + const a = [...s]; + const target = idx + dir; + if (target < 0 || target >= a.length) return a; + [a[idx], a[target]] = [a[target], a[idx]]; + return a; + }); + }, []); + + const resetStunDefaults = useCallback(() => { + setEditStun([ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302', + 'stun:stun.cloudflare.com:3478', + 'stun:stun.services.mozilla.com:3478', + ]); + setActionMsg(''); + }, []); + const doPairOperator = useCallback(async () => { setPairBusy(true); setPairStatus(''); @@ -559,6 +618,51 @@ export function NodePage({ groups }) { </div> </div> `} + + ${editStun && html` + <div class="node-group"> + <span class="settings-heading">${t('node.stun_servers')}</span> + <p class="node-hint">${t('node.stun_hint')}</p> + ${editStun.length === 0 ? html` + <p class="node-hint">${t('node.stun_empty')}</p> + ` : html` + <div class="stun-server-list"> + ${editStun.map((url, idx) => html` + <div class="stun-server-entry" key=${url + idx}> + <span class="stun-server-url">${url}</span> + <div class="stun-server-actions"> + <button class="btn btn-small btn-icon" disabled=${idx === 0} + onClick=${() => moveStunServer(idx, -1)} + title=${t('node.stun_move_up')}>▲</button> + <button class="btn btn-small btn-icon" disabled=${idx === editStun.length - 1} + onClick=${() => moveStunServer(idx, 1)} + title=${t('node.stun_move_down')}>▼</button> + <button class="btn btn-small btn-danger btn-icon" + onClick=${() => removeStunServer(idx)} + title=${t('node.stun_remove')}>✕</button> + </div> + </div> + `)} + </div> + `} + <div class="stun-add-row"> + <input type="text" placeholder=${t('node.stun_add_placeholder')} + value=${stunInput} + onInput=${e => setStunInput(e.target.value)} + onKeyDown=${e => { if (e.key === 'Enter') addStunServer(); }} /> + <button class="btn btn-small btn-secondary" onClick=${addStunServer}> + ${t('node.stun_add')}</button> + </div> + <div class="node-settings-actions"> + <button class="btn btn-primary btn-small" disabled=${savingStun || busy} + onClick=${saveStun}> + ${savingStun ? t('node.stun_saving') : t('node.stun_save')}</button> + <button class="btn btn-small btn-secondary" disabled=${savingStun || busy} + onClick=${resetStunDefaults}> + ${t('node.stun_reset')}</button> + </div> + </div> + `} </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 2523c1a..e217cc8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -2708,6 +2708,59 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } } .node-settings-actions { margin-top: 12px; + display: flex; + gap: 8px; +} + +/* ── STUN server list ─────────────────────────────────────────────────────── */ +.stun-server-list { + display: flex; + flex-direction: column; + gap: 0; + margin-top: 8px; +} +.stun-server-entry { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid var(--border); +} +.stun-server-url { + font-family: monospace; + font-size: 0.85em; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.stun-server-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} +.btn-icon { + min-width: 28px; + padding: 2px 6px; + font-size: 0.75em; + line-height: 1; +} +.stun-add-row { + display: flex; + gap: 6px; + margin-top: 10px; +} +.stun-add-row input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg-base); + color: var(--text); + font-family: monospace; + font-size: 0.85em; } /* ── Create Group Wizard ──────────────────────────────────────────────────── */ diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index b60bd93..286bc9d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -321,7 +321,12 @@ class MeshBayTransport { this._newNodeBundle = null; this._joinError = null; this._pc = new RTCPeerConnection({ - iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], + iceServers: [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun.cloudflare.com:3478' }, + { urls: 'stun:stun.services.mozilla.com:3478' }, + ], }); this._channel = this._pc.createDataChannel('mnp', { ordered: true }); 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)) diff --git a/packages/meshbay-node/tests/test_cli_dispatch.py b/packages/meshbay-node/tests/test_cli_dispatch.py index aa966fd..d912b43 100644 --- a/packages/meshbay-node/tests/test_cli_dispatch.py +++ b/packages/meshbay-node/tests/test_cli_dispatch.py @@ -40,6 +40,7 @@ VERBS = [ ["video", "rematch", "--yes"], ["denylist", "show"], ["denylist", "clear", "--yes"], + ["stun", "list"], ["reload"], ["restart-daemon"], ] |