@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.
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[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, 15, 50, Number.POSITIVE_INFINITY]) {
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 15 Clients, Pro stops at 50, and Team remains unbounded', () => {
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, 15).allowedDeviceIds.length, 15);
38
- assert.equal(createPlanDeviceAccessSnapshot(manyClients, 15).blockedDeviceIds.length, 36);
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 = 15;
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: {