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 90c9366..9e9b830 100644 --- a/src/client.js +++ b/src/client.js @@ -123,6 +123,7 @@ class Client extends EventEmitter { } clearNegotiationTimeouts () { + this._offerController?.abort() if (this._negotiationTimeout) { clearTimeout(this._negotiationTimeout) this._negotiationTimeout = null @@ -215,11 +216,19 @@ class Client extends EventEmitter { } async createOffer () { - 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 new file mode 100644 index 0000000..fa53509 --- /dev/null +++ b/src/turnRedirect.js @@ -0,0 +1,110 @@ +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 + +// 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 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) } + } + } + offset = next + } + 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 = 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 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) + 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) + } + }) +} + +// 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 = 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] } + })) +} + +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 new file mode 100644 index 0000000..bf1f03a --- /dev/null +++ b/test/turnRedirect.test.js @@ -0,0 +1,176 @@ +/* eslint-env mocha */ +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() } + }) +})