From 9066d583dc3ae3443e43f530b4520fec361c02d9 Mon Sep 17 00:00:00 2001 From: Pix3lPirat3 Date: Mon, 21 Sep 2026 14:39:44 -0700 Subject: [PATCH 1/3] Follow TURN 300 Try Alternate redirect for Realm connections --- src/client.js | 10 +++++ src/turnRedirect.js | 81 +++++++++++++++++++++++++++++++++++++++ test/turnRedirect.test.js | 37 ++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/turnRedirect.js create mode 100644 test/turnRedirect.test.js diff --git a/src/client.js b/src/client.js index 90c9366..17b5466 100644 --- a/src/client.js +++ b/src/client.js @@ -5,6 +5,7 @@ const { Connection } = require('./connection') const { ErrorCode, SignalType, SignalStructure } = require('./signalling') const { getRandomUint64, normalizeIceServers, validateIceServers, createPacketData, prepareSecurePacket, processSecurePacket } = require('./util') +const { resolveTurnRedirects } = require('./turnRedirect') const { getWebRTC } = require('./webrtc') const { PACKET_TYPE, createSerializer, createDeserializer } = require('./serializer') @@ -215,6 +216,15 @@ class Client extends EventEmitter { } async createOffer () { + // Follow a TURN "300 Try Alternate" redirect before building the peer connection. Microsoft's Realm relay redirects + // the initial Allocate to a regional relay; the werift backend does not follow it, so pre-resolve the alternate here + // and hand werift the redirected URL (native backends already follow it and are unaffected). Best-effort: on any + // probe failure the original ICE servers are used unchanged. See turnRedirect.js. + try { + this.credentials = await resolveTurnRedirects(normalizeIceServers(this.credentials)) + } catch (err) { + debug('TURN redirect pre-resolve failed, using original ICE servers:', err) + } debug('Creating RTCPeerConnection with ICE servers:', this.credentials) try { diff --git a/src/turnRedirect.js b/src/turnRedirect.js new file mode 100644 index 0000000..6460c65 --- /dev/null +++ b/src/turnRedirect.js @@ -0,0 +1,81 @@ +'use strict' +// TURN "300 Try Alternate" pre-resolution (PoC). +// +// Microsoft's Realm relay (gateway-world.az.relay.communications.svc.cloud.microsoft) answers the initial TURN Allocate +// with STUN error 300 "Try Alternate" + an ALTERNATE-SERVER, redirecting the client to a regional relay. Native +// libwebrtc (@roamhq/wrtc) follows the redirect; the pure-JS werift backend does not, so on werift the allocation fails, +// no relay candidate is gathered, and the Realm connection times out. +// +// This resolves the redirect at the node-nethernet layer using only werift's SUPPORTED iceServers config: send one +// unauthenticated Allocate to each TURN server, and if it answers 300 + ALTERNATE-SERVER, rewrite the iceServer URL to +// point straight at the alternate. werift then does its normal (working) 401 auth handshake against the alternate. +// No werift internals are touched. Best-effort: any probe failure leaves the original URL unchanged. +const dgram = require('dgram') +const crypto = require('crypto') +const MAGIC = 0x2112a442 + +function buildAllocate () { + const tid = crypto.randomBytes(12) + const attr = Buffer.from([0x00, 0x19, 0x00, 0x04, 17, 0x00, 0x00, 0x00]) // REQUESTED-TRANSPORT = UDP + const header = Buffer.alloc(20) + header.writeUInt16BE(0x0003, 0) // Allocate request + header.writeUInt16BE(attr.length, 2) + header.writeUInt32BE(MAGIC, 4) + tid.copy(header, 8) + return Buffer.concat([header, attr]) +} + +function parseAttrs (msg) { + const out = {} + const mlen = msg.readUInt16BE(2) + let off = 20 + while (off + 4 <= 20 + mlen) { + const type = msg.readUInt16BE(off) + const len = msg.readUInt16BE(off + 2) + const val = msg.slice(off + 4, off + 4 + len) + if (type === 0x0009 && val.length >= 4) { // ERROR-CODE + out.errorCode = (val[2] & 0x07) * 100 + val[3] + } else if (type === 0x8023 && val.length >= 8) { // ALTERNATE-SERVER (MAPPED-ADDRESS format, not XOR'd) + const family = val[1] + if (family === 0x01) out.alternate = { ip: Array.from(val.slice(4, 8)).join('.'), port: val.readUInt16BE(2) } + } + off += 4 + len + ((4 - (len % 4)) % 4) + } + return out +} + +function probeTurnAlternate (host, port, timeoutMs = 3000) { + return new Promise((resolve) => { + let done = false + const sock = dgram.createSocket('udp4') + const finish = (r) => { if (done) return; done = true; try { sock.close() } catch {} resolve(r) } + const timer = setTimeout(() => finish(null), timeoutMs) + sock.on('message', (msg) => { clearTimeout(timer); try { const a = parseAttrs(msg); finish(a.errorCode === 300 && a.alternate ? a.alternate : null) } catch { finish(null) } }) + sock.on('error', () => { clearTimeout(timer); finish(null) }) + try { sock.send(buildAllocate(), port, host) } catch { finish(null) } + }) +} + +// Rewrite each plain-UDP `turn:` server that redirects via 300 to point at its ALTERNATE-SERVER. `turns:` (TLS) and +// `stun:` entries are left untouched. Results are cached per host:port so repeated connects probe at most once. +async function resolveTurnRedirects (iceServers, { timeoutMs = 3000, cache = resolveTurnRedirects._cache } = {}) { + const out = [] + for (const server of iceServers || []) { + const urls = Array.isArray(server.urls) ? server.urls : [server.urls] + const rewritten = [] + for (const url of urls) { + const m = /^turn:([^:/?]+)(?::(\d+))?(\?.*)?$/i.exec(url || '') + if (!m) { rewritten.push(url); continue } + const host = m[1]; const port = Number(m[2] || 3478); const query = m[3] || '' + const key = host + ':' + port + let alt = cache.get(key) + if (alt === undefined) { alt = await probeTurnAlternate(host, port, timeoutMs); cache.set(key, alt) } + rewritten.push(alt ? `turn:${alt.ip}:${alt.port}${query}` : url) + } + out.push({ ...server, urls: rewritten.length === 1 ? rewritten[0] : rewritten }) + } + return out +} +resolveTurnRedirects._cache = new Map() + +module.exports = { resolveTurnRedirects, probeTurnAlternate } diff --git a/test/turnRedirect.test.js b/test/turnRedirect.test.js new file mode 100644 index 0000000..117bb91 --- /dev/null +++ b/test/turnRedirect.test.js @@ -0,0 +1,37 @@ +/* eslint-env mocha */ +const assert = require('assert') +const { resolveTurnRedirects } = require('../src/turnRedirect') + +// Network-free tests: seed the per-call cache so no STUN probe is sent. resolveTurnRedirects should rewrite a plain +// turn: URL to its cached ALTERNATE-SERVER, leave a non-redirecting turn: URL as-is, and never touch turns:/stun:. +describe('turnRedirect.resolveTurnRedirects', function () { + it('rewrites a turn: URL that redirects (300 Try Alternate) to its alternate', async function () { + const cache = new Map([['gateway-world.example:3478', { ip: '20.202.32.149', port: 3478 }]]) + const out = await resolveTurnRedirects([{ urls: 'turn:gateway-world.example:3478', username: 'u', credential: 'p' }], { cache }) + assert.strictEqual(out[0].urls, 'turn:20.202.32.149:3478') + assert.strictEqual(out[0].username, 'u') // credentials preserved + }) + + it('leaves a turn: URL unchanged when there is no redirect', async function () { + const cache = new Map([['relay.example:3478', null]]) + const out = await resolveTurnRedirects([{ urls: 'turn:relay.example:3478' }], { cache }) + assert.strictEqual(out[0].urls, 'turn:relay.example:3478') + }) + + it('never probes or rewrites turns: (TLS) or stun: URLs', async function () { + const cache = new Map() + const out = await resolveTurnRedirects([ + { urls: 'turns:secure.example:5349' }, + { urls: 'stun:stun.example:3478' } + ], { cache }) + assert.strictEqual(out[0].urls, 'turns:secure.example:5349') + assert.strictEqual(out[1].urls, 'stun:stun.example:3478') + assert.strictEqual(cache.size, 0) // nothing probed + }) + + it('preserves a query string on a rewritten turn: URL', async function () { + const cache = new Map([['g.example:3478', { ip: '1.2.3.4', port: 3479 }]]) + const out = await resolveTurnRedirects([{ urls: 'turn:g.example:3478?transport=udp' }], { cache }) + assert.strictEqual(out[0].urls, 'turn:1.2.3.4:3479?transport=udp') + }) +}) From 892aad018c002b423dfb3fffb6a9ffb39a291256 Mon Sep 17 00:00:00 2001 From: Pix3lPirat3 Date: Mon, 21 Sep 2026 14:57:28 -0700 Subject: [PATCH 2/3] Only pre-resolve TURN when a plain turn: server is present --- src/client.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/client.js b/src/client.js index 17b5466..369d2ac 100644 --- a/src/client.js +++ b/src/client.js @@ -219,11 +219,15 @@ class Client extends EventEmitter { // Follow a TURN "300 Try Alternate" redirect before building the peer connection. Microsoft's Realm relay redirects // the initial Allocate to a regional relay; the werift backend does not follow it, so pre-resolve the alternate here // and hand werift the redirected URL (native backends already follow it and are unaffected). Best-effort: on any - // probe failure the original ICE servers are used unchanged. See turnRedirect.js. + // probe failure the original ICE servers are used unchanged. See turnRedirect.js. Only awaited when there is a plain + // turn: server to probe, so configs without one (stun-only, or an invalid config) keep createOffer's synchronous + // error path intact. try { - this.credentials = await resolveTurnRedirects(normalizeIceServers(this.credentials)) + const normalized = normalizeIceServers(this.credentials) + const hasPlainTurn = normalized.some(s => s && (Array.isArray(s.urls) ? s.urls : [s.urls]).some(u => typeof u === 'string' && /^turn:/i.test(u))) + if (hasPlainTurn) this.credentials = await resolveTurnRedirects(normalized) } catch (err) { - debug('TURN redirect pre-resolve failed, using original ICE servers:', err) + debug('TURN redirect pre-resolve skipped, using original ICE servers:', err) } debug('Creating RTCPeerConnection with ICE servers:', this.credentials) From 70b69bb7a02115b3d3d060c93918e57d58fa0ff6 Mon Sep 17 00:00:00 2001 From: extremeheat Date: Tue, 22 Sep 2026 09:27:18 +0000 Subject: [PATCH 3/3] Validate and cancel Werift TURN probes without caching stale results --- README.md | 5 + src/client.js | 29 +++--- src/turnRedirect.js | 157 +++++++++++++++++------------ src/werift.js | 3 +- test/backends.test.js | 2 + test/turnRedirect.test.js | 207 +++++++++++++++++++++++++++++++------- 6 files changed, 287 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index b6dd369..e144438 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,11 @@ backends or retry a connection through another implementation. Werift's `setLocalDescription()` waits for ICE gathering. NetherNet sends the resulting SDP with its gathered candidates, in addition to candidate signals. Backend-specific compatibility handling is isolated in `src/werift.js`. +For Werift clients, IPv4 UDP `turn:` URLs are probed for the Realm gateway's +`300 Try Alternate` redirect before creating the peer. Each probe waits at most +3 seconds (bounded by the connection attempt's lifetime); failures preserve the +original URL. Redirects are refreshed on every join rather than cached. TCP, +TLS and IPv6 TURN URLs, and the native backend, bypass this workaround. `npm test` runs lint, strict TypeScript checks, and runtime tests without forcing process exit. Tests cover local transport, signalling errors, cancellation, and diff --git a/src/client.js b/src/client.js index 369d2ac..9e9b830 100644 --- a/src/client.js +++ b/src/client.js @@ -5,7 +5,6 @@ const { Connection } = require('./connection') const { ErrorCode, SignalType, SignalStructure } = require('./signalling') const { getRandomUint64, normalizeIceServers, validateIceServers, createPacketData, prepareSecurePacket, processSecurePacket } = require('./util') -const { resolveTurnRedirects } = require('./turnRedirect') const { getWebRTC } = require('./webrtc') const { PACKET_TYPE, createSerializer, createDeserializer } = require('./serializer') @@ -124,6 +123,7 @@ class Client extends EventEmitter { } clearNegotiationTimeouts () { + this._offerController?.abort() if (this._negotiationTimeout) { clearTimeout(this._negotiationTimeout) this._negotiationTimeout = null @@ -216,24 +216,19 @@ class Client extends EventEmitter { } async createOffer () { - // Follow a TURN "300 Try Alternate" redirect before building the peer connection. Microsoft's Realm relay redirects - // the initial Allocate to a regional relay; the werift backend does not follow it, so pre-resolve the alternate here - // and hand werift the redirected URL (native backends already follow it and are unaffected). Best-effort: on any - // probe failure the original ICE servers are used unchanged. See turnRedirect.js. Only awaited when there is a plain - // turn: server to probe, so configs without one (stun-only, or an invalid config) keep createOffer's synchronous - // error path intact. - try { - const normalized = normalizeIceServers(this.credentials) - const hasPlainTurn = normalized.some(s => s && (Array.isArray(s.urls) ? s.urls : [s.urls]).some(u => typeof u === 'string' && /^turn:/i.test(u))) - if (hasPlainTurn) this.credentials = await resolveTurnRedirects(normalized) - } catch (err) { - debug('TURN redirect pre-resolve skipped, using original ICE servers:', err) - } - debug('Creating RTCPeerConnection with ICE servers:', this.credentials) - + if (this._closed) return + this._offerController?.abort() + const controller = new AbortController() + this._offerController = controller try { - this.rtcConnection = new this.webrtc.RTCPeerConnection({ iceServers: validateIceServers(normalizeIceServers(this.credentials)) }) + let iceServers = validateIceServers(normalizeIceServers(this.credentials)) + if (this.webrtc.resolveIceServers) { + iceServers = await this.webrtc.resolveIceServers(iceServers, { signal: controller.signal }) + } + if (controller.signal.aborted || this._closed) return + this.rtcConnection = new this.webrtc.RTCPeerConnection({ iceServers }) } catch (err) { + if (controller.signal.aborted || this._closed) return debug('Failed to create RTCPeerConnection:', err) this.failNegotiation(this.serverNetworkId, ErrorCode.FailedToCreatePeerConnection) this.reportError(new Error(`Failed to create peer connection: ${err.message}`)) diff --git a/src/turnRedirect.js b/src/turnRedirect.js index 6460c65..fa53509 100644 --- a/src/turnRedirect.js +++ b/src/turnRedirect.js @@ -1,81 +1,110 @@ -'use strict' -// TURN "300 Try Alternate" pre-resolution (PoC). -// -// Microsoft's Realm relay (gateway-world.az.relay.communications.svc.cloud.microsoft) answers the initial TURN Allocate -// with STUN error 300 "Try Alternate" + an ALTERNATE-SERVER, redirecting the client to a regional relay. Native -// libwebrtc (@roamhq/wrtc) follows the redirect; the pure-JS werift backend does not, so on werift the allocation fails, -// no relay candidate is gathered, and the Realm connection times out. -// -// This resolves the redirect at the node-nethernet layer using only werift's SUPPORTED iceServers config: send one -// unauthenticated Allocate to each TURN server, and if it answers 300 + ALTERNATE-SERVER, rewrite the iceServer URL to -// point straight at the alternate. werift then does its normal (working) 401 auth handshake against the alternate. -// No werift internals are touched. Best-effort: any probe failure leaves the original URL unchanged. -const dgram = require('dgram') -const crypto = require('crypto') +const dgram = require('node:dgram') +const { randomBytes } = require('node:crypto') +const { isIP } = require('node:net') + const MAGIC = 0x2112a442 +const ALLOCATE_ERROR = 0x0113 +const ERROR_CODE = 0x0009 +const ALTERNATE_SERVER = 0x8023 +const PROBE_TIMEOUT_MS = 3000 -function buildAllocate () { - const tid = crypto.randomBytes(12) - const attr = Buffer.from([0x00, 0x19, 0x00, 0x04, 17, 0x00, 0x00, 0x00]) // REQUESTED-TRANSPORT = UDP - const header = Buffer.alloc(20) - header.writeUInt16BE(0x0003, 0) // Allocate request - header.writeUInt16BE(attr.length, 2) - header.writeUInt32BE(MAGIC, 4) - tid.copy(header, 8) - return Buffer.concat([header, attr]) +// Only IPv4 UDP TURN is handled here. TCP/TLS and IPv6 stay with the backend. +function parseTurnUrl (url) { + if (typeof url !== 'string') return null + const match = /^turn:([^:/?]+)(?::(\d+))?(\?transport=udp)?$/i.exec(url) + if (!match || match[1].includes('[')) return null + const port = Number(match[2] || 3478) + if (port < 1 || port > 65535) return null + return { host: match[1], port, query: match[3] || '' } } -function parseAttrs (msg) { - const out = {} - const mlen = msg.readUInt16BE(2) - let off = 20 - while (off + 4 <= 20 + mlen) { - const type = msg.readUInt16BE(off) - const len = msg.readUInt16BE(off + 2) - const val = msg.slice(off + 4, off + 4 + len) - if (type === 0x0009 && val.length >= 4) { // ERROR-CODE - out.errorCode = (val[2] & 0x07) * 100 + val[3] - } else if (type === 0x8023 && val.length >= 8) { // ALTERNATE-SERVER (MAPPED-ADDRESS format, not XOR'd) - const family = val[1] - if (family === 0x01) out.alternate = { ip: Array.from(val.slice(4, 8)).join('.'), port: val.readUInt16BE(2) } +function parseRedirect (msg, request) { + if (msg.length < 20 || msg.readUInt16BE(0) !== ALLOCATE_ERROR || + msg.readUInt32BE(4) !== MAGIC || !msg.subarray(8, 20).equals(request.subarray(8, 20))) return + const length = msg.readUInt16BE(2) + if (length % 4 || length + 20 !== msg.length) return + let code + let alternate + for (let offset = 20; offset < msg.length;) { + if (offset + 4 > msg.length) return + const type = msg.readUInt16BE(offset) + const size = msg.readUInt16BE(offset + 2) + const end = offset + 4 + size + const next = end + ((4 - size % 4) % 4) + if (next > msg.length) return + const value = msg.subarray(offset + 4, end) + if (type === ERROR_CODE) { + if (size < 4) return + code = (value[2] & 7) * 100 + value[3] + } else if (type === ALTERNATE_SERVER) { + if (size === 8 && value[1] === 1) { + alternate = { ip: [...value.subarray(4)].join('.'), port: value.readUInt16BE(2) } + } } - off += 4 + len + ((4 - (len % 4)) % 4) + offset = next } - return out + if (code === undefined) return + // null means a matched response without a usable redirect; undefined is invalid. + return code === 300 && alternate?.port ? alternate : null } -function probeTurnAlternate (host, port, timeoutMs = 3000) { - return new Promise((resolve) => { +function probeTurnAlternate (host, port, { timeoutMs = PROBE_TIMEOUT_MS, signal } = {}) { + if (signal?.aborted || isIP(host) === 6) return Promise.resolve(null) + const request = Buffer.alloc(28) + request.writeUInt16BE(3, 0) // Allocate request + request.writeUInt16BE(8, 2) + request.writeUInt32BE(MAGIC, 4) + randomBytes(12).copy(request, 8) + request.writeUInt16BE(0x0019, 20) // REQUESTED-TRANSPORT: UDP relay allocation + request.writeUInt16BE(4, 22) + request[24] = 17 + + return new Promise(resolve => { + const socket = dgram.createSocket('udp4') let done = false - const sock = dgram.createSocket('udp4') - const finish = (r) => { if (done) return; done = true; try { sock.close() } catch {} resolve(r) } + const finish = result => { + if (done) return + done = true + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + try { socket.close() } catch (error) { + if (error.code !== 'ERR_SOCKET_DGRAM_NOT_RUNNING') throw error + } + resolve(result) + } + const onAbort = () => finish(null) const timer = setTimeout(() => finish(null), timeoutMs) - sock.on('message', (msg) => { clearTimeout(timer); try { const a = parseAttrs(msg); finish(a.errorCode === 300 && a.alternate ? a.alternate : null) } catch { finish(null) } }) - sock.on('error', () => { clearTimeout(timer); finish(null) }) - try { sock.send(buildAllocate(), port, host) } catch { finish(null) } + signal?.addEventListener('abort', onAbort, { once: true }) + socket.on('error', () => finish(null)) + socket.on('message', msg => { + const result = parseRedirect(msg, request) + if (result !== undefined) finish(result) + }) + // Connecting filters packets from other endpoints, including different ports. + try { + socket.connect(port, host, () => { + if (!done) socket.send(request, error => { if (error) finish(null) }) + }) + } catch { + finish(null) + } }) } -// Rewrite each plain-UDP `turn:` server that redirects via 300 to point at its ALTERNATE-SERVER. `turns:` (TLS) and -// `stun:` entries are left untouched. Results are cached per host:port so repeated connects probe at most once. -async function resolveTurnRedirects (iceServers, { timeoutMs = 3000, cache = resolveTurnRedirects._cache } = {}) { - const out = [] - for (const server of iceServers || []) { +// Werift does not follow the Realm gateway's TURN 300 redirect. Resolve it using +// an unauthenticated probe, then let Werift authenticate with the alternate. +// Do not cache: gateway assignments and network failures can change between joins. +async function resolveTurnRedirects (iceServers, options = {}) { + return Promise.all(iceServers.map(async server => { const urls = Array.isArray(server.urls) ? server.urls : [server.urls] - const rewritten = [] - for (const url of urls) { - const m = /^turn:([^:/?]+)(?::(\d+))?(\?.*)?$/i.exec(url || '') - if (!m) { rewritten.push(url); continue } - const host = m[1]; const port = Number(m[2] || 3478); const query = m[3] || '' - const key = host + ':' + port - let alt = cache.get(key) - if (alt === undefined) { alt = await probeTurnAlternate(host, port, timeoutMs); cache.set(key, alt) } - rewritten.push(alt ? `turn:${alt.ip}:${alt.port}${query}` : url) - } - out.push({ ...server, urls: rewritten.length === 1 ? rewritten[0] : rewritten }) - } - return out + const rewritten = await Promise.all(urls.map(async url => { + const target = parseTurnUrl(url) + if (!target) return url + const alternate = await probeTurnAlternate(target.host, target.port, options) + return alternate ? `turn:${alternate.ip}:${alternate.port}${target.query}` : url + })) + return { ...server, urls: Array.isArray(server.urls) ? rewritten : rewritten[0] } + })) } -resolveTurnRedirects._cache = new Map() module.exports = { resolveTurnRedirects, probeTurnAlternate } diff --git a/src/werift.js b/src/werift.js index 672d282..8027a1d 100644 --- a/src/werift.js +++ b/src/werift.js @@ -1,4 +1,5 @@ const { RTCPeerConnection } = require('werift') +const { resolveTurnRedirects } = require('./turnRedirect') class WeriftPeerConnection extends RTCPeerConnection { createDataChannel (label, options) { @@ -49,4 +50,4 @@ class WeriftPeerConnection extends RTCPeerConnection { } } -module.exports = { RTCPeerConnection: WeriftPeerConnection } +module.exports = { RTCPeerConnection: WeriftPeerConnection, resolveIceServers: resolveTurnRedirects } diff --git a/test/backends.test.js b/test/backends.test.js index ed45d19..d61c95b 100644 --- a/test/backends.test.js +++ b/test/backends.test.js @@ -41,6 +41,8 @@ describe('WebRTC backends', function () { if (process.env.TEST_NATIVE_WEBRTC) { it('auto selects an installed native backend', () => { assert.equal(getWebRTC('auto'), require('@roamhq/wrtc')) + assert.equal(getWebRTC('auto').resolveIceServers, undefined) + assert.equal(getWebRTC('wrtc').resolveIceServers, undefined) }) } diff --git a/test/turnRedirect.test.js b/test/turnRedirect.test.js index 117bb91..bf1f03a 100644 --- a/test/turnRedirect.test.js +++ b/test/turnRedirect.test.js @@ -1,37 +1,176 @@ /* eslint-env mocha */ -const assert = require('assert') -const { resolveTurnRedirects } = require('../src/turnRedirect') - -// Network-free tests: seed the per-call cache so no STUN probe is sent. resolveTurnRedirects should rewrite a plain -// turn: URL to its cached ALTERNATE-SERVER, leave a non-redirecting turn: URL as-is, and never touch turns:/stun:. -describe('turnRedirect.resolveTurnRedirects', function () { - it('rewrites a turn: URL that redirects (300 Try Alternate) to its alternate', async function () { - const cache = new Map([['gateway-world.example:3478', { ip: '20.202.32.149', port: 3478 }]]) - const out = await resolveTurnRedirects([{ urls: 'turn:gateway-world.example:3478', username: 'u', credential: 'p' }], { cache }) - assert.strictEqual(out[0].urls, 'turn:20.202.32.149:3478') - assert.strictEqual(out[0].username, 'u') // credentials preserved - }) - - it('leaves a turn: URL unchanged when there is no redirect', async function () { - const cache = new Map([['relay.example:3478', null]]) - const out = await resolveTurnRedirects([{ urls: 'turn:relay.example:3478' }], { cache }) - assert.strictEqual(out[0].urls, 'turn:relay.example:3478') - }) - - it('never probes or rewrites turns: (TLS) or stun: URLs', async function () { - const cache = new Map() - const out = await resolveTurnRedirects([ - { urls: 'turns:secure.example:5349' }, - { urls: 'stun:stun.example:3478' } - ], { cache }) - assert.strictEqual(out[0].urls, 'turns:secure.example:5349') - assert.strictEqual(out[1].urls, 'stun:stun.example:3478') - assert.strictEqual(cache.size, 0) // nothing probed - }) - - it('preserves a query string on a rewritten turn: URL', async function () { - const cache = new Map([['g.example:3478', { ip: '1.2.3.4', port: 3479 }]]) - const out = await resolveTurnRedirects([{ urls: 'turn:g.example:3478?transport=udp' }], { cache }) - assert.strictEqual(out[0].urls, 'turn:1.2.3.4:3479?transport=udp') +const assert = require('node:assert/strict') +const dgram = require('node:dgram') +const { once } = require('node:events') +const { setTimeout: delay } = require('node:timers/promises') +const { resolveTurnRedirects, probeTurnAlternate } = require('../src/turnRedirect') +const { Client } = require('../src/client') + +function response (request, code = 300, ip = [192, 0, 2, 10]) { + const packet = Buffer.alloc(40) + request.copy(packet, 0, 0, 20) + packet.writeUInt16BE(0x0113, 0) + packet.writeUInt16BE(20, 2) + packet.writeUInt16BE(9, 20) + packet.writeUInt16BE(4, 22) + packet[26] = Math.floor(code / 100) + packet[27] = code % 100 + packet.writeUInt16BE(0x8023, 28) + packet.writeUInt16BE(8, 30) + packet[33] = 1 + packet.writeUInt16BE(3478, 34) + Buffer.from(ip).copy(packet, 36) + return packet +} + +describe('TURN redirect probes', function () { + this.timeout(5000) + let relay + let port + beforeEach(async () => { + relay = dgram.createSocket('udp4') + relay.bind(0, '127.0.0.1') + await once(relay, 'listening') + port = relay.address().port + }) + afterEach(async () => { + const closed = once(relay, 'close') + relay.close() + await closed + }) + + it('sends Allocate and preserves credentials, URL shape and UDP query', async () => { + relay.on('message', (request, peer) => { + assert.equal(request.readUInt16BE(0), 3) + assert.equal(request.readUInt32BE(4), 0x2112a442) + assert.equal(request.readUInt16BE(20), 0x19) + assert.equal(request[24], 17) + relay.send(response(request), peer.port, peer.address) + }) + const input = [{ urls: [`turn:127.0.0.1:${port}?transport=udp`], username: 'user', credential: 'secret' }] + const output = await resolveTurnRedirects(input) + assert.deepEqual(output, [{ urls: ['turn:192.0.2.10:3478?transport=udp'], username: 'user', credential: 'secret' }]) + assert.equal(input[0].urls[0], `turn:127.0.0.1:${port}?transport=udp`) + }) + + it('ignores a different sender and malformed/unmatched replies before a valid reply', async () => { + const stranger = dgram.createSocket('udp4') + let sends + relay.once('message', (request, peer) => { + sends = (async () => { + stranger.send(response(request, 300, [192, 0, 2, 99]), peer.port, peer.address) + const invalid = [Buffer.alloc(2)] + for (const offset of [0, 4, 8]) { + const packet = response(request) + packet[offset] ^= 1 + invalid.push(packet) + } + const truncated = response(request) + truncated.writeUInt16BE(24, 2) + invalid.push(truncated) + const badAttribute = response(request) + badAttribute.writeUInt16BE(100, 30) + invalid.push(badAttribute) + for (const packet of invalid) relay.send(packet, peer.port, peer.address) + await delay(30) + relay.send(response(request), peer.port, peer.address) + })() + }) + try { + assert.deepEqual(await probeTurnAlternate('127.0.0.1', port), { ip: '192.0.2.10', port: 3478 }) + await sends + } finally { + stranger.close() + } + }) + + it('leaves a non-redirecting server unchanged', async () => { + relay.on('message', (request, peer) => relay.send(response(request, 401), peer.port, peer.address)) + const input = [{ urls: `turn:127.0.0.1:${port}`, username: 'u', credential: 'p' }] + assert.deepEqual(await resolveTurnRedirects(input), input) + }) + + it('does not probe TCP, TLS, STUN or unsupported URLs', async () => { + let probes = 0 + relay.on('message', () => { probes++ }) + const urls = [ + `turn:127.0.0.1:${port}?transport=tcp`, `turns:127.0.0.1:${port}`, + `stun:127.0.0.1:${port}`, 'turn:[::1]:3478', 'turn:127.0.0.1:99999', + `turn:127.0.0.1:${port}?transport=unknown` + ] + assert.deepEqual(await resolveTurnRedirects([{ urls }]), [{ urls }]) + await delay(20) + assert.equal(probes, 0) + }) + + it('retries after a timeout and refreshes successful redirects on later joins', async () => { + const input = [{ urls: `turn:127.0.0.1:${port}` }] + assert.deepEqual(await resolveTurnRedirects(input, { timeoutMs: 30 }), input) + let attempts = 0 + relay.on('message', (request, peer) => { + attempts++ + relay.send(response(request, 300, [192, 0, 2, attempts]), peer.port, peer.address) + }) + assert.equal((await resolveTurnRedirects(input))[0].urls, 'turn:192.0.2.1:3478') + assert.equal((await resolveTurnRedirects(input))[0].urls, 'turn:192.0.2.2:3478') + }) + + it('aborts an outstanding probe and does not start an already-aborted probe', async () => { + const controller = new AbortController() + const received = once(relay, 'message') + const pending = probeTurnAlternate('127.0.0.1', port, { signal: controller.signal }) + await received + controller.abort() + assert.equal(await pending, null) + let probes = 0 + relay.on('message', () => { probes++ }) + assert.equal(await probeTurnAlternate('127.0.0.1', port, { signal: controller.signal }), null) + await delay(20) + assert.equal(probes, 0) + }) + + it('does not create a peer after close during a probe', async () => { + const client = new Client(1n, '127.0.0.1', { + iceServers: [{ urls: `turn:127.0.0.1:${port}`, username: 'u', credential: 'p' }] + }) + let peers = 0 + client.webrtc = { ...client.webrtc, RTCPeerConnection: class { constructor () { peers++ } } } + try { + const received = once(relay, 'message') + const pending = client.createOffer() + await received + client.close() + await pending + assert.equal(peers, 0) + assert.equal(client.rtcConnection, null) + } finally { client.close() } + }) + + it('discards a timed-out attempt when a retry starts before its lookup settles', async () => { + const client = new Client(1n, '127.0.0.1', { responseTimeoutMs: 20 }) + const lookups = [] + const configurations = [] + client.webrtc = { + resolveIceServers: (servers, { signal }) => new Promise(resolve => lookups.push({ resolve, signal })), + RTCPeerConnection: class { + constructor (config) { configurations.push(config); throw new Error('stop test at peer creation') } + } + } + client.signalHandler = () => {} + client.on('error', () => {}) + try { + const disconnected = once(client, 'disconnect') + client.connect() + await disconnected + assert.equal(lookups[0].signal.aborted, true) + client.responseTimeoutMs = 1000 + client.connect() + lookups[1].resolve([{ urls: 'stun:new.example' }]) + await delay(0) + lookups[0].resolve([{ urls: 'stun:old.example' }]) + await delay(0) + assert.deepEqual(configurations, [{ iceServers: [{ urls: 'stun:new.example' }] }]) + assert.deepEqual(client.credentials, []) + } finally { client.close() } }) })