dsh-ssh-workbench 0.0.0-stage → 1.6.4
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 +504 -0
- package/CONTRIBUTING.md +62 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/SECURITY.md +25 -0
- package/cordis.patch.yml +5 -0
- package/docs/architecture.md +124 -0
- package/docs/quickstart.md +94 -0
- package/lib/client.js +4977 -0
- package/lib/core/activity.js +83 -0
- package/lib/core/background.js +69 -0
- package/lib/core/broker.js +799 -0
- package/lib/core/dialect.js +28 -0
- package/lib/core/errors.js +138 -0
- package/lib/core/gate-pwsh.js +88 -0
- package/lib/core/gate.js +572 -0
- package/lib/core/guard.js +71 -0
- package/lib/core/known-hosts.js +74 -0
- package/lib/core/local-file-api.js +174 -0
- package/lib/core/localfs.js +381 -0
- package/lib/core/paths.js +104 -0
- package/lib/core/posix-quote.js +4 -0
- package/lib/core/prefs.js +38 -0
- package/lib/core/remote-bash-tool.js +321 -0
- package/lib/core/remote-bash.js +337 -0
- package/lib/core/secrets.js +96 -0
- package/lib/core/server-reference.js +299 -0
- package/lib/core/shell-adapters.js +48 -0
- package/lib/core/ssh.js +761 -0
- package/lib/core/store.js +83 -0
- package/lib/core/targets.js +250 -0
- package/lib/core/terminals.js +299 -0
- package/lib/core/win32-adapter.js +141 -0
- package/lib/index.js +450 -0
- package/lib/routes.js +733 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +88 -4
- package/vendor/NOTICE.md +50 -0
- package/vendor/XTERM-LICENSE +21 -0
- package/vendor/node_modules/asn1/LICENSE +19 -0
- package/vendor/node_modules/asn1/README.md +50 -0
- package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
- package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
- package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
- package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
- package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
- package/vendor/node_modules/asn1/lib/index.js +20 -0
- package/vendor/node_modules/asn1/package.json +31 -0
- package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
- package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
- package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
- package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
- package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
- package/vendor/node_modules/safer-buffer/LICENSE +21 -0
- package/vendor/node_modules/safer-buffer/Readme.md +156 -0
- package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
- package/vendor/node_modules/safer-buffer/package.json +34 -0
- package/vendor/node_modules/safer-buffer/safer.js +77 -0
- package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
- package/vendor/node_modules/tweetnacl/LICENSE +24 -0
- package/vendor/node_modules/tweetnacl/README.md +459 -0
- package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
- package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
- package/vendor/node_modules/tweetnacl/package.json +58 -0
- package/vendor/ssh2/LICENSE +19 -0
- package/vendor/ssh2/lib/Channel.js +295 -0
- package/vendor/ssh2/lib/agent.js +1123 -0
- package/vendor/ssh2/lib/client.js +2176 -0
- package/vendor/ssh2/lib/http-agents.js +84 -0
- package/vendor/ssh2/lib/index.js +44 -0
- package/vendor/ssh2/lib/keygen.js +582 -0
- package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
- package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
- package/vendor/ssh2/lib/protocol/constants.js +356 -0
- package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
- package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
- package/vendor/ssh2/lib/protocol/handlers.js +16 -0
- package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
- package/vendor/ssh2/lib/protocol/kex.js +1908 -0
- package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
- package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
- package/vendor/ssh2/lib/protocol/utils.js +356 -0
- package/vendor/ssh2/lib/protocol/zlib.js +255 -0
- package/vendor/ssh2/lib/server.js +1380 -0
- package/vendor/ssh2/lib/utils.js +336 -0
- package/vendor/ssh2/package.json +34 -0
- package/vendor/xterm.js +53 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session broker: one pooled SSH connection per managed server.
|
|
3
|
+
*
|
|
4
|
+
* Why: every `exec`/SFTP call used to pay a full TCP + handshake + auth
|
|
5
|
+
* (~300ms) plus channel/SFTP setup (~150-250ms). The broker keeps one ssh2
|
|
6
|
+
* Client per server (SSH multiplexes channels natively), reuses the SFTP
|
|
7
|
+
* subsystem, enforces bounded concurrency, evicts idle connections, and
|
|
8
|
+
* reconnects once when the connection (not the command) fails.
|
|
9
|
+
*
|
|
10
|
+
* Invariants:
|
|
11
|
+
* - Never fake success: a connection lost mid-command is reported as
|
|
12
|
+
* `unconfirmed`/failed, never as a clean exit.
|
|
13
|
+
* - Never hang: every operation has a timeout; SFTP errors are always
|
|
14
|
+
* normalized and rejected (no recursive normalizer).
|
|
15
|
+
* - Never leak: idle sweep + plugin unload (`closeAll`) close every client.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
SshError,
|
|
24
|
+
classifyExecFailure,
|
|
25
|
+
errorResult,
|
|
26
|
+
isBenignReadOnlyExit,
|
|
27
|
+
isConnectionLevelError,
|
|
28
|
+
normalizeError,
|
|
29
|
+
suggestedActionFor,
|
|
30
|
+
toSftpError,
|
|
31
|
+
} from './errors.js'
|
|
32
|
+
import { effectiveDialect } from './dialect.js'
|
|
33
|
+
|
|
34
|
+
const BROKER_DEFAULTS = {
|
|
35
|
+
connectTimeoutMs: 15000,
|
|
36
|
+
commandTimeoutMs: 60000,
|
|
37
|
+
sftpTimeoutMs: 30000,
|
|
38
|
+
maxOutputBytes: 100 * 1024,
|
|
39
|
+
// stderr is mostly noise/diagnostics; keeping it well below stdout protects
|
|
40
|
+
// the model context from a single chatty (or failing) command.
|
|
41
|
+
maxStderrBytes: 16 * 1024,
|
|
42
|
+
keepaliveInterval: 30000,
|
|
43
|
+
keepaliveCountMax: 3,
|
|
44
|
+
poolIdleMs: 90000,
|
|
45
|
+
poolMaxServers: 8,
|
|
46
|
+
maxChannelsPerServer: 6,
|
|
47
|
+
sweepIntervalMs: 15000,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
/** POSIX single-quote escaping for a remote path. */
|
|
52
|
+
function quotePosix(value) {
|
|
53
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** PowerShell single-quote escaping. */
|
|
57
|
+
function quotePowerShell(value) {
|
|
58
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wrap a command so it runs inside `cwd` (SSH exec channels have no cwd of
|
|
63
|
+
* their own). POSIX uses `cd -- <dir> && ...`; Windows uses cmd or PowerShell
|
|
64
|
+
* depending on the configured shell.
|
|
65
|
+
*/
|
|
66
|
+
function withCwd(command, cwd, server = {}) {
|
|
67
|
+
const dir = String(cwd ?? '').trim()
|
|
68
|
+
if (!dir) return command
|
|
69
|
+
if (effectiveDialect(server) === 'windows') {
|
|
70
|
+
if (/powershell/i.test(String(server.defaultShell ?? ''))) {
|
|
71
|
+
return `Set-Location -LiteralPath ${quotePowerShell(dir)}; ${command}`
|
|
72
|
+
}
|
|
73
|
+
return `cd /d "${String(dir).replace(/"/g, '""')}" && ${command}`
|
|
74
|
+
}
|
|
75
|
+
return `cd -- ${quotePosix(dir)} && ${command}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function expandHome(input) {
|
|
79
|
+
if (typeof input !== 'string') return ''
|
|
80
|
+
if (input === '~') return os.homedir()
|
|
81
|
+
if (input.startsWith('~/')) return path.join(os.homedir(), input.slice(2))
|
|
82
|
+
return input
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createCapture(cap) {
|
|
86
|
+
const headCap = Math.floor(cap / 2)
|
|
87
|
+
let head = ''
|
|
88
|
+
let tail = ''
|
|
89
|
+
let original = 0
|
|
90
|
+
return {
|
|
91
|
+
add(text) {
|
|
92
|
+
original += Buffer.byteLength(text, 'utf8')
|
|
93
|
+
let rest = text
|
|
94
|
+
if (head.length < headCap) {
|
|
95
|
+
const room = headCap - head.length
|
|
96
|
+
if (rest.length <= room) {
|
|
97
|
+
head += rest
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
head += rest.slice(0, room)
|
|
101
|
+
rest = rest.slice(room)
|
|
102
|
+
}
|
|
103
|
+
if (rest.length > 0) {
|
|
104
|
+
tail += rest
|
|
105
|
+
if (tail.length > cap) tail = tail.slice(-Math.floor(cap / 2))
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
finish() {
|
|
109
|
+
const truncated = original > cap
|
|
110
|
+
const text = truncated ? `${head}\n...[truncated ${original - head.length - tail.length} bytes]...\n${tail}` : head + tail
|
|
111
|
+
return { text, truncated, original }
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} deps
|
|
119
|
+
* @param {(key: string) => string|undefined} [deps.getSecret]
|
|
120
|
+
* @param {{ verify: Function, has: Function }} [deps.knownHosts]
|
|
121
|
+
* @param {object} [deps.config] plugin config slice
|
|
122
|
+
* @param {(message: string, fields?: object) => void} [deps.log]
|
|
123
|
+
* @param {new () => any} [deps.ClientCtor] test seam (defaults to ssh2.Client)
|
|
124
|
+
*/
|
|
125
|
+
export function createSessionBroker({ getSecret, knownHosts, config = {}, log = () => {}, ClientCtor } = {}) {
|
|
126
|
+
const settings = { ...BROKER_DEFAULTS, ...Object.fromEntries(Object.entries(config ?? {}).filter(([, value]) => value !== undefined)) }
|
|
127
|
+
// Kill switch: `connectionPool: false` restores the old "one connection per
|
|
128
|
+
// call" behaviour without touching the rest of the pipeline.
|
|
129
|
+
const poolEnabled = settings.connectionPool !== false
|
|
130
|
+
const now = () => Date.now()
|
|
131
|
+
|
|
132
|
+
let ssh2Promise
|
|
133
|
+
const loadSsh2 = async () => {
|
|
134
|
+
if (ClientCtor) return { Client: ClientCtor }
|
|
135
|
+
if (!ssh2Promise) {
|
|
136
|
+
// ssh2 is vendored (pure JS) so installing the plugin never invokes
|
|
137
|
+
// node-gyp or downloads packages at runtime; see vendor/NOTICE.md.
|
|
138
|
+
ssh2Promise = import('../../vendor/ssh2/lib/index.js').then((module) => {
|
|
139
|
+
// ssh2 is CommonJS: depending on the Node interop the named exports may
|
|
140
|
+
// be absent, leaving the whole module under `default`.
|
|
141
|
+
const lib = module && (module.Client || module.Server) ? module : module?.default
|
|
142
|
+
if (!lib || typeof lib.Client !== 'function') throw new SshError('EXEC_FAILED', 'ssh2 module has no Client export')
|
|
143
|
+
return lib
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
return ssh2Promise
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @type {Map<string, any>} server.id → entry */
|
|
150
|
+
const entries = new Map()
|
|
151
|
+
function authFor(server) {
|
|
152
|
+
const ref = server.authRef
|
|
153
|
+
if (!ref || ref.type !== 'sshKey') {
|
|
154
|
+
const password = getSecret?.(`server:${server.id}:password`)
|
|
155
|
+
if (!password) throw new SshError('AUTH_MISSING', `no password stored for ${server.name}`)
|
|
156
|
+
return { password }
|
|
157
|
+
}
|
|
158
|
+
const keyPath = expandHome(ref.path)
|
|
159
|
+
let privateKey
|
|
160
|
+
try {
|
|
161
|
+
privateKey = fs.readFileSync(keyPath)
|
|
162
|
+
} catch (error) {
|
|
163
|
+
throw new SshError('AUTH_FAILED', `cannot read private key ${ref.path}: ${error instanceof Error ? error.message : String(error)}`)
|
|
164
|
+
}
|
|
165
|
+
const passphrase = getSecret?.(`server:${server.id}:passphrase`)
|
|
166
|
+
return { privateKey, ...(passphrase ? { passphrase } : {}) }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function serverKey(server) {
|
|
170
|
+
const ref = server.authRef ?? {}
|
|
171
|
+
let auth
|
|
172
|
+
if (ref.type === 'sshKey') auth = `key:${ref.path ?? ''}`
|
|
173
|
+
else {
|
|
174
|
+
const secret = getSecret?.(`server:${server.id}:password`) ?? ''
|
|
175
|
+
auth = `pwd:${createHash('sha256').update(secret).digest('hex').slice(0, 16)}`
|
|
176
|
+
}
|
|
177
|
+
return `${server.host}|${server.port}|${server.username}|${auth}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function connectOptions(server, state) {
|
|
181
|
+
const hostKeyMode = settings.hostKeyMode === 'accept-new' ? 'accept-new' : 'strict'
|
|
182
|
+
return {
|
|
183
|
+
host: server.host,
|
|
184
|
+
port: server.port,
|
|
185
|
+
username: server.username,
|
|
186
|
+
readyTimeout: settings.connectTimeoutMs,
|
|
187
|
+
keepaliveInterval: settings.keepaliveInterval,
|
|
188
|
+
keepaliveCountMax: settings.keepaliveCountMax,
|
|
189
|
+
hostHash: 'sha256',
|
|
190
|
+
hostVerifier: (hashedKey) => {
|
|
191
|
+
const fingerprint = Buffer.isBuffer(hashedKey) ? hashedKey.toString('hex') : String(hashedKey)
|
|
192
|
+
state.fingerprint = fingerprint
|
|
193
|
+
return knownHosts ? knownHosts.verify(server, fingerprint, hostKeyMode) : hostKeyMode === 'accept-new'
|
|
194
|
+
},
|
|
195
|
+
...authFor(server),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Open a fresh client and resolve on `ready`. */
|
|
200
|
+
async function connectClient(server) {
|
|
201
|
+
const { Client } = await loadSsh2()
|
|
202
|
+
const state = {}
|
|
203
|
+
const client = new Client()
|
|
204
|
+
const started = now()
|
|
205
|
+
return await new Promise((resolve, reject) => {
|
|
206
|
+
let settled = false
|
|
207
|
+
const fail = (error) => {
|
|
208
|
+
if (settled) return
|
|
209
|
+
settled = true
|
|
210
|
+
try {
|
|
211
|
+
client.end()
|
|
212
|
+
} catch {
|
|
213
|
+
// socket may already be gone
|
|
214
|
+
}
|
|
215
|
+
reject(normalizeError(error, state.fingerprint))
|
|
216
|
+
}
|
|
217
|
+
client.once('ready', () => {
|
|
218
|
+
if (settled) return
|
|
219
|
+
settled = true
|
|
220
|
+
resolve({ client, fingerprint: state.fingerprint, connectMs: now() - started })
|
|
221
|
+
})
|
|
222
|
+
client.once('error', fail)
|
|
223
|
+
// Keep a listener after `ready`: ssh2 can emit a second error while the
|
|
224
|
+
// socket tears down, and an unhandled 'error' would crash the host.
|
|
225
|
+
client.on('error', (error) => {
|
|
226
|
+
log('ssh client error', { server: server.name, error: error instanceof Error ? error.message : String(error) })
|
|
227
|
+
})
|
|
228
|
+
try {
|
|
229
|
+
client.connect(connectOptions(server, state))
|
|
230
|
+
} catch (error) {
|
|
231
|
+
fail(error)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function markDead(entry, reason = 'connection lost') {
|
|
237
|
+
if (entry.state === 'dead') return
|
|
238
|
+
entry.state = 'dead'
|
|
239
|
+
if (entries.get(entry.id) === entry) entries.delete(entry.id)
|
|
240
|
+
try {
|
|
241
|
+
entry.sftp?.end?.()
|
|
242
|
+
} catch {
|
|
243
|
+
// best effort
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
entry.client.end()
|
|
247
|
+
} catch {
|
|
248
|
+
// best effort
|
|
249
|
+
}
|
|
250
|
+
const waiters = entry.waiters.splice(0)
|
|
251
|
+
for (const wake of waiters) wake()
|
|
252
|
+
log('ssh connection marked dead', { server: entry.name, reason })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function evict(entry, reason = 'evicted') {
|
|
256
|
+
if (!entry) return
|
|
257
|
+
if (entry.state !== 'dead') entry.state = 'dead'
|
|
258
|
+
if (entries.get(entry.id) === entry) entries.delete(entry.id)
|
|
259
|
+
try {
|
|
260
|
+
entry.sftp?.end?.()
|
|
261
|
+
} catch {
|
|
262
|
+
// best effort
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
entry.client.end()
|
|
266
|
+
} catch {
|
|
267
|
+
// best effort
|
|
268
|
+
}
|
|
269
|
+
const waiters = entry.waiters.splice(0)
|
|
270
|
+
for (const wake of waiters) wake()
|
|
271
|
+
log('ssh connection evicted', { server: entry.name, reason })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function trimIdle() {
|
|
275
|
+
const idle = [...entries.values()].filter((entry) => entry.channels === 0 && entry.state === 'ready')
|
|
276
|
+
while (entries.size > settings.poolMaxServers && idle.length > 0) {
|
|
277
|
+
const oldest = idle.sort((left, right) => left.lastUsed - right.lastUsed)[0]
|
|
278
|
+
idle.shift()
|
|
279
|
+
evict(oldest, 'pool capacity')
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
async function acquire(server) {
|
|
285
|
+
const id = server.id ?? server.name
|
|
286
|
+
const key = serverKey(server)
|
|
287
|
+
let entry = entries.get(id)
|
|
288
|
+
let warm = false
|
|
289
|
+
|
|
290
|
+
if (entry && entry.key !== key) {
|
|
291
|
+
evict(entry, 'server settings changed')
|
|
292
|
+
entry = undefined
|
|
293
|
+
}
|
|
294
|
+
if (entry && entry.state === 'dead') {
|
|
295
|
+
entries.delete(id)
|
|
296
|
+
entry = undefined
|
|
297
|
+
}
|
|
298
|
+
if (entry && entry.state === 'connecting' && entry.pending) {
|
|
299
|
+
const ready = await entry.pending
|
|
300
|
+
return { entry: ready, warm: true }
|
|
301
|
+
}
|
|
302
|
+
if (entry && entry.state === 'ready' && !poolEnabled) {
|
|
303
|
+
evict(entry, 'pool disabled')
|
|
304
|
+
entry = undefined
|
|
305
|
+
}
|
|
306
|
+
if (entry && entry.state === 'ready') {
|
|
307
|
+
entry.lastUsed = now()
|
|
308
|
+
warm = true
|
|
309
|
+
return { entry, warm }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
trimIdle()
|
|
313
|
+
const created = {
|
|
314
|
+
id,
|
|
315
|
+
name: server.name,
|
|
316
|
+
key,
|
|
317
|
+
client: null,
|
|
318
|
+
fingerprint: undefined,
|
|
319
|
+
sftp: null,
|
|
320
|
+
sftpPending: null,
|
|
321
|
+
state: 'connecting',
|
|
322
|
+
pending: null,
|
|
323
|
+
channels: 0,
|
|
324
|
+
waiters: [],
|
|
325
|
+
lastUsed: now(),
|
|
326
|
+
createdAt: now(),
|
|
327
|
+
connectMs: 0,
|
|
328
|
+
}
|
|
329
|
+
created.pending = (async () => {
|
|
330
|
+
try {
|
|
331
|
+
const { client, fingerprint, connectMs } = await connectClient(server)
|
|
332
|
+
created.client = client
|
|
333
|
+
created.fingerprint = fingerprint
|
|
334
|
+
created.connectMs = connectMs
|
|
335
|
+
created.state = 'ready'
|
|
336
|
+
created.pending = null
|
|
337
|
+
created.lastUsed = now()
|
|
338
|
+
client.on('close', () => markDead(created, 'connection closed'))
|
|
339
|
+
return created
|
|
340
|
+
} catch (error) {
|
|
341
|
+
created.state = 'dead'
|
|
342
|
+
created.pending = null
|
|
343
|
+
if (entries.get(id) === created) entries.delete(id)
|
|
344
|
+
throw error
|
|
345
|
+
}
|
|
346
|
+
})()
|
|
347
|
+
entries.set(id, created)
|
|
348
|
+
try {
|
|
349
|
+
const ready = await created.pending
|
|
350
|
+
return { entry: ready, warm: false }
|
|
351
|
+
} catch (error) {
|
|
352
|
+
throw error instanceof SshError ? error : normalizeError(error)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function acquireChannel(entry) {
|
|
357
|
+
if (entry.channels < settings.maxChannelsPerServer) {
|
|
358
|
+
entry.channels += 1
|
|
359
|
+
return Promise.resolve()
|
|
360
|
+
}
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
entry.waiters.push(resolve)
|
|
363
|
+
}).then(() => {
|
|
364
|
+
entry.channels += 1
|
|
365
|
+
})
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function releaseChannel(entry) {
|
|
369
|
+
if (entry.channels > 0) entry.channels -= 1
|
|
370
|
+
const wake = entry.waiters.shift()
|
|
371
|
+
if (wake) wake()
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function ensureSftp(entry) {
|
|
375
|
+
if (entry.sftp) return Promise.resolve(entry.sftp)
|
|
376
|
+
if (entry.sftpPending) return entry.sftpPending
|
|
377
|
+
entry.sftpPending = new Promise((resolve, reject) => {
|
|
378
|
+
try {
|
|
379
|
+
entry.client.sftp((error, session) => (error ? reject(toSftpError(error)) : resolve(session)))
|
|
380
|
+
} catch (error) {
|
|
381
|
+
reject(toSftpError(error))
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
return entry.sftpPending
|
|
385
|
+
.then((session) => {
|
|
386
|
+
entry.sftp = session
|
|
387
|
+
return session
|
|
388
|
+
})
|
|
389
|
+
.finally(() => {
|
|
390
|
+
entry.sftpPending = null
|
|
391
|
+
})
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Run one command on an established entry. Never throws for command-level
|
|
396
|
+
* failures; returns `{ result, retryable }`.
|
|
397
|
+
*/
|
|
398
|
+
function runOnEntry(entry, command, options = {}, startedAt = now()) {
|
|
399
|
+
const timeoutMs = options.timeoutMs ?? settings.commandTimeoutMs
|
|
400
|
+
const cap = options.cap ?? settings.maxOutputBytes
|
|
401
|
+
const stderrCap = options.stderrCap ?? settings.maxStderrBytes
|
|
402
|
+
const out = createCapture(cap)
|
|
403
|
+
const err = createCapture(stderrCap)
|
|
404
|
+
const warm = options.warm === true
|
|
405
|
+
const connectMs = entry.connectMs ?? 0
|
|
406
|
+
const channelStarted = now()
|
|
407
|
+
|
|
408
|
+
return acquireChannel(entry).then(
|
|
409
|
+
() =>
|
|
410
|
+
new Promise((resolve) => {
|
|
411
|
+
const channelMs = now() - channelStarted
|
|
412
|
+
const commandStarted = now()
|
|
413
|
+
let settled = false
|
|
414
|
+
let exitCode = null
|
|
415
|
+
let exitSignal = null
|
|
416
|
+
let retryable = false
|
|
417
|
+
let stream
|
|
418
|
+
let killTimer
|
|
419
|
+
let firstDataAt = 0
|
|
420
|
+
|
|
421
|
+
const finish = (forcedCode, forcedMessage) => {
|
|
422
|
+
if (settled) return
|
|
423
|
+
settled = true
|
|
424
|
+
clearTimeout(timer)
|
|
425
|
+
if (killTimer) clearTimeout(killTimer)
|
|
426
|
+
options.signal?.removeEventListener?.('abort', onAbort)
|
|
427
|
+
releaseChannel(entry)
|
|
428
|
+
const stdout = out.finish()
|
|
429
|
+
const stderr = err.finish()
|
|
430
|
+
const durationMs = now() - startedAt
|
|
431
|
+
const timings = {
|
|
432
|
+
connectMs: Math.round(connectMs),
|
|
433
|
+
channelMs: Math.round(channelMs),
|
|
434
|
+
commandMs: Math.round(now() - commandStarted),
|
|
435
|
+
totalMs: Math.round(durationMs),
|
|
436
|
+
}
|
|
437
|
+
if (forcedCode) {
|
|
438
|
+
resolve({
|
|
439
|
+
result: errorResult(forcedCode, forcedMessage ?? 'operation failed', {
|
|
440
|
+
stdout: stdout.text,
|
|
441
|
+
stderr: stderr.text,
|
|
442
|
+
durationMs,
|
|
443
|
+
truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
|
|
444
|
+
warm,
|
|
445
|
+
timings,
|
|
446
|
+
...(firstDataAt ? { firstDataAt: Math.round(firstDataAt - startedAt) } : {}),
|
|
447
|
+
}),
|
|
448
|
+
retryable,
|
|
449
|
+
})
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
// An exec channel may close without an explicit exit status (the
|
|
453
|
+
// server called end() instead of exit()). Treat that as success
|
|
454
|
+
// unless a terminating signal was observed.
|
|
455
|
+
const missingCode = exitCode === null || exitCode === undefined
|
|
456
|
+
// The remote `timeout` wrapper exits 124 when its own deadline fired
|
|
457
|
+
// before the client-side timer did (e.g. the host event loop was
|
|
458
|
+
// busy). Report that as a timeout, not as an ordinary exit 124.
|
|
459
|
+
if (!missingCode && exitCode === 124 && now() - commandStarted >= timeoutMs - 1500) {
|
|
460
|
+
resolve({
|
|
461
|
+
result: {
|
|
462
|
+
outcome: 'timeout',
|
|
463
|
+
exitCode: null,
|
|
464
|
+
signal: null,
|
|
465
|
+
stdout: stdout.text,
|
|
466
|
+
stderr: stderr.text,
|
|
467
|
+
durationMs,
|
|
468
|
+
truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
|
|
469
|
+
warm,
|
|
470
|
+
timings,
|
|
471
|
+
...(firstDataAt ? { firstDataAt: Math.round(firstDataAt - startedAt) } : {}),
|
|
472
|
+
note: 'remote timeout deadline reached',
|
|
473
|
+
},
|
|
474
|
+
retryable: false,
|
|
475
|
+
})
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
const failed = (!missingCode && exitCode !== 0) || Boolean(exitSignal)
|
|
479
|
+
const benign = failed && isBenignReadOnlyExit({ readOnly: options.readOnly === true, exitCode, stderr: stderr.text, signal: exitSignal })
|
|
480
|
+
resolve({
|
|
481
|
+
result: {
|
|
482
|
+
outcome: failed && !benign ? 'failed' : 'success',
|
|
483
|
+
exitCode,
|
|
484
|
+
signal: exitSignal,
|
|
485
|
+
stdout: stdout.text,
|
|
486
|
+
stderr: stderr.text,
|
|
487
|
+
durationMs,
|
|
488
|
+
truncated: { stdout: stdout.truncated, stderr: stderr.truncated },
|
|
489
|
+
warm,
|
|
490
|
+
timings,
|
|
491
|
+
...(firstDataAt ? { firstDataAt: Math.round(firstDataAt - startedAt) } : {}),
|
|
492
|
+
...(benign
|
|
493
|
+
? { note: 'exit code 1 with empty stderr is normal for grep/diff (no match or difference); treated as a successful read' }
|
|
494
|
+
: failed
|
|
495
|
+
? (() => {
|
|
496
|
+
const failureCode = classifyExecFailure({ exitCode, stderr: stderr.text, signal: exitSignal })
|
|
497
|
+
return { errorCode: failureCode, suggestedAction: suggestedActionFor(failureCode) }
|
|
498
|
+
})()
|
|
499
|
+
: {}),
|
|
500
|
+
},
|
|
501
|
+
retryable: false,
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const onAbort = () => {
|
|
506
|
+
finish('CANCELLED', 'operation cancelled')
|
|
507
|
+
closeStream()
|
|
508
|
+
}
|
|
509
|
+
const closeStream = () => {
|
|
510
|
+
try {
|
|
511
|
+
stream?.close?.()
|
|
512
|
+
} catch {
|
|
513
|
+
// channel may already be closed
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
stream?.destroy?.()
|
|
517
|
+
} catch {
|
|
518
|
+
// best effort
|
|
519
|
+
}
|
|
520
|
+
// A command that ignores channel close must not pin the pooled
|
|
521
|
+
// connection forever: evict after a short grace period.
|
|
522
|
+
killTimer = setTimeout(() => {
|
|
523
|
+
if (entry.state === 'ready') markDead(entry, 'timed-out command left the channel open')
|
|
524
|
+
}, 2000)
|
|
525
|
+
if (killTimer.unref) killTimer.unref()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const timer = setTimeout(() => {
|
|
529
|
+
finish('TIMEOUT', `command timed out after ${timeoutMs}ms`)
|
|
530
|
+
closeStream()
|
|
531
|
+
}, timeoutMs)
|
|
532
|
+
if (timer.unref) timer.unref()
|
|
533
|
+
|
|
534
|
+
if (options.signal?.aborted) {
|
|
535
|
+
finish('CANCELLED', 'operation aborted before start')
|
|
536
|
+
return
|
|
537
|
+
}
|
|
538
|
+
options.signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
entry.client.exec(command, (error, nextStream) => {
|
|
542
|
+
if (error) {
|
|
543
|
+
retryable = isConnectionLevelError(error)
|
|
544
|
+
if (retryable) markDead(entry, 'exec channel failed')
|
|
545
|
+
finish('EXEC_FAILED', error instanceof Error ? error.message : String(error))
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
stream = nextStream
|
|
549
|
+
stream.on('data', (chunk) => {
|
|
550
|
+
if (!firstDataAt) firstDataAt = now()
|
|
551
|
+
out.add(chunk.toString('utf8'))
|
|
552
|
+
})
|
|
553
|
+
stream.stderr.on('data', (chunk) => err.add(chunk.toString('utf8')))
|
|
554
|
+
stream.once('close', (code, signal) => {
|
|
555
|
+
exitCode = code === undefined ? null : code
|
|
556
|
+
exitSignal = signal === undefined ? null : signal
|
|
557
|
+
finish()
|
|
558
|
+
})
|
|
559
|
+
stream.once('error', (streamError) => {
|
|
560
|
+
retryable = isConnectionLevelError(streamError)
|
|
561
|
+
if (retryable) markDead(entry, 'exec stream failed')
|
|
562
|
+
finish('EXEC_FAILED', streamError instanceof Error ? streamError.message : String(streamError))
|
|
563
|
+
})
|
|
564
|
+
// The broker never writes to a command's stdin. Sending EOF right
|
|
565
|
+
// away turns stdin-reading commands (`cat`, `read`, interactive
|
|
566
|
+
// installers) into immediate EOF instead of hanging until the
|
|
567
|
+
// timeout; it is equivalent to a remote `</dev/null`.
|
|
568
|
+
try {
|
|
569
|
+
stream.end?.()
|
|
570
|
+
} catch {
|
|
571
|
+
// channel may already be half-closed
|
|
572
|
+
}
|
|
573
|
+
})
|
|
574
|
+
} catch (error) {
|
|
575
|
+
retryable = isConnectionLevelError(error)
|
|
576
|
+
if (retryable) markDead(entry, 'exec threw')
|
|
577
|
+
finish('EXEC_FAILED', error instanceof Error ? error.message : String(error))
|
|
578
|
+
}
|
|
579
|
+
}),
|
|
580
|
+
)
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function exec(server, command, options = {}) {
|
|
584
|
+
const startedAt = now()
|
|
585
|
+
// SSH exec channels start in the login home directory; `cwd` is applied as
|
|
586
|
+
// a `cd` prefix so callers can keep a working directory per call.
|
|
587
|
+
const remoteCommand = options.cwd ? withCwd(command, options.cwd, server) : command
|
|
588
|
+
let attempt = 0
|
|
589
|
+
for (;;) {
|
|
590
|
+
let acquired
|
|
591
|
+
try {
|
|
592
|
+
acquired = await acquire(server)
|
|
593
|
+
} catch (error) {
|
|
594
|
+
const normalized = error instanceof SshError ? error : normalizeError(error)
|
|
595
|
+
const result = errorResult(normalized.code, normalized.message, { durationMs: now() - startedAt })
|
|
596
|
+
if (normalized.fingerprint) result.fingerprint = normalized.fingerprint
|
|
597
|
+
return result
|
|
598
|
+
}
|
|
599
|
+
const { entry, warm } = acquired
|
|
600
|
+
const { result, retryable } = await runOnEntry(entry, remoteCommand, { ...options, warm }, startedAt)
|
|
601
|
+
// Retry once only for idempotent read-only commands whose failure was a
|
|
602
|
+
// connection-level error before any output arrived. Never retry a write:
|
|
603
|
+
// the remote side may already have executed it.
|
|
604
|
+
const canRetry =
|
|
605
|
+
retryable && attempt === 0 && options.readOnly === true && !result.stdout && result.outcome === 'failed'
|
|
606
|
+
if (canRetry) {
|
|
607
|
+
attempt += 1
|
|
608
|
+
evict(entry, 'retry after connection error')
|
|
609
|
+
continue
|
|
610
|
+
}
|
|
611
|
+
if (!poolEnabled) evict(entry, 'pool disabled')
|
|
612
|
+
return result
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function withSftp(server, handler, options = {}) {
|
|
617
|
+
const timeoutMs = options.timeoutMs ?? settings.sftpTimeoutMs
|
|
618
|
+
let acquired
|
|
619
|
+
try {
|
|
620
|
+
acquired = await acquire(server)
|
|
621
|
+
} catch (error) {
|
|
622
|
+
throw toSftpError(error)
|
|
623
|
+
}
|
|
624
|
+
const { entry } = acquired
|
|
625
|
+
await acquireChannel(entry)
|
|
626
|
+
let timer
|
|
627
|
+
try {
|
|
628
|
+
const sftp = await ensureSftp(entry)
|
|
629
|
+
const work = Promise.resolve().then(() => handler(sftp))
|
|
630
|
+
work.catch(() => {})
|
|
631
|
+
const guarded = new Promise((_, reject) => {
|
|
632
|
+
timer = setTimeout(() => reject(new SshError('TIMEOUT', `SFTP operation timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
633
|
+
if (timer.unref) timer.unref()
|
|
634
|
+
})
|
|
635
|
+
return await Promise.race([work, guarded])
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (isConnectionLevelError(error)) markDead(entry, 'sftp failed')
|
|
638
|
+
throw toSftpError(error)
|
|
639
|
+
} finally {
|
|
640
|
+
clearTimeout(timer)
|
|
641
|
+
releaseChannel(entry)
|
|
642
|
+
if (!poolEnabled) evict(entry, 'pool disabled')
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function test(server) {
|
|
647
|
+
const started = now()
|
|
648
|
+
let acquired
|
|
649
|
+
try {
|
|
650
|
+
acquired = await acquire(server)
|
|
651
|
+
} catch (error) {
|
|
652
|
+
const normalized = error instanceof SshError ? error : normalizeError(error)
|
|
653
|
+
return {
|
|
654
|
+
ok: false,
|
|
655
|
+
errorCode: normalized.code,
|
|
656
|
+
message: normalized.message,
|
|
657
|
+
latencyMs: now() - started,
|
|
658
|
+
fingerprint: normalized.fingerprint,
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const { entry, warm } = acquired
|
|
662
|
+
const probe = effectiveDialect(server) === 'windows' ? 'cmd /c ver' : 'uname -s'
|
|
663
|
+
const { result } = await runOnEntry(
|
|
664
|
+
entry,
|
|
665
|
+
probe,
|
|
666
|
+
{ timeoutMs: Math.min(settings.commandTimeoutMs, 15000), readOnly: true, warm },
|
|
667
|
+
started,
|
|
668
|
+
)
|
|
669
|
+
if (!poolEnabled) evict(entry, 'pool disabled')
|
|
670
|
+
return {
|
|
671
|
+
ok: result.outcome === 'success',
|
|
672
|
+
latencyMs: now() - started,
|
|
673
|
+
platform: effectiveDialect(server),
|
|
674
|
+
hostKey: knownHosts?.has(server) ? 'trusted' : 'unknown',
|
|
675
|
+
probe: String(result.stdout ?? '').trim().split('\n')[0] ?? '',
|
|
676
|
+
errorCode: result.errorCode,
|
|
677
|
+
fingerprint: entry.fingerprint,
|
|
678
|
+
warm,
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Interactive PTY shell on a dedicated connection (not pooled): terminals are
|
|
684
|
+
* long-lived and must not share a connection that an idle sweep could close.
|
|
685
|
+
*/
|
|
686
|
+
async function shell(server, options = {}) {
|
|
687
|
+
const { client } = await connectClient(server)
|
|
688
|
+
const rows = Number.isInteger(options.rows) ? options.rows : 40
|
|
689
|
+
const cols = Number.isInteger(options.cols) ? options.cols : 120
|
|
690
|
+
let stream
|
|
691
|
+
try {
|
|
692
|
+
stream = await new Promise((resolve, reject) => {
|
|
693
|
+
client.shell({ term: options.term ?? 'xterm-256color', rows, cols }, (error, channel) => {
|
|
694
|
+
if (error) reject(normalizeError(error))
|
|
695
|
+
else resolve(channel)
|
|
696
|
+
})
|
|
697
|
+
})
|
|
698
|
+
} catch (error) {
|
|
699
|
+
try {
|
|
700
|
+
client.end()
|
|
701
|
+
} catch {
|
|
702
|
+
// socket may already be gone
|
|
703
|
+
}
|
|
704
|
+
throw error
|
|
705
|
+
}
|
|
706
|
+
let closed = false
|
|
707
|
+
return {
|
|
708
|
+
stream,
|
|
709
|
+
write(data) {
|
|
710
|
+
if (closed) return
|
|
711
|
+
try {
|
|
712
|
+
stream.write(String(data))
|
|
713
|
+
} catch (error) {
|
|
714
|
+
log('ssh shell write failed', { server: server.name, error: error instanceof Error ? error.message : String(error) })
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
onData(handler) {
|
|
718
|
+
stream.on('data', handler)
|
|
719
|
+
},
|
|
720
|
+
resize(cols, rows) {
|
|
721
|
+
if (closed) return
|
|
722
|
+
try {
|
|
723
|
+
stream.setWindow(Number(rows) || 24, Number(cols) || 80)
|
|
724
|
+
} catch (error) {
|
|
725
|
+
log('ssh shell resize failed', { server: server.name, error: error instanceof Error ? error.message : String(error) })
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
onClose(handler) {
|
|
729
|
+
stream.once('close', handler)
|
|
730
|
+
},
|
|
731
|
+
onError(handler) {
|
|
732
|
+
stream.once('error', handler)
|
|
733
|
+
},
|
|
734
|
+
async close() {
|
|
735
|
+
if (closed) return
|
|
736
|
+
closed = true
|
|
737
|
+
try {
|
|
738
|
+
stream.end()
|
|
739
|
+
} catch {
|
|
740
|
+
// channel may already be closed
|
|
741
|
+
}
|
|
742
|
+
try {
|
|
743
|
+
client.end()
|
|
744
|
+
} catch {
|
|
745
|
+
// socket may already be closed
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** Fire-and-forget connection warm-up. */
|
|
752
|
+
function warm(server) {
|
|
753
|
+
acquire(server).catch((error) => {
|
|
754
|
+
log('ssh warm-up failed', { server: server.name, error: error instanceof Error ? error.message : String(error) })
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function invalidate(serverId) {
|
|
759
|
+
const entry = entries.get(serverId)
|
|
760
|
+
if (entry) evict(entry, 'invalidated')
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
async function closeAll() {
|
|
766
|
+
clearInterval(sweeper)
|
|
767
|
+
for (const entry of [...entries.values()]) evict(entry, 'plugin unloaded')
|
|
768
|
+
entries.clear()
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
async function preload() {
|
|
772
|
+
await loadSsh2()
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Dedicated (unpooled) connection for interactive shells and chunked transfers. */
|
|
776
|
+
async function connectDedicated(server) {
|
|
777
|
+
return await connectClient(server)
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const sweeper = setInterval(() => {
|
|
781
|
+
const cutoff = now() - settings.poolIdleMs
|
|
782
|
+
for (const entry of [...entries.values()]) {
|
|
783
|
+
if (entry.state === 'ready' && entry.channels === 0 && entry.lastUsed < cutoff) evict(entry, 'idle')
|
|
784
|
+
}
|
|
785
|
+
}, settings.sweepIntervalMs)
|
|
786
|
+
if (sweeper.unref) sweeper.unref()
|
|
787
|
+
|
|
788
|
+
return {
|
|
789
|
+
exec,
|
|
790
|
+
withSftp,
|
|
791
|
+
test,
|
|
792
|
+
shell,
|
|
793
|
+
warm,
|
|
794
|
+
invalidate,
|
|
795
|
+
closeAll,
|
|
796
|
+
preload,
|
|
797
|
+
connectDedicated,
|
|
798
|
+
}
|
|
799
|
+
}
|