@livedesk/hub 0.1.30 → 0.1.31
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 +31 -4
- package/src/live-desk-update.js +87 -19
- package/src/remote-hub.js +658 -166
- package/src/security/device-credential-authority.js +358 -0
- package/src/security/security-audit-store.js +238 -0
- package/src/server.js +755 -191
- package/src/settings/settings-schema.js +19 -39
- package/src/transport/relay-hub-control.js +329 -3
- package/src/transport/secure-direct-acceptor.js +432 -0
- package/src/transport/udp-hub-transport.js +17 -5
- package/src/transport/udp-rendezvous.js +54 -2
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,57 @@ 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 recordSecurityAudit(event = {}) {
|
|
818
|
+
if (!securityAuditStore) return;
|
|
819
|
+
const runtime = runtimeManager.getSnapshot();
|
|
820
|
+
void securityAuditStore.record({
|
|
821
|
+
actorAccountId: event.actorAccountId || runtime.userId || '',
|
|
822
|
+
actorUserId: event.actorUserId || runtime.userId || '',
|
|
823
|
+
hubId: event.hubId || runtime.deviceId || '',
|
|
824
|
+
...event
|
|
825
|
+
}).catch(error => {
|
|
826
|
+
securityAuditHealthy = false;
|
|
827
|
+
console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
|
|
832
|
+
const captureStore = new CaptureStore({
|
|
833
|
+
dataDir: agentDataDir,
|
|
834
|
+
getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
|
|
835
|
+
onRetentionDelete: result => recordSecurityAudit({
|
|
836
|
+
action: 'privacy.capture.retention-delete',
|
|
837
|
+
phase: 'complete',
|
|
838
|
+
result: 'success',
|
|
839
|
+
authMethod: 'settings-retention-policy',
|
|
840
|
+
details: result
|
|
841
|
+
})
|
|
842
|
+
});
|
|
843
|
+
void captureStore.initialize().catch(error => {
|
|
844
|
+
console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
845
|
+
});
|
|
846
|
+
void liveDeskSettingsStore.getRecord()
|
|
847
|
+
.then(() => captureStore.enforceRetention())
|
|
848
|
+
.catch(error => {
|
|
849
|
+
console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
850
|
+
});
|
|
851
|
+
const captureRetentionTimer = setInterval(() => {
|
|
852
|
+
void captureStore.enforceRetention().catch(error => {
|
|
853
|
+
console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
854
|
+
});
|
|
855
|
+
}, 60 * 60_000);
|
|
856
|
+
captureRetentionTimer.unref?.();
|
|
671
857
|
|
|
672
858
|
const udpTransport = createHubUdpTransport({
|
|
673
859
|
env: process.env,
|
|
@@ -675,9 +861,10 @@ const udpTransport = createHubUdpTransport({
|
|
|
675
861
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
|
|
676
862
|
});
|
|
677
863
|
|
|
678
|
-
const remoteHub = createRemoteHub({
|
|
679
|
-
managerPackage: '@livedesk/hub',
|
|
680
|
-
managerVersion: packageInfo.version,
|
|
864
|
+
const remoteHub = createRemoteHub({
|
|
865
|
+
managerPackage: '@livedesk/hub',
|
|
866
|
+
managerVersion: packageInfo.version,
|
|
867
|
+
dataDir: agentDataDir,
|
|
681
868
|
env: {
|
|
682
869
|
...process.env,
|
|
683
870
|
MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
|
|
@@ -688,7 +875,11 @@ const remoteHub = createRemoteHub({
|
|
|
688
875
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
|
|
689
876
|
emitEvent: handleRemoteHubEvent,
|
|
690
877
|
emitFrame: broadcastRemoteBinaryFrame,
|
|
691
|
-
emitAudio: broadcastRemoteBinaryAudio,
|
|
878
|
+
emitAudio: broadcastRemoteBinaryAudio,
|
|
879
|
+
getSecurityIdentity: () => ({
|
|
880
|
+
accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
|
|
881
|
+
hubId: remoteHub?.getSecurityStatus?.().hubId || ''
|
|
882
|
+
}),
|
|
692
883
|
getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
|
|
693
884
|
getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
|
|
694
885
|
const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
|
|
@@ -791,9 +982,11 @@ function getLiveDeskUpdateStatus() {
|
|
|
791
982
|
if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
|
|
792
983
|
liveDeskUpdateManager = createLiveDeskUpdateManager({
|
|
793
984
|
remoteHub,
|
|
794
|
-
currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
|
|
795
|
-
currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
|
|
796
|
-
|
|
985
|
+
currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
|
|
986
|
+
currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
|
|
987
|
+
updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
|
|
988
|
+
updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
|
|
989
|
+
restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
|
|
797
990
|
requestHubRestart
|
|
798
991
|
});
|
|
799
992
|
}
|
|
@@ -1111,7 +1304,7 @@ function validateAgentMcpArguments(name, args) {
|
|
|
1111
1304
|
return '';
|
|
1112
1305
|
}
|
|
1113
1306
|
|
|
1114
|
-
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1307
|
+
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1115
1308
|
if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
|
|
1116
1309
|
// This check runs synchronously before the first await, so concurrent Node
|
|
1117
1310
|
// requests cannot pass the same remaining budget and queue extra Client work.
|
|
@@ -1119,8 +1312,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
|
1119
1312
|
session.toolLimitReached = true;
|
|
1120
1313
|
return { ok: false, error: 'codex-tool-limit-reached' };
|
|
1121
1314
|
}
|
|
1122
|
-
session.toolCallCount += 1;
|
|
1123
|
-
|
|
1315
|
+
session.toolCallCount += 1;
|
|
1316
|
+
if (isRetiredAgentMutatingToolName(name)) {
|
|
1317
|
+
recordAgentAudit({
|
|
1318
|
+
event: 'tool-rejected',
|
|
1319
|
+
runId: session.runId,
|
|
1320
|
+
deviceIds: [],
|
|
1321
|
+
toolName: name,
|
|
1322
|
+
category: 'mutation',
|
|
1323
|
+
decision: 'deny',
|
|
1324
|
+
status: 'rejected',
|
|
1325
|
+
permissionMode: session.permissionPolicy?.mode,
|
|
1326
|
+
policyHash: session.permissionPolicyHash,
|
|
1327
|
+
details: { reason: 'agent-mutating-tool-disabled' }
|
|
1328
|
+
});
|
|
1329
|
+
return { ok: false, error: 'agent-mutating-tool-disabled' };
|
|
1330
|
+
}
|
|
1331
|
+
const tool = getAgentToolDefinition(name);
|
|
1124
1332
|
if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
|
|
1125
1333
|
const validationError = validateAgentMcpArguments(name, args);
|
|
1126
1334
|
if (validationError) return { ok: false, error: validationError };
|
|
@@ -1312,12 +1520,15 @@ async function synchronizeAgentEnablement() {
|
|
|
1312
1520
|
}
|
|
1313
1521
|
|
|
1314
1522
|
const hubFilesystem = createHubFilesystem();
|
|
1315
|
-
hubTransferJobs = createHubTransferJobs({
|
|
1316
|
-
filesystem: hubFilesystem,
|
|
1317
|
-
remoteHub,
|
|
1318
|
-
maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
|
|
1319
|
-
commandResultTimeoutMs: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000)
|
|
1320
|
-
|
|
1523
|
+
hubTransferJobs = createHubTransferJobs({
|
|
1524
|
+
filesystem: hubFilesystem,
|
|
1525
|
+
remoteHub,
|
|
1526
|
+
maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
|
|
1527
|
+
commandResultTimeoutMs: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000),
|
|
1528
|
+
getMaxFileSizeBytes: () => Number(
|
|
1529
|
+
liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
|
|
1530
|
+
|| 1024 * 1024 * 1024)
|
|
1531
|
+
});
|
|
1321
1532
|
const hubSharedFolders = createHubSharedFolders({
|
|
1322
1533
|
filesystem: hubFilesystem,
|
|
1323
1534
|
transferJobs: hubTransferJobs,
|
|
@@ -1437,16 +1648,74 @@ app.use((req, res, next) => {
|
|
|
1437
1648
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1438
1649
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
1439
1650
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
1440
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
1651
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
|
|
1441
1652
|
res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
|
|
1442
1653
|
}
|
|
1443
1654
|
if (req.method === 'OPTIONS') {
|
|
1444
1655
|
res.status(204).end();
|
|
1445
1656
|
return;
|
|
1446
1657
|
}
|
|
1447
|
-
next();
|
|
1448
|
-
});
|
|
1449
|
-
app.use(
|
|
1658
|
+
next();
|
|
1659
|
+
});
|
|
1660
|
+
app.use((req, res, next) => {
|
|
1661
|
+
if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
|
|
1662
|
+
next();
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const authorizedLauncherShutdown = req.method === 'POST'
|
|
1666
|
+
&& req.path === '/api/runtime/shutdown'
|
|
1667
|
+
&& isLoopbackAddress(req.socket?.remoteAddress)
|
|
1668
|
+
&& launcherShutdownToken
|
|
1669
|
+
&& secureExactTestTokenMatches(
|
|
1670
|
+
String(req.headers['x-livedesk-launcher-shutdown'] || ''),
|
|
1671
|
+
launcherShutdownToken
|
|
1672
|
+
);
|
|
1673
|
+
if (authorizedLauncherShutdown) {
|
|
1674
|
+
req.liveDeskLauncherShutdown = true;
|
|
1675
|
+
next();
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
|
|
1679
|
+
const sensitiveRead = req.method === 'GET'
|
|
1680
|
+
&& ['/api/security/audit', '/api/privacy/inventory'].includes(req.path);
|
|
1681
|
+
if (!mutating && !sensitiveRead) {
|
|
1682
|
+
next();
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
|
|
1686
|
+
const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
|
|
1687
|
+
if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
|
|
1688
|
+
recordSecurityAudit({
|
|
1689
|
+
action: 'local-admin.request',
|
|
1690
|
+
phase: 'complete',
|
|
1691
|
+
result: 'rejected',
|
|
1692
|
+
reason: 'admin-session-required',
|
|
1693
|
+
requestHash: requestAuditHash({ method: req.method, path: req.path }),
|
|
1694
|
+
authMethod: 'local-admin-session'
|
|
1695
|
+
});
|
|
1696
|
+
res.status(401).json({ ok: false, error: 'local-admin-session-required' });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
|
|
1700
|
+
recordSecurityAudit({
|
|
1701
|
+
action: 'local-admin.request',
|
|
1702
|
+
phase: 'complete',
|
|
1703
|
+
result: 'rejected',
|
|
1704
|
+
reason: 'csrf-token-required',
|
|
1705
|
+
requestHash: requestAuditHash({ method: req.method, path: req.path }),
|
|
1706
|
+
authMethod: 'local-admin-session'
|
|
1707
|
+
});
|
|
1708
|
+
res.status(403).json({ ok: false, error: 'csrf-token-required' });
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
if (!securityAuditHealthy) {
|
|
1712
|
+
res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
req.liveDeskAdminSessionId = localAdminSessionId;
|
|
1716
|
+
next();
|
|
1717
|
+
});
|
|
1718
|
+
app.use(express.json({ limit: '32mb' }));
|
|
1450
1719
|
app.use((req, res, next) => {
|
|
1451
1720
|
if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
|
|
1452
1721
|
res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
|
|
@@ -1597,11 +1866,12 @@ function normalizeTransferFiles(value) {
|
|
|
1597
1866
|
if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
|
|
1598
1867
|
return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
|
|
1599
1868
|
}
|
|
1600
|
-
files.push({
|
|
1601
|
-
name,
|
|
1602
|
-
relativePath,
|
|
1603
|
-
size: byteLength,
|
|
1604
|
-
|
|
1869
|
+
files.push({
|
|
1870
|
+
name,
|
|
1871
|
+
relativePath,
|
|
1872
|
+
size: byteLength,
|
|
1873
|
+
sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
|
|
1874
|
+
mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
|
|
1605
1875
|
lastModified: Number(entry?.lastModified || 0) || 0,
|
|
1606
1876
|
dataBase64
|
|
1607
1877
|
});
|
|
@@ -1612,24 +1882,39 @@ function normalizeTransferFiles(value) {
|
|
|
1612
1882
|
return { ok: true, files, totalBytes };
|
|
1613
1883
|
}
|
|
1614
1884
|
|
|
1615
|
-
function normalizeTransferChunk(body = {}) {
|
|
1885
|
+
function normalizeTransferChunk(body = {}) {
|
|
1616
1886
|
const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
1617
1887
|
const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
|
|
1618
1888
|
const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
|
|
1619
1889
|
const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
|
|
1620
1890
|
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
|
-
|
|
1891
|
+
const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
|
|
1892
|
+
const final = body.final === true;
|
|
1893
|
+
const maxFileSizeBytes = Math.max(1, Number(
|
|
1894
|
+
liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
|
|
1895
|
+
|| 1024 * 1024 * 1024));
|
|
1896
|
+
|
|
1897
|
+
if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
|
|
1898
|
+
return { ok: false, error: 'invalid-file-transfer-chunk' };
|
|
1899
|
+
}
|
|
1900
|
+
if (totalBytes > maxFileSizeBytes) {
|
|
1901
|
+
return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
|
|
1902
|
+
}
|
|
1627
1903
|
if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
|
|
1628
1904
|
return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
|
|
1629
1905
|
}
|
|
1630
|
-
if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
1631
|
-
return { ok: false, error: 'empty-file-transfer-chunk' };
|
|
1632
|
-
}
|
|
1906
|
+
if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
1907
|
+
return { ok: false, error: 'empty-file-transfer-chunk' };
|
|
1908
|
+
}
|
|
1909
|
+
const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
|
|
1910
|
+
const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
|
|
1911
|
+
? suppliedSha256
|
|
1912
|
+
: final && offset === 0 && totalBytes === byteLength
|
|
1913
|
+
? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
|
|
1914
|
+
: '';
|
|
1915
|
+
if (final && !sha256) {
|
|
1916
|
+
return { ok: false, error: 'file-transfer-sha256-required', byteLength };
|
|
1917
|
+
}
|
|
1633
1918
|
|
|
1634
1919
|
return {
|
|
1635
1920
|
ok: true,
|
|
@@ -1639,8 +1924,9 @@ function normalizeTransferChunk(body = {}) {
|
|
|
1639
1924
|
offset,
|
|
1640
1925
|
totalBytes,
|
|
1641
1926
|
byteLength,
|
|
1642
|
-
dataBase64,
|
|
1643
|
-
final,
|
|
1927
|
+
dataBase64,
|
|
1928
|
+
final,
|
|
1929
|
+
sha256,
|
|
1644
1930
|
mimeType: String(body.type || body.mimeType || '').slice(0, 160),
|
|
1645
1931
|
lastModified: Number(body.lastModified || 0) || 0
|
|
1646
1932
|
}
|
|
@@ -2746,6 +3032,37 @@ function updateFrameSubscription(ws, payload = {}) {
|
|
|
2746
3032
|
}
|
|
2747
3033
|
}
|
|
2748
3034
|
|
|
3035
|
+
function refreshFrameSubscriptionLive(ws, payload = {}) {
|
|
3036
|
+
if (!ws || ws.readyState !== 1) {
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3039
|
+
const requestedIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
|
|
3040
|
+
const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
|
|
3041
|
+
const targetIds = requestedIds.length > 0
|
|
3042
|
+
? requestedIds.filter(deviceId => ws.liveDeskDeviceIds?.has?.(deviceId))
|
|
3043
|
+
: subscribedIds;
|
|
3044
|
+
if (targetIds.length === 0) {
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(
|
|
3048
|
+
payload.autoStartLive ?? payload.startLive ?? ws.liveDeskAutoStart ?? ''
|
|
3049
|
+
));
|
|
3050
|
+
ws.liveDeskLiveOptions = normalizeLiveOptions({
|
|
3051
|
+
...(ws.liveDeskLiveOptions || {}),
|
|
3052
|
+
...payload,
|
|
3053
|
+
monitorSelections: {
|
|
3054
|
+
...(ws.liveDeskLiveOptions?.monitorSelections || {}),
|
|
3055
|
+
...(payload.monitorSelections || {})
|
|
3056
|
+
}
|
|
3057
|
+
});
|
|
3058
|
+
for (const deviceId of targetIds.slice(0, 80)) {
|
|
3059
|
+
startFrameSubscriptionLive(ws, 'watchdog', deviceId, {
|
|
3060
|
+
...ws.liveDeskLiveOptions,
|
|
3061
|
+
reuseExisting: true
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
|
|
2749
3066
|
function startFrameSubscriptionLive(
|
|
2750
3067
|
ws,
|
|
2751
3068
|
reason = 'subscribe',
|
|
@@ -3677,10 +3994,26 @@ function buildHubHealthPayload({
|
|
|
3677
3994
|
};
|
|
3678
3995
|
}
|
|
3679
3996
|
|
|
3680
|
-
app.get('/api/health', (_req, res) => {
|
|
3681
|
-
noStore(res);
|
|
3682
|
-
res.json(buildHubHealthPayload());
|
|
3683
|
-
});
|
|
3997
|
+
app.get('/api/health', (_req, res) => {
|
|
3998
|
+
noStore(res);
|
|
3999
|
+
res.json(buildHubHealthPayload());
|
|
4000
|
+
});
|
|
4001
|
+
|
|
4002
|
+
app.get('/api/security/session', (req, res) => {
|
|
4003
|
+
noStore(res);
|
|
4004
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
|
|
4005
|
+
res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
res.json({
|
|
4009
|
+
ok: true,
|
|
4010
|
+
sessionId: localAdminSessionId,
|
|
4011
|
+
adminToken: localAdminSessionToken,
|
|
4012
|
+
csrfToken: localAdminCsrfToken,
|
|
4013
|
+
issuedAt: new Date().toISOString(),
|
|
4014
|
+
expiresOnProcessExit: true
|
|
4015
|
+
});
|
|
4016
|
+
});
|
|
3684
4017
|
|
|
3685
4018
|
if (persistentSessionGcEnabled) {
|
|
3686
4019
|
app.post('/api/test/persistent-session/gc', (req, res) => {
|
|
@@ -3733,9 +4066,18 @@ app.patch('/api/settings', async (req, res) => {
|
|
|
3733
4066
|
const record = await liveDeskSettingsStore.update(patch, revision);
|
|
3734
4067
|
if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
|
|
3735
4068
|
await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
|
|
3736
|
-
}
|
|
3737
|
-
remoteHub.refreshDevicePolicies();
|
|
3738
|
-
|
|
4069
|
+
}
|
|
4070
|
+
remoteHub.refreshDevicePolicies();
|
|
4071
|
+
void captureStore.enforceRetention().catch(() => undefined);
|
|
4072
|
+
recordSecurityAudit({
|
|
4073
|
+
action: 'settings.security-policy.updated',
|
|
4074
|
+
phase: 'complete',
|
|
4075
|
+
result: 'success',
|
|
4076
|
+
requestHash: requestAuditHash(patch),
|
|
4077
|
+
authMethod: 'local-admin-session',
|
|
4078
|
+
details: { revision: record.revision, sections: Object.keys(patch) }
|
|
4079
|
+
});
|
|
4080
|
+
res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
|
|
3739
4081
|
} catch (error) {
|
|
3740
4082
|
if (error instanceof SettingsConflictError) {
|
|
3741
4083
|
res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
|
|
@@ -3890,7 +4232,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
|
|
|
3890
4232
|
});
|
|
3891
4233
|
});
|
|
3892
4234
|
|
|
3893
|
-
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
4235
|
+
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
3894
4236
|
noStore(res);
|
|
3895
4237
|
const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
|
|
3896
4238
|
deviceId: device.deviceId,
|
|
@@ -3902,24 +4244,128 @@ app.get('/api/security/trusted-devices', (_req, res) => {
|
|
|
3902
4244
|
lastSeenAt: device.lastSeenAt || '',
|
|
3903
4245
|
ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
|
|
3904
4246
|
}));
|
|
3905
|
-
res.json({ ok: true, devices });
|
|
3906
|
-
});
|
|
3907
|
-
|
|
3908
|
-
app.
|
|
3909
|
-
noStore(res);
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
4247
|
+
res.json({ ok: true, devices });
|
|
4248
|
+
});
|
|
4249
|
+
|
|
4250
|
+
app.get('/api/security/audit', async (req, res) => {
|
|
4251
|
+
noStore(res);
|
|
4252
|
+
try {
|
|
4253
|
+
res.json({
|
|
4254
|
+
ok: true,
|
|
4255
|
+
healthy: securityAuditHealthy,
|
|
4256
|
+
verification: await securityAuditStore.verify(),
|
|
4257
|
+
audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
|
|
4258
|
+
});
|
|
4259
|
+
} catch (error) {
|
|
4260
|
+
securityAuditHealthy = false;
|
|
4261
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
4262
|
+
}
|
|
4263
|
+
});
|
|
4264
|
+
|
|
4265
|
+
app.get('/api/privacy/inventory', async (_req, res) => {
|
|
4266
|
+
noStore(res);
|
|
4267
|
+
try {
|
|
4268
|
+
const [captures, sharedFolders, verification] = await Promise.all([
|
|
4269
|
+
captureStore.list(),
|
|
4270
|
+
hubSharedFolders.list(),
|
|
4271
|
+
securityAuditStore.verify()
|
|
4272
|
+
]);
|
|
4273
|
+
res.json({
|
|
4274
|
+
ok: true,
|
|
4275
|
+
inventory: {
|
|
4276
|
+
authenticated: runtimeManager.getSnapshot().authenticated === true,
|
|
4277
|
+
captures: captures.length,
|
|
4278
|
+
sharedFolderDefinitions: sharedFolders.length,
|
|
4279
|
+
trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
|
|
4280
|
+
securityAuditRecords: verification.records,
|
|
4281
|
+
agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
|
|
4282
|
+
}
|
|
4283
|
+
});
|
|
4284
|
+
} catch (error) {
|
|
4285
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
|
|
4286
|
+
}
|
|
4287
|
+
});
|
|
4288
|
+
|
|
4289
|
+
app.delete('/api/privacy/local-data', async (req, res) => {
|
|
4290
|
+
noStore(res);
|
|
4291
|
+
if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
|
|
4292
|
+
res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
|
|
4293
|
+
return;
|
|
4294
|
+
}
|
|
4295
|
+
try {
|
|
4296
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
4297
|
+
const hostTarget = runtimeRole === 'hub'
|
|
4298
|
+
? await clearHubHostTarget('privacy-delete')
|
|
4299
|
+
: { ok: true, active: false };
|
|
4300
|
+
clearRuntimeSession();
|
|
4301
|
+
for (const device of remoteHub.getSecurityStatus().devices) {
|
|
4302
|
+
remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
|
|
4303
|
+
}
|
|
4304
|
+
const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
|
|
4305
|
+
const transferJobs = hubTransferJobs.clear();
|
|
4306
|
+
const captures = await captureStore.removeAll();
|
|
4307
|
+
const sharedFolders = await hubSharedFolders.clear();
|
|
4308
|
+
await agentAuditStore.clear();
|
|
4309
|
+
await securityAuditStore.reset();
|
|
4310
|
+
securityAuditHealthy = true;
|
|
4311
|
+
await securityAuditStore.record({
|
|
4312
|
+
action: 'privacy.local-data.deleted',
|
|
4313
|
+
phase: 'complete',
|
|
4314
|
+
result: 'success',
|
|
4315
|
+
authMethod: 'local-admin-session',
|
|
4316
|
+
details: {
|
|
4317
|
+
capturesRemoved: captures.removed,
|
|
4318
|
+
captureBytesRemoved: captures.removedBytes,
|
|
4319
|
+
sharedFolderDefinitionsRemoved: sharedFolders.removed,
|
|
4320
|
+
trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
|
|
4321
|
+
transferJobRecordsRemoved: transferJobs.removed,
|
|
4322
|
+
providerSessionRevoked: providerLogout.revoked === true
|
|
4323
|
+
}
|
|
4324
|
+
});
|
|
4325
|
+
res.json({
|
|
4326
|
+
ok: true,
|
|
4327
|
+
localDeleted: true,
|
|
4328
|
+
providerLogout,
|
|
4329
|
+
hostTarget,
|
|
4330
|
+
captures,
|
|
4331
|
+
sharedFolders,
|
|
4332
|
+
trustedDeviceCredentials,
|
|
4333
|
+
transferJobs,
|
|
4334
|
+
preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
|
|
4335
|
+
});
|
|
4336
|
+
} catch (error) {
|
|
4337
|
+
securityAuditHealthy = false;
|
|
4338
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
|
|
4339
|
+
}
|
|
4340
|
+
});
|
|
4341
|
+
|
|
4342
|
+
app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
|
|
4343
|
+
noStore(res);
|
|
4344
|
+
const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
|
|
4345
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
|
|
4346
|
+
const revoked = disconnected === true || credentialRevoked === true;
|
|
4347
|
+
recordSecurityAudit({
|
|
4348
|
+
action: 'security.device.revoked',
|
|
4349
|
+
phase: 'complete',
|
|
4350
|
+
result: revoked ? 'success' : 'not-found',
|
|
4351
|
+
deviceId: req.params.deviceId,
|
|
4352
|
+
authMethod: 'local-admin-session'
|
|
4353
|
+
});
|
|
4354
|
+
res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
|
|
4355
|
+
});
|
|
3913
4356
|
|
|
3914
4357
|
app.post('/api/security/revoke-all', (_req, res) => {
|
|
3915
4358
|
noStore(res);
|
|
3916
|
-
const devices = remoteHub.listDevices({ includeDataUrl: false });
|
|
3917
|
-
let revoked = 0;
|
|
3918
|
-
for (const device of devices) {
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
}
|
|
4359
|
+
const devices = remoteHub.listDevices({ includeDataUrl: false });
|
|
4360
|
+
let revoked = 0;
|
|
4361
|
+
for (const device of devices) {
|
|
4362
|
+
const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
|
|
4363
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
|
|
4364
|
+
if (disconnected || credentialRevoked) revoked += 1;
|
|
4365
|
+
}
|
|
4366
|
+
recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
|
|
4367
|
+
res.json({ ok: true, revoked });
|
|
4368
|
+
});
|
|
3923
4369
|
|
|
3924
4370
|
app.get('/api/settings/agent', async (_req, res) => {
|
|
3925
4371
|
noStore(res);
|
|
@@ -4281,9 +4727,17 @@ app.post('/api/runtime/restart', (_req, res) => {
|
|
|
4281
4727
|
}, 150);
|
|
4282
4728
|
});
|
|
4283
4729
|
|
|
4284
|
-
app.post('/api/runtime/shutdown', (
|
|
4285
|
-
noStore(res);
|
|
4286
|
-
|
|
4730
|
+
app.post('/api/runtime/shutdown', (req, res) => {
|
|
4731
|
+
noStore(res);
|
|
4732
|
+
recordSecurityAudit({
|
|
4733
|
+
action: 'runtime.shutdown',
|
|
4734
|
+
phase: 'requested',
|
|
4735
|
+
result: 'accepted',
|
|
4736
|
+
authMethod: req.liveDeskLauncherShutdown === true
|
|
4737
|
+
? 'launcher-internal-shutdown-token'
|
|
4738
|
+
: 'local-admin-session'
|
|
4739
|
+
});
|
|
4740
|
+
runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
|
|
4287
4741
|
res.json({ ok: true, shuttingDown: true, role: runtimeRole });
|
|
4288
4742
|
setTimeout(() => {
|
|
4289
4743
|
void shutdownHub('API_SHUTDOWN')
|
|
@@ -4320,8 +4774,10 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4320
4774
|
}
|
|
4321
4775
|
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
4322
4776
|
try {
|
|
4323
|
-
const user = await verifySupabaseUser(accessToken);
|
|
4324
|
-
|
|
4777
|
+
const user = await verifySupabaseUser(accessToken);
|
|
4778
|
+
runtimeSessionGeneration += 1;
|
|
4779
|
+
runtimeRefreshPromise = null;
|
|
4780
|
+
runtimeAccessToken = accessToken;
|
|
4325
4781
|
runtimeRefreshToken = refreshToken;
|
|
4326
4782
|
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
4327
4783
|
runtimeManager.setAuthenticated(true, user.id);
|
|
@@ -4334,12 +4790,29 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4334
4790
|
const hostTarget = runtimeRole === 'hub'
|
|
4335
4791
|
? await publishHubHostTargetWithPendingRoleTakeover('session-received')
|
|
4336
4792
|
: { ok: true, active: false };
|
|
4337
|
-
if (runtimeRole === 'hub') {
|
|
4338
|
-
startHubHostTargetLeaseRenewal();
|
|
4339
|
-
}
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4793
|
+
if (runtimeRole === 'hub') {
|
|
4794
|
+
startHubHostTargetLeaseRenewal();
|
|
4795
|
+
}
|
|
4796
|
+
recordSecurityAudit({
|
|
4797
|
+
action: 'auth.session.established',
|
|
4798
|
+
phase: 'complete',
|
|
4799
|
+
result: 'success',
|
|
4800
|
+
actorAccountId: user.id,
|
|
4801
|
+
actorUserId: user.id,
|
|
4802
|
+
authMethod: 'supabase-access-refresh',
|
|
4803
|
+
requestHash: requestAuditHash({ userId: user.id, expiresAt })
|
|
4804
|
+
});
|
|
4805
|
+
res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
|
|
4806
|
+
} catch (error) {
|
|
4807
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4808
|
+
recordSecurityAudit({
|
|
4809
|
+
action: 'auth.session.establish',
|
|
4810
|
+
phase: 'complete',
|
|
4811
|
+
result: 'rejected',
|
|
4812
|
+
reason: message,
|
|
4813
|
+
authMethod: 'supabase-access-refresh',
|
|
4814
|
+
requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
|
|
4815
|
+
});
|
|
4343
4816
|
const status = authVerificationHttpStatus(message);
|
|
4344
4817
|
res.status(status).json({ ok: false, error: message });
|
|
4345
4818
|
}
|
|
@@ -4633,14 +5106,33 @@ function startHubHostTargetLeaseRenewal() {
|
|
|
4633
5106
|
hubHostTargetRenewTimer.unref?.();
|
|
4634
5107
|
}
|
|
4635
5108
|
|
|
4636
|
-
app.delete('/api/auth/session', async (_req, res) => {
|
|
4637
|
-
noStore(res);
|
|
4638
|
-
const
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
5109
|
+
app.delete('/api/auth/session', async (_req, res) => {
|
|
5110
|
+
noStore(res);
|
|
5111
|
+
const actorUserId = runtimeManager.getSnapshot().userId || '';
|
|
5112
|
+
const hostTarget = runtimeRole === 'hub'
|
|
5113
|
+
? await clearHubHostTarget('logout')
|
|
5114
|
+
: { ok: true, active: false };
|
|
5115
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
5116
|
+
clearRuntimeSession();
|
|
5117
|
+
recordSecurityAudit({
|
|
5118
|
+
action: 'auth.session.revoked',
|
|
5119
|
+
phase: 'complete',
|
|
5120
|
+
result: providerLogout.ok ? 'success' : 'provider-error',
|
|
5121
|
+
actorAccountId: actorUserId,
|
|
5122
|
+
actorUserId,
|
|
5123
|
+
reason: providerLogout.error || '',
|
|
5124
|
+
authMethod: 'supabase-local-signout',
|
|
5125
|
+
details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
|
|
5126
|
+
});
|
|
5127
|
+
res.status(providerLogout.ok ? 200 : 502).json({
|
|
5128
|
+
ok: providerLogout.ok,
|
|
5129
|
+
authenticated: false,
|
|
5130
|
+
localCleared: true,
|
|
5131
|
+
role: runtimeRole,
|
|
5132
|
+
hostTarget,
|
|
5133
|
+
providerLogout
|
|
5134
|
+
});
|
|
5135
|
+
});
|
|
4644
5136
|
|
|
4645
5137
|
app.get('/api/hub/status', (_req, res) => {
|
|
4646
5138
|
noStore(res);
|
|
@@ -5072,15 +5564,17 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
|
|
|
5072
5564
|
}
|
|
5073
5565
|
});
|
|
5074
5566
|
|
|
5075
|
-
app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
5567
|
+
app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
5076
5568
|
noStore(res);
|
|
5077
5569
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5078
|
-
if (deviceIds.length === 0) {
|
|
5079
|
-
|
|
5570
|
+
if (deviceIds.length === 0) {
|
|
5571
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5572
|
+
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5080
5573
|
return;
|
|
5081
5574
|
}
|
|
5082
5575
|
const normalized = normalizeTransferFiles(req.body?.files);
|
|
5083
|
-
if (!normalized.ok) {
|
|
5576
|
+
if (!normalized.ok) {
|
|
5577
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5084
5578
|
res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
|
|
5085
5579
|
return;
|
|
5086
5580
|
}
|
|
@@ -5100,7 +5594,18 @@ app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
|
5100
5594
|
}
|
|
5101
5595
|
})
|
|
5102
5596
|
}));
|
|
5103
|
-
const queued = results.filter(result => result.ok).length;
|
|
5597
|
+
const queued = results.filter(result => result.ok).length;
|
|
5598
|
+
recordSecurityAudit({
|
|
5599
|
+
action: 'remote.file.transfer',
|
|
5600
|
+
phase: 'start',
|
|
5601
|
+
result: queued > 0 ? 'queued' : 'rejected',
|
|
5602
|
+
reason: queued > 0 ? '' : 'no-transfer-queued',
|
|
5603
|
+
deviceIds,
|
|
5604
|
+
sessionId: transferId,
|
|
5605
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
|
|
5606
|
+
authMethod: 'local-admin-session',
|
|
5607
|
+
details: { queued, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
|
|
5608
|
+
});
|
|
5104
5609
|
res.json({
|
|
5105
5610
|
ok: queued > 0,
|
|
5106
5611
|
transferId,
|
|
@@ -5113,15 +5618,17 @@ app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
|
5113
5618
|
});
|
|
5114
5619
|
});
|
|
5115
5620
|
|
|
5116
|
-
app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
5621
|
+
app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
5117
5622
|
noStore(res);
|
|
5118
5623
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5119
|
-
if (deviceIds.length === 0) {
|
|
5624
|
+
if (deviceIds.length === 0) {
|
|
5625
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5120
5626
|
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5121
5627
|
return;
|
|
5122
5628
|
}
|
|
5123
5629
|
const normalized = normalizeTransferChunk(req.body || {});
|
|
5124
|
-
if (!normalized.ok) {
|
|
5630
|
+
if (!normalized.ok) {
|
|
5631
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5125
5632
|
res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
|
|
5126
5633
|
return;
|
|
5127
5634
|
}
|
|
@@ -5141,7 +5648,18 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
|
5141
5648
|
}
|
|
5142
5649
|
})
|
|
5143
5650
|
}));
|
|
5144
|
-
const queued = results.filter(result => result.ok).length;
|
|
5651
|
+
const queued = results.filter(result => result.ok).length;
|
|
5652
|
+
recordSecurityAudit({
|
|
5653
|
+
action: 'remote.file.chunk',
|
|
5654
|
+
phase: normalized.chunk.final ? 'complete' : 'progress',
|
|
5655
|
+
result: queued > 0 ? 'queued' : 'rejected',
|
|
5656
|
+
reason: queued > 0 ? '' : 'no-transfer-queued',
|
|
5657
|
+
deviceIds,
|
|
5658
|
+
sessionId: transferId,
|
|
5659
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
|
|
5660
|
+
authMethod: 'local-admin-session',
|
|
5661
|
+
details: { queued, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
|
|
5662
|
+
});
|
|
5145
5663
|
res.json({
|
|
5146
5664
|
ok: queued > 0,
|
|
5147
5665
|
transferId,
|
|
@@ -5426,7 +5944,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
|
|
|
5426
5944
|
}
|
|
5427
5945
|
});
|
|
5428
5946
|
|
|
5429
|
-
if (existsSync(webIndexPath)) {
|
|
5947
|
+
if (existsSync(webIndexPath)) {
|
|
5430
5948
|
app.use(express.static(webDistPath, {
|
|
5431
5949
|
etag: true,
|
|
5432
5950
|
index: false,
|
|
@@ -5439,14 +5957,39 @@ if (existsSync(webIndexPath)) {
|
|
|
5439
5957
|
}
|
|
5440
5958
|
res.sendFile(webIndexPath);
|
|
5441
5959
|
});
|
|
5442
|
-
}
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
if (!
|
|
5960
|
+
}
|
|
5961
|
+
|
|
5962
|
+
function isAuthorizedAdminWebSocket(req) {
|
|
5963
|
+
if (!enforceLocalAdminAuth) return true;
|
|
5964
|
+
const protocols = String(req.headers['sec-websocket-protocol'] || '')
|
|
5965
|
+
.split(',')
|
|
5966
|
+
.map(value => value.trim())
|
|
5967
|
+
.filter(Boolean);
|
|
5968
|
+
const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
|
|
5969
|
+
const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
|
|
5970
|
+
return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
|
|
5971
|
+
&& secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
|
|
5972
|
+
}
|
|
5973
|
+
|
|
5974
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
5975
|
+
if (!isTrustedBrowserRequest(req)) {
|
|
5446
5976
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
5447
5977
|
socket.destroy();
|
|
5448
|
-
return;
|
|
5449
|
-
}
|
|
5978
|
+
return;
|
|
5979
|
+
}
|
|
5980
|
+
if (!isAuthorizedAdminWebSocket(req)) {
|
|
5981
|
+
recordSecurityAudit({
|
|
5982
|
+
action: 'local-admin.websocket',
|
|
5983
|
+
phase: 'complete',
|
|
5984
|
+
result: 'rejected',
|
|
5985
|
+
reason: 'admin-session-required',
|
|
5986
|
+
requestHash: requestAuditHash({ path: req.url || '' }),
|
|
5987
|
+
authMethod: 'local-admin-websocket'
|
|
5988
|
+
});
|
|
5989
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
5990
|
+
socket.destroy();
|
|
5991
|
+
return;
|
|
5992
|
+
}
|
|
5450
5993
|
try {
|
|
5451
5994
|
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
5452
5995
|
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
@@ -5496,6 +6039,8 @@ frameWss.on('connection', (ws, req) => {
|
|
|
5496
6039
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5497
6040
|
if (payload?.type === 'subscribe') {
|
|
5498
6041
|
updateFrameSubscription(ws, payload);
|
|
6042
|
+
} else if (payload?.type === 'refresh-live') {
|
|
6043
|
+
refreshFrameSubscriptionLive(ws, payload);
|
|
5499
6044
|
} else if (payload?.type === 'restart-live') {
|
|
5500
6045
|
restartFrameSubscriptionLive(ws, payload);
|
|
5501
6046
|
}
|
|
@@ -5581,10 +6126,17 @@ audioWss.on('connection', (ws, req) => {
|
|
|
5581
6126
|
});
|
|
5582
6127
|
});
|
|
5583
6128
|
|
|
5584
|
-
inputWss.on('connection', ws => {
|
|
6129
|
+
inputWss.on('connection', ws => {
|
|
5585
6130
|
inputClients.add(ws);
|
|
5586
6131
|
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
5587
|
-
ws.liveDeskInputDeviceIds = new Set();
|
|
6132
|
+
ws.liveDeskInputDeviceIds = new Set();
|
|
6133
|
+
recordSecurityAudit({
|
|
6134
|
+
action: 'remote.control.browser-session',
|
|
6135
|
+
phase: 'start',
|
|
6136
|
+
result: 'success',
|
|
6137
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6138
|
+
authMethod: 'local-admin-websocket'
|
|
6139
|
+
});
|
|
5588
6140
|
try {
|
|
5589
6141
|
ws._socket?.setNoDelay?.(true);
|
|
5590
6142
|
} catch {
|
|
@@ -5630,11 +6182,22 @@ inputWss.on('connection', ws => {
|
|
|
5630
6182
|
timestamp: new Date().toISOString()
|
|
5631
6183
|
});
|
|
5632
6184
|
});
|
|
5633
|
-
const releaseBrowserInputOwner = reason => {
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
6185
|
+
const releaseBrowserInputOwner = reason => {
|
|
6186
|
+
if (ws.liveDeskInputReleased) return;
|
|
6187
|
+
ws.liveDeskInputReleased = true;
|
|
6188
|
+
inputClients.delete(ws);
|
|
6189
|
+
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
6190
|
+
remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
|
|
6191
|
+
}
|
|
6192
|
+
recordSecurityAudit({
|
|
6193
|
+
action: 'remote.control.browser-session',
|
|
6194
|
+
phase: 'complete',
|
|
6195
|
+
result: 'success',
|
|
6196
|
+
reason,
|
|
6197
|
+
deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
|
|
6198
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6199
|
+
authMethod: 'local-admin-websocket'
|
|
6200
|
+
});
|
|
5638
6201
|
ws.liveDeskInputDeviceIds?.clear?.();
|
|
5639
6202
|
};
|
|
5640
6203
|
ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
|
|
@@ -5739,7 +6302,8 @@ function shutdownHub(signal) {
|
|
|
5739
6302
|
hubShutdownPromise = (async () => {
|
|
5740
6303
|
const startedAt = Date.now();
|
|
5741
6304
|
console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
|
|
5742
|
-
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6305
|
+
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6306
|
+
clearInterval(captureRetentionTimer);
|
|
5743
6307
|
clearInterval(browserWebSocketHeartbeatTimer);
|
|
5744
6308
|
runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
|
|
5745
6309
|
atlasClients.clear();
|