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
+7 -1
View File
@@ -50,7 +50,7 @@ test('refuses to start without allowed servers', async () => {
test('config lists the allowed servers', async () => {
await withProxy({ servers: parseServers('voice.example.org=Friends') }, async base => {
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false, stun: null });
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false, stun: null, turn: false });
});
});
@@ -108,6 +108,12 @@ test('the proxy answers STUN over UDP and announces the port', async () => {
setTimeout(() => reject(new Error('no STUN answer')), 3000);
});
assert.equal(((answer[26] << 8) | answer[27]) ^ 0x2112, client.address().port);
// Relay credentials for people on this site: a username that expires and its password
const base = `http://127.0.0.1:${proxy.port}`;
assert.equal((await (await fetch(`${base}/api/config`)).json()).turn, true);
const cred = await (await post(base, 'turn', {})).json();
assert.ok(Number(cred.username) > Date.now() / 1000 && cred.credential.length > 20 && cred.port === proxy.stunPort);
assert.equal((await post(base, 'turn', {}, { Origin: 'https://evil.example' })).status, 200, 'origins are open in this test');
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
client.close();
} finally {