@livedesk/hub 0.1.74 → 0.1.76

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,323 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { createConsoleRouterMapping, parseRouterMappingCandidate } from './console-router-mapping.mjs';
5
+ import { parseDefaultRouterRoute, parseUpnpService, routerUrl, createUpnpSoapGateway, reserveConsoleIceSocket } from './console-upnp-gateway.mjs';
6
+ import { createConsoleIceSetupGate } from './console-ice-setup-gate.mjs';
7
+
8
+ const key = n => `11111111-1111-4111-8111-111111111111:22222222-2222-4222-8222-222222222222:${n}`;
9
+ const reflected = (port = 54443) => `candidate:2 1 UDP 123 8.8.8.8 ${port} typ srflx raddr 0.0.0.0 rport 0`;
10
+ function observe(handle, port = 54443) { handle.observe(candidate(port)); handle.observe(reflected(port)); }
11
+ const candidate = (port = 54443, address = '192.168.0.4') => `candidate:1 1 UDP 123 ${address} ${port} typ host`;
12
+ async function waitFor(condition) {
13
+ const end = Date.now() + 1000;
14
+ while (!condition() && Date.now() < end) await delay(2);
15
+ assert.ok(condition(), 'condition did not settle');
16
+ }
17
+ function fixture(extra = {}) {
18
+ const entries = new Map();
19
+ const calls = [];
20
+ const gateway = {
21
+ address: '192.168.0.1', localAddress: '192.168.0.4', publicAddress: '8.8.8.8',
22
+ async read(port) { calls.push(['read', port]); return entries.get(port) || null; },
23
+ async add(port, internalPort, description) {
24
+ calls.push(['add', port]);
25
+ entries.set(port, { port: internalPort, description, localAddress: gateway.localAddress, enabled: true, leaseSeconds: 120 });
26
+ },
27
+ async remove(port) { calls.push(['remove', port]); entries.delete(port); },
28
+ ...extra
29
+ };
30
+ let nextPort = 54443;
31
+ const manager = createConsoleRouterMapping({ isEnabled: () => true, discover: async () => gateway,
32
+ reserveSocket: async () => ({ port: nextPort++, async close() {} }) });
33
+ return { entries, calls, gateway, manager };
34
+ }
35
+
36
+ test('mapping is opt-in and only accepts private host UDP candidates on unprivileged ports', async () => {
37
+ let calls = 0;
38
+ const disabled = createConsoleRouterMapping({ discover: async () => { calls++; } });
39
+ assert.equal(disabled.createOwner(key(1)), null);
40
+ assert.equal(calls, 0);
41
+ for (const value of [candidate(80), candidate(70000), candidate(5000, '203.0.113.2'),
42
+ candidate().replace('UDP', 'TCP'), candidate().replace('typ host', 'typ srflx'),
43
+ candidate().replace('typ host', 'typ relay'), 'x'.repeat(4097)]) assert.equal(parseRouterMappingCandidate(value), null);
44
+ assert.deepEqual(parseRouterMappingCandidate(candidate()), { type: 'host', address: '192.168.0.4', port: 54443, relatedPort: 0 });
45
+ });
46
+
47
+ test('exact concurrent owners release only their own actual candidate ports, with no renewal', async () => {
48
+ const { entries, calls, manager } = fixture();
49
+ const a = manager.createOwner(key(1)), b = manager.createOwner(key(2));
50
+ try {
51
+ observe(a); observe(b, 54444);
52
+ await waitFor(() => manager.inspect().activeMappings === 2);
53
+ await a.release();
54
+ a.observe(candidate(54445)); // stale callback after cleanup cannot restart work
55
+ assert.equal(entries.has(54443), false);
56
+ assert.equal(entries.has(54444), true);
57
+ assert.equal(calls.filter(c => c[0] === 'add').length, 2);
58
+ assert.equal(manager.inspect().activeMappings, 1);
59
+ } finally { await manager.releaseAll(); }
60
+ assert.equal(entries.size, 0);
61
+ assert.equal(manager.inspect().activeOwners, 0);
62
+ assert.equal(manager.inspect().resourceTimers, 0);
63
+ assert.doesNotMatch(JSON.stringify(manager.inspect()), /192\.168|VuvoDesk-ICE-/);
64
+ });
65
+
66
+ test('media setup waits for one of four router slots and cancellation cannot start stale work', async () => {
67
+ const { manager, calls } = fixture();
68
+ const active = Array.from({length:4}, (_,n)=>manager.createOwner(key(n+1)));
69
+ try {
70
+ await Promise.all(active.map(owner=>owner.ready));
71
+ const queued = manager.createOwner(key(5), {queue:true});
72
+ const cancelled = manager.createOwner(key(6), {queue:true});
73
+ assert.equal(manager.inspect().activeOwners,4);
74
+ assert.equal(manager.inspect().queuedOwners,2);
75
+ assert.equal(manager.inspect().resourceSockets,4);
76
+ assert.equal(manager.createOwner(key(7)),null, 'legacy caller still has four immediate slots');
77
+ await cancelled.release();
78
+ await active[0].release();
79
+ await queued.ready;
80
+ assert.equal(manager.inspect().activeOwners,4);
81
+ assert.equal(manager.inspect().queuedOwners,0);
82
+ assert.equal(calls.filter(call=>call[0]==='add').length,5);
83
+ } finally { await manager.releaseAll(); }
84
+ assert.equal(manager.inspect().activeOwners,0);
85
+ assert.equal(manager.inspect().resourceSockets,0);
86
+ assert.equal(manager.inspect().resourceTimers,0);
87
+ });
88
+
89
+ test('queued setup is bounded and closing the family starts no queued router operations', async () => {
90
+ const { manager, calls } = fixture();
91
+ const active=Array.from({length:4},(_,n)=>manager.createOwner(key(n+1)));
92
+ await Promise.all(active.map(owner=>owner.ready));
93
+ for(let n=0;n<64;n++) assert.ok(manager.createOwner(key(n+5),{queue:true}));
94
+ assert.equal(manager.createOwner(key(100),{queue:true}),null);
95
+ await manager.releaseAll();
96
+ assert.equal(calls.filter(call=>call[0]==='add').length,4);
97
+ assert.equal(manager.inspect().queuedOwners,0);
98
+ assert.equal(manager.inspect().activeOwners,0);
99
+ assert.equal(manager.inspect().resourceTimers,0);
100
+ });
101
+
102
+ test('existing mappings and changed ownership are never overwritten or deleted', async () => {
103
+ const { entries, calls, manager } = fixture();
104
+ const foreign = { port: 54443, localAddress: '192.168.0.9', description: 'another-program', leaseSeconds: 0 };
105
+ entries.set(54443, foreign);
106
+ observe(manager.createOwner(key(1)));
107
+ await waitFor(() => manager.inspect().activeOwners === 0);
108
+ assert.deepEqual(calls, [['read', 54443]]);
109
+ assert.equal(entries.get(54443), foreign);
110
+ const a = manager.createOwner(key(2)); observe(a, 54444);
111
+ await waitFor(() => manager.inspect().activeMappings === 1);
112
+ entries.set(54444, { ...foreign, port: 54444 });
113
+ await a.release();
114
+ assert.equal(calls.some(c => c[0] === 'remove'), false);
115
+ assert.equal(manager.inspect().recent.at(-1).cleanup, 'ownership-changed');
116
+ });
117
+
118
+ test('a reserved port is mapped before native candidates can start STUN', async () => {
119
+ const f = fixture();
120
+ const a = f.manager.createOwner(key(1));
121
+ await a.ready;
122
+ assert.equal(f.entries.get(54443).port, 54443);
123
+ assert.deepEqual(await a.handoff(), { port: 54443 });
124
+ a.observe(candidate()); a.observe(reflected());
125
+ await a.release();
126
+ assert.deepEqual(f.calls.filter(c => c[0] === 'remove'), [['remove', 54443]]);
127
+ });
128
+
129
+ test('workspace cleanup suppresses late history and cancels a stalled discovery', async () => {
130
+ const manager = createConsoleRouterMapping({ isEnabled: () => true,
131
+ discover: signal => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })) });
132
+ const a = manager.createOwner(key(1)); observe(a);
133
+ await manager.releaseAll({ forgetHistory: true });
134
+ assert.equal(manager.inspect().activeOwners, 0);
135
+ assert.equal(manager.inspect().pendingOperations, 0);
136
+ assert.equal(manager.inspect().resourceTimers, 0);
137
+ assert.deepEqual(manager.inspect().recent, []);
138
+ });
139
+
140
+ test('rejected permanent leases are removed and an expired entry is not renewed', async () => {
141
+ const f = fixture();
142
+ const originalAdd = f.gateway.add;
143
+ f.gateway.add = async (...args) => { await originalAdd(...args); f.entries.get(args[0]).leaseSeconds = 0; };
144
+ observe(f.manager.createOwner(key(1)));
145
+ await waitFor(() => f.manager.inspect().activeOwners === 0);
146
+ assert.equal(f.entries.size, 0);
147
+ assert.equal(f.manager.inspect().recent[0].state, 'lease-rejected');
148
+ f.gateway.add = originalAdd;
149
+ const a = f.manager.createOwner(key(2)); observe(a);
150
+ await waitFor(() => f.manager.inspect().activeMappings === 1);
151
+ f.entries.clear(); // ipTIME can expire a lease; setup owner does not renew it
152
+ await a.release();
153
+ assert.equal(f.manager.inspect().recent.at(-1).cleanup, 'absent');
154
+ assert.equal(f.calls.filter(c => c[0] === 'add').length, 2);
155
+ });
156
+
157
+ test('cancellation after an in-flight add removes a late mapping before the slot is reusable', async () => {
158
+ let completeAdd;
159
+ const f = fixture();
160
+ const originalAdd = f.gateway.add;
161
+ f.gateway.add = (...args) => new Promise(resolve => { completeAdd = async () => { await originalAdd(...args); resolve(); }; });
162
+ const a = f.manager.createOwner(key(1)); observe(a);
163
+ await waitFor(() => !!completeAdd);
164
+ const closing = a.release();
165
+ assert.equal(f.manager.inspect().activeOwners, 1);
166
+ await completeAdd(); await closing;
167
+ assert.equal(f.entries.size, 0);
168
+ assert.equal(f.manager.inspect().resourceTimers, 0);
169
+ assert.equal(f.manager.inspect().recent[0].cleanup, 'removed');
170
+ });
171
+
172
+ test('stalled socket reservation expires with zero mutations, and bounds remain exact', async () => {
173
+ const f = fixture();
174
+ const manager = createConsoleRouterMapping({ isEnabled: () => true, discover: async () => f.gateway, setupMs: 15,
175
+ reserveSocket: (_route, signal) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true })) });
176
+ const handles = [1, 2, 3, 4].map(n => manager.createOwner(key(n)));
177
+ assert.equal(manager.createOwner(key(5)), null);
178
+ handles.forEach(h => h.observe(candidate(5000, '10.0.0.2')));
179
+ await waitFor(() => manager.inspect().activeOwners === 0);
180
+ assert.equal(f.calls.length, 0);
181
+ assert.equal(manager.inspect().resourceTimers, 0);
182
+ assert.equal(manager.inspect().recent[0].state, 'setup-expired');
183
+ for (let n = 10; n < 30; n++) await manager.createOwner(key(n)).release();
184
+ assert.equal(manager.inspect().recent.length, 8);
185
+ });
186
+
187
+ test('route parsing uses only the current local interface and pins every URL to that gateway', () => {
188
+ const interfaces = { en0: [{ family: 'IPv4', internal: false, address: '192.168.0.4' }] };
189
+ const expected = { address: '192.168.0.1', localAddress: '192.168.0.4' };
190
+ assert.deepEqual(parseDefaultRouterRoute('win32', '{"gateway":"192.168.0.1","address":"192.168.0.4"}', interfaces), expected);
191
+ assert.deepEqual(parseDefaultRouterRoute('linux', '[{"gateway":"192.168.0.1","prefsrc":"192.168.0.4"}]', interfaces), expected);
192
+ assert.deepEqual(parseDefaultRouterRoute('darwin', 'gateway: 192.168.0.1\ninterface: en0', interfaces), expected);
193
+ assert.throws(() => parseDefaultRouterRoute('win32', '{"gateway":"192.168.0.1","address":"192.168.0.8"}', interfaces));
194
+ for (const url of ['http://127.0.0.1/', 'https://192.168.0.1/', 'http://other.invalid/', 'http://user@192.168.0.1/']) {
195
+ assert.throws(() => routerUrl(url, expected.address));
196
+ }
197
+ const doc = '<service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType><controlURL>/control</controlURL></service>';
198
+ assert.equal(parseUpnpService(doc, 'http://192.168.0.1/desc', expected.address).control.href, 'http://192.168.0.1/control');
199
+ assert.throws(() => parseUpnpService(doc.replace('/control', 'http://127.0.0.1/'), 'http://192.168.0.1/desc', expected.address));
200
+ assert.throws(() => parseUpnpService('<!DOCTYPE anything>' + doc, 'http://192.168.0.1/desc', expected.address));
201
+ });
202
+
203
+ test('SOAP reports absence only for 714, sends a temporary UDP lease, and bounds responses', async () => {
204
+ const requests = [];
205
+ let body = '<errorCode>714</errorCode>', status = 500;
206
+ const gateway = createUpnpSoapGateway({ address: '192.168.0.1', localAddress: '192.168.0.4' },
207
+ { control: new URL('http://192.168.0.1/control'), service: 'urn:schemas-upnp-org:service:WANIPConnection:1' },
208
+ async (url, options) => { requests.push(options); return new Response(body, { status }); });
209
+ const signal = new AbortController().signal;
210
+ assert.equal(await gateway.read(54443, signal), null);
211
+ body = '<errorCode>401</errorCode>';
212
+ await assert.rejects(gateway.read(54443, signal), /router-request-rejected/);
213
+ status = 200; body = '<u:AddPortMappingResponse/>';
214
+ await gateway.add(54443, 50000, 'fixture', signal);
215
+ assert.match(requests.at(-1).body, /<NewInternalPort>50000<\/NewInternalPort>/);
216
+ assert.match(requests.at(-1).body, /<NewProtocol>UDP<\/NewProtocol>/);
217
+ assert.match(requests.at(-1).body, /<NewLeaseDuration>120<\/NewLeaseDuration>/);
218
+ assert.equal(requests.at(-1).redirect, 'error');
219
+ body = 'x'.repeat(8193);
220
+ await assert.rejects(gateway.read(54443, signal), /router-response-too-large/);
221
+ });
222
+
223
+ test('trickled candidates wait in order for native setup and duplicates consume no extra reservation', () => {
224
+ const gate = createConsoleIceSetupGate();
225
+ const lan = candidate(50000);
226
+ const publicCandidate = reflected(55000);
227
+ const ipv6 = 'candidate:3 1 UDP 123 2001:4860::1 50000 typ host';
228
+ assert.equal(gate.defer(lan, '0'), true);
229
+ assert.equal(gate.defer(publicCandidate, '0'), true);
230
+ assert.equal(gate.defer(publicCandidate, '0'), true);
231
+ assert.equal(gate.defer(ipv6, '0'), true);
232
+ assert.equal(gate.inspect().candidates, 3);
233
+ const applied = [];
234
+ gate.flush((...args) => applied.push(args), () => true);
235
+ assert.deepEqual(applied, [[lan, '0'], [publicCandidate, '0'], [ipv6, '0']]);
236
+ assert.equal(gate.defer(publicCandidate, '0'), false);
237
+ assert.equal(gate.inspect().candidates, 0);
238
+ });
239
+
240
+ test('setup candidate bounds and closed/replaced-owner cleanup retain no deferred callbacks', () => {
241
+ const gate = createConsoleIceSetupGate();
242
+ for (let i = 0; i < 16; i++) gate.defer(reflected(50000 + i), '0');
243
+ assert.equal(gate.defer(reflected(50000), '0'), true);
244
+ assert.throws(() => gate.defer(reflected(50016), '0'), /candidate-invalid/);
245
+ const applied = [];
246
+ gate.flush(value => applied.push(value), () => applied.length === 0);
247
+ assert.equal(applied.length, 1);
248
+ assert.equal(gate.inspect().candidates, 0);
249
+ const cancelled = createConsoleIceSetupGate();
250
+ cancelled.defer(reflected(), '0'); cancelled.close();
251
+ cancelled.flush(() => assert.fail('retired candidate applied'), () => true);
252
+ assert.deepEqual(cancelled.inspect(), { waiting: false, candidates: 0 });
253
+ });
254
+
255
+ test('setup readiness resolves at accepted mapping or cancellation without waiting for lease expiry', async () => {
256
+ const f = fixture();
257
+ const a = f.manager.createOwner(key(1));
258
+ observe(a);
259
+ await a.ready;
260
+ assert.equal(f.manager.inspect().activeMappings, 1);
261
+ await a.release();
262
+ const b = f.manager.createOwner(key(2));
263
+ await b.release(); await b.ready;
264
+ assert.equal(f.manager.inspect().activeOwners, 0);
265
+ });
266
+
267
+ test('native preconnect reserves a vacant socket before mapping and hands it off exactly once', async () => {
268
+ const f = fixture();
269
+ let socketOpen = false, closes = 0;
270
+ const manager = createConsoleRouterMapping({ isEnabled: () => true, discover: async () => f.gateway,
271
+ reserveSocket: async () => {
272
+ socketOpen = true;
273
+ return { port: 53000, async close() { if (socketOpen) closes++; socketOpen = false; } };
274
+ } });
275
+ const a = manager.createOwner(key(10), { preconnect: true });
276
+ assert.deepEqual(await a.ready, { port: 53000 });
277
+ assert.equal(socketOpen, true);
278
+ assert.equal(f.entries.get(53000).port, 53000);
279
+ assert.deepEqual(await a.handoff(), { port: 53000 });
280
+ assert.equal(socketOpen, false);
281
+ assert.equal(manager.inspect().resourceSockets, 0);
282
+ a.observe(candidate(53000)); a.observe(reflected(53000));
283
+ await a.release();
284
+ assert.equal(f.entries.size, 0);
285
+ assert.equal(closes, 1);
286
+ assert.equal(manager.inspect().resourceTimers, 0);
287
+ });
288
+
289
+ test('preconnect cancellation drains its reserved socket and a changed STUN port removes its mapping', async () => {
290
+ const f = fixture();
291
+ let closed = 0;
292
+ const manager = createConsoleRouterMapping({ isEnabled: () => true, discover: async () => f.gateway,
293
+ reserveSocket: async () => ({ port: 53000, async close() { closed++; } }) });
294
+ const a = manager.createOwner(key(1), { preconnect: true });
295
+ await a.ready; await a.release();
296
+ assert.equal(closed, 1);
297
+ assert.equal(f.entries.size, 0);
298
+ const b = manager.createOwner(key(2), { preconnect: true });
299
+ await b.ready; await b.handoff();
300
+ b.observe(reflected(53001));
301
+ await waitFor(() => manager.inspect().activeOwners === 0);
302
+ assert.equal(f.entries.size, 0);
303
+ assert.equal(manager.inspect().recent.at(-1).state, 'candidate-mismatch');
304
+ });
305
+
306
+ test('real UDP reservations release on handoff, collision and cancellation during bind', async () => {
307
+ const udpCount = () => process.getActiveResourcesInfo().filter(name => name === 'UDPWrap').length;
308
+ const baseline = udpCount();
309
+ const route = { localAddress: '127.0.0.1' };
310
+ const controller = new AbortController();
311
+ const socket = await reserveConsoleIceSocket(route, controller.signal);
312
+ try {
313
+ assert.ok(socket.port >= 1024 && socket.port <= 65535);
314
+ await assert.rejects(reserveConsoleIceSocket(route, controller.signal, socket.port), { code: 'EADDRINUSE' });
315
+ } finally { await socket.close(); await socket.close(); }
316
+ const successor = await reserveConsoleIceSocket(route, controller.signal, socket.port);
317
+ await successor.close();
318
+ const cancelled = new AbortController();
319
+ const pending = reserveConsoleIceSocket(route, cancelled.signal);
320
+ cancelled.abort();
321
+ await assert.rejects(pending, /router-socket-reservation-unavailable/);
322
+ await waitFor(() => udpCount() === baseline);
323
+ });
@@ -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
+ }
package/src/server.js CHANGED
@@ -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: {