peer-term 1.3.10 → 1.4.0
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/bin/peer-term.js +1 -1
- package/package.json +1 -1
- package/src/cli.js +128 -0
- package/src/config.js +91 -0
- package/src/file-transfer.js +230 -0
- package/src/index.js +1037 -1849
- package/src/prompts.js +143 -0
- package/src/utils.js +113 -0
- package/src/viewer.js +259 -0
package/src/index.js
CHANGED
|
@@ -1,1849 +1,1037 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PeerTerm Host Agent
|
|
3
|
-
*
|
|
4
|
-
* CLI tool that:
|
|
5
|
-
* 1. Connects to the relay server and registers sessions
|
|
6
|
-
* 2. Manages multiple simultaneous sessions
|
|
7
|
-
* 3. Performs ECDH key exchange for E2E encryption per session
|
|
8
|
-
* 4. Spawns independent PTY instances per session
|
|
9
|
-
* 5. Supports read-only mode and terminal resize
|
|
10
|
-
*
|
|
11
|
-
* CLI flags:
|
|
12
|
-
* --expiry <value> Session code expiry (e.g. 5m, 30s, 1h). Default: 5m
|
|
13
|
-
* --rejoin <value> Reconnection window (e.g. 5m, 1h, 6h). Default: 2m
|
|
14
|
-
* --readonly Prevent client keystrokes from reaching the PTY
|
|
15
|
-
* --path <dir> Starting directory for the terminal session
|
|
16
|
-
* --relay <url> Custom relay server URL
|
|
17
|
-
* --verbose Enable debug-level logging
|
|
18
|
-
* --help Show usage information
|
|
19
|
-
* --version Print version number
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import
|
|
34
|
-
import
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
} from './
|
|
43
|
-
import {
|
|
44
|
-
import
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
// ───
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
this.
|
|
398
|
-
|
|
399
|
-
this.
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
try {
|
|
491
|
-
const
|
|
492
|
-
this.
|
|
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
|
-
|
|
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
|
-
this.
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
this.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
this.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
this.
|
|
791
|
-
this.
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
});
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (this.
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
async
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
this.
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
// Close any existing viewer connection before accepting a new one
|
|
1039
|
-
if (this.viewerSocket) {
|
|
1040
|
-
this.logDebug('Replacing existing viewer connection.');
|
|
1041
|
-
this.viewerSocket.removeAllListeners();
|
|
1042
|
-
try { this.viewerSocket.destroy(); } catch {}
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
// ── Token authentication gate ──────────────────────────────────
|
|
1046
|
-
// The first data packet must be the viewer token followed by a newline.
|
|
1047
|
-
// Reject the connection if no valid token arrives within 2 seconds.
|
|
1048
|
-
let authenticated = false;
|
|
1049
|
-
let authBuf = '';
|
|
1050
|
-
|
|
1051
|
-
const authTimeout = setTimeout(() => {
|
|
1052
|
-
if (!authenticated) {
|
|
1053
|
-
this.logDebug('Viewer auth timeout — closing connection.');
|
|
1054
|
-
try { socket.destroy(); } catch {}
|
|
1055
|
-
}
|
|
1056
|
-
}, 2000);
|
|
1057
|
-
|
|
1058
|
-
const onAuthData = (data) => {
|
|
1059
|
-
authBuf += data.toString();
|
|
1060
|
-
const newlineIdx = authBuf.indexOf('\n');
|
|
1061
|
-
if (newlineIdx === -1) {
|
|
1062
|
-
// Accumulated too much data without a newline — reject
|
|
1063
|
-
if (authBuf.length > 256) {
|
|
1064
|
-
clearTimeout(authTimeout);
|
|
1065
|
-
this.logDebug('Viewer auth buffer overflow — closing connection.');
|
|
1066
|
-
try { socket.destroy(); } catch {}
|
|
1067
|
-
}
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
const token = authBuf.slice(0, newlineIdx).trim();
|
|
1072
|
-
const remaining = authBuf.slice(newlineIdx + 1);
|
|
1073
|
-
clearTimeout(authTimeout);
|
|
1074
|
-
socket.removeListener('data', onAuthData);
|
|
1075
|
-
|
|
1076
|
-
if (token !== this.viewerToken) {
|
|
1077
|
-
this.logDebug('Viewer auth failed — wrong token.');
|
|
1078
|
-
try { socket.destroy(); } catch {}
|
|
1079
|
-
return;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// Authenticated — promote to active viewer
|
|
1083
|
-
authenticated = true;
|
|
1084
|
-
this.viewerSocket = socket;
|
|
1085
|
-
this.log('Host viewer connected (authenticated).');
|
|
1086
|
-
|
|
1087
|
-
// Install the real data handler
|
|
1088
|
-
let recvBuf = '';
|
|
1089
|
-
|
|
1090
|
-
// Process any leftover data that arrived after the token line
|
|
1091
|
-
if (remaining.length > 0) {
|
|
1092
|
-
handleViewerData(remaining);
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
socket.on('data', (chunk) => handleViewerData(chunk.toString()));
|
|
1096
|
-
|
|
1097
|
-
const handleViewerData = (chunk) => {
|
|
1098
|
-
// Control messages start with \x00{
|
|
1099
|
-
if (recvBuf.length > 0 || chunk.startsWith('\x00{')) {
|
|
1100
|
-
recvBuf += chunk;
|
|
1101
|
-
let nlIdx;
|
|
1102
|
-
while ((nlIdx = recvBuf.indexOf('\n')) !== -1) {
|
|
1103
|
-
const line = recvBuf.slice(0, nlIdx).trim();
|
|
1104
|
-
recvBuf = recvBuf.slice(nlIdx + 1);
|
|
1105
|
-
|
|
1106
|
-
if (line.startsWith('\x00')) {
|
|
1107
|
-
try {
|
|
1108
|
-
const msg = JSON.parse(line.slice(1));
|
|
1109
|
-
if (msg.type === 'resize' &&
|
|
1110
|
-
Number.isInteger(msg.cols) && msg.cols > 0 &&
|
|
1111
|
-
Number.isInteger(msg.rows) && msg.rows > 0 &&
|
|
1112
|
-
this.ptyProcess) {
|
|
1113
|
-
this.ptyProcess.resize(msg.cols, msg.rows);
|
|
1114
|
-
this._sendResize(msg.cols, msg.rows);
|
|
1115
|
-
this.logDebug(`Viewer resized to ${msg.cols}x${msg.rows}`);
|
|
1116
|
-
}
|
|
1117
|
-
} catch {}
|
|
1118
|
-
} else if (this.ptyProcess) {
|
|
1119
|
-
this.ptyProcess.write(line + '\n');
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
// Flush any non-control data left in the buffer
|
|
1123
|
-
if (recvBuf.length > 0 && !recvBuf.startsWith('\x00')) {
|
|
1124
|
-
if (this.ptyProcess) this.ptyProcess.write(recvBuf);
|
|
1125
|
-
recvBuf = '';
|
|
1126
|
-
}
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// Host keystrokes → PTY (host always has access)
|
|
1131
|
-
if (this.ptyProcess) {
|
|
1132
|
-
this.ptyProcess.write(chunk);
|
|
1133
|
-
}
|
|
1134
|
-
};
|
|
1135
|
-
|
|
1136
|
-
socket.on('close', () => {
|
|
1137
|
-
this.viewerSocket = null;
|
|
1138
|
-
this.logDebug('Host viewer disconnected.');
|
|
1139
|
-
});
|
|
1140
|
-
|
|
1141
|
-
socket.on('error', () => {
|
|
1142
|
-
this.viewerSocket = null;
|
|
1143
|
-
});
|
|
1144
|
-
};
|
|
1145
|
-
|
|
1146
|
-
socket.on('data', onAuthData);
|
|
1147
|
-
|
|
1148
|
-
socket.on('error', () => {
|
|
1149
|
-
clearTimeout(authTimeout);
|
|
1150
|
-
});
|
|
1151
|
-
});
|
|
1152
|
-
|
|
1153
|
-
this.viewerServer.listen(0, '127.0.0.1', () => {
|
|
1154
|
-
const port = this.viewerServer.address().port;
|
|
1155
|
-
this.logDebug(`Viewer server on port ${port}`);
|
|
1156
|
-
this._openViewerTerminal(port, this.viewerToken);
|
|
1157
|
-
});
|
|
1158
|
-
|
|
1159
|
-
this.viewerServer.on('error', (err) => {
|
|
1160
|
-
this.log(`Viewer server error: ${err.message}`);
|
|
1161
|
-
this.viewerServer = null;
|
|
1162
|
-
});
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
_openViewerTerminal(port, token) {
|
|
1166
|
-
if (!/^[A-Za-z0-9_-]+$/.test(this.code)) {
|
|
1167
|
-
this.log('Invalid session code format. Aborting viewer terminal.');
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
const viewerScript = path.join(__dirname, 'session-viewer.js');
|
|
1172
|
-
const platform = os.platform();
|
|
1173
|
-
const portStr = String(port);
|
|
1174
|
-
const nodeArgs = [viewerScript, portStr, this.code, token];
|
|
1175
|
-
|
|
1176
|
-
if (platform === 'win32') {
|
|
1177
|
-
// spawn with 'cmd' to open a new window; arguments are passed as an
|
|
1178
|
-
// array so the install path is never interpreted by the shell.
|
|
1179
|
-
spawn('cmd', ['/c', 'start', `PeerTerm - ${this.code}`, 'cmd', '/c', 'node', ...nodeArgs], {
|
|
1180
|
-
stdio: 'ignore',
|
|
1181
|
-
detached: true,
|
|
1182
|
-
windowsHide: false,
|
|
1183
|
-
}).unref();
|
|
1184
|
-
} else if (platform === 'darwin') {
|
|
1185
|
-
// osascript receives the AppleScript source as a single -e argument;
|
|
1186
|
-
// node args are baked into the script string, but execFile does NOT
|
|
1187
|
-
// invoke a shell so the outer path cannot break out.
|
|
1188
|
-
const script = `tell app "Terminal" to do script "node '${viewerScript.replace(/'/g, "'\\''")}' ${portStr} ${this.code} ${token}"`;
|
|
1189
|
-
execFile('osascript', ['-e', script], { stdio: 'ignore' }, () => {});
|
|
1190
|
-
} else {
|
|
1191
|
-
// Linux: try common terminal emulators in order, falling through on
|
|
1192
|
-
// failure. Each call uses execFile (no shell), so paths with
|
|
1193
|
-
// metacharacters are safe.
|
|
1194
|
-
const tryTerminals = [
|
|
1195
|
-
['x-terminal-emulator', ['-e', 'node', ...nodeArgs]],
|
|
1196
|
-
['gnome-terminal', ['--', 'node', ...nodeArgs]],
|
|
1197
|
-
['xterm', ['-e', 'node', ...nodeArgs]],
|
|
1198
|
-
];
|
|
1199
|
-
|
|
1200
|
-
const tryNext = (index) => {
|
|
1201
|
-
if (index >= tryTerminals.length) {
|
|
1202
|
-
this.log('Could not open any terminal emulator for viewer.');
|
|
1203
|
-
return;
|
|
1204
|
-
}
|
|
1205
|
-
const [cmd, args] = tryTerminals[index];
|
|
1206
|
-
execFile(cmd, args, { stdio: 'ignore' }, (err) => {
|
|
1207
|
-
if (err) tryNext(index + 1);
|
|
1208
|
-
});
|
|
1209
|
-
};
|
|
1210
|
-
tryNext(0);
|
|
1211
|
-
}
|
|
1212
|
-
this.log('Opening terminal viewer...');
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
// ─── Host Reconnect Logic ───────────────────────────────────────────
|
|
1216
|
-
_startReconnecting() {
|
|
1217
|
-
if (this.destroyed || this.intentionalClose) return;
|
|
1218
|
-
|
|
1219
|
-
let attempts = 0;
|
|
1220
|
-
const maxAttempts = 24; // 24 × 5s = 2 minutes
|
|
1221
|
-
|
|
1222
|
-
this.reconnectTimer = setInterval(() => {
|
|
1223
|
-
attempts++;
|
|
1224
|
-
if (attempts > maxAttempts) {
|
|
1225
|
-
this.log('Could not reconnect. Session expired.');
|
|
1226
|
-
this._stopReconnecting();
|
|
1227
|
-
this.destroy();
|
|
1228
|
-
return;
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
this.log(`Reconnect attempt ${attempts}/${maxAttempts}...`);
|
|
1232
|
-
|
|
1233
|
-
// Close any in-flight reconnect socket from the previous tick
|
|
1234
|
-
if (this._pendingReconnectWs) {
|
|
1235
|
-
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
1236
|
-
this._pendingReconnectWs = null;
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
try {
|
|
1240
|
-
const newWs = new WebSocket(this.relayUrl);
|
|
1241
|
-
this._pendingReconnectWs = newWs;
|
|
1242
|
-
|
|
1243
|
-
newWs.on('open', () => {
|
|
1244
|
-
newWs.send(JSON.stringify({ type: 'host-rejoin', code: this.code, hostToken: this.hostToken }));
|
|
1245
|
-
});
|
|
1246
|
-
|
|
1247
|
-
newWs.on('message', async (raw) => {
|
|
1248
|
-
try {
|
|
1249
|
-
let msg;
|
|
1250
|
-
try {
|
|
1251
|
-
msg = JSON.parse(raw.toString());
|
|
1252
|
-
} catch {
|
|
1253
|
-
return;
|
|
1254
|
-
}
|
|
1255
|
-
if (!msg || typeof msg.type !== 'string') {
|
|
1256
|
-
this._rejectProtocolMessage('Malformed relay message');
|
|
1257
|
-
return;
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
if (msg.type === 'rejoined') {
|
|
1261
|
-
// Success — replace the old ws with this new one
|
|
1262
|
-
this._pendingReconnectWs = null;
|
|
1263
|
-
this.ws = newWs;
|
|
1264
|
-
this._stopReconnecting();
|
|
1265
|
-
this.log(`Reconnected. Session restored.`);
|
|
1266
|
-
|
|
1267
|
-
// Re-attach the full message handler by wiring up events
|
|
1268
|
-
this._attachWsHandlers(newWs);
|
|
1269
|
-
|
|
1270
|
-
await this._restartPeerSessionAfterHostRejoin();
|
|
1271
|
-
} else if (msg.type === 'error') {
|
|
1272
|
-
this.log(`Rejoin failed: ${msg.msg}`);
|
|
1273
|
-
this._pendingReconnectWs = null;
|
|
1274
|
-
this._stopReconnecting();
|
|
1275
|
-
this.destroy();
|
|
1276
|
-
try { newWs.close(); } catch {}
|
|
1277
|
-
}
|
|
1278
|
-
} catch (err) {
|
|
1279
|
-
this._handleMessageError(err);
|
|
1280
|
-
}
|
|
1281
|
-
});
|
|
1282
|
-
|
|
1283
|
-
newWs.on('error', () => {
|
|
1284
|
-
// Connection failed, next retry in 5s
|
|
1285
|
-
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
1286
|
-
try { newWs.close(); } catch {}
|
|
1287
|
-
});
|
|
1288
|
-
|
|
1289
|
-
newWs.on('close', () => {
|
|
1290
|
-
// Clean up reference if this was the pending socket
|
|
1291
|
-
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
1292
|
-
});
|
|
1293
|
-
} catch (e) {
|
|
1294
|
-
// Connection failed, next retry in 5s
|
|
1295
|
-
}
|
|
1296
|
-
}, 5000);
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
_stopReconnecting() {
|
|
1300
|
-
if (this.reconnectTimer) {
|
|
1301
|
-
clearInterval(this.reconnectTimer);
|
|
1302
|
-
this.reconnectTimer = null;
|
|
1303
|
-
}
|
|
1304
|
-
if (this._pendingReconnectWs) {
|
|
1305
|
-
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
1306
|
-
this._pendingReconnectWs = null;
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
/**
|
|
1311
|
-
* Re-attach message/close/error handlers to a new WebSocket after rejoin.
|
|
1312
|
-
* This mirrors the handlers set in _tryConnect but skips the initial registration flow.
|
|
1313
|
-
*/
|
|
1314
|
-
_attachWsHandlers(newWs) {
|
|
1315
|
-
newWs.on('message', async (raw) => {
|
|
1316
|
-
try {
|
|
1317
|
-
let msg;
|
|
1318
|
-
try {
|
|
1319
|
-
msg = JSON.parse(raw.toString());
|
|
1320
|
-
} catch {
|
|
1321
|
-
this.log('Invalid message from relay');
|
|
1322
|
-
return;
|
|
1323
|
-
}
|
|
1324
|
-
if (!msg || typeof msg.type !== 'string') {
|
|
1325
|
-
this._rejectProtocolMessage('Malformed relay message');
|
|
1326
|
-
return;
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
switch (msg.type) {
|
|
1330
|
-
case 'client-connected': {
|
|
1331
|
-
if (this.awaitingRejoin) {
|
|
1332
|
-
this.log('Client reconnected.');
|
|
1333
|
-
this.awaitingRejoin = false;
|
|
1334
|
-
} else {
|
|
1335
|
-
this.log('Client connected! Starting key exchange...');
|
|
1336
|
-
}
|
|
1337
|
-
this.isClientConnected = true;
|
|
1338
|
-
this.missedPings = 0;
|
|
1339
|
-
|
|
1340
|
-
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1341
|
-
// Keep existing keys if direct session is healthy
|
|
1342
|
-
this.startHeartbeat();
|
|
1343
|
-
break;
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
await this._beginKeyExchange();
|
|
1347
|
-
break;
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
case 'key-exchange': {
|
|
1351
|
-
await this._completeKeyExchange(msg.publicKey);
|
|
1352
|
-
break;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
case 'signal': {
|
|
1356
|
-
if (this.webrtc && this.sharedKey) {
|
|
1357
|
-
try {
|
|
1358
|
-
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
1359
|
-
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
1360
|
-
} catch (err) {
|
|
1361
|
-
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
break;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
case 'heartbeat': {
|
|
1368
|
-
this.missedPings = 0;
|
|
1369
|
-
break;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
case 'data': {
|
|
1373
|
-
if (!this.sharedKey || !this.ptyProcess) return;
|
|
1374
|
-
try {
|
|
1375
|
-
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
1376
|
-
|
|
1377
|
-
if (msg.meta === 'resize') {
|
|
1378
|
-
try {
|
|
1379
|
-
const resizeData = JSON.parse(plaintext);
|
|
1380
|
-
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
1381
|
-
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
1382
|
-
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
1383
|
-
}
|
|
1384
|
-
} catch {}
|
|
1385
|
-
return;
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
// Check if decrypted message is a file transfer message
|
|
1389
|
-
try {
|
|
1390
|
-
const parsedMsg = JSON.parse(plaintext);
|
|
1391
|
-
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
1392
|
-
if (this.readOnly) {
|
|
1393
|
-
this._sendFileError(parsedMsg.id, 'File uploads are disabled in read-only mode');
|
|
1394
|
-
return;
|
|
1395
|
-
}
|
|
1396
|
-
this._handleFileMessage(parsedMsg);
|
|
1397
|
-
return;
|
|
1398
|
-
}
|
|
1399
|
-
} catch {
|
|
1400
|
-
// Not JSON — normal keystroke data, fall through
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
if (this.readOnly) return;
|
|
1404
|
-
this.ptyProcess.write(plaintext);
|
|
1405
|
-
} catch (err) {
|
|
1406
|
-
this.log(`Decryption failed: ${err.message}`);
|
|
1407
|
-
}
|
|
1408
|
-
break;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
case 'peer-disconnected': {
|
|
1412
|
-
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1413
|
-
this.log('Client relay connection lost; direct DataChannel still active.');
|
|
1414
|
-
this.missedPings = 0;
|
|
1415
|
-
this.startHeartbeat();
|
|
1416
|
-
break;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
|
|
1420
|
-
this.isClientConnected = false;
|
|
1421
|
-
this.awaitingRejoin = true;
|
|
1422
|
-
this.sharedKey = null;
|
|
1423
|
-
this.keyPair = null;
|
|
1424
|
-
this.hostPublicKeyBase64 = null;
|
|
1425
|
-
this.clientPublicKeyBase64 = null;
|
|
1426
|
-
this.securityFingerprint = null;
|
|
1427
|
-
this.fingerprintAuthorized = false;
|
|
1428
|
-
this.stopHeartbeat();
|
|
1429
|
-
this._cleanupWebRTC();
|
|
1430
|
-
break;
|
|
1431
|
-
}
|
|
1432
|
-
|
|
1433
|
-
case 'session-expired': {
|
|
1434
|
-
this.log('Rejoin window expired. Session ended.');
|
|
1435
|
-
this.destroy();
|
|
1436
|
-
break;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
case 'rejoined': {
|
|
1440
|
-
this.log(`Reconnected. Session restored. (code: ${msg.code})`);
|
|
1441
|
-
await this._restartPeerSessionAfterHostRejoin();
|
|
1442
|
-
break;
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
case 'error': {
|
|
1446
|
-
this.log(`Error: ${msg.msg}`);
|
|
1447
|
-
break;
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
} catch (err) {
|
|
1451
|
-
this._handleMessageError(err);
|
|
1452
|
-
}
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
newWs.on('close', () => {
|
|
1456
|
-
if (this.destroyed) return;
|
|
1457
|
-
if (this.intentionalClose) {
|
|
1458
|
-
this.log('Connection to relay closed (intentional).');
|
|
1459
|
-
this.destroy();
|
|
1460
|
-
} else {
|
|
1461
|
-
this.log('Connection to relay lost. Starting reconnect...');
|
|
1462
|
-
this.stopHeartbeat();
|
|
1463
|
-
this._startReconnecting();
|
|
1464
|
-
}
|
|
1465
|
-
});
|
|
1466
|
-
|
|
1467
|
-
newWs.on('error', (err) => {
|
|
1468
|
-
this.log(`WS error: ${err.message}`);
|
|
1469
|
-
});
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
|
-
// ─── Destroy ────────────────────────────────────────────────────────
|
|
1473
|
-
destroy() {
|
|
1474
|
-
if (this.destroyed) return;
|
|
1475
|
-
this.destroyed = true;
|
|
1476
|
-
this.intentionalClose = true;
|
|
1477
|
-
this.stopHeartbeat();
|
|
1478
|
-
this._stopReconnecting();
|
|
1479
|
-
this._stopDurationLogging();
|
|
1480
|
-
// Clean up in-progress file uploads
|
|
1481
|
-
this._incomingFiles = {};
|
|
1482
|
-
// Clean up viewer
|
|
1483
|
-
if (this.viewerSocket) {
|
|
1484
|
-
try { this.viewerSocket.destroy(); } catch {}
|
|
1485
|
-
this.viewerSocket = null;
|
|
1486
|
-
}
|
|
1487
|
-
if (this.viewerServer) {
|
|
1488
|
-
try { this.viewerServer.close(); } catch {}
|
|
1489
|
-
this.viewerServer = null;
|
|
1490
|
-
}
|
|
1491
|
-
// Phase 4: Clean up WebRTC
|
|
1492
|
-
this._cleanupWebRTC();
|
|
1493
|
-
if (this.ptyProcess) {
|
|
1494
|
-
try { this.ptyProcess.kill(); } catch {}
|
|
1495
|
-
this.ptyProcess = null;
|
|
1496
|
-
}
|
|
1497
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1498
|
-
// Send session-ended so relay knows this is intentional and destroys session immediately
|
|
1499
|
-
try { this.ws.send(JSON.stringify({ type: 'session-ended' })); } catch {}
|
|
1500
|
-
this.ws.close();
|
|
1501
|
-
}
|
|
1502
|
-
// Log session duration on end
|
|
1503
|
-
if (this.sessionStartedAt) {
|
|
1504
|
-
const duration = formatDuration(Date.now() - this.sessionStartedAt);
|
|
1505
|
-
this.log(`Session ended. Duration: ${duration}`);
|
|
1506
|
-
} else {
|
|
1507
|
-
this.log('Session ended.');
|
|
1508
|
-
}
|
|
1509
|
-
if (this.onDestroy) this.onDestroy(this.code);
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
// ─── Phase 4: WebRTC helpers ─────────────────────────────────────
|
|
1513
|
-
|
|
1514
|
-
_initiateWebRTC() {
|
|
1515
|
-
// Clean up any previous WebRTC instance
|
|
1516
|
-
this._cleanupWebRTC();
|
|
1517
|
-
|
|
1518
|
-
// Pass debug-level logger to WebRTC (internal messages are verbose)
|
|
1519
|
-
this.webrtc = new HostWebRTC((msg) => this.logDebug(msg));
|
|
1520
|
-
|
|
1521
|
-
this.webrtc.onOpen(() => {
|
|
1522
|
-
this.useDataChannel = true;
|
|
1523
|
-
this.log('DataChannel open — relay bypassed');
|
|
1524
|
-
});
|
|
1525
|
-
|
|
1526
|
-
this.webrtc.onClose(() => {
|
|
1527
|
-
if (this.useDataChannel) {
|
|
1528
|
-
this.log('DataChannel closed — falling back to relay');
|
|
1529
|
-
this.useDataChannel = false;
|
|
1530
|
-
}
|
|
1531
|
-
});
|
|
1532
|
-
|
|
1533
|
-
this.webrtc.onMessage(async (data) => {
|
|
1534
|
-
if (!this.sharedKey) return;
|
|
1535
|
-
try {
|
|
1536
|
-
// Check if this is a tagged message (resize)
|
|
1537
|
-
let parsed;
|
|
1538
|
-
try {
|
|
1539
|
-
parsed = JSON.parse(data);
|
|
1540
|
-
} catch {
|
|
1541
|
-
parsed = null;
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
if (parsed && parsed._meta === 'resize') {
|
|
1545
|
-
const resizeData = JSON.parse(await decrypt(this.sharedKey, parsed.payload));
|
|
1546
|
-
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
1547
|
-
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
1548
|
-
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
1549
|
-
}
|
|
1550
|
-
return;
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
// Normal encrypted data — decrypt first
|
|
1554
|
-
const plaintext = await decrypt(this.sharedKey, data);
|
|
1555
|
-
|
|
1556
|
-
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
1557
|
-
try {
|
|
1558
|
-
const msg = JSON.parse(plaintext);
|
|
1559
|
-
if (msg && typeof msg.type === 'string' && msg.type.startsWith('file-')) {
|
|
1560
|
-
if (this.readOnly) {
|
|
1561
|
-
this._sendFileError(msg.id, 'File uploads are disabled in read-only mode');
|
|
1562
|
-
return;
|
|
1563
|
-
}
|
|
1564
|
-
this._handleFileMessage(msg);
|
|
1565
|
-
return;
|
|
1566
|
-
}
|
|
1567
|
-
} catch {
|
|
1568
|
-
// Not JSON — normal keystroke data, fall through
|
|
1569
|
-
}
|
|
1570
|
-
|
|
1571
|
-
// Normal keystroke — drop if read-only or no PTY
|
|
1572
|
-
if (!this.ptyProcess) return;
|
|
1573
|
-
if (this.readOnly) return;
|
|
1574
|
-
this.ptyProcess.write(plaintext);
|
|
1575
|
-
} catch (err) {
|
|
1576
|
-
this.logDebug(`[WebRTC] Decryption failed: ${err.message}`);
|
|
1577
|
-
}
|
|
1578
|
-
});
|
|
1579
|
-
|
|
1580
|
-
// Start the WebRTC negotiation
|
|
1581
|
-
this.webrtc.initiate(async (msg) => {
|
|
1582
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sharedKey) {
|
|
1583
|
-
try {
|
|
1584
|
-
const payloadStr = JSON.stringify(msg.payload);
|
|
1585
|
-
const encryptedPayload = await encrypt(this.sharedKey, payloadStr);
|
|
1586
|
-
this.ws.send(JSON.stringify({ type: 'signal', payload: encryptedPayload }));
|
|
1587
|
-
} catch (e) {
|
|
1588
|
-
this.logDebug(`[WebRTC] Failed to encrypt signal: ${e.message}`);
|
|
1589
|
-
}
|
|
1590
|
-
}
|
|
1591
|
-
});
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
async _restartPeerSessionAfterHostRejoin() {
|
|
1595
|
-
this.missedPings = 0;
|
|
1596
|
-
|
|
1597
|
-
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
1598
|
-
this.startHeartbeat();
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
this.sharedKey = null;
|
|
1603
|
-
this.hostPublicKeyBase64 = null;
|
|
1604
|
-
this.clientPublicKeyBase64 = null;
|
|
1605
|
-
this.securityFingerprint = null;
|
|
1606
|
-
this.fingerprintAuthorized = false;
|
|
1607
|
-
this._cleanupWebRTC();
|
|
1608
|
-
|
|
1609
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
1610
|
-
await this._beginKeyExchange();
|
|
1611
|
-
}
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
_cleanupWebRTC() {
|
|
1615
|
-
this.useDataChannel = false;
|
|
1616
|
-
if (this.webrtc) {
|
|
1617
|
-
this.webrtc.close();
|
|
1618
|
-
this.webrtc = null;
|
|
1619
|
-
}
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
// ─── Status string for list command ─────────────────────────────────
|
|
1623
|
-
getStatus() {
|
|
1624
|
-
const elapsed = Date.now() - this.createdAt;
|
|
1625
|
-
const remaining = Math.max(0, this.expiryMs - elapsed);
|
|
1626
|
-
let status = '';
|
|
1627
|
-
if (this.securityFingerprint && !this.fingerprintAuthorized) {
|
|
1628
|
-
status = `verify fingerprint ${this.securityFingerprint}`;
|
|
1629
|
-
} else if (this.isClientConnected) {
|
|
1630
|
-
const duration = this.sessionStartedAt
|
|
1631
|
-
? ` (${formatDuration(Date.now() - this.sessionStartedAt)})`
|
|
1632
|
-
: '';
|
|
1633
|
-
status = `client connected${duration}`;
|
|
1634
|
-
} else if (this.awaitingRejoin) {
|
|
1635
|
-
status = 'awaiting rejoin';
|
|
1636
|
-
} else {
|
|
1637
|
-
status = `waiting for client (expires in ${formatDuration(remaining)})`;
|
|
1638
|
-
}
|
|
1639
|
-
if (this.readOnly) status += ' (readonly)';
|
|
1640
|
-
return status;
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
// ─── Session Duration Logging ────────────────────────────────────────
|
|
1644
|
-
|
|
1645
|
-
_formatElapsedCompact(ms) {
|
|
1646
|
-
const totalSeconds = Math.floor(ms / 1000);
|
|
1647
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
1648
|
-
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1649
|
-
const seconds = totalSeconds % 60;
|
|
1650
|
-
const pad = (n) => String(n).padStart(2, '0');
|
|
1651
|
-
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
|
1652
|
-
return `${pad(minutes)}:${pad(seconds)}`;
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
_startDurationLogging() {
|
|
1656
|
-
this._stopDurationLogging();
|
|
1657
|
-
// Log session duration every 5 minutes
|
|
1658
|
-
this.sessionDurationInterval = setInterval(() => {
|
|
1659
|
-
if (!this.sessionStartedAt || this.destroyed) return;
|
|
1660
|
-
const elapsed = Date.now() - this.sessionStartedAt;
|
|
1661
|
-
this.log(`Session active — ${this._formatElapsedCompact(elapsed)}`);
|
|
1662
|
-
}, 5 * 60 * 1000);
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
_stopDurationLogging() {
|
|
1666
|
-
if (this.sessionDurationInterval) {
|
|
1667
|
-
clearInterval(this.sessionDurationInterval);
|
|
1668
|
-
this.sessionDurationInterval = null;
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
// ─── Session Manager ─────────────────────────────────────────────────────────
|
|
1674
|
-
|
|
1675
|
-
class SessionManager {
|
|
1676
|
-
constructor(shell, expiryMs, rejoinMs, readOnly, startPath) {
|
|
1677
|
-
this.shell = shell;
|
|
1678
|
-
this.expiryMs = expiryMs;
|
|
1679
|
-
this.rejoinMs = rejoinMs;
|
|
1680
|
-
this.readOnly = readOnly;
|
|
1681
|
-
this.startPath = startPath;
|
|
1682
|
-
this.sessions = new Map(); // code → Session
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
async createSession() {
|
|
1686
|
-
const session = new Session(this.shell, this.expiryMs, this.rejoinMs, this.readOnly, this.startPath, (code) => {
|
|
1687
|
-
this.sessions.delete(code);
|
|
1688
|
-
});
|
|
1689
|
-
|
|
1690
|
-
try {
|
|
1691
|
-
const code = await session.start();
|
|
1692
|
-
this.sessions.set(code, session);
|
|
1693
|
-
return code;
|
|
1694
|
-
} catch (err) {
|
|
1695
|
-
logger.error(`Failed to create session: ${err.message}`, err);
|
|
1696
|
-
return null;
|
|
1697
|
-
}
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
listSessions() {
|
|
1701
|
-
if (this.sessions.size === 0) {
|
|
1702
|
-
logger.info('No active sessions.');
|
|
1703
|
-
return;
|
|
1704
|
-
}
|
|
1705
|
-
console.log('');
|
|
1706
|
-
console.log(' Active sessions:');
|
|
1707
|
-
for (const [code, session] of this.sessions) {
|
|
1708
|
-
console.log(` ${code} — ${session.getStatus()}`);
|
|
1709
|
-
}
|
|
1710
|
-
console.log('');
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
killSession(code) {
|
|
1714
|
-
const session = this.sessions.get(code);
|
|
1715
|
-
if (!session) {
|
|
1716
|
-
logger.warn(`Session ${code} not found.`);
|
|
1717
|
-
return;
|
|
1718
|
-
}
|
|
1719
|
-
session.destroy();
|
|
1720
|
-
logger.info(`Session ${code} killed.`);
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
async authorizeSession(code) {
|
|
1724
|
-
const session = this.sessions.get(code);
|
|
1725
|
-
if (!session) {
|
|
1726
|
-
logger.warn(`Session ${code} not found.`);
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
await session.authorizeFingerprint();
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
killAll() {
|
|
1733
|
-
for (const [, session] of this.sessions) {
|
|
1734
|
-
session.destroy();
|
|
1735
|
-
}
|
|
1736
|
-
this.sessions.clear();
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1739
|
-
|
|
1740
|
-
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1741
|
-
|
|
1742
|
-
async function main() {
|
|
1743
|
-
const shell = detectShell();
|
|
1744
|
-
const readOnly = argv.readonly;
|
|
1745
|
-
const startPath = resolveStartPath(argv.path);
|
|
1746
|
-
|
|
1747
|
-
// Print startup banner
|
|
1748
|
-
printBanner();
|
|
1749
|
-
|
|
1750
|
-
// Interactive prompts — always ask unless flags are passed or non-interactive
|
|
1751
|
-
const expiryMs = await getExpiryInteractive();
|
|
1752
|
-
const rejoinMs = await getRejoinInteractive();
|
|
1753
|
-
console.log('');
|
|
1754
|
-
|
|
1755
|
-
logger.info(`Shell: ${shell}`);
|
|
1756
|
-
logger.info(`Relays: ${RELAY_URLS.join(', ')}`);
|
|
1757
|
-
logger.info(`Path: ${startPath}`);
|
|
1758
|
-
logger.info(`Expiry: ${formatDuration(expiryMs)}`);
|
|
1759
|
-
logger.info(`Rejoin Window: ${formatDuration(rejoinMs)}`);
|
|
1760
|
-
if (readOnly) logger.info('Mode: READ-ONLY');
|
|
1761
|
-
if (argv.verbose) logger.info('Verbose logging enabled');
|
|
1762
|
-
console.log('');
|
|
1763
|
-
|
|
1764
|
-
const manager = new SessionManager(shell, expiryMs, rejoinMs, readOnly, startPath);
|
|
1765
|
-
|
|
1766
|
-
// Create first session automatically
|
|
1767
|
-
const firstCode = await manager.createSession();
|
|
1768
|
-
if (!firstCode) {
|
|
1769
|
-
logger.error('Failed to start. Is the relay server running?');
|
|
1770
|
-
process.exit(1);
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
// ─── Interactive CLI menu ──────────────────────────────────────────
|
|
1774
|
-
console.log(' ─────────────────────────────────────────');
|
|
1775
|
-
console.log(' Commands:');
|
|
1776
|
-
console.log(' [n] New session');
|
|
1777
|
-
console.log(' [l] List sessions');
|
|
1778
|
-
console.log(' [a <code>] Authorize verified fingerprint');
|
|
1779
|
-
console.log(' [k <code>] Kill session');
|
|
1780
|
-
console.log(' [q] Quit all');
|
|
1781
|
-
console.log(' ─────────────────────────────────────────');
|
|
1782
|
-
console.log('');
|
|
1783
|
-
|
|
1784
|
-
const rl = readline.createInterface({
|
|
1785
|
-
input: process.stdin,
|
|
1786
|
-
output: process.stdout,
|
|
1787
|
-
prompt: ' > ',
|
|
1788
|
-
});
|
|
1789
|
-
|
|
1790
|
-
rl.prompt();
|
|
1791
|
-
|
|
1792
|
-
rl.on('line', async (line) => {
|
|
1793
|
-
const input = line.trim();
|
|
1794
|
-
|
|
1795
|
-
if (input === 'n') {
|
|
1796
|
-
logger.info('Creating new session...');
|
|
1797
|
-
await manager.createSession();
|
|
1798
|
-
} else if (input === 'l') {
|
|
1799
|
-
manager.listSessions();
|
|
1800
|
-
} else if (input.startsWith('a ')) {
|
|
1801
|
-
const code = input.slice(2).trim();
|
|
1802
|
-
await manager.authorizeSession(code);
|
|
1803
|
-
} else if (input.startsWith('k ')) {
|
|
1804
|
-
const code = input.slice(2).trim();
|
|
1805
|
-
manager.killSession(code);
|
|
1806
|
-
} else if (input === 'q') {
|
|
1807
|
-
logger.info('Shutting down all sessions...');
|
|
1808
|
-
manager.killAll();
|
|
1809
|
-
rl.close();
|
|
1810
|
-
process.exit(0);
|
|
1811
|
-
} else if (input) {
|
|
1812
|
-
console.log(' Unknown command. Use n, l, a <code>, k <code>, or q.');
|
|
1813
|
-
}
|
|
1814
|
-
|
|
1815
|
-
rl.prompt();
|
|
1816
|
-
});
|
|
1817
|
-
|
|
1818
|
-
rl.on('close', () => {
|
|
1819
|
-
manager.killAll();
|
|
1820
|
-
process.exit(0);
|
|
1821
|
-
});
|
|
1822
|
-
|
|
1823
|
-
process.on('SIGINT', () => {
|
|
1824
|
-
console.log('');
|
|
1825
|
-
logger.info('Shutting down...');
|
|
1826
|
-
manager.killAll();
|
|
1827
|
-
rl.close();
|
|
1828
|
-
process.exit(0);
|
|
1829
|
-
});
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
// ─── Global Error Handling ───────────────────────────────────────────────────
|
|
1833
|
-
|
|
1834
|
-
process.on('uncaughtException', (err) => {
|
|
1835
|
-
logger.error(`Uncaught exception: ${err.message}`, err);
|
|
1836
|
-
process.exit(1);
|
|
1837
|
-
});
|
|
1838
|
-
|
|
1839
|
-
process.on('unhandledRejection', (reason) => {
|
|
1840
|
-
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
1841
|
-
logger.error(`Unhandled rejection: ${err.message}`, err);
|
|
1842
|
-
});
|
|
1843
|
-
|
|
1844
|
-
// ─── Run ─────────────────────────────────────────────────────────────────────
|
|
1845
|
-
|
|
1846
|
-
main().catch((err) => {
|
|
1847
|
-
logger.error(`Fatal error: ${err.message}`, err);
|
|
1848
|
-
process.exit(1);
|
|
1849
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* PeerTerm Host Agent
|
|
3
|
+
*
|
|
4
|
+
* CLI tool that:
|
|
5
|
+
* 1. Connects to the relay server and registers sessions
|
|
6
|
+
* 2. Manages multiple simultaneous sessions
|
|
7
|
+
* 3. Performs ECDH key exchange for E2E encryption per session
|
|
8
|
+
* 4. Spawns independent PTY instances per session
|
|
9
|
+
* 5. Supports read-only mode and terminal resize
|
|
10
|
+
*
|
|
11
|
+
* CLI flags:
|
|
12
|
+
* --expiry <value> Session code expiry (e.g. 5m, 30s, 1h). Default: 5m
|
|
13
|
+
* --rejoin <value> Reconnection window (e.g. 5m, 1h, 6h). Default: 2m
|
|
14
|
+
* --readonly Prevent client keystrokes from reaching the PTY
|
|
15
|
+
* --path <dir> Starting directory for the terminal session
|
|
16
|
+
* --relay <url> Custom relay server URL
|
|
17
|
+
* --verbose Enable debug-level logging
|
|
18
|
+
* --help Show usage information
|
|
19
|
+
* --version Print version number
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import WebSocket from 'ws';
|
|
23
|
+
import pty from 'node-pty';
|
|
24
|
+
import {
|
|
25
|
+
generateKeyPair,
|
|
26
|
+
exportPublicKey,
|
|
27
|
+
importPublicKey,
|
|
28
|
+
deriveSharedKey,
|
|
29
|
+
fingerprintPublicKeys,
|
|
30
|
+
encrypt,
|
|
31
|
+
decrypt,
|
|
32
|
+
} from './crypto.js';
|
|
33
|
+
import { HostWebRTC } from './webrtc.js';
|
|
34
|
+
import logger from './logger.js';
|
|
35
|
+
import { printSessionBox } from './ui.js';
|
|
36
|
+
import {
|
|
37
|
+
argv,
|
|
38
|
+
version,
|
|
39
|
+
RELAY_URLS,
|
|
40
|
+
HEARTBEAT_INTERVAL_MS,
|
|
41
|
+
MAX_MISSED_PINGS,
|
|
42
|
+
} from './config.js';
|
|
43
|
+
import { formatDuration } from './utils.js';
|
|
44
|
+
import { FileTransferHandler } from './file-transfer.js';
|
|
45
|
+
import { ViewerServer } from './viewer.js';
|
|
46
|
+
|
|
47
|
+
// ─── Session Class ───────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
class Session {
|
|
50
|
+
constructor(shell, expiryMs, rejoinMs, readOnly, startPath, onDestroy) {
|
|
51
|
+
this.shell = shell;
|
|
52
|
+
this.expiryMs = expiryMs;
|
|
53
|
+
this.rejoinMs = rejoinMs;
|
|
54
|
+
this.readOnly = readOnly;
|
|
55
|
+
this.startPath = startPath;
|
|
56
|
+
this.onDestroy = onDestroy;
|
|
57
|
+
this.secureMode = argv.secure || false;
|
|
58
|
+
|
|
59
|
+
this.ws = null;
|
|
60
|
+
this.code = null;
|
|
61
|
+
this.hostToken = null;
|
|
62
|
+
this.keyPair = null;
|
|
63
|
+
this.hostPublicKeyBase64 = null;
|
|
64
|
+
this.clientPublicKeyBase64 = null;
|
|
65
|
+
this.sharedKey = null;
|
|
66
|
+
this.securityFingerprint = null;
|
|
67
|
+
this.fingerprintAuthorized = false;
|
|
68
|
+
this.ptyProcess = null;
|
|
69
|
+
this.heartbeatInterval = null;
|
|
70
|
+
this.missedPings = 0;
|
|
71
|
+
this.isClientConnected = false;
|
|
72
|
+
this.awaitingRejoin = false;
|
|
73
|
+
this.destroyed = false;
|
|
74
|
+
this.intentionalClose = false;
|
|
75
|
+
this.ptyOutputQueue = Promise.resolve();
|
|
76
|
+
this.reconnectTimer = null;
|
|
77
|
+
this.createdAt = Date.now();
|
|
78
|
+
this.relayUrl = null; // Track which relay URL we connected to
|
|
79
|
+
|
|
80
|
+
// Phase 4: WebRTC state
|
|
81
|
+
this.webrtc = null;
|
|
82
|
+
this.useDataChannel = false;
|
|
83
|
+
|
|
84
|
+
// File upload handler
|
|
85
|
+
this._fileTransfer = new FileTransferHandler(this._buildFileTransferContext());
|
|
86
|
+
|
|
87
|
+
// Local TCP viewer
|
|
88
|
+
this._viewer = null;
|
|
89
|
+
|
|
90
|
+
// Session duration tracking
|
|
91
|
+
this.sessionStartedAt = null; // Timestamp: when the terminal session became active
|
|
92
|
+
this.sessionDurationInterval = null; // Interval: periodic CLI status log
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the context object for FileTransferHandler.
|
|
97
|
+
* Called on construction and whenever shared key changes.
|
|
98
|
+
*/
|
|
99
|
+
_buildFileTransferContext() {
|
|
100
|
+
return {
|
|
101
|
+
get sharedKey() { return this.session.sharedKey; },
|
|
102
|
+
get readOnly() { return this.session.readOnly; },
|
|
103
|
+
get ptyProcess() { return this.session.ptyProcess; },
|
|
104
|
+
log: (msg) => this.log(msg),
|
|
105
|
+
logDebug: (msg) => this.logDebug(msg),
|
|
106
|
+
sendEncrypted: (payload) => this._sendEncryptedToClient(payload),
|
|
107
|
+
session: this,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log(msg) {
|
|
112
|
+
const prefix = this.code ? `[${this.code}]` : '[???]';
|
|
113
|
+
logger.info(`${prefix} ${msg}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
logDebug(msg) {
|
|
117
|
+
const prefix = this.code ? `[${this.code}]` : '[???]';
|
|
118
|
+
logger.debug(`${prefix} ${msg}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async _beginKeyExchange() {
|
|
122
|
+
this.keyPair = await generateKeyPair();
|
|
123
|
+
this.hostPublicKeyBase64 = await exportPublicKey(this.keyPair.publicKey);
|
|
124
|
+
this.clientPublicKeyBase64 = null;
|
|
125
|
+
this.sharedKey = null;
|
|
126
|
+
this.securityFingerprint = null;
|
|
127
|
+
this.fingerprintAuthorized = false;
|
|
128
|
+
this.stopHeartbeat();
|
|
129
|
+
this._cleanupWebRTC();
|
|
130
|
+
this.ws.send(JSON.stringify({
|
|
131
|
+
type: 'key-exchange',
|
|
132
|
+
publicKey: this.hostPublicKeyBase64,
|
|
133
|
+
secureMode: this.secureMode
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async _completeKeyExchange(clientPublicKeyBase64) {
|
|
138
|
+
if (!this.keyPair || !this.hostPublicKeyBase64) return;
|
|
139
|
+
if (typeof clientPublicKeyBase64 !== 'string' || clientPublicKeyBase64.length === 0) {
|
|
140
|
+
this._rejectProtocolMessage('Invalid key-exchange public key');
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
this.logDebug('Deriving shared secret...');
|
|
146
|
+
this.clientPublicKeyBase64 = clientPublicKeyBase64;
|
|
147
|
+
|
|
148
|
+
const peerPublicKey = await importPublicKey(clientPublicKeyBase64);
|
|
149
|
+
this.sharedKey = await deriveSharedKey(this.keyPair.privateKey, peerPublicKey);
|
|
150
|
+
|
|
151
|
+
if (this.secureMode) {
|
|
152
|
+
this.securityFingerprint = await fingerprintPublicKeys(
|
|
153
|
+
this.code,
|
|
154
|
+
this.hostPublicKeyBase64,
|
|
155
|
+
this.clientPublicKeyBase64
|
|
156
|
+
);
|
|
157
|
+
this.fingerprintAuthorized = false;
|
|
158
|
+
this._printFingerprintAuthorization();
|
|
159
|
+
} else {
|
|
160
|
+
this.fingerprintAuthorized = true;
|
|
161
|
+
await this._activateSecureSession();
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this._rejectProtocolMessage(`Invalid key exchange: ${err.message}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_rejectProtocolMessage(reason) {
|
|
169
|
+
this.log(`Protocol error: ${reason}`);
|
|
170
|
+
this.isClientConnected = false;
|
|
171
|
+
this.awaitingRejoin = false;
|
|
172
|
+
this.sharedKey = null;
|
|
173
|
+
this.keyPair = null;
|
|
174
|
+
this.hostPublicKeyBase64 = null;
|
|
175
|
+
this.clientPublicKeyBase64 = null;
|
|
176
|
+
this.securityFingerprint = null;
|
|
177
|
+
this.fingerprintAuthorized = false;
|
|
178
|
+
this.stopHeartbeat();
|
|
179
|
+
this._cleanupWebRTC();
|
|
180
|
+
this.destroy();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_handleMessageError(err) {
|
|
184
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
185
|
+
this.log(`Protocol handler error: ${message}`);
|
|
186
|
+
this.destroy();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_printFingerprintAuthorization() {
|
|
190
|
+
console.log('');
|
|
191
|
+
this.log('Encrypted candidate established. Verify before terminal access.');
|
|
192
|
+
console.log(` Fingerprint: ${this.securityFingerprint}`);
|
|
193
|
+
console.log(' Ask the viewer to compare this exact fingerprint.');
|
|
194
|
+
console.log(` Type "a ${this.code}" after it matches, or "k ${this.code}" to kill the session.`);
|
|
195
|
+
console.log('');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async authorizeFingerprint() {
|
|
199
|
+
if (!this.sharedKey || !this.securityFingerprint) {
|
|
200
|
+
logger.warn(`Session ${this.code} has no fingerprint awaiting authorization.`);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (this.fingerprintAuthorized) {
|
|
204
|
+
logger.info(`Session ${this.code} is already authorized.`);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.fingerprintAuthorized = true;
|
|
209
|
+
await this._activateSecureSession();
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async _activateSecureSession() {
|
|
214
|
+
if (!this.sharedKey || !this.fingerprintAuthorized) return;
|
|
215
|
+
|
|
216
|
+
this.log(`Fingerprint authorized: ${this.securityFingerprint}`);
|
|
217
|
+
this.log('Encrypted tunnel active');
|
|
218
|
+
await this._sendSessionConfig();
|
|
219
|
+
|
|
220
|
+
// Mark session start time for duration tracking
|
|
221
|
+
if (!this.sessionStartedAt) {
|
|
222
|
+
this.sessionStartedAt = Date.now();
|
|
223
|
+
this._startDurationLogging();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.startHeartbeat();
|
|
227
|
+
|
|
228
|
+
if (!this.ptyProcess) {
|
|
229
|
+
this.spawnTerminal();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this._initiateWebRTC();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async _sendSessionConfig() {
|
|
236
|
+
if (!this.sharedKey || !this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
237
|
+
|
|
238
|
+
const config = JSON.stringify({
|
|
239
|
+
type: 'session-config',
|
|
240
|
+
readonly: this.readOnly,
|
|
241
|
+
version: version,
|
|
242
|
+
shell: this.shell,
|
|
243
|
+
startPath: this.startPath,
|
|
244
|
+
});
|
|
245
|
+
try {
|
|
246
|
+
const payload = await encrypt(this.sharedKey, config);
|
|
247
|
+
this.ws.send(JSON.stringify({ type: 'data', payload }));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
this.log(`Failed to send session config: ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ─── Start the session ──────────────────────────────────────────────
|
|
254
|
+
async start() {
|
|
255
|
+
for (let i = 0; i < RELAY_URLS.length; i++) {
|
|
256
|
+
const url = RELAY_URLS[i];
|
|
257
|
+
try {
|
|
258
|
+
const code = await this._tryConnect(url);
|
|
259
|
+
return code;
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (i === RELAY_URLS.length - 1) {
|
|
262
|
+
throw new Error(`All relay servers failed. Last error: ${err.message}`);
|
|
263
|
+
}
|
|
264
|
+
logger.warn(`Failed to connect to ${url}: ${err.message}. Trying next relay...`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async _tryConnect(url) {
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
this.ws = new WebSocket(url);
|
|
272
|
+
this.relayUrl = url;
|
|
273
|
+
|
|
274
|
+
let isConnected = false;
|
|
275
|
+
|
|
276
|
+
this.ws.on('open', () => {
|
|
277
|
+
isConnected = true;
|
|
278
|
+
this.ws.send(JSON.stringify({
|
|
279
|
+
type: 'host-register',
|
|
280
|
+
expiry: this.expiryMs,
|
|
281
|
+
rejoinWindow: this.rejoinMs,
|
|
282
|
+
readonly: this.readOnly,
|
|
283
|
+
}));
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
this.ws.on('message', async (raw) => {
|
|
287
|
+
try {
|
|
288
|
+
let msg;
|
|
289
|
+
try {
|
|
290
|
+
msg = JSON.parse(raw.toString());
|
|
291
|
+
} catch {
|
|
292
|
+
this.log('Invalid message from relay');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
296
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await this._handleRelayMessage(msg, url, resolve);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
this._handleMessageError(err);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.ws.on('close', () => {
|
|
307
|
+
if (!isConnected) {
|
|
308
|
+
reject(new Error('WebSocket closed before connection was established'));
|
|
309
|
+
} else if (this.destroyed) {
|
|
310
|
+
// Already destroyed, nothing to do
|
|
311
|
+
} else if (this.intentionalClose) {
|
|
312
|
+
this.log('Connection to relay closed (intentional).');
|
|
313
|
+
this.destroy();
|
|
314
|
+
} else {
|
|
315
|
+
// Unexpected disconnect — start reconnect loop
|
|
316
|
+
this.log('Connection to relay lost. Starting reconnect...');
|
|
317
|
+
this.stopHeartbeat();
|
|
318
|
+
this._startReconnecting();
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
this.ws.on('error', (err) => {
|
|
323
|
+
if (!isConnected) {
|
|
324
|
+
reject(err);
|
|
325
|
+
} else {
|
|
326
|
+
this.log(`WS error: ${err.message}`);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Central relay message handler — shared by _tryConnect and _attachWsHandlers.
|
|
334
|
+
* The optional `resolve` parameter is only provided during initial registration.
|
|
335
|
+
*/
|
|
336
|
+
async _handleRelayMessage(msg, url, resolve) {
|
|
337
|
+
switch (msg.type) {
|
|
338
|
+
case 'code': {
|
|
339
|
+
this.code = msg.code;
|
|
340
|
+
this.hostToken = msg.hostToken || null;
|
|
341
|
+
printSessionBox({
|
|
342
|
+
code: this.code,
|
|
343
|
+
expiry: formatDuration(this.expiryMs),
|
|
344
|
+
rejoinWindow: formatDuration(this.rejoinMs),
|
|
345
|
+
mode: (this.readOnly ? 'Read-Only' : 'Read-Write') + (this.secureMode ? ' (Secure)' : ''),
|
|
346
|
+
shell: this.shell,
|
|
347
|
+
startPath: this.startPath,
|
|
348
|
+
shareUrl: url.replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://')
|
|
349
|
+
});
|
|
350
|
+
if (resolve) resolve(this.code);
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
case 'client-connected': {
|
|
355
|
+
if (this.awaitingRejoin) {
|
|
356
|
+
this.log('Client reconnected.');
|
|
357
|
+
this.awaitingRejoin = false;
|
|
358
|
+
} else {
|
|
359
|
+
this.log('Client connected! Starting key exchange...');
|
|
360
|
+
}
|
|
361
|
+
this.isClientConnected = true;
|
|
362
|
+
this.missedPings = 0;
|
|
363
|
+
|
|
364
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
365
|
+
this.startHeartbeat();
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await this._beginKeyExchange();
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
case 'key-exchange': {
|
|
374
|
+
await this._completeKeyExchange(msg.publicKey);
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Phase 4: WebRTC signaling messages
|
|
379
|
+
case 'signal': {
|
|
380
|
+
if (this.webrtc && this.sharedKey) {
|
|
381
|
+
try {
|
|
382
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
383
|
+
this.webrtc.handleSignal(JSON.parse(plaintext));
|
|
384
|
+
} catch (err) {
|
|
385
|
+
this.logDebug(`[WebRTC] Signal decryption failed: ${err.message}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
case 'heartbeat': {
|
|
392
|
+
this.missedPings = 0;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case 'data': {
|
|
397
|
+
if (!this.sharedKey || !this.ptyProcess) return;
|
|
398
|
+
try {
|
|
399
|
+
const plaintext = await decrypt(this.sharedKey, msg.payload);
|
|
400
|
+
|
|
401
|
+
// Check if this is a resize event
|
|
402
|
+
if (msg.meta === 'resize') {
|
|
403
|
+
try {
|
|
404
|
+
const resizeData = JSON.parse(plaintext);
|
|
405
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
406
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
407
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
408
|
+
}
|
|
409
|
+
} catch {}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
414
|
+
try {
|
|
415
|
+
const parsedMsg = JSON.parse(plaintext);
|
|
416
|
+
if (parsedMsg && typeof parsedMsg.type === 'string' && parsedMsg.type.startsWith('file-')) {
|
|
417
|
+
if (this.readOnly) {
|
|
418
|
+
this._fileTransfer._sendError(parsedMsg.id, 'File uploads are disabled in read-only mode');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this._fileTransfer.handleMessage(parsedMsg);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
} catch {
|
|
425
|
+
// Not JSON — normal keystroke data, fall through
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Normal keystroke — drop if read-only
|
|
429
|
+
if (this.readOnly) return;
|
|
430
|
+
this.ptyProcess.write(plaintext);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
this.log(`Decryption failed: ${err.message}`);
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
case 'peer-disconnected': {
|
|
438
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
439
|
+
this.log('Client relay connection lost; direct DataChannel still active.');
|
|
440
|
+
this.missedPings = 0;
|
|
441
|
+
this.startHeartbeat();
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
this.log(`Client disconnected. Rejoin window: ${formatDuration(this.rejoinMs)}.`);
|
|
446
|
+
this.isClientConnected = false;
|
|
447
|
+
this.awaitingRejoin = true;
|
|
448
|
+
this._resetCryptoState();
|
|
449
|
+
this.stopHeartbeat();
|
|
450
|
+
// Phase 4: Clean up WebRTC on peer disconnect
|
|
451
|
+
this._cleanupWebRTC();
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
case 'session-expired': {
|
|
456
|
+
this.log('Rejoin window expired. Session ended.');
|
|
457
|
+
this.destroy();
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
case 'rejoined': {
|
|
462
|
+
this.log(`\u2705 Reconnected. Session restored. (code: ${msg.code})`);
|
|
463
|
+
await this._restartPeerSessionAfterHostRejoin();
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
case 'error': {
|
|
468
|
+
this.log(`Error: ${msg.msg}`);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Reset all crypto state (shared key, key pair, fingerprint).
|
|
476
|
+
* Used on peer disconnect and heartbeat loss.
|
|
477
|
+
*/
|
|
478
|
+
_resetCryptoState() {
|
|
479
|
+
this.sharedKey = null;
|
|
480
|
+
this.keyPair = null;
|
|
481
|
+
this.hostPublicKeyBase64 = null;
|
|
482
|
+
this.clientPublicKeyBase64 = null;
|
|
483
|
+
this.securityFingerprint = null;
|
|
484
|
+
this.fingerprintAuthorized = false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ─── Send encrypted resize to client ────────────────────────────────
|
|
488
|
+
async _sendResize(cols, rows) {
|
|
489
|
+
if (!this.sharedKey) return;
|
|
490
|
+
try {
|
|
491
|
+
const resizeJson = JSON.stringify({ type: 'resize', cols, rows });
|
|
492
|
+
const payload = await encrypt(this.sharedKey, resizeJson);
|
|
493
|
+
this._sendEncryptedToClient(payload, {
|
|
494
|
+
directPayload: JSON.stringify({ _meta: 'resize', payload }),
|
|
495
|
+
meta: 'resize',
|
|
496
|
+
});
|
|
497
|
+
} catch {}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ─── Heartbeat ──────────────────────────────────────────────────────
|
|
501
|
+
startHeartbeat() {
|
|
502
|
+
this.stopHeartbeat();
|
|
503
|
+
this.missedPings = 0;
|
|
504
|
+
this.heartbeatInterval = setInterval(() => {
|
|
505
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
506
|
+
this.ws.send(JSON.stringify({ type: 'heartbeat' }));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
510
|
+
this.missedPings = 0;
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
this.missedPings++;
|
|
515
|
+
if (this.missedPings >= MAX_MISSED_PINGS + 1) {
|
|
516
|
+
this.log('Client heartbeat lost. Waiting for reconnect...');
|
|
517
|
+
this.isClientConnected = false;
|
|
518
|
+
this.awaitingRejoin = true;
|
|
519
|
+
this._resetCryptoState();
|
|
520
|
+
this.stopHeartbeat();
|
|
521
|
+
}
|
|
522
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
stopHeartbeat() {
|
|
526
|
+
if (this.heartbeatInterval) {
|
|
527
|
+
clearInterval(this.heartbeatInterval);
|
|
528
|
+
this.heartbeatInterval = null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ─── Spawn PTY ──────────────────────────────────────────────────────
|
|
533
|
+
spawnTerminal() {
|
|
534
|
+
const cols = 80;
|
|
535
|
+
const rows = 24;
|
|
536
|
+
this.log(`Spawning terminal: ${this.shell} (${cols}x${rows})`);
|
|
537
|
+
|
|
538
|
+
this.ptyProcess = pty.spawn(this.shell, [], {
|
|
539
|
+
name: 'xterm-256color',
|
|
540
|
+
cols,
|
|
541
|
+
rows,
|
|
542
|
+
cwd: this.startPath,
|
|
543
|
+
env: process.env,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
this.ptyProcess.onData((data) => {
|
|
547
|
+
// Mirror PTY output to local viewer terminal
|
|
548
|
+
if (this._viewer) {
|
|
549
|
+
this._viewer.write(data);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!this.sharedKey) return;
|
|
553
|
+
this._queuePtyOutput(data, this.sharedKey);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
this.ptyProcess.onExit(({ exitCode }) => {
|
|
557
|
+
this.log(`Shell exited with code ${exitCode}`);
|
|
558
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
559
|
+
this.ws.send(JSON.stringify({ type: 'session-ended' }));
|
|
560
|
+
}
|
|
561
|
+
this.destroy();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// Open a local viewer terminal for the host
|
|
565
|
+
this._startViewer();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ─── PTY Output Queue ──────────────────────────────────────────────
|
|
569
|
+
_queuePtyOutput(data, sharedKey) {
|
|
570
|
+
this.ptyOutputQueue = this.ptyOutputQueue
|
|
571
|
+
.then(() => this._sendPtyOutput(data, sharedKey))
|
|
572
|
+
.catch((err) => {
|
|
573
|
+
this.logDebug(`Failed to send PTY output: ${err.message}`);
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
async _sendPtyOutput(data, sharedKey) {
|
|
578
|
+
if (this.destroyed || !sharedKey || sharedKey !== this.sharedKey) return;
|
|
579
|
+
|
|
580
|
+
const payload = await encrypt(sharedKey, data);
|
|
581
|
+
if (this.destroyed || sharedKey !== this.sharedKey) return;
|
|
582
|
+
|
|
583
|
+
this._sendEncryptedToClient(payload, { enforceBackpressure: true });
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
_sendEncryptedToClient(payload, options = {}) {
|
|
587
|
+
const directPayload = options.directPayload || payload;
|
|
588
|
+
|
|
589
|
+
if (this.useDataChannel) {
|
|
590
|
+
if (this.webrtc && this.webrtc.isActive()) {
|
|
591
|
+
let sentDirect = false;
|
|
592
|
+
try {
|
|
593
|
+
sentDirect = this.webrtc.send(directPayload);
|
|
594
|
+
} catch {}
|
|
595
|
+
|
|
596
|
+
if (sentDirect) return true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
this.useDataChannel = false;
|
|
600
|
+
this.log('DataChannel send failed — falling back to relay');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return this._sendViaRelay(payload, options);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
_sendViaRelay(payload, { meta, enforceBackpressure = false } = {}) {
|
|
607
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
|
|
608
|
+
if (enforceBackpressure && this.ws.bufferedAmount >= 1.25 * 1024 * 1024) return false;
|
|
609
|
+
|
|
610
|
+
const msg = { type: 'data', payload };
|
|
611
|
+
if (meta) msg.meta = meta;
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
this.ws.send(JSON.stringify(msg));
|
|
615
|
+
return true;
|
|
616
|
+
} catch {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ─── Viewer ─────────────────────────────────────────────────────────
|
|
622
|
+
_startViewer() {
|
|
623
|
+
this._viewer = new ViewerServer({
|
|
624
|
+
code: this.code,
|
|
625
|
+
log: (msg) => this.log(msg),
|
|
626
|
+
logDebug: (msg) => this.logDebug(msg),
|
|
627
|
+
onResize: (cols, rows) => {
|
|
628
|
+
if (this.ptyProcess) {
|
|
629
|
+
this.ptyProcess.resize(cols, rows);
|
|
630
|
+
this._sendResize(cols, rows);
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
onKeystroke: (data) => {
|
|
634
|
+
if (this.ptyProcess) {
|
|
635
|
+
this.ptyProcess.write(data);
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
this._viewer.start();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ─── Host Reconnect Logic ───────────────────────────────────────────
|
|
643
|
+
_startReconnecting() {
|
|
644
|
+
if (this.destroyed || this.intentionalClose) return;
|
|
645
|
+
|
|
646
|
+
let attempts = 0;
|
|
647
|
+
const maxAttempts = 24; // 24 × 5s = 2 minutes
|
|
648
|
+
|
|
649
|
+
this.reconnectTimer = setInterval(() => {
|
|
650
|
+
attempts++;
|
|
651
|
+
if (attempts > maxAttempts) {
|
|
652
|
+
this.log('Could not reconnect. Session expired.');
|
|
653
|
+
this._stopReconnecting();
|
|
654
|
+
this.destroy();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
this.log(`Reconnect attempt ${attempts}/${maxAttempts}...`);
|
|
659
|
+
|
|
660
|
+
// Close any in-flight reconnect socket from the previous tick
|
|
661
|
+
if (this._pendingReconnectWs) {
|
|
662
|
+
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
663
|
+
this._pendingReconnectWs = null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
try {
|
|
667
|
+
const newWs = new WebSocket(this.relayUrl);
|
|
668
|
+
this._pendingReconnectWs = newWs;
|
|
669
|
+
|
|
670
|
+
newWs.on('open', () => {
|
|
671
|
+
newWs.send(JSON.stringify({ type: 'host-rejoin', code: this.code, hostToken: this.hostToken }));
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
newWs.on('message', async (raw) => {
|
|
675
|
+
try {
|
|
676
|
+
let msg;
|
|
677
|
+
try {
|
|
678
|
+
msg = JSON.parse(raw.toString());
|
|
679
|
+
} catch {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
683
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (msg.type === 'rejoined') {
|
|
688
|
+
// Success — replace the old ws with this new one
|
|
689
|
+
this._pendingReconnectWs = null;
|
|
690
|
+
this.ws = newWs;
|
|
691
|
+
this._stopReconnecting();
|
|
692
|
+
this.log(`Reconnected. Session restored.`);
|
|
693
|
+
|
|
694
|
+
// Re-attach the full message handler by wiring up events
|
|
695
|
+
this._attachWsHandlers(newWs);
|
|
696
|
+
|
|
697
|
+
await this._restartPeerSessionAfterHostRejoin();
|
|
698
|
+
} else if (msg.type === 'error') {
|
|
699
|
+
this.log(`Rejoin failed: ${msg.msg}`);
|
|
700
|
+
this._pendingReconnectWs = null;
|
|
701
|
+
this._stopReconnecting();
|
|
702
|
+
this.destroy();
|
|
703
|
+
try { newWs.close(); } catch {}
|
|
704
|
+
}
|
|
705
|
+
} catch (err) {
|
|
706
|
+
this._handleMessageError(err);
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
newWs.on('error', () => {
|
|
711
|
+
// Connection failed, next retry in 5s
|
|
712
|
+
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
713
|
+
try { newWs.close(); } catch {}
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
newWs.on('close', () => {
|
|
717
|
+
// Clean up reference if this was the pending socket
|
|
718
|
+
if (this._pendingReconnectWs === newWs) this._pendingReconnectWs = null;
|
|
719
|
+
});
|
|
720
|
+
} catch (e) {
|
|
721
|
+
// Connection failed, next retry in 5s
|
|
722
|
+
}
|
|
723
|
+
}, 5000);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
_stopReconnecting() {
|
|
727
|
+
if (this.reconnectTimer) {
|
|
728
|
+
clearInterval(this.reconnectTimer);
|
|
729
|
+
this.reconnectTimer = null;
|
|
730
|
+
}
|
|
731
|
+
if (this._pendingReconnectWs) {
|
|
732
|
+
try { this._pendingReconnectWs.removeAllListeners(); this._pendingReconnectWs.close(); } catch {}
|
|
733
|
+
this._pendingReconnectWs = null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Re-attach message/close/error handlers to a new WebSocket after rejoin.
|
|
739
|
+
* Uses the shared _handleRelayMessage method to avoid duplicating the switch block.
|
|
740
|
+
*/
|
|
741
|
+
_attachWsHandlers(newWs) {
|
|
742
|
+
newWs.on('message', async (raw) => {
|
|
743
|
+
try {
|
|
744
|
+
let msg;
|
|
745
|
+
try {
|
|
746
|
+
msg = JSON.parse(raw.toString());
|
|
747
|
+
} catch {
|
|
748
|
+
this.log('Invalid message from relay');
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (!msg || typeof msg.type !== 'string') {
|
|
752
|
+
this._rejectProtocolMessage('Malformed relay message');
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
await this._handleRelayMessage(msg, this.relayUrl, null);
|
|
757
|
+
} catch (err) {
|
|
758
|
+
this._handleMessageError(err);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
newWs.on('close', () => {
|
|
763
|
+
if (this.destroyed) return;
|
|
764
|
+
if (this.intentionalClose) {
|
|
765
|
+
this.log('Connection to relay closed (intentional).');
|
|
766
|
+
this.destroy();
|
|
767
|
+
} else {
|
|
768
|
+
this.log('Connection to relay lost. Starting reconnect...');
|
|
769
|
+
this.stopHeartbeat();
|
|
770
|
+
this._startReconnecting();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
newWs.on('error', (err) => {
|
|
775
|
+
this.log(`WS error: ${err.message}`);
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ─── Destroy ────────────────────────────────────────────────────────
|
|
780
|
+
destroy() {
|
|
781
|
+
if (this.destroyed) return;
|
|
782
|
+
this.destroyed = true;
|
|
783
|
+
this.intentionalClose = true;
|
|
784
|
+
this.stopHeartbeat();
|
|
785
|
+
this._stopReconnecting();
|
|
786
|
+
this._stopDurationLogging();
|
|
787
|
+
// Clean up in-progress file uploads
|
|
788
|
+
this._fileTransfer.cleanup();
|
|
789
|
+
// Clean up viewer
|
|
790
|
+
if (this._viewer) {
|
|
791
|
+
this._viewer.destroy();
|
|
792
|
+
this._viewer = null;
|
|
793
|
+
}
|
|
794
|
+
// Phase 4: Clean up WebRTC
|
|
795
|
+
this._cleanupWebRTC();
|
|
796
|
+
if (this.ptyProcess) {
|
|
797
|
+
try { this.ptyProcess.kill(); } catch {}
|
|
798
|
+
this.ptyProcess = null;
|
|
799
|
+
}
|
|
800
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
801
|
+
// Send session-ended so relay knows this is intentional and destroys session immediately
|
|
802
|
+
try { this.ws.send(JSON.stringify({ type: 'session-ended' })); } catch {}
|
|
803
|
+
this.ws.close();
|
|
804
|
+
}
|
|
805
|
+
// Log session duration on end
|
|
806
|
+
if (this.sessionStartedAt) {
|
|
807
|
+
const duration = formatDuration(Date.now() - this.sessionStartedAt);
|
|
808
|
+
this.log(`Session ended. Duration: ${duration}`);
|
|
809
|
+
} else {
|
|
810
|
+
this.log('Session ended.');
|
|
811
|
+
}
|
|
812
|
+
if (this.onDestroy) this.onDestroy(this.code);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// ─── Phase 4: WebRTC helpers ─────────────────────────────────────
|
|
816
|
+
|
|
817
|
+
_initiateWebRTC() {
|
|
818
|
+
// Clean up any previous WebRTC instance
|
|
819
|
+
this._cleanupWebRTC();
|
|
820
|
+
|
|
821
|
+
// Pass debug-level logger to WebRTC (internal messages are verbose)
|
|
822
|
+
this.webrtc = new HostWebRTC((msg) => this.logDebug(msg));
|
|
823
|
+
|
|
824
|
+
this.webrtc.onOpen(() => {
|
|
825
|
+
this.useDataChannel = true;
|
|
826
|
+
this.log('DataChannel open — relay bypassed');
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
this.webrtc.onClose(() => {
|
|
830
|
+
if (this.useDataChannel) {
|
|
831
|
+
this.log('DataChannel closed — falling back to relay');
|
|
832
|
+
this.useDataChannel = false;
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
this.webrtc.onMessage(async (data) => {
|
|
837
|
+
if (!this.sharedKey) return;
|
|
838
|
+
try {
|
|
839
|
+
// Check if this is a tagged message (resize)
|
|
840
|
+
let parsed;
|
|
841
|
+
try {
|
|
842
|
+
parsed = JSON.parse(data);
|
|
843
|
+
} catch {
|
|
844
|
+
parsed = null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (parsed && parsed._meta === 'resize') {
|
|
848
|
+
const resizeData = JSON.parse(await decrypt(this.sharedKey, parsed.payload));
|
|
849
|
+
if (resizeData.type === 'resize' && resizeData.cols && resizeData.rows) {
|
|
850
|
+
this.ptyProcess.resize(resizeData.cols, resizeData.rows);
|
|
851
|
+
this.logDebug(`Terminal resized to ${resizeData.cols}x${resizeData.rows}`);
|
|
852
|
+
}
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Normal encrypted data — decrypt first
|
|
857
|
+
const plaintext = await decrypt(this.sharedKey, data);
|
|
858
|
+
|
|
859
|
+
// Check if decrypted message is a file transfer message (file-start, file-chunk, file-end)
|
|
860
|
+
try {
|
|
861
|
+
const msg = JSON.parse(plaintext);
|
|
862
|
+
if (msg && typeof msg.type === 'string' && msg.type.startsWith('file-')) {
|
|
863
|
+
if (this.readOnly) {
|
|
864
|
+
this._fileTransfer._sendError(msg.id, 'File uploads are disabled in read-only mode');
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
this._fileTransfer.handleMessage(msg);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
} catch {
|
|
871
|
+
// Not JSON — normal keystroke data, fall through
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Normal keystroke — drop if read-only or no PTY
|
|
875
|
+
if (!this.ptyProcess) return;
|
|
876
|
+
if (this.readOnly) return;
|
|
877
|
+
this.ptyProcess.write(plaintext);
|
|
878
|
+
} catch (err) {
|
|
879
|
+
this.logDebug(`[WebRTC] Decryption failed: ${err.message}`);
|
|
880
|
+
}
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// Start the WebRTC negotiation
|
|
884
|
+
this.webrtc.initiate(async (msg) => {
|
|
885
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.sharedKey) {
|
|
886
|
+
try {
|
|
887
|
+
const payloadStr = JSON.stringify(msg.payload);
|
|
888
|
+
const encryptedPayload = await encrypt(this.sharedKey, payloadStr);
|
|
889
|
+
this.ws.send(JSON.stringify({ type: 'signal', payload: encryptedPayload }));
|
|
890
|
+
} catch (e) {
|
|
891
|
+
this.logDebug(`[WebRTC] Failed to encrypt signal: ${e.message}`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async _restartPeerSessionAfterHostRejoin() {
|
|
898
|
+
this.missedPings = 0;
|
|
899
|
+
|
|
900
|
+
if (this.useDataChannel && this.webrtc && this.webrtc.isActive()) {
|
|
901
|
+
this.startHeartbeat();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
this._resetCryptoState();
|
|
906
|
+
this._cleanupWebRTC();
|
|
907
|
+
|
|
908
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
909
|
+
await this._beginKeyExchange();
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
_cleanupWebRTC() {
|
|
914
|
+
this.useDataChannel = false;
|
|
915
|
+
if (this.webrtc) {
|
|
916
|
+
this.webrtc.close();
|
|
917
|
+
this.webrtc = null;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// ─── Status string for list command ─────────────────────────────────
|
|
922
|
+
getStatus() {
|
|
923
|
+
const elapsed = Date.now() - this.createdAt;
|
|
924
|
+
const remaining = Math.max(0, this.expiryMs - elapsed);
|
|
925
|
+
let status = '';
|
|
926
|
+
if (this.securityFingerprint && !this.fingerprintAuthorized) {
|
|
927
|
+
status = `verify fingerprint ${this.securityFingerprint}`;
|
|
928
|
+
} else if (this.isClientConnected) {
|
|
929
|
+
const duration = this.sessionStartedAt
|
|
930
|
+
? ` (${formatDuration(Date.now() - this.sessionStartedAt)})`
|
|
931
|
+
: '';
|
|
932
|
+
status = `client connected${duration}`;
|
|
933
|
+
} else if (this.awaitingRejoin) {
|
|
934
|
+
status = 'awaiting rejoin';
|
|
935
|
+
} else {
|
|
936
|
+
status = `waiting for client (expires in ${formatDuration(remaining)})`;
|
|
937
|
+
}
|
|
938
|
+
if (this.readOnly) status += ' (readonly)';
|
|
939
|
+
return status;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ─── Session Duration Logging ────────────────────────────────────────
|
|
943
|
+
|
|
944
|
+
_formatElapsedCompact(ms) {
|
|
945
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
946
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
947
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
948
|
+
const seconds = totalSeconds % 60;
|
|
949
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
950
|
+
if (hours > 0) return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
|
951
|
+
return `${pad(minutes)}:${pad(seconds)}`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
_startDurationLogging() {
|
|
955
|
+
this._stopDurationLogging();
|
|
956
|
+
// Log session duration every 5 minutes
|
|
957
|
+
this.sessionDurationInterval = setInterval(() => {
|
|
958
|
+
if (!this.sessionStartedAt || this.destroyed) return;
|
|
959
|
+
const elapsed = Date.now() - this.sessionStartedAt;
|
|
960
|
+
this.log(`Session active — ${this._formatElapsedCompact(elapsed)}`);
|
|
961
|
+
}, 5 * 60 * 1000);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
_stopDurationLogging() {
|
|
965
|
+
if (this.sessionDurationInterval) {
|
|
966
|
+
clearInterval(this.sessionDurationInterval);
|
|
967
|
+
this.sessionDurationInterval = null;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// ─── Session Manager ─────────────────────────────────────────────────────────
|
|
973
|
+
|
|
974
|
+
export class SessionManager {
|
|
975
|
+
constructor(shell, expiryMs, rejoinMs, readOnly, startPath) {
|
|
976
|
+
this.shell = shell;
|
|
977
|
+
this.expiryMs = expiryMs;
|
|
978
|
+
this.rejoinMs = rejoinMs;
|
|
979
|
+
this.readOnly = readOnly;
|
|
980
|
+
this.startPath = startPath;
|
|
981
|
+
this.sessions = new Map(); // code → Session
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async createSession() {
|
|
985
|
+
const session = new Session(this.shell, this.expiryMs, this.rejoinMs, this.readOnly, this.startPath, (code) => {
|
|
986
|
+
this.sessions.delete(code);
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
const code = await session.start();
|
|
991
|
+
this.sessions.set(code, session);
|
|
992
|
+
return code;
|
|
993
|
+
} catch (err) {
|
|
994
|
+
logger.error(`Failed to create session: ${err.message}`, err);
|
|
995
|
+
return null;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
listSessions() {
|
|
1000
|
+
if (this.sessions.size === 0) {
|
|
1001
|
+
logger.info('No active sessions.');
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
console.log('');
|
|
1005
|
+
console.log(' Active sessions:');
|
|
1006
|
+
for (const [code, session] of this.sessions) {
|
|
1007
|
+
console.log(` ${code} — ${session.getStatus()}`);
|
|
1008
|
+
}
|
|
1009
|
+
console.log('');
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
killSession(code) {
|
|
1013
|
+
const session = this.sessions.get(code);
|
|
1014
|
+
if (!session) {
|
|
1015
|
+
logger.warn(`Session ${code} not found.`);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
session.destroy();
|
|
1019
|
+
logger.info(`Session ${code} killed.`);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
async authorizeSession(code) {
|
|
1023
|
+
const session = this.sessions.get(code);
|
|
1024
|
+
if (!session) {
|
|
1025
|
+
logger.warn(`Session ${code} not found.`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
await session.authorizeFingerprint();
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
killAll() {
|
|
1032
|
+
for (const [, session] of this.sessions) {
|
|
1033
|
+
session.destroy();
|
|
1034
|
+
}
|
|
1035
|
+
this.sessions.clear();
|
|
1036
|
+
}
|
|
1037
|
+
}
|