@livedesk/hub 0.1.33 → 0.1.35

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "LiveDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
package/src/remote-hub.js CHANGED
@@ -47,7 +47,7 @@ const RECENT_TASK_LIMIT = 12;
47
47
  const RECENT_TASK_BATCH_LIMIT = 16;
48
48
  const RECENT_FRAME_CACHE_TTL_MS = 4000;
49
49
  const RECENT_THUMBNAIL_FRAME_CACHE_LIMIT = 2;
50
- const RECENT_LIVE_FRAME_CACHE_LIMIT = 1;
50
+ const RECENT_LIVE_FRAME_CACHE_LIMIT = 2;
51
51
  const RECENT_THUMBNAIL_FRAME_CACHE_MAX_BYTES = 2 * 1024 * 1024;
52
52
  const RECENT_LIVE_FRAME_CACHE_MAX_BYTES = 8 * 1024 * 1024;
53
53
  const LIVE_STREAM_PENDING_REUSE_MS = 5000;
@@ -1478,7 +1478,7 @@ function getRecentFrameCacheMaxBytes(frameKind) {
1478
1478
  : RECENT_LIVE_FRAME_CACHE_MAX_BYTES;
1479
1479
  }
1480
1480
 
1481
- function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1481
+ export function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1482
1482
  if (!Array.isArray(cache)) {
1483
1483
  return;
1484
1484
  }
@@ -1499,6 +1499,20 @@ function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1499
1499
  }
1500
1500
  }
1501
1501
 
1502
+ if (frameKind !== 'thumbnail' && cache.length > 1) {
1503
+ // Keep the newest frame plus the newest independently decodable key
1504
+ // frame. A browser can subscribe after capture has already started;
1505
+ // retaining only the latest 30 fps delta makes that browser wait for a
1506
+ // future GOP even though the Hub accepted a current key frame moments
1507
+ // earlier. Exact stream-owner matching below prevents stale replay.
1508
+ const latest = cache[0];
1509
+ const keyFrame = cache.find(entry =>
1510
+ entry !== latest
1511
+ && (entry?.frame?.isKeyFrame === true
1512
+ || safeString(entry?.frame?.chunkType, 20).toLowerCase() === 'key'));
1513
+ cache.splice(0, cache.length, ...[latest, keyFrame].filter(Boolean));
1514
+ }
1515
+
1502
1516
  for (let index = 0; index < cache.length; index += 1) {
1503
1517
  const entry = cache[index];
1504
1518
  const byteLength = Number(entry?.byteLength || entry?.frame?.payload?.length || 0) || 0;
@@ -1510,7 +1524,7 @@ function pruneRecentFrameCache(cache, frameKind, nowMs = Date.now()) {
1510
1524
  }
1511
1525
  }
1512
1526
 
