diff --git a/CLAUDE.md b/CLAUDE.md index 2b1ba21..4f79a1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ A reused test server keeps registrations and channels from earlier runs; tests m ## 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). -- `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/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. diff --git a/README.md b/README.md index 4a820df..d827394 100644 --- a/README.md +++ b/README.md @@ -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 - 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 -- 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 ### 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_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_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_MAX_CONNECTIONS`, `MUMH5_MAX_PER_ADDRESS` | `200`, `8` | Connection limits, in total and per client address | diff --git a/scripts/dev-web.mjs b/scripts/dev-web.mjs index a1f599b..268abd5 100644 --- a/scripts/dev-web.mjs +++ b/scripts/dev-web.mjs @@ -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, // 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. -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' }); const proxy = spawn(process.execPath, ['server/main.ts'], { stdio: 'inherit', env }); diff --git a/server/main.ts b/server/main.ts index ddde361..d0ea103 100644 --- a/server/main.ts +++ b/server/main.ts @@ -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; try { - const { port } = await startProxy(config); + const { port, stunPort } = await startProxy(config); 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(', ')}`); 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'); } catch (e) { console.error((e as Error).message); diff --git a/server/proxy.ts b/server/proxy.ts index 1e872ae..b820411 100644 --- a/server/proxy.ts +++ b/server/proxy.ts @@ -4,6 +4,7 @@ import http from 'node:http'; import dns from 'node:dns'; import net from 'node:net'; +import dgram from 'node:dgram'; import path from 'node:path'; import { promises as fs } from 'node:fs'; 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. sendProxy: boolean; 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; maxPerAddress: number; } export const defaults: ProxyConfig = { 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" @@ -64,6 +68,8 @@ export function configFromEnv(env: NodeJS.ProcessEnv): ProxyConfig { trustProxy: on(env.MUMH5_TRUST_PROXY), sendProxy: on(env.MUMH5_SEND_PROXY), 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), 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 = { '.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', @@ -110,12 +149,13 @@ class HttpError extends Error { constructor(status: number, message: string) { super(message); this.status = status; } } -export async function startProxy(config: ProxyConfig): Promise<{ port: number; close(): Promise }> { +export async function startProxy(config: ProxyConfig): Promise<{ port: number; stunPort: number | null; close(): Promise }> { if (!config.allowAny && !config.servers.length) { 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 perAddress = new Map(); + let stunPort: number | null = null; // Identity requests per address in the current minute; key generation is the costly part const identityUse = new Map(); 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 { 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 (!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.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(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 { port: (server.address() as net.AddressInfo).port, + stunPort, close: () => new Promise(resolve => { clearInterval(sweep); + (stun as dgram.Socket | null)?.close(); for (const ws of wss.clients) ws.terminate(); wss.close(); server.close(() => resolve()); diff --git a/src/lib/share.svelte.ts b/src/lib/share.svelte.ts index 7413365..c32e893 100644 --- a/src/lib/share.svelte.ts +++ b/src/lib/share.svelte.ts @@ -5,7 +5,8 @@ import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } from '../core/share-signal.ts'; import type { User } from '../core/client.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 { ui } from './ui.svelte.ts'; @@ -51,7 +52,10 @@ class ScreenShare { private iceServers(): RTCIceServer[] { 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[] { diff --git a/src/lib/web.svelte.ts b/src/lib/web.svelte.ts index 810d551..737c717 100644 --- a/src/lib/web.svelte.ts +++ b/src/lib/web.svelte.ts @@ -135,14 +135,17 @@ export interface ProxyServer { host: string; port: number; label: string } class ProxyInfo { servers = $state([]); any = $state(false); + // UDP port of the proxy's STUN responder, if it runs one + stun = $state(null); loaded = $state(false); error = $state(''); async load(): Promise { 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.any = c.any; + this.stun = c.stun ?? null; this.error = ''; } catch (e) { this.error = (e as Error).message; @@ -151,3 +154,9 @@ class 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; } +} diff --git a/test/e2e/web-shell.cjs b/test/e2e/web-shell.cjs index 9fdf083..eb30fdd 100644 --- a/test/e2e/web-shell.cjs +++ b/test/e2e/web-shell.cjs @@ -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); }); diff --git a/test/e2e/web.e2e.ts b/test/e2e/web.e2e.ts index 17bbad5..5f34787 100644 --- a/test/e2e/web.e2e.ts +++ b/test/e2e/web.e2e.ts @@ -42,7 +42,7 @@ function within(p: Promise, label: string, ms = 10000): Promise { 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((res, rej) => { proxy.stdout.on('data', d => { if (String(d).includes('listening')) res(); }); @@ -50,12 +50,14 @@ await within(new Promise((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 }); +const app = await browser(); +let second: Awaited> | 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(); } diff --git a/test/proxy.test.ts b/test/proxy.test.ts index 4ed867f..33eabef 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -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((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' });