@livedesk/hub 0.1.3 → 0.1.5

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,216 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export const UDP_P2P_PROTOCOL = 'livedesk.udp.p2p.v1';
4
+ export const UDP_P2P_VERSION = 1;
5
+ export const UDP_MAX_DATAGRAM_BYTES = 1200;
6
+ export const UDP_PACKET_TYPES = Object.freeze({
7
+ probe: 1,
8
+ 'probe-ack': 2,
9
+ ready: 3,
10
+ ping: 4,
11
+ pong: 5,
12
+ frame: 6,
13
+ close: 7
14
+ });
15
+ const UDP_PACKET_TYPE_NAMES = new Map(Object.entries(UDP_PACKET_TYPES).map(([name, value]) => [value, name]));
16
+ const UDP_FIXED_HEADER_BYTES = 15;
17
+ const UDP_NONCE_BYTES = 12;
18
+ const UDP_TAG_BYTES = 16;
19
+ const UDP_MAX_SESSION_ID_BYTES = 96;
20
+ const UDP_MAX_HEADER_BYTES = 4096;
21
+ const UDP_MAX_PAYLOAD_BYTES = UDP_MAX_DATAGRAM_BYTES - UDP_FIXED_HEADER_BYTES - UDP_NONCE_BYTES - UDP_TAG_BYTES - 1;
22
+
23
+ function asBuffer(value) {
24
+ if (Buffer.isBuffer(value)) return value;
25
+ if (value instanceof Uint8Array) return Buffer.from(value);
26
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
27
+ return Buffer.alloc(0);
28
+ }
29
+
30
+ function normalizeKey(key) {
31
+ const value = asBuffer(key);
32
+ return value.length === 32 ? value : crypto.createHash('sha256').update(value).digest();
33
+ }
34
+
35
+ function normalizeSessionId(value) {
36
+ const sessionId = String(value || '').trim();
37
+ if (!sessionId || Buffer.byteLength(sessionId, 'utf8') > UDP_MAX_SESSION_ID_BYTES) {
38
+ throw new Error('invalid-udp-session-id');
39
+ }
40
+ return sessionId;
41
+ }
42
+
43
+ function packetTypeCode(type) {
44
+ const code = typeof type === 'number' ? type : UDP_PACKET_TYPES[String(type || '').toLowerCase()];
45
+ if (!code || !UDP_PACKET_TYPE_NAMES.has(code)) throw new Error('invalid-udp-packet-type');
46
+ return code;
47
+ }
48
+
49
+ function encodeHeader(header) {
50
+ const bytes = Buffer.from(JSON.stringify(header && typeof header === 'object' ? header : {}), 'utf8');
51
+ if (bytes.length > UDP_MAX_HEADER_BYTES) throw new Error('udp-packet-header-too-large');
52
+ return bytes;
53
+ }
54
+
55
+ export function deriveUdpSessionKey(keyMaterial) {
56
+ return normalizeKey(keyMaterial);
57
+ }
58
+
59
+ export function createUdpSessionKey() {
60
+ return crypto.randomBytes(32);
61
+ }
62
+
63
+ export function encodeUdpPacket({ type, sessionId, key, sequence = 0, header = {}, payload = Buffer.alloc(0) }) {
64
+ const normalizedSessionId = normalizeSessionId(sessionId);
65
+ const sessionBytes = Buffer.from(normalizedSessionId, 'utf8');
66
+ const headerBytes = encodeHeader(header);
67
+ const payloadBytes = asBuffer(payload);
68
+ if (payloadBytes.length > UDP_MAX_PAYLOAD_BYTES) throw new Error('udp-packet-payload-too-large');
69
+
70
+ const preamble = Buffer.allocUnsafe(UDP_FIXED_HEADER_BYTES);
71
+ UDP_P2P_PROTOCOL.startsWith('livedesk') && Buffer.from('LDU1', 'ascii').copy(preamble, 0);
72
+ preamble[4] = UDP_P2P_VERSION;
73
+ preamble[5] = packetTypeCode(type);
74
+ preamble[6] = sessionBytes.length;
75
+ preamble.writeUInt16BE(headerBytes.length, 7);
76
+ preamble.writeUInt16BE(payloadBytes.length, 9);
77
+ preamble.writeUInt32BE((Number(sequence) >>> 0), 11);
78
+ const nonce = crypto.randomBytes(UDP_NONCE_BYTES);
79
+ const associatedData = Buffer.concat([preamble, nonce, sessionBytes, headerBytes]);
80
+ const cipher = crypto.createCipheriv('aes-256-gcm', normalizeKey(key), nonce);
81
+ cipher.setAAD(associatedData);
82
+ const encrypted = Buffer.concat([cipher.update(payloadBytes), cipher.final()]);
83
+ const tag = cipher.getAuthTag();
84
+ const packet = Buffer.concat([associatedData, encrypted, tag]);
85
+ if (packet.length > UDP_MAX_DATAGRAM_BYTES) throw new Error('udp-datagram-too-large');
86
+ return packet;
87
+ }
88
+
89
+ export function decodeUdpPacket(packet, key) {
90
+ const bytes = asBuffer(packet);
91
+ if (bytes.length < UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + UDP_TAG_BYTES) return null;
92
+ if (!bytes.subarray(0, 4).equals(Buffer.from('LDU1', 'ascii')) || bytes[4] !== UDP_P2P_VERSION) return null;
93
+ const type = UDP_PACKET_TYPE_NAMES.get(bytes[5]);
94
+ const sessionLength = bytes[6];
95
+ const headerLength = bytes.readUInt16BE(7);
96
+ const payloadLength = bytes.readUInt16BE(9);
97
+ const sequence = bytes.readUInt32BE(11);
98
+ const associatedLength = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES + sessionLength + headerLength;
99
+ const expectedLength = associatedLength + payloadLength + UDP_TAG_BYTES;
100
+ if (!type || sessionLength < 1 || sessionLength > UDP_MAX_SESSION_ID_BYTES
101
+ || headerLength > UDP_MAX_HEADER_BYTES || payloadLength > UDP_MAX_PAYLOAD_BYTES
102
+ || expectedLength !== bytes.length) return null;
103
+
104
+ const associatedData = bytes.subarray(0, associatedLength);
105
+ const sessionStart = UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES;
106
+ const sessionId = bytes.subarray(sessionStart, sessionStart + sessionLength).toString('utf8');
107
+ const headerStart = sessionStart + sessionLength;
108
+ let header;
109
+ try {
110
+ header = JSON.parse(bytes.subarray(headerStart, headerStart + headerLength).toString('utf8'));
111
+ } catch {
112
+ return null;
113
+ }
114
+ const nonce = bytes.subarray(UDP_FIXED_HEADER_BYTES, UDP_FIXED_HEADER_BYTES + UDP_NONCE_BYTES);
115
+ const ciphertextStart = associatedLength;
116
+ const ciphertext = bytes.subarray(ciphertextStart, ciphertextStart + payloadLength);
117
+ const tag = bytes.subarray(ciphertextStart + payloadLength);
118
+ try {
119
+ const decipher = crypto.createDecipheriv('aes-256-gcm', normalizeKey(key), nonce);
120
+ decipher.setAAD(associatedData);
121
+ decipher.setAuthTag(tag);
122
+ const payload = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
123
+ return { type, sessionId, sequence, header, payload };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ export class UdpReplayWindow {
130
+ constructor(size = 512) {
131
+ this.size = Math.max(32, Math.min(4096, Math.floor(Number(size) || 512)));
132
+ this.highest = -1;
133
+ this.seen = new Set();
134
+ }
135
+
136
+ accept(sequence) {
137
+ const value = Number(sequence) >>> 0;
138
+ if (this.highest >= 0 && value + this.size < this.highest) return false;
139
+ if (this.seen.has(value)) return false;
140
+ this.seen.add(value);
141
+ if (this.highest < 0 || value > this.highest) this.highest = value;
142
+ const minimum = Math.max(0, this.highest - this.size);
143
+ for (const item of this.seen) {
144
+ if (item < minimum) this.seen.delete(item);
145
+ }
146
+ return true;
147
+ }
148
+ }
149
+
150
+ export class UdpFrameReassembler {
151
+ constructor({ maxPending = 2, timeoutMs = 80, maxFrameBytes = 8 * 1024 * 1024 } = {}) {
152
+ this.maxPending = Math.max(1, Math.min(16, Math.floor(Number(maxPending) || 2)));
153
+ this.timeoutMs = Math.max(20, Math.min(2000, Math.floor(Number(timeoutMs) || 80)));
154
+ this.maxFrameBytes = Math.max(64 * 1024, Math.min(16 * 1024 * 1024, Math.floor(Number(maxFrameBytes) || 8 * 1024 * 1024)));
155
+ this.pending = new Map();
156
+ }
157
+
158
+ ingest(packet, now = Date.now()) {
159
+ if (!packet || packet.type !== 'frame') return null;
160
+ this.expire(now);
161
+ const header = packet.header && typeof packet.header === 'object' ? packet.header : {};
162
+ const frameId = String(header.frameId || '').slice(0, 160);
163
+ const chunkIndex = Number(header.chunkIndex);
164
+ const chunkCount = Number(header.chunkCount);
165
+ if (!frameId || !Number.isInteger(chunkIndex) || !Number.isInteger(chunkCount)
166
+ || chunkIndex < 0 || chunkCount < 1 || chunkCount > 4096 || chunkIndex >= chunkCount) return null;
167
+ let state = this.pending.get(frameId);
168
+ if (!state) {
169
+ while (this.pending.size >= this.maxPending) {
170
+ const oldest = this.pending.keys().next().value;
171
+ if (oldest === undefined) break;
172
+ this.pending.delete(oldest);
173
+ }
174
+ state = { createdAt: now, chunkCount, chunks: new Array(chunkCount), received: 0, totalBytes: 0, frameHeader: null };
175
+ this.pending.set(frameId, state);
176
+ }
177
+ if (state.chunkCount !== chunkCount || state.chunks[chunkIndex]) return null;
178
+ const payload = asBuffer(packet.payload);
179
+ state.chunks[chunkIndex] = payload;
180
+ state.received += 1;
181
+ state.totalBytes += payload.length;
182
+ if (header.frameHeader && typeof header.frameHeader === 'object') state.frameHeader = header.frameHeader;
183
+ if (state.totalBytes > this.maxFrameBytes) {
184
+ this.pending.delete(frameId);
185
+ return null;
186
+ }
187
+ if (state.received !== state.chunkCount) return null;
188
+ this.pending.delete(frameId);
189
+ return {
190
+ frameId,
191
+ header: state.frameHeader || header.frameHeader || {},
192
+ payload: Buffer.concat(state.chunks, state.totalBytes)
193
+ };
194
+ }
195
+
196
+ expire(now = Date.now()) {
197
+ for (const [frameId, state] of this.pending) {
198
+ if (now - state.createdAt > this.timeoutMs) this.pending.delete(frameId);
199
+ }
200
+ }
201
+ }
202
+
203
+ export function encodeRendezvousMessage(message) {
204
+ const payload = Buffer.from(JSON.stringify(message && typeof message === 'object' ? message : {}), 'utf8');
205
+ if (payload.length > UDP_MAX_DATAGRAM_BYTES) throw new Error('rendezvous-message-too-large');
206
+ return payload;
207
+ }
208
+
209
+ export function decodeRendezvousMessage(payload) {
210
+ try {
211
+ const value = JSON.parse(asBuffer(payload).toString('utf8'));
212
+ return value && typeof value === 'object' ? value : null;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
@@ -0,0 +1,78 @@
1
+ import dgram from 'node:dgram';
2
+ import { decodeRendezvousMessage, encodeRendezvousMessage, UDP_MAX_DATAGRAM_BYTES } from './udp-protocol.js';
3
+
4
+ function safeText(value, max = 160) {
5
+ return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, max);
6
+ }
7
+
8
+ function normalizePort(value, fallback) {
9
+ const port = Number(value);
10
+ return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
11
+ }
12
+
13
+ export function createUdpRendezvousServer({ host = '0.0.0.0', port = 5199, log = () => {} } = {}) {
14
+ const socket = dgram.createSocket('udp4');
15
+ const rooms = new Map();
16
+ let started = false;
17
+ let boundPort = normalizePort(port, 5199);
18
+
19
+ function send(message, address, targetPort) {
20
+ const payload = encodeRendezvousMessage(message);
21
+ socket.send(payload, targetPort, address);
22
+ }
23
+
24
+ function onMessage(payload, rinfo) {
25
+ if (!Buffer.isBuffer(payload) || payload.length > UDP_MAX_DATAGRAM_BYTES) return;
26
+ const message = decodeRendezvousMessage(payload);
27
+ if (message?.type !== 'rendezvous.register') return;
28
+ const roomId = safeText(message.roomId, 160);
29
+ const role = safeText(message.role, 16).toLowerCase();
30
+ const token = safeText(message.token, 256);
31
+ if (!roomId || !token || !['hub', 'client'].includes(role)) return;
32
+ let room = rooms.get(roomId);
33
+ if (!room || room.token !== token || Date.now() - room.updatedAt > 60_000) {
34
+ room = { token, peers: new Map(), updatedAt: Date.now() };
35
+ rooms.set(roomId, room);
36
+ }
37
+ room.updatedAt = Date.now();
38
+ room.peers.set(role, { address: rinfo.address, port: rinfo.port });
39
+ const peers = [...room.peers.entries()];
40
+ if (peers.length < 2) return;
41
+ for (const [peerRole, peer] of peers) {
42
+ const other = peers.find(([candidateRole]) => candidateRole !== peerRole)?.[1];
43
+ if (!other) continue;
44
+ send({ type: 'rendezvous.peer', roomId, role: peerRole === 'hub' ? 'client' : 'hub', host: other.address, port: other.port }, peer.address, peer.port);
45
+ }
46
+ }
47
+
48
+ socket.on('message', onMessage);
49
+ socket.on('error', error => log(`UDP rendezvous error: ${error?.message || error}`));
50
+
51
+ async function start() {
52
+ if (started) return { host, port: boundPort };
53
+ await new Promise((resolve, reject) => {
54
+ const onError = error => { socket.off('listening', onListening); reject(error); };
55
+ const onListening = () => { socket.off('error', onError); resolve(); };
56
+ socket.once('error', onError);
57
+ socket.once('listening', onListening);
58
+ socket.bind(boundPort, host);
59
+ });
60
+ started = true;
61
+ boundPort = socket.address().port;
62
+ log(`UDP rendezvous listening on udp://${host}:${boundPort}`);
63
+ return { host, port: boundPort };
64
+ }
65
+
66
+ async function close() {
67
+ rooms.clear();
68
+ if (!started) return;
69
+ await new Promise(resolve => socket.close(() => resolve()));
70
+ started = false;
71
+ }
72
+
73
+ return {
74
+ start,
75
+ close,
76
+ getStatus: () => ({ started, host, port: boundPort, rooms: rooms.size })
77
+ };
78
+ }