Screen sharing #5
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+60
-3
@@ -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<string, string> = {
|
||||
'.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<void> }> {
|
||||
export async function startProxy(config: ProxyConfig): Promise<{ port: number; stunPort: number | null; close(): Promise<void> }> {
|
||||
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<string, number>();
|
||||
let stunPort: number | null = null;
|
||||
// Identity requests per address in the current minute; key generation is the costly part
|
||||
const identityUse = new Map<string, number>();
|
||||
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> {
|
||||
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<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 {
|
||||
port: (server.address() as net.AddressInfo).port,
|
||||
stunPort,
|
||||
close: () => new Promise<void>(resolve => {
|
||||
clearInterval(sweep);
|
||||
(stun as dgram.Socket | null)?.close();
|
||||
for (const ws of wss.clients) ws.terminate();
|
||||
wss.close();
|
||||
server.close(() => resolve());
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
+10
-1
@@ -135,14 +135,17 @@ export interface ProxyServer { host: string; port: number; label: string }
|
||||
class ProxyInfo {
|
||||
servers = $state<ProxyServer[]>([]);
|
||||
any = $state(false);
|
||||
// UDP port of the proxy's STUN responder, if it runs one
|
||||
stun = $state<number | null>(null);
|
||||
loaded = $state(false);
|
||||
error = $state('');
|
||||
|
||||
async load(): Promise<void> {
|
||||
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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user