@livedesk/hub 0.1.25 → 0.1.27

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/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();
@@ -2112,6 +2137,10 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
2112
2137
  ? frame.nativeEncoderHardwareAccelerated
2113
2138
  : null,
2114
2139
  nativeEncoderHardwareQueryStatus: frame.nativeEncoderHardwareQueryStatus || '',
2140
+ nativeCaptureDisplayId: Number.isSafeInteger(Number(frame.nativeCaptureDisplayId))
2141
+ && Number(frame.nativeCaptureDisplayId) > 0
2142
+ ? Number(frame.nativeCaptureDisplayId)
2143
+ : 0,
2115
2144
  nativeCaptureFps: Number(frame.nativeCaptureFps || 0) || 0,
2116
2145
  nativeCompressionFps: Number(frame.nativeCompressionFps || 0) || 0,
2117
2146
  nativeEncodedFps: Number(frame.nativeEncodedFps || 0) || 0,
@@ -3093,7 +3122,7 @@ app.post('/api/settings/agent/summary', async (req, res) => {
3093
3122
  }
3094
3123
  });
3095
3124
 
3096
- app.get('/api/remote/status', (_req, res) => {
3125
+ app.get('/api/remote/status', (_req, res) => {
3097
3126
  noStore(res);
3098
3127
  if (runtimeRole !== 'hub') {
3099
3128
  res.status(403).json({ ok: false, error: 'role-not-allowed' });
@@ -3111,9 +3140,134 @@ app.get('/api/remote/status', (_req, res) => {
3111
3140
  agentPackage: '@livedesk/client',
3112
3141
  update: getLiveDeskUpdateStatus()
3113
3142
  });
3114
- });
3115
-
3116
- app.get('/api/runtime/status', (_req, res) => {
3143
+ });
3144
+
3145
+ app.post('/api/remote/diagnostics/transport/start', async (req, res) => {
3146
+ noStore(res);
3147
+ if (!requireLocalTransportDiagnosticRequest(req, res)) return;
3148
+ const requestController = new AbortController();
3149
+ let responseFinished = false;
3150
+ let issuedLease = null;
3151
+ let releaseIssuedLeasePromise = null;
3152
+ const detachResponseLifecycle = () => {
3153
+ res.off('finish', markResponseFinished);
3154
+ res.off('close', abortPendingStart);
3155
+ };
3156
+ const releaseIssuedLease = reason => {
3157
+ if (!issuedLease?.leaseId || !issuedLease?.token) {
3158
+ return releaseIssuedLeasePromise || Promise.resolve(null);
3159
+ }
3160
+ if (!releaseIssuedLeasePromise) {
3161
+ const lease = issuedLease;
3162
+ releaseIssuedLeasePromise = Promise.resolve(remoteHub.stopTransportDiagnostic(
3163
+ lease.leaseId,
3164
+ lease.token,
3165
+ reason
3166
+ )).catch(() => null);
3167
+ }
3168
+ return releaseIssuedLeasePromise;
3169
+ };
3170
+ const markResponseFinished = () => {
3171
+ responseFinished = true;
3172
+ issuedLease = null;
3173
+ detachResponseLifecycle();
3174
+ };
3175
+ const abortPendingStart = () => {
3176
+ if (responseFinished) return;
3177
+ requestController.abort();
3178
+ void releaseIssuedLease('start-response-closed');
3179
+ detachResponseLifecycle();
3180
+ };
3181
+ res.once('finish', markResponseFinished);
3182
+ res.once('close', abortPendingStart);
3183
+ try {
3184
+ const result = await remoteHub.startTransportDiagnostic(
3185
+ String(req.body?.deviceId || ''),
3186
+ Number(req.body?.ttlMs || 0),
3187
+ { signal: requestController.signal });
3188
+ if (result?.leaseId && result?.token) {
3189
+ issuedLease = {
3190
+ leaseId: result.leaseId,
3191
+ token: result.token
3192
+ };
3193
+ }
3194
+ if (requestController.signal.aborted) {
3195
+ await releaseIssuedLease('start-response-closed');
3196
+ return;
3197
+ }
3198
+ res.status(result.ok ? 200 : 409).json(result);
3199
+ } catch (error) {
3200
+ if (requestController.signal.aborted) return;
3201
+ res.status(500).json({
3202
+ ok: false,
3203
+ error: error instanceof Error ? error.message : String(error)
3204
+ });
3205
+ } finally {
3206
+ if (res.writableFinished) {
3207
+ markResponseFinished();
3208
+ } else if (requestController.signal.aborted) {
3209
+ detachResponseLifecycle();
3210
+ }
3211
+ }
3212
+ });
3213
+
3214
+ app.get('/api/remote/diagnostics/transport/:leaseId', (req, res) => {
3215
+ noStore(res);
3216
+ if (!requireLocalTransportDiagnosticRequest(req, res)) return;
3217
+ const token = transportDiagnosticToken(req);
3218
+ if (!token) {
3219
+ res.status(401).json({ ok: false, error: 'transport-diagnostic-token-required' });
3220
+ return;
3221
+ }
3222
+ const result = remoteHub.getTransportDiagnostic(req.params.leaseId, token);
3223
+ res.status(result.ok ? 200 : 404).json(result);
3224
+ });
3225
+
3226
+ app.post('/api/remote/diagnostics/transport/:leaseId/phase', async (req, res) => {
3227
+ noStore(res);
3228
+ if (!requireLocalTransportDiagnosticRequest(req, res)) return;
3229
+ const token = transportDiagnosticToken(req);
3230
+ if (!token) {
3231
+ res.status(401).json({ ok: false, error: 'transport-diagnostic-token-required' });
3232
+ return;
3233
+ }
3234
+ try {
3235
+ const result = await remoteHub.setTransportDiagnosticPhase(
3236
+ req.params.leaseId,
3237
+ token,
3238
+ String(req.body?.phase || ''));
3239
+ res.status(result.ok ? 200 : 409).json(result);
3240
+ } catch (error) {
3241
+ res.status(500).json({
3242
+ ok: false,
3243
+ error: error instanceof Error ? error.message : String(error)
3244
+ });
3245
+ }
3246
+ });
3247
+
3248
+ app.delete('/api/remote/diagnostics/transport/:leaseId', async (req, res) => {
3249
+ noStore(res);
3250
+ if (!requireLocalTransportDiagnosticRequest(req, res)) return;
3251
+ const token = transportDiagnosticToken(req);
3252
+ if (!token) {
3253
+ res.status(401).json({ ok: false, error: 'transport-diagnostic-token-required' });
3254
+ return;
3255
+ }
3256
+ try {
3257
+ const result = await remoteHub.stopTransportDiagnostic(
3258
+ req.params.leaseId,
3259
+ token,
3260
+ String(req.body?.reason || 'runner-complete'));
3261
+ res.status(result.ok ? 200 : 409).json(result);
3262
+ } catch (error) {
3263
+ res.status(500).json({
3264
+ ok: false,
3265
+ error: error instanceof Error ? error.message : String(error)
3266
+ });
3267
+ }
3268
+ });
3269
+
3270
+ app.get('/api/runtime/status', (_req, res) => {
3117
3271
  noStore(res);
3118
3272
  res.json({ ok: true, ...hubRuntimeStatus(runtimeManager, { role: runtimeRole, deviceId: runtimeDeviceId, deviceName: runtimeDeviceName, roleSource: runtimeRoleSource }) });
3119
3273
  });
@@ -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
- session.peerEndpoint = { address, port };
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}` });
@@ -230,7 +263,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
230
263
  if (!complete) return;
231
264
  session.framesReceived += 1;
232
265
  const reassembly = session.reassembler.getStats();
233
- frameHandler({
266
+ const handled = frameHandler({
234
267
  deviceId: session.deviceId,
235
268
  sessionId: session.sessionId,
236
269
  header: {
@@ -243,11 +276,34 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
243
276
  },
244
277
  payload: complete.payload,
245
278
  transport: 'udp-p2p'
246
- });
247
- sendPacket(session, 'pong', {
248
- state: 'frame-ack',
249
- frameSeq: Number(complete.header?.frameSeq || 0) || 0
250
- });
279
+ });
280
+ const accepted = handled === true || handled?.accepted === true;
281
+ const proofId = safeText(complete.header?.frameTransportProofId, 96);
282
+ if (proofId) {
283
+ if (handled?.suppressProofResponse === true) return;
284
+ const proofAccepted = accepted && handled?.proofAccepted === true;
285
+ sendPacket(session, 'pong', {
286
+ state: proofAccepted ? 'frame-proof-ack' : 'frame-proof-reject',
287
+ proofId,
288
+ protocol: 'livedesk.frame-transport-proof.v1',
289
+ streamId: safeText(complete.header?.streamId, 128),
290
+ commandId: safeText(complete.header?.commandId, 128),
291
+ captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
292
+ monitorIndex: Number(complete.header?.monitorIndex ?? -1),
293
+ error: proofAccepted ? '' : safeText(handled?.error || 'frame-proof-rejected', 160)
294
+ });
295
+ return;
296
+ }
297
+ if (accepted) {
298
+ sendPacket(session, 'pong', {
299
+ state: 'frame-ack',
300
+ frameSeq: Number(complete.header?.frameSeq || 0) || 0,
301
+ streamId: safeText(complete.header?.streamId, 128),
302
+ commandId: safeText(complete.header?.commandId, 128),
303
+ captureGeneration: Number(complete.header?.captureGeneration || 0) || 0,
304
+ monitorIndex: Number(complete.header?.monitorIndex ?? -1)
305
+ });
306
+ }
251
307
  });
252
308
  socket.on('error', error => logWarn('udp', `UDP socket error: ${error?.message || error}`));
253
309
 
@@ -290,6 +346,8 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
290
346
  sendControl,
291
347
  clientEndpoint: null,
292
348
  peerEndpoint: null,
349
+ hubRendezvousEndpoint: null,
350
+ hubRendezvousObservedAt: 0,
293
351
  replay: new UdpReplayWindow(),
294
352
  reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
295
353
  ready: false,
@@ -327,7 +385,7 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
327
385
  emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
328
386
  }
329
387
 
330
- function getDeviceStatus(deviceId) {
388
+ function getDeviceStatus(deviceId) {
331
389
  const session = sessions.get(safeText(deviceId, 160));
332
390
  if (!session) return { enabled, state: enabled ? 'disabled' : 'tcp-only', ready: false };
333
391
  return {
@@ -340,7 +398,72 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
340
398
  framesReceived: session.framesReceived,
341
399
  ...session.reassembler.getStats()
342
400
  };
343
- }
401
+ }
402
+
403
+ async function refreshDeviceDiagnosticNetwork(deviceId, { timeoutMs = 3_000 } = {}) {
404
+ const session = sessions.get(safeText(deviceId, 160));
405
+ if (!session || !started || !rendezvousHost) return false;
406
+ const requestedAt = Date.now();
407
+ await resolveRendezvousAddresses();
408
+ if (!sendRendezvousRegister(session)) return false;
409
+ const deadline = requestedAt + numberEnv(timeoutMs, 250, 5_000, 3_000);
410
+ while (Date.now() < deadline) {
411
+ if (session.hubRendezvousObservedAt >= requestedAt) return true;
412
+ await new Promise(resolve => {
413
+ const timer = setTimeout(resolve, 40);
414
+ timer.unref?.();
415
+ });
416
+ }
417
+ return false;
418
+ }
419
+
420
+ function getDeviceDiagnosticNetwork(deviceId, { salt = '' } = {}) {
421
+ const session = sessions.get(safeText(deviceId, 160));
422
+ const clientAddress = normalizedAddress(session?.clientEndpoint?.address);
423
+ const normalizedHubAddress = normalizedAddress(session?.hubRendezvousEndpoint?.address);
424
+ const hubAddressObservedAt = session?.hubRendezvousObservedAt
425
+ ? new Date(session.hubRendezvousObservedAt).toISOString()
426
+ : '';
427
+ const digest = value => value
428
+ ? crypto.createHash('sha256')
429
+ .update(`${safeText(salt, 160)}:${value}`)
430
+ .digest('hex')
431
+ .slice(0, 16)
432
+ : '';
433
+ if (!session?.ready || !clientAddress) {
434
+ return {
435
+ evidence: 'authenticated-udp-endpoint-unavailable',
436
+ differentPublicEgress: null,
437
+ clientAddressHash: '',
438
+ hubAddressHash: digest(normalizedHubAddress),
439
+ hubAddressObservedAt,
440
+ hubEndpointSource: normalizedHubAddress
441
+ ? 'trusted-rendezvous-observation'
442
+ : 'unavailable'
443
+ };
444
+ }
445
+ const clientAddressPublic = isPublicIPv4(clientAddress);
446
+ const hubAddressPublic = isPublicIPv4(normalizedHubAddress);
447
+ return {
448
+ evidence: normalizedHubAddress
449
+ ? 'authenticated-udp-endpoint-rendezvous-observed'
450
+ : 'authenticated-udp-endpoint-hub-egress-unavailable',
451
+ differentPublicEgress: clientAddressPublic && hubAddressPublic
452
+ ? clientAddress !== normalizedHubAddress
453
+ : null,
454
+ clientAddressHash: digest(clientAddress),
455
+ hubAddressHash: digest(normalizedHubAddress),
456
+ clientAddressPublic,
457
+ hubAddressPublic,
458
+ endpointAuthenticated: true,
459
+ udpReady: session.ready === true,
460
+ observedAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
461
+ hubAddressObservedAt,
462
+ hubEndpointSource: normalizedHubAddress
463
+ ? 'trusted-rendezvous-observation'
464
+ : 'unavailable'
465
+ };
466
+ }
344
467
 
345
468
  async function close() {
346
469
  stopRendezvousRefreshTimer();
@@ -354,9 +477,11 @@ export function createHubUdpTransport({ env = process.env, logEvent = () => {},
354
477
  start,
355
478
  close,
356
479
  registerDevice,
357
- unregisterDevice,
358
- getDeviceStatus,
359
- getStatus,
480
+ unregisterDevice,
481
+ getDeviceStatus,
482
+ refreshDeviceDiagnosticNetwork,
483
+ getDeviceDiagnosticNetwork,
484
+ getStatus,
360
485
  setFrameHandler: handler => { frameHandler = typeof handler === 'function' ? handler : () => {}; },
361
486
  setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
362
487
  };
@@ -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