dsh-remote 0.8.72 → 0.8.73
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/LICENSE +21 -21
- package/README.en.md +389 -389
- package/README.md +462 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4022 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4550 -4540
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/package.json +100 -99
package/lib/pool.js
CHANGED
|
@@ -1,893 +1,963 @@
|
|
|
1
|
-
// Persistent SSH/SFTP connection pool (one identity → one live client).
|
|
2
|
-
import ssh2 from 'ssh2'
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
|
-
import path from 'node:path'
|
|
6
|
-
import { truncate } from './paths.js'
|
|
7
|
-
import { createHostKeyGuard } from './hostkey.js'
|
|
8
|
-
import { buildSftpSurface } from './sftp-surface.js'
|
|
9
|
-
import { friendlyMessage } from './errors.js'
|
|
10
|
-
import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
|
|
11
|
-
|
|
12
|
-
const { Client } = ssh2
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
this
|
|
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
|
-
this.
|
|
188
|
-
this.
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
this.
|
|
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
|
-
return
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
} catch {
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1
|
+
// Persistent SSH/SFTP connection pool (one identity → one live client).
|
|
2
|
+
import ssh2 from 'ssh2'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { truncate } from './paths.js'
|
|
7
|
+
import { createHostKeyGuard } from './hostkey.js'
|
|
8
|
+
import { buildSftpSurface } from './sftp-surface.js'
|
|
9
|
+
import { friendlyMessage } from './errors.js'
|
|
10
|
+
import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
|
|
11
|
+
|
|
12
|
+
const { Client } = ssh2
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A key path that names a PUBLIC key. `IdentityFile ~/.ssh/x.pub` is OpenSSH's
|
|
16
|
+
* idiom for "the private key lives in the agent (1Password, Bitwarden, rbw…);
|
|
17
|
+
* use the agent key matching this file" — it must never be handed to ssh2 as
|
|
18
|
+
* a private key.
|
|
19
|
+
*/
|
|
20
|
+
export function isPublicKeyPath(p) {
|
|
21
|
+
return /\.pub$/i.test(String(p || ''))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The wire blob of an OpenSSH public key line (`type base64 [comment]`). */
|
|
25
|
+
export function publicKeyBlob(text) {
|
|
26
|
+
const parts = String(text || '').trim().split(/\s+/)
|
|
27
|
+
if (parts.length < 2) return null
|
|
28
|
+
const blob = Buffer.from(parts[1], 'base64')
|
|
29
|
+
return blob.length ? blob : null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* An agent that offers the key matching `wantBlob` FIRST — and, with
|
|
34
|
+
* `only` (IdentitiesOnly yes), nothing else. Without the ordering ssh2 offers
|
|
35
|
+
* every agent key in turn, and an agent holding many keys trips the server's
|
|
36
|
+
* MaxAuthTries before reaching the right one.
|
|
37
|
+
*/
|
|
38
|
+
export class PreferredKeyAgent extends ssh2.BaseAgent {
|
|
39
|
+
constructor(inner, wantBlob, only) {
|
|
40
|
+
super()
|
|
41
|
+
this.inner = inner
|
|
42
|
+
this.wantBlob = wantBlob
|
|
43
|
+
this.only = !!only
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getIdentities(cb) {
|
|
47
|
+
this.inner.getIdentities((err, keys) => {
|
|
48
|
+
if (err) return cb(err)
|
|
49
|
+
const list = Array.isArray(keys) ? keys : []
|
|
50
|
+
const isWanted = (k) => {
|
|
51
|
+
try { return Buffer.from(k.getPublicSSH()).equals(this.wantBlob) } catch { return false }
|
|
52
|
+
}
|
|
53
|
+
const wanted = list.filter(isWanted)
|
|
54
|
+
cb(null, this.only ? wanted : [...wanted, ...list.filter((k) => !isWanted(k))])
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
sign(...args) {
|
|
59
|
+
return this.inner.sign(...args)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the pool for a config.
|
|
65
|
+
*
|
|
66
|
+
* Two backends exist (issue #51): the native one below, which speaks SSH itself
|
|
67
|
+
* via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
|
|
68
|
+
* the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
|
|
69
|
+
* `~/.ssh/config` are honoured. Both expose one identical surface, so every
|
|
70
|
+
* caller can keep using `new SshPool(...)`-shaped code — this factory only has
|
|
71
|
+
* to decide which class to instantiate.
|
|
72
|
+
*/
|
|
73
|
+
/** Hard cap on proxy-chain depth. OpenSSH's ProxyJump has no explicit limit;
|
|
74
|
+
* this exists to turn a config CYCLE into a clear error instead of unbounded
|
|
75
|
+
* recursion. A legitimate chain is 1-3 hops.
|
|
76
|
+
* @type {number} */
|
|
77
|
+
export const MAX_JUMP_HOPS = 8
|
|
78
|
+
|
|
79
|
+
export function createPool(config, opts) {
|
|
80
|
+
return isSystemOpenSshBackend(config)
|
|
81
|
+
? new SystemOpenSshPool(config, opts)
|
|
82
|
+
: new SshPool(config, opts)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Run one `forwardOut` and resolve with the resulting channel.
|
|
87
|
+
*
|
|
88
|
+
* ssh2 reports a dead connection by THROWING synchronously —
|
|
89
|
+
* `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
|
|
90
|
+
* (ssh2 >= 1.16, `Client.forwardOut`) — and never calls the callback, so a
|
|
91
|
+
* callback-only error path cannot guard this call (issue #54). Two further
|
|
92
|
+
* consequences justify a shared helper rather than an inline try/catch:
|
|
93
|
+
*
|
|
94
|
+
* • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
|
|
95
|
+
* an unhandled REJECTION and leaves the promise pending forever;
|
|
96
|
+
* • called from a `net.Server` connection handler the throw escapes to the
|
|
97
|
+
* process top level and kills the host.
|
|
98
|
+
*
|
|
99
|
+
* This guards the CALLER's side of the contract rather than assuming one
|
|
100
|
+
* backend's behaviour: the system-OpenSSH backend (lib/openssh.js `forwardOut`)
|
|
101
|
+
* already converts its own spawn failure into a callback error, so for it the
|
|
102
|
+
* try/catch below is dead code — but a backend is free to throw, and the one
|
|
103
|
+
* that does must not be able to take the process down.
|
|
104
|
+
*
|
|
105
|
+
* @param {object} client - an ssh2 Client or OpenSshConnection.
|
|
106
|
+
* @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
|
|
107
|
+
* @returns {Promise<object>} the forwarded channel / duplex stream.
|
|
108
|
+
*/
|
|
109
|
+
export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
// `reject` is called at most once: a callback that fires after a successful
|
|
112
|
+
// throw-path rejection would be a no-op, which Promise guarantees.
|
|
113
|
+
try {
|
|
114
|
+
client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
|
|
115
|
+
} catch (err) {
|
|
116
|
+
reject(err)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The ACTIVE pool: one long-lived object whose concrete implementation can be
|
|
123
|
+
* swapped when the current machine's transport backend changes.
|
|
124
|
+
*
|
|
125
|
+
* Why a delegating facade instead of just constructing the right class once:
|
|
126
|
+
* `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
|
|
127
|
+
* `openssh` while every other machine stays on `native`), but the plugin keeps
|
|
128
|
+
* exactly one active pool for its whole lifetime and hangs `passwordResolver` /
|
|
129
|
+
* `onReady` / `onCloseHook` off it once at startup. Replacing the object
|
|
130
|
+
* wholesale on every machine switch would silently drop those hooks — the port
|
|
131
|
+
* forwards and keychain passwords would stop working after the first switch.
|
|
132
|
+
*
|
|
133
|
+
* So the facade owns the hooks and the shared `config` object, and forwards
|
|
134
|
+
* them to whichever concrete pool is right for the current config. Everything
|
|
135
|
+
* else is a straight delegation, which is why callers cannot tell the
|
|
136
|
+
* difference (and why the class is deliberately not part of the public story).
|
|
137
|
+
*/
|
|
138
|
+
export class ActivePool {
|
|
139
|
+
constructor(config, opts = {}) {
|
|
140
|
+
this.config = config
|
|
141
|
+
this.opts = opts
|
|
142
|
+
// The hooks are assigned by the plugin AFTER construction, so they are
|
|
143
|
+
// stored here and re-applied to every delegate this facade ever creates.
|
|
144
|
+
this._passwordResolver = null
|
|
145
|
+
this._onReady = null
|
|
146
|
+
this._onCloseHook = null
|
|
147
|
+
this._delegate = null
|
|
148
|
+
this._sync()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Which concrete class the current config asks for. */
|
|
152
|
+
_wantsSystemOpenSsh() {
|
|
153
|
+
return isSystemOpenSshBackend(this.config)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
_delegateIsCurrent() {
|
|
157
|
+
if (!this._delegate) return false
|
|
158
|
+
const isOpenssh = this._delegate instanceof SystemOpenSshPool
|
|
159
|
+
return isOpenssh === this._wantsSystemOpenSsh()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Create (or replace) the delegate so it matches the current config. */
|
|
163
|
+
_sync() {
|
|
164
|
+
if (this._delegateIsCurrent()) return this._delegate
|
|
165
|
+
const previous = this._delegate
|
|
166
|
+
const next = createPool(this.config, this.opts)
|
|
167
|
+
next.passwordResolver = this._passwordResolver
|
|
168
|
+
next.onReady = this._onReady
|
|
169
|
+
next.onCloseHook = this._onCloseHook
|
|
170
|
+
this._delegate = next
|
|
171
|
+
// Tear the old backend down LAST, and never let a teardown error mask the
|
|
172
|
+
// new pool: the connection it was holding belongs to the old host anyway.
|
|
173
|
+
if (previous) { try { previous.close() } catch { /* already gone */ } }
|
|
174
|
+
return next
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get delegate() { return this._sync() }
|
|
178
|
+
|
|
179
|
+
// ── hooks: stored here, mirrored onto every delegate ──────────────────────
|
|
180
|
+
get passwordResolver() { return this._passwordResolver }
|
|
181
|
+
set passwordResolver(fn) {
|
|
182
|
+
this._passwordResolver = fn
|
|
183
|
+
if (this._delegate) this._delegate.passwordResolver = fn
|
|
184
|
+
}
|
|
185
|
+
get onReady() { return this._onReady }
|
|
186
|
+
set onReady(fn) {
|
|
187
|
+
this._onReady = fn
|
|
188
|
+
if (this._delegate) this._delegate.onReady = fn
|
|
189
|
+
}
|
|
190
|
+
get onCloseHook() { return this._onCloseHook }
|
|
191
|
+
set onCloseHook(fn) {
|
|
192
|
+
this._onCloseHook = fn
|
|
193
|
+
if (this._delegate) this._delegate.onCloseHook = fn
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── pass-through state the plugin reads directly ──────────────────────────
|
|
197
|
+
get client() { return this._delegate ? this._delegate.client : null }
|
|
198
|
+
// Assignable because the plugin's own tests arm a fake live connection on the
|
|
199
|
+
// active pool; a getter-only facade would make that assignment a silent
|
|
200
|
+
// no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
|
|
201
|
+
set client(v) { if (this._sync()) this._delegate.client = v }
|
|
202
|
+
get connecting() { return this._delegate ? this._delegate.connecting : null }
|
|
203
|
+
get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
|
|
204
|
+
get epoch() { return this._delegate ? this._delegate.epoch : 0 }
|
|
205
|
+
get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
|
|
206
|
+
set platform(v) { if (this._sync()) this._delegate.platform = v }
|
|
207
|
+
get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
|
|
208
|
+
set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
|
|
209
|
+
get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
|
|
210
|
+
set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
|
|
211
|
+
|
|
212
|
+
// ── delegated operations ──────────────────────────────────────────────────
|
|
213
|
+
/** Re-point at a new target, switching backend first if the machine asks. */
|
|
214
|
+
setTarget(fields, previous) {
|
|
215
|
+
// The backend may have changed with the machine, so apply that part of the
|
|
216
|
+
// request to `config` BEFORE picking the delegate: _sync() chooses the class
|
|
217
|
+
// from config.backend, and the old backend must not be handed a host it can
|
|
218
|
+
// no longer reach. The delegate then receives the whole request as usual.
|
|
219
|
+
if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
|
|
220
|
+
if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
|
|
221
|
+
const d = this._sync()
|
|
222
|
+
return d.setTarget(fields, previous)
|
|
223
|
+
}
|
|
224
|
+
connect() { return this.delegate.connect() }
|
|
225
|
+
exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
|
|
226
|
+
sftp() { return this.delegate.sftp() }
|
|
227
|
+
shell(opts) { return this.delegate.shell(opts) }
|
|
228
|
+
detect() { return this.delegate.detect() }
|
|
229
|
+
invalidate() { return this.delegate.invalidate() }
|
|
230
|
+
close() { return this.delegate.close() }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export class SshPool {
|
|
234
|
+
constructor(config, opts = {}) {
|
|
235
|
+
this.config = config
|
|
236
|
+
this._knownHostsFile = opts.knownHostsFile
|
|
237
|
+
this.client = null
|
|
238
|
+
this.connecting = null
|
|
239
|
+
this.proxyPool = null
|
|
240
|
+
// Generational token: bumped on every target change / close so a stale
|
|
241
|
+
// in-flight connect can never hand this pool a connection to an old host.
|
|
242
|
+
this.epoch = 0
|
|
243
|
+
/** Auto-detected remote platform: unknown | windows | posix (per target). */
|
|
244
|
+
this.platform = 'unknown'
|
|
245
|
+
/** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
|
|
246
|
+
this.gitBashPath = ''
|
|
247
|
+
/** Resolved terminal strategy: native | git-bash. */
|
|
248
|
+
this.shellMode = 'native'
|
|
249
|
+
/** In-flight platform detection promise (cached). */
|
|
250
|
+
this._detecting = null
|
|
251
|
+
/** Optional async resolver for a machine-stored (keychain) password. */
|
|
252
|
+
this.passwordResolver = null
|
|
253
|
+
/** Optional hook called with the live client after a successful connect. */
|
|
254
|
+
this.onReady = null
|
|
255
|
+
/** Optional hook called when the pool closes. */
|
|
256
|
+
this.onCloseHook = null
|
|
257
|
+
/** Consecutive SFTP subchannel-open timeouts (issue #65). */
|
|
258
|
+
this._sftpTimeouts = 0
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
resolveKeyPath() {
|
|
262
|
+
const p = this.config.privateKeyPath
|
|
263
|
+
if (!p) return ''
|
|
264
|
+
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
265
|
+
return p
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
|
|
269
|
+
// Collect the requested changes first: an UNCHANGED target is a no-op. The
|
|
270
|
+
// old behavior closed the live connection and re-ran platform detection
|
|
271
|
+
// even for an identical target, so every picker keystroke
|
|
272
|
+
// (POST /current → applyActiveMachine → setTarget) paid a full SSH
|
|
273
|
+
// reconnect + re-detect — the "slow remote path autocomplete" bug.
|
|
274
|
+
//
|
|
275
|
+
// `previous` is the diff BASELINE. Callers whose shared config was already
|
|
276
|
+
// mutated BEFORE this call (applyActiveMachine: applyMachine rewrites the
|
|
277
|
+
// live config IN PLACE, and pool.config IS that config object) pass a
|
|
278
|
+
// pre-mutation snapshot — otherwise the comparison runs the new values
|
|
279
|
+
// against themselves, is always equal and never closes even for a machine
|
|
280
|
+
// switch (the wrong-host hazard of issue #25). Defaults to the current
|
|
281
|
+
// config for callers that pass fresh values read independently of it.
|
|
282
|
+
const base = previous || this.config
|
|
283
|
+
const wanted = []
|
|
284
|
+
if (host !== undefined) wanted.push(['host', String(host)])
|
|
285
|
+
if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
|
|
286
|
+
if (username !== undefined) wanted.push(['username', String(username)])
|
|
287
|
+
if (password !== undefined && password !== null) wanted.push(['password', String(password)])
|
|
288
|
+
if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
|
|
289
|
+
if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
|
|
290
|
+
if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
|
|
291
|
+
if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
|
|
292
|
+
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
293
|
+
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
294
|
+
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
295
|
+
// Issue #51: the transport backend is part of the target identity — changing
|
|
296
|
+
// it must close the live connection exactly like changing the host does.
|
|
297
|
+
if (backend !== undefined) wanted.push(['backend', String(backend)])
|
|
298
|
+
if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
|
|
299
|
+
const differs = ([k, v]) => (k === 'proxy'
|
|
300
|
+
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
301
|
+
: base[k] !== v)
|
|
302
|
+
// Something actually changed → apply it and force a fresh connection.
|
|
303
|
+
// (A caller that WANTS a reconnect for an identical target uses
|
|
304
|
+
// invalidate(); setTarget semantics stay "re-point when moved".)
|
|
305
|
+
if (wanted.some(differs)) {
|
|
306
|
+
for (const [k, v] of wanted) this.config[k] = v
|
|
307
|
+
// the new target may be a different OS — re-detect on the next command
|
|
308
|
+
this.platform = 'unknown'
|
|
309
|
+
this.gitBashPath = ''
|
|
310
|
+
this.shellMode = 'native'
|
|
311
|
+
this._detecting = null
|
|
312
|
+
this.close()
|
|
313
|
+
}
|
|
314
|
+
return this
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
connect() {
|
|
318
|
+
if (this.client) return Promise.resolve(this.client)
|
|
319
|
+
if (this.connecting) return this.connecting
|
|
320
|
+
const epoch = this.epoch
|
|
321
|
+
const pending = this._doConnect(epoch)
|
|
322
|
+
this.connecting = pending
|
|
323
|
+
const clear = () => {
|
|
324
|
+
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
325
|
+
}
|
|
326
|
+
pending.then(clear, clear)
|
|
327
|
+
return pending
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async _doConnect(epoch) {
|
|
331
|
+
const isCurrent = () => this.epoch === epoch
|
|
332
|
+
const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
|
|
333
|
+
const guard = createHostKeyGuard(this.config, knownHostsFile)
|
|
334
|
+
const client = new Client()
|
|
335
|
+
let settled = false
|
|
336
|
+
// Every connect-stage failure is classified HERE, at the layer that knows
|
|
337
|
+
// which machine was being dialed: a bare ssh2 errno ("Timed out while
|
|
338
|
+
// waiting for handshake") names no host, and on a multi-machine plugin the
|
|
339
|
+
// user cannot tell WHICH box failed. friendlyMessage is idempotent, so
|
|
340
|
+
// callers that classify again (rw_connect, routes) do not double-wrap.
|
|
341
|
+
const classify = (err) => new Error(friendlyMessage(err, { host: this.config.host, port: this.config.port }))
|
|
342
|
+
const fail = (err) => {
|
|
343
|
+
if (settled) return
|
|
344
|
+
settled = true
|
|
345
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
346
|
+
throw classify(guard.lastError ? new Error(guard.lastError) : err)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Proxy jump: SSH to the bastion first, then tunnel to the target through it.
|
|
350
|
+
//
|
|
351
|
+
// MULTI-HOP (OpenSSH `ProxyJump a,b,c` semantics): each bastion may itself
|
|
352
|
+
// carry a `proxy`, and the chain is built from the FARTHEST hop inward — hop
|
|
353
|
+
// N is dialed through the already-open connection to hop N-1. The recursion
|
|
354
|
+
// IS the mechanism, so there is no depth limit beyond what the config
|
|
355
|
+
// expresses; MAX_JUMP_HOPS is a cycle/typo guard, not a feature limit.
|
|
356
|
+
let sock = null
|
|
357
|
+
const proxyCfg = this.config.proxy
|
|
358
|
+
if (proxyCfg && proxyCfg.host) {
|
|
359
|
+
try {
|
|
360
|
+
const depth = Number(this.config._hopDepth) || 0
|
|
361
|
+
if (depth >= MAX_JUMP_HOPS) {
|
|
362
|
+
throw new Error(`proxy chain is deeper than ${MAX_JUMP_HOPS} hops — check the machine config for a proxy cycle`)
|
|
363
|
+
}
|
|
364
|
+
this.proxyPool = new SshPool({
|
|
365
|
+
...this.config,
|
|
366
|
+
host: proxyCfg.host,
|
|
367
|
+
port: Number(proxyCfg.port) || 22,
|
|
368
|
+
username: proxyCfg.username || this.config.username || 'root',
|
|
369
|
+
// CREDENTIALS DO NOT INHERIT ACROSS HOPS. `...this.config` brings the
|
|
370
|
+
// TARGET's key/passphrase along, which is wrong in two ways: it may
|
|
371
|
+
// not be authorized on the bastion, and presenting the target's key to
|
|
372
|
+
// an intermediate host hands that host a credential it should never
|
|
373
|
+
// see. A hop with no key of its own falls back to the agent/default
|
|
374
|
+
// discovery path, which is what an operator's own ssh setup provides.
|
|
375
|
+
password: proxyCfg.password || '',
|
|
376
|
+
privateKeyPath: proxyCfg.privateKeyPath || '',
|
|
377
|
+
passphrase: proxyCfg.passphrase || '',
|
|
378
|
+
agentSocket: proxyCfg.agentSocket || '',
|
|
379
|
+
identitiesOnly: !!proxyCfg.identitiesOnly,
|
|
380
|
+
...(proxyCfg.useAgent !== undefined ? { useAgent: !!proxyCfg.useAgent } : {}),
|
|
381
|
+
...(proxyCfg.keyboardInteractive !== undefined ? { keyboardInteractive: !!proxyCfg.keyboardInteractive } : {}),
|
|
382
|
+
...(proxyCfg.hostKeyMode ? { hostKeyMode: proxyCfg.hostKeyMode } : {}),
|
|
383
|
+
// The nested hop keeps its OWN proxy — that is what builds the chain —
|
|
384
|
+
// and carries an incremented depth so a cycle is reported instead of
|
|
385
|
+
// recursing until the process dies.
|
|
386
|
+
proxy: proxyCfg.proxy && proxyCfg.proxy.host ? proxyCfg.proxy : undefined,
|
|
387
|
+
_hopDepth: depth + 1,
|
|
388
|
+
}, { knownHostsFile: this._knownHostsFile })
|
|
389
|
+
const pclient = await this.proxyPool.connect()
|
|
390
|
+
if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
|
|
391
|
+
try {
|
|
392
|
+
sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
|
|
393
|
+
} catch (e) {
|
|
394
|
+
throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return fail(err)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
const rejectOnce = (err) => {
|
|
403
|
+
if (settled) return
|
|
404
|
+
settled = true
|
|
405
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
406
|
+
reject(classify(guard.lastError ? new Error(guard.lastError) : err))
|
|
407
|
+
}
|
|
408
|
+
client.on('ready', () => {
|
|
409
|
+
if (settled) return
|
|
410
|
+
settled = true
|
|
411
|
+
if (!isCurrent()) {
|
|
412
|
+
try { client.end() } catch {}
|
|
413
|
+
reject(classify(new Error('ssh target changed during connect')))
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
this.client = client
|
|
417
|
+
this._sftpTimeouts = 0
|
|
418
|
+
resolve(client)
|
|
419
|
+
if (this.onReady) { try { this.onReady(client) } catch {} }
|
|
420
|
+
})
|
|
421
|
+
client.on('error', (e) => rejectOnce(e))
|
|
422
|
+
client.on('close', () => {
|
|
423
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
424
|
+
rejectOnce(new Error('ssh connection closed'))
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
const buildOpts = async () => {
|
|
428
|
+
const opts = {
|
|
429
|
+
host: this.config.host,
|
|
430
|
+
port: this.config.port,
|
|
431
|
+
username: this.config.username,
|
|
432
|
+
readyTimeout: this.config.connectTimeoutMs,
|
|
433
|
+
keepaliveInterval: 15000,
|
|
434
|
+
keepaliveCountMax: 3,
|
|
435
|
+
hostVerifier: (key) => guard.verifier(key),
|
|
436
|
+
}
|
|
437
|
+
if (sock) opts.sock = sock
|
|
438
|
+
// `useAgent` asks for agent auth, but ssh2 can only offer it when this
|
|
439
|
+
// PROCESS has an agent socket. On Windows there is no agent by default,
|
|
440
|
+
// so the flag alone used to fall through to the generic "no credentials"
|
|
441
|
+
// error — which reads as "the plugin lost my settings" rather than "this
|
|
442
|
+
// machine has no agent". Track the intent so the failure can say which.
|
|
443
|
+
let agentRequested = false
|
|
444
|
+
// `IdentityAgent` from ~/.ssh/config (agentSocket) wins over the
|
|
445
|
+
// process SSH_AUTH_SOCK, exactly as in ssh; `none` turns the agent off.
|
|
446
|
+
const agentSocket = String(this.config.agentSocket || '')
|
|
447
|
+
const sockPath = agentSocket === 'none' ? '' : (agentSocket || process.env.SSH_AUTH_SOCK || '')
|
|
448
|
+
const keyPathRaw = this.config.privateKeyPath ? this.resolveKeyPath() : ''
|
|
449
|
+
const agentKeyHint = isPublicKeyPath(keyPathRaw)
|
|
450
|
+
if (this.config.useAgent || agentKeyHint || (agentSocket && agentSocket !== 'none')) {
|
|
451
|
+
if (sockPath) opts.agent = sockPath
|
|
452
|
+
else agentRequested = true
|
|
453
|
+
}
|
|
454
|
+
let password = this.config.password || ''
|
|
455
|
+
if (!password && this.passwordResolver) {
|
|
456
|
+
try { password = (await this.passwordResolver()) || '' } catch {}
|
|
457
|
+
}
|
|
458
|
+
if (password) {
|
|
459
|
+
opts.password = password
|
|
460
|
+
opts.tryKeyboard = true
|
|
461
|
+
} else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
|
|
462
|
+
opts.tryKeyboard = true
|
|
463
|
+
}
|
|
464
|
+
if (agentKeyHint) {
|
|
465
|
+
// The key file is a PUBLIC key: authenticate through the agent with
|
|
466
|
+
// the matching key (see isPublicKeyPath).
|
|
467
|
+
if (!opts.agent) {
|
|
468
|
+
throw new Error(`no SSH agent available: the identity "${keyPathRaw}" is a public key, so its private key must come from an SSH agent, but neither IdentityAgent nor SSH_AUTH_SOCK gives one to the DSH host process`)
|
|
469
|
+
}
|
|
470
|
+
let blob
|
|
471
|
+
try {
|
|
472
|
+
blob = publicKeyBlob(readFileSync(keyPathRaw, 'utf8'))
|
|
473
|
+
} catch (err) {
|
|
474
|
+
throw new Error(`cannot read public key "${keyPathRaw}": ${err && err.message}`)
|
|
475
|
+
}
|
|
476
|
+
if (!blob) throw new Error(`cannot parse public key "${keyPathRaw}"`)
|
|
477
|
+
opts.agent = new PreferredKeyAgent(ssh2.createAgent(opts.agent), blob, this.config.identitiesOnly)
|
|
478
|
+
} else if (this.config.privateKeyPath) {
|
|
479
|
+
const keyPath = this.resolveKeyPath()
|
|
480
|
+
if (!keyPath) {
|
|
481
|
+
throw new Error('no credentials: set a password or a privateKeyPath to connect')
|
|
482
|
+
}
|
|
483
|
+
let key
|
|
484
|
+
try {
|
|
485
|
+
key = readFileSync(keyPath)
|
|
486
|
+
} catch (err) {
|
|
487
|
+
throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
|
|
488
|
+
}
|
|
489
|
+
opts.privateKey = key
|
|
490
|
+
opts.passphrase = this.config.passphrase || undefined
|
|
491
|
+
} else if (!password && !opts.agent) {
|
|
492
|
+
throw new Error(agentRequested
|
|
493
|
+
? 'no SSH agent available: useAgent is enabled for this machine but SSH_AUTH_SOCK is not set in the DSH host process (Windows has no agent by default) — start an agent (or Pageant) before launching DSH, or save a password / private key for this machine'
|
|
494
|
+
: 'no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
|
|
495
|
+
}
|
|
496
|
+
return opts
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
buildOpts().then(
|
|
500
|
+
(opts) => {
|
|
501
|
+
if (opts.tryKeyboard) {
|
|
502
|
+
client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
|
503
|
+
finish(prompts.map(() => this.config.password || ''))
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
// ssh2 parses the private key SYNCHRONOUSLY inside connect() and
|
|
507
|
+
// throws on an unparseable key (the classic case: an encrypted
|
|
508
|
+
// OpenSSH key without a passphrase). Left uncaught, that throw
|
|
509
|
+
// rejects this orphaned .then chain — an unhandled rejection that
|
|
510
|
+
// crashes the whole host process (issue #48: "dsh: fatal load
|
|
511
|
+
// failure" on every boot, because boot-restore re-dials the bad
|
|
512
|
+
// machine). It also leaves the outer promise pending forever.
|
|
513
|
+
// Convert it into a normal rejection so every caller's try/catch —
|
|
514
|
+
// boot restore, test-connect, rw_connect — sees a regular failure.
|
|
515
|
+
try {
|
|
516
|
+
client.connect(opts)
|
|
517
|
+
} catch (err) {
|
|
518
|
+
rejectOnce(err)
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
(err) => rejectOnce(err),
|
|
522
|
+
)
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
527
|
+
detect() {
|
|
528
|
+
if (this.platform !== 'unknown') return Promise.resolve()
|
|
529
|
+
if (this._detecting) return this._detecting
|
|
530
|
+
this._detecting = this._detect().finally(() => {
|
|
531
|
+
this._detecting = null
|
|
532
|
+
})
|
|
533
|
+
return this._detecting
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async _detect() {
|
|
537
|
+
let res
|
|
538
|
+
try {
|
|
539
|
+
// `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
|
|
540
|
+
// "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
|
|
541
|
+
res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
542
|
+
} catch (err) {
|
|
543
|
+
this.platform = 'unknown'
|
|
544
|
+
this.shellMode = 'native'
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
548
|
+
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
549
|
+
this.platform = 'windows'
|
|
550
|
+
await this._resolveGitBash()
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
// inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
|
|
554
|
+
try {
|
|
555
|
+
const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
556
|
+
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
557
|
+
this.platform = 'windows'
|
|
558
|
+
await this._resolveGitBash()
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
} catch {}
|
|
562
|
+
this.platform = 'posix'
|
|
563
|
+
this.shellMode = 'native'
|
|
564
|
+
this.gitBashPath = ''
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
|
|
568
|
+
async _resolveGitBash() {
|
|
569
|
+
const cfg = String(this.config.shell || '').trim()
|
|
570
|
+
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
571
|
+
if (await this._cmdExists(cfg)) {
|
|
572
|
+
this.gitBashPath = cfg
|
|
573
|
+
this.shellMode = 'git-bash'
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (cfg === 'native') {
|
|
578
|
+
this.shellMode = 'native'
|
|
579
|
+
this.gitBashPath = ''
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
|
|
584
|
+
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
585
|
+
if (first) {
|
|
586
|
+
this.gitBashPath = first
|
|
587
|
+
this.shellMode = 'git-bash'
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
} catch {}
|
|
591
|
+
const candidates = [
|
|
592
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
593
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
594
|
+
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
595
|
+
'%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
|
|
596
|
+
]
|
|
597
|
+
for (const c of candidates) {
|
|
598
|
+
if (await this._cmdExists(c)) {
|
|
599
|
+
this.gitBashPath = c
|
|
600
|
+
this.shellMode = 'git-bash'
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
|
|
605
|
+
this.gitBashPath = ''
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async _cmdExists(p) {
|
|
609
|
+
try {
|
|
610
|
+
const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
|
|
611
|
+
return /Y/.test(String(r.stdout || ''))
|
|
612
|
+
} catch {
|
|
613
|
+
return false
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
618
|
+
* On Windows remotes with Git Bash the script is piped to `bash -s` over the
|
|
619
|
+
* exec-channel stdin — no shell quoting round-trip, so any content (quotes,
|
|
620
|
+
* backslashes, newlines) survives verbatim. */
|
|
621
|
+
exec(command, timeoutMsOrOpts) {
|
|
622
|
+
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
623
|
+
return this.detect().then(() => {
|
|
624
|
+
if (this.platform === 'windows' && this.gitBashPath) {
|
|
625
|
+
const script = String(command)
|
|
626
|
+
return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
|
|
627
|
+
try { stream.end(script) } catch {}
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
return this._execRaw(command, opts)
|
|
631
|
+
})
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
|
|
635
|
+
* the remote process stdin (used by the Git Bash `-s` mode). */
|
|
636
|
+
_execRaw(command, opts, stdinWriter) {
|
|
637
|
+
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
638
|
+
const signal = opts && opts.signal
|
|
639
|
+
// Streaming tap for background jobs (issue #70): a promoted command's value
|
|
640
|
+
// is its LIVE output, which the job ring must receive as it arrives rather
|
|
641
|
+
// than only as a return value the caller already abandoned. Called with
|
|
642
|
+
// ('stdout'|'stderr', chunk); failures inside the tap must never break the
|
|
643
|
+
// command itself, so every call site guards it.
|
|
644
|
+
const onData = opts && typeof opts.onData === 'function' ? opts.onData : null
|
|
645
|
+
return this.connect().then(
|
|
646
|
+
(client) =>
|
|
647
|
+
new Promise((resolve, reject) => {
|
|
648
|
+
let retried = false
|
|
649
|
+
// Caller cancellation (issue #44): a tool that hands us exec.signal
|
|
650
|
+
// expects the remote command to stop when the turn is cancelled.
|
|
651
|
+
// Resolve the same shape as a timeout so callers need no new branch.
|
|
652
|
+
let onAbort = null
|
|
653
|
+
const cleanupAbort = () => { if (signal && onAbort) signal.removeEventListener('abort', onAbort) }
|
|
654
|
+
const runOn = (c) => {
|
|
655
|
+
const execOpts = {}
|
|
656
|
+
if (opts && opts.pty) execOpts.pty = true
|
|
657
|
+
if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
658
|
+
c.exec(command, execOpts, (err, stream) => {
|
|
659
|
+
if (err) {
|
|
660
|
+
// Channel-open failure — or a session termination — usually
|
|
661
|
+
// means the pooled connection died server-side (idle timeout /
|
|
662
|
+
// network reset) while keepalive hadn't noticed. Drop it and
|
|
663
|
+
// retry ONCE on a fresh connection.
|
|
664
|
+
if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
665
|
+
retried = true
|
|
666
|
+
this.invalidate()
|
|
667
|
+
return this.connect().then(
|
|
668
|
+
(fresh) => runOn(fresh),
|
|
669
|
+
(e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
673
|
+
}
|
|
674
|
+
let stdout = ''
|
|
675
|
+
let stderr = ''
|
|
676
|
+
let settled = false
|
|
677
|
+
let exitCode = null
|
|
678
|
+
let exitSignal = null
|
|
679
|
+
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
680
|
+
const settle = () => {
|
|
681
|
+
if (settled) return
|
|
682
|
+
settled = true
|
|
683
|
+
clearTimeout(timer)
|
|
684
|
+
cleanupAbort()
|
|
685
|
+
resolve({
|
|
686
|
+
code: exitCode,
|
|
687
|
+
signal: exitSignal,
|
|
688
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
689
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
const timer = setTimeout(() => {
|
|
693
|
+
if (settled) return
|
|
694
|
+
exitCode = -1
|
|
695
|
+
exitSignal = 'TIMEOUT'
|
|
696
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
697
|
+
// channel, so a runaway process cannot keep running and holding
|
|
698
|
+
// the SSH connection after we've given up on its output.
|
|
699
|
+
try {
|
|
700
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
701
|
+
} catch {}
|
|
702
|
+
const hardClose = setTimeout(() => {
|
|
703
|
+
try { stream.close() } catch {}
|
|
704
|
+
}, 800)
|
|
705
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
706
|
+
settle()
|
|
707
|
+
}, timeoutMs)
|
|
708
|
+
if (signal) {
|
|
709
|
+
if (signal.aborted) {
|
|
710
|
+
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
711
|
+
try { stream.close() } catch {}
|
|
712
|
+
exitCode = -1
|
|
713
|
+
exitSignal = 'ABORTED'
|
|
714
|
+
return settle()
|
|
715
|
+
}
|
|
716
|
+
onAbort = () => {
|
|
717
|
+
if (settled) return
|
|
718
|
+
exitCode = -1
|
|
719
|
+
exitSignal = 'ABORTED'
|
|
720
|
+
try { if (typeof stream.signal === 'function') stream.signal('SIGTERM') } catch {}
|
|
721
|
+
const hardClose = setTimeout(() => { try { stream.close() } catch {} }, 500)
|
|
722
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
723
|
+
settle()
|
|
724
|
+
}
|
|
725
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
726
|
+
}
|
|
727
|
+
stream.on('close', (code, sigName) => {
|
|
728
|
+
if (settled) return
|
|
729
|
+
exitCode = code
|
|
730
|
+
// `sigName` is the signal NAME that killed the remote process
|
|
731
|
+
// (e.g. 'SIGTERM'), not the caller's AbortSignal. The rename
|
|
732
|
+
// matters: `signal` in this scope is the AbortSignal, and
|
|
733
|
+
// assigning it here would leak an object into the result.
|
|
734
|
+
exitSignal = sigName
|
|
735
|
+
settle()
|
|
736
|
+
})
|
|
737
|
+
stream.on('data', (d) => {
|
|
738
|
+
if (stdout.length < hardCap) stdout += d
|
|
739
|
+
if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
|
|
740
|
+
})
|
|
741
|
+
stream.stderr.on('data', (d) => {
|
|
742
|
+
if (stderr.length < hardCap) stderr += d
|
|
743
|
+
if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
|
|
744
|
+
})
|
|
745
|
+
stream.on('error', (e) => {
|
|
746
|
+
if (settled) return
|
|
747
|
+
settled = true
|
|
748
|
+
clearTimeout(timer)
|
|
749
|
+
cleanupAbort()
|
|
750
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
751
|
+
})
|
|
752
|
+
if (stdinWriter) {
|
|
753
|
+
try { stdinWriter(stream) } catch {}
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
runOn(client)
|
|
758
|
+
}),
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Open an INTERACTIVE shell channel on the remote host (PTY-backed).
|
|
764
|
+
*
|
|
765
|
+
* This is what an embedded terminal needs and what `exec` cannot provide:
|
|
766
|
+
* `exec` runs one command and closes, with no PTY and no stdin after the
|
|
767
|
+
* writer returns. A shell channel keeps the session alive across commands, so
|
|
768
|
+
* `vim`, `top`, `sudo` prompts and anything else that talks to a terminal
|
|
769
|
+
* works the way it does in an ssh session.
|
|
770
|
+
*
|
|
771
|
+
* The returned stream is ssh2's channel: write to it for keystrokes, listen
|
|
772
|
+
* for `data` for output, call `setWindow(rows, cols, height, width)` on
|
|
773
|
+
* resize, and `end()`/`close()` to hang up. Columns/rows are negotiated per
|
|
774
|
+
* channel, so two terminals on the same pooled connection stay independent.
|
|
775
|
+
*
|
|
776
|
+
* @param {{cols?: number, rows?: number, term?: string, pty?: boolean}} [opts]
|
|
777
|
+
* @returns {Promise<object>} the ssh2 Channel (interactive stream).
|
|
778
|
+
*/
|
|
779
|
+
shell(opts = {}) {
|
|
780
|
+
const cols = Math.max(2, Math.min(Number(opts.cols) || 80, 1000))
|
|
781
|
+
const rows = Math.max(2, Math.min(Number(opts.rows) || 24, 1000))
|
|
782
|
+
const term = String(opts.term || 'xterm-256color')
|
|
783
|
+
return this.connect().then(
|
|
784
|
+
(client) =>
|
|
785
|
+
new Promise((resolve, reject) => {
|
|
786
|
+
let retried = false
|
|
787
|
+
const runOn = (c) => {
|
|
788
|
+
const wndopts = { cols, rows, height: 0, width: 0, term }
|
|
789
|
+
// A shell on a Windows remote is a cmd/PowerShell via the default
|
|
790
|
+
// sshd shell; the PTY request is identical either way.
|
|
791
|
+
c.shell(wndopts, opts.pty === false ? {} : { pty: wndopts }, (err, stream) => {
|
|
792
|
+
if (err) {
|
|
793
|
+
// Same stale-connection recovery as exec/sftp: a session
|
|
794
|
+
// termination means the pooled client died server-side.
|
|
795
|
+
if (!retried && /channel open failure|open failed|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
796
|
+
retried = true
|
|
797
|
+
this.invalidate()
|
|
798
|
+
return this.connect().then(
|
|
799
|
+
(fresh) => runOn(fresh),
|
|
800
|
+
(e2) => reject(new Error('ssh shell failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
return reject(new Error('ssh shell failed: ' + ((err && err.message) || err)))
|
|
804
|
+
}
|
|
805
|
+
// An idle shell must not be killed by the connection's own idle
|
|
806
|
+
// handling: keepalive is already on, and the channel lives until
|
|
807
|
+
// the client calls end(). Nothing to schedule here.
|
|
808
|
+
resolve(stream)
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
runOn(client)
|
|
812
|
+
}),
|
|
813
|
+
)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
|
|
817
|
+
sftp() {
|
|
818
|
+
return this.connect().then(
|
|
819
|
+
(client) =>
|
|
820
|
+
new Promise((resolve, reject) => {
|
|
821
|
+
let retried = false
|
|
822
|
+
const runOn = (c) => {
|
|
823
|
+
// Bound the subchannel open itself, per attempt. Without this, a
|
|
824
|
+
// remote whose sftp subsystem is unresponsive (overloaded sshd,
|
|
825
|
+
// exceeded MaxSessions, half-open TCP) leaves `c.sftp(cb)` parked
|
|
826
|
+
// forever with no callback — issue #44 point 3. ssh2 only calls back
|
|
827
|
+
// on success or on a channel error, and a wedged peer produces
|
|
828
|
+
// neither.
|
|
829
|
+
//
|
|
830
|
+
// The timer must be created per attempt, not once outside: the
|
|
831
|
+
// callback clears it, so a single outer timer would leave the retry
|
|
832
|
+
// (below) with no timeout at all.
|
|
833
|
+
let timedOut = false
|
|
834
|
+
const attemptTimer = setTimeout(() => {
|
|
835
|
+
timedOut = true
|
|
836
|
+
reject(new Error(`ssh sftp open timed out after ${this.config.commandTimeoutMs}ms`))
|
|
837
|
+
}, this.config.commandTimeoutMs)
|
|
838
|
+
// ★ 这里**不能 unref**。
|
|
839
|
+
//
|
|
840
|
+
// 这个定时器是"本次 await 的 sftp 打开"的**唯一结算者**:要么它超时 reject,
|
|
841
|
+
// 要么 ssh2 回调 resolve。若把它 unref,而进程此刻没有别的 pending 句柄,
|
|
842
|
+
// Node 会认为无事可做而**直接退出**,调用方永远等不到结果。
|
|
843
|
+
// 实测症状:CI 上该用例之后的 4 个用例被标记 `cancelledByParent`
|
|
844
|
+
// (同文件子进程提前退出),而本地因运行器恰好持着句柄而侥幸通过。
|
|
845
|
+
// 注意与上面 exec() 的超时定时器保持一致 —— 那个本来就没有 unref。
|
|
846
|
+
// 下面的 hardClose 仍然 unref,因为它只做清理、不负责结算。
|
|
847
|
+
const clearAttempt = () => { clearTimeout(attemptTimer) }
|
|
848
|
+
try {
|
|
849
|
+
c.sftp((err, sftp) => {
|
|
850
|
+
clearAttempt()
|
|
851
|
+
if (timedOut) return
|
|
852
|
+
if (err) {
|
|
853
|
+
// Same dead-connection recovery as exec(): a channel open
|
|
854
|
+
// failure — or a session termination (remote closed the
|
|
855
|
+
// SFTP subchannel, e.g. transient network blip / sshd idle
|
|
856
|
+
// drop) — means the pooled connection is stale: drop it and
|
|
857
|
+
// retry ONCE on a fresh connection.
|
|
858
|
+
if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
859
|
+
retried = true
|
|
860
|
+
this.invalidate()
|
|
861
|
+
return this.connect().then(
|
|
862
|
+
(fresh) => runOn(fresh),
|
|
863
|
+
(e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
864
|
+
)
|
|
865
|
+
}
|
|
866
|
+
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
867
|
+
}
|
|
868
|
+
// A successful open proves the connection healthy: the strike
|
|
869
|
+
// counter counts CONSECUTIVE timeouts, so it resets here —
|
|
870
|
+
// two unrelated transient blips hours apart must not tear down
|
|
871
|
+
// a fine connection.
|
|
872
|
+
this._sftpTimeouts = 0
|
|
873
|
+
// The promisified surface (timeouts, dead-channel fail-fast,
|
|
874
|
+
// range reads) is shared verbatim with the system-OpenSSH
|
|
875
|
+
// backend — see lib/sftp-surface.js for the rules it encodes.
|
|
876
|
+
resolve(buildSftpSurface(sftp, {
|
|
877
|
+
commandTimeoutMs: this.config.commandTimeoutMs,
|
|
878
|
+
onTimeout: () => {
|
|
879
|
+
// A timed-out SFTP subchannel is dead: close it so the next
|
|
880
|
+
// operation does not queue behind the wedged one, and count
|
|
881
|
+
// the strike so a second consecutive timeout tears down the
|
|
882
|
+
// whole pooled connection (issue #65).
|
|
883
|
+
try { sftp.end() } catch { /* already gone */ }
|
|
884
|
+
this._noteSftpTimeout()
|
|
885
|
+
},
|
|
886
|
+
}))
|
|
887
|
+
})
|
|
888
|
+
} catch (e) {
|
|
889
|
+
clearAttempt()
|
|
890
|
+
reject(new Error('ssh sftp failed: ' + ((e && e.message) || e)))
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
runOn(client)
|
|
894
|
+
}),
|
|
895
|
+
)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Count a timed-out SFTP subchannel open. A second consecutive timeout means
|
|
900
|
+
* the pooled connection itself is wedged (issue #65): tear it down so the
|
|
901
|
+
* next sftp() call re-handshakes instead of queueing behind the dead one.
|
|
902
|
+
*/
|
|
903
|
+
_noteSftpTimeout() {
|
|
904
|
+
this._sftpTimeouts = (this._sftpTimeouts || 0) + 1
|
|
905
|
+
if (this._sftpTimeouts >= 2) this.invalidate()
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Drop the cached client and force a fresh connection on the next call.
|
|
910
|
+
* Called when a channel open fails (e.g. "Channel open failure: open
|
|
911
|
+
* failed") — the pooled SSH connection is usually dead server-side while
|
|
912
|
+
* keepalive has not yet noticed, and reusing it keeps failing. The epoch
|
|
913
|
+
* bump orphans any in-flight connect; the client is ended so ssh2 frees
|
|
914
|
+
* its sockets.
|
|
915
|
+
*/
|
|
916
|
+
invalidate() {
|
|
917
|
+
this.epoch++
|
|
918
|
+
const client = this.client
|
|
919
|
+
this.client = null
|
|
920
|
+
const pending = this.connecting
|
|
921
|
+
this.connecting = null
|
|
922
|
+
if (pending && typeof pending.catch === 'function') {
|
|
923
|
+
try { pending.catch(() => {}) } catch {}
|
|
924
|
+
}
|
|
925
|
+
if (this.proxyPool) {
|
|
926
|
+
try { this.proxyPool.close() } catch {}
|
|
927
|
+
this.proxyPool = null
|
|
928
|
+
}
|
|
929
|
+
if (client) {
|
|
930
|
+
try { client.end() } catch {}
|
|
931
|
+
}
|
|
932
|
+
// Same teardown as close(): the forwards manager must detach from the
|
|
933
|
+
// dying client, or its stale `close -> stopAll()` listener later tears
|
|
934
|
+
// down forwards that were re-attached to the NEW client (onReady does
|
|
935
|
+
// the re-attach on the next successful connect).
|
|
936
|
+
if (this.onCloseHook) {
|
|
937
|
+
try { this.onCloseHook() } catch {}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
close() {
|
|
942
|
+
this.epoch++
|
|
943
|
+
const client = this.client
|
|
944
|
+
this.client = null
|
|
945
|
+
const pending = this.connecting
|
|
946
|
+
this.connecting = null
|
|
947
|
+
if (pending && typeof pending.catch === 'function') {
|
|
948
|
+
try { pending.catch(() => {}) } catch {}
|
|
949
|
+
}
|
|
950
|
+
if (this.proxyPool) {
|
|
951
|
+
try { this.proxyPool.close() } catch {}
|
|
952
|
+
this.proxyPool = null
|
|
953
|
+
}
|
|
954
|
+
if (client) {
|
|
955
|
+
try {
|
|
956
|
+
client.end()
|
|
957
|
+
} catch {}
|
|
958
|
+
}
|
|
959
|
+
if (this.onCloseHook) {
|
|
960
|
+
try { this.onCloseHook() } catch {}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|