@livedesk/hub 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +32 -32
- package/src/agents/agent-audit-store.js +6 -16
- package/src/agents/agent-permissions.js +27 -33
- package/src/agents/agent-tool-registry.js +322 -340
- package/src/captures/capture-store.js +252 -299
- package/src/filesystem/shared-folders.js +181 -189
- package/src/filesystem/transfer-jobs.js +314 -345
- package/src/live-desk-update.js +615 -683
- package/src/remote-hub.js +85 -654
- package/src/server.js +154 -965
- package/src/settings/settings-schema.js +40 -32
- package/src/settings/settings-store.js +2 -7
- package/src/transport/relay-hub-control.js +1376 -1703
- package/src/transport/udp-hub-transport.js +520 -543
- package/src/transport/udp-rendezvous.js +408 -574
- package/src/security/device-credential-authority.js +0 -406
- package/src/security/security-audit-store.js +0 -260
- package/src/transport/secure-direct-acceptor.js +0 -543
|
@@ -1,1703 +1,1376 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import { EventEmitter } from 'node:events';
|
|
3
|
-
import net from 'node:net';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
throw relayError('relay-
|
|
121
|
-
}
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
.
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
nonce =
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
this.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
return
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (this.destroyed)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
:
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
:
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
:
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
+
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
function
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
return
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
if (
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
counters.
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
return;
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
return;
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
+
}
|