Skip to content

Commit e5bb875

Browse files
Follow TURN 300 Try Alternate redirect for Realm connections (#29)
* Follow TURN 300 Try Alternate redirect for Realm connections * Only pre-resolve TURN when a plain turn: server is present * Validate and cancel Werift TURN probes without caching stale results --------- Co-authored-by: extremeheat <extreme@protonmail.ch>
1 parent eae7c8e commit e5bb875

6 files changed

Lines changed: 307 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,11 @@ backends or retry a connection through another implementation.
113113
Werift's `setLocalDescription()` waits for ICE gathering. NetherNet sends the
114114
resulting SDP with its gathered candidates, in addition to candidate signals.
115115
Backend-specific compatibility handling is isolated in `src/werift.js`.
116+
For Werift clients, IPv4 UDP `turn:` URLs are probed for the Realm gateway's
117+
`300 Try Alternate` redirect before creating the peer. Each probe waits at most
118+
3 seconds (bounded by the connection attempt's lifetime); failures preserve the
119+
original URL. Redirects are refreshed on every join rather than cached. TCP,
120+
TLS and IPv6 TURN URLs, and the native backend, bypass this workaround.
116121

117122
`npm test` runs lint, strict TypeScript checks, and runtime tests without forcing
118123
process exit. Tests cover local transport, signalling errors, cancellation, and

‎src/client.js‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class Client extends EventEmitter {
123123
}
124124

125125
clearNegotiationTimeouts () {
126+
this._offerController?.abort()
126127
if (this._negotiationTimeout) {
127128
clearTimeout(this._negotiationTimeout)
128129
this._negotiationTimeout = null
@@ -215,11 +216,19 @@ class Client extends EventEmitter {
215216
}
216217

217218
async createOffer () {
218-
debug('Creating RTCPeerConnection with ICE servers:', this.credentials)
219-
219+
if (this._closed) return
220+
this._offerController?.abort()
221+
const controller = new AbortController()
222+
this._offerController = controller
220223
try {
221-
this.rtcConnection = new this.webrtc.RTCPeerConnection({ iceServers: validateIceServers(normalizeIceServers(this.credentials)) })
224+
let iceServers = validateIceServers(normalizeIceServers(this.credentials))
225+
if (this.webrtc.resolveIceServers) {
226+
iceServers = await this.webrtc.resolveIceServers(iceServers, { signal: controller.signal })
227+
}
228+
if (controller.signal.aborted || this._closed) return
229+
this.rtcConnection = new this.webrtc.RTCPeerConnection({ iceServers })
222230
} catch (err) {
231+
if (controller.signal.aborted || this._closed) return
223232
debug('Failed to create RTCPeerConnection:', err)
224233
this.failNegotiation(this.serverNetworkId, ErrorCode.FailedToCreatePeerConnection)
225234
this.reportError(new Error(`Failed to create peer connection: ${err.message}`))

‎src/turnRedirect.js‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
const dgram = require('node:dgram')
2+
const { randomBytes } = require('node:crypto')
3+
const { isIP } = require('node:net')
4+
5+
const MAGIC = 0x2112a442
6+
const ALLOCATE_ERROR = 0x0113
7+
const ERROR_CODE = 0x0009
8+
const ALTERNATE_SERVER = 0x8023
9+
const PROBE_TIMEOUT_MS = 3000
10+
11+
// Only IPv4 UDP TURN is handled here. TCP/TLS and IPv6 stay with the backend.
12+
function parseTurnUrl (url) {
13+
if (typeof url !== 'string') return null
14+
const match = /^turn:([^:/?]+)(?::(\d+))?(\?transport=udp)?$/i.exec(url)
15+
if (!match || match[1].includes('[')) return null
16+
const port = Number(match[2] || 3478)
17+
if (port < 1 || port > 65535) return null
18+
return { host: match[1], port, query: match[3] || '' }
19+
}
20+
21+
function parseRedirect (msg, request) {
22+
if (msg.length < 20 || msg.readUInt16BE(0) !== ALLOCATE_ERROR ||
23+
msg.readUInt32BE(4) !== MAGIC || !msg.subarray(8, 20).equals(request.subarray(8, 20))) return
24+
const length = msg.readUInt16BE(2)
25+
if (length % 4 || length + 20 !== msg.length) return
26+
let code
27+
let alternate
28+
for (let offset = 20; offset < msg.length;) {
29+
if (offset + 4 > msg.length) return
30+
const type = msg.readUInt16BE(offset)
31+
const size = msg.readUInt16BE(offset + 2)
32+
const end = offset + 4 + size
33+
const next = end + ((4 - size % 4) % 4)
34+
if (next > msg.length) return
35+
const value = msg.subarray(offset + 4, end)
36+
if (type === ERROR_CODE) {
37+
if (size < 4) return
38+
code = (value[2] & 7) * 100 + value[3]
39+
} else if (type === ALTERNATE_SERVER) {
40+
if (size === 8 && value[1] === 1) {
41+
alternate = { ip: [...value.subarray(4)].join('.'), port: value.readUInt16BE(2) }
42+
}
43+
}
44+
offset = next
45+
}
46+
if (code === undefined) return
47+
// null means a matched response without a usable redirect; undefined is invalid.
48+
return code === 300 && alternate?.port ? alternate : null
49+
}
50+
51+
function probeTurnAlternate (host, port, { timeoutMs = PROBE_TIMEOUT_MS, signal } = {}) {
52+
if (signal?.aborted || isIP(host) === 6) return Promise.resolve(null)
53+
const request = Buffer.alloc(28)
54+
request.writeUInt16BE(3, 0) // Allocate request
55+
request.writeUInt16BE(8, 2)
56+
request.writeUInt32BE(MAGIC, 4)
57+
randomBytes(12).copy(request, 8)
58+
request.writeUInt16BE(0x0019, 20) // REQUESTED-TRANSPORT: UDP relay allocation
59+
request.writeUInt16BE(4, 22)
60+
request[24] = 17
61+
62+
return new Promise(resolve => {
63+
const socket = dgram.createSocket('udp4')
64+
let done = false
65+
const finish = result => {
66+
if (done) return
67+
done = true
68+
clearTimeout(timer)
69+
signal?.removeEventListener('abort', onAbort)
70+
try { socket.close() } catch (error) {
71+
if (error.code !== 'ERR_SOCKET_DGRAM_NOT_RUNNING') throw error
72+
}
73+
resolve(result)
74+
}
75+
const onAbort = () => finish(null)
76+
const timer = setTimeout(() => finish(null), timeoutMs)
77+
signal?.addEventListener('abort', onAbort, { once: true })
78+
socket.on('error', () => finish(null))
79+
socket.on('message', msg => {
80+
const result = parseRedirect(msg, request)
81+
if (result !== undefined) finish(result)
82+
})
83+
// Connecting filters packets from other endpoints, including different ports.
84+
try {
85+
socket.connect(port, host, () => {
86+
if (!done) socket.send(request, error => { if (error) finish(null) })
87+
})
88+
} catch {
89+
finish(null)
90+
}
91+
})
92+
}
93+
94+
// Werift does not follow the Realm gateway's TURN 300 redirect. Resolve it using
95+
// an unauthenticated probe, then let Werift authenticate with the alternate.
96+
// Do not cache: gateway assignments and network failures can change between joins.
97+
async function resolveTurnRedirects (iceServers, options = {}) {
98+
return Promise.all(iceServers.map(async server => {
99+
const urls = Array.isArray(server.urls) ? server.urls : [server.urls]
100+
const rewritten = await Promise.all(urls.map(async url => {
101+
const target = parseTurnUrl(url)
102+
if (!target) return url
103+
const alternate = await probeTurnAlternate(target.host, target.port, options)
104+
return alternate ? `turn:${alternate.ip}:${alternate.port}${target.query}` : url
105+
}))
106+
return { ...server, urls: Array.isArray(server.urls) ? rewritten : rewritten[0] }
107+
}))
108+
}
109+
110+
module.exports = { resolveTurnRedirects, probeTurnAlternate }

‎src/werift.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { RTCPeerConnection } = require('werift')
2+
const { resolveTurnRedirects } = require('./turnRedirect')
23

34
class WeriftPeerConnection extends RTCPeerConnection {
45
createDataChannel (label, options) {
@@ -49,4 +50,4 @@ class WeriftPeerConnection extends RTCPeerConnection {
4950
}
5051
}
5152

52-
module.exports = { RTCPeerConnection: WeriftPeerConnection }
53+
module.exports = { RTCPeerConnection: WeriftPeerConnection, resolveIceServers: resolveTurnRedirects }

‎test/backends.test.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ describe('WebRTC backends', function () {
4141
if (process.env.TEST_NATIVE_WEBRTC) {
4242
it('auto selects an installed native backend', () => {
4343
assert.equal(getWebRTC('auto'), require('@roamhq/wrtc'))
44+
assert.equal(getWebRTC('auto').resolveIceServers, undefined)
45+
assert.equal(getWebRTC('wrtc').resolveIceServers, undefined)
4446
})
4547
}
4648

‎test/turnRedirect.test.js‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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

Comments
 (0)