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
+1 -1
View File
@@ -43,7 +43,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m
## Architecture ## Architecture
- `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox). - `electron/` (Node, main process): window, TLS sockets to Mumble servers (`tls-transport.ts`), encrypted UDP voice (`udp-voice.ts`, `ocb2.ts`, tested against Mumble's OCB2 vectors), identities and PKCS#12 (`identity.ts`, `identity-store.ts`), certificate parsing (`certs.ts`), tray (`tray.ts`). The renderer only gets the narrow `window.mumh5Native` API from `preload.ts` (context isolation, sandbox).
- `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. - `server/` (Node): the web proxy. `proxy.ts` serves `dist-web`, bridges WebSocket connections to Mumble over TLS (reusing `electron/tls-transport.ts`) and has stateless identity endpoints. It stores nothing; the browser keeps identities in localStorage and sends one with each connect. It also answers STUN on UDP for screen sharing between browser users.
- `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here. - `src/core/` (browser-safe TypeScript, also runs in Node for tests): framing and codec (`proto.ts`), the Mumble client state machine (`client.ts`), voice packet formats (`voice-packet.ts`). No DOM, no Electron, no Node imports here.
- `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`. - `src/lib/native.ts`: `desktop` is the Electron preload API or null; `native` is what both platforms provide (identities, certificates), backed by `web.svelte.ts` in the browser build (`isWeb`, vite `--mode web`). Desktop-only features check `desktop`.
- `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text. - `src/lib/`: app state. `session.svelte.ts` has one `Session` per server plus the `sessions` manager; `session` is a Proxy to the active one. `audio/voice.svelte.ts` is the voice engine (WebCodecs Opus, capture and playback AudioWorklets). `html.ts` sanitizes incoming HTML and serializes outgoing rich text.
+2 -1
View File
@@ -82,7 +82,7 @@ mumh5 keeps the foundation and replaces the experience.
- Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers - Sound: on Linux one program or everything except mumh5 itself (through PipeWire, so viewers do not hear the voice chat twice); on Windows the whole system; in a browser what the browser offers
- People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. Your tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting. Viewers set the stream's volume - People in the channel see an indicator next to your name, on your tile and in your profile, and click to watch. Your tile shows the picture; a click makes it large with everyone else in a strip below, like a meeting. Viewers set the stream's volume
- No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams - No server setup and no extra account: the setup messages travel through the Mumble server, the stream goes directly between the two clients (WebRTC), up to 8 viewers. Regular Mumble clients do not see streams
- Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Behind most home routers a STUN server (Settings, Voice) is needed; none is contacted unless you enter one - Direct connections mean sharer and viewer see each other's IP address; mumh5 says so before the first use. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you enter one in Settings, Voice (the proxy's address works there too), and none is contacted unless you do
- Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested - Tested between two desktop instances on one machine with a test picture. Sound capture, real screens, connections across the internet, Windows and the browser build are untested
### Chat ### Chat
@@ -176,6 +176,7 @@ Then open `http://127.0.0.1:8080`. For development, `npm run dev:web` starts the
| `MUMH5_ORIGINS` | same host | Origins allowed to use the API, comma-separated, when the page is hosted elsewhere | | `MUMH5_ORIGINS` | same host | Origins allowed to use the API, comma-separated, when the page is hosted elsewhere |
| `MUMH5_TRUST_PROXY` | off | Take client addresses from `X-Forwarded-For` (set this behind a reverse proxy) | | `MUMH5_TRUST_PROXY` | off | Take client addresses from `X-Forwarded-For` (set this behind a reverse proxy) |
| `MUMH5_SEND_PROXY` | off | Announce each visitor's address to the server with the PROXY protocol (see below). Breaks connections to a plain Mumble server | | `MUMH5_SEND_PROXY` | off | Announce each visitor's address to the server with the PROXY protocol (see below). Breaks connections to a plain Mumble server |
| `MUMH5_STUN_PORT`, `MUMH5_STUN_BIND` | `3478`, all addresses | UDP port of the built-in STUN responder that lets browser users find a direct route for screen sharing. Open this UDP port in the firewall; it does not go through nginx. `0` turns it off |
| `MUMH5_STATIC` | `../dist-web` | Folder with the web build | | `MUMH5_STATIC` | `../dist-web` | Folder with the web build |
| `MUMH5_MAX_CONNECTIONS`, `MUMH5_MAX_PER_ADDRESS` | `200`, `8` | Connection limits, in total and per client address | | `MUMH5_MAX_CONNECTIONS`, `MUMH5_MAX_PER_ADDRESS` | `200`, `8` | Connection limits, in total and per client address |
+1 -1
View File
@@ -4,7 +4,7 @@ import { createServer } from 'vite';
// The browser build for development: the web proxy on 127.0.0.1:8080 and the Vite dev server, // The browser build for development: the web proxy on 127.0.0.1:8080 and the Vite dev server,
// which forwards /api to it (vite.config.ts). Without MUMH5_SERVERS the proxy allows any // which forwards /api to it (vite.config.ts). Without MUMH5_SERVERS the proxy allows any
// server, private addresses included; it only listens on this machine. // server, private addresses included; it only listens on this machine.
const env = { ...process.env, MUMH5_PORT: '8080', MUMH5_BIND: '127.0.0.1' }; const env = { MUMH5_STUN_BIND: '127.0.0.1', ...process.env, MUMH5_PORT: '8080', MUMH5_BIND: '127.0.0.1' };
if (!env.MUMH5_SERVERS && !env.MUMH5_ALLOW_ANY) Object.assign(env, { MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1' }); if (!env.MUMH5_SERVERS && !env.MUMH5_ALLOW_ANY) Object.assign(env, { MUMH5_ALLOW_ANY: '1', MUMH5_ALLOW_PRIVATE: '1' });
const proxy = spawn(process.execPath, ['server/main.ts'], { stdio: 'inherit', env }); const proxy = spawn(process.execPath, ['server/main.ts'], { stdio: 'inherit', env });
+2 -1
View File
@@ -8,10 +8,11 @@ const config = configFromEnv(process.env);
config.staticDir ??= [path.resolve(import.meta.dirname, '../dist-web')].find(d => existsSync(path.join(d, 'index.html'))) ?? null; config.staticDir ??= [path.resolve(import.meta.dirname, '../dist-web')].find(d => existsSync(path.join(d, 'index.html'))) ?? null;
try { try {
const { port } = await startProxy(config); const { port, stunPort } = await startProxy(config);
console.log(`mumh5 proxy listening on http://${config.bind}:${port}`); console.log(`mumh5 proxy listening on http://${config.bind}:${port}`);
console.log(config.allowAny ? `Allowed servers: any${config.allowPrivate ? ', private addresses included' : ' public address'}` : `Allowed servers: ${config.servers.map(s => `${s.host}:${s.port}`).join(', ')}`); console.log(config.allowAny ? `Allowed servers: any${config.allowPrivate ? ', private addresses included' : ' public address'}` : `Allowed servers: ${config.servers.map(s => `${s.host}:${s.port}`).join(', ')}`);
if (config.sendProxy) console.log('Announcing client addresses with the PROXY protocol; the allowed servers must expect it'); if (config.sendProxy) console.log('Announcing client addresses with the PROXY protocol; the allowed servers must expect it');
console.log(stunPort ? `STUN for screen sharing on UDP port ${stunPort} (must be reachable from the internet)` : 'STUN is off; screen sharing between browser users will only work on the same network');
console.log(config.staticDir ? `Serving the web app from ${config.staticDir}` : 'No web build found (npm run build:web); serving the API only'); console.log(config.staticDir ? `Serving the web app from ${config.staticDir}` : 'No web build found (npm run build:web); serving the API only');
} catch (e) { } catch (e) {
console.error((e as Error).message); console.error((e as Error).message);
+60 -3
View File
@@ -4,6 +4,7 @@
import http from 'node:http'; import http from 'node:http';
import dns from 'node:dns'; import dns from 'node:dns';
import net from 'node:net'; import net from 'node:net';
import dgram from 'node:dgram';
import path from 'node:path'; import path from 'node:path';
import { promises as fs } from 'node:fs'; import { promises as fs } from 'node:fs';
import { WebSocketServer, type WebSocket } from 'ws'; import { WebSocketServer, type WebSocket } from 'ws';
@@ -29,13 +30,16 @@ export interface ProxyConfig {
// Only for servers behind something that understands it (go-mmproxy); plain Mumble does not. // Only for servers behind something that understands it (go-mmproxy); plain Mumble does not.
sendProxy: boolean; sendProxy: boolean;
staticDir: string | null; staticDir: string | null;
// UDP port of the built-in STUN responder for screen sharing between browser users; null turns it off
stunPort: number | null;
stunBind: string;
maxConnections: number; maxConnections: number;
maxPerAddress: number; maxPerAddress: number;
} }
export const defaults: ProxyConfig = { export const defaults: ProxyConfig = {
port: 8080, bind: '127.0.0.1', servers: [], allowAny: false, allowPrivate: false, origins: [], port: 8080, bind: '127.0.0.1', servers: [], allowAny: false, allowPrivate: false, origins: [],
trustProxy: false, sendProxy: false, staticDir: null, maxConnections: 200, maxPerAddress: 8 trustProxy: false, sendProxy: false, staticDir: null, stunPort: null, stunBind: '::', maxConnections: 200, maxPerAddress: 8
}; };
// "host", "host:port", "[v6]:port", each optionally followed by "=Label" // "host", "host:port", "[v6]:port", each optionally followed by "=Label"
@@ -64,6 +68,8 @@ export function configFromEnv(env: NodeJS.ProcessEnv): ProxyConfig {
trustProxy: on(env.MUMH5_TRUST_PROXY), trustProxy: on(env.MUMH5_TRUST_PROXY),
sendProxy: on(env.MUMH5_SEND_PROXY), sendProxy: on(env.MUMH5_SEND_PROXY),
staticDir: env.MUMH5_STATIC ?? null, staticDir: env.MUMH5_STATIC ?? null,
stunPort: Number(env.MUMH5_STUN_PORT ?? 3478) || null,
stunBind: env.MUMH5_STUN_BIND ?? defaults.stunBind,
maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections), maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections),
maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress) maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress)
}; };
@@ -93,6 +99,39 @@ const publicLookup: net.LookupFunction = (hostname, options, callback) => {
}); });
}; };
// Answer to a STUN binding request (RFC 5389): tells the sender the address its packet came
// from, which is how two browsers behind routers find a direct route for screen sharing.
// Returns null for anything that is not a binding request. The answer is about as small as
// the request, so the port is of no use for amplifying traffic.
export function stunResponse(msg: Uint8Array, address: string, port: number): Uint8Array | null {
const COOKIE = 0x2112a442;
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
if (msg.length < 20 || view.getUint16(0) !== 0x0001 || view.getUint32(4) !== COOKIE) return null;
if (view.getUint16(2) !== msg.length - 20) return null;
const v4 = address.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
let bytes: number[];
if (net.isIPv4(v4)) bytes = v4.split('.').map(Number);
else if (net.isIPv6(address)) {
// Expand "::" and write the eight groups out as bytes
const [head, tail = ''] = address.split('%')[0].split('::');
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
const groups = address.includes('::') ? [...h, ...new Array(8 - h.length - t.length).fill('0'), ...t] : h;
bytes = groups.flatMap(g => { const n = parseInt(g, 16); return [n >> 8, n & 255]; });
} else return null;
const out = new Uint8Array(20 + 8 + bytes.length);
const o = new DataView(out.buffer);
o.setUint16(0, 0x0101); // binding success
o.setUint16(2, 8 + bytes.length);
out.set(msg.subarray(4, 20), 4); // cookie and transaction id
o.setUint16(20, 0x0020); // XOR-MAPPED-ADDRESS
o.setUint16(22, 4 + bytes.length);
out[25] = bytes.length === 4 ? 1 : 2;
o.setUint16(26, port ^ (COOKIE >>> 16));
// The address is masked with the cookie, and for IPv6 with the transaction id after it
for (let i = 0; i < bytes.length; i++) out[28 + i] = bytes[i] ^ msg[4 + i];
return out;
}
const TYPES: Record<string, string> = { const TYPES: Record<string, string> = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.jpg': 'image/jpeg', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.jpg': 'image/jpeg',
@@ -110,12 +149,13 @@ class HttpError extends Error {
constructor(status: number, message: string) { super(message); this.status = status; } constructor(status: number, message: string) { super(message); this.status = status; }
} }
export async function startProxy(config: ProxyConfig): Promise<{ port: number; close(): Promise<void> }> { export async function startProxy(config: ProxyConfig): Promise<{ port: number; stunPort: number | null; close(): Promise<void> }> {
if (!config.allowAny && !config.servers.length) { if (!config.allowAny && !config.servers.length) {
throw new Error('No servers allowed. Set MUMH5_SERVERS=host[:port][=Label],... or MUMH5_ALLOW_ANY=1.'); throw new Error('No servers allowed. Set MUMH5_SERVERS=host[:port][=Label],... or MUMH5_ALLOW_ANY=1.');
} }
const staticDir = config.staticDir ? path.resolve(config.staticDir) : null; const staticDir = config.staticDir ? path.resolve(config.staticDir) : null;
const perAddress = new Map<string, number>(); const perAddress = new Map<string, number>();
let stunPort: number | null = null;
// Identity requests per address in the current minute; key generation is the costly part // Identity requests per address in the current minute; key generation is the costly part
const identityUse = new Map<string, number>(); const identityUse = new Map<string, number>();
const sweep = setInterval(() => identityUse.clear(), 60000); const sweep = setInterval(() => identityUse.clear(), 60000);
@@ -154,7 +194,7 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; c
async function api(req: http.IncomingMessage, route: string): Promise<unknown> { async function api(req: http.IncomingMessage, route: string): Promise<unknown> {
if (route === 'config' && req.method === 'GET') { if (route === 'config' && req.method === 'GET') {
return { servers: config.servers, any: config.allowAny }; return { servers: config.servers, any: config.allowAny, stun: stunPort };
} }
if (req.method !== 'POST') throw new HttpError(404, 'Not found'); if (req.method !== 'POST') throw new HttpError(404, 'Not found');
if (!originOk(req)) throw new HttpError(403, 'Origin not allowed'); if (!originOk(req)) throw new HttpError(403, 'Origin not allowed');
@@ -305,10 +345,27 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; c
server.once('error', reject); server.once('error', reject);
server.listen(config.port, config.bind, resolve); server.listen(config.port, config.bind, resolve);
}); });
// STUN on UDP. Failing to open it (port taken, no permission) only costs screen sharing its helper.
let stun: dgram.Socket | null = null;
if (config.stunPort != null) {
const socket = dgram.createSocket(net.isIPv4(config.stunBind) ? 'udp4' : 'udp6');
socket.on('message', (msg, from) => {
const answer = stunResponse(msg, from.address, from.port);
if (answer) socket.send(answer, from.port, from.address);
});
await new Promise<void>(resolve => {
socket.once('error', () => { socket.close(); resolve(); });
socket.bind(config.stunPort!, config.stunBind, () => { socket.removeAllListeners('error'); socket.on('error', () => {}); stun = socket; stunPort = socket.address().port; resolve(); });
});
}
return { return {
port: (server.address() as net.AddressInfo).port, port: (server.address() as net.AddressInfo).port,
stunPort,
close: () => new Promise<void>(resolve => { close: () => new Promise<void>(resolve => {
clearInterval(sweep); clearInterval(sweep);
(stun as dgram.Socket | null)?.close();
for (const ws of wss.clients) ws.terminate(); for (const ws of wss.clients) ws.terminate();
wss.close(); wss.close();
server.close(() => resolve()); server.close(() => resolve());
+6 -2
View File
@@ -5,7 +5,8 @@
import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } from '../core/share-signal.ts'; import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } from '../core/share-signal.ts';
import type { User } from '../core/client.ts'; import type { User } from '../core/client.ts';
import type { Session } from './session.svelte.ts'; import type { Session } from './session.svelte.ts';
import { desktop } from './native.ts'; import { desktop, isWeb } from './native.ts';
import { proxyStun } from './web.svelte.ts';
import { store } from './settings.svelte.ts'; import { store } from './settings.svelte.ts';
import { ui } from './ui.svelte.ts'; import { ui } from './ui.svelte.ts';
@@ -51,7 +52,10 @@ class ScreenShare {
private iceServers(): RTCIceServer[] { private iceServers(): RTCIceServer[] {
const stun = store.settings.stunServer.trim(); const stun = store.settings.stunServer.trim();
return stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : []; if (stun) return [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }];
// The browser build falls back to the proxy it is served from
const own = isWeb ? proxyStun() : null;
return own ? [{ urls: own }] : [];
} }
private others(s: Session): number[] { private others(s: Session): number[] {
+10 -1
View File
@@ -135,14 +135,17 @@ export interface ProxyServer { host: string; port: number; label: string }
class ProxyInfo { class ProxyInfo {
servers = $state<ProxyServer[]>([]); servers = $state<ProxyServer[]>([]);
any = $state(false); any = $state(false);
// UDP port of the proxy's STUN responder, if it runs one
stun = $state<number | null>(null);
loaded = $state(false); loaded = $state(false);
error = $state(''); error = $state('');
async load(): Promise<void> { async load(): Promise<void> {
try { try {
const c = await call<{ servers: ProxyServer[]; any: boolean }>('config'); const c = await call<{ servers: ProxyServer[]; any: boolean; stun?: number | null }>('config');
this.servers = c.servers; this.servers = c.servers;
this.any = c.any; this.any = c.any;
this.stun = c.stun ?? null;
this.error = ''; this.error = '';
} catch (e) { } catch (e) {
this.error = (e as Error).message; this.error = (e as Error).message;
@@ -151,3 +154,9 @@ class ProxyInfo {
} }
} }
export const proxyInfo = new ProxyInfo(); export const proxyInfo = new ProxyInfo();
// The proxy's own STUN address: a host the user already uses, so nothing new is contacted
export function proxyStun(): string | null {
if (!proxyInfo.stun || !base) return null;
try { return `stun:${new URL(base).hostname}:${proxyInfo.stun}`; } catch { return null; }
}
+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. // 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 fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -11,6 +11,12 @@ app.whenReady().then(() => {
item.setSavePath(file); item.setSavePath(file);
item.once('done', (_ev, state) => { if (state === 'completed') fs.writeFileSync(file + '.done', ''); }); 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 } }); const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { contextIsolation: true, sandbox: true, nodeIntegration: false } });
win.loadURL(process.env.MUMH5_WEB_URL); 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 proxyPort = 18000 + Math.floor(Math.random() * 1000);
const { ELECTRON_RUN_AS_NODE, ...env } = process.env; const { ELECTRON_RUN_AS_NODE, ...env } = process.env;
const proxy = spawn(process.execPath, [path.join(root, 'dist-proxy/proxy.mjs')], { 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) => { await within(new Promise<void>((res, rej) => {
proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); }); proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); });
@@ -50,12 +50,14 @@ await within(new Promise<void>((res, rej) => {
}), 'proxy start'); }), 'proxy start');
const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-')); const downloads = mkdtempSync(path.join(tmpdir(), 'mumh5-e2e-dl-'));
const app = await electron.launch({ const browser = () => electron.launch({
executablePath: electronPath as unknown as string, 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', 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'], '--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> 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 bob = await headless('bob');
const name = `webalice${Date.now() % 100000}`; const name = `webalice${Date.now() % 100000}`;
try { try {
@@ -160,9 +162,38 @@ try {
assert.equal(hash(), before, 'same certificate after a reload'); assert.equal(hash(), before, 'same certificate after a reload');
console.log('ok: identity kept across 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'); console.log('WEB E2E PASSED');
} finally { } finally {
bob.disconnect(); bob.disconnect();
await app.close().catch(() => {}); await app.close().catch(() => {});
await second?.close().catch(() => {});
proxy.kill(); 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). // real Mumble server and are skipped unless MUMBLE_TEST_HOST is set (see server.test.ts).
import { test } from 'node:test'; import { test } from 'node:test';
import net from 'node:net'; import net from 'node:net';
import dgram from 'node:dgram';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { proxyLine } from '../electron/tls-transport.ts'; 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 { startProxy, defaults, parseServers, isPrivateAddress, type ProxyConfig } from '../server/proxy.ts';
import { WebSocket as WsClient } from 'ws'; import { WebSocket as WsClient } from 'ws';
import { WebSocketTransport } from '../src/core/ws-transport.ts'; 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 () => { test('config lists the allowed servers', async () => {
await withProxy({ servers: parseServers('voice.example.org=Friends') }, async base => { 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 () => { test('requests from other origins are refused', async () => {
await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => { await withProxy({ servers: parseServers('voice.example.org'), origins: [] }, async base => {
const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' }); const cross = await post(base, 'identity/create', { name: 'x' }, { Origin: 'https://evil.example' });