@livedesk/hub 0.1.35 → 0.1.37

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.
@@ -1,1703 +1,1376 @@
1
- import crypto from 'node:crypto';
2
- import { EventEmitter } from 'node:events';
3
- import net from 'node:net';
4
- import {
5
- SECURE_SESSION_MAX_CLOCK_SKEW_MS,
6
- SECURE_SESSION_PROTOCOL,
7
- deriveSecureSessionKey,
8
- enrollmentProof,
9
- fixedTimeBase64UrlEqual,
10
- normalizeSecureChannel,
11
- secureClientTranscript,
12
- secureServerTranscript
13
- } from '@livedesk/runtime-core';
14
-
15
- export const RELAY_CONTROL_PROTOCOL = 'livedesk.relay.v1';
16
- export const RELAY_CONTROL_MAX_PLAINTEXT_BYTES = 512 * 1024;
17
- export const RELAY_CONTROL_DIRECTIONS = Object.freeze({
18
- clientToHub: 'client-to-hub',
19
- hubToClient: 'hub-to-client'
20
- });
21
-
22
- const RELAY_MAX_LINE_BYTES = 768 * 1024;
23
- const RELAY_MAX_RECEIVE_CHUNKS = 256;
24
- const RELAY_NONCE_BYTES = 12;
25
- const RELAY_TAG_BYTES = 16;
26
- const RELAY_PUBLIC_KEY_BYTES = 65;
27
- const RELAY_PEER_ID_BYTES = 16;
28
- const DEFAULT_MAX_PEERS = 128;
29
- const DEFAULT_MAX_QUEUE_BYTES = 4 * 1024 * 1024;
30
- const DEFAULT_MAX_QUEUE_MESSAGES = 256;
31
- const DEFAULT_MAX_PEER_QUEUE_BYTES = 1024 * 1024;
32
- const DEFAULT_MAX_PEER_QUEUE_MESSAGES = 64;
33
- const DEFAULT_RECONNECT_MIN_MS = 1_000;
34
- const DEFAULT_RECONNECT_MAX_MS = 30_000;
35
- const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
36
-
37
- function isEnabled(value, fallback = false) {
38
- if (value === undefined || value === null || value === '') return fallback;
39
- return /^(1|true|yes|on)$/i.test(String(value).trim());
40
- }
41
-
42
- function safeText(value, maximum = 160) {
43
- return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maximum);
44
- }
45
-
46
- function boundedInteger(value, minimum, maximum, fallback) {
47
- const number = Number(value);
48
- return Number.isFinite(number)
49
- ? Math.max(minimum, Math.min(maximum, Math.floor(number)))
50
- : fallback;
51
- }
52
-
53
- function encodeBase64Url(value) {
54
- return (Buffer.isBuffer(value) ? value : Buffer.from(value)).toString('base64url');
55
- }
56
-
57
- function decodeCanonicalBase64Url(value, expectedBytes = 0, maximumBytes = 1024) {
58
- const text = String(value || '');
59
- if (!text || !/^[A-Za-z0-9_-]+$/.test(text)) {
60
- throw relayError('relay-invalid-base64url');
61
- }
62
- const decoded = Buffer.from(text, 'base64url');
63
- if (decoded.length > maximumBytes
64
- || (expectedBytes > 0 && decoded.length !== expectedBytes)
65
- || encodeBase64Url(decoded) !== text) {
66
- throw relayError('relay-invalid-base64url');
67
- }
68
- return decoded;
69
- }
70
-
71
- function relayError(code) {
72
- const error = new Error(code);
73
- error.code = code;
74
- return error;
75
- }
76
-
77
- function requirePairToken(pairToken) {
78
- const token = String(pairToken || '');
79
- if (!token || Buffer.byteLength(token, 'utf8') > 1024) {
80
- throw relayError('relay-pair-token-required');
81
- }
82
- return token;
83
- }
84
-
85
- function requireRoomId(roomId) {
86
- const value = String(roomId || '');
87
- if (!value || value.length > 128 || !/^[A-Za-z0-9_-]+$/.test(value)) {
88
- throw relayError('relay-room-invalid');
89
- }
90
- return value;
91
- }
92
-
93
- function requirePeerId(peerId) {
94
- const value = String(peerId || '');
95
- decodeCanonicalBase64Url(value, RELAY_PEER_ID_BYTES, RELAY_PEER_ID_BYTES);
96
- return value;
97
- }
98
-
99
- function requirePublicKey(publicKey) {
100
- const value = String(publicKey || '');
101
- const decoded = decodeCanonicalBase64Url(value, RELAY_PUBLIC_KEY_BYTES, RELAY_PUBLIC_KEY_BYTES);
102
- if (decoded[0] !== 0x04) {
103
- throw relayError('relay-public-key-invalid');
104
- }
105
- return { text: value, bytes: decoded };
106
- }
107
-
108
- function requireDirection(direction) {
109
- const value = String(direction || '');
110
- if (value !== RELAY_CONTROL_DIRECTIONS.clientToHub
111
- && value !== RELAY_CONTROL_DIRECTIONS.hubToClient) {
112
- throw relayError('relay-direction-invalid');
113
- }
114
- return value;
115
- }
116
-
117
- function requireSequence(sequence) {
118
- const value = Number(sequence);
119
- if (!Number.isSafeInteger(value) || value < 1) {
120
- throw relayError('relay-sequence-invalid');
121
- }
122
- return value;
123
- }
124
-
125
- function normalizeSessionKey(sessionKey) {
126
- const key = Buffer.isBuffer(sessionKey)
127
- ? sessionKey
128
- : Buffer.from(sessionKey || []);
129
- if (key.length !== 32) {
130
- throw relayError('relay-session-key-invalid');
131
- }
132
- return key;
133
- }
134
-
135
- function proofMatches(actual, expected) {
136
- let actualBytes;
137
- try {
138
- actualBytes = decodeCanonicalBase64Url(actual, 32, 32);
139
- } catch {
140
- return false;
141
- }
142
- const expectedBytes = decodeCanonicalBase64Url(expected, 32, 32);
143
- return crypto.timingSafeEqual(actualBytes, expectedBytes);
144
- }
145
-
146
- function controlAad(roomId, peerId, direction, sequence) {
147
- return Buffer.from(
148
- `livedesk-relay-data-v1\0${requireRoomId(roomId)}\0${requirePeerId(peerId)}\0${requireDirection(direction)}\0${requireSequence(sequence)}`,
149
- 'utf8');
150
- }
151
-
152
- function sanitizeSocketError(error) {
153
- const code = safeText(error?.code, 80);
154
- return code && /^[A-Z0-9_]+$/.test(code) ? code : 'relay-socket-error';
155
- }
156
-
157
- export function deriveRelayRoomId(pairToken) {
158
- const token = requirePairToken(pairToken);
159
- return crypto
160
- .createHash('sha256')
161
- .update(Buffer.from(`livedesk-relay-room-v1\0${token}`, 'utf8'))
162
- .digest('base64url');
163
- }
164
-
165
- export function deriveRelayRegistrationToken(pairToken, roomId = deriveRelayRoomId(pairToken)) {
166
- const token = requirePairToken(pairToken);
167
- const room = requireRoomId(roomId);
168
- return crypto
169
- .createHmac('sha256', Buffer.from(token, 'utf8'))
170
- .update(Buffer.from(`livedesk-relay-register-v1\0${room}`, 'utf8'))
171
- .digest('base64url');
172
- }
173
-
174
- export function deriveCredentialRelayRoomId(hubId, hubPublicKey) {
175
- const id = safeText(hubId, 128);
176
- const key = decodeCanonicalBase64Url(hubPublicKey, 0, 160).toString('base64url');
177
- if (!id) throw relayError('relay-hub-id-required');
178
- return crypto.createHash('sha256')
179
- .update(Buffer.from(`livedesk-relay-credential-room-v1\0${id}\0${key}`, 'utf8'))
180
- .digest('base64url');
181
- }
182
-
183
- export function deriveCredentialRelayRegistrationToken(hubId, hubPublicKey) {
184
- const roomId = deriveCredentialRelayRoomId(hubId, hubPublicKey);
185
- return crypto.createHash('sha256')
186
- .update(Buffer.from(`livedesk-relay-credential-register-v1\0${roomId}\0${hubPublicKey}`, 'utf8'))
187
- .digest('base64url');
188
- }
189
-
190
- function requireP256SpkiPublicKey(value) {
191
- const der = decodeCanonicalBase64Url(value, 0, 160);
192
- if (der.length < 80) throw relayError('relay-public-key-invalid');
193
- let key;
194
- try {
195
- key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
196
- } catch {
197
- throw relayError('relay-public-key-invalid');
198
- }
199
- if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
200
- throw relayError('relay-public-key-invalid');
201
- }
202
- return { key, text: der.toString('base64url') };
203
- }
204
-
205
- function verifyP256Signature(publicKey, message, signature) {
206
- const bytes = decodeCanonicalBase64Url(signature, 64, 64);
207
- if (!crypto.verify('sha256', Buffer.from(String(message || ''), 'utf8'), {
208
- key: publicKey,
209
- dsaEncoding: 'ieee-p1363'
210
- }, bytes)) {
211
- throw relayError('device-signature-invalid');
212
- }
213
- }
214
-
215
- export function deriveRelayClientProof(pairToken, {
216
- roomId = deriveRelayRoomId(pairToken),
217
- peerId,
218
- clientPublicKey
219
- } = {}) {
220
- const token = requirePairToken(pairToken);
221
- const room = requireRoomId(roomId);
222
- const peer = requirePeerId(peerId);
223
- const clientKey = requirePublicKey(clientPublicKey).text;
224
- return crypto
225
- .createHmac('sha256', Buffer.from(token, 'utf8'))
226
- .update(Buffer.from(`livedesk-relay-client-v1\0${room}\0${peer}\0${clientKey}`, 'utf8'))
227
- .digest('base64url');
228
- }
229
-
230
- export function deriveRelayHubProof(pairToken, {
231
- roomId = deriveRelayRoomId(pairToken),
232
- peerId,
233
- clientPublicKey,
234
- hubPublicKey
235
- } = {}) {
236
- const token = requirePairToken(pairToken);
237
- const room = requireRoomId(roomId);
238
- const peer = requirePeerId(peerId);
239
- const clientKey = requirePublicKey(clientPublicKey).text;
240
- const hubKey = requirePublicKey(hubPublicKey).text;
241
- return crypto
242
- .createHmac('sha256', Buffer.from(token, 'utf8'))
243
- .update(Buffer.from(`livedesk-relay-hub-v1\0${room}\0${peer}\0${clientKey}\0${hubKey}`, 'utf8'))
244
- .digest('base64url');
245
- }
246
-
247
- export function deriveRelaySessionKey(pairToken, {
248
- roomId = deriveRelayRoomId(pairToken),
249
- peerId,
250
- clientPublicKey,
251
- hubPublicKey,
252
- sharedSecret
253
- } = {}) {
254
- const token = requirePairToken(pairToken);
255
- const room = requireRoomId(roomId);
256
- const peer = requirePeerId(peerId);
257
- const clientKey = requirePublicKey(clientPublicKey).text;
258
- const hubKey = requirePublicKey(hubPublicKey).text;
259
- const secret = Buffer.from(sharedSecret || []);
260
- if (secret.length < 16 || secret.length > 256) {
261
- throw relayError('relay-shared-secret-invalid');
262
- }
263
- const salt = crypto
264
- .createHmac('sha256', Buffer.from(token, 'utf8'))
265
- .update(Buffer.from(`livedesk-relay-salt-v1\0${room}\0${peer}`, 'utf8'))
266
- .digest();
267
- const info = Buffer.from(`livedesk-relay-e2e-v1\0${clientKey}\0${hubKey}`, 'utf8');
268
- return Buffer.from(crypto.hkdfSync('sha256', secret, salt, info, 32));
269
- }
270
-
271
- export function encryptRelayControlPayload({
272
- sessionKey,
273
- roomId,
274
- peerId,
275
- direction,
276
- sequence,
277
- plaintext,
278
- nonce = crypto.randomBytes(RELAY_NONCE_BYTES)
279
- } = {}) {
280
- const key = normalizeSessionKey(sessionKey);
281
- const cleartext = Buffer.isBuffer(plaintext)
282
- ? plaintext
283
- : Buffer.from(plaintext || []);
284
- if (cleartext.length < 1 || cleartext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
285
- throw relayError('relay-plaintext-size-invalid');
286
- }
287
- const nonceBytes = Buffer.isBuffer(nonce)
288
- ? nonce
289
- : Buffer.from(nonce || []);
290
- if (nonceBytes.length !== RELAY_NONCE_BYTES) {
291
- throw relayError('relay-nonce-invalid');
292
- }
293
- const cipher = crypto.createCipheriv('aes-256-gcm', key, nonceBytes, { authTagLength: RELAY_TAG_BYTES });
294
- cipher.setAAD(controlAad(roomId, peerId, direction, sequence));
295
- const update = cipher.update(cleartext);
296
- const final = cipher.final();
297
- const ciphertext = final.length > 0
298
- ? Buffer.concat([update, final], update.length + final.length)
299
- : update;
300
- const tag = cipher.getAuthTag();
301
- const envelope = Buffer.allocUnsafe(nonceBytes.length + ciphertext.length + tag.length);
302
- nonceBytes.copy(envelope, 0);
303
- ciphertext.copy(envelope, nonceBytes.length);
304
- tag.copy(envelope, nonceBytes.length + ciphertext.length);
305
- return encodeBase64Url(envelope);
306
- }
307
-
308
- export function decryptRelayControlPayload({
309
- sessionKey,
310
- roomId,
311
- peerId,
312
- direction,
313
- sequence,
314
- payload
315
- } = {}) {
316
- const key = normalizeSessionKey(sessionKey);
317
- let envelope;
318
- try {
319
- envelope = decodeCanonicalBase64Url(
320
- payload,
321
- 0,
322
- RELAY_NONCE_BYTES + RELAY_CONTROL_MAX_PLAINTEXT_BYTES + RELAY_TAG_BYTES);
323
- } catch {
324
- throw relayError('relay-data-invalid');
325
- }
326
- if (envelope.length <= RELAY_NONCE_BYTES + RELAY_TAG_BYTES) {
327
- throw relayError('relay-data-invalid');
328
- }
329
- const nonce = envelope.subarray(0, RELAY_NONCE_BYTES);
330
- const ciphertext = envelope.subarray(RELAY_NONCE_BYTES, envelope.length - RELAY_TAG_BYTES);
331
- const tag = envelope.subarray(envelope.length - RELAY_TAG_BYTES);
332
- try {
333
- const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: RELAY_TAG_BYTES });
334
- decipher.setAAD(controlAad(roomId, peerId, direction, sequence));
335
- decipher.setAuthTag(tag);
336
- const update = decipher.update(ciphertext);
337
- const final = decipher.final();
338
- const plaintext = final.length > 0
339
- ? Buffer.concat([update, final], update.length + final.length)
340
- : update;
341
- if (plaintext.length < 1 || plaintext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
342
- throw relayError('relay-plaintext-size-invalid');
343
- }
344
- return plaintext;
345
- } catch (error) {
346
- if (error?.code === 'relay-plaintext-size-invalid') throw error;
347
- throw relayError('relay-data-authentication-failed');
348
- }
349
- }
350
-
351
- export class HubRelayVirtualSocket extends EventEmitter {
352
- constructor(owner, peerId, remoteAddress, remotePort) {
353
- super();
354
- this.owner = owner;
355
- this.peerId = peerId;
356
- this.remoteAddress = remoteAddress;
357
- this.remotePort = remotePort;
358
- this.destroyed = false;
359
- this.connecting = false;
360
- this.readable = true;
361
- this.writable = true;
362
- this.__liveDeskRelayControl = true;
363
- this.__liveDeskRelayPeerId = peerId;
364
- }
365
-
366
- setNoDelay() {
367
- return this;
368
- }
369
-
370
- setKeepAlive() {
371
- return this;
372
- }
373
-
374
- address() {
375
- return {
376
- address: this.remoteAddress,
377
- family: 'relay',
378
- port: this.remotePort
379
- };
380
- }
381
-
382
- write(chunk, encoding, callback) {
383
- let completion = callback;
384
- let textEncoding = encoding;
385
- if (typeof encoding === 'function') {
386
- completion = encoding;
387
- textEncoding = undefined;
388
- }
389
- if (this.destroyed) {
390
- if (typeof completion === 'function') {
391
- queueMicrotask(() => completion(relayError('relay-virtual-socket-closed')));
392
- }
393
- return false;
394
- }
395
- const payload = Buffer.isBuffer(chunk)
396
- ? chunk
397
- : Buffer.from(String(chunk ?? ''), typeof textEncoding === 'string' ? textEncoding : 'utf8');
398
- const accepted = this.owner.sendPeerPlaintext(this.peerId, payload);
399
- if (typeof completion === 'function') {
400
- queueMicrotask(() => completion(accepted ? undefined : relayError('relay-write-rejected')));
401
- }
402
- return accepted && !this.owner.isPeerBackpressured(this.peerId);
403
- }
404
-
405
- end(chunk, encoding, callback) {
406
- if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
407
- this.write(chunk, typeof encoding === 'string' ? encoding : undefined);
408
- }
409
- const completion = typeof chunk === 'function'
410
- ? chunk
411
- : typeof encoding === 'function'
412
- ? encoding
413
- : callback;
414
- this.destroy();
415
- if (typeof completion === 'function') queueMicrotask(completion);
416
- return this;
417
- }
418
-
419
- destroy(error) {
420
- if (!this.destroyed) {
421
- this.owner.closePeer(this.peerId, 'virtual-socket-destroyed', {
422
- notify: true,
423
- error: error instanceof Error ? error : null
424
- });
425
- }
426
- return this;
427
- }
428
-
429
- _deliver(plaintext) {
430
- if (this.destroyed) return;
431
- if (Buffer.isBuffer(plaintext)) {
432
- this.emit('data', plaintext);
433
- return;
434
- }
435
- this.owner.recordPlaintextDeliveryCopy();
436
- this.emit('data', Buffer.from(plaintext));
437
- }
438
-
439
- _drain() {
440
- if (!this.destroyed) this.emit('drain');
441
- }
442
-
443
- _close(reason, error = null) {
444
- if (this.destroyed) return;
445
- this.destroyed = true;
446
- this.readable = false;
447
- this.writable = false;
448
- this.closeReason = safeText(reason, 120) || 'relay-peer-closed';
449
- if (error && this.listenerCount('error') > 0) this.emit('error', error);
450
- this.emit('close', error ? true : false);
451
- }
452
- }
453
-
454
- export function createHubRelayControl({
455
- env = process.env,
456
- pairToken,
457
- secureSession = null,
458
- onPeerSocket = () => {},
459
- logEvent = () => {},
460
- logWarn = () => {},
461
- connect = options => net.createConnection(options),
462
- randomBytes = size => crypto.randomBytes(size)
463
- } = {}) {
464
- const secureMode = secureSession?.mode === 'credential'
465
- ? 'credential'
466
- : secureSession?.mode === 'enroll'
467
- ? 'enroll'
468
- : '';
469
- const authority = secureMode ? secureSession?.authority : null;
470
- if (secureMode && (!authority?.verifyCredential || !authority?.issueCredential || !authority?.signHubMessage)) {
471
- throw relayError('relay-device-authority-required');
472
- }
473
- const token = secureMode === 'credential'
474
- ? authority.hubPublicKey
475
- : requirePairToken(pairToken);
476
- const roomId = secureMode === 'credential'
477
- ? deriveCredentialRelayRoomId(authority.hubId, authority.hubPublicKey)
478
- : deriveRelayRoomId(token);
479
- const registrationToken = secureMode === 'credential'
480
- ? deriveCredentialRelayRegistrationToken(authority.hubId, authority.hubPublicKey)
481
- : deriveRelayRegistrationToken(token, roomId);
482
- const enabled = isEnabled(
483
- env.LIVEDESK_RELAY_ENABLED,
484
- isEnabled(env.LIVEDESK_UDP_ENABLED, true));
485
- const host = safeText(
486
- env.LIVEDESK_RELAY_HOST
487
- || env.LIVEDESK_UDP_RENDEZVOUS_HOST
488
- || 'www.gnsoftech.com',
489
- 253);
490
- const port = boundedInteger(
491
- env.LIVEDESK_RELAY_PORT || env.LIVEDESK_UDP_RENDEZVOUS_PORT,
492
- 1,
493
- 65535,
494
- 5199);
495
- const maxPeers = boundedInteger(env.LIVEDESK_RELAY_MAX_PEERS, 1, 1024, DEFAULT_MAX_PEERS);
496
- const maxQueueBytes = boundedInteger(
497
- env.LIVEDESK_RELAY_MAX_QUEUE_BYTES,
498
- RELAY_MAX_LINE_BYTES,
499
- 32 * 1024 * 1024,
500
- DEFAULT_MAX_QUEUE_BYTES);
501
- const maxQueueMessages = boundedInteger(
502
- env.LIVEDESK_RELAY_MAX_QUEUE_MESSAGES,
503
- 1,
504
- 4096,
505
- DEFAULT_MAX_QUEUE_MESSAGES);
506
- const maxPeerQueueBytes = boundedInteger(
507
- env.LIVEDESK_RELAY_MAX_PEER_QUEUE_BYTES,
508
- RELAY_MAX_LINE_BYTES,
509
- 8 * 1024 * 1024,
510
- DEFAULT_MAX_PEER_QUEUE_BYTES);
511
- const maxPeerQueueMessages = boundedInteger(
512
- env.LIVEDESK_RELAY_MAX_PEER_QUEUE_MESSAGES,
513
- 1,
514
- 512,
515
- DEFAULT_MAX_PEER_QUEUE_MESSAGES);
516
- const reconnectMinMs = boundedInteger(
517
- env.LIVEDESK_RELAY_RECONNECT_MIN_MS,
518
- 50,
519
- 60_000,
520
- DEFAULT_RECONNECT_MIN_MS);
521
- const reconnectMaxMs = boundedInteger(
522
- env.LIVEDESK_RELAY_RECONNECT_MAX_MS,
523
- reconnectMinMs,
524
- 5 * 60_000,
525
- DEFAULT_RECONNECT_MAX_MS);
526
- const connectTimeoutMs = boundedInteger(
527
- env.LIVEDESK_RELAY_CONNECT_TIMEOUT_MS,
528
- 250,
529
- 30_000,
530
- DEFAULT_CONNECT_TIMEOUT_MS);
531
- const peers = new Map();
532
- const retiredPeerIds = new Map();
533
- const connectionOwners = new Map();
534
- const sendQueue = [];
535
- let sendQueueHead = 0;
536
- const peerQueueStats = new Map();
537
- const counters = {
538
- connections: 0,
539
- reconnects: 0,
540
- handshakes: 0,
541
- rejectedPeers: 0,
542
- encryptedMessagesReceived: 0,
543
- encryptedMessagesSent: 0,
544
- queueDrops: 0,
545
- receiveLineAssemblyCopies: 0,
546
- receiveLineAssemblyBytes: 0,
547
- plaintextDeliveryCopies: 0,
548
- peerQueuePurges: 0,
549
- peerQueuePurgedBytes: 0,
550
- staleConnectionEventDrops: 0,
551
- socketOwnersCreated: 0,
552
- socketOwnersClosed: 0,
553
- peerOwnersCreated: 0,
554
- peerOwnersClosed: 0
555
- };
556
-
557
- let desired = false;
558
- let started = false;
559
- let closing = false;
560
- let connection = null;
561
- let connectionGeneration = 0;
562
- let reconnectTimer = null;
563
- let connectAttemptTimer = null;
564
- let closeSocketTimer = null;
565
- let closeTimedOut = false;
566
- let reconnectAttempt = 0;
567
- let receiveChunks = [];
568
- let receiveBytes = 0;
569
- let receiveChunkHighWater = 0;
570
- let receiveByteHighWater = 0;
571
- let queuedBytes = 0;
572
- let queuedMessageHighWater = 0;
573
- let queuedByteHighWater = 0;
574
- let socketWritableByteHighWater = 0;
575
- let totalOutboundByteHighWater = 0;
576
- let socketOwnerHighWater = 0;
577
- let peerOwnerHighWater = 0;
578
- let timerOwnerHighWater = 0;
579
- let awaitingDrain = false;
580
- let lastError = '';
581
- let lastDisconnectReason = '';
582
- let lastConnectedAt = '';
583
- let lastTransitionAt = '';
584
- let state = enabled ? 'idle' : 'disabled';
585
-
586
- function transition(nextState, error = '') {
587
- state = nextState;
588
- lastTransitionAt = new Date().toISOString();
589
- if (error) lastError = safeText(error, 120);
590
- }
591
-
592
- function activeTimerCount() {
593
- return Number(Boolean(reconnectTimer))
594
- + Number(Boolean(connectAttemptTimer))
595
- + Number(Boolean(closeSocketTimer));
596
- }
597
-
598
- function recordTimerOwnerHighWater() {
599
- timerOwnerHighWater = Math.max(
600
- timerOwnerHighWater,
601
- activeTimerCount());
602
- }
603
-
604
- function addConnectionOwner(socket, generation) {
605
- if (connectionOwners.has(socket)) {
606
- throw relayError('relay-socket-owner-duplicate');
607
- }
608
- connectionOwners.set(socket, {
609
- socket,
610
- generation,
611
- phase: 'current'
612
- });
613
- counters.socketOwnersCreated += 1;
614
- socketOwnerHighWater = Math.max(
615
- socketOwnerHighWater,
616
- connectionOwners.size);
617
- }
618
-
619
- function markConnectionOwnerClosing(socket, generation) {
620
- const owner = connectionOwners.get(socket);
621
- if (!owner || owner.generation !== generation) return false;
622
- owner.phase = 'closing';
623
- return true;
624
- }
625
-
626
- function queueStats(peerId) {
627
- if (!peerId) return { messages: 0, bytes: 0, backpressured: false };
628
- let stats = peerQueueStats.get(peerId);
629
- if (!stats) {
630
- stats = { messages: 0, bytes: 0, backpressured: false };
631
- peerQueueStats.set(peerId, stats);
632
- }
633
- return stats;
634
- }
635
-
636
- function releaseQueuedItem(item) {
637
- queuedBytes = Math.max(0, queuedBytes - item.buffer.length);
638
- if (!item.peerId) return;
639
- const stats = peerQueueStats.get(item.peerId);
640
- if (!stats) return;
641
- stats.messages = Math.max(0, stats.messages - 1);
642
- stats.bytes = Math.max(0, stats.bytes - item.buffer.length);
643
- }
644
-
645
- function queuedMessageCount() {
646
- return sendQueue.length - sendQueueHead;
647
- }
648
-
649
- function socketWritableBytes(socket = connection) {
650
- const value = Number(socket?.writableLength);
651
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
652
- }
653
-
654
- function recordOutboundHighWater(socket = connection) {
655
- const writableBytes = socketWritableBytes(socket);
656
- socketWritableByteHighWater = Math.max(
657
- socketWritableByteHighWater,
658
- writableBytes);
659
- totalOutboundByteHighWater = Math.max(
660
- totalOutboundByteHighWater,
661
- queuedBytes + writableBytes);
662
- }
663
-
664
- function dequeueQueuedItem() {
665
- if (queuedMessageCount() <= 0) return null;
666
- const item = sendQueue[sendQueueHead];
667
- sendQueue[sendQueueHead] = null;
668
- sendQueueHead += 1;
669
- if (sendQueueHead >= 64 && sendQueueHead * 2 >= sendQueue.length) {
670
- sendQueue.splice(0, sendQueueHead);
671
- sendQueueHead = 0;
672
- }
673
- return item;
674
- }
675
-
676
- function clearQueue() {
677
- sendQueue.length = 0;
678
- sendQueueHead = 0;
679
- queuedBytes = 0;
680
- awaitingDrain = false;
681
- peerQueueStats.clear();
682
- }
683
-
684
- function purgePeerQueue(peerId) {
685
- if (!peerId || queuedMessageCount() === 0) return;
686
- let writeIndex = 0;
687
- let purgedBytes = 0;
688
- for (let readIndex = sendQueueHead; readIndex < sendQueue.length; readIndex += 1) {
689
- const item = sendQueue[readIndex];
690
- if (item.peerId === peerId) {
691
- purgedBytes += item.buffer.length;
692
- releaseQueuedItem(item);
693
- continue;
694
- }
695
- sendQueue[writeIndex] = item;
696
- writeIndex += 1;
697
- }
698
- for (let index = writeIndex; index < sendQueue.length; index += 1) {
699
- sendQueue[index] = null;
700
- }
701
- if (purgedBytes > 0) {
702
- sendQueue.length = writeIndex;
703
- sendQueueHead = 0;
704
- counters.peerQueuePurges += 1;
705
- counters.peerQueuePurgedBytes += purgedBytes;
706
- } else if (sendQueueHead > 0) {
707
- sendQueue.splice(0, sendQueueHead);
708
- sendQueueHead = 0;
709
- }
710
- }
711
-
712
- function emitPeerDrains() {
713
- if (awaitingDrain) return;
714
- for (const [peerId, stats] of peerQueueStats) {
715
- if (!stats.backpressured
716
- || stats.messages >= maxPeerQueueMessages
717
- || stats.bytes >= maxPeerQueueBytes) {
718
- continue;
719
- }
720
- stats.backpressured = false;
721
- peers.get(peerId)?.socket?._drain();
722
- }
723
- }
724
-
725
- function flushQueue() {
726
- const socket = connection;
727
- if (!socket || socket.destroyed || !socket.writable || awaitingDrain) return;
728
- while (queuedMessageCount() > 0 && !awaitingDrain) {
729
- const item = dequeueQueuedItem();
730
- if (!item) break;
731
- releaseQueuedItem(item);
732
- let writable = false;
733
- try {
734
- writable = socket.write(item.buffer);
735
- } catch {
736
- socket.destroy();
737
- return;
738
- }
739
- if (!writable) awaitingDrain = true;
740
- recordOutboundHighWater(socket);
741
- }
742
- emitPeerDrains();
743
- }
744
-
745
- function enqueueEnvelope(envelope, peerId = '') {
746
- if (!connection || connection.destroyed || !connection.writable) return false;
747
- let buffer;
748
- try {
749
- buffer = Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8');
750
- } catch {
751
- return false;
752
- }
753
- if (buffer.length > RELAY_MAX_LINE_BYTES) return false;
754
- const stats = queueStats(peerId);
755
- const writableBytes = socketWritableBytes();
756
- if (queuedMessageCount() >= maxQueueMessages
757
- || queuedBytes + writableBytes + buffer.length > maxQueueBytes
758
- || (peerId && (stats.messages >= maxPeerQueueMessages
759
- || stats.bytes + buffer.length > maxPeerQueueBytes))) {
760
- counters.queueDrops += 1;
761
- if (peerId) stats.backpressured = true;
762
- return false;
763
- }
764
- sendQueue.push({ buffer, peerId });
765
- queuedBytes += buffer.length;
766
- queuedMessageHighWater = Math.max(queuedMessageHighWater, queuedMessageCount());
767
- queuedByteHighWater = Math.max(queuedByteHighWater, queuedBytes);
768
- recordOutboundHighWater();
769
- if (peerId) {
770
- stats.messages += 1;
771
- stats.bytes += buffer.length;
772
- }
773
- flushQueue();
774
- if (peerId && awaitingDrain) stats.backpressured = true;
775
- return true;
776
- }
777
-
778
- function rememberRetiredPeer(peerId) {
779
- if (!peerId) return;
780
- retiredPeerIds.delete(peerId);
781
- retiredPeerIds.set(peerId, Date.now());
782
- while (retiredPeerIds.size > maxPeers * 4) {
783
- retiredPeerIds.delete(retiredPeerIds.keys().next().value);
784
- }
785
- }
786
-
787
- function sendRelayClose(peerId, reason) {
788
- if (!connection || connection.destroyed) return false;
789
- return enqueueEnvelope({
790
- type: 'relay.close',
791
- protocol: RELAY_CONTROL_PROTOCOL,
792
- roomId,
793
- peerId,
794
- reason: safeText(reason, 80) || 'relay-peer-closed'
795
- });
796
- }
797
-
798
- function closePeer(peerId, reason = 'relay-peer-closed', {
799
- notify = true,
800
- error = null
801
- } = {}) {
802
- const id = safeText(peerId, 64);
803
- const peer = peers.get(id);
804
- if (!peer) {
805
- if (notify) {
806
- try {
807
- sendRelayClose(requirePeerId(id), reason);
808
- } catch {
809
- // Invalid untrusted routing fields are ignored.
810
- }
811
- }
812
- return false;
813
- }
814
- peers.delete(id);
815
- counters.peerOwnersClosed += 1;
816
- rememberRetiredPeer(id);
817
- purgePeerQueue(id);
818
- peerQueueStats.delete(id);
819
- if (notify) sendRelayClose(id, reason);
820
- if (Buffer.isBuffer(peer.sessionKey)) peer.sessionKey.fill(0);
821
- peer.socket?._close(reason, error);
822
- return true;
823
- }
824
-
825
- function rejectPeer(peerId, reason) {
826
- counters.rejectedPeers += 1;
827
- closePeer(peerId, reason, { notify: true });
828
- }
829
-
830
- function closeAllPeers(reason) {
831
- for (const peerId of [...peers.keys()]) {
832
- closePeer(peerId, reason, { notify: false });
833
- }
834
- }
835
-
836
- function isPeerBackpressured(peerId) {
837
- return peerQueueStats.get(String(peerId || ''))?.backpressured === true;
838
- }
839
-
840
- function sendPeerPlaintext(peerId, plaintext) {
841
- const peer = peers.get(String(peerId || ''));
842
- if (!peer || peer.socket.destroyed || peer.handshakeState !== 'established') return false;
843
- const cleartext = Buffer.isBuffer(plaintext)
844
- ? plaintext
845
- : Buffer.from(plaintext || []);
846
- if (cleartext.length < 1 || cleartext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
847
- rejectPeer(peer.peerId, 'relay-plaintext-size-invalid');
848
- return false;
849
- }
850
- const sequence = peer.sendSequence + 1;
851
- let payload;
852
- try {
853
- payload = encryptRelayControlPayload({
854
- sessionKey: peer.sessionKey,
855
- roomId,
856
- peerId: peer.peerId,
857
- direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
858
- sequence,
859
- plaintext: cleartext,
860
- nonce: randomBytes(RELAY_NONCE_BYTES)
861
- });
862
- } catch {
863
- rejectPeer(peer.peerId, 'relay-encryption-failed');
864
- return false;
865
- }
866
- const accepted = enqueueEnvelope({
867
- type: 'relay.data',
868
- protocol: RELAY_CONTROL_PROTOCOL,
869
- roomId,
870
- peerId: peer.peerId,
871
- direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
872
- sequence,
873
- payload
874
- }, peer.peerId);
875
- if (!accepted) {
876
- counters.queueDrops += 1;
877
- closePeer(peer.peerId, 'relay-backpressure-limit', { notify: false });
878
- return false;
879
- }
880
- peer.sendSequence = sequence;
881
- peer.lastActivityAt = Date.now();
882
- counters.encryptedMessagesSent += 1;
883
- return true;
884
- }
885
-
886
- function handleClientHandshake(message) {
887
- if (secureMode) {
888
- handleSecureClientHandshake(message);
889
- return;
890
- }
891
- let peerId = '';
892
- try {
893
- peerId = requirePeerId(message.peerId);
894
- if (message.direction
895
- && requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
896
- throw relayError('relay-direction-invalid');
897
- }
898
- const stage = safeText(message.stage || message.handshake, 40).toLowerCase();
899
- if (stage && stage !== 'client-hello') {
900
- throw relayError('relay-handshake-stage-invalid');
901
- }
902
- if (peers.has(peerId) || retiredPeerIds.has(peerId)) {
903
- throw relayError('relay-handshake-replay');
904
- }
905
- if (peers.size >= maxPeers) {
906
- throw relayError('relay-peer-capacity');
907
- }
908
- const clientPublic = requirePublicKey(message.clientPublicKey);
909
- const expectedProof = deriveRelayClientProof(token, {
910
- roomId,
911
- peerId,
912
- clientPublicKey: clientPublic.text
913
- });
914
- if (!proofMatches(message.proof, expectedProof)) {
915
- throw relayError('relay-client-proof-invalid');
916
- }
917
- const ecdh = crypto.createECDH('prime256v1');
918
- ecdh.generateKeys();
919
- const hubPublicKey = ecdh.getPublicKey(null, 'uncompressed').toString('base64url');
920
- const sharedSecret = ecdh.computeSecret(clientPublic.bytes);
921
- let sessionKey;
922
- try {
923
- sessionKey = deriveRelaySessionKey(token, {
924
- roomId,
925
- peerId,
926
- clientPublicKey: clientPublic.text,
927
- hubPublicKey,
928
- sharedSecret
929
- });
930
- } finally {
931
- sharedSecret.fill(0);
932
- }
933
- const proof = deriveRelayHubProof(token, {
934
- roomId,
935
- peerId,
936
- clientPublicKey: clientPublic.text,
937
- hubPublicKey
938
- });
939
- const remoteAddress = `relay://${host}:${port}/${peerId}`;
940
- const peer = {
941
- peerId,
942
- clientPublicKey: clientPublic.text,
943
- hubPublicKey,
944
- sessionKey,
945
- receiveSequence: 0,
946
- sendSequence: 0,
947
- handshakeState: 'established',
948
- createdAt: Date.now(),
949
- lastActivityAt: Date.now(),
950
- socket: null
951
- };
952
- peer.socket = new HubRelayVirtualSocket(api, peerId, remoteAddress, port);
953
- peers.set(peerId, peer);
954
- counters.peerOwnersCreated += 1;
955
- peerOwnerHighWater = Math.max(peerOwnerHighWater, peers.size);
956
- const sent = enqueueEnvelope({
957
- type: 'relay.handshake',
958
- protocol: RELAY_CONTROL_PROTOCOL,
959
- roomId,
960
- peerId,
961
- direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
962
- stage: 'hub-hello',
963
- clientPublicKey: clientPublic.text,
964
- hubPublicKey,
965
- proof
966
- }, peerId);
967
- if (!sent) {
968
- closePeer(peerId, 'relay-handshake-backpressure', { notify: false });
969
- return;
970
- }
971
- try {
972
- onPeerSocket(peer.socket, {
973
- protocol: RELAY_CONTROL_PROTOCOL,
974
- peerId,
975
- endpoint: `${host}:${port}`
976
- });
977
- } catch {
978
- closePeer(peerId, 'relay-peer-handler-failed', { notify: true });
979
- return;
980
- }
981
- counters.handshakes += 1;
982
- } catch (error) {
983
- counters.rejectedPeers += 1;
984
- if (peerId) {
985
- rememberRetiredPeer(peerId);
986
- sendRelayClose(peerId, safeText(error?.code, 80) || 'relay-handshake-rejected');
987
- }
988
- }
989
- }
990
-
991
- function handleSecureClientHandshake(message) {
992
- let peerId = '';
993
- try {
994
- peerId = requirePeerId(message.peerId);
995
- if (message.direction && requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
996
- throw relayError('relay-direction-invalid');
997
- }
998
- if (safeText(message.stage, 40).toLowerCase() !== 'client-hello') throw relayError('relay-secure-stage-invalid');
999
- if (message.securityProtocol !== SECURE_SESSION_PROTOCOL) {
1000
- lastError = `relay-secure-protocol:${safeText(message.securityProtocol, 80) || 'missing'}`;
1001
- throw relayError(message.securityProtocol === undefined
1002
- ? 'relay-secure-protocol-missing'
1003
- : 'relay-secure-protocol-invalid');
1004
- }
1005
- if (peers.has(peerId) || retiredPeerIds.has(peerId)) throw relayError('relay-handshake-replay');
1006
- if (peers.size >= maxPeers) throw relayError('relay-peer-capacity');
1007
- const expectedMode = secureMode === 'credential' ? 'resume' : 'enroll';
1008
- if (message.mode !== expectedMode) throw relayError('relay-secure-mode-invalid');
1009
- const current = Date.now();
1010
- const timestamp = Number(message.timestamp);
1011
- if (!Number.isSafeInteger(timestamp) || Math.abs(current - timestamp) > SECURE_SESSION_MAX_CLOCK_SKEW_MS) {
1012
- throw relayError('secure-handshake-timestamp-invalid');
1013
- }
1014
- const channel = normalizeSecureChannel(message.channel);
1015
- const deviceId = safeText(message.deviceId, 128);
1016
- if (!deviceId) throw relayError('device-id-required');
1017
- const devicePublic = requireP256SpkiPublicKey(message.devicePublicKey);
1018
- const clientEphemeral = requireP256SpkiPublicKey(message.clientEphemeralPublicKey);
1019
- const clientTranscript = secureClientTranscript(message);
1020
- const identity = secureSession.getIdentity?.() || {};
1021
- const accountId = safeText(identity.accountId, 128);
1022
- if (!accountId) throw relayError('secure-account-session-required');
1023
-
1024
- let issued;
1025
- let enrollmentSecret = '';
1026
- if (expectedMode === 'enroll') {
1027
- enrollmentSecret = String(secureSession.getEnrollmentToken?.() || token);
1028
- if (!fixedTimeBase64UrlEqual(message.enrollmentProof, enrollmentProof(enrollmentSecret, clientTranscript), 32)) {
1029
- throw relayError('secure-enrollment-proof-invalid');
1030
- }
1031
- verifyP256Signature(devicePublic.key, clientTranscript, message.deviceSignature);
1032
- authority.assertEnrollmentAllowed?.({ accountId, deviceId, devicePublicKey: devicePublic.text });
1033
- if (!secureSession.consumeEnrollmentToken?.(enrollmentSecret)) throw relayError('secure-enrollment-token-used');
1034
- issued = authority.issueCredential({ accountId, deviceId, devicePublicKey: devicePublic.text });
1035
- } else {
1036
- const verified = authority.verifyCredential(message.credential, { accountId, deviceId });
1037
- if (verified.payload.devicePublicKey !== devicePublic.text) throw relayError('device-credential-binding-invalid');
1038
- authority.verifyDeviceSignature(verified, clientTranscript, message.deviceSignature);
1039
- issued = { credential: verified.text, payload: verified.payload };
1040
- }
1041
-
1042
- const { privateKey: serverPrivate, publicKey: serverPublic } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
1043
- const response = {
1044
- type: 'relay.handshake',
1045
- protocol: RELAY_CONTROL_PROTOCOL,
1046
- roomId,
1047
- peerId,
1048
- direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
1049
- stage: 'hub-hello',
1050
- securityProtocol: SECURE_SESSION_PROTOCOL,
1051
- sessionId: crypto.randomBytes(16).toString('base64url'),
1052
- channel,
1053
- timestamp: current,
1054
- serverNonce: crypto.randomBytes(16).toString('base64url'),
1055
- serverEphemeralPublicKey: serverPublic.export({ format: 'der', type: 'spki' }).toString('base64url'),
1056
- credential: issued.credential,
1057
- hubId: authority.hubId,
1058
- accountId,
1059
- deviceId
1060
- };
1061
- const serverTranscript = secureServerTranscript(response, clientTranscript);
1062
- response.hubSignature = authority.signHubMessage(serverTranscript);
1063
- if (expectedMode === 'enroll') response.enrollmentServerProof = enrollmentProof(enrollmentSecret, serverTranscript);
1064
- const sharedSecret = crypto.diffieHellman({ privateKey: serverPrivate, publicKey: clientEphemeral.key });
1065
- let sessionKey;
1066
- try {
1067
- sessionKey = deriveSecureSessionKey({
1068
- sharedSecret,
1069
- clientTranscript,
1070
- serverTranscript,
1071
- sessionId: response.sessionId,
1072
- channel
1073
- });
1074
- } finally {
1075
- sharedSecret.fill(0);
1076
- }
1077
- const remoteAddress = `relay://${host}:${port}/${peerId}`;
1078
- const peer = {
1079
- peerId,
1080
- sessionKey,
1081
- receiveSequence: 0,
1082
- sendSequence: 0,
1083
- handshakeState: 'established',
1084
- createdAt: current,
1085
- lastActivityAt: current,
1086
- socket: null
1087
- };
1088
- peer.socket = new HubRelayVirtualSocket(api, peerId, remoteAddress, port);
1089
- peer.socket.__liveDeskSecurityContext = Object.freeze({
1090
- protocol: SECURE_SESSION_PROTOCOL,
1091
- accountId,
1092
- hubId: authority.hubId,
1093
- deviceId,
1094
- channel,
1095
- credentialSerial: issued.payload.serial,
1096
- authenticated: true,
1097
- encrypted: true,
1098
- transport: 'relay'
1099
- });
1100
- peers.set(peerId, peer);
1101
- counters.peerOwnersCreated += 1;
1102
- peerOwnerHighWater = Math.max(peerOwnerHighWater, peers.size);
1103
- if (!enqueueEnvelope(response, peerId)) {
1104
- closePeer(peerId, 'relay-handshake-backpressure', { notify: false });
1105
- return;
1106
- }
1107
- onPeerSocket(peer.socket, {
1108
- protocol: RELAY_CONTROL_PROTOCOL,
1109
- securityProtocol: SECURE_SESSION_PROTOCOL,
1110
- peerId,
1111
- endpoint: `${host}:${port}`,
1112
- accountId,
1113
- hubId: authority.hubId,
1114
- deviceId,
1115
- channel
1116
- });
1117
- authority.markConnected(deviceId);
1118
- counters.handshakes += 1;
1119
- } catch (error) {
1120
- counters.rejectedPeers += 1;
1121
- if (peerId) {
1122
- rememberRetiredPeer(peerId);
1123
- sendRelayClose(peerId, safeText(error?.code || error?.message, 80) || 'relay-handshake-rejected');
1124
- }
1125
- }
1126
- }
1127
-
1128
- function handleClientData(message) {
1129
- let peerId = '';
1130
- try {
1131
- peerId = safeText(message.peerId, 64);
1132
- const peer = peers.get(peerId);
1133
- if (!peer || peer.handshakeState !== 'established') {
1134
- throw relayError('relay-handshake-required');
1135
- }
1136
- if (requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
1137
- throw relayError('relay-direction-invalid');
1138
- }
1139
- const sequence = requireSequence(message.sequence);
1140
- if (sequence !== peer.receiveSequence + 1) {
1141
- throw relayError('relay-sequence-invalid');
1142
- }
1143
- const plaintext = decryptRelayControlPayload({
1144
- sessionKey: peer.sessionKey,
1145
- roomId,
1146
- peerId,
1147
- direction: RELAY_CONTROL_DIRECTIONS.clientToHub,
1148
- sequence,
1149
- payload: message.payload
1150
- });
1151
- peer.receiveSequence = sequence;
1152
- peer.lastActivityAt = Date.now();
1153
- counters.encryptedMessagesReceived += 1;
1154
- peer.socket._deliver(plaintext);
1155
- } catch (error) {
1156
- if (peerId) rejectPeer(peerId, safeText(error?.code, 80) || 'relay-data-rejected');
1157
- }
1158
- }
1159
-
1160
- function handleRelayEnvelope(message) {
1161
- if (!message || typeof message !== 'object' || Array.isArray(message)) return;
1162
- if (message.type === 'relay.registered') {
1163
- if (message.protocol !== RELAY_CONTROL_PROTOCOL
1164
- || message.roomId !== roomId
1165
- || message.role !== 'hub'
1166
- || safeText(message.peerId, 64) !== ''
1167
- || state !== 'registering') {
1168
- lastError = 'relay-registration-invalid';
1169
- connection?.destroy();
1170
- return;
1171
- }
1172
- clearConnectAttemptTimer();
1173
- reconnectAttempt = 0;
1174
- lastConnectedAt = new Date().toISOString();
1175
- lastError = '';
1176
- transition('registered');
1177
- logEvent('relay', `Encrypted relay control registered at ${host}:${port}`);
1178
- return;
1179
- }
1180
- const peerId = safeText(message.peerId, 64);
1181
- if (message.protocol !== RELAY_CONTROL_PROTOCOL || message.roomId !== roomId) {
1182
- if (peerId) rejectPeer(peerId, 'relay-routing-mismatch');
1183
- return;
1184
- }
1185
- if (message.type === 'relay.handshake') {
1186
- handleClientHandshake(message);
1187
- return;
1188
- }
1189
- if (message.type === 'relay.data') {
1190
- handleClientData(message);
1191
- return;
1192
- }
1193
- if (message.type === 'relay.close') {
1194
- const reason = safeText(message.reason, 80) || 'relay-server-closed';
1195
- if (!peerId) {
1196
- lastError = reason;
1197
- lastDisconnectReason = reason;
1198
- connection?.destroy();
1199
- return;
1200
- }
1201
- try {
1202
- closePeer(requirePeerId(message.peerId), reason, { notify: false });
1203
- } catch {
1204
- // Invalid untrusted routing fields are ignored.
1205
- }
1206
- }
1207
- }
1208
-
1209
- function clearReceiveChunks() {
1210
- receiveChunks = [];
1211
- receiveBytes = 0;
1212
- }
1213
-
1214
- function retainReceiveTail(incoming, offset) {
1215
- const tail = incoming.subarray(offset);
1216
- if (tail.length === 0) return true;
1217
- if (receiveBytes + tail.length > RELAY_MAX_LINE_BYTES) return false;
1218
- // A tail following a complete line must not retain the complete large
1219
- // TCP chunk. A chunk containing no line is already exact ownership and
1220
- // can be retained without copying.
1221
- const retained = offset > 0 ? Buffer.from(tail) : tail;
1222
- if (receiveChunks.length >= RELAY_MAX_RECEIVE_CHUNKS) return false;
1223
- receiveChunks.push(retained);
1224
- receiveBytes += retained.length;
1225
- receiveChunkHighWater = Math.max(receiveChunkHighWater, receiveChunks.length);
1226
- receiveByteHighWater = Math.max(receiveByteHighWater, receiveBytes);
1227
- return true;
1228
- }
1229
-
1230
- function assembleReceiveLine(segment) {
1231
- if (receiveBytes === 0) return segment;
1232
- const totalBytes = receiveBytes + segment.length;
1233
- const line = Buffer.allocUnsafe(totalBytes);
1234
- let offset = 0;
1235
- for (const chunk of receiveChunks) {
1236
- chunk.copy(line, offset);
1237
- offset += chunk.length;
1238
- }
1239
- segment.copy(line, offset);
1240
- counters.receiveLineAssemblyCopies += 1;
1241
- counters.receiveLineAssemblyBytes += totalBytes;
1242
- clearReceiveChunks();
1243
- return line;
1244
- }
1245
-
1246
- function isCurrentConnectionOwner(socket, generation) {
1247
- return desired
1248
- && !closing
1249
- && connection === socket
1250
- && generation === connectionGeneration
1251
- && !socket.destroyed;
1252
- }
1253
-
1254
- function processRelayLine(socket, generation, line) {
1255
- if (!isCurrentConnectionOwner(socket, generation)) {
1256
- counters.staleConnectionEventDrops += 1;
1257
- return false;
1258
- }
1259
- if (line.length === 0) return true;
1260
- let message;
1261
- try {
1262
- message = JSON.parse(line.toString('utf8'));
1263
- } catch {
1264
- lastError = 'relay-invalid-json';
1265
- socket.destroy();
1266
- return false;
1267
- }
1268
- if (!isCurrentConnectionOwner(socket, generation)) {
1269
- counters.staleConnectionEventDrops += 1;
1270
- return false;
1271
- }
1272
- handleRelayEnvelope(message);
1273
- return true;
1274
- }
1275
-
1276
- function onConnectionData(socket, generation, chunk) {
1277
- if (!isCurrentConnectionOwner(socket, generation)) {
1278
- counters.staleConnectionEventDrops += 1;
1279
- return;
1280
- }
1281
- const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1282
- let offset = 0;
1283
- while (offset < incoming.length) {
1284
- if (!isCurrentConnectionOwner(socket, generation)) {
1285
- counters.staleConnectionEventDrops += 1;
1286
- return;
1287
- }
1288
- const newline = incoming.indexOf(0x0a, offset);
1289
- if (newline < 0) {
1290
- if (!retainReceiveTail(incoming, offset)) {
1291
- lastError = 'relay-line-too-large';
1292
- socket.destroy();
1293
- }
1294
- return;
1295
- }
1296
- const segmentLength = newline - offset;
1297
- if (receiveBytes + segmentLength > RELAY_MAX_LINE_BYTES) {
1298
- lastError = 'relay-line-too-large';
1299
- socket.destroy();
1300
- return;
1301
- }
1302
- const line = assembleReceiveLine(incoming.subarray(offset, newline));
1303
- if (!processRelayLine(socket, generation, line)) return;
1304
- offset = newline + 1;
1305
- }
1306
- }
1307
-
1308
- function scheduleReconnect() {
1309
- if (!desired || closing || reconnectTimer) return;
1310
- reconnectAttempt += 1;
1311
- counters.reconnects += 1;
1312
- const exponential = Math.min(
1313
- reconnectMaxMs,
1314
- reconnectMinMs * (2 ** Math.min(10, reconnectAttempt - 1)));
1315
- const jitter = Math.floor(exponential * 0.2 * Math.random());
1316
- const delay = Math.min(reconnectMaxMs, exponential + jitter);
1317
- transition('reconnect-wait');
1318
- reconnectTimer = setTimeout(() => {
1319
- reconnectTimer = null;
1320
- connectNow();
1321
- }, delay);
1322
- reconnectTimer.unref?.();
1323
- recordTimerOwnerHighWater();
1324
- }
1325
-
1326
- function clearConnectAttemptTimer() {
1327
- if (connectAttemptTimer) clearTimeout(connectAttemptTimer);
1328
- connectAttemptTimer = null;
1329
- }
1330
-
1331
- function handleConnectionClosed(socket, generation) {
1332
- const owner = connectionOwners.get(socket);
1333
- if (!owner || owner.generation !== generation) return;
1334
- connectionOwners.delete(socket);
1335
- counters.socketOwnersClosed += 1;
1336
- if (owner.phase === 'closing') return;
1337
- if (connection !== socket || generation !== connectionGeneration) return;
1338
- clearConnectAttemptTimer();
1339
- connection = null;
1340
- const disconnectReason = safeText(lastError, 120) || 'relay-socket-closed';
1341
- lastDisconnectReason = disconnectReason;
1342
- logWarn(
1343
- 'relay',
1344
- `Encrypted relay control disconnected reason=${disconnectReason} peers=${peers.size}`
1345
- );
1346
- clearReceiveChunks();
1347
- clearQueue();
1348
- closeAllPeers('relay-connection-lost');
1349
- if (desired && !closing) scheduleReconnect();
1350
- else transition(enabled ? 'idle' : 'disabled');
1351
- }
1352
-
1353
- function connectNow() {
1354
- if (!enabled || !desired || closing || connection) return;
1355
- transition('connecting');
1356
- const generation = ++connectionGeneration;
1357
- let socket;
1358
- try {
1359
- socket = connect({ host, port });
1360
- } catch (error) {
1361
- lastError = sanitizeSocketError(error);
1362
- scheduleReconnect();
1363
- return;
1364
- }
1365
- connection = socket;
1366
- addConnectionOwner(socket, generation);
1367
- socket.setNoDelay?.(true);
1368
- socket.setKeepAlive?.(true, 10_000);
1369
- socket.once('connect', () => {
1370
- if (connection !== socket || generation !== connectionGeneration || !desired) return;
1371
- counters.connections += 1;
1372
- transition('registering');
1373
- const registered = enqueueEnvelope({
1374
- type: 'relay.register',
1375
- protocol: RELAY_CONTROL_PROTOCOL,
1376
- roomId,
1377
- role: 'hub',
1378
- peerId: '',
1379
- ack: true,
1380
- token: registrationToken
1381
- });
1382
- if (!registered) socket.destroy();
1383
- });
1384
- socket.on('data', chunk => onConnectionData(socket, generation, chunk));
1385
- socket.on('drain', () => {
1386
- if (connection !== socket || generation !== connectionGeneration) return;
1387
- awaitingDrain = false;
1388
- flushQueue();
1389
- });
1390
- socket.once('error', error => {
1391
- if (connection !== socket || generation !== connectionGeneration) return;
1392
- lastError = sanitizeSocketError(error);
1393
- logWarn('relay', `Encrypted relay control connection failed code=${lastError}`);
1394
- });
1395
- socket.once('close', () => handleConnectionClosed(socket, generation));
1396
- connectAttemptTimer = setTimeout(() => {
1397
- if (connection !== socket
1398
- || generation !== connectionGeneration
1399
- || socket.destroyed
1400
- || (state !== 'connecting' && state !== 'registering')) return;
1401
- lastError = 'relay-connect-timeout';
1402
- logWarn('relay', `Encrypted relay control connection failed code=${lastError}`);
1403
- socket.destroy();
1404
- }, connectTimeoutMs);
1405
- connectAttemptTimer.unref?.();
1406
- recordTimerOwnerHighWater();
1407
- }
1408
-
1409
- async function start() {
1410
- if (!enabled || started) return getStatus();
1411
- if (closing || closeTimedOut || connectionOwners.size > 0) {
1412
- throw relayError('relay-close-nonterminal');
1413
- }
1414
- started = true;
1415
- desired = true;
1416
- closing = false;
1417
- connectNow();
1418
- return getStatus();
1419
- }
1420
-
1421
- async function close() {
1422
- const closeDeadlineAt = Date.now() + 1_000;
1423
- desired = false;
1424
- started = false;
1425
- closing = true;
1426
- closeTimedOut = false;
1427
- transition('closing');
1428
- if (reconnectTimer) clearTimeout(reconnectTimer);
1429
- reconnectTimer = null;
1430
- clearConnectAttemptTimer();
1431
- closeAllPeers('hub-shutdown');
1432
- clearQueue();
1433
- const socket = connection;
1434
- const generation = connectionGeneration;
1435
- if (socket) markConnectionOwnerClosing(socket, generation);
1436
- connection = null;
1437
- connectionGeneration += 1;
1438
- clearReceiveChunks();
1439
- const closingOwners = [...connectionOwners.values()];
1440
- if (closingOwners.length > 0) {
1441
- const ownersClosed = new Promise((resolve, reject) => {
1442
- let settled = false;
1443
- const onClose = () => {
1444
- if (connectionOwners.size === 0) settle();
1445
- };
1446
- const settle = () => {
1447
- if (settled) return;
1448
- settled = true;
1449
- if (closeSocketTimer) clearTimeout(closeSocketTimer);
1450
- closeSocketTimer = null;
1451
- for (const owner of closingOwners) {
1452
- owner.socket.off?.('close', onClose);
1453
- }
1454
- resolve();
1455
- };
1456
- const fail = () => {
1457
- if (settled) return;
1458
- settled = true;
1459
- closeSocketTimer = null;
1460
- for (const owner of closingOwners) {
1461
- owner.socket.off?.('close', onClose);
1462
- }
1463
- reject(relayError('relay-close-timeout'));
1464
- };
1465
- for (const owner of closingOwners) {
1466
- owner.phase = 'closing';
1467
- owner.socket.once('close', onClose);
1468
- }
1469
- closeSocketTimer = setTimeout(() => {
1470
- fail();
1471
- }, Math.max(1, closeDeadlineAt - Date.now()));
1472
- recordTimerOwnerHighWater();
1473
- if (connectionOwners.size === 0) settle();
1474
- });
1475
- for (const owner of closingOwners) {
1476
- try {
1477
- owner.socket.end();
1478
- } catch {
1479
- // The exact owner remains in the ledger until close.
1480
- }
1481
- try {
1482
- owner.socket.destroy();
1483
- } catch {
1484
- // Timeout remains explicit and nonterminal.
1485
- }
1486
- }
1487
- try {
1488
- await ownersClosed;
1489
- } catch (error) {
1490
- closeTimedOut = true;
1491
- lastError = 'relay-close-timeout';
1492
- transition('close-timeout', lastError);
1493
- throw error;
1494
- }
1495
- }
1496
- if (connectionOwners.size !== 0) {
1497
- closeTimedOut = true;
1498
- lastError = 'relay-close-nonterminal';
1499
- transition('close-timeout', lastError);
1500
- throw relayError(lastError);
1501
- }
1502
- closeTimedOut = false;
1503
- closing = false;
1504
- lastError = '';
1505
- transition(enabled ? 'idle' : 'disabled');
1506
- }
1507
-
1508
- function getStatus() {
1509
- const writableBytes = socketWritableBytes();
1510
- recordOutboundHighWater();
1511
- return {
1512
- enabled,
1513
- started,
1514
- state,
1515
- connected: !!connection && !connection.destroyed && state === 'registered',
1516
- closing,
1517
- closeTimedOut,
1518
- activeSocketCount: connectionOwners.size,
1519
- currentSocketCount: connection && connectionOwners.has(connection) ? 1 : 0,
1520
- closingSocketCount: [...connectionOwners.values()]
1521
- .filter(owner => owner.phase === 'closing').length,
1522
- activeTimerCount: activeTimerCount(),
1523
- timerOwnerHighWater,
1524
- socketOwnerHighWater,
1525
- socketOwnersCreated: counters.socketOwnersCreated,
1526
- socketOwnersClosed: counters.socketOwnersClosed,
1527
- protocol: RELAY_CONTROL_PROTOCOL,
1528
- endpoint: `${host}:${port}`,
1529
- host,
1530
- port,
1531
- peerCount: peers.size,
1532
- peerOwnerHighWater,
1533
- peerOwnersCreated: counters.peerOwnersCreated,
1534
- peerOwnersClosed: counters.peerOwnersClosed,
1535
- peerQueueCount: peerQueueStats.size,
1536
- queuedMessages: queuedMessageCount(),
1537
- queuedBytes,
1538
- socketWritableBytes: writableBytes,
1539
- totalOutboundBufferedBytes: queuedBytes + writableBytes,
1540
- queuedMessageHighWater,
1541
- queuedByteHighWater,
1542
- socketWritableByteHighWater,
1543
- totalOutboundByteHighWater,
1544
- receiveBufferedChunks: receiveChunks.length,
1545
- receiveBufferedBytes: receiveBytes,
1546
- receiveChunkHighWater,
1547
- receiveByteHighWater,
1548
- receiveLineAssemblyCopyCount: counters.receiveLineAssemblyCopies,
1549
- receiveLineAssemblyBytes: counters.receiveLineAssemblyBytes,
1550
- plaintextDeliveryCopyCount: counters.plaintextDeliveryCopies,
1551
- peerQueuePurgeCount: counters.peerQueuePurges,
1552
- peerQueuePurgedBytes: counters.peerQueuePurgedBytes,
1553
- staleConnectionEventDropCount: counters.staleConnectionEventDrops,
1554
- maxPeers,
1555
- maxQueueMessages,
1556
- maxQueueBytes,
1557
- reconnectCount: counters.reconnects,
1558
- handshakeCount: counters.handshakes,
1559
- rejectedPeerCount: counters.rejectedPeers,
1560
- encryptedMessagesReceived: counters.encryptedMessagesReceived,
1561
- encryptedMessagesSent: counters.encryptedMessagesSent,
1562
- queueDropCount: counters.queueDrops,
1563
- lastConnectedAt,
1564
- lastTransitionAt,
1565
- lastDisconnectReason,
1566
- lastError
1567
- };
1568
- }
1569
-
1570
- function recordPlaintextDeliveryCopy() {
1571
- counters.plaintextDeliveryCopies += 1;
1572
- }
1573
-
1574
- const api = {
1575
- start,
1576
- close,
1577
- getStatus,
1578
- sendPeerPlaintext,
1579
- closePeer,
1580
- isPeerBackpressured,
1581
- recordPlaintextDeliveryCopy
1582
- };
1583
- return api;
1584
- }
1585
-
1586
- export function createSecureHubRelayControl({
1587
- env = process.env,
1588
- authority,
1589
- getIdentity = () => ({}),
1590
- getEnrollmentToken = () => '',
1591
- consumeEnrollmentToken = () => false,
1592
- onPeerSocket = () => {},
1593
- logEvent = () => {},
1594
- logWarn = () => {}
1595
- } = {}) {
1596
- if (!authority?.hubId || !authority?.hubPublicKey) throw relayError('relay-device-authority-required');
1597
- let started = false;
1598
- let closed = false;
1599
- let enrollment = null;
1600
- const retired = new Set();
1601
- const retirementTimers = new Set();
1602
- const stable = createHubRelayControl({
1603
- env,
1604
- pairToken: authority.hubPublicKey,
1605
- secureSession: { mode: 'credential', authority, getIdentity },
1606
- onPeerSocket,
1607
- logEvent,
1608
- logWarn
1609
- });
1610
-
1611
- function makeEnrollmentControl(token) {
1612
- return createHubRelayControl({
1613
- env,
1614
- pairToken: token,
1615
- secureSession: {
1616
- mode: 'enroll',
1617
- authority,
1618
- getIdentity,
1619
- getEnrollmentToken: () => token,
1620
- consumeEnrollmentToken: expected => {
1621
- if (expected !== token || !consumeEnrollmentToken(expected)) return false;
1622
- queueMicrotask(() => { void rotateEnrollment(); });
1623
- return true;
1624
- }
1625
- },
1626
- onPeerSocket,
1627
- logEvent,
1628
- logWarn
1629
- });
1630
- }
1631
-
1632
- function retireControl(control) {
1633
- if (!control) return;
1634
- retired.add(control);
1635
- const deadline = Date.now() + 10_000;
1636
- const poll = () => {
1637
- if (closed || control.getStatus().peerCount === 0 || Date.now() >= deadline) {
1638
- retired.delete(control);
1639
- void control.close().catch(error => logWarn('relay', `Secure enrollment relay retirement failed code=${safeText(error?.code, 80) || 'unknown'}`));
1640
- return;
1641
- }
1642
- const timer = setTimeout(() => {
1643
- retirementTimers.delete(timer);
1644
- poll();
1645
- }, 100);
1646
- timer.unref?.();
1647
- retirementTimers.add(timer);
1648
- };
1649
- poll();
1650
- }
1651
-
1652
- async function rotateEnrollment() {
1653
- if (closed) return;
1654
- const previous = enrollment;
1655
- enrollment = makeEnrollmentControl(getEnrollmentToken());
1656
- if (started) await enrollment.start();
1657
- retireControl(previous);
1658
- }
1659
-
1660
- async function start() {
1661
- if (started || closed) return getStatus();
1662
- started = true;
1663
- enrollment = makeEnrollmentControl(getEnrollmentToken());
1664
- await Promise.all([stable.start(), enrollment.start()]);
1665
- return getStatus();
1666
- }
1667
-
1668
- async function close() {
1669
- if (closed) return;
1670
- closed = true;
1671
- started = false;
1672
- for (const timer of retirementTimers) clearTimeout(timer);
1673
- retirementTimers.clear();
1674
- const controls = [stable, enrollment, ...retired].filter(Boolean);
1675
- retired.clear();
1676
- enrollment = null;
1677
- const results = await Promise.allSettled(controls.map(control => control.close()));
1678
- const failure = results.find(result => result.status === 'rejected');
1679
- if (failure) throw failure.reason;
1680
- }
1681
-
1682
- function getStatus() {
1683
- const stableStatus = stable.getStatus();
1684
- const enrollmentStatus = enrollment?.getStatus() || null;
1685
- const retiredStatuses = [...retired].map(control => control.getStatus());
1686
- const statuses = [stableStatus, enrollmentStatus, ...retiredStatuses].filter(Boolean);
1687
- return {
1688
- ...stableStatus,
1689
- started,
1690
- connected: statuses.some(status => status.connected),
1691
- state: stableStatus.connected ? 'registered' : enrollmentStatus?.state || stableStatus.state,
1692
- peerCount: statuses.reduce((sum, status) => sum + Number(status.peerCount || 0), 0),
1693
- activeSocketCount: statuses.reduce((sum, status) => sum + Number(status.activeSocketCount || 0), 0),
1694
- secureCredentialRelay: true,
1695
- credentialRoomId: deriveCredentialRelayRoomId(authority.hubId, authority.hubPublicKey),
1696
- enrollmentGenerationCount: 1 + retiredStatuses.length,
1697
- enrollment: enrollmentStatus,
1698
- retiredEnrollmentCount: retiredStatuses.length
1699
- };
1700
- }
1701
-
1702
- return Object.freeze({ start, close, getStatus });
1703
- }
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_MAX_RECEIVE_CHUNKS = 256;
14
+ const RELAY_NONCE_BYTES = 12;
15
+ const RELAY_TAG_BYTES = 16;
16
+ const RELAY_PUBLIC_KEY_BYTES = 65;
17
+ const RELAY_PEER_ID_BYTES = 16;
18
+ const DEFAULT_MAX_PEERS = 128;
19
+ const DEFAULT_MAX_QUEUE_BYTES = 4 * 1024 * 1024;
20
+ const DEFAULT_MAX_QUEUE_MESSAGES = 256;
21
+ const DEFAULT_MAX_PEER_QUEUE_BYTES = 1024 * 1024;
22
+ const DEFAULT_MAX_PEER_QUEUE_MESSAGES = 64;
23
+ const DEFAULT_RECONNECT_MIN_MS = 1_000;
24
+ const DEFAULT_RECONNECT_MAX_MS = 30_000;
25
+ const DEFAULT_CONNECT_TIMEOUT_MS = 5_000;
26
+
27
+ function isEnabled(value, fallback = false) {
28
+ if (value === undefined || value === null || value === '') return fallback;
29
+ return /^(1|true|yes|on)$/i.test(String(value).trim());
30
+ }
31
+
32
+ function safeText(value, maximum = 160) {
33
+ return String(value || '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maximum);
34
+ }
35
+
36
+ function boundedInteger(value, minimum, maximum, fallback) {
37
+ const number = Number(value);
38
+ return Number.isFinite(number)
39
+ ? Math.max(minimum, Math.min(maximum, Math.floor(number)))
40
+ : fallback;
41
+ }
42
+
43
+ function encodeBase64Url(value) {
44
+ return (Buffer.isBuffer(value) ? value : Buffer.from(value)).toString('base64url');
45
+ }
46
+
47
+ function decodeCanonicalBase64Url(value, expectedBytes = 0, maximumBytes = 1024) {
48
+ const text = String(value || '');
49
+ if (!text || !/^[A-Za-z0-9_-]+$/.test(text)) {
50
+ throw relayError('relay-invalid-base64url');
51
+ }
52
+ const decoded = Buffer.from(text, 'base64url');
53
+ if (decoded.length > maximumBytes
54
+ || (expectedBytes > 0 && decoded.length !== expectedBytes)
55
+ || encodeBase64Url(decoded) !== text) {
56
+ throw relayError('relay-invalid-base64url');
57
+ }
58
+ return decoded;
59
+ }
60
+
61
+ function relayError(code) {
62
+ const error = new Error(code);
63
+ error.code = code;
64
+ return error;
65
+ }
66
+
67
+ function requirePairToken(pairToken) {
68
+ const token = String(pairToken || '');
69
+ if (!token || Buffer.byteLength(token, 'utf8') > 1024) {
70
+ throw relayError('relay-pair-token-required');
71
+ }
72
+ return token;
73
+ }
74
+
75
+ function requireRoomId(roomId) {
76
+ const value = String(roomId || '');
77
+ if (!value || value.length > 128 || !/^[A-Za-z0-9_-]+$/.test(value)) {
78
+ throw relayError('relay-room-invalid');
79
+ }
80
+ return value;
81
+ }
82
+
83
+ function requirePeerId(peerId) {
84
+ const value = String(peerId || '');
85
+ decodeCanonicalBase64Url(value, RELAY_PEER_ID_BYTES, RELAY_PEER_ID_BYTES);
86
+ return value;
87
+ }
88
+
89
+ function requirePublicKey(publicKey) {
90
+ const value = String(publicKey || '');
91
+ const decoded = decodeCanonicalBase64Url(value, RELAY_PUBLIC_KEY_BYTES, RELAY_PUBLIC_KEY_BYTES);
92
+ if (decoded[0] !== 0x04) {
93
+ throw relayError('relay-public-key-invalid');
94
+ }
95
+ return { text: value, bytes: decoded };
96
+ }
97
+
98
+ function requireDirection(direction) {
99
+ const value = String(direction || '');
100
+ if (value !== RELAY_CONTROL_DIRECTIONS.clientToHub
101
+ && value !== RELAY_CONTROL_DIRECTIONS.hubToClient) {
102
+ throw relayError('relay-direction-invalid');
103
+ }
104
+ return value;
105
+ }
106
+
107
+ function requireSequence(sequence) {
108
+ const value = Number(sequence);
109
+ if (!Number.isSafeInteger(value) || value < 1) {
110
+ throw relayError('relay-sequence-invalid');
111
+ }
112
+ return value;
113
+ }
114
+
115
+ function normalizeSessionKey(sessionKey) {
116
+ const key = Buffer.isBuffer(sessionKey)
117
+ ? sessionKey
118
+ : Buffer.from(sessionKey || []);
119
+ if (key.length !== 32) {
120
+ throw relayError('relay-session-key-invalid');
121
+ }
122
+ return key;
123
+ }
124
+
125
+ function proofMatches(actual, expected) {
126
+ let actualBytes;
127
+ try {
128
+ actualBytes = decodeCanonicalBase64Url(actual, 32, 32);
129
+ } catch {
130
+ return false;
131
+ }
132
+ const expectedBytes = decodeCanonicalBase64Url(expected, 32, 32);
133
+ return crypto.timingSafeEqual(actualBytes, expectedBytes);
134
+ }
135
+
136
+ function controlAad(roomId, peerId, direction, sequence) {
137
+ return Buffer.from(
138
+ `livedesk-relay-data-v1\0${requireRoomId(roomId)}\0${requirePeerId(peerId)}\0${requireDirection(direction)}\0${requireSequence(sequence)}`,
139
+ 'utf8');
140
+ }
141
+
142
+ function sanitizeSocketError(error) {
143
+ const code = safeText(error?.code, 80);
144
+ return code && /^[A-Z0-9_]+$/.test(code) ? code : 'relay-socket-error';
145
+ }
146
+
147
+ export function deriveRelayRoomId(pairToken) {
148
+ const token = requirePairToken(pairToken);
149
+ return crypto
150
+ .createHash('sha256')
151
+ .update(Buffer.from(`livedesk-relay-room-v1\0${token}`, 'utf8'))
152
+ .digest('base64url');
153
+ }
154
+
155
+ export function deriveRelayRegistrationToken(pairToken, roomId = deriveRelayRoomId(pairToken)) {
156
+ const token = requirePairToken(pairToken);
157
+ const room = requireRoomId(roomId);
158
+ return crypto
159
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
160
+ .update(Buffer.from(`livedesk-relay-register-v1\0${room}`, 'utf8'))
161
+ .digest('base64url');
162
+ }
163
+
164
+ export function deriveRelayClientProof(pairToken, {
165
+ roomId = deriveRelayRoomId(pairToken),
166
+ peerId,
167
+ clientPublicKey
168
+ } = {}) {
169
+ const token = requirePairToken(pairToken);
170
+ const room = requireRoomId(roomId);
171
+ const peer = requirePeerId(peerId);
172
+ const clientKey = requirePublicKey(clientPublicKey).text;
173
+ return crypto
174
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
175
+ .update(Buffer.from(`livedesk-relay-client-v1\0${room}\0${peer}\0${clientKey}`, 'utf8'))
176
+ .digest('base64url');
177
+ }
178
+
179
+ export function deriveRelayHubProof(pairToken, {
180
+ roomId = deriveRelayRoomId(pairToken),
181
+ peerId,
182
+ clientPublicKey,
183
+ hubPublicKey
184
+ } = {}) {
185
+ const token = requirePairToken(pairToken);
186
+ const room = requireRoomId(roomId);
187
+ const peer = requirePeerId(peerId);
188
+ const clientKey = requirePublicKey(clientPublicKey).text;
189
+ const hubKey = requirePublicKey(hubPublicKey).text;
190
+ return crypto
191
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
192
+ .update(Buffer.from(`livedesk-relay-hub-v1\0${room}\0${peer}\0${clientKey}\0${hubKey}`, 'utf8'))
193
+ .digest('base64url');
194
+ }
195
+
196
+ export function deriveRelaySessionKey(pairToken, {
197
+ roomId = deriveRelayRoomId(pairToken),
198
+ peerId,
199
+ clientPublicKey,
200
+ hubPublicKey,
201
+ sharedSecret
202
+ } = {}) {
203
+ const token = requirePairToken(pairToken);
204
+ const room = requireRoomId(roomId);
205
+ const peer = requirePeerId(peerId);
206
+ const clientKey = requirePublicKey(clientPublicKey).text;
207
+ const hubKey = requirePublicKey(hubPublicKey).text;
208
+ const secret = Buffer.from(sharedSecret || []);
209
+ if (secret.length < 16 || secret.length > 256) {
210
+ throw relayError('relay-shared-secret-invalid');
211
+ }
212
+ const salt = crypto
213
+ .createHmac('sha256', Buffer.from(token, 'utf8'))
214
+ .update(Buffer.from(`livedesk-relay-salt-v1\0${room}\0${peer}`, 'utf8'))
215
+ .digest();
216
+ const info = Buffer.from(`livedesk-relay-e2e-v1\0${clientKey}\0${hubKey}`, 'utf8');
217
+ return Buffer.from(crypto.hkdfSync('sha256', secret, salt, info, 32));
218
+ }
219
+
220
+ export function encryptRelayControlPayload({
221
+ sessionKey,
222
+ roomId,
223
+ peerId,
224
+ direction,
225
+ sequence,
226
+ plaintext,
227
+ nonce = crypto.randomBytes(RELAY_NONCE_BYTES)
228
+ } = {}) {
229
+ const key = normalizeSessionKey(sessionKey);
230
+ const cleartext = Buffer.isBuffer(plaintext)
231
+ ? plaintext
232
+ : Buffer.from(plaintext || []);
233
+ if (cleartext.length < 1 || cleartext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
234
+ throw relayError('relay-plaintext-size-invalid');
235
+ }
236
+ const nonceBytes = Buffer.isBuffer(nonce)
237
+ ? nonce
238
+ : Buffer.from(nonce || []);
239
+ if (nonceBytes.length !== RELAY_NONCE_BYTES) {
240
+ throw relayError('relay-nonce-invalid');
241
+ }
242
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, nonceBytes, { authTagLength: RELAY_TAG_BYTES });
243
+ cipher.setAAD(controlAad(roomId, peerId, direction, sequence));
244
+ const update = cipher.update(cleartext);
245
+ const final = cipher.final();
246
+ const ciphertext = final.length > 0
247
+ ? Buffer.concat([update, final], update.length + final.length)
248
+ : update;
249
+ const tag = cipher.getAuthTag();
250
+ const envelope = Buffer.allocUnsafe(nonceBytes.length + ciphertext.length + tag.length);
251
+ nonceBytes.copy(envelope, 0);
252
+ ciphertext.copy(envelope, nonceBytes.length);
253
+ tag.copy(envelope, nonceBytes.length + ciphertext.length);
254
+ return encodeBase64Url(envelope);
255
+ }
256
+
257
+ export function decryptRelayControlPayload({
258
+ sessionKey,
259
+ roomId,
260
+ peerId,
261
+ direction,
262
+ sequence,
263
+ payload
264
+ } = {}) {
265
+ const key = normalizeSessionKey(sessionKey);
266
+ let envelope;
267
+ try {
268
+ envelope = decodeCanonicalBase64Url(
269
+ payload,
270
+ 0,
271
+ RELAY_NONCE_BYTES + RELAY_CONTROL_MAX_PLAINTEXT_BYTES + RELAY_TAG_BYTES);
272
+ } catch {
273
+ throw relayError('relay-data-invalid');
274
+ }
275
+ if (envelope.length <= RELAY_NONCE_BYTES + RELAY_TAG_BYTES) {
276
+ throw relayError('relay-data-invalid');
277
+ }
278
+ const nonce = envelope.subarray(0, RELAY_NONCE_BYTES);
279
+ const ciphertext = envelope.subarray(RELAY_NONCE_BYTES, envelope.length - RELAY_TAG_BYTES);
280
+ const tag = envelope.subarray(envelope.length - RELAY_TAG_BYTES);
281
+ try {
282
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce, { authTagLength: RELAY_TAG_BYTES });
283
+ decipher.setAAD(controlAad(roomId, peerId, direction, sequence));
284
+ decipher.setAuthTag(tag);
285
+ const update = decipher.update(ciphertext);
286
+ const final = decipher.final();
287
+ const plaintext = final.length > 0
288
+ ? Buffer.concat([update, final], update.length + final.length)
289
+ : update;
290
+ if (plaintext.length < 1 || plaintext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
291
+ throw relayError('relay-plaintext-size-invalid');
292
+ }
293
+ return plaintext;
294
+ } catch (error) {
295
+ if (error?.code === 'relay-plaintext-size-invalid') throw error;
296
+ throw relayError('relay-data-authentication-failed');
297
+ }
298
+ }
299
+
300
+ export class HubRelayVirtualSocket extends EventEmitter {
301
+ constructor(owner, peerId, remoteAddress, remotePort) {
302
+ super();
303
+ this.owner = owner;
304
+ this.peerId = peerId;
305
+ this.remoteAddress = remoteAddress;
306
+ this.remotePort = remotePort;
307
+ this.destroyed = false;
308
+ this.connecting = false;
309
+ this.readable = true;
310
+ this.writable = true;
311
+ this.__liveDeskRelayControl = true;
312
+ this.__liveDeskRelayPeerId = peerId;
313
+ }
314
+
315
+ setNoDelay() {
316
+ return this;
317
+ }
318
+
319
+ setKeepAlive() {
320
+ return this;
321
+ }
322
+
323
+ address() {
324
+ return {
325
+ address: this.remoteAddress,
326
+ family: 'relay',
327
+ port: this.remotePort
328
+ };
329
+ }
330
+
331
+ write(chunk, encoding, callback) {
332
+ let completion = callback;
333
+ let textEncoding = encoding;
334
+ if (typeof encoding === 'function') {
335
+ completion = encoding;
336
+ textEncoding = undefined;
337
+ }
338
+ if (this.destroyed) {
339
+ if (typeof completion === 'function') {
340
+ queueMicrotask(() => completion(relayError('relay-virtual-socket-closed')));
341
+ }
342
+ return false;
343
+ }
344
+ const payload = Buffer.isBuffer(chunk)
345
+ ? chunk
346
+ : Buffer.from(String(chunk ?? ''), typeof textEncoding === 'string' ? textEncoding : 'utf8');
347
+ const accepted = this.owner.sendPeerPlaintext(this.peerId, payload);
348
+ if (typeof completion === 'function') {
349
+ queueMicrotask(() => completion(accepted ? undefined : relayError('relay-write-rejected')));
350
+ }
351
+ return accepted && !this.owner.isPeerBackpressured(this.peerId);
352
+ }
353
+
354
+ end(chunk, encoding, callback) {
355
+ if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
356
+ this.write(chunk, typeof encoding === 'string' ? encoding : undefined);
357
+ }
358
+ const completion = typeof chunk === 'function'
359
+ ? chunk
360
+ : typeof encoding === 'function'
361
+ ? encoding
362
+ : callback;
363
+ this.destroy();
364
+ if (typeof completion === 'function') queueMicrotask(completion);
365
+ return this;
366
+ }
367
+
368
+ destroy(error) {
369
+ if (!this.destroyed) {
370
+ this.owner.closePeer(this.peerId, 'virtual-socket-destroyed', {
371
+ notify: true,
372
+ error: error instanceof Error ? error : null
373
+ });
374
+ }
375
+ return this;
376
+ }
377
+
378
+ _deliver(plaintext) {
379
+ if (this.destroyed) return;
380
+ if (Buffer.isBuffer(plaintext)) {
381
+ this.emit('data', plaintext);
382
+ return;
383
+ }
384
+ this.owner.recordPlaintextDeliveryCopy();
385
+ this.emit('data', Buffer.from(plaintext));
386
+ }
387
+
388
+ _drain() {
389
+ if (!this.destroyed) this.emit('drain');
390
+ }
391
+
392
+ _close(reason, error = null) {
393
+ if (this.destroyed) return;
394
+ this.destroyed = true;
395
+ this.readable = false;
396
+ this.writable = false;
397
+ this.closeReason = safeText(reason, 120) || 'relay-peer-closed';
398
+ if (error && this.listenerCount('error') > 0) this.emit('error', error);
399
+ this.emit('close', error ? true : false);
400
+ }
401
+ }
402
+
403
+ export function createHubRelayControl({
404
+ env = process.env,
405
+ pairToken,
406
+ onPeerSocket = () => {},
407
+ logEvent = () => {},
408
+ logWarn = () => {},
409
+ connect = options => net.createConnection(options),
410
+ randomBytes = size => crypto.randomBytes(size)
411
+ } = {}) {
412
+ const token = requirePairToken(pairToken);
413
+ const roomId = deriveRelayRoomId(token);
414
+ const registrationToken = deriveRelayRegistrationToken(token, roomId);
415
+ const enabled = isEnabled(
416
+ env.LIVEDESK_RELAY_ENABLED,
417
+ isEnabled(env.LIVEDESK_UDP_ENABLED, true));
418
+ const host = safeText(
419
+ env.LIVEDESK_RELAY_HOST
420
+ || env.LIVEDESK_UDP_RENDEZVOUS_HOST
421
+ || 'www.gnsoftech.com',
422
+ 253);
423
+ const port = boundedInteger(
424
+ env.LIVEDESK_RELAY_PORT || env.LIVEDESK_UDP_RENDEZVOUS_PORT,
425
+ 1,
426
+ 65535,
427
+ 5199);
428
+ const maxPeers = boundedInteger(env.LIVEDESK_RELAY_MAX_PEERS, 1, 1024, DEFAULT_MAX_PEERS);
429
+ const maxQueueBytes = boundedInteger(
430
+ env.LIVEDESK_RELAY_MAX_QUEUE_BYTES,
431
+ RELAY_MAX_LINE_BYTES,
432
+ 32 * 1024 * 1024,
433
+ DEFAULT_MAX_QUEUE_BYTES);
434
+ const maxQueueMessages = boundedInteger(
435
+ env.LIVEDESK_RELAY_MAX_QUEUE_MESSAGES,
436
+ 1,
437
+ 4096,
438
+ DEFAULT_MAX_QUEUE_MESSAGES);
439
+ const maxPeerQueueBytes = boundedInteger(
440
+ env.LIVEDESK_RELAY_MAX_PEER_QUEUE_BYTES,
441
+ RELAY_MAX_LINE_BYTES,
442
+ 8 * 1024 * 1024,
443
+ DEFAULT_MAX_PEER_QUEUE_BYTES);
444
+ const maxPeerQueueMessages = boundedInteger(
445
+ env.LIVEDESK_RELAY_MAX_PEER_QUEUE_MESSAGES,
446
+ 1,
447
+ 512,
448
+ DEFAULT_MAX_PEER_QUEUE_MESSAGES);
449
+ const reconnectMinMs = boundedInteger(
450
+ env.LIVEDESK_RELAY_RECONNECT_MIN_MS,
451
+ 50,
452
+ 60_000,
453
+ DEFAULT_RECONNECT_MIN_MS);
454
+ const reconnectMaxMs = boundedInteger(
455
+ env.LIVEDESK_RELAY_RECONNECT_MAX_MS,
456
+ reconnectMinMs,
457
+ 5 * 60_000,
458
+ DEFAULT_RECONNECT_MAX_MS);
459
+ const connectTimeoutMs = boundedInteger(
460
+ env.LIVEDESK_RELAY_CONNECT_TIMEOUT_MS,
461
+ 250,
462
+ 30_000,
463
+ DEFAULT_CONNECT_TIMEOUT_MS);
464
+ const peers = new Map();
465
+ const retiredPeerIds = new Map();
466
+ const connectionOwners = new Map();
467
+ const sendQueue = [];
468
+ let sendQueueHead = 0;
469
+ const peerQueueStats = new Map();
470
+ const counters = {
471
+ connections: 0,
472
+ reconnects: 0,
473
+ handshakes: 0,
474
+ rejectedPeers: 0,
475
+ encryptedMessagesReceived: 0,
476
+ encryptedMessagesSent: 0,
477
+ queueDrops: 0,
478
+ receiveLineAssemblyCopies: 0,
479
+ receiveLineAssemblyBytes: 0,
480
+ plaintextDeliveryCopies: 0,
481
+ peerQueuePurges: 0,
482
+ peerQueuePurgedBytes: 0,
483
+ staleConnectionEventDrops: 0,
484
+ socketOwnersCreated: 0,
485
+ socketOwnersClosed: 0,
486
+ peerOwnersCreated: 0,
487
+ peerOwnersClosed: 0
488
+ };
489
+
490
+ let desired = false;
491
+ let started = false;
492
+ let closing = false;
493
+ let connection = null;
494
+ let connectionGeneration = 0;
495
+ let reconnectTimer = null;
496
+ let connectAttemptTimer = null;
497
+ let closeSocketTimer = null;
498
+ let closeTimedOut = false;
499
+ let reconnectAttempt = 0;
500
+ let receiveChunks = [];
501
+ let receiveBytes = 0;
502
+ let receiveChunkHighWater = 0;
503
+ let receiveByteHighWater = 0;
504
+ let queuedBytes = 0;
505
+ let queuedMessageHighWater = 0;
506
+ let queuedByteHighWater = 0;
507
+ let socketWritableByteHighWater = 0;
508
+ let totalOutboundByteHighWater = 0;
509
+ let socketOwnerHighWater = 0;
510
+ let peerOwnerHighWater = 0;
511
+ let timerOwnerHighWater = 0;
512
+ let awaitingDrain = false;
513
+ let lastError = '';
514
+ let lastDisconnectReason = '';
515
+ let lastConnectedAt = '';
516
+ let lastTransitionAt = '';
517
+ let state = enabled ? 'idle' : 'disabled';
518
+
519
+ function transition(nextState, error = '') {
520
+ state = nextState;
521
+ lastTransitionAt = new Date().toISOString();
522
+ if (error) lastError = safeText(error, 120);
523
+ }
524
+
525
+ function activeTimerCount() {
526
+ return Number(Boolean(reconnectTimer))
527
+ + Number(Boolean(connectAttemptTimer))
528
+ + Number(Boolean(closeSocketTimer));
529
+ }
530
+
531
+ function recordTimerOwnerHighWater() {
532
+ timerOwnerHighWater = Math.max(
533
+ timerOwnerHighWater,
534
+ activeTimerCount());
535
+ }
536
+
537
+ function addConnectionOwner(socket, generation) {
538
+ if (connectionOwners.has(socket)) {
539
+ throw relayError('relay-socket-owner-duplicate');
540
+ }
541
+ connectionOwners.set(socket, {
542
+ socket,
543
+ generation,
544
+ phase: 'current'
545
+ });
546
+ counters.socketOwnersCreated += 1;
547
+ socketOwnerHighWater = Math.max(
548
+ socketOwnerHighWater,
549
+ connectionOwners.size);
550
+ }
551
+
552
+ function markConnectionOwnerClosing(socket, generation) {
553
+ const owner = connectionOwners.get(socket);
554
+ if (!owner || owner.generation !== generation) return false;
555
+ owner.phase = 'closing';
556
+ return true;
557
+ }
558
+
559
+ function queueStats(peerId) {
560
+ if (!peerId) return { messages: 0, bytes: 0, backpressured: false };
561
+ let stats = peerQueueStats.get(peerId);
562
+ if (!stats) {
563
+ stats = { messages: 0, bytes: 0, backpressured: false };
564
+ peerQueueStats.set(peerId, stats);
565
+ }
566
+ return stats;
567
+ }
568
+
569
+ function releaseQueuedItem(item) {
570
+ queuedBytes = Math.max(0, queuedBytes - item.buffer.length);
571
+ if (!item.peerId) return;
572
+ const stats = peerQueueStats.get(item.peerId);
573
+ if (!stats) return;
574
+ stats.messages = Math.max(0, stats.messages - 1);
575
+ stats.bytes = Math.max(0, stats.bytes - item.buffer.length);
576
+ }
577
+
578
+ function queuedMessageCount() {
579
+ return sendQueue.length - sendQueueHead;
580
+ }
581
+
582
+ function socketWritableBytes(socket = connection) {
583
+ const value = Number(socket?.writableLength);
584
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
585
+ }
586
+
587
+ function recordOutboundHighWater(socket = connection) {
588
+ const writableBytes = socketWritableBytes(socket);
589
+ socketWritableByteHighWater = Math.max(
590
+ socketWritableByteHighWater,
591
+ writableBytes);
592
+ totalOutboundByteHighWater = Math.max(
593
+ totalOutboundByteHighWater,
594
+ queuedBytes + writableBytes);
595
+ }
596
+
597
+ function dequeueQueuedItem() {
598
+ if (queuedMessageCount() <= 0) return null;
599
+ const item = sendQueue[sendQueueHead];
600
+ sendQueue[sendQueueHead] = null;
601
+ sendQueueHead += 1;
602
+ if (sendQueueHead >= 64 && sendQueueHead * 2 >= sendQueue.length) {
603
+ sendQueue.splice(0, sendQueueHead);
604
+ sendQueueHead = 0;
605
+ }
606
+ return item;
607
+ }
608
+
609
+ function clearQueue() {
610
+ sendQueue.length = 0;
611
+ sendQueueHead = 0;
612
+ queuedBytes = 0;
613
+ awaitingDrain = false;
614
+ peerQueueStats.clear();
615
+ }
616
+
617
+ function purgePeerQueue(peerId) {
618
+ if (!peerId || queuedMessageCount() === 0) return;
619
+ let writeIndex = 0;
620
+ let purgedBytes = 0;
621
+ for (let readIndex = sendQueueHead; readIndex < sendQueue.length; readIndex += 1) {
622
+ const item = sendQueue[readIndex];
623
+ if (item.peerId === peerId) {
624
+ purgedBytes += item.buffer.length;
625
+ releaseQueuedItem(item);
626
+ continue;
627
+ }
628
+ sendQueue[writeIndex] = item;
629
+ writeIndex += 1;
630
+ }
631
+ for (let index = writeIndex; index < sendQueue.length; index += 1) {
632
+ sendQueue[index] = null;
633
+ }
634
+ if (purgedBytes > 0) {
635
+ sendQueue.length = writeIndex;
636
+ sendQueueHead = 0;
637
+ counters.peerQueuePurges += 1;
638
+ counters.peerQueuePurgedBytes += purgedBytes;
639
+ } else if (sendQueueHead > 0) {
640
+ sendQueue.splice(0, sendQueueHead);
641
+ sendQueueHead = 0;
642
+ }
643
+ }
644
+
645
+ function emitPeerDrains() {
646
+ if (awaitingDrain) return;
647
+ for (const [peerId, stats] of peerQueueStats) {
648
+ if (!stats.backpressured
649
+ || stats.messages >= maxPeerQueueMessages
650
+ || stats.bytes >= maxPeerQueueBytes) {
651
+ continue;
652
+ }
653
+ stats.backpressured = false;
654
+ peers.get(peerId)?.socket?._drain();
655
+ }
656
+ }
657
+
658
+ function flushQueue() {
659
+ const socket = connection;
660
+ if (!socket || socket.destroyed || !socket.writable || awaitingDrain) return;
661
+ while (queuedMessageCount() > 0 && !awaitingDrain) {
662
+ const item = dequeueQueuedItem();
663
+ if (!item) break;
664
+ releaseQueuedItem(item);
665
+ let writable = false;
666
+ try {
667
+ writable = socket.write(item.buffer);
668
+ } catch {
669
+ socket.destroy();
670
+ return;
671
+ }
672
+ if (!writable) awaitingDrain = true;
673
+ recordOutboundHighWater(socket);
674
+ }
675
+ emitPeerDrains();
676
+ }
677
+
678
+ function enqueueEnvelope(envelope, peerId = '') {
679
+ if (!connection || connection.destroyed || !connection.writable) return false;
680
+ let buffer;
681
+ try {
682
+ buffer = Buffer.from(`${JSON.stringify(envelope)}\n`, 'utf8');
683
+ } catch {
684
+ return false;
685
+ }
686
+ if (buffer.length > RELAY_MAX_LINE_BYTES) return false;
687
+ const stats = queueStats(peerId);
688
+ const writableBytes = socketWritableBytes();
689
+ if (queuedMessageCount() >= maxQueueMessages
690
+ || queuedBytes + writableBytes + buffer.length > maxQueueBytes
691
+ || (peerId && (stats.messages >= maxPeerQueueMessages
692
+ || stats.bytes + buffer.length > maxPeerQueueBytes))) {
693
+ counters.queueDrops += 1;
694
+ if (peerId) stats.backpressured = true;
695
+ return false;
696
+ }
697
+ sendQueue.push({ buffer, peerId });
698
+ queuedBytes += buffer.length;
699
+ queuedMessageHighWater = Math.max(queuedMessageHighWater, queuedMessageCount());
700
+ queuedByteHighWater = Math.max(queuedByteHighWater, queuedBytes);
701
+ recordOutboundHighWater();
702
+ if (peerId) {
703
+ stats.messages += 1;
704
+ stats.bytes += buffer.length;
705
+ }
706
+ flushQueue();
707
+ if (peerId && awaitingDrain) stats.backpressured = true;
708
+ return true;
709
+ }
710
+
711
+ function rememberRetiredPeer(peerId) {
712
+ if (!peerId) return;
713
+ retiredPeerIds.delete(peerId);
714
+ retiredPeerIds.set(peerId, Date.now());
715
+ while (retiredPeerIds.size > maxPeers * 4) {
716
+ retiredPeerIds.delete(retiredPeerIds.keys().next().value);
717
+ }
718
+ }
719
+
720
+ function sendRelayClose(peerId, reason) {
721
+ if (!connection || connection.destroyed) return false;
722
+ return enqueueEnvelope({
723
+ type: 'relay.close',
724
+ protocol: RELAY_CONTROL_PROTOCOL,
725
+ roomId,
726
+ peerId,
727
+ reason: safeText(reason, 80) || 'relay-peer-closed'
728
+ });
729
+ }
730
+
731
+ function closePeer(peerId, reason = 'relay-peer-closed', {
732
+ notify = true,
733
+ error = null
734
+ } = {}) {
735
+ const id = safeText(peerId, 64);
736
+ const peer = peers.get(id);
737
+ if (!peer) {
738
+ if (notify) {
739
+ try {
740
+ sendRelayClose(requirePeerId(id), reason);
741
+ } catch {
742
+ // Invalid untrusted routing fields are ignored.
743
+ }
744
+ }
745
+ return false;
746
+ }
747
+ peers.delete(id);
748
+ counters.peerOwnersClosed += 1;
749
+ rememberRetiredPeer(id);
750
+ purgePeerQueue(id);
751
+ peerQueueStats.delete(id);
752
+ if (notify) sendRelayClose(id, reason);
753
+ if (Buffer.isBuffer(peer.sessionKey)) peer.sessionKey.fill(0);
754
+ peer.socket?._close(reason, error);
755
+ return true;
756
+ }
757
+
758
+ function rejectPeer(peerId, reason) {
759
+ counters.rejectedPeers += 1;
760
+ closePeer(peerId, reason, { notify: true });
761
+ }
762
+
763
+ function closeAllPeers(reason) {
764
+ for (const peerId of [...peers.keys()]) {
765
+ closePeer(peerId, reason, { notify: false });
766
+ }
767
+ }
768
+
769
+ function isPeerBackpressured(peerId) {
770
+ return peerQueueStats.get(String(peerId || ''))?.backpressured === true;
771
+ }
772
+
773
+ function sendPeerPlaintext(peerId, plaintext) {
774
+ const peer = peers.get(String(peerId || ''));
775
+ if (!peer || peer.socket.destroyed || peer.handshakeState !== 'established') return false;
776
+ const cleartext = Buffer.isBuffer(plaintext)
777
+ ? plaintext
778
+ : Buffer.from(plaintext || []);
779
+ if (cleartext.length < 1 || cleartext.length > RELAY_CONTROL_MAX_PLAINTEXT_BYTES) {
780
+ rejectPeer(peer.peerId, 'relay-plaintext-size-invalid');
781
+ return false;
782
+ }
783
+ const sequence = peer.sendSequence + 1;
784
+ let payload;
785
+ try {
786
+ payload = encryptRelayControlPayload({
787
+ sessionKey: peer.sessionKey,
788
+ roomId,
789
+ peerId: peer.peerId,
790
+ direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
791
+ sequence,
792
+ plaintext: cleartext,
793
+ nonce: randomBytes(RELAY_NONCE_BYTES)
794
+ });
795
+ } catch {
796
+ rejectPeer(peer.peerId, 'relay-encryption-failed');
797
+ return false;
798
+ }
799
+ const accepted = enqueueEnvelope({
800
+ type: 'relay.data',
801
+ protocol: RELAY_CONTROL_PROTOCOL,
802
+ roomId,
803
+ peerId: peer.peerId,
804
+ direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
805
+ sequence,
806
+ payload
807
+ }, peer.peerId);
808
+ if (!accepted) {
809
+ counters.queueDrops += 1;
810
+ closePeer(peer.peerId, 'relay-backpressure-limit', { notify: false });
811
+ return false;
812
+ }
813
+ peer.sendSequence = sequence;
814
+ peer.lastActivityAt = Date.now();
815
+ counters.encryptedMessagesSent += 1;
816
+ return true;
817
+ }
818
+
819
+ function handleClientHandshake(message) {
820
+ let peerId = '';
821
+ try {
822
+ peerId = requirePeerId(message.peerId);
823
+ if (message.direction
824
+ && requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
825
+ throw relayError('relay-direction-invalid');
826
+ }
827
+ const stage = safeText(message.stage || message.handshake, 40).toLowerCase();
828
+ if (stage && stage !== 'client-hello') {
829
+ throw relayError('relay-handshake-stage-invalid');
830
+ }
831
+ if (peers.has(peerId) || retiredPeerIds.has(peerId)) {
832
+ throw relayError('relay-handshake-replay');
833
+ }
834
+ if (peers.size >= maxPeers) {
835
+ throw relayError('relay-peer-capacity');
836
+ }
837
+ const clientPublic = requirePublicKey(message.clientPublicKey);
838
+ const expectedProof = deriveRelayClientProof(token, {
839
+ roomId,
840
+ peerId,
841
+ clientPublicKey: clientPublic.text
842
+ });
843
+ if (!proofMatches(message.proof, expectedProof)) {
844
+ throw relayError('relay-client-proof-invalid');
845
+ }
846
+ const ecdh = crypto.createECDH('prime256v1');
847
+ ecdh.generateKeys();
848
+ const hubPublicKey = ecdh.getPublicKey(null, 'uncompressed').toString('base64url');
849
+ const sharedSecret = ecdh.computeSecret(clientPublic.bytes);
850
+ let sessionKey;
851
+ try {
852
+ sessionKey = deriveRelaySessionKey(token, {
853
+ roomId,
854
+ peerId,
855
+ clientPublicKey: clientPublic.text,
856
+ hubPublicKey,
857
+ sharedSecret
858
+ });
859
+ } finally {
860
+ sharedSecret.fill(0);
861
+ }
862
+ const proof = deriveRelayHubProof(token, {
863
+ roomId,
864
+ peerId,
865
+ clientPublicKey: clientPublic.text,
866
+ hubPublicKey
867
+ });
868
+ const remoteAddress = `relay://${host}:${port}/${peerId}`;
869
+ const peer = {
870
+ peerId,
871
+ clientPublicKey: clientPublic.text,
872
+ hubPublicKey,
873
+ sessionKey,
874
+ receiveSequence: 0,
875
+ sendSequence: 0,
876
+ handshakeState: 'established',
877
+ createdAt: Date.now(),
878
+ lastActivityAt: Date.now(),
879
+ socket: null
880
+ };
881
+ peer.socket = new HubRelayVirtualSocket(api, peerId, remoteAddress, port);
882
+ peers.set(peerId, peer);
883
+ counters.peerOwnersCreated += 1;
884
+ peerOwnerHighWater = Math.max(peerOwnerHighWater, peers.size);
885
+ const sent = enqueueEnvelope({
886
+ type: 'relay.handshake',
887
+ protocol: RELAY_CONTROL_PROTOCOL,
888
+ roomId,
889
+ peerId,
890
+ direction: RELAY_CONTROL_DIRECTIONS.hubToClient,
891
+ stage: 'hub-hello',
892
+ clientPublicKey: clientPublic.text,
893
+ hubPublicKey,
894
+ proof
895
+ }, peerId);
896
+ if (!sent) {
897
+ closePeer(peerId, 'relay-handshake-backpressure', { notify: false });
898
+ return;
899
+ }
900
+ try {
901
+ onPeerSocket(peer.socket, {
902
+ protocol: RELAY_CONTROL_PROTOCOL,
903
+ peerId,
904
+ endpoint: `${host}:${port}`
905
+ });
906
+ } catch {
907
+ closePeer(peerId, 'relay-peer-handler-failed', { notify: true });
908
+ return;
909
+ }
910
+ counters.handshakes += 1;
911
+ } catch (error) {
912
+ counters.rejectedPeers += 1;
913
+ if (peerId) {
914
+ rememberRetiredPeer(peerId);
915
+ sendRelayClose(peerId, safeText(error?.code, 80) || 'relay-handshake-rejected');
916
+ }
917
+ }
918
+ }
919
+
920
+ function handleClientData(message) {
921
+ let peerId = '';
922
+ try {
923
+ peerId = safeText(message.peerId, 64);
924
+ const peer = peers.get(peerId);
925
+ if (!peer || peer.handshakeState !== 'established') {
926
+ throw relayError('relay-handshake-required');
927
+ }
928
+ if (requireDirection(message.direction) !== RELAY_CONTROL_DIRECTIONS.clientToHub) {
929
+ throw relayError('relay-direction-invalid');
930
+ }
931
+ const sequence = requireSequence(message.sequence);
932
+ if (sequence !== peer.receiveSequence + 1) {
933
+ throw relayError('relay-sequence-invalid');
934
+ }
935
+ const plaintext = decryptRelayControlPayload({
936
+ sessionKey: peer.sessionKey,
937
+ roomId,
938
+ peerId,
939
+ direction: RELAY_CONTROL_DIRECTIONS.clientToHub,
940
+ sequence,
941
+ payload: message.payload
942
+ });
943
+ peer.receiveSequence = sequence;
944
+ peer.lastActivityAt = Date.now();
945
+ counters.encryptedMessagesReceived += 1;
946
+ peer.socket._deliver(plaintext);
947
+ } catch (error) {
948
+ if (peerId) rejectPeer(peerId, safeText(error?.code, 80) || 'relay-data-rejected');
949
+ }
950
+ }
951
+
952
+ function handleRelayEnvelope(message) {
953
+ if (!message || typeof message !== 'object' || Array.isArray(message)) return;
954
+ if (message.type === 'relay.registered') {
955
+ if (message.protocol !== RELAY_CONTROL_PROTOCOL
956
+ || message.roomId !== roomId
957
+ || message.role !== 'hub'
958
+ || safeText(message.peerId, 64) !== ''
959
+ || state !== 'registering') {
960
+ lastError = 'relay-registration-invalid';
961
+ connection?.destroy();
962
+ return;
963
+ }
964
+ clearConnectAttemptTimer();
965
+ reconnectAttempt = 0;
966
+ lastConnectedAt = new Date().toISOString();
967
+ lastError = '';
968
+ transition('registered');
969
+ logEvent('relay', `Encrypted relay control registered at ${host}:${port}`);
970
+ return;
971
+ }
972
+ const peerId = safeText(message.peerId, 64);
973
+ if (message.protocol !== RELAY_CONTROL_PROTOCOL || message.roomId !== roomId) {
974
+ if (peerId) rejectPeer(peerId, 'relay-routing-mismatch');
975
+ return;
976
+ }
977
+ if (message.type === 'relay.handshake') {
978
+ handleClientHandshake(message);
979
+ return;
980
+ }
981
+ if (message.type === 'relay.data') {
982
+ handleClientData(message);
983
+ return;
984
+ }
985
+ if (message.type === 'relay.close') {
986
+ const reason = safeText(message.reason, 80) || 'relay-server-closed';
987
+ if (!peerId) {
988
+ lastError = reason;
989
+ lastDisconnectReason = reason;
990
+ connection?.destroy();
991
+ return;
992
+ }
993
+ try {
994
+ closePeer(requirePeerId(message.peerId), reason, { notify: false });
995
+ } catch {
996
+ // Invalid untrusted routing fields are ignored.
997
+ }
998
+ }
999
+ }
1000
+
1001
+ function clearReceiveChunks() {
1002
+ receiveChunks = [];
1003
+ receiveBytes = 0;
1004
+ }
1005
+
1006
+ function retainReceiveTail(incoming, offset) {
1007
+ const tail = incoming.subarray(offset);
1008
+ if (tail.length === 0) return true;
1009
+ if (receiveBytes + tail.length > RELAY_MAX_LINE_BYTES) return false;
1010
+ // A tail following a complete line must not retain the complete large
1011
+ // TCP chunk. A chunk containing no line is already exact ownership and
1012
+ // can be retained without copying.
1013
+ const retained = offset > 0 ? Buffer.from(tail) : tail;
1014
+ if (receiveChunks.length >= RELAY_MAX_RECEIVE_CHUNKS) return false;
1015
+ receiveChunks.push(retained);
1016
+ receiveBytes += retained.length;
1017
+ receiveChunkHighWater = Math.max(receiveChunkHighWater, receiveChunks.length);
1018
+ receiveByteHighWater = Math.max(receiveByteHighWater, receiveBytes);
1019
+ return true;
1020
+ }
1021
+
1022
+ function assembleReceiveLine(segment) {
1023
+ if (receiveBytes === 0) return segment;
1024
+ const totalBytes = receiveBytes + segment.length;
1025
+ const line = Buffer.allocUnsafe(totalBytes);
1026
+ let offset = 0;
1027
+ for (const chunk of receiveChunks) {
1028
+ chunk.copy(line, offset);
1029
+ offset += chunk.length;
1030
+ }
1031
+ segment.copy(line, offset);
1032
+ counters.receiveLineAssemblyCopies += 1;
1033
+ counters.receiveLineAssemblyBytes += totalBytes;
1034
+ clearReceiveChunks();
1035
+ return line;
1036
+ }
1037
+
1038
+ function isCurrentConnectionOwner(socket, generation) {
1039
+ return desired
1040
+ && !closing
1041
+ && connection === socket
1042
+ && generation === connectionGeneration
1043
+ && !socket.destroyed;
1044
+ }
1045
+
1046
+ function processRelayLine(socket, generation, line) {
1047
+ if (!isCurrentConnectionOwner(socket, generation)) {
1048
+ counters.staleConnectionEventDrops += 1;
1049
+ return false;
1050
+ }
1051
+ if (line.length === 0) return true;
1052
+ let message;
1053
+ try {
1054
+ message = JSON.parse(line.toString('utf8'));
1055
+ } catch {
1056
+ lastError = 'relay-invalid-json';
1057
+ socket.destroy();
1058
+ return false;
1059
+ }
1060
+ if (!isCurrentConnectionOwner(socket, generation)) {
1061
+ counters.staleConnectionEventDrops += 1;
1062
+ return false;
1063
+ }
1064
+ handleRelayEnvelope(message);
1065
+ return true;
1066
+ }
1067
+
1068
+ function onConnectionData(socket, generation, chunk) {
1069
+ if (!isCurrentConnectionOwner(socket, generation)) {
1070
+ counters.staleConnectionEventDrops += 1;
1071
+ return;
1072
+ }
1073
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1074
+ let offset = 0;
1075
+ while (offset < incoming.length) {
1076
+ if (!isCurrentConnectionOwner(socket, generation)) {
1077
+ counters.staleConnectionEventDrops += 1;
1078
+ return;
1079
+ }
1080
+ const newline = incoming.indexOf(0x0a, offset);
1081
+ if (newline < 0) {
1082
+ if (!retainReceiveTail(incoming, offset)) {
1083
+ lastError = 'relay-line-too-large';
1084
+ socket.destroy();
1085
+ }
1086
+ return;
1087
+ }
1088
+ const segmentLength = newline - offset;
1089
+ if (receiveBytes + segmentLength > RELAY_MAX_LINE_BYTES) {
1090
+ lastError = 'relay-line-too-large';
1091
+ socket.destroy();
1092
+ return;
1093
+ }
1094
+ const line = assembleReceiveLine(incoming.subarray(offset, newline));
1095
+ if (!processRelayLine(socket, generation, line)) return;
1096
+ offset = newline + 1;
1097
+ }
1098
+ }
1099
+
1100
+ function scheduleReconnect() {
1101
+ if (!desired || closing || reconnectTimer) return;
1102
+ reconnectAttempt += 1;
1103
+ counters.reconnects += 1;
1104
+ const exponential = Math.min(
1105
+ reconnectMaxMs,
1106
+ reconnectMinMs * (2 ** Math.min(10, reconnectAttempt - 1)));
1107
+ const jitter = Math.floor(exponential * 0.2 * Math.random());
1108
+ const delay = Math.min(reconnectMaxMs, exponential + jitter);
1109
+ transition('reconnect-wait');
1110
+ reconnectTimer = setTimeout(() => {
1111
+ reconnectTimer = null;
1112
+ connectNow();
1113
+ }, delay);
1114
+ reconnectTimer.unref?.();
1115
+ recordTimerOwnerHighWater();
1116
+ }
1117
+
1118
+ function clearConnectAttemptTimer() {
1119
+ if (connectAttemptTimer) clearTimeout(connectAttemptTimer);
1120
+ connectAttemptTimer = null;
1121
+ }
1122
+
1123
+ function handleConnectionClosed(socket, generation) {
1124
+ const owner = connectionOwners.get(socket);
1125
+ if (!owner || owner.generation !== generation) return;
1126
+ connectionOwners.delete(socket);
1127
+ counters.socketOwnersClosed += 1;
1128
+ if (owner.phase === 'closing') return;
1129
+ if (connection !== socket || generation !== connectionGeneration) return;
1130
+ clearConnectAttemptTimer();
1131
+ connection = null;
1132
+ const disconnectReason = safeText(lastError, 120) || 'relay-socket-closed';
1133
+ lastDisconnectReason = disconnectReason;
1134
+ logWarn(
1135
+ 'relay',
1136
+ `Encrypted relay control disconnected reason=${disconnectReason} peers=${peers.size}`
1137
+ );
1138
+ clearReceiveChunks();
1139
+ clearQueue();
1140
+ closeAllPeers('relay-connection-lost');
1141
+ if (desired && !closing) scheduleReconnect();
1142
+ else transition(enabled ? 'idle' : 'disabled');
1143
+ }
1144
+
1145
+ function connectNow() {
1146
+ if (!enabled || !desired || closing || connection) return;
1147
+ transition('connecting');
1148
+ const generation = ++connectionGeneration;
1149
+ let socket;
1150
+ try {
1151
+ socket = connect({ host, port });
1152
+ } catch (error) {
1153
+ lastError = sanitizeSocketError(error);
1154
+ scheduleReconnect();
1155
+ return;
1156
+ }
1157
+ connection = socket;
1158
+ addConnectionOwner(socket, generation);
1159
+ socket.setNoDelay?.(true);
1160
+ socket.setKeepAlive?.(true, 10_000);
1161
+ socket.once('connect', () => {
1162
+ if (connection !== socket || generation !== connectionGeneration || !desired) return;
1163
+ counters.connections += 1;
1164
+ transition('registering');
1165
+ const registered = enqueueEnvelope({
1166
+ type: 'relay.register',
1167
+ protocol: RELAY_CONTROL_PROTOCOL,
1168
+ roomId,
1169
+ role: 'hub',
1170
+ peerId: '',
1171
+ ack: true,
1172
+ token: registrationToken
1173
+ });
1174
+ if (!registered) socket.destroy();
1175
+ });
1176
+ socket.on('data', chunk => onConnectionData(socket, generation, chunk));
1177
+ socket.on('drain', () => {
1178
+ if (connection !== socket || generation !== connectionGeneration) return;
1179
+ awaitingDrain = false;
1180
+ flushQueue();
1181
+ });
1182
+ socket.once('error', error => {
1183
+ if (connection !== socket || generation !== connectionGeneration) return;
1184
+ lastError = sanitizeSocketError(error);
1185
+ logWarn('relay', `Encrypted relay control connection failed code=${lastError}`);
1186
+ });
1187
+ socket.once('close', () => handleConnectionClosed(socket, generation));
1188
+ connectAttemptTimer = setTimeout(() => {
1189
+ if (connection !== socket
1190
+ || generation !== connectionGeneration
1191
+ || socket.destroyed
1192
+ || (state !== 'connecting' && state !== 'registering')) return;
1193
+ lastError = 'relay-connect-timeout';
1194
+ logWarn('relay', `Encrypted relay control connection failed code=${lastError}`);
1195
+ socket.destroy();
1196
+ }, connectTimeoutMs);
1197
+ connectAttemptTimer.unref?.();
1198
+ recordTimerOwnerHighWater();
1199
+ }
1200
+
1201
+ async function start() {
1202
+ if (!enabled || started) return getStatus();
1203
+ if (closing || closeTimedOut || connectionOwners.size > 0) {
1204
+ throw relayError('relay-close-nonterminal');
1205
+ }
1206
+ started = true;
1207
+ desired = true;
1208
+ closing = false;
1209
+ connectNow();
1210
+ return getStatus();
1211
+ }
1212
+
1213
+ async function close() {
1214
+ const closeDeadlineAt = Date.now() + 1_000;
1215
+ desired = false;
1216
+ started = false;
1217
+ closing = true;
1218
+ closeTimedOut = false;
1219
+ transition('closing');
1220
+ if (reconnectTimer) clearTimeout(reconnectTimer);
1221
+ reconnectTimer = null;
1222
+ clearConnectAttemptTimer();
1223
+ closeAllPeers('hub-shutdown');
1224
+ clearQueue();
1225
+ const socket = connection;
1226
+ const generation = connectionGeneration;
1227
+ if (socket) markConnectionOwnerClosing(socket, generation);
1228
+ connection = null;
1229
+ connectionGeneration += 1;
1230
+ clearReceiveChunks();
1231
+ const closingOwners = [...connectionOwners.values()];
1232
+ if (closingOwners.length > 0) {
1233
+ const ownersClosed = new Promise((resolve, reject) => {
1234
+ let settled = false;
1235
+ const onClose = () => {
1236
+ if (connectionOwners.size === 0) settle();
1237
+ };
1238
+ const settle = () => {
1239
+ if (settled) return;
1240
+ settled = true;
1241
+ if (closeSocketTimer) clearTimeout(closeSocketTimer);
1242
+ closeSocketTimer = null;
1243
+ for (const owner of closingOwners) {
1244
+ owner.socket.off?.('close', onClose);
1245
+ }
1246
+ resolve();
1247
+ };
1248
+ const fail = () => {
1249
+ if (settled) return;
1250
+ settled = true;
1251
+ closeSocketTimer = null;
1252
+ for (const owner of closingOwners) {
1253
+ owner.socket.off?.('close', onClose);
1254
+ }
1255
+ reject(relayError('relay-close-timeout'));
1256
+ };
1257
+ for (const owner of closingOwners) {
1258
+ owner.phase = 'closing';
1259
+ owner.socket.once('close', onClose);
1260
+ }
1261
+ closeSocketTimer = setTimeout(() => {
1262
+ fail();
1263
+ }, Math.max(1, closeDeadlineAt - Date.now()));
1264
+ recordTimerOwnerHighWater();
1265
+ if (connectionOwners.size === 0) settle();
1266
+ });
1267
+ for (const owner of closingOwners) {
1268
+ try {
1269
+ owner.socket.end();
1270
+ } catch {
1271
+ // The exact owner remains in the ledger until close.
1272
+ }
1273
+ try {
1274
+ owner.socket.destroy();
1275
+ } catch {
1276
+ // Timeout remains explicit and nonterminal.
1277
+ }
1278
+ }
1279
+ try {
1280
+ await ownersClosed;
1281
+ } catch (error) {
1282
+ closeTimedOut = true;
1283
+ lastError = 'relay-close-timeout';
1284
+ transition('close-timeout', lastError);
1285
+ throw error;
1286
+ }
1287
+ }
1288
+ if (connectionOwners.size !== 0) {
1289
+ closeTimedOut = true;
1290
+ lastError = 'relay-close-nonterminal';
1291
+ transition('close-timeout', lastError);
1292
+ throw relayError(lastError);
1293
+ }
1294
+ closeTimedOut = false;
1295
+ closing = false;
1296
+ lastError = '';
1297
+ transition(enabled ? 'idle' : 'disabled');
1298
+ }
1299
+
1300
+ function getStatus() {
1301
+ const writableBytes = socketWritableBytes();
1302
+ recordOutboundHighWater();
1303
+ return {
1304
+ enabled,
1305
+ started,
1306
+ state,
1307
+ connected: !!connection && !connection.destroyed && state === 'registered',
1308
+ closing,
1309
+ closeTimedOut,
1310
+ activeSocketCount: connectionOwners.size,
1311
+ currentSocketCount: connection && connectionOwners.has(connection) ? 1 : 0,
1312
+ closingSocketCount: [...connectionOwners.values()]
1313
+ .filter(owner => owner.phase === 'closing').length,
1314
+ activeTimerCount: activeTimerCount(),
1315
+ timerOwnerHighWater,
1316
+ socketOwnerHighWater,
1317
+ socketOwnersCreated: counters.socketOwnersCreated,
1318
+ socketOwnersClosed: counters.socketOwnersClosed,
1319
+ protocol: RELAY_CONTROL_PROTOCOL,
1320
+ endpoint: `${host}:${port}`,
1321
+ host,
1322
+ port,
1323
+ peerCount: peers.size,
1324
+ peerOwnerHighWater,
1325
+ peerOwnersCreated: counters.peerOwnersCreated,
1326
+ peerOwnersClosed: counters.peerOwnersClosed,
1327
+ peerQueueCount: peerQueueStats.size,
1328
+ queuedMessages: queuedMessageCount(),
1329
+ queuedBytes,
1330
+ socketWritableBytes: writableBytes,
1331
+ totalOutboundBufferedBytes: queuedBytes + writableBytes,
1332
+ queuedMessageHighWater,
1333
+ queuedByteHighWater,
1334
+ socketWritableByteHighWater,
1335
+ totalOutboundByteHighWater,
1336
+ receiveBufferedChunks: receiveChunks.length,
1337
+ receiveBufferedBytes: receiveBytes,
1338
+ receiveChunkHighWater,
1339
+ receiveByteHighWater,
1340
+ receiveLineAssemblyCopyCount: counters.receiveLineAssemblyCopies,
1341
+ receiveLineAssemblyBytes: counters.receiveLineAssemblyBytes,
1342
+ plaintextDeliveryCopyCount: counters.plaintextDeliveryCopies,
1343
+ peerQueuePurgeCount: counters.peerQueuePurges,
1344
+ peerQueuePurgedBytes: counters.peerQueuePurgedBytes,
1345
+ staleConnectionEventDropCount: counters.staleConnectionEventDrops,
1346
+ maxPeers,
1347
+ maxQueueMessages,
1348
+ maxQueueBytes,
1349
+ reconnectCount: counters.reconnects,
1350
+ handshakeCount: counters.handshakes,
1351
+ rejectedPeerCount: counters.rejectedPeers,
1352
+ encryptedMessagesReceived: counters.encryptedMessagesReceived,
1353
+ encryptedMessagesSent: counters.encryptedMessagesSent,
1354
+ queueDropCount: counters.queueDrops,
1355
+ lastConnectedAt,
1356
+ lastTransitionAt,
1357
+ lastDisconnectReason,
1358
+ lastError
1359
+ };
1360
+ }
1361
+
1362
+ function recordPlaintextDeliveryCopy() {
1363
+ counters.plaintextDeliveryCopies += 1;
1364
+ }
1365
+
1366
+ const api = {
1367
+ start,
1368
+ close,
1369
+ getStatus,
1370
+ sendPeerPlaintext,
1371
+ closePeer,
1372
+ isPeerBackpressured,
1373
+ recordPlaintextDeliveryCopy
1374
+ };
1375
+ return api;
1376
+ }