@livedesk/hub 0.1.38 → 0.1.40
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 +1 -1
- package/src/http/hub-ui-session.js +119 -0
- package/src/live-desk-update.js +88 -39
- package/src/remote-hub.js +61 -9
- package/src/server.js +130 -41
package/package.json
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const HUB_UI_SESSION_COOKIE = 'livedesk_hub_ui';
|
|
4
|
+
export const HUB_UI_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
7
|
+
|
|
8
|
+
function secureEqual(left, right) {
|
|
9
|
+
const leftBuffer = Buffer.from(String(left || ''), 'utf8');
|
|
10
|
+
const rightBuffer = Buffer.from(String(right || ''), 'utf8');
|
|
11
|
+
return leftBuffer.length === rightBuffer.length
|
|
12
|
+
&& leftBuffer.length > 0
|
|
13
|
+
&& crypto.timingSafeEqual(leftBuffer, rightBuffer);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseCookies(value) {
|
|
17
|
+
const result = new Map();
|
|
18
|
+
for (const part of String(value || '').split(';')) {
|
|
19
|
+
const separator = part.indexOf('=');
|
|
20
|
+
if (separator <= 0) continue;
|
|
21
|
+
const key = part.slice(0, separator).trim();
|
|
22
|
+
const rawValue = part.slice(separator + 1).trim();
|
|
23
|
+
if (!key) continue;
|
|
24
|
+
try {
|
|
25
|
+
result.set(key, decodeURIComponent(rawValue));
|
|
26
|
+
} catch {
|
|
27
|
+
result.set(key, rawValue);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function randomToken() {
|
|
34
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createHubUiSessionAuthority({
|
|
38
|
+
ttlMs = HUB_UI_SESSION_TTL_MS,
|
|
39
|
+
maxSessions = 32,
|
|
40
|
+
now = () => Date.now()
|
|
41
|
+
} = {}) {
|
|
42
|
+
const sessions = new Map();
|
|
43
|
+
|
|
44
|
+
function purgeExpired() {
|
|
45
|
+
const current = now();
|
|
46
|
+
for (const [sessionId, session] of sessions) {
|
|
47
|
+
if (session.expiresAt <= current) sessions.delete(sessionId);
|
|
48
|
+
}
|
|
49
|
+
while (sessions.size >= maxSessions) {
|
|
50
|
+
sessions.delete(sessions.keys().next().value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function issue(userId) {
|
|
55
|
+
const normalizedUserId = String(userId || '').trim();
|
|
56
|
+
if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
|
|
57
|
+
purgeExpired();
|
|
58
|
+
const sessionId = randomToken();
|
|
59
|
+
const csrfToken = randomToken();
|
|
60
|
+
const expiresAt = now() + ttlMs;
|
|
61
|
+
sessions.set(sessionId, { userId: normalizedUserId, csrfToken, expiresAt });
|
|
62
|
+
return { sessionId, csrfToken, expiresAt };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function authorize(req, currentUserId, {
|
|
66
|
+
requireCsrf = !SAFE_HTTP_METHODS.has(String(req.method || 'GET').toUpperCase())
|
|
67
|
+
} = {}) {
|
|
68
|
+
purgeExpired();
|
|
69
|
+
const sessionId = parseCookies(req.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
|
|
70
|
+
if (!sessionId) {
|
|
71
|
+
return { ok: false, status: 401, error: 'hub-ui-session-required' };
|
|
72
|
+
}
|
|
73
|
+
const session = sessions.get(sessionId);
|
|
74
|
+
if (!session) {
|
|
75
|
+
return { ok: false, status: 401, error: 'hub-ui-session-expired' };
|
|
76
|
+
}
|
|
77
|
+
const normalizedUserId = String(currentUserId || '').trim();
|
|
78
|
+
if (!normalizedUserId || session.userId !== normalizedUserId) {
|
|
79
|
+
sessions.delete(sessionId);
|
|
80
|
+
return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
|
|
81
|
+
}
|
|
82
|
+
if (requireCsrf && !secureEqual(req.headers?.['x-livedesk-csrf'], session.csrfToken)) {
|
|
83
|
+
return { ok: false, status: 403, error: 'hub-ui-csrf-invalid' };
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, sessionId, userId: session.userId, expiresAt: session.expiresAt };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
issue,
|
|
90
|
+
authorize,
|
|
91
|
+
revokeAll() {
|
|
92
|
+
sessions.clear();
|
|
93
|
+
},
|
|
94
|
+
sessionCount: () => sessions.size
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function serializeHubUiSessionCookie(sessionId, expiresAt, { secure = false } = {}) {
|
|
99
|
+
const maxAgeSeconds = Math.max(1, Math.floor((Number(expiresAt || 0) - Date.now()) / 1000));
|
|
100
|
+
return [
|
|
101
|
+
`${HUB_UI_SESSION_COOKIE}=${encodeURIComponent(String(sessionId || ''))}`,
|
|
102
|
+
'Path=/',
|
|
103
|
+
'HttpOnly',
|
|
104
|
+
'SameSite=Strict',
|
|
105
|
+
`Max-Age=${maxAgeSeconds}`,
|
|
106
|
+
secure ? 'Secure' : ''
|
|
107
|
+
].filter(Boolean).join('; ');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function clearHubUiSessionCookie({ secure = false } = {}) {
|
|
111
|
+
return [
|
|
112
|
+
`${HUB_UI_SESSION_COOKIE}=`,
|
|
113
|
+
'Path=/',
|
|
114
|
+
'HttpOnly',
|
|
115
|
+
'SameSite=Strict',
|
|
116
|
+
'Max-Age=0',
|
|
117
|
+
secure ? 'Secure' : ''
|
|
118
|
+
].filter(Boolean).join('; ');
|
|
119
|
+
}
|
package/src/live-desk-update.js
CHANGED
|
@@ -8,10 +8,11 @@ export const LIVE_DESK_UPDATE_COMMAND = 'livedesk.client-update';
|
|
|
8
8
|
// same device returned, so the Hub-owned compatibility bridge upgrades them.
|
|
9
9
|
export const LIVE_DESK_DEDICATED_CLIENT_UPDATE_MIN_VERSION = '0.1.172';
|
|
10
10
|
export const LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE = 5;
|
|
11
|
-
export const LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS = 480_000;
|
|
12
|
-
export const LIVE_DESK_UPDATE_TIMEOUT_MS = 90 * 60_000;
|
|
13
|
-
export const LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS = 60_000;
|
|
14
|
-
export const
|
|
11
|
+
export const LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS = 480_000;
|
|
12
|
+
export const LIVE_DESK_UPDATE_TIMEOUT_MS = 90 * 60_000;
|
|
13
|
+
export const LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS = 60_000;
|
|
14
|
+
export const LIVE_DESK_UPDATE_VERIFIED_CLIENT_STABILITY_MS = 10_000;
|
|
15
|
+
export const LIVE_DESK_UPDATE_CHECK_INTERVAL_MS = 60_000;
|
|
15
16
|
export const LIVE_DESK_LEGACY_COMMAND_MAX_LENGTH = 3900;
|
|
16
17
|
|
|
17
18
|
function cleanVersion(value) {
|
|
@@ -194,9 +195,10 @@ function statusForRun(run) {
|
|
|
194
195
|
activeCount,
|
|
195
196
|
queuedCount,
|
|
196
197
|
pendingOfflineCount,
|
|
197
|
-
failedCount,
|
|
198
|
-
batchSize: run.batchSize,
|
|
199
|
-
clientRestartStabilityMs: run.clientRestartStabilityMs,
|
|
198
|
+
failedCount,
|
|
199
|
+
batchSize: run.batchSize,
|
|
200
|
+
clientRestartStabilityMs: run.clientRestartStabilityMs,
|
|
201
|
+
verifiedClientRestartStabilityMs: run.verifiedClientRestartStabilityMs,
|
|
200
202
|
latestManagerVersion: run.latestManagerVersion,
|
|
201
203
|
latestClientVersion: run.latestClientVersion,
|
|
202
204
|
error: run.error || '',
|
|
@@ -226,10 +228,14 @@ function resetReconnectCandidate(target) {
|
|
|
226
228
|
target.candidatePid = 0;
|
|
227
229
|
target.candidateConnectedAt = '';
|
|
228
230
|
target.candidateProductVersion = '';
|
|
229
|
-
target.candidateAgentVersion = '';
|
|
230
|
-
target.
|
|
231
|
-
target.
|
|
232
|
-
|
|
231
|
+
target.candidateAgentVersion = '';
|
|
232
|
+
target.candidateSupervisorProofId = '';
|
|
233
|
+
target.verificationSource = '';
|
|
234
|
+
target.requiredStabilityMs = 0;
|
|
235
|
+
target.supervisorProofAt = '';
|
|
236
|
+
target.candidateSince = '';
|
|
237
|
+
target.stabilityDeadlineAt = '';
|
|
238
|
+
}
|
|
233
239
|
|
|
234
240
|
export function createLiveDeskUpdateManager({
|
|
235
241
|
remoteHub,
|
|
@@ -240,10 +246,11 @@ export function createLiveDeskUpdateManager({
|
|
|
240
246
|
fetchImpl = globalThis.fetch,
|
|
241
247
|
now = () => Date.now(),
|
|
242
248
|
clientBatchSize = LIVE_DESK_UPDATE_CLIENT_BATCH_SIZE,
|
|
243
|
-
targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
|
|
244
|
-
operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS,
|
|
245
|
-
clientRestartStabilityMs = LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
|
|
246
|
-
|
|
249
|
+
targetTimeoutMs = LIVE_DESK_UPDATE_TARGET_TIMEOUT_MS,
|
|
250
|
+
operationTimeoutMs = LIVE_DESK_UPDATE_TIMEOUT_MS,
|
|
251
|
+
clientRestartStabilityMs = LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS,
|
|
252
|
+
verifiedClientRestartStabilityMs = LIVE_DESK_UPDATE_VERIFIED_CLIENT_STABILITY_MS
|
|
253
|
+
}) {
|
|
247
254
|
let latestRelease = null;
|
|
248
255
|
let checkError = '';
|
|
249
256
|
let checkPromise = null;
|
|
@@ -262,12 +269,18 @@ export function createLiveDeskUpdateManager({
|
|
|
262
269
|
effectiveTargetTimeoutMs,
|
|
263
270
|
Number(operationTimeoutMs) || LIVE_DESK_UPDATE_TIMEOUT_MS
|
|
264
271
|
);
|
|
265
|
-
const effectiveClientRestartStabilityMs = Math.max(
|
|
272
|
+
const effectiveClientRestartStabilityMs = Math.max(
|
|
266
273
|
0,
|
|
267
274
|
Number.isFinite(Number(clientRestartStabilityMs))
|
|
268
275
|
? Number(clientRestartStabilityMs)
|
|
269
|
-
: LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
|
|
270
|
-
);
|
|
276
|
+
: LIVE_DESK_UPDATE_CLIENT_RESTART_STABILITY_MS
|
|
277
|
+
);
|
|
278
|
+
const effectiveVerifiedClientRestartStabilityMs = Math.max(
|
|
279
|
+
5_000,
|
|
280
|
+
Number.isFinite(Number(verifiedClientRestartStabilityMs))
|
|
281
|
+
? Number(verifiedClientRestartStabilityMs)
|
|
282
|
+
: LIVE_DESK_UPDATE_VERIFIED_CLIENT_STABILITY_MS
|
|
283
|
+
);
|
|
271
284
|
|
|
272
285
|
const touch = () => {
|
|
273
286
|
if (run) run.updatedAt = new Date(now()).toISOString();
|
|
@@ -341,9 +354,10 @@ export function createLiveDeskUpdateManager({
|
|
|
341
354
|
queuedCount: 0,
|
|
342
355
|
pendingOfflineCount: 0,
|
|
343
356
|
failedCount: 0,
|
|
344
|
-
batchSize: effectiveClientBatchSize,
|
|
345
|
-
clientRestartStabilityMs: effectiveClientRestartStabilityMs,
|
|
346
|
-
|
|
357
|
+
batchSize: effectiveClientBatchSize,
|
|
358
|
+
clientRestartStabilityMs: effectiveClientRestartStabilityMs,
|
|
359
|
+
verifiedClientRestartStabilityMs: effectiveVerifiedClientRestartStabilityMs,
|
|
360
|
+
targets: []
|
|
347
361
|
})
|
|
348
362
|
};
|
|
349
363
|
};
|
|
@@ -535,9 +549,30 @@ export function createLiveDeskUpdateManager({
|
|
|
535
549
|
const connectedAtEpochMs = Date.parse(connectedAt);
|
|
536
550
|
const dispatchedAtEpochMs = Date.parse(target.dispatchedAt || '');
|
|
537
551
|
const candidatePid = Math.max(0, Math.floor(Number(device?.pid) || 0));
|
|
538
|
-
const candidateProductVersion = String(device?.productVersion || '');
|
|
539
|
-
const candidateAgentVersion = String(device?.agentVersion || '');
|
|
540
|
-
const
|
|
552
|
+
const candidateProductVersion = String(device?.productVersion || '');
|
|
553
|
+
const candidateAgentVersion = String(device?.agentVersion || '');
|
|
554
|
+
const supervisorProof = device?.clientUpdateProof;
|
|
555
|
+
const supervisorProofId = [
|
|
556
|
+
String(supervisorProof?.operationId || ''),
|
|
557
|
+
String(supervisorProof?.attemptId || ''),
|
|
558
|
+
String(supervisorProof?.sessionId || ''),
|
|
559
|
+
String(supervisorProof?.agentPid || ''),
|
|
560
|
+
String(supervisorProof?.launcherPid || ''),
|
|
561
|
+
String(supervisorProof?.completedAt || '')
|
|
562
|
+
].join(':');
|
|
563
|
+
const hasSupervisorProof = !!supervisorProof
|
|
564
|
+
&& String(supervisorProof.operationId || '') === run.operationId
|
|
565
|
+
&& !!String(supervisorProof.attemptId || '')
|
|
566
|
+
&& String(supervisorProof.sessionId || '') === sessionId
|
|
567
|
+
&& Number(supervisorProof.agentPid || 0) === candidatePid
|
|
568
|
+
&& Number(supervisorProof.launcherPid || 0) > 1
|
|
569
|
+
&& cleanVersion(supervisorProof.productVersion) === cleanVersion(candidateProductVersion)
|
|
570
|
+
&& cleanVersion(supervisorProof.agentVersion) === cleanVersion(candidateAgentVersion)
|
|
571
|
+
&& Date.parse(String(supervisorProof.receivedAt || '')) >= connectedAtEpochMs;
|
|
572
|
+
const requiredStabilityMs = hasSupervisorProof
|
|
573
|
+
? run.verifiedClientRestartStabilityMs
|
|
574
|
+
: run.clientRestartStabilityMs;
|
|
575
|
+
const qualifiesForStability = device?.connected === true
|
|
541
576
|
&& !!sessionId
|
|
542
577
|
&& candidatePid > 1
|
|
543
578
|
&& sessionId !== target.dispatchedSessionId
|
|
@@ -546,9 +581,10 @@ export function createLiveDeskUpdateManager({
|
|
|
546
581
|
const sameCandidate = qualifiesForStability
|
|
547
582
|
&& target.candidateSessionId === sessionId
|
|
548
583
|
&& target.candidatePid === candidatePid
|
|
549
|
-
&& target.candidateConnectedAt === connectedAt
|
|
550
|
-
&& target.candidateProductVersion === candidateProductVersion
|
|
551
|
-
&& target.candidateAgentVersion === candidateAgentVersion
|
|
584
|
+
&& target.candidateConnectedAt === connectedAt
|
|
585
|
+
&& target.candidateProductVersion === candidateProductVersion
|
|
586
|
+
&& target.candidateAgentVersion === candidateAgentVersion
|
|
587
|
+
&& target.candidateSupervisorProofId === (hasSupervisorProof ? supervisorProofId : '');
|
|
552
588
|
|
|
553
589
|
if (!qualifiesForStability) {
|
|
554
590
|
resetReconnectCandidate(target);
|
|
@@ -556,12 +592,18 @@ export function createLiveDeskUpdateManager({
|
|
|
556
592
|
target.candidateSessionId = sessionId;
|
|
557
593
|
target.candidatePid = candidatePid;
|
|
558
594
|
target.candidateConnectedAt = connectedAt;
|
|
559
|
-
target.candidateProductVersion = candidateProductVersion;
|
|
560
|
-
target.candidateAgentVersion = candidateAgentVersion;
|
|
561
|
-
target.
|
|
562
|
-
target.
|
|
563
|
-
|
|
564
|
-
|
|
595
|
+
target.candidateProductVersion = candidateProductVersion;
|
|
596
|
+
target.candidateAgentVersion = candidateAgentVersion;
|
|
597
|
+
target.candidateSupervisorProofId = hasSupervisorProof ? supervisorProofId : '';
|
|
598
|
+
target.verificationSource = hasSupervisorProof ? 'supervisor-proof' : 'hub-session';
|
|
599
|
+
target.requiredStabilityMs = requiredStabilityMs;
|
|
600
|
+
target.supervisorProofAt = hasSupervisorProof
|
|
601
|
+
? String(supervisorProof.receivedAt || supervisorProof.completedAt || '')
|
|
602
|
+
: '';
|
|
603
|
+
target.candidateSince = new Date(currentTime).toISOString();
|
|
604
|
+
target.stabilityDeadlineAt = new Date(
|
|
605
|
+
currentTime + requiredStabilityMs
|
|
606
|
+
).toISOString();
|
|
565
607
|
}
|
|
566
608
|
|
|
567
609
|
if (qualifiesForStability
|
|
@@ -646,8 +688,9 @@ export function createLiveDeskUpdateManager({
|
|
|
646
688
|
latestManagerVersion: release.latestManagerVersion,
|
|
647
689
|
latestClientVersion: release.latestClientVersion,
|
|
648
690
|
needsHubRestart,
|
|
649
|
-
batchSize: effectiveClientBatchSize,
|
|
650
|
-
clientRestartStabilityMs: effectiveClientRestartStabilityMs,
|
|
691
|
+
batchSize: effectiveClientBatchSize,
|
|
692
|
+
clientRestartStabilityMs: effectiveClientRestartStabilityMs,
|
|
693
|
+
verifiedClientRestartStabilityMs: effectiveVerifiedClientRestartStabilityMs,
|
|
651
694
|
error: '',
|
|
652
695
|
targets: targets.map(device => ({
|
|
653
696
|
deviceId: String(device.deviceId || ''),
|
|
@@ -666,9 +709,13 @@ export function createLiveDeskUpdateManager({
|
|
|
666
709
|
candidateSessionId: '',
|
|
667
710
|
candidatePid: 0,
|
|
668
711
|
candidateConnectedAt: '',
|
|
669
|
-
candidateProductVersion: '',
|
|
670
|
-
candidateAgentVersion: '',
|
|
671
|
-
|
|
712
|
+
candidateProductVersion: '',
|
|
713
|
+
candidateAgentVersion: '',
|
|
714
|
+
candidateSupervisorProofId: '',
|
|
715
|
+
verificationSource: '',
|
|
716
|
+
requiredStabilityMs: 0,
|
|
717
|
+
supervisorProofAt: '',
|
|
718
|
+
candidateSince: '',
|
|
672
719
|
stabilityDeadlineAt: '',
|
|
673
720
|
completedAt: '',
|
|
674
721
|
error: ''
|
|
@@ -698,7 +745,9 @@ export function createLiveDeskUpdateManager({
|
|
|
698
745
|
|
|
699
746
|
const handleRemoteEvent = (type, event) => {
|
|
700
747
|
if (!run || run.state !== 'waiting-for-clients') return;
|
|
701
|
-
if (type === 'RemoteDeviceConnected'
|
|
748
|
+
if (type === 'RemoteDeviceConnected'
|
|
749
|
+
|| type === 'RemoteDeviceDisconnected'
|
|
750
|
+
|| type === 'RemoteClientUpdateVerified') {
|
|
702
751
|
verifyTargets();
|
|
703
752
|
return;
|
|
704
753
|
}
|
package/src/remote-hub.js
CHANGED
|
@@ -286,7 +286,7 @@ function normalizePort(value) {
|
|
|
286
286
|
return clampNumber(value, 0, 65535, DEFAULT_REMOTE_HUB_PORT);
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
-
function safeString(value, maxLength = 200) {
|
|
289
|
+
function safeString(value, maxLength = 200) {
|
|
290
290
|
return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
|
|
291
291
|
}
|
|
292
292
|
|
|
@@ -1123,6 +1123,42 @@ function safeText(value, maxLength = 1000) {
|
|
|
1123
1123
|
return String(value ?? '').replace(/\0/g, '').trim().slice(0, maxLength);
|
|
1124
1124
|
}
|
|
1125
1125
|
|
|
1126
|
+
function normalizeClientUpdateVerifiedProof(device, message, receivedAt) {
|
|
1127
|
+
const operationId = safeString(message?.operationId, 180);
|
|
1128
|
+
const attemptId = safeString(message?.attemptId, 180);
|
|
1129
|
+
const productVersion = safeString(message?.productVersion, 40);
|
|
1130
|
+
const agentVersion = safeString(message?.agentVersion, 40);
|
|
1131
|
+
const completedAt = safeString(message?.completedAt, 64);
|
|
1132
|
+
const launcherPid = Math.max(0, Math.floor(Number(message?.launcherPid) || 0));
|
|
1133
|
+
const agentPid = Math.max(0, Math.floor(Number(message?.agentPid) || 0));
|
|
1134
|
+
const supervisorPid = Math.max(0, Math.floor(Number(message?.supervisorPid) || 0));
|
|
1135
|
+
if (!operationId
|
|
1136
|
+
|| !attemptId
|
|
1137
|
+
|| !productVersion
|
|
1138
|
+
|| !agentVersion
|
|
1139
|
+
|| !Number.isFinite(Date.parse(completedAt))
|
|
1140
|
+
|| launcherPid <= 1
|
|
1141
|
+
|| agentPid <= 1
|
|
1142
|
+
|| supervisorPid <= 1
|
|
1143
|
+
|| agentPid !== Number(device?.pid || 0)
|
|
1144
|
+
|| productVersion !== String(device?.productVersion || '')
|
|
1145
|
+
|| agentVersion !== String(device?.agentVersion || '')) {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
return {
|
|
1149
|
+
operationId,
|
|
1150
|
+
attemptId,
|
|
1151
|
+
sessionId: String(device.sessionId || ''),
|
|
1152
|
+
launcherPid,
|
|
1153
|
+
agentPid,
|
|
1154
|
+
supervisorPid,
|
|
1155
|
+
productVersion,
|
|
1156
|
+
agentVersion,
|
|
1157
|
+
completedAt,
|
|
1158
|
+
receivedAt
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1126
1162
|
function normalizeRemoteKeyboardKey(value) {
|
|
1127
1163
|
const raw = String(value ?? '').replace(/\0/g, '');
|
|
1128
1164
|
return raw === ' ' ? ' ' : safeString(raw, 80);
|
|
@@ -1626,8 +1662,9 @@ function serializeDevice(device, options = {}) {
|
|
|
1626
1662
|
byteLength: device.latestAudioFrame.byteLength
|
|
1627
1663
|
}
|
|
1628
1664
|
: null,
|
|
1629
|
-
latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
|
|
1630
|
-
|
|
1665
|
+
latestAudioStatus: device.latestAudioStatus ? { ...device.latestAudioStatus } : null,
|
|
1666
|
+
clientUpdateProof: device.clientUpdateProof ? { ...device.clientUpdateProof } : null,
|
|
1667
|
+
udp: device.udp ? { ...device.udp } : { enabled: false, state: 'tcp-only', ready: false },
|
|
1631
1668
|
latestTask: device.latestTask ? { ...device.latestTask } : null,
|
|
1632
1669
|
synthetic: device.synthetic === true,
|
|
1633
1670
|
recentTasks: Array.isArray(device.recentTasks)
|
|
@@ -4427,8 +4464,9 @@ export function createRemoteHub(options = {}) {
|
|
|
4427
4464
|
liveCapturePause: existing?.liveCapturePause || null,
|
|
4428
4465
|
activeAudioStream: null,
|
|
4429
4466
|
latestAudioFrame: null,
|
|
4430
|
-
latestAudioStatus: null,
|
|
4431
|
-
|
|
4467
|
+
latestAudioStatus: null,
|
|
4468
|
+
clientUpdateProof: null,
|
|
4469
|
+
udp: {
|
|
4432
4470
|
enabled: capabilities.udpP2p === true,
|
|
4433
4471
|
state: 'tcp-only',
|
|
4434
4472
|
ready: false,
|
|
@@ -7441,7 +7479,7 @@ export function createRemoteHub(options = {}) {
|
|
|
7441
7479
|
device.counters.statusReceived += 1;
|
|
7442
7480
|
emitRemoteEvent('RemoteDeviceStatus', device);
|
|
7443
7481
|
break;
|
|
7444
|
-
case 'command.result':
|
|
7482
|
+
case 'command.result':
|
|
7445
7483
|
case 'command.error':
|
|
7446
7484
|
device.counters.commandResultsReceived += 1;
|
|
7447
7485
|
{
|
|
@@ -7469,9 +7507,23 @@ export function createRemoteHub(options = {}) {
|
|
|
7469
7507
|
commandId: safeString(message.commandId, 128),
|
|
7470
7508
|
result: message.result ?? null,
|
|
7471
7509
|
error: safeString(message.error, 500)
|
|
7472
|
-
});
|
|
7473
|
-
break;
|
|
7474
|
-
case '
|
|
7510
|
+
});
|
|
7511
|
+
break;
|
|
7512
|
+
case 'client.update.verified': {
|
|
7513
|
+
const receivedAt = new Date().toISOString();
|
|
7514
|
+
const proof = normalizeClientUpdateVerifiedProof(device, message, receivedAt);
|
|
7515
|
+
if (!proof) {
|
|
7516
|
+
logWarn(
|
|
7517
|
+
'remote',
|
|
7518
|
+
`ignored invalid Client update supervisor proof from ${device.deviceName} (${device.deviceId})`
|
|
7519
|
+
);
|
|
7520
|
+
break;
|
|
7521
|
+
}
|
|
7522
|
+
device.clientUpdateProof = proof;
|
|
7523
|
+
emitRemoteEvent('RemoteClientUpdateVerified', device, { proof });
|
|
7524
|
+
break;
|
|
7525
|
+
}
|
|
7526
|
+
case 'input.applied':
|
|
7475
7527
|
case 'input.error':
|
|
7476
7528
|
handleRemoteInputOutcome(device, message, 'main');
|
|
7477
7529
|
break;
|
package/src/server.js
CHANGED
|
@@ -51,8 +51,13 @@ import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-cor
|
|
|
51
51
|
import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
|
|
52
52
|
import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
|
|
53
53
|
import { createHubRuntime } from './runtime/hub-runtime.js';
|
|
54
|
-
import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
|
|
55
|
-
import { roleGuardResponse } from './http/hub-role-guard.js';
|
|
54
|
+
import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
|
|
55
|
+
import { roleGuardResponse } from './http/hub-role-guard.js';
|
|
56
|
+
import {
|
|
57
|
+
clearHubUiSessionCookie,
|
|
58
|
+
createHubUiSessionAuthority,
|
|
59
|
+
serializeHubUiSessionCookie
|
|
60
|
+
} from './http/hub-ui-session.js';
|
|
56
61
|
|
|
57
62
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
58
63
|
const webDistCandidates = [
|
|
@@ -182,9 +187,10 @@ const persistentSessionGcToken = persistentSessionGcEnabled
|
|
|
182
187
|
? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
|
|
183
188
|
: '';
|
|
184
189
|
let connectedDeviceCount = 0;
|
|
185
|
-
let runtimeAccessToken = '';
|
|
186
|
-
let runtimeRefreshToken = '';
|
|
187
|
-
let runtimeAccessTokenExpiresAt = 0;
|
|
190
|
+
let runtimeAccessToken = '';
|
|
191
|
+
let runtimeRefreshToken = '';
|
|
192
|
+
let runtimeAccessTokenExpiresAt = 0;
|
|
193
|
+
const hubUiSessionAuthority = createHubUiSessionAuthority();
|
|
188
194
|
let roleWatchInFlight = false;
|
|
189
195
|
let verifiedLicense = {
|
|
190
196
|
userId: '',
|
|
@@ -485,11 +491,12 @@ function clearPersistedRuntimeSession() {
|
|
|
485
491
|
return writePrivateRuntimeAuthState(state);
|
|
486
492
|
}
|
|
487
493
|
|
|
488
|
-
function clearRuntimeSession() {
|
|
489
|
-
runtimeAccessToken = '';
|
|
490
|
-
runtimeRefreshToken = '';
|
|
491
|
-
runtimeAccessTokenExpiresAt = 0;
|
|
492
|
-
|
|
494
|
+
function clearRuntimeSession() {
|
|
495
|
+
runtimeAccessToken = '';
|
|
496
|
+
runtimeRefreshToken = '';
|
|
497
|
+
runtimeAccessTokenExpiresAt = 0;
|
|
498
|
+
hubUiSessionAuthority.revokeAll();
|
|
499
|
+
runtimeManager.setAuthenticated(false);
|
|
493
500
|
try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
|
|
494
501
|
}
|
|
495
502
|
|
|
@@ -1405,7 +1412,7 @@ function requestHostname(req) {
|
|
|
1405
1412
|
}
|
|
1406
1413
|
}
|
|
1407
1414
|
|
|
1408
|
-
function isTrustedBrowserRequest(req) {
|
|
1415
|
+
function isTrustedBrowserRequest(req) {
|
|
1409
1416
|
const origin = String(req.headers.origin || '').trim();
|
|
1410
1417
|
if (!origin) {
|
|
1411
1418
|
const fetchSite = String(req.headers['sec-fetch-site'] || '').toLowerCase();
|
|
@@ -1422,22 +1429,59 @@ function isTrustedBrowserRequest(req) {
|
|
|
1422
1429
|
const targetHost = requestHostname(req);
|
|
1423
1430
|
return (originHost === targetHost && isPrivateHubHostname(targetHost))
|
|
1424
1431
|
|| (isLoopbackHostname(originHost) && isLoopbackHostname(targetHost));
|
|
1425
|
-
} catch {
|
|
1426
|
-
return false;
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
|
|
1432
|
+
} catch {
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function isSecureHubHttpRequest(req) {
|
|
1438
|
+
return req.socket?.encrypted === true
|
|
1439
|
+
|| String(req.headers['x-forwarded-proto'] || '').trim().toLowerCase() === 'https';
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function currentBoundHubUserId() {
|
|
1443
|
+
return String(
|
|
1444
|
+
runtimeManager.getSnapshot().userId
|
|
1445
|
+
|| readPersistedRuntimeSession()?.user?.id
|
|
1446
|
+
|| ''
|
|
1447
|
+
).trim();
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function isPublicHubApiRequest(req) {
|
|
1451
|
+
const method = String(req.method || 'GET').toUpperCase();
|
|
1452
|
+
const path = String(req.path || '');
|
|
1453
|
+
return (method === 'GET' && (path === '/api/health' || path === '/api/runtime/status' || path === '/api/auth/status'))
|
|
1454
|
+
|| (method === 'POST' && path === '/api/auth/session');
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function isTrustedNativeLoopbackRequest(req) {
|
|
1458
|
+
return isLoopbackAddress(req.socket?.remoteAddress)
|
|
1459
|
+
&& !String(req.headers.origin || '').trim()
|
|
1460
|
+
&& !String(req.headers['sec-fetch-site'] || '').trim();
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function authorizeHubUiRequest(req) {
|
|
1464
|
+
if (process.env.LIVEDESK_TEST_MODE === '1') {
|
|
1465
|
+
return { ok: true, explicitTestMode: true };
|
|
1466
|
+
}
|
|
1467
|
+
if (isTrustedNativeLoopbackRequest(req)) {
|
|
1468
|
+
return { ok: true, nativeLoopback: true };
|
|
1469
|
+
}
|
|
1470
|
+
return hubUiSessionAuthority.authorize(req, currentBoundHubUserId());
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
app.use((req, res, next) => {
|
|
1431
1474
|
if (!isTrustedBrowserRequest(req)) {
|
|
1432
1475
|
res.status(403).json({ ok: false, error: 'untrusted-hub-origin' });
|
|
1433
1476
|
return;
|
|
1434
1477
|
}
|
|
1435
1478
|
const origin = String(req.headers.origin || '').trim();
|
|
1436
1479
|
if (origin) {
|
|
1437
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1438
|
-
res.setHeader('Access-Control-Allow-
|
|
1480
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
1481
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
1482
|
+
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
1439
1483
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
1440
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
1484
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-CSRF');
|
|
1441
1485
|
res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
|
|
1442
1486
|
}
|
|
1443
1487
|
if (req.method === 'OPTIONS') {
|
|
@@ -1446,8 +1490,21 @@ app.use((req, res, next) => {
|
|
|
1446
1490
|
}
|
|
1447
1491
|
next();
|
|
1448
1492
|
});
|
|
1449
|
-
app.use(express.json({ limit: '32mb' }));
|
|
1450
|
-
app.use((req, res, next) => {
|
|
1493
|
+
app.use(express.json({ limit: '32mb' }));
|
|
1494
|
+
app.use((req, res, next) => {
|
|
1495
|
+
if (!req.path.startsWith('/api/') || isPublicHubApiRequest(req)) {
|
|
1496
|
+
next();
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
const authorization = authorizeHubUiRequest(req);
|
|
1500
|
+
if (!authorization.ok) {
|
|
1501
|
+
noStore(res);
|
|
1502
|
+
res.status(authorization.status).json({ ok: false, error: authorization.error });
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
next();
|
|
1506
|
+
});
|
|
1507
|
+
app.use((req, res, next) => {
|
|
1451
1508
|
if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
|
|
1452
1509
|
res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
|
|
1453
1510
|
return;
|
|
@@ -4351,10 +4408,19 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4351
4408
|
res.status(400).json({ ok: false, error: normalized.error });
|
|
4352
4409
|
return;
|
|
4353
4410
|
}
|
|
4354
|
-
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
4355
|
-
try {
|
|
4356
|
-
const user = await verifySupabaseUser(accessToken);
|
|
4357
|
-
|
|
4411
|
+
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
4412
|
+
try {
|
|
4413
|
+
const user = await verifySupabaseUser(accessToken);
|
|
4414
|
+
const boundUserId = currentBoundHubUserId();
|
|
4415
|
+
if (boundUserId && boundUserId !== user.id) {
|
|
4416
|
+
res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
|
|
4417
|
+
return;
|
|
4418
|
+
}
|
|
4419
|
+
if (!boundUserId && !isLoopbackAddress(req.socket?.remoteAddress)) {
|
|
4420
|
+
res.status(403).json({ ok: false, error: 'hub-first-account-binding-must-be-local' });
|
|
4421
|
+
return;
|
|
4422
|
+
}
|
|
4423
|
+
runtimeAccessToken = accessToken;
|
|
4358
4424
|
runtimeRefreshToken = refreshToken;
|
|
4359
4425
|
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
4360
4426
|
runtimeManager.setAuthenticated(true, user.id);
|
|
@@ -4367,10 +4433,25 @@ app.post('/api/auth/session', async (req, res) => {
|
|
|
4367
4433
|
const hostTarget = runtimeRole === 'hub'
|
|
4368
4434
|
? await publishHubHostTargetWithPendingRoleTakeover('session-received')
|
|
4369
4435
|
: { ok: true, active: false };
|
|
4370
|
-
if (runtimeRole === 'hub') {
|
|
4371
|
-
startHubHostTargetLeaseRenewal();
|
|
4372
|
-
}
|
|
4373
|
-
|
|
4436
|
+
if (runtimeRole === 'hub') {
|
|
4437
|
+
startHubHostTargetLeaseRenewal();
|
|
4438
|
+
}
|
|
4439
|
+
const uiSession = hubUiSessionAuthority.issue(user.id);
|
|
4440
|
+
res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
|
|
4441
|
+
secure: isSecureHubHttpRequest(req)
|
|
4442
|
+
}));
|
|
4443
|
+
res.json({
|
|
4444
|
+
ok: true,
|
|
4445
|
+
authenticated: true,
|
|
4446
|
+
persisted,
|
|
4447
|
+
userId: user.id,
|
|
4448
|
+
role: runtimeRole,
|
|
4449
|
+
hostTarget,
|
|
4450
|
+
uiSession: {
|
|
4451
|
+
csrfToken: uiSession.csrfToken,
|
|
4452
|
+
expiresAt: uiSession.expiresAt
|
|
4453
|
+
}
|
|
4454
|
+
});
|
|
4374
4455
|
} catch (error) {
|
|
4375
4456
|
const message = error instanceof Error ? error.message : String(error);
|
|
4376
4457
|
const status = authVerificationHttpStatus(message);
|
|
@@ -4666,14 +4747,15 @@ function startHubHostTargetLeaseRenewal() {
|
|
|
4666
4747
|
hubHostTargetRenewTimer.unref?.();
|
|
4667
4748
|
}
|
|
4668
4749
|
|
|
4669
|
-
app.delete('/api/auth/session', async (
|
|
4750
|
+
app.delete('/api/auth/session', async (req, res) => {
|
|
4670
4751
|
noStore(res);
|
|
4671
4752
|
const hostTarget = runtimeRole === 'hub'
|
|
4672
4753
|
? await clearHubHostTarget('logout')
|
|
4673
4754
|
: { ok: true, active: false };
|
|
4674
|
-
clearRuntimeSession();
|
|
4675
|
-
res.
|
|
4676
|
-
});
|
|
4755
|
+
clearRuntimeSession();
|
|
4756
|
+
res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
|
|
4757
|
+
res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
|
|
4758
|
+
});
|
|
4677
4759
|
|
|
4678
4760
|
app.get('/api/hub/status', (_req, res) => {
|
|
4679
4761
|
noStore(res);
|
|
@@ -5474,13 +5556,20 @@ if (existsSync(webIndexPath)) {
|
|
|
5474
5556
|
});
|
|
5475
5557
|
}
|
|
5476
5558
|
|
|
5477
|
-
httpServer.on('upgrade', (req, socket, head) => {
|
|
5478
|
-
if (!isTrustedBrowserRequest(req)) {
|
|
5479
|
-
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
5480
|
-
socket.destroy();
|
|
5481
|
-
return;
|
|
5482
|
-
}
|
|
5483
|
-
|
|
5559
|
+
httpServer.on('upgrade', (req, socket, head) => {
|
|
5560
|
+
if (!isTrustedBrowserRequest(req)) {
|
|
5561
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
5562
|
+
socket.destroy();
|
|
5563
|
+
return;
|
|
5564
|
+
}
|
|
5565
|
+
const authorization = authorizeHubUiRequest(req);
|
|
5566
|
+
if (!authorization.ok) {
|
|
5567
|
+
const label = authorization.status === 403 ? 'Forbidden' : 'Unauthorized';
|
|
5568
|
+
socket.write(`HTTP/1.1 ${authorization.status} ${label}\r\nConnection: close\r\n\r\n`);
|
|
5569
|
+
socket.destroy();
|
|
5570
|
+
return;
|
|
5571
|
+
}
|
|
5572
|
+
try {
|
|
5484
5573
|
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
5485
5574
|
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
5486
5575
|
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|