@livedesk/hub 0.1.34 → 0.1.36
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 +32 -32
- package/src/server.js +33 -0
- package/src/transport/secure-direct-acceptor.js +113 -10
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@livedesk/hub",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "LiveDesk local Hub API and browser frame bridge",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "src/server.js",
|
|
7
|
-
"files": [
|
|
8
|
-
"src/",
|
|
9
|
-
"README.md"
|
|
10
|
-
],
|
|
11
|
-
"scripts": {
|
|
12
|
-
"dev": "node src/server.js",
|
|
13
|
-
"start": "node src/server.js",
|
|
14
|
-
"check": "node --check src/server.js && node --check src/remote-hub.js",
|
|
15
|
-
"prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
|
|
16
|
-
},
|
|
17
|
-
"dependencies": {
|
|
18
|
-
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
19
|
-
"@livedesk/runtime-core": "0.1.4",
|
|
20
|
-
"@openai/codex-sdk": "0.145.0",
|
|
21
|
-
"cors": "^2.8.5",
|
|
22
|
-
"express": "^4.21.2",
|
|
23
|
-
"path-to-regexp": "0.1.13",
|
|
24
|
-
"ws": "^8.18.3"
|
|
25
|
-
},
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=20"
|
|
28
|
-
},
|
|
29
|
-
"publishConfig": {
|
|
30
|
-
"access": "public"
|
|
31
|
-
}
|
|
32
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@livedesk/hub",
|
|
3
|
+
"version": "0.1.36",
|
|
4
|
+
"description": "LiveDesk local Hub API and browser frame bridge",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/server.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"src/",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "node src/server.js",
|
|
13
|
+
"start": "node src/server.js",
|
|
14
|
+
"check": "node --check src/server.js && node --check src/remote-hub.js",
|
|
15
|
+
"prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
19
|
+
"@livedesk/runtime-core": "0.1.4",
|
|
20
|
+
"@openai/codex-sdk": "0.145.0",
|
|
21
|
+
"cors": "^2.8.5",
|
|
22
|
+
"express": "^4.21.2",
|
|
23
|
+
"path-to-regexp": "0.1.13",
|
|
24
|
+
"ws": "^8.18.3"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/server.js
CHANGED
|
@@ -647,6 +647,36 @@ async function getRuntimeAccessToken() {
|
|
|
647
647
|
}
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
+
async function restorePersistedHubSessionAtStartup() {
|
|
651
|
+
if (runtimeRole !== 'hub' || runtimeManager.getSnapshot().authenticated) {
|
|
652
|
+
return { ok: true, skipped: true, reason: 'not-required' };
|
|
653
|
+
}
|
|
654
|
+
const persisted = readPersistedRuntimeSession();
|
|
655
|
+
const normalized = normalizeRuntimeAuthSession(persisted, { requireRefreshToken: true });
|
|
656
|
+
const userId = String(normalized.ok ? normalized.session?.user?.id || '' : '').trim();
|
|
657
|
+
if (!normalized.ok || !userId) {
|
|
658
|
+
return { ok: false, skipped: true, reason: normalized.ok ? 'saved-user-missing' : normalized.error };
|
|
659
|
+
}
|
|
660
|
+
runtimeSessionGeneration += 1;
|
|
661
|
+
runtimeRefreshPromise = null;
|
|
662
|
+
runtimeAccessToken = String(normalized.session.access_token || '').trim();
|
|
663
|
+
runtimeRefreshToken = String(normalized.session.refresh_token || '').trim();
|
|
664
|
+
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(normalized.session.expires_at);
|
|
665
|
+
const accessToken = await getRuntimeAccessToken();
|
|
666
|
+
if (!accessToken) throw new Error('saved-hub-session-access-token-unavailable');
|
|
667
|
+
runtimeManager.setAuthenticated(true, userId);
|
|
668
|
+
persistRuntimeSession({
|
|
669
|
+
access_token: runtimeAccessToken,
|
|
670
|
+
refresh_token: runtimeRefreshToken,
|
|
671
|
+
expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000),
|
|
672
|
+
user: normalized.session.user
|
|
673
|
+
});
|
|
674
|
+
const hostTarget = await publishHubHostTargetWithPendingRoleTakeover('saved-session-restored');
|
|
675
|
+
startHubHostTargetLeaseRenewal();
|
|
676
|
+
console.log(`[LiveDesk Hub] Saved sign-in restored locally; host target publication ${hostTarget?.ok === false ? 'will retry' : 'is active'}.`);
|
|
677
|
+
return { ok: true, skipped: false, hostTarget };
|
|
678
|
+
}
|
|
679
|
+
|
|
650
680
|
function isLoopbackBindHost(value) {
|
|
651
681
|
const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
|
|
652
682
|
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
@@ -6471,6 +6501,9 @@ httpServer.listen(httpPort, httpHost, () => {
|
|
|
6471
6501
|
console.log(`[LiveDesk Hub] Version ${managerVersion}`);
|
|
6472
6502
|
console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
|
|
6473
6503
|
console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
|
|
6504
|
+
void restorePersistedHubSessionAtStartup().catch(error => {
|
|
6505
|
+
console.warn(`[LiveDesk Hub] Saved sign-in startup restore deferred to the Hub page: ${error?.message || error}`);
|
|
6506
|
+
});
|
|
6474
6507
|
});
|
|
6475
6508
|
|
|
6476
6509
|
const roleWatchTimer = runtimeRole === 'hub'
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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,
|