Add a STUN responder to the proxy for screen sharing between browsers

The browser build uses it automatically, so no outside server is contacted.
Covered by unit tests and a two-browser step in the web E2E.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:38:42 +02:00
co-authored by Claude Opus 5.5
parent 13b7013004
commit 826542ed8a
10 changed files with 166 additions and 14 deletions
+7 -1
View File
@@ -1,5 +1,5 @@
// A bare browser window for the web E2E: no preload, so the page runs as it would in a browser.
const { app, BrowserWindow, session } = require('electron');
const { app, BrowserWindow, desktopCapturer, session } = require('electron');
const fs = require('node:fs');
const path = require('node:path');
@@ -11,6 +11,12 @@ app.whenReady().then(() => {
item.setSavePath(file);
item.once('done', (_ev, state) => { if (state === 'completed') fs.writeFileSync(file + '.done', ''); });
});
// Stands in for the browser's own "choose what to share" dialog: always the first screen
session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => {
let sources = [];
for (let i = 0; i < 10 && !sources.length; i++) sources = await desktopCapturer.getSources({ types: ['screen'] });
try { callback(sources.length ? { video: sources[0] } : {}); } catch { /* refused */ }
});
const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false } });
win.loadURL(process.env.MUMH5_WEB_URL);
});
+33 -2
View File
@@ -42,7 +42,7 @@ 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` }, 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' }, stdio: ['ignore', 'pipe', 'inherit']
});
await within(new Promise<void>((res, rej) => {
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
@@ -50,12 +50,14 @@ await within(new Promise<void>((res, rej) => {
}), 'proxy start');
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
const app = await electron.launch({
const browser = () => electron.launch({
executablePath: electronPath as unknown as string,
args: [path.join(root, 'test/e2e/web-shell.cjs'), `--user-data-dir=${mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-'))}`, '--ozone-platform=x11',
'--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'],
env: { ...env, MUMH5_WEB_URL: `http://127.0.0.1:${proxyPort}/`, MUMH5_DOWNLOADS: downloads } as Record<string, string>
});
const app = await browser();
let second: Awaited<ReturnType<typeof browser>> | null = null;
const bob = await headless('bob');
const name = `webalice${Date.now() % 100000}`;
try {
@@ -160,9 +162,38 @@ try {
assert.equal(hash(), before, 'same certificate after a reload');
console.log('ok: identity kept across a reload');
// Screen sharing between two browsers: the proxy's own STUN is used without any setting
assert.equal((await (await fetch(`http://127.0.0.1:${proxyPort}/api/config`)).json()).stun, proxyPort + 1000);
second = await browser();
const page2 = await second.firstWindow();
page2.on('console', m => { if (m.type() === 'error') console.log('[page2]', m.text()); });
await page2.setViewportSize({ width: 1280, height: 800 });
await page2.getByRole('button', { name: /Create a new identity/ }).click();
await page2.getByLabel('Name', { exact: true }).fill(`webcarol${Date.now() % 100000}`);
await page2.getByRole('button', { name: 'Create', exact: true }).click();
await page2.getByRole('button', { name: 'Skip for now' }).click();
await page2.getByRole('button', { name: 'Done' }).click();
await page2.getByTitle('Add a server').click();
await page2.getByRole('button', { name: 'Save and connect' }).click();
await page2.getByText(/Connected/).first().waitFor();
await page.locator('.me').getByRole('button', { name: 'Share your screen' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
const dialog = page.getByRole('dialog', { name: 'Share your screen' });
await dialog.getByRole('button', { name: 'Choose a screen or window...' }).click();
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.getByText('You are sharing your screen').waitFor();
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 });
await page.getByText('You are sharing your screen, 1 watching').waitFor();
console.log('ok: screen sharing between two browsers');
console.log('WEB E2E PASSED');
} finally {
bob.disconnect();
await app.close().catch(() => {});
await second?.close().catch(() => {});
proxy.kill();
}
+44 -1
View File
@@ -2,8 +2,10 @@
// real Mumble server and are skipped unless MUMBLE_TEST_HOST is set (see server.test.ts).
import { test } from 'node:test';
import net from 'node:net';
import dgram from 'node:dgram';
import assert from 'node:assert/strict';
import { proxyLine } from '../electron/tls-transport.ts';
import { stunResponse } from '../server/proxy.ts';
import { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
import { WebSocket as WsClient } from 'ws';
import { WebSocketTransport } from '../src/core/ws-transport.ts';
@@ -40,7 +42,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 });
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false, stun: null });
});
});
@@ -64,6 +66,47 @@ test('identity create, describe, export and import round trip', async () => {
});
});
// A binding request: type, length 0, magic cookie, 12 byte transaction id
const stunRequest = () => Uint8Array.from([0, 1, 0, 0, 0x21, 0x12, 0xa4, 0x42, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
test('STUN answers carry the sender address, masked as the protocol asks', () => {
const v4 = stunResponse(stunRequest(), '203.0.113.7', 54321)!;
assert.deepEqual([...v4.subarray(0, 4)], [1, 1, 0, 12]);
assert.deepEqual([...v4.subarray(4, 20)], [...stunRequest().subarray(4, 20)]);
assert.deepEqual([...v4.subarray(20, 26)], [0, 0x20, 0, 8, 0, 1]);
assert.equal(((v4[26] << 8) | v4[27]) ^ 0x2112, 54321);
assert.deepEqual([...v4.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [203, 0, 113, 7]);
// An IPv4 sender seen through an IPv6 socket is still IPv4
assert.deepEqual([...stunResponse(stunRequest(), '::ffff:203.0.113.7', 54321)!], [...v4]);
const v6 = stunResponse(stunRequest(), '2001:db8::7', 1)!;
assert.equal(v6[25], 2);
const mask = stunRequest().subarray(4, 20);
assert.deepEqual([...v6.subarray(28)].map((b, i) => b ^ mask[i]), [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7]);
// Not a binding request
assert.equal(stunResponse(new Uint8Array(20), '203.0.113.7', 1), null);
assert.equal(stunResponse(stunRequest().subarray(0, 12), '203.0.113.7', 1), null);
});
test('the proxy answers STUN over UDP and announces the port', async () => {
const proxy = await startProxy({ ...defaults, port: 0, origins: ['*'], servers: parseServers('voice.example.org'), stunPort: 0, stunBind: '127.0.0.1' });
try {
assert.ok(proxy.stunPort);
assert.equal((await (await fetch(`http://127.0.0.1:${proxy.port}/api/config`)).json()).stun, proxy.stunPort);
const client = dgram.createSocket('udp4');
const answer = await new Promise<Buffer>((resolve, reject) => {
client.once('message', resolve);
client.once('error', reject);
client.send(stunRequest(), proxy.stunPort!, '127.0.0.1');
setTimeout(() => reject(new Error('no STUN answer')), 3000);
});
assert.equal(((answer[26] << 8) | answer[27]) ^ 0x2112, client.address().port);
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
client.close();
} finally {
await proxy.close();
}
});
test('requests from other origins are refused', async () => {
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });