Add a stream relay (TURN) to the proxy for browsers that cannot connect directly

Vanadium forbids direct UDP for WebRTC, and mobile and company networks often
block direct connections; the only route then is a relay reached over TCP.

- server/turn.ts: STUN and TURN on one port, over UDP and TCP, with
  short-lived credentials from /api/turn, quotas and a peer filter
- The browser build fetches credentials and offers the relay automatically
- Stats for nerds says when a stream is relayed and how the relay is reached
- Tests: a TURN client over UDP and TCP, and a browser limited to the relay
  over TCP in the web E2E

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:30:09 +02:00
co-authored by Claude Opus 5.5
parent 98e44d7082
commit 08aa5268d0
13 changed files with 720 additions and 85 deletions
+17 -2
View File
@@ -42,7 +42,9 @@ function within<T>(p: Promise<T>, label: string, ms = 10000): Promise<T> {
const proxyPort = 18000 + Math.floor(Math.random() * 1000);
const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], {
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server`, MUMH5_STUN_PORT: String(proxyPort + 1000), MUMH5_STUN_BIND: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'inherit']
env: { ...env, MUMH5_PORT: String(proxyPort), MUMH5_SERVERS: `${target}=Test Server`, MUMH5_STUN_PORT: String(proxyPort + 1000), MUMH5_STUN_BIND: '127.0.0.1',
// Everything is on this machine, so the relay has to be allowed to reach loopback
MUMH5_ALLOW_PRIVATE: '1', MUMH5_TURN_IP: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'inherit']
});
await within(new Promise<void>((res, rej) => {
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
@@ -184,6 +186,9 @@ try {
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
await dialog.getByRole('button', { name: 'Start sharing' }).click();
await page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
// The viewer is limited to what a browser without direct UDP has (Vanadium's default): only
// the proxy's relay, reached over TCP
await page2.evaluate(() => localStorage.setItem('mumh5.iceDebug', 'relay-tcp'));
await page2.locator('.stage .tile', { hasText: name }).click();
await page2.getByRole('button', { name: 'Continue' }).click();
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
@@ -195,7 +200,17 @@ try {
return !!v && v.videoWidth > 0 && !v.paused && v.getBoundingClientRect().height > 100 && v.getBoundingClientRect().width > 300;
}, null, { timeout: 15000 });
if (process.env.SHOTS_DIR) await page2.screenshot({ path: `${process.env.SHOTS_DIR}/phone.png` });
console.log('ok: screen sharing between two browsers');
// The statistics confirm which way the stream came
await page2.setViewportSize({ width: 1280, height: 800 });
await page2.locator('.spot video').click({ button: 'right' });
await page2.getByRole('menuitem', { name: 'Stats for nerds' }).click();
const stats = page2.getByRole('status', { name: 'Stream statistics' });
await stats.getByText(/^(direct|relayed)/).first().waitFor({ timeout: 8000 });
const route = (await stats.innerText()).replace(/\n+/g, ' | ');
assert.match(route, /relayed \(TURN\), reached over TCP/, route);
assert.match(route, /0 direct, 0 through STUN, [1-9]\d* relayed/, route);
await stats.getByText(/^\d+ kbit\/s$/).first().waitFor({ timeout: 8000 });
console.log('ok: screen sharing to a browser without direct UDP, through the relay over TCP');
console.log('WEB E2E PASSED');
} finally {