@livedesk/hub 0.1.74 → 0.1.76

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.
@@ -158,6 +158,7 @@ class FakePeerConnection {
158
158
  onDataChannel(callback) { this.callbacks.dataChannel = callback; }
159
159
  onStateChange(callback) { this.callbacks.state = callback; }
160
160
  onIceStateChange(callback) { this.callbacks.iceState = callback; }
161
+ onGatheringStateChange(callback) { this.callbacks.gatheringState = callback; }
161
162
 
162
163
  setRemoteDescription(sdp, type) {
163
164
  this.remote = { sdp, type };
@@ -233,6 +234,8 @@ async function connectedDirect(options = {}) {
233
234
  hubInstanceId: options.hubInstanceId || HUB_EPOCH,
234
235
  httpBaseUrl: 'http://127.0.0.1:5179',
235
236
  stunUrls: options.stunUrls || ['stun:stun.example.test:3478'],
237
+ icePortRange: options.icePortRange,
238
+ routerMapping: options.routerMapping,
236
239
  getAccessToken: async () => 'access-token',
237
240
  ...(options.workspaceAccess ? { getWorkspaceAccess: () => options.workspaceAccess } : {}),
238
241
  ...(options.consoleProxyToken ? { consoleProxyToken: options.consoleProxyToken } : {}),
@@ -295,6 +298,120 @@ function offer(signal, connectionId = CONNECTION_ONE, sdp = 'v=0\r\na=fake-offer
295
298
  return FakePeerConnection.instances.at(-1);
296
299
  }
297
300
 
301
+ function openPeerControl(peer) {
302
+ const control = new FakeDataChannel('livedesk-control-v1');
303
+ peer.callbacks.dataChannel(control);
304
+ control.open();
305
+ return control;
306
+ }
307
+
308
+ function mediaSignal(control, mediaId, signal, parentConnectionId = CONNECTION_ONE) {
309
+ sendWire(control, { type: 'media-signal', connectionId: parentConnectionId,
310
+ hubEpoch: HUB_EPOCH, mediaId, signal: { hubEpoch: HUB_EPOCH, ...signal } });
311
+ }
312
+
313
+ test('dedicated media peers isolate congestion and retirement from HTTP, siblings and other Consoles', async () => {
314
+ const { direct, signal } = await connectedDirect();
315
+ try {
316
+ const root = offer(signal), control = openPeerControl(root);
317
+ const externalSignals = signal.sent.length;
318
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
319
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
320
+ const first = FakePeerConnection.instances.at(-1), firstControl = openPeerControl(first);
321
+ mediaSignal(control, CHANNEL_ID_TWO, { type: 'rtc-offer', connectionId: REQUEST_ID,
322
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
323
+ const second = FakePeerConnection.instances.at(-1); openPeerControl(second);
324
+ assert.notEqual(first, root);
325
+ assert.notEqual(first, second);
326
+ assert.equal(signal.sent.length, externalSignals, 'media SDP/ICE never goes through the Worker');
327
+ assert.equal(direct.inspect().consoleConnections, 1);
328
+ assert.equal(direct.inspect().mediaPeerConnections, 2);
329
+ const replies = decodeWireMessages(control.sent).map(m => JSON.parse(m.data));
330
+ assert.ok(replies.some(m => m.type === 'media-signal' && m.mediaId === CHANNEL_ID
331
+ && m.signal.type === 'rtc-answer' && m.connectionId === CONNECTION_ONE));
332
+ firstControl.buffered = 900_000;
333
+ sendWire(control, { type: 'http-request', requestId: REQUEST_ID, method: 'GET', path: '/api/remote/status' });
334
+ await settle();
335
+ assert.ok(decodeWireMessages(control.sent).map(m => JSON.parse(m.data)).some(m => m.type === 'http-response'));
336
+ first.emitState('failed');
337
+ assert.equal(first.closed, true);
338
+ assert.equal(root.closed, false);
339
+ assert.equal(second.closed, false);
340
+ assert.equal(direct.inspect().mediaPeerConnections, 1);
341
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_ONE,
342
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
343
+ const replacement = FakePeerConnection.instances.at(-1); openPeerControl(replacement);
344
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-close', connectionId: CONNECTION_TWO });
345
+ assert.equal(replacement.closed, false, 'old media close cannot retire a replacement');
346
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
347
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
348
+ assert.equal(replacement.closed, false, 'an older offer cannot replace the active media owner');
349
+ assert.equal(FakePeerConnection.instances.at(-1), replacement);
350
+ root.emitState('closed');
351
+ assert.equal(second.closed, true);
352
+ assert.equal(replacement.closed, true);
353
+ assert.equal(direct.inspect().peerConnections, 0);
354
+ assert.equal(direct.inspect().retainedAssemblyBytes, 0);
355
+ assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
356
+ } finally { direct.close(); }
357
+ });
358
+
359
+ test('media offers cannot cross parent authority, nest children or consume the four Console slots', async () => {
360
+ const { direct, signal } = await connectedDirect();
361
+ try {
362
+ const root = offer(signal), control = openPeerControl(root);
363
+ const description = { type: 'offer', sdp: 'v=0\r\n' };
364
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description }, CONNECTION_TWO);
365
+ assert.equal(direct.inspect().peerConnections, 1, 'stale parent rejected');
366
+ let childControl;
367
+ for (let n = 0; n < 16; n++) {
368
+ const id = `bbbbbbbb-bbbb-4bbb-8bbb-${String(n).padStart(12, '0')}`;
369
+ mediaSignal(control, id, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description });
370
+ childControl = openPeerControl(FakePeerConnection.instances.at(-1));
371
+ }
372
+ const child = FakePeerConnection.instances.at(-1);
373
+ mediaSignal(childControl, CHANNEL_ID, { type: 'rtc-offer', connectionId: REQUEST_ID, description }, CONNECTION_TWO);
374
+ assert.equal(direct.inspect().mediaPeerConnections, 16, 'child cannot create a grandchild');
375
+ const forbiddenInput = new FakeDataChannel(`livedesk-ws-v1:${CHANNEL_ID}:input`);
376
+ child.callbacks.dataChannel(forbiddenInput);
377
+ assert.equal(forbiddenInput.closed, true, 'media child cannot acquire input');
378
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO, description });
379
+ assert.equal(direct.inspect().mediaPeerConnections, 16);
380
+ for (let n = 1; n <= 3; n++) {
381
+ offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: `aaaaaaaa-aaaa-4aaa-8aaa-${String(n).padStart(12, '0')}` });
382
+ }
383
+ assert.equal(direct.inspect().consoleConnections, 4);
384
+ assert.equal(direct.inspect().peerConnections, 20);
385
+ offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CHANNEL_ID });
386
+ assert.equal(direct.inspect().consoleConnections, 4);
387
+ } finally { direct.close(); }
388
+ assert.equal(direct.inspect().peerConnections, 0);
389
+ });
390
+
391
+ test('media assembly shares one Console budget and closing a child releases only its reservations', async () => {
392
+ const { direct, signal } = await connectedDirect();
393
+ try {
394
+ const root = offer(signal), control = openPeerControl(root);
395
+ mediaSignal(control, CHANNEL_ID, { type: 'rtc-offer', connectionId: CONNECTION_TWO,
396
+ description: { type: 'offer', sdp: 'v=0\r\n' } });
397
+ const child = FakePeerConnection.instances.at(-1), childControl = openPeerControl(child);
398
+ const partial = encodeDirectConsoleWireMessage('x'.repeat(40_000), { messageId: 77, kind: 'control' })[0];
399
+ childControl.receive(partial);
400
+ const childBytes = direct.inspect().retainedAssemblyBytes;
401
+ assert.ok(childBytes > 0);
402
+ control.receive(partial);
403
+ assert.equal(direct.inspect().retainedAssemblyBytes, childBytes * 2,
404
+ 'a shared budget is counted once, not once per transport');
405
+ child.emitState('failed');
406
+ assert.equal(direct.inspect().retainedAssemblyBytes, childBytes);
407
+ assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
408
+ assert.equal(root.closed, false);
409
+ } finally { direct.close(); }
410
+ assert.equal(direct.inspect().retainedAssemblyBytes, 0);
411
+ assert.equal(direct.inspect().resourceTimers, 0);
412
+ assert.equal(direct.inspect().wireBudgetCleanupFailures, 0);
413
+ });
414
+
298
415
  test('native buffered sends complete each media message without closing input or control', async () => {
299
416
  const { direct, signal } = await connectedDirect();
300
417
  try {
@@ -340,6 +457,8 @@ test('native buffered sends complete each media message without closing input or
340
457
  assert.equal(peer.closed, false);
341
458
  assert.equal(direct.inspect().dataChannelSendFailures, 0);
342
459
  assert.equal(direct.inspect().dataChannelBackpressureCloses, 0);
460
+ assert.ok(direct.inspect().peerDiagnostics[0].lanes.find(l => l.channelId === CHANNEL_ID).frameAdmission.sendBatches > 0,
461
+ 'accepted native false returns are accounted before the lane closes');
343
462
  lane.sendMessageBinary = () => { throw new Error('native-send-failed'); };
344
463
  local.receive(Buffer.alloc(10), true);
345
464
  assert.equal(lane.closed, true, 'an actual native exception still retires the exact lane');
@@ -353,11 +472,134 @@ test('native buffered sends complete each media message without closing input or
353
472
  }
354
473
  });
355
474
 
475
+ test('an explicit direct port range reaches the native peer without enabling relay or ICE TCP', async () => {
476
+ const { direct, signal } = await connectedDirect({ icePortRange: '54443-54450' });
477
+ try {
478
+ const peer = offer(signal);
479
+ assert.equal(peer.config.portRangeBegin, 54443);
480
+ assert.equal(peer.config.portRangeEnd, 54450);
481
+ assert.equal(peer.config.enableIceTcp, undefined);
482
+ assert.equal(peer.config.disableFingerprintVerification, false);
483
+ assert.deepEqual(peer.config.iceServers, ['stun:stun.example.test:3478']);
484
+ } finally { direct.close(); }
485
+ });
486
+
487
+ test('router setup belongs to the exact peer and ends on control ready without closing that peer', async () => {
488
+ const leases = [];
489
+ const routerMapping = {
490
+ createOwner(key) {
491
+ const lease = { key, candidates: [], released: false,
492
+ observe(candidate) { this.candidates.push(candidate); },
493
+ async release() { this.released = true; } };
494
+ leases.push(lease);
495
+ return lease;
496
+ },
497
+ async releaseAll() { await Promise.all(leases.map(lease => lease.release())); },
498
+ inspect() { return { resourceTimers: 0 }; }
499
+ };
500
+ const { direct, signal } = await connectedDirect({ routerMapping });
501
+ try {
502
+ const first = offer(signal);
503
+ const second = offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CONNECTION_TWO });
504
+ first.emitLocalCandidate('candidate:1 1 udp 1 192.168.0.4 54443 typ host');
505
+ assert.equal(leases[0].candidates.length, 1);
506
+ assert.notEqual(leases[0].key, leases[1].key);
507
+ const control = new FakeDataChannel('livedesk-control-v1');
508
+ first.emitDataChannel(control); control.open();
509
+ assert.equal(leases[0].released, true);
510
+ assert.equal(leases[1].released, false);
511
+ assert.equal(first.closed, false);
512
+ assert.equal(second.closed, false);
513
+ first.emitLocalCandidate('candidate:2 1 udp 1 192.168.0.4 54444 typ host');
514
+ assert.equal(leases[0].candidates.length, 1, 'late gathering cannot reopen an established peer mapping');
515
+ direct.invalidateWorkspaceAccess();
516
+ await direct.releaseRouterMappings();
517
+ assert.ok(leases.every(lease => lease.released));
518
+ } finally { direct.close(); }
519
+ });
520
+
521
+ test('router readiness precedes native gathering and a retired peer cannot consume late setup completion', async () => {
522
+ const completions = [];
523
+ const routerMapping = {
524
+ createOwner() {
525
+ return { ready: new Promise(resolve => completions.push(resolve)),
526
+ async handoff() { return { port: 54443 }; }, observe() {}, release() {} };
527
+ },
528
+ async releaseAll() {},
529
+ inspect: () => ({ resourceTimers: 0 })
530
+ };
531
+ const { direct, signal } = await connectedDirect({ routerMapping });
532
+ const candidate = 'candidate:7 1 UDP 123 8.8.8.8 50000 typ srflx';
533
+ const sdp = `v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\na=${candidate}\r\n`;
534
+ try {
535
+ offer(signal, CONNECTION_ONE, sdp);
536
+ assert.equal(FakePeerConnection.instances.length, 0);
537
+ signal.receive(JSON.stringify({ type: 'rtc-ice', consoleId: CONSOLE_ID, connectionId: CONNECTION_ONE,
538
+ hubEpoch: HUB_EPOCH, candidate, sdpMid: '0' }));
539
+ assert.equal(FakePeerConnection.instances.length, 0);
540
+ completions[0](); await settle();
541
+ const first = FakePeerConnection.instances[0];
542
+ assert.equal(first.config.portRangeBegin, 54443);
543
+ assert.equal(first.config.portRangeEnd, 54443);
544
+ assert.equal(first.remote.sdp, sdp);
545
+ assert.deepEqual(first.remoteCandidates, [{ candidate, sdpMid: '0' }]);
546
+ offer(signal, CONNECTION_TWO, sdp);
547
+ direct.invalidateWorkspaceAccess();
548
+ completions[1](); await settle();
549
+ assert.equal(FakePeerConnection.instances.length, 1, 'retired setup must not create a native peer');
550
+ assert.equal(first.closed, true);
551
+ } finally { direct.close(); }
552
+ });
553
+
554
+ test('failed ICE owners retain bounded evidence while a healthy peer and signaling survive', async () => {
555
+ const { direct, signal } = await connectedDirect({ iceConnectTimeoutMs: 30 });
556
+ try {
557
+ const healthy = offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CONNECTION_TWO });
558
+ const control = new FakeDataChannel('livedesk-control-v1');
559
+ healthy.emitDataChannel(control);
560
+ control.open();
561
+ const failed = offer(signal);
562
+ failed.emitLocalCandidate('candidate:1 1 udp 1 203.0.113.1 40000 typ srflx raddr 192.168.0.4 rport 40000');
563
+ failed.callbacks.gatheringState('complete');
564
+ failed.callbacks.iceState('checking');
565
+ signal.receive(JSON.stringify({ type: 'rtc-ice', consoleId: CONSOLE_ID,
566
+ connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH, sdpMid: '0',
567
+ candidate: 'candidate:2 1 udp 1 2001:db8::abcd 50000 typ host' }));
568
+ await waitFor(() => failed.closed);
569
+ const evidence = direct.inspect().recentPeerConnections.at(-1);
570
+ assert.equal(evidence.reason, 'console-direct-ice-timeout');
571
+ assert.equal(evidence.localDescriptionSent, true);
572
+ assert.equal(evidence.remoteDescriptionApplied, true);
573
+ assert.equal(evidence.gatheringState, 'complete');
574
+ assert.equal(evidence.local.srflx, 1);
575
+ assert.equal(evidence.remote.ipv6, 1);
576
+ assert.equal(evidence.controlOpened, false);
577
+ assert.equal(healthy.closed, false);
578
+ assert.equal(direct.inspect().controlChannels, 1);
579
+ assert.equal(direct.inspect().state, 'connected');
580
+ assert.equal(direct.inspect().iceConnectTimers, 0);
581
+ failed.emitLocalCandidate('candidate:3 1 udp 1 192.168.0.7 40000 typ host');
582
+ failed.callbacks.gatheringState('new');
583
+ assert.deepEqual(direct.inspect().recentPeerConnections.at(-1), evidence);
584
+ evidence.remote.ipv6 = 500;
585
+ assert.equal(direct.inspect().recentPeerConnections.at(-1).remote.ipv6, 1);
586
+ assert.doesNotMatch(JSON.stringify(direct.inspect().recentPeerConnections), /203\.0\.113|192\.168|2001:db8/);
587
+ direct.invalidateWorkspaceAccess();
588
+ assert.deepEqual(direct.inspect().recentPeerConnections, []);
589
+ } finally {
590
+ direct.close();
591
+ assert.equal(direct.inspect().resourceTimers, 0);
592
+ assert.deepEqual(direct.inspect().recentPeerConnections, []);
593
+ }
594
+ });
595
+
356
596
  test('STUN-only peer answers and rejects stale owner callbacks after replacement', async () => {
357
597
  const { direct, signal } = await connectedDirect();
358
598
  const firstPeer = offer(signal, CONNECTION_ONE);
359
599
  assert.deepEqual(firstPeer.config.iceServers, ['stun:stun.example.test:3478']);
360
600
  assert.equal(firstPeer.config.iceTransportPolicy, 'all');
601
+ assert.equal(firstPeer.config.portRangeBegin, undefined);
602
+ assert.equal(firstPeer.config.portRangeEnd, undefined);
361
603
  assert.equal(firstPeer.config.disableFingerprintVerification, false);
362
604
  assert.equal(firstPeer.config.maxMessageSize, DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
363
605
  'native SCTP accepts wire chunks, not whole frames; a frame-sized setting silently enlarges its hidden buffers');
@@ -664,7 +906,8 @@ test('reliable control channel bridges bounded HTTP over fragmented wire message
664
906
  assert.deepEqual(JSON.parse(messages[0].data), {
665
907
  type: 'direct-ready',
666
908
  connectionId: CONNECTION_ONE,
667
- hubEpoch: HUB_EPOCH
909
+ hubEpoch: HUB_EPOCH,
910
+ mediaPeerVersion: 1
668
911
  });
669
912
 
670
913
  sendWire(control, {
@@ -0,0 +1,30 @@
1
+ // The authenticated offer retains its original SDP while the native socket is
2
+ // prepared. Only bounded trickled candidates wait here, with no timer or peer.
3
+ export function createConsoleIceSetupGate() {
4
+ let waiting = true;
5
+ let closed = false;
6
+ const candidates = [];
7
+ function defer(candidate, mid) {
8
+ if (closed || !waiting) return false;
9
+ mid = String(mid || '');
10
+ if (candidate.length > 4096 || mid.length > 256) throw new Error('console-direct-candidate-invalid');
11
+ if (candidates.some(value => value.candidate === candidate && value.mid === mid)) return true;
12
+ if (candidates.length >= 16) throw new Error('console-direct-candidate-invalid');
13
+ candidates.push({ candidate, mid });
14
+ return true;
15
+ }
16
+ return {
17
+ defer,
18
+ flush(apply, isCurrent) {
19
+ if (closed) return;
20
+ waiting = false;
21
+ while (candidates.length > 0) {
22
+ const value = candidates.shift();
23
+ if (!isCurrent()) { candidates.length = 0; return; }
24
+ apply(value.candidate, value.mid);
25
+ }
26
+ },
27
+ close() { closed = true; waiting = false; candidates.length = 0; },
28
+ inspect: () => ({ waiting: waiting && !closed, candidates: candidates.length })
29
+ };
30
+ }
@@ -0,0 +1,206 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { discoverConsoleUpnpGateway, reserveConsoleIceSocket, isPrivateRouterIPv4, isPublicRouterIPv4 } from './console-upnp-gateway.mjs';
3
+
4
+ export const CONSOLE_ROUTER_MAPPING_BOUNDS = Object.freeze({
5
+ owners: 4, queuedOwners: 64, candidates: 8, setupMs: 8000, cleanupMs: 1800, leaseSeconds: 120, history: 8
6
+ });
7
+
8
+ export function parseRouterMappingCandidate(value) {
9
+ if (typeof value !== 'string' || value.length > 4096) return null;
10
+ const parts = value.trim().replace(/^a=/, '').split(/\s+/);
11
+ const port = Number(parts[5]);
12
+ if (!/^candidate:/i.test(parts[0]) || parts[1] !== '1' || parts[2]?.toLowerCase() !== 'udp'
13
+ || parts[6] !== 'typ' || !['host', 'srflx'].includes(parts[7])
14
+ || !(parts[7] === 'host' ? isPrivateRouterIPv4(parts[4]) : isPublicRouterIPv4(parts[4]))
15
+ || !Number.isInteger(port) || port < 1024 || port > 65535) return null;
16
+ const relatedPort = parts.includes('rport') ? Number(parts[parts.indexOf('rport') + 1]) : 0;
17
+ return { type: parts[7], address: parts[4], port, relatedPort };
18
+ }
19
+
20
+ function untilAborted(signal) {
21
+ return new Promise(resolve => {
22
+ if (signal.aborted) return resolve();
23
+ signal.addEventListener('abort', resolve, { once: true });
24
+ });
25
+ }
26
+
27
+ // One short-lived setup owner per authenticated peer. This never owns a peer,
28
+ // frame, encoder or retransmit queue. Mapping removal does not close ICE.
29
+ export function createConsoleRouterMapping(options = {}) {
30
+ const enabled = options.isEnabled || (() => false);
31
+ const discover = options.discover || discoverConsoleUpnpGateway;
32
+ const reserveSocket = options.reserveSocket || reserveConsoleIceSocket;
33
+ const bounds = CONSOLE_ROUTER_MAPPING_BOUNDS;
34
+ const setupMs = Math.min(bounds.setupMs, Math.max(1, Number(options.setupMs) || bounds.setupMs));
35
+ const cleanupMs = Math.min(bounds.cleanupMs, Math.max(1, Number(options.cleanupMs) || bounds.cleanupMs));
36
+ const owners = new Set();
37
+ const waiting = new Set();
38
+ const reservedPorts = new Map();
39
+ const history = [];
40
+ let historyEpoch = 0;
41
+ let attempts = 0, mapped = 0, cleanupUnconfirmed = 0;
42
+
43
+ function finish(owner) {
44
+ owners.delete(owner);
45
+ if (reservedPorts.get(owner.reservation) === owner) reservedPorts.delete(owner.reservation);
46
+ if (owner.historyEpoch === historyEpoch) history.push(Object.freeze({ owner: owner.key, state: owner.state, cleanup: owner.cleanup,
47
+ durationMs: Math.max(0, Date.now() - owner.startedAt) }));
48
+ if (history.length > bounds.history) history.shift();
49
+ owner.gateway = null;
50
+ owner.candidates.length = 0;
51
+ owner.finished = true;
52
+ owner.resolveDone();
53
+ for (const next of waiting) {
54
+ if (owners.size >= bounds.owners) break;
55
+ waiting.delete(next);
56
+ if (!next.controller.signal.aborted && enabled()) start(next);
57
+ else next.release();
58
+ }
59
+ }
60
+
61
+ function start(owner) {
62
+ owner.state = 'discovering';
63
+ owners.add(owner);
64
+ void run(owner);
65
+ }
66
+
67
+ const own = (entry, owner) => entry && entry.localAddress === owner.gateway.localAddress
68
+ && entry.port === owner.port && entry.description === owner.description;
69
+
70
+ async function cleanup(owner) {
71
+ if (!owner.attempted || !owner.gateway) return;
72
+ const controller = new AbortController();
73
+ owner.cleanupTimer = setTimeout(() => controller.abort(), cleanupMs);
74
+ try {
75
+ const entry = await owner.gateway.read(owner.externalPort, controller.signal);
76
+ if (entry === null) { owner.cleanup = 'absent'; return; }
77
+ if (!own(entry, owner)) { owner.cleanup = 'ownership-changed'; return; }
78
+ await owner.gateway.remove(owner.externalPort, controller.signal);
79
+ owner.cleanup = await owner.gateway.read(owner.externalPort, controller.signal) === null ? 'removed' : 'unconfirmed';
80
+ } catch { owner.cleanup = 'unconfirmed'; }
81
+ finally {
82
+ clearTimeout(owner.cleanupTimer);
83
+ owner.cleanupTimer = null;
84
+ if (owner.cleanup === 'unconfirmed') cleanupUnconfirmed += 1;
85
+ }
86
+ }
87
+
88
+ async function run(owner) {
89
+ attempts += 1;
90
+ const signal = owner.controller.signal;
91
+ owner.setupTimer = setTimeout(() => {
92
+ owner.state = 'setup-expired';
93
+ owner.controller.abort();
94
+ }, setupMs);
95
+ try {
96
+ owner.gateway = await discover(signal);
97
+ signal.throwIfAborted();
98
+ const range = owner.portRange;
99
+ const requestedPort = range?.portRangeBegin || 0;
100
+ const lastPort = range?.portRangeEnd || requestedPort;
101
+ if (!Number.isInteger(requestedPort) || !Number.isInteger(lastPort)
102
+ || requestedPort < 0 || (requestedPort > 0 && requestedPort < 1024)
103
+ || (requestedPort === 0 && lastPort !== 0)
104
+ || lastPort < requestedPort || lastPort > 65535 || lastPort - requestedPort >= 64) {
105
+ throw new Error('router-port-range-invalid');
106
+ }
107
+ for (let port = requestedPort; port <= lastPort; port++) {
108
+ try { owner.socket = await reserveSocket(owner.gateway, signal, port); break; }
109
+ catch (error) { if (error?.code !== 'EADDRINUSE' || port === lastPort) throw error; }
110
+ }
111
+ signal.throwIfAborted();
112
+ owner.port = owner.socket.port;
113
+ owner.externalPort = owner.socket.port;
114
+ owner.reservation = `${owner.gateway.address}:${owner.externalPort}`;
115
+ if (reservedPorts.has(owner.reservation)) { owner.state = 'port-busy'; return; }
116
+ reservedPorts.set(owner.reservation, owner);
117
+ if (await owner.gateway.read(owner.externalPort, signal) !== null) { owner.state = 'port-busy'; return; }
118
+ signal.throwIfAborted();
119
+ owner.attempted = true;
120
+ await owner.gateway.add(owner.externalPort, owner.port, owner.description, signal);
121
+ signal.throwIfAborted();
122
+ const accepted = await owner.gateway.read(owner.externalPort, signal);
123
+ if (!own(accepted, owner) || !accepted.enabled || !Number.isInteger(accepted.leaseSeconds)
124
+ || accepted.leaseSeconds < 1 || accepted.leaseSeconds > bounds.leaseSeconds) {
125
+ owner.state = 'lease-rejected';
126
+ return;
127
+ }
128
+ owner.state = 'mapped';
129
+ mapped += 1;
130
+ owner.resolveReady({ port: owner.port });
131
+ await untilAborted(signal);
132
+ } catch {
133
+ if (!signal.aborted) owner.state = 'unavailable';
134
+ } finally {
135
+ owner.resolveReady();
136
+ clearTimeout(owner.setupTimer);
137
+ owner.setupTimer = null;
138
+ await cleanup(owner);
139
+ await owner.socket?.close();
140
+ owner.socket = null;
141
+ finish(owner);
142
+ }
143
+ }
144
+
145
+ function createOwner(key, { portRange, queue = false } = {}) {
146
+ if (!enabled() || (owners.size >= bounds.owners && (!queue || waiting.size >= bounds.queuedOwners)) || typeof key !== 'string'
147
+ || !/^[a-f0-9-]{36}:[a-f0-9-]{36}:\d{1,12}$/i.test(key)) return null;
148
+ const owner = { key, historyEpoch, portRange, socket: null, state: 'discovering', cleanup: 'not-needed', finished: false,
149
+ controller: new AbortController(), description: `VuvoDesk-ICE-${randomUUID()}`,
150
+ candidates: [], gateway: null, port: 0, reservation: '', attempted: false,
151
+ setupTimer: null, cleanupTimer: null, promise: null, startedAt: Date.now() };
152
+ const ready = new Promise(resolve => { owner.resolveReady = resolve; });
153
+ owner.promise = new Promise(resolve => { owner.resolveDone = resolve; });
154
+ const release = () => {
155
+ if (owner.finished) return owner.promise || Promise.resolve();
156
+ owner.state = 'released';
157
+ owner.controller.abort();
158
+ owner.resolveReady();
159
+ if (!owners.has(owner)) { waiting.delete(owner); finish(owner); }
160
+ return owner.promise || Promise.resolve();
161
+ };
162
+ owner.release = release;
163
+ if (owners.size < bounds.owners) start(owner);
164
+ else { owner.state = 'queued'; waiting.add(owner); }
165
+ return Object.freeze({
166
+ ready,
167
+ async handoff() {
168
+ await owner.socket?.close();
169
+ owner.socket = null;
170
+ if (owner.controller.signal.aborted || owner.finished) return null;
171
+ return owner.state === 'mapped' ? { port: owner.port } : null;
172
+ },
173
+ observe(value) {
174
+ if (owner.finished || owner.controller.signal.aborted || !enabled() || owner.state !== 'mapped') return;
175
+ const candidate = parseRouterMappingCandidate(value);
176
+ if (!candidate || owner.candidates.length >= bounds.candidates
177
+ || owner.candidates.some(c => c.type === candidate.type && c.address === candidate.address && c.port === candidate.port)) return;
178
+ owner.candidates.push(candidate);
179
+ if (candidate.type === 'srflx'
180
+ && (candidate.address !== owner.gateway?.publicAddress || candidate.port !== owner.externalPort)) {
181
+ owner.state = 'candidate-mismatch';
182
+ owner.controller.abort();
183
+ }
184
+ },
185
+ release
186
+ });
187
+ }
188
+
189
+ return Object.freeze({
190
+ createOwner,
191
+ releaseAll: ({ forgetHistory = false } = {}) => {
192
+ if (forgetHistory) { historyEpoch += 1; history.length = 0; }
193
+ // Remove waiting owners before releasing active slots: a shutdown must
194
+ // never start a queued router operation while draining the active ones.
195
+ const pending = [...waiting];
196
+ waiting.clear();
197
+ return Promise.all([...pending, ...owners].map(owner => owner.release()));
198
+ },
199
+ inspect: () => ({ enabled: Boolean(enabled()), activeOwners: owners.size, queuedOwners: waiting.size,
200
+ activeMappings: [...owners].filter(owner => owner.state === 'mapped').length,
201
+ pendingOperations: [...owners].filter(owner => owner.promise).length,
202
+ resourceTimers: [...owners].reduce((n, owner) => n + Number(!!owner.setupTimer) + Number(!!owner.cleanupTimer), 0),
203
+ resourceSockets: [...owners].filter(owner => owner.socket).length,
204
+ attempts, mapped, cleanupUnconfirmed, recent: history.map(value => ({ ...value })) })
205
+ });
206
+ }