node-rtc-connection 1.0.19 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -85
- package/dist/index.cjs +20 -5606
- package/dist/index.mjs +25 -5598
- package/dist/types/crypto/der.d.ts +107 -0
- package/dist/types/crypto/x509.d.ts +56 -0
- package/dist/types/datachannel/RTCDataChannel.d.ts +179 -0
- package/dist/types/dtls/RTCCertificate.d.ts +163 -0
- package/dist/types/dtls/cipher.d.ts +81 -0
- package/dist/types/dtls/connection.d.ts +81 -0
- package/dist/types/dtls/prf.d.ts +29 -0
- package/dist/types/dtls/protocol.d.ts +127 -0
- package/dist/types/foundation/ByteBufferQueue.d.ts +71 -0
- package/dist/types/foundation/RTCError.d.ts +152 -0
- package/dist/types/ice/RTCIceCandidate.d.ts +161 -0
- package/dist/types/ice/ice-agent.d.ts +154 -0
- package/dist/types/ice/stun-message.d.ts +92 -0
- package/dist/types/index.d.ts +29 -0
- package/dist/types/peerconnection/RTCPeerConnection.d.ts +74 -0
- package/dist/types/sctp/association.d.ts +77 -0
- package/dist/types/sctp/chunks.d.ts +200 -0
- package/dist/types/sctp/crc32c.d.ts +24 -0
- package/dist/types/sctp/datachannel-manager.d.ts +51 -0
- package/dist/types/sctp/dcep.d.ts +56 -0
- package/dist/types/sdp/RTCSessionDescription.d.ts +73 -0
- package/dist/types/sdp/sdp-utils.d.ts +103 -0
- package/dist/types/stun/stun-client.d.ts +119 -0
- package/dist/types/transport-stack.d.ts +68 -0
- package/package.json +26 -21
- package/src/crypto/der.ts +205 -0
- package/src/crypto/x509.ts +146 -0
- package/src/datachannel/RTCDataChannel.ts +388 -0
- package/src/dtls/RTCCertificate.ts +396 -0
- package/src/dtls/cipher.ts +198 -0
- package/src/dtls/connection.ts +974 -0
- package/src/dtls/prf.ts +62 -0
- package/src/dtls/protocol.ts +204 -0
- package/src/foundation/{ByteBufferQueue.js → ByteBufferQueue.ts} +74 -72
- package/src/foundation/{RTCError.js → RTCError.ts} +110 -60
- package/src/ice/{RTCIceCandidate.js → RTCIceCandidate.ts} +140 -92
- package/src/ice/ice-agent.ts +609 -0
- package/src/ice/stun-message.ts +260 -0
- package/src/index.ts +72 -0
- package/src/peerconnection/RTCPeerConnection.ts +430 -0
- package/src/sctp/association.ts +523 -0
- package/src/sctp/chunks.ts +350 -0
- package/src/sctp/crc32c.ts +57 -0
- package/src/sctp/datachannel-manager.ts +187 -0
- package/src/sctp/dcep.ts +94 -0
- package/src/sdp/{RTCSessionDescription.js → RTCSessionDescription.ts} +42 -29
- package/src/sdp/sdp-utils.ts +229 -0
- package/src/stun/{stun-client.js → stun-client.ts} +346 -187
- package/src/transport-stack.ts +165 -0
- package/dist/index.cjs.map +0 -1
- package/dist/index.mjs.map +0 -1
- package/src/datachannel/RTCDataChannel.js +0 -354
- package/src/dtls/RTCCertificate.js +0 -310
- package/src/dtls/RTCDtlsTransport.js +0 -247
- package/src/ice/RTCIceTransport.js +0 -1018
- package/src/index.d.ts +0 -400
- package/src/index.js +0 -92
- package/src/network/network-transport.js +0 -478
- package/src/peerconnection/RTCPeerConnection.js +0 -875
- package/src/sctp/RTCSctpTransport.js +0 -253
- package/src/sdp/sdp-utils.js +0 -224
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file ice-agent.ts
|
|
3
|
+
* @description A small but RFC 8445-compliant ICE agent for a single data
|
|
4
|
+
* component, with browser-compatible connectivity checks and TURN relay.
|
|
5
|
+
* @module ice/ice-agent
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Gather UDP host candidates, server-reflexive (srflx) candidates via STUN,
|
|
9
|
+
* and relay candidates via TURN (RFC 5766 ALLOCATE).
|
|
10
|
+
* - Send/answer STUN Binding connectivity checks carrying USERNAME,
|
|
11
|
+
* MESSAGE-INTEGRITY (keyed by the remote/local ice-pwd), PRIORITY, the
|
|
12
|
+
* ICE-CONTROLLING/CONTROLLED role attribute, and USE-CANDIDATE.
|
|
13
|
+
* - Nominate a candidate pair and expose it as the selected path.
|
|
14
|
+
* - Demultiplex inbound datagrams per RFC 7983: STUN (first byte 0-3) is
|
|
15
|
+
* handled internally; everything else (DTLS records, first byte 20-63) is
|
|
16
|
+
* emitted as 'data' for the upper stack.
|
|
17
|
+
*
|
|
18
|
+
* Each local candidate carries a `transport` with a uniform interface so the
|
|
19
|
+
* connectivity-check and data paths are identical whether the candidate is a
|
|
20
|
+
* host socket or a TURN relay:
|
|
21
|
+
* transport.send(buf, remoteAddress, remotePort)
|
|
22
|
+
* transport.onMessage = (buf, {address, port}) => ...
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
import * as dgram from 'dgram';
|
|
28
|
+
import * as os from 'os';
|
|
29
|
+
import * as crypto from 'crypto';
|
|
30
|
+
import { EventEmitter } from 'events';
|
|
31
|
+
import * as S from './stun-message';
|
|
32
|
+
import STUNClient from '../stun/stun-client';
|
|
33
|
+
|
|
34
|
+
const TYPE_PREF: Record<string, number> = { host: 126, srflx: 100, prflx: 110, relay: 0 };
|
|
35
|
+
const CHECK_INTERVAL_MS = 50;
|
|
36
|
+
const CHECK_TIMEOUT_MS = 10000;
|
|
37
|
+
|
|
38
|
+
/** Remote info accompanying an inbound datagram. */
|
|
39
|
+
interface RemoteInfo {
|
|
40
|
+
address: string;
|
|
41
|
+
port: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Uniform transport abstraction shared by host sockets and TURN relays.
|
|
46
|
+
*/
|
|
47
|
+
interface Transport {
|
|
48
|
+
kind: string;
|
|
49
|
+
send(buf: Buffer, address: string, port: number): void;
|
|
50
|
+
onMessage: ((msg: Buffer, rinfo: RemoteInfo) => void) | null;
|
|
51
|
+
close(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A local ICE candidate. */
|
|
55
|
+
interface LocalCandidate {
|
|
56
|
+
foundation: string;
|
|
57
|
+
component: number;
|
|
58
|
+
protocol: string;
|
|
59
|
+
priority: number;
|
|
60
|
+
address: string;
|
|
61
|
+
port: number;
|
|
62
|
+
type: string;
|
|
63
|
+
transport: Transport;
|
|
64
|
+
sdp: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A remote ICE candidate (parsed from an a=candidate line or object). */
|
|
68
|
+
interface RemoteCandidate {
|
|
69
|
+
address: string;
|
|
70
|
+
port: number;
|
|
71
|
+
priority?: number;
|
|
72
|
+
type?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A candidate pair under connectivity checking. */
|
|
76
|
+
interface CandidatePair {
|
|
77
|
+
key?: string;
|
|
78
|
+
local: { transport: Transport } & Partial<LocalCandidate>;
|
|
79
|
+
remote: { address: string; port: number } & Partial<RemoteCandidate>;
|
|
80
|
+
state?: string;
|
|
81
|
+
nominated?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Description of a single ICE server entry. */
|
|
85
|
+
interface IceServer {
|
|
86
|
+
urls: string | string[];
|
|
87
|
+
username?: string;
|
|
88
|
+
credential?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Options accepted by {@link IceAgent#gather}. */
|
|
92
|
+
interface GatherOptions {
|
|
93
|
+
iceServers?: IceServer[];
|
|
94
|
+
iceTransportPolicy?: 'all' | 'relay';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Parsed query parameters from a STUN/TURN URL. */
|
|
98
|
+
type IceServerParams = Record<string, string | true>;
|
|
99
|
+
|
|
100
|
+
/** Result of {@link parseIceServerUrl}. */
|
|
101
|
+
interface ParsedIceServerUrl {
|
|
102
|
+
scheme: string;
|
|
103
|
+
protocol: string;
|
|
104
|
+
host: string;
|
|
105
|
+
port: number;
|
|
106
|
+
transport: string;
|
|
107
|
+
params: IceServerParams;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Options accepted by the {@link IceAgent} constructor. */
|
|
111
|
+
interface IceAgentOptions {
|
|
112
|
+
role: 'controlling' | 'controlled';
|
|
113
|
+
localUfrag: string;
|
|
114
|
+
localPwd: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Bookkeeping for a bound host socket and its derived candidate. */
|
|
118
|
+
interface HostEntry {
|
|
119
|
+
socket: dgram.Socket;
|
|
120
|
+
address: string;
|
|
121
|
+
port: number;
|
|
122
|
+
transport: Transport;
|
|
123
|
+
candidate: LocalCandidate;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Extra fields stored alongside a candidate (related address/port). */
|
|
127
|
+
interface CandidateExtra {
|
|
128
|
+
relatedAddress?: string;
|
|
129
|
+
relatedPort?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compute an ICE candidate priority (RFC 8445 §5.1.2.1).
|
|
134
|
+
*/
|
|
135
|
+
function candidatePriority(type: string, localPref = 65535, componentId = 1): number {
|
|
136
|
+
return ((TYPE_PREF[type]! << 24) + (localPref << 8) + (256 - componentId)) >>> 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse a STUN/TURN server URL: (stun|turn|turns):host[:port][?key=val&...].
|
|
141
|
+
* Query parameters are returned in `params`; a flag without a value (e.g.
|
|
142
|
+
* "?secure") is recorded as `true`, an empty value ("?transport=") as "".
|
|
143
|
+
* @param {string} url
|
|
144
|
+
* @returns {{scheme:string, protocol:string, host:string, port:number,
|
|
145
|
+
* transport:string, params:Object}|null} null if the URL is invalid.
|
|
146
|
+
*/
|
|
147
|
+
function parseIceServerUrl(url: string): ParsedIceServerUrl | null {
|
|
148
|
+
const m = url.match(/^(stuns?|turns?):\/?\/?([^:?]+):?(\d+)?(?:\?(.+))?$/);
|
|
149
|
+
if (!m) return null;
|
|
150
|
+
const scheme = m[1]!;
|
|
151
|
+
const host = m[2]!;
|
|
152
|
+
const params: IceServerParams = {};
|
|
153
|
+
if (m[4]) {
|
|
154
|
+
for (const kv of m[4].split('&')) {
|
|
155
|
+
if (!kv) continue;
|
|
156
|
+
const eq = kv.indexOf('=');
|
|
157
|
+
if (eq === -1) params[kv] = true; // flag without a value
|
|
158
|
+
else params[kv.slice(0, eq)] = kv.slice(eq + 1); // value may be ""
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const defaultPort = scheme === 'turns' || scheme === 'stuns' ? 5349 : 3478;
|
|
162
|
+
return {
|
|
163
|
+
scheme,
|
|
164
|
+
protocol: scheme, // alias
|
|
165
|
+
host,
|
|
166
|
+
port: parseInt(m[3] || String(defaultPort), 10),
|
|
167
|
+
transport: typeof params.transport === 'string' ? params.transport : 'udp',
|
|
168
|
+
params,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A host transport: a bound UDP socket. send() targets an arbitrary peer.
|
|
174
|
+
*/
|
|
175
|
+
class HostTransport implements Transport {
|
|
176
|
+
kind: string;
|
|
177
|
+
#socket: dgram.Socket;
|
|
178
|
+
onMessage: ((msg: Buffer, rinfo: RemoteInfo) => void) | null;
|
|
179
|
+
|
|
180
|
+
constructor(socket: dgram.Socket) {
|
|
181
|
+
this.kind = 'host';
|
|
182
|
+
this.#socket = socket;
|
|
183
|
+
this.onMessage = null;
|
|
184
|
+
socket.on('message', (msg: Buffer, rinfo: dgram.RemoteInfo) => {
|
|
185
|
+
if (this.onMessage) this.onMessage(msg, { address: rinfo.address, port: rinfo.port });
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
send(buf: Buffer, address: string, port: number): void {
|
|
189
|
+
this.#socket.send(buf, port, address);
|
|
190
|
+
}
|
|
191
|
+
close(): void {
|
|
192
|
+
try { this.#socket.close(); } catch (_) {}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A relay transport backed by a TURN allocation. send() installs a permission
|
|
198
|
+
* for the peer (idempotent best-effort) and forwards via SEND indication;
|
|
199
|
+
* inbound arrives as DATA indications on the TURN client's 'data' event.
|
|
200
|
+
*/
|
|
201
|
+
class RelayTransport implements Transport {
|
|
202
|
+
kind: string;
|
|
203
|
+
#client: STUNClient;
|
|
204
|
+
onMessage: ((msg: Buffer, rinfo: RemoteInfo) => void) | null;
|
|
205
|
+
#permitted: Set<string>;
|
|
206
|
+
|
|
207
|
+
constructor(turnClient: STUNClient) {
|
|
208
|
+
this.kind = 'relay';
|
|
209
|
+
this.#client = turnClient;
|
|
210
|
+
this.onMessage = null;
|
|
211
|
+
this.#permitted = new Set();
|
|
212
|
+
turnClient.on('data', (data: Buffer, peer: { address: string; port: number }) => {
|
|
213
|
+
if (this.onMessage) this.onMessage(data, { address: peer.address, port: peer.port });
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
send(buf: Buffer, address: string, port: number): void {
|
|
217
|
+
const key = `${address}:${port}`;
|
|
218
|
+
if (!this.#permitted.has(key)) {
|
|
219
|
+
this.#permitted.add(key);
|
|
220
|
+
// Install permission, then send. Subsequent sends skip the permission.
|
|
221
|
+
this.#client.createPermission(address)
|
|
222
|
+
.then(() => this.#client.sendIndication(address, port, buf))
|
|
223
|
+
.catch(() => {});
|
|
224
|
+
} else {
|
|
225
|
+
this.#client.sendIndication(address, port, buf).catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
close(): void {
|
|
229
|
+
try { this.#client.close(); } catch (_) {}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
class IceAgent extends EventEmitter {
|
|
234
|
+
role: 'controlling' | 'controlled';
|
|
235
|
+
localUfrag: string;
|
|
236
|
+
localPwd: string;
|
|
237
|
+
remoteUfrag: string | null;
|
|
238
|
+
remotePwd: string | null;
|
|
239
|
+
|
|
240
|
+
#tieBreaker: Buffer;
|
|
241
|
+
#transports: Transport[];
|
|
242
|
+
#localCandidates: LocalCandidate[];
|
|
243
|
+
#remoteCandidates: RemoteCandidate[];
|
|
244
|
+
#pairs: CandidatePair[];
|
|
245
|
+
#selected: CandidatePair | null;
|
|
246
|
+
#closed: boolean;
|
|
247
|
+
#checkTimer: NodeJS.Timeout | null;
|
|
248
|
+
#timeoutTimer: NodeJS.Timeout | null;
|
|
249
|
+
#connected: boolean;
|
|
250
|
+
#pendingChecks: Map<string, CandidatePair>;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {Object} opts
|
|
254
|
+
* @param {'controlling'|'controlled'} opts.role
|
|
255
|
+
* @param {string} opts.localUfrag
|
|
256
|
+
* @param {string} opts.localPwd
|
|
257
|
+
*/
|
|
258
|
+
constructor(opts: IceAgentOptions) {
|
|
259
|
+
super();
|
|
260
|
+
this.role = opts.role;
|
|
261
|
+
this.localUfrag = opts.localUfrag;
|
|
262
|
+
this.localPwd = opts.localPwd;
|
|
263
|
+
this.remoteUfrag = null;
|
|
264
|
+
this.remotePwd = null;
|
|
265
|
+
|
|
266
|
+
this.#tieBreaker = crypto.randomBytes(8);
|
|
267
|
+
this.#transports = []; // HostTransport | RelayTransport
|
|
268
|
+
this.#localCandidates = [];
|
|
269
|
+
this.#remoteCandidates = [];
|
|
270
|
+
this.#pairs = [];
|
|
271
|
+
this.#selected = null;
|
|
272
|
+
this.#closed = false;
|
|
273
|
+
this.#checkTimer = null;
|
|
274
|
+
this.#timeoutTimer = null;
|
|
275
|
+
this.#connected = false;
|
|
276
|
+
this.#pendingChecks = new Map(); // txid hex -> pair
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Gather candidates. Host candidates always; srflx/relay when iceServers are
|
|
281
|
+
* given. With iceTransportPolicy 'relay', only relay candidates are kept.
|
|
282
|
+
* @param {Object} [opts]
|
|
283
|
+
* @param {Array<{urls:string|string[],username?:string,credential?:string}>} [opts.iceServers]
|
|
284
|
+
* @param {'all'|'relay'} [opts.iceTransportPolicy='all']
|
|
285
|
+
*/
|
|
286
|
+
async gather(opts: GatherOptions = {}): Promise<void> {
|
|
287
|
+
const iceServers = opts.iceServers || [];
|
|
288
|
+
const relayOnly = opts.iceTransportPolicy === 'relay';
|
|
289
|
+
|
|
290
|
+
const hostEntries = await this.#gatherHosts();
|
|
291
|
+
|
|
292
|
+
// Server-reflexive + relay candidates need a host socket to originate from.
|
|
293
|
+
for (const server of iceServers) {
|
|
294
|
+
const urls = Array.isArray(server.urls) ? server.urls : [server.urls];
|
|
295
|
+
for (const url of urls) {
|
|
296
|
+
const parsed = parseIceServerUrl(url);
|
|
297
|
+
if (!parsed || parsed.transport !== 'udp') continue; // UDP only for now
|
|
298
|
+
try {
|
|
299
|
+
if (parsed.scheme === 'stun' && !relayOnly) {
|
|
300
|
+
await this.#gatherSrflx(parsed, hostEntries[0]);
|
|
301
|
+
} else if (parsed.scheme === 'turn' || parsed.scheme === 'turns') {
|
|
302
|
+
await this.#gatherRelay(parsed, server);
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
// A failed server must not abort gathering; just skip it.
|
|
306
|
+
this.emit('gathererror', { url, error: err instanceof Error ? err.message : String(err) });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (relayOnly) {
|
|
312
|
+
// Drop host/srflx candidates and their transports from the working set.
|
|
313
|
+
this.#localCandidates = this.#localCandidates.filter((c) => c.type === 'relay');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.emit('gatheringcomplete');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Bind one UDP socket per non-internal IPv4 interface; emit host candidates. */
|
|
320
|
+
async #gatherHosts(): Promise<HostEntry[]> {
|
|
321
|
+
const ifaces = os.networkInterfaces();
|
|
322
|
+
const addrs: string[] = [];
|
|
323
|
+
for (const list of Object.values(ifaces)) {
|
|
324
|
+
if (!list) continue;
|
|
325
|
+
for (const a of list) {
|
|
326
|
+
if (a.family === 'IPv4' && !a.internal) addrs.push(a.address);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (addrs.length === 0) addrs.push('127.0.0.1');
|
|
330
|
+
|
|
331
|
+
const entries: HostEntry[] = [];
|
|
332
|
+
for (const address of addrs) {
|
|
333
|
+
entries.push(await this.#bindHost(address));
|
|
334
|
+
}
|
|
335
|
+
return entries;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#bindHost(address: string): Promise<HostEntry> {
|
|
339
|
+
return new Promise((resolve, _reject) => {
|
|
340
|
+
const socket = dgram.createSocket('udp4');
|
|
341
|
+
socket.on('error', (err: Error) => this.emit('error', err));
|
|
342
|
+
socket.bind(0, address, () => {
|
|
343
|
+
const { port } = socket.address();
|
|
344
|
+
const transport = new HostTransport(socket);
|
|
345
|
+
transport.onMessage = (msg, rinfo) => this.#onDatagram(transport, msg, rinfo);
|
|
346
|
+
this.#transports.push(transport);
|
|
347
|
+
const cand = this.#addLocalCandidate('host', address, port, transport);
|
|
348
|
+
resolve({ socket, address, port, transport, candidate: cand });
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Discover the server-reflexive address via a STUN binding request. */
|
|
354
|
+
async #gatherSrflx(parsed: ParsedIceServerUrl, hostEntry: HostEntry | undefined): Promise<void> {
|
|
355
|
+
if (!hostEntry) return;
|
|
356
|
+
const stun = new STUNClient({ server: parsed.host, port: parsed.port });
|
|
357
|
+
try {
|
|
358
|
+
const addr = await stun.getReflexiveAddress() as { address: string; port: number };
|
|
359
|
+
// srflx is reached through the host socket; reuse its transport.
|
|
360
|
+
this.#addLocalCandidate('srflx', addr.address, addr.port, hostEntry.transport, {
|
|
361
|
+
relatedAddress: hostEntry.address, relatedPort: hostEntry.port,
|
|
362
|
+
});
|
|
363
|
+
} finally {
|
|
364
|
+
stun.close();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Allocate a TURN relay and expose it as a relay candidate + transport. */
|
|
369
|
+
async #gatherRelay(parsed: ParsedIceServerUrl, server: IceServer): Promise<void> {
|
|
370
|
+
if (!server.username || !server.credential) {
|
|
371
|
+
throw new Error('TURN server requires username and credential');
|
|
372
|
+
}
|
|
373
|
+
const turn = new STUNClient({
|
|
374
|
+
server: parsed.host,
|
|
375
|
+
port: parsed.port,
|
|
376
|
+
username: server.username,
|
|
377
|
+
credential: server.credential,
|
|
378
|
+
transport: parsed.transport,
|
|
379
|
+
});
|
|
380
|
+
const alloc = await turn.allocateRelay(600) as { relayedAddress: string; relayedPort: number };
|
|
381
|
+
const transport = new RelayTransport(turn);
|
|
382
|
+
transport.onMessage = (msg, rinfo) => this.#onDatagram(transport, msg, rinfo);
|
|
383
|
+
this.#transports.push(transport);
|
|
384
|
+
this.#addLocalCandidate('relay', alloc.relayedAddress, alloc.relayedPort, transport, {
|
|
385
|
+
relatedAddress: parsed.host, relatedPort: parsed.port,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#addLocalCandidate(
|
|
390
|
+
type: string,
|
|
391
|
+
address: string,
|
|
392
|
+
port: number,
|
|
393
|
+
transport: Transport,
|
|
394
|
+
extra: CandidateExtra = {}
|
|
395
|
+
): LocalCandidate {
|
|
396
|
+
const foundation = crypto.createHash('md5')
|
|
397
|
+
.update(`${type}:${address}:${transport.kind}`).digest('hex').slice(0, 8);
|
|
398
|
+
const priority = candidatePriority(type);
|
|
399
|
+
let sdp = `candidate:${foundation} 1 udp ${priority} ${address} ${port} typ ${type}`;
|
|
400
|
+
if (extra.relatedAddress) {
|
|
401
|
+
sdp += ` raddr ${extra.relatedAddress} rport ${extra.relatedPort}`;
|
|
402
|
+
}
|
|
403
|
+
const cand: LocalCandidate = { foundation, component: 1, protocol: 'udp', priority, address, port, type, transport, sdp };
|
|
404
|
+
this.#localCandidates.push(cand);
|
|
405
|
+
this.emit('candidate', cand);
|
|
406
|
+
return cand;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
getLocalCandidates(): LocalCandidate[] {
|
|
410
|
+
return this.#localCandidates.slice();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Set remote ICE credentials (from the peer's SDP). */
|
|
414
|
+
setRemoteCredentials(ufrag: string, pwd: string): void {
|
|
415
|
+
this.remoteUfrag = ufrag;
|
|
416
|
+
this.remotePwd = pwd;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Add a remote candidate (parsed from an a=candidate line or object).
|
|
421
|
+
* @param {{address:string, port:number, priority?:number, type?:string}} cand
|
|
422
|
+
*/
|
|
423
|
+
addRemoteCandidate(cand: RemoteCandidate): void {
|
|
424
|
+
if (!cand || !cand.address || !cand.port) return;
|
|
425
|
+
// Browsers obfuscate host candidates as mDNS ".local" hostnames. We don't
|
|
426
|
+
// run an mDNS resolver, so these are unusable and sending checks to them
|
|
427
|
+
// triggers failing DNS lookups. Skip them — connectivity still succeeds via
|
|
428
|
+
// the peer-reflexive candidate we learn from the browser's inbound checks.
|
|
429
|
+
if (typeof cand.address === 'string' && cand.address.endsWith('.local')) return;
|
|
430
|
+
this.#remoteCandidates.push(cand);
|
|
431
|
+
this.#formPairs();
|
|
432
|
+
if (!this.#checkTimer && this.remotePwd) this.#startChecks();
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Begin connectivity checks (call once remote creds + candidates exist). */
|
|
436
|
+
start(): void {
|
|
437
|
+
if (this.remotePwd && this.#remoteCandidates.length > 0) {
|
|
438
|
+
this.#startChecks();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#formPairs(): void {
|
|
443
|
+
for (const local of this.#localCandidates) {
|
|
444
|
+
for (const remote of this.#remoteCandidates) {
|
|
445
|
+
const key = `${local.type}:${local.address}:${local.port}->${remote.address}:${remote.port}`;
|
|
446
|
+
if (this.#pairs.find((p) => p.key === key)) continue;
|
|
447
|
+
this.#pairs.push({ key, local, remote, state: 'frozen', nominated: false });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#startChecks(): void {
|
|
453
|
+
if (this.#checkTimer || this.#closed) return;
|
|
454
|
+
this.#checkTimer = setInterval(() => this.#tick(), CHECK_INTERVAL_MS);
|
|
455
|
+
if (this.#checkTimer.unref) this.#checkTimer.unref();
|
|
456
|
+
this.#timeoutTimer = setTimeout(() => {
|
|
457
|
+
if (!this.#connected) this.emit('failed');
|
|
458
|
+
this.#stopChecks();
|
|
459
|
+
}, CHECK_TIMEOUT_MS);
|
|
460
|
+
if (this.#timeoutTimer.unref) this.#timeoutTimer.unref();
|
|
461
|
+
this.#tick();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
#stopChecks(): void {
|
|
465
|
+
if (this.#checkTimer) { clearInterval(this.#checkTimer); this.#checkTimer = null; }
|
|
466
|
+
if (this.#timeoutTimer) { clearTimeout(this.#timeoutTimer); this.#timeoutTimer = null; }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#tick(): void {
|
|
470
|
+
if (this.#closed) return;
|
|
471
|
+
for (const pair of this.#pairs) {
|
|
472
|
+
if (pair.state === 'succeeded') continue;
|
|
473
|
+
this.#sendCheck(pair);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#sendCheck(pair: CandidatePair): void {
|
|
478
|
+
const txid = crypto.randomBytes(12);
|
|
479
|
+
const username = `${this.remoteUfrag}:${this.localUfrag}`;
|
|
480
|
+
const builder = new S.StunMessageBuilder(S.MSG_TYPE.BINDING_REQUEST, txid)
|
|
481
|
+
.addUsername(username)
|
|
482
|
+
.addPriority(pair.local.priority!);
|
|
483
|
+
|
|
484
|
+
if (this.role === 'controlling') {
|
|
485
|
+
builder.addIceControlling(this.#tieBreaker);
|
|
486
|
+
builder.addUseCandidate(); // aggressive nomination
|
|
487
|
+
} else {
|
|
488
|
+
builder.addIceControlled(this.#tieBreaker);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const msg = builder.build(this.remotePwd ?? undefined);
|
|
492
|
+
this.#pendingChecks.set(txid.toString('hex'), pair);
|
|
493
|
+
pair.state = 'in-progress';
|
|
494
|
+
pair.local.transport.send(msg, pair.remote.address, pair.remote.port);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
#onDatagram(transport: Transport, msg: Buffer, rinfo: RemoteInfo): void {
|
|
498
|
+
if (msg.length === 0) return;
|
|
499
|
+
const b0 = msg[0]!;
|
|
500
|
+
// RFC 7983 demux: 0-3 => STUN, 20-63 => DTLS, else ignore.
|
|
501
|
+
if (b0 <= 3) {
|
|
502
|
+
this.#onStun(transport, msg, rinfo);
|
|
503
|
+
} else {
|
|
504
|
+
this.emit('data', msg, { transport, address: rinfo.address, port: rinfo.port });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#onStun(transport: Transport, msg: Buffer, rinfo: RemoteInfo): void {
|
|
509
|
+
const parsed = S.parse(msg);
|
|
510
|
+
if (!parsed) return;
|
|
511
|
+
if (parsed.type === S.MSG_TYPE.BINDING_REQUEST) {
|
|
512
|
+
this.#handleBindingRequest(transport, parsed, rinfo);
|
|
513
|
+
} else if (parsed.type === S.MSG_TYPE.BINDING_SUCCESS) {
|
|
514
|
+
this.#handleBindingSuccess(transport, parsed, rinfo);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
#handleBindingRequest(transport: Transport, parsed: S.ParsedStunMessage, rinfo: RemoteInfo): void {
|
|
519
|
+
// Verify MESSAGE-INTEGRITY with our local password (peer keyed it with our pwd).
|
|
520
|
+
if (this.localPwd && !S.verifyIntegrity(parsed.raw, this.localPwd)) {
|
|
521
|
+
return; // drop unauthenticated checks
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const resp = new S.StunMessageBuilder(S.MSG_TYPE.BINDING_SUCCESS, parsed.transactionId)
|
|
525
|
+
.addXorMappedAddress(rinfo.address, rinfo.port)
|
|
526
|
+
.build(this.localPwd);
|
|
527
|
+
transport.send(resp, rinfo.address, rinfo.port);
|
|
528
|
+
|
|
529
|
+
// Learn a peer-reflexive remote candidate if unknown.
|
|
530
|
+
const known = this.#remoteCandidates.find((c) => c.address === rinfo.address && c.port === rinfo.port);
|
|
531
|
+
if (!known) {
|
|
532
|
+
this.addRemoteCandidate({ address: rinfo.address, port: rinfo.port, type: 'prflx', priority: 0 });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const useCandidate = parsed.attrs.has(S.ATTR.USE_CANDIDATE);
|
|
536
|
+
const pair = this.#findPair(transport, rinfo);
|
|
537
|
+
|
|
538
|
+
if (useCandidate && this.role === 'controlled') {
|
|
539
|
+
this.#select(pair || this.#syntheticPair(transport, rinfo));
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#handleBindingSuccess(_transport: Transport, parsed: S.ParsedStunMessage, _rinfo: RemoteInfo): void {
|
|
544
|
+
const pair = this.#pendingChecks.get(parsed.transactionId.toString('hex'));
|
|
545
|
+
if (!pair) return;
|
|
546
|
+
this.#pendingChecks.delete(parsed.transactionId.toString('hex'));
|
|
547
|
+
pair.state = 'succeeded';
|
|
548
|
+
|
|
549
|
+
if (this.role === 'controlling') {
|
|
550
|
+
this.#select(pair);
|
|
551
|
+
}
|
|
552
|
+
// Controlled agent: a successful check confirms the pair is valid, but the
|
|
553
|
+
// path is selected when the controlling peer sends USE-CANDIDATE.
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#findPair(transport: Transport, rinfo: RemoteInfo): CandidatePair | undefined {
|
|
557
|
+
return this.#pairs.find((p) =>
|
|
558
|
+
p.remote.address === rinfo.address && p.remote.port === rinfo.port && p.local.transport === transport);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
#syntheticPair(transport: Transport, rinfo: RemoteInfo): CandidatePair {
|
|
562
|
+
return { local: { transport }, remote: { address: rinfo.address, port: rinfo.port } };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
#select(pair: CandidatePair | undefined): void {
|
|
566
|
+
if (this.#selected || !pair) return;
|
|
567
|
+
this.#selected = pair;
|
|
568
|
+
this.#connected = true;
|
|
569
|
+
this.#stopChecks();
|
|
570
|
+
this.emit('selected', {
|
|
571
|
+
transport: pair.local.transport,
|
|
572
|
+
candidateType: pair.local.type,
|
|
573
|
+
remoteAddress: pair.remote.address,
|
|
574
|
+
remotePort: pair.remote.port,
|
|
575
|
+
});
|
|
576
|
+
this.emit('connected');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Send application (DTLS) data over the selected path.
|
|
581
|
+
* @param {Buffer} data
|
|
582
|
+
*/
|
|
583
|
+
send(data: Buffer): void {
|
|
584
|
+
if (!this.#selected) throw new Error('ICE not connected');
|
|
585
|
+
this.#selected.local.transport.send(data, this.#selected.remote.address, this.#selected.remote.port);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
getSelectedPair(): CandidatePair | null {
|
|
589
|
+
return this.#selected;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Type of the selected local candidate ('host'|'srflx'|'relay'|'prflx'). */
|
|
593
|
+
getSelectedCandidateType(): string | null | undefined {
|
|
594
|
+
return this.#selected ? this.#selected.local.type : null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
close(): void {
|
|
598
|
+
if (this.#closed) return;
|
|
599
|
+
this.#closed = true;
|
|
600
|
+
this.#stopChecks();
|
|
601
|
+
for (const t of this.#transports) {
|
|
602
|
+
try { t.close(); } catch (_) {}
|
|
603
|
+
}
|
|
604
|
+
this.#transports = [];
|
|
605
|
+
this.emit('closed');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export { IceAgent, candidatePriority, parseIceServerUrl };
|