|
| 1 | +/* eslint-env mocha */ |
| 2 | +const assert = require('node:assert/strict') |
| 3 | +const dgram = require('node:dgram') |
| 4 | +const { once } = require('node:events') |
| 5 | +const { setTimeout: delay } = require('node:timers/promises') |
| 6 | +const { resolveTurnRedirects, probeTurnAlternate } = require('../src/turnRedirect') |
| 7 | +const { Client } = require('../src/client') |
| 8 | + |
| 9 | +function response (request, code = 300, ip = [192, 0, 2, 10]) { |
| 10 | + const packet = Buffer.alloc(40) |
| 11 | + request.copy(packet, 0, 0, 20) |
| 12 | + packet.writeUInt16BE(0x0113, 0) |
| 13 | + packet.writeUInt16BE(20, 2) |
| 14 | + packet.writeUInt16BE(9, 20) |
| 15 | + packet.writeUInt16BE(4, 22) |
| 16 | + packet[26] = Math.floor(code / 100) |
| 17 | + packet[27] = code % 100 |
| 18 | + packet.writeUInt16BE(0x8023, 28) |
| 19 | + packet.writeUInt16BE(8, 30) |
| 20 | + packet[33] = 1 |
| 21 | + packet.writeUInt16BE(3478, 34) |
| 22 | + Buffer.from(ip).copy(packet, 36) |
| 23 | + return packet |
| 24 | +} |
| 25 | + |
| 26 | +describe('TURN redirect probes', function () { |
| 27 | + this.timeout(5000) |
| 28 | + let relay |
| 29 | + let port |
| 30 | + beforeEach(async () => { |
| 31 | + relay = dgram.createSocket('udp4') |
| 32 | + relay.bind(0, '127.0.0.1') |
| 33 | + await once(relay, 'listening') |
| 34 | + port = relay.address().port |
| 35 | + }) |
| 36 | + afterEach(async () => { |
| 37 | + const closed = once(relay, 'close') |
| 38 | + relay.close() |
| 39 | + await closed |
| 40 | + }) |
| 41 | + |
| 42 | + it('sends Allocate and preserves credentials, URL shape and UDP query', async () => { |
| 43 | + relay.on('message', (request, peer) => { |
| 44 | + assert.equal(request.readUInt16BE(0), 3) |
| 45 | + assert.equal(request.readUInt32BE(4), 0x2112a442) |
| 46 | + assert.equal(request.readUInt16BE(20), 0x19) |
| 47 | + assert.equal(request[24], 17) |
| 48 | + relay.send(response(request), peer.port, peer.address) |
| 49 | + }) |
| 50 | + const input = [{ urls: [`turn:127.0.0.1:${port}?transport=udp`], username: 'user', credential: 'secret' }] |
| 51 | + const output = await resolveTurnRedirects(input) |
| 52 | + assert.deepEqual(output, [{ urls: ['turn:192.0.2.10:3478?transport=udp'], username: 'user', credential: 'secret' }]) |
| 53 | + assert.equal(input[0].urls[0], `turn:127.0.0.1:${port}?transport=udp`) |
| 54 | + }) |
| 55 | + |
| 56 | + it('ignores a different sender and malformed/unmatched replies before a valid reply', async () => { |
| 57 | + const stranger = dgram.createSocket('udp4') |
| 58 | + let sends |
| 59 | + relay.once('message', (request, peer) => { |
| 60 | + sends = (async () => { |
| 61 | + stranger.send(response(request, 300, [192, 0, 2, 99]), peer.port, peer.address) |
| 62 | + const invalid = [Buffer.alloc(2)] |
| 63 | + for (const offset of [0, 4, 8]) { |
| 64 | + const packet = response(request) |
| 65 | + packet[offset] ^= 1 |
| 66 | + invalid.push(packet) |
| 67 | + } |
| 68 | + const truncated = response(request) |
| 69 | + truncated.writeUInt16BE(24, 2) |
| 70 | + invalid.push(truncated) |
| 71 | + const badAttribute = response(request) |
| 72 | + badAttribute.writeUInt16BE(100, 30) |
| 73 | + invalid.push(badAttribute) |
| 74 | + for (const packet of invalid) relay.send(packet, peer.port, peer.address) |
| 75 | + await delay(30) |
| 76 | + relay.send(response(request), peer.port, peer.address) |
| 77 | + })() |
| 78 | + }) |
| 79 | + try { |
| 80 | + assert.deepEqual(await probeTurnAlternate('127.0.0.1', port), { ip: '192.0.2.10', port: 3478 }) |
| 81 | + await sends |
| 82 | + } finally { |
| 83 | + stranger.close() |
| 84 | + } |
| 85 | + }) |
| 86 | + |
| 87 | + it('leaves a non-redirecting server unchanged', async () => { |
| 88 | + relay.on('message', (request, peer) => relay.send(response(request, 401), peer.port, peer.address)) |
| 89 | + const input = [{ urls: `turn:127.0.0.1:${port}`, username: 'u', credential: 'p' }] |
| 90 | + assert.deepEqual(await resolveTurnRedirects(input), input) |
| 91 | + }) |
| 92 | + |
| 93 | + it('does not probe TCP, TLS, STUN or unsupported URLs', async () => { |
| 94 | + let probes = 0 |
| 95 | + relay.on('message', () => { probes++ }) |
| 96 | + const urls = [ |
| 97 | + `turn:127.0.0.1:${port}?transport=tcp`, `turns:127.0.0.1:${port}`, |
| 98 | + `stun:127.0.0.1:${port}`, 'turn:[::1]:3478', 'turn:127.0.0.1:99999', |
| 99 | + `turn:127.0.0.1:${port}?transport=unknown` |
| 100 | + ] |
| 101 | + assert.deepEqual(await resolveTurnRedirects([{ urls }]), [{ urls }]) |
| 102 | + await delay(20) |
| 103 | + assert.equal(probes, 0) |
| 104 | + }) |
| 105 | + |
| 106 | + it('retries after a timeout and refreshes successful redirects on later joins', async () => { |
| 107 | + const input = [{ urls: `turn:127.0.0.1:${port}` }] |
| 108 | + assert.deepEqual(await resolveTurnRedirects(input, { timeoutMs: 30 }), input) |
| 109 | + let attempts = 0 |
| 110 | + relay.on('message', (request, peer) => { |
| 111 | + attempts++ |
| 112 | + relay.send(response(request, 300, [192, 0, 2, attempts]), peer.port, peer.address) |
| 113 | + }) |
| 114 | + assert.equal((await resolveTurnRedirects(input))[0].urls, 'turn:192.0.2.1:3478') |
| 115 | + assert.equal((await resolveTurnRedirects(input))[0].urls, 'turn:192.0.2.2:3478') |
| 116 | + }) |
| 117 | + |
| 118 | + it('aborts an outstanding probe and does not start an already-aborted probe', async () => { |
| 119 | + const controller = new AbortController() |
| 120 | + const received = once(relay, 'message') |
| 121 | + const pending = probeTurnAlternate('127.0.0.1', port, { signal: controller.signal }) |
| 122 | + await received |
| 123 | + controller.abort() |
| 124 | + assert.equal(await pending, null) |
| 125 | + let probes = 0 |
| 126 | + relay.on('message', () => { probes++ }) |
| 127 | + assert.equal(await probeTurnAlternate('127.0.0.1', port, { signal: controller.signal }), null) |
| 128 | + await delay(20) |
| 129 | + assert.equal(probes, 0) |
| 130 | + }) |
| 131 | + |
| 132 | + it('does not create a peer after close during a probe', async () => { |
| 133 | + const client = new Client(1n, '127.0.0.1', { |
| 134 | + iceServers: [{ urls: `turn:127.0.0.1:${port}`, username: 'u', credential: 'p' }] |
| 135 | + }) |
| 136 | + let peers = 0 |
| 137 | + client.webrtc = { ...client.webrtc, RTCPeerConnection: class { constructor () { peers++ } } } |
| 138 | + try { |
| 139 | + const received = once(relay, 'message') |
| 140 | + const pending = client.createOffer() |
| 141 | + await received |
| 142 | + client.close() |
| 143 | + await pending |
| 144 | + assert.equal(peers, 0) |
| 145 | + assert.equal(client.rtcConnection, null) |
| 146 | + } finally { client.close() } |
| 147 | + }) |
| 148 | + |
| 149 | + it('discards a timed-out attempt when a retry starts before its lookup settles', async () => { |
| 150 | + const client = new Client(1n, '127.0.0.1', { responseTimeoutMs: 20 }) |
| 151 | + const lookups = [] |
| 152 | + const configurations = [] |
| 153 | + client.webrtc = { |
| 154 | + resolveIceServers: (servers, { signal }) => new Promise(resolve => lookups.push({ resolve, signal })), |
| 155 | + RTCPeerConnection: class { |
| 156 | + constructor (config) { configurations.push(config); throw new Error('stop test at peer creation') } |
| 157 | + } |
| 158 | + } |
| 159 | + client.signalHandler = () => {} |
| 160 | + client.on('error', () => {}) |
| 161 | + try { |
| 162 | + const disconnected = once(client, 'disconnect') |
| 163 | + client.connect() |
| 164 | + await disconnected |
| 165 | + assert.equal(lookups[0].signal.aborted, true) |
| 166 | + client.responseTimeoutMs = 1000 |
| 167 | + client.connect() |
| 168 | + lookups[1].resolve([{ urls: 'stun:new.example' }]) |
| 169 | + await delay(0) |
| 170 | + lookups[0].resolve([{ urls: 'stun:old.example' }]) |
| 171 | + await delay(0) |
| 172 | + assert.deepEqual(configurations, [{ iceServers: [{ urls: 'stun:new.example' }] }]) |
| 173 | + assert.deepEqual(client.credentials, []) |
| 174 | + } finally { client.close() } |
| 175 | + }) |
| 176 | +}) |
0 commit comments