@livedesk/hub 0.1.3 → 0.1.5
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/README.md +15 -15
- package/package.json +2 -1
- package/src/agents/agent-audit-store.js +93 -0
- package/src/agents/agent-device-scope.js +29 -0
- package/src/agents/agent-manager.js +175 -0
- package/src/agents/agent-permission-store.js +78 -0
- package/src/agents/agent-permissions.js +209 -0
- package/src/agents/agent-settings.js +195 -0
- package/src/agents/agent-tool-registry.js +322 -0
- package/src/agents/codex-agent-runtime.js +720 -0
- package/src/agents/codex-mcp-server.js +100 -0
- package/src/agents/opencode-go-provider.js +260 -0
- package/src/agents/provider-errors.js +12 -0
- package/src/agents/secret-store.js +165 -0
- package/src/captures/capture-store.js +252 -0
- package/src/http/hub-role-guard.js +8 -0
- package/src/live-desk-update.js +457 -0
- package/src/mode4-atlas.js +7 -2
- package/src/remote-hub.js +5648 -5059
- package/src/runtime/hub-runtime-status.js +10 -0
- package/src/runtime/hub-runtime.js +14 -0
- package/src/server.js +3706 -1632
- package/src/settings/effective-device-policy.js +16 -0
- package/src/settings/settings-schema.js +251 -0
- package/src/settings/settings-store.js +77 -0
- package/src/transport/udp-hub-transport.js +281 -0
- package/src/transport/udp-protocol.js +216 -0
- package/src/transport/udp-rendezvous.js +78 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { effectiveDevicePolicy } from './settings-schema.js';
|
|
2
|
+
|
|
3
|
+
export function buildEffectiveDevicePolicy(settings, { deviceId = '', capabilities = {} } = {}) {
|
|
4
|
+
const policy = effectiveDevicePolicy(settings);
|
|
5
|
+
return {
|
|
6
|
+
...policy,
|
|
7
|
+
deviceId: String(deviceId || ''),
|
|
8
|
+
supported: {
|
|
9
|
+
control: capabilities.control !== false,
|
|
10
|
+
clipboardText: capabilities.clipboardText !== false,
|
|
11
|
+
fileTransfer: capabilities.fileTransfer !== false,
|
|
12
|
+
remoteAudio: capabilities.audio === true || capabilities.remoteAudio === true,
|
|
13
|
+
agent: Array.isArray(capabilities.agentTools) && capabilities.agentTools.length > 0
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const SETTINGS_SCHEMA_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
|
|
7
|
+
settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
|
|
8
|
+
connection: {
|
|
9
|
+
usePinToAddDevices: true,
|
|
10
|
+
pinValidityMinutes: 30,
|
|
11
|
+
rotatePinAfterPairing: true,
|
|
12
|
+
allowNewDevices: true,
|
|
13
|
+
approveNewDevices: true,
|
|
14
|
+
startWithComputer: true,
|
|
15
|
+
keepRunningInTray: true,
|
|
16
|
+
showConnectionNotifications: true
|
|
17
|
+
},
|
|
18
|
+
security: {
|
|
19
|
+
accessMode: 'trusted-only',
|
|
20
|
+
requireEncryptedConnections: true,
|
|
21
|
+
allowInternetConnections: false,
|
|
22
|
+
allowLanConnections: true,
|
|
23
|
+
allowUnencryptedLanFallback: false,
|
|
24
|
+
requireNewDeviceApproval: true,
|
|
25
|
+
requireAdministratorForSecurityChanges: true,
|
|
26
|
+
visibleControlIndicator: true,
|
|
27
|
+
lockOnControlEnd: true,
|
|
28
|
+
disconnectIdleControlSessions: true,
|
|
29
|
+
idleControlMinutes: 30,
|
|
30
|
+
keepSessionHistory: true,
|
|
31
|
+
sessionHistoryDays: 30
|
|
32
|
+
},
|
|
33
|
+
control: {
|
|
34
|
+
allowKeyboardMouse: true,
|
|
35
|
+
allowSystemShortcuts: true,
|
|
36
|
+
allowClipboardText: true,
|
|
37
|
+
allowRemoteRestart: false,
|
|
38
|
+
reconnectAfterRemoteRestart: true,
|
|
39
|
+
allowSwitchingMonitors: true,
|
|
40
|
+
showConnectionToolbar: true,
|
|
41
|
+
showRemoteCursor: true,
|
|
42
|
+
openControlOnDoubleClick: true,
|
|
43
|
+
startRemoteAudioWithControl: false,
|
|
44
|
+
fitRemoteScreen: true,
|
|
45
|
+
rememberLastMonitor: true,
|
|
46
|
+
keepControlReadyBetweenPages: true
|
|
47
|
+
},
|
|
48
|
+
wall: {
|
|
49
|
+
performanceMode: 'auto',
|
|
50
|
+
autoStart: true,
|
|
51
|
+
connectedOnly: false,
|
|
52
|
+
keepEmptySlots: true,
|
|
53
|
+
showDeviceStatus: true,
|
|
54
|
+
showPerformanceDetails: false,
|
|
55
|
+
pauseHiddenTiles: true,
|
|
56
|
+
reduceWhenHidden: true,
|
|
57
|
+
autoAdjustTileQuality: true,
|
|
58
|
+
rememberDevicePositions: true
|
|
59
|
+
},
|
|
60
|
+
filesAudio: {
|
|
61
|
+
allowFileTransfer: true,
|
|
62
|
+
allowFolderSync: false,
|
|
63
|
+
askBeforeReceivingFiles: true,
|
|
64
|
+
openReceivedFolder: false,
|
|
65
|
+
notifyTransferComplete: true,
|
|
66
|
+
allowOverwrite: false,
|
|
67
|
+
defaultReceiveFolder: 'Desktop/LiveDeskFiles',
|
|
68
|
+
maxFileSizeBytes: 1024 * 1024 * 1024,
|
|
69
|
+
allowRemoteAudio: true,
|
|
70
|
+
startAudioMuted: false,
|
|
71
|
+
rememberVolume: true,
|
|
72
|
+
automaticallyRecoverAudio: true,
|
|
73
|
+
showAudioTroubleshooting: false,
|
|
74
|
+
rollingBufferEnabled: false,
|
|
75
|
+
recordingQuality: 'standard',
|
|
76
|
+
timelapseIntervalSeconds: 10,
|
|
77
|
+
includeRemoteCursor: true,
|
|
78
|
+
captureSaveLocation: 'LiveDesk Captures',
|
|
79
|
+
captureAutoDelete: 'never'
|
|
80
|
+
},
|
|
81
|
+
agent: {
|
|
82
|
+
enabled: false,
|
|
83
|
+
defaultPermissionMode: 'safe-auto',
|
|
84
|
+
askBeforeDestructive: true,
|
|
85
|
+
allowProcessManagement: true,
|
|
86
|
+
allowServiceManagement: true,
|
|
87
|
+
allowFileChanges: true,
|
|
88
|
+
allowCommandExecution: false,
|
|
89
|
+
allowSoftwareInstallation: false,
|
|
90
|
+
allowPowerActions: false,
|
|
91
|
+
keepAuditHistory: true,
|
|
92
|
+
timeoutMinutes: 10
|
|
93
|
+
},
|
|
94
|
+
advanced: {
|
|
95
|
+
wallFrameMode: 'auto',
|
|
96
|
+
wallFps: 20,
|
|
97
|
+
wallMaxWidth: 960,
|
|
98
|
+
wallMaxHeight: 540,
|
|
99
|
+
wallQuality: 55,
|
|
100
|
+
controlFrameMode: 'mode3-h264-hw',
|
|
101
|
+
controlFps: 30,
|
|
102
|
+
controlMaxWidth: 1920,
|
|
103
|
+
controlMaxHeight: 1080,
|
|
104
|
+
controlQuality: 60,
|
|
105
|
+
transport: 'auto',
|
|
106
|
+
allowPlainLanFallback: false,
|
|
107
|
+
verboseLogs: false,
|
|
108
|
+
frameStatistics: false
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const ENUMS = {
|
|
113
|
+
accessMode: new Set(['trusted-only', 'ask-every-time', 'view-only', 'block-remote-access']),
|
|
114
|
+
performanceMode: new Set(['auto', 'responsive', 'quality', 'custom']),
|
|
115
|
+
permissionMode: new Set(['ask', 'safe-auto', 'full-access', 'custom']),
|
|
116
|
+
wallFrameMode: new Set(['auto', 'mode2-lzo', 'mode3-h264-hw', 'mode4-h264-atlas']),
|
|
117
|
+
controlFrameMode: new Set(['mode3-h264-hw', 'mode5-lzo-delta']),
|
|
118
|
+
transport: new Set(['auto', 'encrypted', 'plain-lan']),
|
|
119
|
+
recordingQuality: new Set(['standard', 'high']),
|
|
120
|
+
captureAutoDelete: new Set(['never', '7-days', '30-days'])
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function booleanValue(value, fallback) {
|
|
124
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function numberValue(value, min, max, fallback) {
|
|
128
|
+
const number = Number(value);
|
|
129
|
+
return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.round(number))) : fallback;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function enumValue(value, allowed, fallback) {
|
|
133
|
+
const normalized = String(value ?? fallback).trim().toLowerCase();
|
|
134
|
+
return allowed.has(normalized) ? normalized : fallback;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function stringValue(value, fallback, maxLength = 600) {
|
|
138
|
+
return typeof value === 'string' ? value.replace(/[\0\r\n]/g, ' ').trim().slice(0, maxLength) : fallback;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeSection(source, defaults, rules = {}) {
|
|
142
|
+
const input = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const [key, fallback] of Object.entries(defaults)) {
|
|
145
|
+
const rule = rules[key];
|
|
146
|
+
if (rule?.type === 'boolean') result[key] = booleanValue(input[key], fallback);
|
|
147
|
+
else if (rule?.type === 'number') result[key] = numberValue(input[key], rule.min, rule.max, fallback);
|
|
148
|
+
else if (rule?.type === 'enum') result[key] = enumValue(input[key], rule.values, fallback);
|
|
149
|
+
else if (rule?.type === 'string') result[key] = stringValue(input[key], fallback, rule.maxLength);
|
|
150
|
+
else result[key] = input[key] === undefined ? fallback : fallback;
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const bools = keys => Object.fromEntries(keys.map(key => [key, { type: 'boolean' }]));
|
|
156
|
+
const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) => [key, { type: 'number', min, max }]));
|
|
157
|
+
|
|
158
|
+
const RULES = {
|
|
159
|
+
connection: {
|
|
160
|
+
...bools(['usePinToAddDevices', 'rotatePinAfterPairing', 'allowNewDevices', 'approveNewDevices', 'startWithComputer', 'keepRunningInTray', 'showConnectionNotifications']),
|
|
161
|
+
...numbers([['pinValidityMinutes', 10, 1440]])
|
|
162
|
+
},
|
|
163
|
+
security: {
|
|
164
|
+
accessMode: { type: 'enum', values: ENUMS.accessMode },
|
|
165
|
+
...bools(['requireEncryptedConnections', 'allowInternetConnections', 'allowLanConnections', 'allowUnencryptedLanFallback', 'requireNewDeviceApproval', 'requireAdministratorForSecurityChanges', 'visibleControlIndicator', 'lockOnControlEnd', 'disconnectIdleControlSessions', 'keepSessionHistory']),
|
|
166
|
+
...numbers([['idleControlMinutes', 5, 1440], ['sessionHistoryDays', 7, 365]])
|
|
167
|
+
},
|
|
168
|
+
control: {
|
|
169
|
+
...bools(['allowKeyboardMouse', 'allowSystemShortcuts', 'allowClipboardText', 'allowRemoteRestart', 'reconnectAfterRemoteRestart', 'allowSwitchingMonitors', 'showConnectionToolbar', 'showRemoteCursor', 'openControlOnDoubleClick', 'startRemoteAudioWithControl', 'fitRemoteScreen', 'rememberLastMonitor', 'keepControlReadyBetweenPages'])
|
|
170
|
+
},
|
|
171
|
+
wall: {
|
|
172
|
+
performanceMode: { type: 'enum', values: ENUMS.performanceMode },
|
|
173
|
+
...bools(['autoStart', 'connectedOnly', 'keepEmptySlots', 'showDeviceStatus', 'showPerformanceDetails', 'pauseHiddenTiles', 'reduceWhenHidden', 'autoAdjustTileQuality', 'rememberDevicePositions'])
|
|
174
|
+
},
|
|
175
|
+
filesAudio: {
|
|
176
|
+
...bools(['allowFileTransfer', 'allowFolderSync', 'askBeforeReceivingFiles', 'openReceivedFolder', 'notifyTransferComplete', 'allowOverwrite', 'allowRemoteAudio', 'startAudioMuted', 'rememberVolume', 'automaticallyRecoverAudio', 'showAudioTroubleshooting', 'rollingBufferEnabled', 'includeRemoteCursor']),
|
|
177
|
+
recordingQuality: { type: 'enum', values: ENUMS.recordingQuality },
|
|
178
|
+
captureAutoDelete: { type: 'enum', values: ENUMS.captureAutoDelete },
|
|
179
|
+
timelapseIntervalSeconds: { type: 'number', min: 5, max: 60 },
|
|
180
|
+
defaultReceiveFolder: { type: 'string', maxLength: 600 },
|
|
181
|
+
captureSaveLocation: { type: 'string', maxLength: 120 },
|
|
182
|
+
maxFileSizeBytes: { type: 'number', min: 1, max: Number.MAX_SAFE_INTEGER }
|
|
183
|
+
},
|
|
184
|
+
agent: {
|
|
185
|
+
defaultPermissionMode: { type: 'enum', values: ENUMS.permissionMode },
|
|
186
|
+
...bools(['enabled', 'askBeforeDestructive', 'allowProcessManagement', 'allowServiceManagement', 'allowFileChanges', 'allowCommandExecution', 'allowSoftwareInstallation', 'allowPowerActions', 'keepAuditHistory']),
|
|
187
|
+
timeoutMinutes: { type: 'number', min: 1, max: 120 }
|
|
188
|
+
},
|
|
189
|
+
advanced: {
|
|
190
|
+
wallFrameMode: { type: 'enum', values: ENUMS.wallFrameMode },
|
|
191
|
+
controlFrameMode: { type: 'enum', values: ENUMS.controlFrameMode },
|
|
192
|
+
transport: { type: 'enum', values: ENUMS.transport },
|
|
193
|
+
...bools(['allowPlainLanFallback', 'verboseLogs', 'frameStatistics']),
|
|
194
|
+
...numbers([['wallFps', 1, 60], ['wallMaxWidth', 320, 3840], ['wallMaxHeight', 180, 2160], ['wallQuality', 20, 95], ['controlFps', 20, 60], ['controlMaxWidth', 640, 3840], ['controlMaxHeight', 360, 2160], ['controlQuality', 20, 95]])
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export function normalizeLiveDeskSettings(value = {}) {
|
|
199
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
200
|
+
const settings = {
|
|
201
|
+
settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
|
|
202
|
+
connection: normalizeSection(source.connection, DEFAULT_LIVEDESK_SETTINGS.connection, RULES.connection),
|
|
203
|
+
security: normalizeSection(source.security, DEFAULT_LIVEDESK_SETTINGS.security, RULES.security),
|
|
204
|
+
control: normalizeSection(source.control, DEFAULT_LIVEDESK_SETTINGS.control, RULES.control),
|
|
205
|
+
wall: normalizeSection(source.wall, DEFAULT_LIVEDESK_SETTINGS.wall, RULES.wall),
|
|
206
|
+
filesAudio: normalizeSection(source.filesAudio, DEFAULT_LIVEDESK_SETTINGS.filesAudio, RULES.filesAudio),
|
|
207
|
+
agent: normalizeSection(source.agent, DEFAULT_LIVEDESK_SETTINGS.agent, RULES.agent),
|
|
208
|
+
advanced: normalizeSection(source.advanced, DEFAULT_LIVEDESK_SETTINGS.advanced, RULES.advanced)
|
|
209
|
+
};
|
|
210
|
+
// Security invariants are enforced at the authority boundary, not just in
|
|
211
|
+
// the browser. Internet access can never use an unencrypted transport.
|
|
212
|
+
if (settings.security.requireEncryptedConnections) {
|
|
213
|
+
settings.security.allowUnencryptedLanFallback = false;
|
|
214
|
+
}
|
|
215
|
+
if (!settings.security.allowInternetConnections) {
|
|
216
|
+
settings.advanced.transport = settings.advanced.transport === 'encrypted' ? 'encrypted' : 'auto';
|
|
217
|
+
}
|
|
218
|
+
if (settings.agent.defaultPermissionMode === 'safe-auto') {
|
|
219
|
+
settings.agent.askBeforeDestructive = true;
|
|
220
|
+
}
|
|
221
|
+
if (![5, 10, 30, 60].includes(settings.filesAudio.timelapseIntervalSeconds)) {
|
|
222
|
+
settings.filesAudio.timelapseIntervalSeconds = 10;
|
|
223
|
+
}
|
|
224
|
+
return settings;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function defaultSettingsPath(dataDir = undefined) {
|
|
228
|
+
return path.join(dataDir || path.join(os.homedir(), '.livedesk'), 'settings.json');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function publicSettings(settings) {
|
|
232
|
+
return normalizeLiveDeskSettings(settings);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function effectiveDevicePolicy(settings = DEFAULT_LIVEDESK_SETTINGS) {
|
|
236
|
+
const normalized = normalizeLiveDeskSettings(settings);
|
|
237
|
+
return {
|
|
238
|
+
settingsSchemaVersion: normalized.settingsSchemaVersion,
|
|
239
|
+
policyRevision: Number(settings?.revision || 0),
|
|
240
|
+
accessMode: normalized.security.accessMode,
|
|
241
|
+
allowInternetConnections: normalized.security.allowInternetConnections,
|
|
242
|
+
allowLanConnections: normalized.security.allowLanConnections,
|
|
243
|
+
requireEncryptedConnections: normalized.security.requireEncryptedConnections,
|
|
244
|
+
allowUnencryptedLanFallback: normalized.security.allowUnencryptedLanFallback,
|
|
245
|
+
allowControl: normalized.security.accessMode !== 'block-remote-access' && normalized.control.allowKeyboardMouse,
|
|
246
|
+
allowClipboardText: normalized.security.accessMode !== 'block-remote-access' && normalized.control.allowClipboardText,
|
|
247
|
+
allowFileTransfer: normalized.security.accessMode !== 'block-remote-access' && normalized.filesAudio.allowFileTransfer,
|
|
248
|
+
allowRemoteAudio: normalized.security.accessMode !== 'block-remote-access' && normalized.filesAudio.allowRemoteAudio,
|
|
249
|
+
allowAgent: normalized.security.accessMode !== 'block-remote-access' && normalized.agent.enabled
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DEFAULT_LIVEDESK_SETTINGS, defaultSettingsPath, normalizeLiveDeskSettings, publicSettings } from './settings-schema.js';
|
|
4
|
+
|
|
5
|
+
export class SettingsConflictError extends Error {
|
|
6
|
+
constructor(settings) {
|
|
7
|
+
super('Settings changed in another session. Reload before saving again.');
|
|
8
|
+
this.name = 'SettingsConflictError';
|
|
9
|
+
this.code = 'settings-revision-conflict';
|
|
10
|
+
this.status = 409;
|
|
11
|
+
this.settings = settings;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function writeJsonAtomic(filePath, value) {
|
|
16
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
17
|
+
const temporaryPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
18
|
+
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
19
|
+
await rename(temporaryPath, filePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class LiveDeskSettingsStore {
|
|
23
|
+
constructor({ dataDir } = {}) {
|
|
24
|
+
this.filePath = defaultSettingsPath(dataDir);
|
|
25
|
+
this.record = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async getRecord() {
|
|
29
|
+
if (this.record) return structuredClone(this.record);
|
|
30
|
+
try {
|
|
31
|
+
const raw = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
32
|
+
this.record = {
|
|
33
|
+
revision: Math.max(0, Number(raw?.revision) || 0),
|
|
34
|
+
updatedAt: String(raw?.updatedAt || ''),
|
|
35
|
+
settings: normalizeLiveDeskSettings(raw?.settings || raw)
|
|
36
|
+
};
|
|
37
|
+
} catch {
|
|
38
|
+
this.record = { revision: 0, updatedAt: '', settings: normalizeLiveDeskSettings(DEFAULT_LIVEDESK_SETTINGS) };
|
|
39
|
+
}
|
|
40
|
+
return structuredClone(this.record);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
getCached() {
|
|
44
|
+
if (!this.record) return DEFAULT_LIVEDESK_SETTINGS;
|
|
45
|
+
return { ...this.record.settings, revision: this.record.revision };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async get() {
|
|
49
|
+
return publicSettings((await this.getRecord()).settings);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async update(patch = {}, expectedRevision = undefined) {
|
|
53
|
+
const current = await this.getRecord();
|
|
54
|
+
if (expectedRevision !== undefined && Number(expectedRevision) !== current.revision) {
|
|
55
|
+
throw new SettingsConflictError({ revision: current.revision, updatedAt: current.updatedAt, settings: publicSettings(current.settings) });
|
|
56
|
+
}
|
|
57
|
+
const source = patch && typeof patch === 'object' && !Array.isArray(patch) ? patch : {};
|
|
58
|
+
const merged = {};
|
|
59
|
+
for (const section of Object.keys(current.settings)) {
|
|
60
|
+
if (section === 'settingsSchemaVersion') continue;
|
|
61
|
+
merged[section] = { ...current.settings[section], ...(source[section] && typeof source[section] === 'object' ? source[section] : {}) };
|
|
62
|
+
}
|
|
63
|
+
const next = {
|
|
64
|
+
revision: current.revision + 1,
|
|
65
|
+
updatedAt: new Date().toISOString(),
|
|
66
|
+
settings: normalizeLiveDeskSettings(merged)
|
|
67
|
+
};
|
|
68
|
+
await writeJsonAtomic(this.filePath, next);
|
|
69
|
+
this.record = next;
|
|
70
|
+
return structuredClone(next);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async reset() {
|
|
74
|
+
const current = await this.getRecord();
|
|
75
|
+
return this.update(normalizeLiveDeskSettings(DEFAULT_LIVEDESK_SETTINGS), current.revision);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import dgram from 'node:dgram';
|
|
3
|
+
import {
|
|
4
|
+
UDP_MAX_DATAGRAM_BYTES,
|
|
5
|
+
UDP_P2P_PROTOCOL,
|
|
6
|
+
UdpFrameReassembler,
|
|
7
|
+
UdpReplayWindow,
|
|
8
|
+
decodeRendezvousMessage,
|
|
9
|
+
decodeUdpPacket,
|
|
10
|
+
encodeRendezvousMessage,
|
|
11
|
+
encodeUdpPacket,
|
|
12
|
+
createUdpSessionKey
|
|
13
|
+
} from './udp-protocol.js';
|
|
14
|
+
|
|
15
|
+
function isEnabled(value, fallback = false) {
|
|
16
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
17
|
+
return /^(1|true|yes|on)$/i.test(String(value).trim());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function safeText(value, max = 160) {
|
|
21
|
+
return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function numberEnv(value, min, max, fallback) {
|
|
25
|
+
const number = Number(value);
|
|
26
|
+
return Number.isFinite(number) ? Math.max(min, Math.min(max, Math.floor(number))) : fallback;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sameEndpoint(left, right) {
|
|
30
|
+
return !!left && !!right && String(left.address) === String(right.address) && Number(left.port) === Number(right.port);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createHubUdpTransport({ env = process.env, logEvent = () => {}, logWarn = () => {} } = {}) {
|
|
34
|
+
const enabled = isEnabled(env.LIVEDESK_UDP_ENABLED, false);
|
|
35
|
+
const preferP2p = isEnabled(env.LIVEDESK_UDP_PREFER_P2P, true);
|
|
36
|
+
const bindHost = safeText(env.LIVEDESK_UDP_BIND_HOST || '0.0.0.0', 128) || '0.0.0.0';
|
|
37
|
+
const requestedPort = numberEnv(env.LIVEDESK_UDP_PORT, 0, 65535, 0);
|
|
38
|
+
const advertiseHost = safeText(env.LIVEDESK_UDP_ADVERTISE_HOST || env.LIVEDESK_REMOTE_PUBLIC_HOST || '', 128);
|
|
39
|
+
const rendezvousHost = safeText(env.LIVEDESK_UDP_RENDEZVOUS_HOST || '', 128);
|
|
40
|
+
const rendezvousPort = numberEnv(env.LIVEDESK_UDP_RENDEZVOUS_PORT, 1, 65535, 5199);
|
|
41
|
+
const punchTimeoutMs = numberEnv(env.LIVEDESK_UDP_PUNCH_TIMEOUT_MS, 500, 10_000, 3000);
|
|
42
|
+
const frameTimeoutMs = numberEnv(env.LIVEDESK_UDP_FRAME_TIMEOUT_MS, 20, 2000, 80);
|
|
43
|
+
const maxPendingFrames = numberEnv(env.LIVEDESK_UDP_MAX_PENDING_FRAMES, 1, 16, 2);
|
|
44
|
+
const trace = isEnabled(env.LIVEDESK_UDP_TRACE, false);
|
|
45
|
+
const socket = dgram.createSocket('udp4');
|
|
46
|
+
const sessions = new Map();
|
|
47
|
+
let frameHandler = () => {};
|
|
48
|
+
let eventHandler = () => {};
|
|
49
|
+
let started = false;
|
|
50
|
+
let boundPort = requestedPort;
|
|
51
|
+
let sequence = crypto.randomInt(1, 0x7fffffff);
|
|
52
|
+
|
|
53
|
+
function emit(type, event = {}) {
|
|
54
|
+
eventHandler(type, event);
|
|
55
|
+
if (trace) logEvent('udp', `${type} ${JSON.stringify({ deviceId: event.deviceId || '', state: event.state || '', reason: event.reason || '' })}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nextSequence() {
|
|
59
|
+
sequence = (sequence + 1) >>> 0;
|
|
60
|
+
return sequence;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sendPacket(session, type, header = {}, payload = Buffer.alloc(0), address = session.peerEndpoint || session.clientEndpoint) {
|
|
64
|
+
if (!address || !session) return false;
|
|
65
|
+
let packet;
|
|
66
|
+
try {
|
|
67
|
+
packet = encodeUdpPacket({ type, sessionId: session.sessionId, key: session.key, sequence: nextSequence(), header, payload });
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logWarn('udp', `UDP packet rejected device=${session.deviceId}: ${error?.message || error}`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
socket.send(packet, address.port, address.address);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sendRendezvousRegister(session) {
|
|
77
|
+
if (!rendezvousHost) return;
|
|
78
|
+
const message = encodeRendezvousMessage({
|
|
79
|
+
type: 'rendezvous.register',
|
|
80
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
81
|
+
roomId: session.sessionId,
|
|
82
|
+
role: 'hub',
|
|
83
|
+
token: session.rendezvousToken
|
|
84
|
+
});
|
|
85
|
+
socket.send(message, rendezvousPort, rendezvousHost);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sessionForPacket(packet, rinfo) {
|
|
89
|
+
if (!Buffer.isBuffer(packet) || packet.length < 7) return null;
|
|
90
|
+
const sessionLength = packet[6];
|
|
91
|
+
if (!sessionLength || packet.length < 27 + sessionLength) return null;
|
|
92
|
+
const sessionId = packet.subarray(27, 27 + sessionLength).toString('utf8');
|
|
93
|
+
const session = [...sessions.values()].find(item => item.sessionId === sessionId);
|
|
94
|
+
if (!session) return null;
|
|
95
|
+
if (!session.clientEndpoint) session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
|
|
96
|
+
if (!sameEndpoint(session.clientEndpoint, rinfo)) return null;
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function onRendezvousPeer(message, rinfo) {
|
|
101
|
+
const session = [...sessions.values()].find(item => item.sessionId === safeText(message.roomId, 160));
|
|
102
|
+
if (!session || !rendezvousHost || Number(rinfo.port) !== rendezvousPort) return;
|
|
103
|
+
const address = safeText(message.host, 128);
|
|
104
|
+
const port = Number(message.port);
|
|
105
|
+
if (!address || !Number.isInteger(port) || port < 1 || port > 65535) return;
|
|
106
|
+
session.peerEndpoint = { address, port };
|
|
107
|
+
session.lastPeerAt = Date.now();
|
|
108
|
+
sendPacket(session, 'probe', { punch: true }, Buffer.alloc(0), session.peerEndpoint);
|
|
109
|
+
emit('UdpPeerEndpointObserved', { deviceId: session.deviceId, endpoint: `${address}:${port}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
socket.on('message', (payload, rinfo) => {
|
|
113
|
+
const rendezvous = decodeRendezvousMessage(payload);
|
|
114
|
+
if (rendezvous?.type === 'rendezvous.peer') {
|
|
115
|
+
onRendezvousPeer(rendezvous, rinfo);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const session = sessionForPacket(payload, rinfo);
|
|
119
|
+
if (!session) return;
|
|
120
|
+
const packet = decodeUdpPacket(payload, session.key);
|
|
121
|
+
if (!packet || packet.sessionId !== session.sessionId || !session.replay.accept(packet.sequence)) return;
|
|
122
|
+
session.lastPacketAt = Date.now();
|
|
123
|
+
if (packet.type === 'probe') {
|
|
124
|
+
session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
|
|
125
|
+
sendPacket(session, 'probe-ack', { observedHost: rinfo.address, observedPort: rinfo.port });
|
|
126
|
+
emit('UdpProbeReceived', { deviceId: session.deviceId });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (packet.type === 'ready') {
|
|
130
|
+
session.ready = true;
|
|
131
|
+
session.clientEndpoint = { address: rinfo.address, port: rinfo.port };
|
|
132
|
+
sendPacket(session, 'pong', { state: 'ready' });
|
|
133
|
+
session.sendControl?.({
|
|
134
|
+
type: 'udp.session.ready',
|
|
135
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
136
|
+
sessionId: session.sessionId,
|
|
137
|
+
transport: 'udp-p2p',
|
|
138
|
+
ready: true,
|
|
139
|
+
endpoint: { host: advertiseHost, port: boundPort }
|
|
140
|
+
});
|
|
141
|
+
emit('UdpSessionReady', { deviceId: session.deviceId, state: 'udp-ready' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (packet.type === 'ping') {
|
|
145
|
+
sendPacket(session, 'pong', { now: Date.now() });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (packet.type !== 'frame') return;
|
|
149
|
+
const complete = session.reassembler.ingest(packet);
|
|
150
|
+
if (!complete) return;
|
|
151
|
+
session.framesReceived += 1;
|
|
152
|
+
frameHandler({
|
|
153
|
+
deviceId: session.deviceId,
|
|
154
|
+
sessionId: session.sessionId,
|
|
155
|
+
header: complete.header,
|
|
156
|
+
payload: complete.payload,
|
|
157
|
+
transport: 'udp-p2p'
|
|
158
|
+
});
|
|
159
|
+
sendPacket(session, 'pong', {
|
|
160
|
+
state: 'frame-ack',
|
|
161
|
+
frameSeq: Number(complete.header?.frameSeq || 0) || 0
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
socket.on('error', error => logWarn('udp', `UDP socket error: ${error?.message || error}`));
|
|
165
|
+
|
|
166
|
+
async function start() {
|
|
167
|
+
if (!enabled || started) return getStatus();
|
|
168
|
+
await new Promise((resolve, reject) => {
|
|
169
|
+
const onError = error => { socket.off('listening', onListening); reject(error); };
|
|
170
|
+
const onListening = () => { socket.off('error', onError); resolve(); };
|
|
171
|
+
socket.once('error', onError);
|
|
172
|
+
socket.once('listening', onListening);
|
|
173
|
+
socket.bind(requestedPort, bindHost);
|
|
174
|
+
});
|
|
175
|
+
started = true;
|
|
176
|
+
boundPort = socket.address().port;
|
|
177
|
+
logEvent('udp', `UDP P2P socket listening on udp://${bindHost}:${boundPort}`);
|
|
178
|
+
return getStatus();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function registerDevice({ deviceId, sessionId: tcpSessionId, sendControl, capabilities } = {}) {
|
|
182
|
+
if (!enabled || capabilities?.udpP2p !== true || !started) return null;
|
|
183
|
+
const id = safeText(deviceId, 160);
|
|
184
|
+
if (!id) return null;
|
|
185
|
+
sessions.get(id)?.sendControl?.({ type: 'udp.session.close', reason: 'replaced' });
|
|
186
|
+
const key = createUdpSessionKey();
|
|
187
|
+
const session = {
|
|
188
|
+
deviceId: id,
|
|
189
|
+
tcpSessionId: safeText(tcpSessionId, 160),
|
|
190
|
+
sessionId: crypto.randomUUID(),
|
|
191
|
+
key,
|
|
192
|
+
keyBase64: key.toString('base64'),
|
|
193
|
+
rendezvousToken: crypto.randomBytes(16).toString('hex'),
|
|
194
|
+
sendControl,
|
|
195
|
+
clientEndpoint: null,
|
|
196
|
+
peerEndpoint: null,
|
|
197
|
+
replay: new UdpReplayWindow(),
|
|
198
|
+
reassembler: new UdpFrameReassembler({ maxPending: maxPendingFrames, timeoutMs: frameTimeoutMs }),
|
|
199
|
+
ready: false,
|
|
200
|
+
createdAt: Date.now(),
|
|
201
|
+
lastPacketAt: 0,
|
|
202
|
+
framesReceived: 0
|
|
203
|
+
};
|
|
204
|
+
sessions.set(id, session);
|
|
205
|
+
sendControl?.({
|
|
206
|
+
type: 'udp.session.offer',
|
|
207
|
+
protocol: UDP_P2P_PROTOCOL,
|
|
208
|
+
sessionId: session.sessionId,
|
|
209
|
+
key: session.keyBase64,
|
|
210
|
+
host: advertiseHost,
|
|
211
|
+
port: boundPort,
|
|
212
|
+
expiresInMs: punchTimeoutMs + 15_000,
|
|
213
|
+
punchTimeoutMs,
|
|
214
|
+
maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
|
|
215
|
+
frameTimeoutMs,
|
|
216
|
+
maxPendingFrames,
|
|
217
|
+
rendezvous: rendezvousHost
|
|
218
|
+
? { host: rendezvousHost, port: rendezvousPort, roomId: session.sessionId, token: session.rendezvousToken }
|
|
219
|
+
: null
|
|
220
|
+
});
|
|
221
|
+
sendRendezvousRegister(session);
|
|
222
|
+
emit('UdpSessionCreated', { deviceId: id, state: 'udp-session-created' });
|
|
223
|
+
return { sessionId: session.sessionId, ready: false };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function unregisterDevice(deviceId, tcpSessionId = '') {
|
|
227
|
+
const id = safeText(deviceId, 160);
|
|
228
|
+
const session = sessions.get(id);
|
|
229
|
+
if (!session || (tcpSessionId && session.tcpSessionId !== tcpSessionId)) return;
|
|
230
|
+
sessions.delete(id);
|
|
231
|
+
emit('UdpSessionClosed', { deviceId: id, reason: 'tcp-session-closed' });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getDeviceStatus(deviceId) {
|
|
235
|
+
const session = sessions.get(safeText(deviceId, 160));
|
|
236
|
+
if (!session) return { enabled, state: enabled ? 'disabled' : 'tcp-only', ready: false };
|
|
237
|
+
return {
|
|
238
|
+
enabled,
|
|
239
|
+
preferred: preferP2p,
|
|
240
|
+
state: session.ready ? 'udp-ready' : 'udp-session-created',
|
|
241
|
+
ready: session.ready,
|
|
242
|
+
sessionId: session.sessionId,
|
|
243
|
+
lastPacketAt: session.lastPacketAt ? new Date(session.lastPacketAt).toISOString() : '',
|
|
244
|
+
framesReceived: session.framesReceived
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function close() {
|
|
249
|
+
sessions.clear();
|
|
250
|
+
if (!started) return;
|
|
251
|
+
await new Promise(resolve => socket.close(() => resolve()));
|
|
252
|
+
started = false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
start,
|
|
257
|
+
close,
|
|
258
|
+
registerDevice,
|
|
259
|
+
unregisterDevice,
|
|
260
|
+
getDeviceStatus,
|
|
261
|
+
getStatus,
|
|
262
|
+
setFrameHandler: handler => { frameHandler = typeof handler === 'function' ? handler : () => {}; },
|
|
263
|
+
setEventHandler: handler => { eventHandler = typeof handler === 'function' ? handler : () => {}; }
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
function getStatus() {
|
|
267
|
+
return {
|
|
268
|
+
enabled,
|
|
269
|
+
preferred: preferP2p,
|
|
270
|
+
started,
|
|
271
|
+
bindHost,
|
|
272
|
+
port: boundPort,
|
|
273
|
+
advertiseHost,
|
|
274
|
+
rendezvousHost,
|
|
275
|
+
rendezvousPort,
|
|
276
|
+
maxDatagramBytes: UDP_MAX_DATAGRAM_BYTES,
|
|
277
|
+
sessionCount: sessions.size,
|
|
278
|
+
readySessionCount: [...sessions.values()].filter(session => session.ready).length
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|