aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-30 22:17:30 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-30 22:17:30 +0200
commit07e6e4271ea3a45e9cd364eb6ec05801a653d9e8 (patch)
tree5d2dea4251ee2ecea32aeef80d09167d08085dd0 /packages
parentbf56b60b95c5413d592f0199320d637978792756 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js104
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js7
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py77
-rw-r--r--packages/meshbay-node/src/meshbay_node/ops.py55
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py11
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py3
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py4
-rw-r--r--packages/meshbay-node/tests/test_cli_dispatch.py1
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"],
]