Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -215,6 +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)

try {
Expand Down
81 changes: 81 additions & 0 deletions src/turnRedirect.js
Original file line number Diff line number Diff line change
@@ -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 }
37 changes: 37 additions & 0 deletions test/turnRedirect.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading