Screen sharing #15
@@ -191,6 +191,8 @@ For screen sharing, open these in the firewall (and forward them on a router in
|
|||||||
| `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_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` | 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-49659` | UDP ports the relayed streams use, one per relayed route |
|
| `MUMH5_TURN_PORTS` | `49160-49659` | UDP ports the relayed streams use, one per relayed route |
|
||||||
|
| `MUMH5_TURN_URLS` | none | Extra relay addresses handed to clients, comma-separated, for example `turns:turn.example.com:443?transport=tcp` when a TLS front forwards to port 3478. For networks that only let port 443 out |
|
||||||
|
| `MUMH5_DEBUG` | off | Log relay events (credentials handed out, routes granted or refused) |
|
||||||
| `MUMH5_TURN_MAX`, `MUMH5_TURN_PER_ADDRESS` | `500`, `64` | Relayed routes in total and per client address. A camera round uses several per person |
|
| `MUMH5_TURN_MAX`, `MUMH5_TURN_PER_ADDRESS` | `500`, `64` | Relayed routes in total and per client address. A camera round uses several per person |
|
||||||
| `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_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 |
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
MUMH5_TURN: ${MUMH5_TURN:-}
|
MUMH5_TURN: ${MUMH5_TURN:-}
|
||||||
MUMH5_TURN_IP: ${MUMH5_TURN_IP:-}
|
MUMH5_TURN_IP: ${MUMH5_TURN_IP:-}
|
||||||
MUMH5_TURN_PORTS: ${MUMH5_TURN_PORTS:-}
|
MUMH5_TURN_PORTS: ${MUMH5_TURN_PORTS:-}
|
||||||
|
MUMH5_TURN_URLS: ${MUMH5_TURN_URLS:-}
|
||||||
|
MUMH5_DEBUG: ${MUMH5_DEBUG:-}
|
||||||
MUMH5_TURN_MAX: ${MUMH5_TURN_MAX:-}
|
MUMH5_TURN_MAX: ${MUMH5_TURN_MAX:-}
|
||||||
MUMH5_TURN_PER_ADDRESS: ${MUMH5_TURN_PER_ADDRESS:-}
|
MUMH5_TURN_PER_ADDRESS: ${MUMH5_TURN_PER_ADDRESS:-}
|
||||||
MUMH5_MAX_CONNECTIONS: ${MUMH5_MAX_CONNECTIONS:-}
|
MUMH5_MAX_CONNECTIONS: ${MUMH5_MAX_CONNECTIONS:-}
|
||||||
|
|||||||
+11
-3
@@ -46,13 +46,17 @@ export interface ProxyConfig {
|
|||||||
// interface, and a camera round has a stream per pair of people, so these run high.
|
// interface, and a camera round has a stream per pair of people, so these run high.
|
||||||
turnMax: number;
|
turnMax: number;
|
||||||
turnPerAddress: number;
|
turnPerAddress: number;
|
||||||
|
// Addresses to hand to clients instead of this host's port, e.g. a TLS front on port 443
|
||||||
|
turnUrls: string[];
|
||||||
|
// Log relay events
|
||||||
|
debug: boolean;
|
||||||
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: '::', turn: true, turnIp: null, turnMinPort: 49160, turnMaxPort: 49659, turnMax: 500, turnPerAddress: 64, maxConnections: 200, maxPerAddress: 8
|
trustProxy: false, sendProxy: false, staticDir: null, stunPort: null, stunBind: '::', turn: true, turnIp: null, turnMinPort: 49160, turnMaxPort: 49659, turnMax: 500, turnPerAddress: 64, turnUrls: [], debug: false, maxConnections: 200, maxPerAddress: 8
|
||||||
};
|
};
|
||||||
|
|
||||||
// "host", "host:port", "[v6]:port", each optionally followed by "=Label"
|
// "host", "host:port", "[v6]:port", each optionally followed by "=Label"
|
||||||
@@ -91,6 +95,8 @@ export function configFromEnv(source: NodeJS.ProcessEnv): ProxyConfig {
|
|||||||
turnMaxPort: Number((env.MUMH5_TURN_PORTS ?? '').split('-')[1]) || defaults.turnMaxPort,
|
turnMaxPort: Number((env.MUMH5_TURN_PORTS ?? '').split('-')[1]) || defaults.turnMaxPort,
|
||||||
turnMax: Number(env.MUMH5_TURN_MAX ?? defaults.turnMax),
|
turnMax: Number(env.MUMH5_TURN_MAX ?? defaults.turnMax),
|
||||||
turnPerAddress: Number(env.MUMH5_TURN_PER_ADDRESS ?? defaults.turnPerAddress),
|
turnPerAddress: Number(env.MUMH5_TURN_PER_ADDRESS ?? defaults.turnPerAddress),
|
||||||
|
turnUrls: (env.MUMH5_TURN_URLS ?? '').split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
debug: on(env.MUMH5_DEBUG),
|
||||||
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)
|
||||||
};
|
};
|
||||||
@@ -185,7 +191,8 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
|
|||||||
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');
|
||||||
// The username is the time it runs out; the relay recomputes the password from it
|
// 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);
|
const username = String(Math.floor(Date.now() / 1000) + CREDENTIAL_TTL);
|
||||||
return { port: stunPort, username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL };
|
if (config.debug) console.log(new Date().toISOString(), `relay: credentials for ${addr}`);
|
||||||
|
return { port: stunPort, username, credential: turnCredential(turnSecret, username), ttl: CREDENTIAL_TTL, ...(config.turnUrls.length ? { urls: config.turnUrls } : {}) };
|
||||||
}
|
}
|
||||||
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');
|
||||||
@@ -348,7 +355,8 @@ export async function startProxy(config: ProxyConfig): Promise<{ port: number; s
|
|||||||
turn = await startTurn({
|
turn = await startTurn({
|
||||||
port: config.stunPort, bind: config.stunBind, relay: config.turn, publicIp: config.turnIp,
|
port: config.stunPort, bind: config.stunBind, relay: config.turn, publicIp: config.turnIp,
|
||||||
minPort: config.turnMinPort, maxPort: config.turnMaxPort, maxAllocations: config.turnMax, maxPerAddress: config.turnPerAddress,
|
minPort: config.turnMinPort, maxPort: config.turnMaxPort, maxAllocations: config.turnMax, maxPerAddress: config.turnPerAddress,
|
||||||
peerAllowed: ip => config.allowPrivate || !isPrivateAddress(ip)
|
peerAllowed: ip => config.allowPrivate || !isPrivateAddress(ip),
|
||||||
|
log: config.debug ? line => console.log(new Date().toISOString(), line) : undefined
|
||||||
}, turnSecret);
|
}, turnSecret);
|
||||||
stunPort = turn.port;
|
stunPort = turn.port;
|
||||||
} catch { /* port taken or not permitted */ }
|
} catch { /* port taken or not permitted */ }
|
||||||
|
|||||||
+21
-4
@@ -40,6 +40,8 @@ export interface TurnOptions {
|
|||||||
maxPerAddress: number;
|
maxPerAddress: number;
|
||||||
// Peers the relay may talk to; keeps it from being used to reach private networks
|
// Peers the relay may talk to; keeps it from being used to reach private networks
|
||||||
peerAllowed: (ip: string) => boolean;
|
peerAllowed: (ip: string) => boolean;
|
||||||
|
// One line per relay event, for finding out why someone cannot connect
|
||||||
|
log?: (line: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loopback, private, link-local and other addresses that are not on the public internet
|
// Loopback, private, link-local and other addresses that are not on the public internet
|
||||||
@@ -208,7 +210,9 @@ export async function startTurn(opts: TurnOptions, secret: string): Promise<{ po
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const integrity = get(A.MESSAGE_INTEGRITY);
|
const integrity = get(A.MESSAGE_INTEGRITY);
|
||||||
|
// The first request always comes without credentials; that is how a client learns the realm
|
||||||
if (!integrity) return fail(401, 'Unauthorized');
|
if (!integrity) return fail(401, 'Unauthorized');
|
||||||
|
opts.log?.(`relay: ${client.key} sent credentials`);
|
||||||
const username = get(A.USERNAME), n = get(A.NONCE);
|
const username = get(A.USERNAME), n = get(A.NONCE);
|
||||||
if (!username || !n || integrity.value.length !== 20) return fail(400, 'Bad Request');
|
if (!username || !n || integrity.value.length !== 20) return fail(400, 'Bad Request');
|
||||||
if (!nonceValid(new TextDecoder().decode(n.value))) return fail(438, 'Stale Nonce');
|
if (!nonceValid(new TextDecoder().decode(n.value))) return fail(438, 'Stale Nonce');
|
||||||
@@ -223,8 +227,17 @@ export async function startTurn(opts: TurnOptions, secret: string): Promise<{ po
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two clients of this relay reach each other at its announced address. Behind a 1:1 NAT that
|
||||||
|
// address is not on this machine and the router will not turn the packet around, so such
|
||||||
|
// traffic stays on loopback and is presented as coming from the announced address.
|
||||||
|
const loopback = (ip: string) => ip === '127.0.0.1' || ip === '::1';
|
||||||
|
function toPeer(a: Allocation, data: Uint8Array, ip: string, port: number): void {
|
||||||
|
a.relay.send(data, port, ip === a.relayIp ? (net.isIPv4(ip) ? '127.0.0.1' : '::1') : ip);
|
||||||
|
}
|
||||||
|
|
||||||
function fromPeer(a: Allocation, data: Buffer, peerIp: string, peerPort: number): void {
|
function fromPeer(a: Allocation, data: Buffer, peerIp: string, peerPort: number): void {
|
||||||
const ip = v4(peerIp);
|
const seen = v4(peerIp);
|
||||||
|
const ip = loopback(seen) && !loopback(a.relayIp) ? a.relayIp : seen;
|
||||||
if ((a.permissions.get(ip) ?? 0) < now()) return;
|
if ((a.permissions.get(ip) ?? 0) < now()) return;
|
||||||
const channel = a.byPeer.get(`${ip}:${peerPort}`);
|
const channel = a.byPeer.get(`${ip}:${peerPort}`);
|
||||||
if (channel != null && (a.channels.get(channel)?.expires ?? 0) >= now()) {
|
if (channel != null && (a.channels.get(channel)?.expires ?? 0) >= now()) {
|
||||||
@@ -248,7 +261,7 @@ export async function startTurn(opts: TurnOptions, secret: string): Promise<{ po
|
|||||||
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
||||||
const bound = a?.channels.get(view.getUint16(0));
|
const bound = a?.channels.get(view.getUint16(0));
|
||||||
const length = view.getUint16(2);
|
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);
|
if (a && bound && bound.expires >= now() && 4 + length <= msg.length) toPeer(a, msg.subarray(4, 4 + length), bound.ip, bound.port);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const m = parse(msg);
|
const m = parse(msg);
|
||||||
@@ -265,14 +278,17 @@ export async function startTurn(opts: TurnOptions, secret: string): Promise<{ po
|
|||||||
if (m.type === SEND) {
|
if (m.type === SEND) {
|
||||||
const peer = get(A.XOR_PEER_ADDRESS), data = get(A.DATA);
|
const peer = get(A.XOR_PEER_ADDRESS), data = get(A.DATA);
|
||||||
const to = peer && unxorAddress(peer.value, m.header);
|
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);
|
if (existing && to && data && (existing.permissions.get(to.ip) ?? 0) >= now()) toPeer(existing, data.value, to.ip, to.port);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (![ALLOCATE, REFRESH, CREATE_PERMISSION, CHANNEL_BIND].includes(m.type)) return;
|
if (![ALLOCATE, REFRESH, CREATE_PERMISSION, CHANNEL_BIND].includes(m.type)) return;
|
||||||
const key = authenticate(m, client);
|
const key = authenticate(m, client);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const ok = (attrs: [number, Uint8Array][] = []) => client.send(build(m.type | 0x100, m.header, attrs, key));
|
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));
|
const error = (code: number, reason: string) => {
|
||||||
|
opts.log?.(`relay: ${client.key} refused, ${code} ${reason}`);
|
||||||
|
client.send(build(m.type | 0x110, m.header, [[A.ERROR_CODE, errorCode(code, reason)]], key));
|
||||||
|
};
|
||||||
|
|
||||||
if (m.type === ALLOCATE) {
|
if (m.type === ALLOCATE) {
|
||||||
if (existing) return error(437, 'Allocation Mismatch');
|
if (existing) return error(437, 'Allocation Mismatch');
|
||||||
@@ -288,6 +304,7 @@ export async function startTurn(opts: TurnOptions, secret: string): Promise<{ po
|
|||||||
const a: Allocation = { client, authKey: key, relay, relayIp, permissions: new Map(), channels: new Map(), byPeer: new Map(), expires: now() + LIFETIME };
|
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);
|
allocations.set(client.key, a);
|
||||||
relay.on('message', (data, from) => fromPeer(a, data, from.address, from.port));
|
relay.on('message', (data, from) => fromPeer(a, data, from.address, from.port));
|
||||||
|
opts.log?.(`relay: ${client.key} got ${relayIp}:${relay.address().port} (${allocations.size} in use)`);
|
||||||
return ok([
|
return ok([
|
||||||
[A.XOR_RELAYED_ADDRESS, xorAddress(relayIp, relay.address().port, m.header)!],
|
[A.XOR_RELAYED_ADDRESS, xorAddress(relayIp, relay.address().port, m.header)!],
|
||||||
[A.LIFETIME, u32(LIFETIME)],
|
[A.LIFETIME, u32(LIFETIME)],
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ class VoiceEngine {
|
|||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
if (typeof AudioEncoder === 'undefined' || !(await AudioEncoder.isConfigSupported(this.encoderConfig())).supported) {
|
if (typeof AudioEncoder === 'undefined' || !(await AudioEncoder.isConfigSupported(this.encoderConfig())).supported) {
|
||||||
throw new Error('This system cannot encode Opus audio');
|
throw new Error('This browser cannot encode Opus audio (WebCodecs is missing), so you cannot talk or hear others here. Chromium-based browsers and the desktop app can.');
|
||||||
}
|
}
|
||||||
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
|
const ctx = new AudioContext({ sampleRate: SAMPLE_RATE, latencyHint: 'interactive' });
|
||||||
this.ctx = ctx;
|
this.ctx = ctx;
|
||||||
|
|||||||
@@ -168,10 +168,12 @@ export async function relayIce(address: string): Promise<RTCIceServer[]> {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${root}api/relay`);
|
const res = await fetch(`${root}api/relay`);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const c = await res.json() as { port: number; username?: string; credential?: string; ttl?: number };
|
const c = await res.json() as { port: number; username?: string; credential?: string; ttl?: number; urls?: string[] };
|
||||||
const servers: RTCIceServer[] = [{ urls: `stun:${url.hostname}:${c.port}` }];
|
const servers: RTCIceServer[] = [{ urls: `stun:${url.hostname}:${c.port}` }];
|
||||||
// UDP where it is allowed, TCP for networks and browsers that forbid it
|
// UDP where it is allowed, TCP for networks and browsers that forbid it
|
||||||
if (c.username) servers.push({ urls: [`turn:${url.hostname}:${c.port}?transport=udp`, `turn:${url.hostname}:${c.port}?transport=tcp`], username: c.username, credential: c.credential });
|
// The operator may name other ways in, such as a TLS front on port 443 for networks that allow nothing else
|
||||||
|
const direct = [`turn:${url.hostname}:${c.port}?transport=udp`, `turn:${url.hostname}:${c.port}?transport=tcp`];
|
||||||
|
if (c.username) servers.push({ urls: [...direct, ...(c.urls ?? [])], username: c.username, credential: c.credential });
|
||||||
relays.set(root, { servers, until: Date.now() + Math.max(60, (c.ttl ?? 3600) - 3000) * 1000 });
|
relays.set(root, { servers, until: Date.now() + Math.max(60, (c.ttl ?? 3600) - 3000) * 1000 });
|
||||||
return servers;
|
return servers;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -76,15 +76,27 @@
|
|||||||
async function showCamera(deviceId = camera) {
|
async function showCamera(deviceId = camera) {
|
||||||
error = '';
|
error = '';
|
||||||
dropPreview();
|
dropPreview();
|
||||||
|
const wanted: MediaTrackConstraints = { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: fps() } };
|
||||||
|
if (deviceId) wanted.deviceId = { exact: deviceId };
|
||||||
|
else wanted.facingMode = 'user';
|
||||||
try {
|
try {
|
||||||
preview = await navigator.mediaDevices.getUserMedia({
|
try {
|
||||||
video: { deviceId: deviceId ? { exact: deviceId } : undefined, width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: fps() }
|
preview = await navigator.mediaDevices.getUserMedia({ video: wanted });
|
||||||
});
|
} catch (e) {
|
||||||
|
// A refusal or a missing camera is final; anything else may be a phone camera that
|
||||||
|
// dislikes the wishes above, so ask once more for whatever it has
|
||||||
|
if (['NotAllowedError', 'SecurityError', 'NotFoundError'].includes((e as Error).name)) throw e;
|
||||||
|
preview = await navigator.mediaDevices.getUserMedia({ video: deviceId ? { deviceId: { exact: deviceId } } : true });
|
||||||
|
}
|
||||||
// Names are only given out once a camera was allowed
|
// Names are only given out once a camera was allowed
|
||||||
cameras = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
|
cameras = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
|
||||||
camera = preview.getVideoTracks()[0]?.getSettings().deviceId ?? deviceId;
|
camera = preview.getVideoTracks()[0]?.getSettings().deviceId ?? deviceId;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = (e as Error).name === 'NotFoundError' ? 'No camera was found.' : `Could not open the camera: ${(e as Error).message}`;
|
const { name, message } = e as Error;
|
||||||
|
error = name === 'NotFoundError' ? 'No camera was found.'
|
||||||
|
: name === 'NotAllowedError' || name === 'SecurityError'
|
||||||
|
? 'The camera was refused. If no question appeared, access is blocked: allow the camera for this site (the icon next to the address), and for the browser app in the phone\'s settings, then try again.'
|
||||||
|
: `Could not open the camera (${name}): ${message}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,6 +253,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if error}<p class="err">{error}</p>{/if}
|
{#if error}<p class="err">{error}</p>{/if}
|
||||||
|
{#if kind === 'camera' && error}<div class="row"><button class="btn" onclick={() => showCamera()}>Try the camera again</button></div>{/if}
|
||||||
|
|
||||||
{#snippet footer()}
|
{#snippet footer()}
|
||||||
<button class="btn" onclick={onclose}>Cancel</button>
|
<button class="btn" onclick={onclose}>Cancel</button>
|
||||||
|
|||||||
@@ -234,6 +234,7 @@
|
|||||||
$effect(() => () => popup?.close());
|
$effect(() => () => popup?.close());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if voice.error && session.status === 'connected'}<div class="bar bad" role="alert"><span>Voice is not working: {voice.error}</span></div>{/if}
|
||||||
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
{#if share.error}<div class="bar bad" role="alert"><span>{share.error}</span><button class="btn" onclick={() => (share.error = '')}>Dismiss</button></div>{/if}
|
||||||
|
|
||||||
{#snippet spot()}
|
{#snippet spot()}
|
||||||
|
|||||||
@@ -144,6 +144,32 @@ for (const transport of ['udp', 'tcp'] as const) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('two clients of the relay reach each other at its announced address, even when that is not on this machine', async () => {
|
||||||
|
// As behind a 1:1 NAT: the address handed out is the public one, which the host itself cannot loop through
|
||||||
|
const turn = await startTurn(options({ publicIp: '203.0.113.9', peerAllowed: ip => !ip.startsWith('127.') }), SECRET);
|
||||||
|
const a = await connect(turn.port, 'tcp'), b = await connect(turn.port, 'udp');
|
||||||
|
try {
|
||||||
|
const ra = await allocate(a), rb = await allocate(b);
|
||||||
|
assert.equal(ra.relay!.ip, '203.0.113.9');
|
||||||
|
for (const [me, creds, other] of [[a, ra, rb], [b, rb, ra]] as const) {
|
||||||
|
const h = header();
|
||||||
|
me.send(build(0x0008, h, [[0x0012, xorAddress('203.0.113.9', other.relay!.port, h)!], ...creds.auth], creds.key));
|
||||||
|
assert.equal(parse(await me.next())!.type, 0x0108);
|
||||||
|
}
|
||||||
|
const h = header();
|
||||||
|
a.send(build(0x0016, h, [[0x0012, xorAddress('203.0.113.9', rb.relay!.port, h)!], [0x0013, text('between clients')]]));
|
||||||
|
const got = parse(await b.next())!;
|
||||||
|
assert.equal(got.type, 0x0017);
|
||||||
|
assert.equal(new TextDecoder().decode(attr(got, 0x0013)!), 'between clients');
|
||||||
|
// It appears to come from the other client's relayed address, not from loopback
|
||||||
|
assert.deepEqual(unxorAddress(attr(got, 0x0012)!, got.header), { ip: '203.0.113.9', port: ra.relay!.port });
|
||||||
|
} finally {
|
||||||
|
a.close();
|
||||||
|
b.close();
|
||||||
|
turn.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('wrong credentials and forbidden peers are refused', async () => {
|
test('wrong credentials and forbidden peers are refused', async () => {
|
||||||
const turn = await startTurn(options({ peerAllowed: ip => ip !== '10.1.2.3' }), SECRET);
|
const turn = await startTurn(options({ peerAllowed: ip => ip !== '10.1.2.3' }), SECRET);
|
||||||
const client = await connect(turn.port, 'udp');
|
const client = await connect(turn.port, 'udp');
|
||||||
|
|||||||
Reference in New Issue
Block a user