@livedesk/hub 0.1.25 → 0.1.26
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/remote-hub.js +813 -30
- package/src/server.js +155 -5
- package/src/transport/udp-hub-transport.js +110 -8
- package/src/transport/udp-rendezvous.js +6 -2
package/package.json
CHANGED
package/src/remote-hub.js
CHANGED
|
@@ -31,6 +31,17 @@ const LIVE_STREAM_MIN_FRESH_MS = 3000;
|
|
|
31
31
|
const LIVE_STREAM_MAX_FRESH_MS = 12000;
|
|
32
32
|
const DEFAULT_REMOTE_INPUT_ACK_TIMEOUT_MS = 5000;
|
|
33
33
|
const DEFAULT_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS = 8000;
|
|
34
|
+
const TRANSPORT_DIAGNOSTIC_MIN_TTL_MS = 15_000;
|
|
35
|
+
const TRANSPORT_DIAGNOSTIC_MAX_TTL_MS = 180_000;
|
|
36
|
+
const TRANSPORT_DIAGNOSTIC_COMMAND_TIMEOUT_MS = 8_000;
|
|
37
|
+
const TRANSPORT_DIAGNOSTIC_RECORD_RETENTION_MS = 60_000;
|
|
38
|
+
const TRANSPORT_DIAGNOSTIC_MAX_RECORDS = 32;
|
|
39
|
+
const TRANSPORT_DIAGNOSTIC_PHASES = Object.freeze([
|
|
40
|
+
'direct',
|
|
41
|
+
'p2p',
|
|
42
|
+
'relay',
|
|
43
|
+
'recover-direct'
|
|
44
|
+
]);
|
|
34
45
|
const REMOTE_POLICY_BYPASS_COMMANDS = new Set([
|
|
35
46
|
'ping',
|
|
36
47
|
'stream.stop',
|
|
@@ -429,13 +440,23 @@ function isPublicIPv4(value) {
|
|
|
429
440
|
return false;
|
|
430
441
|
}
|
|
431
442
|
|
|
432
|
-
if (parts[0] === 192
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
443
|
+
if (parts[0] === 192
|
|
444
|
+
&& (parts[1] === 0
|
|
445
|
+
|| parts[1] === 168
|
|
446
|
+
|| (parts[1] === 88 && parts[2] === 99))) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (parts[0] === 198
|
|
451
|
+
&& (parts[1] === 18
|
|
452
|
+
|| parts[1] === 19
|
|
453
|
+
|| (parts[1] === 51 && parts[2] === 100))) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
439
460
|
|
|
440
461
|
return true;
|
|
441
462
|
}
|
|
@@ -1528,6 +1549,11 @@ class RemoteHubWebSocketAgentSocket {
|
|
|
1528
1549
|
|
|
1529
1550
|
export function createRemoteHub(options = {}) {
|
|
1530
1551
|
const env = options.env || process.env;
|
|
1552
|
+
const transportDiagnosticCommandTimeoutMs = clampNumber(
|
|
1553
|
+
options.transportDiagnosticCommandTimeoutMs,
|
|
1554
|
+
25,
|
|
1555
|
+
TRANSPORT_DIAGNOSTIC_COMMAND_TIMEOUT_MS,
|
|
1556
|
+
TRANSPORT_DIAGNOSTIC_COMMAND_TIMEOUT_MS);
|
|
1531
1557
|
const rejectFrameChannelForTest = isEnabledValue(env.LIVEDESK_TEST_MODE, false)
|
|
1532
1558
|
&& isEnabledValue(env.LIVEDESK_TEST_REJECT_FRAME_CHANNEL, false);
|
|
1533
1559
|
const logEvent = options.logEvent || (() => {});
|
|
@@ -1631,7 +1657,9 @@ export function createRemoteHub(options = {}) {
|
|
|
1631
1657
|
const fileSockets = new Map();
|
|
1632
1658
|
const allSockets = new Set();
|
|
1633
1659
|
const pendingCommandResultWaiters = new Map();
|
|
1634
|
-
const
|
|
1660
|
+
const transportDiagnostics = new Map();
|
|
1661
|
+
const transportDiagnosticStartingDevices = new Set();
|
|
1662
|
+
const duplicateDeviceLogAt = new Map();
|
|
1635
1663
|
let server = null;
|
|
1636
1664
|
let started = false;
|
|
1637
1665
|
let boundPort = requestedPort;
|
|
@@ -2266,6 +2294,700 @@ export function createRemoteHub(options = {}) {
|
|
|
2266
2294
|
});
|
|
2267
2295
|
}
|
|
2268
2296
|
|
|
2297
|
+
function inspectAnnexBH264(payload) {
|
|
2298
|
+
if (!Buffer.isBuffer(payload) || payload.length < 5) {
|
|
2299
|
+
return {
|
|
2300
|
+
annexB: false,
|
|
2301
|
+
hasIdr: false,
|
|
2302
|
+
hasSps: false,
|
|
2303
|
+
hasPps: false,
|
|
2304
|
+
nalOrderValid: false
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
let annexB = false;
|
|
2308
|
+
let hasIdr = false;
|
|
2309
|
+
let hasSps = false;
|
|
2310
|
+
let hasPps = false;
|
|
2311
|
+
let firstSpsAt = -1;
|
|
2312
|
+
let firstPpsAfterSpsAt = -1;
|
|
2313
|
+
let firstIdrAfterPpsAt = -1;
|
|
2314
|
+
for (let index = 0; index + 4 < payload.length; index += 1) {
|
|
2315
|
+
let headerIndex = -1;
|
|
2316
|
+
if (payload[index] === 0 && payload[index + 1] === 0 && payload[index + 2] === 1) {
|
|
2317
|
+
headerIndex = index + 3;
|
|
2318
|
+
} else if (payload[index] === 0
|
|
2319
|
+
&& payload[index + 1] === 0
|
|
2320
|
+
&& payload[index + 2] === 0
|
|
2321
|
+
&& payload[index + 3] === 1) {
|
|
2322
|
+
headerIndex = index + 4;
|
|
2323
|
+
}
|
|
2324
|
+
if (headerIndex < 0 || headerIndex >= payload.length) continue;
|
|
2325
|
+
annexB = true;
|
|
2326
|
+
const nalType = payload[headerIndex] & 0x1f;
|
|
2327
|
+
if (nalType === 5) {
|
|
2328
|
+
hasIdr = true;
|
|
2329
|
+
if (firstPpsAfterSpsAt >= 0 && firstIdrAfterPpsAt < 0) {
|
|
2330
|
+
firstIdrAfterPpsAt = headerIndex;
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
if (nalType === 7) {
|
|
2334
|
+
hasSps = true;
|
|
2335
|
+
if (firstSpsAt < 0) firstSpsAt = headerIndex;
|
|
2336
|
+
}
|
|
2337
|
+
if (nalType === 8) {
|
|
2338
|
+
hasPps = true;
|
|
2339
|
+
if (firstSpsAt >= 0 && firstPpsAfterSpsAt < 0) {
|
|
2340
|
+
firstPpsAfterSpsAt = headerIndex;
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
index = headerIndex;
|
|
2344
|
+
}
|
|
2345
|
+
return {
|
|
2346
|
+
annexB,
|
|
2347
|
+
hasIdr,
|
|
2348
|
+
hasSps,
|
|
2349
|
+
hasPps,
|
|
2350
|
+
nalOrderValid: firstSpsAt >= 0
|
|
2351
|
+
&& firstPpsAfterSpsAt > firstSpsAt
|
|
2352
|
+
&& firstIdrAfterPpsAt > firstPpsAfterSpsAt
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
function expectedTransportForDiagnosticPhase(phase) {
|
|
2357
|
+
if (phase === 'p2p') return 'p2p';
|
|
2358
|
+
if (phase === 'relay') return 'relay';
|
|
2359
|
+
return 'direct';
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function observedTransportPath(frame) {
|
|
2363
|
+
const transport = safeString(frame?.transport, 32).toLowerCase();
|
|
2364
|
+
if (transport === 'udp-p2p') return 'p2p';
|
|
2365
|
+
if (transport === 'relay-binary') return 'relay';
|
|
2366
|
+
if (transport === 'binary' || transport === 'ws-binary') return 'direct';
|
|
2367
|
+
return '';
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function optionalFiniteNumber(value) {
|
|
2371
|
+
return value !== null && value !== undefined && value !== ''
|
|
2372
|
+
&& Number.isFinite(Number(value))
|
|
2373
|
+
? Number(value)
|
|
2374
|
+
: null;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function transportDiagnosticIdentifierHash(value, salt) {
|
|
2378
|
+
const normalized = safeString(value, 240);
|
|
2379
|
+
return normalized
|
|
2380
|
+
? crypto.createHash('sha256')
|
|
2381
|
+
.update(`${safeString(salt, 160)}:${normalized}`)
|
|
2382
|
+
.digest('hex')
|
|
2383
|
+
.slice(0, 16)
|
|
2384
|
+
: '';
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
function currentTransportDiagnosticOwner(device) {
|
|
2388
|
+
const frame = device?.latestLiveFrame;
|
|
2389
|
+
if (!device?.connected
|
|
2390
|
+
|| !device.sessionId
|
|
2391
|
+
|| !frame
|
|
2392
|
+
|| frame.currentGenerationVerified !== true
|
|
2393
|
+
|| safeString(frame.mimeType, 80).toLowerCase() !== 'video/h264'
|
|
2394
|
+
|| !safeString(frame.streamId, 128)
|
|
2395
|
+
|| !safeString(frame.commandId, 128)
|
|
2396
|
+
|| Number(frame.captureGeneration || 0) <= 0) {
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2399
|
+
const monitorIndex = parseExactLiveStreamMonitorIndex(frame.monitorIndex);
|
|
2400
|
+
const streamState = getDeviceLiveStream(device, frame.streamId);
|
|
2401
|
+
if (monitorIndex === null
|
|
2402
|
+
|| !streamState?.active
|
|
2403
|
+
|| safeString(streamState.commandId, 128) !== safeString(frame.commandId, 128)
|
|
2404
|
+
|| Number(streamState.captureGeneration || 0) !== Number(frame.captureGeneration || 0)
|
|
2405
|
+
|| parseExactLiveStreamMonitorIndex(streamState.monitorIndex) !== monitorIndex) {
|
|
2406
|
+
return null;
|
|
2407
|
+
}
|
|
2408
|
+
return {
|
|
2409
|
+
deviceId: device.deviceId,
|
|
2410
|
+
sessionId: device.sessionId,
|
|
2411
|
+
streamId: safeString(frame.streamId, 128),
|
|
2412
|
+
streamCommandId: safeString(frame.commandId, 128),
|
|
2413
|
+
captureGeneration: Number(frame.captureGeneration),
|
|
2414
|
+
monitorIndex
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function transportDiagnosticOwnersEqual(left, right) {
|
|
2419
|
+
return !!left
|
|
2420
|
+
&& !!right
|
|
2421
|
+
&& left.deviceId === right.deviceId
|
|
2422
|
+
&& left.sessionId === right.sessionId
|
|
2423
|
+
&& left.streamId === right.streamId
|
|
2424
|
+
&& left.streamCommandId === right.streamCommandId
|
|
2425
|
+
&& left.captureGeneration === right.captureGeneration
|
|
2426
|
+
&& left.monitorIndex === right.monitorIndex;
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function transportDiagnosticOwnerMatches(record, device) {
|
|
2430
|
+
const current = currentTransportDiagnosticOwner(device);
|
|
2431
|
+
return transportDiagnosticOwnersEqual(current, record.owner);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
function serializeTransportDiagnostic(record) {
|
|
2435
|
+
const refreshedNetwork = udpTransport?.getDeviceDiagnosticNetwork?.(record.owner.deviceId, {
|
|
2436
|
+
salt: record.leaseId
|
|
2437
|
+
});
|
|
2438
|
+
if (refreshedNetwork?.evidence
|
|
2439
|
+
=== 'authenticated-udp-endpoint-rendezvous-observed'
|
|
2440
|
+
|| record.network?.evidence
|
|
2441
|
+
!== 'authenticated-udp-endpoint-rendezvous-observed') {
|
|
2442
|
+
record.network = refreshedNetwork || record.network;
|
|
2443
|
+
}
|
|
2444
|
+
return {
|
|
2445
|
+
ok: record.state !== 'failed',
|
|
2446
|
+
leaseId: record.leaseId,
|
|
2447
|
+
state: record.state,
|
|
2448
|
+
phase: record.phase,
|
|
2449
|
+
startedAt: record.startedAt,
|
|
2450
|
+
updatedAt: record.updatedAt,
|
|
2451
|
+
expiresAt: record.expiresAt,
|
|
2452
|
+
completedAt: record.completedAt || '',
|
|
2453
|
+
error: safeString(record.error, 240),
|
|
2454
|
+
owner: {
|
|
2455
|
+
deviceId: record.owner.deviceId,
|
|
2456
|
+
streamId: record.owner.streamId,
|
|
2457
|
+
streamCommandId: record.owner.streamCommandId,
|
|
2458
|
+
captureGeneration: record.owner.captureGeneration,
|
|
2459
|
+
monitorIndex: record.owner.monitorIndex
|
|
2460
|
+
},
|
|
2461
|
+
network: record.network,
|
|
2462
|
+
baseline: { ...record.baseline },
|
|
2463
|
+
frames: record.frames.map(frame => ({ ...frame })),
|
|
2464
|
+
cleanup: record.cleanup.map(entry => ({ ...entry }))
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
function getTransportDiagnostic(leaseId, token) {
|
|
2469
|
+
const record = transportDiagnostics.get(safeString(leaseId, 128));
|
|
2470
|
+
if (!record || !diagnosticSecretEquals(record.token, token)) {
|
|
2471
|
+
return { ok: false, error: 'transport-diagnostic-not-found' };
|
|
2472
|
+
}
|
|
2473
|
+
return serializeTransportDiagnostic(record);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
function diagnosticSecretEquals(expected, provided) {
|
|
2477
|
+
const expectedHash = crypto.createHash('sha256').update(String(expected || '')).digest();
|
|
2478
|
+
const providedHash = crypto.createHash('sha256').update(String(provided || '')).digest();
|
|
2479
|
+
return crypto.timingSafeEqual(expectedHash, providedHash);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
async function sendTransportDiagnosticCommand(record, operation, phase = record.phase) {
|
|
2483
|
+
const device = devices.get(record.owner.deviceId);
|
|
2484
|
+
if (!device?.connected || device.sessionId !== record.owner.sessionId) {
|
|
2485
|
+
return { ok: false, error: 'transport-diagnostic-device-disconnected' };
|
|
2486
|
+
}
|
|
2487
|
+
if (operation !== 'release' && !transportDiagnosticOwnerMatches(record, device)) {
|
|
2488
|
+
return { ok: false, error: 'transport-diagnostic-owner-changed' };
|
|
2489
|
+
}
|
|
2490
|
+
const commandId = crypto.randomUUID();
|
|
2491
|
+
const waiter = waitForCommandResult(device, commandId, transportDiagnosticCommandTimeoutMs);
|
|
2492
|
+
const queued = sendCommand(device.deviceId, {
|
|
2493
|
+
commandId,
|
|
2494
|
+
command: 'transport.diagnostic',
|
|
2495
|
+
payload: {
|
|
2496
|
+
operation,
|
|
2497
|
+
leaseId: record.leaseId,
|
|
2498
|
+
token: record.token,
|
|
2499
|
+
streamId: record.owner.streamId,
|
|
2500
|
+
streamCommandId: record.owner.streamCommandId,
|
|
2501
|
+
captureGeneration: record.owner.captureGeneration,
|
|
2502
|
+
monitorIndex: record.owner.monitorIndex,
|
|
2503
|
+
phase,
|
|
2504
|
+
ttlMs: Math.max(0, Date.parse(record.expiresAt) - Date.now())
|
|
2505
|
+
}
|
|
2506
|
+
});
|
|
2507
|
+
if (!queued.ok) {
|
|
2508
|
+
failPendingCommandResultWaiter(commandId, queued.error);
|
|
2509
|
+
}
|
|
2510
|
+
const outcome = await waiter;
|
|
2511
|
+
if (!outcome.ok || outcome.result?.ok === false) {
|
|
2512
|
+
return {
|
|
2513
|
+
ok: false,
|
|
2514
|
+
error: safeString(outcome.error || outcome.result?.error, 240)
|
|
2515
|
+
|| 'transport-diagnostic-agent-rejected'
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
return { ok: true };
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function appendTransportDiagnosticCleanup(record, state, reason = '') {
|
|
2522
|
+
record.cleanup.push({
|
|
2523
|
+
at: new Date().toISOString(),
|
|
2524
|
+
state,
|
|
2525
|
+
reason: safeString(reason, 160)
|
|
2526
|
+
});
|
|
2527
|
+
if (record.cleanup.length > 16) record.cleanup.splice(0, record.cleanup.length - 16);
|
|
2528
|
+
record.updatedAt = new Date().toISOString();
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function clearTransportDiagnosticTimers(record) {
|
|
2532
|
+
clearTimeout(record?.expiryTimer);
|
|
2533
|
+
clearTimeout(record?.removalTimer);
|
|
2534
|
+
if (record) {
|
|
2535
|
+
record.expiryTimer = null;
|
|
2536
|
+
record.removalTimer = null;
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
function removeTransportDiagnostic(record) {
|
|
2541
|
+
if (!record) return;
|
|
2542
|
+
clearTransportDiagnosticTimers(record);
|
|
2543
|
+
if (transportDiagnostics.get(record.leaseId) === record) {
|
|
2544
|
+
transportDiagnostics.delete(record.leaseId);
|
|
2545
|
+
}
|
|
2546
|
+
record.token = '';
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function scheduleTransportDiagnosticRemoval(record) {
|
|
2550
|
+
if (!record) return;
|
|
2551
|
+
clearTimeout(record.expiryTimer);
|
|
2552
|
+
clearTimeout(record.removalTimer);
|
|
2553
|
+
record.expiryTimer = null;
|
|
2554
|
+
record.removalTimer = setTimeout(() => {
|
|
2555
|
+
removeTransportDiagnostic(record);
|
|
2556
|
+
}, TRANSPORT_DIAGNOSTIC_RECORD_RETENTION_MS);
|
|
2557
|
+
record.removalTimer.unref?.();
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
function pruneTransportDiagnosticRecords() {
|
|
2561
|
+
const finalized = [...transportDiagnostics.values()]
|
|
2562
|
+
.filter(record => record.state !== 'active'
|
|
2563
|
+
&& record.state !== 'acquiring'
|
|
2564
|
+
&& record.state !== 'expiring')
|
|
2565
|
+
.sort((left, right) => Date.parse(left.updatedAt || '') - Date.parse(right.updatedAt || ''));
|
|
2566
|
+
while (transportDiagnostics.size >= TRANSPORT_DIAGNOSTIC_MAX_RECORDS && finalized.length > 0) {
|
|
2567
|
+
removeTransportDiagnostic(finalized.shift());
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
async function expireTransportDiagnostic(record, reason = 'ttl-expired') {
|
|
2572
|
+
if (!record || record.state !== 'active') return;
|
|
2573
|
+
if (record.operationInFlight === true) {
|
|
2574
|
+
record.expiryTimer = setTimeout(() => {
|
|
2575
|
+
void expireTransportDiagnostic(record, reason);
|
|
2576
|
+
}, 250);
|
|
2577
|
+
record.expiryTimer.unref?.();
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
record.operationInFlight = true;
|
|
2581
|
+
record.state = 'expiring';
|
|
2582
|
+
appendTransportDiagnosticCleanup(record, 'release-requested', reason);
|
|
2583
|
+
try {
|
|
2584
|
+
const released = await sendTransportDiagnosticCommand(record, 'release');
|
|
2585
|
+
record.state = reason === 'ttl-expired' ? 'expired' : 'aborted';
|
|
2586
|
+
record.error = released.ok ? '' : released.error;
|
|
2587
|
+
record.completedAt = new Date().toISOString();
|
|
2588
|
+
appendTransportDiagnosticCleanup(
|
|
2589
|
+
record,
|
|
2590
|
+
released.ok ? 'agent-released' : 'agent-release-unconfirmed',
|
|
2591
|
+
released.error);
|
|
2592
|
+
} finally {
|
|
2593
|
+
record.operationInFlight = false;
|
|
2594
|
+
scheduleTransportDiagnosticRemoval(record);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function abortTransportDiagnosticsForDevice(device, reason) {
|
|
2599
|
+
for (const record of transportDiagnostics.values()) {
|
|
2600
|
+
if (!['active', 'acquiring', 'expiring'].includes(record.state)
|
|
2601
|
+
|| record.owner.deviceId !== device?.deviceId
|
|
2602
|
+
|| record.owner.sessionId !== device?.sessionId) {
|
|
2603
|
+
continue;
|
|
2604
|
+
}
|
|
2605
|
+
clearTimeout(record.expiryTimer);
|
|
2606
|
+
record.state = 'aborted';
|
|
2607
|
+
record.error = safeString(reason, 160) || 'device-disconnected';
|
|
2608
|
+
record.completedAt = new Date().toISOString();
|
|
2609
|
+
appendTransportDiagnosticCleanup(record, 'connection-closed', record.error);
|
|
2610
|
+
scheduleTransportDiagnosticRemoval(record);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
async function startTransportDiagnostic(deviceId, ttlMs = 150_000, options = {}) {
|
|
2615
|
+
const requestSignal = options?.signal;
|
|
2616
|
+
const requestAborted = () => requestSignal?.aborted === true;
|
|
2617
|
+
const normalizedDeviceId = safeString(deviceId, 160);
|
|
2618
|
+
const device = devices.get(normalizedDeviceId);
|
|
2619
|
+
const owner = currentTransportDiagnosticOwner(device);
|
|
2620
|
+
if (!owner) {
|
|
2621
|
+
return { ok: false, error: 'transport-diagnostic-current-h264-stream-required' };
|
|
2622
|
+
}
|
|
2623
|
+
if (observedTransportPath(device.latestLiveFrame) !== 'direct'
|
|
2624
|
+
|| safeString(device.latestLiveFrame?.frameTransportActive, 20).toLowerCase() !== 'direct') {
|
|
2625
|
+
return { ok: false, error: 'transport-diagnostic-direct-start-required' };
|
|
2626
|
+
}
|
|
2627
|
+
if (transportDiagnosticStartingDevices.has(normalizedDeviceId)) {
|
|
2628
|
+
return { ok: false, error: 'transport-diagnostic-device-lease-active' };
|
|
2629
|
+
}
|
|
2630
|
+
const existingLease = [...transportDiagnostics.values()].some(existing =>
|
|
2631
|
+
['active', 'acquiring', 'expiring'].includes(existing.state)
|
|
2632
|
+
&& existing.owner.deviceId === owner.deviceId);
|
|
2633
|
+
if (existingLease) {
|
|
2634
|
+
return { ok: false, error: 'transport-diagnostic-device-lease-active' };
|
|
2635
|
+
}
|
|
2636
|
+
transportDiagnosticStartingDevices.add(normalizedDeviceId);
|
|
2637
|
+
try {
|
|
2638
|
+
const refreshedDiagnosticNetwork =
|
|
2639
|
+
await udpTransport?.refreshDeviceDiagnosticNetwork?.(
|
|
2640
|
+
owner.deviceId,
|
|
2641
|
+
{ timeoutMs: 3_000 });
|
|
2642
|
+
if (requestAborted()) {
|
|
2643
|
+
return { ok: false, error: 'transport-diagnostic-request-aborted' };
|
|
2644
|
+
}
|
|
2645
|
+
const physicalNetwork = udpTransport?.getDeviceDiagnosticNetwork?.(
|
|
2646
|
+
owner.deviceId,
|
|
2647
|
+
{ salt: 'transport-diagnostic-preflight' }) || {};
|
|
2648
|
+
if (refreshedDiagnosticNetwork !== true
|
|
2649
|
+
|| physicalNetwork.evidence
|
|
2650
|
+
!== 'authenticated-udp-endpoint-rendezvous-observed'
|
|
2651
|
+
|| physicalNetwork.endpointAuthenticated !== true
|
|
2652
|
+
|| physicalNetwork.clientAddressPublic !== true
|
|
2653
|
+
|| physicalNetwork.hubAddressPublic !== true) {
|
|
2654
|
+
return {
|
|
2655
|
+
ok: false,
|
|
2656
|
+
error: 'transport-diagnostic-fresh-rendezvous-egress-required'
|
|
2657
|
+
};
|
|
2658
|
+
}
|
|
2659
|
+
if (physicalNetwork.differentPublicEgress !== true) {
|
|
2660
|
+
return {
|
|
2661
|
+
ok: false,
|
|
2662
|
+
error: 'transport-diagnostic-distinct-public-egress-required'
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
const currentDevice = devices.get(normalizedDeviceId);
|
|
2666
|
+
const refreshedOwner = currentTransportDiagnosticOwner(currentDevice);
|
|
2667
|
+
if (!transportDiagnosticOwnersEqual(owner, refreshedOwner)
|
|
2668
|
+
|| observedTransportPath(currentDevice?.latestLiveFrame) !== 'direct'
|
|
2669
|
+
|| safeString(currentDevice?.latestLiveFrame?.frameTransportActive, 20).toLowerCase() !== 'direct') {
|
|
2670
|
+
return { ok: false, error: 'transport-diagnostic-owner-changed' };
|
|
2671
|
+
}
|
|
2672
|
+
const concurrentLease = [...transportDiagnostics.values()].some(existing =>
|
|
2673
|
+
['active', 'acquiring', 'expiring'].includes(existing.state)
|
|
2674
|
+
&& existing.owner.deviceId === owner.deviceId);
|
|
2675
|
+
if (concurrentLease) {
|
|
2676
|
+
return { ok: false, error: 'transport-diagnostic-device-lease-active' };
|
|
2677
|
+
}
|
|
2678
|
+
pruneTransportDiagnosticRecords();
|
|
2679
|
+
if (transportDiagnostics.size >= TRANSPORT_DIAGNOSTIC_MAX_RECORDS) {
|
|
2680
|
+
return { ok: false, error: 'transport-diagnostic-capacity-reached' };
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
const boundedTtlMs = clampNumber(
|
|
2684
|
+
ttlMs,
|
|
2685
|
+
TRANSPORT_DIAGNOSTIC_MIN_TTL_MS,
|
|
2686
|
+
TRANSPORT_DIAGNOSTIC_MAX_TTL_MS,
|
|
2687
|
+
150_000);
|
|
2688
|
+
const now = new Date();
|
|
2689
|
+
const leaseId = crypto.randomUUID();
|
|
2690
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
2691
|
+
const currentFrame = currentDevice.latestLiveFrame;
|
|
2692
|
+
const udpStatus = udpTransport?.getDeviceStatus?.(owner.deviceId) || {};
|
|
2693
|
+
const hubAddressObservedAt = safeString(
|
|
2694
|
+
physicalNetwork.hubAddressObservedAt,
|
|
2695
|
+
80);
|
|
2696
|
+
const record = {
|
|
2697
|
+
leaseId,
|
|
2698
|
+
token,
|
|
2699
|
+
owner,
|
|
2700
|
+
phase: 'direct',
|
|
2701
|
+
phaseStartedAt: '',
|
|
2702
|
+
state: 'acquiring',
|
|
2703
|
+
operationInFlight: true,
|
|
2704
|
+
startedAt: now.toISOString(),
|
|
2705
|
+
updatedAt: now.toISOString(),
|
|
2706
|
+
expiresAt: new Date(now.getTime() + boundedTtlMs).toISOString(),
|
|
2707
|
+
completedAt: '',
|
|
2708
|
+
error: '',
|
|
2709
|
+
hubAddressObservedAt,
|
|
2710
|
+
baseline: {
|
|
2711
|
+
observedAt: safeString(currentFrame.receivedAt, 80),
|
|
2712
|
+
frameSeq: optionalFiniteNumber(currentFrame.frameSeq),
|
|
2713
|
+
switchCount: optionalFiniteNumber(currentFrame.frameTransportSwitchCount),
|
|
2714
|
+
agentQueueDropped: optionalFiniteNumber(currentFrame.nativeOutputQueueDroppedCount),
|
|
2715
|
+
hubIngressDropped: optionalFiniteNumber(currentFrame.hubIngressDropped),
|
|
2716
|
+
udpIncompleteFrames: optionalFiniteNumber(udpStatus.droppedFrames),
|
|
2717
|
+
hubIngressCounterEpochHash: transportDiagnosticIdentifierHash(
|
|
2718
|
+
currentFrame.hubIngressCounterEpoch,
|
|
2719
|
+
leaseId),
|
|
2720
|
+
udpCounterEpochHash: transportDiagnosticIdentifierHash(
|
|
2721
|
+
udpStatus.sessionId,
|
|
2722
|
+
leaseId),
|
|
2723
|
+
agentQueueTelemetryAvailable:
|
|
2724
|
+
currentFrame.nativeOutputQueueTelemetryAvailable === true,
|
|
2725
|
+
hubIngressTelemetryAvailable:
|
|
2726
|
+
currentFrame.hubIngressTelemetryAvailable === true
|
|
2727
|
+
&& !!currentFrame.hubIngressCounterEpoch,
|
|
2728
|
+
udpTelemetryAvailable:
|
|
2729
|
+
optionalFiniteNumber(udpStatus.droppedFrames) !== null
|
|
2730
|
+
&& !!udpStatus.sessionId
|
|
2731
|
+
},
|
|
2732
|
+
frames: [],
|
|
2733
|
+
cleanup: [],
|
|
2734
|
+
network: udpTransport?.getDeviceDiagnosticNetwork?.(owner.deviceId, {
|
|
2735
|
+
salt: leaseId
|
|
2736
|
+
}) || {
|
|
2737
|
+
evidence: 'unavailable',
|
|
2738
|
+
differentPublicEgress: null,
|
|
2739
|
+
hubAddressObservedAt
|
|
2740
|
+
},
|
|
2741
|
+
expiryTimer: null,
|
|
2742
|
+
removalTimer: null
|
|
2743
|
+
};
|
|
2744
|
+
transportDiagnostics.set(leaseId, record);
|
|
2745
|
+
const acquired = await sendTransportDiagnosticCommand(record, 'acquire', 'direct');
|
|
2746
|
+
record.operationInFlight = false;
|
|
2747
|
+
if (!acquired.ok || record.state !== 'acquiring') {
|
|
2748
|
+
const acquireError = acquired.ok
|
|
2749
|
+
? safeString(record.error, 240) || 'transport-diagnostic-acquire-interrupted'
|
|
2750
|
+
: acquired.error;
|
|
2751
|
+
record.error = acquireError;
|
|
2752
|
+
record.completedAt = new Date().toISOString();
|
|
2753
|
+
appendTransportDiagnosticCleanup(record, 'acquire-failed', record.error);
|
|
2754
|
+
appendTransportDiagnosticCleanup(
|
|
2755
|
+
record,
|
|
2756
|
+
'release-requested',
|
|
2757
|
+
'acquire-outcome-uncertain');
|
|
2758
|
+
record.operationInFlight = true;
|
|
2759
|
+
let released;
|
|
2760
|
+
try {
|
|
2761
|
+
released = await sendTransportDiagnosticCommand(record, 'release');
|
|
2762
|
+
} catch (error) {
|
|
2763
|
+
released = {
|
|
2764
|
+
ok: false,
|
|
2765
|
+
error: safeString(error instanceof Error ? error.message : error, 240)
|
|
2766
|
+
|| 'transport-diagnostic-agent-release-failed'
|
|
2767
|
+
};
|
|
2768
|
+
} finally {
|
|
2769
|
+
record.operationInFlight = false;
|
|
2770
|
+
}
|
|
2771
|
+
appendTransportDiagnosticCleanup(
|
|
2772
|
+
record,
|
|
2773
|
+
released.ok ? 'agent-released' : 'agent-release-unconfirmed',
|
|
2774
|
+
released.error);
|
|
2775
|
+
record.state = 'failed';
|
|
2776
|
+
record.completedAt = new Date().toISOString();
|
|
2777
|
+
const failed = { ...serializeTransportDiagnostic(record), token: '' };
|
|
2778
|
+
removeTransportDiagnostic(record);
|
|
2779
|
+
return failed;
|
|
2780
|
+
}
|
|
2781
|
+
record.state = 'active';
|
|
2782
|
+
record.phaseStartedAt = new Date().toISOString();
|
|
2783
|
+
record.updatedAt = record.phaseStartedAt;
|
|
2784
|
+
appendTransportDiagnosticCleanup(record, 'lease-acquired');
|
|
2785
|
+
if (requestAborted()) {
|
|
2786
|
+
await expireTransportDiagnostic(record, 'start-request-aborted');
|
|
2787
|
+
return { ok: false, error: 'transport-diagnostic-request-aborted' };
|
|
2788
|
+
}
|
|
2789
|
+
const remainingTtlMs = Math.max(1, Date.parse(record.expiresAt) - Date.now());
|
|
2790
|
+
record.expiryTimer = setTimeout(() => {
|
|
2791
|
+
void expireTransportDiagnostic(record, 'ttl-expired');
|
|
2792
|
+
}, remainingTtlMs);
|
|
2793
|
+
record.expiryTimer.unref?.();
|
|
2794
|
+
return { ...serializeTransportDiagnostic(record), token };
|
|
2795
|
+
} finally {
|
|
2796
|
+
transportDiagnosticStartingDevices.delete(normalizedDeviceId);
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
async function setTransportDiagnosticPhase(leaseId, token, phase) {
|
|
2801
|
+
const record = transportDiagnostics.get(safeString(leaseId, 128));
|
|
2802
|
+
const normalizedPhase = safeString(phase, 32).toLowerCase();
|
|
2803
|
+
if (!record || !diagnosticSecretEquals(record.token, token)) {
|
|
2804
|
+
return { ok: false, error: 'transport-diagnostic-not-found' };
|
|
2805
|
+
}
|
|
2806
|
+
if (record.state !== 'active') {
|
|
2807
|
+
return { ok: false, error: 'transport-diagnostic-not-active' };
|
|
2808
|
+
}
|
|
2809
|
+
if (record.operationInFlight === true) {
|
|
2810
|
+
return { ok: false, error: 'transport-diagnostic-operation-in-flight' };
|
|
2811
|
+
}
|
|
2812
|
+
if (!TRANSPORT_DIAGNOSTIC_PHASES.includes(normalizedPhase)) {
|
|
2813
|
+
return { ok: false, error: 'transport-diagnostic-phase-invalid' };
|
|
2814
|
+
}
|
|
2815
|
+
const currentIndex = TRANSPORT_DIAGNOSTIC_PHASES.indexOf(record.phase);
|
|
2816
|
+
const nextIndex = TRANSPORT_DIAGNOSTIC_PHASES.indexOf(normalizedPhase);
|
|
2817
|
+
const currentObserved = record.frames.some(frame => frame.phase === record.phase && frame.accepted === true);
|
|
2818
|
+
if (nextIndex !== currentIndex + 1 || !currentObserved) {
|
|
2819
|
+
return { ok: false, error: 'transport-diagnostic-phase-order-required' };
|
|
2820
|
+
}
|
|
2821
|
+
record.operationInFlight = true;
|
|
2822
|
+
try {
|
|
2823
|
+
const updated = await sendTransportDiagnosticCommand(record, 'set-phase', normalizedPhase);
|
|
2824
|
+
if (!updated.ok) {
|
|
2825
|
+
record.error = updated.error;
|
|
2826
|
+
record.updatedAt = new Date().toISOString();
|
|
2827
|
+
return { ok: false, error: updated.error };
|
|
2828
|
+
}
|
|
2829
|
+
if (record.state !== 'active') {
|
|
2830
|
+
return { ok: false, error: 'transport-diagnostic-interrupted' };
|
|
2831
|
+
}
|
|
2832
|
+
record.phase = normalizedPhase;
|
|
2833
|
+
record.phaseStartedAt = new Date().toISOString();
|
|
2834
|
+
record.updatedAt = record.phaseStartedAt;
|
|
2835
|
+
return serializeTransportDiagnostic(record);
|
|
2836
|
+
} finally {
|
|
2837
|
+
record.operationInFlight = false;
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
async function stopTransportDiagnostic(leaseId, token, reason = 'runner-complete') {
|
|
2842
|
+
const record = transportDiagnostics.get(safeString(leaseId, 128));
|
|
2843
|
+
if (!record || !diagnosticSecretEquals(record.token, token)) {
|
|
2844
|
+
return { ok: false, error: 'transport-diagnostic-not-found' };
|
|
2845
|
+
}
|
|
2846
|
+
if (record.state !== 'active') return serializeTransportDiagnostic(record);
|
|
2847
|
+
if (record.operationInFlight === true) {
|
|
2848
|
+
return { ok: false, error: 'transport-diagnostic-operation-in-flight' };
|
|
2849
|
+
}
|
|
2850
|
+
clearTimeout(record.expiryTimer);
|
|
2851
|
+
record.operationInFlight = true;
|
|
2852
|
+
try {
|
|
2853
|
+
appendTransportDiagnosticCleanup(record, 'release-requested', reason);
|
|
2854
|
+
const released = await sendTransportDiagnosticCommand(record, 'release');
|
|
2855
|
+
record.state = released.ok ? 'completed' : 'failed';
|
|
2856
|
+
record.error = released.ok ? '' : released.error;
|
|
2857
|
+
record.completedAt = new Date().toISOString();
|
|
2858
|
+
appendTransportDiagnosticCleanup(
|
|
2859
|
+
record,
|
|
2860
|
+
released.ok ? 'agent-released' : 'agent-release-unconfirmed',
|
|
2861
|
+
released.error);
|
|
2862
|
+
const serialized = serializeTransportDiagnostic(record);
|
|
2863
|
+
scheduleTransportDiagnosticRemoval(record);
|
|
2864
|
+
return serialized;
|
|
2865
|
+
} finally {
|
|
2866
|
+
record.operationInFlight = false;
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
function recordTransportDiagnosticFrame(device, message, payload, frame) {
|
|
2871
|
+
if (transportDiagnostics.size === 0 || message.transportDiagnosticApplied !== true) {
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
const leaseId = safeString(message.transportDiagnosticLeaseId, 128);
|
|
2875
|
+
const phase = safeString(message.transportDiagnosticPhase, 32).toLowerCase();
|
|
2876
|
+
if (!leaseId) return;
|
|
2877
|
+
const record = transportDiagnostics.get(leaseId);
|
|
2878
|
+
if (!record
|
|
2879
|
+
|| record.state !== 'active'
|
|
2880
|
+
|| record.phase !== phase
|
|
2881
|
+
|| !transportDiagnosticOwnerMatches(record, device)
|
|
2882
|
+
|| frame.streamId !== record.owner.streamId
|
|
2883
|
+
|| frame.commandId !== record.owner.streamCommandId
|
|
2884
|
+
|| frame.captureGeneration !== record.owner.captureGeneration
|
|
2885
|
+
|| frame.monitorIndex !== record.owner.monitorIndex
|
|
2886
|
+
|| safeString(frame.mimeType, 80).toLowerCase() !== 'video/h264') {
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
const expectedPath = expectedTransportForDiagnosticPhase(phase);
|
|
2890
|
+
const h264 = inspectAnnexBH264(payload);
|
|
2891
|
+
const agentPath = safeString(frame.frameTransportActive, 20).toLowerCase();
|
|
2892
|
+
const hubObservedPath = observedTransportPath(frame);
|
|
2893
|
+
const frameSeq = optionalFiniteNumber(frame.frameSeq);
|
|
2894
|
+
const switchCount = optionalFiniteNumber(message.frameTransportSwitchCount);
|
|
2895
|
+
const lastAcceptedFrame = [...record.frames]
|
|
2896
|
+
.reverse()
|
|
2897
|
+
.find(item => item.accepted === true);
|
|
2898
|
+
const previousFrameSeq = optionalFiniteNumber(
|
|
2899
|
+
lastAcceptedFrame?.frameSeq ?? record.baseline?.frameSeq);
|
|
2900
|
+
const previousSwitchCount = optionalFiniteNumber(
|
|
2901
|
+
lastAcceptedFrame?.switchCount ?? record.baseline?.switchCount);
|
|
2902
|
+
const sequenceAdvanced = frameSeq !== null
|
|
2903
|
+
&& (previousFrameSeq === null || frameSeq > previousFrameSeq);
|
|
2904
|
+
const switchAdvanced = switchCount !== null
|
|
2905
|
+
&& (lastAcceptedFrame
|
|
2906
|
+
? previousSwitchCount !== null && switchCount > previousSwitchCount
|
|
2907
|
+
: previousSwitchCount === null || switchCount >= previousSwitchCount);
|
|
2908
|
+
const agentQueueDepth = optionalFiniteNumber(message.nativeOutputQueueDepth);
|
|
2909
|
+
const agentQueueBytes = optionalFiniteNumber(message.nativeOutputQueueBytes);
|
|
2910
|
+
const agentQueueDropped = optionalFiniteNumber(message.nativeOutputQueueDroppedCount);
|
|
2911
|
+
const hubIngressDropped = optionalFiniteNumber(message.hubIngressDropped);
|
|
2912
|
+
const udpIncompleteFrames = optionalFiniteNumber(message.udpIncompleteFrames);
|
|
2913
|
+
const hubIngressCounterEpochHash = transportDiagnosticIdentifierHash(
|
|
2914
|
+
message.hubIngressCounterEpoch,
|
|
2915
|
+
record.leaseId);
|
|
2916
|
+
const agentQueueTelemetryAvailable = agentQueueDepth !== null
|
|
2917
|
+
&& agentQueueBytes !== null
|
|
2918
|
+
&& agentQueueDropped !== null;
|
|
2919
|
+
const hubIngressTelemetryAvailable = hubIngressDropped !== null
|
|
2920
|
+
&& !!hubIngressCounterEpochHash;
|
|
2921
|
+
const udpTelemetryAvailable = phase !== 'p2p' || udpIncompleteFrames !== null;
|
|
2922
|
+
const phaseLatencyMs = Math.max(
|
|
2923
|
+
0,
|
|
2924
|
+
Date.parse(frame.receivedAt || '') - Date.parse(record.phaseStartedAt || ''));
|
|
2925
|
+
const accepted = agentPath === expectedPath
|
|
2926
|
+
&& hubObservedPath === expectedPath
|
|
2927
|
+
&& frame.isKeyFrame === true
|
|
2928
|
+
&& h264.annexB
|
|
2929
|
+
&& h264.hasIdr
|
|
2930
|
+
&& h264.hasSps
|
|
2931
|
+
&& h264.hasPps
|
|
2932
|
+
&& h264.nalOrderValid
|
|
2933
|
+
&& sequenceAdvanced
|
|
2934
|
+
&& switchAdvanced
|
|
2935
|
+
&& agentQueueTelemetryAvailable
|
|
2936
|
+
&& hubIngressTelemetryAvailable
|
|
2937
|
+
&& udpTelemetryAvailable
|
|
2938
|
+
&& Number.isFinite(phaseLatencyMs);
|
|
2939
|
+
if (record.frames.some(item => item.phase === phase && item.accepted === true)) return;
|
|
2940
|
+
const observation = {
|
|
2941
|
+
phase,
|
|
2942
|
+
expectedPath,
|
|
2943
|
+
actualPath: agentPath,
|
|
2944
|
+
hubObservedPath,
|
|
2945
|
+
protocol: safeString(frame.frameTransportProtocol, 20),
|
|
2946
|
+
transport: safeString(frame.transport, 32),
|
|
2947
|
+
reason: safeString(frame.frameTransportReason, 160),
|
|
2948
|
+
receivedAt: frame.receivedAt,
|
|
2949
|
+
frameSeq,
|
|
2950
|
+
byteLength: frame.byteLength,
|
|
2951
|
+
contentHash: safeString(frame.contentHash, 128),
|
|
2952
|
+
isKeyFrame: frame.isKeyFrame === true,
|
|
2953
|
+
annexB: h264.annexB,
|
|
2954
|
+
hasIdr: h264.hasIdr,
|
|
2955
|
+
hasSps: h264.hasSps,
|
|
2956
|
+
hasPps: h264.hasPps,
|
|
2957
|
+
nalOrderValid: h264.nalOrderValid,
|
|
2958
|
+
switchCount,
|
|
2959
|
+
tcpFailureCount: frame.frameTransportTcpFailureCount,
|
|
2960
|
+
phaseLatencyMs,
|
|
2961
|
+
sequenceAdvanced,
|
|
2962
|
+
switchAdvanced,
|
|
2963
|
+
agentQueueDepth,
|
|
2964
|
+
agentQueueBytes,
|
|
2965
|
+
agentQueueDropped,
|
|
2966
|
+
hubIngressDropped,
|
|
2967
|
+
udpIncompleteFrames,
|
|
2968
|
+
hubIngressCounterEpochHash,
|
|
2969
|
+
agentQueueTelemetryAvailable,
|
|
2970
|
+
hubIngressTelemetryAvailable,
|
|
2971
|
+
udpTelemetryAvailable,
|
|
2972
|
+
accepted
|
|
2973
|
+
};
|
|
2974
|
+
if (accepted) {
|
|
2975
|
+
record.frames = record.frames.filter(item => item.phase !== phase);
|
|
2976
|
+
record.frames.push(observation);
|
|
2977
|
+
} else {
|
|
2978
|
+
const rejectedIndex = record.frames.findIndex(item =>
|
|
2979
|
+
item.phase === phase && item.accepted !== true);
|
|
2980
|
+
if (rejectedIndex >= 0) record.frames[rejectedIndex] = observation;
|
|
2981
|
+
else record.frames.push(observation);
|
|
2982
|
+
}
|
|
2983
|
+
if (record.frames.length > TRANSPORT_DIAGNOSTIC_PHASES.length * 2) {
|
|
2984
|
+
record.frames = record.frames.filter(item => item.accepted === true)
|
|
2985
|
+
.concat(record.frames.filter(item => item.accepted !== true)
|
|
2986
|
+
.slice(-TRANSPORT_DIAGNOSTIC_PHASES.length));
|
|
2987
|
+
}
|
|
2988
|
+
record.updatedAt = new Date().toISOString();
|
|
2989
|
+
}
|
|
2990
|
+
|
|
2269
2991
|
function makeSyntheticFrame(device, streamId, mode = 'thumbnail', options = {}) {
|
|
2270
2992
|
const now = new Date().toISOString();
|
|
2271
2993
|
const descriptor = buildRemoteFrameTransferDescriptor(mode, mode === 'thumbnail' ? 'thumbnail' : DEFAULT_REMOTE_FRAME_MODE);
|
|
@@ -3135,9 +3857,10 @@ export function createRemoteHub(options = {}) {
|
|
|
3135
3857
|
return;
|
|
3136
3858
|
}
|
|
3137
3859
|
|
|
3138
|
-
device.connected = false;
|
|
3139
|
-
device.socket = null;
|
|
3140
|
-
|
|
3860
|
+
device.connected = false;
|
|
3861
|
+
device.socket = null;
|
|
3862
|
+
abortTransportDiagnosticsForDevice(device, reason);
|
|
3863
|
+
udpTransport?.unregisterDevice?.(deviceId, device.sessionId);
|
|
3141
3864
|
closeInputSocket(device, reason);
|
|
3142
3865
|
closeFrameSocket(device, reason);
|
|
3143
3866
|
device.disconnectedAt = new Date().toISOString();
|
|
@@ -3578,6 +4301,48 @@ export function createRemoteHub(options = {}) {
|
|
|
3578
4301
|
?? result.HubForwardedAtEpochMs
|
|
3579
4302
|
?? pending?.hubForwardedAtEpochMs
|
|
3580
4303
|
?? 0) || 0,
|
|
4304
|
+
duplicate: message.duplicate === true
|
|
4305
|
+
|| message.Duplicate === true
|
|
4306
|
+
|| result.duplicate === true
|
|
4307
|
+
|| result.Duplicate === true,
|
|
4308
|
+
nativeInputBackend: safeString(
|
|
4309
|
+
message.nativeInputBackend
|
|
4310
|
+
|| message.NativeInputBackend
|
|
4311
|
+
|| result.nativeInputBackend
|
|
4312
|
+
|| result.NativeInputBackend,
|
|
4313
|
+
80),
|
|
4314
|
+
nativeEventsRequested: Math.max(0, Number(
|
|
4315
|
+
message.nativeEventsRequested
|
|
4316
|
+
?? message.NativeEventsRequested
|
|
4317
|
+
?? result.nativeEventsRequested
|
|
4318
|
+
?? result.NativeEventsRequested
|
|
4319
|
+
?? 0) || 0),
|
|
4320
|
+
nativeEventsAccepted: Math.max(0, Number(
|
|
4321
|
+
message.nativeEventsAccepted
|
|
4322
|
+
?? message.NativeEventsAccepted
|
|
4323
|
+
?? result.nativeEventsAccepted
|
|
4324
|
+
?? result.NativeEventsAccepted
|
|
4325
|
+
?? 0) || 0),
|
|
4326
|
+
hookEventsObserved: Math.max(0, Number(
|
|
4327
|
+
message.hookEventsObserved
|
|
4328
|
+
?? message.HookEventsObserved
|
|
4329
|
+
?? result.hookEventsObserved
|
|
4330
|
+
?? result.HookEventsObserved
|
|
4331
|
+
?? 0) || 0),
|
|
4332
|
+
hookEventsBlocked: Math.max(0, Number(
|
|
4333
|
+
message.hookEventsBlocked
|
|
4334
|
+
?? message.HookEventsBlocked
|
|
4335
|
+
?? result.hookEventsBlocked
|
|
4336
|
+
?? result.HookEventsBlocked
|
|
4337
|
+
?? 0) || 0),
|
|
4338
|
+
focusChanged: message.focusChanged === true
|
|
4339
|
+
|| message.FocusChanged === true
|
|
4340
|
+
|| result.focusChanged === true
|
|
4341
|
+
|| result.FocusChanged === true,
|
|
4342
|
+
inputDiagnostic: message.inputDiagnostic === true
|
|
4343
|
+
|| message.InputDiagnostic === true
|
|
4344
|
+
|| result.inputDiagnostic === true
|
|
4345
|
+
|| result.InputDiagnostic === true,
|
|
3581
4346
|
fallback: pending?.fallback === true,
|
|
3582
4347
|
hubAcknowledgedAtEpochMs: Date.now()
|
|
3583
4348
|
};
|
|
@@ -4426,10 +5191,17 @@ export function createRemoteHub(options = {}) {
|
|
|
4426
5191
|
nativeOutputQueueHighWatermark: Number.isFinite(Number(message.nativeOutputQueueHighWatermark)) ? Number(message.nativeOutputQueueHighWatermark) : 0,
|
|
4427
5192
|
nativeOutputQueueDroppedCount: Number.isFinite(Number(message.nativeOutputQueueDroppedCount)) ? Number(message.nativeOutputQueueDroppedCount) : 0,
|
|
4428
5193
|
nativeOutputQueueAwaitingKeyFrame: message.nativeOutputQueueAwaitingKeyFrame === true,
|
|
5194
|
+
nativeOutputQueueTelemetryAvailable:
|
|
5195
|
+
optionalFiniteNumber(message.nativeOutputQueueDepth) !== null
|
|
5196
|
+
&& optionalFiniteNumber(message.nativeOutputQueueBytes) !== null
|
|
5197
|
+
&& optionalFiniteNumber(message.nativeOutputQueueDroppedCount) !== null,
|
|
4429
5198
|
nativeCaptureTelemetryAt: safeString(message.nativeCaptureTelemetryAt, 80),
|
|
4430
5199
|
droppedByAgent: Number.isFinite(Number(message.droppedByAgent)) ? Number(message.droppedByAgent) : 0,
|
|
4431
5200
|
hubIngressDropped: Number.isFinite(Number(message.hubIngressDropped)) ? Number(message.hubIngressDropped) : 0,
|
|
4432
5201
|
udpIncompleteFrames: Number.isFinite(Number(message.udpIncompleteFrames)) ? Number(message.udpIncompleteFrames) : 0,
|
|
5202
|
+
hubIngressTelemetryAvailable: optionalFiniteNumber(message.hubIngressDropped) !== null,
|
|
5203
|
+
udpIncompleteTelemetryAvailable: optionalFiniteNumber(message.udpIncompleteFrames) !== null,
|
|
5204
|
+
hubIngressCounterEpoch: safeString(message.hubIngressCounterEpoch, 160),
|
|
4433
5205
|
hardwareEncoder: safeString(message.hardwareEncoder, 80),
|
|
4434
5206
|
platformProfile: safeString(message.platformProfile, 80),
|
|
4435
5207
|
monitorIndex: activeMonitorIndex,
|
|
@@ -4480,9 +5252,10 @@ export function createRemoteHub(options = {}) {
|
|
|
4480
5252
|
frameTransportTcpFailureCount: Number.isFinite(Number(message.frameTransportTcpFailureCount)) ? Number(message.frameTransportTcpFailureCount) : 0,
|
|
4481
5253
|
sameContentStreak,
|
|
4482
5254
|
payload,
|
|
4483
|
-
accessToken: createFrameAccessToken()
|
|
4484
|
-
};
|
|
4485
|
-
|
|
5255
|
+
accessToken: createFrameAccessToken()
|
|
5256
|
+
};
|
|
5257
|
+
recordTransportDiagnosticFrame(device, message, payload, device.latestLiveFrame);
|
|
5258
|
+
streamState.latestFrame = device.latestLiveFrame;
|
|
4486
5259
|
rememberRecentFramePayload(device, 'live', device.latestLiveFrame);
|
|
4487
5260
|
emitFrame({
|
|
4488
5261
|
kind: 'live',
|
|
@@ -4677,7 +5450,8 @@ export function createRemoteHub(options = {}) {
|
|
|
4677
5450
|
if (frameKind === 'stream' || frameKind === 'live' || header.type === 'stream.binary') {
|
|
4678
5451
|
applyLiveFrame(device, {
|
|
4679
5452
|
...header,
|
|
4680
|
-
hubIngressDropped: Number(state.binaryQueueDrops || 0)
|
|
5453
|
+
hubIngressDropped: Number(state.binaryQueueDrops || 0),
|
|
5454
|
+
hubIngressCounterEpoch: state.binaryQueueEpoch
|
|
4681
5455
|
}, framePayload, binaryTransport);
|
|
4682
5456
|
return;
|
|
4683
5457
|
}
|
|
@@ -4718,10 +5492,11 @@ export function createRemoteHub(options = {}) {
|
|
|
4718
5492
|
lastFrameAt: new Date().toISOString(),
|
|
4719
5493
|
framesReceived: Number(device.udp?.framesReceived || 0) + 1
|
|
4720
5494
|
};
|
|
4721
|
-
return applyLiveFrame(device, {
|
|
4722
|
-
...header,
|
|
4723
|
-
transport: 'udp-p2p'
|
|
4724
|
-
|
|
5495
|
+
return applyLiveFrame(device, {
|
|
5496
|
+
...header,
|
|
5497
|
+
transport: 'udp-p2p',
|
|
5498
|
+
hubIngressCounterEpoch: sessionId
|
|
5499
|
+
}, payload, 'udp-p2p');
|
|
4725
5500
|
}
|
|
4726
5501
|
|
|
4727
5502
|
function dropQueuedAgentBinaryFrame(state) {
|
|
@@ -5051,9 +5826,10 @@ export function createRemoteHub(options = {}) {
|
|
|
5051
5826
|
binaryQueue: [],
|
|
5052
5827
|
binaryQueueDraining: false,
|
|
5053
5828
|
binaryQueueScheduled: false,
|
|
5054
|
-
binaryQueueSocket: null,
|
|
5055
|
-
binaryQueueDrops: 0,
|
|
5056
|
-
|
|
5829
|
+
binaryQueueSocket: null,
|
|
5830
|
+
binaryQueueDrops: 0,
|
|
5831
|
+
binaryQueueEpoch: crypto.randomUUID(),
|
|
5832
|
+
closed: false
|
|
5057
5833
|
};
|
|
5058
5834
|
|
|
5059
5835
|
const helloTimer = setTimeout(() => {
|
|
@@ -5211,9 +5987,10 @@ export function createRemoteHub(options = {}) {
|
|
|
5211
5987
|
binaryQueue: [],
|
|
5212
5988
|
binaryQueueDraining: false,
|
|
5213
5989
|
binaryQueueScheduled: false,
|
|
5214
|
-
binaryQueueSocket: null,
|
|
5215
|
-
binaryQueueDrops: 0,
|
|
5216
|
-
|
|
5990
|
+
binaryQueueSocket: null,
|
|
5991
|
+
binaryQueueDrops: 0,
|
|
5992
|
+
binaryQueueEpoch: crypto.randomUUID(),
|
|
5993
|
+
closed: false
|
|
5217
5994
|
};
|
|
5218
5995
|
|
|
5219
5996
|
const helloTimer = setTimeout(() => {
|
|
@@ -5437,8 +6214,9 @@ export function createRemoteHub(options = {}) {
|
|
|
5437
6214
|
return getStatus({ includeSecrets: false });
|
|
5438
6215
|
}
|
|
5439
6216
|
|
|
5440
|
-
async function close() {
|
|
6217
|
+
async function close() {
|
|
5441
6218
|
for (const device of devices.values()) {
|
|
6219
|
+
abortTransportDiagnosticsForDevice(device, 'hub-shutdown');
|
|
5442
6220
|
failAllPendingTasks(device, 'hub-shutdown');
|
|
5443
6221
|
failAllPendingInputFallbacks(device, 'hub-shutdown');
|
|
5444
6222
|
failPendingCommandResultWaiters(device, 'hub-shutdown');
|
|
@@ -5490,10 +6268,11 @@ export function createRemoteHub(options = {}) {
|
|
|
5490
6268
|
|
|
5491
6269
|
function disconnectDevice(deviceId, reason = 'manager-disconnect') {
|
|
5492
6270
|
const device = devices.get(String(deviceId || ''));
|
|
5493
|
-
if (device?.synthetic === true) {
|
|
5494
|
-
device.connected = false;
|
|
5495
|
-
device.disconnectedAt = new Date().toISOString();
|
|
5496
|
-
device.lastDisconnectReason = reason;
|
|
6271
|
+
if (device?.synthetic === true) {
|
|
6272
|
+
device.connected = false;
|
|
6273
|
+
device.disconnectedAt = new Date().toISOString();
|
|
6274
|
+
device.lastDisconnectReason = reason;
|
|
6275
|
+
abortTransportDiagnosticsForDevice(device, reason);
|
|
5497
6276
|
deactivateDeviceLiveStreams(device, reason, device.disconnectedAt);
|
|
5498
6277
|
failAllPendingTasks(device, 'device-disconnected');
|
|
5499
6278
|
failAllPendingInputFallbacks(device, 'device-disconnected');
|
|
@@ -7547,6 +8326,10 @@ export function createRemoteHub(options = {}) {
|
|
|
7547
8326
|
stopLiveStream,
|
|
7548
8327
|
pauseLiveCapture,
|
|
7549
8328
|
resumeLiveCapture,
|
|
8329
|
+
startTransportDiagnostic,
|
|
8330
|
+
getTransportDiagnostic,
|
|
8331
|
+
setTransportDiagnosticPhase,
|
|
8332
|
+
stopTransportDiagnostic,
|
|
7550
8333
|
startAudioStream,
|
|
7551
8334
|
stopAudioStream,
|
|
7552
8335
|
getDeviceLiveFrame,
|
package/src/server.js
CHANGED
|
@@ -1303,7 +1303,7 @@ function isPrivateHubHostname(value) {
|
|
|
1303
1303
|
return !!match && Number(match[2]) >= 16 && Number(match[2]) <= 31;
|
|
1304
1304
|
}
|
|
1305
1305
|
|
|
1306
|
-
function isLoopbackAddress(value) {
|
|
1306
|
+
function isLoopbackAddress(value) {
|
|
1307
1307
|
const address = String(value || '').trim().toLowerCase();
|
|
1308
1308
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
1309
1309
|
}
|
|
@@ -1449,6 +1449,31 @@ function normalizeDeviceIds(value) {
|
|
|
1449
1449
|
return ids;
|
|
1450
1450
|
}
|
|
1451
1451
|
|
|
1452
|
+
function requireLocalTransportDiagnosticRequest(req, res) {
|
|
1453
|
+
const forwardedFor = String(req.headers['x-forwarded-for'] || '').trim();
|
|
1454
|
+
const forwardedHost = String(req.headers['x-forwarded-host'] || '').trim();
|
|
1455
|
+
const browserOrigin = String(req.headers.origin || '').trim();
|
|
1456
|
+
const browserFetchSite = String(req.headers['sec-fetch-site'] || '').trim();
|
|
1457
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress)
|
|
1458
|
+
|| !isLoopbackHostname(requestHostname(req))
|
|
1459
|
+
|| forwardedFor
|
|
1460
|
+
|| forwardedHost
|
|
1461
|
+
|| browserOrigin
|
|
1462
|
+
|| browserFetchSite) {
|
|
1463
|
+
res.status(403).json({ ok: false, error: 'transport-diagnostic-loopback-only' });
|
|
1464
|
+
return false;
|
|
1465
|
+
}
|
|
1466
|
+
if (runtimeRole !== 'hub') {
|
|
1467
|
+
res.status(403).json({ ok: false, error: 'role-not-allowed' });
|
|
1468
|
+
return false;
|
|
1469
|
+
}
|
|
1470
|
+
return true;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function transportDiagnosticToken(req) {
|
|
1474
|
+
return String(req.headers['x-livedesk-diagnostic-token'] || '').trim();
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1452
1477
|
function rejectRetiredAgentTaskRequest(req, res) {
|
|
1453
1478
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
1454
1479
|
const approvalLevel = String(body.approvalLevel || '').trim().toLowerCase();
|
|
@@ -3093,7 +3118,7 @@ app.post('/api/settings/agent/summary', async (req, res) => {
|
|
|
3093
3118
|
}
|
|
3094
3119
|
});
|
|
3095
3120
|
|
|
3096
|
-
app.get('/api/remote/status', (_req, res) => {
|
|
3121
|
+
app.get('/api/remote/status', (_req, res) => {
|
|
3097
3122
|
noStore(res);
|
|
3098
3123
|
if (runtimeRole !== 'hub') {
|
|
3099
3124
|
res.status(403).json({ ok: false, error: 'role-not-allowed' });
|
|
@@ -3111,9 +3136,134 @@ app.get('/api/remote/status', (_req, res) => {
|
|
|
3111
3136
|
agentPackage: '@livedesk/client',
|
|
3112
3137
|
update: getLiveDeskUpdateStatus()
|
|
3113
3138
|
});
|
|
3114
|
-
});
|
|
3115
|
-
|
|
3116
|
-
app.
|
|
3139
|
+
});
|
|
3140
|
+
|
|
3141
|
+
app.post('/api/remote/diagnostics/transport/start', async (req, res) => {
|
|
3142
|
+
noStore(res);
|
|
3143
|
+
if (!requireLocalTransportDiagnosticRequest(req, res)) return;
|
|
3144
|
+
const requestController = new AbortController();
|
|
3145
|
+
let responseFinished = false;
|
|
3146
|
+
let issuedLease = null;
|
|
3147
|
+
let releaseIssuedLeasePromise = null;
|
|
3148
|
+
const detachResponseLifecycle = () => {
|
|
3149
|
+
res.off('finish', markResponseFinished);
|
|
3150
|
+
res.off('close', abortPendingStart);
|
|
3151
|
+
};
|
|
3152
|
+
const releaseIssuedLease = reason => {
|
|
3153
|
+
if (!issuedLease?.leaseId || !issuedLease?.token) {
|
|
3154
|
+
return releaseIssuedLeasePromise || Promise.resolve(null);
|
|
3155
|
+
}
|
|
3156
|
+
if (!releaseIssuedLeasePromise) {
|
|
3157
|
+
const lease = issuedLease;
|
|
3158
|
+
releaseIssuedLeasePromise = Promise.resolve(remoteHub.stopTransportDiagnostic(
|
|
3159
|
+
lease.leaseId,
|
|
3160
|
+
lease.token,
|
|
3161
|
+
reason
|
|
3162
|
+
)).catch(() => null);
|
|
3163
|
+
}
|
|
3164
|
+
return releaseIssuedLeasePromise;
|
|
3165
|
+
};
|
|
3166
|
+
const markResponseFinished = () => {
|
|
3167
|
+
responseFinished = true;
|
|
3168
|
+
issuedLease = null;
|
|
3169
|
+
detachResponseLifecycle();
|
|
3170
|
+
};
|
|
3171
|
+
const abortPendingStart = () => {
|
|
3172
|
+
if (responseFinished) return;
|
|
3173
|
+
requestController.abort();
|
|
3174
|
+
void releaseIssuedLease('start-response-closed');
|
|
3175
|
+
detachResponseLifecycle();
|
|
3176
|
+
};
|
|
3177
|
+
res.once('finish', markResponseFinished);
|
|
3178
|
+
res.once('close', abortPendingStart);
|
|
3179
|
+
try {
|
|
3180
|
+
const result = await remoteHub.startTransportDiagnostic(
|
|
3181
|
+
String(req.body?.deviceId || ''),
|
|
3182
|
+
Number(req.body?.ttlMs || 0),
|
|
3183
|
+
{ signal: requestController.signal });
|
|
3184
|
+
if (result?.leaseId && result?.token) {
|
|
3185
|
+
issuedLease = {
|
|
3186
|
+
leaseId: result.leaseId,
|
|
3187
|
+
token: result.token
|
|
3188
|
+
};
|
|
3189
|
+
}
|
|
3190
|
+
if (requestController.signal.aborted) {
|
|
3191
|
+
await releaseIssuedLease('start-response-closed');
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3195
|
+
} catch (error) {
|
|
3196
|
+
if (requestController.signal.aborted) return;
|
|
3197
|
+
res.status(500).json({
|
|
3198
|
+
ok: false,
|
|
3199
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3200
|
+
});
|
|
3201
|
+
} finally {
|
|
3202
|
+
if (res.writableFinished) {
|
|
3203
|
+
markResponseFinished();
|
|
3204
|
+
} else if (requestController.signal.aborted) {
|
|
3205
|
+
detachResponseLifecycle();
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
});
|
|
3209
|
+
|
|
3210
|
+
app.get('/api/remote/diagnostics/transport/:leaseId', (req, res) => {
|
|
3211
|
+
noStore(res);
|
|
3212
|
+
if (!requireLocalTransportDiagnosticRequest(req, res)) return;
|
|
3213
|
+
const token = transportDiagnosticToken(req);
|
|
3214
|
+
if (!token) {
|
|
3215
|
+
res.status(401).json({ ok: false, error: 'transport-diagnostic-token-required' });
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
const result = remoteHub.getTransportDiagnostic(req.params.leaseId, token);
|
|
3219
|
+
res.status(result.ok ? 200 : 404).json(result);
|
|
3220
|
+
});
|
|
3221
|
+
|
|
3222
|
+
app.post('/api/remote/diagnostics/transport/:leaseId/phase', async (req, res) => {
|
|
3223
|
+
noStore(res);
|
|
3224
|
+
if (!requireLocalTransportDiagnosticRequest(req, res)) return;
|
|
3225
|
+
const token = transportDiagnosticToken(req);
|
|
3226
|
+
if (!token) {
|
|
3227
|
+
res.status(401).json({ ok: false, error: 'transport-diagnostic-token-required' });
|
|
3228
|
+
return;
|
|
3229
|
+
}
|
|
3230
|
+
try {
|
|
3231
|
+
const result = await remoteHub.setTransportDiagnosticPhase(
|
|
3232
|
+
req.params.leaseId,
|
|
3233
|
+
token,
|
|
3234
|
+
String(req.body?.phase || ''));
|
|
3235
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3236
|
+
} catch (error) {
|
|
3237
|
+
res.status(500).json({
|
|
3238
|
+
ok: false,
|
|
3239
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3240
|
+
});
|
|
3241
|
+
}
|
|
3242
|
+
});
|
|
3243
|
+
|
|
3244
|
+
app.delete('/api/remote/diagnostics/transport/:leaseId', async (req, res) => {
|
|
3245
|
+
noStore(res);
|
|
3246
|
+
if (!requireLocalTransportDiagnosticRequest(req, res)) return;
|
|
3247
|
+
const token = transportDiagnosticToken(req);
|
|
3248
|
+
if (!token) {
|
|
3249
|
+
res.status(401).json({ ok: false, error: 'transport-diagnostic-token-required' });
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
try {
|
|
3253
|
+
const result = await remoteHub.stopTransportDiagnostic(
|
|
3254
|
+
req.params.leaseId,
|
|
3255
|
+
token,
|
|
3256
|
+
String(req.body?.reason || 'runner-complete'));
|
|
3257
|
+
res.status(result.ok ? 200 : 409).json(result);
|
|
3258
|
+
} catch (error) {
|
|
3259
|
+
res.status(500).json({
|
|
3260
|
+
ok: false,
|
|
3261
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3262
|
+
});
|
|
3263
|
+
}
|
|
3264
|
+
});
|
|
3265
|
+
|
|
3266
|
+
app.get('/api/runtime/status', (_req, res) => {
|
|
3117
3267
|
noStore(res);
|
|
3118
3268
|
res.json({ ok: true, ...hubRuntimeStatus(runtimeManager, { role: runtimeRole, deviceId: runtimeDeviceId, deviceName: runtimeDeviceName, roleSource: runtimeRoleSource }) });
|
|
3119
3269
|
});
|
|
@@ -36,6 +36,27 @@ function normalizedAddress(value) {
|
|
|
36
36
|
return address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
function isPublicIPv4(value) {
|
|
40
|
+
const address = normalizedAddress(value);
|
|
41
|
+
const octets = address.split('.').map(Number);
|
|
42
|
+
if (octets.length !== 4
|
|
43
|
+
|| octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const [a, b] = octets;
|
|
47
|
+
const c = octets[2];
|
|
48
|
+
return a !== 0
|
|
49
|
+
&& a !== 10
|
|
50
|
+
&& a !== 127
|
|
51
|
+
&& !(a === 100 && b >= 64 && b <= 127)
|
|
52
|
+
&& !(a === 169 && b === 254)
|
|
53
|
+
&& !(a === 172 && b >= 16 && b <= 31)
|
|
54
|
+
&& !(a === 192 && (b === 0 || b === 168 || (b === 88 && c === 99)))
|
|
55
|
+
&& !(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100)))
|
|
56
|
+
&& !(a === 203 && b === 0 && c === 113)
|
|
57
|
+
&& !(a >= 224);
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
|
|
40
61
|
const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, true);
|
|
41
62
|
const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, false);
|
|
@@ -182,9 +203,21 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
182
203
|
const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
|
|
183
204
|
if (!session || message.protocol !== UDP_P2P_PROTOCOL || !isTrustedRendezvousSource(rinfo)) return;
|
|
184
205
|
const address = safeText(message.host, 128);
|
|
185
|
-
const port = Number(message.port);
|
|
186
|
-
if (!address || !Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
187
|
-
|
|
206
|
+
const port = Number(message.port);
|
|
207
|
+
if (!address || !Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
208
|
+
const observedAddress = normalizedAddress(message.observedHost);
|
|
209
|
+
const observedPort = Number(message.observedPort);
|
|
210
|
+
if (observedAddress
|
|
211
|
+
&& Number.isInteger(observedPort)
|
|
212
|
+
&& observedPort >= 1
|
|
213
|
+
&& observedPort <= 65535) {
|
|
214
|
+
session.hubRendezvousEndpoint = {
|
|
215
|
+
address: observedAddress,
|
|
216
|
+
port: observedPort
|
|
217
|
+
};
|
|
218
|
+
session.hubRendezvousObservedAt = Date.now();
|
|
219
|
+
}
|
|
220
|
+
session.peerEndpoint = { address, port };
|
|
188
221
|
session.lastPeerAt = Date.now();
|
|
189
222
|
sendPacket(session, 'probe', { punch: true }, Buffer.alloc(0), session.peerEndpoint);
|
|
190
223
|
emit('UdpPeerEndpointObserved', { deviceId: session.deviceId, endpoint: `${address}:${port}` });
|
|
@@ -290,6 +323,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
290
323
|
sendControl,
|
|
291
324
|
clientEndpoint: null,
|
|
292
325
|
peerEndpoint: null,
|
|
326
|
+
hubRendezvousEndpoint: null,
|
|
327
|
+
hubRendezvousObservedAt: 0,
|
|
293
328
|
replay: new UdpReplayWindow(),
|
|
294
329
|
reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
|
|
295
330
|
ready: false,
|
|
@@ -327,7 +362,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
327
362
|
emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
|
|
328
363
|
}
|
|
329
364
|
|
|
330
|
-
function getDeviceStatus(deviceId) {
|
|
365
|
+
function getDeviceStatus(deviceId) {
|
|
331
366
|
const session = sessions.get(safeText(deviceId, 160));
|
|
332
367
|
if (!session) return { enabled, state: enabled ? 'disabled' : 'tcp-only', ready: false };
|
|
333
368
|
return {
|
|
@@ -340,7 +375,72 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
340
375
|
framesReceived: session.framesReceived,
|
|
341
376
|
...session.reassembler.getStats()
|
|
342
377
|
};
|
|
343
|
-
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function refreshDeviceDiagnosticNetwork(deviceId, { timeoutMs = 3_000 } = {}) {
|
|
381
|
+
const session = sessions.get(safeText(deviceId, 160));
|
|
382
|
+
if (!session || !started || !rendezvousHost) return false;
|
|
383
|
+
const requestedAt = Date.now();
|
|
384
|
+
await resolveRendezvousAddresses();
|
|
385
|
+
if (!sendRendezvousRegister(session)) return false;
|
|
386
|
+
const deadline = requestedAt + numberEnv(timeoutMs, 250, 5_000, 3_000);
|
|
387
|
+
while (Date.now() < deadline) {
|
|
388
|
+
if (session.hubRendezvousObservedAt >= requestedAt) return true;
|
|
389
|
+
await new Promise(resolve => {
|
|
390
|
+
const timer = setTimeout(resolve, 40);
|
|
391
|
+
timer.unref?.();
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getDeviceDiagnosticNetwork(deviceId, { salt = '' } = {}) {
|
|
398
|
+
const session = sessions.get(safeText(deviceId, 160));
|
|
399
|
+
const clientAddress = normalizedAddress(session?.clientEndpoint?.address);
|
|
400
|
+
const normalizedHubAddress = normalizedAddress(session?.hubRendezvousEndpoint?.address);
|
|
401
|
+
const hubAddressObservedAt = session?.hubRendezvousObservedAt
|
|
402
|
+
? new Date(session.hubRendezvousObservedAt).toISOString()
|
|
403
|
+
: '';
|
|
404
|
+
const digest = value => value
|
|
405
|
+
? crypto.createHash('sha256')
|
|
406
|
+
.update(`${safeText(salt, 160)}:${value}`)
|
|
407
|
+
.digest('hex')
|
|
408
|
+
.slice(0, 16)
|
|
409
|
+
: '';
|
|
410
|
+
if (!session?.ready || !clientAddress) {
|
|
411
|
+
return {
|
|
412
|
+
evidence: 'authenticated-udp-endpoint-unavailable',
|
|
413
|
+
differentPublicEgress: null,
|
|
414
|
+
clientAddressHash: '',
|
|
415
|
+
hubAddressHash: digest(normalizedHubAddress),
|
|
416
|
+
hubAddressObservedAt,
|
|
417
|
+
hubEndpointSource: normalizedHubAddress
|
|
418
|
+
? 'trusted-rendezvous-observation'
|
|
419
|
+
: 'unavailable'
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const clientAddressPublic = isPublicIPv4(clientAddress);
|
|
423
|
+
const hubAddressPublic = isPublicIPv4(normalizedHubAddress);
|
|
424
|
+
return {
|
|
425
|
+
evidence: normalizedHubAddress
|
|
426
|
+
? 'authenticated-udp-endpoint-rendezvous-observed'
|
|
427
|
+
: 'authenticated-udp-endpoint-hub-egress-unavailable',
|
|
428
|
+
differentPublicEgress: clientAddressPublic && hubAddressPublic
|
|
429
|
+
? clientAddress !== normalizedHubAddress
|
|
430
|
+
: null,
|
|
431
|
+
clientAddressHash: digest(clientAddress),
|
|
432
|
+
hubAddressHash: digest(normalizedHubAddress),
|
|
433
|
+
clientAddressPublic,
|
|
434
|
+
hubAddressPublic,
|
|
435
|
+
endpointAuthenticated: true,
|
|
436
|
+
udpReady: session.ready === true,
|
|
437
|
+
observedAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
|
|
438
|
+
hubAddressObservedAt,
|
|
439
|
+
hubEndpointSource: normalizedHubAddress
|
|
440
|
+
? 'trusted-rendezvous-observation'
|
|
441
|
+
: 'unavailable'
|
|
442
|
+
};
|
|
443
|
+
}
|
|
344
444
|
|
|
345
445
|
async function close() {
|
|
346
446
|
stopRendezvousRefreshTimer();
|
|
@@ -354,9 +454,11 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
354
454
|
start,
|
|
355
455
|
close,
|
|
356
456
|
registerDevice,
|
|
357
|
-
unregisterDevice,
|
|
358
|
-
getDeviceStatus,
|
|
359
|
-
|
|
457
|
+
unregisterDevice,
|
|
458
|
+
getDeviceStatus,
|
|
459
|
+
refreshDeviceDiagnosticNetwork,
|
|
460
|
+
getDeviceDiagnosticNetwork,
|
|
461
|
+
getStatus,
|
|
360
462
|
setFrameHandler: handler => { frameHandler = typeof handler === 'function' ? handler : () => {}; },
|
|
361
463
|
setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
|
|
362
464
|
};
|
|
@@ -300,7 +300,9 @@ export function createUdpRendezvousServer({
|
|
|
300
300
|
roomId,
|
|
301
301
|
role: 'client',
|
|
302
302
|
host: client.address,
|
|
303
|
-
port: client.port
|
|
303
|
+
port: client.port,
|
|
304
|
+
observedHost: hub.address,
|
|
305
|
+
observedPort: hub.port
|
|
304
306
|
}, hub.address, hub.port);
|
|
305
307
|
send({
|
|
306
308
|
type: 'rendezvous.peer',
|
|
@@ -308,7 +310,9 @@ export function createUdpRendezvousServer({
|
|
|
308
310
|
roomId,
|
|
309
311
|
role: 'hub',
|
|
310
312
|
host: hub.address,
|
|
311
|
-
port: hub.port
|
|
313
|
+
port: hub.port,
|
|
314
|
+
observedHost: client.address,
|
|
315
|
+
observedPort: client.port
|
|
312
316
|
}, client.address, client.port);
|
|
313
317
|
}
|
|
314
318
|
|