@livedesk/hub 0.1.13 → 0.1.14

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