@livedesk/hub 0.1.74 → 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/server.js +9 -2
- package/src/settings/settings-schema.js +3 -2
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { discoverConsoleUpnpGateway, reserveConsoleIceSocket, isPrivateRouterIPv4, isPublicRouterIPv4 } from './console-upnp-gateway.mjs';
|
|
3
|
+
|
|
4
|
+
export const CONSOLE_ROUTER_MAPPING_BOUNDS = Object.freeze({
|
|
5
|
+
owners: 4, candidates: 8, setupMs: 8000, cleanupMs: 1800, leaseSeconds: 120, history: 8
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export function parseRouterMappingCandidate(value) {
|
|
9
|
+
if (typeof value !== 'string' || value.length > 4096) return null;
|
|
10
|
+
const parts = value.trim().replace(/^a=/, '').split(/\s+/);
|
|
11
|
+
const port = Number(parts[5]);
|
|
12
|
+
if (!/^candidate:/i.test(parts[0]) || parts[1] !== '1' || parts[2]?.toLowerCase() !== 'udp'
|
|
13
|
+
|| parts[6] !== 'typ' || !['host', 'srflx'].includes(parts[7])
|
|
14
|
+
|| !(parts[7] === 'host' ? isPrivateRouterIPv4(parts[4]) : isPublicRouterIPv4(parts[4]))
|
|
15
|
+
|| !Number.isInteger(port) || port < 1024 || port > 65535) return null;
|
|
16
|
+
const relatedPort = parts.includes('rport') ? Number(parts[parts.indexOf('rport') + 1]) : 0;
|
|
17
|
+
return { type: parts[7], address: parts[4], port, relatedPort };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function untilAborted(signal) {
|
|
21
|
+
return new Promise(resolve => {
|
|
22
|
+
if (signal.aborted) return resolve();
|
|
23
|
+
signal.addEventListener('abort', resolve, { once: true });
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// One short-lived setup owner per authenticated peer. This never owns a peer,
|
|
28
|
+
// frame, encoder or retransmit queue. Mapping removal does not close ICE.
|
|
29
|
+
export function createConsoleRouterMapping(options = {}) {
|
|
30
|
+
const enabled = options.isEnabled || (() => false);
|
|
31
|
+
const discover = options.discover || discoverConsoleUpnpGateway;
|
|
32
|
+
const reserveSocket = options.reserveSocket || reserveConsoleIceSocket;
|
|
33
|
+
const bounds = CONSOLE_ROUTER_MAPPING_BOUNDS;
|
|
34
|
+
const setupMs = Math.min(bounds.setupMs, Math.max(1, Number(options.setupMs) || bounds.setupMs));
|
|
35
|
+
const cleanupMs = Math.min(bounds.cleanupMs, Math.max(1, Number(options.cleanupMs) || bounds.cleanupMs));
|
|
36
|
+
const owners = new Set();
|
|
37
|
+
const reservedPorts = new Map();
|
|
38
|
+
const history = [];
|
|
39
|
+
let historyEpoch = 0;
|
|
40
|
+
let attempts = 0, mapped = 0, cleanupUnconfirmed = 0;
|
|
41
|
+
|
|
42
|
+
function finish(owner) {
|
|
43
|
+
owners.delete(owner);
|
|
44
|
+
if (reservedPorts.get(owner.reservation) === owner) reservedPorts.delete(owner.reservation);
|
|
45
|
+
if (owner.historyEpoch === historyEpoch) history.push(Object.freeze({ owner: owner.key, state: owner.state, cleanup: owner.cleanup,
|
|
46
|
+
durationMs: Math.max(0, Date.now() - owner.startedAt) }));
|
|
47
|
+
if (history.length > bounds.history) history.shift();
|
|
48
|
+
owner.gateway = null;
|
|
49
|
+
owner.candidates.length = 0;
|
|
50
|
+
owner.finished = true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const own = (entry, owner) => entry && entry.localAddress === owner.gateway.localAddress
|
|
54
|
+
&& entry.port === owner.port && entry.description === owner.description;
|
|
55
|
+
|
|
56
|
+
async function cleanup(owner) {
|
|
57
|
+
if (!owner.attempted || !owner.gateway) return;
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
owner.cleanupTimer = setTimeout(() => controller.abort(), cleanupMs);
|
|
60
|
+
try {
|
|
61
|
+
const entry = await owner.gateway.read(owner.externalPort, controller.signal);
|
|
62
|
+
if (entry === null) { owner.cleanup = 'absent'; return; }
|
|
63
|
+
if (!own(entry, owner)) { owner.cleanup = 'ownership-changed'; return; }
|
|
64
|
+
await owner.gateway.remove(owner.externalPort, controller.signal);
|
|
65
|
+
owner.cleanup = await owner.gateway.read(owner.externalPort, controller.signal) === null ? 'removed' : 'unconfirmed';
|
|
66
|
+
} catch { owner.cleanup = 'unconfirmed'; }
|
|
67
|
+
finally {
|
|
68
|
+
clearTimeout(owner.cleanupTimer);
|
|
69
|
+
owner.cleanupTimer = null;
|
|
70
|
+
if (owner.cleanup === 'unconfirmed') cleanupUnconfirmed += 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function run(owner) {
|
|
75
|
+
attempts += 1;
|
|
76
|
+
const signal = owner.controller.signal;
|
|
77
|
+
owner.setupTimer = setTimeout(() => {
|
|
78
|
+
owner.state = 'setup-expired';
|
|
79
|
+
owner.controller.abort();
|
|
80
|
+
}, setupMs);
|
|
81
|
+
try {
|
|
82
|
+
owner.gateway = await discover(signal);
|
|
83
|
+
signal.throwIfAborted();
|
|
84
|
+
const range = owner.portRange;
|
|
85
|
+
const requestedPort = range?.portRangeBegin || 0;
|
|
86
|
+
const lastPort = range?.portRangeEnd || requestedPort;
|
|
87
|
+
if (!Number.isInteger(requestedPort) || !Number.isInteger(lastPort)
|
|
88
|
+
|| requestedPort < 0 || (requestedPort > 0 && requestedPort < 1024)
|
|
89
|
+
|| (requestedPort === 0 && lastPort !== 0)
|
|
90
|
+
|| lastPort < requestedPort || lastPort > 65535 || lastPort - requestedPort >= 64) {
|
|
91
|
+
throw new Error('router-port-range-invalid');
|
|
92
|
+
}
|
|
93
|
+
for (let port = requestedPort; port <= lastPort; port++) {
|
|
94
|
+
try { owner.socket = await reserveSocket(owner.gateway, signal, port); break; }
|
|
95
|
+
catch (error) { if (error?.code !== 'EADDRINUSE' || port === lastPort) throw error; }
|
|
96
|
+
}
|
|
97
|
+
signal.throwIfAborted();
|
|
98
|
+
owner.port = owner.socket.port;
|
|
99
|
+
owner.externalPort = owner.socket.port;
|
|
100
|
+
owner.reservation = `${owner.gateway.address}:${owner.externalPort}`;
|
|
101
|
+
if (reservedPorts.has(owner.reservation)) { owner.state = 'port-busy'; return; }
|
|
102
|
+
reservedPorts.set(owner.reservation, owner);
|
|
103
|
+
if (await owner.gateway.read(owner.externalPort, signal) !== null) { owner.state = 'port-busy'; return; }
|
|
104
|
+
signal.throwIfAborted();
|
|
105
|
+
owner.attempted = true;
|
|
106
|
+
await owner.gateway.add(owner.externalPort, owner.port, owner.description, signal);
|
|
107
|
+
signal.throwIfAborted();
|
|
108
|
+
const accepted = await owner.gateway.read(owner.externalPort, signal);
|
|
109
|
+
if (!own(accepted, owner) || !accepted.enabled || !Number.isInteger(accepted.leaseSeconds)
|
|
110
|
+
|| accepted.leaseSeconds < 1 || accepted.leaseSeconds > bounds.leaseSeconds) {
|
|
111
|
+
owner.state = 'lease-rejected';
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
owner.state = 'mapped';
|
|
115
|
+
mapped += 1;
|
|
116
|
+
owner.resolveReady({ port: owner.port });
|
|
117
|
+
await untilAborted(signal);
|
|
118
|
+
} catch {
|
|
119
|
+
if (!signal.aborted) owner.state = 'unavailable';
|
|
120
|
+
} finally {
|
|
121
|
+
owner.resolveReady();
|
|
122
|
+
clearTimeout(owner.setupTimer);
|
|
123
|
+
owner.setupTimer = null;
|
|
124
|
+
await cleanup(owner);
|
|
125
|
+
await owner.socket?.close();
|
|
126
|
+
owner.socket = null;
|
|
127
|
+
finish(owner);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createOwner(key, { portRange } = {}) {
|
|
132
|
+
if (!enabled() || owners.size >= bounds.owners || typeof key !== 'string'
|
|
133
|
+
|| !/^[a-f0-9-]{36}:[a-f0-9-]{36}:\d{1,12}$/i.test(key)) return null;
|
|
134
|
+
const owner = { key, historyEpoch, portRange, socket: null, state: 'discovering', cleanup: 'not-needed', finished: false,
|
|
135
|
+
controller: new AbortController(), description: `VuvoDesk-ICE-${randomUUID()}`,
|
|
136
|
+
candidates: [], gateway: null, port: 0, reservation: '', attempted: false,
|
|
137
|
+
setupTimer: null, cleanupTimer: null, promise: null, startedAt: Date.now() };
|
|
138
|
+
const ready = new Promise(resolve => { owner.resolveReady = resolve; });
|
|
139
|
+
owners.add(owner);
|
|
140
|
+
const release = () => {
|
|
141
|
+
if (owner.finished) return owner.promise || Promise.resolve();
|
|
142
|
+
owner.state = 'released';
|
|
143
|
+
owner.controller.abort();
|
|
144
|
+
owner.resolveReady();
|
|
145
|
+
if (!owner.promise) finish(owner);
|
|
146
|
+
return owner.promise || Promise.resolve();
|
|
147
|
+
};
|
|
148
|
+
owner.release = release;
|
|
149
|
+
owner.promise = run(owner);
|
|
150
|
+
return Object.freeze({
|
|
151
|
+
ready,
|
|
152
|
+
async handoff() {
|
|
153
|
+
await owner.socket?.close();
|
|
154
|
+
owner.socket = null;
|
|
155
|
+
if (owner.controller.signal.aborted || owner.finished) return null;
|
|
156
|
+
return owner.state === 'mapped' ? { port: owner.port } : null;
|
|
157
|
+
},
|
|
158
|
+
observe(value) {
|
|
159
|
+
if (owner.finished || owner.controller.signal.aborted || !enabled() || owner.state !== 'mapped') return;
|
|
160
|
+
const candidate = parseRouterMappingCandidate(value);
|
|
161
|
+
if (!candidate || owner.candidates.length >= bounds.candidates
|
|
162
|
+
|| owner.candidates.some(c => c.type === candidate.type && c.address === candidate.address && c.port === candidate.port)) return;
|
|
163
|
+
owner.candidates.push(candidate);
|
|
164
|
+
if (candidate.type === 'srflx'
|
|
165
|
+
&& (candidate.address !== owner.gateway?.publicAddress || candidate.port !== owner.externalPort)) {
|
|
166
|
+
owner.state = 'candidate-mismatch';
|
|
167
|
+
owner.controller.abort();
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
release
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return Object.freeze({
|
|
175
|
+
createOwner,
|
|
176
|
+
releaseAll: ({ forgetHistory = false } = {}) => {
|
|
177
|
+
if (forgetHistory) { historyEpoch += 1; history.length = 0; }
|
|
178
|
+
return Promise.all([...owners].map(owner => owner.release()));
|
|
179
|
+
},
|
|
180
|
+
inspect: () => ({ enabled: Boolean(enabled()), activeOwners: owners.size,
|
|
181
|
+
activeMappings: [...owners].filter(owner => owner.state === 'mapped').length,
|
|
182
|
+
pendingOperations: [...owners].filter(owner => owner.promise).length,
|
|
183
|
+
resourceTimers: [...owners].reduce((n, owner) => n + Number(!!owner.setupTimer) + Number(!!owner.cleanupTimer), 0),
|
|
184
|
+
resourceSockets: [...owners].filter(owner => owner.socket).length,
|
|
185
|
+
attempts, mapped, cleanupUnconfirmed, recent: history.map(value => ({ ...value })) })
|
|
186
|
+
});
|
|
187
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
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('existing mappings and changed ownership are never overwritten or deleted', async () => {
|
|
67
|
+
const { entries, calls, manager } = fixture();
|
|
68
|
+
const foreign = { port: 54443, localAddress: '192.168.0.9', description: 'another-program', leaseSeconds: 0 };
|
|
69
|
+
entries.set(54443, foreign);
|
|
70
|
+
observe(manager.createOwner(key(1)));
|
|
71
|
+
await waitFor(() => manager.inspect().activeOwners === 0);
|
|
72
|
+
assert.deepEqual(calls, [['read', 54443]]);
|
|
73
|
+
assert.equal(entries.get(54443), foreign);
|
|
74
|
+
const a = manager.createOwner(key(2)); observe(a, 54444);
|
|
75
|
+
await waitFor(() => manager.inspect().activeMappings === 1);
|
|
76
|
+
entries.set(54444, { ...foreign, port: 54444 });
|
|
77
|
+
await a.release();
|
|
78
|
+
assert.equal(calls.some(c => c[0] === 'remove'), false);
|
|
79
|
+
assert.equal(manager.inspect().recent.at(-1).cleanup, 'ownership-changed');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a reserved port is mapped before native candidates can start STUN', async () => {
|
|
83
|
+
const f = fixture();
|
|
84
|
+
const a = f.manager.createOwner(key(1));
|
|
85
|
+
await a.ready;
|
|
86
|
+
assert.equal(f.entries.get(54443).port, 54443);
|
|
87
|
+
assert.deepEqual(await a.handoff(), { port: 54443 });
|
|
88
|
+
a.observe(candidate()); a.observe(reflected());
|
|
89
|
+
await a.release();
|
|
90
|
+
assert.deepEqual(f.calls.filter(c => c[0] === 'remove'), [['remove', 54443]]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('workspace cleanup suppresses late history and cancels a stalled discovery', async () => {
|
|
94
|
+
const manager = createConsoleRouterMapping({ isEnabled: () => true,
|
|
95
|
+
discover: signal => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('aborted')), { once: true })) });
|
|
96
|
+
const a = manager.createOwner(key(1)); observe(a);
|
|
97
|
+
await manager.releaseAll({ forgetHistory: true });
|
|
98
|
+
assert.equal(manager.inspect().activeOwners, 0);
|
|
99
|
+
assert.equal(manager.inspect().pendingOperations, 0);
|
|
100
|
+
assert.equal(manager.inspect().resourceTimers, 0);
|
|
101
|
+
assert.deepEqual(manager.inspect().recent, []);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('rejected permanent leases are removed and an expired entry is not renewed', async () => {
|
|
105
|
+
const f = fixture();
|
|
106
|
+
const originalAdd = f.gateway.add;
|
|
107
|
+
f.gateway.add = async (...args) => { await originalAdd(...args); f.entries.get(args[0]).leaseSeconds = 0; };
|
|
108
|
+
observe(f.manager.createOwner(key(1)));
|
|
109
|
+
await waitFor(() => f.manager.inspect().activeOwners === 0);
|
|
110
|
+
assert.equal(f.entries.size, 0);
|
|
111
|
+
assert.equal(f.manager.inspect().recent[0].state, 'lease-rejected');
|
|
112
|
+
f.gateway.add = originalAdd;
|
|
113
|
+
const a = f.manager.createOwner(key(2)); observe(a);
|
|
114
|
+
await waitFor(() => f.manager.inspect().activeMappings === 1);
|
|
115
|
+
f.entries.clear(); // ipTIME can expire a lease; setup owner does not renew it
|
|
116
|
+
await a.release();
|
|
117
|
+
assert.equal(f.manager.inspect().recent.at(-1).cleanup, 'absent');
|
|
118
|
+
assert.equal(f.calls.filter(c => c[0] === 'add').length, 2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('cancellation after an in-flight add removes a late mapping before the slot is reusable', async () => {
|
|
122
|
+
let completeAdd;
|
|
123
|
+
const f = fixture();
|
|
124
|
+
const originalAdd = f.gateway.add;
|
|
125
|
+
f.gateway.add = (...args) => new Promise(resolve => { completeAdd = async () => { await originalAdd(...args); resolve(); }; });
|
|
126
|
+
const a = f.manager.createOwner(key(1)); observe(a);
|
|
127
|
+
await waitFor(() => !!completeAdd);
|
|
128
|
+
const closing = a.release();
|
|
129
|
+
assert.equal(f.manager.inspect().activeOwners, 1);
|
|
130
|
+
await completeAdd(); await closing;
|
|
131
|
+
assert.equal(f.entries.size, 0);
|
|
132
|
+
assert.equal(f.manager.inspect().resourceTimers, 0);
|
|
133
|
+
assert.equal(f.manager.inspect().recent[0].cleanup, 'removed');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('stalled socket reservation expires with zero mutations, and bounds remain exact', async () => {
|
|
137
|
+
const f = fixture();
|
|
138
|
+
const manager = createConsoleRouterMapping({ isEnabled: () => true, discover: async () => f.gateway, setupMs: 15,
|
|
139
|
+
reserveSocket: (_route, signal) => new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true })) });
|
|
140
|
+
const handles = [1, 2, 3, 4].map(n => manager.createOwner(key(n)));
|
|
141
|
+
assert.equal(manager.createOwner(key(5)), null);
|
|
142
|
+
handles.forEach(h => h.observe(candidate(5000, '10.0.0.2')));
|
|
143
|
+
await waitFor(() => manager.inspect().activeOwners === 0);
|
|
144
|
+
assert.equal(f.calls.length, 0);
|
|
145
|
+
assert.equal(manager.inspect().resourceTimers, 0);
|
|
146
|
+
assert.equal(manager.inspect().recent[0].state, 'setup-expired');
|
|
147
|
+
for (let n = 10; n < 30; n++) await manager.createOwner(key(n)).release();
|
|
148
|
+
assert.equal(manager.inspect().recent.length, 8);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('route parsing uses only the current local interface and pins every URL to that gateway', () => {
|
|
152
|
+
const interfaces = { en0: [{ family: 'IPv4', internal: false, address: '192.168.0.4' }] };
|
|
153
|
+
const expected = { address: '192.168.0.1', localAddress: '192.168.0.4' };
|
|
154
|
+
assert.deepEqual(parseDefaultRouterRoute('win32', '{"gateway":"192.168.0.1","address":"192.168.0.4"}', interfaces), expected);
|
|
155
|
+
assert.deepEqual(parseDefaultRouterRoute('linux', '[{"gateway":"192.168.0.1","prefsrc":"192.168.0.4"}]', interfaces), expected);
|
|
156
|
+
assert.deepEqual(parseDefaultRouterRoute('darwin', 'gateway: 192.168.0.1\ninterface: en0', interfaces), expected);
|
|
157
|
+
assert.throws(() => parseDefaultRouterRoute('win32', '{"gateway":"192.168.0.1","address":"192.168.0.8"}', interfaces));
|
|
158
|
+
for (const url of ['http://127.0.0.1/', 'https://192.168.0.1/', 'http://other.invalid/', 'http://user@192.168.0.1/']) {
|
|
159
|
+
assert.throws(() => routerUrl(url, expected.address));
|
|
160
|
+
}
|
|
161
|
+
const doc = '<service><serviceType>urn:schemas-upnp-org:service:WANIPConnection:1</serviceType><controlURL>/control</controlURL></service>';
|
|
162
|
+
assert.equal(parseUpnpService(doc, 'http://192.168.0.1/desc', expected.address).control.href, 'http://192.168.0.1/control');
|
|
163
|
+
assert.throws(() => parseUpnpService(doc.replace('/control', 'http://127.0.0.1/'), 'http://192.168.0.1/desc', expected.address));
|
|
164
|
+
assert.throws(() => parseUpnpService('<!DOCTYPE anything>' + doc, 'http://192.168.0.1/desc', expected.address));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('SOAP reports absence only for 714, sends a temporary UDP lease, and bounds responses', async () => {
|
|
168
|
+
const requests = [];
|
|
169
|
+
let body = '<errorCode>714</errorCode>', status = 500;
|
|
170
|
+
const gateway = createUpnpSoapGateway({ address: '192.168.0.1', localAddress: '192.168.0.4' },
|
|
171
|
+
{ control: new URL('http://192.168.0.1/control'), service: 'urn:schemas-upnp-org:service:WANIPConnection:1' },
|
|
172
|
+
async (url, options) => { requests.push(options); return new Response(body, { status }); });
|
|
173
|
+
const signal = new AbortController().signal;
|
|
174
|
+
assert.equal(await gateway.read(54443, signal), null);
|
|
175
|
+
body = '<errorCode>401</errorCode>';
|
|
176
|
+
await assert.rejects(gateway.read(54443, signal), /router-request-rejected/);
|
|
177
|
+
status = 200; body = '<u:AddPortMappingResponse/>';
|
|
178
|
+
await gateway.add(54443, 50000, 'fixture', signal);
|
|
179
|
+
assert.match(requests.at(-1).body, /<NewInternalPort>50000<\/NewInternalPort>/);
|
|
180
|
+
assert.match(requests.at(-1).body, /<NewProtocol>UDP<\/NewProtocol>/);
|
|
181
|
+
assert.match(requests.at(-1).body, /<NewLeaseDuration>120<\/NewLeaseDuration>/);
|
|
182
|
+
assert.equal(requests.at(-1).redirect, 'error');
|
|
183
|
+
body = 'x'.repeat(8193);
|
|
184
|
+
await assert.rejects(gateway.read(54443, signal), /router-response-too-large/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('trickled candidates wait in order for native setup and duplicates consume no extra reservation', () => {
|
|
188
|
+
const gate = createConsoleIceSetupGate();
|
|
189
|
+
const lan = candidate(50000);
|
|
190
|
+
const publicCandidate = reflected(55000);
|
|
191
|
+
const ipv6 = 'candidate:3 1 UDP 123 2001:4860::1 50000 typ host';
|
|
192
|
+
assert.equal(gate.defer(lan, '0'), true);
|
|
193
|
+
assert.equal(gate.defer(publicCandidate, '0'), true);
|
|
194
|
+
assert.equal(gate.defer(publicCandidate, '0'), true);
|
|
195
|
+
assert.equal(gate.defer(ipv6, '0'), true);
|
|
196
|
+
assert.equal(gate.inspect().candidates, 3);
|
|
197
|
+
const applied = [];
|
|
198
|
+
gate.flush((...args) => applied.push(args), () => true);
|
|
199
|
+
assert.deepEqual(applied, [[lan, '0'], [publicCandidate, '0'], [ipv6, '0']]);
|
|
200
|
+
assert.equal(gate.defer(publicCandidate, '0'), false);
|
|
201
|
+
assert.equal(gate.inspect().candidates, 0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('setup candidate bounds and closed/replaced-owner cleanup retain no deferred callbacks', () => {
|
|
205
|
+
const gate = createConsoleIceSetupGate();
|
|
206
|
+
for (let i = 0; i < 16; i++) gate.defer(reflected(50000 + i), '0');
|
|
207
|
+
assert.equal(gate.defer(reflected(50000), '0'), true);
|
|
208
|
+
assert.throws(() => gate.defer(reflected(50016), '0'), /candidate-invalid/);
|
|
209
|
+
const applied = [];
|
|
210
|
+
gate.flush(value => applied.push(value), () => applied.length === 0);
|
|
211
|
+
assert.equal(applied.length, 1);
|
|
212
|
+
assert.equal(gate.inspect().candidates, 0);
|
|
213
|
+
const cancelled = createConsoleIceSetupGate();
|
|
214
|
+
cancelled.defer(reflected(), '0'); cancelled.close();
|
|
215
|
+
cancelled.flush(() => assert.fail('retired candidate applied'), () => true);
|
|
216
|
+
assert.deepEqual(cancelled.inspect(), { waiting: false, candidates: 0 });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('setup readiness resolves at accepted mapping or cancellation without waiting for lease expiry', async () => {
|
|
220
|
+
const f = fixture();
|
|
221
|
+
const a = f.manager.createOwner(key(1));
|
|
222
|
+
observe(a);
|
|
223
|
+
await a.ready;
|
|
224
|
+
assert.equal(f.manager.inspect().activeMappings, 1);
|
|
225
|
+
await a.release();
|
|
226
|
+
const b = f.manager.createOwner(key(2));
|
|
227
|
+
await b.release(); await b.ready;
|
|
228
|
+
assert.equal(f.manager.inspect().activeOwners, 0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('native preconnect reserves a vacant socket before mapping and hands it off exactly once', async () => {
|
|
232
|
+
const f = fixture();
|
|
233
|
+
let socketOpen = false, closes = 0;
|
|
234
|
+
const manager = createConsoleRouterMapping({ isEnabled: () => true, discover: async () => f.gateway,
|
|
235
|
+
reserveSocket: async () => {
|
|
236
|
+
socketOpen = true;
|
|
237
|
+
return { port: 53000, async close() { if (socketOpen) closes++; socketOpen = false; } };
|
|
238
|
+
} });
|
|
239
|
+
const a = manager.createOwner(key(10), { preconnect: true });
|
|
240
|
+
assert.deepEqual(await a.ready, { port: 53000 });
|
|
241
|
+
assert.equal(socketOpen, true);
|
|
242
|
+
assert.equal(f.entries.get(53000).port, 53000);
|
|
243
|
+
assert.deepEqual(await a.handoff(), { port: 53000 });
|
|
244
|
+
assert.equal(socketOpen, false);
|
|
245
|
+
assert.equal(manager.inspect().resourceSockets, 0);
|
|
246
|
+
a.observe(candidate(53000)); a.observe(reflected(53000));
|
|
247
|
+
await a.release();
|
|
248
|
+
assert.equal(f.entries.size, 0);
|
|
249
|
+
assert.equal(closes, 1);
|
|
250
|
+
assert.equal(manager.inspect().resourceTimers, 0);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('preconnect cancellation drains its reserved socket and a changed STUN port removes its mapping', async () => {
|
|
254
|
+
const f = fixture();
|
|
255
|
+
let closed = 0;
|
|
256
|
+
const manager = createConsoleRouterMapping({ isEnabled: () => true, discover: async () => f.gateway,
|
|
257
|
+
reserveSocket: async () => ({ port: 53000, async close() { closed++; } }) });
|
|
258
|
+
const a = manager.createOwner(key(1), { preconnect: true });
|
|
259
|
+
await a.ready; await a.release();
|
|
260
|
+
assert.equal(closed, 1);
|
|
261
|
+
assert.equal(f.entries.size, 0);
|
|
262
|
+
const b = manager.createOwner(key(2), { preconnect: true });
|
|
263
|
+
await b.ready; await b.handoff();
|
|
264
|
+
b.observe(reflected(53001));
|
|
265
|
+
await waitFor(() => manager.inspect().activeOwners === 0);
|
|
266
|
+
assert.equal(f.entries.size, 0);
|
|
267
|
+
assert.equal(manager.inspect().recent.at(-1).state, 'candidate-mismatch');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('real UDP reservations release on handoff, collision and cancellation during bind', async () => {
|
|
271
|
+
const udpCount = () => process.getActiveResourcesInfo().filter(name => name === 'UDPWrap').length;
|
|
272
|
+
const baseline = udpCount();
|
|
273
|
+
const route = { localAddress: '127.0.0.1' };
|
|
274
|
+
const controller = new AbortController();
|
|
275
|
+
const socket = await reserveConsoleIceSocket(route, controller.signal);
|
|
276
|
+
try {
|
|
277
|
+
assert.ok(socket.port >= 1024 && socket.port <= 65535);
|
|
278
|
+
await assert.rejects(reserveConsoleIceSocket(route, controller.signal, socket.port), { code: 'EADDRINUSE' });
|
|
279
|
+
} finally { await socket.close(); await socket.close(); }
|
|
280
|
+
const successor = await reserveConsoleIceSocket(route, controller.signal, socket.port);
|
|
281
|
+
await successor.close();
|
|
282
|
+
const cancelled = new AbortController();
|
|
283
|
+
const pending = reserveConsoleIceSocket(route, cancelled.signal);
|
|
284
|
+
cancelled.abort();
|
|
285
|
+
await assert.rejects(pending, /router-socket-reservation-unavailable/);
|
|
286
|
+
await waitFor(() => udpCount() === baseline);
|
|
287
|
+
});
|