dsh-ssh-tunnel 1.0.0 → 1.0.2
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/CHANGELOG.md +68 -0
- package/README.md +80 -38
- package/README_en.md +80 -38
- package/cordis.patch.yml +4 -4
- package/icon.svg +26 -0
- package/lib/client.js +1643 -1472
- package/lib/index.js +1739 -1611
- package/lib/session.js +616 -525
- package/lib/shared/args.js +23 -0
- package/lib/shared/host-key.js +32 -32
- package/lib/shared/host-summary.js +75 -75
- package/lib/shared/http-trust.js +59 -59
- package/lib/shared/path.js +21 -22
- package/lib/shared/persist.js +41 -41
- package/lib/shared/session-auth.js +47 -39
- package/lib/shared/session-policy.js +151 -125
- package/lib/shared/shell-buffer.js +10 -10
- package/lib/shared/vendor.js +49 -49
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +91 -84
- package/scripts/install.ps1 +191 -93
- package/scripts/install.sh +278 -226
- package/scripts/lib/bundle-check.cjs +108 -0
- package/scripts/lib/strip-mount.cjs +114 -0
- package/scripts/lib/ws-exclude.cjs +143 -0
- package/scripts/smoke-test.mjs +899 -511
- package/scripts/sync-to-dsh.sh +87 -5
package/lib/session.js
CHANGED
|
@@ -1,525 +1,616 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory SSH session registry: live / tombstone / close.
|
|
3
|
-
* Transport connect (host-key prompts) is injected as connectClient.
|
|
4
|
-
*/
|
|
5
|
-
import { randomUUID } from 'node:crypto'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
SSH_EXEC_DEFAULT_TIMEOUT_MS,
|
|
10
|
-
SSH_READY_TIMEOUT_MS,
|
|
11
|
-
SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
12
|
-
SSH_RECONNECT_DELAYS_MS,
|
|
13
|
-
SSH_RECONNECT_MAX_ATTEMPTS,
|
|
14
|
-
SSH_SFTP_META_TIMEOUT_MS,
|
|
15
|
-
SSH_SHELL_OPEN_TIMEOUT_MS,
|
|
16
|
-
SSH_STATUS_CONNECTED,
|
|
17
|
-
SSH_STATUS_DISCONNECTED,
|
|
18
|
-
SSH_STATUS_RECONNECTING,
|
|
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
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
rec.
|
|
53
|
-
rec
|
|
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
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
rec
|
|
262
|
-
rec.sftp =
|
|
263
|
-
rec.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
rec.
|
|
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
|
-
if (rec.closing
|
|
328
|
-
|
|
329
|
-
if (
|
|
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
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
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
|
-
|
|
1
|
+
/**
|
|
2
|
+
* In-memory SSH session registry: live / tombstone / close.
|
|
3
|
+
* Transport connect (host-key prompts) is injected as connectClient.
|
|
4
|
+
*/
|
|
5
|
+
import { randomUUID } from 'node:crypto'
|
|
6
|
+
import { normalizeProjectKey } from './shared/path.js'
|
|
7
|
+
import {
|
|
8
|
+
MAX_BYTES_EXEC_DEFAULT,
|
|
9
|
+
SSH_EXEC_DEFAULT_TIMEOUT_MS,
|
|
10
|
+
SSH_READY_TIMEOUT_MS,
|
|
11
|
+
SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
12
|
+
SSH_RECONNECT_DELAYS_MS,
|
|
13
|
+
SSH_RECONNECT_MAX_ATTEMPTS,
|
|
14
|
+
SSH_SFTP_META_TIMEOUT_MS,
|
|
15
|
+
SSH_SHELL_OPEN_TIMEOUT_MS,
|
|
16
|
+
SSH_STATUS_CONNECTED,
|
|
17
|
+
SSH_STATUS_DISCONNECTED,
|
|
18
|
+
SSH_STATUS_RECONNECTING,
|
|
19
|
+
clampMaxBytes,
|
|
20
|
+
clampTimeoutMs,
|
|
21
|
+
findReusableLive,
|
|
22
|
+
hostKeyPromptMessage,
|
|
23
|
+
isLiveStatus,
|
|
24
|
+
kbiReconnectError,
|
|
25
|
+
kbiUnsupportedError,
|
|
26
|
+
sshError,
|
|
27
|
+
withTimeout,
|
|
28
|
+
} from './shared/session-policy.js'
|
|
29
|
+
|
|
30
|
+
const MAX_OUT = 512 * 1024
|
|
31
|
+
// Per-shellRead response cap. A client that falls behind reads in bounded
|
|
32
|
+
// slices and keeps advancing its cursor instead of one unbounded dump.
|
|
33
|
+
const SHELL_READ_CHUNK_MAX = 256 * 1024
|
|
34
|
+
|
|
35
|
+
async function openSftp(client, opts = {}) {
|
|
36
|
+
return withTimeout(
|
|
37
|
+
new Promise((resolve, reject) => {
|
|
38
|
+
client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)))
|
|
39
|
+
}),
|
|
40
|
+
opts.timeoutMs ?? SSH_SFTP_META_TIMEOUT_MS,
|
|
41
|
+
{ signal: opts.signal },
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function applyStatus(rec, status) {
|
|
46
|
+
rec.status = status
|
|
47
|
+
rec.running = status === SSH_STATUS_CONNECTED
|
|
48
|
+
rec.updatedAt = Date.now()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function tombstone(rec) {
|
|
52
|
+
if (rec.closing) return
|
|
53
|
+
applyStatus(rec, SSH_STATUS_DISCONNECTED)
|
|
54
|
+
try {
|
|
55
|
+
rec.shellStream?.close()
|
|
56
|
+
} catch {}
|
|
57
|
+
rec.shellStream = null
|
|
58
|
+
rec.sftp = null
|
|
59
|
+
rec.client = null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function attachTransport(rec, client, onDrop) {
|
|
63
|
+
const handle = () => {
|
|
64
|
+
if (rec.closing) return
|
|
65
|
+
if (rec.client !== client) return
|
|
66
|
+
onDrop(rec, client)
|
|
67
|
+
}
|
|
68
|
+
client.on('close', handle)
|
|
69
|
+
client.on('end', handle)
|
|
70
|
+
client.on('error', handle)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sleep(ms, signal) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timer = setTimeout(resolve, ms)
|
|
76
|
+
const onAbort = () => {
|
|
77
|
+
clearTimeout(timer)
|
|
78
|
+
const err = new Error('Cancelled')
|
|
79
|
+
err.aborted = true
|
|
80
|
+
reject(err)
|
|
81
|
+
}
|
|
82
|
+
if (!signal) return
|
|
83
|
+
if (signal.aborted) {
|
|
84
|
+
onAbort()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sessionSummary(s) {
|
|
92
|
+
return {
|
|
93
|
+
session_id: s.id,
|
|
94
|
+
host_id: s.hostId,
|
|
95
|
+
projectPathKey: s.projectPathKey,
|
|
96
|
+
status: s.status,
|
|
97
|
+
sftpEnabled: !!s.sftpEnabled,
|
|
98
|
+
title: s.title,
|
|
99
|
+
running: !!s.running,
|
|
100
|
+
created_at: s.createdAt,
|
|
101
|
+
updated_at: s.updatedAt,
|
|
102
|
+
endpoint: s.endpoint || '',
|
|
103
|
+
reconnect_attempt: s.reconnectAttempt || 0,
|
|
104
|
+
reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now }) {
|
|
109
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
110
|
+
if (!key) throw new Error('projectPathKey required')
|
|
111
|
+
// Each retained chunk carries its push order (seq). Clients cursor by seq,
|
|
112
|
+
// so ring eviction can no longer pin their read position to the buffer tail:
|
|
113
|
+
// after eviction the reader sees baseSeq jump forward and `dropped: true`.
|
|
114
|
+
const outputBuf = []
|
|
115
|
+
let outputBytes = 0
|
|
116
|
+
let outputSeq = 0
|
|
117
|
+
const rec = {
|
|
118
|
+
id,
|
|
119
|
+
hostId: host.id,
|
|
120
|
+
projectPathKey: key,
|
|
121
|
+
status: SSH_STATUS_CONNECTED,
|
|
122
|
+
sftpEnabled: !!sftp,
|
|
123
|
+
title: title || `SSH: ${host.name || host.host}`,
|
|
124
|
+
running: true,
|
|
125
|
+
createdAt: now,
|
|
126
|
+
updatedAt: now,
|
|
127
|
+
endpoint,
|
|
128
|
+
client,
|
|
129
|
+
sftp,
|
|
130
|
+
shellStream: null,
|
|
131
|
+
closing: false,
|
|
132
|
+
reconnectRunner: false,
|
|
133
|
+
reconnectAttempt: 0,
|
|
134
|
+
connectionGeneration: 0,
|
|
135
|
+
outputBuf,
|
|
136
|
+
outputSeq: 0,
|
|
137
|
+
pushOutput(chunk) {
|
|
138
|
+
const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
139
|
+
outputBuf.push({ text: s, seq: ++outputSeq })
|
|
140
|
+
rec.outputSeq = outputSeq
|
|
141
|
+
outputBytes += Buffer.byteLength(s)
|
|
142
|
+
while (outputBytes > MAX_OUT && outputBuf.length > 1) {
|
|
143
|
+
outputBytes -= Buffer.byteLength(outputBuf.shift().text)
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
getOutputText() {
|
|
147
|
+
return outputBuf.map((c) => c.text).join('')
|
|
148
|
+
},
|
|
149
|
+
/**
|
|
150
|
+
* `since` is the last seq the reader has already seen. Returns everything
|
|
151
|
+
* newer, capped at SHELL_READ_CHUNK_MAX bytes per call.
|
|
152
|
+
*/
|
|
153
|
+
chunkFrom(since) {
|
|
154
|
+
const last = Number.isFinite(Number(since)) && Number(since) >= 0 ? Math.floor(Number(since)) : 0
|
|
155
|
+
const base = outputBuf.length ? outputBuf[0].seq : outputSeq + 1
|
|
156
|
+
const dropped = outputSeq > 0 && base > last + 1
|
|
157
|
+
const parts = []
|
|
158
|
+
let bytes = 0
|
|
159
|
+
let upto = last
|
|
160
|
+
let chunkTruncated = false
|
|
161
|
+
for (const c of outputBuf) {
|
|
162
|
+
if (c.seq <= last) continue
|
|
163
|
+
const b = Buffer.byteLength(c.text)
|
|
164
|
+
if (parts.length && bytes + b > SHELL_READ_CHUNK_MAX) {
|
|
165
|
+
chunkTruncated = true
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
parts.push(c.text)
|
|
169
|
+
bytes += b
|
|
170
|
+
upto = c.seq
|
|
171
|
+
if (bytes > SHELL_READ_CHUNK_MAX) chunkTruncated = true
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
full: parts.join(''),
|
|
175
|
+
chunk: parts.join(''),
|
|
176
|
+
length: bytes,
|
|
177
|
+
since: upto,
|
|
178
|
+
seq: outputSeq,
|
|
179
|
+
baseSeq: base,
|
|
180
|
+
dropped,
|
|
181
|
+
chunkTruncated,
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
return rec
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function createSessionRegistry({
|
|
189
|
+
connectClient,
|
|
190
|
+
loadSecrets,
|
|
191
|
+
getPrompt,
|
|
192
|
+
getHostById,
|
|
193
|
+
isHostAuthorized,
|
|
194
|
+
}) {
|
|
195
|
+
const sessions = new Map()
|
|
196
|
+
|
|
197
|
+
function listSessionsForProject(projectPathKey) {
|
|
198
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
199
|
+
return [...sessions.values()].filter((s) => s.projectPathKey === key).map(sessionSummary)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function findReusableSession(projectPathKey, hostId, needsSftp) {
|
|
203
|
+
return findReusableLive([...sessions.values()], {
|
|
204
|
+
projectPathKey: normalizeProjectKey(projectPathKey),
|
|
205
|
+
hostId,
|
|
206
|
+
needsSftp,
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function createLiveSession(params) {
|
|
211
|
+
const { host, projectPathKey, title, sftpEnabled, trustHostKey } = params
|
|
212
|
+
const secrets = loadSecrets()
|
|
213
|
+
const id = randomUUID()
|
|
214
|
+
const now = Date.now()
|
|
215
|
+
const endpoint = `${host.username}@${host.host}:${host.port || 22}`
|
|
216
|
+
|
|
217
|
+
if (host.authType === 'keyboardInteractive') {
|
|
218
|
+
throw sshError('bad_request', kbiUnsupportedError())
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let client
|
|
222
|
+
try {
|
|
223
|
+
client = await withTimeout(
|
|
224
|
+
connectClient(host, secrets, {
|
|
225
|
+
trustHostKey: !!trustHostKey,
|
|
226
|
+
projectPathKey,
|
|
227
|
+
}),
|
|
228
|
+
SSH_READY_TIMEOUT_MS,
|
|
229
|
+
{ signal: params.signal },
|
|
230
|
+
)
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e && e.message === 'SSH_PROMPT') {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
sshPrompt: getPrompt?.(e.promptId),
|
|
236
|
+
promptId: e.promptId,
|
|
237
|
+
message: hostKeyPromptMessage(),
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
throw e
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let sftp = null
|
|
244
|
+
if (sftpEnabled !== false) {
|
|
245
|
+
try {
|
|
246
|
+
sftp = await openSftp(client)
|
|
247
|
+
} catch {
|
|
248
|
+
sftp = null
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const rec = makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now })
|
|
253
|
+
rec.connectionGeneration += 1
|
|
254
|
+
attachTransport(rec, client, onTransportDrop)
|
|
255
|
+
sessions.set(id, rec)
|
|
256
|
+
return { ok: true, session: sessionSummary(rec) }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function markReconnecting(rec, attempt) {
|
|
260
|
+
rec.reconnectAttempt = attempt
|
|
261
|
+
applyStatus(rec, SSH_STATUS_RECONNECTING)
|
|
262
|
+
rec.sftp = null
|
|
263
|
+
rec.shellStream = null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function reconnectOnce(rec, { signal } = {}) {
|
|
267
|
+
if (typeof isHostAuthorized === 'function' && !isHostAuthorized(rec.projectPathKey, rec.hostId)) {
|
|
268
|
+
throw sshError('forbidden', 'SSH session host is not authorized for the current project.')
|
|
269
|
+
}
|
|
270
|
+
const host = getHostById?.(rec.hostId)
|
|
271
|
+
if (!host) throw sshError('not_found', 'host not found')
|
|
272
|
+
if (host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
|
|
273
|
+
const secrets = loadSecrets()
|
|
274
|
+
try {
|
|
275
|
+
rec.shellStream?.close()
|
|
276
|
+
} catch {}
|
|
277
|
+
rec.shellStream = null
|
|
278
|
+
const oldClient = rec.client
|
|
279
|
+
rec.client = null
|
|
280
|
+
try {
|
|
281
|
+
oldClient?.end()
|
|
282
|
+
} catch {}
|
|
283
|
+
let client
|
|
284
|
+
try {
|
|
285
|
+
client = await withTimeout(
|
|
286
|
+
connectClient(host, secrets, { trustHostKey: false, projectPathKey: rec.projectPathKey }),
|
|
287
|
+
SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
288
|
+
{ signal },
|
|
289
|
+
)
|
|
290
|
+
} catch (e) {
|
|
291
|
+
if (e && e.message === 'SSH_PROMPT') {
|
|
292
|
+
// Same shape the create path maps for the tab UI; without this the
|
|
293
|
+
// reconnect path loses the promptId in a generic 500.
|
|
294
|
+
const err = sshError('conflict', hostKeyPromptMessage())
|
|
295
|
+
err.isPromptError = true
|
|
296
|
+
err.promptId = e.promptId
|
|
297
|
+
throw err
|
|
298
|
+
}
|
|
299
|
+
throw e
|
|
300
|
+
}
|
|
301
|
+
if (rec.closing) {
|
|
302
|
+
try { client.end() } catch {}
|
|
303
|
+
throw sshError('conflict', 'SSH session is closing')
|
|
304
|
+
}
|
|
305
|
+
let sftp = null
|
|
306
|
+
if (rec.sftpEnabled) {
|
|
307
|
+
try {
|
|
308
|
+
sftp = await openSftp(client)
|
|
309
|
+
} catch {
|
|
310
|
+
sftp = null
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (rec.closing) {
|
|
314
|
+
try { client.end() } catch {}
|
|
315
|
+
throw sshError('conflict', 'SSH session is closing')
|
|
316
|
+
}
|
|
317
|
+
rec.client = client
|
|
318
|
+
rec.sftp = sftp
|
|
319
|
+
rec.connectionGeneration += 1
|
|
320
|
+
rec.reconnectAttempt = 0
|
|
321
|
+
applyStatus(rec, SSH_STATUS_CONNECTED)
|
|
322
|
+
attachTransport(rec, client, onTransportDrop)
|
|
323
|
+
return sessionSummary(rec)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function runAutoReconnect(rec) {
|
|
327
|
+
if (rec.closing || rec.reconnectRunner) return
|
|
328
|
+
const host = getHostById?.(rec.hostId)
|
|
329
|
+
if (host && host.authType === 'keyboardInteractive') {
|
|
330
|
+
tombstone(rec)
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
rec.reconnectRunner = true
|
|
334
|
+
try {
|
|
335
|
+
for (let attempt = 1; attempt <= SSH_RECONNECT_MAX_ATTEMPTS; attempt++) {
|
|
336
|
+
if (rec.closing) return
|
|
337
|
+
if (typeof isHostAuthorized === 'function' && !isHostAuthorized(rec.projectPathKey, rec.hostId)) {
|
|
338
|
+
// Host was revoked while disconnected: tombstone it, never resurrect.
|
|
339
|
+
tombstone(rec)
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
markReconnecting(rec, attempt)
|
|
343
|
+
const delay = SSH_RECONNECT_DELAYS_MS[attempt - 1] || SSH_RECONNECT_DELAYS_MS[SSH_RECONNECT_DELAYS_MS.length - 1]
|
|
344
|
+
try {
|
|
345
|
+
await sleep(delay)
|
|
346
|
+
} catch {
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (rec.closing) return
|
|
350
|
+
try {
|
|
351
|
+
await reconnectOnce(rec)
|
|
352
|
+
return
|
|
353
|
+
} catch (e) {
|
|
354
|
+
// A host-key/MFA prompt cannot self-resolve by retrying: park the
|
|
355
|
+
// session and wait for the manual reconnect after confirmation.
|
|
356
|
+
if (e && e.isPromptError) {
|
|
357
|
+
tombstone(rec)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
tombstone(rec)
|
|
363
|
+
} finally {
|
|
364
|
+
rec.reconnectRunner = false
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function onTransportDrop(rec) {
|
|
369
|
+
if (rec.closing) return
|
|
370
|
+
const host = getHostById?.(rec.hostId)
|
|
371
|
+
if (host && host.authType === 'keyboardInteractive') {
|
|
372
|
+
tombstone(rec)
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
void runAutoReconnect(rec)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function reconnectSession(sessionId, { manual = true, signal, projectPathKey } = {}) {
|
|
379
|
+
const rec = sessions.get(String(sessionId || ''))
|
|
380
|
+
if (!rec) throw sshError('not_found', 'session not found')
|
|
381
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
382
|
+
if (!key) throw sshError('bad_request', 'projectPathKey required')
|
|
383
|
+
if (rec.projectPathKey !== key) throw sshError('forbidden', 'session not in this project')
|
|
384
|
+
if (rec.closing) throw sshError('conflict', 'SSH session is closing')
|
|
385
|
+
if (isLiveStatus(rec.status, rec.running) && rec.client) return { ok: true, session: sessionSummary(rec) }
|
|
386
|
+
if (rec.reconnectRunner) throw sshError('conflict', 'SSH reconnect already in progress')
|
|
387
|
+
const host = getHostById?.(rec.hostId)
|
|
388
|
+
if (host && host.authType === 'keyboardInteractive') throw sshError('bad_request', kbiReconnectError())
|
|
389
|
+
rec.reconnectRunner = true
|
|
390
|
+
try {
|
|
391
|
+
markReconnecting(rec, 1)
|
|
392
|
+
const session = await reconnectOnce(rec, { signal })
|
|
393
|
+
return { ok: true, session }
|
|
394
|
+
} catch (e) {
|
|
395
|
+
tombstone(rec)
|
|
396
|
+
throw e
|
|
397
|
+
} finally {
|
|
398
|
+
rec.reconnectRunner = false
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function waitForLive(sessionId, timeoutMs) {
|
|
403
|
+
const rec = sessions.get(String(sessionId || ''))
|
|
404
|
+
if (!rec) return Promise.reject(sshError('not_found', 'session not found'))
|
|
405
|
+
if (isLiveStatus(rec.status, rec.running) && rec.client) return Promise.resolve(rec)
|
|
406
|
+
const deadline = Date.now() + (Number(timeoutMs) || SSH_RECONNECT_ATTEMPT_TIMEOUT_MS)
|
|
407
|
+
return new Promise((resolve, reject) => {
|
|
408
|
+
const tick = () => {
|
|
409
|
+
const cur = sessions.get(String(sessionId || ''))
|
|
410
|
+
if (!cur) {
|
|
411
|
+
reject(sshError('not_found', 'session not found'))
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
if (isLiveStatus(cur.status, cur.running) && cur.client) {
|
|
415
|
+
resolve(cur)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
if (cur.status === SSH_STATUS_DISCONNECTED || Date.now() >= deadline) {
|
|
419
|
+
reject(sshError('conflict', 'session not running'))
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
setTimeout(tick, 100)
|
|
423
|
+
}
|
|
424
|
+
tick()
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function requireLiveSession(sessionId, projectPathKey) {
|
|
429
|
+
const s = sessions.get(String(sessionId || ''))
|
|
430
|
+
if (!s) throw sshError('not_found', 'session not found')
|
|
431
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
432
|
+
if (!key) throw sshError('bad_request', 'projectPathKey required')
|
|
433
|
+
if (s.projectPathKey !== key) throw sshError('forbidden', 'session not in this project')
|
|
434
|
+
if (!isLiveStatus(s.status, s.running) || !s.client) throw sshError('conflict', 'session not running')
|
|
435
|
+
// Grant revocation must take effect on established sessions, not only new
|
|
436
|
+
// connections. The session is bound to one project; re-check it on every use.
|
|
437
|
+
if (typeof isHostAuthorized === 'function' && !isHostAuthorized(s.projectPathKey, s.hostId)) {
|
|
438
|
+
throw sshError('forbidden', 'SSH session host is not authorized for the current project.')
|
|
439
|
+
}
|
|
440
|
+
return s
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function closeSessionsForHost(projectPathKey, hostId) {
|
|
444
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
445
|
+
const closed = []
|
|
446
|
+
for (const s of [...sessions.values()]) {
|
|
447
|
+
if (s.projectPathKey === key && s.hostId === String(hostId) && !s.closing) {
|
|
448
|
+
closeSession(s.id, s.projectPathKey, { force: true })
|
|
449
|
+
closed.push(s.id)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return { closed: closed.length, sessionIds: closed }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function closeSession(sessionId, projectPathKey, { force = false } = {}) {
|
|
456
|
+
const s = sessions.get(sessionId)
|
|
457
|
+
if (!s) throw sshError('not_found', 'session not found')
|
|
458
|
+
if (!force) {
|
|
459
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
460
|
+
if (!key) throw sshError('bad_request', 'projectPathKey required')
|
|
461
|
+
if (s.projectPathKey !== key) throw sshError('forbidden', 'session not in this project')
|
|
462
|
+
}
|
|
463
|
+
s.closing = true
|
|
464
|
+
try {
|
|
465
|
+
if (s.shellStream) s.shellStream.close()
|
|
466
|
+
} catch {}
|
|
467
|
+
try {
|
|
468
|
+
s.client?.end()
|
|
469
|
+
} catch {}
|
|
470
|
+
applyStatus(s, SSH_STATUS_DISCONNECTED)
|
|
471
|
+
sessions.delete(sessionId)
|
|
472
|
+
return { ok: true }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function execOnSession(session, command, opts = {}) {
|
|
476
|
+
// Check before dialing, not only when the result comes back: the remote
|
|
477
|
+
// side must not see a single byte of work from a revoked host.
|
|
478
|
+
if (typeof isHostAuthorized === 'function' && !isHostAuthorized(session.projectPathKey, session.hostId)) {
|
|
479
|
+
throw sshError('forbidden', 'SSH session host is not authorized for the current project.')
|
|
480
|
+
}
|
|
481
|
+
const client = session.client
|
|
482
|
+
if (!isLiveStatus(session.status, session.running) || !client) {
|
|
483
|
+
throw sshError('conflict', 'session not running')
|
|
484
|
+
}
|
|
485
|
+
const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_EXEC_DEFAULT_TIMEOUT_MS)
|
|
486
|
+
const maxBytes = clampMaxBytes(opts.maxBytes, MAX_BYTES_EXEC_DEFAULT)
|
|
487
|
+
const cwd = opts.cwd ? String(opts.cwd).trim() : ''
|
|
488
|
+
if (cwd && !cwd.startsWith('/')) {
|
|
489
|
+
throw sshError('bad_request', 'cwd must be an absolute POSIX path on the remote host')
|
|
490
|
+
}
|
|
491
|
+
const cmd = cwd ? `cd -- '${cwd.replace(/'/g, `'\\''`)}' && ${command}` : command
|
|
492
|
+
let stream
|
|
493
|
+
return withTimeout(
|
|
494
|
+
new Promise((resolve, reject) => {
|
|
495
|
+
client.exec(cmd, (err, st) => {
|
|
496
|
+
if (err) {
|
|
497
|
+
reject(err)
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
stream = st
|
|
501
|
+
// Aggregate raw bytes and decode once at the end: a multi-byte UTF-8
|
|
502
|
+
// sequence split across data events must not become U+FFFD.
|
|
503
|
+
const outParts = []
|
|
504
|
+
const errParts = []
|
|
505
|
+
let outTotal = 0
|
|
506
|
+
let errTotal = 0
|
|
507
|
+
let outKept = 0
|
|
508
|
+
let errKept = 0
|
|
509
|
+
stream.on('data', (d) => {
|
|
510
|
+
outTotal += d.length
|
|
511
|
+
if (outKept < maxBytes) {
|
|
512
|
+
outParts.push(d)
|
|
513
|
+
outKept += d.length
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
stream.stderr.on('data', (d) => {
|
|
517
|
+
errTotal += d.length
|
|
518
|
+
if (errKept < maxBytes) {
|
|
519
|
+
errParts.push(d)
|
|
520
|
+
errKept += d.length
|
|
521
|
+
}
|
|
522
|
+
})
|
|
523
|
+
stream.on('close', (code, sig) => {
|
|
524
|
+
const toText = (parts) => {
|
|
525
|
+
let buf = Buffer.concat(parts)
|
|
526
|
+
if (buf.length > maxBytes) buf = buf.subarray(0, maxBytes)
|
|
527
|
+
return buf.toString('utf8')
|
|
528
|
+
}
|
|
529
|
+
const exitKnown = typeof code === 'number'
|
|
530
|
+
resolve({
|
|
531
|
+
stdout: toText(outParts),
|
|
532
|
+
stderr: toText(errParts),
|
|
533
|
+
exitKnown,
|
|
534
|
+
code: exitKnown ? code : null,
|
|
535
|
+
signal: exitKnown ? null : sig || null,
|
|
536
|
+
// ssh2 sends exit-status on normal exit and exit-signal on signal
|
|
537
|
+
// death. Neither arrives when the connection itself was cut.
|
|
538
|
+
connectionDropped: !exitKnown && !sig,
|
|
539
|
+
truncated: outTotal > maxBytes || errTotal > maxBytes,
|
|
540
|
+
cwd: cwd || null,
|
|
541
|
+
})
|
|
542
|
+
})
|
|
543
|
+
stream.on('error', reject)
|
|
544
|
+
})
|
|
545
|
+
}),
|
|
546
|
+
timeoutMs,
|
|
547
|
+
{
|
|
548
|
+
signal: opts.signal,
|
|
549
|
+
onTimeout: () => {
|
|
550
|
+
try {
|
|
551
|
+
stream?.close()
|
|
552
|
+
} catch {}
|
|
553
|
+
try {
|
|
554
|
+
stream?.destroy?.()
|
|
555
|
+
} catch {}
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function ensureShell(session, opts = {}) {
|
|
562
|
+
if (session.shellStream) return session.shellStream
|
|
563
|
+
if (!session.client) throw sshError('conflict', 'session not running')
|
|
564
|
+
const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_SHELL_OPEN_TIMEOUT_MS)
|
|
565
|
+
let opened
|
|
566
|
+
let cancelled = false
|
|
567
|
+
return withTimeout(
|
|
568
|
+
new Promise((resolve, reject) => {
|
|
569
|
+
session.client.shell({ term: 'xterm-color' }, (err, stream) => {
|
|
570
|
+
if (cancelled) {
|
|
571
|
+
try { stream?.close() } catch {}
|
|
572
|
+
return
|
|
573
|
+
}
|
|
574
|
+
if (err) return reject(err)
|
|
575
|
+
opened = stream
|
|
576
|
+
session.shellStream = stream
|
|
577
|
+
stream.on('data', (d) => session.pushOutput(d))
|
|
578
|
+
stream.stderr?.on?.('data', (d) => session.pushOutput(d))
|
|
579
|
+
stream.on('close', () => {
|
|
580
|
+
session.shellStream = null
|
|
581
|
+
})
|
|
582
|
+
resolve(stream)
|
|
583
|
+
})
|
|
584
|
+
}),
|
|
585
|
+
timeoutMs,
|
|
586
|
+
{
|
|
587
|
+
signal: opts.signal,
|
|
588
|
+
onTimeout: () => {
|
|
589
|
+
cancelled = true
|
|
590
|
+
try { opened?.close() } catch {}
|
|
591
|
+
try { opened?.destroy?.() } catch {}
|
|
592
|
+
if (session.shellStream === opened) session.shellStream = null
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
sessions,
|
|
600
|
+
sessionSummary,
|
|
601
|
+
listSessionsForProject,
|
|
602
|
+
findReusableSession,
|
|
603
|
+
createLiveSession,
|
|
604
|
+
requireLiveSession,
|
|
605
|
+
closeSession,
|
|
606
|
+
closeSessionsForHost,
|
|
607
|
+
execOnSession,
|
|
608
|
+
ensureShell,
|
|
609
|
+
openSftp,
|
|
610
|
+
reconnectSession,
|
|
611
|
+
waitForLive,
|
|
612
|
+
get(id) {
|
|
613
|
+
return sessions.get(id)
|
|
614
|
+
},
|
|
615
|
+
}
|
|
616
|
+
}
|