dsh-server-connector 0.2.3 → 0.3.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 +72 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1728
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/pool.js
CHANGED
|
@@ -1,1464 +1,1496 @@
|
|
|
1
|
-
// dsh-server-connector — SSH connection pool and execution (design doc §7).
|
|
2
|
-
//
|
|
3
|
-
// One long-lived ssh2 connection per server (§7.2), commands on the same server
|
|
4
|
-
// serialised, different servers in parallel. The pool knows nothing about
|
|
5
|
-
// policy, tools, credentials storage or the audit log: it turns a server record
|
|
6
|
-
// into a connection or a typed error, and an execution request into
|
|
7
|
-
// `{ exitCode, stdout, stderr, timedOut, truncated }`.
|
|
8
|
-
//
|
|
9
|
-
// Design points that are easy to get wrong, and how they are handled here:
|
|
10
|
-
//
|
|
11
|
-
// - **No PTY** (§7.3): `pty: false` keeps `\r\n` and colour escapes out of the
|
|
12
|
-
// output.
|
|
13
|
-
// - **cwd** (§7.3): `cd '<cwd>' && …` is assembled on the remote side; nothing
|
|
14
|
-
// is ever passed through a local shell.
|
|
15
|
-
// - **Timeout must kill the remote process group** (§7.3). `stream.signal()`
|
|
16
|
-
// only reaches the channel's own process, so the command runs under a remote
|
|
17
|
-
// watchdog that `kill -KILL`s the child's process group. The watchdog is
|
|
18
|
-
// started with `setsid`, because on a non-interactive SSH channel the remote
|
|
19
|
-
// shell has job control off: a background command then *stays in the shell's
|
|
20
|
-
// own process group*, and `kill -- -$child` would address a pid that is not a
|
|
21
|
-
// process-group leader (it silently fails, leaving the process alive). With
|
|
22
|
-
// `setsid` the child really is its own group leader, so the kill lands.
|
|
23
|
-
// The watchdog subshell's own stdout/stderr are redirected to /dev/null:
|
|
24
|
-
// otherwise its `sleep` inherits the channel's stdout pipe, sshd waits for
|
|
25
|
-
// that pipe to close, and *every* command returns only when its timeout
|
|
26
|
-
// expires (measured: 5000 ms instead of 48 ms with the redirect).
|
|
27
|
-
// - **Reconnect once** (§7.2): a connection that fails while idle is rebuilt on
|
|
28
|
-
// the next call; if that also fails, the caller gets `CONNECT_FAILED`.
|
|
29
|
-
// - **Host keys** (§7.4): verification is delegated to the `hostVerifier`
|
|
30
|
-
// callback, which consults {@link KnownHosts} through an injected gateway, so
|
|
31
|
-
// this module stays free of file IO and is testable without a file system.
|
|
32
|
-
// - **The output ceiling is per stream, not shared** (§5.4). stdout and stderr
|
|
33
|
-
// are two independent fields of the result, so `maxOutputChars` caps each of
|
|
34
|
-
// them; one shared counter would let a chatty stdout close stderr entirely,
|
|
35
|
-
// dropping its bytes and leaving the truncation marker on a stream that was
|
|
36
|
-
// never cut.
|
|
37
|
-
// - **Error normalisation** (§7.5): every transport failure surfaces as a
|
|
38
|
-
// {@link PoolError} with one of the documented codes and an actionable hint.
|
|
39
|
-
|
|
40
|
-
import { readFile } from 'node:fs/promises'
|
|
41
|
-
|
|
42
|
-
import ssh2 from 'ssh2'
|
|
43
|
-
|
|
44
|
-
import { fingerprintHostKey } from './known-hosts.js'
|
|
45
|
-
|
|
46
|
-
/** The ssh2 client class; pulled off the default export so both ESM and CJS consumers work. */
|
|
47
|
-
const { Client } = ssh2
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The transport-layer error codes of §5.5/§7.5. `EXIT_NONZERO` is listed because
|
|
51
|
-
* the tools layer must return it, but the pool never raises it: a nonzero exit is
|
|
52
|
-
* a *result* (`exitCode`), not a transport failure — srv_exec decides whether the
|
|
53
|
-
* model sees an error. `POLICY_*` and `UNKNOWN_SERVER`/`AMBIGUOUS_SERVER` belong
|
|
54
|
-
* to the policy engine and the registry, not to this module.
|
|
55
|
-
*/
|
|
56
|
-
export const POOL_ERROR_CODES = Object.freeze([
|
|
57
|
-
'CONNECT_FAILED',
|
|
58
|
-
'HOST_KEY_UNKNOWN',
|
|
59
|
-
'AUTH_FAILED',
|
|
60
|
-
'TIMEOUT',
|
|
61
|
-
'EXIT_NONZERO',
|
|
62
|
-
'NO_CREDENTIAL',
|
|
63
|
-
'CONNECTION_LOST',
|
|
64
|
-
'INVALID_SERVER',
|
|
65
|
-
])
|
|
66
|
-
|
|
67
|
-
/** Default idle timeout of one connection (§7.2, §10). */
|
|
68
|
-
export const DEFAULT_IDLE_TIMEOUT_MS = 600000
|
|
69
|
-
|
|
70
|
-
/** Default timeout of one command (§10). */
|
|
71
|
-
export const DEFAULT_COMMAND_TIMEOUT_MS = 60000
|
|
72
|
-
|
|
73
|
-
/** Default connect timeout (§10). */
|
|
74
|
-
export const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Appended to a stream that was cut at `maxOutputChars` (§5.4). The boolean
|
|
78
|
-
* `truncated` says *that* output was lost; the marker shows *where*.
|
|
79
|
-
*
|
|
80
|
-
* The ceiling applies to each stream on its own: stdout and stderr are separate
|
|
81
|
-
* result fields, so neither can consume the other's room and the marker lands
|
|
82
|
-
* only in the stream that was actually cut.
|
|
83
|
-
*/
|
|
84
|
-
export const TRUNCATED_MARKER = '\n…[TRUNCATED]'
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Machine-readable pool failure. `code` is the stable, documented identifier
|
|
88
|
-
* (§5.5); `message` is written for the model and the panel to act on in one
|
|
89
|
-
* turn, which is why every code carries a next step.
|
|
90
|
-
*/
|
|
91
|
-
export class PoolError extends Error {
|
|
92
|
-
/**
|
|
93
|
-
* @param {typeof POOL_ERROR_CODES[number]} code
|
|
94
|
-
* @param {string} message
|
|
95
|
-
* @param {{serverId?: string, host?: string, port?: number, username?: string, cause?: unknown, producedOutput?: boolean}} [details]
|
|
96
|
-
*/
|
|
97
|
-
constructor(code, message, details = {}) {
|
|
98
|
-
super(message, details.cause === undefined ? undefined : { cause: details.cause })
|
|
99
|
-
this.name = 'PoolError'
|
|
100
|
-
this.code = code
|
|
101
|
-
this.serverId = details.serverId
|
|
102
|
-
this.host = details.host
|
|
103
|
-
this.port = details.port
|
|
104
|
-
this.username = details.username
|
|
105
|
-
/** Whether a channel had already delivered output when it failed (§7.2 retry safety). */
|
|
106
|
-
this.producedOutput = details.producedOutput
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* One command execution request.
|
|
112
|
-
* @typedef {object} ExecRequest
|
|
113
|
-
* @property {string} command - the command line, passed to `sh -c` on the remote side.
|
|
114
|
-
* @property {string|null} [cwd] - working directory; defaults to the server's `defaultWorkDir`.
|
|
115
|
-
* @property {number} [timeoutMs] - wall-clock budget of the whole command.
|
|
116
|
-
* @property {number} [maxOutputChars] - per-stream ceiling for stdout and for stderr; beyond it that stream is marked truncated.
|
|
117
|
-
*/
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* One command result.
|
|
121
|
-
* @typedef {object} ExecResult
|
|
122
|
-
* @property {string} stdout
|
|
123
|
-
* @property {string} stderr
|
|
124
|
-
* @property {number} exitCode - 0 on success; a negated signal number when the process was killed (137 = SIGKILL).
|
|
125
|
-
* @property {number} durationMs
|
|
126
|
-
* @property {boolean} timedOut - whether the remote watchdog killed the process group.
|
|
127
|
-
* @property {boolean} truncated - whether stdout or stderr crossed `maxOutputChars`.
|
|
128
|
-
* @property {boolean} reconnected - whether the command needed a fresh connection.
|
|
129
|
-
* @property {string|null} timeoutError - `TIMEOUT` when `timedOut` is true, so the
|
|
130
|
-
* tools layer can return the §5.5 code without re-deriving it from exit 137.
|
|
131
|
-
*/
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Normalise one ssh2/transport error (§7.5). Exported so the tools layer can map
|
|
135
|
-
* errors it catches outside a pool call (e.g. from `ctx.credentials`) the same way.
|
|
136
|
-
* @param {unknown} error
|
|
137
|
-
* @param {{serverId?: string, host?: string, port?: number, username?: string, phase?: 'connect'|'handshake'|'exec'}} [context]
|
|
138
|
-
* @returns {PoolError}
|
|
139
|
-
*/
|
|
140
|
-
export function normalizeError(error, context = {}) {
|
|
141
|
-
const details = {
|
|
142
|
-
serverId: context.serverId,
|
|
143
|
-
host: context.host,
|
|
144
|
-
port: context.port,
|
|
145
|
-
username: context.username,
|
|
146
|
-
cause: error,
|
|
147
|
-
}
|
|
148
|
-
const where = describeTarget(context)
|
|
149
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
150
|
-
const errno = isPlainObject(error) ? error.code : undefined
|
|
151
|
-
const level = isPlainObject(error) ? error.level : undefined
|
|
152
|
-
|
|
153
|
-
// §7.5 row 1: the socket never came up.
|
|
154
|
-
if (errno === 'ECONNREFUSED' || errno === 'EHOSTUNREACH' || errno === 'ENETUNREACH' || errno === 'ENOTFOUND') {
|
|
155
|
-
return new PoolError(
|
|
156
|
-
'CONNECT_FAILED',
|
|
157
|
-
`Cannot reach ${where}: ${message}. Check the host/port and that sshd is running on the target.`,
|
|
158
|
-
details,
|
|
159
|
-
)
|
|
160
|
-
}
|
|
161
|
-
if (errno === 'ETIMEDOUT' || level === 'client-timeout') {
|
|
162
|
-
return new PoolError(
|
|
163
|
-
'CONNECT_FAILED',
|
|
164
|
-
`Connecting to ${where} timed out. Check the host/port, the network path, and that sshd is running on the target.`,
|
|
165
|
-
details,
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
// §7.5 rows 3 and 4: hostVerifier refused the key.
|
|
169
|
-
if (error instanceof HostKeyRejectedError) {
|
|
170
|
-
return new PoolError('HOST_KEY_UNKNOWN', error.message, details)
|
|
171
|
-
}
|
|
172
|
-
// §7.5 row 2: SSH asked for credentials and every method was refused.
|
|
173
|
-
if (level === 'client-authentication' || /all configured authentication methods failed/i.test(message)) {
|
|
174
|
-
return new PoolError(
|
|
175
|
-
'AUTH_FAILED',
|
|
176
|
-
`Authentication failed for ${context.username ? `${context.username}@` : ''}${where}: ${message}. ` +
|
|
177
|
-
'Check the username, the private key or stored password.',
|
|
178
|
-
details,
|
|
179
|
-
)
|
|
180
|
-
}
|
|
181
|
-
// A missing/unreadable key file is a local configuration problem, not a
|
|
182
|
-
// network one; surfacing it as AUTH_FAILED keeps one actionable bucket.
|
|
183
|
-
// (`lib/pool.js#authFor` reads the file before dialling, so this also covers
|
|
184
|
-
// an unreadable path reported by ssh2 itself.)
|
|
185
|
-
if (errno === 'ENOENT' && /privateKey|private key|key/i.test(message)) {
|
|
186
|
-
return new PoolError('AUTH_FAILED', `Cannot read the configured private key file: ${message}.`, details)
|
|
187
|
-
}
|
|
188
|
-
if (error instanceof PoolError) return error
|
|
189
|
-
// Anything left over is either a dead socket mid-command or a dial that got
|
|
190
|
-
// further than the errno cases above; the phase decides which bucket it is.
|
|
191
|
-
const code = context.phase === 'exec' ? 'CONNECTION_LOST' : 'CONNECT_FAILED'
|
|
192
|
-
return new PoolError(
|
|
193
|
-
code,
|
|
194
|
-
context.phase === 'exec'
|
|
195
|
-
? `The SSH connection to ${where} failed while the command was running: ${message}. Retry the command; if it keeps failing, check that sshd is still running.`
|
|
196
|
-
: `SSH connection to ${where} failed: ${message}. Retry; if it persists, check that sshd is running and reachable.`,
|
|
197
|
-
details,
|
|
198
|
-
)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* A host-key rejection raised inside the `hostVerifier` callback. ssh2 only
|
|
203
|
-
* reports the generic "Host denied (verification failed)" handshake error, so
|
|
204
|
-
* the real reason travels on this field and is unwrapped by the connect path.
|
|
205
|
-
*/
|
|
206
|
-
export class HostKeyRejectedError extends Error {
|
|
207
|
-
/**
|
|
208
|
-
* @param {string} serverId
|
|
209
|
-
* @param {string} message
|
|
210
|
-
*/
|
|
211
|
-
constructor(serverId, message) {
|
|
212
|
-
super(message)
|
|
213
|
-
this.name = 'HostKeyRejectedError'
|
|
214
|
-
this.serverId = serverId
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* @param {unknown} value
|
|
220
|
-
* @returns {value is Record<string, any>}
|
|
221
|
-
*/
|
|
222
|
-
function isPlainObject(value) {
|
|
223
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* @param {{host?: string, port?: number, serverId?: string}} context
|
|
228
|
-
* @returns {string}
|
|
229
|
-
*/
|
|
230
|
-
function describeTarget(context) {
|
|
231
|
-
if (context.host === undefined) return context.serverId === undefined ? 'the server' : `server ${context.serverId}`
|
|
232
|
-
const port = context.port === undefined ? '' : `:${context.port}`
|
|
233
|
-
return `${context.host}${port}`
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* The remote watchdog of §7.3, extended by the two corrections this module
|
|
238
|
-
* documents at the top of the file: `setsid` so the child leads its own process
|
|
239
|
-
* group, and stdout/stderr redirection on the watcher so it cannot hold the
|
|
240
|
-
* channel open.
|
|
241
|
-
*
|
|
242
|
-
* Layout, with `command` substituted through {@link shellQuote}:
|
|
243
|
-
*
|
|
244
|
-
* trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT
|
|
245
|
-
* setsid sh -c '<command>' & child=$!
|
|
246
|
-
* ( sleep <seconds>; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!
|
|
247
|
-
* wait $child; rc=$?
|
|
248
|
-
* kill $watchdog 2>/dev/null
|
|
249
|
-
* exit $rc
|
|
250
|
-
*
|
|
251
|
-
* The `trap` is a third correction, found by cancelling a long command and then
|
|
252
|
-
* looking for it with `ps`: without it, closing the channel kills only this
|
|
253
|
-
* outer shell, and the `setsid` child keeps running until its watchdog fires.
|
|
254
|
-
* So a cancelled or disconnected command would keep burning CPU and holding its
|
|
255
|
-
* locks for up to `timeoutMs` — the very bug §7.3 exists to prevent, just moved
|
|
256
|
-
* from the timeout path to the cancellation path. With the trap, SIGHUP/SIGTERM
|
|
257
|
-
* from a closed channel takes the process group down immediately.
|
|
258
|
-
*
|
|
259
|
-
* The script also omits the spec's `exec 2>&1`, which folds stderr into stdout:
|
|
260
|
-
* §5.4 returns them as two distinct fields, so the command runs under a single
|
|
261
|
-
* `sh -c` and its two streams stay separate all the way to the channel.
|
|
262
|
-
* @param {string} command
|
|
263
|
-
* @param {number} timeoutMs
|
|
264
|
-
* @returns {string}
|
|
265
|
-
*/
|
|
266
|
-
export function buildWatchdogScript(command, timeoutMs) {
|
|
267
|
-
// Sub-second budgets are honoured with a fractional `sleep`, which both GNU
|
|
268
|
-
// coreutils and busybox accept; rounding up to whole seconds would let a
|
|
269
|
-
// 1.5 s budget run to 2 s.
|
|
270
|
-
const seconds = Math.max(0.1, timeoutMs / 1000).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
|
|
271
|
-
return [
|
|
272
|
-
// 129 = 128 + SIGHUP, so a killed command is reported as signalled rather
|
|
273
|
-
// than as a clean exit.
|
|
274
|
-
`trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT`,
|
|
275
|
-
'setsid sh -c ' + shellQuote(command) + ' & child=$!',
|
|
276
|
-
'( sleep ' + seconds + '; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!',
|
|
277
|
-
'wait $child; rc=$?',
|
|
278
|
-
'kill $watchdog 2>/dev/null',
|
|
279
|
-
'exit $rc',
|
|
280
|
-
].join('\n')
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/**
|
|
284
|
-
* The full remote script: the watchdog, optionally wrapped in `cd '<cwd>' && …`
|
|
285
|
-
* (§7.3). A failed `cd` therefore fails the command with the shell's own
|
|
286
|
-
* message, which is what the caller wants to read.
|
|
287
|
-
* @param {string} command
|
|
288
|
-
* @param {number} timeoutMs
|
|
289
|
-
* @param {string|null} cwd
|
|
290
|
-
* @returns {string}
|
|
291
|
-
*/
|
|
292
|
-
export function buildRemoteScript(command, timeoutMs, cwd) {
|
|
293
|
-
const body = buildWatchdogScript(command, timeoutMs)
|
|
294
|
-
return cwd === null || cwd === undefined || cwd === '' ? body : 'cd ' + shellQuote(cwd) + ' && {\n' + body + '\n}'
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Quote one string for a POSIX shell, single-quote style: everything inside
|
|
299
|
-
* `'…'` is literal, and an embedded quote becomes `'\''`. This is the only
|
|
300
|
-
* transformation the pool applies to caller text, and it happens for the remote
|
|
301
|
-
* shell only — never for a local one (§7.1's whole point).
|
|
302
|
-
* @param {string} value
|
|
303
|
-
* @returns {string}
|
|
304
|
-
*/
|
|
305
|
-
export function shellQuote(value) {
|
|
306
|
-
return "'" + String(value).replace(/'/g, "'\\''") + "'"
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* The `hostVerifier` decision of §7.4, factored out so both the client wiring
|
|
311
|
-
* and the unit tests run the same code. The two outcomes are a discriminated
|
|
312
|
-
* union, so an accepted key cannot be passed where a refusal reason is needed.
|
|
313
|
-
* @param {{mode: 'accept-new'|'verify'|'off', known: {hash: string}|null}} state
|
|
314
|
-
* @param {string} keyHash
|
|
315
|
-
* @returns {{accept: true, store: boolean, reason: 'accept-new'|'match'|'disabled'} | {accept: false, store: false, reason: 'changed'|'unknown'}}
|
|
316
|
-
*/
|
|
317
|
-
export function decideHostKey(state, keyHash) {
|
|
318
|
-
if (state.mode === 'off') return { accept: true, store: false, reason: 'disabled' }
|
|
319
|
-
if (state.known === null || state.known === undefined) {
|
|
320
|
-
if (state.mode === 'accept-new') return { accept: true, store: true, reason: 'accept-new' }
|
|
321
|
-
return { accept: false, store: false, reason: 'unknown' }
|
|
322
|
-
}
|
|
323
|
-
return state.known.hash === keyHash
|
|
324
|
-
? { accept: true, store: false, reason: 'match' }
|
|
325
|
-
: { accept: false, store: false, reason: 'changed' }
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* The pool. One instance per plugin process; `dispose()` closes every
|
|
330
|
-
* connection (the plugin's cordis dispose hook calls it).
|
|
331
|
-
*/
|
|
332
|
-
export class Pool {
|
|
333
|
-
/** @type {number} */
|
|
334
|
-
#idleTimeoutMs
|
|
335
|
-
/** @type {number} */
|
|
336
|
-
#connectTimeoutMs
|
|
337
|
-
/** @type {number} */
|
|
338
|
-
#commandTimeoutMs
|
|
339
|
-
/** @type {number} */
|
|
340
|
-
#maxOutputChars
|
|
341
|
-
/** @type {number} */
|
|
342
|
-
#keepaliveIntervalMs
|
|
343
|
-
/** @type {import('./known-hosts.js').KnownHosts} */
|
|
344
|
-
#knownHosts
|
|
345
|
-
/** @type {(server: any) => Promise<string|null>} */
|
|
346
|
-
#resolvePassword
|
|
347
|
-
/** @type {{info:Function, warn:Function, error:Function}|null} */
|
|
348
|
-
#logger
|
|
349
|
-
/**
|
|
350
|
-
* Live state per server id. `queue` is the serial tail of §7.2; `connection`
|
|
351
|
-
* is `null` while no socket is open.
|
|
352
|
-
* @type {Map<string, {connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}>}
|
|
353
|
-
*/
|
|
354
|
-
#servers = new Map()
|
|
355
|
-
/** @type {boolean} */
|
|
356
|
-
#disposed = false
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* @param {{
|
|
360
|
-
* knownHosts?: import('./known-hosts.js').KnownHosts,
|
|
361
|
-
* idleTimeoutMs?: number,
|
|
362
|
-
* connectTimeoutMs?: number,
|
|
363
|
-
* commandTimeoutMs?: number,
|
|
364
|
-
* maxOutputChars?: number,
|
|
365
|
-
* keepaliveIntervalMs?: number,
|
|
366
|
-
* resolvePassword?: (server: any) => Promise<string|null>,
|
|
367
|
-
* logger?: {info:Function, warn:Function, error:Function} | null,
|
|
368
|
-
* }} [options]
|
|
369
|
-
*/
|
|
370
|
-
constructor(options = {}) {
|
|
371
|
-
if (options.knownHosts === undefined) {
|
|
372
|
-
throw new TypeError('Pool requires a knownHosts store (see lib/known-hosts.js).')
|
|
373
|
-
}
|
|
374
|
-
this.#knownHosts = options.knownHosts
|
|
375
|
-
this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
376
|
-
this.#connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
377
|
-
this.#commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
378
|
-
this.#maxOutputChars = options.maxOutputChars ?? 200000
|
|
379
|
-
// Slightly under a minute: a NAT/firewall idle drop is detected by ssh2
|
|
380
|
-
// before the pool hands the next command a dead socket.
|
|
381
|
-
this.#keepaliveIntervalMs = options.keepaliveIntervalMs ?? 30000
|
|
382
|
-
this.#resolvePassword = options.resolvePassword ?? (async () => null)
|
|
383
|
-
this.#logger = options.logger ?? null
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/** Effective idle timeout of this pool. */
|
|
387
|
-
get idleTimeoutMs() {
|
|
388
|
-
return this.#idleTimeoutMs
|
|
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
|
-
this.#
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
* @param {
|
|
540
|
-
* @returns {Promise<
|
|
541
|
-
*/
|
|
542
|
-
async
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
this.#
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
client
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
})
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
handshake.settled
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
)
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
)
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
stream
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
if (
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
//
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
entry.
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
if (entry
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
* @
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1
|
+
// dsh-server-connector — SSH connection pool and execution (design doc §7).
|
|
2
|
+
//
|
|
3
|
+
// One long-lived ssh2 connection per server (§7.2), commands on the same server
|
|
4
|
+
// serialised, different servers in parallel. The pool knows nothing about
|
|
5
|
+
// policy, tools, credentials storage or the audit log: it turns a server record
|
|
6
|
+
// into a connection or a typed error, and an execution request into
|
|
7
|
+
// `{ exitCode, stdout, stderr, timedOut, truncated }`.
|
|
8
|
+
//
|
|
9
|
+
// Design points that are easy to get wrong, and how they are handled here:
|
|
10
|
+
//
|
|
11
|
+
// - **No PTY** (§7.3): `pty: false` keeps `\r\n` and colour escapes out of the
|
|
12
|
+
// output.
|
|
13
|
+
// - **cwd** (§7.3): `cd '<cwd>' && …` is assembled on the remote side; nothing
|
|
14
|
+
// is ever passed through a local shell.
|
|
15
|
+
// - **Timeout must kill the remote process group** (§7.3). `stream.signal()`
|
|
16
|
+
// only reaches the channel's own process, so the command runs under a remote
|
|
17
|
+
// watchdog that `kill -KILL`s the child's process group. The watchdog is
|
|
18
|
+
// started with `setsid`, because on a non-interactive SSH channel the remote
|
|
19
|
+
// shell has job control off: a background command then *stays in the shell's
|
|
20
|
+
// own process group*, and `kill -- -$child` would address a pid that is not a
|
|
21
|
+
// process-group leader (it silently fails, leaving the process alive). With
|
|
22
|
+
// `setsid` the child really is its own group leader, so the kill lands.
|
|
23
|
+
// The watchdog subshell's own stdout/stderr are redirected to /dev/null:
|
|
24
|
+
// otherwise its `sleep` inherits the channel's stdout pipe, sshd waits for
|
|
25
|
+
// that pipe to close, and *every* command returns only when its timeout
|
|
26
|
+
// expires (measured: 5000 ms instead of 48 ms with the redirect).
|
|
27
|
+
// - **Reconnect once** (§7.2): a connection that fails while idle is rebuilt on
|
|
28
|
+
// the next call; if that also fails, the caller gets `CONNECT_FAILED`.
|
|
29
|
+
// - **Host keys** (§7.4): verification is delegated to the `hostVerifier`
|
|
30
|
+
// callback, which consults {@link KnownHosts} through an injected gateway, so
|
|
31
|
+
// this module stays free of file IO and is testable without a file system.
|
|
32
|
+
// - **The output ceiling is per stream, not shared** (§5.4). stdout and stderr
|
|
33
|
+
// are two independent fields of the result, so `maxOutputChars` caps each of
|
|
34
|
+
// them; one shared counter would let a chatty stdout close stderr entirely,
|
|
35
|
+
// dropping its bytes and leaving the truncation marker on a stream that was
|
|
36
|
+
// never cut.
|
|
37
|
+
// - **Error normalisation** (§7.5): every transport failure surfaces as a
|
|
38
|
+
// {@link PoolError} with one of the documented codes and an actionable hint.
|
|
39
|
+
|
|
40
|
+
import { readFile } from 'node:fs/promises'
|
|
41
|
+
|
|
42
|
+
import ssh2 from 'ssh2'
|
|
43
|
+
|
|
44
|
+
import { fingerprintHostKey } from './known-hosts.js'
|
|
45
|
+
|
|
46
|
+
/** The ssh2 client class; pulled off the default export so both ESM and CJS consumers work. */
|
|
47
|
+
const { Client } = ssh2
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The transport-layer error codes of §5.5/§7.5. `EXIT_NONZERO` is listed because
|
|
51
|
+
* the tools layer must return it, but the pool never raises it: a nonzero exit is
|
|
52
|
+
* a *result* (`exitCode`), not a transport failure — srv_exec decides whether the
|
|
53
|
+
* model sees an error. `POLICY_*` and `UNKNOWN_SERVER`/`AMBIGUOUS_SERVER` belong
|
|
54
|
+
* to the policy engine and the registry, not to this module.
|
|
55
|
+
*/
|
|
56
|
+
export const POOL_ERROR_CODES = Object.freeze([
|
|
57
|
+
'CONNECT_FAILED',
|
|
58
|
+
'HOST_KEY_UNKNOWN',
|
|
59
|
+
'AUTH_FAILED',
|
|
60
|
+
'TIMEOUT',
|
|
61
|
+
'EXIT_NONZERO',
|
|
62
|
+
'NO_CREDENTIAL',
|
|
63
|
+
'CONNECTION_LOST',
|
|
64
|
+
'INVALID_SERVER',
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
/** Default idle timeout of one connection (§7.2, §10). */
|
|
68
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 600000
|
|
69
|
+
|
|
70
|
+
/** Default timeout of one command (§10). */
|
|
71
|
+
export const DEFAULT_COMMAND_TIMEOUT_MS = 60000
|
|
72
|
+
|
|
73
|
+
/** Default connect timeout (§10). */
|
|
74
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Appended to a stream that was cut at `maxOutputChars` (§5.4). The boolean
|
|
78
|
+
* `truncated` says *that* output was lost; the marker shows *where*.
|
|
79
|
+
*
|
|
80
|
+
* The ceiling applies to each stream on its own: stdout and stderr are separate
|
|
81
|
+
* result fields, so neither can consume the other's room and the marker lands
|
|
82
|
+
* only in the stream that was actually cut.
|
|
83
|
+
*/
|
|
84
|
+
export const TRUNCATED_MARKER = '\n…[TRUNCATED]'
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Machine-readable pool failure. `code` is the stable, documented identifier
|
|
88
|
+
* (§5.5); `message` is written for the model and the panel to act on in one
|
|
89
|
+
* turn, which is why every code carries a next step.
|
|
90
|
+
*/
|
|
91
|
+
export class PoolError extends Error {
|
|
92
|
+
/**
|
|
93
|
+
* @param {typeof POOL_ERROR_CODES[number]} code
|
|
94
|
+
* @param {string} message
|
|
95
|
+
* @param {{serverId?: string, host?: string, port?: number, username?: string, cause?: unknown, producedOutput?: boolean}} [details]
|
|
96
|
+
*/
|
|
97
|
+
constructor(code, message, details = {}) {
|
|
98
|
+
super(message, details.cause === undefined ? undefined : { cause: details.cause })
|
|
99
|
+
this.name = 'PoolError'
|
|
100
|
+
this.code = code
|
|
101
|
+
this.serverId = details.serverId
|
|
102
|
+
this.host = details.host
|
|
103
|
+
this.port = details.port
|
|
104
|
+
this.username = details.username
|
|
105
|
+
/** Whether a channel had already delivered output when it failed (§7.2 retry safety). */
|
|
106
|
+
this.producedOutput = details.producedOutput
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One command execution request.
|
|
112
|
+
* @typedef {object} ExecRequest
|
|
113
|
+
* @property {string} command - the command line, passed to `sh -c` on the remote side.
|
|
114
|
+
* @property {string|null} [cwd] - working directory; defaults to the server's `defaultWorkDir`.
|
|
115
|
+
* @property {number} [timeoutMs] - wall-clock budget of the whole command.
|
|
116
|
+
* @property {number} [maxOutputChars] - per-stream ceiling for stdout and for stderr; beyond it that stream is marked truncated.
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One command result.
|
|
121
|
+
* @typedef {object} ExecResult
|
|
122
|
+
* @property {string} stdout
|
|
123
|
+
* @property {string} stderr
|
|
124
|
+
* @property {number} exitCode - 0 on success; a negated signal number when the process was killed (137 = SIGKILL).
|
|
125
|
+
* @property {number} durationMs
|
|
126
|
+
* @property {boolean} timedOut - whether the remote watchdog killed the process group.
|
|
127
|
+
* @property {boolean} truncated - whether stdout or stderr crossed `maxOutputChars`.
|
|
128
|
+
* @property {boolean} reconnected - whether the command needed a fresh connection.
|
|
129
|
+
* @property {string|null} timeoutError - `TIMEOUT` when `timedOut` is true, so the
|
|
130
|
+
* tools layer can return the §5.5 code without re-deriving it from exit 137.
|
|
131
|
+
*/
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalise one ssh2/transport error (§7.5). Exported so the tools layer can map
|
|
135
|
+
* errors it catches outside a pool call (e.g. from `ctx.credentials`) the same way.
|
|
136
|
+
* @param {unknown} error
|
|
137
|
+
* @param {{serverId?: string, host?: string, port?: number, username?: string, phase?: 'connect'|'handshake'|'exec'}} [context]
|
|
138
|
+
* @returns {PoolError}
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeError(error, context = {}) {
|
|
141
|
+
const details = {
|
|
142
|
+
serverId: context.serverId,
|
|
143
|
+
host: context.host,
|
|
144
|
+
port: context.port,
|
|
145
|
+
username: context.username,
|
|
146
|
+
cause: error,
|
|
147
|
+
}
|
|
148
|
+
const where = describeTarget(context)
|
|
149
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
150
|
+
const errno = isPlainObject(error) ? error.code : undefined
|
|
151
|
+
const level = isPlainObject(error) ? error.level : undefined
|
|
152
|
+
|
|
153
|
+
// §7.5 row 1: the socket never came up.
|
|
154
|
+
if (errno === 'ECONNREFUSED' || errno === 'EHOSTUNREACH' || errno === 'ENETUNREACH' || errno === 'ENOTFOUND') {
|
|
155
|
+
return new PoolError(
|
|
156
|
+
'CONNECT_FAILED',
|
|
157
|
+
`Cannot reach ${where}: ${message}. Check the host/port and that sshd is running on the target.`,
|
|
158
|
+
details,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
if (errno === 'ETIMEDOUT' || level === 'client-timeout') {
|
|
162
|
+
return new PoolError(
|
|
163
|
+
'CONNECT_FAILED',
|
|
164
|
+
`Connecting to ${where} timed out. Check the host/port, the network path, and that sshd is running on the target.`,
|
|
165
|
+
details,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
// §7.5 rows 3 and 4: hostVerifier refused the key.
|
|
169
|
+
if (error instanceof HostKeyRejectedError) {
|
|
170
|
+
return new PoolError('HOST_KEY_UNKNOWN', error.message, details)
|
|
171
|
+
}
|
|
172
|
+
// §7.5 row 2: SSH asked for credentials and every method was refused.
|
|
173
|
+
if (level === 'client-authentication' || /all configured authentication methods failed/i.test(message)) {
|
|
174
|
+
return new PoolError(
|
|
175
|
+
'AUTH_FAILED',
|
|
176
|
+
`Authentication failed for ${context.username ? `${context.username}@` : ''}${where}: ${message}. ` +
|
|
177
|
+
'Check the username, the private key or stored password.',
|
|
178
|
+
details,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
// A missing/unreadable key file is a local configuration problem, not a
|
|
182
|
+
// network one; surfacing it as AUTH_FAILED keeps one actionable bucket.
|
|
183
|
+
// (`lib/pool.js#authFor` reads the file before dialling, so this also covers
|
|
184
|
+
// an unreadable path reported by ssh2 itself.)
|
|
185
|
+
if (errno === 'ENOENT' && /privateKey|private key|key/i.test(message)) {
|
|
186
|
+
return new PoolError('AUTH_FAILED', `Cannot read the configured private key file: ${message}.`, details)
|
|
187
|
+
}
|
|
188
|
+
if (error instanceof PoolError) return error
|
|
189
|
+
// Anything left over is either a dead socket mid-command or a dial that got
|
|
190
|
+
// further than the errno cases above; the phase decides which bucket it is.
|
|
191
|
+
const code = context.phase === 'exec' ? 'CONNECTION_LOST' : 'CONNECT_FAILED'
|
|
192
|
+
return new PoolError(
|
|
193
|
+
code,
|
|
194
|
+
context.phase === 'exec'
|
|
195
|
+
? `The SSH connection to ${where} failed while the command was running: ${message}. Retry the command; if it keeps failing, check that sshd is still running.`
|
|
196
|
+
: `SSH connection to ${where} failed: ${message}. Retry; if it persists, check that sshd is running and reachable.`,
|
|
197
|
+
details,
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A host-key rejection raised inside the `hostVerifier` callback. ssh2 only
|
|
203
|
+
* reports the generic "Host denied (verification failed)" handshake error, so
|
|
204
|
+
* the real reason travels on this field and is unwrapped by the connect path.
|
|
205
|
+
*/
|
|
206
|
+
export class HostKeyRejectedError extends Error {
|
|
207
|
+
/**
|
|
208
|
+
* @param {string} serverId
|
|
209
|
+
* @param {string} message
|
|
210
|
+
*/
|
|
211
|
+
constructor(serverId, message) {
|
|
212
|
+
super(message)
|
|
213
|
+
this.name = 'HostKeyRejectedError'
|
|
214
|
+
this.serverId = serverId
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {unknown} value
|
|
220
|
+
* @returns {value is Record<string, any>}
|
|
221
|
+
*/
|
|
222
|
+
function isPlainObject(value) {
|
|
223
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {{host?: string, port?: number, serverId?: string}} context
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
function describeTarget(context) {
|
|
231
|
+
if (context.host === undefined) return context.serverId === undefined ? 'the server' : `server ${context.serverId}`
|
|
232
|
+
const port = context.port === undefined ? '' : `:${context.port}`
|
|
233
|
+
return `${context.host}${port}`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The remote watchdog of §7.3, extended by the two corrections this module
|
|
238
|
+
* documents at the top of the file: `setsid` so the child leads its own process
|
|
239
|
+
* group, and stdout/stderr redirection on the watcher so it cannot hold the
|
|
240
|
+
* channel open.
|
|
241
|
+
*
|
|
242
|
+
* Layout, with `command` substituted through {@link shellQuote}:
|
|
243
|
+
*
|
|
244
|
+
* trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT
|
|
245
|
+
* setsid sh -c '<command>' & child=$!
|
|
246
|
+
* ( sleep <seconds>; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!
|
|
247
|
+
* wait $child; rc=$?
|
|
248
|
+
* kill $watchdog 2>/dev/null
|
|
249
|
+
* exit $rc
|
|
250
|
+
*
|
|
251
|
+
* The `trap` is a third correction, found by cancelling a long command and then
|
|
252
|
+
* looking for it with `ps`: without it, closing the channel kills only this
|
|
253
|
+
* outer shell, and the `setsid` child keeps running until its watchdog fires.
|
|
254
|
+
* So a cancelled or disconnected command would keep burning CPU and holding its
|
|
255
|
+
* locks for up to `timeoutMs` — the very bug §7.3 exists to prevent, just moved
|
|
256
|
+
* from the timeout path to the cancellation path. With the trap, SIGHUP/SIGTERM
|
|
257
|
+
* from a closed channel takes the process group down immediately.
|
|
258
|
+
*
|
|
259
|
+
* The script also omits the spec's `exec 2>&1`, which folds stderr into stdout:
|
|
260
|
+
* §5.4 returns them as two distinct fields, so the command runs under a single
|
|
261
|
+
* `sh -c` and its two streams stay separate all the way to the channel.
|
|
262
|
+
* @param {string} command
|
|
263
|
+
* @param {number} timeoutMs
|
|
264
|
+
* @returns {string}
|
|
265
|
+
*/
|
|
266
|
+
export function buildWatchdogScript(command, timeoutMs) {
|
|
267
|
+
// Sub-second budgets are honoured with a fractional `sleep`, which both GNU
|
|
268
|
+
// coreutils and busybox accept; rounding up to whole seconds would let a
|
|
269
|
+
// 1.5 s budget run to 2 s.
|
|
270
|
+
const seconds = Math.max(0.1, timeoutMs / 1000).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
|
|
271
|
+
return [
|
|
272
|
+
// 129 = 128 + SIGHUP, so a killed command is reported as signalled rather
|
|
273
|
+
// than as a clean exit.
|
|
274
|
+
`trap 'kill -KILL -$child 2>/dev/null; kill $watchdog 2>/dev/null; exit 129' HUP TERM INT`,
|
|
275
|
+
'setsid sh -c ' + shellQuote(command) + ' & child=$!',
|
|
276
|
+
'( sleep ' + seconds + '; kill -KILL -$child 2>/dev/null ) >/dev/null 2>&1 & watchdog=$!',
|
|
277
|
+
'wait $child; rc=$?',
|
|
278
|
+
'kill $watchdog 2>/dev/null',
|
|
279
|
+
'exit $rc',
|
|
280
|
+
].join('\n')
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The full remote script: the watchdog, optionally wrapped in `cd '<cwd>' && …`
|
|
285
|
+
* (§7.3). A failed `cd` therefore fails the command with the shell's own
|
|
286
|
+
* message, which is what the caller wants to read.
|
|
287
|
+
* @param {string} command
|
|
288
|
+
* @param {number} timeoutMs
|
|
289
|
+
* @param {string|null} cwd
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
export function buildRemoteScript(command, timeoutMs, cwd) {
|
|
293
|
+
const body = buildWatchdogScript(command, timeoutMs)
|
|
294
|
+
return cwd === null || cwd === undefined || cwd === '' ? body : 'cd ' + shellQuote(cwd) + ' && {\n' + body + '\n}'
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Quote one string for a POSIX shell, single-quote style: everything inside
|
|
299
|
+
* `'…'` is literal, and an embedded quote becomes `'\''`. This is the only
|
|
300
|
+
* transformation the pool applies to caller text, and it happens for the remote
|
|
301
|
+
* shell only — never for a local one (§7.1's whole point).
|
|
302
|
+
* @param {string} value
|
|
303
|
+
* @returns {string}
|
|
304
|
+
*/
|
|
305
|
+
export function shellQuote(value) {
|
|
306
|
+
return "'" + String(value).replace(/'/g, "'\\''") + "'"
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The `hostVerifier` decision of §7.4, factored out so both the client wiring
|
|
311
|
+
* and the unit tests run the same code. The two outcomes are a discriminated
|
|
312
|
+
* union, so an accepted key cannot be passed where a refusal reason is needed.
|
|
313
|
+
* @param {{mode: 'accept-new'|'verify'|'off', known: {hash: string}|null}} state
|
|
314
|
+
* @param {string} keyHash
|
|
315
|
+
* @returns {{accept: true, store: boolean, reason: 'accept-new'|'match'|'disabled'} | {accept: false, store: false, reason: 'changed'|'unknown'}}
|
|
316
|
+
*/
|
|
317
|
+
export function decideHostKey(state, keyHash) {
|
|
318
|
+
if (state.mode === 'off') return { accept: true, store: false, reason: 'disabled' }
|
|
319
|
+
if (state.known === null || state.known === undefined) {
|
|
320
|
+
if (state.mode === 'accept-new') return { accept: true, store: true, reason: 'accept-new' }
|
|
321
|
+
return { accept: false, store: false, reason: 'unknown' }
|
|
322
|
+
}
|
|
323
|
+
return state.known.hash === keyHash
|
|
324
|
+
? { accept: true, store: false, reason: 'match' }
|
|
325
|
+
: { accept: false, store: false, reason: 'changed' }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The pool. One instance per plugin process; `dispose()` closes every
|
|
330
|
+
* connection (the plugin's cordis dispose hook calls it).
|
|
331
|
+
*/
|
|
332
|
+
export class Pool {
|
|
333
|
+
/** @type {number} */
|
|
334
|
+
#idleTimeoutMs
|
|
335
|
+
/** @type {number} */
|
|
336
|
+
#connectTimeoutMs
|
|
337
|
+
/** @type {number} */
|
|
338
|
+
#commandTimeoutMs
|
|
339
|
+
/** @type {number} */
|
|
340
|
+
#maxOutputChars
|
|
341
|
+
/** @type {number} */
|
|
342
|
+
#keepaliveIntervalMs
|
|
343
|
+
/** @type {import('./known-hosts.js').KnownHosts} */
|
|
344
|
+
#knownHosts
|
|
345
|
+
/** @type {(server: any) => Promise<string|null>} */
|
|
346
|
+
#resolvePassword
|
|
347
|
+
/** @type {{info:Function, warn:Function, error:Function}|null} */
|
|
348
|
+
#logger
|
|
349
|
+
/**
|
|
350
|
+
* Live state per server id. `queue` is the serial tail of §7.2; `connection`
|
|
351
|
+
* is `null` while no socket is open.
|
|
352
|
+
* @type {Map<string, {connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}>}
|
|
353
|
+
*/
|
|
354
|
+
#servers = new Map()
|
|
355
|
+
/** @type {boolean} */
|
|
356
|
+
#disposed = false
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @param {{
|
|
360
|
+
* knownHosts?: import('./known-hosts.js').KnownHosts,
|
|
361
|
+
* idleTimeoutMs?: number,
|
|
362
|
+
* connectTimeoutMs?: number,
|
|
363
|
+
* commandTimeoutMs?: number,
|
|
364
|
+
* maxOutputChars?: number,
|
|
365
|
+
* keepaliveIntervalMs?: number,
|
|
366
|
+
* resolvePassword?: (server: any) => Promise<string|null>,
|
|
367
|
+
* logger?: {info:Function, warn:Function, error:Function} | null,
|
|
368
|
+
* }} [options]
|
|
369
|
+
*/
|
|
370
|
+
constructor(options = {}) {
|
|
371
|
+
if (options.knownHosts === undefined) {
|
|
372
|
+
throw new TypeError('Pool requires a knownHosts store (see lib/known-hosts.js).')
|
|
373
|
+
}
|
|
374
|
+
this.#knownHosts = options.knownHosts
|
|
375
|
+
this.#idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
|
|
376
|
+
this.#connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
|
377
|
+
this.#commandTimeoutMs = options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS
|
|
378
|
+
this.#maxOutputChars = options.maxOutputChars ?? 200000
|
|
379
|
+
// Slightly under a minute: a NAT/firewall idle drop is detected by ssh2
|
|
380
|
+
// before the pool hands the next command a dead socket.
|
|
381
|
+
this.#keepaliveIntervalMs = options.keepaliveIntervalMs ?? 30000
|
|
382
|
+
this.#resolvePassword = options.resolvePassword ?? (async () => null)
|
|
383
|
+
this.#logger = options.logger ?? null
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Effective idle timeout of this pool. */
|
|
387
|
+
get idleTimeoutMs() {
|
|
388
|
+
return this.#idleTimeoutMs
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Apply an edited global configuration without dropping live connections (doc
|
|
393
|
+
* §10, live settings).
|
|
394
|
+
*
|
|
395
|
+
* A volatile settings edit is written into the row config in place, so the
|
|
396
|
+
* pool has to take the new values on the same object it already holds rather
|
|
397
|
+
* than being rebuilt: rebuilding would close every socket and make the user
|
|
398
|
+
* reconnect after changing a timeout. Only the four values the pool owns are
|
|
399
|
+
* accepted; anything else is ignored.
|
|
400
|
+
* @param {{
|
|
401
|
+
* connectTimeoutMs?: number,
|
|
402
|
+
* commandTimeoutMs?: number,
|
|
403
|
+
* idleTimeoutMs?: number,
|
|
404
|
+
* maxOutputChars?: number,
|
|
405
|
+
* }} values
|
|
406
|
+
* @returns {void}
|
|
407
|
+
*/
|
|
408
|
+
configure(values) {
|
|
409
|
+
if (typeof values?.connectTimeoutMs === 'number') this.#connectTimeoutMs = values.connectTimeoutMs
|
|
410
|
+
if (typeof values?.commandTimeoutMs === 'number') this.#commandTimeoutMs = values.commandTimeoutMs
|
|
411
|
+
if (typeof values?.maxOutputChars === 'number') this.#maxOutputChars = values.maxOutputChars
|
|
412
|
+
if (typeof values?.idleTimeoutMs === 'number') {
|
|
413
|
+
this.#idleTimeoutMs = values.idleTimeoutMs
|
|
414
|
+
// Re-arm the timers that are already running: a shortened timeout must
|
|
415
|
+
// take effect now, and a disabled one must stop closing sockets.
|
|
416
|
+
for (const entry of this.#servers.values()) {
|
|
417
|
+
this.#clearIdle(entry)
|
|
418
|
+
this.#touch(entry)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Whether a live connection exists for one server, for the panel's green/grey
|
|
425
|
+
* dot (doc §8.2). Never opens a connection.
|
|
426
|
+
* @param {string} serverId
|
|
427
|
+
* @returns {boolean}
|
|
428
|
+
*/
|
|
429
|
+
isConnected(serverId) {
|
|
430
|
+
const entry = this.#servers.get(serverId)
|
|
431
|
+
return entry !== undefined && entry.connection !== null && entry.connection.alive === true
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Server ids with a live connection (doc §8.2).
|
|
436
|
+
* @returns {string[]}
|
|
437
|
+
*/
|
|
438
|
+
connectedServerIds() {
|
|
439
|
+
/** @type {string[]} */
|
|
440
|
+
const ids = []
|
|
441
|
+
for (const [serverId, entry] of this.#servers) {
|
|
442
|
+
if (entry.connection !== null && entry.connection.alive === true) ids.push(serverId)
|
|
443
|
+
}
|
|
444
|
+
return ids.sort()
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Ensure a reusable connection for one server and return it (§7.2). Two
|
|
449
|
+
* concurrent calls share one dial, so the "one long connection per server"
|
|
450
|
+
* rule survives a burst.
|
|
451
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
452
|
+
* @returns {Promise<{connection: any, reconnected: boolean}>}
|
|
453
|
+
*/
|
|
454
|
+
async connect(server) {
|
|
455
|
+
this.#assertUsable()
|
|
456
|
+
const id = requireServer(server).id
|
|
457
|
+
const entry = this.#entry(id)
|
|
458
|
+
const live = entry.connection
|
|
459
|
+
const reused = live !== null && live.alive === true
|
|
460
|
+
const connection = await this.#ensureConnection(server, entry)
|
|
461
|
+
this.#touch(entry)
|
|
462
|
+
// "Reconnected" describes this call: a live socket was reused, or a new one
|
|
463
|
+
// had to be built because the previous one was gone.
|
|
464
|
+
return { connection, reconnected: !reused }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Run one command on one server (§7.2, §7.3).
|
|
469
|
+
*
|
|
470
|
+
* Commands for the same server are serialised through the entry's promise
|
|
471
|
+
* queue; different servers run concurrently because each has its own socket
|
|
472
|
+
* and channel. A connection that turns out to be dead is rebuilt once, and
|
|
473
|
+
* the command is replayed on the new connection exactly once (§7.2).
|
|
474
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
475
|
+
* @param {ExecRequest} request
|
|
476
|
+
* @returns {Promise<ExecResult>}
|
|
477
|
+
*/
|
|
478
|
+
async exec(server, request) {
|
|
479
|
+
this.#assertUsable()
|
|
480
|
+
const record = requireServer(server)
|
|
481
|
+
const command = normalizeCommand(request)
|
|
482
|
+
const timeoutMs = normalizePositive(request.timeoutMs, this.#commandTimeoutMs)
|
|
483
|
+
const maxOutputChars = normalizePositive(request.maxOutputChars, this.#maxOutputChars)
|
|
484
|
+
const cwd = normalizeCwd(request.cwd, record)
|
|
485
|
+
const entry = this.#entry(record.id)
|
|
486
|
+
// A queued command must not race the idle timer armed by the previous one:
|
|
487
|
+
// otherwise a command that outlives idleTimeoutMs has its own socket closed
|
|
488
|
+
// and its serial queue reset under it.
|
|
489
|
+
this.#clearIdle(entry)
|
|
490
|
+
|
|
491
|
+
return this.#enqueue(entry, async () => {
|
|
492
|
+
const script = buildRemoteScript(command, timeoutMs, cwd)
|
|
493
|
+
let reconnected = false
|
|
494
|
+
let connection = await this.#ensureConnection(record, entry)
|
|
495
|
+
let outcome
|
|
496
|
+
try {
|
|
497
|
+
outcome = await this.#run(connection, script, timeoutMs, maxOutputChars)
|
|
498
|
+
} catch (error) {
|
|
499
|
+
if (!(error instanceof PoolError) || error.code !== 'CONNECTION_LOST') throw error
|
|
500
|
+
// §7.2 allows exactly one automatic retry. It is only safe when the
|
|
501
|
+
// command cannot have run: a channel that already delivered output may
|
|
502
|
+
// have changed the server, and replaying it would repeat those side
|
|
503
|
+
// effects. In that case the caller gets CONNECTION_LOST instead.
|
|
504
|
+
if (error.producedOutput === true) throw error
|
|
505
|
+
reconnected = true
|
|
506
|
+
this.#discard(record.id, entry)
|
|
507
|
+
connection = await this.#ensureConnection(record, entry)
|
|
508
|
+
outcome = await this.#run(connection, script, timeoutMs, maxOutputChars)
|
|
509
|
+
}
|
|
510
|
+
this.#touch(entry)
|
|
511
|
+
return {
|
|
512
|
+
...outcome,
|
|
513
|
+
durationMs: Math.max(0, Math.round(outcome.endedAt - outcome.startedAt)),
|
|
514
|
+
reconnected,
|
|
515
|
+
timeoutError: outcome.timedOut ? 'TIMEOUT' : null,
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Run one command and stream its output while it runs (doc §5.4 async jobs).
|
|
522
|
+
*
|
|
523
|
+
* Same transport, watchdog, timeout and error normalisation as {@link exec};
|
|
524
|
+
* the difference is what the caller gets: `onChunk` is called for every
|
|
525
|
+
* stdout/stderr chunk as it arrives, and `onCancel` receives a function that
|
|
526
|
+
* signals the remote script to kill the command's process group and then
|
|
527
|
+
* releases the channel. The watchdog's SIGKILL at the budget stays as the
|
|
528
|
+
* fallback for a process that ignores the signal.
|
|
529
|
+
*
|
|
530
|
+
* No replay: a streaming caller may already have handed bytes to its
|
|
531
|
+
* consumer, so a connection lost mid-command is reported as
|
|
532
|
+
* `CONNECTION_LOST` instead of being retried (§7.2's one-retry rule only
|
|
533
|
+
* holds for a call that produced nothing).
|
|
534
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
535
|
+
* @param {ExecRequest & {
|
|
536
|
+
* onChunk?: (channel: 'stdout'|'stderr', text: string) => void,
|
|
537
|
+
* onCancel?: (cancel: () => void) => void,
|
|
538
|
+
* }} request
|
|
539
|
+
* @param {AbortSignal} [signal] - caller cancellation (the tool call's signal).
|
|
540
|
+
* @returns {Promise<ExecResult & {cancelled: boolean}>}
|
|
541
|
+
*/
|
|
542
|
+
async execStream(server, request, signal) {
|
|
543
|
+
this.#assertUsable()
|
|
544
|
+
const record = requireServer(server)
|
|
545
|
+
const command = normalizeCommand(request)
|
|
546
|
+
const timeoutMs = normalizePositive(request.timeoutMs, this.#commandTimeoutMs)
|
|
547
|
+
const maxOutputChars = normalizePositive(request.maxOutputChars, this.#maxOutputChars)
|
|
548
|
+
const cwd = normalizeCwd(request.cwd, record)
|
|
549
|
+
const entry = this.#entry(record.id)
|
|
550
|
+
const onChunk = typeof request.onChunk === 'function' ? request.onChunk : () => {}
|
|
551
|
+
const onCancel = typeof request.onCancel === 'function' ? request.onCancel : () => {}
|
|
552
|
+
this.#clearIdle(entry)
|
|
553
|
+
|
|
554
|
+
return this.#enqueue(entry, async () => {
|
|
555
|
+
const script = buildRemoteScript(command, timeoutMs, cwd)
|
|
556
|
+
const connection = await this.#ensureConnection(record, entry)
|
|
557
|
+
const outcome = await this.#runStream(connection, script, timeoutMs, maxOutputChars, onChunk, onCancel, signal)
|
|
558
|
+
this.#touch(entry)
|
|
559
|
+
return {
|
|
560
|
+
...outcome,
|
|
561
|
+
durationMs: Math.max(0, Math.round(outcome.endedAt - outcome.startedAt)),
|
|
562
|
+
reconnected: false,
|
|
563
|
+
timeoutError: outcome.timedOut ? 'TIMEOUT' : null,
|
|
564
|
+
}
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Close one server's connection and forget its queue (§8.3: deleting a server
|
|
570
|
+
* disconnects the pool). A running command finishes first.
|
|
571
|
+
* @param {string} serverId
|
|
572
|
+
* @returns {Promise<void>}
|
|
573
|
+
*/
|
|
574
|
+
async disconnect(serverId) {
|
|
575
|
+
const entry = this.#servers.get(serverId)
|
|
576
|
+
if (entry === undefined) return
|
|
577
|
+
// Stop accepting new work for this server, then let whatever is already
|
|
578
|
+
// running finish before the socket goes away.
|
|
579
|
+
this.#servers.delete(serverId)
|
|
580
|
+
this.#clearIdle(entry)
|
|
581
|
+
await entry.queue.catch(() => {})
|
|
582
|
+
const connection = entry.connection
|
|
583
|
+
entry.connection = null
|
|
584
|
+
if (connection !== null && connection.ready === 'ready') await closeConnection(connection).catch(() => {})
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Close every connection. Idempotent; later calls to exec()/connect() report
|
|
589
|
+
* `CONNECTION_LOST` rather than dialling a disposed pool.
|
|
590
|
+
* @returns {Promise<void>}
|
|
591
|
+
*/
|
|
592
|
+
async dispose() {
|
|
593
|
+
if (this.#disposed) return
|
|
594
|
+
this.#disposed = true
|
|
595
|
+
/** @type {Promise<void>[]} */
|
|
596
|
+
const closing = []
|
|
597
|
+
for (const [serverId, entry] of this.#servers) {
|
|
598
|
+
this.#clearIdle(entry)
|
|
599
|
+
const connection = entry.connection
|
|
600
|
+
entry.connection = null
|
|
601
|
+
if (connection !== null && connection.ready === 'ready') {
|
|
602
|
+
closing.push(closeConnection(connection).catch(() => {}))
|
|
603
|
+
}
|
|
604
|
+
this.#servers.delete(serverId)
|
|
605
|
+
}
|
|
606
|
+
await Promise.all(closing)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
611
|
+
* @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
|
|
612
|
+
* @returns {Promise<any>}
|
|
613
|
+
*/
|
|
614
|
+
#dial(server, entry) {
|
|
615
|
+
const id = server.id
|
|
616
|
+
// A dial that is already in flight is shared instead of duplicated: two
|
|
617
|
+
// concurrent callers await this same promise, so neither can leave it
|
|
618
|
+
// rejected-but-unobserved.
|
|
619
|
+
if (entry.connection !== null && entry.connection.ready === 'pending') {
|
|
620
|
+
return entry.connection.promise
|
|
621
|
+
}
|
|
622
|
+
this.#clearIdle(entry)
|
|
623
|
+
/** @type {Promise<any>} */
|
|
624
|
+
const promise = this.#openConnection(server).then(
|
|
625
|
+
(connection) => {
|
|
626
|
+
entry.connection = connection
|
|
627
|
+
return connection
|
|
628
|
+
},
|
|
629
|
+
(error) => {
|
|
630
|
+
entry.connection = null
|
|
631
|
+
const normalized =
|
|
632
|
+
error instanceof PoolError
|
|
633
|
+
? error
|
|
634
|
+
: normalizeError(error, {
|
|
635
|
+
serverId: id,
|
|
636
|
+
host: server.host,
|
|
637
|
+
port: server.port,
|
|
638
|
+
username: server.username,
|
|
639
|
+
phase: 'connect',
|
|
640
|
+
})
|
|
641
|
+
this.#log('warn', 'dsh-server-connector: ssh connect to %s failed: %s', id, normalized.message)
|
|
642
|
+
throw normalized
|
|
643
|
+
},
|
|
644
|
+
)
|
|
645
|
+
/** @type {{ready: 'pending', promise: Promise<any>, alive: boolean, reconnected: boolean}} */
|
|
646
|
+
entry.connection = { ready: 'pending', promise, alive: false, reconnected: false }
|
|
647
|
+
return promise
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Open one ssh2 client, apply the §7.4 host-key decision, and resolve once the
|
|
652
|
+
* handshake and authentication completed.
|
|
653
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
654
|
+
* @returns {Promise<any>}
|
|
655
|
+
*/
|
|
656
|
+
async #openConnection(server) {
|
|
657
|
+
const auth = await this.#authFor(server)
|
|
658
|
+
const known = await this.#knownHosts.get(server.id)
|
|
659
|
+
/** @type {HostKeyRejectedError|null} */
|
|
660
|
+
let rejection = null
|
|
661
|
+
|
|
662
|
+
const client = new Client()
|
|
663
|
+
/** @type {{value: {hash: string, algorithm: string}|null}} */
|
|
664
|
+
const accepted = { value: null }
|
|
665
|
+
// One listener for the whole life of the client, installed *before*
|
|
666
|
+
// connect(): ssh2 emits 'error' both while the handshake is running and
|
|
667
|
+
// long after `ready`. An EventEmitter 'error' with no listener throws, so a
|
|
668
|
+
// socket that dies during a later command must always have somewhere to go.
|
|
669
|
+
/** @type {{settled: boolean, resolve: (value: any) => void, reject: (reason: unknown) => void}} */
|
|
670
|
+
const handshake = { settled: false, resolve: () => {}, reject: () => {} }
|
|
671
|
+
/** @type {Promise<any>} */
|
|
672
|
+
const ready = new Promise((resolve, reject) => {
|
|
673
|
+
handshake.resolve = resolve
|
|
674
|
+
handshake.reject = reject
|
|
675
|
+
})
|
|
676
|
+
/** @param {Error & {level?: string, code?: string}} error */
|
|
677
|
+
const onClientError = (error) => {
|
|
678
|
+
if (!handshake.settled) {
|
|
679
|
+
handshake.settled = true
|
|
680
|
+
handshake.reject(
|
|
681
|
+
rejection ??
|
|
682
|
+
normalizeError(error, {
|
|
683
|
+
serverId: server.id,
|
|
684
|
+
host: server.host,
|
|
685
|
+
port: server.port,
|
|
686
|
+
username: server.username,
|
|
687
|
+
phase: 'connect',
|
|
688
|
+
}),
|
|
689
|
+
)
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
this.#log('warn', 'dsh-server-connector: ssh connection to %s dropped: %s', server.id, error.message)
|
|
693
|
+
}
|
|
694
|
+
client.on('error', onClientError)
|
|
695
|
+
client.on('close', () => {
|
|
696
|
+
if (handshake.settled) return
|
|
697
|
+
handshake.settled = true
|
|
698
|
+
handshake.reject(
|
|
699
|
+
normalizeError(new Error('the connection closed before the SSH handshake finished'), {
|
|
700
|
+
serverId: server.id,
|
|
701
|
+
host: server.host,
|
|
702
|
+
port: server.port,
|
|
703
|
+
username: server.username,
|
|
704
|
+
phase: 'connect',
|
|
705
|
+
}),
|
|
706
|
+
)
|
|
707
|
+
})
|
|
708
|
+
client.once('ready', () => {
|
|
709
|
+
if (handshake.settled) return
|
|
710
|
+
handshake.settled = true
|
|
711
|
+
handshake.resolve(client)
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
const options = {
|
|
715
|
+
host: server.host,
|
|
716
|
+
port: server.port,
|
|
717
|
+
username: server.username,
|
|
718
|
+
readyTimeout: this.#connectTimeoutMs,
|
|
719
|
+
keepaliveInterval: this.#keepaliveIntervalMs,
|
|
720
|
+
keepaliveCountMax: 3,
|
|
721
|
+
// §7.4: the callback decides; the store only persists the outcome.
|
|
722
|
+
/** @type {(keyBlob: Buffer|string, callback: (accept: boolean) => void) => void} */
|
|
723
|
+
hostVerifier: (keyBlob, callback) => {
|
|
724
|
+
const fingerprint =
|
|
725
|
+
typeof keyBlob === 'string'
|
|
726
|
+
? { hash: keyBlob, algorithm: 'unknown' }
|
|
727
|
+
: fingerprintOrNull(keyBlob)
|
|
728
|
+
if (fingerprint === null) {
|
|
729
|
+
rejection = new HostKeyRejectedError(
|
|
730
|
+
server.id,
|
|
731
|
+
`The host key of ${server.host}:${server.port} could not be read; refusing to connect.`,
|
|
732
|
+
)
|
|
733
|
+
callback(false)
|
|
734
|
+
return
|
|
735
|
+
}
|
|
736
|
+
const decision = decideHostKey({ mode: server.hostKeyMode, known }, fingerprint.hash)
|
|
737
|
+
if (!decision.accept) {
|
|
738
|
+
rejection = hostKeyMessage(server, decision.reason)
|
|
739
|
+
callback(false)
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
if (decision.store) accepted.value = fingerprint
|
|
743
|
+
callback(true)
|
|
744
|
+
},
|
|
745
|
+
...auth,
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
client.connect(options)
|
|
750
|
+
await ready
|
|
751
|
+
} catch (error) {
|
|
752
|
+
safeDestroy(client)
|
|
753
|
+
if (accepted.value !== null) {
|
|
754
|
+
// Record first, then report: a fingerprint accepted for an
|
|
755
|
+
// `accept-new` host must survive the failure of a later auth attempt,
|
|
756
|
+
// otherwise the next call would silently re-trust whatever answers.
|
|
757
|
+
await this.#knownHosts
|
|
758
|
+
.set(server.id, { hash: accepted.value.hash, algorithm: accepted.value.algorithm })
|
|
759
|
+
.catch((storeError) => {
|
|
760
|
+
this.#log('warn', 'dsh-server-connector: cannot store host key for %s: %s', server.id, String(storeError))
|
|
761
|
+
})
|
|
762
|
+
}
|
|
763
|
+
throw error
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (accepted.value !== null) {
|
|
767
|
+
await this.#knownHosts.set(server.id, { hash: accepted.value.hash, algorithm: accepted.value.algorithm })
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const connection = { client, alive: true, ready: 'ready', openedAt: Date.now(), reconnected: false }
|
|
771
|
+
// The single 'error' listener installed above already logs a post-handshake
|
|
772
|
+
// drop; this only flips the liveness flag the pool checks before reuse.
|
|
773
|
+
client.on('error', () => {
|
|
774
|
+
connection.alive = false
|
|
775
|
+
})
|
|
776
|
+
client.on('close', () => {
|
|
777
|
+
connection.alive = false
|
|
778
|
+
})
|
|
779
|
+
return connection
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Resolve the `ssh2` authentication fields for one server (§6.3). Password
|
|
784
|
+
* authentication goes through the injected resolver, so this module never
|
|
785
|
+
* reads `ctx.credentials` itself.
|
|
786
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
787
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
788
|
+
*/
|
|
789
|
+
async #authFor(server) {
|
|
790
|
+
const auth = server.auth
|
|
791
|
+
if (auth.kind === 'agent') return {}
|
|
792
|
+
if (auth.kind === 'key') {
|
|
793
|
+
/** @type {Buffer} */
|
|
794
|
+
let privateKey
|
|
795
|
+
try {
|
|
796
|
+
privateKey = await readFile(auth.privateKeyPath)
|
|
797
|
+
} catch (error) {
|
|
798
|
+
throw normalizeError(error, {
|
|
799
|
+
serverId: server.id,
|
|
800
|
+
host: server.host,
|
|
801
|
+
port: server.port,
|
|
802
|
+
username: server.username,
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
return { privateKey }
|
|
806
|
+
}
|
|
807
|
+
if (auth.kind === 'password') {
|
|
808
|
+
const password = await this.#resolvePassword(server)
|
|
809
|
+
if (password === null || password === undefined || password === '') {
|
|
810
|
+
throw new PoolError(
|
|
811
|
+
'NO_CREDENTIAL',
|
|
812
|
+
`No password is stored for server ${server.id} (credentialRef ${auth.credentialRef}). ` +
|
|
813
|
+
'Set it in the server panel, or switch the server to key authentication.',
|
|
814
|
+
{ serverId: server.id, host: server.host, port: server.port, username: server.username },
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
return { password }
|
|
818
|
+
}
|
|
819
|
+
throw new PoolError('INVALID_SERVER', `Server ${server.id} has an unsupported auth configuration.`, {
|
|
820
|
+
serverId: server.id,
|
|
821
|
+
host: server.host,
|
|
822
|
+
port: server.port,
|
|
823
|
+
username: server.username,
|
|
824
|
+
})
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Run one already-assembled script over one connection.
|
|
829
|
+
* @param {any} connection
|
|
830
|
+
* @param {string} script
|
|
831
|
+
* @param {number} timeoutMs
|
|
832
|
+
* @param {number} maxOutputChars
|
|
833
|
+
* @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean, startedAt: number, endedAt: number}>}
|
|
834
|
+
*/
|
|
835
|
+
async #run(connection, script, timeoutMs, maxOutputChars) {
|
|
836
|
+
if (connection.alive === false) {
|
|
837
|
+
throw new PoolError('CONNECTION_LOST', 'The SSH connection is no longer alive.', {})
|
|
838
|
+
}
|
|
839
|
+
const startedAt = Date.now()
|
|
840
|
+
return new Promise((resolve, reject) => {
|
|
841
|
+
let settled = false
|
|
842
|
+
/** @type {NodeJS.Timeout|null} */
|
|
843
|
+
let killTimer = null
|
|
844
|
+
const finish = (/** @type {any} */ value, /** @type {unknown} */ error) => {
|
|
845
|
+
if (settled) return
|
|
846
|
+
settled = true
|
|
847
|
+
if (killTimer !== null) clearTimeout(killTimer)
|
|
848
|
+
if (error !== undefined) reject(error)
|
|
849
|
+
else resolve(value)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
let producedOutput = false
|
|
853
|
+
/** @param {Error & {level?: string, code?: string}} error */
|
|
854
|
+
const onExecError = (error) => {
|
|
855
|
+
connection.alive = false
|
|
856
|
+
const normalized = normalizeError(error, { phase: 'exec' })
|
|
857
|
+
// Attach, rather than rebuild: the code and message stay the ones the
|
|
858
|
+
// caller expects, and exec() reads this flag to decide on a replay.
|
|
859
|
+
if (normalized instanceof PoolError) normalized.producedOutput = producedOutput
|
|
860
|
+
finish(undefined, normalized)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** @type {any} */
|
|
864
|
+
let channel = null
|
|
865
|
+
try {
|
|
866
|
+
connection.client.exec(script, { pty: false }, (/** @type {any} */ error, /** @type {any} */ stream) => {
|
|
867
|
+
if (error) {
|
|
868
|
+
onExecError(error)
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
channel = stream
|
|
872
|
+
stream.on('data', () => {
|
|
873
|
+
producedOutput = true
|
|
874
|
+
})
|
|
875
|
+
stream.stderr.on('data', () => {
|
|
876
|
+
producedOutput = true
|
|
877
|
+
})
|
|
878
|
+
this.#consumeChannel(
|
|
879
|
+
stream,
|
|
880
|
+
maxOutputChars,
|
|
881
|
+
(result) => {
|
|
882
|
+
finish(
|
|
883
|
+
{
|
|
884
|
+
stdout: result.stdout,
|
|
885
|
+
stderr: result.stderr,
|
|
886
|
+
exitCode: result.exitCode,
|
|
887
|
+
timedOut: result.timedOut,
|
|
888
|
+
truncated: result.truncated,
|
|
889
|
+
startedAt,
|
|
890
|
+
endedAt: Date.now(),
|
|
891
|
+
},
|
|
892
|
+
undefined,
|
|
893
|
+
)
|
|
894
|
+
},
|
|
895
|
+
(deadError) => {
|
|
896
|
+
onExecError(deadError)
|
|
897
|
+
},
|
|
898
|
+
)
|
|
899
|
+
})
|
|
900
|
+
} catch (error) {
|
|
901
|
+
// `client.exec` throws synchronously when the socket is already gone.
|
|
902
|
+
onExecError(/** @type {Error} */ (error))
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// The remote watchdog is the primary timeout; this is the safety net for
|
|
907
|
+
// a channel that never reports back (say, a hung sshd). It closes the
|
|
908
|
+
// channel, which the remote kernel turns into a SIGHUP for the command.
|
|
909
|
+
killTimer = setTimeout(() => {
|
|
910
|
+
if (settled || channel === null) return
|
|
911
|
+
try {
|
|
912
|
+
channel.signal('KILL')
|
|
913
|
+
} catch {
|
|
914
|
+
// The channel may already be closed; closing it below is harmless.
|
|
915
|
+
}
|
|
916
|
+
try {
|
|
917
|
+
channel.close()
|
|
918
|
+
} catch {
|
|
919
|
+
// Same: a closed channel needs no second close.
|
|
920
|
+
}
|
|
921
|
+
finish(
|
|
922
|
+
undefined,
|
|
923
|
+
new PoolError(
|
|
924
|
+
'TIMEOUT',
|
|
925
|
+
`Command did not return within ${timeoutMs} ms and the SSH channel was closed. The remote watchdog kills the process group at the same budget; check the server if the process is still running.`,
|
|
926
|
+
{},
|
|
927
|
+
),
|
|
928
|
+
)
|
|
929
|
+
}, timeoutMs + 5000)
|
|
930
|
+
killTimer.unref?.()
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The streaming twin of {@link #run}: same watchdog, same ceiling, but every
|
|
936
|
+
* chunk is forwarded to `onChunk` as it arrives, and the caller can cancel
|
|
937
|
+
* through the returned `onCancel` handle or its abort signal.
|
|
938
|
+
* @param {any} connection
|
|
939
|
+
* @param {string} script
|
|
940
|
+
* @param {number} timeoutMs
|
|
941
|
+
* @param {number} maxOutputChars
|
|
942
|
+
* @param {(channel: 'stdout'|'stderr', text: string) => void} onChunk
|
|
943
|
+
* @param {(cancel: () => void) => void} onCancel
|
|
944
|
+
* @param {AbortSignal} [signal]
|
|
945
|
+
* @returns {Promise<{stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean, cancelled: boolean, startedAt: number, endedAt: number}>}
|
|
946
|
+
*/
|
|
947
|
+
async #runStream(connection, script, timeoutMs, maxOutputChars, onChunk, onCancel, signal) {
|
|
948
|
+
if (connection.alive === false) {
|
|
949
|
+
throw new PoolError('CONNECTION_LOST', 'The SSH connection is no longer alive.', {})
|
|
950
|
+
}
|
|
951
|
+
const startedAt = Date.now()
|
|
952
|
+
return new Promise((resolve, reject) => {
|
|
953
|
+
let settled = false
|
|
954
|
+
let cancelled = false
|
|
955
|
+
/** @type {NodeJS.Timeout|null} */
|
|
956
|
+
let killTimer = null
|
|
957
|
+
/** @type {any} */
|
|
958
|
+
let channel = null
|
|
959
|
+
/** @type {(() => void)|null} */
|
|
960
|
+
let detachAbort = null
|
|
961
|
+
|
|
962
|
+
const finish = (/** @type {any} */ value, /** @type {unknown} */ error) => {
|
|
963
|
+
if (settled) return
|
|
964
|
+
settled = true
|
|
965
|
+
if (killTimer !== null) clearTimeout(killTimer)
|
|
966
|
+
if (detachAbort !== null) detachAbort()
|
|
967
|
+
if (error !== undefined) reject(error)
|
|
968
|
+
else resolve(value)
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Abort the remote command.
|
|
973
|
+
*
|
|
974
|
+
* `TERM`, not `KILL`: the remote script traps TERM (and HUP/INT) and uses
|
|
975
|
+
* the trap to kill the command's own process group. A KILL would reach the
|
|
976
|
+
* outer shell only and leave the `setsid` child — the actual command —
|
|
977
|
+
* running until its watchdog fired, which is exactly the leak this path
|
|
978
|
+
* must not have. Closing the channel afterwards releases our side.
|
|
979
|
+
*/
|
|
980
|
+
const abortChannel = () => {
|
|
981
|
+
const open = channel
|
|
982
|
+
if (open === null) return
|
|
983
|
+
try {
|
|
984
|
+
open.signal('TERM')
|
|
985
|
+
} catch {
|
|
986
|
+
// The channel may already be closed.
|
|
987
|
+
}
|
|
988
|
+
// The signal is delivered on the same channel, so closing it in the
|
|
989
|
+
// same tick can drop the request before it is sent. One macrotask later
|
|
990
|
+
// the packet is on the wire, and closing releases our side.
|
|
991
|
+
const closer = setTimeout(() => {
|
|
992
|
+
try {
|
|
993
|
+
open.close()
|
|
994
|
+
} catch {
|
|
995
|
+
// Already closed.
|
|
996
|
+
}
|
|
997
|
+
}, 50)
|
|
998
|
+
closer.unref?.()
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** @param {Error & {level?: string, code?: string}} error */
|
|
1002
|
+
const onExecError = (error) => {
|
|
1003
|
+
connection.alive = false
|
|
1004
|
+
finish(undefined, normalizeError(error, { phase: 'exec' }))
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
onCancel(() => {
|
|
1008
|
+
if (settled) return
|
|
1009
|
+
cancelled = true
|
|
1010
|
+
abortChannel()
|
|
1011
|
+
finish(
|
|
1012
|
+
{
|
|
1013
|
+
stdout: '',
|
|
1014
|
+
stderr: '',
|
|
1015
|
+
exitCode: null,
|
|
1016
|
+
timedOut: false,
|
|
1017
|
+
truncated: false,
|
|
1018
|
+
cancelled: true,
|
|
1019
|
+
startedAt,
|
|
1020
|
+
endedAt: Date.now(),
|
|
1021
|
+
},
|
|
1022
|
+
undefined,
|
|
1023
|
+
)
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
try {
|
|
1027
|
+
connection.client.exec(script, { pty: false }, (/** @type {any} */ error, /** @type {any} */ stream) => {
|
|
1028
|
+
if (error) {
|
|
1029
|
+
onExecError(error)
|
|
1030
|
+
return
|
|
1031
|
+
}
|
|
1032
|
+
channel = stream
|
|
1033
|
+
this.#consumeStreamChannel(
|
|
1034
|
+
stream,
|
|
1035
|
+
maxOutputChars,
|
|
1036
|
+
onChunk,
|
|
1037
|
+
(result) => {
|
|
1038
|
+
finish({ ...result, cancelled, startedAt, endedAt: Date.now() }, undefined)
|
|
1039
|
+
},
|
|
1040
|
+
onExecError,
|
|
1041
|
+
)
|
|
1042
|
+
})
|
|
1043
|
+
} catch (error) {
|
|
1044
|
+
onExecError(/** @type {Error} */ (error))
|
|
1045
|
+
return
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if (signal !== undefined) {
|
|
1049
|
+
const onAbort = () => {
|
|
1050
|
+
cancelled = true
|
|
1051
|
+
abortChannel()
|
|
1052
|
+
finish(
|
|
1053
|
+
{
|
|
1054
|
+
stdout: '',
|
|
1055
|
+
stderr: '',
|
|
1056
|
+
exitCode: null,
|
|
1057
|
+
timedOut: false,
|
|
1058
|
+
truncated: false,
|
|
1059
|
+
cancelled: true,
|
|
1060
|
+
startedAt,
|
|
1061
|
+
endedAt: Date.now(),
|
|
1062
|
+
},
|
|
1063
|
+
undefined,
|
|
1064
|
+
)
|
|
1065
|
+
}
|
|
1066
|
+
if (signal.aborted) {
|
|
1067
|
+
onAbort()
|
|
1068
|
+
return
|
|
1069
|
+
}
|
|
1070
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
1071
|
+
detachAbort = () => signal.removeEventListener('abort', onAbort)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
killTimer = setTimeout(() => {
|
|
1075
|
+
if (settled) return
|
|
1076
|
+
abortChannel()
|
|
1077
|
+
finish(
|
|
1078
|
+
undefined,
|
|
1079
|
+
new PoolError(
|
|
1080
|
+
'TIMEOUT',
|
|
1081
|
+
`Command did not return within ${timeoutMs} ms and the SSH channel was closed. The remote watchdog kills the process group at the same budget; check the server if the process is still running.`,
|
|
1082
|
+
{},
|
|
1083
|
+
),
|
|
1084
|
+
)
|
|
1085
|
+
}, timeoutMs + 5000)
|
|
1086
|
+
killTimer.unref?.()
|
|
1087
|
+
})
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Read one channel to completion while forwarding each chunk. The same
|
|
1092
|
+
* `maxOutputChars` ceiling bounds each stream's assembled result separately;
|
|
1093
|
+
* a streaming consumer (a job ring) applies its own retention.
|
|
1094
|
+
* @param {any} stream
|
|
1095
|
+
* @param {number} maxOutputChars
|
|
1096
|
+
* @param {(channel: 'stdout'|'stderr', text: string) => void} onChunk
|
|
1097
|
+
* @param {(result: {stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean}) => void} done
|
|
1098
|
+
* @param {(error: Error) => void} failed
|
|
1099
|
+
* @returns {void}
|
|
1100
|
+
*/
|
|
1101
|
+
#consumeStreamChannel(stream, maxOutputChars, onChunk, done, failed) {
|
|
1102
|
+
// One counter per stream: the ceiling is a property of each field of the
|
|
1103
|
+
// result, not of their sum, so stderr still arrives after stdout filled up.
|
|
1104
|
+
/** @type {Record<'stdout'|'stderr', number>} */
|
|
1105
|
+
const size = { stdout: 0, stderr: 0 }
|
|
1106
|
+
let truncated = false
|
|
1107
|
+
let closed = false
|
|
1108
|
+
let stdout = ''
|
|
1109
|
+
let stderr = ''
|
|
1110
|
+
/** @type {{stdout: boolean, stderr: boolean}} */
|
|
1111
|
+
const cut = { stdout: false, stderr: false }
|
|
1112
|
+
|
|
1113
|
+
const forward = (/** @type {'stdout'|'stderr'} */ channel, /** @type {unknown} */ chunk) => {
|
|
1114
|
+
const used = size[channel]
|
|
1115
|
+
if (used >= maxOutputChars) {
|
|
1116
|
+
cut[channel] = true
|
|
1117
|
+
truncated = true
|
|
1118
|
+
return
|
|
1119
|
+
}
|
|
1120
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.from(/** @type {Buffer} */ (chunk)).toString('utf8')
|
|
1121
|
+
const remaining = maxOutputChars - used
|
|
1122
|
+
const kept = text.length > remaining ? text.slice(0, remaining) : text
|
|
1123
|
+
size[channel] = used + kept.length
|
|
1124
|
+
if (kept.length < text.length) {
|
|
1125
|
+
cut[channel] = true
|
|
1126
|
+
truncated = true
|
|
1127
|
+
}
|
|
1128
|
+
if (kept === '') return
|
|
1129
|
+
if (channel === 'stdout') stdout += kept
|
|
1130
|
+
else stderr += kept
|
|
1131
|
+
onChunk(channel, kept)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
stream.on('data', (/** @type {unknown} */ chunk) => forward('stdout', chunk))
|
|
1135
|
+
stream.stderr.on('data', (/** @type {unknown} */ chunk) => forward('stderr', chunk))
|
|
1136
|
+
|
|
1137
|
+
let exitSignal = /** @type {string|null} */ (null)
|
|
1138
|
+
stream.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
|
|
1139
|
+
exitSignal = signal
|
|
1140
|
+
stream.exitCode = code
|
|
1141
|
+
})
|
|
1142
|
+
stream.on('close', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
|
|
1143
|
+
if (closed) return
|
|
1144
|
+
closed = true
|
|
1145
|
+
const finalSignal = signal ?? exitSignal
|
|
1146
|
+
const exitCode = typeof code === 'number' ? code : signalExitCode(finalSignal)
|
|
1147
|
+
done({
|
|
1148
|
+
stdout: stdout + (cut.stdout ? TRUNCATED_MARKER : ''),
|
|
1149
|
+
stderr: stderr + (cut.stderr ? TRUNCATED_MARKER : ''),
|
|
1150
|
+
exitCode,
|
|
1151
|
+
timedOut: finalSignal === 'SIGKILL' || exitCode === 137,
|
|
1152
|
+
truncated,
|
|
1153
|
+
})
|
|
1154
|
+
})
|
|
1155
|
+
stream.on('error', (/** @type {Error} */ error) => {
|
|
1156
|
+
if (closed) return
|
|
1157
|
+
closed = true
|
|
1158
|
+
failed(error)
|
|
1159
|
+
})
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Read one channel to completion. Output beyond `maxOutputChars` is dropped
|
|
1164
|
+
* (and flagged) instead of growing without bound; each stream is capped
|
|
1165
|
+
* separately, because the two are returned as two fields.
|
|
1166
|
+
* @param {any} stream
|
|
1167
|
+
* @param {number} maxOutputChars
|
|
1168
|
+
* @param {(result: {stdout: string, stderr: string, exitCode: number, timedOut: boolean, truncated: boolean}) => void} done
|
|
1169
|
+
* @param {(error: Error) => void} failed
|
|
1170
|
+
* @returns {void}
|
|
1171
|
+
*/
|
|
1172
|
+
#consumeChannel(stream, maxOutputChars, done, failed) {
|
|
1173
|
+
/** @type {string[]} */
|
|
1174
|
+
const stdoutParts = []
|
|
1175
|
+
/** @type {string[]} */
|
|
1176
|
+
const stderrParts = []
|
|
1177
|
+
// One counter per stream, for the same reason as #consumeStreamChannel:
|
|
1178
|
+
// a full stdout must not swallow stderr.
|
|
1179
|
+
/** @type {Record<'stdout'|'stderr', number>} */
|
|
1180
|
+
const size = { stdout: 0, stderr: 0 }
|
|
1181
|
+
let truncated = false
|
|
1182
|
+
let closed = false
|
|
1183
|
+
|
|
1184
|
+
// The cut is tracked per stream so the marker lands in the stream that was
|
|
1185
|
+
// actually truncated (and only there).
|
|
1186
|
+
/** @type {{stdout: boolean, stderr: boolean}} */
|
|
1187
|
+
const cut = { stdout: false, stderr: false }
|
|
1188
|
+
const append = (
|
|
1189
|
+
/** @type {string[]} */ parts,
|
|
1190
|
+
/** @type {unknown} */ chunk,
|
|
1191
|
+
/** @type {'stdout'|'stderr'} */ streamName,
|
|
1192
|
+
) => {
|
|
1193
|
+
const used = size[streamName]
|
|
1194
|
+
if (used >= maxOutputChars) {
|
|
1195
|
+
cut[streamName] = true
|
|
1196
|
+
truncated = true
|
|
1197
|
+
return
|
|
1198
|
+
}
|
|
1199
|
+
const remaining = maxOutputChars - used
|
|
1200
|
+
const text = typeof chunk === 'string' ? chunk : Buffer.from(/** @type {Buffer} */ (chunk)).toString('utf8')
|
|
1201
|
+
if (text.length > remaining) {
|
|
1202
|
+
parts.push(text.slice(0, remaining))
|
|
1203
|
+
size[streamName] = maxOutputChars
|
|
1204
|
+
cut[streamName] = true
|
|
1205
|
+
truncated = true
|
|
1206
|
+
return
|
|
1207
|
+
}
|
|
1208
|
+
parts.push(text)
|
|
1209
|
+
size[streamName] = used + text.length
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
stream.on('data', (/** @type {unknown} */ chunk) => append(stdoutParts, chunk, 'stdout'))
|
|
1213
|
+
stream.stderr.on('data', (/** @type {unknown} */ chunk) => append(stderrParts, chunk, 'stderr'))
|
|
1214
|
+
|
|
1215
|
+
let exitSignal = /** @type {string|null} */ (null)
|
|
1216
|
+
stream.on('exit', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
|
|
1217
|
+
exitSignal = signal
|
|
1218
|
+
stream.exitCode = code
|
|
1219
|
+
})
|
|
1220
|
+
stream.on('close', (/** @type {number|null} */ code, /** @type {string|null} */ signal) => {
|
|
1221
|
+
if (closed) return
|
|
1222
|
+
closed = true
|
|
1223
|
+
const finalSignal = signal ?? exitSignal
|
|
1224
|
+
const exitCode = typeof code === 'number' ? code : signalExitCode(finalSignal)
|
|
1225
|
+
// The watchdog KILLs the process group, so a timed-out command reports
|
|
1226
|
+
// 137 (128 + SIGKILL) on the channel. Any non-zero code when the process
|
|
1227
|
+
// was signalled is treated as a timeout only when the signal is SIGKILL;
|
|
1228
|
+
// a `kill -9` from inside the command is indistinguishable, which is
|
|
1229
|
+
// acceptable: both mean "the command did not finish on its own".
|
|
1230
|
+
done({
|
|
1231
|
+
stdout: stdoutParts.join('') + (cut.stdout ? TRUNCATED_MARKER : ''),
|
|
1232
|
+
stderr: stderrParts.join('') + (cut.stderr ? TRUNCATED_MARKER : ''),
|
|
1233
|
+
exitCode,
|
|
1234
|
+
timedOut: finalSignal === 'SIGKILL' || exitCode === 137,
|
|
1235
|
+
truncated,
|
|
1236
|
+
})
|
|
1237
|
+
})
|
|
1238
|
+
stream.on('error', (/** @type {Error} */ error) => {
|
|
1239
|
+
if (closed) return
|
|
1240
|
+
closed = true
|
|
1241
|
+
failed(error)
|
|
1242
|
+
})
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* Record a used connection and arm its idle timer (§7.2).
|
|
1247
|
+
* @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
|
|
1248
|
+
* @returns {void}
|
|
1249
|
+
*/
|
|
1250
|
+
#touch(entry) {
|
|
1251
|
+
this.#clearIdle(entry)
|
|
1252
|
+
entry.lastUsed = Date.now()
|
|
1253
|
+
if (this.#idleTimeoutMs <= 0) return
|
|
1254
|
+
entry.idleTimer = setTimeout(() => {
|
|
1255
|
+
// Only a connection nobody is using may be closed. `queue` is *not*
|
|
1256
|
+
// touched here: it is the serialisation tail of the server, and clearing
|
|
1257
|
+
// it would let two commands run at once.
|
|
1258
|
+
const connection = entry.connection
|
|
1259
|
+
entry.connection = null
|
|
1260
|
+
entry.idleTimer = null
|
|
1261
|
+
if (connection !== null && connection.alive === true) {
|
|
1262
|
+
this.#log('info', 'dsh-server-connector: closing idle ssh connection')
|
|
1263
|
+
closeConnection(connection).catch(() => {})
|
|
1264
|
+
}
|
|
1265
|
+
}, this.#idleTimeoutMs)
|
|
1266
|
+
entry.idleTimer.unref?.()
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* @param {{idleTimer: NodeJS.Timeout|null}} entry
|
|
1271
|
+
* @returns {void}
|
|
1272
|
+
*/
|
|
1273
|
+
#clearIdle(entry) {
|
|
1274
|
+
if (entry.idleTimer !== null) {
|
|
1275
|
+
clearTimeout(entry.idleTimer)
|
|
1276
|
+
entry.idleTimer = null
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* @param {string} serverId
|
|
1282
|
+
* @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
|
|
1283
|
+
* @returns {void}
|
|
1284
|
+
*/
|
|
1285
|
+
#discard(serverId, entry) {
|
|
1286
|
+
this.#clearIdle(entry)
|
|
1287
|
+
const connection = entry.connection
|
|
1288
|
+
entry.connection = null
|
|
1289
|
+
if (connection !== null && connection.ready === 'ready') closeConnection(connection).catch(() => {})
|
|
1290
|
+
this.#log('info', 'dsh-server-connector: discarded dead connection for %s', serverId)
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Get a live connection, or dial one. A dead-but-remembered connection is
|
|
1295
|
+
* rebuilt exactly once (§7.2); if the rebuild fails, CONNECT_FAILED (or a
|
|
1296
|
+
* more specific code) travels to the caller unchanged.
|
|
1297
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
1298
|
+
* @param {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}} entry
|
|
1299
|
+
* @returns {Promise<any>}
|
|
1300
|
+
*/
|
|
1301
|
+
async #ensureConnection(server, entry) {
|
|
1302
|
+
if (entry.connection !== null && entry.connection.alive === true) {
|
|
1303
|
+
return entry.connection
|
|
1304
|
+
}
|
|
1305
|
+
if (entry.connection !== null) {
|
|
1306
|
+
// One retry only: the previous socket is gone, dial a fresh one.
|
|
1307
|
+
closeConnection(entry.connection).catch(() => {})
|
|
1308
|
+
entry.connection = null
|
|
1309
|
+
}
|
|
1310
|
+
return this.#dial(server, entry)
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
/**
|
|
1314
|
+
* Serialise one operation behind the server's previous one (§7.2). A rejected
|
|
1315
|
+
* operation must not poison the queue for the next caller.
|
|
1316
|
+
* @param {{queue: Promise<void>}} entry
|
|
1317
|
+
* @param {() => Promise<any>} operation
|
|
1318
|
+
* @returns {Promise<any>}
|
|
1319
|
+
*/
|
|
1320
|
+
async #enqueue(entry, operation) {
|
|
1321
|
+
const previous = entry.queue
|
|
1322
|
+
/** @type {Promise<any>} */
|
|
1323
|
+
const result = previous.then(operation, operation)
|
|
1324
|
+
entry.queue = result.then(
|
|
1325
|
+
() => {},
|
|
1326
|
+
() => {},
|
|
1327
|
+
)
|
|
1328
|
+
return result
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* @param {string} id
|
|
1333
|
+
* @returns {{connection: any, queue: Promise<void>, lastUsed: number, idleTimer: NodeJS.Timeout|null}}
|
|
1334
|
+
*/
|
|
1335
|
+
#entry(id) {
|
|
1336
|
+
let entry = this.#servers.get(id)
|
|
1337
|
+
if (entry === undefined) {
|
|
1338
|
+
entry = { connection: null, queue: Promise.resolve(), lastUsed: Date.now(), idleTimer: null }
|
|
1339
|
+
this.#servers.set(id, entry)
|
|
1340
|
+
}
|
|
1341
|
+
return entry
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** @returns {void} */
|
|
1345
|
+
#assertUsable() {
|
|
1346
|
+
if (this.#disposed) {
|
|
1347
|
+
throw new PoolError('CONNECTION_LOST', 'The SSH pool has been disposed; the plugin is shutting down.', {})
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/**
|
|
1352
|
+
* @param {'info'|'warn'|'error'} level
|
|
1353
|
+
* @param {string} message
|
|
1354
|
+
* @param {...unknown} args
|
|
1355
|
+
* @returns {void}
|
|
1356
|
+
*/
|
|
1357
|
+
#log(level, message, ...args) {
|
|
1358
|
+
const logger = this.#logger
|
|
1359
|
+
if (logger === null || logger === undefined) return
|
|
1360
|
+
const write = logger[level] ?? logger.info
|
|
1361
|
+
if (typeof write === 'function') write.call(logger, message, ...args)
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* Turn a socket-level signal into the exit code a shell would report.
|
|
1367
|
+
* @param {string|null} signal
|
|
1368
|
+
* @returns {number}
|
|
1369
|
+
*/
|
|
1370
|
+
function signalExitCode(signal) {
|
|
1371
|
+
if (signal === null) return 0
|
|
1372
|
+
/** @type {Record<string, number>} */
|
|
1373
|
+
const numbers = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }
|
|
1374
|
+
const number = numbers[signal]
|
|
1375
|
+
return number === undefined ? 1 : 128 + number
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Fingerprint one host key blob through the store's own implementation.
|
|
1380
|
+
* Returns `null` instead of throwing, because the ssh2 callback that calls this
|
|
1381
|
+
* has no channel to report an error through: the pool then refuses the host.
|
|
1382
|
+
* @param {Buffer|Uint8Array|string} keyBlob
|
|
1383
|
+
* @returns {{hash: string, algorithm: string}|null}
|
|
1384
|
+
*/
|
|
1385
|
+
function fingerprintOrNull(keyBlob) {
|
|
1386
|
+
try {
|
|
1387
|
+
return fingerprintHostKey(typeof keyBlob === 'string' ? Buffer.from(keyBlob, 'latin1') : keyBlob)
|
|
1388
|
+
} catch {
|
|
1389
|
+
return null
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* The §7.5 messages for a refused host key.
|
|
1395
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
1396
|
+
* @param {'changed'|'unknown'} reason
|
|
1397
|
+
* @returns {HostKeyRejectedError}
|
|
1398
|
+
*/
|
|
1399
|
+
function hostKeyMessage(server, reason) {
|
|
1400
|
+
const target = `${server.host}:${server.port}`
|
|
1401
|
+
if (reason === 'changed') {
|
|
1402
|
+
return new HostKeyRejectedError(
|
|
1403
|
+
server.id,
|
|
1404
|
+
`The host key of ${target} changed; refusing to connect. If the change is expected, ` +
|
|
1405
|
+
`remove the entry for server ${server.id} from known_hosts.json (or trust the new key in the panel) and retry.`,
|
|
1406
|
+
)
|
|
1407
|
+
}
|
|
1408
|
+
return new HostKeyRejectedError(
|
|
1409
|
+
server.id,
|
|
1410
|
+
`The host key of ${target} is not trusted yet and server ${server.id} uses hostKeyMode "verify". ` +
|
|
1411
|
+
'Switch it to "accept-new" (or add the fingerprint to known_hosts.json) and retry.',
|
|
1412
|
+
)
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* @param {unknown} value
|
|
1417
|
+
* @returns {import('./registry.js').ServerRecord}
|
|
1418
|
+
*/
|
|
1419
|
+
function requireServer(value) {
|
|
1420
|
+
if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.trim() === '') {
|
|
1421
|
+
throw new PoolError('INVALID_SERVER', 'A complete server record is required (it must carry an id).', {})
|
|
1422
|
+
}
|
|
1423
|
+
if (typeof value.host !== 'string' || value.host.trim() === '') {
|
|
1424
|
+
throw new PoolError('INVALID_SERVER', `Server ${value.id} has no host.`, { serverId: value.id })
|
|
1425
|
+
}
|
|
1426
|
+
return /** @type {import('./registry.js').ServerRecord} */ (value)
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* @param {ExecRequest} request
|
|
1431
|
+
* @returns {string}
|
|
1432
|
+
*/
|
|
1433
|
+
function normalizeCommand(request) {
|
|
1434
|
+
if (!isPlainObject(request) || typeof request.command !== 'string' || request.command.trim() === '') {
|
|
1435
|
+
throw new PoolError('INVALID_SERVER', 'exec() needs a non-empty command.', {})
|
|
1436
|
+
}
|
|
1437
|
+
return request.command
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* @param {unknown} value
|
|
1442
|
+
* @param {number} fallback
|
|
1443
|
+
* @returns {number}
|
|
1444
|
+
*/
|
|
1445
|
+
function normalizePositive(value, fallback) {
|
|
1446
|
+
if (value === undefined || value === null) return fallback
|
|
1447
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
1448
|
+
throw new PoolError('INVALID_SERVER', 'timeoutMs and maxOutputChars must be positive numbers.', {})
|
|
1449
|
+
}
|
|
1450
|
+
return Math.round(value)
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* @param {unknown} value
|
|
1455
|
+
* @param {import('./registry.js').ServerRecord} server
|
|
1456
|
+
* @returns {string|null}
|
|
1457
|
+
*/
|
|
1458
|
+
function normalizeCwd(value, server) {
|
|
1459
|
+
if (value === undefined) return server.defaultWorkDir ?? null
|
|
1460
|
+
if (value === null) return server.defaultWorkDir ?? null
|
|
1461
|
+
if (typeof value !== 'string') {
|
|
1462
|
+
throw new PoolError('INVALID_SERVER', 'cwd must be a string or null.', { serverId: server.id })
|
|
1463
|
+
}
|
|
1464
|
+
const trimmed = value.trim()
|
|
1465
|
+
return trimmed === '' ? server.defaultWorkDir ?? null : trimmed
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Close one connection without letting a half-open socket throw on the way out.
|
|
1470
|
+
* @param {any} connection
|
|
1471
|
+
* @returns {Promise<void>}
|
|
1472
|
+
*/
|
|
1473
|
+
async function closeConnection(connection) {
|
|
1474
|
+
connection.alive = false
|
|
1475
|
+
const client = connection.client
|
|
1476
|
+
if (client === undefined) return
|
|
1477
|
+
try {
|
|
1478
|
+
client.end()
|
|
1479
|
+
} catch {
|
|
1480
|
+
// Already destroyed.
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* @param {any} client
|
|
1486
|
+
* @returns {void}
|
|
1487
|
+
*/
|
|
1488
|
+
function safeDestroy(client) {
|
|
1489
|
+
try {
|
|
1490
|
+
client.destroy()
|
|
1491
|
+
} catch {
|
|
1492
|
+
// Never connected.
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
export default Pool
|