dsh-remote 0.8.71 → 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/audit.js +45 -0
- package/lib/binding.js +172 -172
- package/lib/client.js +4022 -3990
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -118
- 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 -4548
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -886
- 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/sshconfig.js
CHANGED
|
@@ -1,705 +1,779 @@
|
|
|
1
|
-
// dsh-remote — ~/.ssh/config reading and OpenSSH alias resolution (issue #38).
|
|
2
|
-
//
|
|
3
|
-
// ## Why this module exists
|
|
4
|
-
//
|
|
5
|
-
// A saved machine can be an ALIAS from the user's ssh config: the registry keeps
|
|
6
|
-
// `{ host: '<alias>', useSshConfig: true }` and NOTHING else — no HostName, no
|
|
7
|
-
// user, no port, no key path. Every connect resolves that alias against
|
|
8
|
-
// `~/.ssh/config` as it is RIGHT NOW, which is what VSCode Remote-SSH does with
|
|
9
|
-
// the same file: editing the config (a new jump host, a rotated IdentityFile, a
|
|
10
|
-
// different port) is picked up without re-importing anything into the plugin.
|
|
11
|
-
//
|
|
12
|
-
// ## What is read
|
|
13
|
-
//
|
|
14
|
-
// Host / HostName / User / Port / IdentityFile / ProxyJump / ProxyCommand
|
|
15
|
-
// `IdentityFile` is imported as a PATH
|
|
16
|
-
// key material (v0.5.5 policy)
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// •
|
|
23
|
-
//
|
|
24
|
-
// •
|
|
25
|
-
// (
|
|
26
|
-
// •
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import
|
|
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
|
-
* @param {
|
|
73
|
-
* @param {
|
|
74
|
-
* @param {
|
|
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
|
-
const
|
|
102
|
-
if (
|
|
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
|
-
if (
|
|
130
|
-
if (
|
|
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
|
-
const
|
|
198
|
-
if (
|
|
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
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
else if (key === '
|
|
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
|
-
else if (key === '
|
|
332
|
-
else if (key === '
|
|
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
|
-
* @param {object}
|
|
492
|
-
* @param {
|
|
493
|
-
* @
|
|
494
|
-
*
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
const
|
|
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
|
-
const
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
return
|
|
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
|
-
const
|
|
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
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1
|
+
// dsh-remote — ~/.ssh/config reading and OpenSSH alias resolution (issue #38).
|
|
2
|
+
//
|
|
3
|
+
// ## Why this module exists
|
|
4
|
+
//
|
|
5
|
+
// A saved machine can be an ALIAS from the user's ssh config: the registry keeps
|
|
6
|
+
// `{ host: '<alias>', useSshConfig: true }` and NOTHING else — no HostName, no
|
|
7
|
+
// user, no port, no key path. Every connect resolves that alias against
|
|
8
|
+
// `~/.ssh/config` as it is RIGHT NOW, which is what VSCode Remote-SSH does with
|
|
9
|
+
// the same file: editing the config (a new jump host, a rotated IdentityFile, a
|
|
10
|
+
// different port) is picked up without re-importing anything into the plugin.
|
|
11
|
+
//
|
|
12
|
+
// ## What is read
|
|
13
|
+
//
|
|
14
|
+
// Host / HostName / User / Port / IdentityFile / ProxyJump / ProxyCommand /
|
|
15
|
+
// IdentityAgent / IdentitiesOnly. `IdentityFile` is imported as a PATH
|
|
16
|
+
// reference only — the plugin never opens key material (v0.5.5 policy); its
|
|
17
|
+
// `%n`-style tokens are expanded (expandSshTokens). A `.pub` IdentityFile is
|
|
18
|
+
// OpenSSH's "use THIS agent key" idiom, which the pool honours.
|
|
19
|
+
//
|
|
20
|
+
// ## OpenSSH semantics implemented
|
|
21
|
+
//
|
|
22
|
+
// • `Host a b` / `Host *.example.com` / `Host !x *.y` pattern lists, evaluated
|
|
23
|
+
// with `*` and `?` wildcards, case-insensitively (see hostBlockMatches).
|
|
24
|
+
// • first-obtained-value-wins ACROSS all matching blocks, not first-block-wins:
|
|
25
|
+
// ssh_config(5) — "for each parameter, the first obtained value will be used".
|
|
26
|
+
// • `Include` directives, globbed, expanded in place, relative to ~/.ssh
|
|
27
|
+
// (falling back to the including file's directory), depth- and cycle-limited.
|
|
28
|
+
// • backslash line continuations.
|
|
29
|
+
//
|
|
30
|
+
// The module is pure (I/O is injectable), so it is unit-testable without a real
|
|
31
|
+
// home directory — see test/sshconfig.test.js.
|
|
32
|
+
|
|
33
|
+
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync, chmodSync } from 'node:fs'
|
|
34
|
+
import { homedir, hostname as osHostname, userInfo } from 'node:os'
|
|
35
|
+
import path from 'node:path'
|
|
36
|
+
|
|
37
|
+
/** Default location of the user's ssh config. An env override exists so tests
|
|
38
|
+
* (and any deployment that isolates its home directory) can point the plugin
|
|
39
|
+
* at a substitute file instead of the real ~/.ssh/config. */
|
|
40
|
+
export const sshConfigPath = () => {
|
|
41
|
+
const env = process.env.DSH_REMOTE_SSH_CONFIG
|
|
42
|
+
if (env && String(env).trim()) return path.resolve(String(env).trim())
|
|
43
|
+
return path.join(homedir(), '.ssh', 'config')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Directory `Include` paths are resolved against (ssh_config(5)). */
|
|
47
|
+
export const sshConfigDir = () => {
|
|
48
|
+
const env = process.env.DSH_REMOTE_SSH_CONFIG
|
|
49
|
+
if (env && String(env).trim()) return path.dirname(path.resolve(String(env).trim()))
|
|
50
|
+
return path.join(homedir(), '.ssh')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parameters the resolver cares about, lowercase (as parsed). */
|
|
54
|
+
const KNOWN_PARAMS = new Set([
|
|
55
|
+
'hostname', 'user', 'port', 'identityfile', 'proxyjump', 'proxycommand', 'identitiesonly',
|
|
56
|
+
'identityagent',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
/** Read the user's ~/.ssh/config text, Includes expanded ('' when absent). */
|
|
60
|
+
export function readSshConfigText(filePath = sshConfigPath()) {
|
|
61
|
+
return loadSshConfigText(filePath)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read an ssh config file and expand its `Include` directives in place.
|
|
66
|
+
*
|
|
67
|
+
* Relative includes resolve against `~/.ssh` first (ssh_config(5)) and then
|
|
68
|
+
* against the including file's directory, because real-world configs use both.
|
|
69
|
+
* A missing or unreadable include is skipped: one broken line must not hide the
|
|
70
|
+
* rest of the config.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} [filePath] - the config file (default ~/.ssh/config).
|
|
73
|
+
* @param {object} [io] - injectable filesystem hooks (tests).
|
|
74
|
+
* @param {(p: string) => string} [io.readFile]
|
|
75
|
+
* @param {(p: string) => boolean} [io.exists]
|
|
76
|
+
* @param {(p: string) => string[]} [io.listDir]
|
|
77
|
+
* @param {number} [io.maxDepth]
|
|
78
|
+
* @returns {string} the expanded config text ('' when nothing could be read).
|
|
79
|
+
*/
|
|
80
|
+
export function loadSshConfigText(filePath = sshConfigPath(), io = {}) {
|
|
81
|
+
const readFile = io.readFile || ((p) => readFileSync(p, 'utf8'))
|
|
82
|
+
const listDir = io.listDir || ((p) => readdirSync(p))
|
|
83
|
+
return expandFile(filePath, { readFile, listDir, maxDepth: io.maxDepth ?? 3, depth: 0, seen: new Set() })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function expandFile(file, state) {
|
|
87
|
+
let text
|
|
88
|
+
try {
|
|
89
|
+
text = state.readFile(file)
|
|
90
|
+
} catch {
|
|
91
|
+
return ''
|
|
92
|
+
}
|
|
93
|
+
if (typeof text !== 'string') return ''
|
|
94
|
+
if (state.depth >= state.maxDepth) return text
|
|
95
|
+
const out = []
|
|
96
|
+
for (const line of joinContinuations(text)) {
|
|
97
|
+
const patterns = matchInclude(line)
|
|
98
|
+
if (!patterns) { out.push(line); continue }
|
|
99
|
+
for (const pattern of patterns) {
|
|
100
|
+
for (const included of expandIncludePath(pattern, path.dirname(file), state.listDir)) {
|
|
101
|
+
const key = path.resolve(included)
|
|
102
|
+
if (state.seen.has(key)) continue
|
|
103
|
+
state.seen.add(key)
|
|
104
|
+
const child = expandFile(included, { ...state, depth: state.depth + 1 })
|
|
105
|
+
if (child.trim()) out.push(child)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out.join('\n')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The value tokens of `Include <pattern> [pattern...]`, or null. */
|
|
113
|
+
function matchInclude(line) {
|
|
114
|
+
const m = String(line).match(/^\s*include\s+(.+?)\s*$/i)
|
|
115
|
+
if (!m) return null
|
|
116
|
+
return splitValue(m[1])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Expand one include pattern.
|
|
120
|
+
*
|
|
121
|
+
* ssh_config(5): a relative path is relative to `~/.ssh`. The including file's
|
|
122
|
+
* own directory is tried only when that yields nothing — which is what makes
|
|
123
|
+
* the plugin able to read a config the user pointed it at elsewhere, without
|
|
124
|
+
* double-resolving (and double-reading) the normal `~/.ssh` case.
|
|
125
|
+
*/
|
|
126
|
+
function expandIncludePath(pattern, callerDir, listDir) {
|
|
127
|
+
const raw = stripQuotes(pattern)
|
|
128
|
+
if (!raw) return []
|
|
129
|
+
if (path.isAbsolute(raw)) return globFiles(raw, listDir).sort()
|
|
130
|
+
if (raw.startsWith('~')) return globFiles(path.join(homedir(), raw.replace(/^~[\\/]?/, '')), listDir).sort()
|
|
131
|
+
const primary = globFiles(path.join(sshConfigDir(), raw), listDir)
|
|
132
|
+
if (primary.length) return primary.sort()
|
|
133
|
+
if (callerDir && path.resolve(callerDir) !== path.resolve(sshConfigDir())) {
|
|
134
|
+
return globFiles(path.join(callerDir, raw), listDir).sort()
|
|
135
|
+
}
|
|
136
|
+
return []
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Files matching a path whose basename may contain `*` / `?` (one level). */
|
|
140
|
+
function globFiles(target, listDir) {
|
|
141
|
+
const base = path.basename(target)
|
|
142
|
+
if (!/[*?]/.test(base)) return [target]
|
|
143
|
+
const dir = path.dirname(target)
|
|
144
|
+
let entries
|
|
145
|
+
try {
|
|
146
|
+
entries = listDir(dir)
|
|
147
|
+
} catch {
|
|
148
|
+
return []
|
|
149
|
+
}
|
|
150
|
+
const re = globRegex(base)
|
|
151
|
+
return entries
|
|
152
|
+
.filter((name) => re.test(name))
|
|
153
|
+
.map((name) => path.join(dir, name))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Join lines ending in a backslash (ssh config line continuation). */
|
|
157
|
+
function joinContinuations(text) {
|
|
158
|
+
const lines = []
|
|
159
|
+
let pending = ''
|
|
160
|
+
for (const raw of String(text).split('\n')) {
|
|
161
|
+
const line = pending + raw
|
|
162
|
+
if (/\\\s*$/.test(line) && !/\\\\\s*$/.test(line)) {
|
|
163
|
+
pending = line.replace(/\\\s*$/, ' ') + ' '
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
pending = ''
|
|
167
|
+
lines.push(line)
|
|
168
|
+
}
|
|
169
|
+
if (pending) lines.push(pending)
|
|
170
|
+
return lines
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Config lines with continuations joined — the form every parser consumes. */
|
|
174
|
+
const configLines = (text) => joinContinuations(text)
|
|
175
|
+
|
|
176
|
+
/** Split a parameter value into whitespace-separated tokens, honoring quotes. */
|
|
177
|
+
export function splitValue(value) {
|
|
178
|
+
return (String(value || '').match(/"[^"]*"|'[^']*'|\S+/g) || [])
|
|
179
|
+
.map(stripQuotes)
|
|
180
|
+
.filter((token) => token !== '')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const stripQuotes = (s) => String(s || '').replace(/^"(.*)"$/s, '$1').replace(/^'(.*)'$/s, '$1')
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parse OpenSSH config text into Host BLOCKS, preserving pattern lists.
|
|
187
|
+
* @param {string} text
|
|
188
|
+
* @returns {Array<{patterns: string[], values: Record<string, string|number>, line: number}>}
|
|
189
|
+
*/
|
|
190
|
+
export function parseHostBlocks(text) {
|
|
191
|
+
const blocks = []
|
|
192
|
+
let current = null
|
|
193
|
+
const lines = configLines(text)
|
|
194
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
195
|
+
const line = lines[i].trim()
|
|
196
|
+
if (!line || line.startsWith('#')) continue
|
|
197
|
+
const m = line.match(/^(\S+)\s+(.*)$/)
|
|
198
|
+
if (!m) continue
|
|
199
|
+
const key = m[1].toLowerCase()
|
|
200
|
+
const value = m[2].trim()
|
|
201
|
+
if (key === 'host') {
|
|
202
|
+
current = { patterns: splitValue(value), values: {}, line: i + 1 }
|
|
203
|
+
blocks.push(current)
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
if (!current || !KNOWN_PARAMS.has(key)) continue
|
|
207
|
+
// First value wins inside a block too (a repeated parameter is ignored).
|
|
208
|
+
if (Object.prototype.hasOwnProperty.call(current.values, key)) continue
|
|
209
|
+
current.values[key] = key === 'port' ? (Number(value) || value) : stripQuotes(value)
|
|
210
|
+
}
|
|
211
|
+
return blocks
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Legacy block list: one entry per `Host` line, flattened to the fields the
|
|
216
|
+
* import UI shows. Kept stable for the existing importer + tests.
|
|
217
|
+
* @returns {Array<{host: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string}>}
|
|
218
|
+
*/
|
|
219
|
+
export function parseSshConfig(text) {
|
|
220
|
+
const entries = []
|
|
221
|
+
let cur = null
|
|
222
|
+
for (const raw of configLines(text)) {
|
|
223
|
+
const line = raw.trim()
|
|
224
|
+
if (!line || line.startsWith('#')) continue
|
|
225
|
+
const m = line.match(/^(\S+)\s+(.*)$/)
|
|
226
|
+
if (!m) continue
|
|
227
|
+
const key = m[1].toLowerCase()
|
|
228
|
+
const val = m[2].trim()
|
|
229
|
+
if (key === 'host') {
|
|
230
|
+
cur = { host: stripQuotes(val), hostName: '', user: '', port: 22, identityFile: '', proxyJump: '' }
|
|
231
|
+
entries.push(cur)
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (!cur) continue
|
|
235
|
+
if (key === 'hostname' && !cur.hostName) cur.hostName = stripQuotes(val)
|
|
236
|
+
else if (key === 'user' && !cur.user) cur.user = stripQuotes(val)
|
|
237
|
+
else if (key === 'port' && cur.port === 22) cur.port = Number(val) || 22
|
|
238
|
+
else if (key === 'identityfile' && !cur.identityFile) cur.identityFile = stripQuotes(val)
|
|
239
|
+
else if (key === 'proxyjump' && !cur.proxyJump) cur.proxyJump = stripQuotes(val)
|
|
240
|
+
}
|
|
241
|
+
return entries
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Entries a user can actually import: concrete aliases (skip wildcards). */
|
|
245
|
+
export function importableEntries(text) {
|
|
246
|
+
return parseSshConfig(text).filter((e) => {
|
|
247
|
+
if (!e.host || e.host.includes('*') || e.host.includes('!') || e.host.includes(',')) return false
|
|
248
|
+
return e.hostName || e.user || e.port !== 22 || e.identityFile || e.proxyJump
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Every concrete (non-wildcard) alias the config defines, with the effective
|
|
254
|
+
* values after OpenSSH first-wins resolution — the list the import UI offers.
|
|
255
|
+
* @param {string} text
|
|
256
|
+
* @returns {Array<{alias: string, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string}>}
|
|
257
|
+
*/
|
|
258
|
+
export function listAliases(text) {
|
|
259
|
+
const seen = new Set()
|
|
260
|
+
const out = []
|
|
261
|
+
for (const block of parseHostBlocks(text)) {
|
|
262
|
+
for (const pattern of block.patterns) {
|
|
263
|
+
if (!pattern || /[*?!]/.test(pattern) || seen.has(pattern)) continue
|
|
264
|
+
seen.add(pattern)
|
|
265
|
+
out.push(resolveSshHost(pattern, text))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return out.sort((a, b) => compareText(a.alias, b.alias))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Glob match for ssh host patterns (`*`, `?`), case-insensitive. */
|
|
272
|
+
export function globMatch(pattern, value) {
|
|
273
|
+
return globRegex(pattern).test(String(value || ''))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function globRegex(pattern) {
|
|
277
|
+
let re = ''
|
|
278
|
+
for (const ch of String(pattern || '')) {
|
|
279
|
+
if (ch === '*') re += '.*'
|
|
280
|
+
else if (ch === '?') re += '.'
|
|
281
|
+
else re += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
282
|
+
}
|
|
283
|
+
return new RegExp(`^${re}$`, 'i')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether a `Host` pattern list applies to a name: any positive pattern matches
|
|
288
|
+
* AND no negated (`!`) pattern does.
|
|
289
|
+
* @param {string[]|string} patterns
|
|
290
|
+
* @param {string} name
|
|
291
|
+
*/
|
|
292
|
+
export function hostBlockMatches(patterns, name) {
|
|
293
|
+
const list = Array.isArray(patterns) ? patterns : splitValue(patterns)
|
|
294
|
+
const negative = list.filter((p) => p.startsWith('!')).map((p) => p.slice(1))
|
|
295
|
+
const positive = list.filter((p) => !p.startsWith('!'))
|
|
296
|
+
if (negative.some((p) => globMatch(p, name))) return false
|
|
297
|
+
return positive.some((p) => globMatch(p, name))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Effective configuration for one alias, with OpenSSH first-wins semantics.
|
|
302
|
+
*
|
|
303
|
+
* `hostName` falls back to the alias itself (ssh's own behaviour), so a block
|
|
304
|
+
* that only sets `User`/`IdentityFile` still yields a connectable host.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} alias
|
|
307
|
+
* @param {string} text - FULL config text (Include-expanded).
|
|
308
|
+
* @returns {{alias: string, matched: boolean, hostName: string, user: string, port: number, identityFile: string, proxyJump: string, proxyCommand: string, identitiesOnly: string, identityAgent: string}}
|
|
309
|
+
*/
|
|
310
|
+
export function resolveSshHost(alias, text) {
|
|
311
|
+
const name = String(alias || '').trim()
|
|
312
|
+
const out = {
|
|
313
|
+
alias: name,
|
|
314
|
+
matched: false,
|
|
315
|
+
hostName: '',
|
|
316
|
+
user: '',
|
|
317
|
+
port: 0,
|
|
318
|
+
identityFile: '',
|
|
319
|
+
proxyJump: '',
|
|
320
|
+
proxyCommand: '',
|
|
321
|
+
identitiesOnly: '',
|
|
322
|
+
identityAgent: '',
|
|
323
|
+
}
|
|
324
|
+
if (!name) return out
|
|
325
|
+
for (const block of parseHostBlocks(text)) {
|
|
326
|
+
if (!hostBlockMatches(block.patterns, name)) continue
|
|
327
|
+
out.matched = true
|
|
328
|
+
// first obtained value wins, ACROSS blocks in file order
|
|
329
|
+
for (const [key, value] of Object.entries(block.values)) {
|
|
330
|
+
if (key === 'hostname') { if (!out.hostName) out.hostName = String(value) }
|
|
331
|
+
else if (key === 'user') { if (!out.user) out.user = String(value) }
|
|
332
|
+
else if (key === 'port') { if (!out.port) out.port = Number(value) || 0 }
|
|
333
|
+
else if (key === 'identityfile') { if (!out.identityFile) out.identityFile = String(value) }
|
|
334
|
+
else if (key === 'proxyjump') { if (!out.proxyJump) out.proxyJump = String(value) }
|
|
335
|
+
else if (key === 'proxycommand') { if (!out.proxyCommand) out.proxyCommand = String(value) }
|
|
336
|
+
else if (key === 'identitiesonly') { if (!out.identitiesOnly) out.identitiesOnly = String(value) }
|
|
337
|
+
else if (key === 'identityagent') { if (!out.identityAgent) out.identityAgent = String(value) }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!out.hostName) out.hostName = name
|
|
341
|
+
return out
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve an alias from a config FILE (Include-expanded) or from given text.
|
|
346
|
+
* @param {string} alias
|
|
347
|
+
* @param {{file?: string, text?: string, io?: object}} [opts]
|
|
348
|
+
*/
|
|
349
|
+
export function resolveSshAlias(alias, opts = {}) {
|
|
350
|
+
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
351
|
+
return resolveSshHost(alias, text)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Expand a leading `~` in an ssh config path (IdentityFile). */
|
|
355
|
+
export function expandHome(p) {
|
|
356
|
+
const s = String(p || '')
|
|
357
|
+
if (!s.startsWith('~')) return s
|
|
358
|
+
if (s === '~') return homedir()
|
|
359
|
+
if (s.startsWith('~/') || s.startsWith('~\\')) return path.join(homedir(), s.slice(2))
|
|
360
|
+
return s
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Expand ssh_config(5) TOKENS and `${VAR}` environment references in an
|
|
365
|
+
* IdentityFile / IdentityAgent value: `%%` `%d` `%h` `%n` `%p` `%r` `%u` `%i`
|
|
366
|
+
* `%l` `%L`. A config like `IdentityFile ~/.ssh/%n.pub` (one key per alias)
|
|
367
|
+
* used to be read as a file literally named `%n.pub`. Unknown tokens are left
|
|
368
|
+
* as-is, so the resulting "file not found" names what was not understood.
|
|
369
|
+
*
|
|
370
|
+
* @param {string} value
|
|
371
|
+
* @param {{alias?: string, host?: string, port?: number, user?: string}} ctx
|
|
372
|
+
*/
|
|
373
|
+
export function expandSshTokens(value, ctx = {}) {
|
|
374
|
+
const s = String(value || '')
|
|
375
|
+
let localUser = ''
|
|
376
|
+
try { localUser = userInfo().username } catch {}
|
|
377
|
+
const longHost = (() => { try { return osHostname() } catch { return '' } })()
|
|
378
|
+
const map = {
|
|
379
|
+
'%': '%',
|
|
380
|
+
d: homedir(),
|
|
381
|
+
h: String(ctx.host || ctx.alias || ''),
|
|
382
|
+
n: String(ctx.alias || ctx.host || ''),
|
|
383
|
+
p: String(Number(ctx.port) || 22),
|
|
384
|
+
r: String(ctx.user || localUser),
|
|
385
|
+
u: localUser,
|
|
386
|
+
i: (() => { try { return String(process.getuid ? process.getuid() : '') } catch { return '' } })(),
|
|
387
|
+
l: longHost.split('.')[0],
|
|
388
|
+
L: longHost.split('.')[0],
|
|
389
|
+
}
|
|
390
|
+
return s
|
|
391
|
+
.replace(/%([%a-zA-Z])/g, (m, t) => (Object.prototype.hasOwnProperty.call(map, t) ? map[t] : m))
|
|
392
|
+
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, v) => (process.env[v] !== undefined ? process.env[v] : m))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** An IdentityFile path, tokens and `~` expanded. */
|
|
396
|
+
function identityPath(value, ctx) {
|
|
397
|
+
return value ? expandHome(expandSshTokens(value, ctx)) : ''
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The agent socket an `IdentityAgent` value selects. `''` means "the process
|
|
402
|
+
* SSH_AUTH_SOCK" (also what `SSH_AUTH_SOCK` itself and an unset value mean);
|
|
403
|
+
* `none` disables the agent and is returned as the literal `'none'`.
|
|
404
|
+
*/
|
|
405
|
+
function identityAgentPath(value, ctx) {
|
|
406
|
+
const v = String(value || '').trim()
|
|
407
|
+
if (!v || v === 'SSH_AUTH_SOCK') return ''
|
|
408
|
+
if (/^none$/i.test(v)) return 'none'
|
|
409
|
+
// `$VAR` (no braces) names an environment variable holding the socket path.
|
|
410
|
+
if (/^\$[A-Za-z_][A-Za-z0-9_]*$/.test(v)) return process.env[v.slice(1)] || ''
|
|
411
|
+
return expandHome(expandSshTokens(v, ctx))
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Split a ProxyJump value into hops: `[user@]host[:port]`, comma-separated, and
|
|
416
|
+
* `none` meaning "no jump".
|
|
417
|
+
* @param {string} value
|
|
418
|
+
* @returns {Array<{user: string, host: string, port: number}>}
|
|
419
|
+
*/
|
|
420
|
+
export function parseProxyJump(value) {
|
|
421
|
+
const raw = String(value || '').trim()
|
|
422
|
+
if (!raw || /^none$/i.test(raw)) return []
|
|
423
|
+
return raw.split(',').map((hop) => hop.trim()).filter(Boolean).map((hop) => {
|
|
424
|
+
const m = hop.match(/^(?:([^@]+)@)?([^:[\] ]+)(?::(\d+))?$/)
|
|
425
|
+
if (!m) return { user: '', host: hop, port: 0 }
|
|
426
|
+
return { user: m[1] || '', host: m[2], port: Number(m[3]) || 0 }
|
|
427
|
+
})
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Shared proxy/warning shaping for alias and literal resolutions (issue #50).
|
|
432
|
+
*
|
|
433
|
+
* `ProxyJump` maps onto the plugin's single jump host. A multi-hop chain cannot
|
|
434
|
+
* be represented by one `proxy`, so the FIRST hop is used and `warnings` says so
|
|
435
|
+
* — silently connecting through the wrong path would be worse. `ProxyCommand`
|
|
436
|
+
* (an arbitrary local command) is reported but never executed.
|
|
437
|
+
*/
|
|
438
|
+
function targetFromResolved(resolved, text, { defaultPort }) {
|
|
439
|
+
const warnings = []
|
|
440
|
+
const ctx = {
|
|
441
|
+
alias: resolved.alias,
|
|
442
|
+
host: resolved.hostName || resolved.alias,
|
|
443
|
+
port: resolved.port || defaultPort || 22,
|
|
444
|
+
user: resolved.user,
|
|
445
|
+
}
|
|
446
|
+
let proxy
|
|
447
|
+
const hops = parseProxyJump(resolved.proxyJump)
|
|
448
|
+
if (hops.length > 1) {
|
|
449
|
+
warnings.push(`ProxyJump has ${hops.length} hops; dsh-remote supports one jump host — using the first (${hops[0].host}).`)
|
|
450
|
+
}
|
|
451
|
+
if (hops.length) {
|
|
452
|
+
const hop = hops[0]
|
|
453
|
+
const hopResolved = resolveSshHost(hop.host, text)
|
|
454
|
+
proxy = {
|
|
455
|
+
host: hopResolved.hostName || hop.host,
|
|
456
|
+
port: hop.port || hopResolved.port || 22,
|
|
457
|
+
username: hop.user || hopResolved.user || '',
|
|
458
|
+
privateKeyPath: identityPath(hopResolved.identityFile, {
|
|
459
|
+
alias: hop.host, host: hopResolved.hostName || hop.host,
|
|
460
|
+
port: hop.port || hopResolved.port, user: hop.user || hopResolved.user,
|
|
461
|
+
}),
|
|
462
|
+
passphrase: '',
|
|
463
|
+
password: '',
|
|
464
|
+
}
|
|
465
|
+
// Only when set, so a hop without them keeps its established shape.
|
|
466
|
+
const hopAgent = identityAgentPath(hopResolved.identityAgent, { alias: hop.host, host: hopResolved.hostName || hop.host })
|
|
467
|
+
if (hopAgent) proxy.agentSocket = hopAgent
|
|
468
|
+
if (/^yes$/i.test(hopResolved.identitiesOnly || '')) proxy.identitiesOnly = true
|
|
469
|
+
} else if (resolved.proxyCommand) {
|
|
470
|
+
warnings.push('ProxyCommand is not supported by dsh-remote (no jump host was applied).')
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
alias: resolved.alias,
|
|
474
|
+
matched: resolved.matched,
|
|
475
|
+
host: resolved.hostName || resolved.alias,
|
|
476
|
+
port: resolved.port || defaultPort,
|
|
477
|
+
username: resolved.user || '',
|
|
478
|
+
privateKeyPath: identityPath(resolved.identityFile, ctx),
|
|
479
|
+
agentSocket: identityAgentPath(resolved.identityAgent, ctx),
|
|
480
|
+
identitiesOnly: /^yes$/i.test(resolved.identitiesOnly || ''),
|
|
481
|
+
proxy,
|
|
482
|
+
proxyJump: resolved.proxyJump,
|
|
483
|
+
warnings,
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Connect target for an ssh-config alias: the shape the plugin's pool expects.
|
|
489
|
+
*
|
|
490
|
+
* @param {string} alias
|
|
491
|
+
* @param {object} [opts]
|
|
492
|
+
* @param {string} [opts.text] - config text (else the real file is read).
|
|
493
|
+
* @param {string} [opts.file]
|
|
494
|
+
* @param {object} [opts.io]
|
|
495
|
+
* @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
|
|
496
|
+
*/
|
|
497
|
+
export function sshAliasTarget(alias, opts = {}) {
|
|
498
|
+
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
499
|
+
return targetFromResolved(resolveSshHost(alias, text), text, { defaultPort: 22 })
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Effective connect identity for a LITERAL machine (issue #50).
|
|
504
|
+
*
|
|
505
|
+
* A machine saved with literal host/user/port/key holds a COPY of what may also
|
|
506
|
+
* be written in ~/.ssh/config, and the copy goes stale the moment the file is
|
|
507
|
+
* edited. The file is the single source for any host it NAMES: when a Host
|
|
508
|
+
* block matches the machine's literal host — with at least one positive pattern
|
|
509
|
+
* that is not the bare `*` — the resolved values win per field and the record
|
|
510
|
+
* fills the gaps. A bare `Host *` block deliberately does NOT name a host:
|
|
511
|
+
* treating universal defaults as an override would hijack every saved machine
|
|
512
|
+
* at once. Group patterns (`*.corp`, `web-?`) DO name hosts, exactly as ssh
|
|
513
|
+
* itself applies them to a literal dial.
|
|
514
|
+
*
|
|
515
|
+
* `port` is 0 (not 22) when the file says nothing, so the caller falls back to
|
|
516
|
+
* the record instead of the default.
|
|
517
|
+
*
|
|
518
|
+
* @param {object} machine - a registry record with a literal `host`.
|
|
519
|
+
* @param {{text?: string, file?: string, io?: object}} [opts]
|
|
520
|
+
* @returns {null | {alias: string, matched: true, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, proxyJump: string, warnings: string[]}}
|
|
521
|
+
* null when no Host block names the machine's host.
|
|
522
|
+
*/
|
|
523
|
+
export function resolveLiteralSshConfig(machine, opts = {}) {
|
|
524
|
+
if (!machine || machine.useSshConfig) return null
|
|
525
|
+
const name = String(machine.host || '').trim()
|
|
526
|
+
if (!name) return null
|
|
527
|
+
const text = opts.text !== undefined ? String(opts.text) : loadSshConfigText(opts.file || sshConfigPath(), opts.io || {})
|
|
528
|
+
const resolved = {
|
|
529
|
+
alias: name, matched: false, hostName: '', user: '', port: 0,
|
|
530
|
+
identityFile: '', proxyJump: '', proxyCommand: '', identitiesOnly: '', identityAgent: '',
|
|
531
|
+
}
|
|
532
|
+
for (const block of parseHostBlocks(text)) {
|
|
533
|
+
if (!hostBlockMatches(block.patterns, name)) continue
|
|
534
|
+
const positive = block.patterns.filter((p) => !p.startsWith('!'))
|
|
535
|
+
// A block whose only positive pattern is the bare `*` is a defaults block,
|
|
536
|
+
// not a block that names this host.
|
|
537
|
+
if (positive.length && positive.every((p) => p === '*')) continue
|
|
538
|
+
resolved.matched = true
|
|
539
|
+
for (const [key, value] of Object.entries(block.values)) {
|
|
540
|
+
if (key === 'hostname') { if (!resolved.hostName) resolved.hostName = String(value) }
|
|
541
|
+
else if (key === 'user') { if (!resolved.user) resolved.user = String(value) }
|
|
542
|
+
else if (key === 'port') { if (!resolved.port) resolved.port = Number(value) || 0 }
|
|
543
|
+
else if (key === 'identityfile') { if (!resolved.identityFile) resolved.identityFile = String(value) }
|
|
544
|
+
else if (key === 'proxyjump') { if (!resolved.proxyJump) resolved.proxyJump = String(value) }
|
|
545
|
+
else if (key === 'proxycommand') { if (!resolved.proxyCommand) resolved.proxyCommand = String(value) }
|
|
546
|
+
else if (key === 'identitiesonly') { if (!resolved.identitiesOnly) resolved.identitiesOnly = String(value) }
|
|
547
|
+
else if (key === 'identityagent') { if (!resolved.identityAgent) resolved.identityAgent = String(value) }
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
if (!resolved.matched) return null
|
|
551
|
+
return targetFromResolved(resolved, text, { defaultPort: 0 })
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Effective connect identity for a saved machine.
|
|
556
|
+
*
|
|
557
|
+
* A machine in ALIAS mode (`useSshConfig: true`, `host` = the alias) inherits
|
|
558
|
+
* everything from ~/.ssh/config; a field the user filled in explicitly still
|
|
559
|
+
* wins. "Explicitly" cannot be distinguished from "form default" for the two
|
|
560
|
+
* fields that have defaults (port 22, user `root`), so those inherit unless they
|
|
561
|
+
* differ from the default — stated in the settings UI.
|
|
562
|
+
*
|
|
563
|
+
* @param {object} machine
|
|
564
|
+
* @param {{text?: string, file?: string, io?: object}} [opts]
|
|
565
|
+
* @returns {{alias: string, matched: boolean, host: string, port: number, username: string, privateKeyPath: string, proxy: object|undefined, warnings: string[]}|null}
|
|
566
|
+
* null when the machine is not in alias mode.
|
|
567
|
+
*/
|
|
568
|
+
export function resolveMachineSshConfig(machine, opts = {}) {
|
|
569
|
+
if (!machine || !machine.useSshConfig) return null
|
|
570
|
+
const alias = String(machine.host || '').trim()
|
|
571
|
+
if (!alias) return null
|
|
572
|
+
const target = sshAliasTarget(alias, opts)
|
|
573
|
+
const warnings = [...target.warnings]
|
|
574
|
+
if (!target.matched) warnings.push(`no ~/.ssh/config block matches "${alias}" — using the name itself.`)
|
|
575
|
+
const port = machine.port && Number(machine.port) !== 22 ? Number(machine.port) : target.port
|
|
576
|
+
const username = machine.username && machine.username !== 'root' ? String(machine.username) : (target.username || machine.username || '')
|
|
577
|
+
const privateKeyPath = machine.privateKeyPath ? String(machine.privateKeyPath) : target.privateKeyPath
|
|
578
|
+
const proxy = machine.proxy && machine.proxy.host ? machine.proxy : target.proxy
|
|
579
|
+
return {
|
|
580
|
+
alias,
|
|
581
|
+
matched: target.matched,
|
|
582
|
+
host: target.host,
|
|
583
|
+
port: Number(port) || 22,
|
|
584
|
+
username: String(username || ''),
|
|
585
|
+
privateKeyPath,
|
|
586
|
+
agentSocket: target.agentSocket,
|
|
587
|
+
identitiesOnly: target.identitiesOnly,
|
|
588
|
+
proxy,
|
|
589
|
+
warnings,
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function compareText(left, right) {
|
|
594
|
+
return left < right ? -1 : left > right ? 1 : 0
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ── Writing: the managed Host block (issue #50) ─────────────────────────────
|
|
598
|
+
//
|
|
599
|
+
// The four connection fields (host/user/port/key) have ONE home: ~/.ssh/config.
|
|
600
|
+
// When the plugin saves a literal machine it upserts a Host block tagged with
|
|
601
|
+
// MANAGED_MARK, so the file and the registry can never drift — and so the
|
|
602
|
+
// plugin can tell ITS OWN block apart from one the user wrote. Two rules keep
|
|
603
|
+
// the file safe:
|
|
604
|
+
// • a Host block without the marker is never touched — a same-named user
|
|
605
|
+
// block is a CONFLICT, reported to the caller instead of overwritten;
|
|
606
|
+
// • inside a managed block, lines the plugin does not own (a user-added
|
|
607
|
+
// `ForwardAgent yes`, a comment) survive an update — only the four known
|
|
608
|
+
// parameters are rewritten.
|
|
609
|
+
|
|
610
|
+
/** Marker comment that tags a Host block as owned by dsh-remote. */
|
|
611
|
+
export const MANAGED_MARK = '# dsh-remote: managed'
|
|
612
|
+
|
|
613
|
+
/** Parameters the managed block rewrites (lowercase). */
|
|
614
|
+
const MANAGED_PARAMS = new Set(['hostname', 'user', 'port', 'identityfile'])
|
|
615
|
+
|
|
616
|
+
/** Read the RAW config text — no Include expansion (a write targets ONE file). */
|
|
617
|
+
export function readRawSshConfig(filePath = sshConfigPath()) {
|
|
618
|
+
try {
|
|
619
|
+
const text = readFileSync(filePath, 'utf8')
|
|
620
|
+
return typeof text === 'string' ? text : ''
|
|
621
|
+
} catch {
|
|
622
|
+
return ''
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Quote one value for a config line when it contains whitespace. */
|
|
627
|
+
function quoteCfg(value) {
|
|
628
|
+
const s = String(value)
|
|
629
|
+
return /[\s#]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** The values a managed block must carry for these fields ('' = omit). */
|
|
633
|
+
function desiredValues(fields) {
|
|
634
|
+
const alias = String(fields.alias || '').trim()
|
|
635
|
+
const hostName = String(fields.hostName || '').trim()
|
|
636
|
+
const port = Number(fields.port) || 0
|
|
637
|
+
return {
|
|
638
|
+
hostname: hostName && hostName !== alias ? hostName : '',
|
|
639
|
+
user: String(fields.user || '').trim(),
|
|
640
|
+
port: port && port !== 22 ? port : '',
|
|
641
|
+
identityfile: String(fields.identityFile || '').trim(),
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** Render the owned body lines of a managed block. */
|
|
646
|
+
function desiredLines(fields) {
|
|
647
|
+
const want = desiredValues(fields)
|
|
648
|
+
const out = []
|
|
649
|
+
if (want.hostname) out.push(` HostName ${quoteCfg(want.hostname)}`)
|
|
650
|
+
if (want.user) out.push(` User ${quoteCfg(want.user)}`)
|
|
651
|
+
if (want.port) out.push(` Port ${want.port}`)
|
|
652
|
+
if (want.identityfile) out.push(` IdentityFile ${quoteCfg(want.identityfile)}`)
|
|
653
|
+
return out
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Locate the MANAGED Host block for `alias` in raw config lines.
|
|
658
|
+
* The marker must be the nearest non-blank line above the Host line; a block
|
|
659
|
+
* that names the alias without the marker is the user's, not ours.
|
|
660
|
+
*/
|
|
661
|
+
function locateManagedBlock(lines, alias) {
|
|
662
|
+
const wanted = alias.toLowerCase()
|
|
663
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
664
|
+
const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
|
|
665
|
+
if (!m) continue
|
|
666
|
+
const names = splitValue(m[1]).map((p) => p.toLowerCase())
|
|
667
|
+
if (!names.includes(wanted)) continue
|
|
668
|
+
let j = i - 1
|
|
669
|
+
while (j >= 0 && lines[j].trim() === '') j -= 1
|
|
670
|
+
if (j < 0 || lines[j].trim().toLowerCase() !== MANAGED_MARK) continue
|
|
671
|
+
let end = lines.length
|
|
672
|
+
for (let k = i + 1; k < lines.length; k += 1) {
|
|
673
|
+
if (/^\s*(host|match)\s/i.test(lines[k])) { end = k; break }
|
|
674
|
+
}
|
|
675
|
+
return { markerLine: j, hostLine: i, end }
|
|
676
|
+
}
|
|
677
|
+
return null
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Whether an UNMANAGED Host block names `alias` (exact token, case-insensitive). */
|
|
681
|
+
function hasUnmanagedConflict(lines, alias) {
|
|
682
|
+
const wanted = alias.toLowerCase()
|
|
683
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
684
|
+
const m = lines[i].match(/^\s*host\s+(.+?)\s*$/i)
|
|
685
|
+
if (!m) continue
|
|
686
|
+
const names = splitValue(m[1]).map((p) => p.toLowerCase())
|
|
687
|
+
if (!names.includes(wanted)) continue
|
|
688
|
+
let j = i - 1
|
|
689
|
+
while (j >= 0 && lines[j].trim() === '') j -= 1
|
|
690
|
+
if (j >= 0 && lines[j].trim().toLowerCase() === MANAGED_MARK) continue // ours
|
|
691
|
+
return true
|
|
692
|
+
}
|
|
693
|
+
return false
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Insert or refresh the managed Host block for a saved machine.
|
|
698
|
+
*
|
|
699
|
+
* @param {string} rawText - the RAW ~/.ssh/config text ('' when absent).
|
|
700
|
+
* @param {{alias: string, hostName?: string, user?: string, port?: number|string, identityFile?: string}} fields
|
|
701
|
+
* @returns {{status: 'appended'|'updated'|'unchanged'|'conflict'|'invalid', text: string}}
|
|
702
|
+
* 'conflict' — an unmanaged block already names the alias (text untouched);
|
|
703
|
+
* 'invalid' — the alias can never be a safe Host token (space/wildcard).
|
|
704
|
+
*/
|
|
705
|
+
export function upsertManagedHostBlock(rawText, fields) {
|
|
706
|
+
const text = String(rawText || '')
|
|
707
|
+
const alias = String((fields && fields.alias) || '').trim()
|
|
708
|
+
if (!alias || /[\s*?!,]/.test(alias)) return { status: 'invalid', text }
|
|
709
|
+
const lines = text.split('\n')
|
|
710
|
+
if (hasUnmanagedConflict(lines, alias)) return { status: 'conflict', text }
|
|
711
|
+
|
|
712
|
+
const want = desiredValues({ ...fields, alias })
|
|
713
|
+
const found = locateManagedBlock(lines, alias)
|
|
714
|
+
if (!found) {
|
|
715
|
+
const block = [MANAGED_MARK, `Host ${alias}`, ...desiredLines({ ...fields, alias })]
|
|
716
|
+
const trimmedEnd = text.replace(/\s+$/, '')
|
|
717
|
+
const prefix = trimmedEnd === '' ? '' : trimmedEnd + '\n\n'
|
|
718
|
+
return { status: 'appended', text: prefix + block.join('\n') + '\n' }
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Compare what the block HAS with what it MUST have; a rewrite that changes
|
|
722
|
+
// nothing would only cost the file's mtime.
|
|
723
|
+
const current = parseHostBlocks(lines.slice(found.hostLine, found.end).join('\n'))[0]
|
|
724
|
+
const cur = (current && current.values) || {}
|
|
725
|
+
const same = ['hostname', 'user', 'port', 'identityfile']
|
|
726
|
+
.every((k) => String(cur[k] ?? '') === String(want[k] ?? ''))
|
|
727
|
+
if (same) return { status: 'unchanged', text }
|
|
728
|
+
|
|
729
|
+
// Rewrite only the four owned parameters; user lines inside OUR block stay.
|
|
730
|
+
const body = lines.slice(found.hostLine + 1, found.end)
|
|
731
|
+
const kept = body.filter((l) => {
|
|
732
|
+
const m = l.match(/^\s*(\S+)\s/)
|
|
733
|
+
return !m || !MANAGED_PARAMS.has(m[1].toLowerCase())
|
|
734
|
+
})
|
|
735
|
+
const next = [
|
|
736
|
+
...lines.slice(0, found.hostLine + 1),
|
|
737
|
+
...desiredLines({ ...fields, alias }),
|
|
738
|
+
...kept,
|
|
739
|
+
...lines.slice(found.end),
|
|
740
|
+
]
|
|
741
|
+
return { status: 'updated', text: next.join('\n') }
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Remove the managed block for `alias` (used when a machine is RENAMED away:
|
|
746
|
+
* the old block is the plugin's own artifact of that edit). Deleting a machine
|
|
747
|
+
* deliberately does NOT call this — the Host block is the user's ssh access,
|
|
748
|
+
* not plugin state. Unmanaged blocks are never removed.
|
|
749
|
+
*
|
|
750
|
+
* @returns {{status: 'removed'|'absent', text: string}}
|
|
751
|
+
*/
|
|
752
|
+
export function removeManagedHostBlock(rawText, alias) {
|
|
753
|
+
const text = String(rawText || '')
|
|
754
|
+
const name = String(alias || '').trim()
|
|
755
|
+
if (!name) return { status: 'absent', text }
|
|
756
|
+
const lines = text.split('\n')
|
|
757
|
+
const found = locateManagedBlock(lines, name)
|
|
758
|
+
if (!found) return { status: 'absent', text }
|
|
759
|
+
let start = found.markerLine
|
|
760
|
+
while (start > 0 && lines[start - 1].trim() === '') start -= 1
|
|
761
|
+
const next = [...lines.slice(0, start), ...lines.slice(found.end)]
|
|
762
|
+
return { status: 'removed', text: next.join('\n') }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Persist a config text, creating ~/.ssh first. Permissions follow OpenSSH's
|
|
767
|
+
* expectations on POSIX (dir 0700, file 0600) and are a no-op elsewhere.
|
|
768
|
+
*/
|
|
769
|
+
export function writeSshConfig(text, filePath = sshConfigPath(), io = {}) {
|
|
770
|
+
const mkdir = io.mkdir || ((p) => mkdirSync(p, { recursive: true }))
|
|
771
|
+
const write = io.write || ((p, data) => writeFileSync(p, data, 'utf8'))
|
|
772
|
+
const chmod = io.chmod || ((p, mode) => { try { chmodSync(p, mode) } catch { /* no modes here */ } })
|
|
773
|
+
mkdir(path.dirname(filePath))
|
|
774
|
+
write(filePath, String(text))
|
|
775
|
+
if (process.platform !== 'win32') {
|
|
776
|
+
chmod(path.dirname(filePath), 0o700)
|
|
777
|
+
chmod(filePath, 0o600)
|
|
778
|
+
}
|
|
779
|
+
}
|