@livedesk/hub 0.1.32 → 0.1.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -32
- package/src/remote-hub.js +782 -728
- package/src/server.js +1058 -980
- package/src/settings/settings-schema.js +251 -239
- package/src/settings/settings-store.js +82 -77
- package/src/transport/secure-direct-acceptor.js +440 -433
package/src/server.js
CHANGED
|
@@ -39,17 +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 { createSecurityAuditStore } from './security/security-audit-store.js';
|
|
44
|
-
import { getAgentToolDefinition, isRetiredAgentMutatingToolName } from './agents/agent-tool-registry.js';
|
|
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';
|
|
45
45
|
import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
|
|
46
46
|
import { effectiveDevicePolicy } from './settings/settings-schema.js';
|
|
47
47
|
import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
|
|
48
48
|
import { CaptureStore } from './captures/capture-store.js';
|
|
49
49
|
import { createLiveDeskUpdateManager } from './live-desk-update.js';
|
|
50
50
|
import { createHubUdpTransport } from './transport/udp-hub-transport.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
|
+
import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
|
|
52
|
+
import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../runtime-core/src/os-secret-store.js';
|
|
53
53
|
import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
|
|
54
54
|
import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
|
|
55
55
|
import { createHubRuntime } from './runtime/hub-runtime.js';
|
|
@@ -65,20 +65,20 @@ const webDistCandidates = [
|
|
|
65
65
|
const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
|
|
66
66
|
const webIndexPath = resolve(webDistPath, 'index.html');
|
|
67
67
|
const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
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
|
+
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';
|
|
82
82
|
const runtimeRole = String(process.env.LIVEDESK_RUNTIME_ROLE || 'hub').trim().toLowerCase() === 'client' ? 'client' : 'hub';
|
|
83
83
|
const runtimeDeviceId = String(process.env.LIVEDESK_DEVICE_ID || '').trim();
|
|
84
84
|
const runtimeDeviceName = String(process.env.LIVEDESK_DEVICE_NAME || os.hostname()).trim() || os.hostname();
|
|
@@ -179,21 +179,21 @@ const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
|
|
|
179
179
|
? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
|
|
180
180
|
: AUTH_REQUEST_TIMEOUT_MS;
|
|
181
181
|
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
182
|
-
const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
|
|
182
|
+
const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
|
|
183
183
|
? ''
|
|
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'
|
|
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'
|
|
191
191
|
&& ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
|
|
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
|
-
: '';
|
|
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
|
+
: '';
|
|
197
197
|
const persistentSessionGcEnabled =
|
|
198
198
|
process.env.LIVEDESK_TEST_MODE === '1'
|
|
199
199
|
&& process.env.LIVEDESK_PERSISTENT_SESSION_TEST_GC === '1';
|
|
@@ -203,12 +203,12 @@ const traceRemoteTestEventsEnabled =
|
|
|
203
203
|
const persistentSessionGcToken = persistentSessionGcEnabled
|
|
204
204
|
? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
|
|
205
205
|
: '';
|
|
206
|
-
let connectedDeviceCount = 0;
|
|
207
|
-
let runtimeAccessToken = '';
|
|
208
|
-
let runtimeRefreshToken = '';
|
|
209
|
-
let runtimeAccessTokenExpiresAt = 0;
|
|
210
|
-
let runtimeSessionGeneration = 0;
|
|
211
|
-
let runtimeRefreshPromise = null;
|
|
206
|
+
let connectedDeviceCount = 0;
|
|
207
|
+
let runtimeAccessToken = '';
|
|
208
|
+
let runtimeRefreshToken = '';
|
|
209
|
+
let runtimeAccessTokenExpiresAt = 0;
|
|
210
|
+
let runtimeSessionGeneration = 0;
|
|
211
|
+
let runtimeRefreshPromise = null;
|
|
212
212
|
let roleWatchInFlight = false;
|
|
213
213
|
let verifiedLicense = {
|
|
214
214
|
userId: '',
|
|
@@ -220,10 +220,10 @@ let verifiedLicense = {
|
|
|
220
220
|
let frameClientSeq = 0;
|
|
221
221
|
let inputClientSeq = 0;
|
|
222
222
|
let audioClientSeq = 0;
|
|
223
|
-
let liveDeskUpdateManager = null;
|
|
224
|
-
let hubTransferJobs = null;
|
|
225
|
-
let securityAuditStore = null;
|
|
226
|
-
let securityAuditHealthy = true;
|
|
223
|
+
let liveDeskUpdateManager = null;
|
|
224
|
+
let hubTransferJobs = null;
|
|
225
|
+
let securityAuditStore = null;
|
|
226
|
+
let securityAuditHealthy = true;
|
|
227
227
|
const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
|
|
228
228
|
const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
|
|
229
229
|
readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
|
|
@@ -254,7 +254,7 @@ let hubWakeNotificationState = {
|
|
|
254
254
|
};
|
|
255
255
|
let hubWakeNextAttemptAtMs = 0;
|
|
256
256
|
|
|
257
|
-
function readPositiveIntegerEnv(name, fallback) {
|
|
257
|
+
function readPositiveIntegerEnv(name, fallback) {
|
|
258
258
|
const value = Number(process.env[name]);
|
|
259
259
|
return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
|
|
260
260
|
}
|
|
@@ -267,42 +267,42 @@ function secureExactTestTokenMatches(actual, expected) {
|
|
|
267
267
|
&& crypto.timingSafeEqual(actualBytes, expectedBytes);
|
|
268
268
|
}
|
|
269
269
|
|
|
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
|
-
}
|
|
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
|
+
}
|
|
306
306
|
if (traceRemoteTestEventsEnabled
|
|
307
307
|
&& (type === 'RemoteFrameDropped' || type === 'RemoteFrameTransportProofAccepted')) {
|
|
308
308
|
console.warn(`[LiveDesk Hub Test Event] ${type} ${JSON.stringify({
|
|
@@ -349,16 +349,16 @@ function handleRemoteHubEvent(type, event) {
|
|
|
349
349
|
}
|
|
350
350
|
return;
|
|
351
351
|
}
|
|
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') {
|
|
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') {
|
|
362
362
|
return;
|
|
363
363
|
}
|
|
364
364
|
const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
@@ -490,23 +490,23 @@ function readRuntimeAuthState() {
|
|
|
490
490
|
}
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
-
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
493
|
+
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
494
494
|
try {
|
|
495
495
|
const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
|
|
496
496
|
const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
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 };
|
|
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 };
|
|
510
510
|
} catch {
|
|
511
511
|
return null;
|
|
512
512
|
}
|
|
@@ -549,34 +549,34 @@ function persistRuntimeSession(session) {
|
|
|
549
549
|
...(session?.user && typeof session.user === 'object' ? session.user : {})
|
|
550
550
|
}
|
|
551
551
|
}, { requireRefreshToken: true });
|
|
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);
|
|
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);
|
|
557
557
|
writePrivateRuntimeAuthState(state);
|
|
558
558
|
return true;
|
|
559
559
|
}
|
|
560
560
|
|
|
561
|
-
function clearPersistedRuntimeSession() {
|
|
561
|
+
function clearPersistedRuntimeSession() {
|
|
562
562
|
if (!runtimeAuthStatePath) return false;
|
|
563
563
|
const state = readRuntimeAuthState();
|
|
564
|
-
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
565
|
-
runtimeRefreshSecretStore.clear();
|
|
564
|
+
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
565
|
+
runtimeRefreshSecretStore.clear();
|
|
566
566
|
return writePrivateRuntimeAuthState(state);
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
-
function clearRuntimeSession() {
|
|
570
|
-
runtimeSessionGeneration += 1;
|
|
571
|
-
runtimeRefreshPromise = null;
|
|
572
|
-
runtimeAccessToken = '';
|
|
573
|
-
runtimeRefreshToken = '';
|
|
569
|
+
function clearRuntimeSession() {
|
|
570
|
+
runtimeSessionGeneration += 1;
|
|
571
|
+
runtimeRefreshPromise = null;
|
|
572
|
+
runtimeAccessToken = '';
|
|
573
|
+
runtimeRefreshToken = '';
|
|
574
574
|
runtimeAccessTokenExpiresAt = 0;
|
|
575
575
|
runtimeManager.setAuthenticated(false);
|
|
576
576
|
try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
-
async function getRuntimeAccessToken() {
|
|
579
|
+
async function getRuntimeAccessToken() {
|
|
580
580
|
const accessToken = String(runtimeAccessToken || '').trim();
|
|
581
581
|
const expiresSoon = runtimeAccessTokenExpiresAt > 0
|
|
582
582
|
&& runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
@@ -585,112 +585,112 @@ async function getRuntimeAccessToken() {
|
|
|
585
585
|
}
|
|
586
586
|
|
|
587
587
|
const refreshToken = String(runtimeRefreshToken || '').trim();
|
|
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
|
-
}
|
|
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
|
+
}
|
|
694
694
|
|
|
695
695
|
async function verifySupabaseUser(accessToken) {
|
|
696
696
|
const token = String(accessToken || '').trim();
|
|
@@ -803,81 +803,81 @@ function requireHubFeatureAccess(_req, res, next) {
|
|
|
803
803
|
res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
|
|
804
804
|
}
|
|
805
805
|
|
|
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?.();
|
|
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?.();
|
|
881
881
|
|
|
882
882
|
const udpTransport = createHubUdpTransport({
|
|
883
883
|
env: process.env,
|
|
@@ -885,10 +885,10 @@ const udpTransport = createHubUdpTransport({
|
|
|
885
885
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
|
|
886
886
|
});
|
|
887
887
|
|
|
888
|
-
const remoteHub = createRemoteHub({
|
|
889
|
-
managerPackage: '@livedesk/hub',
|
|
890
|
-
managerVersion: packageInfo.version,
|
|
891
|
-
dataDir: agentDataDir,
|
|
888
|
+
const remoteHub = createRemoteHub({
|
|
889
|
+
managerPackage: '@livedesk/hub',
|
|
890
|
+
managerVersion: packageInfo.version,
|
|
891
|
+
dataDir: agentDataDir,
|
|
892
892
|
env: {
|
|
893
893
|
...process.env,
|
|
894
894
|
MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
|
|
@@ -899,11 +899,11 @@ const remoteHub = createRemoteHub({
|
|
|
899
899
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
|
|
900
900
|
emitEvent: handleRemoteHubEvent,
|
|
901
901
|
emitFrame: broadcastRemoteBinaryFrame,
|
|
902
|
-
emitAudio: broadcastRemoteBinaryAudio,
|
|
903
|
-
getSecurityIdentity: () => ({
|
|
904
|
-
accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
|
|
905
|
-
hubId: remoteHub?.getSecurityStatus?.().hubId || ''
|
|
906
|
-
}),
|
|
902
|
+
emitAudio: broadcastRemoteBinaryAudio,
|
|
903
|
+
getSecurityIdentity: () => ({
|
|
904
|
+
accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
|
|
905
|
+
hubId: remoteHub?.getSecurityStatus?.().hubId || ''
|
|
906
|
+
}),
|
|
907
907
|
getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
|
|
908
908
|
getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
|
|
909
909
|
const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
|
|
@@ -1006,11 +1006,11 @@ function getLiveDeskUpdateStatus() {
|
|
|
1006
1006
|
if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
|
|
1007
1007
|
liveDeskUpdateManager = createLiveDeskUpdateManager({
|
|
1008
1008
|
remoteHub,
|
|
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(),
|
|
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(),
|
|
1014
1014
|
requestHubRestart
|
|
1015
1015
|
});
|
|
1016
1016
|
}
|
|
@@ -1328,7 +1328,7 @@ function validateAgentMcpArguments(name, args) {
|
|
|
1328
1328
|
return '';
|
|
1329
1329
|
}
|
|
1330
1330
|
|
|
1331
|
-
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1331
|
+
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1332
1332
|
if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
|
|
1333
1333
|
// This check runs synchronously before the first await, so concurrent Node
|
|
1334
1334
|
// requests cannot pass the same remaining budget and queue extra Client work.
|
|
@@ -1336,23 +1336,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
|
1336
1336
|
session.toolLimitReached = true;
|
|
1337
1337
|
return { ok: false, error: 'codex-tool-limit-reached' };
|
|
1338
1338
|
}
|
|
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);
|
|
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);
|
|
1356
1356
|
if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
|
|
1357
1357
|
const validationError = validateAgentMcpArguments(name, args);
|
|
1358
1358
|
if (validationError) return { ok: false, error: validationError };
|
|
@@ -1543,20 +1543,20 @@ async function synchronizeAgentEnablement() {
|
|
|
1543
1543
|
return enabled;
|
|
1544
1544
|
}
|
|
1545
1545
|
|
|
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
|
-
});
|
|
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
|
+
});
|
|
1560
1560
|
const hubSharedFolders = createHubSharedFolders({
|
|
1561
1561
|
filesystem: hubFilesystem,
|
|
1562
1562
|
transferJobs: hubTransferJobs,
|
|
@@ -1676,125 +1676,152 @@ app.use((req, res, next) => {
|
|
|
1676
1676
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1677
1677
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
1678
1678
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
1679
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
|
|
1679
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
|
|
1680
1680
|
res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
|
|
1681
1681
|
}
|
|
1682
1682
|
if (req.method === 'OPTIONS') {
|
|
1683
1683
|
res.status(204).end();
|
|
1684
1684
|
return;
|
|
1685
1685
|
}
|
|
1686
|
-
next();
|
|
1687
|
-
});
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
if (
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
res.
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1686
|
+
next();
|
|
1687
|
+
});
|
|
1688
|
+
const parseHubJsonBody = express.json({ limit: '32mb' });
|
|
1689
|
+
function parseHubJsonRequest(req, res, next, onParsed = next) {
|
|
1690
|
+
parseHubJsonBody(req, res, error => {
|
|
1691
|
+
if (!error) {
|
|
1692
|
+
onParsed();
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
const type = String(error?.type || '');
|
|
1696
|
+
const code = String(error?.code || '');
|
|
1697
|
+
const bodyError = type === 'entity.parse.failed'
|
|
1698
|
+
? { status: 400, error: 'invalid-json' }
|
|
1699
|
+
: type === 'entity.too.large'
|
|
1700
|
+
? { status: 413, error: 'request-too-large' }
|
|
1701
|
+
: type === 'request.aborted' || type === 'stream.not.readable' || code === 'ECONNABORTED'
|
|
1702
|
+
? { status: 400, error: 'request-body-unavailable' }
|
|
1703
|
+
: null;
|
|
1704
|
+
if (!bodyError) {
|
|
1705
|
+
next(error);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
if (res.headersSent || res.destroyed || req.destroyed) return;
|
|
1709
|
+
res.status(bodyError.status).json({ ok: false, error: bodyError.error });
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
app.use((req, res, next) => {
|
|
1713
|
+
const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
|
|
1714
|
+
if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
|
|
1715
|
+
if (mutating) parseHubJsonRequest(req, res, next);
|
|
1716
|
+
else next();
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const authorizedLauncherShutdown = req.method === 'POST'
|
|
1720
|
+
&& req.path === '/api/runtime/shutdown'
|
|
1721
|
+
&& isLoopbackAddress(req.socket?.remoteAddress)
|
|
1722
|
+
&& launcherShutdownToken
|
|
1723
|
+
&& secureExactTestTokenMatches(
|
|
1724
|
+
String(req.headers['x-livedesk-launcher-shutdown'] || ''),
|
|
1725
|
+
launcherShutdownToken
|
|
1726
|
+
);
|
|
1727
|
+
if (authorizedLauncherShutdown) {
|
|
1728
|
+
req.liveDeskLauncherShutdown = true;
|
|
1729
|
+
parseHubJsonRequest(req, res, next);
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
const sensitiveRead = req.method === 'GET'
|
|
1733
|
+
&& ['/api/security/audit', '/api/security/rendezvous-issuer', '/api/privacy/inventory'].includes(req.path);
|
|
1734
|
+
if (!mutating && !sensitiveRead) {
|
|
1735
|
+
next();
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
|
|
1739
|
+
const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
|
|
1740
|
+
if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
|
|
1741
|
+
recordSecurityAudit({
|
|
1742
|
+
action: 'local-admin.request',
|
|
1743
|
+
phase: 'complete',
|
|
1744
|
+
result: 'rejected',
|
|
1745
|
+
reason: 'admin-session-required',
|
|
1746
|
+
requestHash: requestAuditHash({ method: req.method, path: req.path }),
|
|
1747
|
+
authMethod: 'local-admin-session'
|
|
1748
|
+
});
|
|
1749
|
+
res.status(401).json({ ok: false, error: 'local-admin-session-required' });
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
|
|
1753
|
+
recordSecurityAudit({
|
|
1754
|
+
action: 'local-admin.request',
|
|
1755
|
+
phase: 'complete',
|
|
1756
|
+
result: 'rejected',
|
|
1757
|
+
reason: 'csrf-token-required',
|
|
1758
|
+
requestHash: requestAuditHash({ method: req.method, path: req.path }),
|
|
1759
|
+
authMethod: 'local-admin-session'
|
|
1760
|
+
});
|
|
1761
|
+
res.status(403).json({ ok: false, error: 'csrf-token-required' });
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
if (!securityAuditHealthy) {
|
|
1765
|
+
res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
req.liveDeskAdminSessionId = localAdminSessionId;
|
|
1769
|
+
if (!mutating) {
|
|
1770
|
+
next();
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const beginMutationAudit = () => {
|
|
1775
|
+
const mutationRequestHash = requestAuditHash({ method: req.method, path: req.path });
|
|
1776
|
+
void recordSecurityAuditRequired({
|
|
1777
|
+
action: 'local-admin.mutation',
|
|
1778
|
+
phase: 'accepted',
|
|
1779
|
+
result: 'accepted',
|
|
1780
|
+
sessionId: localAdminSessionId,
|
|
1781
|
+
requestHash: mutationRequestHash,
|
|
1782
|
+
authMethod: 'local-admin-session',
|
|
1783
|
+
details: { method: req.method, path: req.path }
|
|
1784
|
+
}).then(() => {
|
|
1785
|
+
const originalEnd = res.end.bind(res);
|
|
1786
|
+
let completionStarted = false;
|
|
1787
|
+
res.end = (...args) => {
|
|
1788
|
+
if (completionStarted) return res;
|
|
1789
|
+
completionStarted = true;
|
|
1790
|
+
const statusCode = Number(res.statusCode || 200);
|
|
1791
|
+
void recordSecurityAuditRequired({
|
|
1792
|
+
action: 'local-admin.mutation',
|
|
1793
|
+
phase: 'complete',
|
|
1794
|
+
result: statusCode >= 200 && statusCode < 400 ? 'success' : 'rejected',
|
|
1795
|
+
reason: statusCode >= 400 ? `http-${statusCode}` : '',
|
|
1796
|
+
sessionId: localAdminSessionId,
|
|
1797
|
+
requestHash: mutationRequestHash,
|
|
1798
|
+
authMethod: 'local-admin-session',
|
|
1799
|
+
details: { method: req.method, path: req.path, statusCode }
|
|
1800
|
+
}).then(() => {
|
|
1801
|
+
originalEnd(...args);
|
|
1802
|
+
}).catch(error => {
|
|
1803
|
+
if (res.headersSent) {
|
|
1804
|
+
res.destroy(error);
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
const callback = [...args].reverse().find(value => typeof value === 'function');
|
|
1808
|
+
const body = JSON.stringify({ ok: false, error: 'security-audit-unavailable' });
|
|
1809
|
+
res.statusCode = 503;
|
|
1810
|
+
res.removeHeader('Content-Length');
|
|
1811
|
+
res.removeHeader('Transfer-Encoding');
|
|
1812
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
1813
|
+
res.setHeader('Content-Length', Buffer.byteLength(body));
|
|
1814
|
+
originalEnd(body, 'utf8', callback);
|
|
1815
|
+
});
|
|
1816
|
+
return res;
|
|
1817
|
+
};
|
|
1818
|
+
next();
|
|
1819
|
+
}).catch(() => {
|
|
1820
|
+
res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
|
|
1821
|
+
});
|
|
1822
|
+
};
|
|
1823
|
+
parseHubJsonRequest(req, res, next, beginMutationAudit);
|
|
1824
|
+
});
|
|
1798
1825
|
app.use((req, res, next) => {
|
|
1799
1826
|
if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
|
|
1800
1827
|
res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
|
|
@@ -1945,12 +1972,12 @@ function normalizeTransferFiles(value) {
|
|
|
1945
1972
|
if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
|
|
1946
1973
|
return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
|
|
1947
1974
|
}
|
|
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),
|
|
1975
|
+
files.push({
|
|
1976
|
+
name,
|
|
1977
|
+
relativePath,
|
|
1978
|
+
size: byteLength,
|
|
1979
|
+
sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
|
|
1980
|
+
mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
|
|
1954
1981
|
lastModified: Number(entry?.lastModified || 0) || 0,
|
|
1955
1982
|
dataBase64
|
|
1956
1983
|
});
|
|
@@ -1961,39 +1988,39 @@ function normalizeTransferFiles(value) {
|
|
|
1961
1988
|
return { ok: true, files, totalBytes };
|
|
1962
1989
|
}
|
|
1963
1990
|
|
|
1964
|
-
function normalizeTransferChunk(body = {}) {
|
|
1991
|
+
function normalizeTransferChunk(body = {}) {
|
|
1965
1992
|
const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
1966
1993
|
const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
|
|
1967
1994
|
const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
|
|
1968
1995
|
const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
|
|
1969
1996
|
const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
|
|
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
|
-
}
|
|
1997
|
+
const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
|
|
1998
|
+
const final = body.final === true;
|
|
1999
|
+
const maxFileSizeBytes = Math.max(1, Number(
|
|
2000
|
+
liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
|
|
2001
|
+
|| 1024 * 1024 * 1024));
|
|
2002
|
+
|
|
2003
|
+
if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
|
|
2004
|
+
return { ok: false, error: 'invalid-file-transfer-chunk' };
|
|
2005
|
+
}
|
|
2006
|
+
if (totalBytes > maxFileSizeBytes) {
|
|
2007
|
+
return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
|
|
2008
|
+
}
|
|
1982
2009
|
if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
|
|
1983
2010
|
return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
|
|
1984
2011
|
}
|
|
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
|
-
}
|
|
2012
|
+
if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
2013
|
+
return { ok: false, error: 'empty-file-transfer-chunk' };
|
|
2014
|
+
}
|
|
2015
|
+
const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
|
|
2016
|
+
const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
|
|
2017
|
+
? suppliedSha256
|
|
2018
|
+
: final && offset === 0 && totalBytes === byteLength
|
|
2019
|
+
? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
|
|
2020
|
+
: '';
|
|
2021
|
+
if (final && !sha256) {
|
|
2022
|
+
return { ok: false, error: 'file-transfer-sha256-required', byteLength };
|
|
2023
|
+
}
|
|
1997
2024
|
|
|
1998
2025
|
return {
|
|
1999
2026
|
ok: true,
|
|
@@ -2003,9 +2030,9 @@ function normalizeTransferChunk(body = {}) {
|
|
|
2003
2030
|
offset,
|
|
2004
2031
|
totalBytes,
|
|
2005
2032
|
byteLength,
|
|
2006
|
-
dataBase64,
|
|
2007
|
-
final,
|
|
2008
|
-
sha256,
|
|
2033
|
+
dataBase64,
|
|
2034
|
+
final,
|
|
2035
|
+
sha256,
|
|
2009
2036
|
mimeType: String(body.type || body.mimeType || '').slice(0, 160),
|
|
2010
2037
|
lastModified: Number(body.lastModified || 0) || 0
|
|
2011
2038
|
}
|
|
@@ -2711,6 +2738,11 @@ function replaceExpectedFrameBindingForClient(ws, deviceId, expectedBinding = nu
|
|
|
2711
2738
|
&& bindingIdentity === frameLaneBindingIdentity(currentExpectedBinding)) {
|
|
2712
2739
|
expectedBinding.readySent = currentExpectedBinding?.readySent === true
|
|
2713
2740
|
|| expectedBinding.readySent === true;
|
|
2741
|
+
expectedBinding.replayedKeyFrameSeq = Math.max(
|
|
2742
|
+
0,
|
|
2743
|
+
Number(currentExpectedBinding?.replayedKeyFrameSeq || 0),
|
|
2744
|
+
Number(expectedBinding.replayedKeyFrameSeq || 0)
|
|
2745
|
+
);
|
|
2714
2746
|
ws.liveDeskExpectedStreamBindingsByDeviceId.set(normalizedDeviceId, expectedBinding);
|
|
2715
2747
|
if (!lane.bindingEpochsByDeviceId.has(normalizedDeviceId)) {
|
|
2716
2748
|
lane.bindingEpochsByDeviceId.set(normalizedDeviceId, 1);
|
|
@@ -2749,6 +2781,50 @@ function replaceExpectedFrameBindingForClient(ws, deviceId, expectedBinding = nu
|
|
|
2749
2781
|
return bindingIdentity;
|
|
2750
2782
|
}
|
|
2751
2783
|
|
|
2784
|
+
function replayExpectedBindingKeyFrame(ws, expectedBinding) {
|
|
2785
|
+
if (!ws || ws.readyState !== ws.OPEN || !expectedBinding) {
|
|
2786
|
+
return false;
|
|
2787
|
+
}
|
|
2788
|
+
const deviceId = String(expectedBinding.deviceId || '').trim();
|
|
2789
|
+
const currentBinding = ws.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
|
|
2790
|
+
? ws.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
|
|
2791
|
+
: null;
|
|
2792
|
+
const bindingIdentity = frameLaneBindingIdentity(expectedBinding);
|
|
2793
|
+
if (!deviceId
|
|
2794
|
+
|| !bindingIdentity
|
|
2795
|
+
|| bindingIdentity !== frameLaneBindingIdentity(currentBinding)) {
|
|
2796
|
+
return false;
|
|
2797
|
+
}
|
|
2798
|
+
const cached = remoteHub.getFramePayload(deviceId, 'live', {
|
|
2799
|
+
requireKeyFrame: true,
|
|
2800
|
+
streamId: expectedBinding.streamId,
|
|
2801
|
+
sessionId: expectedBinding.sessionId,
|
|
2802
|
+
commandId: expectedBinding.commandId,
|
|
2803
|
+
captureGeneration: expectedBinding.captureGeneration,
|
|
2804
|
+
streamPurpose: expectedBinding.streamPurpose,
|
|
2805
|
+
monitorIndex: expectedBinding.monitorIndex
|
|
2806
|
+
});
|
|
2807
|
+
const frameSeq = Number(cached?.frame?.frameSeq || 0);
|
|
2808
|
+
if (!cached
|
|
2809
|
+
|| cached.frame?.isKeyFrame !== true
|
|
2810
|
+
|| !Number.isSafeInteger(frameSeq)
|
|
2811
|
+
|| frameSeq <= 0
|
|
2812
|
+
|| Number(currentBinding?.replayedKeyFrameSeq || 0) === frameSeq) {
|
|
2813
|
+
return false;
|
|
2814
|
+
}
|
|
2815
|
+
currentBinding.replayedKeyFrameSeq = frameSeq;
|
|
2816
|
+
broadcastRemoteBinaryFrame({
|
|
2817
|
+
kind: 'live',
|
|
2818
|
+
deviceId,
|
|
2819
|
+
frame: cached.frame,
|
|
2820
|
+
payload: cached.payload,
|
|
2821
|
+
mimeType: cached.mimeType,
|
|
2822
|
+
byteLength: cached.byteLength,
|
|
2823
|
+
replayedForBinding: true
|
|
2824
|
+
});
|
|
2825
|
+
return true;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2752
2828
|
function dropQueuedFrameAtForLane(lane, dropIndex) {
|
|
2753
2829
|
const droppedBefore = lane.dropped;
|
|
2754
2830
|
const droppedItem = removeFrameLaneQueueItem(lane, dropIndex);
|
|
@@ -3242,6 +3318,7 @@ function startFrameSubscriptionLive(
|
|
|
3242
3318
|
continue;
|
|
3243
3319
|
}
|
|
3244
3320
|
cancelPendingFrameStreamStop(deviceId, result.streamId, String(liveOptions.streamPurpose || 'wall'));
|
|
3321
|
+
const replayedKeyFrame = replayExpectedBindingKeyFrame(ws, expectedBinding);
|
|
3245
3322
|
started.push({
|
|
3246
3323
|
deviceId: expectedBinding.deviceId,
|
|
3247
3324
|
sessionId: expectedBinding.sessionId,
|
|
@@ -3250,7 +3327,8 @@ function startFrameSubscriptionLive(
|
|
|
3250
3327
|
commandId: expectedBinding.commandId,
|
|
3251
3328
|
captureGeneration: expectedBinding.captureGeneration,
|
|
3252
3329
|
monitorIndex: expectedBinding.monitorIndex,
|
|
3253
|
-
ready:
|
|
3330
|
+
ready: expectedBinding.readySent === true,
|
|
3331
|
+
replayedKeyFrame,
|
|
3254
3332
|
fps: result.fps,
|
|
3255
3333
|
reused: result.reused === true
|
|
3256
3334
|
});
|
|
@@ -4073,26 +4151,26 @@ function buildHubHealthPayload({
|
|
|
4073
4151
|
};
|
|
4074
4152
|
}
|
|
4075
4153
|
|
|
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
|
-
});
|
|
4154
|
+
app.get('/api/health', (_req, res) => {
|
|
4155
|
+
noStore(res);
|
|
4156
|
+
res.json(buildHubHealthPayload());
|
|
4157
|
+
});
|
|
4158
|
+
|
|
4159
|
+
app.get('/api/security/session', (req, res) => {
|
|
4160
|
+
noStore(res);
|
|
4161
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
|
|
4162
|
+
res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
|
|
4163
|
+
return;
|
|
4164
|
+
}
|
|
4165
|
+
res.json({
|
|
4166
|
+
ok: true,
|
|
4167
|
+
sessionId: localAdminSessionId,
|
|
4168
|
+
adminToken: localAdminSessionToken,
|
|
4169
|
+
csrfToken: localAdminCsrfToken,
|
|
4170
|
+
issuedAt: new Date().toISOString(),
|
|
4171
|
+
expiresOnProcessExit: true
|
|
4172
|
+
});
|
|
4173
|
+
});
|
|
4096
4174
|
|
|
4097
4175
|
if (persistentSessionGcEnabled) {
|
|
4098
4176
|
app.post('/api/test/persistent-session/gc', (req, res) => {
|
|
@@ -4145,18 +4223,18 @@ app.patch('/api/settings', async (req, res) => {
|
|
|
4145
4223
|
const record = await liveDeskSettingsStore.update(patch, revision);
|
|
4146
4224
|
if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
|
|
4147
4225
|
await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
|
|
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 });
|
|
4226
|
+
}
|
|
4227
|
+
remoteHub.refreshDevicePolicies();
|
|
4228
|
+
void captureStore.enforceRetention().catch(() => undefined);
|
|
4229
|
+
recordSecurityAudit({
|
|
4230
|
+
action: 'settings.security-policy.updated',
|
|
4231
|
+
phase: 'complete',
|
|
4232
|
+
result: 'success',
|
|
4233
|
+
requestHash: requestAuditHash(patch),
|
|
4234
|
+
authMethod: 'local-admin-session',
|
|
4235
|
+
details: { revision: record.revision, sections: Object.keys(patch) }
|
|
4236
|
+
});
|
|
4237
|
+
res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
|
|
4160
4238
|
} catch (error) {
|
|
4161
4239
|
if (error instanceof SettingsConflictError) {
|
|
4162
4240
|
res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
|
|
@@ -4311,7 +4389,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
|
|
|
4311
4389
|
});
|
|
4312
4390
|
});
|
|
4313
4391
|
|
|
4314
|
-
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
4392
|
+
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
4315
4393
|
noStore(res);
|
|
4316
4394
|
const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
|
|
4317
4395
|
deviceId: device.deviceId,
|
|
@@ -4323,141 +4401,141 @@ app.get('/api/security/trusted-devices', (_req, res) => {
|
|
|
4323
4401
|
lastSeenAt: device.lastSeenAt || '',
|
|
4324
4402
|
ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
|
|
4325
4403
|
}));
|
|
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
|
-
});
|
|
4404
|
+
res.json({ ok: true, devices });
|
|
4405
|
+
});
|
|
4406
|
+
|
|
4407
|
+
app.get('/api/security/rendezvous-issuer', (_req, res) => {
|
|
4408
|
+
noStore(res);
|
|
4409
|
+
const security = remoteHub.getSecurityStatus();
|
|
4410
|
+
res.json({
|
|
4411
|
+
ok: true,
|
|
4412
|
+
hubId: security.hubId,
|
|
4413
|
+
issuerKeyId: security.hubIssuerKeyId,
|
|
4414
|
+
publicKey: security.hubPublicKey,
|
|
4415
|
+
algorithm: 'P-256/SHA-256',
|
|
4416
|
+
environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
|
|
4417
|
+
});
|
|
4418
|
+
});
|
|
4419
|
+
|
|
4420
|
+
app.get('/api/security/audit', async (req, res) => {
|
|
4421
|
+
noStore(res);
|
|
4422
|
+
try {
|
|
4423
|
+
res.json({
|
|
4424
|
+
ok: true,
|
|
4425
|
+
healthy: securityAuditHealthy,
|
|
4426
|
+
verification: await securityAuditStore.verify(),
|
|
4427
|
+
audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
|
|
4428
|
+
});
|
|
4429
|
+
} catch (error) {
|
|
4430
|
+
securityAuditHealthy = false;
|
|
4431
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
4432
|
+
}
|
|
4433
|
+
});
|
|
4434
|
+
|
|
4435
|
+
app.get('/api/privacy/inventory', async (_req, res) => {
|
|
4436
|
+
noStore(res);
|
|
4437
|
+
try {
|
|
4438
|
+
const [captures, sharedFolders, verification] = await Promise.all([
|
|
4439
|
+
captureStore.list(),
|
|
4440
|
+
hubSharedFolders.list(),
|
|
4441
|
+
securityAuditStore.verify()
|
|
4442
|
+
]);
|
|
4443
|
+
res.json({
|
|
4444
|
+
ok: true,
|
|
4445
|
+
inventory: {
|
|
4446
|
+
authenticated: runtimeManager.getSnapshot().authenticated === true,
|
|
4447
|
+
captures: captures.length,
|
|
4448
|
+
sharedFolderDefinitions: sharedFolders.length,
|
|
4449
|
+
trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
|
|
4450
|
+
securityAuditRecords: verification.records,
|
|
4451
|
+
agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
|
|
4452
|
+
}
|
|
4453
|
+
});
|
|
4454
|
+
} catch (error) {
|
|
4455
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
|
|
4456
|
+
}
|
|
4457
|
+
});
|
|
4458
|
+
|
|
4459
|
+
app.delete('/api/privacy/local-data', async (req, res) => {
|
|
4460
|
+
noStore(res);
|
|
4461
|
+
if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
|
|
4462
|
+
res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
|
|
4463
|
+
return;
|
|
4464
|
+
}
|
|
4465
|
+
try {
|
|
4466
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
4467
|
+
const hostTarget = runtimeRole === 'hub'
|
|
4468
|
+
? await clearHubHostTarget('privacy-delete')
|
|
4469
|
+
: { ok: true, active: false };
|
|
4470
|
+
clearRuntimeSession();
|
|
4471
|
+
for (const device of remoteHub.getSecurityStatus().devices) {
|
|
4472
|
+
remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
|
|
4473
|
+
}
|
|
4474
|
+
const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
|
|
4475
|
+
const transferJobs = hubTransferJobs.clear();
|
|
4476
|
+
const captures = await captureStore.removeAll();
|
|
4477
|
+
const sharedFolders = await hubSharedFolders.clear();
|
|
4478
|
+
await agentAuditStore.clear();
|
|
4479
|
+
await securityAuditStore.reset();
|
|
4480
|
+
securityAuditHealthy = true;
|
|
4481
|
+
await securityAuditStore.record({
|
|
4482
|
+
action: 'privacy.local-data.deleted',
|
|
4483
|
+
phase: 'complete',
|
|
4484
|
+
result: 'success',
|
|
4485
|
+
authMethod: 'local-admin-session',
|
|
4486
|
+
details: {
|
|
4487
|
+
capturesRemoved: captures.removed,
|
|
4488
|
+
captureBytesRemoved: captures.removedBytes,
|
|
4489
|
+
sharedFolderDefinitionsRemoved: sharedFolders.removed,
|
|
4490
|
+
trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
|
|
4491
|
+
transferJobRecordsRemoved: transferJobs.removed,
|
|
4492
|
+
providerSessionRevoked: providerLogout.revoked === true
|
|
4493
|
+
}
|
|
4494
|
+
});
|
|
4495
|
+
res.json({
|
|
4496
|
+
ok: true,
|
|
4497
|
+
localDeleted: true,
|
|
4498
|
+
providerLogout,
|
|
4499
|
+
hostTarget,
|
|
4500
|
+
captures,
|
|
4501
|
+
sharedFolders,
|
|
4502
|
+
trustedDeviceCredentials,
|
|
4503
|
+
transferJobs,
|
|
4504
|
+
preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
|
|
4505
|
+
});
|
|
4506
|
+
} catch (error) {
|
|
4507
|
+
securityAuditHealthy = false;
|
|
4508
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
|
|
4509
|
+
}
|
|
4510
|
+
});
|
|
4511
|
+
|
|
4512
|
+
app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
|
|
4513
|
+
noStore(res);
|
|
4514
|
+
const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
|
|
4515
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
|
|
4516
|
+
const revoked = disconnected === true || credentialRevoked === true;
|
|
4517
|
+
recordSecurityAudit({
|
|
4518
|
+
action: 'security.device.revoked',
|
|
4519
|
+
phase: 'complete',
|
|
4520
|
+
result: revoked ? 'success' : 'not-found',
|
|
4521
|
+
deviceId: req.params.deviceId,
|
|
4522
|
+
authMethod: 'local-admin-session'
|
|
4523
|
+
});
|
|
4524
|
+
res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
|
|
4525
|
+
});
|
|
4448
4526
|
|
|
4449
4527
|
app.post('/api/security/revoke-all', (_req, res) => {
|
|
4450
4528
|
noStore(res);
|
|
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
|
-
});
|
|
4529
|
+
const devices = remoteHub.listDevices({ includeDataUrl: false });
|
|
4530
|
+
let revoked = 0;
|
|
4531
|
+
for (const device of devices) {
|
|
4532
|
+
const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
|
|
4533
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
|
|
4534
|
+
if (disconnected || credentialRevoked) revoked += 1;
|
|
4535
|
+
}
|
|
4536
|
+
recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
|
|
4537
|
+
res.json({ ok: true, revoked });
|
|
4538
|
+
});
|
|
4461
4539
|
|
|
4462
4540
|
app.get('/api/settings/agent', async (_req, res) => {
|
|
4463
4541
|
noStore(res);
|
|
@@ -4819,17 +4897,17 @@ app.post('/api/runtime/restart', (_req, res) => {
|
|
|
4819
4897
|
}, 150);
|
|
4820
4898
|
});
|
|
4821
4899
|
|
|
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 });
|
|
4900
|
+
app.post('/api/runtime/shutdown', (req, res) => {
|
|
4901
|
+
noStore(res);
|
|
4902
|
+
recordSecurityAudit({
|
|
4903
|
+
action: 'runtime.shutdown',
|
|
4904
|
+
phase: 'requested',
|
|
4905
|
+
result: 'accepted',
|
|
4906
|
+
authMethod: req.liveDeskLauncherShutdown === true
|
|
4907
|
+
? 'launcher-internal-shutdown-token'
|
|
4908
|
+
: 'local-admin-session'
|
|
4909
|
+
});
|
|
4910
|
+
runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
|
|
4833
4911
|
res.json({ ok: true, shuttingDown: true, role: runtimeRole });
|
|
4834
4912
|
setTimeout(() => {
|
|
4835
4913
|
void shutdownHub('API_SHUTDOWN')
|
|
@@ -4866,10 +4944,10 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4866
4944
|
}
|
|
4867
4945
|
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
4868
4946
|
try {
|
|
4869
|
-
const user = await verifySupabaseUser(accessToken);
|
|
4870
|
-
runtimeSessionGeneration += 1;
|
|
4871
|
-
runtimeRefreshPromise = null;
|
|
4872
|
-
runtimeAccessToken = accessToken;
|
|
4947
|
+
const user = await verifySupabaseUser(accessToken);
|
|
4948
|
+
runtimeSessionGeneration += 1;
|
|
4949
|
+
runtimeRefreshPromise = null;
|
|
4950
|
+
runtimeAccessToken = accessToken;
|
|
4873
4951
|
runtimeRefreshToken = refreshToken;
|
|
4874
4952
|
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
4875
4953
|
runtimeManager.setAuthenticated(true, user.id);
|
|
@@ -4882,29 +4960,29 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4882
4960
|
const hostTarget = runtimeRole === 'hub'
|
|
4883
4961
|
? await publishHubHostTargetWithPendingRoleTakeover('session-received')
|
|
4884
4962
|
: { ok: true, active: false };
|
|
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
|
-
});
|
|
4963
|
+
if (runtimeRole === 'hub') {
|
|
4964
|
+
startHubHostTargetLeaseRenewal();
|
|
4965
|
+
}
|
|
4966
|
+
recordSecurityAudit({
|
|
4967
|
+
action: 'auth.session.established',
|
|
4968
|
+
phase: 'complete',
|
|
4969
|
+
result: 'success',
|
|
4970
|
+
actorAccountId: user.id,
|
|
4971
|
+
actorUserId: user.id,
|
|
4972
|
+
authMethod: 'supabase-access-refresh',
|
|
4973
|
+
requestHash: requestAuditHash({ userId: user.id, expiresAt })
|
|
4974
|
+
});
|
|
4975
|
+
res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
|
|
4976
|
+
} catch (error) {
|
|
4977
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4978
|
+
recordSecurityAudit({
|
|
4979
|
+
action: 'auth.session.establish',
|
|
4980
|
+
phase: 'complete',
|
|
4981
|
+
result: 'rejected',
|
|
4982
|
+
reason: message,
|
|
4983
|
+
authMethod: 'supabase-access-refresh',
|
|
4984
|
+
requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
|
|
4985
|
+
});
|
|
4908
4986
|
const status = authVerificationHttpStatus(message);
|
|
4909
4987
|
res.status(status).json({ ok: false, error: message });
|
|
4910
4988
|
}
|
|
@@ -5198,33 +5276,33 @@ function startHubHostTargetLeaseRenewal() {
|
|
|
5198
5276
|
hubHostTargetRenewTimer.unref?.();
|
|
5199
5277
|
}
|
|
5200
5278
|
|
|
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
|
-
});
|
|
5279
|
+
app.delete('/api/auth/session', async (_req, res) => {
|
|
5280
|
+
noStore(res);
|
|
5281
|
+
const actorUserId = runtimeManager.getSnapshot().userId || '';
|
|
5282
|
+
const hostTarget = runtimeRole === 'hub'
|
|
5283
|
+
? await clearHubHostTarget('logout')
|
|
5284
|
+
: { ok: true, active: false };
|
|
5285
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
5286
|
+
clearRuntimeSession();
|
|
5287
|
+
recordSecurityAudit({
|
|
5288
|
+
action: 'auth.session.revoked',
|
|
5289
|
+
phase: 'complete',
|
|
5290
|
+
result: providerLogout.ok ? 'success' : 'provider-error',
|
|
5291
|
+
actorAccountId: actorUserId,
|
|
5292
|
+
actorUserId,
|
|
5293
|
+
reason: providerLogout.error || '',
|
|
5294
|
+
authMethod: 'supabase-local-signout',
|
|
5295
|
+
details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
|
|
5296
|
+
});
|
|
5297
|
+
res.status(providerLogout.ok ? 200 : 502).json({
|
|
5298
|
+
ok: providerLogout.ok,
|
|
5299
|
+
authenticated: false,
|
|
5300
|
+
localCleared: true,
|
|
5301
|
+
role: runtimeRole,
|
|
5302
|
+
hostTarget,
|
|
5303
|
+
providerLogout
|
|
5304
|
+
});
|
|
5305
|
+
});
|
|
5228
5306
|
|
|
5229
5307
|
app.get('/api/hub/status', (_req, res) => {
|
|
5230
5308
|
noStore(res);
|
|
@@ -5571,37 +5649,37 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
|
|
|
5571
5649
|
res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
|
|
5572
5650
|
return;
|
|
5573
5651
|
}
|
|
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
|
-
});
|
|
5652
|
+
const job = hubTransferJobs.create({
|
|
5653
|
+
itemIds,
|
|
5654
|
+
deviceIds,
|
|
5655
|
+
remoteDirectory: req.body?.remoteDirectory,
|
|
5656
|
+
onComplete: async ({ completed, job: completedJob }) => {
|
|
5657
|
+
await recordSecurityAuditRequired({
|
|
5658
|
+
action: 'remote.file.job',
|
|
5659
|
+
phase: 'complete',
|
|
5660
|
+
result: completed ? 'success' : 'failed',
|
|
5661
|
+
reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
|
|
5662
|
+
deviceIds,
|
|
5663
|
+
sessionId: completedJob?.jobId || '',
|
|
5664
|
+
requestHash: requestAuditHash({
|
|
5665
|
+
jobId: completedJob?.jobId || '',
|
|
5666
|
+
deviceIds,
|
|
5667
|
+
remoteDirectory: req.body?.remoteDirectory || ''
|
|
5668
|
+
}),
|
|
5669
|
+
authMethod: 'local-admin-session',
|
|
5670
|
+
details: {
|
|
5671
|
+
state: completedJob?.state || '',
|
|
5672
|
+
totalFiles: Number(completedJob?.totalFiles || 0),
|
|
5673
|
+
completedFiles: Number(completedJob?.completedFiles || 0),
|
|
5674
|
+
totalBytes: Number(completedJob?.totalBytes || 0),
|
|
5675
|
+
sentBytes: Number(completedJob?.sentBytes || 0),
|
|
5676
|
+
failedTargets: Array.isArray(completedJob?.failedTargets)
|
|
5677
|
+
? completedJob.failedTargets.length
|
|
5678
|
+
: 0
|
|
5679
|
+
}
|
|
5680
|
+
});
|
|
5681
|
+
}
|
|
5682
|
+
});
|
|
5605
5683
|
res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
|
|
5606
5684
|
} catch (error) {
|
|
5607
5685
|
sendFilesystemError(res, error);
|
|
@@ -5682,80 +5760,80 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
|
|
|
5682
5760
|
}
|
|
5683
5761
|
});
|
|
5684
5762
|
|
|
5685
|
-
app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
|
|
5763
|
+
app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
|
|
5686
5764
|
noStore(res);
|
|
5687
5765
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
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' });
|
|
5766
|
+
if (deviceIds.length === 0) {
|
|
5767
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5768
|
+
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5691
5769
|
return;
|
|
5692
5770
|
}
|
|
5693
5771
|
const normalized = normalizeTransferFiles(req.body?.files);
|
|
5694
|
-
if (!normalized.ok) {
|
|
5695
|
-
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5772
|
+
if (!normalized.ok) {
|
|
5773
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5696
5774
|
res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
|
|
5697
5775
|
return;
|
|
5698
5776
|
}
|
|
5699
5777
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5700
5778
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5701
5779
|
const queuedAt = new Date().toISOString();
|
|
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) => {
|
|
5780
|
+
try {
|
|
5781
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5782
|
+
deviceId,
|
|
5783
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5784
|
+
command: 'file.transfer',
|
|
5785
|
+
payload: {
|
|
5786
|
+
transferId,
|
|
5787
|
+
remoteDirectory,
|
|
5788
|
+
files: normalized.files,
|
|
5789
|
+
totalBytes: normalized.totalBytes,
|
|
5790
|
+
requestedAt: queuedAt
|
|
5791
|
+
}
|
|
5792
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5793
|
+
})));
|
|
5794
|
+
const queued = results.filter(result => result.queued).length;
|
|
5795
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5796
|
+
const success = acknowledged === deviceIds.length;
|
|
5797
|
+
const partial = acknowledged > 0 && !success;
|
|
5798
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5799
|
+
await recordSecurityAuditRequired({
|
|
5800
|
+
action: 'remote.file.transfer',
|
|
5801
|
+
phase: 'complete',
|
|
5802
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5803
|
+
reason: error || '',
|
|
5804
|
+
deviceIds,
|
|
5805
|
+
sessionId: transferId,
|
|
5806
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
|
|
5807
|
+
authMethod: 'local-admin-session',
|
|
5808
|
+
details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
|
|
5809
|
+
});
|
|
5810
|
+
res.json({
|
|
5811
|
+
ok: success,
|
|
5812
|
+
transferId,
|
|
5813
|
+
queued,
|
|
5814
|
+
acknowledged,
|
|
5815
|
+
total: deviceIds.length,
|
|
5816
|
+
totalBytes: normalized.totalBytes,
|
|
5817
|
+
files: normalized.files.length,
|
|
5818
|
+
results,
|
|
5819
|
+
error
|
|
5820
|
+
});
|
|
5821
|
+
} catch (error) {
|
|
5822
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5823
|
+
}
|
|
5824
|
+
});
|
|
5825
|
+
|
|
5826
|
+
app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
|
|
5749
5827
|
noStore(res);
|
|
5750
5828
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5751
|
-
if (deviceIds.length === 0) {
|
|
5752
|
-
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5829
|
+
if (deviceIds.length === 0) {
|
|
5830
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5753
5831
|
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5754
5832
|
return;
|
|
5755
5833
|
}
|
|
5756
5834
|
const normalized = normalizeTransferChunk(req.body || {});
|
|
5757
|
-
if (!normalized.ok) {
|
|
5758
|
-
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5835
|
+
if (!normalized.ok) {
|
|
5836
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5759
5837
|
res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
|
|
5760
5838
|
return;
|
|
5761
5839
|
}
|
|
@@ -5763,51 +5841,51 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) =>
|
|
|
5763
5841
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5764
5842
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5765
5843
|
const queuedAt = new Date().toISOString();
|
|
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
|
-
});
|
|
5844
|
+
try {
|
|
5845
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5846
|
+
deviceId,
|
|
5847
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5848
|
+
command: 'file.transfer.chunk',
|
|
5849
|
+
payload: {
|
|
5850
|
+
transferId,
|
|
5851
|
+
remoteDirectory,
|
|
5852
|
+
...normalized.chunk,
|
|
5853
|
+
requestedAt: queuedAt
|
|
5854
|
+
}
|
|
5855
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5856
|
+
})));
|
|
5857
|
+
const queued = results.filter(result => result.queued).length;
|
|
5858
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5859
|
+
const success = acknowledged === deviceIds.length;
|
|
5860
|
+
const partial = acknowledged > 0 && !success;
|
|
5861
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5862
|
+
await recordSecurityAuditRequired({
|
|
5863
|
+
action: 'remote.file.chunk',
|
|
5864
|
+
phase: normalized.chunk.final ? 'complete' : 'progress',
|
|
5865
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5866
|
+
reason: error || '',
|
|
5867
|
+
deviceIds,
|
|
5868
|
+
sessionId: transferId,
|
|
5869
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
|
|
5870
|
+
authMethod: 'local-admin-session',
|
|
5871
|
+
details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
|
|
5872
|
+
});
|
|
5873
|
+
res.json({
|
|
5874
|
+
ok: success,
|
|
5875
|
+
transferId,
|
|
5876
|
+
queued,
|
|
5877
|
+
acknowledged,
|
|
5878
|
+
total: deviceIds.length,
|
|
5879
|
+
byteLength: normalized.chunk.byteLength,
|
|
5880
|
+
offset: normalized.chunk.offset,
|
|
5881
|
+
final: normalized.chunk.final,
|
|
5882
|
+
results,
|
|
5883
|
+
error
|
|
5884
|
+
});
|
|
5885
|
+
} catch (error) {
|
|
5886
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5887
|
+
}
|
|
5888
|
+
});
|
|
5811
5889
|
|
|
5812
5890
|
const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
|
|
5813
5891
|
|
|
@@ -6080,7 +6158,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
|
|
|
6080
6158
|
}
|
|
6081
6159
|
});
|
|
6082
6160
|
|
|
6083
|
-
if (existsSync(webIndexPath)) {
|
|
6161
|
+
if (existsSync(webIndexPath)) {
|
|
6084
6162
|
app.use(express.static(webDistPath, {
|
|
6085
6163
|
etag: true,
|
|
6086
6164
|
index: false,
|
|
@@ -6093,70 +6171,70 @@ if (existsSync(webIndexPath)) {
|
|
|
6093
6171
|
}
|
|
6094
6172
|
res.sendFile(webIndexPath);
|
|
6095
6173
|
});
|
|
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)) {
|
|
6174
|
+
}
|
|
6175
|
+
|
|
6176
|
+
function isAuthorizedAdminWebSocket(req) {
|
|
6177
|
+
if (!enforceLocalAdminAuth) return true;
|
|
6178
|
+
const protocols = String(req.headers['sec-websocket-protocol'] || '')
|
|
6179
|
+
.split(',')
|
|
6180
|
+
.map(value => value.trim())
|
|
6181
|
+
.filter(Boolean);
|
|
6182
|
+
const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
|
|
6183
|
+
const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
|
|
6184
|
+
return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
|
|
6185
|
+
&& secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
|
|
6186
|
+
}
|
|
6187
|
+
|
|
6188
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
6189
|
+
if (!isTrustedBrowserRequest(req)) {
|
|
6112
6190
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
6113
6191
|
socket.destroy();
|
|
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;
|
|
6192
|
+
return;
|
|
6193
|
+
}
|
|
6194
|
+
if (!isAuthorizedAdminWebSocket(req)) {
|
|
6195
|
+
recordSecurityAudit({
|
|
6196
|
+
action: 'local-admin.websocket',
|
|
6197
|
+
phase: 'complete',
|
|
6198
|
+
result: 'rejected',
|
|
6199
|
+
reason: 'admin-session-required',
|
|
6200
|
+
requestHash: requestAuditHash({ path: req.url || '' }),
|
|
6201
|
+
authMethod: 'local-admin-websocket'
|
|
6202
|
+
});
|
|
6203
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
6204
|
+
socket.destroy();
|
|
6205
|
+
return;
|
|
6206
|
+
}
|
|
6207
|
+
let parsed;
|
|
6208
|
+
try {
|
|
6209
|
+
parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6210
|
+
} catch {
|
|
6211
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
6212
|
+
socket.destroy();
|
|
6213
|
+
return;
|
|
6214
|
+
}
|
|
6215
|
+
const websocketPath = parsed.pathname;
|
|
6216
|
+
if (![
|
|
6217
|
+
'/api/remote/frames/ws',
|
|
6218
|
+
'/api/remote/atlas/ws',
|
|
6219
|
+
'/api/remote/input/ws',
|
|
6220
|
+
'/api/remote/audio/ws'
|
|
6221
|
+
].includes(websocketPath)) {
|
|
6222
|
+
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
6223
|
+
socket.destroy();
|
|
6224
|
+
return;
|
|
6225
|
+
}
|
|
6226
|
+
void recordSecurityAuditRequired({
|
|
6227
|
+
action: 'local-admin.websocket',
|
|
6228
|
+
phase: 'accepted',
|
|
6229
|
+
result: 'accepted',
|
|
6230
|
+
sessionId: localAdminSessionId,
|
|
6231
|
+
requestHash: requestAuditHash({ path: websocketPath }),
|
|
6232
|
+
authMethod: 'local-admin-websocket',
|
|
6233
|
+
details: { path: websocketPath }
|
|
6234
|
+
}).then(() => {
|
|
6235
|
+
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
6236
|
+
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|
|
6237
|
+
return;
|
|
6160
6238
|
}
|
|
6161
6239
|
if (parsed.pathname === '/api/remote/atlas/ws') {
|
|
6162
6240
|
atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
|
|
@@ -6167,14 +6245,14 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
6167
6245
|
return;
|
|
6168
6246
|
}
|
|
6169
6247
|
if (parsed.pathname === '/api/remote/audio/ws') {
|
|
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
|
-
});
|
|
6248
|
+
audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
|
|
6249
|
+
return;
|
|
6250
|
+
}
|
|
6251
|
+
}).catch(() => {
|
|
6252
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
6253
|
+
socket.destroy();
|
|
6254
|
+
});
|
|
6255
|
+
});
|
|
6178
6256
|
|
|
6179
6257
|
frameWss.on('connection', (ws, req) => {
|
|
6180
6258
|
frameClients.add(ws);
|
|
@@ -6286,22 +6364,22 @@ audioWss.on('connection', (ws, req) => {
|
|
|
6286
6364
|
});
|
|
6287
6365
|
});
|
|
6288
6366
|
|
|
6289
|
-
inputWss.on('connection', ws => {
|
|
6290
|
-
inputClients.add(ws);
|
|
6291
|
-
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
6292
|
-
ws.liveDeskInputDeviceIds = new Set();
|
|
6293
|
-
ws.liveDeskInputAuditReady = false;
|
|
6367
|
+
inputWss.on('connection', ws => {
|
|
6368
|
+
inputClients.add(ws);
|
|
6369
|
+
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
6370
|
+
ws.liveDeskInputDeviceIds = new Set();
|
|
6371
|
+
ws.liveDeskInputAuditReady = false;
|
|
6294
6372
|
try {
|
|
6295
6373
|
ws._socket?.setNoDelay?.(true);
|
|
6296
6374
|
} catch {
|
|
6297
6375
|
// Best-effort latency hint for browser input sockets.
|
|
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;
|
|
6376
|
+
}
|
|
6377
|
+
ws.on('message', data => {
|
|
6378
|
+
if (ws.liveDeskInputAuditReady !== true) {
|
|
6379
|
+
sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
|
|
6380
|
+
return;
|
|
6381
|
+
}
|
|
6382
|
+
let payload;
|
|
6305
6383
|
try {
|
|
6306
6384
|
payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
6307
6385
|
} catch {
|
|
@@ -6340,46 +6418,46 @@ inputWss.on('connection', ws => {
|
|
|
6340
6418
|
timestamp: new Date().toISOString()
|
|
6341
6419
|
});
|
|
6342
6420
|
});
|
|
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
|
-
});
|
|
6421
|
+
const releaseBrowserInputOwner = reason => {
|
|
6422
|
+
if (ws.liveDeskInputReleased) return;
|
|
6423
|
+
ws.liveDeskInputReleased = true;
|
|
6424
|
+
inputClients.delete(ws);
|
|
6425
|
+
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
6426
|
+
remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
|
|
6427
|
+
}
|
|
6428
|
+
void recordSecurityAuditRequired({
|
|
6429
|
+
action: 'remote.control.browser-session',
|
|
6430
|
+
phase: 'complete',
|
|
6431
|
+
result: 'success',
|
|
6432
|
+
reason,
|
|
6433
|
+
deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
|
|
6434
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6435
|
+
authMethod: 'local-admin-websocket'
|
|
6436
|
+
}).catch(() => {
|
|
6437
|
+
// The global fail-closed gate is marked by recordSecurityAuditRequired.
|
|
6438
|
+
});
|
|
6439
|
+
ws.liveDeskInputDeviceIds?.clear?.();
|
|
6440
|
+
};
|
|
6441
|
+
ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
|
|
6442
|
+
ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
|
|
6443
|
+
void recordSecurityAuditRequired({
|
|
6444
|
+
action: 'remote.control.browser-session',
|
|
6445
|
+
phase: 'start',
|
|
6446
|
+
result: 'success',
|
|
6447
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6448
|
+
authMethod: 'local-admin-websocket'
|
|
6449
|
+
}).then(() => {
|
|
6450
|
+
ws.liveDeskInputAuditReady = true;
|
|
6451
|
+
sendJson(ws, {
|
|
6452
|
+
type: 'RemoteInputSocketReady',
|
|
6453
|
+
protocol: 'livedesk.remote.input.json.v1',
|
|
6454
|
+
clientId: ws.liveDeskInputClientId,
|
|
6455
|
+
timestamp: new Date().toISOString()
|
|
6456
|
+
});
|
|
6457
|
+
}).catch(() => {
|
|
6458
|
+
ws.close(1011, 'security-audit-unavailable');
|
|
6459
|
+
});
|
|
6460
|
+
});
|
|
6383
6461
|
|
|
6384
6462
|
await remoteHub.start();
|
|
6385
6463
|
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
@@ -6473,8 +6551,8 @@ function shutdownHub(signal) {
|
|
|
6473
6551
|
hubShutdownPromise = (async () => {
|
|
6474
6552
|
const startedAt = Date.now();
|
|
6475
6553
|
console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
|
|
6476
|
-
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6477
|
-
clearInterval(captureRetentionTimer);
|
|
6554
|
+
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6555
|
+
clearInterval(captureRetentionTimer);
|
|
6478
6556
|
clearInterval(browserWebSocketHeartbeatTimer);
|
|
6479
6557
|
runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
|
|
6480
6558
|
atlasClients.clear();
|