@agentdance/node-webrtc-ice 1.0.0
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/dist/agent.d.ts +77 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +689 -0
- package/dist/agent.js.map +1 -0
- package/dist/candidate.d.ts +9 -0
- package/dist/candidate.d.ts.map +1 -0
- package/dist/candidate.js +139 -0
- package/dist/candidate.js.map +1 -0
- package/dist/checklist.d.ts +10 -0
- package/dist/checklist.d.ts.map +1 -0
- package/dist/checklist.js +102 -0
- package/dist/checklist.js.map +1 -0
- package/dist/gather.d.ts +15 -0
- package/dist/gather.d.ts.map +1 -0
- package/dist/gather.js +169 -0
- package/dist/gather.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/transport.d.ts +21 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +70 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +77 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +27 -0
- package/dist/types.js.map +1 -0
- package/package.json +59 -0
- package/src/agent.ts +961 -0
- package/src/candidate.ts +175 -0
- package/src/checklist.ts +142 -0
- package/src/gather.ts +224 -0
- package/src/index.ts +6 -0
- package/src/transport.ts +88 -0
- package/src/types.ts +77 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { AttributeType, MessageClass, MessageMethod, createBindingRequest, decodeMessage, encodeMessage, encodeUsername, encodePriority, encodeUseCandidate, encodeIceControlling, encodeIceControlled, computeMessageIntegrity, verifyMessageIntegrity, decodeXorMappedAddress, encodeXorMappedAddress, isStunMessage, generateTransactionId, encodeErrorCode, decodeUsername, decodePriority, } from '@agentdance/node-webrtc-stun';
|
|
4
|
+
import { computeFingerprint, } from '@agentdance/node-webrtc-stun';
|
|
5
|
+
import { IceAgentState, IceConnectionState, CandidatePairState, } from './types.js';
|
|
6
|
+
import { generateUfrag, generatePassword, computeFoundation, computePriority, } from './candidate.js';
|
|
7
|
+
import { gatherHostCandidates, gatherSrflxCandidate } from './gather.js';
|
|
8
|
+
import { UdpTransport } from './transport.js';
|
|
9
|
+
import { formCandidatePairs, unfreezeInitialPairs, getOrCreatePair, findPairByAddresses, } from './checklist.js';
|
|
10
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
11
|
+
const KEEPALIVE_INTERVAL_MS = 15_000;
|
|
12
|
+
const CHECK_INTERVAL_MS = 20;
|
|
13
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
14
|
+
// ─── IceAgent ────────────────────────────────────────────────────────────────
|
|
15
|
+
export class IceAgent extends EventEmitter {
|
|
16
|
+
localParameters;
|
|
17
|
+
_role;
|
|
18
|
+
_tiebreaker;
|
|
19
|
+
_nomination;
|
|
20
|
+
_gatheringState = IceAgentState.New;
|
|
21
|
+
_connectionState = IceConnectionState.New;
|
|
22
|
+
_localCandidates = [];
|
|
23
|
+
_remoteCandidates = [];
|
|
24
|
+
_remoteParameters;
|
|
25
|
+
_candidatePairs = [];
|
|
26
|
+
_nomineePair;
|
|
27
|
+
_transport;
|
|
28
|
+
_checkInterval;
|
|
29
|
+
_keepAliveTimer;
|
|
30
|
+
_failTimer;
|
|
31
|
+
// txId hex → pending entry
|
|
32
|
+
_pending = new Map();
|
|
33
|
+
_remoteGatheringComplete = false;
|
|
34
|
+
_connectResolve;
|
|
35
|
+
_connectReject;
|
|
36
|
+
_portRange;
|
|
37
|
+
_stunServers;
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
super();
|
|
40
|
+
this._role = options.role ?? 'controlling';
|
|
41
|
+
this._tiebreaker =
|
|
42
|
+
options.tiebreaker ??
|
|
43
|
+
BigInt('0x' + crypto.randomBytes(8).toString('hex'));
|
|
44
|
+
this._nomination = options.nomination ?? 'regular';
|
|
45
|
+
this._portRange = options.portRange;
|
|
46
|
+
this._stunServers = options.stunServers ?? [];
|
|
47
|
+
// Build IceParameters carefully to avoid exactOptionalPropertyTypes issues
|
|
48
|
+
const params = {
|
|
49
|
+
usernameFragment: options.ufrag ?? generateUfrag(),
|
|
50
|
+
password: options.password ?? generatePassword(),
|
|
51
|
+
};
|
|
52
|
+
if (options.lite !== undefined) {
|
|
53
|
+
params.iceLite = options.lite;
|
|
54
|
+
}
|
|
55
|
+
this.localParameters = params;
|
|
56
|
+
}
|
|
57
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
58
|
+
async gather() {
|
|
59
|
+
if (this._gatheringState !== IceAgentState.New)
|
|
60
|
+
return;
|
|
61
|
+
this._setGatheringState(IceAgentState.Gathering);
|
|
62
|
+
this._transport = new UdpTransport();
|
|
63
|
+
await this._transport.bind(this._pickPort(), '0.0.0.0');
|
|
64
|
+
const port = this._transport.localPort;
|
|
65
|
+
// Host candidates from all local interfaces (loopback + physical + virtual)
|
|
66
|
+
// gatherHostCandidates now includes loopback with highest localPref so that
|
|
67
|
+
// same-machine pairs (loopback↔loopback) get the highest pair priority.
|
|
68
|
+
const hostCandidates = await gatherHostCandidates(port, 1, 'udp');
|
|
69
|
+
for (const c of hostCandidates) {
|
|
70
|
+
this._addLocalCandidate(c);
|
|
71
|
+
}
|
|
72
|
+
// srflx via STUN servers
|
|
73
|
+
if (this._stunServers.length > 0) {
|
|
74
|
+
const rawSocket = this._transport._socket;
|
|
75
|
+
if (rawSocket) {
|
|
76
|
+
for (const server of this._stunServers) {
|
|
77
|
+
for (const local of hostCandidates) {
|
|
78
|
+
const srflx = await gatherSrflxCandidate(rawSocket, local, server);
|
|
79
|
+
if (srflx)
|
|
80
|
+
this._addLocalCandidate(srflx);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Wire packet demux
|
|
86
|
+
this._transport.on('stun', (buf, rinfo) => {
|
|
87
|
+
this._handleStunPacket(buf, rinfo);
|
|
88
|
+
});
|
|
89
|
+
this._transport.on('rtp', (buf, rinfo) => {
|
|
90
|
+
this.emit('data', buf, { address: rinfo.address, port: rinfo.port });
|
|
91
|
+
});
|
|
92
|
+
// Relay DTLS packets to the upper layer (PeerInternals wires to DtlsTransport)
|
|
93
|
+
this._transport.on('dtls', (buf, rinfo) => {
|
|
94
|
+
this.emit('data', buf, { address: rinfo.address, port: rinfo.port });
|
|
95
|
+
});
|
|
96
|
+
// Also relay unknown packets as data (e.g. plain buffers in tests)
|
|
97
|
+
this._transport.on('unknown',
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
(...args) => {
|
|
100
|
+
const buf = args[0];
|
|
101
|
+
const rinfo = args[1];
|
|
102
|
+
this.emit('data', buf, { address: rinfo.address, port: rinfo.port });
|
|
103
|
+
});
|
|
104
|
+
this._setGatheringState(IceAgentState.Complete);
|
|
105
|
+
this.emit('gathering-complete');
|
|
106
|
+
}
|
|
107
|
+
setRemoteParameters(params) {
|
|
108
|
+
this._remoteParameters = params;
|
|
109
|
+
}
|
|
110
|
+
addRemoteCandidate(candidate) {
|
|
111
|
+
// ts-rtc uses UDP-only transport; ignore non-UDP remote candidates
|
|
112
|
+
if (candidate.transport !== 'udp')
|
|
113
|
+
return;
|
|
114
|
+
// ts-rtc binds a udp4 socket; IPv6 candidates will silently fail — skip them
|
|
115
|
+
if (candidate.address.includes(':'))
|
|
116
|
+
return;
|
|
117
|
+
this._remoteCandidates.push(candidate);
|
|
118
|
+
if (this._connectionState === IceConnectionState.Checking ||
|
|
119
|
+
this._connectionState === IceConnectionState.Connected ||
|
|
120
|
+
this._connectionState === IceConnectionState.Failed) {
|
|
121
|
+
// Re-enter checking if we previously failed with no candidates (trickle ICE timing)
|
|
122
|
+
if (this._connectionState === IceConnectionState.Failed && !this._nomineePair) {
|
|
123
|
+
this._setConnectionState(IceConnectionState.Checking);
|
|
124
|
+
// Restart checks
|
|
125
|
+
this._checkInterval = setInterval(() => { this._tick(); }, CHECK_INTERVAL_MS);
|
|
126
|
+
}
|
|
127
|
+
this._formPairsForRemote(candidate);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
remoteGatheringComplete() {
|
|
131
|
+
this._remoteGatheringComplete = true;
|
|
132
|
+
// Now that we know all remote candidates, check if we already failed
|
|
133
|
+
if (this._connectionState === IceConnectionState.Checking) {
|
|
134
|
+
this._checkAllFailed();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async connect() {
|
|
138
|
+
if (!this._transport) {
|
|
139
|
+
throw new Error('Must call gather() before connect()');
|
|
140
|
+
}
|
|
141
|
+
if (!this._remoteParameters) {
|
|
142
|
+
throw new Error('Must call setRemoteParameters() before connect()');
|
|
143
|
+
}
|
|
144
|
+
if (this._connectionState !== IceConnectionState.New) {
|
|
145
|
+
throw new Error('connect() already called');
|
|
146
|
+
}
|
|
147
|
+
this._setConnectionState(IceConnectionState.Checking);
|
|
148
|
+
this._candidatePairs = formCandidatePairs(this._localCandidates, this._remoteCandidates, this._role);
|
|
149
|
+
unfreezeInitialPairs(this._candidatePairs);
|
|
150
|
+
console.log(`[ICE] connect(): formed ${this._candidatePairs.length} pairs`);
|
|
151
|
+
for (const p of this._candidatePairs) {
|
|
152
|
+
console.log(`[ICE] pair ${p.local.address}:${p.local.port} -> ${p.remote.address}:${p.remote.port} state=${p.state} prio=${p.priority}`);
|
|
153
|
+
}
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
this._connectResolve = resolve;
|
|
156
|
+
this._connectReject = reject;
|
|
157
|
+
this._checkInterval = setInterval(() => {
|
|
158
|
+
this._tick();
|
|
159
|
+
}, CHECK_INTERVAL_MS);
|
|
160
|
+
const failTimer = setTimeout(() => {
|
|
161
|
+
if (this._connectionState === IceConnectionState.Checking) {
|
|
162
|
+
this._setConnectionState(IceConnectionState.Failed);
|
|
163
|
+
this._stopChecks();
|
|
164
|
+
const rej = this._connectReject;
|
|
165
|
+
this._connectReject = undefined;
|
|
166
|
+
this._connectResolve = undefined;
|
|
167
|
+
rej?.(new Error('ICE connection timed out'));
|
|
168
|
+
}
|
|
169
|
+
}, CONNECT_TIMEOUT_MS);
|
|
170
|
+
failTimer.unref?.();
|
|
171
|
+
this._failTimer = failTimer;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
send(data) {
|
|
175
|
+
if (!this._transport) {
|
|
176
|
+
throw new Error('No nominated pair – not yet connected');
|
|
177
|
+
}
|
|
178
|
+
// Use nominated pair if available; otherwise fall back to the best valid
|
|
179
|
+
// pair during the window between ICE success and pair nomination (e.g. when
|
|
180
|
+
// the remote sends DTLS data before our USE-CANDIDATE response arrives).
|
|
181
|
+
const pair = this._nomineePair ??
|
|
182
|
+
this._candidatePairs.find((p) => p.valid) ??
|
|
183
|
+
this._candidatePairs.find((p) => p.state === CandidatePairState.Succeeded);
|
|
184
|
+
if (!pair) {
|
|
185
|
+
throw new Error('No nominated pair – not yet connected');
|
|
186
|
+
}
|
|
187
|
+
const { address, port } = pair.remote;
|
|
188
|
+
this._transport.send(data, port, address).catch(() => { });
|
|
189
|
+
}
|
|
190
|
+
async restart() {
|
|
191
|
+
this._stopChecks();
|
|
192
|
+
this._stopKeepAlive();
|
|
193
|
+
this._clearFailTimer();
|
|
194
|
+
this._cancelAllPending();
|
|
195
|
+
this.localParameters.usernameFragment =
|
|
196
|
+
generateUfrag();
|
|
197
|
+
this.localParameters.password = generatePassword();
|
|
198
|
+
this._localCandidates = [];
|
|
199
|
+
this._remoteCandidates = [];
|
|
200
|
+
this._candidatePairs = [];
|
|
201
|
+
this._nomineePair = undefined;
|
|
202
|
+
this._gatheringState = IceAgentState.New;
|
|
203
|
+
this._connectionState = IceConnectionState.New;
|
|
204
|
+
if (this._transport) {
|
|
205
|
+
this._transport.close();
|
|
206
|
+
this._transport = undefined;
|
|
207
|
+
}
|
|
208
|
+
await this.gather();
|
|
209
|
+
}
|
|
210
|
+
close() {
|
|
211
|
+
this._stopChecks();
|
|
212
|
+
this._stopKeepAlive();
|
|
213
|
+
this._clearFailTimer();
|
|
214
|
+
this._cancelAllPending();
|
|
215
|
+
if (this._transport) {
|
|
216
|
+
this._transport.close();
|
|
217
|
+
this._transport = undefined;
|
|
218
|
+
}
|
|
219
|
+
this._setConnectionState(IceConnectionState.Closed);
|
|
220
|
+
this._setGatheringState(IceAgentState.Closed);
|
|
221
|
+
}
|
|
222
|
+
get connectionState() {
|
|
223
|
+
return this._connectionState;
|
|
224
|
+
}
|
|
225
|
+
getLocalCandidates() {
|
|
226
|
+
return [...this._localCandidates];
|
|
227
|
+
}
|
|
228
|
+
getSelectedPair() {
|
|
229
|
+
return this._nomineePair;
|
|
230
|
+
}
|
|
231
|
+
// ─── Private: state setters ────────────────────────────────────────────────
|
|
232
|
+
_pickPort() {
|
|
233
|
+
if (this._portRange) {
|
|
234
|
+
return (this._portRange.min +
|
|
235
|
+
Math.floor(Math.random() * (this._portRange.max - this._portRange.min)));
|
|
236
|
+
}
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
_addLocalCandidate(c) {
|
|
240
|
+
this._localCandidates.push(c);
|
|
241
|
+
this.emit('local-candidate', c);
|
|
242
|
+
}
|
|
243
|
+
_setGatheringState(s) {
|
|
244
|
+
if (this._gatheringState === s)
|
|
245
|
+
return;
|
|
246
|
+
this._gatheringState = s;
|
|
247
|
+
this.emit('gathering-state', s);
|
|
248
|
+
}
|
|
249
|
+
_setConnectionState(s) {
|
|
250
|
+
if (this._connectionState === s)
|
|
251
|
+
return;
|
|
252
|
+
this._connectionState = s;
|
|
253
|
+
this.emit('connection-state', s);
|
|
254
|
+
}
|
|
255
|
+
// ─── Connectivity check scheduling ────────────────────────────────────────
|
|
256
|
+
_tick() {
|
|
257
|
+
// Pick next Waiting pair; if none, unfreeze the highest-priority Frozen pair
|
|
258
|
+
// (RFC 8445 §6.1.4.2 — must keep making progress even when no Waiting pairs)
|
|
259
|
+
let next = this._candidatePairs.find((p) => p.state === CandidatePairState.Waiting);
|
|
260
|
+
if (!next) {
|
|
261
|
+
const frozen = this._candidatePairs.find((p) => p.state === CandidatePairState.Frozen);
|
|
262
|
+
if (frozen) {
|
|
263
|
+
frozen.state = CandidatePairState.Waiting;
|
|
264
|
+
next = frozen;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (next) {
|
|
268
|
+
next.state = CandidatePairState.InProgress;
|
|
269
|
+
next.retransmitCount = 0;
|
|
270
|
+
const aggressive = this._role === 'controlling' && this._nomination === 'aggressive';
|
|
271
|
+
this._doCheck(next, aggressive);
|
|
272
|
+
}
|
|
273
|
+
this._checkAllFailed();
|
|
274
|
+
}
|
|
275
|
+
_doCheck(pair, useCandidate) {
|
|
276
|
+
if (!this._transport || !this._remoteParameters)
|
|
277
|
+
return;
|
|
278
|
+
const txId = generateTransactionId();
|
|
279
|
+
const txIdHex = txId.toString('hex');
|
|
280
|
+
const signedBuf = this._buildSignedRequest(txId, pair, useCandidate);
|
|
281
|
+
// Retransmit schedule: send at t=0, 200ms, 600ms, 1400ms; timeout at 3800ms
|
|
282
|
+
const timers = [];
|
|
283
|
+
const retransmitDelays = [200, 600, 1400];
|
|
284
|
+
// Send immediately
|
|
285
|
+
this._sendBuf(signedBuf, pair.remote.address, pair.remote.port);
|
|
286
|
+
for (const delay of retransmitDelays) {
|
|
287
|
+
const t = setTimeout(() => {
|
|
288
|
+
if (this._pending.has(txIdHex)) {
|
|
289
|
+
this._sendBuf(signedBuf, pair.remote.address, pair.remote.port);
|
|
290
|
+
}
|
|
291
|
+
}, delay);
|
|
292
|
+
t.unref?.();
|
|
293
|
+
timers.push(t);
|
|
294
|
+
}
|
|
295
|
+
// Final timeout
|
|
296
|
+
const finalTimer = setTimeout(() => {
|
|
297
|
+
if (this._pending.has(txIdHex)) {
|
|
298
|
+
this._pending.delete(txIdHex);
|
|
299
|
+
this._onCheckTimeout(pair);
|
|
300
|
+
}
|
|
301
|
+
}, 3800);
|
|
302
|
+
finalTimer.unref?.();
|
|
303
|
+
timers.push(finalTimer);
|
|
304
|
+
this._pending.set(txIdHex, { pair, signedBuf, useCandidate, timers });
|
|
305
|
+
}
|
|
306
|
+
_sendBuf(buf, address, port) {
|
|
307
|
+
this._transport?.send(buf, port, address).catch(() => { });
|
|
308
|
+
}
|
|
309
|
+
_buildSignedRequest(txId, pair, useCandidate) {
|
|
310
|
+
const remoteParams = this._remoteParameters;
|
|
311
|
+
const req = createBindingRequest(txId);
|
|
312
|
+
req.attributes.push({
|
|
313
|
+
type: AttributeType.Username,
|
|
314
|
+
value: encodeUsername(`${remoteParams.usernameFragment}:${this.localParameters.usernameFragment}`),
|
|
315
|
+
});
|
|
316
|
+
const prflxPriority = computePriority('prflx', 65535, pair.local.component);
|
|
317
|
+
req.attributes.push({
|
|
318
|
+
type: AttributeType.Priority,
|
|
319
|
+
value: encodePriority(prflxPriority),
|
|
320
|
+
});
|
|
321
|
+
if (useCandidate) {
|
|
322
|
+
req.attributes.push({
|
|
323
|
+
type: AttributeType.UseCandidate,
|
|
324
|
+
value: encodeUseCandidate(),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
if (this._role === 'controlling') {
|
|
328
|
+
req.attributes.push({
|
|
329
|
+
type: AttributeType.IceControlling,
|
|
330
|
+
value: encodeIceControlling(this._tiebreaker),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
req.attributes.push({
|
|
335
|
+
type: AttributeType.IceControlled,
|
|
336
|
+
value: encodeIceControlled(this._tiebreaker),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return this._appendMIAndFP(encodeMessage(req), remoteParams.password);
|
|
340
|
+
}
|
|
341
|
+
/** Append MESSAGE-INTEGRITY + FINGERPRINT to an already-encoded STUN buffer
|
|
342
|
+
* (RFC 5389 §15.4 + §15.5 — both required for ICE checks per RFC 8445) */
|
|
343
|
+
_appendMIAndFP(partialBuf, password) {
|
|
344
|
+
// Step 1: compute MI with length field reflecting MI TLV (24 bytes)
|
|
345
|
+
const withMiLen = Buffer.from(partialBuf);
|
|
346
|
+
withMiLen.writeUInt16BE(partialBuf.readUInt16BE(2) + 24, 2);
|
|
347
|
+
const key = Buffer.from(password, 'utf8');
|
|
348
|
+
const hmac = computeMessageIntegrity(withMiLen, key);
|
|
349
|
+
const miTlv = Buffer.alloc(24);
|
|
350
|
+
miTlv.writeUInt16BE(AttributeType.MessageIntegrity, 0);
|
|
351
|
+
miTlv.writeUInt16BE(20, 2);
|
|
352
|
+
hmac.copy(miTlv, 4);
|
|
353
|
+
// Append MESSAGE-INTEGRITY
|
|
354
|
+
const withMi = Buffer.concat([partialBuf, miTlv]);
|
|
355
|
+
withMi.writeUInt16BE(partialBuf.readUInt16BE(2) + miTlv.length, 2);
|
|
356
|
+
// Step 2: append FINGERPRINT (RFC 5389 §15.5)
|
|
357
|
+
const fpTlvSize = 8; // 2 type + 2 len + 4 value
|
|
358
|
+
const withFpLen = Buffer.from(withMi);
|
|
359
|
+
withFpLen.writeUInt16BE(withMi.readUInt16BE(2) + fpTlvSize, 2);
|
|
360
|
+
const crc = computeFingerprint(withFpLen);
|
|
361
|
+
const fpTlv = Buffer.alloc(fpTlvSize);
|
|
362
|
+
fpTlv.writeUInt16BE(AttributeType.Fingerprint, 0);
|
|
363
|
+
fpTlv.writeUInt16BE(4, 2);
|
|
364
|
+
fpTlv.writeUInt32BE(crc, 4);
|
|
365
|
+
const result = Buffer.concat([withMi, fpTlv]);
|
|
366
|
+
result.writeUInt16BE(withMi.readUInt16BE(2) + fpTlvSize, 2);
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
// ─── Response handling ────────────────────────────────────────────────────
|
|
370
|
+
_onStunResponse(msg) {
|
|
371
|
+
const txIdHex = msg.transactionId.toString('hex');
|
|
372
|
+
const entry = this._pending.get(txIdHex);
|
|
373
|
+
if (!entry) {
|
|
374
|
+
console.log(`[ICE] stun response txId=${txIdHex.slice(0, 8)} — no pending entry (stale?)`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
this._pending.delete(txIdHex);
|
|
378
|
+
for (const t of entry.timers)
|
|
379
|
+
clearTimeout(t);
|
|
380
|
+
const { pair, useCandidate } = entry;
|
|
381
|
+
if (msg.messageClass === MessageClass.ErrorResponse) {
|
|
382
|
+
const errAttr = msg.attributes.find((a) => a.type === 0x0009);
|
|
383
|
+
const errCode = errAttr ? errAttr.value.readUInt8(3) + (errAttr.value.readUInt8(2) & 0x7) * 100 : 0;
|
|
384
|
+
console.log(`[ICE] error response for ${pair.local.address} -> ${pair.remote.address}:${pair.remote.port} code=${errCode}`);
|
|
385
|
+
pair.state = CandidatePairState.Failed;
|
|
386
|
+
this._checkAllFailed();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (msg.messageClass !== MessageClass.SuccessResponse)
|
|
390
|
+
return;
|
|
391
|
+
pair.state = CandidatePairState.Succeeded;
|
|
392
|
+
pair.valid = true;
|
|
393
|
+
pair.lastBindingResponseReceived = Date.now();
|
|
394
|
+
console.log(`[ICE] success: ${pair.local.address}:${pair.local.port} -> ${pair.remote.address}:${pair.remote.port}`);
|
|
395
|
+
// RFC 8445 §7.2.5.2.1 – discover local peer-reflexive candidate
|
|
396
|
+
// The XOR-MAPPED-ADDRESS tells us our own address as seen by the remote.
|
|
397
|
+
// If it differs from any known local candidate, add a prflx local candidate.
|
|
398
|
+
const xorAttr = msg.attributes.find((a) => a.type === AttributeType.XorMappedAddress);
|
|
399
|
+
if (xorAttr) {
|
|
400
|
+
try {
|
|
401
|
+
const mapped = decodeXorMappedAddress(xorAttr.value, msg.transactionId);
|
|
402
|
+
const alreadyKnown = this._localCandidates.some((c) => c.address === mapped.address && c.port === mapped.port);
|
|
403
|
+
if (!alreadyKnown) {
|
|
404
|
+
const base = pair.local;
|
|
405
|
+
const prflxPriority = computePriority('prflx', 65535, base.component);
|
|
406
|
+
const prflx = {
|
|
407
|
+
foundation: computeFoundation('prflx', mapped.address, 'udp'),
|
|
408
|
+
component: base.component,
|
|
409
|
+
transport: 'udp',
|
|
410
|
+
priority: prflxPriority,
|
|
411
|
+
address: mapped.address,
|
|
412
|
+
port: mapped.port,
|
|
413
|
+
type: 'prflx',
|
|
414
|
+
};
|
|
415
|
+
this._localCandidates.push(prflx);
|
|
416
|
+
// Update the pair to use the prflx local candidate
|
|
417
|
+
pair.local = prflx;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// ignore decode errors
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
this._handlePairSucceeded(pair, useCandidate);
|
|
425
|
+
}
|
|
426
|
+
_handlePairSucceeded(pair, usedCandidateFlag) {
|
|
427
|
+
if (this._role === 'controlling') {
|
|
428
|
+
if (this._nomination === 'aggressive' ||
|
|
429
|
+
usedCandidateFlag ||
|
|
430
|
+
pair.nominateOnSuccess) {
|
|
431
|
+
this._nominatePair(pair);
|
|
432
|
+
}
|
|
433
|
+
else if (!this._nomineePair) {
|
|
434
|
+
// Regular nomination: re-send with USE-CANDIDATE
|
|
435
|
+
pair.nominateOnSuccess = true;
|
|
436
|
+
pair.state = CandidatePairState.Waiting;
|
|
437
|
+
pair.retransmitCount = 0;
|
|
438
|
+
setImmediate(() => {
|
|
439
|
+
if (pair.state === CandidatePairState.Waiting) {
|
|
440
|
+
pair.state = CandidatePairState.InProgress;
|
|
441
|
+
pair.retransmitCount = 0;
|
|
442
|
+
this._doCheck(pair, true);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
// Controlled: nominate if USE-CANDIDATE was set on this pair
|
|
449
|
+
if (pair.nominateOnSuccess) {
|
|
450
|
+
this._nominatePair(pair);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
_onCheckTimeout(pair) {
|
|
455
|
+
console.log(`[ICE] timeout: ${pair.local.address}:${pair.local.port} -> ${pair.remote.address}:${pair.remote.port}`);
|
|
456
|
+
if (pair.state !== CandidatePairState.Succeeded &&
|
|
457
|
+
pair.state !== CandidatePairState.Failed) {
|
|
458
|
+
pair.state = CandidatePairState.Failed;
|
|
459
|
+
}
|
|
460
|
+
this._checkAllFailed();
|
|
461
|
+
}
|
|
462
|
+
// ─── Binding request handling ─────────────────────────────────────────────
|
|
463
|
+
_onBindingRequest(rawBuf, msg, rinfo) {
|
|
464
|
+
if (!this._transport)
|
|
465
|
+
return;
|
|
466
|
+
// Verify MESSAGE-INTEGRITY
|
|
467
|
+
if (!verifyMessageIntegrity(rawBuf, Buffer.from(this.localParameters.password, 'utf8'))) {
|
|
468
|
+
this._sendError(msg, 401, 'Unauthorized', rinfo);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// Verify USERNAME
|
|
472
|
+
const usernameAttr = msg.attributes.find((a) => a.type === AttributeType.Username);
|
|
473
|
+
if (!usernameAttr) {
|
|
474
|
+
this._sendError(msg, 400, 'Bad Request', rinfo);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const username = decodeUsername(usernameAttr.value);
|
|
478
|
+
const colonIdx = username.indexOf(':');
|
|
479
|
+
const localUfragInMsg = colonIdx >= 0 ? username.slice(0, colonIdx) : username;
|
|
480
|
+
if (localUfragInMsg !== this.localParameters.usernameFragment) {
|
|
481
|
+
this._sendError(msg, 401, 'Unauthorized', rinfo);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const useCandidate = msg.attributes.some((a) => a.type === AttributeType.UseCandidate);
|
|
485
|
+
// Send success response
|
|
486
|
+
this._sendBindingResponse(msg, rinfo);
|
|
487
|
+
// Find best local candidate
|
|
488
|
+
const localPort = this._transport.localPort;
|
|
489
|
+
const localCandidate = this._localCandidates.find((c) => c.port === localPort) ??
|
|
490
|
+
this._localCandidates[0];
|
|
491
|
+
if (!localCandidate)
|
|
492
|
+
return;
|
|
493
|
+
// Find or create remote candidate
|
|
494
|
+
let remoteCandidate = this._remoteCandidates.find((c) => c.address === rinfo.address && c.port === rinfo.port);
|
|
495
|
+
if (!remoteCandidate) {
|
|
496
|
+
const priorityAttr = msg.attributes.find((a) => a.type === AttributeType.Priority);
|
|
497
|
+
const prflxPriority = priorityAttr
|
|
498
|
+
? decodePriority(priorityAttr.value)
|
|
499
|
+
: computePriority('prflx', 65535, localCandidate.component);
|
|
500
|
+
remoteCandidate = {
|
|
501
|
+
foundation: computeFoundation('prflx', rinfo.address, 'udp'),
|
|
502
|
+
component: localCandidate.component,
|
|
503
|
+
transport: 'udp',
|
|
504
|
+
priority: prflxPriority,
|
|
505
|
+
address: rinfo.address,
|
|
506
|
+
port: rinfo.port,
|
|
507
|
+
type: 'prflx',
|
|
508
|
+
};
|
|
509
|
+
this._remoteCandidates.push(remoteCandidate);
|
|
510
|
+
}
|
|
511
|
+
const { pair, isNew } = getOrCreatePair(this._candidatePairs, localCandidate, remoteCandidate, this._role);
|
|
512
|
+
if (isNew) {
|
|
513
|
+
this._candidatePairs.push(pair);
|
|
514
|
+
this._sortPairs();
|
|
515
|
+
}
|
|
516
|
+
pair.lastBindingRequestReceived = Date.now();
|
|
517
|
+
if (this._role === 'controlled' && useCandidate) {
|
|
518
|
+
pair.nominateOnSuccess = true;
|
|
519
|
+
}
|
|
520
|
+
// Triggered check
|
|
521
|
+
if (pair.state !== CandidatePairState.InProgress &&
|
|
522
|
+
pair.state !== CandidatePairState.Succeeded) {
|
|
523
|
+
pair.state = CandidatePairState.Waiting;
|
|
524
|
+
pair.retransmitCount = 0;
|
|
525
|
+
setImmediate(() => {
|
|
526
|
+
if (pair.state === CandidatePairState.Waiting) {
|
|
527
|
+
pair.state = CandidatePairState.InProgress;
|
|
528
|
+
pair.retransmitCount = 0;
|
|
529
|
+
this._doCheck(pair, false);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
// Controlled: nominate immediately if USE-CANDIDATE and pair already succeeded
|
|
534
|
+
if (this._role === 'controlled' &&
|
|
535
|
+
useCandidate &&
|
|
536
|
+
pair.state === CandidatePairState.Succeeded) {
|
|
537
|
+
this._nominatePair(pair);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
_sendBindingResponse(msg, rinfo) {
|
|
541
|
+
if (!this._transport)
|
|
542
|
+
return;
|
|
543
|
+
const xorValue = encodeXorMappedAddress({ family: 4, port: rinfo.port, address: rinfo.address }, msg.transactionId);
|
|
544
|
+
const resp = {
|
|
545
|
+
messageClass: MessageClass.SuccessResponse,
|
|
546
|
+
messageMethod: MessageMethod.Binding,
|
|
547
|
+
transactionId: Buffer.from(msg.transactionId),
|
|
548
|
+
attributes: [{ type: AttributeType.XorMappedAddress, value: xorValue }],
|
|
549
|
+
};
|
|
550
|
+
const partial = encodeMessage(resp);
|
|
551
|
+
const signed = this._appendMIAndFP(partial, this.localParameters.password);
|
|
552
|
+
this._transport.send(signed, rinfo.port, rinfo.address).catch(() => { });
|
|
553
|
+
}
|
|
554
|
+
_sendError(request, code, reason, rinfo) {
|
|
555
|
+
if (!this._transport)
|
|
556
|
+
return;
|
|
557
|
+
const resp = {
|
|
558
|
+
messageClass: MessageClass.ErrorResponse,
|
|
559
|
+
messageMethod: MessageMethod.Binding,
|
|
560
|
+
transactionId: Buffer.from(request.transactionId),
|
|
561
|
+
attributes: [
|
|
562
|
+
{
|
|
563
|
+
type: AttributeType.ErrorCode,
|
|
564
|
+
value: encodeErrorCode({ code, reason }),
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
};
|
|
568
|
+
this._transport
|
|
569
|
+
.send(encodeMessage(resp), rinfo.port, rinfo.address)
|
|
570
|
+
.catch(() => { });
|
|
571
|
+
}
|
|
572
|
+
// ─── Packet demux ─────────────────────────────────────────────────────────
|
|
573
|
+
_handleStunPacket(rawBuf, rinfo) {
|
|
574
|
+
if (!isStunMessage(rawBuf))
|
|
575
|
+
return;
|
|
576
|
+
let msg;
|
|
577
|
+
try {
|
|
578
|
+
msg = decodeMessage(rawBuf);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (msg.messageMethod !== MessageMethod.Binding)
|
|
584
|
+
return;
|
|
585
|
+
if (msg.messageClass === MessageClass.Request) {
|
|
586
|
+
this._onBindingRequest(rawBuf, msg, rinfo);
|
|
587
|
+
}
|
|
588
|
+
else if (msg.messageClass === MessageClass.SuccessResponse ||
|
|
589
|
+
msg.messageClass === MessageClass.ErrorResponse) {
|
|
590
|
+
this._onStunResponse(msg);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// ─── Nomination ───────────────────────────────────────────────────────────
|
|
594
|
+
_nominatePair(pair) {
|
|
595
|
+
if (this._nomineePair)
|
|
596
|
+
return;
|
|
597
|
+
pair.nominated = true;
|
|
598
|
+
this._nomineePair = pair;
|
|
599
|
+
this._stopChecks();
|
|
600
|
+
this._clearFailTimer();
|
|
601
|
+
this._setConnectionState(IceConnectionState.Connected);
|
|
602
|
+
this.emit('connected', pair);
|
|
603
|
+
const resolve = this._connectResolve;
|
|
604
|
+
this._connectResolve = undefined;
|
|
605
|
+
this._connectReject = undefined;
|
|
606
|
+
resolve?.(pair);
|
|
607
|
+
this._startKeepAlive();
|
|
608
|
+
}
|
|
609
|
+
// ─── All-failed detection ─────────────────────────────────────────────────
|
|
610
|
+
_checkAllFailed() {
|
|
611
|
+
if (this._nomineePair)
|
|
612
|
+
return;
|
|
613
|
+
if (this._connectionState !== IceConnectionState.Checking)
|
|
614
|
+
return;
|
|
615
|
+
// Don't fail until we know all remote candidates (trickle ICE)
|
|
616
|
+
if (!this._remoteGatheringComplete)
|
|
617
|
+
return;
|
|
618
|
+
const hasAlive = this._candidatePairs.some((p) => p.state === CandidatePairState.Waiting ||
|
|
619
|
+
p.state === CandidatePairState.InProgress ||
|
|
620
|
+
p.state === CandidatePairState.Frozen);
|
|
621
|
+
if (!hasAlive && this._pending.size === 0) {
|
|
622
|
+
this._setConnectionState(IceConnectionState.Failed);
|
|
623
|
+
this._stopChecks();
|
|
624
|
+
this._clearFailTimer();
|
|
625
|
+
const reject = this._connectReject;
|
|
626
|
+
this._connectReject = undefined;
|
|
627
|
+
this._connectResolve = undefined;
|
|
628
|
+
reject?.(new Error('All candidate pairs failed'));
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// ─── Timer management ─────────────────────────────────────────────────────
|
|
632
|
+
_sortPairs() {
|
|
633
|
+
this._candidatePairs.sort((a, b) => {
|
|
634
|
+
if (b.priority > a.priority)
|
|
635
|
+
return 1;
|
|
636
|
+
if (b.priority < a.priority)
|
|
637
|
+
return -1;
|
|
638
|
+
return 0;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
_stopChecks() {
|
|
642
|
+
if (this._checkInterval) {
|
|
643
|
+
clearInterval(this._checkInterval);
|
|
644
|
+
this._checkInterval = undefined;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
_clearFailTimer() {
|
|
648
|
+
if (this._failTimer) {
|
|
649
|
+
clearTimeout(this._failTimer);
|
|
650
|
+
this._failTimer = undefined;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
_startKeepAlive() {
|
|
654
|
+
const timer = setInterval(() => {
|
|
655
|
+
if (this._nomineePair) {
|
|
656
|
+
this._doCheck(this._nomineePair, false);
|
|
657
|
+
}
|
|
658
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
659
|
+
timer.unref?.();
|
|
660
|
+
this._keepAliveTimer = timer;
|
|
661
|
+
}
|
|
662
|
+
_stopKeepAlive() {
|
|
663
|
+
if (this._keepAliveTimer) {
|
|
664
|
+
clearInterval(this._keepAliveTimer);
|
|
665
|
+
this._keepAliveTimer = undefined;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
_cancelAllPending() {
|
|
669
|
+
for (const entry of this._pending.values()) {
|
|
670
|
+
for (const t of entry.timers)
|
|
671
|
+
clearTimeout(t);
|
|
672
|
+
}
|
|
673
|
+
this._pending.clear();
|
|
674
|
+
}
|
|
675
|
+
_formPairsForRemote(remote) {
|
|
676
|
+
for (const local of this._localCandidates) {
|
|
677
|
+
if (local.component !== remote.component)
|
|
678
|
+
continue;
|
|
679
|
+
const exists = findPairByAddresses(this._candidatePairs, local.address, local.port, remote.address, remote.port);
|
|
680
|
+
if (!exists) {
|
|
681
|
+
const { pair } = getOrCreatePair(this._candidatePairs, local, remote, this._role);
|
|
682
|
+
this._candidatePairs.push(pair);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
this._sortPairs();
|
|
686
|
+
unfreezeInitialPairs(this._candidatePairs);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
//# sourceMappingURL=agent.js.map
|