@livedesk/hub 0.1.73 → 0.1.75
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/console-direct-frame-admission.mjs +60 -12
- package/src/console-direct-frame-admission.test.mjs +57 -0
- package/src/console-direct-ice-evidence.mjs +87 -0
- package/src/console-direct-ice-evidence.test.mjs +59 -0
- package/src/console-direct.js +156 -93
- package/src/console-direct.test.mjs +128 -0
- package/src/console-ice-setup-gate.mjs +30 -0
- package/src/console-router-mapping.mjs +187 -0
- package/src/console-router-mapping.test.mjs +287 -0
- package/src/console-upnp-gateway.mjs +230 -0
- package/src/plan-device-access.test.mjs +10 -4
- package/src/server.js +10 -3
- package/src/settings/settings-schema.js +3 -2
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import dgram from 'node:dgram';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { networkInterfaces, platform } from 'node:os';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
|
|
6
|
+
export function isPrivateRouterIPv4(address) {
|
|
7
|
+
if (isIP(address) !== 4) return false;
|
|
8
|
+
const [a, b] = address.split('.').map(Number);
|
|
9
|
+
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isPublicRouterIPv4(address) {
|
|
13
|
+
if (isIP(address) !== 4 || isPrivateRouterIPv4(address)) return false;
|
|
14
|
+
const [a, b, c] = address.split('.').map(Number);
|
|
15
|
+
return a > 0 && a < 224 && a !== 127 && !(a === 169 && b === 254)
|
|
16
|
+
&& !(a === 100 && b >= 64 && b <= 127)
|
|
17
|
+
&& !(a === 192 && b === 0 && [0, 2].includes(c))
|
|
18
|
+
&& !(a === 198 && [18, 19, 51].includes(b)) && !(a === 203 && b === 0 && c === 113);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function command(file, args, signal) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
execFile(file, args, { signal, timeout: 1800, maxBuffer: 16 * 1024, windowsHide: true },
|
|
24
|
+
(error, stdout) => error ? reject(new Error('router-route-unavailable')) : resolve(stdout));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseDefaultRouterRoute(os, output, interfaces = networkInterfaces()) {
|
|
29
|
+
let address, localAddress;
|
|
30
|
+
if (os === 'win32') {
|
|
31
|
+
const data = JSON.parse(output);
|
|
32
|
+
address = data.gateway;
|
|
33
|
+
localAddress = data.address;
|
|
34
|
+
} else if (os === 'linux') {
|
|
35
|
+
const data = JSON.parse(output)?.[0];
|
|
36
|
+
address = data?.gateway;
|
|
37
|
+
localAddress = data?.prefsrc;
|
|
38
|
+
} else if (os === 'darwin') {
|
|
39
|
+
address = output.match(/^\s*gateway:\s*(\S+)\s*$/m)?.[1];
|
|
40
|
+
const device = output.match(/^\s*interface:\s*(\S+)\s*$/m)?.[1];
|
|
41
|
+
localAddress = interfaces[device]?.find(item => item.family === 'IPv4' && !item.internal)?.address;
|
|
42
|
+
}
|
|
43
|
+
const local = Object.values(interfaces).flat().some(item => (
|
|
44
|
+
item?.family === 'IPv4' && !item.internal && item.address === localAddress
|
|
45
|
+
));
|
|
46
|
+
if (!local || !isPrivateRouterIPv4(address) || !isPrivateRouterIPv4(localAddress)) {
|
|
47
|
+
throw new Error('router-route-unavailable');
|
|
48
|
+
}
|
|
49
|
+
return { address, localAddress };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function resolveRoute(signal) {
|
|
53
|
+
const os = platform();
|
|
54
|
+
let output;
|
|
55
|
+
if (os === 'win32') {
|
|
56
|
+
// Fixed command, no caller text or shell interpolation. Find-NetRoute reads
|
|
57
|
+
// the selected route; it sends no traffic to this reference destination.
|
|
58
|
+
output = await command('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
59
|
+
"$r = Find-NetRoute -RemoteIPAddress 1.1.1.1; $a = $r | Where-Object { $_.IPAddress } | Select-Object -First 1; $g = $r | Where-Object { $_.NextHop } | Select-Object -First 1; @{ address=$a.IPAddress; gateway=$g.NextHop } | ConvertTo-Json -Compress"], signal);
|
|
60
|
+
} else if (os === 'linux') output = await command('ip', ['-j', 'route', 'get', '1.1.1.1'], signal);
|
|
61
|
+
else if (os === 'darwin') output = await command('/sbin/route', ['-n', 'get', '1.1.1.1'], signal);
|
|
62
|
+
else throw new Error('router-platform-unavailable');
|
|
63
|
+
return parseDefaultRouterRoute(os, output);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function routerUrl(value, gateway, base) {
|
|
67
|
+
const url = new URL(value, base);
|
|
68
|
+
if (url.protocol !== 'http:' || url.hostname !== gateway || url.username || url.password || url.hash) {
|
|
69
|
+
throw new Error('router-control-url-invalid');
|
|
70
|
+
}
|
|
71
|
+
return url;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function discoverLocation(route, signal) {
|
|
75
|
+
signal.throwIfAborted();
|
|
76
|
+
const socket = dgram.createSocket('udp4');
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
let settled = false;
|
|
79
|
+
const finish = (error, location) => {
|
|
80
|
+
if (settled) return;
|
|
81
|
+
settled = true;
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
signal.removeEventListener('abort', abort);
|
|
84
|
+
try { socket.close(); } catch { /* no bound UDP handle remains */ }
|
|
85
|
+
error ? reject(error) : resolve(location);
|
|
86
|
+
};
|
|
87
|
+
const abort = () => finish(new Error('router-discovery-cancelled'));
|
|
88
|
+
const timer = setTimeout(() => finish(new Error('router-discovery-unavailable')), 1200);
|
|
89
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
90
|
+
socket.once('error', () => finish(new Error('router-discovery-unavailable')));
|
|
91
|
+
socket.on('message', (message, source) => {
|
|
92
|
+
if (source.address !== route.address || message.length > 8192) return;
|
|
93
|
+
const location = message.toString().match(/^location:\s*(\S+)\s*$/im)?.[1];
|
|
94
|
+
if (!location) return;
|
|
95
|
+
try { finish(null, routerUrl(location, route.address)); } catch { /* ignore unrelated SSDP */ }
|
|
96
|
+
});
|
|
97
|
+
socket.bind(0, route.localAddress, () => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
socket.setMulticastTTL(1);
|
|
100
|
+
socket.send(Buffer.from('M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\nMX: 1\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n'),
|
|
101
|
+
1900, '239.255.255.250', error => { if (error) finish(new Error('router-discovery-unavailable')); });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function upnpField(xml, name) {
|
|
107
|
+
return xml.match(new RegExp(`<(?:[\\w-]+:)?${name}\\b[^>]*>\\s*([^<]*)\\s*</(?:[\\w-]+:)?${name}>`))?.[1]?.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function boundedText(response, limit) {
|
|
111
|
+
const reader = response.body?.getReader();
|
|
112
|
+
if (!reader) throw new Error('router-response-invalid');
|
|
113
|
+
let bytes = 0;
|
|
114
|
+
const chunks = [];
|
|
115
|
+
try {
|
|
116
|
+
while (true) {
|
|
117
|
+
const next = await reader.read();
|
|
118
|
+
if (next.done) break;
|
|
119
|
+
bytes += next.value.byteLength;
|
|
120
|
+
if (bytes > limit) {
|
|
121
|
+
await reader.cancel().catch(() => {});
|
|
122
|
+
throw new Error('router-response-too-large');
|
|
123
|
+
}
|
|
124
|
+
chunks.push(next.value);
|
|
125
|
+
}
|
|
126
|
+
return Buffer.concat(chunks, bytes).toString('utf8');
|
|
127
|
+
} finally { reader.releaseLock(); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseUpnpService(document, location, gateway) {
|
|
131
|
+
if (document.length > 64 * 1024 || /<!DOCTYPE|<!ENTITY/i.test(document)) throw new Error('router-description-invalid');
|
|
132
|
+
const base = upnpField(document, 'URLBase') || location;
|
|
133
|
+
routerUrl(base, gateway);
|
|
134
|
+
for (const match of document.matchAll(/<(?:[\w-]+:)?service>([\s\S]*?)<\/(?:[\w-]+:)?service>/g)) {
|
|
135
|
+
const service = upnpField(match[1], 'serviceType');
|
|
136
|
+
const control = upnpField(match[1], 'controlURL');
|
|
137
|
+
if (/^urn:schemas-upnp-org:service:WAN(?:IP|PPP)Connection:[12]$/.test(service || '') && control) {
|
|
138
|
+
return { service, control: routerUrl(control, gateway, base) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
throw new Error('router-service-unavailable');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createUpnpSoapGateway(route, endpoint, fetchImpl = fetch) {
|
|
145
|
+
const controlUrl = routerUrl(endpoint.control, route.address);
|
|
146
|
+
async function soap(action, fields, signal) {
|
|
147
|
+
signal.throwIfAborted();
|
|
148
|
+
const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
149
|
+
const xml = Object.entries(fields).map(([key, value]) => `<${key}>${escape(value)}</${key}>`).join('');
|
|
150
|
+
const response = await fetchImpl(controlUrl, {
|
|
151
|
+
method: 'POST', redirect: 'error', signal,
|
|
152
|
+
headers: { 'Content-Type': 'text/xml; charset="utf-8"', SOAPAction: `"${endpoint.service}#${action}"` },
|
|
153
|
+
body: `<?xml version="1.0"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:${action} xmlns:u="${endpoint.service}">${xml}</u:${action}></s:Body></s:Envelope>`
|
|
154
|
+
});
|
|
155
|
+
const body = await boundedText(response, 8192);
|
|
156
|
+
const error = Number(upnpField(body, 'errorCode') || 0);
|
|
157
|
+
if (error === 714 && action === 'GetSpecificPortMappingEntry') return null;
|
|
158
|
+
if (!response.ok || error || !body.includes(`${action}Response`)) throw new Error('router-request-rejected');
|
|
159
|
+
return body;
|
|
160
|
+
}
|
|
161
|
+
const identity = port => ({ NewRemoteHost: '', NewExternalPort: port, NewProtocol: 'UDP' });
|
|
162
|
+
return {
|
|
163
|
+
...route,
|
|
164
|
+
async requirePublicWan(signal) {
|
|
165
|
+
const address = upnpField(await soap('GetExternalIPAddress', {}, signal), 'NewExternalIPAddress');
|
|
166
|
+
if (!isPublicRouterIPv4(address)) {
|
|
167
|
+
throw new Error('router-public-address-unavailable');
|
|
168
|
+
}
|
|
169
|
+
return address;
|
|
170
|
+
},
|
|
171
|
+
async read(port, signal) {
|
|
172
|
+
const body = await soap('GetSpecificPortMappingEntry', identity(port), signal);
|
|
173
|
+
return body === null ? null : {
|
|
174
|
+
localAddress: upnpField(body, 'NewInternalClient'),
|
|
175
|
+
port: Number(upnpField(body, 'NewInternalPort')),
|
|
176
|
+
description: upnpField(body, 'NewPortMappingDescription'),
|
|
177
|
+
leaseSeconds: Number(upnpField(body, 'NewLeaseDuration')),
|
|
178
|
+
enabled: upnpField(body, 'NewEnabled') === '1'
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
async add(port, internalPort, description, signal) {
|
|
182
|
+
await soap('AddPortMapping', { ...identity(port), NewInternalPort: internalPort,
|
|
183
|
+
NewInternalClient: route.localAddress, NewEnabled: 1,
|
|
184
|
+
NewPortMappingDescription: description, NewLeaseDuration: 120 }, signal);
|
|
185
|
+
},
|
|
186
|
+
async remove(port, signal) { await soap('DeletePortMapping', identity(port), signal); }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function discoverConsoleUpnpGateway(signal) {
|
|
191
|
+
const route = await resolveRoute(signal);
|
|
192
|
+
const location = await discoverLocation(route, signal);
|
|
193
|
+
const response = await fetch(location, { redirect: 'error', signal });
|
|
194
|
+
if (!response.ok) throw new Error('router-description-unavailable');
|
|
195
|
+
const endpoint = parseUpnpService(await boundedText(response, 64 * 1024), location, route.address);
|
|
196
|
+
const gateway = createUpnpSoapGateway(route, endpoint);
|
|
197
|
+
gateway.publicAddress = await gateway.requirePublicWan(signal);
|
|
198
|
+
return gateway;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Reserve an OS-assigned socket before exposing a port at the router. The
|
|
202
|
+
// caller closes this exact reservation immediately before native ICE binds it.
|
|
203
|
+
export function reserveConsoleIceSocket(route, signal, port = 0) {
|
|
204
|
+
signal.throwIfAborted();
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const socket = dgram.createSocket('udp4');
|
|
207
|
+
let settled = false;
|
|
208
|
+
let closePromise;
|
|
209
|
+
const close = () => closePromise ||= new Promise(done => {
|
|
210
|
+
try { socket.close(done); } catch { done(); }
|
|
211
|
+
});
|
|
212
|
+
const failed = error => {
|
|
213
|
+
if (settled) return;
|
|
214
|
+
settled = true;
|
|
215
|
+
signal.removeEventListener('abort', failed);
|
|
216
|
+
void close();
|
|
217
|
+
const failure = new Error('router-socket-reservation-unavailable');
|
|
218
|
+
if (error?.code === 'EADDRINUSE') failure.code = 'EADDRINUSE';
|
|
219
|
+
reject(failure);
|
|
220
|
+
};
|
|
221
|
+
socket.on('error', failed);
|
|
222
|
+
signal.addEventListener('abort', failed, { once: true });
|
|
223
|
+
socket.bind(port, route.localAddress, () => {
|
|
224
|
+
if (settled || signal.aborted) { void close(); return; }
|
|
225
|
+
settled = true;
|
|
226
|
+
signal.removeEventListener('abort', failed);
|
|
227
|
+
resolve({ port: socket.address().port, close });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
@@ -15,7 +15,7 @@ const clients = [6, 3, 1, 5, 2, 4].map(slotNumber => ({
|
|
|
15
15
|
}));
|
|
16
16
|
|
|
17
17
|
test('Hub and browser choose the same stable Free, Plus, Pro, and Team device owners', () => {
|
|
18
|
-
for (const limit of [5,
|
|
18
|
+
for (const limit of [5, 20, 50, Number.POSITIVE_INFINITY]) {
|
|
19
19
|
assert.deepEqual(
|
|
20
20
|
stableHubPlanAllowedDeviceIds(clients, limit),
|
|
21
21
|
stableBrowserPlanAllowedDeviceIds(clients, limit)
|
|
@@ -27,15 +27,21 @@ test('Hub and browser choose the same stable Free, Plus, Pro, and Team device ow
|
|
|
27
27
|
);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
test('Plus stops at
|
|
30
|
+
test('Plus stops at 20 Clients, Pro stops at 50, and Team remains unbounded', () => {
|
|
31
31
|
const manyClients = Array.from({ length: 51 }, (_, index) => ({
|
|
32
32
|
deviceId: `client-${index + 1}`,
|
|
33
33
|
slotNumber: index + 1,
|
|
34
34
|
connected: true
|
|
35
35
|
}));
|
|
36
36
|
|
|
37
|
-
assert.equal(createPlanDeviceAccessSnapshot(manyClients,
|
|
38
|
-
assert.equal(createPlanDeviceAccessSnapshot(manyClients,
|
|
37
|
+
assert.equal(createPlanDeviceAccessSnapshot(manyClients, 20).allowedDeviceIds.length, 20);
|
|
38
|
+
assert.equal(createPlanDeviceAccessSnapshot(manyClients, 20).blockedDeviceIds.length, 31);
|
|
39
|
+
const plus = createPlanDeviceAccessSnapshot(manyClients, 20);
|
|
40
|
+
assert.equal(plus.allowedDeviceIdSet.has('client-20'), true);
|
|
41
|
+
assert.equal(plus.allowedDeviceIdSet.has('client-21'), false);
|
|
42
|
+
assert.deepEqual(partitionPlanDeviceIds(['client-20', 'client-21'], plus), {
|
|
43
|
+
allowedDeviceIds: ['client-20'], blockedDeviceIds: ['client-21']
|
|
44
|
+
});
|
|
39
45
|
assert.equal(createPlanDeviceAccessSnapshot(manyClients, 50).allowedDeviceIds.length, 50);
|
|
40
46
|
assert.deepEqual(createPlanDeviceAccessSnapshot(manyClients, 50).blockedDeviceIds, ['client-51']);
|
|
41
47
|
assert.equal(
|
package/src/server.js
CHANGED
|
@@ -178,7 +178,7 @@ const atlasPool = new Mode4AtlasPool({
|
|
|
178
178
|
const inputClients = new Set();
|
|
179
179
|
const audioClients = new Set();
|
|
180
180
|
const FREE_DEVICE_LIMIT = 5;
|
|
181
|
-
const PLUS_DEVICE_LIMIT =
|
|
181
|
+
const PLUS_DEVICE_LIMIT = 20;
|
|
182
182
|
const PRO_DEVICE_LIMIT = 50;
|
|
183
183
|
const LICENSE_VERIFY_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
184
184
|
const ROLE_TRANSITION_EXIT_CODE = 43;
|
|
@@ -1880,6 +1880,9 @@ hubConsoleDirect = createHubConsoleDirect({
|
|
|
1880
1880
|
deviceId: runtimeDeviceId,
|
|
1881
1881
|
httpBaseUrl: `http://127.0.0.1:${httpPort}`,
|
|
1882
1882
|
stunUrls: hubConsoleDirectStunUrls,
|
|
1883
|
+
icePortRange: process.env.LIVEDESK_CONSOLE_ICE_PORT_RANGE,
|
|
1884
|
+
isRouterMappingEnabled: () => runtimeRole === 'hub'
|
|
1885
|
+
&& liveDeskSettingsStore.getCached()?.connection?.temporaryRouterMapping === true,
|
|
1883
1886
|
getAccessToken: () => getRuntimeAccessToken(),
|
|
1884
1887
|
getWorkspaceAccess: () => runtimeWorkspaceAccess,
|
|
1885
1888
|
consoleProxyToken
|
|
@@ -5152,7 +5155,10 @@ app.patch('/api/settings', async (req, res) => {
|
|
|
5152
5155
|
const revision = body.revision === undefined ? undefined : Number(body.revision);
|
|
5153
5156
|
const patch = { ...body };
|
|
5154
5157
|
delete patch.revision;
|
|
5155
|
-
const record = await liveDeskSettingsStore.update(patch, revision);
|
|
5158
|
+
const record = await liveDeskSettingsStore.update(patch, revision);
|
|
5159
|
+
if (patch.connection?.temporaryRouterMapping === false) {
|
|
5160
|
+
await hubConsoleDirect?.releaseRouterMappings();
|
|
5161
|
+
}
|
|
5156
5162
|
if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
|
|
5157
5163
|
await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
|
|
5158
5164
|
}
|
|
@@ -7811,7 +7817,8 @@ function shutdownHub(signal) {
|
|
|
7811
7817
|
forceCloseTimer.unref?.();
|
|
7812
7818
|
|
|
7813
7819
|
const cleanupBudgetMs = Math.max(500, hubShutdownTimeoutMs - hubShutdownGraceMs - 500);
|
|
7814
|
-
await Promise.all([
|
|
7820
|
+
await Promise.all([
|
|
7821
|
+
boundedShutdownStep('temporary router mappings removed', () => hubConsoleDirect?.releaseRouterMappings(), cleanupBudgetMs),
|
|
7815
7822
|
boundedShutdownStep('atlas pool closed', () => atlasPool.close(), cleanupBudgetMs),
|
|
7816
7823
|
boundedShutdownStep('host target lease cleared', () => clearHubHostTarget(`process-${String(signal).toLowerCase()}`), Math.min(3_000, cleanupBudgetMs)),
|
|
7817
7824
|
boundedShutdownStep('remote transport closed', () => remoteHub.close(), cleanupBudgetMs)
|
|
@@ -13,7 +13,8 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
|
|
|
13
13
|
approveNewDevices: true,
|
|
14
14
|
startWithComputer: true,
|
|
15
15
|
keepRunningInTray: true,
|
|
16
|
-
showConnectionNotifications: true,
|
|
16
|
+
showConnectionNotifications: true,
|
|
17
|
+
temporaryRouterMapping: false,
|
|
17
18
|
showThisComputer: false
|
|
18
19
|
},
|
|
19
20
|
security: {
|
|
@@ -180,7 +181,7 @@ export function migrateLiveDeskSettings(value = {}) {
|
|
|
180
181
|
|
|
181
182
|
const RULES = {
|
|
182
183
|
connection: {
|
|
183
|
-
...bools(['usePinToAddDevices', 'rotatePinAfterPairing', 'allowNewDevices', 'approveNewDevices', 'startWithComputer', 'keepRunningInTray', 'showConnectionNotifications', 'showThisComputer']),
|
|
184
|
+
...bools(['usePinToAddDevices', 'rotatePinAfterPairing', 'allowNewDevices', 'approveNewDevices', 'startWithComputer', 'keepRunningInTray', 'showConnectionNotifications', 'temporaryRouterMapping', 'showThisComputer']),
|
|
184
185
|
...numbers([['pinValidityMinutes', 10, 1440]])
|
|
185
186
|
},
|
|
186
187
|
security: {
|