Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ class Client extends EventEmitter {
}

clearNegotiationTimeouts () {
this._offerController?.abort()
if (this._negotiationTimeout) {
clearTimeout(this._negotiationTimeout)
this._negotiationTimeout = null
Expand Down Expand Up @@ -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}`))
Expand Down
110 changes: 110 additions & 0 deletions src/turnRedirect.js
Original file line number Diff line number Diff line change
@@ -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 }
3 changes: 2 additions & 1 deletion src/werift.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { RTCPeerConnection } = require('werift')
const { resolveTurnRedirects } = require('./turnRedirect')

class WeriftPeerConnection extends RTCPeerConnection {
createDataChannel (label, options) {
Expand Down Expand Up @@ -49,4 +50,4 @@ class WeriftPeerConnection extends RTCPeerConnection {
}
}

module.exports = { RTCPeerConnection: WeriftPeerConnection }
module.exports = { RTCPeerConnection: WeriftPeerConnection, resolveIceServers: resolveTurnRedirects }
2 changes: 2 additions & 0 deletions test/backends.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}

Expand Down
176 changes: 176 additions & 0 deletions test/turnRedirect.test.js
Original file line number Diff line number Diff line change
@@ -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() }
})
})
Loading