@livedesk/hub 0.1.31 → 0.1.33
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/filesystem/transfer-jobs.js +5 -1
- package/src/remote-hub.js +721 -658
- package/src/security/device-credential-authority.js +75 -27
- package/src/security/security-audit-store.js +24 -2
- package/src/server.js +978 -780
- package/src/settings/settings-schema.js +251 -239
- package/src/settings/settings-store.js +82 -77
- package/src/transport/relay-hub-control.js +2 -1
- package/src/transport/secure-direct-acceptor.js +440 -432
- package/src/transport/udp-hub-transport.js +19 -8
- package/src/transport/udp-rendezvous.js +143 -29
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,57 +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
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
})
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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?.();
|
|
857
881
|
|
|
858
882
|
const udpTransport = createHubUdpTransport({
|
|
859
883
|
env: process.env,
|
|
@@ -861,10 +885,10 @@ const udpTransport = createHubUdpTransport({
|
|
|
861
885
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
|
|
862
886
|
});
|
|
863
887
|
|
|
864
|
-
const remoteHub = createRemoteHub({
|
|
865
|
-
managerPackage: '@livedesk/hub',
|
|
866
|
-
managerVersion: packageInfo.version,
|
|
867
|
-
dataDir: agentDataDir,
|
|
888
|
+
const remoteHub = createRemoteHub({
|
|
889
|
+
managerPackage: '@livedesk/hub',
|
|
890
|
+
managerVersion: packageInfo.version,
|
|
891
|
+
dataDir: agentDataDir,
|
|
868
892
|
env: {
|
|
869
893
|
...process.env,
|
|
870
894
|
MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
|
|
@@ -875,11 +899,11 @@ const remoteHub = createRemoteHub({
|
|
|
875
899
|
logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
|
|
876
900
|
emitEvent: handleRemoteHubEvent,
|
|
877
901
|
emitFrame: broadcastRemoteBinaryFrame,
|
|
878
|
-
emitAudio: broadcastRemoteBinaryAudio,
|
|
879
|
-
getSecurityIdentity: () => ({
|
|
880
|
-
accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
|
|
881
|
-
hubId: remoteHub?.getSecurityStatus?.().hubId || ''
|
|
882
|
-
}),
|
|
902
|
+
emitAudio: broadcastRemoteBinaryAudio,
|
|
903
|
+
getSecurityIdentity: () => ({
|
|
904
|
+
accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
|
|
905
|
+
hubId: remoteHub?.getSecurityStatus?.().hubId || ''
|
|
906
|
+
}),
|
|
883
907
|
getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
|
|
884
908
|
getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
|
|
885
909
|
const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
|
|
@@ -982,11 +1006,11 @@ function getLiveDeskUpdateStatus() {
|
|
|
982
1006
|
if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
|
|
983
1007
|
liveDeskUpdateManager = createLiveDeskUpdateManager({
|
|
984
1008
|
remoteHub,
|
|
985
|
-
currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
|
|
986
|
-
currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
|
|
987
|
-
updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
|
|
988
|
-
updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
|
|
989
|
-
restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
|
|
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(),
|
|
990
1014
|
requestHubRestart
|
|
991
1015
|
});
|
|
992
1016
|
}
|
|
@@ -1304,7 +1328,7 @@ function validateAgentMcpArguments(name, args) {
|
|
|
1304
1328
|
return '';
|
|
1305
1329
|
}
|
|
1306
1330
|
|
|
1307
|
-
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1331
|
+
async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
1308
1332
|
if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
|
|
1309
1333
|
// This check runs synchronously before the first await, so concurrent Node
|
|
1310
1334
|
// requests cannot pass the same remaining budget and queue extra Client work.
|
|
@@ -1312,23 +1336,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
|
|
|
1312
1336
|
session.toolLimitReached = true;
|
|
1313
1337
|
return { ok: false, error: 'codex-tool-limit-reached' };
|
|
1314
1338
|
}
|
|
1315
|
-
session.toolCallCount += 1;
|
|
1316
|
-
if (isRetiredAgentMutatingToolName(name)) {
|
|
1317
|
-
recordAgentAudit({
|
|
1318
|
-
event: 'tool-rejected',
|
|
1319
|
-
runId: session.runId,
|
|
1320
|
-
deviceIds: [],
|
|
1321
|
-
toolName: name,
|
|
1322
|
-
category: 'mutation',
|
|
1323
|
-
decision: 'deny',
|
|
1324
|
-
status: 'rejected',
|
|
1325
|
-
permissionMode: session.permissionPolicy?.mode,
|
|
1326
|
-
policyHash: session.permissionPolicyHash,
|
|
1327
|
-
details: { reason: 'agent-mutating-tool-disabled' }
|
|
1328
|
-
});
|
|
1329
|
-
return { ok: false, error: 'agent-mutating-tool-disabled' };
|
|
1330
|
-
}
|
|
1331
|
-
const tool = getAgentToolDefinition(name);
|
|
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);
|
|
1332
1356
|
if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
|
|
1333
1357
|
const validationError = validateAgentMcpArguments(name, args);
|
|
1334
1358
|
if (validationError) return { ok: false, error: validationError };
|
|
@@ -1520,15 +1544,19 @@ async function synchronizeAgentEnablement() {
|
|
|
1520
1544
|
}
|
|
1521
1545
|
|
|
1522
1546
|
const hubFilesystem = createHubFilesystem();
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
+
});
|
|
1532
1560
|
const hubSharedFolders = createHubSharedFolders({
|
|
1533
1561
|
filesystem: hubFilesystem,
|
|
1534
1562
|
transferJobs: hubTransferJobs,
|
|
@@ -1648,74 +1676,152 @@ app.use((req, res, next) => {
|
|
|
1648
1676
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1649
1677
|
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
1650
1678
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
1651
|
-
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');
|
|
1652
1680
|
res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
|
|
1653
1681
|
}
|
|
1654
1682
|
if (req.method === 'OPTIONS') {
|
|
1655
1683
|
res.status(204).end();
|
|
1656
1684
|
return;
|
|
1657
1685
|
}
|
|
1658
|
-
next();
|
|
1659
|
-
});
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
const
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
if (
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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
|
+
});
|
|
1719
1825
|
app.use((req, res, next) => {
|
|
1720
1826
|
if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
|
|
1721
1827
|
res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
|
|
@@ -1866,12 +1972,12 @@ function normalizeTransferFiles(value) {
|
|
|
1866
1972
|
if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
|
|
1867
1973
|
return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
|
|
1868
1974
|
}
|
|
1869
|
-
files.push({
|
|
1870
|
-
name,
|
|
1871
|
-
relativePath,
|
|
1872
|
-
size: byteLength,
|
|
1873
|
-
sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
|
|
1874
|
-
mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
|
|
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),
|
|
1875
1981
|
lastModified: Number(entry?.lastModified || 0) || 0,
|
|
1876
1982
|
dataBase64
|
|
1877
1983
|
});
|
|
@@ -1882,39 +1988,39 @@ function normalizeTransferFiles(value) {
|
|
|
1882
1988
|
return { ok: true, files, totalBytes };
|
|
1883
1989
|
}
|
|
1884
1990
|
|
|
1885
|
-
function normalizeTransferChunk(body = {}) {
|
|
1991
|
+
function normalizeTransferChunk(body = {}) {
|
|
1886
1992
|
const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
|
|
1887
1993
|
const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
|
|
1888
1994
|
const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
|
|
1889
1995
|
const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
|
|
1890
1996
|
const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
|
|
1891
|
-
const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
|
|
1892
|
-
const final = body.final === true;
|
|
1893
|
-
const maxFileSizeBytes = Math.max(1, Number(
|
|
1894
|
-
liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
|
|
1895
|
-
|| 1024 * 1024 * 1024));
|
|
1896
|
-
|
|
1897
|
-
if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
|
|
1898
|
-
return { ok: false, error: 'invalid-file-transfer-chunk' };
|
|
1899
|
-
}
|
|
1900
|
-
if (totalBytes > maxFileSizeBytes) {
|
|
1901
|
-
return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
|
|
1902
|
-
}
|
|
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
|
+
}
|
|
1903
2009
|
if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
|
|
1904
2010
|
return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
|
|
1905
2011
|
}
|
|
1906
|
-
if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
|
|
1907
|
-
return { ok: false, error: 'empty-file-transfer-chunk' };
|
|
1908
|
-
}
|
|
1909
|
-
const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
|
|
1910
|
-
const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
|
|
1911
|
-
? suppliedSha256
|
|
1912
|
-
: final && offset === 0 && totalBytes === byteLength
|
|
1913
|
-
? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
|
|
1914
|
-
: '';
|
|
1915
|
-
if (final && !sha256) {
|
|
1916
|
-
return { ok: false, error: 'file-transfer-sha256-required', byteLength };
|
|
1917
|
-
}
|
|
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
|
+
}
|
|
1918
2024
|
|
|
1919
2025
|
return {
|
|
1920
2026
|
ok: true,
|
|
@@ -1924,9 +2030,9 @@ function normalizeTransferChunk(body = {}) {
|
|
|
1924
2030
|
offset,
|
|
1925
2031
|
totalBytes,
|
|
1926
2032
|
byteLength,
|
|
1927
|
-
dataBase64,
|
|
1928
|
-
final,
|
|
1929
|
-
sha256,
|
|
2033
|
+
dataBase64,
|
|
2034
|
+
final,
|
|
2035
|
+
sha256,
|
|
1930
2036
|
mimeType: String(body.type || body.mimeType || '').slice(0, 160),
|
|
1931
2037
|
lastModified: Number(body.lastModified || 0) || 0
|
|
1932
2038
|
}
|
|
@@ -3994,26 +4100,26 @@ function buildHubHealthPayload({
|
|
|
3994
4100
|
};
|
|
3995
4101
|
}
|
|
3996
4102
|
|
|
3997
|
-
app.get('/api/health', (_req, res) => {
|
|
3998
|
-
noStore(res);
|
|
3999
|
-
res.json(buildHubHealthPayload());
|
|
4000
|
-
});
|
|
4001
|
-
|
|
4002
|
-
app.get('/api/security/session', (req, res) => {
|
|
4003
|
-
noStore(res);
|
|
4004
|
-
if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
|
|
4005
|
-
res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
|
|
4006
|
-
return;
|
|
4007
|
-
}
|
|
4008
|
-
res.json({
|
|
4009
|
-
ok: true,
|
|
4010
|
-
sessionId: localAdminSessionId,
|
|
4011
|
-
adminToken: localAdminSessionToken,
|
|
4012
|
-
csrfToken: localAdminCsrfToken,
|
|
4013
|
-
issuedAt: new Date().toISOString(),
|
|
4014
|
-
expiresOnProcessExit: true
|
|
4015
|
-
});
|
|
4016
|
-
});
|
|
4103
|
+
app.get('/api/health', (_req, res) => {
|
|
4104
|
+
noStore(res);
|
|
4105
|
+
res.json(buildHubHealthPayload());
|
|
4106
|
+
});
|
|
4107
|
+
|
|
4108
|
+
app.get('/api/security/session', (req, res) => {
|
|
4109
|
+
noStore(res);
|
|
4110
|
+
if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
|
|
4111
|
+
res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
4114
|
+
res.json({
|
|
4115
|
+
ok: true,
|
|
4116
|
+
sessionId: localAdminSessionId,
|
|
4117
|
+
adminToken: localAdminSessionToken,
|
|
4118
|
+
csrfToken: localAdminCsrfToken,
|
|
4119
|
+
issuedAt: new Date().toISOString(),
|
|
4120
|
+
expiresOnProcessExit: true
|
|
4121
|
+
});
|
|
4122
|
+
});
|
|
4017
4123
|
|
|
4018
4124
|
if (persistentSessionGcEnabled) {
|
|
4019
4125
|
app.post('/api/test/persistent-session/gc', (req, res) => {
|
|
@@ -4066,18 +4172,18 @@ app.patch('/api/settings', async (req, res) => {
|
|
|
4066
4172
|
const record = await liveDeskSettingsStore.update(patch, revision);
|
|
4067
4173
|
if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
|
|
4068
4174
|
await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
|
|
4069
|
-
}
|
|
4070
|
-
remoteHub.refreshDevicePolicies();
|
|
4071
|
-
void captureStore.enforceRetention().catch(() => undefined);
|
|
4072
|
-
recordSecurityAudit({
|
|
4073
|
-
action: 'settings.security-policy.updated',
|
|
4074
|
-
phase: 'complete',
|
|
4075
|
-
result: 'success',
|
|
4076
|
-
requestHash: requestAuditHash(patch),
|
|
4077
|
-
authMethod: 'local-admin-session',
|
|
4078
|
-
details: { revision: record.revision, sections: Object.keys(patch) }
|
|
4079
|
-
});
|
|
4080
|
-
res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
|
|
4175
|
+
}
|
|
4176
|
+
remoteHub.refreshDevicePolicies();
|
|
4177
|
+
void captureStore.enforceRetention().catch(() => undefined);
|
|
4178
|
+
recordSecurityAudit({
|
|
4179
|
+
action: 'settings.security-policy.updated',
|
|
4180
|
+
phase: 'complete',
|
|
4181
|
+
result: 'success',
|
|
4182
|
+
requestHash: requestAuditHash(patch),
|
|
4183
|
+
authMethod: 'local-admin-session',
|
|
4184
|
+
details: { revision: record.revision, sections: Object.keys(patch) }
|
|
4185
|
+
});
|
|
4186
|
+
res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
|
|
4081
4187
|
} catch (error) {
|
|
4082
4188
|
if (error instanceof SettingsConflictError) {
|
|
4083
4189
|
res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
|
|
@@ -4232,7 +4338,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
|
|
|
4232
4338
|
});
|
|
4233
4339
|
});
|
|
4234
4340
|
|
|
4235
|
-
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
4341
|
+
app.get('/api/security/trusted-devices', (_req, res) => {
|
|
4236
4342
|
noStore(res);
|
|
4237
4343
|
const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
|
|
4238
4344
|
deviceId: device.deviceId,
|
|
@@ -4244,128 +4350,141 @@ app.get('/api/security/trusted-devices', (_req, res) => {
|
|
|
4244
4350
|
lastSeenAt: device.lastSeenAt || '',
|
|
4245
4351
|
ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
|
|
4246
4352
|
}));
|
|
4247
|
-
res.json({ ok: true, devices });
|
|
4248
|
-
});
|
|
4249
|
-
|
|
4250
|
-
app.get('/api/security/
|
|
4251
|
-
noStore(res);
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
await
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
res.
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4353
|
+
res.json({ ok: true, devices });
|
|
4354
|
+
});
|
|
4355
|
+
|
|
4356
|
+
app.get('/api/security/rendezvous-issuer', (_req, res) => {
|
|
4357
|
+
noStore(res);
|
|
4358
|
+
const security = remoteHub.getSecurityStatus();
|
|
4359
|
+
res.json({
|
|
4360
|
+
ok: true,
|
|
4361
|
+
hubId: security.hubId,
|
|
4362
|
+
issuerKeyId: security.hubIssuerKeyId,
|
|
4363
|
+
publicKey: security.hubPublicKey,
|
|
4364
|
+
algorithm: 'P-256/SHA-256',
|
|
4365
|
+
environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
|
|
4366
|
+
});
|
|
4367
|
+
});
|
|
4368
|
+
|
|
4369
|
+
app.get('/api/security/audit', async (req, res) => {
|
|
4370
|
+
noStore(res);
|
|
4371
|
+
try {
|
|
4372
|
+
res.json({
|
|
4373
|
+
ok: true,
|
|
4374
|
+
healthy: securityAuditHealthy,
|
|
4375
|
+
verification: await securityAuditStore.verify(),
|
|
4376
|
+
audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
|
|
4377
|
+
});
|
|
4378
|
+
} catch (error) {
|
|
4379
|
+
securityAuditHealthy = false;
|
|
4380
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
4381
|
+
}
|
|
4382
|
+
});
|
|
4383
|
+
|
|
4384
|
+
app.get('/api/privacy/inventory', async (_req, res) => {
|
|
4385
|
+
noStore(res);
|
|
4386
|
+
try {
|
|
4387
|
+
const [captures, sharedFolders, verification] = await Promise.all([
|
|
4388
|
+
captureStore.list(),
|
|
4389
|
+
hubSharedFolders.list(),
|
|
4390
|
+
securityAuditStore.verify()
|
|
4391
|
+
]);
|
|
4392
|
+
res.json({
|
|
4393
|
+
ok: true,
|
|
4394
|
+
inventory: {
|
|
4395
|
+
authenticated: runtimeManager.getSnapshot().authenticated === true,
|
|
4396
|
+
captures: captures.length,
|
|
4397
|
+
sharedFolderDefinitions: sharedFolders.length,
|
|
4398
|
+
trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
|
|
4399
|
+
securityAuditRecords: verification.records,
|
|
4400
|
+
agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
|
|
4401
|
+
}
|
|
4402
|
+
});
|
|
4403
|
+
} catch (error) {
|
|
4404
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
|
|
4405
|
+
}
|
|
4406
|
+
});
|
|
4407
|
+
|
|
4408
|
+
app.delete('/api/privacy/local-data', async (req, res) => {
|
|
4409
|
+
noStore(res);
|
|
4410
|
+
if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
|
|
4411
|
+
res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
|
|
4412
|
+
return;
|
|
4413
|
+
}
|
|
4414
|
+
try {
|
|
4415
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
4416
|
+
const hostTarget = runtimeRole === 'hub'
|
|
4417
|
+
? await clearHubHostTarget('privacy-delete')
|
|
4418
|
+
: { ok: true, active: false };
|
|
4419
|
+
clearRuntimeSession();
|
|
4420
|
+
for (const device of remoteHub.getSecurityStatus().devices) {
|
|
4421
|
+
remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
|
|
4422
|
+
}
|
|
4423
|
+
const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
|
|
4424
|
+
const transferJobs = hubTransferJobs.clear();
|
|
4425
|
+
const captures = await captureStore.removeAll();
|
|
4426
|
+
const sharedFolders = await hubSharedFolders.clear();
|
|
4427
|
+
await agentAuditStore.clear();
|
|
4428
|
+
await securityAuditStore.reset();
|
|
4429
|
+
securityAuditHealthy = true;
|
|
4430
|
+
await securityAuditStore.record({
|
|
4431
|
+
action: 'privacy.local-data.deleted',
|
|
4432
|
+
phase: 'complete',
|
|
4433
|
+
result: 'success',
|
|
4434
|
+
authMethod: 'local-admin-session',
|
|
4435
|
+
details: {
|
|
4436
|
+
capturesRemoved: captures.removed,
|
|
4437
|
+
captureBytesRemoved: captures.removedBytes,
|
|
4438
|
+
sharedFolderDefinitionsRemoved: sharedFolders.removed,
|
|
4439
|
+
trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
|
|
4440
|
+
transferJobRecordsRemoved: transferJobs.removed,
|
|
4441
|
+
providerSessionRevoked: providerLogout.revoked === true
|
|
4442
|
+
}
|
|
4443
|
+
});
|
|
4444
|
+
res.json({
|
|
4445
|
+
ok: true,
|
|
4446
|
+
localDeleted: true,
|
|
4447
|
+
providerLogout,
|
|
4448
|
+
hostTarget,
|
|
4449
|
+
captures,
|
|
4450
|
+
sharedFolders,
|
|
4451
|
+
trustedDeviceCredentials,
|
|
4452
|
+
transferJobs,
|
|
4453
|
+
preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
|
|
4454
|
+
});
|
|
4455
|
+
} catch (error) {
|
|
4456
|
+
securityAuditHealthy = false;
|
|
4457
|
+
res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
|
|
4458
|
+
}
|
|
4459
|
+
});
|
|
4460
|
+
|
|
4461
|
+
app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
|
|
4462
|
+
noStore(res);
|
|
4463
|
+
const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
|
|
4464
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
|
|
4465
|
+
const revoked = disconnected === true || credentialRevoked === true;
|
|
4466
|
+
recordSecurityAudit({
|
|
4467
|
+
action: 'security.device.revoked',
|
|
4468
|
+
phase: 'complete',
|
|
4469
|
+
result: revoked ? 'success' : 'not-found',
|
|
4470
|
+
deviceId: req.params.deviceId,
|
|
4471
|
+
authMethod: 'local-admin-session'
|
|
4472
|
+
});
|
|
4473
|
+
res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
|
|
4474
|
+
});
|
|
4356
4475
|
|
|
4357
4476
|
app.post('/api/security/revoke-all', (_req, res) => {
|
|
4358
4477
|
noStore(res);
|
|
4359
|
-
const devices = remoteHub.listDevices({ includeDataUrl: false });
|
|
4360
|
-
let revoked = 0;
|
|
4361
|
-
for (const device of devices) {
|
|
4362
|
-
const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
|
|
4363
|
-
const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
|
|
4364
|
-
if (disconnected || credentialRevoked) revoked += 1;
|
|
4365
|
-
}
|
|
4366
|
-
recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
|
|
4367
|
-
res.json({ ok: true, revoked });
|
|
4368
|
-
});
|
|
4478
|
+
const devices = remoteHub.listDevices({ includeDataUrl: false });
|
|
4479
|
+
let revoked = 0;
|
|
4480
|
+
for (const device of devices) {
|
|
4481
|
+
const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
|
|
4482
|
+
const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
|
|
4483
|
+
if (disconnected || credentialRevoked) revoked += 1;
|
|
4484
|
+
}
|
|
4485
|
+
recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
|
|
4486
|
+
res.json({ ok: true, revoked });
|
|
4487
|
+
});
|
|
4369
4488
|
|
|
4370
4489
|
app.get('/api/settings/agent', async (_req, res) => {
|
|
4371
4490
|
noStore(res);
|
|
@@ -4727,17 +4846,17 @@ app.post('/api/runtime/restart', (_req, res) => {
|
|
|
4727
4846
|
}, 150);
|
|
4728
4847
|
});
|
|
4729
4848
|
|
|
4730
|
-
app.post('/api/runtime/shutdown', (req, res) => {
|
|
4731
|
-
noStore(res);
|
|
4732
|
-
recordSecurityAudit({
|
|
4733
|
-
action: 'runtime.shutdown',
|
|
4734
|
-
phase: 'requested',
|
|
4735
|
-
result: 'accepted',
|
|
4736
|
-
authMethod: req.liveDeskLauncherShutdown === true
|
|
4737
|
-
? 'launcher-internal-shutdown-token'
|
|
4738
|
-
: 'local-admin-session'
|
|
4739
|
-
});
|
|
4740
|
-
runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
|
|
4849
|
+
app.post('/api/runtime/shutdown', (req, res) => {
|
|
4850
|
+
noStore(res);
|
|
4851
|
+
recordSecurityAudit({
|
|
4852
|
+
action: 'runtime.shutdown',
|
|
4853
|
+
phase: 'requested',
|
|
4854
|
+
result: 'accepted',
|
|
4855
|
+
authMethod: req.liveDeskLauncherShutdown === true
|
|
4856
|
+
? 'launcher-internal-shutdown-token'
|
|
4857
|
+
: 'local-admin-session'
|
|
4858
|
+
});
|
|
4859
|
+
runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
|
|
4741
4860
|
res.json({ ok: true, shuttingDown: true, role: runtimeRole });
|
|
4742
4861
|
setTimeout(() => {
|
|
4743
4862
|
void shutdownHub('API_SHUTDOWN')
|
|
@@ -4774,10 +4893,10 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4774
4893
|
}
|
|
4775
4894
|
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
4776
4895
|
try {
|
|
4777
|
-
const user = await verifySupabaseUser(accessToken);
|
|
4778
|
-
runtimeSessionGeneration += 1;
|
|
4779
|
-
runtimeRefreshPromise = null;
|
|
4780
|
-
runtimeAccessToken = accessToken;
|
|
4896
|
+
const user = await verifySupabaseUser(accessToken);
|
|
4897
|
+
runtimeSessionGeneration += 1;
|
|
4898
|
+
runtimeRefreshPromise = null;
|
|
4899
|
+
runtimeAccessToken = accessToken;
|
|
4781
4900
|
runtimeRefreshToken = refreshToken;
|
|
4782
4901
|
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
4783
4902
|
runtimeManager.setAuthenticated(true, user.id);
|
|
@@ -4790,29 +4909,29 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4790
4909
|
const hostTarget = runtimeRole === 'hub'
|
|
4791
4910
|
? await publishHubHostTargetWithPendingRoleTakeover('session-received')
|
|
4792
4911
|
: { ok: true, active: false };
|
|
4793
|
-
if (runtimeRole === 'hub') {
|
|
4794
|
-
startHubHostTargetLeaseRenewal();
|
|
4795
|
-
}
|
|
4796
|
-
recordSecurityAudit({
|
|
4797
|
-
action: 'auth.session.established',
|
|
4798
|
-
phase: 'complete',
|
|
4799
|
-
result: 'success',
|
|
4800
|
-
actorAccountId: user.id,
|
|
4801
|
-
actorUserId: user.id,
|
|
4802
|
-
authMethod: 'supabase-access-refresh',
|
|
4803
|
-
requestHash: requestAuditHash({ userId: user.id, expiresAt })
|
|
4804
|
-
});
|
|
4805
|
-
res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
|
|
4806
|
-
} catch (error) {
|
|
4807
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4808
|
-
recordSecurityAudit({
|
|
4809
|
-
action: 'auth.session.establish',
|
|
4810
|
-
phase: 'complete',
|
|
4811
|
-
result: 'rejected',
|
|
4812
|
-
reason: message,
|
|
4813
|
-
authMethod: 'supabase-access-refresh',
|
|
4814
|
-
requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
|
|
4815
|
-
});
|
|
4912
|
+
if (runtimeRole === 'hub') {
|
|
4913
|
+
startHubHostTargetLeaseRenewal();
|
|
4914
|
+
}
|
|
4915
|
+
recordSecurityAudit({
|
|
4916
|
+
action: 'auth.session.established',
|
|
4917
|
+
phase: 'complete',
|
|
4918
|
+
result: 'success',
|
|
4919
|
+
actorAccountId: user.id,
|
|
4920
|
+
actorUserId: user.id,
|
|
4921
|
+
authMethod: 'supabase-access-refresh',
|
|
4922
|
+
requestHash: requestAuditHash({ userId: user.id, expiresAt })
|
|
4923
|
+
});
|
|
4924
|
+
res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
|
|
4925
|
+
} catch (error) {
|
|
4926
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4927
|
+
recordSecurityAudit({
|
|
4928
|
+
action: 'auth.session.establish',
|
|
4929
|
+
phase: 'complete',
|
|
4930
|
+
result: 'rejected',
|
|
4931
|
+
reason: message,
|
|
4932
|
+
authMethod: 'supabase-access-refresh',
|
|
4933
|
+
requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
|
|
4934
|
+
});
|
|
4816
4935
|
const status = authVerificationHttpStatus(message);
|
|
4817
4936
|
res.status(status).json({ ok: false, error: message });
|
|
4818
4937
|
}
|
|
@@ -5106,33 +5225,33 @@ function startHubHostTargetLeaseRenewal() {
|
|
|
5106
5225
|
hubHostTargetRenewTimer.unref?.();
|
|
5107
5226
|
}
|
|
5108
5227
|
|
|
5109
|
-
app.delete('/api/auth/session', async (_req, res) => {
|
|
5110
|
-
noStore(res);
|
|
5111
|
-
const actorUserId = runtimeManager.getSnapshot().userId || '';
|
|
5112
|
-
const hostTarget = runtimeRole === 'hub'
|
|
5113
|
-
? await clearHubHostTarget('logout')
|
|
5114
|
-
: { ok: true, active: false };
|
|
5115
|
-
const providerLogout = await revokeRuntimeProviderSession();
|
|
5116
|
-
clearRuntimeSession();
|
|
5117
|
-
recordSecurityAudit({
|
|
5118
|
-
action: 'auth.session.revoked',
|
|
5119
|
-
phase: 'complete',
|
|
5120
|
-
result: providerLogout.ok ? 'success' : 'provider-error',
|
|
5121
|
-
actorAccountId: actorUserId,
|
|
5122
|
-
actorUserId,
|
|
5123
|
-
reason: providerLogout.error || '',
|
|
5124
|
-
authMethod: 'supabase-local-signout',
|
|
5125
|
-
details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
|
|
5126
|
-
});
|
|
5127
|
-
res.status(providerLogout.ok ? 200 : 502).json({
|
|
5128
|
-
ok: providerLogout.ok,
|
|
5129
|
-
authenticated: false,
|
|
5130
|
-
localCleared: true,
|
|
5131
|
-
role: runtimeRole,
|
|
5132
|
-
hostTarget,
|
|
5133
|
-
providerLogout
|
|
5134
|
-
});
|
|
5135
|
-
});
|
|
5228
|
+
app.delete('/api/auth/session', async (_req, res) => {
|
|
5229
|
+
noStore(res);
|
|
5230
|
+
const actorUserId = runtimeManager.getSnapshot().userId || '';
|
|
5231
|
+
const hostTarget = runtimeRole === 'hub'
|
|
5232
|
+
? await clearHubHostTarget('logout')
|
|
5233
|
+
: { ok: true, active: false };
|
|
5234
|
+
const providerLogout = await revokeRuntimeProviderSession();
|
|
5235
|
+
clearRuntimeSession();
|
|
5236
|
+
recordSecurityAudit({
|
|
5237
|
+
action: 'auth.session.revoked',
|
|
5238
|
+
phase: 'complete',
|
|
5239
|
+
result: providerLogout.ok ? 'success' : 'provider-error',
|
|
5240
|
+
actorAccountId: actorUserId,
|
|
5241
|
+
actorUserId,
|
|
5242
|
+
reason: providerLogout.error || '',
|
|
5243
|
+
authMethod: 'supabase-local-signout',
|
|
5244
|
+
details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
|
|
5245
|
+
});
|
|
5246
|
+
res.status(providerLogout.ok ? 200 : 502).json({
|
|
5247
|
+
ok: providerLogout.ok,
|
|
5248
|
+
authenticated: false,
|
|
5249
|
+
localCleared: true,
|
|
5250
|
+
role: runtimeRole,
|
|
5251
|
+
hostTarget,
|
|
5252
|
+
providerLogout
|
|
5253
|
+
});
|
|
5254
|
+
});
|
|
5136
5255
|
|
|
5137
5256
|
app.get('/api/hub/status', (_req, res) => {
|
|
5138
5257
|
noStore(res);
|
|
@@ -5482,7 +5601,33 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
|
|
|
5482
5601
|
const job = hubTransferJobs.create({
|
|
5483
5602
|
itemIds,
|
|
5484
5603
|
deviceIds,
|
|
5485
|
-
remoteDirectory: req.body?.remoteDirectory
|
|
5604
|
+
remoteDirectory: req.body?.remoteDirectory,
|
|
5605
|
+
onComplete: async ({ completed, job: completedJob }) => {
|
|
5606
|
+
await recordSecurityAuditRequired({
|
|
5607
|
+
action: 'remote.file.job',
|
|
5608
|
+
phase: 'complete',
|
|
5609
|
+
result: completed ? 'success' : 'failed',
|
|
5610
|
+
reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
|
|
5611
|
+
deviceIds,
|
|
5612
|
+
sessionId: completedJob?.jobId || '',
|
|
5613
|
+
requestHash: requestAuditHash({
|
|
5614
|
+
jobId: completedJob?.jobId || '',
|
|
5615
|
+
deviceIds,
|
|
5616
|
+
remoteDirectory: req.body?.remoteDirectory || ''
|
|
5617
|
+
}),
|
|
5618
|
+
authMethod: 'local-admin-session',
|
|
5619
|
+
details: {
|
|
5620
|
+
state: completedJob?.state || '',
|
|
5621
|
+
totalFiles: Number(completedJob?.totalFiles || 0),
|
|
5622
|
+
completedFiles: Number(completedJob?.completedFiles || 0),
|
|
5623
|
+
totalBytes: Number(completedJob?.totalBytes || 0),
|
|
5624
|
+
sentBytes: Number(completedJob?.sentBytes || 0),
|
|
5625
|
+
failedTargets: Array.isArray(completedJob?.failedTargets)
|
|
5626
|
+
? completedJob.failedTargets.length
|
|
5627
|
+
: 0
|
|
5628
|
+
}
|
|
5629
|
+
});
|
|
5630
|
+
}
|
|
5486
5631
|
});
|
|
5487
5632
|
res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
|
|
5488
5633
|
} catch (error) {
|
|
@@ -5564,71 +5709,80 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
|
|
|
5564
5709
|
}
|
|
5565
5710
|
});
|
|
5566
5711
|
|
|
5567
|
-
app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
|
|
5712
|
+
app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
|
|
5568
5713
|
noStore(res);
|
|
5569
5714
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5570
|
-
if (deviceIds.length === 0) {
|
|
5571
|
-
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5572
|
-
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5715
|
+
if (deviceIds.length === 0) {
|
|
5716
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5717
|
+
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5573
5718
|
return;
|
|
5574
5719
|
}
|
|
5575
5720
|
const normalized = normalizeTransferFiles(req.body?.files);
|
|
5576
|
-
if (!normalized.ok) {
|
|
5577
|
-
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5721
|
+
if (!normalized.ok) {
|
|
5722
|
+
recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5578
5723
|
res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
|
|
5579
5724
|
return;
|
|
5580
5725
|
}
|
|
5581
5726
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5582
5727
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5583
5728
|
const queuedAt = new Date().toISOString();
|
|
5584
|
-
|
|
5585
|
-
deviceId
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
5604
|
-
|
|
5605
|
-
|
|
5606
|
-
|
|
5607
|
-
|
|
5608
|
-
|
|
5609
|
-
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5729
|
+
try {
|
|
5730
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5731
|
+
deviceId,
|
|
5732
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5733
|
+
command: 'file.transfer',
|
|
5734
|
+
payload: {
|
|
5735
|
+
transferId,
|
|
5736
|
+
remoteDirectory,
|
|
5737
|
+
files: normalized.files,
|
|
5738
|
+
totalBytes: normalized.totalBytes,
|
|
5739
|
+
requestedAt: queuedAt
|
|
5740
|
+
}
|
|
5741
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5742
|
+
})));
|
|
5743
|
+
const queued = results.filter(result => result.queued).length;
|
|
5744
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5745
|
+
const success = acknowledged === deviceIds.length;
|
|
5746
|
+
const partial = acknowledged > 0 && !success;
|
|
5747
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5748
|
+
await recordSecurityAuditRequired({
|
|
5749
|
+
action: 'remote.file.transfer',
|
|
5750
|
+
phase: 'complete',
|
|
5751
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5752
|
+
reason: error || '',
|
|
5753
|
+
deviceIds,
|
|
5754
|
+
sessionId: transferId,
|
|
5755
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
|
|
5756
|
+
authMethod: 'local-admin-session',
|
|
5757
|
+
details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
|
|
5758
|
+
});
|
|
5759
|
+
res.json({
|
|
5760
|
+
ok: success,
|
|
5761
|
+
transferId,
|
|
5762
|
+
queued,
|
|
5763
|
+
acknowledged,
|
|
5764
|
+
total: deviceIds.length,
|
|
5765
|
+
totalBytes: normalized.totalBytes,
|
|
5766
|
+
files: normalized.files.length,
|
|
5767
|
+
results,
|
|
5768
|
+
error
|
|
5769
|
+
});
|
|
5770
|
+
} catch (error) {
|
|
5771
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5772
|
+
}
|
|
5619
5773
|
});
|
|
5620
5774
|
|
|
5621
|
-
app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
5775
|
+
app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
|
|
5622
5776
|
noStore(res);
|
|
5623
5777
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
|
|
5624
|
-
if (deviceIds.length === 0) {
|
|
5625
|
-
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5778
|
+
if (deviceIds.length === 0) {
|
|
5779
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
|
|
5626
5780
|
res.status(400).json({ ok: false, error: 'no-target-devices' });
|
|
5627
5781
|
return;
|
|
5628
5782
|
}
|
|
5629
5783
|
const normalized = normalizeTransferChunk(req.body || {});
|
|
5630
|
-
if (!normalized.ok) {
|
|
5631
|
-
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5784
|
+
if (!normalized.ok) {
|
|
5785
|
+
recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
|
|
5632
5786
|
res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
|
|
5633
5787
|
return;
|
|
5634
5788
|
}
|
|
@@ -5636,41 +5790,50 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
|
|
|
5636
5790
|
const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
|
|
5637
5791
|
const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
|
|
5638
5792
|
const queuedAt = new Date().toISOString();
|
|
5639
|
-
|
|
5640
|
-
deviceId
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5793
|
+
try {
|
|
5794
|
+
const results = await Promise.all(deviceIds.map(async deviceId => ({
|
|
5795
|
+
deviceId,
|
|
5796
|
+
...await remoteHub.sendCommandAwaitResult(deviceId, {
|
|
5797
|
+
command: 'file.transfer.chunk',
|
|
5798
|
+
payload: {
|
|
5799
|
+
transferId,
|
|
5800
|
+
remoteDirectory,
|
|
5801
|
+
...normalized.chunk,
|
|
5802
|
+
requestedAt: queuedAt
|
|
5803
|
+
}
|
|
5804
|
+
}, { timeoutMs: fileTransferCommandAckTimeoutMs })
|
|
5805
|
+
})));
|
|
5806
|
+
const queued = results.filter(result => result.queued).length;
|
|
5807
|
+
const acknowledged = results.filter(result => result.acknowledged).length;
|
|
5808
|
+
const success = acknowledged === deviceIds.length;
|
|
5809
|
+
const partial = acknowledged > 0 && !success;
|
|
5810
|
+
const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
|
|
5811
|
+
await recordSecurityAuditRequired({
|
|
5812
|
+
action: 'remote.file.chunk',
|
|
5813
|
+
phase: normalized.chunk.final ? 'complete' : 'progress',
|
|
5814
|
+
result: success ? 'success' : partial ? 'partial' : 'rejected',
|
|
5815
|
+
reason: error || '',
|
|
5816
|
+
deviceIds,
|
|
5817
|
+
sessionId: transferId,
|
|
5818
|
+
requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
|
|
5819
|
+
authMethod: 'local-admin-session',
|
|
5820
|
+
details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
|
|
5821
|
+
});
|
|
5822
|
+
res.json({
|
|
5823
|
+
ok: success,
|
|
5824
|
+
transferId,
|
|
5825
|
+
queued,
|
|
5826
|
+
acknowledged,
|
|
5827
|
+
total: deviceIds.length,
|
|
5828
|
+
byteLength: normalized.chunk.byteLength,
|
|
5829
|
+
offset: normalized.chunk.offset,
|
|
5830
|
+
final: normalized.chunk.final,
|
|
5831
|
+
results,
|
|
5832
|
+
error
|
|
5833
|
+
});
|
|
5834
|
+
} catch (error) {
|
|
5835
|
+
res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
|
|
5836
|
+
}
|
|
5674
5837
|
});
|
|
5675
5838
|
|
|
5676
5839
|
const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
|
|
@@ -5944,7 +6107,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
|
|
|
5944
6107
|
}
|
|
5945
6108
|
});
|
|
5946
6109
|
|
|
5947
|
-
if (existsSync(webIndexPath)) {
|
|
6110
|
+
if (existsSync(webIndexPath)) {
|
|
5948
6111
|
app.use(express.static(webDistPath, {
|
|
5949
6112
|
etag: true,
|
|
5950
6113
|
index: false,
|
|
@@ -5957,41 +6120,67 @@ if (existsSync(webIndexPath)) {
|
|
|
5957
6120
|
}
|
|
5958
6121
|
res.sendFile(webIndexPath);
|
|
5959
6122
|
});
|
|
5960
|
-
}
|
|
5961
|
-
|
|
5962
|
-
function isAuthorizedAdminWebSocket(req) {
|
|
5963
|
-
if (!enforceLocalAdminAuth) return true;
|
|
5964
|
-
const protocols = String(req.headers['sec-websocket-protocol'] || '')
|
|
5965
|
-
.split(',')
|
|
5966
|
-
.map(value => value.trim())
|
|
5967
|
-
.filter(Boolean);
|
|
5968
|
-
const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
|
|
5969
|
-
const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
|
|
5970
|
-
return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
|
|
5971
|
-
&& secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
|
|
5972
|
-
}
|
|
5973
|
-
|
|
5974
|
-
httpServer.on('upgrade', (req, socket, head) => {
|
|
5975
|
-
if (!isTrustedBrowserRequest(req)) {
|
|
6123
|
+
}
|
|
6124
|
+
|
|
6125
|
+
function isAuthorizedAdminWebSocket(req) {
|
|
6126
|
+
if (!enforceLocalAdminAuth) return true;
|
|
6127
|
+
const protocols = String(req.headers['sec-websocket-protocol'] || '')
|
|
6128
|
+
.split(',')
|
|
6129
|
+
.map(value => value.trim())
|
|
6130
|
+
.filter(Boolean);
|
|
6131
|
+
const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
|
|
6132
|
+
const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
|
|
6133
|
+
return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
|
|
6134
|
+
&& secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
|
|
6135
|
+
}
|
|
6136
|
+
|
|
6137
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
6138
|
+
if (!isTrustedBrowserRequest(req)) {
|
|
5976
6139
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
5977
6140
|
socket.destroy();
|
|
5978
|
-
return;
|
|
5979
|
-
}
|
|
5980
|
-
if (!isAuthorizedAdminWebSocket(req)) {
|
|
5981
|
-
recordSecurityAudit({
|
|
5982
|
-
action: 'local-admin.websocket',
|
|
5983
|
-
phase: 'complete',
|
|
5984
|
-
result: 'rejected',
|
|
5985
|
-
reason: 'admin-session-required',
|
|
5986
|
-
requestHash: requestAuditHash({ path: req.url || '' }),
|
|
5987
|
-
authMethod: 'local-admin-websocket'
|
|
5988
|
-
});
|
|
5989
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
5990
|
-
socket.destroy();
|
|
5991
|
-
return;
|
|
5992
|
-
}
|
|
6141
|
+
return;
|
|
6142
|
+
}
|
|
6143
|
+
if (!isAuthorizedAdminWebSocket(req)) {
|
|
6144
|
+
recordSecurityAudit({
|
|
6145
|
+
action: 'local-admin.websocket',
|
|
6146
|
+
phase: 'complete',
|
|
6147
|
+
result: 'rejected',
|
|
6148
|
+
reason: 'admin-session-required',
|
|
6149
|
+
requestHash: requestAuditHash({ path: req.url || '' }),
|
|
6150
|
+
authMethod: 'local-admin-websocket'
|
|
6151
|
+
});
|
|
6152
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
6153
|
+
socket.destroy();
|
|
6154
|
+
return;
|
|
6155
|
+
}
|
|
6156
|
+
let parsed;
|
|
5993
6157
|
try {
|
|
5994
|
-
|
|
6158
|
+
parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6159
|
+
} catch {
|
|
6160
|
+
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
6161
|
+
socket.destroy();
|
|
6162
|
+
return;
|
|
6163
|
+
}
|
|
6164
|
+
const websocketPath = parsed.pathname;
|
|
6165
|
+
if (![
|
|
6166
|
+
'/api/remote/frames/ws',
|
|
6167
|
+
'/api/remote/atlas/ws',
|
|
6168
|
+
'/api/remote/input/ws',
|
|
6169
|
+
'/api/remote/audio/ws'
|
|
6170
|
+
].includes(websocketPath)) {
|
|
6171
|
+
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
6172
|
+
socket.destroy();
|
|
6173
|
+
return;
|
|
6174
|
+
}
|
|
6175
|
+
void recordSecurityAuditRequired({
|
|
6176
|
+
action: 'local-admin.websocket',
|
|
6177
|
+
phase: 'accepted',
|
|
6178
|
+
result: 'accepted',
|
|
6179
|
+
sessionId: localAdminSessionId,
|
|
6180
|
+
requestHash: requestAuditHash({ path: websocketPath }),
|
|
6181
|
+
authMethod: 'local-admin-websocket',
|
|
6182
|
+
details: { path: websocketPath }
|
|
6183
|
+
}).then(() => {
|
|
5995
6184
|
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
5996
6185
|
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|
|
5997
6186
|
return;
|
|
@@ -6008,12 +6197,10 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
6008
6197
|
audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
|
|
6009
6198
|
return;
|
|
6010
6199
|
}
|
|
6011
|
-
|
|
6012
|
-
socket.
|
|
6013
|
-
} catch {
|
|
6014
|
-
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
|
6200
|
+
}).catch(() => {
|
|
6201
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
|
6015
6202
|
socket.destroy();
|
|
6016
|
-
}
|
|
6203
|
+
});
|
|
6017
6204
|
});
|
|
6018
6205
|
|
|
6019
6206
|
frameWss.on('connection', (ws, req) => {
|
|
@@ -6126,23 +6313,21 @@ audioWss.on('connection', (ws, req) => {
|
|
|
6126
6313
|
});
|
|
6127
6314
|
});
|
|
6128
6315
|
|
|
6129
|
-
inputWss.on('connection', ws => {
|
|
6316
|
+
inputWss.on('connection', ws => {
|
|
6130
6317
|
inputClients.add(ws);
|
|
6131
6318
|
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
6132
|
-
ws.liveDeskInputDeviceIds = new Set();
|
|
6133
|
-
|
|
6134
|
-
action: 'remote.control.browser-session',
|
|
6135
|
-
phase: 'start',
|
|
6136
|
-
result: 'success',
|
|
6137
|
-
sessionId: ws.liveDeskInputClientId,
|
|
6138
|
-
authMethod: 'local-admin-websocket'
|
|
6139
|
-
});
|
|
6319
|
+
ws.liveDeskInputDeviceIds = new Set();
|
|
6320
|
+
ws.liveDeskInputAuditReady = false;
|
|
6140
6321
|
try {
|
|
6141
6322
|
ws._socket?.setNoDelay?.(true);
|
|
6142
6323
|
} catch {
|
|
6143
6324
|
// Best-effort latency hint for browser input sockets.
|
|
6144
6325
|
}
|
|
6145
6326
|
ws.on('message', data => {
|
|
6327
|
+
if (ws.liveDeskInputAuditReady !== true) {
|
|
6328
|
+
sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
|
|
6329
|
+
return;
|
|
6330
|
+
}
|
|
6146
6331
|
let payload;
|
|
6147
6332
|
try {
|
|
6148
6333
|
payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
@@ -6182,31 +6367,44 @@ inputWss.on('connection', ws => {
|
|
|
6182
6367
|
timestamp: new Date().toISOString()
|
|
6183
6368
|
});
|
|
6184
6369
|
});
|
|
6185
|
-
const releaseBrowserInputOwner = reason => {
|
|
6186
|
-
if (ws.liveDeskInputReleased) return;
|
|
6187
|
-
ws.liveDeskInputReleased = true;
|
|
6188
|
-
inputClients.delete(ws);
|
|
6189
|
-
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
6190
|
-
remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
|
|
6191
|
-
}
|
|
6192
|
-
|
|
6193
|
-
action: 'remote.control.browser-session',
|
|
6194
|
-
phase: 'complete',
|
|
6195
|
-
result: 'success',
|
|
6196
|
-
reason,
|
|
6197
|
-
deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
|
|
6198
|
-
sessionId: ws.liveDeskInputClientId,
|
|
6199
|
-
authMethod: 'local-admin-websocket'
|
|
6200
|
-
})
|
|
6370
|
+
const releaseBrowserInputOwner = reason => {
|
|
6371
|
+
if (ws.liveDeskInputReleased) return;
|
|
6372
|
+
ws.liveDeskInputReleased = true;
|
|
6373
|
+
inputClients.delete(ws);
|
|
6374
|
+
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
6375
|
+
remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
|
|
6376
|
+
}
|
|
6377
|
+
void recordSecurityAuditRequired({
|
|
6378
|
+
action: 'remote.control.browser-session',
|
|
6379
|
+
phase: 'complete',
|
|
6380
|
+
result: 'success',
|
|
6381
|
+
reason,
|
|
6382
|
+
deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
|
|
6383
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6384
|
+
authMethod: 'local-admin-websocket'
|
|
6385
|
+
}).catch(() => {
|
|
6386
|
+
// The global fail-closed gate is marked by recordSecurityAuditRequired.
|
|
6387
|
+
});
|
|
6201
6388
|
ws.liveDeskInputDeviceIds?.clear?.();
|
|
6202
6389
|
};
|
|
6203
6390
|
ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
|
|
6204
6391
|
ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6392
|
+
void recordSecurityAuditRequired({
|
|
6393
|
+
action: 'remote.control.browser-session',
|
|
6394
|
+
phase: 'start',
|
|
6395
|
+
result: 'success',
|
|
6396
|
+
sessionId: ws.liveDeskInputClientId,
|
|
6397
|
+
authMethod: 'local-admin-websocket'
|
|
6398
|
+
}).then(() => {
|
|
6399
|
+
ws.liveDeskInputAuditReady = true;
|
|
6400
|
+
sendJson(ws, {
|
|
6401
|
+
type: 'RemoteInputSocketReady',
|
|
6402
|
+
protocol: 'livedesk.remote.input.json.v1',
|
|
6403
|
+
clientId: ws.liveDeskInputClientId,
|
|
6404
|
+
timestamp: new Date().toISOString()
|
|
6405
|
+
});
|
|
6406
|
+
}).catch(() => {
|
|
6407
|
+
ws.close(1011, 'security-audit-unavailable');
|
|
6210
6408
|
});
|
|
6211
6409
|
});
|
|
6212
6410
|
|
|
@@ -6302,8 +6500,8 @@ function shutdownHub(signal) {
|
|
|
6302
6500
|
hubShutdownPromise = (async () => {
|
|
6303
6501
|
const startedAt = Date.now();
|
|
6304
6502
|
console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
|
|
6305
|
-
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6306
|
-
clearInterval(captureRetentionTimer);
|
|
6503
|
+
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6504
|
+
clearInterval(captureRetentionTimer);
|
|
6307
6505
|
clearInterval(browserWebSocketHeartbeatTimer);
|
|
6308
6506
|
runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
|
|
6309
6507
|
atlasClients.clear();
|