Add a stream relay (TURN) to the proxy for browsers that cannot connect directly #11
@@ -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. It also answers STUN on UDP for screen sharing between browser users.
|
- `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. `turn.ts` is its STUN and TURN server for screen sharing between browser users (UDP and TCP on one port, credentials from `/api/turn`, secret made up at start). In the browser build `localStorage mumh5.iceDebug = relay` or `relay-tcp` limits a client to relayed routes, which is how the E2E exercises the relay.
|
||||||
- `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.
|
||||||
|
|||||||
+1
-1
@@ -14,5 +14,5 @@ COPY --from=build /src/dist-web ./dist-web
|
|||||||
COPY --from=build /src/dist-proxy ./dist-proxy
|
COPY --from=build /src/dist-proxy ./dist-proxy
|
||||||
USER node
|
USER node
|
||||||
ENV MUMH5_PORT=8080
|
ENV MUMH5_PORT=8080
|
||||||
EXPOSE 8080/tcp 3478/udp
|
EXPOSE 8080/tcp 3478/udp 3478/tcp 49160-49359/udp
|
||||||
CMD ["node", "dist-proxy/proxy.mjs"]
|
CMD ["node", "dist-proxy/proxy.mjs"]
|
||||||
|
|||||||
@@ -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. The tiles above the chat fill a stage you can drag taller or shorter. A stream opens large, with everyone as a strip of tiles below, like a meeting, and can move to a window of its own; in the stacked layout it is just the picture, as wide as the chat. Sounds announce streams and viewers. 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. The tiles above the chat fill a stage you can drag taller or shorter. A stream opens large, with everyone as a strip of tiles below, like a meeting, and can move to a window of its own; in the stacked layout it is just the picture, as wide as the chat. Sounds announce streams and viewers. 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. Across the internet both sides need a STUN server. The browser version uses the one built into its proxy; in the desktop app you choose one in Settings, Voice (Google, Cloudflare, or any address such as your proxy's), and none is contacted unless you do
|
- 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, and the proxy's relay when no direct connection is possible (mobile networks, Vanadium); in the desktop app you choose one in Settings, Voice (Google, Cloudflare, or any address such as your proxy's), 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
|
||||||
@@ -175,7 +175,9 @@ echo 'MUMH5_SERVERS=mumble.example.com=My server' > .env
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
The container uses the host's network: the proxy listens on `127.0.0.1:8080` for your reverse proxy and on UDP 3478 for STUN, and a Mumble server on the same machine is reachable as `localhost`.
|
The container uses the host's network: the proxy listens on `127.0.0.1:8080` for your reverse proxy, and a Mumble server on the same machine is reachable as `localhost`.
|
||||||
|
|
||||||
|
For screen sharing, open these in the firewall (and forward them on a router in front of the server): port 3478 for UDP and TCP, and UDP 49160-49359. They carry STUN and the relay, which the browser version uses without any setting. Relayed streams pass through your server and use its bandwidth, a few Mbit/s per viewer; only people using your site get credentials for it, and there is no bandwidth cap yet.
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -185,7 +187,10 @@ The container uses the host's network: the proxy listens on `127.0.0.1:8080` for
|
|||||||
| `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_STUN_PORT`, `MUMH5_STUN_BIND` | `3478`, all addresses | Port for screen sharing between browser users: STUN over UDP, and the relay over UDP and TCP. Browsers reach it directly, not through nginx. `0` turns both off |
|
||||||
|
| `MUMH5_TURN` | on | The relay (TURN) for people who cannot connect directly: mobile networks, strict company networks, browsers that forbid direct UDP such as Vanadium. `0` leaves only STUN |
|
||||||
|
| `MUMH5_TURN_PORTS` | `49160-49359` | UDP ports the relayed streams use |
|
||||||
|
| `MUMH5_TURN_IP` | found automatically | The server's public address, announced for relayed streams. Set it when the server sits behind a 1:1 NAT, as on many cloud hosts |
|
||||||
| `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 |
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -7,7 +7,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Host networking, so STUN sees each visitor's real address (through Docker's port
|
# Host networking, so STUN sees each visitor's real address (through Docker's port
|
||||||
# forwarding it would often see Docker's own) and a Mumble server on this machine is
|
# forwarding it would often see Docker's own) and a Mumble server on this machine is
|
||||||
# reachable as localhost. The proxy listens on 127.0.0.1:8080 for nginx and on UDP 3478.
|
# reachable as localhost. The proxy listens on 127.0.0.1:8080 for nginx, on port 3478 (UDP and
|
||||||
|
# TCP) for STUN and the relay, and relays streams on UDP 49160-49359.
|
||||||
network_mode: host
|
network_mode: host
|
||||||
# Passed on from .env (or the shell); empty means the proxy's default. Written out one by
|
# Passed on from .env (or the shell); empty means the proxy's default. Written out one by
|
||||||
# one because older docker-compose versions cannot mark an env_file as optional.
|
# one because older docker-compose versions cannot mark an env_file as optional.
|
||||||
@@ -22,5 +23,8 @@ services:
|
|||||||
MUMH5_SEND_PROXY: ${MUMH5_SEND_PROXY:-}
|
MUMH5_SEND_PROXY: ${MUMH5_SEND_PROXY:-}
|
||||||
MUMH5_STUN_PORT: ${MUMH5_STUN_PORT:-3478}
|
MUMH5_STUN_PORT: ${MUMH5_STUN_PORT:-3478}
|
||||||
MUMH5_STUN_BIND: ${MUMH5_STUN_BIND:-}
|
MUMH5_STUN_BIND: ${MUMH5_STUN_BIND:-}
|
||||||
|
MUMH5_TURN: ${MUMH5_TURN:-}
|
||||||
|
MUMH5_TURN_IP: ${MUMH5_TURN_IP:-}
|
||||||
|
MUMH5_TURN_PORTS: ${MUMH5_TURN_PORTS:-}
|
||||||
MUMH5_MAX_CONNECTIONS: ${MUMH5_MAX_CONNECTIONS:-}
|
MUMH5_MAX_CONNECTIONS: ${MUMH5_MAX_CONNECTIONS:-}
|
||||||
MUMH5_MAX_PER_ADDRESS: ${MUMH5_MAX_PER_ADDRESS:-}
|
MUMH5_MAX_PER_ADDRESS: ${MUMH5_MAX_PER_ADDRESS:-}
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,9 @@ try {
|
|||||||
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(!stunPort ? 'STUN is off; screen sharing between browser users will only work on the same network'
|
||||||
|
: config.turn ? `Screen sharing: STUN and relay on port ${stunPort} (UDP and TCP), relayed streams on UDP ${config.turnMinPort}-${config.turnMaxPort}; all must be reachable from the internet`
|
||||||
|
: `Screen sharing: STUN on UDP port ${stunPort} (must be reachable from the internet), no relay`);
|
||||||
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);
|
||||||
|
|||||||
+39
-61
@@ -4,13 +4,16 @@
|
|||||||
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 { randomBytes } from 'node:crypto';
|
||||||
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';
|
||||||
import { openTls } from '../electron/tls-transport.ts';
|
import { openTls } from '../electron/tls-transport.ts';
|
||||||
import { generateIdentity, identityFromP12, identityToP12, certCommonName } from '../electron/identity.ts';
|
import { generateIdentity, identityFromP12, identityToP12, certCommonName } from '../electron/identity.ts';
|
||||||
import { describeCert } from '../electron/certs.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 AllowedServer { host: string; port: number; label: string }
|
||||||
|
|
||||||
@@ -33,13 +36,19 @@ export interface ProxyConfig {
|
|||||||
// UDP port of the built-in STUN responder for screen sharing between browser users; null turns it off
|
// UDP port of the built-in STUN responder for screen sharing between browser users; null turns it off
|
||||||
stunPort: number | null;
|
stunPort: number | null;
|
||||||
stunBind: string;
|
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;
|
||||||
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, stunPort: null, stunBind: '::', maxConnections: 200, maxPerAddress: 8
|
trustProxy: false, sendProxy: false, staticDir: null, stunPort: null, stunBind: '::', turn: true, turnIp: null, turnMinPort: 49160, turnMaxPort: 49359, maxConnections: 200, maxPerAddress: 8
|
||||||
};
|
};
|
||||||
|
|
||||||
// "host", "host:port", "[v6]:port", each optionally followed by "=Label"
|
// "host", "host:port", "[v6]:port", each optionally followed by "=Label"
|
||||||
@@ -72,24 +81,15 @@ export function configFromEnv(source: NodeJS.ProcessEnv): ProxyConfig {
|
|||||||
staticDir: env.MUMH5_STATIC ?? null,
|
staticDir: env.MUMH5_STATIC ?? null,
|
||||||
stunPort: Number(env.MUMH5_STUN_PORT ?? 3478) || null,
|
stunPort: Number(env.MUMH5_STUN_PORT ?? 3478) || null,
|
||||||
stunBind: env.MUMH5_STUN_BIND ?? defaults.stunBind,
|
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,
|
||||||
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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loopback, private, link-local and other addresses that are not on the public internet
|
|
||||||
export function isPrivateAddress(address: string): boolean {
|
|
||||||
if (net.isIPv4(address)) {
|
|
||||||
const [a, b] = address.split('.').map(Number);
|
|
||||||
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
|
||||||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
|
||||||
}
|
|
||||||
const v6 = address.toLowerCase();
|
|
||||||
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
|
|
||||||
if (mapped) return isPrivateAddress(mapped[1]);
|
|
||||||
return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || /^ff/.test(v6);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DNS lookup that fails for private addresses, so a public name cannot point the proxy inward
|
// DNS lookup that fails for private addresses, so a public name cannot point the proxy inward
|
||||||
const publicLookup: net.LookupFunction = (hostname, options, callback) => {
|
const publicLookup: net.LookupFunction = (hostname, options, callback) => {
|
||||||
// With `all` the result is a list of addresses, otherwise one address string
|
// With `all` the result is a list of addresses, otherwise one address string
|
||||||
@@ -101,39 +101,6 @@ 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',
|
||||||
@@ -158,6 +125,8 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
|
|||||||
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;
|
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
|
// 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);
|
||||||
@@ -196,7 +165,7 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
|
|||||||
|
|
||||||
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, stun: stunPort };
|
return { servers: config.servers, any: config.allowAny, stun: stunPort, turn: stunPort != null && config.turn };
|
||||||
}
|
}
|
||||||
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');
|
||||||
@@ -207,6 +176,16 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
|
|||||||
identityUse.set(addr, used);
|
identityUse.set(addr, used);
|
||||||
if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute');
|
if (used > 20) throw new HttpError(429, 'Too many requests, try again in a minute');
|
||||||
}
|
}
|
||||||
|
if (route === 'turn') {
|
||||||
|
if (stunPort == null || !config.turn) throw new HttpError(404, 'No relay here');
|
||||||
|
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 { username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL, port: stunPort };
|
||||||
|
}
|
||||||
switch (route) {
|
switch (route) {
|
||||||
// Nothing is stored here: the browser keeps its identities and sends one along when it connects
|
// Nothing is stored here: the browser keeps its identities and sends one along when it connects
|
||||||
case 'identity/create':
|
case 'identity/create':
|
||||||
@@ -348,18 +327,17 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
|
|||||||
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.
|
// STUN and the relay. Failing to open the port only costs screen sharing its helper.
|
||||||
let stun: dgram.Socket | null = null;
|
let turn: { port: number; close(): void } | null = null;
|
||||||
if (config.stunPort != null) {
|
if (config.stunPort != null) {
|
||||||
const socket = dgram.createSocket(net.isIPv4(config.stunBind) ? 'udp4' : 'udp6');
|
try {
|
||||||
socket.on('message', (msg, from) => {
|
turn = await startTurn({
|
||||||
const answer = stunResponse(msg, from.address, from.port);
|
port: config.stunPort, bind: config.stunBind, relay: config.turn, publicIp: config.turnIp,
|
||||||
if (answer) socket.send(answer, from.port, from.address);
|
minPort: config.turnMinPort, maxPort: config.turnMaxPort, maxAllocations: config.maxConnections, maxPerAddress: config.maxPerAddress,
|
||||||
});
|
peerAllowed: ip => config.allowPrivate || !isPrivateAddress(ip)
|
||||||
await new Promise<void>(resolve => {
|
}, turnSecret);
|
||||||
socket.once('error', () => { socket.close(); resolve(); });
|
stunPort = turn.port;
|
||||||
socket.bind(config.stunPort!, config.stunBind, () => { socket.removeAllListeners('error'); socket.on('error', () => {}); stun = socket; stunPort = socket.address().port; resolve(); });
|
} catch { /* port taken or not permitted */ }
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -367,7 +345,7 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
|
|||||||
stunPort,
|
stunPort,
|
||||||
close: () => new Promise<void>(resolve => {
|
close: () => new Promise<void>(resolve => {
|
||||||
clearInterval(sweep);
|
clearInterval(sweep);
|
||||||
(stun as dgram.Socket | null)?.close();
|
turn?.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());
|
||||||
|
|||||||
+389
@@ -0,0 +1,389 @@
|
|||||||
|
// STUN and TURN for screen sharing between browser users (RFC 5389, RFC 5766).
|
||||||
|
// STUN tells a browser its public address so two of them can connect directly. Where that is
|
||||||
|
// not possible (mobile carriers, company networks, browsers that forbid direct UDP such as
|
||||||
|
// Vanadium) the stream goes through this relay instead: the browser reaches it over UDP or
|
||||||
|
// TCP, and the relay passes the packets on over UDP. Credentials are short-lived and come
|
||||||
|
// from the proxy's API, so only people using this site can relay through it.
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const COOKIE = 0x2112a442;
|
||||||
|
const REALM = 'mumh5';
|
||||||
|
|
||||||
|
// Message types: request, with success (| 0x100) and error (| 0x110) answers
|
||||||
|
const BINDING = 0x0001, ALLOCATE = 0x0003, REFRESH = 0x0004, SEND = 0x0016, DATA = 0x0017, CREATE_PERMISSION = 0x0008, CHANNEL_BIND = 0x0009;
|
||||||
|
const A = {
|
||||||
|
USERNAME: 0x0006, MESSAGE_INTEGRITY: 0x0008, ERROR_CODE: 0x0009, CHANNEL_NUMBER: 0x000c, LIFETIME: 0x000d, XOR_PEER_ADDRESS: 0x0012,
|
||||||
|
DATA: 0x0013, REALM: 0x0014, NONCE: 0x0015, XOR_RELAYED_ADDRESS: 0x0016, REQUESTED_ADDRESS_FAMILY: 0x0017, REQUESTED_TRANSPORT: 0x0019,
|
||||||
|
XOR_MAPPED_ADDRESS: 0x0020
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIFETIME = 600; // an allocation, unless refreshed
|
||||||
|
const PERMISSION = 300; // a peer address may send for this long after CreatePermission
|
||||||
|
const CHANNEL = 600;
|
||||||
|
const NONCE_AGE = 3600;
|
||||||
|
export const CREDENTIAL_TTL = 12 * 3600;
|
||||||
|
|
||||||
|
export interface TurnOptions {
|
||||||
|
port: number;
|
||||||
|
bind: string;
|
||||||
|
// false: answer STUN binding requests only
|
||||||
|
relay: boolean;
|
||||||
|
// Address announced for relayed traffic; found from the connection or the network interfaces when unset
|
||||||
|
publicIp: string | null;
|
||||||
|
// UDP ports used for relaying, so a firewall can open exactly these
|
||||||
|
minPort: number;
|
||||||
|
maxPort: number;
|
||||||
|
maxAllocations: number;
|
||||||
|
maxPerAddress: number;
|
||||||
|
// Peers the relay may talk to; keeps it from being used to reach private networks
|
||||||
|
peerAllowed: (ip: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loopback, private, link-local and other addresses that are not on the public internet
|
||||||
|
export function isPrivateAddress(address: string): boolean {
|
||||||
|
if (net.isIPv4(address)) {
|
||||||
|
const [a, b] = address.split('.').map(Number);
|
||||||
|
return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
||||||
|
}
|
||||||
|
const v6 = address.toLowerCase();
|
||||||
|
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
|
||||||
|
if (mapped) return isPrivateAddress(mapped[1]);
|
||||||
|
return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || /^ff/.test(v6);
|
||||||
|
}
|
||||||
|
|
||||||
|
const v4 = (address: string) => address.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, '$1');
|
||||||
|
|
||||||
|
function ipBytes(address: string): number[] | null {
|
||||||
|
const a = v4(address);
|
||||||
|
if (net.isIPv4(a)) return a.split('.').map(Number);
|
||||||
|
if (!net.isIPv6(a)) return null;
|
||||||
|
const [head, tail = ''] = a.split('%')[0].split('::');
|
||||||
|
const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
|
||||||
|
const groups = a.includes('::') ? [...h, ...new Array(8 - h.length - t.length).fill('0'), ...t] : h;
|
||||||
|
return groups.flatMap(g => { const n = parseInt(g, 16); return [n >> 8, n & 255]; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address attribute value, masked with the cookie and (for IPv6) the transaction id
|
||||||
|
export function xorAddress(address: string, port: number, header: Uint8Array): Uint8Array | null {
|
||||||
|
const bytes = ipBytes(address);
|
||||||
|
if (!bytes) return null;
|
||||||
|
const out = new Uint8Array(4 + bytes.length);
|
||||||
|
out[1] = bytes.length === 4 ? 1 : 2;
|
||||||
|
out[2] = (port >> 8) ^ 0x21;
|
||||||
|
out[3] = (port & 255) ^ 0x12;
|
||||||
|
for (let i = 0; i < bytes.length; i++) out[4 + i] = bytes[i] ^ header[4 + i];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unxorAddress(value: Uint8Array, header: Uint8Array): { ip: string; port: number } | null {
|
||||||
|
if (value.length !== 8 && value.length !== 20) return null;
|
||||||
|
const port = ((value[2] ^ 0x21) << 8) | (value[3] ^ 0x12);
|
||||||
|
const raw = [...value.subarray(4)].map((b, i) => b ^ header[4 + i]);
|
||||||
|
if (raw.length === 4) return { ip: raw.join('.'), port };
|
||||||
|
const groups: string[] = [];
|
||||||
|
for (let i = 0; i < 16; i += 2) groups.push(((raw[i] << 8) | raw[i + 1]).toString(16));
|
||||||
|
return { ip: groups.join(':'), port };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stun { type: number; header: Uint8Array; attrs: { type: number; value: Uint8Array; offset: number }[]; raw: Uint8Array }
|
||||||
|
|
||||||
|
export function parse(msg: Uint8Array): Stun | null {
|
||||||
|
if (msg.length < 20) return null;
|
||||||
|
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
|
if (view.getUint32(4) !== COOKIE || view.getUint16(2) !== msg.length - 20) return null;
|
||||||
|
const attrs: Stun['attrs'] = [];
|
||||||
|
for (let at = 20; at + 4 <= msg.length;) {
|
||||||
|
const type = view.getUint16(at), length = view.getUint16(at + 2);
|
||||||
|
if (at + 4 + length > msg.length) return null;
|
||||||
|
attrs.push({ type, value: msg.subarray(at + 4, at + 4 + length), offset: at });
|
||||||
|
at += 4 + ((length + 3) & ~3);
|
||||||
|
}
|
||||||
|
return { type: view.getUint16(0), header: msg.subarray(0, 20), attrs, raw: msg };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a message; with a key it is signed (MESSAGE-INTEGRITY), which browsers check on answers
|
||||||
|
export function build(type: number, header: Uint8Array, attrs: [number, Uint8Array][], key?: Buffer): Uint8Array {
|
||||||
|
const body = attrs.reduce((n, [, v]) => n + 4 + ((v.length + 3) & ~3), 0);
|
||||||
|
const out = new Uint8Array(20 + body + (key ? 24 : 0));
|
||||||
|
const view = new DataView(out.buffer);
|
||||||
|
view.setUint16(0, type);
|
||||||
|
out.set(header.subarray(4, 20), 4);
|
||||||
|
let at = 20;
|
||||||
|
for (const [t, v] of attrs) {
|
||||||
|
view.setUint16(at, t);
|
||||||
|
view.setUint16(at + 2, v.length);
|
||||||
|
out.set(v, at + 4);
|
||||||
|
at += 4 + ((v.length + 3) & ~3);
|
||||||
|
}
|
||||||
|
// The length field counts the integrity attribute while it is being computed
|
||||||
|
view.setUint16(2, out.length - 20);
|
||||||
|
if (key) {
|
||||||
|
view.setUint16(at, A.MESSAGE_INTEGRITY);
|
||||||
|
view.setUint16(at + 2, 20);
|
||||||
|
out.set(createHmac('sha1', key).update(out.subarray(0, at)).digest(), at + 4);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (s: string) => new TextEncoder().encode(s);
|
||||||
|
const u32 = (n: number) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, n); return b; };
|
||||||
|
const errorCode = (code: number, reason: string) => Uint8Array.from([0, 0, Math.floor(code / 100), code % 100, ...text(reason)]);
|
||||||
|
|
||||||
|
// Binding answer on its own, for the STUN-only case and the tests
|
||||||
|
export function stunResponse(msg: Uint8Array, address: string, port: number): Uint8Array | null {
|
||||||
|
const m = parse(msg);
|
||||||
|
if (!m || m.type !== BINDING) return null;
|
||||||
|
const mapped = xorAddress(address, port, m.header);
|
||||||
|
return mapped && build(BINDING | 0x100, m.header, [[A.XOR_MAPPED_ADDRESS, mapped]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The password that goes with a username, as handed out by the API and checked here
|
||||||
|
export function turnCredential(secret: string, username: string): string {
|
||||||
|
return createHmac('sha1', secret).update(username).digest('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Client { key: string; ip: string; port: number; send(bytes: Uint8Array): void; local: string | null }
|
||||||
|
interface Allocation {
|
||||||
|
client: Client;
|
||||||
|
authKey: Buffer;
|
||||||
|
relay: dgram.Socket;
|
||||||
|
relayIp: string;
|
||||||
|
permissions: Map<string, number>;
|
||||||
|
channels: Map<number, { ip: string; port: number; expires: number }>;
|
||||||
|
byPeer: Map<string, number>;
|
||||||
|
expires: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startTurn(opts: TurnOptions, secret: string): Promise<{ port: number; close(): void }> {
|
||||||
|
const allocations = new Map<string, Allocation>();
|
||||||
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const nonce = () => { const t = String(now()); return `${t}-${createHmac('sha1', secret).update(`nonce:${t}`).digest('hex').slice(0, 24)}`; };
|
||||||
|
const nonceValid = (n: string) => {
|
||||||
|
const [t, mac] = n.split('-');
|
||||||
|
return !!mac && now() - Number(t) < NONCE_AGE && mac === createHmac('sha1', secret).update(`nonce:${t}`).digest('hex').slice(0, 24);
|
||||||
|
};
|
||||||
|
|
||||||
|
function free(a: Allocation): void {
|
||||||
|
if (allocations.get(a.client.key) === a) allocations.delete(a.client.key);
|
||||||
|
try { a.relay.close(); } catch { /* already closed */ }
|
||||||
|
}
|
||||||
|
const sweep = setInterval(() => { for (const a of allocations.values()) if (a.expires < now()) free(a); }, 15000);
|
||||||
|
sweep.unref();
|
||||||
|
|
||||||
|
// The address peers must send to. Behind a 1:1 NAT (many cloud hosts) it has to be configured.
|
||||||
|
function relayAddress(family: 4 | 6, local: string | null): string | null {
|
||||||
|
if (opts.publicIp && (net.isIPv4(opts.publicIp) ? 4 : 6) === family) return opts.publicIp;
|
||||||
|
if (local && (net.isIPv4(local) ? 4 : 6) === family && !/^(0\.0\.0\.0|::)$/.test(local)) return local;
|
||||||
|
const want = family === 4 ? 'IPv4' : 'IPv6';
|
||||||
|
const found = Object.values(os.networkInterfaces()).flat().filter(i => i && i.family === want && !i.address.startsWith('fe80'));
|
||||||
|
// A public address first; Docker bridges and the like come before loopback only
|
||||||
|
return (found.find(i => !isPrivateAddress(i!.address)) ?? found.find(i => !i!.internal) ?? found[0])?.address ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindRelay(family: 4 | 6): Promise<dgram.Socket | null> {
|
||||||
|
const span = opts.maxPort - opts.minPort + 1;
|
||||||
|
const start = Math.floor(Math.random() * span);
|
||||||
|
for (let i = 0; i < Math.min(span, 64); i++) {
|
||||||
|
const socket = dgram.createSocket({ type: family === 4 ? 'udp4' : 'udp6', ipv6Only: family === 6 });
|
||||||
|
const ok = await new Promise<boolean>(resolve => {
|
||||||
|
socket.once('error', () => resolve(false));
|
||||||
|
socket.bind(opts.minPort + ((start + i) % span), () => resolve(true));
|
||||||
|
});
|
||||||
|
if (ok) { socket.removeAllListeners('error'); socket.on('error', () => {}); return socket; }
|
||||||
|
try { socket.close(); } catch { /* never opened */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checks the credentials of a request; answers with the right error and returns null otherwise
|
||||||
|
function authenticate(m: Stun, client: Client): Buffer | null {
|
||||||
|
const get = (t: number) => m.attrs.find(a => a.type === t);
|
||||||
|
const fail = (code: number, reason: string) => {
|
||||||
|
client.send(build(m.type | 0x110, m.header, [[A.ERROR_CODE, errorCode(code, reason)], [A.REALM, text(REALM)], [A.NONCE, text(nonce())]]));
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const integrity = get(A.MESSAGE_INTEGRITY);
|
||||||
|
if (!integrity) return fail(401, 'Unauthorized');
|
||||||
|
const username = get(A.USERNAME), n = get(A.NONCE);
|
||||||
|
if (!username || !n || integrity.value.length !== 20) return fail(400, 'Bad Request');
|
||||||
|
if (!nonceValid(new TextDecoder().decode(n.value))) return fail(438, 'Stale Nonce');
|
||||||
|
const user = new TextDecoder().decode(username.value);
|
||||||
|
// The username is the time the credential runs out
|
||||||
|
if (!(Number(user.split(':')[0]) > now())) return fail(401, 'Unauthorized');
|
||||||
|
const key = createHash('md5').update(`${user}:${REALM}:${turnCredential(secret, user)}`).digest();
|
||||||
|
const signed = Uint8Array.from(m.raw.subarray(0, integrity.offset));
|
||||||
|
new DataView(signed.buffer).setUint16(2, integrity.offset + 24 - 20);
|
||||||
|
const expected = createHmac('sha1', key).update(signed).digest();
|
||||||
|
if (!timingSafeEqual(expected, integrity.value)) return fail(401, 'Unauthorized');
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromPeer(a: Allocation, data: Buffer, peerIp: string, peerPort: number): void {
|
||||||
|
const ip = v4(peerIp);
|
||||||
|
if ((a.permissions.get(ip) ?? 0) < now()) return;
|
||||||
|
const channel = a.byPeer.get(`${ip}:${peerPort}`);
|
||||||
|
if (channel != null && (a.channels.get(channel)?.expires ?? 0) >= now()) {
|
||||||
|
const out = new Uint8Array(4 + data.length);
|
||||||
|
new DataView(out.buffer).setUint16(0, channel);
|
||||||
|
new DataView(out.buffer).setUint16(2, data.length);
|
||||||
|
out.set(data, 4);
|
||||||
|
return a.client.send(out);
|
||||||
|
}
|
||||||
|
const header = new Uint8Array(20);
|
||||||
|
new DataView(header.buffer).setUint32(4, COOKIE);
|
||||||
|
header.set(randomBytes(12), 8);
|
||||||
|
const peer = xorAddress(ip, peerPort, header);
|
||||||
|
if (peer) a.client.send(build(DATA, header, [[A.XOR_PEER_ADDRESS, peer], [A.DATA, data]]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(msg: Uint8Array, client: Client): Promise<void> {
|
||||||
|
// ChannelData: channel number, length, payload
|
||||||
|
if (msg.length >= 4 && (msg[0] & 0xc0) === 0x40) {
|
||||||
|
const a = allocations.get(client.key);
|
||||||
|
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
|
const bound = a?.channels.get(view.getUint16(0));
|
||||||
|
const length = view.getUint16(2);
|
||||||
|
if (a && bound && bound.expires >= now() && 4 + length <= msg.length) a.relay.send(msg.subarray(4, 4 + length), bound.port, bound.ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const m = parse(msg);
|
||||||
|
if (!m) return;
|
||||||
|
if (m.type === BINDING) {
|
||||||
|
const mapped = xorAddress(client.ip, client.port, m.header);
|
||||||
|
if (mapped) client.send(build(BINDING | 0x100, m.header, [[A.XOR_MAPPED_ADDRESS, mapped]]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!opts.relay) return;
|
||||||
|
const get = (t: number) => m.attrs.find(a => a.type === t);
|
||||||
|
const existing = allocations.get(client.key);
|
||||||
|
|
||||||
|
if (m.type === SEND) {
|
||||||
|
const peer = get(A.XOR_PEER_ADDRESS), data = get(A.DATA);
|
||||||
|
const to = peer && unxorAddress(peer.value, m.header);
|
||||||
|
if (existing && to && data && (existing.permissions.get(to.ip) ?? 0) >= now()) existing.relay.send(data.value, to.port, to.ip);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (![ALLOCATE, REFRESH, CREATE_PERMISSION, CHANNEL_BIND].includes(m.type)) return;
|
||||||
|
const key = authenticate(m, client);
|
||||||
|
if (!key) return;
|
||||||
|
const ok = (attrs: [number, Uint8Array][] = []) => client.send(build(m.type | 0x100, m.header, attrs, key));
|
||||||
|
const error = (code: number, reason: string) => client.send(build(m.type | 0x110, m.header, [[A.ERROR_CODE, errorCode(code, reason)]], key));
|
||||||
|
|
||||||
|
if (m.type === ALLOCATE) {
|
||||||
|
if (existing) return error(437, 'Allocation Mismatch');
|
||||||
|
if (get(A.REQUESTED_TRANSPORT)?.value[0] !== 17) return error(442, 'Unsupported Transport Protocol');
|
||||||
|
const family: 4 | 6 = get(A.REQUESTED_ADDRESS_FAMILY)?.value[0] === 2 ? 6 : 4;
|
||||||
|
const mine = [...allocations.values()].filter(a => a.client.ip === client.ip).length;
|
||||||
|
if (allocations.size >= opts.maxAllocations || mine >= opts.maxPerAddress) return error(486, 'Allocation Quota Reached');
|
||||||
|
const relayIp = relayAddress(family, client.local);
|
||||||
|
const relay = relayIp ? await bindRelay(family) : null;
|
||||||
|
if (!relay || !relayIp) return error(508, 'Insufficient Capacity');
|
||||||
|
// Another request for the same client may have won while the port was being opened
|
||||||
|
if (allocations.has(client.key)) { relay.close(); return error(437, 'Allocation Mismatch'); }
|
||||||
|
const a: Allocation = { client, authKey: key, relay, relayIp, permissions: new Map(), channels: new Map(), byPeer: new Map(), expires: now() + LIFETIME };
|
||||||
|
allocations.set(client.key, a);
|
||||||
|
relay.on('message', (data, from) => fromPeer(a, data, from.address, from.port));
|
||||||
|
return ok([
|
||||||
|
[A.XOR_RELAYED_ADDRESS, xorAddress(relayIp, relay.address().port, m.header)!],
|
||||||
|
[A.LIFETIME, u32(LIFETIME)],
|
||||||
|
[A.XOR_MAPPED_ADDRESS, xorAddress(client.ip, client.port, m.header)!]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (!existing) return error(437, 'Allocation Mismatch');
|
||||||
|
|
||||||
|
if (m.type === REFRESH) {
|
||||||
|
const wanted = get(A.LIFETIME);
|
||||||
|
const seconds = wanted ? Math.min(new DataView(wanted.value.buffer, wanted.value.byteOffset).getUint32(0), LIFETIME) : LIFETIME;
|
||||||
|
if (seconds === 0) free(existing);
|
||||||
|
else existing.expires = now() + seconds;
|
||||||
|
return ok([[A.LIFETIME, u32(seconds)]]);
|
||||||
|
}
|
||||||
|
const peers = m.attrs.filter(a => a.type === A.XOR_PEER_ADDRESS).map(a => unxorAddress(a.value, m.header));
|
||||||
|
if (!peers.length || peers.some(p => !p)) return error(400, 'Bad Request');
|
||||||
|
// Other clients of this relay are reached at its own address
|
||||||
|
if (peers.some(p => p!.ip !== existing.relayIp && !opts.peerAllowed(p!.ip))) return error(403, 'Forbidden');
|
||||||
|
|
||||||
|
if (m.type === CREATE_PERMISSION) {
|
||||||
|
for (const p of peers) existing.permissions.set(p!.ip, now() + PERMISSION);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
// ChannelBind
|
||||||
|
const number = get(A.CHANNEL_NUMBER);
|
||||||
|
const channel = number ? new DataView(number.value.buffer, number.value.byteOffset).getUint16(0) : 0;
|
||||||
|
const peer = peers[0]!;
|
||||||
|
const peerKey = `${peer.ip}:${peer.port}`;
|
||||||
|
if (channel < 0x4000 || channel > 0x7ffe) return error(400, 'Bad Request');
|
||||||
|
const boundTo = existing.channels.get(channel), boundAs = existing.byPeer.get(peerKey);
|
||||||
|
if ((boundTo && `${boundTo.ip}:${boundTo.port}` !== peerKey) || (boundAs != null && boundAs !== channel)) return error(400, 'Bad Request');
|
||||||
|
existing.channels.set(channel, { ...peer, expires: now() + CHANNEL });
|
||||||
|
existing.byPeer.set(peerKey, channel);
|
||||||
|
existing.permissions.set(peer.ip, now() + PERMISSION);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// UDP: one message per packet
|
||||||
|
const udp = dgram.createSocket(net.isIPv4(opts.bind) ? 'udp4' : 'udp6');
|
||||||
|
udp.on('message', (msg, from) => {
|
||||||
|
const ip = v4(from.address);
|
||||||
|
handle(msg, { key: `udp:${ip}:${from.port}`, ip, port: from.port, local: null, send: bytes => udp.send(bytes, from.port, from.address) }).catch(() => {});
|
||||||
|
});
|
||||||
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
|
udp.once('error', reject);
|
||||||
|
udp.bind(opts.port, opts.bind, () => { udp.removeAllListeners('error'); udp.on('error', () => {}); resolve(udp.address().port); });
|
||||||
|
});
|
||||||
|
|
||||||
|
// TCP, for browsers and networks that allow no UDP: the same messages, one after another, padded to 4 bytes
|
||||||
|
const tcp = net.createServer(socket => {
|
||||||
|
const ip = v4(socket.remoteAddress ?? ''), remotePort = socket.remotePort ?? 0;
|
||||||
|
const client: Client = {
|
||||||
|
key: `tcp:${ip}:${remotePort}`, ip, port: remotePort, local: socket.localAddress ? v4(socket.localAddress) : null,
|
||||||
|
send: bytes => {
|
||||||
|
if (socket.destroyed) return;
|
||||||
|
// A slow reader must not grow our memory; it loses packets instead, like on UDP
|
||||||
|
if (socket.writableLength > 4 * 1024 * 1024) return;
|
||||||
|
const padded = (bytes.length + 3) & ~3;
|
||||||
|
socket.write(padded === bytes.length ? bytes : Buffer.concat([bytes, Buffer.alloc(padded - bytes.length)]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let pending: Buffer = Buffer.alloc(0);
|
||||||
|
socket.setNoDelay(true);
|
||||||
|
socket.setTimeout(LIFETIME * 1000, () => socket.destroy());
|
||||||
|
socket.on('data', (chunk: Buffer) => {
|
||||||
|
pending = pending.length ? Buffer.concat([pending, chunk]) : chunk;
|
||||||
|
while (pending.length >= 4) {
|
||||||
|
const kind = pending[0] & 0xc0;
|
||||||
|
if (kind !== 0x00 && kind !== 0x40) return socket.destroy();
|
||||||
|
const length = (kind === 0x00 ? 20 : 4) + pending.readUInt16BE(2);
|
||||||
|
const framed = (length + 3) & ~3;
|
||||||
|
if (length > 65536) return socket.destroy();
|
||||||
|
if (pending.length < framed) break;
|
||||||
|
handle(Uint8Array.from(pending.subarray(0, length)), client).catch(() => {});
|
||||||
|
pending = pending.subarray(framed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', () => {});
|
||||||
|
socket.on('close', () => { const a = allocations.get(client.key); if (a) free(a); });
|
||||||
|
});
|
||||||
|
if (opts.relay) {
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
// Relaying over UDP still works without the TCP port
|
||||||
|
tcp.once('error', () => resolve());
|
||||||
|
tcp.listen({ port, host: opts.bind }, () => { tcp.removeAllListeners('error'); tcp.on('error', () => {}); resolve(); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
close() {
|
||||||
|
clearInterval(sweep);
|
||||||
|
for (const a of [...allocations.values()]) free(a);
|
||||||
|
try { udp.close(); } catch { /* already closed */ }
|
||||||
|
tcp.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+23
-8
@@ -6,7 +6,7 @@ import { SHARE, SHARE_DATA_ID, encodeShare, ShareAssembler, type ShareType } fro
|
|||||||
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, isWeb } from './native.ts';
|
import { desktop, isWeb } from './native.ts';
|
||||||
import { proxyStun } from './web.svelte.ts';
|
import { proxyIce } 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';
|
||||||
import { sounds } from './audio/sounds.svelte.ts';
|
import { sounds } from './audio/sounds.svelte.ts';
|
||||||
@@ -55,12 +55,23 @@ class ScreenShare {
|
|||||||
return !!s.client && s.client.serverVersionNum >= 0x010400 && typeof RTCPeerConnection !== 'undefined';
|
return !!s.client && s.client.serverVersionNum >= 0x010400 && typeof RTCPeerConnection !== 'undefined';
|
||||||
}
|
}
|
||||||
|
|
||||||
private iceServers(): RTCIceServer[] {
|
private async iceServers(): Promise<RTCIceServer[]> {
|
||||||
const stun = store.settings.stunServer.trim();
|
const stun = store.settings.stunServer.trim();
|
||||||
if (stun) return [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }];
|
const chosen: RTCIceServer[] = stun ? [{ urls: /^stuns?:/.test(stun) ? stun : `stun:${stun}` }] : [];
|
||||||
// The browser build falls back to the proxy it is served from
|
// The browser build also has the proxy it is served from: its STUN, and its relay for
|
||||||
const own = isWeb ? proxyStun() : null;
|
// networks and browsers that allow no direct connection
|
||||||
return own ? [{ urls: own }] : [];
|
return isWeb ? [...chosen, ...await proxyIce()] : chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For testing the relay: localStorage mumh5.iceDebug = "relay" uses only relayed routes,
|
||||||
|
// "relay-tcp" only the relay reached over TCP (what a browser without direct UDP is left with)
|
||||||
|
private async rtcConfig(): Promise<RTCConfiguration> {
|
||||||
|
const iceServers = await this.iceServers();
|
||||||
|
let debug = '';
|
||||||
|
try { debug = localStorage.getItem('mumh5.iceDebug') ?? ''; } catch { /* storage unavailable */ }
|
||||||
|
if (!debug.startsWith('relay')) return { iceServers };
|
||||||
|
const tcpOnly = (urls: string | string[]) => [urls].flat().filter(u => !u.startsWith('turn') || u.includes('transport=tcp'));
|
||||||
|
return { iceTransportPolicy: 'relay', iceServers: debug === 'relay-tcp' ? iceServers.map(s => ({ ...s, urls: tcpOnly(s.urls) })) : iceServers };
|
||||||
}
|
}
|
||||||
|
|
||||||
private others(s: Session): number[] {
|
private others(s: Session): number[] {
|
||||||
@@ -198,9 +209,11 @@ class ScreenShare {
|
|||||||
private async offerTo(s: Session, viewer: number): Promise<void> {
|
private async offerTo(s: Session, viewer: number): Promise<void> {
|
||||||
const stream = this.stream;
|
const stream = this.stream;
|
||||||
if (!stream || this.host !== s) return;
|
if (!stream || this.host !== s) return;
|
||||||
|
const config = await this.rtcConfig();
|
||||||
|
if (this.stream !== stream || this.host !== s) return;
|
||||||
this.dropPeer(viewer);
|
this.dropPeer(viewer);
|
||||||
if (this.peers.size >= MAX_VIEWERS) return;
|
if (this.peers.size >= MAX_VIEWERS) return;
|
||||||
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
|
const pc = new RTCPeerConnection(config);
|
||||||
this.peers.set(viewer, pc);
|
this.peers.set(viewer, pc);
|
||||||
this.viewers = this.peers.size;
|
this.viewers = this.peers.size;
|
||||||
pc.addEventListener('connectionstatechange', () => {
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
@@ -227,8 +240,10 @@ class ScreenShare {
|
|||||||
private async answerTo(s: Session, sharer: number, sdp: string): Promise<void> {
|
private async answerTo(s: Session, sharer: number, sdp: string): Promise<void> {
|
||||||
const w = this.watching;
|
const w = this.watching;
|
||||||
if (!w || w.host !== s || w.session !== sharer) return;
|
if (!w || w.host !== s || w.session !== sharer) return;
|
||||||
|
const config = await this.rtcConfig();
|
||||||
|
if (this.watching?.host !== s || this.watching.session !== sharer) return;
|
||||||
this.watchPc?.close();
|
this.watchPc?.close();
|
||||||
const pc = new RTCPeerConnection({ iceServers: this.iceServers() });
|
const pc = new RTCPeerConnection(config);
|
||||||
this.watchPc = pc;
|
this.watchPc = pc;
|
||||||
// The track is announced with the description, before any route exists: the stream only
|
// The track is announced with the description, before any route exists: the stream only
|
||||||
// counts as live once the connection is up, otherwise the viewer stares at a black picture
|
// counts as live once the connection is up, otherwise the viewer stares at a black picture
|
||||||
|
|||||||
+24
-5
@@ -137,15 +137,18 @@ class ProxyInfo {
|
|||||||
any = $state(false);
|
any = $state(false);
|
||||||
// UDP port of the proxy's STUN responder, if it runs one
|
// UDP port of the proxy's STUN responder, if it runs one
|
||||||
stun = $state<number | null>(null);
|
stun = $state<number | null>(null);
|
||||||
|
// Whether it also relays streams for browsers that cannot connect directly
|
||||||
|
turn = $state(false);
|
||||||
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; stun?: number | null }>('config');
|
const c = await call<{ servers: ProxyServer[]; any: boolean; stun?: number | null; turn?: boolean }>('config');
|
||||||
this.servers = c.servers;
|
this.servers = c.servers;
|
||||||
this.any = c.any;
|
this.any = c.any;
|
||||||
this.stun = c.stun ?? null;
|
this.stun = c.stun ?? null;
|
||||||
|
this.turn = !!c.turn;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.error = (e as Error).message;
|
this.error = (e as Error).message;
|
||||||
@@ -155,8 +158,24 @@ 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
|
// STUN and relay on the proxy itself: a host the user already uses, so nothing new is contacted.
|
||||||
export function proxyStun(): string | null {
|
// The relay needs credentials, which the proxy hands out for a few hours at a time.
|
||||||
if (!proxyInfo.stun || !base) return null;
|
let relay: { servers: RTCIceServer[]; until: number } | null = null;
|
||||||
try { return `stun:${new URL(base).hostname}:${proxyInfo.stun}`; } catch { return null; }
|
export async function proxyIce(): Promise<RTCIceServer[]> {
|
||||||
|
if (!proxyInfo.loaded) await proxyInfo.load();
|
||||||
|
if (!proxyInfo.stun || !base) return [];
|
||||||
|
let host: string;
|
||||||
|
try { host = new URL(base).hostname; } catch { return []; }
|
||||||
|
const stun: RTCIceServer = { urls: `stun:${host}:${proxyInfo.stun}` };
|
||||||
|
if (!proxyInfo.turn) return [stun];
|
||||||
|
if (relay && relay.until > Date.now()) return relay.servers;
|
||||||
|
try {
|
||||||
|
const c = await call<{ username: string; credential: string; ttl: number; port: number }>('turn', {});
|
||||||
|
// UDP where it is allowed, TCP for networks and browsers that forbid it
|
||||||
|
const servers = [stun, { urls: [`turn:${host}:${c.port}?transport=udp`, `turn:${host}:${c.port}?transport=tcp`], username: c.username, credential: c.credential }];
|
||||||
|
relay = { servers, until: Date.now() + Math.max(60, c.ttl - 3600) * 1000 };
|
||||||
|
return servers;
|
||||||
|
} catch {
|
||||||
|
return [stun];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,17 @@
|
|||||||
const pair = (transport?.selectedCandidatePairId && report.get(transport.selectedCandidatePairId)) || all.find(s => s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded');
|
const pair = (transport?.selectedCandidatePairId && report.get(transport.selectedCandidatePairId)) || all.find(s => s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded');
|
||||||
if (pair) {
|
if (pair) {
|
||||||
const local = report.get(pair.localCandidateId), remote = report.get(pair.remoteCandidateId);
|
const local = report.get(pair.localCandidateId), remote = report.get(pair.remoteCandidateId);
|
||||||
rows.push(['Connection', `${KIND[local?.candidateType] ?? local?.candidateType ?? 'n/a'}, ${String(local?.protocol ?? '').toUpperCase()}`]);
|
// What this side had to offer. A route discovered during the checks (prflx) is still a
|
||||||
|
// relayed one when relays were all we had.
|
||||||
|
const mine = all.filter(s => s.type === 'local-candidate' && s.candidateType !== 'prflx');
|
||||||
|
const relays = mine.filter(s => s.candidateType === 'relay');
|
||||||
|
const relayed = local?.candidateType === 'relay' || (local?.candidateType === 'prflx' && relays.length > 0 && relays.length === mine.length);
|
||||||
|
const relayVia = (local?.relayProtocol ?? relays[0]?.relayProtocol) as string | undefined;
|
||||||
|
// A relayed route is UDP beyond the relay; what matters is how we reach the relay
|
||||||
|
rows.push(['Connection', relayed ? `${KIND.relay}${relayVia ? `, reached over ${relayVia.toUpperCase()}` : ''}`
|
||||||
|
: `${KIND[local?.candidateType] ?? local?.candidateType ?? 'n/a'}, ${String(local?.protocol ?? '').toUpperCase()}`]);
|
||||||
|
const count = (type: string) => mine.filter(s => s.candidateType === type).length;
|
||||||
|
rows.push(['Routes offered', `${count('host')} direct, ${count('srflx')} through STUN, ${relays.length} relayed`]);
|
||||||
if (remote?.candidateType) rows.push(['Other side', KIND[remote.candidateType] ?? remote.candidateType]);
|
if (remote?.candidateType) rows.push(['Other side', KIND[remote.candidateType] ?? remote.candidateType]);
|
||||||
rows.push(['Round trip', ms(pair.currentRoundTripTime)]);
|
rows.push(['Round trip', ms(pair.currentRoundTripTime)]);
|
||||||
if (pair.availableOutgoingBitrate) rows.push(['Estimated capacity', `${Math.round(pair.availableOutgoingBitrate / 1000)} kbit/s`]);
|
if (pair.availableOutgoingBitrate) rows.push(['Estimated capacity', `${Math.round(pair.availableOutgoingBitrate / 1000)} kbit/s`]);
|
||||||
|
|||||||
+17
-2
@@ -42,7 +42,9 @@ 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`, MUMH5_STUN_PORT: String(proxyPort + 1000), MUMH5_STUN_BIND: '127.0.0.1' }, 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',
|
||||||
|
// Everything is on this machine, so the relay has to be allowed to reach loopback
|
||||||
|
MUMH5_ALLOW_PRIVATE: '1', MUMH5_TURN_IP: '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(); });
|
||||||
@@ -184,6 +186,9 @@ try {
|
|||||||
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
|
await page.waitForFunction(() => (document.querySelector('.preview video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 60000 });
|
||||||
await dialog.getByRole('button', { name: 'Start sharing' }).click();
|
await dialog.getByRole('button', { name: 'Start sharing' }).click();
|
||||||
await page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
|
await page.locator('.stage .status', { hasText: '0 watching' }).waitFor();
|
||||||
|
// The viewer is limited to what a browser without direct UDP has (Vanadium's default): only
|
||||||
|
// the proxy's relay, reached over TCP
|
||||||
|
await page2.evaluate(() => localStorage.setItem('mumh5.iceDebug', 'relay-tcp'));
|
||||||
await page2.locator('.stage .tile', { hasText: name }).click();
|
await page2.locator('.stage .tile', { hasText: name }).click();
|
||||||
await page2.getByRole('button', { name: 'Continue' }).click();
|
await page2.getByRole('button', { name: 'Continue' }).click();
|
||||||
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
|
await page2.waitForFunction(() => (document.querySelector('.spot video') as HTMLVideoElement | null)?.videoWidth! > 0, null, { timeout: 30000 });
|
||||||
@@ -195,7 +200,17 @@ try {
|
|||||||
return !!v && v.videoWidth > 0 && !v.paused && v.getBoundingClientRect().height > 100 && v.getBoundingClientRect().width > 300;
|
return !!v && v.videoWidth > 0 && !v.paused && v.getBoundingClientRect().height > 100 && v.getBoundingClientRect().width > 300;
|
||||||
}, null, { timeout: 15000 });
|
}, null, { timeout: 15000 });
|
||||||
if (process.env.SHOTS_DIR) await page2.screenshot({ path: `${process.env.SHOTS_DIR}/phone.png` });
|
if (process.env.SHOTS_DIR) await page2.screenshot({ path: `${process.env.SHOTS_DIR}/phone.png` });
|
||||||
console.log('ok: screen sharing between two browsers');
|
// The statistics confirm which way the stream came
|
||||||
|
await page2.setViewportSize({ width: 1280, height: 800 });
|
||||||
|
await page2.locator('.spot video').click({ button: 'right' });
|
||||||
|
await page2.getByRole('menuitem', { name: 'Stats for nerds' }).click();
|
||||||
|
const stats = page2.getByRole('status', { name: 'Stream statistics' });
|
||||||
|
await stats.getByText(/^(direct|relayed)/).first().waitFor({ timeout: 8000 });
|
||||||
|
const route = (await stats.innerText()).replace(/\n+/g, ' | ');
|
||||||
|
assert.match(route, /relayed \(TURN\), reached over TCP/, route);
|
||||||
|
assert.match(route, /0 direct, 0 through STUN, [1-9]\d* relayed/, route);
|
||||||
|
await stats.getByText(/^\d+ kbit\/s$/).first().waitFor({ timeout: 8000 });
|
||||||
|
console.log('ok: screen sharing to a browser without direct UDP, through the relay over TCP');
|
||||||
|
|
||||||
console.log('WEB E2E PASSED');
|
console.log('WEB E2E PASSED');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+7
-1
@@ -50,7 +50,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, stun: null });
|
assert.deepEqual(await (await fetch(`${base}/api/config`)).json(), { servers: [{ host: 'voice.example.org', port: 64738, label: 'Friends' }], any: false, stun: null, turn: false });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,6 +108,12 @@ test('the proxy answers STUN over UDP and announces the port', async () => {
|
|||||||
setTimeout(() => reject(new Error('no STUN answer')), 3000);
|
setTimeout(() => reject(new Error('no STUN answer')), 3000);
|
||||||
});
|
});
|
||||||
assert.equal(((answer[26] << 8) | answer[27]) ^ 0x2112, client.address().port);
|
assert.equal(((answer[26] << 8) | answer[27]) ^ 0x2112, client.address().port);
|
||||||
|
// Relay credentials for people on this site: a username that expires and its password
|
||||||
|
const base = `http://127.0.0.1:${proxy.port}`;
|
||||||
|
assert.equal((await (await fetch(`${base}/api/config`)).json()).turn, true);
|
||||||
|
const cred = await (await post(base, 'turn', {})).json();
|
||||||
|
assert.ok(Number(cred.username) > Date.now() / 1000 && cred.credential.length > 20 && cred.port === proxy.stunPort);
|
||||||
|
assert.equal((await post(base, 'turn', {}, { Origin: 'https://evil.example' })).status, 200, 'origins are open in this test');
|
||||||
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
|
assert.deepEqual([...answer.subarray(28)].map((b, i) => b ^ [0x21, 0x12, 0xa4, 0x42][i]), [127, 0, 0, 1]);
|
||||||
client.close();
|
client.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// The relay (TURN) in the web proxy, driven by a small client written here: the same steps a
|
||||||
|
// browser takes, over UDP and over TCP.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import dgram from 'node:dgram';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { startTurn, turnCredential, build, parse, xorAddress, unxorAddress, type TurnOptions } from '../server/turn.ts';
|
||||||
|
|
||||||
|
const SECRET = 'test-secret';
|
||||||
|
const options = (over: Partial<TurnOptions> = {}): TurnOptions => ({
|
||||||
|
port: 0, bind: '127.0.0.1', relay: true, publicIp: '127.0.0.1', minPort: 41000, maxPort: 41999, maxAllocations: 10, maxPerAddress: 4, peerAllowed: () => true, ...over
|
||||||
|
});
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
const header = () => { const h = new Uint8Array(20); new DataView(h.buffer).setUint32(4, 0x2112a442); h[19] = ++counter; h[18] = counter >> 8; return h; };
|
||||||
|
const text = (s: string) => new TextEncoder().encode(s);
|
||||||
|
const attr = (m: NonNullable<ReturnType<typeof parse>>, type: number) => m.attrs.find(a => a.type === type)?.value;
|
||||||
|
const code = (m: NonNullable<ReturnType<typeof parse>>) => { const e = attr(m, 0x0009); return e ? e[2] * 100 + e[3] : 0; };
|
||||||
|
|
||||||
|
// One client connection to the relay, UDP or TCP
|
||||||
|
async function connect(port: number, transport: 'udp' | 'tcp') {
|
||||||
|
const inbox: Uint8Array[] = [];
|
||||||
|
const waiting: ((m: Uint8Array) => void)[] = [];
|
||||||
|
const deliver = (m: Uint8Array) => { const w = waiting.shift(); if (w) w(m); else inbox.push(m); };
|
||||||
|
const next = () => new Promise<Uint8Array>((resolve, reject) => {
|
||||||
|
const m = inbox.shift();
|
||||||
|
if (m) return resolve(m);
|
||||||
|
const timer = setTimeout(() => reject(new Error('no answer from the relay')), 3000);
|
||||||
|
waiting.push(v => { clearTimeout(timer); resolve(v); });
|
||||||
|
});
|
||||||
|
if (transport === 'udp') {
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
socket.on('message', deliver);
|
||||||
|
await new Promise<void>(r => socket.bind(0, '127.0.0.1', r));
|
||||||
|
return { next, send: (b: Uint8Array) => socket.send(b, port, '127.0.0.1'), close: () => socket.close() };
|
||||||
|
}
|
||||||
|
const socket = net.connect(port, '127.0.0.1');
|
||||||
|
await new Promise<void>((r, j) => { socket.once('connect', () => r()); socket.once('error', j); });
|
||||||
|
let pending: Buffer = Buffer.alloc(0);
|
||||||
|
socket.on('data', (chunk: Buffer) => {
|
||||||
|
pending = Buffer.concat([pending, chunk]);
|
||||||
|
while (pending.length >= 4) {
|
||||||
|
const length = ((pending[0] & 0xc0) === 0 ? 20 : 4) + pending.readUInt16BE(2);
|
||||||
|
const framed = (length + 3) & ~3;
|
||||||
|
if (pending.length < framed) break;
|
||||||
|
deliver(Uint8Array.from(pending.subarray(0, length)));
|
||||||
|
pending = pending.subarray(framed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
next,
|
||||||
|
send: (b: Uint8Array) => { const padded = (b.length + 3) & ~3; socket.write(Buffer.concat([b, Buffer.alloc(padded - b.length)])); },
|
||||||
|
close: () => socket.destroy()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate: asked for credentials first, then granted
|
||||||
|
async function allocate(c: Awaited<ReturnType<typeof connect>>, secret = SECRET) {
|
||||||
|
const transport: [number, Uint8Array] = [0x0019, Uint8Array.from([17, 0, 0, 0])];
|
||||||
|
c.send(build(0x0003, header(), [transport]));
|
||||||
|
const challenge = parse(await c.next())!;
|
||||||
|
assert.equal(challenge.type, 0x0113);
|
||||||
|
assert.equal(code(challenge), 401);
|
||||||
|
const realm = attr(challenge, 0x0014)!, nonce = attr(challenge, 0x0015)!;
|
||||||
|
const username = String(Math.floor(Date.now() / 1000) + 600);
|
||||||
|
const key = createHash('md5').update(`${username}:mumh5:${turnCredential(secret, username)}`).digest();
|
||||||
|
const auth: [number, Uint8Array][] = [[0x0006, text(username)], [0x0014, realm], [0x0015, nonce]];
|
||||||
|
const h = header();
|
||||||
|
c.send(build(0x0003, h, [transport, ...auth], key));
|
||||||
|
const answer = parse(await c.next())!;
|
||||||
|
return { answer, key, auth, relay: answer.type === 0x0103 ? unxorAddress(attr(answer, 0x0016)!, h) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transport of ['udp', 'tcp'] as const) {
|
||||||
|
test(`relays between a client and a peer over ${transport}`, async () => {
|
||||||
|
const turn = await startTurn(options(), SECRET);
|
||||||
|
const client = await connect(turn.port, transport);
|
||||||
|
const peer = dgram.createSocket('udp4');
|
||||||
|
await new Promise<void>(r => peer.bind(0, '127.0.0.1', r));
|
||||||
|
const peerPort = peer.address().port;
|
||||||
|
const atPeer: { data: string; port: number }[] = [];
|
||||||
|
let peerGot: (() => void) | null = null;
|
||||||
|
peer.on('message', (m, from) => { atPeer.push({ data: m.toString(), port: from.port }); peerGot?.(); });
|
||||||
|
const peerNext = () => atPeer.length ? Promise.resolve() : new Promise<void>((r, j) => { peerGot = r; setTimeout(() => j(new Error('peer got nothing')), 3000); });
|
||||||
|
try {
|
||||||
|
const { answer, key, auth, relay } = await allocate(client);
|
||||||
|
assert.equal(answer.type, 0x0103);
|
||||||
|
assert.equal(relay!.ip, '127.0.0.1');
|
||||||
|
assert.ok(relay!.port >= 41000 && relay!.port <= 41999, 'relay port within the configured range');
|
||||||
|
// The answer is signed, which browsers insist on
|
||||||
|
assert.ok(attr(answer, 0x0008), 'message integrity on the answer');
|
||||||
|
|
||||||
|
// Without permission, nothing gets through in either direction
|
||||||
|
let h = header();
|
||||||
|
const peerAddr = (hd: Uint8Array): [number, Uint8Array] => [0x0012, xorAddress('127.0.0.1', peerPort, hd)!];
|
||||||
|
client.send(build(0x0016, h, [peerAddr(h), [0x0013, text('too early')]]));
|
||||||
|
peer.send('unwanted', relay!.port, '127.0.0.1');
|
||||||
|
// Let both arrive (and be dropped) before permission is given
|
||||||
|
await new Promise(r => setTimeout(r, 150));
|
||||||
|
assert.equal(atPeer.length, 0, 'nothing reaches the peer without permission');
|
||||||
|
|
||||||
|
h = header();
|
||||||
|
client.send(build(0x0008, h, [peerAddr(h), ...auth], key));
|
||||||
|
assert.equal(parse(await client.next())!.type, 0x0108);
|
||||||
|
|
||||||
|
// Send indication out, data indication back
|
||||||
|
h = header();
|
||||||
|
client.send(build(0x0016, h, [peerAddr(h), [0x0013, text('hello peer')]]));
|
||||||
|
await peerNext();
|
||||||
|
assert.deepEqual(atPeer.map(p => p.data), ['hello peer']);
|
||||||
|
assert.equal(atPeer[0].port, relay!.port, 'the peer sees the relay as the sender');
|
||||||
|
peer.send('hello client', relay!.port, '127.0.0.1');
|
||||||
|
const indication = parse(await client.next())!;
|
||||||
|
assert.equal(indication.type, 0x0017);
|
||||||
|
assert.equal(new TextDecoder().decode(attr(indication, 0x0013)!), 'hello client');
|
||||||
|
assert.deepEqual(unxorAddress(attr(indication, 0x0012)!, indication.header), { ip: '127.0.0.1', port: peerPort });
|
||||||
|
|
||||||
|
// Channel: the compact framing browsers switch to for media
|
||||||
|
h = header();
|
||||||
|
client.send(build(0x0009, h, [[0x000c, Uint8Array.from([0x40, 0x01, 0, 0])], peerAddr(h), ...auth], key));
|
||||||
|
assert.equal(parse(await client.next())!.type, 0x0109);
|
||||||
|
atPeer.length = 0;
|
||||||
|
client.send(Uint8Array.from([0x40, 0x01, 0, 5, ...text('media')]));
|
||||||
|
await peerNext();
|
||||||
|
assert.equal(atPeer[0].data, 'media');
|
||||||
|
peer.send('frames', relay!.port, '127.0.0.1');
|
||||||
|
const data = await client.next();
|
||||||
|
assert.deepEqual([...data.subarray(0, 4)], [0x40, 0x01, 0, 6]);
|
||||||
|
assert.equal(new TextDecoder().decode(data.subarray(4)), 'frames');
|
||||||
|
|
||||||
|
// Refresh with lifetime 0 ends the allocation
|
||||||
|
h = header();
|
||||||
|
client.send(build(0x0004, h, [[0x000d, Uint8Array.from([0, 0, 0, 0])], ...auth], key));
|
||||||
|
assert.equal(parse(await client.next())!.type, 0x0104);
|
||||||
|
h = header();
|
||||||
|
client.send(build(0x0008, h, [peerAddr(h), ...auth], key));
|
||||||
|
assert.equal(code(parse(await client.next())!), 437);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
peer.close();
|
||||||
|
turn.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('wrong credentials and forbidden peers are refused', async () => {
|
||||||
|
const turn = await startTurn(options({ peerAllowed: ip => ip !== '10.1.2.3' }), SECRET);
|
||||||
|
const client = await connect(turn.port, 'udp');
|
||||||
|
try {
|
||||||
|
const bad = await allocate(client, 'another-secret');
|
||||||
|
assert.equal(code(bad.answer), 401);
|
||||||
|
const { answer, key, auth } = await allocate(client);
|
||||||
|
assert.equal(answer.type, 0x0103);
|
||||||
|
const h = header();
|
||||||
|
client.send(build(0x0008, h, [[0x0012, xorAddress('10.1.2.3', 9, h)!], ...auth], key));
|
||||||
|
assert.equal(code(parse(await client.next())!), 403);
|
||||||
|
// A second allocation for the same client
|
||||||
|
const again = await allocate(client);
|
||||||
|
assert.equal(code(again.answer), 437);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
turn.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota per address', async () => {
|
||||||
|
const turn = await startTurn(options({ maxPerAddress: 1 }), SECRET);
|
||||||
|
const a = await connect(turn.port, 'udp'), b = await connect(turn.port, 'udp');
|
||||||
|
try {
|
||||||
|
assert.equal((await allocate(a)).answer.type, 0x0103);
|
||||||
|
assert.equal(code((await allocate(b)).answer), 486);
|
||||||
|
} finally {
|
||||||
|
a.close();
|
||||||
|
b.close();
|
||||||
|
turn.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('STUN only: binding is answered, allocation is ignored', async () => {
|
||||||
|
const turn = await startTurn(options({ relay: false }), SECRET);
|
||||||
|
const client = await connect(turn.port, 'udp');
|
||||||
|
try {
|
||||||
|
client.send(build(0x0001, header(), []));
|
||||||
|
assert.equal(parse(await client.next())!.type, 0x0101);
|
||||||
|
client.send(build(0x0003, header(), [[0x0019, Uint8Array.from([17, 0, 0, 0])]]));
|
||||||
|
await assert.rejects(client.next(), /no answer/);
|
||||||
|
} finally {
|
||||||
|
client.close();
|
||||||
|
turn.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user