dsh-ssh-tunnel 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +305 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/README_en.md +171 -0
- package/cordis.patch.yml +4 -0
- package/lib/client.js +1472 -0
- package/lib/index.js +1611 -0
- package/lib/session.js +525 -0
- package/lib/shared/host-key.js +32 -0
- package/lib/shared/host-summary.js +75 -0
- package/lib/shared/http-trust.js +59 -0
- package/lib/shared/path.js +156 -0
- package/lib/shared/persist.js +41 -0
- package/lib/shared/session-auth.js +39 -0
- package/lib/shared/session-policy.js +125 -0
- package/lib/shared/shell-buffer.js +10 -0
- package/lib/shared/vendor.js +50 -0
- package/package.json +84 -0
- package/scripts/install.ps1 +148 -0
- package/scripts/install.sh +226 -0
- package/scripts/portal-probe.mjs +106 -0
- package/scripts/smoke-test.mjs +511 -0
- package/scripts/sync-to-dsh.sh +32 -0
package/lib/session.js
ADDED
|
@@ -0,0 +1,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 { chunkFromBuffer } from './shared/shell-buffer.js'
|
|
7
|
+
import { normalizeProjectKey } from './shared/path.js'
|
|
8
|
+
import {
|
|
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
|
+
clampTimeoutMs,
|
|
20
|
+
findReusableLive,
|
|
21
|
+
isLiveStatus,
|
|
22
|
+
kbiReconnectError,
|
|
23
|
+
kbiUnsupportedError,
|
|
24
|
+
withTimeout,
|
|
25
|
+
} from './shared/session-policy.js'
|
|
26
|
+
|
|
27
|
+
const MAX_OUT = 512 * 1024
|
|
28
|
+
|
|
29
|
+
async function openSftp(client, opts = {}) {
|
|
30
|
+
return withTimeout(
|
|
31
|
+
new Promise((resolve, reject) => {
|
|
32
|
+
client.sftp((err, sftp) => (err ? reject(err) : resolve(sftp)))
|
|
33
|
+
}),
|
|
34
|
+
opts.timeoutMs ?? SSH_SFTP_META_TIMEOUT_MS,
|
|
35
|
+
{ signal: opts.signal },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function applyStatus(rec, status) {
|
|
40
|
+
rec.status = status
|
|
41
|
+
rec.running = status === SSH_STATUS_CONNECTED
|
|
42
|
+
rec.updatedAt = Date.now()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function tombstone(rec) {
|
|
46
|
+
if (rec.closing) return
|
|
47
|
+
applyStatus(rec, SSH_STATUS_DISCONNECTED)
|
|
48
|
+
try {
|
|
49
|
+
rec.shellStream?.close()
|
|
50
|
+
} catch {}
|
|
51
|
+
rec.shellStream = null
|
|
52
|
+
rec.sftp = null
|
|
53
|
+
rec.client = null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function attachTransport(rec, client, onDrop) {
|
|
57
|
+
const handle = () => {
|
|
58
|
+
if (rec.closing) return
|
|
59
|
+
if (rec.client !== client) return
|
|
60
|
+
onDrop(rec, client)
|
|
61
|
+
}
|
|
62
|
+
client.on('close', handle)
|
|
63
|
+
client.on('end', handle)
|
|
64
|
+
client.on('error', handle)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sleep(ms, signal) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
const timer = setTimeout(resolve, ms)
|
|
70
|
+
const onAbort = () => {
|
|
71
|
+
clearTimeout(timer)
|
|
72
|
+
const err = new Error('Cancelled')
|
|
73
|
+
err.aborted = true
|
|
74
|
+
reject(err)
|
|
75
|
+
}
|
|
76
|
+
if (!signal) return
|
|
77
|
+
if (signal.aborted) {
|
|
78
|
+
onAbort()
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sessionSummary(s) {
|
|
86
|
+
return {
|
|
87
|
+
session_id: s.id,
|
|
88
|
+
host_id: s.hostId,
|
|
89
|
+
projectPathKey: s.projectPathKey,
|
|
90
|
+
status: s.status,
|
|
91
|
+
sftpEnabled: !!s.sftpEnabled,
|
|
92
|
+
title: s.title,
|
|
93
|
+
running: !!s.running,
|
|
94
|
+
created_at: s.createdAt,
|
|
95
|
+
updated_at: s.updatedAt,
|
|
96
|
+
endpoint: s.endpoint || '',
|
|
97
|
+
reconnect_attempt: s.reconnectAttempt || 0,
|
|
98
|
+
reconnect_max_attempts: SSH_RECONNECT_MAX_ATTEMPTS,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now }) {
|
|
103
|
+
const outputBuf = []
|
|
104
|
+
let outputBytes = 0
|
|
105
|
+
const rec = {
|
|
106
|
+
id,
|
|
107
|
+
hostId: host.id,
|
|
108
|
+
projectPathKey: normalizeProjectKey(projectPathKey),
|
|
109
|
+
status: SSH_STATUS_CONNECTED,
|
|
110
|
+
sftpEnabled: !!sftp,
|
|
111
|
+
title: title || `SSH: ${host.name || host.host}`,
|
|
112
|
+
running: true,
|
|
113
|
+
createdAt: now,
|
|
114
|
+
updatedAt: now,
|
|
115
|
+
endpoint,
|
|
116
|
+
client,
|
|
117
|
+
sftp,
|
|
118
|
+
shellStream: null,
|
|
119
|
+
closing: false,
|
|
120
|
+
reconnectRunner: false,
|
|
121
|
+
reconnectAttempt: 0,
|
|
122
|
+
connectionGeneration: 0,
|
|
123
|
+
outputBuf,
|
|
124
|
+
outputSeq: 0,
|
|
125
|
+
pushOutput(chunk) {
|
|
126
|
+
const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
127
|
+
outputBuf.push(s)
|
|
128
|
+
outputBytes += Buffer.byteLength(s)
|
|
129
|
+
rec.outputSeq = (rec.outputSeq || 0) + 1
|
|
130
|
+
while (outputBytes > MAX_OUT && outputBuf.length > 1) {
|
|
131
|
+
outputBytes -= Buffer.byteLength(outputBuf.shift())
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
getOutputText() {
|
|
135
|
+
return outputBuf.join('')
|
|
136
|
+
},
|
|
137
|
+
chunkFrom(since) {
|
|
138
|
+
const full = outputBuf.join('')
|
|
139
|
+
const sliced = chunkFromBuffer(full, since)
|
|
140
|
+
return { full, chunk: sliced.chunk, length: sliced.length, since: sliced.since }
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
return rec
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function createSessionRegistry({
|
|
147
|
+
connectClient,
|
|
148
|
+
loadSecrets,
|
|
149
|
+
getPrompt,
|
|
150
|
+
getHostById,
|
|
151
|
+
isHostAuthorized,
|
|
152
|
+
}) {
|
|
153
|
+
const sessions = new Map()
|
|
154
|
+
|
|
155
|
+
function listSessionsForProject(projectPathKey) {
|
|
156
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
157
|
+
return [...sessions.values()].filter((s) => s.projectPathKey === key).map(sessionSummary)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function findReusableSession(projectPathKey, hostId, needsSftp) {
|
|
161
|
+
return findReusableLive([...sessions.values()], {
|
|
162
|
+
projectPathKey: normalizeProjectKey(projectPathKey),
|
|
163
|
+
hostId,
|
|
164
|
+
needsSftp,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function createLiveSession(params) {
|
|
169
|
+
const { host, projectPathKey, title, sftpEnabled, trustHostKey } = params
|
|
170
|
+
const secrets = loadSecrets()
|
|
171
|
+
const id = randomUUID()
|
|
172
|
+
const now = Date.now()
|
|
173
|
+
const endpoint = `${host.username}@${host.host}:${host.port || 22}`
|
|
174
|
+
|
|
175
|
+
if (host.authType === 'keyboardInteractive') {
|
|
176
|
+
throw new Error(kbiUnsupportedError())
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let client
|
|
180
|
+
try {
|
|
181
|
+
client = await withTimeout(
|
|
182
|
+
connectClient(host, secrets, {
|
|
183
|
+
trustHostKey: !!trustHostKey,
|
|
184
|
+
}),
|
|
185
|
+
SSH_READY_TIMEOUT_MS,
|
|
186
|
+
{ signal: params.signal },
|
|
187
|
+
)
|
|
188
|
+
} catch (e) {
|
|
189
|
+
if (e && e.message === 'SSH_PROMPT') {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
sshPrompt: getPrompt?.(e.promptId),
|
|
193
|
+
promptId: e.promptId,
|
|
194
|
+
message: '请先在 SSH 隧道 Tab 手动完成连接/信任/MFA 后重试。',
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
throw e
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let sftp = null
|
|
201
|
+
if (sftpEnabled !== false) {
|
|
202
|
+
try {
|
|
203
|
+
sftp = await openSftp(client)
|
|
204
|
+
} catch {
|
|
205
|
+
sftp = null
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const rec = makeRecord({ id, host, projectPathKey, title, endpoint, client, sftp, now })
|
|
210
|
+
rec.connectionGeneration += 1
|
|
211
|
+
attachTransport(rec, client, onTransportDrop)
|
|
212
|
+
sessions.set(id, rec)
|
|
213
|
+
return { ok: true, session: sessionSummary(rec) }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function markReconnecting(rec, attempt) {
|
|
217
|
+
rec.reconnectAttempt = attempt
|
|
218
|
+
applyStatus(rec, SSH_STATUS_RECONNECTING)
|
|
219
|
+
rec.sftp = null
|
|
220
|
+
rec.shellStream = null
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function reconnectOnce(rec, { signal } = {}) {
|
|
224
|
+
if (rec.projectPathKey && typeof isHostAuthorized === 'function' && !isHostAuthorized(rec.projectPathKey, rec.hostId)) {
|
|
225
|
+
throw new Error('SSH session host is not authorized for the current project.')
|
|
226
|
+
}
|
|
227
|
+
const host = getHostById?.(rec.hostId)
|
|
228
|
+
if (!host) throw new Error('host not found')
|
|
229
|
+
if (host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
|
|
230
|
+
const secrets = loadSecrets()
|
|
231
|
+
try {
|
|
232
|
+
rec.shellStream?.close()
|
|
233
|
+
} catch {}
|
|
234
|
+
rec.shellStream = null
|
|
235
|
+
const oldClient = rec.client
|
|
236
|
+
rec.client = null
|
|
237
|
+
try {
|
|
238
|
+
oldClient?.end()
|
|
239
|
+
} catch {}
|
|
240
|
+
const client = await withTimeout(
|
|
241
|
+
connectClient(host, secrets, { trustHostKey: false }),
|
|
242
|
+
SSH_RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
243
|
+
{ signal },
|
|
244
|
+
)
|
|
245
|
+
if (rec.closing) {
|
|
246
|
+
try { client.end() } catch {}
|
|
247
|
+
throw new Error('SSH session is closing')
|
|
248
|
+
}
|
|
249
|
+
let sftp = null
|
|
250
|
+
if (rec.sftpEnabled) {
|
|
251
|
+
try {
|
|
252
|
+
sftp = await openSftp(client)
|
|
253
|
+
} catch {
|
|
254
|
+
sftp = null
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (rec.closing) {
|
|
258
|
+
try { client.end() } catch {}
|
|
259
|
+
throw new Error('SSH session is closing')
|
|
260
|
+
}
|
|
261
|
+
rec.client = client
|
|
262
|
+
rec.sftp = sftp
|
|
263
|
+
rec.connectionGeneration += 1
|
|
264
|
+
rec.reconnectAttempt = 0
|
|
265
|
+
applyStatus(rec, SSH_STATUS_CONNECTED)
|
|
266
|
+
attachTransport(rec, client, onTransportDrop)
|
|
267
|
+
return sessionSummary(rec)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function runAutoReconnect(rec) {
|
|
271
|
+
if (rec.closing || rec.reconnectRunner) return
|
|
272
|
+
const host = getHostById?.(rec.hostId)
|
|
273
|
+
if (host && host.authType === 'keyboardInteractive') {
|
|
274
|
+
tombstone(rec)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
rec.reconnectRunner = true
|
|
278
|
+
try {
|
|
279
|
+
for (let attempt = 1; attempt <= SSH_RECONNECT_MAX_ATTEMPTS; attempt++) {
|
|
280
|
+
if (rec.closing) return
|
|
281
|
+
if (
|
|
282
|
+
rec.projectPathKey &&
|
|
283
|
+
typeof isHostAuthorized === 'function' &&
|
|
284
|
+
!isHostAuthorized(rec.projectPathKey, rec.hostId)
|
|
285
|
+
) {
|
|
286
|
+
// Host was revoked while disconnected: tombstone it, never resurrect.
|
|
287
|
+
tombstone(rec)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
markReconnecting(rec, attempt)
|
|
291
|
+
const delay = SSH_RECONNECT_DELAYS_MS[attempt - 1] || SSH_RECONNECT_DELAYS_MS[SSH_RECONNECT_DELAYS_MS.length - 1]
|
|
292
|
+
try {
|
|
293
|
+
await sleep(delay)
|
|
294
|
+
} catch {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
if (rec.closing) return
|
|
298
|
+
try {
|
|
299
|
+
await reconnectOnce(rec)
|
|
300
|
+
return
|
|
301
|
+
} catch {
|
|
302
|
+
// try next attempt
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
tombstone(rec)
|
|
306
|
+
} finally {
|
|
307
|
+
rec.reconnectRunner = false
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function onTransportDrop(rec) {
|
|
312
|
+
if (rec.closing) return
|
|
313
|
+
const host = getHostById?.(rec.hostId)
|
|
314
|
+
if (host && host.authType === 'keyboardInteractive') {
|
|
315
|
+
tombstone(rec)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
void runAutoReconnect(rec)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function reconnectSession(sessionId, { manual = true, signal, projectPathKey } = {}) {
|
|
322
|
+
const rec = sessions.get(String(sessionId || ''))
|
|
323
|
+
if (!rec) throw new Error('session not found')
|
|
324
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
325
|
+
if (!key) throw new Error('projectPathKey required')
|
|
326
|
+
if (rec.projectPathKey !== key) throw new Error('session not in this project')
|
|
327
|
+
if (rec.closing) throw new Error('SSH session is closing')
|
|
328
|
+
if (isLiveStatus(rec.status, rec.running) && rec.client) return { ok: true, session: sessionSummary(rec) }
|
|
329
|
+
if (rec.reconnectRunner) throw new Error('SSH reconnect already in progress')
|
|
330
|
+
const host = getHostById?.(rec.hostId)
|
|
331
|
+
if (host && host.authType === 'keyboardInteractive') throw new Error(kbiReconnectError())
|
|
332
|
+
rec.reconnectRunner = true
|
|
333
|
+
try {
|
|
334
|
+
markReconnecting(rec, 1)
|
|
335
|
+
const session = await reconnectOnce(rec, { signal })
|
|
336
|
+
return { ok: true, session }
|
|
337
|
+
} catch (e) {
|
|
338
|
+
tombstone(rec)
|
|
339
|
+
throw e
|
|
340
|
+
} finally {
|
|
341
|
+
rec.reconnectRunner = false
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function waitForLive(sessionId, timeoutMs) {
|
|
346
|
+
const rec = sessions.get(String(sessionId || ''))
|
|
347
|
+
if (!rec) return Promise.reject(new Error('session not found'))
|
|
348
|
+
if (isLiveStatus(rec.status, rec.running) && rec.client) return Promise.resolve(rec)
|
|
349
|
+
const deadline = Date.now() + (Number(timeoutMs) || SSH_RECONNECT_ATTEMPT_TIMEOUT_MS)
|
|
350
|
+
return new Promise((resolve, reject) => {
|
|
351
|
+
const tick = () => {
|
|
352
|
+
const cur = sessions.get(String(sessionId || ''))
|
|
353
|
+
if (!cur) {
|
|
354
|
+
reject(new Error('session not found'))
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (isLiveStatus(cur.status, cur.running) && cur.client) {
|
|
358
|
+
resolve(cur)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
if (cur.status === SSH_STATUS_DISCONNECTED || Date.now() >= deadline) {
|
|
362
|
+
reject(new Error('session not running'))
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
setTimeout(tick, 100)
|
|
366
|
+
}
|
|
367
|
+
tick()
|
|
368
|
+
})
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function requireLiveSession(sessionId, projectPathKey) {
|
|
372
|
+
const s = sessions.get(String(sessionId || ''))
|
|
373
|
+
if (!s) throw new Error('session not found')
|
|
374
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
375
|
+
if (!key) throw new Error('projectPathKey required')
|
|
376
|
+
if (s.projectPathKey !== key) throw new Error('session not in this project')
|
|
377
|
+
if (!isLiveStatus(s.status, s.running) || !s.client) throw new Error('session not running')
|
|
378
|
+
// Grant revocation must take effect on established sessions, not only new
|
|
379
|
+
// connections. The session is bound to one project; re-check it on every use.
|
|
380
|
+
if (s.projectPathKey && typeof isHostAuthorized === 'function' && !isHostAuthorized(s.projectPathKey, s.hostId)) {
|
|
381
|
+
throw new Error('SSH session host is not authorized for the current project.')
|
|
382
|
+
}
|
|
383
|
+
return s
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function closeSessionsForHost(projectPathKey, hostId) {
|
|
387
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
388
|
+
const closed = []
|
|
389
|
+
for (const s of [...sessions.values()]) {
|
|
390
|
+
if (s.projectPathKey === key && s.hostId === String(hostId) && !s.closing) {
|
|
391
|
+
closeSession(s.id, s.projectPathKey, { force: true })
|
|
392
|
+
closed.push(s.id)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return { closed: closed.length, sessionIds: closed }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function closeSession(sessionId, projectPathKey, { force = false } = {}) {
|
|
399
|
+
const s = sessions.get(sessionId)
|
|
400
|
+
if (!s) return { ok: false, error: 'session not found' }
|
|
401
|
+
if (!force) {
|
|
402
|
+
const key = normalizeProjectKey(projectPathKey)
|
|
403
|
+
if (!key) throw new Error('projectPathKey required')
|
|
404
|
+
if (s.projectPathKey !== key) throw new Error('session not in this project')
|
|
405
|
+
}
|
|
406
|
+
s.closing = true
|
|
407
|
+
try {
|
|
408
|
+
if (s.shellStream) s.shellStream.close()
|
|
409
|
+
} catch {}
|
|
410
|
+
try {
|
|
411
|
+
s.client?.end()
|
|
412
|
+
} catch {}
|
|
413
|
+
applyStatus(s, SSH_STATUS_DISCONNECTED)
|
|
414
|
+
sessions.delete(sessionId)
|
|
415
|
+
return { ok: true }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function execOnSession(session, command, opts = {}) {
|
|
419
|
+
const client = session.client
|
|
420
|
+
if (!isLiveStatus(session.status, session.running) || !client) {
|
|
421
|
+
throw new Error('session not running')
|
|
422
|
+
}
|
|
423
|
+
const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_EXEC_DEFAULT_TIMEOUT_MS)
|
|
424
|
+
const maxBytes = opts.maxBytes || 256 * 1024
|
|
425
|
+
const cwd = opts.cwd
|
|
426
|
+
const cmd =
|
|
427
|
+
cwd && String(cwd).startsWith('/')
|
|
428
|
+
? `cd -- ${JSON.stringify(cwd)} && ${command}`
|
|
429
|
+
: command
|
|
430
|
+
let stream
|
|
431
|
+
return withTimeout(
|
|
432
|
+
new Promise((resolve, reject) => {
|
|
433
|
+
client.exec(cmd, (err, st) => {
|
|
434
|
+
if (err) {
|
|
435
|
+
reject(err)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
stream = st
|
|
439
|
+
let stdout = ''
|
|
440
|
+
let stderr = ''
|
|
441
|
+
stream.on('data', (d) => {
|
|
442
|
+
stdout += d
|
|
443
|
+
if (Buffer.byteLength(stdout) > maxBytes) stdout = stdout.slice(0, maxBytes)
|
|
444
|
+
})
|
|
445
|
+
stream.stderr.on('data', (d) => {
|
|
446
|
+
stderr += d
|
|
447
|
+
if (Buffer.byteLength(stderr) > maxBytes) stderr = stderr.slice(0, maxBytes)
|
|
448
|
+
})
|
|
449
|
+
stream.on('close', (code, sig) => {
|
|
450
|
+
resolve({ stdout, stderr, code: code ?? 0, signal: sig || null })
|
|
451
|
+
})
|
|
452
|
+
stream.on('error', reject)
|
|
453
|
+
})
|
|
454
|
+
}),
|
|
455
|
+
timeoutMs,
|
|
456
|
+
{
|
|
457
|
+
signal: opts.signal,
|
|
458
|
+
onTimeout: () => {
|
|
459
|
+
try {
|
|
460
|
+
stream?.close()
|
|
461
|
+
} catch {}
|
|
462
|
+
try {
|
|
463
|
+
stream?.destroy?.()
|
|
464
|
+
} catch {}
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
async function ensureShell(session, opts = {}) {
|
|
471
|
+
if (session.shellStream) return session.shellStream
|
|
472
|
+
if (!session.client) throw new Error('session not running')
|
|
473
|
+
const timeoutMs = clampTimeoutMs(opts.timeoutMs, SSH_SHELL_OPEN_TIMEOUT_MS)
|
|
474
|
+
let opened
|
|
475
|
+
let cancelled = false
|
|
476
|
+
return withTimeout(
|
|
477
|
+
new Promise((resolve, reject) => {
|
|
478
|
+
session.client.shell({ term: 'xterm-color' }, (err, stream) => {
|
|
479
|
+
if (cancelled) {
|
|
480
|
+
try { stream?.close() } catch {}
|
|
481
|
+
return
|
|
482
|
+
}
|
|
483
|
+
if (err) return reject(err)
|
|
484
|
+
opened = stream
|
|
485
|
+
session.shellStream = stream
|
|
486
|
+
stream.on('data', (d) => session.pushOutput(d))
|
|
487
|
+
stream.stderr?.on?.('data', (d) => session.pushOutput(d))
|
|
488
|
+
stream.on('close', () => {
|
|
489
|
+
session.shellStream = null
|
|
490
|
+
})
|
|
491
|
+
resolve(stream)
|
|
492
|
+
})
|
|
493
|
+
}),
|
|
494
|
+
timeoutMs,
|
|
495
|
+
{
|
|
496
|
+
signal: opts.signal,
|
|
497
|
+
onTimeout: () => {
|
|
498
|
+
cancelled = true
|
|
499
|
+
try { opened?.close() } catch {}
|
|
500
|
+
try { opened?.destroy?.() } catch {}
|
|
501
|
+
if (session.shellStream === opened) session.shellStream = null
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
sessions,
|
|
509
|
+
sessionSummary,
|
|
510
|
+
listSessionsForProject,
|
|
511
|
+
findReusableSession,
|
|
512
|
+
createLiveSession,
|
|
513
|
+
requireLiveSession,
|
|
514
|
+
closeSession,
|
|
515
|
+
closeSessionsForHost,
|
|
516
|
+
execOnSession,
|
|
517
|
+
ensureShell,
|
|
518
|
+
openSftp,
|
|
519
|
+
reconnectSession,
|
|
520
|
+
waitForLive,
|
|
521
|
+
get(id) {
|
|
522
|
+
return sessions.get(id)
|
|
523
|
+
},
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/** True when stored value is a sha256 hex digest (not legacy raw-key String(Buffer)). */
|
|
4
|
+
export function isUsableHostFingerprint(fp) {
|
|
5
|
+
return typeof fp === 'string' && /^[0-9a-f]{64}$/.test(fp.trim().toLowerCase())
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize ssh2 hostVerifier input to a stable sha256 hex fingerprint.
|
|
10
|
+
* - With connect option hostHash:'sha256', key is already a hex string.
|
|
11
|
+
* - Raw Buffer (no hostHash) is hashed here as a safe fallback.
|
|
12
|
+
*/
|
|
13
|
+
export function fingerprintFromHostKey(key) {
|
|
14
|
+
if (key == null) return ''
|
|
15
|
+
if (typeof key === 'string') {
|
|
16
|
+
const s = key.trim().toLowerCase()
|
|
17
|
+
if (/^[0-9a-f]{64}$/.test(s)) return s
|
|
18
|
+
if (/^[0-9a-f]+$/.test(s) && s.length >= 32) return createHash('sha256').update(Buffer.from(s, 'hex')).digest('hex')
|
|
19
|
+
return createHash('sha256').update(Buffer.from(key, 'utf8')).digest('hex')
|
|
20
|
+
}
|
|
21
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(key)) {
|
|
22
|
+
return createHash('sha256').update(key).digest('hex')
|
|
23
|
+
}
|
|
24
|
+
return createHash('sha256').update(Buffer.from(String(key))).digest('hex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Human-facing label for trust prompts / logs. */
|
|
28
|
+
export function formatHostFingerprint(fp, algo = 'SHA256') {
|
|
29
|
+
if (!fp) return ''
|
|
30
|
+
const hex = String(fp).trim().toLowerCase()
|
|
31
|
+
return `${algo}:${hex}`
|
|
32
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Credential presence without exposing secret material. */
|
|
2
|
+
export function hostCredentialConfigured(host, secrets) {
|
|
3
|
+
if (!host) return false
|
|
4
|
+
if (host.authType === 'keyboardInteractive') return false
|
|
5
|
+
const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
|
|
6
|
+
if (host.authType === 'privateKey') {
|
|
7
|
+
return !!(host.privateKeyPath || sec.privateKeyPem)
|
|
8
|
+
}
|
|
9
|
+
return !!sec.password
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** UI-safe host record (never includes password/PEM). */
|
|
13
|
+
export function publicHost(host, secrets) {
|
|
14
|
+
const configured = hostCredentialConfigured(host, secrets)
|
|
15
|
+
const sec = (secrets && secrets.byHostId && secrets.byHostId[host.id]) || {}
|
|
16
|
+
return {
|
|
17
|
+
id: host.id,
|
|
18
|
+
name: host.name,
|
|
19
|
+
host: host.host,
|
|
20
|
+
port: host.port || 22,
|
|
21
|
+
username: host.username,
|
|
22
|
+
authType: host.authType || 'privateKey',
|
|
23
|
+
privateKeyPath: host.privateKeyPath || '',
|
|
24
|
+
source: host.source || 'manual',
|
|
25
|
+
updatedAt: host.updatedAt || 0,
|
|
26
|
+
credentialConfigured: configured,
|
|
27
|
+
credentialStatus:
|
|
28
|
+
host.authType === 'keyboardInteractive'
|
|
29
|
+
? 'unsupported'
|
|
30
|
+
: configured
|
|
31
|
+
? 'saved'
|
|
32
|
+
: 'missing',
|
|
33
|
+
passwordConfigured: !!sec.password,
|
|
34
|
+
privateKeyConfigured: !!(host.privateKeyPath || sec.privateKeyPem),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Model-facing summary: no paths to key files beyond endpoint metadata. */
|
|
39
|
+
export function modelHostSummary(host, secrets) {
|
|
40
|
+
const p = publicHost(host, secrets)
|
|
41
|
+
return {
|
|
42
|
+
host_id: p.id,
|
|
43
|
+
name: p.name,
|
|
44
|
+
endpoint: `${p.username}@${p.host}:${p.port}`,
|
|
45
|
+
username: p.username,
|
|
46
|
+
host: p.host,
|
|
47
|
+
port: p.port,
|
|
48
|
+
authType: p.authType,
|
|
49
|
+
credentialConfigured: p.credentialConfigured,
|
|
50
|
+
credentialStatus: p.credentialStatus,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Fail if object looks like it still carries raw secrets (for tests / asserts). */
|
|
55
|
+
export function assertNoSecretFields(obj, label = 'object') {
|
|
56
|
+
const banned = ['password', 'privateKeyPem', 'privateKey', 'passphrase', 'secret']
|
|
57
|
+
const stack = [{ value: obj, path: label }]
|
|
58
|
+
while (stack.length) {
|
|
59
|
+
const { value, path } = stack.pop()
|
|
60
|
+
if (!value || typeof value !== 'object') continue
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
value.forEach((v, i) => stack.push({ value: v, path: `${path}[${i}]` }))
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
for (const [k, v] of Object.entries(value)) {
|
|
66
|
+
const key = k.toLowerCase()
|
|
67
|
+
if (banned.includes(key) && v) {
|
|
68
|
+
throw new Error(`${path}.${k} must not carry secret material`)
|
|
69
|
+
}
|
|
70
|
+
// allow *Configured boolean flags
|
|
71
|
+
if (typeof v === 'object' && v) stack.push({ value: v, path: `${path}.${k}` })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback / Origin checks for the local HTTP API.
|
|
3
|
+
* 0.0.0.0 is NOT loopback (it is a bind-all address).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function isLoopbackHostname(hostname) {
|
|
7
|
+
const h = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase()
|
|
8
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '::1'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function originHostPort(originHeader) {
|
|
12
|
+
try {
|
|
13
|
+
const u = new URL(String(originHeader))
|
|
14
|
+
const host = u.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
15
|
+
const port = u.port || (u.protocol === 'https:' ? '443' : u.protocol === 'http:' ? '80' : '')
|
|
16
|
+
return { host, port, hostHeaderLike: port ? `${host}:${port}` : host }
|
|
17
|
+
} catch {
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} req Node HTTP request
|
|
24
|
+
* @param {string[]} trustedHosts
|
|
25
|
+
*/
|
|
26
|
+
export function isTrustedRequest(req, trustedHosts) {
|
|
27
|
+
try {
|
|
28
|
+
const hostHeader = req.headers?.host || req.headers?.Host
|
|
29
|
+
if (!hostHeader) return false
|
|
30
|
+
const url = new URL('http://' + hostHeader)
|
|
31
|
+
const reqHost = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
32
|
+
const list = Array.isArray(trustedHosts) ? trustedHosts : []
|
|
33
|
+
const hostOk =
|
|
34
|
+
isLoopbackHostname(reqHost) ||
|
|
35
|
+
list.some((entry) => {
|
|
36
|
+
const e = String(entry)
|
|
37
|
+
return e === hostHeader || e === url.hostname || e === url.host
|
|
38
|
+
})
|
|
39
|
+
if (!hostOk) return false
|
|
40
|
+
|
|
41
|
+
const origin = req.headers?.origin || req.headers?.Origin
|
|
42
|
+
if (!origin) return true
|
|
43
|
+
const parsed = originHostPort(origin)
|
|
44
|
+
if (!parsed) return false
|
|
45
|
+
if (isLoopbackHostname(parsed.host) && isLoopbackHostname(reqHost)) {
|
|
46
|
+
const reqPort = url.port || '80'
|
|
47
|
+
if (parsed.port && parsed.port !== reqPort) return false
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
return list.some((entry) => {
|
|
51
|
+
const e = String(entry)
|
|
52
|
+
return e === parsed.hostHeaderLike || e === parsed.host || e === origin
|
|
53
|
+
})
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024
|