livedesk 0.1.284 → 0.1.286
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
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 || Number(rinfo.port) !== rendezvousPort) 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
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const UDP_P2P_PROTOCOL = 'livedesk.udp.p2p.v1';
|
|
4
|
+
export const UDP_P2P_VERSION = 1;
|
|
5
|
+
export const UDP_MAX_DATAGRAM_BYTES = 1200;
|
|
6
|
+
export const UDP_PACKET_TYPES = Object.freeze({
|
|
7
|
+
probe: 1,
|
|
8
|
+
'probe-ack': 2,
|
|
9
|
+
ready: 3,
|
|
10
|
+
ping: 4,
|
|
11
|
+
pong: 5,
|
|
12
|
+
frame: 6,
|
|
13
|
+
close: 7
|
|
14
|
+
});
|
|
15
|
+
const UDP_PACKET_TYPE_NAMES = new Map(Object.entries(UDP_PACKET_TYPES).map(([name, value]) => [value, name]));
|
|
16
|
+
const UDP_FIXED_HEADER_BYTES = 15;
|
|
17
|
+
const UDP_NONCE_BYTES = 12;
|
|
18
|
+
const UDP_TAG_BYTES = 16;
|
|
19
|
+
const UDP_MAX_SESSION_ID_BYTES = 96;
|
|
20
|
+
const UDP_MAX_HEADER_BYTES = 4096;
|
|
21
|
+
const UDP_MAX_PAYLOAD_BYTES = UDP_MAX_DATAGRAM_BYTES - UDP_FIXED_HEADER_BYTES - UDP_NONCE_BYTES - UDP_TAG_BYTES - 1;
|
|
22
|
+
|
|
23
|
+
function asBuffer(value) {
|
|
24
|
+
if (Buffer.isBuffer(value)) return value;
|
|
25
|
+
if (value instanceof Uint8Array) return Buffer.from(value);
|
|
26
|
+
if (typeof value === 'string') return Buffer.from(value, 'utf8');
|
|
27
|
+
return Buffer.alloc(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeKey(key) {
|
|
31
|
+
const value = asBuffer(key);
|
|
32
|
+
return value.length === 32 ? value : crypto.createHash('sha256').update(value).digest();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeSessionId(value) {
|
|
36
|
+
const sessionId = String(value || '').trim();
|
|
37
|
+
if (!sessionId || Buffer.byteLength(sessionId, 'utf8') > UDP_MAX_SESSION_ID_BYTES) {
|
|
38
|
+
throw new Error('invalid-udp-session-id');
|
|
39
|
+
}
|
|
40
|
+
return sessionId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function packetTypeCode(type) {
|
|
44
|
+
const code = typeof type === 'number' ? type : UDP_PACKET_TYPES[String(type || '').toLowerCase()];
|
|
45
|
+
if (!code || !UDP_PACKET_TYPE_NAMES.has(code)) throw new Error('invalid-udp-packet-type');
|
|
46
|
+
return code;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function encodeHeader(header) {
|
|
50
|
+
const bytes = Buffer.from(JSON.stringify(header && typeof header === 'object' ? header : {}), 'utf8');
|
|
51
|
+
if (bytes.length > UDP_MAX_HEADER_BYTES) throw new Error('udp-packet-header-too-large');
|
|
52
|
+
return bytes;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function deriveUdpSessionKey(keyMaterial) {
|
|
56
|
+
return normalizeKey(keyMaterial);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createUdpSessionKey() {
|
|
60
|
+
return crypto.randomBytes(32);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function encodeUdpPacket({ type, sessionId, key, sequence = 0, header = {}, payload = Buffer.alloc(0) }) {
|
|
64
|
+
const normalizedSessionId = normalizeSessionId(sessionId);
|
|
65
|
+
const sessionBytes = Buffer.from(normalizedSessionId, 'utf8');
|
|
66
|
+
const headerBytes = encodeHeader(header);
|
|
67
|
+
const payloadBytes = asBuffer(payload);
|
|
68
|
+
if (payloadBytes.length > UDP_MAX_PAYLOAD_BYTES) throw new Error('udp-packet-payload-too-large');
|
|
69
|
+
|
|
70
|
+
const preamble = Buffer.allocUnsafe(UDP_FIXED_HEADER_BYTES);
|
|
71
|
+
UDP_P2P_PROTOCOL.startsWith('livedesk') && Buffer.from('LDU1', 'ascii').copy(preamble, 0);
|
|
72
|
+
preamble[4] = UDP_P2P_VERSION;
|
|
73
|
+
preamble[5] = packetTypeCode(type);
|
|
74
|
+
preamble[6] = sessionBytes.length;
|
|
75
|
+
preamble.writeUInt16BE(headerBytes.length, 7);
|
|
76
|
+
preamble.writeUInt16BE(payloadBytes.length, 9);
|
|
77
|
+
preamble.writeUInt32BE((Number(sequence) >>> 0), 11);
|
|
78
|
+
const nonce = crypto.randomBytes(UDP_NONCE_BYTES);
|
|
79
|
+
const associatedData = Buffer.concat([preamble, nonce, sessionBytes, headerBytes]);
|
|
80
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', normalizeKey(key), nonce);
|
|
81
|
+
cipher.setAAD(associatedData);
|
|
82
|
+
const encrypted = Buffer.concat([cipher.update(payloadBytes), cipher.final()]);
|
|
83
|
+
const tag = cipher.getAuthTag();
|
|
84
|
+
const packet = Buffer.concat([associatedData, encrypted, tag]);
|
|
85
|
+
if (packet.length > UDP_MAX_DATAGRAM_BYTES) throw new Error('udp-datagram-too-large');
|
|
86
|
+
return packet;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function decodeUdpPacket(packet, key) {
|
|
90
|
+
const bytes = asBuffer(packet);
|
|
91
|
+
if (bytes.length < UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + UDP_TAG_BYTES) return null;
|
|
92
|
+
if (!bytes.subarray(0, 4).equals(Buffer.from('LDU1', 'ascii')) || bytes[4] !== UDP_P2P_VERSION) return null;
|
|
93
|
+
const type = UDP_PACKET_TYPE_NAMES.get(bytes[5]);
|
|
94
|
+
const sessionLength = bytes[6];
|
|
95
|
+
const headerLength = bytes.readUInt16BE(7);
|
|
96
|
+
const payloadLength = bytes.readUInt16BE(9);
|
|
97
|
+
const sequence = bytes.readUInt32BE(11);
|
|
98
|
+
const associatedLength = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + sessionLength + headerLength;
|
|
99
|
+
const expectedLength = associatedLength + payloadLength + UDP_TAG_BYTES;
|
|
100
|
+
if (!type || sessionLength < 1 || sessionLength > UDP_MAX_SESSION_ID_BYTES
|
|
101
|
+
|| headerLength > UDP_MAX_HEADER_BYTES || payloadLength > UDP_MAX_PAYLOAD_BYTES
|
|
102
|
+
|| expectedLength !== bytes.length) return null;
|
|
103
|
+
|
|
104
|
+
const associatedData = bytes.subarray(0, associatedLength);
|
|
105
|
+
const sessionStart = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES;
|
|
106
|
+
const sessionId = bytes.subarray(sessionStart, sessionStart + sessionLength).toString('utf8');
|
|
107
|
+
const headerStart = sessionStart + sessionLength;
|
|
108
|
+
let header;
|
|
109
|
+
try {
|
|
110
|
+
header = JSON.parse(bytes.subarray(headerStart, headerStart + headerLength).toString('utf8'));
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const nonce = bytes.subarray(UDP_FIXED_HEADER_BYTES, UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES);
|
|
115
|
+
const ciphertextStart = associatedLength;
|
|
116
|
+
const ciphertext = bytes.subarray(ciphertextStart, ciphertextStart + payloadLength);
|
|
117
|
+
const tag = bytes.subarray(ciphertextStart + payloadLength);
|
|
118
|
+
try {
|
|
119
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', normalizeKey(key), nonce);
|
|
120
|
+
decipher.setAAD(associatedData);
|
|
121
|
+
decipher.setAuthTag(tag);
|
|
122
|
+
const payload = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
123
|
+
return { type, sessionId, sequence, header, payload };
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class UdpReplayWindow {
|
|
130
|
+
constructor(size = 512) {
|
|
131
|
+
this.size = Math.max(32, Math.min(4096, Math.floor(Number(size) || 512)));
|
|
132
|
+
this.highest = -1;
|
|
133
|
+
this.seen = new Set();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
accept(sequence) {
|
|
137
|
+
const value = Number(sequence) >>> 0;
|
|
138
|
+
if (this.highest >= 0 && value + this.size < this.highest) return false;
|
|
139
|
+
if (this.seen.has(value)) return false;
|
|
140
|
+
this.seen.add(value);
|
|
141
|
+
if (this.highest < 0 || value > this.highest) this.highest = value;
|
|
142
|
+
const minimum = Math.max(0, this.highest - this.size);
|
|
143
|
+
for (const item of this.seen) {
|
|
144
|
+
if (item < minimum) this.seen.delete(item);
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class UdpFrameReassembler {
|
|
151
|
+
constructor({ maxPending = 2, timeoutMs = 80, maxFrameBytes = 8 * 1024 * 1024 } = {}) {
|
|
152
|
+
this.maxPending = Math.max(1, Math.min(16, Math.floor(Number(maxPending) || 2)));
|
|
153
|
+
this.timeoutMs = Math.max(20, Math.min(2000, Math.floor(Number(timeoutMs) || 80)));
|
|
154
|
+
this.maxFrameBytes = Math.max(64 * 1024, Math.min(16 * 1024 * 1024, Math.floor(Number(maxFrameBytes) || 8 * 1024 * 1024)));
|
|
155
|
+
this.pending = new Map();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ingest(packet, now = Date.now()) {
|
|
159
|
+
if (!packet || packet.type !== 'frame') return null;
|
|
160
|
+
this.expire(now);
|
|
161
|
+
const header = packet.header && typeof packet.header === 'object' ? packet.header : {};
|
|
162
|
+
const frameId = String(header.frameId || '').slice(0, 160);
|
|
163
|
+
const chunkIndex = Number(header.chunkIndex);
|
|
164
|
+
const chunkCount = Number(header.chunkCount);
|
|
165
|
+
if (!frameId || !Number.isInteger(chunkIndex) || !Number.isInteger(chunkCount)
|
|
166
|
+
|| chunkIndex < 0 || chunkCount < 1 || chunkCount > 4096 || chunkIndex >= chunkCount) return null;
|
|
167
|
+
let state = this.pending.get(frameId);
|
|
168
|
+
if (!state) {
|
|
169
|
+
while (this.pending.size >= this.maxPending) {
|
|
170
|
+
const oldest = this.pending.keys().next().value;
|
|
171
|
+
if (oldest === undefined) break;
|
|
172
|
+
this.pending.delete(oldest);
|
|
173
|
+
}
|
|
174
|
+
state = { createdAt: now, chunkCount, chunks: new Array(chunkCount), received: 0, totalBytes: 0, frameHeader: null };
|
|
175
|
+
this.pending.set(frameId, state);
|
|
176
|
+
}
|
|
177
|
+
if (state.chunkCount !== chunkCount || state.chunks[chunkIndex]) return null;
|
|
178
|
+
const payload = asBuffer(packet.payload);
|
|
179
|
+
state.chunks[chunkIndex] = payload;
|
|
180
|
+
state.received += 1;
|
|
181
|
+
state.totalBytes += payload.length;
|
|
182
|
+
if (header.frameHeader && typeof header.frameHeader === 'object') state.frameHeader = header.frameHeader;
|
|
183
|
+
if (state.totalBytes > this.maxFrameBytes) {
|
|
184
|
+
this.pending.delete(frameId);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
if (state.received !== state.chunkCount) return null;
|
|
188
|
+
this.pending.delete(frameId);
|
|
189
|
+
return {
|
|
190
|
+
frameId,
|
|
191
|
+
header: state.frameHeader || header.frameHeader || {},
|
|
192
|
+
payload: Buffer.concat(state.chunks, state.totalBytes)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
expire(now = Date.now()) {
|
|
197
|
+
for (const [frameId, state] of this.pending) {
|
|
198
|
+
if (now - state.createdAt > this.timeoutMs) this.pending.delete(frameId);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function encodeRendezvousMessage(message) {
|
|
204
|
+
const payload = Buffer.from(JSON.stringify(message && typeof message === 'object' ? message : {}), 'utf8');
|
|
205
|
+
if (payload.length > UDP_MAX_DATAGRAM_BYTES) throw new Error('rendezvous-message-too-large');
|
|
206
|
+
return payload;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function decodeRendezvousMessage(payload) {
|
|
210
|
+
try {
|
|
211
|
+
const value = JSON.parse(asBuffer(payload).toString('utf8'));
|
|
212
|
+
return value && typeof value === 'object' ? value : null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import dgram from 'node:dgram';
|
|
2
|
+
import { decodeRendezvousMessage, encodeRendezvousMessage, UDP_MAX_DATAGRAM_BYTES } from './udp-protocol.js';
|
|
3
|
+
|
|
4
|
+
function safeText(value, max = 160) {
|
|
5
|
+
return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizePort(value, fallback) {
|
|
9
|
+
const port = Number(value);
|
|
10
|
+
return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createUdpRendezvousServer({ host = '0.0.0.0', port = 5199, log = () => {} } = {}) {
|
|
14
|
+
const socket = dgram.createSocket('udp4');
|
|
15
|
+
const rooms = new Map();
|
|
16
|
+
let started = false;
|
|
17
|
+
let boundPort = normalizePort(port, 5199);
|
|
18
|
+
|
|
19
|
+
function send(message, address, targetPort) {
|
|
20
|
+
const payload = encodeRendezvousMessage(message);
|
|
21
|
+
socket.send(payload, targetPort, address);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function onMessage(payload, rinfo) {
|
|
25
|
+
if (!Buffer.isBuffer(payload) || payload.length > UDP_MAX_DATAGRAM_BYTES) return;
|
|
26
|
+
const message = decodeRendezvousMessage(payload);
|
|
27
|
+
if (message?.type !== 'rendezvous.register') return;
|
|
28
|
+
const roomId = safeText(message.roomId, 160);
|
|
29
|
+
const role = safeText(message.role, 16).toLowerCase();
|
|
30
|
+
const token = safeText(message.token, 256);
|
|
31
|
+
if (!roomId || !token || !['hub', 'client'].includes(role)) return;
|
|
32
|
+
let room = rooms.get(roomId);
|
|
33
|
+
if (!room || room.token !== token || Date.now() - room.updatedAt > 60_000) {
|
|
34
|
+
room = { token, peers: new Map(), updatedAt: Date.now() };
|
|
35
|
+
rooms.set(roomId, room);
|
|
36
|
+
}
|
|
37
|
+
room.updatedAt = Date.now();
|
|
38
|
+
room.peers.set(role, { address: rinfo.address, port: rinfo.port });
|
|
39
|
+
const peers = [...room.peers.entries()];
|
|
40
|
+
if (peers.length < 2) return;
|
|
41
|
+
for (const [peerRole, peer] of peers) {
|
|
42
|
+
const other = peers.find(([candidateRole]) => candidateRole !== peerRole)?.[1];
|
|
43
|
+
if (!other) continue;
|
|
44
|
+
send({ type: 'rendezvous.peer', roomId, role: peerRole === 'hub' ? 'client' : 'hub', host: other.address, port: other.port }, peer.address, peer.port);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
socket.on('message', onMessage);
|
|
49
|
+
socket.on('error', error => log(`UDP rendezvous error: ${error?.message || error}`));
|
|
50
|
+
|
|
51
|
+
async function start() {
|
|
52
|
+
if (started) return { host, port: boundPort };
|
|
53
|
+
await new Promise((resolve, reject) => {
|
|
54
|
+
const onError = error => { socket.off('listening', onListening); reject(error); };
|
|
55
|
+
const onListening = () => { socket.off('error', onError); resolve(); };
|
|
56
|
+
socket.once('error', onError);
|
|
57
|
+
socket.once('listening', onListening);
|
|
58
|
+
socket.bind(boundPort, host);
|
|
59
|
+
});
|
|
60
|
+
started = true;
|
|
61
|
+
boundPort = socket.address().port;
|
|
62
|
+
log(`UDP rendezvous listening on udp://${host}:${boundPort}`);
|
|
63
|
+
return { host, port: boundPort };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function close() {
|
|
67
|
+
rooms.clear();
|
|
68
|
+
if (!started) return;
|
|
69
|
+
await new Promise(resolve => socket.close(() => resolve()));
|
|
70
|
+
started = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
start,
|
|
75
|
+
close,
|
|
76
|
+
getStatus: () => ({ started, host, port: boundPort, rooms: rooms.size })
|
|
77
|
+
};
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livedesk",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.286",
|
|
4
4
|
"description": "LiveDesk Hub and client launcher",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
34
|
-
"@livedesk/client": "0.1.
|
|
34
|
+
"@livedesk/client": "0.1.143",
|
|
35
35
|
"@openai/codex-sdk": "0.144.5",
|
|
36
36
|
"cors": "^2.8.5",
|
|
37
37
|
"express": "^4.21.2",
|