livedesk 0.1.283 → 0.1.285
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/hub/src/remote-hub.js +111 -34
- package/hub/src/server.js +10 -2
- package/hub/src/transport/udp-hub-transport.js +281 -0
- package/hub/src/transport/udp-protocol.js +216 -0
- package/hub/src/transport/udp-rendezvous.js +78 -0
- package/package.json +2 -2
- package/web/dist/assets/{index-1el1N3Bn.js → index-Cr8lwSwF.js} +10 -10
- package/web/dist/index.html +1 -1
package/hub/src/remote-hub.js
CHANGED
|
@@ -960,8 +960,9 @@ function serializeDevice(device, options = {}) {
|
|
|
960
960
|
byteLength: device.latestAudioFrame.byteLength
|
|
961
961
|
}
|
|
962
962
|
: null,
|
|
963
|
-
latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
|
|
964
|
-
|
|
963
|
+
latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
|
|
964
|
+
udp: device.udp ? { ...device.udp } : { enabled: false, state: 'tcp-only', ready: false },
|
|
965
|
+
latestTask: device.latestTask ? { ...device.latestTask } : null,
|
|
965
966
|
synthetic: device.synthetic === true,
|
|
966
967
|
recentTasks: Array.isArray(device.recentTasks)
|
|
967
968
|
? device.recentTasks.map(task => ({ ...task }))
|
|
@@ -1395,6 +1396,9 @@ export function createRemoteHub(options = {}) {
|
|
|
1395
1396
|
const getWelcomeDevicePolicy = typeof options.getWelcomeDevicePolicy === 'function'
|
|
1396
1397
|
? options.getWelcomeDevicePolicy
|
|
1397
1398
|
: getEffectiveDevicePolicy;
|
|
1399
|
+
const udpTransport = options.udpTransport && typeof options.udpTransport === 'object'
|
|
1400
|
+
? options.udpTransport
|
|
1401
|
+
: null;
|
|
1398
1402
|
|
|
1399
1403
|
function getDevicePolicy(device) {
|
|
1400
1404
|
try {
|
|
@@ -1746,7 +1750,7 @@ export function createRemoteHub(options = {}) {
|
|
|
1746
1750
|
};
|
|
1747
1751
|
}
|
|
1748
1752
|
|
|
1749
|
-
function getStatus({ includeSecrets = false } = {}) {
|
|
1753
|
+
function getStatus({ includeSecrets = false } = {}) {
|
|
1750
1754
|
const connectedDevices = [...devices.values()].filter(device => device.connected).length;
|
|
1751
1755
|
const activeHostTarget = serializeHostTarget();
|
|
1752
1756
|
const routeInfo = getAgentEndpointRouteInfo();
|
|
@@ -1791,10 +1795,18 @@ export function createRemoteHub(options = {}) {
|
|
|
1791
1795
|
hostTargetExpiresAt: activeHostTarget.expiresAt,
|
|
1792
1796
|
publicIPv4Address: publicIPv4Cache.address,
|
|
1793
1797
|
publicIPv4UpdatedAt: publicIPv4Cache.updatedAt ? new Date(publicIPv4Cache.updatedAt).toISOString() : '',
|
|
1794
|
-
publicIPv4Error: publicIPv4Cache.error,
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
+
publicIPv4Error: publicIPv4Cache.error,
|
|
1799
|
+
udp: udpTransport?.getStatus?.() || {
|
|
1800
|
+
enabled: false,
|
|
1801
|
+
preferred: false,
|
|
1802
|
+
started: false,
|
|
1803
|
+
port: 0,
|
|
1804
|
+
sessionCount: 0,
|
|
1805
|
+
readySessionCount: 0
|
|
1806
|
+
},
|
|
1807
|
+
externalExposure: isWildcardHost(host) || !isLoopbackHost(host),
|
|
1808
|
+
lastError
|
|
1809
|
+
};
|
|
1798
1810
|
}
|
|
1799
1811
|
|
|
1800
1812
|
function rotatePairingPin() {
|
|
@@ -2650,9 +2662,15 @@ export function createRemoteHub(options = {}) {
|
|
|
2650
2662
|
},
|
|
2651
2663
|
activeLiveStream: null,
|
|
2652
2664
|
activeAudioStream: null,
|
|
2653
|
-
latestAudioFrame: null,
|
|
2654
|
-
latestAudioStatus: null,
|
|
2655
|
-
|
|
2665
|
+
latestAudioFrame: null,
|
|
2666
|
+
latestAudioStatus: null,
|
|
2667
|
+
udp: {
|
|
2668
|
+
enabled: capabilities.udpP2p === true,
|
|
2669
|
+
state: 'tcp-only',
|
|
2670
|
+
ready: false,
|
|
2671
|
+
sessionId: ''
|
|
2672
|
+
},
|
|
2673
|
+
latestTask: null,
|
|
2656
2674
|
recentTasks: [],
|
|
2657
2675
|
pendingTaskCommands: new Map(),
|
|
2658
2676
|
pendingTaskTimers: new Map(),
|
|
@@ -2662,7 +2680,7 @@ export function createRemoteHub(options = {}) {
|
|
|
2662
2680
|
|
|
2663
2681
|
devices.set(deviceId, device);
|
|
2664
2682
|
sockets.set(socket, deviceId);
|
|
2665
|
-
writeJsonLine(socket, {
|
|
2683
|
+
writeJsonLine(socket, {
|
|
2666
2684
|
type: 'welcome',
|
|
2667
2685
|
protocol: REMOTE_AGENT_PROTOCOL,
|
|
2668
2686
|
protocolVersion: REMOTE_PROTOCOL_VERSION,
|
|
@@ -2672,8 +2690,22 @@ export function createRemoteHub(options = {}) {
|
|
|
2672
2690
|
frameProtocol: buildRemoteFrameProtocolDescriptor(),
|
|
2673
2691
|
frameModes: getSupportedRemoteFrameModeProfiles(),
|
|
2674
2692
|
effectivePolicy: getWelcomeDevicePolicy({ deviceId, capabilities }),
|
|
2675
|
-
serverTime: now
|
|
2676
|
-
});
|
|
2693
|
+
serverTime: now
|
|
2694
|
+
});
|
|
2695
|
+
|
|
2696
|
+
const udpSession = udpTransport?.registerDevice?.({
|
|
2697
|
+
deviceId,
|
|
2698
|
+
sessionId,
|
|
2699
|
+
sendControl: payload => writeJsonLine(socket, payload),
|
|
2700
|
+
capabilities
|
|
2701
|
+
});
|
|
2702
|
+
if (udpSession?.sessionId) {
|
|
2703
|
+
device.udp = {
|
|
2704
|
+
...device.udp,
|
|
2705
|
+
state: 'udp-session-created',
|
|
2706
|
+
sessionId: udpSession.sessionId
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2677
2709
|
|
|
2678
2710
|
logEvent('remote', `device connected ${device.deviceName} (${deviceId})`, 'success');
|
|
2679
2711
|
emitRemoteEvent('RemoteDeviceConnected', device);
|
|
@@ -2812,8 +2844,9 @@ export function createRemoteHub(options = {}) {
|
|
|
2812
2844
|
return;
|
|
2813
2845
|
}
|
|
2814
2846
|
|
|
2815
|
-
device.connected = false;
|
|
2816
|
-
device.socket = null;
|
|
2847
|
+
device.connected = false;
|
|
2848
|
+
device.socket = null;
|
|
2849
|
+
udpTransport?.unregisterDevice?.(deviceId, device.sessionId);
|
|
2817
2850
|
closeInputSocket(device, reason);
|
|
2818
2851
|
closeFrameSocket(device, reason);
|
|
2819
2852
|
device.disconnectedAt = new Date().toISOString();
|
|
@@ -3687,7 +3720,7 @@ export function createRemoteHub(options = {}) {
|
|
|
3687
3720
|
return true;
|
|
3688
3721
|
}
|
|
3689
3722
|
|
|
3690
|
-
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
3723
|
+
function handleAgentBinaryFrame(socket, state, header, framePayload) {
|
|
3691
3724
|
if (!state.authenticated || !state.device) {
|
|
3692
3725
|
writeJsonLine(socket, { type: 'error', error: 'hello-required' });
|
|
3693
3726
|
socket.destroy();
|
|
@@ -3734,11 +3767,37 @@ export function createRemoteHub(options = {}) {
|
|
|
3734
3767
|
return;
|
|
3735
3768
|
}
|
|
3736
3769
|
|
|
3737
|
-
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
3738
|
-
messageType: safeString(header.type, 80),
|
|
3739
|
-
frameKind
|
|
3740
|
-
});
|
|
3741
|
-
}
|
|
3770
|
+
emitRemoteEvent('RemoteAgentMessageIgnored', device, {
|
|
3771
|
+
messageType: safeString(header.type, 80),
|
|
3772
|
+
frameKind
|
|
3773
|
+
});
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
function handleUdpFrame({ deviceId, sessionId, header, payload } = {}) {
|
|
3777
|
+
const device = devices.get(safeString(deviceId, 160));
|
|
3778
|
+
if (!device || !device.connected) {
|
|
3779
|
+
return false;
|
|
3780
|
+
}
|
|
3781
|
+
if (device.udp?.sessionId && device.udp.sessionId !== safeString(sessionId, 160)) {
|
|
3782
|
+
return false;
|
|
3783
|
+
}
|
|
3784
|
+
const frameMode = safeString(header?.frameMode || header?.mode, 40).toLowerCase();
|
|
3785
|
+
if (frameMode !== 'mode3-h264-hw') {
|
|
3786
|
+
emitRemoteEvent('RemoteFrameDropped', device, { reason: 'udp-frame-mode-not-supported', transport: 'udp-p2p', frameMode });
|
|
3787
|
+
return false;
|
|
3788
|
+
}
|
|
3789
|
+
device.udp = {
|
|
3790
|
+
...device.udp,
|
|
3791
|
+
state: 'udp-active',
|
|
3792
|
+
ready: true,
|
|
3793
|
+
lastFrameAt: new Date().toISOString(),
|
|
3794
|
+
framesReceived: Number(device.udp?.framesReceived || 0) + 1
|
|
3795
|
+
};
|
|
3796
|
+
return applyLiveFrame(device, {
|
|
3797
|
+
...header,
|
|
3798
|
+
transport: 'udp-p2p'
|
|
3799
|
+
}, payload, 'udp-p2p');
|
|
3800
|
+
}
|
|
3742
3801
|
|
|
3743
3802
|
function dropQueuedAgentBinaryFrame(state) {
|
|
3744
3803
|
if (!Array.isArray(state.binaryQueue) || state.binaryQueue.length === 0) {
|
|
@@ -4399,13 +4458,14 @@ export function createRemoteHub(options = {}) {
|
|
|
4399
4458
|
});
|
|
4400
4459
|
}
|
|
4401
4460
|
|
|
4402
|
-
async function start() {
|
|
4461
|
+
async function start() {
|
|
4403
4462
|
if (!enabled || started) {
|
|
4404
4463
|
return getStatus({ includeSecrets: false });
|
|
4405
4464
|
}
|
|
4406
4465
|
|
|
4407
|
-
try {
|
|
4408
|
-
await
|
|
4466
|
+
try {
|
|
4467
|
+
await udpTransport?.start?.();
|
|
4468
|
+
await listenOnPort(requestedPort);
|
|
4409
4469
|
} catch (error) {
|
|
4410
4470
|
if (error?.code === 'EADDRINUSE') {
|
|
4411
4471
|
const conflict = new Error(
|
|
@@ -4422,7 +4482,7 @@ export function createRemoteHub(options = {}) {
|
|
|
4422
4482
|
return getStatus({ includeSecrets: false });
|
|
4423
4483
|
}
|
|
4424
4484
|
|
|
4425
|
-
async function close() {
|
|
4485
|
+
async function close() {
|
|
4426
4486
|
for (const device of devices.values()) {
|
|
4427
4487
|
failAllPendingTasks(device, 'hub-shutdown');
|
|
4428
4488
|
if (device.socket && !device.socket.destroyed) {
|
|
@@ -4437,8 +4497,9 @@ export function createRemoteHub(options = {}) {
|
|
|
4437
4497
|
}
|
|
4438
4498
|
}
|
|
4439
4499
|
|
|
4440
|
-
sockets.clear();
|
|
4441
|
-
|
|
4500
|
+
sockets.clear();
|
|
4501
|
+
await udpTransport?.close?.();
|
|
4502
|
+
if (!server) {
|
|
4442
4503
|
started = false;
|
|
4443
4504
|
return;
|
|
4444
4505
|
}
|
|
@@ -5488,7 +5549,7 @@ export function createRemoteHub(options = {}) {
|
|
|
5488
5549
|
});
|
|
5489
5550
|
}
|
|
5490
5551
|
|
|
5491
|
-
function getFramePayload(deviceId, frameKind, options = {}) {
|
|
5552
|
+
function getFramePayload(deviceId, frameKind, options = {}) {
|
|
5492
5553
|
const device = devices.get(String(deviceId || ''));
|
|
5493
5554
|
if (!device) {
|
|
5494
5555
|
return null;
|
|
@@ -5505,10 +5566,25 @@ export function createRemoteHub(options = {}) {
|
|
|
5505
5566
|
payload: frame.payload,
|
|
5506
5567
|
mimeType: safeString(frame.mimeType || frame.format || 'application/octet-stream', 120) || 'application/octet-stream',
|
|
5507
5568
|
byteLength: frame.payload.length
|
|
5508
|
-
};
|
|
5509
|
-
}
|
|
5510
|
-
|
|
5511
|
-
|
|
5569
|
+
};
|
|
5570
|
+
}
|
|
5571
|
+
|
|
5572
|
+
udpTransport?.setFrameHandler?.(handleUdpFrame);
|
|
5573
|
+
udpTransport?.setEventHandler?.((type, event = {}) => {
|
|
5574
|
+
const device = devices.get(safeString(event.deviceId, 160));
|
|
5575
|
+
if (device?.udp) {
|
|
5576
|
+
if (type === 'UdpSessionReady') {
|
|
5577
|
+
device.udp = { ...device.udp, state: 'udp-ready', ready: true };
|
|
5578
|
+
} else if (type === 'UdpSessionClosed') {
|
|
5579
|
+
device.udp = { ...device.udp, state: 'tcp-fallback', ready: false };
|
|
5580
|
+
}
|
|
5581
|
+
}
|
|
5582
|
+
if (device) {
|
|
5583
|
+
emitRemoteEvent(`Remote${type}`, device, { ...event });
|
|
5584
|
+
}
|
|
5585
|
+
});
|
|
5586
|
+
|
|
5587
|
+
return {
|
|
5512
5588
|
start,
|
|
5513
5589
|
close,
|
|
5514
5590
|
getStatus,
|
|
@@ -5534,8 +5610,9 @@ export function createRemoteHub(options = {}) {
|
|
|
5534
5610
|
stopAudioStream,
|
|
5535
5611
|
getDeviceLiveFrame,
|
|
5536
5612
|
getDeviceThumbnail,
|
|
5537
|
-
getFramePayload,
|
|
5538
|
-
|
|
5613
|
+
getFramePayload,
|
|
5614
|
+
handleUdpFrame,
|
|
5615
|
+
seedSyntheticFleet,
|
|
5539
5616
|
clearSyntheticFleet,
|
|
5540
5617
|
getPairToken: () => pairToken,
|
|
5541
5618
|
getPairingPin: () => pairingPin
|
package/hub/src/server.js
CHANGED
|
@@ -27,6 +27,7 @@ import { effectiveDevicePolicy } from './settings/settings-schema.js';
|
|
|
27
27
|
import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
|
|
28
28
|
import { CaptureStore } from './captures/capture-store.js';
|
|
29
29
|
import { createLiveDeskUpdateManager } from './live-desk-update.js';
|
|
30
|
+
import { createHubUdpTransport } from './transport/udp-hub-transport.js';
|
|
30
31
|
|
|
31
32
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
33
|
const webDistCandidates = [
|
|
@@ -250,14 +251,21 @@ void liveDeskSettingsStore.getRecord().catch(error => {
|
|
|
250
251
|
console.warn(`[LiveDesk Hub] settings load failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
251
252
|
});
|
|
252
253
|
|
|
254
|
+
const udpTransport = createHubUdpTransport({
|
|
255
|
+
env: process.env,
|
|
256
|
+
logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
|
|
257
|
+
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
|
|
258
|
+
});
|
|
259
|
+
|
|
253
260
|
const remoteHub = createRemoteHub({
|
|
254
261
|
managerPackage: '@livedesk/hub',
|
|
255
262
|
managerVersion: packageInfo.version,
|
|
256
|
-
env: {
|
|
263
|
+
env: {
|
|
257
264
|
...process.env,
|
|
258
265
|
MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
|
|
259
266
|
MINDEXEC_MANAGER_VERSION: packageInfo.version
|
|
260
|
-
},
|
|
267
|
+
},
|
|
268
|
+
udpTransport,
|
|
261
269
|
logEvent: (_scope, message) => console.log(`[LiveDesk Hub] ${message}`),
|
|
262
270
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
|
|
263
271
|
emitEvent: handleRemoteHubEvent,
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import dgram from 'node:dgram';
|
|
3
|
+
import {
|
|
4
|
+
UDP_MAX_DATAGRAM_BYTES,
|
|
5
|
+
UDP_P2P_PROTOCOL,
|
|
6
|
+
UdpFrameReassembler,
|
|
7
|
+
UdpReplayWindow,
|
|
8
|
+
decodeRendezvousMessage,
|
|
9
|
+
decodeUdpPacket,
|
|
10
|
+
encodeRendezvousMessage,
|
|
11
|
+
encodeUdpPacket,
|
|
12
|
+
createUdpSessionKey
|
|
13
|
+
} from './udp-protocol.js';
|
|
14
|
+
|
|
15
|
+
function isEnabled(value, fallback = false) {
|
|
16
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
17
|
+
return /^(1|true|yes|on)$/i.test(String(value).trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeText(value, max = 160) {
|
|
21
|
+
return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function numberEnv(value, min, max, fallback) {
|
|
25
|
+
const number = Number(value);
|
|
26
|
+
return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.floor(number))) : fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sameEndpoint(left, right) {
|
|
30
|
+
return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
|
|
34
|
+
const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, false);
|
|
35
|
+
const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, true);
|
|
36
|
+
const bindHost = safeText(env.LIVEDESK_UDP_BIND_HOST || '0.0.0.0', 128) || '0.0.0.0';
|
|
37
|
+
const requestedPort = numberEnv(env.LIVEDESK_UDP_PORT, 0, 65535, 0);
|
|
38
|
+
const advertiseHost = safeText(env.LIVEDESK_UDP_ADVERTISE_HOST || env.LIVEDESK_REMOTE_PUBLIC_HOST || '', 128);
|
|
39
|
+
const rendezvousHost = safeText(env.LIVEDESK_UDP_RENDEZVOUS_HOST || '', 128);
|
|
40
|
+
const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
|
|
41
|
+
const punchTimeoutMs = numberEnv(env.LIVEDESK_UDP_PUNCH_TIMEOUT_MS, 500, 10_000, 3000);
|
|
42
|
+
const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 80);
|
|
43
|
+
const maxPendingFrames = numberEnv(env.LIVEDESK_UDP_MAX_PENDING_FRAMES, 1, 16, 2);
|
|
44
|
+
const trace = isEnabled(env.LIVEDESK_UDP_TRACE, false);
|
|
45
|
+
const socket = dgram.createSocket('udp4');
|
|
46
|
+
const sessions = new Map();
|
|
47
|
+
let frameHandler = () => {};
|
|
48
|
+
let eventHandler = () => {};
|
|
49
|
+
let started = false;
|
|
50
|
+
let boundPort = requestedPort;
|
|
51
|
+
let sequence = crypto.randomInt(1, 0x7fffffff);
|
|
52
|
+
|
|
53
|
+
function emit(type, event = {}) {
|
|
54
|
+
eventHandler(type, event);
|
|
55
|
+
if (trace) logEvent('udp', `${type} ${JSON.stringify({ deviceId: event.deviceId || '', state: event.state || '', reason: event.reason || '' })}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nextSequence() {
|
|
59
|
+
sequence = (sequence + 1) >>> 0;
|
|
60
|
+
return sequence;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sendPacket(session, type, header = {}, payload = Buffer.alloc(0), address = session.peerEndpoint || session.clientEndpoint) {
|
|
64
|
+
if (!address || !session) return false;
|
|
65
|
+
let packet;
|
|
66
|
+
try {
|
|
67
|
+
packet = encodeUdpPacket({ type, sessionId: session.sessionId, key: session.key, sequence: nextSequence(), header, payload });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logWarn('udp', `UDP packet rejected device=${session.deviceId}: ${error?.message || error}`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
socket.send(packet, address.port, address.address);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sendRendezvousRegister(session) {
|
|
77
|
+
if (!rendezvousHost) return;
|
|
78
|
+
const message = encodeRendezvousMessage({
|
|
79
|
+
type: 'rendezvous.register',
|
|
80
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
81
|
+
roomId: session.sessionId,
|
|
82
|
+
role: 'hub',
|
|
83
|
+
token: session.rendezvousToken
|
|
84
|
+
});
|
|
85
|
+
socket.send(message, rendezvousPort, rendezvousHost);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sessionForPacket(packet, rinfo) {
|
|
89
|
+
if (!Buffer.isBuffer(packet) || packet.length < 7) return null;
|
|
90
|
+
const sessionLength = packet[6];
|
|
91
|
+
if (!sessionLength || packet.length < 27 + sessionLength) return null;
|
|
92
|
+
const sessionId = packet.subarray(27, 27 + sessionLength).toString('utf8');
|
|
93
|
+
const session = [...sessions.values()].find(item => item.sessionId === sessionId);
|
|
94
|
+
if (!session) return null;
|
|
95
|
+
if (!session.clientEndpoint) session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
|
|
96
|
+
if (!sameEndpoint(session.clientEndpoint, rinfo)) return null;
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onRendezvousPeer(message, rinfo) {
|
|
101
|
+
const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
|
|
102
|
+
if (!session || !rendezvousHost || rinfo.address !== rendezvousHost) return;
|
|
103
|
+
const address = safeText(message.host, 128);
|
|
104
|
+
const port = Number(message.port);
|
|
105
|
+
if (!address || !Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
106
|
+
session.peerEndpoint = { address, port };
|
|
107
|
+
session.lastPeerAt = Date.now();
|
|
108
|
+
sendPacket(session, 'probe', { punch: true }, Buffer.alloc(0), session.peerEndpoint);
|
|
109
|
+
emit('UdpPeerEndpointObserved', { deviceId: session.deviceId, endpoint: `${address}:${port}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
socket.on('message', (payload, rinfo) => {
|
|
113
|
+
const rendezvous = decodeRendezvousMessage(payload);
|
|
114
|
+
if (rendezvous?.type === 'rendezvous.peer') {
|
|
115
|
+
onRendezvousPeer(rendezvous, rinfo);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const session = sessionForPacket(payload, rinfo);
|
|
119
|
+
if (!session) return;
|
|
120
|
+
const packet = decodeUdpPacket(payload, session.key);
|
|
121
|
+
if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) return;
|
|
122
|
+
session.lastPacketAt = Date.now();
|
|
123
|
+
if (packet.type === 'probe') {
|
|
124
|
+
session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
|
|
125
|
+
sendPacket(session, 'probe-ack', { observedHost: rinfo.address, observedPort: rinfo.port });
|
|
126
|
+
emit('UdpProbeReceived', { deviceId: session.deviceId });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (packet.type === 'ready') {
|
|
130
|
+
session.ready = true;
|
|
131
|
+
session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
|
|
132
|
+
sendPacket(session, 'pong', { state: 'ready' });
|
|
133
|
+
session.sendControl?.({
|
|
134
|
+
type: 'udp.session.ready',
|
|
135
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
136
|
+
sessionId: session.sessionId,
|
|
137
|
+
transport: 'udp-p2p',
|
|
138
|
+
ready: true,
|
|
139
|
+
endpoint: { host: advertiseHost, port: boundPort }
|
|
140
|
+
});
|
|
141
|
+
emit('UdpSessionReady', { deviceId: session.deviceId, state: 'udp-ready' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (packet.type === 'ping') {
|
|
145
|
+
sendPacket(session, 'pong', { now: Date.now() });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (packet.type !== 'frame') return;
|
|
149
|
+
const complete = session.reassembler.ingest(packet);
|
|
150
|
+
if (!complete) return;
|
|
151
|
+
session.framesReceived += 1;
|
|
152
|
+
frameHandler({
|
|
153
|
+
deviceId: session.deviceId,
|
|
154
|
+
sessionId: session.sessionId,
|
|
155
|
+
header: complete.header,
|
|
156
|
+
payload: complete.payload,
|
|
157
|
+
transport: 'udp-p2p'
|
|
158
|
+
});
|
|
159
|
+
sendPacket(session, 'pong', {
|
|
160
|
+
state: 'frame-ack',
|
|
161
|
+
frameSeq: Number(complete.header?.frameSeq || 0) || 0
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
socket.on('error', error => logWarn('udp', `UDP socket error: ${error?.message || error}`));
|
|
165
|
+
|
|
166
|
+
async function start() {
|
|
167
|
+
if (!enabled || started) return getStatus();
|
|
168
|
+
await new Promise((resolve, reject) => {
|
|
169
|
+
const onError = error => { socket.off('listening', onListening); reject(error); };
|
|
170
|
+
const onListening = () => { socket.off('error', onError); resolve(); };
|
|
171
|
+
socket.once('error', onError);
|
|
172
|
+
socket.once('listening', onListening);
|
|
173
|
+
socket.bind(requestedPort, bindHost);
|
|
174
|
+
});
|
|
175
|
+
started = true;
|
|
176
|
+
boundPort = socket.address().port;
|
|
177
|
+
logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
|
|
178
|
+
return getStatus();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
|
|
182
|
+
if (!enabled || capabilities?.udpP2p !== true || !started) return null;
|
|
183
|
+
const id = safeText(deviceId, 160);
|
|
184
|
+
if (!id) return null;
|
|
185
|
+
sessions.get(id)?.sendControl?.({ type: 'udp.session.close', reason: 'replaced' });
|
|
186
|
+
const key = createUdpSessionKey();
|
|
187
|
+
const session = {
|
|
188
|
+
deviceId: id,
|
|
189
|
+
tcpSessionId: safeText(tcpSessionId, 160),
|
|
190
|
+
sessionId: crypto.randomUUID(),
|
|
191
|
+
key,
|
|
192
|
+
keyBase64: key.toString('base64'),
|
|
193
|
+
rendezvousToken: crypto.randomBytes(16).toString('hex'),
|
|
194
|
+
sendControl,
|
|
195
|
+
clientEndpoint: null,
|
|
196
|
+
peerEndpoint: null,
|
|
197
|
+
replay: new UdpReplayWindow(),
|
|
198
|
+
reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
|
|
199
|
+
ready: false,
|
|
200
|
+
createdAt: Date.now(),
|
|
201
|
+
lastPacketAt: 0,
|
|
202
|
+
framesReceived: 0
|
|
203
|
+
};
|
|
204
|
+
sessions.set(id, session);
|
|
205
|
+
sendControl?.({
|
|
206
|
+
type: 'udp.session.offer',
|
|
207
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
208
|
+
sessionId: session.sessionId,
|
|
209
|
+
key: session.keyBase64,
|
|
210
|
+
host: advertiseHost,
|
|
211
|
+
port: boundPort,
|
|
212
|
+
expiresInMs: punchTimeoutMs + 15_000,
|
|
213
|
+
punchTimeoutMs,
|
|
214
|
+
maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
|
|
215
|
+
frameTimeoutMs,
|
|
216
|
+
maxPendingFrames,
|
|
217
|
+
rendezvous: rendezvousHost
|
|
218
|
+
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
|
|
219
|
+
: null
|
|
220
|
+
});
|
|
221
|
+
sendRendezvousRegister(session);
|
|
222
|
+
emit('UdpSessionCreated', { deviceId: id, state: 'udp-session-created' });
|
|
223
|
+
return { sessionId: session.sessionId, ready: false };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function unregisterDevice(deviceId, tcpSessionId = '') {
|
|
227
|
+
const id = safeText(deviceId, 160);
|
|
228
|
+
const session = sessions.get(id);
|
|
229
|
+
if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
|
|
230
|
+
sessions.delete(id);
|
|
231
|
+
emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getDeviceStatus(deviceId) {
|
|
235
|
+
const session = sessions.get(safeText(deviceId, 160));
|
|
236
|
+
if (!session) return { enabled, state: enabled ? 'disabled' : 'tcp-only', ready: false };
|
|
237
|
+
return {
|
|
238
|
+
enabled,
|
|
239
|
+
preferred: preferP2p,
|
|
240
|
+
state: session.ready ? 'udp-ready' : 'udp-session-created',
|
|
241
|
+
ready: session.ready,
|
|
242
|
+
sessionId: session.sessionId,
|
|
243
|
+
lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
|
|
244
|
+
framesReceived: session.framesReceived
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function close() {
|
|
249
|
+
sessions.clear();
|
|
250
|
+
if (!started) return;
|
|
251
|
+
await new Promise(resolve => socket.close(() => resolve()));
|
|
252
|
+
started = false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
start,
|
|
257
|
+
close,
|
|
258
|
+
registerDevice,
|
|
259
|
+
unregisterDevice,
|
|
260
|
+
getDeviceStatus,
|
|
261
|
+
getStatus,
|
|
262
|
+
setFrameHandler: handler => { frameHandler = typeof handler === 'function' ? handler : () => {}; },
|
|
263
|
+
setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
function getStatus() {
|
|
267
|
+
return {
|
|
268
|
+
enabled,
|
|
269
|
+
preferred: preferP2p,
|
|
270
|
+
started,
|
|
271
|
+
bindHost,
|
|
272
|
+
port: boundPort,
|
|
273
|
+
advertiseHost,
|
|
274
|
+
rendezvousHost,
|
|
275
|
+
rendezvousPort,
|
|
276
|
+
maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
|
|
277
|
+
sessionCount: sessions.size,
|
|
278
|
+
readySessionCount: [...sessions.values()].filter(session => session.ready).length
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|