@livedesk/hub 0.1.12 → 0.1.14
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 +284 -88
- package/src/server.js +173 -28
- package/src/transport/relay-hub-control.js +1004 -0
- package/src/transport/udp-hub-transport.js +146 -61
- package/src/transport/udp-rendezvous.js +2 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import dgram from 'node:dgram';
|
|
3
4
|
import {
|
|
4
5
|
UDP_MAX_DATAGRAM_BYTES,
|
|
5
6
|
UDP_P2P_PROTOCOL,
|
|
@@ -26,10 +27,15 @@ function numberEnv(value, min, max, fallback) {
|
|
|
26
27
|
return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.floor(number))) : fallback;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function sameEndpoint(left, right) {
|
|
30
|
-
return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
|
|
31
|
-
}
|
|
32
|
-
|
|
30
|
+
function sameEndpoint(left, right) {
|
|
31
|
+
return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizedAddress(value) {
|
|
35
|
+
const address = safeText(value, 128).toLowerCase();
|
|
36
|
+
return address.startsWith('::ffff:') ? address.slice(7) : address;
|
|
37
|
+
}
|
|
38
|
+
|
|
33
39
|
export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
|
|
34
40
|
const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, true);
|
|
35
41
|
const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, false);
|
|
@@ -37,7 +43,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
37
43
|
const requestedPort = numberEnv(env.LIVEDESK_UDP_PORT, 0, 65535, 0);
|
|
38
44
|
const advertiseHost = safeText(env.LIVEDESK_UDP_ADVERTISE_HOST || env.LIVEDESK_REMOTE_PUBLIC_HOST || '', 128);
|
|
39
45
|
const rendezvousHost = safeText(env.LIVEDESK_UDP_RENDEZVOUS_HOST || 'www.gnsoftech.com', 128);
|
|
40
|
-
const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
|
|
46
|
+
const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
|
|
47
|
+
const rendezvousRefreshMs = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_REFRESH_MS, 1000, 50_000, 25_000);
|
|
41
48
|
const punchTimeoutMs = numberEnv(env.LIVEDESK_UDP_PUNCH_TIMEOUT_MS, 500, 10_000, 3000);
|
|
42
49
|
const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 500);
|
|
43
50
|
const maxPendingFrames = numberEnv(env.LIVEDESK_UDP_MAX_PENDING_FRAMES, 1, 16, 2);
|
|
@@ -46,9 +53,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
46
53
|
const sessions = new Map();
|
|
47
54
|
let frameHandler = () => {};
|
|
48
55
|
let eventHandler = () => {};
|
|
49
|
-
let started = false;
|
|
50
|
-
let boundPort = requestedPort;
|
|
51
|
-
let sequence = crypto.randomInt(1, 0x7fffffff);
|
|
56
|
+
let started = false;
|
|
57
|
+
let boundPort = requestedPort;
|
|
58
|
+
let sequence = crypto.randomInt(1, 0x7fffffff);
|
|
59
|
+
let rendezvousAddresses = new Set();
|
|
60
|
+
let rendezvousRefreshTimer = null;
|
|
61
|
+
let rendezvousRefreshPromise = null;
|
|
52
62
|
|
|
53
63
|
function emit(type, event = {}) {
|
|
54
64
|
eventHandler(type, event);
|
|
@@ -60,8 +70,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
60
70
|
return sequence;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
function sendPacket(session, type, header = {}, payload = Buffer.alloc(0), address = session.
|
|
64
|
-
if (!address || !session) return false;
|
|
73
|
+
function sendPacket(session, type, header = {}, payload = Buffer.alloc(0), address = session.clientEndpoint || session.peerEndpoint) {
|
|
74
|
+
if (!address || !session) return false;
|
|
65
75
|
let packet;
|
|
66
76
|
try {
|
|
67
77
|
packet = encodeUdpPacket({ type, sessionId: session.sessionId, key: session.key, sequence: nextSequence(), header, payload });
|
|
@@ -73,34 +83,105 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
73
83
|
return true;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
function sendRendezvousRegister(session) {
|
|
77
|
-
if (!rendezvousHost) return;
|
|
78
|
-
const message = encodeRendezvousMessage({
|
|
79
|
-
type: 'rendezvous.register',
|
|
86
|
+
function sendRendezvousRegister(session) {
|
|
87
|
+
if (!rendezvousHost || rendezvousAddresses.size === 0 || !started) return false;
|
|
88
|
+
const message = encodeRendezvousMessage({
|
|
89
|
+
type: 'rendezvous.register',
|
|
80
90
|
protocol: UDP_P2P_PROTOCOL,
|
|
81
91
|
roomId: session.sessionId,
|
|
82
|
-
role: 'hub',
|
|
83
|
-
token: session.rendezvousToken
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
92
|
+
role: 'hub',
|
|
93
|
+
token: session.rendezvousToken
|
|
94
|
+
});
|
|
95
|
+
let sent = false;
|
|
96
|
+
for (const address of rendezvousAddresses) {
|
|
97
|
+
try {
|
|
98
|
+
socket.send(message, rendezvousPort, address, error => {
|
|
99
|
+
if (error) logWarn('udp', `UDP rendezvous registration failed: ${error?.message || error}`);
|
|
100
|
+
});
|
|
101
|
+
sent = true;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
logWarn('udp', `UDP rendezvous registration failed: ${error?.message || error}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return sent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function resolveRendezvousAddresses() {
|
|
110
|
+
if (!rendezvousHost) return false;
|
|
111
|
+
try {
|
|
112
|
+
const records = await lookup(rendezvousHost, { all: true, family: 4, verbatim: true });
|
|
113
|
+
const resolved = new Set(records.map(record => normalizedAddress(record.address)).filter(Boolean));
|
|
114
|
+
if (resolved.size === 0) throw new Error('no-ipv4-address');
|
|
115
|
+
rendezvousAddresses = resolved;
|
|
116
|
+
return true;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
logWarn('udp', `UDP rendezvous host resolution failed: ${error?.message || error}`);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function refreshRendezvousRegistrations(reason = 'periodic') {
|
|
124
|
+
if (!started || !rendezvousHost) return Promise.resolve(false);
|
|
125
|
+
if (rendezvousRefreshPromise) return rendezvousRefreshPromise;
|
|
126
|
+
rendezvousRefreshPromise = (async () => {
|
|
127
|
+
await resolveRendezvousAddresses();
|
|
128
|
+
if (!started) return false;
|
|
129
|
+
let refreshed = false;
|
|
130
|
+
for (const session of sessions.values()) {
|
|
131
|
+
refreshed = sendRendezvousRegister(session) || refreshed;
|
|
132
|
+
}
|
|
133
|
+
if (trace && refreshed) emit('UdpRendezvousRegistrationRefreshed', { reason });
|
|
134
|
+
return refreshed;
|
|
135
|
+
})().finally(() => {
|
|
136
|
+
rendezvousRefreshPromise = null;
|
|
137
|
+
});
|
|
138
|
+
return rendezvousRefreshPromise;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function startRendezvousRefreshTimer() {
|
|
142
|
+
if (!rendezvousHost || rendezvousRefreshTimer) return;
|
|
143
|
+
rendezvousRefreshTimer = setInterval(() => {
|
|
144
|
+
void refreshRendezvousRegistrations('periodic');
|
|
145
|
+
}, rendezvousRefreshMs);
|
|
146
|
+
rendezvousRefreshTimer.unref?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function stopRendezvousRefreshTimer() {
|
|
150
|
+
if (rendezvousRefreshTimer) clearInterval(rendezvousRefreshTimer);
|
|
151
|
+
rendezvousRefreshTimer = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isTrustedRendezvousSource(rinfo) {
|
|
155
|
+
return Number(rinfo?.port) === rendezvousPort
|
|
156
|
+
&& rendezvousAddresses.has(normalizedAddress(rinfo?.address));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function sessionForPacket(packet) {
|
|
160
|
+
if (!Buffer.isBuffer(packet) || packet.length < 7) return null;
|
|
161
|
+
const sessionLength = packet[6];
|
|
162
|
+
if (!sessionLength || packet.length < 27 + sessionLength) return null;
|
|
163
|
+
const sessionId = packet.subarray(27, 27 + sessionLength).toString('utf8');
|
|
164
|
+
return [...sessions.values()].find(item => item.sessionId === sessionId) || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function bindAuthenticatedClientEndpoint(session, rinfo) {
|
|
168
|
+
const endpoint = { address: rinfo.address, port: rinfo.port };
|
|
169
|
+
if (sameEndpoint(session.clientEndpoint, endpoint)) return false;
|
|
170
|
+
session.clientEndpoint = endpoint;
|
|
171
|
+
session.lastPeerAt = Date.now();
|
|
172
|
+
sendRendezvousRegister(session);
|
|
173
|
+
void refreshRendezvousRegistrations('authenticated-endpoint-change');
|
|
174
|
+
emit('UdpAuthenticatedEndpointObserved', {
|
|
175
|
+
deviceId: session.deviceId,
|
|
176
|
+
endpoint: `${endpoint.address}:${endpoint.port}`
|
|
177
|
+
});
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function onRendezvousPeer(message, rinfo) {
|
|
182
|
+
const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
|
|
183
|
+
if (!session || message.protocol !== UDP_P2P_PROTOCOL || !isTrustedRendezvousSource(rinfo)) return;
|
|
184
|
+
const address = safeText(message.host, 128);
|
|
104
185
|
const port = Number(message.port);
|
|
105
186
|
if (!address || !Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
106
187
|
session.peerEndpoint = { address, port };
|
|
@@ -112,24 +193,23 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
112
193
|
socket.on('message', (payload, rinfo) => {
|
|
113
194
|
const rendezvous = decodeRendezvousMessage(payload);
|
|
114
195
|
if (rendezvous?.type === 'rendezvous.peer') {
|
|
115
|
-
onRendezvousPeer(rendezvous, rinfo);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const session = sessionForPacket(payload
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
sendPacket(session, 'probe-ack', { observedHost: rinfo.address, observedPort: rinfo.port });
|
|
196
|
+
onRendezvousPeer(rendezvous, rinfo);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const session = sessionForPacket(payload);
|
|
200
|
+
if (!session) return;
|
|
201
|
+
const packet = decodeUdpPacket(payload, session.key);
|
|
202
|
+
if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) return;
|
|
203
|
+
bindAuthenticatedClientEndpoint(session, rinfo);
|
|
204
|
+
session.lastPacketAt = Date.now();
|
|
205
|
+
if (packet.type === 'probe') {
|
|
206
|
+
sendPacket(session, 'probe-ack', { observedHost: rinfo.address, observedPort: rinfo.port });
|
|
126
207
|
emit('UdpProbeReceived', { deviceId: session.deviceId });
|
|
127
208
|
return;
|
|
128
209
|
}
|
|
129
|
-
if (packet.type === 'ready') {
|
|
130
|
-
session.ready = true;
|
|
131
|
-
session
|
|
132
|
-
sendPacket(session, 'pong', { state: 'ready' });
|
|
210
|
+
if (packet.type === 'ready') {
|
|
211
|
+
session.ready = true;
|
|
212
|
+
sendPacket(session, 'pong', { state: 'ready' });
|
|
133
213
|
session.sendControl?.({
|
|
134
214
|
type: 'udp.session.ready',
|
|
135
215
|
protocol: UDP_P2P_PROTOCOL,
|
|
@@ -189,7 +269,9 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
189
269
|
logWarn('udp', `UDP socket buffer tuning was unavailable: ${error?.message || error}`);
|
|
190
270
|
}
|
|
191
271
|
logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
|
|
192
|
-
|
|
272
|
+
void refreshRendezvousRegistrations('startup');
|
|
273
|
+
startRendezvousRefreshTimer();
|
|
274
|
+
return getStatus();
|
|
193
275
|
}
|
|
194
276
|
|
|
195
277
|
function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
|
|
@@ -260,11 +342,12 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
260
342
|
};
|
|
261
343
|
}
|
|
262
344
|
|
|
263
|
-
async function close() {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
started = false;
|
|
345
|
+
async function close() {
|
|
346
|
+
stopRendezvousRefreshTimer();
|
|
347
|
+
sessions.clear();
|
|
348
|
+
if (!started) return;
|
|
349
|
+
started = false;
|
|
350
|
+
await new Promise(resolve => socket.close(() => resolve()));
|
|
268
351
|
}
|
|
269
352
|
|
|
270
353
|
return {
|
|
@@ -286,8 +369,10 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
|
|
|
286
369
|
bindHost,
|
|
287
370
|
port: boundPort,
|
|
288
371
|
advertiseHost,
|
|
289
|
-
rendezvousHost,
|
|
290
|
-
rendezvousPort,
|
|
372
|
+
rendezvousHost,
|
|
373
|
+
rendezvousPort,
|
|
374
|
+
rendezvousRefreshMs,
|
|
375
|
+
rendezvousResolvedAddressCount: rendezvousAddresses.size,
|
|
291
376
|
maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
|
|
292
377
|
sessionCount: sessions.size,
|
|
293
378
|
readySessionCount: [...sessions.values()].filter(session => session.ready).length
|
|
@@ -296,6 +296,7 @@ export function createUdpRendezvousServer({
|
|
|
296
296
|
if (!hub || !client) return;
|
|
297
297
|
send({
|
|
298
298
|
type: 'rendezvous.peer',
|
|
299
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
299
300
|
roomId,
|
|
300
301
|
role: 'client',
|
|
301
302
|
host: client.address,
|
|
@@ -303,6 +304,7 @@ export function createUdpRendezvousServer({
|
|
|
303
304
|
}, hub.address, hub.port);
|
|
304
305
|
send({
|
|
305
306
|
type: 'rendezvous.peer',
|
|
307
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
306
308
|
roomId,
|
|
307
309
|
role: 'hub',
|
|
308
310
|
host: hub.address,
|