@livedesk/hub 0.1.73 → 0.1.75
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/package.json +1 -1
- package/src/console-direct-frame-admission.mjs +60 -12
- package/src/console-direct-frame-admission.test.mjs +57 -0
- package/src/console-direct-ice-evidence.mjs +87 -0
- package/src/console-direct-ice-evidence.test.mjs +59 -0
- package/src/console-direct.js +156 -93
- package/src/console-direct.test.mjs +128 -0
- package/src/console-ice-setup-gate.mjs +30 -0
- package/src/console-router-mapping.mjs +187 -0
- package/src/console-router-mapping.test.mjs +287 -0
- package/src/console-upnp-gateway.mjs +230 -0
- package/src/plan-device-access.test.mjs +10 -4
- package/src/server.js +10 -3
- package/src/settings/settings-schema.js +3 -2
package/src/console-direct.js
CHANGED
|
@@ -10,6 +10,12 @@ import {
|
|
|
10
10
|
} from '../../runtime-core/src/console-direct-wire.js';
|
|
11
11
|
import { workspaceRoleCanControl, workspaceRoleCanRequest } from './auth/workspace-access.js';
|
|
12
12
|
import { createConsoleFrameAdmission } from './console-direct-frame-admission.mjs';
|
|
13
|
+
import { createConsoleRouterMapping } from './console-router-mapping.mjs';
|
|
14
|
+
import { createConsoleIceSetupGate } from './console-ice-setup-gate.mjs';
|
|
15
|
+
import {
|
|
16
|
+
createConsoleIceEvidence, normalizeConsoleIcePortRange, recordConsoleIceCandidate, recordConsoleIceDescription,
|
|
17
|
+
retainConsoleIceRetirement, safeIceState, snapshotConsoleIceEvidence
|
|
18
|
+
} from './console-direct-ice-evidence.mjs';
|
|
13
19
|
|
|
14
20
|
const DEFAULT_SIGNAL_URL = 'https://livedesk-wake.lovecrdm.workers.dev';
|
|
15
21
|
const DEFAULT_STUN_URLS = Object.freeze(['stun:stun.cloudflare.com:3478']);
|
|
@@ -328,6 +334,10 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
328
334
|
const httpBaseUrl = String(options.httpBaseUrl || '').replace(/\/+$/, '');
|
|
329
335
|
const canonicalHttpBaseUrl = normalizeHttpBaseUrl(httpBaseUrl);
|
|
330
336
|
const stunUrls = normalizeStunUrls(options.stunUrls);
|
|
337
|
+
const icePortRange = normalizeConsoleIcePortRange(options.icePortRange);
|
|
338
|
+
const routerMapping = options.routerMapping || createConsoleRouterMapping({
|
|
339
|
+
isEnabled: options.isRouterMappingEnabled
|
|
340
|
+
});
|
|
331
341
|
const SignalingWebSocketImpl = options.SignalingWebSocketImpl || options.WebSocketImpl || WebSocket;
|
|
332
342
|
const LocalWebSocketImpl = options.LocalWebSocketImpl || options.WebSocketImpl || WebSocket;
|
|
333
343
|
const fetchImpl = options.fetchImpl || globalThis.fetch;
|
|
@@ -380,6 +390,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
380
390
|
Number(options.logicalBindTimeoutMs) || LOGICAL_BIND_TIMEOUT_MS
|
|
381
391
|
);
|
|
382
392
|
const peers = new Map();
|
|
393
|
+
const recentPeerConnections = [];
|
|
383
394
|
const revokedMembers = new Map();
|
|
384
395
|
let signalSocket = null;
|
|
385
396
|
let signalGeneration = 0;
|
|
@@ -569,6 +580,9 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
569
580
|
const retirePeer = (owner, reason = 'console-direct-peer-closed', options = {}) => {
|
|
570
581
|
if (!owner || owner.retired) return;
|
|
571
582
|
owner.retired = true;
|
|
583
|
+
owner.iceSetupGate?.close();
|
|
584
|
+
void owner.routerMapping?.release();
|
|
585
|
+
retainConsoleIceRetirement(recentPeerConnections, owner, reason);
|
|
572
586
|
if (peers.get(owner.consoleId) === owner) peers.delete(owner.consoleId);
|
|
573
587
|
clearTimeout(owner.iceTimer);
|
|
574
588
|
owner.iceTimer = null;
|
|
@@ -774,6 +788,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
774
788
|
}
|
|
775
789
|
return false;
|
|
776
790
|
}
|
|
791
|
+
const sendStartedAt = performance.now();
|
|
777
792
|
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
778
793
|
if (!isOwnerActive(owner) || channelState.closed || !channelIsOpen(channelState.channel)) return false;
|
|
779
794
|
try {
|
|
@@ -810,6 +825,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
810
825
|
return false;
|
|
811
826
|
}
|
|
812
827
|
}
|
|
828
|
+
if (!isOwnerActive(owner) || channelState.closed) return false;
|
|
829
|
+
channelState.frameAdmission?.sent(wireBytes, sendStartedAt);
|
|
813
830
|
return true;
|
|
814
831
|
}
|
|
815
832
|
|
|
@@ -1252,6 +1269,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1252
1269
|
channel.onOpen(() => {
|
|
1253
1270
|
if (!isOwnerActive(owner) || channelState.closed) return;
|
|
1254
1271
|
if (channelState === owner.control) {
|
|
1272
|
+
owner.controlOpened = true;
|
|
1273
|
+
void owner.routerMapping?.release();
|
|
1255
1274
|
clearTimeout(owner.iceTimer);
|
|
1256
1275
|
owner.iceTimer = null;
|
|
1257
1276
|
sendControl(owner, {
|
|
@@ -1315,7 +1334,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1315
1334
|
&& existing.connectionId === connectionId
|
|
1316
1335
|
&& existing.hubEpoch === ownerHubEpoch
|
|
1317
1336
|
&& !existing.retired) {
|
|
1318
|
-
const current = existing.peer
|
|
1337
|
+
const current = existing.peer?.localDescription?.();
|
|
1319
1338
|
if (current?.type === 'answer' && typeof current.sdp === 'string') {
|
|
1320
1339
|
sendSignal({
|
|
1321
1340
|
type: 'rtc-answer',
|
|
@@ -1337,29 +1356,6 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1337
1356
|
}
|
|
1338
1357
|
if (existing) retirePeer(existing, 'console-direct-peer-replaced');
|
|
1339
1358
|
let peer;
|
|
1340
|
-
try {
|
|
1341
|
-
peer = createPeerConnection(`console-${consoleId}`, {
|
|
1342
|
-
iceServers: [...stunUrls],
|
|
1343
|
-
iceTransportPolicy: 'all',
|
|
1344
|
-
disableAutoNegotiation: false,
|
|
1345
|
-
disableFingerprintVerification: false,
|
|
1346
|
-
// Each native send is one wire chunk. libdatachannel also uses this
|
|
1347
|
-
// setting as the minimum SCTP socket buffer size, below bufferedAmount.
|
|
1348
|
-
// A whole-frame limit here hides megabytes from our admission gate.
|
|
1349
|
-
// Complete frame/HTTP limits remain enforced by the wire assemblers.
|
|
1350
|
-
maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
|
|
1351
|
-
});
|
|
1352
|
-
} catch (error) {
|
|
1353
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
1354
|
-
sendSignal({
|
|
1355
|
-
type: 'rtc-close',
|
|
1356
|
-
consoleId,
|
|
1357
|
-
connectionId,
|
|
1358
|
-
hubEpoch: ownerHubEpoch,
|
|
1359
|
-
reason: 'console-direct-peer-create-failed'
|
|
1360
|
-
});
|
|
1361
|
-
return null;
|
|
1362
|
-
}
|
|
1363
1359
|
const owner = {
|
|
1364
1360
|
consoleId,
|
|
1365
1361
|
connectionId,
|
|
@@ -1377,70 +1373,18 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1377
1373
|
accessTimer: null,
|
|
1378
1374
|
peerState: 'new',
|
|
1379
1375
|
iceState: 'new',
|
|
1376
|
+
controlOpened: false,
|
|
1377
|
+
routerMapping: null,
|
|
1378
|
+
iceSetupGate: null,
|
|
1379
|
+
iceEvidence: createConsoleIceEvidence(),
|
|
1380
1380
|
retired: false,
|
|
1381
1381
|
createdAt: Date.now()
|
|
1382
1382
|
};
|
|
1383
1383
|
peers.set(consoleId, owner);
|
|
1384
|
+
owner.routerMapping = routerMapping.createOwner(`${consoleId}:${connectionId}:${owner.generation}`,
|
|
1385
|
+
{ portRange: icePortRange });
|
|
1386
|
+
if (owner.routerMapping?.handoff) owner.iceSetupGate = createConsoleIceSetupGate();
|
|
1384
1387
|
armOwnerAccessDeadline(owner);
|
|
1385
|
-
peer.onLocalDescription((sdp, type) => {
|
|
1386
|
-
if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
|
|
1387
|
-
if (candidateIsRelay(sdp)) {
|
|
1388
|
-
rejectedRelayCandidates += 1;
|
|
1389
|
-
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
sendSignal({
|
|
1393
|
-
type: 'rtc-answer',
|
|
1394
|
-
...ownerEnvelope(owner),
|
|
1395
|
-
description: { type: 'answer', sdp: String(sdp) }
|
|
1396
|
-
});
|
|
1397
|
-
});
|
|
1398
|
-
peer.onLocalCandidate((candidate, sdpMid) => {
|
|
1399
|
-
if (!isOwnerActive(owner)) return;
|
|
1400
|
-
const candidateText = String(candidate || '');
|
|
1401
|
-
if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1402
|
-
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1403
|
-
return;
|
|
1404
|
-
}
|
|
1405
|
-
if (candidateIsRelay(candidateText)) {
|
|
1406
|
-
rejectedRelayCandidates += 1;
|
|
1407
|
-
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
sendSignal({
|
|
1411
|
-
type: 'rtc-ice',
|
|
1412
|
-
...ownerEnvelope(owner),
|
|
1413
|
-
candidate: candidateText,
|
|
1414
|
-
sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
|
|
1415
|
-
});
|
|
1416
|
-
});
|
|
1417
|
-
peer.onDataChannel(channel => bindDataChannel(owner, channel));
|
|
1418
|
-
peer.onStateChange(nextState => {
|
|
1419
|
-
if (!isOwnerActive(owner)) return;
|
|
1420
|
-
const normalized = String(nextState || '').toLowerCase();
|
|
1421
|
-
owner.peerState = normalized;
|
|
1422
|
-
if (normalized === 'connected') {
|
|
1423
|
-
if (peerSelectedRelay(peer)) {
|
|
1424
|
-
rejectedRelayCandidates += 1;
|
|
1425
|
-
retirePeer(owner, 'console-direct-relay-pair-rejected');
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1428
|
-
} else if (normalized === 'failed' || normalized === 'closed') {
|
|
1429
|
-
retirePeer(owner, `console-direct-peer-${normalized}`);
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
refreshPeerDisconnectDeadline(owner);
|
|
1433
|
-
});
|
|
1434
|
-
peer.onIceStateChange?.(nextState => {
|
|
1435
|
-
if (!isOwnerActive(owner)) return;
|
|
1436
|
-
const normalized = String(nextState || '').toLowerCase();
|
|
1437
|
-
owner.iceState = normalized;
|
|
1438
|
-
if (normalized === 'failed' || normalized === 'closed') {
|
|
1439
|
-
retirePeer(owner, `console-direct-ice-${normalized}`);
|
|
1440
|
-
return;
|
|
1441
|
-
}
|
|
1442
|
-
refreshPeerDisconnectDeadline(owner);
|
|
1443
|
-
});
|
|
1444
1388
|
owner.iceTimer = setTimeout(() => {
|
|
1445
1389
|
owner.iceTimer = null;
|
|
1446
1390
|
if (isOwnerActive(owner)
|
|
@@ -1449,13 +1393,117 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1449
1393
|
}
|
|
1450
1394
|
}, iceConnectTimeoutMs);
|
|
1451
1395
|
owner.iceTimer.unref?.();
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1396
|
+
const initializePeer = reservedPort => {
|
|
1397
|
+
if (!isOwnerActive(owner)) return null;
|
|
1398
|
+
try {
|
|
1399
|
+
peer = createPeerConnection(`console-${consoleId}`, {
|
|
1400
|
+
iceServers: [...stunUrls],
|
|
1401
|
+
iceTransportPolicy: 'all',
|
|
1402
|
+
...icePortRange,
|
|
1403
|
+
...(reservedPort ? { portRangeBegin: reservedPort, portRangeEnd: reservedPort } : {}),
|
|
1404
|
+
disableAutoNegotiation: false,
|
|
1405
|
+
disableFingerprintVerification: false,
|
|
1406
|
+
// Keep the native SCTP socket's hidden buffer at the wire chunk bound.
|
|
1407
|
+
maxMessageSize: DIRECT_CONSOLE_WIRE_CHUNK_BYTES
|
|
1408
|
+
});
|
|
1409
|
+
owner.peer = peer;
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1412
|
+
retirePeer(owner, 'console-direct-peer-create-failed');
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
peer.onLocalDescription((sdp, type) => {
|
|
1416
|
+
if (!isOwnerActive(owner) || String(type).toLowerCase() !== 'answer' || byteLength(sdp) > SDP_MAX_BYTES) return;
|
|
1417
|
+
if (candidateIsRelay(sdp)) {
|
|
1418
|
+
rejectedRelayCandidates += 1;
|
|
1419
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const sent = sendSignal({
|
|
1423
|
+
type: 'rtc-answer',
|
|
1424
|
+
...ownerEnvelope(owner),
|
|
1425
|
+
description: { type: 'answer', sdp: String(sdp) }
|
|
1426
|
+
});
|
|
1427
|
+
owner.iceEvidence.localDescriptionSent = sent;
|
|
1428
|
+
recordConsoleIceDescription(owner.iceEvidence.local, sdp);
|
|
1429
|
+
if (!owner.controlOpened) {
|
|
1430
|
+
for (const line of sdp.split(/\r?\n/)) {
|
|
1431
|
+
if (line.startsWith('a=candidate:')) owner.routerMapping?.observe(line);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
peer.onLocalCandidate((candidate, sdpMid) => {
|
|
1436
|
+
if (!isOwnerActive(owner)) return;
|
|
1437
|
+
const candidateText = String(candidate || '');
|
|
1438
|
+
if (byteLength(candidateText) > ICE_CANDIDATE_MAX_BYTES) {
|
|
1439
|
+
retirePeer(owner, 'console-direct-candidate-invalid');
|
|
1440
|
+
return;
|
|
1441
|
+
}
|
|
1442
|
+
if (candidateIsRelay(candidateText)) {
|
|
1443
|
+
rejectedRelayCandidates += 1;
|
|
1444
|
+
retirePeer(owner, 'console-direct-relay-candidate-rejected');
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
const sent = sendSignal({
|
|
1448
|
+
type: 'rtc-ice',
|
|
1449
|
+
...ownerEnvelope(owner),
|
|
1450
|
+
candidate: candidateText,
|
|
1451
|
+
sdpMid: sdpMid === undefined || sdpMid === null ? null : String(sdpMid).slice(0, 256)
|
|
1452
|
+
});
|
|
1453
|
+
recordConsoleIceCandidate(owner.iceEvidence.local, candidateText);
|
|
1454
|
+
if (!owner.controlOpened) owner.routerMapping?.observe(candidateText);
|
|
1455
|
+
if (!sent) owner.iceEvidence.localCandidateSendFailures = Math.min(65_535,
|
|
1456
|
+
owner.iceEvidence.localCandidateSendFailures + 1);
|
|
1457
|
+
});
|
|
1458
|
+
peer.onGatheringStateChange?.(nextState => {
|
|
1459
|
+
if (isOwnerActive(owner)) owner.iceEvidence.gatheringState = safeIceState(String(nextState).toLowerCase());
|
|
1460
|
+
});
|
|
1461
|
+
peer.onDataChannel(channel => bindDataChannel(owner, channel));
|
|
1462
|
+
peer.onStateChange(nextState => {
|
|
1463
|
+
if (!isOwnerActive(owner)) return;
|
|
1464
|
+
const normalized = String(nextState || '').toLowerCase();
|
|
1465
|
+
owner.peerState = normalized;
|
|
1466
|
+
if (normalized === 'connected') {
|
|
1467
|
+
if (peerSelectedRelay(peer)) {
|
|
1468
|
+
rejectedRelayCandidates += 1;
|
|
1469
|
+
retirePeer(owner, 'console-direct-relay-pair-rejected');
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
} else if (normalized === 'failed' || normalized === 'closed') {
|
|
1473
|
+
retirePeer(owner, `console-direct-peer-${normalized}`);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
refreshPeerDisconnectDeadline(owner);
|
|
1477
|
+
});
|
|
1478
|
+
peer.onIceStateChange?.(nextState => {
|
|
1479
|
+
if (!isOwnerActive(owner)) return;
|
|
1480
|
+
const normalized = String(nextState || '').toLowerCase();
|
|
1481
|
+
owner.iceState = normalized;
|
|
1482
|
+
if (normalized === 'failed' || normalized === 'closed') {
|
|
1483
|
+
retirePeer(owner, `console-direct-ice-${normalized}`);
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
refreshPeerDisconnectDeadline(owner);
|
|
1487
|
+
});
|
|
1488
|
+
try {
|
|
1489
|
+
peer.setRemoteDescription(description.sdp, 'offer');
|
|
1490
|
+
owner.iceEvidence.remoteDescriptionApplied = true;
|
|
1491
|
+
recordConsoleIceDescription(owner.iceEvidence.remote, description.sdp);
|
|
1492
|
+
owner.iceSetupGate?.flush((candidate, mid) => peer.addRemoteCandidate(candidate, mid),
|
|
1493
|
+
() => isOwnerActive(owner));
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
1496
|
+
retirePeer(owner, 'console-direct-description-failed');
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
return owner;
|
|
1500
|
+
};
|
|
1501
|
+
if (owner.routerMapping?.handoff) {
|
|
1502
|
+
void owner.routerMapping.ready.then(async () => {
|
|
1503
|
+
const reservation = await owner.routerMapping.handoff();
|
|
1504
|
+
if (isOwnerActive(owner)) initializePeer(reservation?.port);
|
|
1505
|
+
}).catch(() => retirePeer(owner, 'console-direct-peer-create-failed'));
|
|
1506
|
+
} else initializePeer();
|
|
1459
1507
|
return owner;
|
|
1460
1508
|
};
|
|
1461
1509
|
|
|
@@ -1594,7 +1642,12 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1594
1642
|
return;
|
|
1595
1643
|
}
|
|
1596
1644
|
try {
|
|
1597
|
-
if (candidate)
|
|
1645
|
+
if (candidate) {
|
|
1646
|
+
recordConsoleIceCandidate(owner.iceEvidence.remote, candidate);
|
|
1647
|
+
if (!owner.iceSetupGate?.defer(candidate, String(payload.sdpMid || ''))) {
|
|
1648
|
+
owner.peer.addRemoteCandidate(candidate, String(payload.sdpMid || ''));
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1598
1651
|
} catch {
|
|
1599
1652
|
retirePeer(owner, 'console-direct-candidate-failed');
|
|
1600
1653
|
}
|
|
@@ -1951,6 +2004,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1951
2004
|
retryTimer = null;
|
|
1952
2005
|
nextRetryAt = '';
|
|
1953
2006
|
retireAllPeers('hub-shutdown');
|
|
2007
|
+
void routerMapping.releaseAll({ forgetHistory: true });
|
|
2008
|
+
recentPeerConnections.length = 0;
|
|
1954
2009
|
hubEpoch = '';
|
|
1955
2010
|
const socket = signalSocket;
|
|
1956
2011
|
signalSocket = null;
|
|
@@ -1961,6 +2016,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
1961
2016
|
|
|
1962
2017
|
const invalidateWorkspaceAccess = (reason = 'console-workspace-access-invalidated') => {
|
|
1963
2018
|
retireAllPeers(String(reason || 'console-workspace-access-invalidated').slice(0, 120));
|
|
2019
|
+
void routerMapping.releaseAll({ forgetHistory: true });
|
|
2020
|
+
recentPeerConnections.length = 0;
|
|
1964
2021
|
refresh();
|
|
1965
2022
|
};
|
|
1966
2023
|
|
|
@@ -2018,6 +2075,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2018
2075
|
generation: owner.generation,
|
|
2019
2076
|
peerState: owner.peerState,
|
|
2020
2077
|
iceState: owner.iceState,
|
|
2078
|
+
iceEvidence: snapshotConsoleIceEvidence(owner.iceEvidence),
|
|
2021
2079
|
rttMs: optionalPeerMetric(owner.peer, 'rtt'),
|
|
2022
2080
|
bytesSent: optionalPeerMetric(owner.peer, 'bytesSent'),
|
|
2023
2081
|
bytesReceived: optionalPeerMetric(owner.peer, 'bytesReceived'),
|
|
@@ -2041,6 +2099,7 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2041
2099
|
const accessTokenDeadlineActive = Boolean(accessTokenAttempt);
|
|
2042
2100
|
const signalReadyDeadlineActive = Boolean(signalReadyDeadline);
|
|
2043
2101
|
const signalHeartbeatTimerActive = Boolean(signalHeartbeat?.timer);
|
|
2102
|
+
const routerResources = routerMapping.inspect();
|
|
2044
2103
|
return Object.freeze({
|
|
2045
2104
|
enabled: signalUrl instanceof URL && Boolean(deviceId),
|
|
2046
2105
|
state,
|
|
@@ -2055,6 +2114,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2055
2114
|
retainedAssemblyBytes,
|
|
2056
2115
|
bufferedSendBytes,
|
|
2057
2116
|
peerDiagnostics,
|
|
2117
|
+
routerMapping: routerResources,
|
|
2118
|
+
recentPeerConnections: recentPeerConnections.map(entry => snapshotConsoleIceEvidence(entry)),
|
|
2058
2119
|
iceConnectTimers,
|
|
2059
2120
|
peerDisconnectTimers,
|
|
2060
2121
|
assemblyDeadlineTimers,
|
|
@@ -2075,7 +2136,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2075
2136
|
+ workspaceAccessTimers
|
|
2076
2137
|
+ Number(Boolean(workspaceReauthAttempt?.timer))
|
|
2077
2138
|
+ Number(signalReadyDeadlineActive)
|
|
2078
|
-
+ Number(signalHeartbeatTimerActive)
|
|
2139
|
+
+ Number(signalHeartbeatTimerActive)
|
|
2140
|
+
+ Number(routerResources.resourceTimers || 0),
|
|
2079
2141
|
rejectedRelayCandidates,
|
|
2080
2142
|
dataChannelBackpressureCloses,
|
|
2081
2143
|
dataChannelSendFailures,
|
|
@@ -2096,7 +2158,8 @@ export function createHubConsoleDirect(options = {}) {
|
|
|
2096
2158
|
close,
|
|
2097
2159
|
invalidateWorkspaceAccess,
|
|
2098
2160
|
revokeWorkspaceMember,
|
|
2099
|
-
inspect
|
|
2161
|
+
inspect,
|
|
2162
|
+
releaseRouterMappings: () => routerMapping.releaseAll()
|
|
2100
2163
|
});
|
|
2101
2164
|
}
|
|
2102
2165
|
|
|
@@ -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 } : {}),
|
|
@@ -340,6 +343,8 @@ test('native buffered sends complete each media message without closing input or
|
|
|
340
343
|
assert.equal(peer.closed, false);
|
|
341
344
|
assert.equal(direct.inspect().dataChannelSendFailures, 0);
|
|
342
345
|
assert.equal(direct.inspect().dataChannelBackpressureCloses, 0);
|
|
346
|
+
assert.ok(direct.inspect().peerDiagnostics[0].lanes.find(l => l.channelId === CHANNEL_ID).frameAdmission.sendBatches > 0,
|
|
347
|
+
'accepted native false returns are accounted before the lane closes');
|
|
343
348
|
lane.sendMessageBinary = () => { throw new Error('native-send-failed'); };
|
|
344
349
|
local.receive(Buffer.alloc(10), true);
|
|
345
350
|
assert.equal(lane.closed, true, 'an actual native exception still retires the exact lane');
|
|
@@ -353,11 +358,134 @@ test('native buffered sends complete each media message without closing input or
|
|
|
353
358
|
}
|
|
354
359
|
});
|
|
355
360
|
|
|
361
|
+
test('an explicit direct port range reaches the native peer without enabling relay or ICE TCP', async () => {
|
|
362
|
+
const { direct, signal } = await connectedDirect({ icePortRange: '54443-54450' });
|
|
363
|
+
try {
|
|
364
|
+
const peer = offer(signal);
|
|
365
|
+
assert.equal(peer.config.portRangeBegin, 54443);
|
|
366
|
+
assert.equal(peer.config.portRangeEnd, 54450);
|
|
367
|
+
assert.equal(peer.config.enableIceTcp, undefined);
|
|
368
|
+
assert.equal(peer.config.disableFingerprintVerification, false);
|
|
369
|
+
assert.deepEqual(peer.config.iceServers, ['stun:stun.example.test:3478']);
|
|
370
|
+
} finally { direct.close(); }
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test('router setup belongs to the exact peer and ends on control ready without closing that peer', async () => {
|
|
374
|
+
const leases = [];
|
|
375
|
+
const routerMapping = {
|
|
376
|
+
createOwner(key) {
|
|
377
|
+
const lease = { key, candidates: [], released: false,
|
|
378
|
+
observe(candidate) { this.candidates.push(candidate); },
|
|
379
|
+
async release() { this.released = true; } };
|
|
380
|
+
leases.push(lease);
|
|
381
|
+
return lease;
|
|
382
|
+
},
|
|
383
|
+
async releaseAll() { await Promise.all(leases.map(lease => lease.release())); },
|
|
384
|
+
inspect() { return { resourceTimers: 0 }; }
|
|
385
|
+
};
|
|
386
|
+
const { direct, signal } = await connectedDirect({ routerMapping });
|
|
387
|
+
try {
|
|
388
|
+
const first = offer(signal);
|
|
389
|
+
const second = offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CONNECTION_TWO });
|
|
390
|
+
first.emitLocalCandidate('candidate:1 1 udp 1 192.168.0.4 54443 typ host');
|
|
391
|
+
assert.equal(leases[0].candidates.length, 1);
|
|
392
|
+
assert.notEqual(leases[0].key, leases[1].key);
|
|
393
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
394
|
+
first.emitDataChannel(control); control.open();
|
|
395
|
+
assert.equal(leases[0].released, true);
|
|
396
|
+
assert.equal(leases[1].released, false);
|
|
397
|
+
assert.equal(first.closed, false);
|
|
398
|
+
assert.equal(second.closed, false);
|
|
399
|
+
first.emitLocalCandidate('candidate:2 1 udp 1 192.168.0.4 54444 typ host');
|
|
400
|
+
assert.equal(leases[0].candidates.length, 1, 'late gathering cannot reopen an established peer mapping');
|
|
401
|
+
direct.invalidateWorkspaceAccess();
|
|
402
|
+
await direct.releaseRouterMappings();
|
|
403
|
+
assert.ok(leases.every(lease => lease.released));
|
|
404
|
+
} finally { direct.close(); }
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('router readiness precedes native gathering and a retired peer cannot consume late setup completion', async () => {
|
|
408
|
+
const completions = [];
|
|
409
|
+
const routerMapping = {
|
|
410
|
+
createOwner() {
|
|
411
|
+
return { ready: new Promise(resolve => completions.push(resolve)),
|
|
412
|
+
async handoff() { return { port: 54443 }; }, observe() {}, release() {} };
|
|
413
|
+
},
|
|
414
|
+
async releaseAll() {},
|
|
415
|
+
inspect: () => ({ resourceTimers: 0 })
|
|
416
|
+
};
|
|
417
|
+
const { direct, signal } = await connectedDirect({ routerMapping });
|
|
418
|
+
const candidate = 'candidate:7 1 UDP 123 8.8.8.8 50000 typ srflx';
|
|
419
|
+
const sdp = `v=0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=mid:0\r\na=${candidate}\r\n`;
|
|
420
|
+
try {
|
|
421
|
+
offer(signal, CONNECTION_ONE, sdp);
|
|
422
|
+
assert.equal(FakePeerConnection.instances.length, 0);
|
|
423
|
+
signal.receive(JSON.stringify({ type: 'rtc-ice', consoleId: CONSOLE_ID, connectionId: CONNECTION_ONE,
|
|
424
|
+
hubEpoch: HUB_EPOCH, candidate, sdpMid: '0' }));
|
|
425
|
+
assert.equal(FakePeerConnection.instances.length, 0);
|
|
426
|
+
completions[0](); await settle();
|
|
427
|
+
const first = FakePeerConnection.instances[0];
|
|
428
|
+
assert.equal(first.config.portRangeBegin, 54443);
|
|
429
|
+
assert.equal(first.config.portRangeEnd, 54443);
|
|
430
|
+
assert.equal(first.remote.sdp, sdp);
|
|
431
|
+
assert.deepEqual(first.remoteCandidates, [{ candidate, sdpMid: '0' }]);
|
|
432
|
+
offer(signal, CONNECTION_TWO, sdp);
|
|
433
|
+
direct.invalidateWorkspaceAccess();
|
|
434
|
+
completions[1](); await settle();
|
|
435
|
+
assert.equal(FakePeerConnection.instances.length, 1, 'retired setup must not create a native peer');
|
|
436
|
+
assert.equal(first.closed, true);
|
|
437
|
+
} finally { direct.close(); }
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('failed ICE owners retain bounded evidence while a healthy peer and signaling survive', async () => {
|
|
441
|
+
const { direct, signal } = await connectedDirect({ iceConnectTimeoutMs: 30 });
|
|
442
|
+
try {
|
|
443
|
+
const healthy = offer(signal, CONNECTION_TWO, 'v=0\r\n', { consoleId: CONNECTION_TWO });
|
|
444
|
+
const control = new FakeDataChannel('livedesk-control-v1');
|
|
445
|
+
healthy.emitDataChannel(control);
|
|
446
|
+
control.open();
|
|
447
|
+
const failed = offer(signal);
|
|
448
|
+
failed.emitLocalCandidate('candidate:1 1 udp 1 203.0.113.1 40000 typ srflx raddr 192.168.0.4 rport 40000');
|
|
449
|
+
failed.callbacks.gatheringState('complete');
|
|
450
|
+
failed.callbacks.iceState('checking');
|
|
451
|
+
signal.receive(JSON.stringify({ type: 'rtc-ice', consoleId: CONSOLE_ID,
|
|
452
|
+
connectionId: CONNECTION_ONE, hubEpoch: HUB_EPOCH, sdpMid: '0',
|
|
453
|
+
candidate: 'candidate:2 1 udp 1 2001:db8::abcd 50000 typ host' }));
|
|
454
|
+
await waitFor(() => failed.closed);
|
|
455
|
+
const evidence = direct.inspect().recentPeerConnections.at(-1);
|
|
456
|
+
assert.equal(evidence.reason, 'console-direct-ice-timeout');
|
|
457
|
+
assert.equal(evidence.localDescriptionSent, true);
|
|
458
|
+
assert.equal(evidence.remoteDescriptionApplied, true);
|
|
459
|
+
assert.equal(evidence.gatheringState, 'complete');
|
|
460
|
+
assert.equal(evidence.local.srflx, 1);
|
|
461
|
+
assert.equal(evidence.remote.ipv6, 1);
|
|
462
|
+
assert.equal(evidence.controlOpened, false);
|
|
463
|
+
assert.equal(healthy.closed, false);
|
|
464
|
+
assert.equal(direct.inspect().controlChannels, 1);
|
|
465
|
+
assert.equal(direct.inspect().state, 'connected');
|
|
466
|
+
assert.equal(direct.inspect().iceConnectTimers, 0);
|
|
467
|
+
failed.emitLocalCandidate('candidate:3 1 udp 1 192.168.0.7 40000 typ host');
|
|
468
|
+
failed.callbacks.gatheringState('new');
|
|
469
|
+
assert.deepEqual(direct.inspect().recentPeerConnections.at(-1), evidence);
|
|
470
|
+
evidence.remote.ipv6 = 500;
|
|
471
|
+
assert.equal(direct.inspect().recentPeerConnections.at(-1).remote.ipv6, 1);
|
|
472
|
+
assert.doesNotMatch(JSON.stringify(direct.inspect().recentPeerConnections), /203\.0\.113|192\.168|2001:db8/);
|
|
473
|
+
direct.invalidateWorkspaceAccess();
|
|
474
|
+
assert.deepEqual(direct.inspect().recentPeerConnections, []);
|
|
475
|
+
} finally {
|
|
476
|
+
direct.close();
|
|
477
|
+
assert.equal(direct.inspect().resourceTimers, 0);
|
|
478
|
+
assert.deepEqual(direct.inspect().recentPeerConnections, []);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
|
|
356
482
|
test('STUN-only peer answers and rejects stale owner callbacks after replacement', async () => {
|
|
357
483
|
const { direct, signal } = await connectedDirect();
|
|
358
484
|
const firstPeer = offer(signal, CONNECTION_ONE);
|
|
359
485
|
assert.deepEqual(firstPeer.config.iceServers, ['stun:stun.example.test:3478']);
|
|
360
486
|
assert.equal(firstPeer.config.iceTransportPolicy, 'all');
|
|
487
|
+
assert.equal(firstPeer.config.portRangeBegin, undefined);
|
|
488
|
+
assert.equal(firstPeer.config.portRangeEnd, undefined);
|
|
361
489
|
assert.equal(firstPeer.config.disableFingerprintVerification, false);
|
|
362
490
|
assert.equal(firstPeer.config.maxMessageSize, DIRECT_CONSOLE_WIRE_CHUNK_BYTES,
|
|
363
491
|
'native SCTP accepts wire chunks, not whole frames; a frame-sized setting silently enlarges its hidden buffers');
|
|
@@ -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
|
+
}
|