@livedesk/hub 0.1.30 → 0.1.32
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 +2 -2
- package/src/agents/agent-audit-store.js +16 -6
- package/src/agents/agent-permissions.js +9 -3
- package/src/agents/agent-tool-registry.js +19 -1
- package/src/captures/capture-store.js +50 -3
- package/src/filesystem/shared-folders.js +8 -0
- package/src/filesystem/transfer-jobs.js +36 -5
- package/src/live-desk-update.js +87 -19
- package/src/remote-hub.js +722 -173
- package/src/security/device-credential-authority.js +406 -0
- package/src/security/security-audit-store.js +260 -0
- package/src/server.js +1012 -277
- package/src/settings/settings-schema.js +19 -39
- package/src/transport/relay-hub-control.js +330 -3
- package/src/transport/secure-direct-acceptor.js +433 -0
- package/src/transport/udp-hub-transport.js +28 -5
- package/src/transport/udp-rendezvous.js +179 -13
package/src/server.js
CHANGED
|
@@ -39,15 +39,17 @@ import { createAgentDeviceScope, resolveAgentTargetIds } from './agents/agent-de
|
|
|
39
39
|
import { AgentRuntimeError } from './agents/agent-runtime-error.js';
|
|
40
40
|
import { AGENT_PERMISSION_MODES, createAgentPermissionPolicy, evaluateAgentToolPermission, hashAgentPermissionPolicy } from './agents/agent-permissions.js';
|
|
41
41
|
import { createAgentPermissionStore } from './agents/agent-permission-store.js';
|
|
42
|
-
import { createAgentAuditStore } from './agents/agent-audit-store.js';
|
|
43
|
-
import {
|
|
42
|
+
import { createAgentAuditStore } from './agents/agent-audit-store.js';
|
|
43
|
+
import { createSecurityAuditStore } from './security/security-audit-store.js';
|
|
44
|
+
import { getAgentToolDefinition, isRetiredAgentMutatingToolName } from './agents/agent-tool-registry.js';
|
|
44
45
|
import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
|
|
45
46
|
import { effectiveDevicePolicy } from './settings/settings-schema.js';
|
|
46
47
|
import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
|
|
47
48
|
import { CaptureStore } from './captures/capture-store.js';
|
|
48
49
|
import { createLiveDeskUpdateManager } from './live-desk-update.js';
|
|
49
50
|
import { createHubUdpTransport } from './transport/udp-hub-transport.js';
|
|
50
|
-
import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
|
|
51
|
+
import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
|
|
52
|
+
import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../runtime-core/src/os-secret-store.js';
|
|
51
53
|
import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
|
|
52
54
|
import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
|
|
53
55
|
import { createHubRuntime } from './runtime/hub-runtime.js';
|
|
@@ -63,8 +65,20 @@ const webDistCandidates = [
|
|
|
63
65
|
const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
|
|
64
66
|
const webIndexPath = resolve(webDistPath, 'index.html');
|
|
65
67
|
const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
66
|
-
const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
|
|
67
|
-
const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
|
|
68
|
+
const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
|
|
69
|
+
const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
|
|
70
|
+
if (!isLoopbackBindHost(httpHost) && process.env.LIVEDESK_TEST_MODE !== '1') {
|
|
71
|
+
throw new Error('hub-http-lan-bind-requires-tls-authenticated-proxy');
|
|
72
|
+
}
|
|
73
|
+
const localAdminSessionToken = crypto.randomBytes(32).toString('base64url');
|
|
74
|
+
const localAdminCsrfToken = crypto.randomBytes(32).toString('base64url');
|
|
75
|
+
const localAdminSessionId = crypto.randomUUID();
|
|
76
|
+
const launcherShutdownToken = /^[A-Za-z0-9_-]{43}$/.test(
|
|
77
|
+
String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN || '').trim()
|
|
78
|
+
)
|
|
79
|
+
? String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN).trim()
|
|
80
|
+
: '';
|
|
81
|
+
const enforceLocalAdminAuth = process.env.LIVEDESK_TEST_MODE !== '1';
|
|
68
82
|
const runtimeRole = String(process.env.LIVEDESK_RUNTIME_ROLE || 'hub').trim().toLowerCase() === 'client' ? 'client' : 'hub';
|
|
69
83
|
const runtimeDeviceId = String(process.env.LIVEDESK_DEVICE_ID || '').trim();
|
|
70
84
|
const runtimeDeviceName = String(process.env.LIVEDESK_DEVICE_NAME || os.hostname()).trim() || os.hostname();
|
|
@@ -165,13 +179,21 @@ const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
|
|
|
165
179
|
? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
|
|
166
180
|
: AUTH_REQUEST_TIMEOUT_MS;
|
|
167
181
|
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
168
|
-
const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
|
|
182
|
+
const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
|
|
169
183
|
? ''
|
|
170
|
-
: String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
|
|
171
|
-
const
|
|
184
|
+
: String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
|
|
185
|
+
const runtimeRefreshSecretStore = createOsSecretStore({
|
|
186
|
+
service: 'LiveDesk',
|
|
187
|
+
account: 'client-refresh-token',
|
|
188
|
+
dataDir: process.env.LIVEDESK_STATE_DIR || (runtimeAuthStatePath ? dirname(runtimeAuthStatePath) : undefined)
|
|
189
|
+
});
|
|
190
|
+
const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
|
|
172
191
|
&& ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
|
|
173
|
-
? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
|
|
174
|
-
: '';
|
|
192
|
+
? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
|
|
193
|
+
: '';
|
|
194
|
+
const testSecurityAccountId = process.env.LIVEDESK_TEST_MODE === '1'
|
|
195
|
+
? String(process.env.LIVEDESK_ACCOUNT_ID || '').trim().slice(0, 128)
|
|
196
|
+
: '';
|
|
175
197
|
const persistentSessionGcEnabled =
|
|
176
198
|
process.env.LIVEDESK_TEST_MODE === '1'
|
|
177
199
|
&& process.env.LIVEDESK_PERSISTENT_SESSION_TEST_GC === '1';
|
|
@@ -181,10 +203,12 @@ const traceRemoteTestEventsEnabled =
|
|
|
181
203
|
const persistentSessionGcToken = persistentSessionGcEnabled
|
|
182
204
|
? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
|
|
183
205
|
: '';
|
|
184
|
-
let connectedDeviceCount = 0;
|
|
185
|
-
let runtimeAccessToken = '';
|
|
186
|
-
let runtimeRefreshToken = '';
|
|
187
|
-
let runtimeAccessTokenExpiresAt = 0;
|
|
206
|
+
let connectedDeviceCount = 0;
|
|
207
|
+
let runtimeAccessToken = '';
|
|
208
|
+
let runtimeRefreshToken = '';
|
|
209
|
+
let runtimeAccessTokenExpiresAt = 0;
|
|
210
|
+
let runtimeSessionGeneration = 0;
|
|
211
|
+
let runtimeRefreshPromise = null;
|
|
188
212
|
let roleWatchInFlight = false;
|
|
189
213
|
let verifiedLicense = {
|
|
190
214
|
userId: '',
|
|
@@ -196,8 +220,10 @@ let verifiedLicense = {
|
|
|
196
220
|
let frameClientSeq = 0;
|
|
197
221
|
let inputClientSeq = 0;
|
|
198
222
|
let audioClientSeq = 0;
|
|
199
|
-
let liveDeskUpdateManager = null;
|
|
200
|
-
let hubTransferJobs = null;
|
|
223
|
+
let liveDeskUpdateManager = null;
|
|
224
|
+
let hubTransferJobs = null;
|
|
225
|
+
let securityAuditStore = null;
|
|
226
|
+
let securityAuditHealthy = true;
|
|
201
227
|
const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
|
|
202
228
|
const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
|
|
203
229
|
readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
|
|
@@ -228,7 +254,7 @@ let hubWakeNotificationState = {
|
|
|
228
254
|
};
|
|
229
255
|
let hubWakeNextAttemptAtMs = 0;
|
|
230
256
|
|
|
231
|
-
function readPositiveIntegerEnv(name, fallback) {
|
|
257
|
+
function readPositiveIntegerEnv(name, fallback) {
|
|
232
258
|
const value = Number(process.env[name]);
|
|
233
259
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
|
|
234
260
|
}
|
|
@@ -241,9 +267,42 @@ function secureExactTestTokenMatches(actual, expected) {
|
|
|
241
267
|
&& crypto.timingSafeEqual(actualBytes, expectedBytes);
|
|
242
268
|
}
|
|
243
269
|
|
|
244
|
-
function handleRemoteHubEvent(type, event) {
|
|
245
|
-
liveDeskUpdateManager?.handleRemoteEvent(type, event);
|
|
246
|
-
hubTransferJobs?.handleRemoteEvent(type, event);
|
|
270
|
+
function handleRemoteHubEvent(type, event) {
|
|
271
|
+
liveDeskUpdateManager?.handleRemoteEvent(type, event);
|
|
272
|
+
hubTransferJobs?.handleRemoteEvent(type, event);
|
|
273
|
+
const auditedRemoteActions = {
|
|
274
|
+
RemoteSecurityHandshakeAudit: event?.action || 'remote.handshake',
|
|
275
|
+
RemoteSecurityPolicyAudit: 'remote.connection-policy',
|
|
276
|
+
RemoteAbuseDefense: 'remote.abuse-defense',
|
|
277
|
+
RemoteDeviceConnected: 'remote.device.connected',
|
|
278
|
+
RemoteDeviceDisconnected: 'remote.device.disconnected',
|
|
279
|
+
RemoteInputSocketConnected: 'remote.control.channel-started',
|
|
280
|
+
RemoteInputSocketDisconnected: 'remote.control.channel-ended',
|
|
281
|
+
RemoteControlSessionStarted: 'remote.control.session-started',
|
|
282
|
+
RemoteControlSessionEnded: 'remote.control.session-ended',
|
|
283
|
+
RemoteFileSocketConnected: 'remote.file.channel-started',
|
|
284
|
+
RemoteFileSocketDisconnected: 'remote.file.channel-ended',
|
|
285
|
+
RemoteEnrollmentTokenRotated: 'remote.enrollment-token.rotated'
|
|
286
|
+
};
|
|
287
|
+
if (auditedRemoteActions[type]) {
|
|
288
|
+
const device = event?.device || {};
|
|
289
|
+
recordSecurityAudit({
|
|
290
|
+
action: auditedRemoteActions[type],
|
|
291
|
+
phase: /Disconnected|ended|rotated/i.test(`${type}:${auditedRemoteActions[type]}`) ? 'complete' : 'start',
|
|
292
|
+
result: event?.result || (event?.reason && /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'rejected' : 'success'),
|
|
293
|
+
reason: event?.reason || '',
|
|
294
|
+
actorAccountId: event?.accountId || '',
|
|
295
|
+
hubId: event?.hubId || event?.remoteHub?.hostInstanceId || '',
|
|
296
|
+
deviceId: event?.deviceId || device.deviceId || '',
|
|
297
|
+
sessionId: event?.sessionId || device.sessionId || '',
|
|
298
|
+
authMethod: /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'device-credential-p256' : 'authenticated-device-session',
|
|
299
|
+
details: {
|
|
300
|
+
transport: event?.transport || device.transport || '',
|
|
301
|
+
channel: event?.channel || '',
|
|
302
|
+
credentialSerial: event?.credentialSerial || ''
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
247
306
|
if (traceRemoteTestEventsEnabled
|
|
248
307
|
&& (type === 'RemoteFrameDropped' || type === 'RemoteFrameTransportProofAccepted')) {
|
|
249
308
|
console.warn(`[LiveDesk Hub Test Event] ${type} ${JSON.stringify({
|
|
@@ -290,10 +349,16 @@ function handleRemoteHubEvent(type, event) {
|
|
|
290
349
|
}
|
|
291
350
|
return;
|
|
292
351
|
}
|
|
293
|
-
if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
|
|
294
|
-
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
295
|
-
}
|
|
296
|
-
if (type
|
|
352
|
+
if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
|
|
353
|
+
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
354
|
+
}
|
|
355
|
+
if (type === 'RemoteEnrollmentTokenRotated') {
|
|
356
|
+
void publishHubHostTargetWithPendingRoleTakeover('enrollment-token-rotated').catch(error => {
|
|
357
|
+
console.warn(`[LiveDesk Hub] Enrollment token publication failed: ${error?.message || error}`);
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (type !== 'RemoteDeviceConnected') {
|
|
297
362
|
return;
|
|
298
363
|
}
|
|
299
364
|
const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
@@ -425,11 +490,23 @@ function readRuntimeAuthState() {
|
|
|
425
490
|
}
|
|
426
491
|
}
|
|
427
492
|
|
|
428
|
-
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
493
|
+
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
429
494
|
try {
|
|
430
495
|
const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
|
|
431
496
|
const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
432
|
-
|
|
497
|
+
if (!session || typeof session !== 'object') return null;
|
|
498
|
+
const plaintextRefreshToken = String(session.refresh_token || '').trim();
|
|
499
|
+
const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
|
|
500
|
+
? runtimeRefreshSecretStore.read()
|
|
501
|
+
: '');
|
|
502
|
+
if (plaintextRefreshToken) {
|
|
503
|
+
if (!runtimeRefreshSecretStore.write(plaintextRefreshToken)) return null;
|
|
504
|
+
const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
|
|
505
|
+
delete migrated.refresh_token;
|
|
506
|
+
state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(migrated);
|
|
507
|
+
writePrivateRuntimeAuthState(state);
|
|
508
|
+
}
|
|
509
|
+
return { ...session, refresh_token: refreshToken };
|
|
433
510
|
} catch {
|
|
434
511
|
return null;
|
|
435
512
|
}
|
|
@@ -472,28 +549,34 @@ function persistRuntimeSession(session) {
|
|
|
472
549
|
...(session?.user && typeof session.user === 'object' ? session.user : {})
|
|
473
550
|
}
|
|
474
551
|
}, { requireRefreshToken: true });
|
|
475
|
-
if (!normalized.ok) return false;
|
|
476
|
-
|
|
552
|
+
if (!normalized.ok) return false;
|
|
553
|
+
if (!runtimeRefreshSecretStore.write(normalized.session.refresh_token)) return false;
|
|
554
|
+
const persisted = { ...normalized.session, refresh_token_ref: OS_SECRET_REFERENCE };
|
|
555
|
+
delete persisted.refresh_token;
|
|
556
|
+
state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(persisted);
|
|
477
557
|
writePrivateRuntimeAuthState(state);
|
|
478
558
|
return true;
|
|
479
559
|
}
|
|
480
560
|
|
|
481
|
-
function clearPersistedRuntimeSession() {
|
|
561
|
+
function clearPersistedRuntimeSession() {
|
|
482
562
|
if (!runtimeAuthStatePath) return false;
|
|
483
563
|
const state = readRuntimeAuthState();
|
|
484
|
-
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
564
|
+
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
565
|
+
runtimeRefreshSecretStore.clear();
|
|
485
566
|
return writePrivateRuntimeAuthState(state);
|
|
486
567
|
}
|
|
487
568
|
|
|
488
|
-
function clearRuntimeSession() {
|
|
489
|
-
|
|
490
|
-
|
|
569
|
+
function clearRuntimeSession() {
|
|
570
|
+
runtimeSessionGeneration += 1;
|
|
571
|
+
runtimeRefreshPromise = null;
|
|
572
|
+
runtimeAccessToken = '';
|
|
573
|
+
runtimeRefreshToken = '';
|
|
491
574
|
runtimeAccessTokenExpiresAt = 0;
|
|
492
575
|
runtimeManager.setAuthenticated(false);
|
|
493
576
|
try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
|
|
494
577
|
}
|
|
495
578
|
|
|
496
|
-
async function getRuntimeAccessToken() {
|
|
579
|
+
async function getRuntimeAccessToken() {
|
|
497
580
|
const accessToken = String(runtimeAccessToken || '').trim();
|
|
498
581
|
const expiresSoon = runtimeAccessTokenExpiresAt > 0
|
|
499
582
|
&& runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
@@ -502,51 +585,112 @@ async function getRuntimeAccessToken() {
|
|
|
502
585
|
}
|
|
503
586
|
|
|
504
587
|
const refreshToken = String(runtimeRefreshToken || '').trim();
|
|
505
|
-
if (!refreshToken) {
|
|
506
|
-
return accessToken;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
588
|
+
if (!refreshToken) {
|
|
589
|
+
return accessToken;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (runtimeRefreshPromise) return runtimeRefreshPromise;
|
|
593
|
+
|
|
594
|
+
const ownerGeneration = runtimeSessionGeneration;
|
|
595
|
+
const refreshOperation = (async () => {
|
|
596
|
+
const response = await fetchAuthResponse(
|
|
597
|
+
fetch,
|
|
598
|
+
`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
|
|
599
|
+
{
|
|
600
|
+
method: 'POST',
|
|
601
|
+
headers: {
|
|
602
|
+
apikey: supabasePublishableKey,
|
|
603
|
+
'Content-Type': 'application/json',
|
|
604
|
+
Accept: 'application/json'
|
|
605
|
+
},
|
|
606
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
607
|
+
},
|
|
608
|
+
SUPABASE_AUTH_TIMEOUT_MS
|
|
609
|
+
);
|
|
610
|
+
if (!response.ok) {
|
|
611
|
+
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
612
|
+
if (runtimeSessionGeneration === ownerGeneration && runtimeRefreshToken === refreshToken) {
|
|
613
|
+
clearRuntimeSession();
|
|
614
|
+
}
|
|
615
|
+
throw new Error(`hub-session-refresh-failed:${response.status}`);
|
|
616
|
+
}
|
|
617
|
+
throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
|
|
618
|
+
}
|
|
619
|
+
const refreshed = await response.json().catch(() => null);
|
|
620
|
+
const nextAccessToken = String(refreshed?.access_token || '').trim();
|
|
621
|
+
if (!nextAccessToken) {
|
|
622
|
+
throw new Error('hub-session-refresh-missing-access-token');
|
|
623
|
+
}
|
|
624
|
+
if (runtimeSessionGeneration !== ownerGeneration || runtimeRefreshToken !== refreshToken) {
|
|
625
|
+
throw new Error('hub-session-refresh-owner-retired');
|
|
626
|
+
}
|
|
627
|
+
runtimeAccessToken = nextAccessToken;
|
|
628
|
+
runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
|
|
629
|
+
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
|
|
630
|
+
|| (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
|
|
631
|
+
try {
|
|
632
|
+
persistRuntimeSession({
|
|
633
|
+
access_token: runtimeAccessToken,
|
|
634
|
+
refresh_token: runtimeRefreshToken,
|
|
635
|
+
expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
|
|
636
|
+
});
|
|
637
|
+
} catch (error) {
|
|
638
|
+
console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
|
|
639
|
+
}
|
|
640
|
+
return runtimeAccessToken;
|
|
641
|
+
})();
|
|
642
|
+
runtimeRefreshPromise = refreshOperation;
|
|
643
|
+
try {
|
|
644
|
+
return await refreshOperation;
|
|
645
|
+
} finally {
|
|
646
|
+
if (runtimeRefreshPromise === refreshOperation) runtimeRefreshPromise = null;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function isLoopbackBindHost(value) {
|
|
651
|
+
const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
|
|
652
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function revokeRuntimeProviderSession() {
|
|
656
|
+
const hasRuntimeSession = Boolean(runtimeAccessToken || runtimeRefreshToken);
|
|
657
|
+
if (!hasRuntimeSession) return { ok: true, skipped: true, reason: 'no-runtime-session' };
|
|
658
|
+
|
|
659
|
+
let accessToken = String(runtimeAccessToken || '').trim();
|
|
660
|
+
try {
|
|
661
|
+
accessToken = await getRuntimeAccessToken() || accessToken;
|
|
662
|
+
} catch (error) {
|
|
663
|
+
const message = String(error?.message || error);
|
|
664
|
+
if (/^hub-session-refresh-failed:(?:400|401|403)$/.test(message)) {
|
|
665
|
+
return { ok: true, alreadyInvalid: true };
|
|
666
|
+
}
|
|
667
|
+
return { ok: false, error: message };
|
|
668
|
+
}
|
|
669
|
+
if (!accessToken) return { ok: false, error: 'provider-session-token-unavailable' };
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
const response = await fetchAuthResponse(
|
|
673
|
+
fetch,
|
|
674
|
+
`${supabaseUrl}/auth/v1/logout?scope=local`,
|
|
675
|
+
{
|
|
676
|
+
method: 'POST',
|
|
677
|
+
headers: {
|
|
678
|
+
apikey: supabasePublishableKey,
|
|
679
|
+
Authorization: `Bearer ${accessToken}`,
|
|
680
|
+
Accept: 'application/json'
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
SUPABASE_AUTH_TIMEOUT_MS
|
|
684
|
+
);
|
|
685
|
+
if (response.ok) return { ok: true, revoked: true };
|
|
686
|
+
if ([401, 403, 404].includes(response.status)) {
|
|
687
|
+
return { ok: true, alreadyInvalid: true, status: response.status };
|
|
688
|
+
}
|
|
689
|
+
return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
|
|
690
|
+
} catch (error) {
|
|
691
|
+
return { ok: false, error: String(error?.message || error) };
|
|
692
|
+
}
|
|
693
|
+
}
|
|
550
694
|
|
|
551
695
|
async function verifySupabaseUser(accessToken) {
|
|
552
696
|
const token = String(accessToken || '').trim();
|
|
@@ -659,15 +803,81 @@ function requireHubFeatureAccess(_req, res, next) {
|
|
|
659
803
|
res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
|
|
660
804
|
}
|
|
661
805
|
|
|
662
|
-
const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
806
|
+
const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
|
|
807
|
+
securityAuditStore = createSecurityAuditStore({ dataDir: agentDataDir });
|
|
808
|
+
|
|
809
|
+
function requestAuditHash(value) {
|
|
810
|
+
try {
|
|
811
|
+
return crypto.createHash('sha256').update(JSON.stringify(value ?? {}), 'utf8').digest('base64url');
|
|
812
|
+
} catch {
|
|
813
|
+
return crypto.createHash('sha256').update('[unserializable]', 'utf8').digest('base64url');
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function securityAuditEvent(event = {}) {
|
|
818
|
+
const runtime = runtimeManager.getSnapshot();
|
|
819
|
+
return {
|
|
820
|
+
actorAccountId: event.actorAccountId || runtime.userId || '',
|
|
821
|
+
actorUserId: event.actorUserId || runtime.userId || '',
|
|
822
|
+
hubId: event.hubId || runtime.deviceId || '',
|
|
823
|
+
...event
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function markSecurityAuditFailed(error) {
|
|
828
|
+
if (securityAuditHealthy) {
|
|
829
|
+
securityAuditHealthy = false;
|
|
830
|
+
console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function recordSecurityAuditRequired(event = {}) {
|
|
835
|
+
if (!securityAuditStore || !securityAuditHealthy) {
|
|
836
|
+
const error = new Error('security-audit-unavailable');
|
|
837
|
+
error.code = 'security-audit-unavailable';
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
return await securityAuditStore.record(securityAuditEvent(event));
|
|
842
|
+
} catch (error) {
|
|
843
|
+
markSecurityAuditFailed(error);
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function recordSecurityAudit(event = {}) {
|
|
849
|
+
void recordSecurityAuditRequired(event).catch(() => {
|
|
850
|
+
// Required mutation paths await recordSecurityAuditRequired directly.
|
|
851
|
+
// Best-effort lifecycle telemetry still marks the global audit gate failed.
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
|
|
856
|
+
const captureStore = new CaptureStore({
|
|
857
|
+
dataDir: agentDataDir,
|
|
858
|
+
getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
|
|
859
|
+
onRetentionDelete: result => recordSecurityAudit({
|
|
860
|
+
action: 'privacy.capture.retention-delete',
|
|
861
|
+
phase: 'complete',
|
|
862
|
+
result: 'success',
|
|
863
|
+
authMethod: 'settings-retention-policy',
|
|
864
|
+
details: result
|
|
865
|
+
})
|
|
866
|
+
});
|
|
867
|
+
void captureStore.initialize().catch(error => {
|
|
868
|
+
console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
869
|
+
});
|
|
870
|
+
void liveDeskSettingsStore.getRecord()
|
|
871
|
+
.then(() => captureStore.enforceRetention())
|
|
872
|
+
.catch(error => {
|
|
873
|
+
console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
874
|
+
});
|
|
875
|
+
const captureRetentionTimer = setInterval(() => {
|
|
876
|
+
void captureStore.enforceRetention().catch(error => {
|
|
877
|
+
console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
878
|
+
});
|
|
879
|
+
}, 60 * 60_000);
|
|
880
|
+
captureRetentionTimer.unref?.();
|
|
671
881
|
|
|
672
882
|
const udpTransport = createHubUdpTransport({
|
|
673
883
|
env: process.env,
|
|
@@ -675,9 +885,10 @@ const udpTransport = createHubUdpTransport({
|
|
|
675
885
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
|
|
676
886
|
});
|
|
677
887
|
|
|
678
|
-
const remoteHub = createRemoteHub({
|
|
679
|
-
managerPackage: '@livedesk/hub',
|
|
680
|
-
managerVersion: packageInfo.version,
|
|
888
|
+
const remoteHub = createRemoteHub({
|
|
889
|
+
managerPackage: '@livedesk/hub',
|
|
890
|
+
managerVersion: packageInfo.version,
|
|
891
|
+
dataDir: agentDataDir,
|
|
681
892
|
env: {
|
|
682
893
|
...process.env,
|
|
683
894
|
MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
|
|
@@ -688,7 +899,11 @@ const remoteHub = createRemoteHub({
|
|
|
688
899
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
|
|
689
900
|
emitEvent: handleRemoteHubEvent,
|
|
690
901
|
emitFrame: broadcastRemoteBinaryFrame,
|
|
691
|
-
emitAudio: broadcastRemoteBinaryAudio,
|
|
902
|
+
emitAudio: broadcastRemoteBinaryAudio,
|
|
903
|
+
getSecurityIdentity: () => ({
|
|
904
|
+
accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
|
|
905
|
+
hubId: remoteHub?.getSecurityStatus?.().hubId || ''
|
|
906
|
+
}),
|
|
692
907
|
getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
|
|
693
908
|
getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
|
|
694
909
|
const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
|
|
@@ -791,9 +1006,11 @@ function getLiveDeskUpdateStatus() {
|
|
|
791
1006
|
if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
|
|
792
1007
|
liveDeskUpdateManager = createLiveDeskUpdateManager({
|
|
793
1008
|
remoteHub,
|
|
794
|
-
currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
|
|
795
|
-
currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
|
|
796
|
-
|
|
1009
|
+
currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
|
|
1010
|
+
currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
|
|
1011
|
+
updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
|
|
1012
|
+
updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
|
|
1013
|
+
restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
|
|
797
1014
|
requestHubRestart
|
|
798
1015
|
});
|
|
799
1016
|
}
|
|
@@ -1111,7 +1328,7 @@ function validateAgentMcpArguments(name, args) {
|
|
|
1111
1328
|
return '';
|
|
1112
1329
|
}
|
|
1113
1330
|
|
|
1114
|
-
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1331
|
+
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1115
1332
|
if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
|
|
1116
1333
|
// This check runs synchronously before the first await, so concurrent Node
|
|
1117
1334
|
// requests cannot pass the same remaining budget and queue extra Client work.
|
|
@@ -1119,8 +1336,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
|
1119
1336
|
session.toolLimitReached = true;
|
|
1120
1337
|
return { ok: false, error: 'codex-tool-limit-reached' };
|
|
1121
1338
|
}
|
|
1122
|
-
session.toolCallCount += 1;
|
|
1123
|
-
|
|
1339
|
+
session.toolCallCount += 1;
|
|
1340
|
+
if (isRetiredAgentMutatingToolName(name)) {
|
|
1341
|
+
recordAgentAudit({
|
|
1342
|
+
event: 'tool-rejected',
|
|
1343
|
+
runId: session.runId,
|
|
1344
|
+
deviceIds: [],
|
|
1345
|
+
toolName: name,
|
|
1346
|
+
category: 'mutation',
|
|
1347
|
+
decision: 'deny',
|
|
1348
|
+
status: 'rejected',
|
|
1349
|
+
permissionMode: session.permissionPolicy?.mode,
|
|
1350
|
+
policyHash: session.permissionPolicyHash,
|
|
1351
|
+
details: { reason: 'agent-mutating-tool-disabled' }
|
|
1352
|
+
});
|
|
1353
|
+
return { ok: false, error: 'agent-mutating-tool-disabled' };
|
|
1354
|
+
}
|
|
1355
|
+
const tool = getAgentToolDefinition(name);
|
|
1124
1356
|
if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
|
|
1125
1357
|
const validationError = validateAgentMcpArguments(name, args);
|
|
1126
1358
|
if (validationError) return { ok: false, error: validationError };
|
|
@@ -1311,13 +1543,20 @@ async function synchronizeAgentEnablement() {
|
|
|
1311
1543
|
return enabled;
|
|
1312
1544
|
}
|
|
1313
1545
|
|
|
1314
|
-
const hubFilesystem = createHubFilesystem();
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1546
|
+
const hubFilesystem = createHubFilesystem();
|
|
1547
|
+
const fileTransferCommandAckTimeoutMs = readPositiveIntegerEnv(
|
|
1548
|
+
'LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS',
|
|
1549
|
+
30_000
|
|
1550
|
+
);
|
|
1551
|
+
hubTransferJobs = createHubTransferJobs({
|
|
1552
|
+
filesystem: hubFilesystem,
|
|
1553
|
+
remoteHub,
|
|
1554
|
+
maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
|
|
1555
|
+
commandResultTimeoutMs: fileTransferCommandAckTimeoutMs,
|
|
1556
|
+
getMaxFileSizeBytes: () => Number(
|
|
1557
|
+
liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
|
|
1558
|
+
|| 1024 * 1024 * 1024)
|
|
1559
|
+
});
|
|
1321
1560
|
const hubSharedFolders = createHubSharedFolders({
|
|
1322
1561
|
filesystem: hubFilesystem,
|
|
1323
1562
|
transferJobs: hubTransferJobs,
|
|
@@ -1437,16 +1676,125 @@ app.use((req, res, next) => {
|
|
|
1437
1676
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1438
1677
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
1439
1678
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
1440
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
1679
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
|
|
1441
1680
|
res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
|
|
1442
1681
|
}
|
|
1443
1682
|
if (req.method === 'OPTIONS') {
|
|
1444
1683
|
res.status(204).end();
|
|
1445
1684
|
return;
|
|
1446
1685
|
}
|
|
1447
|
-
next();
|
|
1448
|
-
});
|
|
1449
|
-
app.use(
|
|
1686
|
+
next();
|
|
1687
|
+
});
|
|
1688
|
+
app.use((req, res, next) => {
|
|
1689
|
+
if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
|
|
1690
|
+
next();
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
const authorizedLauncherShutdown = req.method === 'POST'
|
|
1694
|
+
&& req.path === '/api/runtime/shutdown'
|
|
1695
|
+
&& isLoopbackAddress(req.socket?.remoteAddress)
|
|
1696
|
+
&& launcherShutdownToken
|
|
1697
|
+
&& secureExactTestTokenMatches(
|
|
1698
|
+
String(req.headers['x-livedesk-launcher-shutdown'] || ''),
|
|
1699
|
+
launcherShutdownToken
|
|
1700
|
+
);
|
|
1701
|
+
if (authorizedLauncherShutdown) {
|
|
1702
|
+
req.liveDeskLauncherShutdown = true;
|
|
1703
|
+
next();
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
|
|
1707
|
+
const sensitiveRead = req.method === 'GET'
|
|
1708
|
+
&& ['/api/security/audit', '/api/security/rendezvous-issuer', '/api/privacy/inventory'].includes(req.path);
|
|
1709
|
+
if (!mutating && !sensitiveRead) {
|
|
1710
|
+
next();
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
|
|
1714
|
+
const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
|
|
1715
|
+
if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
|
|
1716
|
+
recordSecurityAudit({
|
|
1717
|
+
action: 'local-admin.request',
|
|
1718
|
+
phase: 'complete',
|
|
1719
|
+
result: 'rejected',
|
|
1720
|
+
reason: 'admin-session-required',
|
|
1721
|
+
requestHash: requestAuditHash({ method: req.method, path: req.path }),
|
|
1722
|
+
authMethod: 'local-admin-session'
|
|
1723
|
+
});
|
|
1724
|
+
res.status(401).json({ ok: false, error: 'local-admin-session-required' });
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
|
|
1728
|
+
recordSecurityAudit({
|
|
1729
|
+
action: 'local-admin.request',
|
|
1730
|
+
phase: 'complete',
|
|
1731
|
+
result: 'rejected',
|
|
1732
|
+
reason: 'csrf-token-required',
|
|
1733
|
+
requestHash: requestAuditHash({ method: req.method, path: req.path }),
|
|
1734
|
+
authMethod: 'local-admin-session'
|
|
1735
|
+
});
|
|
1736
|
+
res.status(403).json({ ok: false, error: 'csrf-token-required' });
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (!securityAuditHealthy) {
|
|
1740
|
+
res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
req.liveDeskAdminSessionId = localAdminSessionId;
|
|
1744
|
+
if (!mutating) {
|
|
1745
|
+
next();
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
const mutationRequestHash = requestAuditHash({ method: req.method, path: req.path });
|
|
1750
|
+
void recordSecurityAuditRequired({
|
|
1751
|
+
action: 'local-admin.mutation',
|
|
1752
|
+
phase: 'accepted',
|
|
1753
|
+
result: 'accepted',
|
|
1754
|
+
sessionId: localAdminSessionId,
|
|
1755
|
+
requestHash: mutationRequestHash,
|
|
1756
|
+
authMethod: 'local-admin-session',
|
|
1757
|
+
details: { method: req.method, path: req.path }
|
|
1758
|
+
}).then(() => {
|
|
1759
|
+
const originalEnd = res.end.bind(res);
|
|
1760
|
+
let completionStarted = false;
|
|
1761
|
+
res.end = (...args) => {
|
|
1762
|
+
if (completionStarted) return res;
|
|
1763
|
+
completionStarted = true;
|
|
1764
|
+
const statusCode = Number(res.statusCode || 200);
|
|
1765
|
+
void recordSecurityAuditRequired({
|
|
1766
|
+
action: 'local-admin.mutation',
|
|
1767
|
+
phase: 'complete',
|
|
1768
|
+
result: statusCode >= 200 && statusCode < 400 ? 'success' : 'rejected',
|
|
1769
|
+
reason: statusCode >= 400 ? `http-${statusCode}` : '',
|
|
1770
|
+
sessionId: localAdminSessionId,
|
|
1771
|
+
requestHash: mutationRequestHash,
|
|
1772
|
+
authMethod: 'local-admin-session',
|
|
1773
|
+
details: { method: req.method, path: req.path, statusCode }
|
|
1774
|
+
}).then(() => {
|
|
1775
|
+
originalEnd(...args);
|
|
1776
|
+
}).catch(error => {
|
|
1777
|
+
if (res.headersSent) {
|
|
1778
|
+
res.destroy(error);
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
const callback = [...args].reverse().find(value => typeof value === 'function');
|
|
1782
|
+
const body = JSON.stringify({ ok: false, error: 'security-audit-unavailable' });
|
|
1783
|
+
res.statusCode = 503;
|
|
1784
|
+
res.removeHeader('Content-Length');
|
|
1785
|
+
res.removeHeader('Transfer-Encoding');
|
|
1786
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1787
|
+
res.setHeader('Content-Length', Buffer.byteLength(body));
|
|
1788
|
+
originalEnd(body, 'utf8', callback);
|
|
1789
|
+
});
|
|
1790
|
+
return res;
|
|
1791
|
+
};
|
|
1792
|
+
next();
|
|
1793
|
+
}).catch(() => {
|
|
1794
|
+
res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
|
|
1795
|
+
});
|
|
1796
|
+
});
|
|
1797
|
+
app.use(express.json({ limit: '32mb' }));
|
|
1450
1798
|
app.use((req, res, next) => {
|
|
1451
1799
|
if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
|
|
1452
1800
|
res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
|
|
@@ -1597,11 +1945,12 @@ function normalizeTransferFiles(value) {
|
|
|
1597
1945
|
if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
|
|
1598
1946
|
return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
|
|
1599
1947
|
}
|
|
1600
|
-
files.push({
|
|
1601
|
-
name,
|
|
1602
|
-
relativePath,
|
|
1603
|
-
size: byteLength,
|
|
1604
|
-
|
|
1948
|
+
files.push({
|
|
1949
|
+
name,
|
|
1950
|
+
relativePath,
|
|
1951
|
+
size: byteLength,
|
|
1952
|
+
sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
|
|
1953
|
+
mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
|
|
1605
1954
|
lastModified: Number(entry?.lastModified || 0) || 0,
|
|
1606
1955
|
dataBase64
|
|
1607
1956
|
});
|
|
@@ -1612,24 +1961,39 @@ function normalizeTransferFiles(value) {
|
|
|
1612
1961
|
return { ok: true, files, totalBytes };
|
|
1613
1962
|
}
|
|
1614
1963
|
|
|
1615
|
-
function normalizeTransferChunk(body = {}) {
|
|
1964
|
+
function normalizeTransferChunk(body = {}) {
|
|
1616
1965
|
const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
1617
1966
|
const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
|
|
1618
1967
|
const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
|
|
1619
1968
|
const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
|
|
1620
1969
|
const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
|
|
1621
|
-
const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
|
|
1622
|
-
const final = body.final === true;
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1970
|
+
const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
|
|
1971
|
+
const final = body.final === true;
|
|
1972
|
+
const maxFileSizeBytes = Math.max(1, Number(
|
|
1973
|
+
liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
|
|
1974
|
+
|| 1024 * 1024 * 1024));
|
|
1975
|
+
|
|
1976
|
+
if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
|
|
1977
|
+
return { ok: false, error: 'invalid-file-transfer-chunk' };
|
|
1978
|
+
}
|
|
1979
|
+
if (totalBytes > maxFileSizeBytes) {
|
|
1980
|
+
return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
|
|
1981
|
+
}
|
|
1627
1982
|
if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
|
|
1628
1983
|
return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
|
|
1629
1984
|
}
|
|
1630
|
-
if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
1631
|
-
return { ok: false, error: 'empty-file-transfer-chunk' };
|
|
1632
|
-
}
|
|
1985
|
+
if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
1986
|
+
return { ok: false, error: 'empty-file-transfer-chunk' };
|
|
1987
|
+
}
|
|
1988
|
+
const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
|
|
1989
|
+
const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
|
|
1990
|
+
? suppliedSha256
|
|
1991
|
+
: final && offset === 0 && totalBytes === byteLength
|
|
1992
|
+
? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
|
|
1993
|
+
: '';
|
|
1994
|
+
if (final && !sha256) {
|
|
1995
|
+
return { ok: false, error: 'file-transfer-sha256-required', byteLength };
|
|
1996
|
+
}
|
|
1633
1997
|
|
|
1634
1998
|
return {
|
|
1635
1999
|
ok: true,
|
|
@@ -1639,8 +2003,9 @@ function normalizeTransferChunk(body = {}) {
|
|
|
1639
2003
|
offset,
|
|
1640
2004
|
totalBytes,
|
|
1641
2005
|
byteLength,
|
|
1642
|
-
dataBase64,
|
|
1643
|
-
final,
|
|
2006
|
+
dataBase64,
|
|
2007
|
+
final,
|
|
2008
|
+
sha256,
|
|
1644
2009
|
mimeType: String(body.type || body.mimeType || '').slice(0, 160),
|
|
1645
2010
|
lastModified: Number(body.lastModified || 0) || 0
|
|
1646
2011
|
}
|
|
@@ -2746,6 +3111,37 @@ function updateFrameSubscription(ws, payload = {}) {
|
|
|
2746
3111
|
}
|
|
2747
3112
|
}
|
|
2748
3113
|
|
|
3114
|
+
function refreshFrameSubscriptionLive(ws, payload = {}) {
|
|
3115
|
+
if (!ws || ws.readyState !== 1) {
|
|
3116
|
+
return;
|
|
3117
|
+
}
|
|
3118
|
+
const requestedIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
|
|
3119
|
+
const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
|
|
3120
|
+
const targetIds = requestedIds.length > 0
|
|
3121
|
+
? requestedIds.filter(deviceId => ws.liveDeskDeviceIds?.has?.(deviceId))
|
|
3122
|
+
: subscribedIds;
|
|
3123
|
+
if (targetIds.length === 0) {
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(
|
|
3127
|
+
payload.autoStartLive ?? payload.startLive ?? ws.liveDeskAutoStart ?? ''
|
|
3128
|
+
));
|
|
3129
|
+
ws.liveDeskLiveOptions = normalizeLiveOptions({
|
|
3130
|
+
...(ws.liveDeskLiveOptions || {}),
|
|
3131
|
+
...payload,
|
|
3132
|
+
monitorSelections: {
|
|
3133
|
+
...(ws.liveDeskLiveOptions?.monitorSelections || {}),
|
|
3134
|
+
...(payload.monitorSelections || {})
|
|
3135
|
+
}
|
|
3136
|
+
});
|
|
3137
|
+
for (const deviceId of targetIds.slice(0, 80)) {
|
|
3138
|
+
startFrameSubscriptionLive(ws, 'watchdog', deviceId, {
|
|
3139
|
+
...ws.liveDeskLiveOptions,
|
|
3140
|
+
reuseExisting: true
|
|
3141
|
+
});
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
|
|
2749
3145
|
function startFrameSubscriptionLive(
|
|
2750
3146
|
ws,
|
|
2751
3147
|
reason = 'subscribe',
|
|
@@ -3677,10 +4073,26 @@ function buildHubHealthPayload({
|
|
|
3677
4073
|
};
|
|
3678
4074
|
}
|
|
3679
4075
|
|
|
3680
|
-
app.get('/api/health', (_req, res) => {
|
|
3681
|
-
noStore(res);
|
|
3682
|
-
res.json(buildHubHealthPayload());
|
|
3683
|
-
});
|
|
4076
|
+
app.get('/api/health', (_req, res) => {
|
|
4077
|
+
noStore(res);
|
|
4078
|
+
res.json(buildHubHealthPayload());
|
|
4079
|
+
});
|
|
4080
|
+
|
|
4081
|
+
app.get('/api/security/session', (req, res) => {
|
|
4082
|
+
noStore(res);
|
|
4083
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
|
|
4084
|
+
res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
4087
|
+
res.json({
|
|
4088
|
+
ok: true,
|
|
4089
|
+
sessionId: localAdminSessionId,
|
|
4090
|
+
adminToken: localAdminSessionToken,
|
|
4091
|
+
csrfToken: localAdminCsrfToken,
|
|
4092
|
+
issuedAt: new Date().toISOString(),
|
|
4093
|
+
expiresOnProcessExit: true
|
|
4094
|
+
});
|
|
4095
|
+
});
|
|
3684
4096
|
|
|
3685
4097
|
if (persistentSessionGcEnabled) {
|
|
3686
4098
|
app.post('/api/test/persistent-session/gc', (req, res) => {
|
|
@@ -3733,9 +4145,18 @@ app.patch('/api/settings', async (req, res) => {
|
|
|
3733
4145
|
const record = await liveDeskSettingsStore.update(patch, revision);
|
|
3734
4146
|
if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
|
|
3735
4147
|
await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
|
|
3736
|
-
}
|
|
3737
|
-
remoteHub.refreshDevicePolicies();
|
|
3738
|
-
|
|
4148
|
+
}
|
|
4149
|
+
remoteHub.refreshDevicePolicies();
|
|
4150
|
+
void captureStore.enforceRetention().catch(() => undefined);
|
|
4151
|
+
recordSecurityAudit({
|
|
4152
|
+
action: 'settings.security-policy.updated',
|
|
4153
|
+
phase: 'complete',
|
|
4154
|
+
result: 'success',
|
|
4155
|
+
requestHash: requestAuditHash(patch),
|
|
4156
|
+
authMethod: 'local-admin-session',
|
|
4157
|
+
details: { revision: record.revision, sections: Object.keys(patch) }
|
|
4158
|
+
});
|
|
4159
|
+
res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
|
|
3739
4160
|
} catch (error) {
|
|
3740
4161
|
if (error instanceof SettingsConflictError) {
|
|
3741
4162
|
res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
|
|
@@ -3890,7 +4311,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
|
|
|
3890
4311
|
});
|
|
3891
4312
|
});
|
|
3892
4313
|
|
|
3893
|
-
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
4314
|
+
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
3894
4315
|
noStore(res);
|
|
3895
4316
|
const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
|
|
3896
4317
|
deviceId: device.deviceId,
|
|
@@ -3902,24 +4323,141 @@ app.get('/api/security/trusted-devices', (_req, res) => {
|
|
|
3902
4323
|
lastSeenAt: device.lastSeenAt || '',
|
|
3903
4324
|
ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
|
|
3904
4325
|
}));
|
|
3905
|
-
res.json({ ok: true, devices });
|
|
3906
|
-
});
|
|
3907
|
-
|
|
3908
|
-
app.
|
|
3909
|
-
noStore(res);
|
|
3910
|
-
const
|
|
3911
|
-
res.json({
|
|
3912
|
-
|
|
4326
|
+
res.json({ ok: true, devices });
|
|
4327
|
+
});
|
|
4328
|
+
|
|
4329
|
+
app.get('/api/security/rendezvous-issuer', (_req, res) => {
|
|
4330
|
+
noStore(res);
|
|
4331
|
+
const security = remoteHub.getSecurityStatus();
|
|
4332
|
+
res.json({
|
|
4333
|
+
ok: true,
|
|
4334
|
+
hubId: security.hubId,
|
|
4335
|
+
issuerKeyId: security.hubIssuerKeyId,
|
|
4336
|
+
publicKey: security.hubPublicKey,
|
|
4337
|
+
algorithm: 'P-256/SHA-256',
|
|
4338
|
+
environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
|
|
4339
|
+
});
|
|
4340
|
+
});
|
|
4341
|
+
|
|
4342
|
+
app.get('/api/security/audit', async (req, res) => {
|
|
4343
|
+
noStore(res);
|
|
4344
|
+
try {
|
|
4345
|
+
res.json({
|
|
4346
|
+
ok: true,
|
|
4347
|
+
healthy: securityAuditHealthy,
|
|
4348
|
+
verification: await securityAuditStore.verify(),
|
|
4349
|
+
audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
|
|
4350
|
+
});
|
|
4351
|
+
} catch (error) {
|
|
4352
|
+
securityAuditHealthy = false;
|
|
4353
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
4354
|
+
}
|
|
4355
|
+
});
|
|
4356
|
+
|
|
4357
|
+
app.get('/api/privacy/inventory', async (_req, res) => {
|
|
4358
|
+
noStore(res);
|
|
4359
|
+
try {
|
|
4360
|
+
const [captures, sharedFolders, verification] = await Promise.all([
|
|
4361
|
+
captureStore.list(),
|
|
4362
|
+
hubSharedFolders.list(),
|
|
4363
|
+
securityAuditStore.verify()
|
|
4364
|
+
]);
|
|
4365
|
+
res.json({
|
|
4366
|
+
ok: true,
|
|
4367
|
+
inventory: {
|
|
4368
|
+
authenticated: runtimeManager.getSnapshot().authenticated === true,
|
|
4369
|
+
captures: captures.length,
|
|
4370
|
+
sharedFolderDefinitions: sharedFolders.length,
|
|
4371
|
+
trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
|
|
4372
|
+
securityAuditRecords: verification.records,
|
|
4373
|
+
agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
|
|
4374
|
+
}
|
|
4375
|
+
});
|
|
4376
|
+
} catch (error) {
|
|
4377
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
|
|
4378
|
+
}
|
|
4379
|
+
});
|
|
4380
|
+
|
|
4381
|
+
app.delete('/api/privacy/local-data', async (req, res) => {
|
|
4382
|
+
noStore(res);
|
|
4383
|
+
if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
|
|
4384
|
+
res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
|
|
4385
|
+
return;
|
|
4386
|
+
}
|
|
4387
|
+
try {
|
|
4388
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
4389
|
+
const hostTarget = runtimeRole === 'hub'
|
|
4390
|
+
? await clearHubHostTarget('privacy-delete')
|
|
4391
|
+
: { ok: true, active: false };
|
|
4392
|
+
clearRuntimeSession();
|
|
4393
|
+
for (const device of remoteHub.getSecurityStatus().devices) {
|
|
4394
|
+
remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
|
|
4395
|
+
}
|
|
4396
|
+
const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
|
|
4397
|
+
const transferJobs = hubTransferJobs.clear();
|
|
4398
|
+
const captures = await captureStore.removeAll();
|
|
4399
|
+
const sharedFolders = await hubSharedFolders.clear();
|
|
4400
|
+
await agentAuditStore.clear();
|
|
4401
|
+
await securityAuditStore.reset();
|
|
4402
|
+
securityAuditHealthy = true;
|
|
4403
|
+
await securityAuditStore.record({
|
|
4404
|
+
action: 'privacy.local-data.deleted',
|
|
4405
|
+
phase: 'complete',
|
|
4406
|
+
result: 'success',
|
|
4407
|
+
authMethod: 'local-admin-session',
|
|
4408
|
+
details: {
|
|
4409
|
+
capturesRemoved: captures.removed,
|
|
4410
|
+
captureBytesRemoved: captures.removedBytes,
|
|
4411
|
+
sharedFolderDefinitionsRemoved: sharedFolders.removed,
|
|
4412
|
+
trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
|
|
4413
|
+
transferJobRecordsRemoved: transferJobs.removed,
|
|
4414
|
+
providerSessionRevoked: providerLogout.revoked === true
|
|
4415
|
+
}
|
|
4416
|
+
});
|
|
4417
|
+
res.json({
|
|
4418
|
+
ok: true,
|
|
4419
|
+
localDeleted: true,
|
|
4420
|
+
providerLogout,
|
|
4421
|
+
hostTarget,
|
|
4422
|
+
captures,
|
|
4423
|
+
sharedFolders,
|
|
4424
|
+
trustedDeviceCredentials,
|
|
4425
|
+
transferJobs,
|
|
4426
|
+
preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
|
|
4427
|
+
});
|
|
4428
|
+
} catch (error) {
|
|
4429
|
+
securityAuditHealthy = false;
|
|
4430
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
|
|
4431
|
+
}
|
|
4432
|
+
});
|
|
4433
|
+
|
|
4434
|
+
app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
|
|
4435
|
+
noStore(res);
|
|
4436
|
+
const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
|
|
4437
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
|
|
4438
|
+
const revoked = disconnected === true || credentialRevoked === true;
|
|
4439
|
+
recordSecurityAudit({
|
|
4440
|
+
action: 'security.device.revoked',
|
|
4441
|
+
phase: 'complete',
|
|
4442
|
+
result: revoked ? 'success' : 'not-found',
|
|
4443
|
+
deviceId: req.params.deviceId,
|
|
4444
|
+
authMethod: 'local-admin-session'
|
|
4445
|
+
});
|
|
4446
|
+
res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
|
|
4447
|
+
});
|
|
3913
4448
|
|
|
3914
4449
|
app.post('/api/security/revoke-all', (_req, res) => {
|
|
3915
4450
|
noStore(res);
|
|
3916
|
-
const devices = remoteHub.listDevices({ includeDataUrl: false });
|
|
3917
|
-
let revoked = 0;
|
|
3918
|
-
for (const device of devices) {
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
}
|
|
4451
|
+
const devices = remoteHub.listDevices({ includeDataUrl: false });
|
|
4452
|
+
let revoked = 0;
|
|
4453
|
+
for (const device of devices) {
|
|
4454
|
+
const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
|
|
4455
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
|
|
4456
|
+
if (disconnected || credentialRevoked) revoked += 1;
|
|
4457
|
+
}
|
|
4458
|
+
recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
|
|
4459
|
+
res.json({ ok: true, revoked });
|
|
4460
|
+
});
|
|
3923
4461
|
|
|
3924
4462
|
app.get('/api/settings/agent', async (_req, res) => {
|
|
3925
4463
|
noStore(res);
|
|
@@ -4281,9 +4819,17 @@ app.post('/api/runtime/restart', (_req, res) => {
|
|
|
4281
4819
|
}, 150);
|
|
4282
4820
|
});
|
|
4283
4821
|
|
|
4284
|
-
app.post('/api/runtime/shutdown', (
|
|
4285
|
-
noStore(res);
|
|
4286
|
-
|
|
4822
|
+
app.post('/api/runtime/shutdown', (req, res) => {
|
|
4823
|
+
noStore(res);
|
|
4824
|
+
recordSecurityAudit({
|
|
4825
|
+
action: 'runtime.shutdown',
|
|
4826
|
+
phase: 'requested',
|
|
4827
|
+
result: 'accepted',
|
|
4828
|
+
authMethod: req.liveDeskLauncherShutdown === true
|
|
4829
|
+
? 'launcher-internal-shutdown-token'
|
|
4830
|
+
: 'local-admin-session'
|
|
4831
|
+
});
|
|
4832
|
+
runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
|
|
4287
4833
|
res.json({ ok: true, shuttingDown: true, role: runtimeRole });
|
|
4288
4834
|
setTimeout(() => {
|
|
4289
4835
|
void shutdownHub('API_SHUTDOWN')
|
|
@@ -4320,8 +4866,10 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4320
4866
|
}
|
|
4321
4867
|
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
4322
4868
|
try {
|
|
4323
|
-
const user = await verifySupabaseUser(accessToken);
|
|
4324
|
-
|
|
4869
|
+
const user = await verifySupabaseUser(accessToken);
|
|
4870
|
+
runtimeSessionGeneration += 1;
|
|
4871
|
+
runtimeRefreshPromise = null;
|
|
4872
|
+
runtimeAccessToken = accessToken;
|
|
4325
4873
|
runtimeRefreshToken = refreshToken;
|
|
4326
4874
|
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
4327
4875
|
runtimeManager.setAuthenticated(true, user.id);
|
|
@@ -4334,12 +4882,29 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4334
4882
|
const hostTarget = runtimeRole === 'hub'
|
|
4335
4883
|
? await publishHubHostTargetWithPendingRoleTakeover('session-received')
|
|
4336
4884
|
: { ok: true, active: false };
|
|
4337
|
-
if (runtimeRole === 'hub') {
|
|
4338
|
-
startHubHostTargetLeaseRenewal();
|
|
4339
|
-
}
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4885
|
+
if (runtimeRole === 'hub') {
|
|
4886
|
+
startHubHostTargetLeaseRenewal();
|
|
4887
|
+
}
|
|
4888
|
+
recordSecurityAudit({
|
|
4889
|
+
action: 'auth.session.established',
|
|
4890
|
+
phase: 'complete',
|
|
4891
|
+
result: 'success',
|
|
4892
|
+
actorAccountId: user.id,
|
|
4893
|
+
actorUserId: user.id,
|
|
4894
|
+
authMethod: 'supabase-access-refresh',
|
|
4895
|
+
requestHash: requestAuditHash({ userId: user.id, expiresAt })
|
|
4896
|
+
});
|
|
4897
|
+
res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
|
|
4898
|
+
} catch (error) {
|
|
4899
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4900
|
+
recordSecurityAudit({
|
|
4901
|
+
action: 'auth.session.establish',
|
|
4902
|
+
phase: 'complete',
|
|
4903
|
+
result: 'rejected',
|
|
4904
|
+
reason: message,
|
|
4905
|
+
authMethod: 'supabase-access-refresh',
|
|
4906
|
+
requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
|
|
4907
|
+
});
|
|
4343
4908
|
const status = authVerificationHttpStatus(message);
|
|
4344
4909
|
res.status(status).json({ ok: false, error: message });
|
|
4345
4910
|
}
|
|
@@ -4633,14 +5198,33 @@ function startHubHostTargetLeaseRenewal() {
|
|
|
4633
5198
|
hubHostTargetRenewTimer.unref?.();
|
|
4634
5199
|
}
|
|
4635
5200
|
|
|
4636
|
-
app.delete('/api/auth/session', async (_req, res) => {
|
|
4637
|
-
noStore(res);
|
|
4638
|
-
const
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
5201
|
+
app.delete('/api/auth/session', async (_req, res) => {
|
|
5202
|
+
noStore(res);
|
|
5203
|
+
const actorUserId = runtimeManager.getSnapshot().userId || '';
|
|
5204
|
+
const hostTarget = runtimeRole === 'hub'
|
|
5205
|
+
? await clearHubHostTarget('logout')
|
|
5206
|
+
: { ok: true, active: false };
|
|
5207
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
5208
|
+
clearRuntimeSession();
|
|
5209
|
+
recordSecurityAudit({
|
|
5210
|
+
action: 'auth.session.revoked',
|
|
5211
|
+
phase: 'complete',
|
|
5212
|
+
result: providerLogout.ok ? 'success' : 'provider-error',
|
|
5213
|
+
actorAccountId: actorUserId,
|
|
5214
|
+
actorUserId,
|
|
5215
|
+
reason: providerLogout.error || '',
|
|
5216
|
+
authMethod: 'supabase-local-signout',
|
|
5217
|
+
details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
|
|
5218
|
+
});
|
|
5219
|
+
res.status(providerLogout.ok ? 200 : 502).json({
|
|
5220
|
+
ok: providerLogout.ok,
|
|
5221
|
+
authenticated: false,
|
|
5222
|
+
localCleared: true,
|
|
5223
|
+
role: runtimeRole,
|
|
5224
|
+
hostTarget,
|
|
5225
|
+
providerLogout
|
|
5226
|
+
});
|
|
5227
|
+
});
|
|
4644
5228
|
|
|
4645
5229
|
app.get('/api/hub/status', (_req, res) => {
|
|
4646
5230
|
noStore(res);
|
|
@@ -4987,11 +5571,37 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
|
|
|
4987
5571
|
res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
|
|
4988
5572
|
return;
|
|
4989
5573
|
}
|
|
4990
|
-
const job = hubTransferJobs.create({
|
|
4991
|
-
itemIds,
|
|
4992
|
-
deviceIds,
|
|
4993
|
-
remoteDirectory: req.body?.remoteDirectory
|
|
4994
|
-
|
|
5574
|
+
const job = hubTransferJobs.create({
|
|
5575
|
+
itemIds,
|
|
5576
|
+
deviceIds,
|
|
5577
|
+
remoteDirectory: req.body?.remoteDirectory,
|
|
5578
|
+
onComplete: async ({ completed, job: completedJob }) => {
|
|
5579
|
+
await recordSecurityAuditRequired({
|
|
5580
|
+
action: 'remote.file.job',
|
|
5581
|
+
phase: 'complete',
|
|
5582
|
+
result: completed ? 'success' : 'failed',
|
|
5583
|
+
reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
|
|
5584
|
+
deviceIds,
|
|
5585
|
+
sessionId: completedJob?.jobId || '',
|
|
5586
|
+
requestHash: requestAuditHash({
|
|
5587
|
+
jobId: completedJob?.jobId || '',
|
|
5588
|
+
deviceIds,
|
|
5589
|
+
remoteDirectory: req.body?.remoteDirectory || ''
|
|
5590
|
+
}),
|
|
5591
|
+
authMethod: 'local-admin-session',
|
|
5592
|
+
details: {
|
|
5593
|
+
state: completedJob?.state || '',
|
|
5594
|
+
totalFiles: Number(completedJob?.totalFiles || 0),
|
|
5595
|
+
completedFiles: Number(completedJob?.completedFiles || 0),
|
|
5596
|
+
totalBytes: Number(completedJob?.totalBytes || 0),
|
|
5597
|
+
sentBytes: Number(completedJob?.sentBytes || 0),
|
|
5598
|
+
failedTargets: Array.isArray(completedJob?.failedTargets)
|
|
5599
|
+
? completedJob.failedTargets.length
|
|
5600
|
+
: 0
|
|
5601
|
+
}
|
|
5602
|
+
});
|
|
5603
|
+
}
|
|
5604
|
+
});
|
|
4995
5605
|
res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
|
|
4996
5606
|
} catch (error) {
|
|
4997
5607
|
sendFilesystemError(res, error);
|
|
@@ -5072,56 +5682,80 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
|
|
|
5072
5682
|
}
|
|
5073
5683
|
});
|
|
5074
5684
|
|
|
5075
|
-
app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
5685
|
+
app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
|
|
5076
5686
|
noStore(res);
|
|
5077
5687
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5078
|
-
if (deviceIds.length === 0) {
|
|
5079
|
-
|
|
5688
|
+
if (deviceIds.length === 0) {
|
|
5689
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5690
|
+
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5080
5691
|
return;
|
|
5081
5692
|
}
|
|
5082
5693
|
const normalized = normalizeTransferFiles(req.body?.files);
|
|
5083
|
-
if (!normalized.ok) {
|
|
5694
|
+
if (!normalized.ok) {
|
|
5695
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5084
5696
|
res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
|
|
5085
5697
|
return;
|
|
5086
5698
|
}
|
|
5087
5699
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5088
5700
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5089
5701
|
const queuedAt = new Date().toISOString();
|
|
5090
|
-
|
|
5091
|
-
deviceId
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5702
|
+
try {
|
|
5703
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5704
|
+
deviceId,
|
|
5705
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5706
|
+
command: 'file.transfer',
|
|
5707
|
+
payload: {
|
|
5708
|
+
transferId,
|
|
5709
|
+
remoteDirectory,
|
|
5710
|
+
files: normalized.files,
|
|
5711
|
+
totalBytes: normalized.totalBytes,
|
|
5712
|
+
requestedAt: queuedAt
|
|
5713
|
+
}
|
|
5714
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5715
|
+
})));
|
|
5716
|
+
const queued = results.filter(result => result.queued).length;
|
|
5717
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5718
|
+
const success = acknowledged === deviceIds.length;
|
|
5719
|
+
const partial = acknowledged > 0 && !success;
|
|
5720
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5721
|
+
await recordSecurityAuditRequired({
|
|
5722
|
+
action: 'remote.file.transfer',
|
|
5723
|
+
phase: 'complete',
|
|
5724
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5725
|
+
reason: error || '',
|
|
5726
|
+
deviceIds,
|
|
5727
|
+
sessionId: transferId,
|
|
5728
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
|
|
5729
|
+
authMethod: 'local-admin-session',
|
|
5730
|
+
details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
|
|
5731
|
+
});
|
|
5732
|
+
res.json({
|
|
5733
|
+
ok: success,
|
|
5734
|
+
transferId,
|
|
5735
|
+
queued,
|
|
5736
|
+
acknowledged,
|
|
5737
|
+
total: deviceIds.length,
|
|
5738
|
+
totalBytes: normalized.totalBytes,
|
|
5739
|
+
files: normalized.files.length,
|
|
5740
|
+
results,
|
|
5741
|
+
error
|
|
5742
|
+
});
|
|
5743
|
+
} catch (error) {
|
|
5744
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5745
|
+
}
|
|
5746
|
+
});
|
|
5747
|
+
|
|
5748
|
+
app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
|
|
5117
5749
|
noStore(res);
|
|
5118
5750
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5119
|
-
if (deviceIds.length === 0) {
|
|
5751
|
+
if (deviceIds.length === 0) {
|
|
5752
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5120
5753
|
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5121
5754
|
return;
|
|
5122
5755
|
}
|
|
5123
5756
|
const normalized = normalizeTransferChunk(req.body || {});
|
|
5124
|
-
if (!normalized.ok) {
|
|
5757
|
+
if (!normalized.ok) {
|
|
5758
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5125
5759
|
res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
|
|
5126
5760
|
return;
|
|
5127
5761
|
}
|
|
@@ -5129,31 +5763,51 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
|
5129
5763
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5130
5764
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5131
5765
|
const queuedAt = new Date().toISOString();
|
|
5132
|
-
|
|
5133
|
-
deviceId
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5766
|
+
try {
|
|
5767
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5768
|
+
deviceId,
|
|
5769
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5770
|
+
command: 'file.transfer.chunk',
|
|
5771
|
+
payload: {
|
|
5772
|
+
transferId,
|
|
5773
|
+
remoteDirectory,
|
|
5774
|
+
...normalized.chunk,
|
|
5775
|
+
requestedAt: queuedAt
|
|
5776
|
+
}
|
|
5777
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5778
|
+
})));
|
|
5779
|
+
const queued = results.filter(result => result.queued).length;
|
|
5780
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5781
|
+
const success = acknowledged === deviceIds.length;
|
|
5782
|
+
const partial = acknowledged > 0 && !success;
|
|
5783
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5784
|
+
await recordSecurityAuditRequired({
|
|
5785
|
+
action: 'remote.file.chunk',
|
|
5786
|
+
phase: normalized.chunk.final ? 'complete' : 'progress',
|
|
5787
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5788
|
+
reason: error || '',
|
|
5789
|
+
deviceIds,
|
|
5790
|
+
sessionId: transferId,
|
|
5791
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
|
|
5792
|
+
authMethod: 'local-admin-session',
|
|
5793
|
+
details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
|
|
5794
|
+
});
|
|
5795
|
+
res.json({
|
|
5796
|
+
ok: success,
|
|
5797
|
+
transferId,
|
|
5798
|
+
queued,
|
|
5799
|
+
acknowledged,
|
|
5800
|
+
total: deviceIds.length,
|
|
5801
|
+
byteLength: normalized.chunk.byteLength,
|
|
5802
|
+
offset: normalized.chunk.offset,
|
|
5803
|
+
final: normalized.chunk.final,
|
|
5804
|
+
results,
|
|
5805
|
+
error
|
|
5806
|
+
});
|
|
5807
|
+
} catch (error) {
|
|
5808
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5809
|
+
}
|
|
5810
|
+
});
|
|
5157
5811
|
|
|
5158
5812
|
const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
|
|
5159
5813
|
|
|
@@ -5426,7 +6080,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
|
|
|
5426
6080
|
}
|
|
5427
6081
|
});
|
|
5428
6082
|
|
|
5429
|
-
if (existsSync(webIndexPath)) {
|
|
6083
|
+
if (existsSync(webIndexPath)) {
|
|
5430
6084
|
app.use(express.static(webDistPath, {
|
|
5431
6085
|
etag: true,
|
|
5432
6086
|
index: false,
|
|
@@ -5439,19 +6093,70 @@ if (existsSync(webIndexPath)) {
|
|
|
5439
6093
|
}
|
|
5440
6094
|
res.sendFile(webIndexPath);
|
|
5441
6095
|
});
|
|
5442
|
-
}
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
if (!
|
|
6096
|
+
}
|
|
6097
|
+
|
|
6098
|
+
function isAuthorizedAdminWebSocket(req) {
|
|
6099
|
+
if (!enforceLocalAdminAuth) return true;
|
|
6100
|
+
const protocols = String(req.headers['sec-websocket-protocol'] || '')
|
|
6101
|
+
.split(',')
|
|
6102
|
+
.map(value => value.trim())
|
|
6103
|
+
.filter(Boolean);
|
|
6104
|
+
const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
|
|
6105
|
+
const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
|
|
6106
|
+
return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
|
|
6107
|
+
&& secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
|
|
6108
|
+
}
|
|
6109
|
+
|
|
6110
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
6111
|
+
if (!isTrustedBrowserRequest(req)) {
|
|
5446
6112
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
5447
6113
|
socket.destroy();
|
|
5448
|
-
return;
|
|
5449
|
-
}
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
6114
|
+
return;
|
|
6115
|
+
}
|
|
6116
|
+
if (!isAuthorizedAdminWebSocket(req)) {
|
|
6117
|
+
recordSecurityAudit({
|
|
6118
|
+
action: 'local-admin.websocket',
|
|
6119
|
+
phase: 'complete',
|
|
6120
|
+
result: 'rejected',
|
|
6121
|
+
reason: 'admin-session-required',
|
|
6122
|
+
requestHash: requestAuditHash({ path: req.url || '' }),
|
|
6123
|
+
authMethod: 'local-admin-websocket'
|
|
6124
|
+
});
|
|
6125
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
6126
|
+
socket.destroy();
|
|
6127
|
+
return;
|
|
6128
|
+
}
|
|
6129
|
+
let parsed;
|
|
6130
|
+
try {
|
|
6131
|
+
parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6132
|
+
} catch {
|
|
6133
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
6134
|
+
socket.destroy();
|
|
6135
|
+
return;
|
|
6136
|
+
}
|
|
6137
|
+
const websocketPath = parsed.pathname;
|
|
6138
|
+
if (![
|
|
6139
|
+
'/api/remote/frames/ws',
|
|
6140
|
+
'/api/remote/atlas/ws',
|
|
6141
|
+
'/api/remote/input/ws',
|
|
6142
|
+
'/api/remote/audio/ws'
|
|
6143
|
+
].includes(websocketPath)) {
|
|
6144
|
+
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
6145
|
+
socket.destroy();
|
|
6146
|
+
return;
|
|
6147
|
+
}
|
|
6148
|
+
void recordSecurityAuditRequired({
|
|
6149
|
+
action: 'local-admin.websocket',
|
|
6150
|
+
phase: 'accepted',
|
|
6151
|
+
result: 'accepted',
|
|
6152
|
+
sessionId: localAdminSessionId,
|
|
6153
|
+
requestHash: requestAuditHash({ path: websocketPath }),
|
|
6154
|
+
authMethod: 'local-admin-websocket',
|
|
6155
|
+
details: { path: websocketPath }
|
|
6156
|
+
}).then(() => {
|
|
6157
|
+
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
6158
|
+
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|
|
6159
|
+
return;
|
|
5455
6160
|
}
|
|
5456
6161
|
if (parsed.pathname === '/api/remote/atlas/ws') {
|
|
5457
6162
|
atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
|
|
@@ -5462,16 +6167,14 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
5462
6167
|
return;
|
|
5463
6168
|
}
|
|
5464
6169
|
if (parsed.pathname === '/api/remote/audio/ws') {
|
|
5465
|
-
audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
|
|
5466
|
-
return;
|
|
5467
|
-
}
|
|
5468
|
-
|
|
5469
|
-
socket.
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
}
|
|
5474
|
-
});
|
|
6170
|
+
audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
|
|
6171
|
+
return;
|
|
6172
|
+
}
|
|
6173
|
+
}).catch(() => {
|
|
6174
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
6175
|
+
socket.destroy();
|
|
6176
|
+
});
|
|
6177
|
+
});
|
|
5475
6178
|
|
|
5476
6179
|
frameWss.on('connection', (ws, req) => {
|
|
5477
6180
|
frameClients.add(ws);
|
|
@@ -5496,6 +6199,8 @@ frameWss.on('connection', (ws, req) => {
|
|
|
5496
6199
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5497
6200
|
if (payload?.type === 'subscribe') {
|
|
5498
6201
|
updateFrameSubscription(ws, payload);
|
|
6202
|
+
} else if (payload?.type === 'refresh-live') {
|
|
6203
|
+
refreshFrameSubscriptionLive(ws, payload);
|
|
5499
6204
|
} else if (payload?.type === 'restart-live') {
|
|
5500
6205
|
restartFrameSubscriptionLive(ws, payload);
|
|
5501
6206
|
}
|
|
@@ -5581,17 +6286,22 @@ audioWss.on('connection', (ws, req) => {
|
|
|
5581
6286
|
});
|
|
5582
6287
|
});
|
|
5583
6288
|
|
|
5584
|
-
inputWss.on('connection', ws => {
|
|
5585
|
-
inputClients.add(ws);
|
|
5586
|
-
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
5587
|
-
ws.liveDeskInputDeviceIds = new Set();
|
|
6289
|
+
inputWss.on('connection', ws => {
|
|
6290
|
+
inputClients.add(ws);
|
|
6291
|
+
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
6292
|
+
ws.liveDeskInputDeviceIds = new Set();
|
|
6293
|
+
ws.liveDeskInputAuditReady = false;
|
|
5588
6294
|
try {
|
|
5589
6295
|
ws._socket?.setNoDelay?.(true);
|
|
5590
6296
|
} catch {
|
|
5591
6297
|
// Best-effort latency hint for browser input sockets.
|
|
5592
|
-
}
|
|
5593
|
-
ws.on('message', data => {
|
|
5594
|
-
|
|
6298
|
+
}
|
|
6299
|
+
ws.on('message', data => {
|
|
6300
|
+
if (ws.liveDeskInputAuditReady !== true) {
|
|
6301
|
+
sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
|
|
6302
|
+
return;
|
|
6303
|
+
}
|
|
6304
|
+
let payload;
|
|
5595
6305
|
try {
|
|
5596
6306
|
payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5597
6307
|
} catch {
|
|
@@ -5630,22 +6340,46 @@ inputWss.on('connection', ws => {
|
|
|
5630
6340
|
timestamp: new Date().toISOString()
|
|
5631
6341
|
});
|
|
5632
6342
|
});
|
|
5633
|
-
const releaseBrowserInputOwner = reason => {
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
})
|
|
6343
|
+
const releaseBrowserInputOwner = reason => {
|
|
6344
|
+
if (ws.liveDeskInputReleased) return;
|
|
6345
|
+
ws.liveDeskInputReleased = true;
|
|
6346
|
+
inputClients.delete(ws);
|
|
6347
|
+
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
6348
|
+
remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
|
|
6349
|
+
}
|
|
6350
|
+
void recordSecurityAuditRequired({
|
|
6351
|
+
action: 'remote.control.browser-session',
|
|
6352
|
+
phase: 'complete',
|
|
6353
|
+
result: 'success',
|
|
6354
|
+
reason,
|
|
6355
|
+
deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
|
|
6356
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6357
|
+
authMethod: 'local-admin-websocket'
|
|
6358
|
+
}).catch(() => {
|
|
6359
|
+
// The global fail-closed gate is marked by recordSecurityAuditRequired.
|
|
6360
|
+
});
|
|
6361
|
+
ws.liveDeskInputDeviceIds?.clear?.();
|
|
6362
|
+
};
|
|
6363
|
+
ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
|
|
6364
|
+
ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
|
|
6365
|
+
void recordSecurityAuditRequired({
|
|
6366
|
+
action: 'remote.control.browser-session',
|
|
6367
|
+
phase: 'start',
|
|
6368
|
+
result: 'success',
|
|
6369
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6370
|
+
authMethod: 'local-admin-websocket'
|
|
6371
|
+
}).then(() => {
|
|
6372
|
+
ws.liveDeskInputAuditReady = true;
|
|
6373
|
+
sendJson(ws, {
|
|
6374
|
+
type: 'RemoteInputSocketReady',
|
|
6375
|
+
protocol: 'livedesk.remote.input.json.v1',
|
|
6376
|
+
clientId: ws.liveDeskInputClientId,
|
|
6377
|
+
timestamp: new Date().toISOString()
|
|
6378
|
+
});
|
|
6379
|
+
}).catch(() => {
|
|
6380
|
+
ws.close(1011, 'security-audit-unavailable');
|
|
6381
|
+
});
|
|
6382
|
+
});
|
|
5649
6383
|
|
|
5650
6384
|
await remoteHub.start();
|
|
5651
6385
|
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
@@ -5739,7 +6473,8 @@ function shutdownHub(signal) {
|
|
|
5739
6473
|
hubShutdownPromise = (async () => {
|
|
5740
6474
|
const startedAt = Date.now();
|
|
5741
6475
|
console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
|
|
5742
|
-
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6476
|
+
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6477
|
+
clearInterval(captureRetentionTimer);
|
|
5743
6478
|
clearInterval(browserWebSocketHeartbeatTimer);
|
|
5744
6479
|
runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
|
|
5745
6480
|
atlasClients.clear();
|