livedesk 0.1.445 → 0.1.447

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,1045 @@
1
+ import crypto from 'node:crypto';
2
+ import { EventEmitter } from 'node:events';
3
+ import net from 'node:net';
4
+
5
+ export const RELAY_CONTROL_PROTOCOL = 'livedesk.relay.v1';
6
+ export const RELAY_CONTROL_MAX_PLAINTEXT_BYTES = 512 * 1024;
7
+ export const RELAY_CONTROL_DIRECTIONS = Object.freeze({
8
+ clientToHub: 'client-to-hub',
9
+ hubToClient: 'hub-to-client'
10
+ });
11
+
12
+ const RELAY_MAX_LINE_BYTES = 768 * 1024;
13
+ const RELAY_NONCE_BYTES = 12;
14
+ const RELAY_TAG_BYTES = 16;
15
+ const RELAY_PUBLIC_KEY_BYTES = 65;
16
+ const RELAY_PEER_ID_BYTES = 16;
17
+ const DEFAULT_MAX_PEERS = 128;
18
+ const DEFAULT_MAX_QUEUE_BYTES = 4 * 1024 * 1024;
19
+ const DEFAULT_MAX_QUEUE_MESSAGES = 256;
20
+ const DEFAULT_MAX_PEER_QUEUE_BYTES = 1024 * 1024;
21
+ const DEFAULT_MAX_PEER_QUEUE_MESSAGES = 64;
22
+ const DEFAULT_RECONNECT_MIN_MS = 1_000;
23
+ const DEFAULT_RECONNECT_MAX_MS = 30_000;
24
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
25
+
26
+ function isEnabled(value, fallback = false) {
27
+ if (value === undefined || value === null || value === '') return fallback;
28
+ return /^(1|true|yes|on)$/i.test(String(value).trim());
29
+ }
30
+
31
+ function safeText(value, maximum = 160) {
32
+ return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maximum);
33
+ }
34
+
35
+ function boundedInteger(value, minimum, maximum, fallback) {
36
+ const number = Number(value);
37
+ return Number.isFinite(number)
38
+ ? Math.max(minimum, Math.min(maximum, Math.floor(number)))
39
+ : fallback;
40
+ }
41
+
42
+ function encodeBase64Url(value) {
43
+ return Buffer.from(value).toString('base64url');
44
+ }
45
+
46
+ function decodeCanonicalBase64Url(value, expectedBytes = 0, maximumBytes = 1024) {
47
+ const text = String(value || '');
48
+ if (!text || !/^[A-Za-z0-9_-]+$/.test(text)) {
49
+ throw relayError('relay-invalid-base64url');
50
+ }
51
+ const decoded = Buffer.from(text, 'base64url');
52
+ if (decoded.length > maximumBytes
53
+ || (expectedBytes > 0 && decoded.length !== expectedBytes)
54
+ || encodeBase64Url(decoded) !== text) {
55
+ throw relayError('relay-invalid-base64url');
56
+ }
57
+ return decoded;
58
+ }
59
+
60
+ function relayError(code) {
61
+ const error = new Error(code);
62
+ error.code = code;
63
+ return error;
64
+ }
65
+
66
+ function requirePairToken(pairToken) {
67
+ const token = String(pairToken || '');
68
+ if (!token || Buffer.byteLength(token, 'utf8') > 1024) {
69
+ throw relayError('relay-pair-token-required');
70
+ }
71
+ return token;
72
+ }
73
+
74
+ function requireRoomId(roomId) {
75
+ const value = String(roomId || '');
76
+ if (!value || value.length > 128 || !/^[A-Za-z0-9_-]+$/.test(value)) {
77
+ throw relayError('relay-room-invalid');
78
+ }
79
+ return value;
80
+ }
81
+
82
+ function requirePeerId(peerId) {
83
+ const value = String(peerId || '');
84
+ decodeCanonicalBase64Url(value, RELAY_PEER_ID_BYTES, RELAY_PEER_ID_BYTES);
85
+ return value;
86
+ }
87
+
88
+ function requirePublicKey(publicKey) {
89
+ const value = String(publicKey || '');
90
+ const decoded = decodeCanonicalBase64Url(value, RELAY_PUBLIC_KEY_BYTES, RELAY_PUBLIC_KEY_BYTES);
91
+ if (decoded[0] !== 0x04) {
92
+ throw relayError('relay-public-key-invalid');
93
+ }
94
+ return { text: value, bytes: decoded };
95
+ }
96
+
97
+ function requireDirection(direction) {
98
+ const value = String(direction || '');
99
+ if (value !== RELAY_CONTROL_DIRECTIONS.clientToHub
100
+ && value !== RELAY_CONTROL_DIRECTIONS.hubToClient) {
101
+ throw relayError('relay-direction-invalid');
102
+ }
103
+ return value;
104
+ }
105
+
106
+ function requireSequence(sequence) {
107
+ const value = Number(sequence);
108
+ if (!Number.isSafeInteger(value) || value < 1) {
109
+ throw relayError('relay-sequence-invalid');
110
+ }
111
+ return value;
112
+ }
113
+
114
+ function normalizeSessionKey(sessionKey) {
115
+ const key = Buffer.from(sessionKey || []);
116
+ if (key.length !== 32) {
117
+ throw relayError('relay-session-key-invalid');
118
+ }
119
+ return key;
120
+ }
121
+
122
+ function proofMatches(actual, expected) {
123
+ let actualBytes;
124
+ try {
125
+ actualBytes = decodeCanonicalBase64Url(actual, 32, 32);
126
+ } catch {
127
+ return false;
128
+ }
129
+ const expectedBytes = decodeCanonicalBase64Url(expected, 32, 32);
130
+ return crypto.timingSafeEqual(actualBytes, expectedBytes);
131
+ }
132
+
133
+ function controlAad(roomId, peerId, direction, sequence) {
134
+ return Buffer.from(
135
+ `livedesk-relay-data-v1\0${requireRoomId(roomId)}\0${requirePeerId(peerId)}\0${requireDirection(direction)}\0${requireSequence(sequence)}`,
136
+ 'utf8');
137
+ }
138
+
139
+ function sanitizeSocketError(error) {
140
+ const code = safeText(error?.code, 80);
141
+ return code && /^[A-Z0-9_]+$/.test(code) ? code : 'relay-socket-error';
142
+ }
143
+
144
+ export function deriveRelayRoomId(pairToken) {
145
+ const token = requirePairToken(pairToken);
146
+ return crypto
147
+ .createHash('sha256')
148
+ .update(Buffer.from(`livedesk-relay-room-v1\0${token}`, 'utf8'))
149
+ .digest('base64url');
150
+ }
151
+
152
+ export function deriveRelayRegistrationToken(pairToken, roomId = deriveRelayRoomId(pairToken)) {
153
+ const token = requirePairToken(pairToken);
154
+ const room = requireRoomId(roomId);
155
+ return crypto
156
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
157
+ .update(Buffer.from(`livedesk-relay-register-v1\0${room}`, 'utf8'))
158
+ .digest('base64url');
159
+ }
160
+
161
+ export function deriveRelayClientProof(pairToken, {
162
+ roomId = deriveRelayRoomId(pairToken),
163
+ peerId,
164
+ clientPublicKey
165
+ } = {}) {
166
+ const token = requirePairToken(pairToken);
167
+ const room = requireRoomId(roomId);
168
+ const peer = requirePeerId(peerId);
169
+ const clientKey = requirePublicKey(clientPublicKey).text;
170
+ return crypto
171
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
172
+ .update(Buffer.from(`livedesk-relay-client-v1\0${room}\0${peer}\0${clientKey}`, 'utf8'))
173
+ .digest('base64url');
174
+ }
175
+
176
+ export function deriveRelayHubProof(pairToken, {
177
+ roomId = deriveRelayRoomId(pairToken),
178
+ peerId,
179
+ clientPublicKey,
180
+ hubPublicKey
181
+ } = {}) {
182
+ const token = requirePairToken(pairToken);
183
+ const room = requireRoomId(roomId);
184
+ const peer = requirePeerId(peerId);
185
+ const clientKey = requirePublicKey(clientPublicKey).text;
186
+ const hubKey = requirePublicKey(hubPublicKey).text;
187
+ return crypto
188
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
189
+ .update(Buffer.from(`livedesk-relay-hub-v1\0${room}\0${peer}\0${clientKey}\0${hubKey}`, 'utf8'))
190
+ .digest('base64url');
191
+ }
192
+
193
+ export function deriveRelaySessionKey(pairToken, {
194
+ roomId = deriveRelayRoomId(pairToken),
195
+ peerId,
196
+ clientPublicKey,
197
+ hubPublicKey,
198
+ sharedSecret
199
+ } = {}) {
200
+ const token = requirePairToken(pairToken);
201
+ const room = requireRoomId(roomId);
202
+ const peer = requirePeerId(peerId);
203
+ const clientKey = requirePublicKey(clientPublicKey).text;
204
+ const hubKey = requirePublicKey(hubPublicKey).text;
205
+ const secret = Buffer.from(sharedSecret || []);
206
+ if (secret.length < 16 || secret.length > 256) {
207
+ throw relayError('relay-shared-secret-invalid');
208
+ }
209
+ const salt = crypto
210
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
211
+ .update(Buffer.from(`livedesk-relay-salt-v1\0${room}\0${peer}`, 'utf8'))
212
+ .digest();
213
+ const info = Buffer.from(`livedesk-relay-e2e-v1\0${clientKey}\0${hubKey}`, 'utf8');
214
+ return Buffer.from(crypto.hkdfSync('sha256', secret, salt, info, 32));
215
+ }
216
+
217
+ export function encryptRelayControlPayload({
218
+ sessionKey,
219
+ roomId,
220
+ peerId,
221
+ direction,
222
+ sequence,
223
+ plaintext,
224
+ nonce = crypto.randomBytes(RELAY_NONCE_BYTES)
225
+ } = {}) {
226
+ const key = normalizeSessionKey(sessionKey);
227
+ const cleartext = Buffer.from(plaintext || []);
228
+ if (cleartext.length < 1 || cleartext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
229
+ throw relayError('relay-plaintext-size-invalid');
230
+ }
231
+ const nonceBytes = Buffer.from(nonce || []);
232
+ if (nonceBytes.length !== RELAY_NONCE_BYTES) {
233
+ throw relayError('relay-nonce-invalid');
234
+ }
235
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonceBytes, { authTagLength: RELAY_TAG_BYTES });
236
+ cipher.setAAD(controlAad(roomId, peerId, direction, sequence));
237
+ const ciphertext = Buffer.concat([cipher.update(cleartext), cipher.final()]);
238
+ return encodeBase64Url(Buffer.concat([nonceBytes, ciphertext, cipher.getAuthTag()]));
239
+ }
240
+
241
+ export function decryptRelayControlPayload({
242
+ sessionKey,
243
+ roomId,
244
+ peerId,
245
+ direction,
246
+ sequence,
247
+ payload
248
+ } = {}) {
249
+ const key = normalizeSessionKey(sessionKey);
250
+ let envelope;
251
+ try {
252
+ envelope = decodeCanonicalBase64Url(
253
+ payload,
254
+ 0,
255
+ RELAY_NONCE_BYTES + RELAY_CONTROL_MAX_PLAINTEXT_BYTES + RELAY_TAG_BYTES);
256
+ } catch {
257
+ throw relayError('relay-data-invalid');
258
+ }
259
+ if (envelope.length <= RELAY_NONCE_BYTES + RELAY_TAG_BYTES) {
260
+ throw relayError('relay-data-invalid');
261
+ }
262
+ const nonce = envelope.subarray(0, RELAY_NONCE_BYTES);
263
+ const ciphertext = envelope.subarray(RELAY_NONCE_BYTES, envelope.length - RELAY_TAG_BYTES);
264
+ const tag = envelope.subarray(envelope.length - RELAY_TAG_BYTES);
265
+ try {
266
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: RELAY_TAG_BYTES });
267
+ decipher.setAAD(controlAad(roomId, peerId, direction, sequence));
268
+ decipher.setAuthTag(tag);
269
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
270
+ if (plaintext.length < 1 || plaintext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
271
+ throw relayError('relay-plaintext-size-invalid');
272
+ }
273
+ return plaintext;
274
+ } catch (error) {
275
+ if (error?.code === 'relay-plaintext-size-invalid') throw error;
276
+ throw relayError('relay-data-authentication-failed');
277
+ }
278
+ }
279
+
280
+ export class HubRelayVirtualSocket extends EventEmitter {
281
+ constructor(owner, peerId, remoteAddress, remotePort) {
282
+ super();
283
+ this.owner = owner;
284
+ this.peerId = peerId;
285
+ this.remoteAddress = remoteAddress;
286
+ this.remotePort = remotePort;
287
+ this.destroyed = false;
288
+ this.connecting = false;
289
+ this.readable = true;
290
+ this.writable = true;
291
+ this.__liveDeskRelayControl = true;
292
+ this.__liveDeskRelayPeerId = peerId;
293
+ }
294
+
295
+ setNoDelay() {
296
+ return this;
297
+ }
298
+
299
+ setKeepAlive() {
300
+ return this;
301
+ }
302
+
303
+ address() {
304
+ return {
305
+ address: this.remoteAddress,
306
+ family: 'relay',
307
+ port: this.remotePort
308
+ };
309
+ }
310
+
311
+ write(chunk, encoding, callback) {
312
+ let completion = callback;
313
+ let textEncoding = encoding;
314
+ if (typeof encoding === 'function') {
315
+ completion = encoding;
316
+ textEncoding = undefined;
317
+ }
318
+ if (this.destroyed) {
319
+ if (typeof completion === 'function') {
320
+ queueMicrotask(() => completion(relayError('relay-virtual-socket-closed')));
321
+ }
322
+ return false;
323
+ }
324
+ const payload = Buffer.isBuffer(chunk)
325
+ ? chunk
326
+ : Buffer.from(String(chunk ?? ''), typeof textEncoding === 'string' ? textEncoding : 'utf8');
327
+ const accepted = this.owner.sendPeerPlaintext(this.peerId, payload);
328
+ if (typeof completion === 'function') {
329
+ queueMicrotask(() => completion(accepted ? undefined : relayError('relay-write-rejected')));
330
+ }
331
+ return accepted && !this.owner.isPeerBackpressured(this.peerId);
332
+ }
333
+
334
+ end(chunk, encoding, callback) {
335
+ if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
336
+ this.write(chunk, typeof encoding === 'string' ? encoding : undefined);
337
+ }
338
+ const completion = typeof chunk === 'function'
339
+ ? chunk
340
+ : typeof encoding === 'function'
341
+ ? encoding
342
+ : callback;
343
+ this.destroy();
344
+ if (typeof completion === 'function') queueMicrotask(completion);
345
+ return this;
346
+ }
347
+
348
+ destroy(error) {
349
+ if (!this.destroyed) {
350
+ this.owner.closePeer(this.peerId, 'virtual-socket-destroyed', {
351
+ notify: true,
352
+ error: error instanceof Error ? error : null
353
+ });
354
+ }
355
+ return this;
356
+ }
357
+
358
+ _deliver(plaintext) {
359
+ if (!this.destroyed) this.emit('data', Buffer.from(plaintext));
360
+ }
361
+
362
+ _drain() {
363
+ if (!this.destroyed) this.emit('drain');
364
+ }
365
+
366
+ _close(reason, error = null) {
367
+ if (this.destroyed) return;
368
+ this.destroyed = true;
369
+ this.readable = false;
370
+ this.writable = false;
371
+ this.closeReason = safeText(reason, 120) || 'relay-peer-closed';
372
+ if (error && this.listenerCount('error') > 0) this.emit('error', error);
373
+ this.emit('close', error ? true : false);
374
+ }
375
+ }
376
+
377
+ export function createHubRelayControl({
378
+ env = process.env,
379
+ pairToken,
380
+ onPeerSocket = () => {},
381
+ logEvent = () => {},
382
+ logWarn = () => {},
383
+ connect = options => net.createConnection(options),
384
+ randomBytes = size => crypto.randomBytes(size)
385
+ } = {}) {
386
+ const token = requirePairToken(pairToken);
387
+ const roomId = deriveRelayRoomId(token);
388
+ const registrationToken = deriveRelayRegistrationToken(token, roomId);
389
+ const enabled = isEnabled(
390
+ env.LIVEDESK_RELAY_ENABLED,
391
+ isEnabled(env.LIVEDESK_UDP_ENABLED, true));
392
+ const host = safeText(
393
+ env.LIVEDESK_RELAY_HOST
394
+ || env.LIVEDESK_UDP_RENDEZVOUS_HOST
395
+ || 'www.gnsoftech.com',
396
+ 253);
397
+ const port = boundedInteger(
398
+ env.LIVEDESK_RELAY_PORT || env.LIVEDESK_UDP_RENDEZVOUS_PORT,
399
+ 1,
400
+ 65535,
401
+ 5199);
402
+ const maxPeers = boundedInteger(env.LIVEDESK_RELAY_MAX_PEERS, 1, 1024, DEFAULT_MAX_PEERS);
403
+ const maxQueueBytes = boundedInteger(
404
+ env.LIVEDESK_RELAY_MAX_QUEUE_BYTES,
405
+ RELAY_MAX_LINE_BYTES,
406
+ 32 * 1024 * 1024,
407
+ DEFAULT_MAX_QUEUE_BYTES);
408
+ const maxQueueMessages = boundedInteger(
409
+ env.LIVEDESK_RELAY_MAX_QUEUE_MESSAGES,
410
+ 1,
411
+ 4096,
412
+ DEFAULT_MAX_QUEUE_MESSAGES);
413
+ const maxPeerQueueBytes = boundedInteger(
414
+ env.LIVEDESK_RELAY_MAX_PEER_QUEUE_BYTES,
415
+ RELAY_MAX_LINE_BYTES,
416
+ 8 * 1024 * 1024,
417
+ DEFAULT_MAX_PEER_QUEUE_BYTES);
418
+ const maxPeerQueueMessages = boundedInteger(
419
+ env.LIVEDESK_RELAY_MAX_PEER_QUEUE_MESSAGES,
420
+ 1,
421
+ 512,
422
+ DEFAULT_MAX_PEER_QUEUE_MESSAGES);
423
+ const reconnectMinMs = boundedInteger(
424
+ env.LIVEDESK_RELAY_RECONNECT_MIN_MS,
425
+ 50,
426
+ 60_000,
427
+ DEFAULT_RECONNECT_MIN_MS);
428
+ const reconnectMaxMs = boundedInteger(
429
+ env.LIVEDESK_RELAY_RECONNECT_MAX_MS,
430
+ reconnectMinMs,
431
+ 5 * 60_000,
432
+ DEFAULT_RECONNECT_MAX_MS);
433
+ const connectTimeoutMs = boundedInteger(
434
+ env.LIVEDESK_RELAY_CONNECT_TIMEOUT_MS,
435
+ 250,
436
+ 30_000,
437
+ DEFAULT_CONNECT_TIMEOUT_MS);
438
+ const peers = new Map();
439
+ const retiredPeerIds = new Map();
440
+ const sendQueue = [];
441
+ const peerQueueStats = new Map();
442
+ const counters = {
443
+ connections: 0,
444
+ reconnects: 0,
445
+ handshakes: 0,
446
+ rejectedPeers: 0,
447
+ encryptedMessagesReceived: 0,
448
+ encryptedMessagesSent: 0,
449
+ queueDrops: 0
450
+ };
451
+
452
+ let desired = false;
453
+ let started = false;
454
+ let closing = false;
455
+ let connection = null;
456
+ let connectionGeneration = 0;
457
+ let reconnectTimer = null;
458
+ let connectAttemptTimer = null;
459
+ let reconnectAttempt = 0;
460
+ let receiveBuffer = Buffer.alloc(0);
461
+ let queuedBytes = 0;
462
+ let awaitingDrain = false;
463
+ let lastError = '';
464
+ let lastConnectedAt = '';
465
+ let lastTransitionAt = '';
466
+ let state = enabled ? 'idle' : 'disabled';
467
+
468
+ function transition(nextState, error = '') {
469
+ state = nextState;
470
+ lastTransitionAt = new Date().toISOString();
471
+ if (error) lastError = safeText(error, 120);
472
+ }
473
+
474
+ function queueStats(peerId) {
475
+ if (!peerId) return { messages: 0, bytes: 0, backpressured: false };
476
+ let stats = peerQueueStats.get(peerId);
477
+ if (!stats) {
478
+ stats = { messages: 0, bytes: 0, backpressured: false };
479
+ peerQueueStats.set(peerId, stats);
480
+ }
481
+ return stats;
482
+ }
483
+
484
+ function releaseQueuedItem(item) {
485
+ queuedBytes = Math.max(0, queuedBytes - item.buffer.length);
486
+ if (!item.peerId) return;
487
+ const stats = peerQueueStats.get(item.peerId);
488
+ if (!stats) return;
489
+ stats.messages = Math.max(0, stats.messages - 1);
490
+ stats.bytes = Math.max(0, stats.bytes - item.buffer.length);
491
+ }
492
+
493
+ function clearQueue() {
494
+ sendQueue.length = 0;
495
+ queuedBytes = 0;
496
+ awaitingDrain = false;
497
+ peerQueueStats.clear();
498
+ }
499
+
500
+ function emitPeerDrains() {
501
+ if (awaitingDrain) return;
502
+ for (const [peerId, stats] of peerQueueStats) {
503
+ if (!stats.backpressured
504
+ || stats.messages >= maxPeerQueueMessages
505
+ || stats.bytes >= maxPeerQueueBytes) {
506
+ continue;
507
+ }
508
+ stats.backpressured = false;
509
+ peers.get(peerId)?.socket?._drain();
510
+ }
511
+ }
512
+
513
+ function flushQueue() {
514
+ const socket = connection;
515
+ if (!socket || socket.destroyed || !socket.writable || awaitingDrain) return;
516
+ while (sendQueue.length > 0 && !awaitingDrain) {
517
+ const item = sendQueue.shift();
518
+ releaseQueuedItem(item);
519
+ let writable = false;
520
+ try {
521
+ writable = socket.write(item.buffer);
522
+ } catch {
523
+ socket.destroy();
524
+ return;
525
+ }
526
+ if (!writable) awaitingDrain = true;
527
+ }
528
+ emitPeerDrains();
529
+ }
530
+
531
+ function enqueueEnvelope(envelope, peerId = '') {
532
+ if (!connection || connection.destroyed || !connection.writable) return false;
533
+ let buffer;
534
+ try {
535
+ buffer = Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8');
536
+ } catch {
537
+ return false;
538
+ }
539
+ if (buffer.length > RELAY_MAX_LINE_BYTES) return false;
540
+ const stats = queueStats(peerId);
541
+ if (sendQueue.length >= maxQueueMessages
542
+ || queuedBytes + buffer.length > maxQueueBytes
543
+ || (peerId && (stats.messages >= maxPeerQueueMessages
544
+ || stats.bytes + buffer.length > maxPeerQueueBytes))) {
545
+ counters.queueDrops += 1;
546
+ if (peerId) stats.backpressured = true;
547
+ return false;
548
+ }
549
+ sendQueue.push({ buffer, peerId });
550
+ queuedBytes += buffer.length;
551
+ if (peerId) {
552
+ stats.messages += 1;
553
+ stats.bytes += buffer.length;
554
+ }
555
+ flushQueue();
556
+ if (peerId && awaitingDrain) stats.backpressured = true;
557
+ return true;
558
+ }
559
+
560
+ function rememberRetiredPeer(peerId) {
561
+ if (!peerId) return;
562
+ retiredPeerIds.delete(peerId);
563
+ retiredPeerIds.set(peerId, Date.now());
564
+ while (retiredPeerIds.size > maxPeers * 4) {
565
+ retiredPeerIds.delete(retiredPeerIds.keys().next().value);
566
+ }
567
+ }
568
+
569
+ function sendRelayClose(peerId, reason) {
570
+ if (!connection || connection.destroyed) return false;
571
+ return enqueueEnvelope({
572
+ type: 'relay.close',
573
+ protocol: RELAY_CONTROL_PROTOCOL,
574
+ roomId,
575
+ peerId,
576
+ reason: safeText(reason, 80) || 'relay-peer-closed'
577
+ });
578
+ }
579
+
580
+ function closePeer(peerId, reason = 'relay-peer-closed', {
581
+ notify = true,
582
+ error = null
583
+ } = {}) {
584
+ const id = safeText(peerId, 64);
585
+ const peer = peers.get(id);
586
+ if (!peer) {
587
+ if (notify) {
588
+ try {
589
+ sendRelayClose(requirePeerId(id), reason);
590
+ } catch {
591
+ // Invalid untrusted routing fields are ignored.
592
+ }
593
+ }
594
+ return false;
595
+ }
596
+ peers.delete(id);
597
+ rememberRetiredPeer(id);
598
+ peerQueueStats.delete(id);
599
+ if (notify) sendRelayClose(id, reason);
600
+ if (Buffer.isBuffer(peer.sessionKey)) peer.sessionKey.fill(0);
601
+ peer.socket?._close(reason, error);
602
+ return true;
603
+ }
604
+
605
+ function rejectPeer(peerId, reason) {
606
+ counters.rejectedPeers += 1;
607
+ closePeer(peerId, reason, { notify: true });
608
+ }
609
+
610
+ function closeAllPeers(reason) {
611
+ for (const peerId of [...peers.keys()]) {
612
+ closePeer(peerId, reason, { notify: false });
613
+ }
614
+ }
615
+
616
+ function isPeerBackpressured(peerId) {
617
+ return peerQueueStats.get(String(peerId || ''))?.backpressured === true;
618
+ }
619
+
620
+ function sendPeerPlaintext(peerId, plaintext) {
621
+ const peer = peers.get(String(peerId || ''));
622
+ if (!peer || peer.socket.destroyed || peer.handshakeState !== 'established') return false;
623
+ const cleartext = Buffer.from(plaintext || []);
624
+ if (cleartext.length < 1 || cleartext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
625
+ rejectPeer(peer.peerId, 'relay-plaintext-size-invalid');
626
+ return false;
627
+ }
628
+ const sequence = peer.sendSequence + 1;
629
+ let payload;
630
+ try {
631
+ payload = encryptRelayControlPayload({
632
+ sessionKey: peer.sessionKey,
633
+ roomId,
634
+ peerId: peer.peerId,
635
+ direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
636
+ sequence,
637
+ plaintext: cleartext,
638
+ nonce: randomBytes(RELAY_NONCE_BYTES)
639
+ });
640
+ } catch {
641
+ rejectPeer(peer.peerId, 'relay-encryption-failed');
642
+ return false;
643
+ }
644
+ const accepted = enqueueEnvelope({
645
+ type: 'relay.data',
646
+ protocol: RELAY_CONTROL_PROTOCOL,
647
+ roomId,
648
+ peerId: peer.peerId,
649
+ direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
650
+ sequence,
651
+ payload
652
+ }, peer.peerId);
653
+ if (!accepted) {
654
+ counters.queueDrops += 1;
655
+ closePeer(peer.peerId, 'relay-backpressure-limit', { notify: false });
656
+ return false;
657
+ }
658
+ peer.sendSequence = sequence;
659
+ peer.lastActivityAt = Date.now();
660
+ counters.encryptedMessagesSent += 1;
661
+ return true;
662
+ }
663
+
664
+ function handleClientHandshake(message) {
665
+ let peerId = '';
666
+ try {
667
+ peerId = requirePeerId(message.peerId);
668
+ if (message.direction
669
+ && requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
670
+ throw relayError('relay-direction-invalid');
671
+ }
672
+ const stage = safeText(message.stage || message.handshake, 40).toLowerCase();
673
+ if (stage && stage !== 'client-hello') {
674
+ throw relayError('relay-handshake-stage-invalid');
675
+ }
676
+ if (peers.has(peerId) || retiredPeerIds.has(peerId)) {
677
+ throw relayError('relay-handshake-replay');
678
+ }
679
+ if (peers.size >= maxPeers) {
680
+ throw relayError('relay-peer-capacity');
681
+ }
682
+ const clientPublic = requirePublicKey(message.clientPublicKey);
683
+ const expectedProof = deriveRelayClientProof(token, {
684
+ roomId,
685
+ peerId,
686
+ clientPublicKey: clientPublic.text
687
+ });
688
+ if (!proofMatches(message.proof, expectedProof)) {
689
+ throw relayError('relay-client-proof-invalid');
690
+ }
691
+ const ecdh = crypto.createECDH('prime256v1');
692
+ ecdh.generateKeys();
693
+ const hubPublicKey = ecdh.getPublicKey(null, 'uncompressed').toString('base64url');
694
+ const sharedSecret = ecdh.computeSecret(clientPublic.bytes);
695
+ let sessionKey;
696
+ try {
697
+ sessionKey = deriveRelaySessionKey(token, {
698
+ roomId,
699
+ peerId,
700
+ clientPublicKey: clientPublic.text,
701
+ hubPublicKey,
702
+ sharedSecret
703
+ });
704
+ } finally {
705
+ sharedSecret.fill(0);
706
+ }
707
+ const proof = deriveRelayHubProof(token, {
708
+ roomId,
709
+ peerId,
710
+ clientPublicKey: clientPublic.text,
711
+ hubPublicKey
712
+ });
713
+ const remoteAddress = `relay://${host}:${port}/${peerId}`;
714
+ const peer = {
715
+ peerId,
716
+ clientPublicKey: clientPublic.text,
717
+ hubPublicKey,
718
+ sessionKey,
719
+ receiveSequence: 0,
720
+ sendSequence: 0,
721
+ handshakeState: 'established',
722
+ createdAt: Date.now(),
723
+ lastActivityAt: Date.now(),
724
+ socket: null
725
+ };
726
+ peer.socket = new HubRelayVirtualSocket(api, peerId, remoteAddress, port);
727
+ peers.set(peerId, peer);
728
+ const sent = enqueueEnvelope({
729
+ type: 'relay.handshake',
730
+ protocol: RELAY_CONTROL_PROTOCOL,
731
+ roomId,
732
+ peerId,
733
+ direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
734
+ stage: 'hub-hello',
735
+ clientPublicKey: clientPublic.text,
736
+ hubPublicKey,
737
+ proof
738
+ }, peerId);
739
+ if (!sent) {
740
+ closePeer(peerId, 'relay-handshake-backpressure', { notify: false });
741
+ return;
742
+ }
743
+ try {
744
+ onPeerSocket(peer.socket, {
745
+ protocol: RELAY_CONTROL_PROTOCOL,
746
+ peerId,
747
+ endpoint: `${host}:${port}`
748
+ });
749
+ } catch {
750
+ closePeer(peerId, 'relay-peer-handler-failed', { notify: true });
751
+ return;
752
+ }
753
+ counters.handshakes += 1;
754
+ } catch (error) {
755
+ counters.rejectedPeers += 1;
756
+ if (peerId) {
757
+ rememberRetiredPeer(peerId);
758
+ sendRelayClose(peerId, safeText(error?.code, 80) || 'relay-handshake-rejected');
759
+ }
760
+ }
761
+ }
762
+
763
+ function handleClientData(message) {
764
+ let peerId = '';
765
+ try {
766
+ peerId = requirePeerId(message.peerId);
767
+ const peer = peers.get(peerId);
768
+ if (!peer || peer.handshakeState !== 'established') {
769
+ throw relayError('relay-handshake-required');
770
+ }
771
+ if (requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
772
+ throw relayError('relay-direction-invalid');
773
+ }
774
+ const sequence = requireSequence(message.sequence);
775
+ if (sequence !== peer.receiveSequence + 1) {
776
+ throw relayError('relay-sequence-invalid');
777
+ }
778
+ const plaintext = decryptRelayControlPayload({
779
+ sessionKey: peer.sessionKey,
780
+ roomId,
781
+ peerId,
782
+ direction: RELAY_CONTROL_DIRECTIONS.clientToHub,
783
+ sequence,
784
+ payload: message.payload
785
+ });
786
+ peer.receiveSequence = sequence;
787
+ peer.lastActivityAt = Date.now();
788
+ counters.encryptedMessagesReceived += 1;
789
+ peer.socket._deliver(plaintext);
790
+ } catch (error) {
791
+ if (peerId) rejectPeer(peerId, safeText(error?.code, 80) || 'relay-data-rejected');
792
+ }
793
+ }
794
+
795
+ function handleRelayEnvelope(message) {
796
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return;
797
+ if (message.type === 'relay.registered') {
798
+ if (message.protocol !== RELAY_CONTROL_PROTOCOL
799
+ || message.roomId !== roomId
800
+ || message.role !== 'hub'
801
+ || safeText(message.peerId, 64) !== ''
802
+ || state !== 'registering') {
803
+ lastError = 'relay-registration-invalid';
804
+ connection?.destroy();
805
+ return;
806
+ }
807
+ clearConnectAttemptTimer();
808
+ reconnectAttempt = 0;
809
+ lastConnectedAt = new Date().toISOString();
810
+ lastError = '';
811
+ transition('registered');
812
+ logEvent('relay', `Encrypted relay control registered at ${host}:${port}`);
813
+ return;
814
+ }
815
+ const peerId = safeText(message.peerId, 64);
816
+ if (message.protocol !== RELAY_CONTROL_PROTOCOL || message.roomId !== roomId) {
817
+ if (peerId) rejectPeer(peerId, 'relay-routing-mismatch');
818
+ return;
819
+ }
820
+ if (message.type === 'relay.handshake') {
821
+ handleClientHandshake(message);
822
+ return;
823
+ }
824
+ if (message.type === 'relay.data') {
825
+ handleClientData(message);
826
+ return;
827
+ }
828
+ if (message.type === 'relay.close') {
829
+ try {
830
+ closePeer(requirePeerId(message.peerId), 'relay-client-closed', { notify: false });
831
+ } catch {
832
+ // Invalid untrusted routing fields are ignored.
833
+ }
834
+ }
835
+ }
836
+
837
+ function onConnectionData(chunk) {
838
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
839
+ receiveBuffer = receiveBuffer.length > 0
840
+ ? Buffer.concat([receiveBuffer, incoming])
841
+ : incoming;
842
+ while (true) {
843
+ const newline = receiveBuffer.indexOf(0x0a);
844
+ if (newline < 0) {
845
+ if (receiveBuffer.length > RELAY_MAX_LINE_BYTES) {
846
+ lastError = 'relay-line-too-large';
847
+ connection?.destroy();
848
+ }
849
+ return;
850
+ }
851
+ if (newline > RELAY_MAX_LINE_BYTES) {
852
+ lastError = 'relay-line-too-large';
853
+ connection?.destroy();
854
+ return;
855
+ }
856
+ const line = receiveBuffer.subarray(0, newline);
857
+ receiveBuffer = receiveBuffer.subarray(newline + 1);
858
+ if (line.length === 0) continue;
859
+ let message;
860
+ try {
861
+ message = JSON.parse(line.toString('utf8'));
862
+ } catch {
863
+ lastError = 'relay-invalid-json';
864
+ connection?.destroy();
865
+ return;
866
+ }
867
+ handleRelayEnvelope(message);
868
+ }
869
+ }
870
+
871
+ function scheduleReconnect() {
872
+ if (!desired || closing || reconnectTimer) return;
873
+ reconnectAttempt += 1;
874
+ counters.reconnects += 1;
875
+ const exponential = Math.min(
876
+ reconnectMaxMs,
877
+ reconnectMinMs * (2 ** Math.min(10, reconnectAttempt - 1)));
878
+ const jitter = Math.floor(exponential * 0.2 * Math.random());
879
+ const delay = Math.min(reconnectMaxMs, exponential + jitter);
880
+ transition('reconnect-wait');
881
+ reconnectTimer = setTimeout(() => {
882
+ reconnectTimer = null;
883
+ connectNow();
884
+ }, delay);
885
+ reconnectTimer.unref?.();
886
+ }
887
+
888
+ function clearConnectAttemptTimer() {
889
+ if (connectAttemptTimer) clearTimeout(connectAttemptTimer);
890
+ connectAttemptTimer = null;
891
+ }
892
+
893
+ function handleConnectionClosed(socket, generation) {
894
+ if (connection !== socket || generation !== connectionGeneration) return;
895
+ clearConnectAttemptTimer();
896
+ connection = null;
897
+ receiveBuffer = Buffer.alloc(0);
898
+ clearQueue();
899
+ closeAllPeers('relay-connection-lost');
900
+ if (desired && !closing) scheduleReconnect();
901
+ else transition(enabled ? 'idle' : 'disabled');
902
+ }
903
+
904
+ function connectNow() {
905
+ if (!enabled || !desired || closing || connection) return;
906
+ transition('connecting');
907
+ const generation = ++connectionGeneration;
908
+ let socket;
909
+ try {
910
+ socket = connect({ host, port });
911
+ } catch (error) {
912
+ lastError = sanitizeSocketError(error);
913
+ scheduleReconnect();
914
+ return;
915
+ }
916
+ connection = socket;
917
+ socket.setNoDelay?.(true);
918
+ socket.setKeepAlive?.(true, 10_000);
919
+ socket.once('connect', () => {
920
+ if (connection !== socket || generation !== connectionGeneration || !desired) return;
921
+ counters.connections += 1;
922
+ transition('registering');
923
+ const registered = enqueueEnvelope({
924
+ type: 'relay.register',
925
+ protocol: RELAY_CONTROL_PROTOCOL,
926
+ roomId,
927
+ role: 'hub',
928
+ peerId: '',
929
+ ack: true,
930
+ token: registrationToken
931
+ });
932
+ if (!registered) socket.destroy();
933
+ });
934
+ socket.on('data', onConnectionData);
935
+ socket.on('drain', () => {
936
+ if (connection !== socket || generation !== connectionGeneration) return;
937
+ awaitingDrain = false;
938
+ flushQueue();
939
+ });
940
+ socket.once('error', error => {
941
+ if (connection !== socket || generation !== connectionGeneration) return;
942
+ lastError = sanitizeSocketError(error);
943
+ logWarn('relay', `Encrypted relay control connection failed code=${lastError}`);
944
+ });
945
+ socket.once('close', () => handleConnectionClosed(socket, generation));
946
+ connectAttemptTimer = setTimeout(() => {
947
+ if (connection !== socket
948
+ || generation !== connectionGeneration
949
+ || socket.destroyed
950
+ || (state !== 'connecting' && state !== 'registering')) return;
951
+ lastError = 'relay-connect-timeout';
952
+ logWarn('relay', `Encrypted relay control connection failed code=${lastError}`);
953
+ socket.destroy();
954
+ }, connectTimeoutMs);
955
+ connectAttemptTimer.unref?.();
956
+ }
957
+
958
+ async function start() {
959
+ if (!enabled || started) return getStatus();
960
+ started = true;
961
+ desired = true;
962
+ closing = false;
963
+ connectNow();
964
+ return getStatus();
965
+ }
966
+
967
+ async function close() {
968
+ desired = false;
969
+ started = false;
970
+ closing = true;
971
+ if (reconnectTimer) clearTimeout(reconnectTimer);
972
+ reconnectTimer = null;
973
+ clearConnectAttemptTimer();
974
+ closeAllPeers('hub-shutdown');
975
+ clearQueue();
976
+ const socket = connection;
977
+ connection = null;
978
+ connectionGeneration += 1;
979
+ receiveBuffer = Buffer.alloc(0);
980
+ if (socket && !socket.destroyed) {
981
+ await new Promise(resolve => {
982
+ let settled = false;
983
+ const settle = () => {
984
+ if (settled) return;
985
+ settled = true;
986
+ clearTimeout(timer);
987
+ resolve();
988
+ };
989
+ const timer = setTimeout(() => {
990
+ socket.destroy();
991
+ settle();
992
+ }, 250);
993
+ timer.unref?.();
994
+ socket.once('close', settle);
995
+ try {
996
+ socket.end();
997
+ } catch {
998
+ socket.destroy();
999
+ settle();
1000
+ }
1001
+ });
1002
+ }
1003
+ closing = false;
1004
+ transition(enabled ? 'idle' : 'disabled');
1005
+ }
1006
+
1007
+ function getStatus() {
1008
+ return {
1009
+ enabled,
1010
+ started,
1011
+ state,
1012
+ connected: !!connection && !connection.destroyed && state === 'registered',
1013
+ protocol: RELAY_CONTROL_PROTOCOL,
1014
+ endpoint: `${host}:${port}`,
1015
+ host,
1016
+ port,
1017
+ peerCount: peers.size,
1018
+ peerQueueCount: peerQueueStats.size,
1019
+ queuedMessages: sendQueue.length,
1020
+ queuedBytes,
1021
+ maxPeers,
1022
+ maxQueueMessages,
1023
+ maxQueueBytes,
1024
+ reconnectCount: counters.reconnects,
1025
+ handshakeCount: counters.handshakes,
1026
+ rejectedPeerCount: counters.rejectedPeers,
1027
+ encryptedMessagesReceived: counters.encryptedMessagesReceived,
1028
+ encryptedMessagesSent: counters.encryptedMessagesSent,
1029
+ queueDropCount: counters.queueDrops,
1030
+ lastConnectedAt,
1031
+ lastTransitionAt,
1032
+ lastError
1033
+ };
1034
+ }
1035
+
1036
+ const api = {
1037
+ start,
1038
+ close,
1039
+ getStatus,
1040
+ sendPeerPlaintext,
1041
+ closePeer,
1042
+ isPeerBackpressured
1043
+ };
1044
+ return api;
1045
+ }