// Web proxy for the browser build: serves the web app and bridges WebSocket connections to // Mumble servers over TLS. Browsers cannot open TLS sockets or present a client certificate, // so the proxy does both. It only connects to the servers on its allowlist. import http from 'node:http'; import dns from 'node:dns'; import net from 'node:net'; import { randomBytes } from 'node:crypto'; import path from 'node:path'; import { promises as fs } from 'node:fs'; import { WebSocketServer, type WebSocket } from 'ws'; import { openTls } from '../electron/tls-transport.ts'; import { generateIdentity, identityFromP12, identityToP12, certCommonName } from '../electron/identity.ts'; import { describeCert } from '../electron/certs.ts'; import { startTurn, turnCredential, stunResponse, isPrivateAddress, CREDENTIAL_TTL } from './turn.ts'; export { stunResponse, isPrivateAddress }; export interface AllowedServer { host: string; port: number; label: string } export interface ProxyConfig { port: number; bind: string; servers: AllowedServer[]; // Connect anywhere on the public internet instead of only to `servers` allowAny: boolean; // With allowAny: also allow loopback and private addresses allowPrivate: boolean; // Allowed Origin headers; empty means the origin must match the Host header, '*' allows all origins: string[]; // Take the client address from X-Forwarded-For (behind a reverse proxy) trustProxy: boolean; // Start every server connection with a PROXY protocol line naming the client's address. // 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; // Relay streams for browsers that cannot connect directly (TURN, same port over UDP and TCP) turn: boolean; // Public address announced for relayed traffic, when it cannot be found (behind a 1:1 NAT) turnIp: string | null; turnMinPort: number; turnMaxPort: number; // Relay routes in total and per client address. A browser opens one per stream and network // interface, and a camera round has a stream per pair of people, so these run high. turnMax: number; turnPerAddress: number; 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, stunPort: null, stunBind: '::', turn: true, turnIp: null, turnMinPort: 49160, turnMaxPort: 49659, turnMax: 500, turnPerAddress: 64, maxConnections: 200, maxPerAddress: 8 }; // "host", "host:port", "[v6]:port", each optionally followed by "=Label" export function parseServers(list: string): AllowedServer[] { return list.split(',').map(s => s.trim()).filter(Boolean).map(entry => { const eq = entry.indexOf('='); const addr = eq === -1 ? entry : entry.slice(0, eq); const label = eq === -1 ? '' : entry.slice(eq + 1).trim(); const m = /^\[([^\]]+)\](?::(\d+))?$/.exec(addr) ?? /^([^:]+)(?::(\d+))?$/.exec(addr); if (!m) throw new Error(`Invalid server "${entry}"`); const host = m[1].trim().toLowerCase(); return { host, port: Number(m[2] ?? 64738), label: label || host }; }); } export function configFromEnv(source: NodeJS.ProcessEnv): ProxyConfig { // A variable set to nothing counts as not set (docker-compose passes unset ones on that way) const env = Object.fromEntries(Object.entries(source).filter(([, v]) => v !== '')) as NodeJS.ProcessEnv; const on = (v?: string) => v === '1' || v === 'true'; return { ...defaults, port: Number(env.MUMH5_PORT ?? defaults.port), bind: env.MUMH5_BIND ?? defaults.bind, servers: parseServers(env.MUMH5_SERVERS ?? ''), allowAny: on(env.MUMH5_ALLOW_ANY), allowPrivate: on(env.MUMH5_ALLOW_PRIVATE), origins: (env.MUMH5_ORIGINS ?? '').split(',').map(s => s.trim()).filter(Boolean), 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, turn: env.MUMH5_TURN !== '0' && env.MUMH5_TURN !== 'false', turnIp: env.MUMH5_TURN_IP ?? null, turnMinPort: Number((env.MUMH5_TURN_PORTS ?? '').split('-')[0]) || defaults.turnMinPort, turnMaxPort: Number((env.MUMH5_TURN_PORTS ?? '').split('-')[1]) || defaults.turnMaxPort, turnMax: Number(env.MUMH5_TURN_MAX ?? defaults.turnMax), turnPerAddress: Number(env.MUMH5_TURN_PER_ADDRESS ?? defaults.turnPerAddress), maxConnections: Number(env.MUMH5_MAX_CONNECTIONS ?? defaults.maxConnections), maxPerAddress: Number(env.MUMH5_MAX_PER_ADDRESS ?? defaults.maxPerAddress) }; } // DNS lookup that fails for private addresses, so a public name cannot point the proxy inward const publicLookup: net.LookupFunction = (hostname, options, callback) => { // With `all` the result is a list of addresses, otherwise one address string (dns.lookup as Function)(hostname, options, (err: Error | null, result: string | dns.LookupAddress[], family?: number) => { if (err) return (callback as Function)(err); const list = Array.isArray(result) ? result : [{ address: result, family }]; if (list.some(r => isPrivateAddress(r.address))) return (callback as Function)(new Error('This proxy does not connect to private addresses')); (callback as Function)(null, result, family); }); }; 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', '.webp': 'image/webp', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.wasm': 'application/wasm' }; const MAX_BODY = 256 * 1024; // Mumble frames are at most 8 MiB; a WebSocket message carries a chunk of that stream const MAX_WS_MESSAGE = 8 * 1024 * 1024 + 64; const MAX_BUFFERED = 32 * 1024 * 1024; class HttpError extends Error { status: number; constructor(status: number, message: string) { super(message); this.status = status; } } 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; // Made up at every start: credentials are handed out by this process and checked by it const turnSecret = randomBytes(24).toString('hex'); // Identity requests per address in the current minute; key generation is the costly part const identityUse = new Map(); const sweep = setInterval(() => identityUse.clear(), 60000); sweep.unref(); const addressOf = (req: http.IncomingMessage): string => { // The last entry is the one our own reverse proxy added; earlier ones come from the client and can be made up const fwd = config.trustProxy ? String(req.headers['x-forwarded-for'] ?? '').split(',').at(-1)!.trim() : ''; return fwd || req.socket.remoteAddress || 'unknown'; }; const originOk = (req: http.IncomingMessage): boolean => { if (config.origins.includes('*')) return true; const origin = req.headers.origin; if (!origin) return false; if (config.origins.length) return config.origins.includes(origin); try { return new URL(origin).host === req.headers.host; } catch { return false; } }; const listed = (host: string, port: number): boolean => config.servers.some(s => s.host === host.toLowerCase() && s.port === port); const allowed = (host: string, port: number): boolean => config.allowAny || listed(host, port); async function readJson(req: http.IncomingMessage): Promise { const chunks: Buffer[] = []; let size = 0; for await (const chunk of req) { size += (chunk as Buffer).length; if (size > MAX_BODY) throw new HttpError(413, 'Request too large'); chunks.push(chunk as Buffer); } try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new HttpError(400, 'Invalid JSON'); } } const withCert = (id: { certPem: string; keyPem: string; fingerprint: string }) => ({ ...id, cert: describeCert(id.certPem), commonName: certCommonName(id.certPem) }); async function api(req: http.IncomingMessage, route: string): Promise { if (route === 'config' && req.method === 'GET') { return { servers: config.servers, any: config.allowAny, stun: stunPort, turn: stunPort != null && config.turn }; } // What a client needs for screen sharing: the STUN port and, with the relay on, credentials // for it. Open to any origin, because the desktop app asks from outside this site; checking // the origin would not keep a script out anyway. The quotas are what limit use. if (route === 'relay' && req.method === 'GET') { if (stunPort == null) throw new HttpError(404, 'No STUN or relay here'); if (!config.turn) return { port: stunPort }; const addr = addressOf(req); const used = (identityUse.get(addr) ?? 0) + 1; identityUse.set(addr, used); if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute'); // The username is the time it runs out; the relay recomputes the password from it const username = String(Math.floor(Date.now() / 1000) + CREDENTIAL_TTL); return { port: stunPort, username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL }; } if (req.method !== 'POST') throw new HttpError(404, 'Not found'); if (!originOk(req)) throw new HttpError(403, 'Origin not allowed'); const body = await readJson(req); if (route.startsWith('identity/')) { const addr = addressOf(req); const used = (identityUse.get(addr) ?? 0) + 1; identityUse.set(addr, used); if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute'); } switch (route) { // Nothing is stored here: the browser keeps its identities and sends one along when it connects case 'identity/create': return withCert(generateIdentity(String(body.name ?? '').slice(0, 128), String(body.email ?? '').slice(0, 128))); case 'identity/import': return withCert(identityFromP12(new Uint8Array(Buffer.from(String(body.p12 ?? ''), 'base64')), String(body.password ?? ''))); case 'identity/export': { const id = { certPem: String(body.certPem), keyPem: String(body.keyPem), fingerprint: '' }; return { p12: Buffer.from(identityToP12(id, String(body.password ?? ''), String(body.name ?? 'Identity').slice(0, 128))).toString('base64') }; } case 'certs/describe': return (Array.isArray(body.ders) ? body.ders.slice(0, 8) : []).flatMap((d: unknown) => { try { return [describeCert(Buffer.from(String(d), 'base64'))]; } catch { return []; } }); } throw new HttpError(404, 'Not found'); } async function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): Promise { if (!staticDir || (req.method !== 'GET' && req.method !== 'HEAD')) throw new HttpError(404, 'Not found'); let rel: string; try { rel = decodeURIComponent(pathname); } catch { throw new HttpError(400, 'Bad path'); } let file = path.join(staticDir, path.normalize('/' + rel)); if (file !== staticDir && !file.startsWith(staticDir + path.sep)) throw new HttpError(404, 'Not found'); let data: Buffer; try { if ((await fs.stat(file)).isDirectory()) file = path.join(file, 'index.html'); data = await fs.readFile(file); } catch { throw new HttpError(404, 'Not found'); } const html = file.endsWith('.html'); res.writeHead(200, { 'Content-Type': TYPES[path.extname(file).toLowerCase()] ?? 'application/octet-stream', 'Content-Length': data.length, // Built assets carry a content hash in their name; the page itself must stay fresh 'Cache-Control': html ? 'no-cache' : 'public, max-age=31536000, immutable', 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY' }); res.end(req.method === 'HEAD' ? undefined : data); } const server = http.createServer((req, res) => { const pathname = new URL(req.url ?? '/', 'http://x').pathname; const work = pathname.startsWith('/api/') ? api(req, pathname.slice(5)).then(result => { const data = Buffer.from(JSON.stringify(result)); res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': data.length, 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', // The desktop app reads this one from another origin ...(pathname === '/api/relay' ? { 'Access-Control-Allow-Origin': '*' } : {}) }); res.end(data); }) : serveStatic(req, res, pathname); work.catch((e: Error) => { const status = e instanceof HttpError ? e.status : 400; if (res.headersSent) return res.destroy(); res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: e.message })); }); }); const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_WS_MESSAGE, perMessageDeflate: false }); server.on('upgrade', (req, socket, head) => { const refuse = (status: string) => { socket.write(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`); socket.destroy(); }; if (new URL(req.url ?? '/', 'http://x').pathname !== '/api/connect') return refuse('404 Not Found'); if (!originOk(req)) return refuse('403 Forbidden'); const addr = addressOf(req); if (wss.clients.size >= config.maxConnections || (perAddress.get(addr) ?? 0) >= config.maxPerAddress) return refuse('503 Service Unavailable'); wss.handleUpgrade(req, socket, head, ws => bridge(ws, addr)); }); // One WebSocket is one Mumble connection. The first message names the server and carries the // client certificate; after that binary messages are the raw Mumble TCP stream in both directions. function bridge(ws: WebSocket, addr: string): void { perAddress.set(addr, (perAddress.get(addr) ?? 0) + 1); const clientAddress = addr.replace(/^::ffff:/, ''); let conn: ReturnType | null = null; let done = false; const finish = (reason: string) => { if (done) return; done = true; clearTimeout(hello); conn?.close(); if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'close', reason })); ws.close(1000); } }; const hello = setTimeout(() => finish('No connection request received'), 10000); ws.on('message', (data, isBinary) => { if (conn) { if (isBinary) conn.send(data as Buffer); return; } if (isBinary) return finish('Expected a connection request'); let req: any; try { req = JSON.parse(data.toString()); } catch { return finish('Invalid connection request'); } const host = String(req.host ?? '').trim(); const port = Number(req.port) || 64738; if (!host || host.length > 255 || port < 1 || port > 65535 || !allowed(host, port)) { return finish(`This proxy does not allow connecting to ${host}:${port}`); } // Servers the operator listed are trusted as they are, also on localhost or a private network const guard = config.allowAny && !config.allowPrivate && !listed(host, port); if (guard && net.isIP(host) && isPrivateAddress(host)) return finish('This proxy does not connect to private addresses'); clearTimeout(hello); conn = openTls(host, port, String(req.certPem ?? ''), String(req.keyPem ?? ''), { onSecure: info => { if (ws.readyState !== ws.OPEN) return; // The server's address stays here; the browser has no use for it without UDP ws.send(JSON.stringify({ type: 'secure', info: { fingerprint: info.fingerprint, authorized: info.authorized, authError: info.authError, chain: info.chain, // Mumble only ever sees this proxy's address; tell the browser its own so the app can show it clientAddress } })); }, onData: chunk => { if (ws.readyState !== ws.OPEN) return; // A browser that stopped reading must not grow our memory without bound if (ws.bufferedAmount > MAX_BUFFERED) return finish('Connection too slow'); ws.send(chunk, { binary: true }); }, onClose: reason => finish(reason) }, { lookup: guard ? publicLookup : undefined, proxyClient: config.sendProxy ? clientAddress : undefined }); }); ws.on('close', () => { done = true; clearTimeout(hello); conn?.close(); const left = (perAddress.get(addr) ?? 1) - 1; if (left > 0) perAddress.set(addr, left); else perAddress.delete(addr); }); ws.on('error', () => ws.terminate()); } await new Promise((resolve, reject) => { server.once('error', reject); server.listen(config.port, config.bind, resolve); }); // STUN and the relay. Failing to open the port only costs screen sharing its helper. let turn: { port: number; close(): void } | null = null; if (config.stunPort != null) { try { turn = await startTurn({ port: config.stunPort, bind: config.stunBind, relay: config.turn, publicIp: config.turnIp, minPort: config.turnMinPort, maxPort: config.turnMaxPort, maxAllocations: config.turnMax, maxPerAddress: config.turnPerAddress, peerAllowed: ip => config.allowPrivate || !isPrivateAddress(ip) }, turnSecret); stunPort = turn.port; } catch { /* port taken or not permitted */ } } return { port: (server.address() as net.AddressInfo).port, stunPort, close: () => new Promise(resolve => { clearInterval(sweep); turn?.close(); for (const ws of wss.clients) ws.terminate(); wss.close(); server.close(() => resolve()); server.closeAllConnections(); }) }; }