1513
- function rememberRecentFramePayload(device, frameKind, frame) {
1527
+ export function rememberRecentFramePayload(device, frameKind, frame) {
1514
1528
  if (!device || !frame || !Buffer.isBuffer(frame.payload)) {
1515
1529
  return;
1516
1530
  }
@@ -1546,7 +1560,7 @@ function rememberRecentFramePayload(device, frameKind, frame) {
1546
1560
  pruneRecentFrameCache(cache, kind, nowMs);
1547
1561
  }
1548
1562
 
1549
- function isFramePayloadRequestMatch(frame, options = {}) {
1563
+ export function isFramePayloadRequestMatch(frame, options = {}) {
1550
1564
  if (!frame || !Buffer.isBuffer(frame.payload)) {
1551
1565
  return false;
1552
1566
  }
@@ -1565,10 +1579,37 @@ function isFramePayloadRequestMatch(frame, options = {}) {
1565
1579
  return false;
1566
1580
  }
1567
1581
 
1582
+ if (options.requireKeyFrame === true
1583
+ && frame.isKeyFrame !== true
1584
+ && safeString(frame.chunkType, 20).toLowerCase() !== 'key') {
1585
+ return false;
1586
+ }
1587
+
1588
+ for (const key of ['streamId', 'sessionId', 'commandId', 'streamPurpose']) {
1589
+ const expected = safeString(options[key], 160);
1590
+ if (expected && safeString(frame[key], 160) !== expected) {
1591
+ return false;
1592
+ }
1593
+ }
1594
+
1595
+ const requestedCaptureGeneration = Number(options.captureGeneration);
1596
+ if (Number.isFinite(requestedCaptureGeneration)
1597
+ && requestedCaptureGeneration > 0
1598
+ && Number(frame.captureGeneration || 0) !== requestedCaptureGeneration) {
1599
+ return false;
1600
+ }
1601
+
1602
+ const requestedMonitorIndex = Number(options.monitorIndex);
1603
+ if (Number.isFinite(requestedMonitorIndex)
1604
+ && requestedMonitorIndex >= 0
1605
+ && Number(frame.monitorIndex ?? -1) !== requestedMonitorIndex) {
1606
+ return false;
1607
+ }
1608
+
1568
1609
  return true;
1569
1610
  }
1570
1611
 
1571
- function findRecentFramePayload(device, frameKind, options = {}) {
1612
+ export function findRecentFramePayload(device, frameKind, options = {}) {
1572
1613
  const kind = frameKind === 'thumbnail' ? 'thumbnail' : 'live';
1573
1614
  const latest = kind === 'thumbnail'
1574
1615
  ? device?.latestThumbnail
@@ -1579,7 +1620,14 @@ function findRecentFramePayload(device, frameKind, options = {}) {
1579
1620
 
1580
1621
  const token = safeString(options.token, 128);
1581
1622
  const requestedSeq = Number(options.frameSeq);
1582
- if (!token && !Number.isFinite(requestedSeq)) {
1623
+ const ownerQuery = options.requireKeyFrame === true
1624
+ || ['streamId', 'sessionId', 'commandId', 'streamPurpose']
1625
+ .some(key => safeString(options[key], 160))
1626
+ || (Number.isFinite(Number(options.captureGeneration))
1627
+ && Number(options.captureGeneration) > 0)
1628
+ || (Number.isFinite(Number(options.monitorIndex))
1629
+ && Number(options.monitorIndex) >= 0);
1630
+ if (!token && !Number.isFinite(requestedSeq) && !ownerQuery) {
1583
1631
  return null;
1584
1632
  }
1585
1633
 
package/src/server.js CHANGED
@@ -2738,6 +2738,11 @@ function replaceExpectedFrameBindingForClient(ws, deviceId, expectedBinding = nu
2738
2738
  && bindingIdentity === frameLaneBindingIdentity(currentExpectedBinding)) {
2739
2739
  expectedBinding.readySent = currentExpectedBinding?.readySent === true
2740
2740
  || expectedBinding.readySent === true;
2741
+ expectedBinding.replayedKeyFrameSeq = Math.max(
2742
+ 0,
2743
+ Number(currentExpectedBinding?.replayedKeyFrameSeq || 0),
2744
+ Number(expectedBinding.replayedKeyFrameSeq || 0)
2745
+ );
2741
2746
  ws.liveDeskExpectedStreamBindingsByDeviceId.set(normalizedDeviceId, expectedBinding);
2742
2747
  if (!lane.bindingEpochsByDeviceId.has(normalizedDeviceId)) {
2743
2748
  lane.bindingEpochsByDeviceId.set(normalizedDeviceId, 1);
@@ -2776,6 +2781,50 @@ function replaceExpectedFrameBindingForClient(ws, deviceId, expectedBinding = nu
2776
2781
  return bindingIdentity;
2777
2782
  }
2778
2783
 
2784
+ function replayExpectedBindingKeyFrame(ws, expectedBinding) {
2785
+ if (!ws || ws.readyState !== ws.OPEN || !expectedBinding) {
2786
+ return false;
2787
+ }
2788
+ const deviceId = String(expectedBinding.deviceId || '').trim();
2789
+ const currentBinding = ws.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
2790
+ ? ws.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
2791
+ : null;
2792
+ const bindingIdentity = frameLaneBindingIdentity(expectedBinding);
2793
+ if (!deviceId
2794
+ || !bindingIdentity
2795
+ || bindingIdentity !== frameLaneBindingIdentity(currentBinding)) {
2796
+ return false;
2797
+ }
2798
+ const cached = remoteHub.getFramePayload(deviceId, 'live', {
2799
+ requireKeyFrame: true,
2800
+ streamId: expectedBinding.streamId,
2801
+ sessionId: expectedBinding.sessionId,
2802
+ commandId: expectedBinding.commandId,
2803
+ captureGeneration: expectedBinding.captureGeneration,
2804
+ streamPurpose: expectedBinding.streamPurpose,
2805
+ monitorIndex: expectedBinding.monitorIndex
2806
+ });
2807
+ const frameSeq = Number(cached?.frame?.frameSeq || 0);
2808
+ if (!cached
2809
+ || cached.frame?.isKeyFrame !== true
2810
+ || !Number.isSafeInteger(frameSeq)
2811
+ || frameSeq <= 0
2812
+ || Number(currentBinding?.replayedKeyFrameSeq || 0) === frameSeq) {
2813
+ return false;
2814
+ }
2815
+ currentBinding.replayedKeyFrameSeq = frameSeq;
2816
+ broadcastRemoteBinaryFrame({
2817
+ kind: 'live',
2818
+ deviceId,
2819
+ frame: cached.frame,
2820
+ payload: cached.payload,
2821
+ mimeType: cached.mimeType,
2822
+ byteLength: cached.byteLength,
2823
+ replayedForBinding: true
2824
+ });
2825
+ return true;
2826
+ }
2827
+
2779
2828
  function dropQueuedFrameAtForLane(lane, dropIndex) {
2780
2829
  const droppedBefore = lane.dropped;
2781
2830
  const droppedItem = removeFrameLaneQueueItem(lane, dropIndex);
@@ -3269,6 +3318,7 @@ function startFrameSubscriptionLive(
3269
3318
  continue;
3270
3319
  }
3271
3320
  cancelPendingFrameStreamStop(deviceId, result.streamId, String(liveOptions.streamPurpose || 'wall'));
3321
+ const replayedKeyFrame = replayExpectedBindingKeyFrame(ws, expectedBinding);
3272
3322
  started.push({
3273
3323
  deviceId: expectedBinding.deviceId,
3274
3324
  sessionId: expectedBinding.sessionId,
@@ -3277,7 +3327,8 @@ function startFrameSubscriptionLive(
3277
3327
  commandId: expectedBinding.commandId,
3278
3328
  captureGeneration: expectedBinding.captureGeneration,
3279
3329
  monitorIndex: expectedBinding.monitorIndex,
3280
- ready: reusedFrameReady,
3330
+ ready: expectedBinding.readySent === true,
3331
+ replayedKeyFrame,
3281
3332
  fps: result.fps,
3282
3333
  reused: result.reused === true
3283
3334
  });
@@ -17,10 +17,18 @@ const HANDSHAKE_TIMEOUT_MS = 5_000;
17
17
  const REPLAY_TTL_MS = 2 * 60 * 1000;
18
18
  const MAX_REPLAY_ENTRIES = 8192;
19
19
  const DEFAULT_MAX_PENDING_HANDSHAKES = 128;
20
- const DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE = 60;
20
+ // One Client can open control, frame, input, file, and audio channels.
21
+ // A 200-computer site behind one NAT can therefore legitimately create
22
+ // more than 1,000 authenticated handshakes during a coordinated restart.
23
+ // Keep a generous raw-IP burst ceiling; actual abuse blocking below is
24
+ // failure-scoped by device whenever a stable device id is available.
25
+ const DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE = 2400;
21
26
  const DEFAULT_MAX_CONSECUTIVE_FAILURES = 8;
22
27
  const DEFAULT_IP_BLOCK_MS = 60_000;
23
28
  const MAX_TRACKED_IPS = 4096;
29
+ const MAX_TRACKED_DEVICE_FAILURES = 16384;
30
+ const BLOCKED_IP_RECOVERY_PROBE_MS = 2_000;
31
+ const IP_SCOPED_FAILURES = new Set(['secure-handshake-json-invalid', 'secure-handshake-too-large']);
24
32
 
25
33
  function directError(code) {
26
34
  const error = new Error(code);
@@ -60,6 +68,26 @@ function safeHandshakeError(error) {
60
68
  const code = clean(error?.code || error?.message, 100).toLowerCase();
61
69
  return /^[-a-z0-9]+$/.test(code) ? code : 'secure-handshake-rejected';
62
70
  }
71
+ function normalizeIpAddress(value) {
72
+ return clean(value, 100).replace(/^::ffff:/, '').toLowerCase() || 'unknown';
73
+ }
74
+
75
+ function isPrivateNetworkAddress(value) {
76
+ const address = normalizeIpAddress(value);
77
+ if (address === '127.0.0.1' || address === '::1' || address === 'localhost') return true;
78
+ if (address.startsWith('fe80:') || address.startsWith('fc') || address.startsWith('fd')) return true;
79
+ const parts = address.split('.').map(part => Number(part));
80
+ return parts.length === 4
81
+ && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
82
+ && (parts[0] === 10
83
+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
84
+ || (parts[0] === 192 && parts[1] === 168));
85
+ }
86
+
87
+ function failureOwnerKey(ip, deviceId) {
88
+ const normalizedDeviceId = clean(deviceId, 128);
89
+ return normalizedDeviceId ? `${normalizeIpAddress(ip)}|${normalizedDeviceId}` : '';
90
+ }
63
91
 
64
92
  export function createSecureDirectAcceptor({
65
93
  authority,
@@ -77,6 +105,7 @@ export function createSecureDirectAcceptor({
77
105
  }
78
106
  const replayCache = new Map();
79
107
  const ipStates = new Map();
108
+ const deviceFailureStates = new Map();
80
109
  const maxPendingHandshakes = Math.max(1, Number(abuseLimits.maxPendingHandshakes)
81
110
  || DEFAULT_MAX_PENDING_HANDSHAKES);
82
111
  const maxConnectionsPerIpPerMinute = Math.max(1, Number(abuseLimits.maxConnectionsPerIpPerMinute)
@@ -93,10 +122,13 @@ export function createSecureDirectAcceptor({
93
122
  let capacityRejections = 0;
94
123
  let rateLimitRejections = 0;
95
124
  let blockedIpRejections = 0;
125
+ let blockedDeviceRejections = 0;
126
+ let compatibilityRejections = 0;
127
+ let recoveryProbeAdmissions = 0;
96
128
  let probeClosures = 0;
97
129
 
98
130
  function remoteAddress(rawSocket) {
99
- return clean(rawSocket?.remoteAddress, 100).replace(/^::ffff:/, '') || 'unknown';
131
+ return normalizeIpAddress(rawSocket?.remoteAddress);
100
132
  }
101
133
 
102
134
  function pruneIpStates(current) {
@@ -121,6 +153,7 @@ export function createSecureDirectAcceptor({
121
153
  connectionCount: 0,
122
154
  consecutiveFailures: 0,
123
155
  blockedUntil: 0,
156
+ nextRecoveryProbeAt: 0,
124
157
  lastSeenAt: current
125
158
  };
126
159
  ipStates.set(ip, state);
@@ -133,11 +166,51 @@ export function createSecureDirectAcceptor({
133
166
  return state;
134
167
  }
135
168
 
169
+ function pruneDeviceFailureStates(current) {
170
+ for (const [key, state] of deviceFailureStates) {
171
+ if (current - state.lastSeenAt >= Math.max(ipBlockMs * 2, 5 * 60_000)) {
172
+ deviceFailureStates.delete(key);
173
+ }
174
+ }
175
+ while (deviceFailureStates.size > MAX_TRACKED_DEVICE_FAILURES) {
176
+ const oldest = deviceFailureStates.keys().next().value;
177
+ if (!oldest) break;
178
+ deviceFailureStates.delete(oldest);
179
+ }
180
+ }
181
+
182
+ function getDeviceFailureState(ip, deviceId, current) {
183
+ const key = failureOwnerKey(ip, deviceId);
184
+ if (!key) return null;
185
+ pruneDeviceFailureStates(current);
186
+ let state = deviceFailureStates.get(key);
187
+ if (!state) {
188
+ state = { consecutiveFailures: 0, blockedUntil: 0, lastSeenAt: current };
189
+ deviceFailureStates.set(key, state);
190
+ }
191
+ state.lastSeenAt = current;
192
+ return state;
193
+ }
194
+
195
+ function deviceAdmissionError(ip, deviceId, current) {
196
+ const state = getDeviceFailureState(ip, deviceId, current);
197
+ if (state?.blockedUntil > current) {
198
+ blockedDeviceRejections += 1;
199
+ return 'secure-device-temporarily-blocked';
200
+ }
201
+ return '';
202
+ }
203
+
136
204
  function admissionError(ip, current) {
137
205
  const state = getIpState(ip, current);
138
206
  if (state.blockedUntil > current) {
139
- blockedIpRejections += 1;
140
- return 'secure-ip-temporarily-blocked';
207
+ if (state.nextRecoveryProbeAt <= current) {
208
+ state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
209
+ recoveryProbeAdmissions += 1;
210
+ } else {
211
+ blockedIpRejections += 1;
212
+ return 'secure-ip-temporarily-blocked';
213
+ }
141
214
  }
142
215
  if (pendingHandshakes >= maxPendingHandshakes) {
143
216
  capacityRejections += 1;
@@ -151,18 +224,39 @@ export function createSecureDirectAcceptor({
151
224
  return '';
152
225
  }
153
226
 
154
- function recordFailure(ip, current) {
227
+ function recordFailure(ip, deviceId, code, current) {
228
+ if (code === 'secure-client-hello-required' && isPrivateNetworkAddress(ip)) {
229
+ compatibilityRejections += 1;
230
+ return;
231
+ }
232
+ const deviceState = !IP_SCOPED_FAILURES.has(code)
233
+ ? getDeviceFailureState(ip, deviceId, current)
234
+ : null;
235
+ if (deviceState) {
236
+ deviceState.consecutiveFailures += 1;
237
+ if (deviceState.consecutiveFailures >= maxConsecutiveFailures) {
238
+ deviceState.blockedUntil = Math.max(deviceState.blockedUntil, current + ipBlockMs);
239
+ }
240
+ return;
241
+ }
155
242
  const state = getIpState(ip, current);
156
243
  state.consecutiveFailures += 1;
157
244
  if (state.consecutiveFailures >= maxConsecutiveFailures) {
158
245
  state.blockedUntil = Math.max(state.blockedUntil, current + ipBlockMs);
246
+ state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
159
247
  }
160
248
  }
161
249
 
162
- function recordSuccess(ip, current) {
250
+ function recordSuccess(ip, deviceId, current) {
163
251
  const state = getIpState(ip, current);
164
252
  state.consecutiveFailures = 0;
165
253
  state.blockedUntil = 0;
254
+ state.nextRecoveryProbeAt = 0;
255
+ const deviceState = getDeviceFailureState(ip, deviceId, current);
256
+ if (deviceState) {
257
+ deviceState.consecutiveFailures = 0;
258
+ deviceState.blockedUntil = 0;
259
+ }
166
260
  }
167
261
 
168
262
  function rejectSocket(rawSocket, code, ip, observedDeviceId = '') {
@@ -360,12 +454,12 @@ export function createSecureDirectAcceptor({
360
454
  settled = true;
361
455
  cleanup();
362
456
  const code = safeHandshakeError(error);
363
- if (code === 'secure-handshake-closed' && bytes === 0) {
457
+ if ((code === 'secure-handshake-closed' || code === 'secure-handshake-timeout') && bytes === 0) {
364
458
  probeClosures += 1;
365
459
  try { rawSocket.destroy(); } catch {}
366
460
  return;
367
461
  }
368
- recordFailure(ip, Math.floor(now()));
462
+ recordFailure(ip, observedDeviceId, code, Math.floor(now()));
369
463
  rejectSocket(rawSocket, code, ip, observedDeviceId);
370
464
  };
371
465
 
@@ -390,7 +484,12 @@ export function createSecureDirectAcceptor({
390
484
  let hello;
391
485
  try {
392
486
  hello = JSON.parse(combined.subarray(0, newline).toString('utf8'));
393
- observedDeviceId = clean(hello?.deviceId, 128);
487
+ observedDeviceId = clean(hello?.deviceId, 128); const deviceDenied = deviceAdmissionError(ip, observedDeviceId, Math.floor(now()));
488
+ if (deviceDenied) {
489
+ settled = false;
490
+ fail(directError(deviceDenied));
491
+ return;
492
+ }
394
493
  } catch {
395
494
  settled = false;
396
495
  fail(directError('secure-handshake-json-invalid'));
@@ -398,7 +497,7 @@ export function createSecureDirectAcceptor({
398
497
  }
399
498
  try {
400
499
  handleHello(rawSocket, hello, combined.subarray(newline + 1));
401
- recordSuccess(ip, Math.floor(now()));
500
+ recordSuccess(ip, observedDeviceId, Math.floor(now()));
402
501
  } catch (error) {
403
502
  // settled is reset only for the common failure path so it can emit the
404
503
  // bounded rejection response and release the raw socket.
@@ -424,9 +523,13 @@ export function createSecureDirectAcceptor({
424
523
  replayRejections,
425
524
  pendingHandshakes,
426
525
  trackedIpCount: ipStates.size,
526
+ trackedDeviceFailureCount: deviceFailureStates.size,
427
527
  capacityRejections,
428
528
  rateLimitRejections,
429
529
  blockedIpRejections,
530
+ blockedDeviceRejections,
531
+ compatibilityRejections,
532
+ recoveryProbeAdmissions,
430
533
  probeClosures,
431
534
  limits: {
432
535
  maxPendingHandshakes,