@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.
- 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 +267 -134
- package/src/console-direct.test.mjs +244 -1
- package/src/console-ice-setup-gate.mjs +30 -0
- package/src/console-router-mapping.mjs +206 -0
- package/src/console-router-mapping.test.mjs +323 -0
- package/src/console-upnp-gateway.mjs +230 -0
- package/src/server.js +9 -2
- package/src/settings/settings-schema.js +3 -2
package/package.json
CHANGED
|
@@ -3,7 +3,8 @@ export const CONSOLE_MEDIA_ADMISSION = Object.freeze({
|
|
|
3
3
|
peerBufferedBytes: 1024 * 1024,
|
|
4
4
|
bufferedAgeMs: 120,
|
|
5
5
|
maxStreams: 64,
|
|
6
|
-
maxMetadataBytes: 64 * 1024
|
|
6
|
+
maxMetadataBytes: 64 * 1024,
|
|
7
|
+
maxSendBatches: 16
|
|
7
8
|
});
|
|
8
9
|
|
|
9
10
|
function h264Identity(packet) {
|
|
@@ -31,13 +32,44 @@ function h264Identity(packet) {
|
|
|
31
32
|
// timer, task, or outbound queue; every decision precedes wire fragmentation.
|
|
32
33
|
export function createConsoleFrameAdmission() {
|
|
33
34
|
const streams = new Map();
|
|
34
|
-
|
|
35
|
+
const sendBatches = [];
|
|
36
|
+
let accountedBytes = 0;
|
|
35
37
|
let droppedFrames = 0;
|
|
36
38
|
let recoveredStreams = 0;
|
|
39
|
+
const flow = { inputFrames: 0, inputKeyFrames: 0, admittedFrames: 0,
|
|
40
|
+
laneByteDrops: 0, peerByteDrops: 0, ageDrops: 0, dependentDrops: 0 };
|
|
41
|
+
let lastInputAt = null, lastKeyAt = null, lastAdmittedAt = null;
|
|
42
|
+
function sent(bytes, now) {
|
|
43
|
+
if (!(bytes > 0)) return;
|
|
44
|
+
accountedBytes += bytes;
|
|
45
|
+
if (sendBatches.length >= CONSOLE_MEDIA_ADMISSION.maxSendBatches) {
|
|
46
|
+
// Coalesce at the newer end using the older timestamp: bounds remain
|
|
47
|
+
// conservative, never younger than the bytes whose age we protect.
|
|
48
|
+
const last = sendBatches.at(-1);
|
|
49
|
+
last.bytes += bytes;
|
|
50
|
+
} else sendBatches.push({ bytes, at: now });
|
|
51
|
+
}
|
|
52
|
+
function oldestBufferedAt(bufferedBytes, now) {
|
|
53
|
+
if (bufferedBytes > accountedBytes) sent(bufferedBytes - accountedBytes, now);
|
|
54
|
+
let drained = Math.max(0, accountedBytes - bufferedBytes);
|
|
55
|
+
while (drained > 0 && sendBatches.length > 0) {
|
|
56
|
+
const oldest = sendBatches[0];
|
|
57
|
+
const consumed = Math.min(drained, oldest.bytes);
|
|
58
|
+
oldest.bytes -= consumed;
|
|
59
|
+
drained -= consumed;
|
|
60
|
+
accountedBytes -= consumed;
|
|
61
|
+
if (oldest.bytes === 0) sendBatches.shift();
|
|
62
|
+
}
|
|
63
|
+
return sendBatches[0]?.at ?? now;
|
|
64
|
+
}
|
|
37
65
|
return {
|
|
66
|
+
sent,
|
|
38
67
|
admit(packet, { laneBufferedBytes, peerBufferedBytes, now }) {
|
|
39
68
|
const identity = h264Identity(packet);
|
|
40
69
|
if (!identity) return 'unmanaged';
|
|
70
|
+
flow.inputFrames += 1;
|
|
71
|
+
lastInputAt = now;
|
|
72
|
+
if (identity.key) { flow.inputKeyFrames += 1; lastKeyAt = now; }
|
|
41
73
|
let state = streams.get(identity.stream);
|
|
42
74
|
if (!state) {
|
|
43
75
|
if (streams.size >= CONSOLE_MEDIA_ADMISSION.maxStreams) return 'capacity';
|
|
@@ -47,34 +79,50 @@ export function createConsoleFrameAdmission() {
|
|
|
47
79
|
state.owner = identity.owner;
|
|
48
80
|
state.awaitingKey = true;
|
|
49
81
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
&&
|
|
57
|
-
if (
|
|
82
|
+
const oldestAt = oldestBufferedAt(Math.max(0, laneBufferedBytes), now);
|
|
83
|
+
const laneBytePressure = laneBufferedBytes > 0
|
|
84
|
+
&& laneBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.laneBufferedBytes;
|
|
85
|
+
const peerBytePressure = peerBufferedBytes > 0
|
|
86
|
+
&& peerBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.peerBufferedBytes;
|
|
87
|
+
const agePressure = laneBufferedBytes > 0
|
|
88
|
+
&& now - oldestAt >= CONSOLE_MEDIA_ADMISSION.bufferedAgeMs;
|
|
89
|
+
if (laneBytePressure || peerBytePressure || agePressure || (state.awaitingKey && !identity.key)) {
|
|
90
|
+
// Exclusive reasons distinguish the original pressure from subsequent
|
|
91
|
+
// dependent deltas. Keep only scalar evidence, never frames or timers.
|
|
92
|
+
if (laneBytePressure) flow.laneByteDrops += 1;
|
|
93
|
+
else if (peerBytePressure) flow.peerByteDrops += 1;
|
|
94
|
+
else if (agePressure) flow.ageDrops += 1;
|
|
95
|
+
else flow.dependentDrops += 1;
|
|
58
96
|
state.awaitingKey = true;
|
|
59
97
|
droppedFrames += 1;
|
|
60
98
|
return 'drop';
|
|
61
99
|
}
|
|
62
100
|
if (state.awaitingKey) recoveredStreams += 1;
|
|
63
101
|
state.awaitingKey = false;
|
|
102
|
+
flow.admittedFrames += 1;
|
|
103
|
+
lastAdmittedAt = now;
|
|
64
104
|
return 'send';
|
|
65
105
|
},
|
|
66
|
-
inspect() {
|
|
106
|
+
inspect(now = performance.now()) {
|
|
107
|
+
const age = at => at === null ? null : Math.max(0, now - at);
|
|
67
108
|
return {
|
|
109
|
+
...flow,
|
|
110
|
+
lastInputAgeMs: age(lastInputAt),
|
|
111
|
+
lastKeyAgeMs: age(lastKeyAt),
|
|
112
|
+
lastAdmittedAgeMs: age(lastAdmittedAt),
|
|
68
113
|
streamOwners: streams.size,
|
|
69
114
|
awaitingKeyStreams: [...streams.values()].filter(state => state.awaitingKey).length,
|
|
70
115
|
droppedFrames,
|
|
71
116
|
recoveredStreams,
|
|
117
|
+
sendBatches: sendBatches.length,
|
|
72
118
|
retainedPayloadBytes: 0
|
|
73
119
|
};
|
|
74
120
|
},
|
|
75
121
|
clear() {
|
|
76
122
|
streams.clear();
|
|
77
|
-
|
|
123
|
+
sendBatches.length = 0;
|
|
124
|
+
accountedBytes = 0;
|
|
125
|
+
lastInputAt = lastKeyAt = lastAdmittedAt = null;
|
|
78
126
|
}
|
|
79
127
|
};
|
|
80
128
|
}
|
|
@@ -58,3 +58,60 @@ test('a multiplexed lane has a fixed owner bound and releases it on close', () =
|
|
|
58
58
|
gate.clear();
|
|
59
59
|
assert.equal(gate.admit(packet({ device: 'replacement', key: true }), empty), 'send');
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
test('a continuously draining nonempty lane does not confuse current bytes with a stale backlog', () => {
|
|
63
|
+
const gate = createConsoleFrameAdmission();
|
|
64
|
+
for (let n = 0; n < 300; n++) {
|
|
65
|
+
const now = n * 33;
|
|
66
|
+
// Half of each accepted batch remains at the next sample, even though
|
|
67
|
+
// every older batch has drained. A never-zero timestamp would drop at 120ms.
|
|
68
|
+
assert.equal(gate.admit(packet({ key: n === 0 }), {
|
|
69
|
+
laneBufferedBytes: n === 0 ? 0 : 1000, peerBufferedBytes: 1000, now
|
|
70
|
+
}), 'send');
|
|
71
|
+
gate.sent(2000, now);
|
|
72
|
+
}
|
|
73
|
+
assert.equal(gate.inspect().droppedFrames, 0);
|
|
74
|
+
assert.ok(gate.inspect().sendBatches <= 2);
|
|
75
|
+
gate.clear();
|
|
76
|
+
assert.equal(gate.inspect().sendBatches, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('fresh sends cannot rejuvenate actually blocked bytes and batch metadata remains bounded', () => {
|
|
80
|
+
const gate = createConsoleFrameAdmission();
|
|
81
|
+
assert.equal(gate.admit(packet({ key: true }), empty), 'send');
|
|
82
|
+
gate.sent(2000, 0);
|
|
83
|
+
let pending = 2000;
|
|
84
|
+
for (let n = 1; n < 120; n++) {
|
|
85
|
+
assert.equal(gate.admit(packet(), { laneBufferedBytes: pending, peerBufferedBytes: pending, now: n }), 'send');
|
|
86
|
+
gate.sent(100, n); pending += 100;
|
|
87
|
+
}
|
|
88
|
+
assert.equal(gate.inspect().sendBatches, CONSOLE_MEDIA_ADMISSION.maxSendBatches);
|
|
89
|
+
assert.equal(gate.admit(packet(), { laneBufferedBytes: pending, peerBufferedBytes: pending, now: 120 }), 'drop');
|
|
90
|
+
assert.equal(gate.admit(packet(), { ...empty, now: 121 }), 'drop');
|
|
91
|
+
assert.equal(gate.admit(packet({ key: true }), { ...empty, now: 122 }), 'send');
|
|
92
|
+
assert.equal(gate.inspect().sendBatches, 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('read-driven flow evidence distinguishes source starvation, pressure and waiting for a key', () => {
|
|
96
|
+
const gate = createConsoleFrameAdmission();
|
|
97
|
+
assert.equal(gate.inspect(0).lastInputAgeMs, null);
|
|
98
|
+
gate.admit(packet({ key: true }), empty);
|
|
99
|
+
gate.sent(2000, 0);
|
|
100
|
+
const queued = { laneBufferedBytes: 2000, peerBufferedBytes: 2000, now: 120 };
|
|
101
|
+
assert.equal(gate.admit(packet(), queued), 'drop');
|
|
102
|
+
assert.equal(gate.admit(packet(), { ...empty, now: 150 }), 'drop');
|
|
103
|
+
const stalled = gate.inspect(200);
|
|
104
|
+
assert.equal(stalled.inputFrames, 3);
|
|
105
|
+
assert.equal(stalled.inputKeyFrames, 1);
|
|
106
|
+
assert.equal(stalled.admittedFrames, 1);
|
|
107
|
+
assert.equal(stalled.ageDrops, 1);
|
|
108
|
+
assert.equal(stalled.dependentDrops, 1);
|
|
109
|
+
assert.equal(stalled.lastInputAgeMs, 50);
|
|
110
|
+
assert.equal(stalled.lastKeyAgeMs, 200);
|
|
111
|
+
assert.equal(stalled.lastAdmittedAgeMs, 200);
|
|
112
|
+
assert.equal(gate.admit(packet({ key: true }), { ...empty, now: 220 }), 'send');
|
|
113
|
+
assert.equal(gate.inspect(221).lastAdmittedAgeMs, 1);
|
|
114
|
+
gate.clear();
|
|
115
|
+
assert.equal(gate.inspect(222).lastInputAgeMs, null);
|
|
116
|
+
assert.equal(gate.inspect(222).streamOwners, 0);
|
|
117
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
|
|
3
|
+
// Connection setup only: no SDP, addresses, credentials, packets, timers, or
|
|
4
|
+
// references to native peers are retained. Counts describe announcements, not
|
|
5
|
+
// unique paths (SDP and trickled candidates can announce the same path).
|
|
6
|
+
export const CONSOLE_ICE_HISTORY_LIMIT = 8;
|
|
7
|
+
// Explicit advanced configuration only. This does not open a firewall/router
|
|
8
|
+
// port or enable a relay; unset configuration retains native ephemeral ports.
|
|
9
|
+
export function normalizeConsoleIcePortRange(value) {
|
|
10
|
+
if (value === undefined || value === null || value === '') return {};
|
|
11
|
+
const match = String(value).trim().match(/^(\d{4,5})-(\d{4,5})$/);
|
|
12
|
+
const begin = Number(match?.[1]);
|
|
13
|
+
const end = Number(match?.[2]);
|
|
14
|
+
if (!match || begin < 1024 || end > 65535 || end < begin || end - begin >= 64) {
|
|
15
|
+
throw new Error('console-direct-ice-port-range-invalid');
|
|
16
|
+
}
|
|
17
|
+
return { portRangeBegin: begin, portRangeEnd: end };
|
|
18
|
+
}
|
|
19
|
+
const MAX_COUNT = 65_535;
|
|
20
|
+
const count = value => Math.min(MAX_COUNT, value + 1);
|
|
21
|
+
const states = new Set(['new', 'gathering', 'complete', 'completed', 'checking',
|
|
22
|
+
'connecting', 'connected', 'disconnected', 'failed', 'closed']);
|
|
23
|
+
const reasons = new Set([
|
|
24
|
+
'console-direct-ice-timeout', 'console-direct-ice-failed', 'console-direct-ice-closed',
|
|
25
|
+
'console-direct-peer-failed', 'console-direct-peer-closed', 'console-direct-peer-replaced',
|
|
26
|
+
'console-direct-description-failed', 'console-direct-candidate-failed',
|
|
27
|
+
'console-direct-candidate-invalid', 'console-direct-relay-candidate-rejected',
|
|
28
|
+
'console-direct-relay-pair-rejected', 'console-direct-remote-closed',
|
|
29
|
+
'console-direct-peer-disconnected-timeout', 'console-direct-stopped'
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export const safeIceState = value => states.has(value) ? value : 'unknown';
|
|
33
|
+
|
|
34
|
+
function candidateCounts() {
|
|
35
|
+
return { announcements: 0, host: 0, srflx: 0, prflx: 0, relay: 0,
|
|
36
|
+
ipv4: 0, ipv6: 0, mdns: 0, udp: 0, tcp: 0, unknown: 0 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createConsoleIceEvidence() {
|
|
40
|
+
return { localDescriptionSent: false, remoteDescriptionApplied: false,
|
|
41
|
+
localCandidateSendFailures: 0,
|
|
42
|
+
gatheringState: 'unknown', local: candidateCounts(), remote: candidateCounts() };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function recordConsoleIceCandidate(counts, value) {
|
|
46
|
+
if (typeof value !== 'string' || !value.trim()) return;
|
|
47
|
+
counts.announcements = count(counts.announcements);
|
|
48
|
+
if (value.length > 4096) { counts.unknown = count(counts.unknown); return; }
|
|
49
|
+
const parts = value.trim().replace(/^a=/, '').split(/\s+/);
|
|
50
|
+
const transport = parts[2]?.toLowerCase();
|
|
51
|
+
const type = parts[7]?.toLowerCase();
|
|
52
|
+
if (!/^candidate:/i.test(parts[0]) || parts[6]?.toLowerCase() !== 'typ'
|
|
53
|
+
|| !['host', 'srflx', 'prflx', 'relay'].includes(type)) {
|
|
54
|
+
counts.unknown = count(counts.unknown);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
counts[type] = count(counts[type]);
|
|
58
|
+
if (transport === 'udp' || transport === 'tcp') counts[transport] = count(counts[transport]);
|
|
59
|
+
const family = isIP(parts[4] || '');
|
|
60
|
+
if (family) counts[`ipv${family}`] = count(counts[`ipv${family}`]);
|
|
61
|
+
else if (/^[a-z0-9-]+\.local$/i.test(parts[4] || '')) counts.mdns = count(counts.mdns);
|
|
62
|
+
else counts.unknown = count(counts.unknown);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function recordConsoleIceDescription(counts, sdp) {
|
|
66
|
+
if (typeof sdp !== 'string' || sdp.length > 64 * 1024) return;
|
|
67
|
+
for (const line of sdp.split(/\r?\n/)) {
|
|
68
|
+
if (/^a=candidate:/i.test(line)) recordConsoleIceCandidate(counts, line);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function snapshotConsoleIceEvidence(evidence) {
|
|
73
|
+
return { ...evidence, local: { ...evidence.local }, remote: { ...evidence.remote } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function retainConsoleIceRetirement(history, owner, reason, now = Date.now()) {
|
|
77
|
+
const snapshot = {
|
|
78
|
+
consoleId: owner.consoleId, connectionId: owner.connectionId, generation: owner.generation,
|
|
79
|
+
endedAt: new Date(now).toISOString(), durationMs: Math.max(0, now - owner.createdAt),
|
|
80
|
+
reason: reasons.has(reason) ? reason : 'other-peer-retirement',
|
|
81
|
+
peerState: safeIceState(owner.peerState), iceState: safeIceState(owner.iceState),
|
|
82
|
+
controlOpened: owner.controlOpened === true,
|
|
83
|
+
...snapshotConsoleIceEvidence(owner.iceEvidence)
|
|
84
|
+
};
|
|
85
|
+
history.push(snapshot);
|
|
86
|
+
if (history.length > CONSOLE_ICE_HISTORY_LIMIT) history.shift();
|
|
87
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
createConsoleIceEvidence, normalizeConsoleIcePortRange, recordConsoleIceCandidate, recordConsoleIceDescription,
|
|
5
|
+
retainConsoleIceRetirement, snapshotConsoleIceEvidence
|
|
6
|
+
} from './console-direct-ice-evidence.mjs';
|
|
7
|
+
|
|
8
|
+
test('explicit ICE port ranges remain bounded and never alter the default', () => {
|
|
9
|
+
assert.deepEqual(normalizeConsoleIcePortRange(), {});
|
|
10
|
+
assert.deepEqual(normalizeConsoleIcePortRange(''), {});
|
|
11
|
+
assert.deepEqual(normalizeConsoleIcePortRange('54443-54443'), { portRangeBegin: 54443, portRangeEnd: 54443 });
|
|
12
|
+
assert.deepEqual(normalizeConsoleIcePortRange('54443-54450'), { portRangeBegin: 54443, portRangeEnd: 54450 });
|
|
13
|
+
for (const invalid of ['1-2', '1023-1024', '65535-65536', '55555-54443', '54443-54507', '54443', 'abc', '54443-54450x']) {
|
|
14
|
+
assert.throws(() => normalizeConsoleIcePortRange(invalid), /port-range-invalid/);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('ICE evidence counts candidate families without retaining endpoints or SDP secrets', () => {
|
|
19
|
+
const evidence = createConsoleIceEvidence();
|
|
20
|
+
recordConsoleIceDescription(evidence.local, [
|
|
21
|
+
'a=ice-pwd:DO-NOT-RETAIN',
|
|
22
|
+
'a=candidate:1 1 UDP 123 192.168.0.4 59900 typ host',
|
|
23
|
+
'a=candidate:2 1 udp 123 203.0.113.123 59901 typ srflx raddr 192.168.0.4 rport 59900',
|
|
24
|
+
'a=candidate:3 1 tcp 123 2001:db8::abcd 59902 typ host tcptype passive',
|
|
25
|
+
'a=candidate:4 1 udp 123 device-secret.local 59903 typ host'
|
|
26
|
+
].join('\r\n'));
|
|
27
|
+
recordConsoleIceCandidate(evidence.remote, 'invalid SDP DO-NOT-RETAIN');
|
|
28
|
+
assert.equal(evidence.local.announcements, 4);
|
|
29
|
+
assert.equal(evidence.local.host, 3);
|
|
30
|
+
assert.equal(evidence.local.srflx, 1);
|
|
31
|
+
assert.equal(evidence.local.ipv4, 2);
|
|
32
|
+
assert.equal(evidence.local.ipv6, 1);
|
|
33
|
+
assert.equal(evidence.local.mdns, 1);
|
|
34
|
+
assert.equal(evidence.local.udp, 3);
|
|
35
|
+
assert.equal(evidence.local.tcp, 1);
|
|
36
|
+
assert.equal(evidence.remote.unknown, 1);
|
|
37
|
+
assert.doesNotMatch(JSON.stringify(evidence), /DO-NOT-RETAIN|192\.168|203\.0|db8|device-secret|5990/);
|
|
38
|
+
const snapshot = snapshotConsoleIceEvidence(evidence);
|
|
39
|
+
snapshot.local.host = 99;
|
|
40
|
+
assert.equal(evidence.local.host, 3);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('retirement history is bounded, detached, and uses fixed reason/state labels', () => {
|
|
44
|
+
const history = [];
|
|
45
|
+
const owner = { consoleId: 'console', connectionId: 'connection', createdAt: 100,
|
|
46
|
+
peerState: 'connecting', iceState: 'checking', iceEvidence: createConsoleIceEvidence() };
|
|
47
|
+
for (let generation = 1; generation <= 30; generation++) {
|
|
48
|
+
retainConsoleIceRetirement(history, { ...owner, generation }, 'console-direct-ice-timeout', 200);
|
|
49
|
+
}
|
|
50
|
+
assert.equal(history.length, 8);
|
|
51
|
+
assert.equal(history[0].generation, 23);
|
|
52
|
+
assert.equal(history.at(-1).durationMs, 100);
|
|
53
|
+
owner.iceEvidence.remote.host = 99;
|
|
54
|
+
assert.equal(history.at(-1).remote.host, 0);
|
|
55
|
+
retainConsoleIceRetirement(history, { ...owner, peerState: 'secret-state' }, 'secret-reason', 200);
|
|
56
|
+
assert.equal(history.at(-1).peerState, 'unknown');
|
|
57
|
+
assert.equal(history.at(-1).reason, 'other-peer-retirement');
|
|
58
|
+
assert.doesNotMatch(JSON.stringify(history), /secret-/);
|
|
59
|
+
});
|