dsh-remote 0.8.72 → 0.8.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.en.md +389 -389
- package/README.md +462 -462
- package/cordis.patch.yml +9 -9
- package/lib/binding.js +172 -172
- package/lib/client.js +4022 -4022
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -141
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4550 -4540
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -893
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/package.json +100 -99
package/lib/openssh.js
CHANGED
|
@@ -1,879 +1,879 @@
|
|
|
1
|
-
// dsh-remote — the "system OpenSSH" SSH backend (issue #51).
|
|
2
|
-
//
|
|
3
|
-
// ## Why this exists
|
|
4
|
-
//
|
|
5
|
-
// The default backend (lib/pool.js) speaks SSH itself through ssh2, so it can
|
|
6
|
-
// only use the authentication methods ssh2 implements: password, private key,
|
|
7
|
-
// ssh-agent and keyboard-interactive. Enterprise hosts that only allow
|
|
8
|
-
// GSSAPI/Kerberos are unreachable, even though `ssh <alias>` in the user's own
|
|
9
|
-
// terminal works — the local Kerberos ticket (kinit) is simply not something
|
|
10
|
-
// ssh2 knows how to present.
|
|
11
|
-
//
|
|
12
|
-
// This backend delegates the transport to the LOCAL `ssh` executable, so every
|
|
13
|
-
// `~/.ssh/config` feature the user already relies on applies: gssapi-with-mic,
|
|
14
|
-
// ProxyJump, ProxyCommand, ControlPath, Host aliases, IdentityFile,
|
|
15
|
-
// PKCS11Provider, Match blocks… Anything OpenSSH can do, this backend can do,
|
|
16
|
-
// because OpenSSH is the one doing it.
|
|
17
|
-
//
|
|
18
|
-
// ## How each primitive is implemented
|
|
19
|
-
//
|
|
20
|
-
// exec → `ssh <opts> -- <dest> <command>` (one process per call)
|
|
21
|
-
// sftp → `ssh <opts> -s <dest> sftp` + ssh2's OWN SFTP protocol engine
|
|
22
|
-
// driven over the resulting raw byte stream
|
|
23
|
-
// forwardOut → `ssh <opts> -W <host>:<port> <dest>` (one process per tunnel)
|
|
24
|
-
// reverse → `ssh <opts> -N -R <bind>:<port>:<h>:<p> <dest>`
|
|
25
|
-
//
|
|
26
|
-
// The SFTP one deserves a note, because it is the reason this backend did not
|
|
27
|
-
// have to reimplement file transfer: `ssh -s <dest> sftp` on stdio is a PLAIN
|
|
28
|
-
// SFTP v3 byte stream (verified against a real host — the subsystem replies
|
|
29
|
-
// with its VERSION packet and nothing else on stdout). ssh2 already ships a
|
|
30
|
-
// complete, battle-tested SFTP implementation that speaks exactly that protocol
|
|
31
|
-
// over a duplex stream, so this module gives it a minimal `_protocol` shim and
|
|
32
|
-
// hands it the child process's stdio. File transfer therefore has the same
|
|
33
|
-
// semantics — and the same large-packet optimisations — as the native backend.
|
|
34
|
-
//
|
|
35
|
-
// ## Trade-offs (documented, not hidden)
|
|
36
|
-
//
|
|
37
|
-
// • spawn-per-operation: unlike the native pool there is no long-lived
|
|
38
|
-
// connection multiplexed across commands, so each `exec`/`forwardOut` pays
|
|
39
|
-
// a fresh SSH handshake. That is inherent to delegating to the `ssh` CLI:
|
|
40
|
-
// Windows OpenSSH rejects ControlMaster ("getsockname failed: Not a
|
|
41
|
-
// socket", measured), and a portable multiplexing layer would be a second
|
|
42
|
-
// implementation of what the native backend already does. The SFTP path IS
|
|
43
|
-
// kept persistent, because the workspace picker and the explorer hammer it.
|
|
44
|
-
// • the passphrase/password prompt: OpenSSH asks for both through the same
|
|
45
|
-
// askpass channel, so when a secret is configured this backend offers it
|
|
46
|
-
// for whichever prompt appears (password first, then passphrase). A host
|
|
47
|
-
// needing two DIFFERENT secrets is not representable here — use the native
|
|
48
|
-
// backend for that.
|
|
49
|
-
// • reverse forwards need their target up front (the CLI takes it as an
|
|
50
|
-
// argument), so they are exposed as `reverseForward()` and ForwardManager
|
|
51
|
-
// prefers it when present.
|
|
52
|
-
// • host keys are checked by ssh itself against the user's own known_hosts,
|
|
53
|
-
// not against the plugin's TOFU store: making `ssh` use a different trust
|
|
54
|
-
// anchor per machine is exactly the kind of override that breaks the
|
|
55
|
-
// enterprise setups this backend exists to serve. `hostKeyMode` maps onto
|
|
56
|
-
// StrictHostKeyChecking instead.
|
|
57
|
-
|
|
58
|
-
import { spawn } from 'node:child_process'
|
|
59
|
-
import { accessSync, chmodSync, constants as fsConstants, mkdirSync, writeFileSync } from 'node:fs'
|
|
60
|
-
import { createRequire } from 'node:module'
|
|
61
|
-
import { EventEmitter } from 'node:events'
|
|
62
|
-
import { homedir, tmpdir } from 'node:os'
|
|
63
|
-
import path from 'node:path'
|
|
64
|
-
import { Duplex } from 'node:stream'
|
|
65
|
-
import { buildSftpSurface } from './sftp-surface.js'
|
|
66
|
-
import { truncate } from './paths.js'
|
|
67
|
-
|
|
68
|
-
// The SFTP protocol engine lives at lib/protocol/SFTP.js inside ssh2 and is not
|
|
69
|
-
// part of its public API. Creating it through createRequire keeps this module
|
|
70
|
-
// ESM-clean while still reaching it; if a future ssh2 moves it, this fails with
|
|
71
|
-
// an actionable message instead of a bare MODULE_NOT_FOUND at connect time.
|
|
72
|
-
const require_ = createRequire(import.meta.url)
|
|
73
|
-
let SftpEngine = null
|
|
74
|
-
export function loadSftpEngine() {
|
|
75
|
-
if (SftpEngine) return SftpEngine
|
|
76
|
-
try {
|
|
77
|
-
const entry = require_.resolve('ssh2')
|
|
78
|
-
const sftpPath = entry.replace(/lib[\\/]index\.js$/, 'lib/protocol/SFTP.js')
|
|
79
|
-
SftpEngine = require_(sftpPath).SFTP
|
|
80
|
-
} catch (err) {
|
|
81
|
-
throw new Error("system-OpenSSH backend could not load ssh2's SFTP engine: " + ((err && err.message) || err))
|
|
82
|
-
}
|
|
83
|
-
if (typeof SftpEngine !== 'function') {
|
|
84
|
-
throw new Error("system-OpenSSH backend found no SFTP engine in this ssh2 install")
|
|
85
|
-
}
|
|
86
|
-
return SftpEngine
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** The `ssh` executable this backend drives (config override wins). */
|
|
90
|
-
export function resolveSshBinary(config = {}) {
|
|
91
|
-
return String(config.sshBinary || '').trim() || 'ssh'
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Path of the user's own known_hosts. Exposed for the UI; the backend itself
|
|
96
|
-
* never points ssh at a different file (see the header).
|
|
97
|
-
*/
|
|
98
|
-
export function defaultKnownHostsPath() {
|
|
99
|
-
return path.join(homedir(), '.ssh', 'known_hosts')
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Translate `hostKeyMode` into OpenSSH's StrictHostKeyChecking.
|
|
104
|
-
*
|
|
105
|
-
* `accept-new` is ssh's own default on modern OpenSSH and matches the plugin's
|
|
106
|
-
* TOFU default; `verify` refuses unknown hosts; `off` accepts anything.
|
|
107
|
-
*/
|
|
108
|
-
export function hostKeyArgs(config = {}) {
|
|
109
|
-
const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new'
|
|
110
|
-
const strict = mode === 'verify' ? 'yes' : mode === 'off' ? 'no' : 'accept-new'
|
|
111
|
-
return ['-o', `StrictHostKeyChecking=${strict}`]
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Where the generated askpass helper lives. One stable directory per user, so
|
|
116
|
-
* the helper is written once and reused; the helper carries NO secret (it reads
|
|
117
|
-
* the secret from the environment), which is why writing it to disk is safe.
|
|
118
|
-
*/
|
|
119
|
-
export function askpassDir() {
|
|
120
|
-
return path.join(tmpdir(), 'dsh-remote-askpass')
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Ensure an askpass helper exists and return its path.
|
|
125
|
-
*
|
|
126
|
-
* The helper prints $DSH_REMOTE_SSH_SECRET. Passing the secret through the
|
|
127
|
-
* environment (rather than as an argument or a file) keeps it out of `ps`,
|
|
128
|
-
* argv and the filesystem. The Windows helper is a .cmd that invokes Node via
|
|
129
|
-
* `process.execPath`, because `@echo %VAR%` would mangle secrets containing cmd
|
|
130
|
-
* metacharacters (&, %, ^ …) and a bare `echo` is not a shell we control.
|
|
131
|
-
*/
|
|
132
|
-
export function ensureAskpassHelper() {
|
|
133
|
-
const dir = askpassDir()
|
|
134
|
-
try { mkdirSync(dir, { recursive: true }) } catch { /* already there */ }
|
|
135
|
-
if (process.platform === 'win32') {
|
|
136
|
-
const js = path.join(dir, 'askpass.js')
|
|
137
|
-
const cmd = path.join(dir, 'askpass.cmd')
|
|
138
|
-
try {
|
|
139
|
-
writeFileSync(js, 'process.stdout.write((process.env.DSH_REMOTE_SSH_SECRET || "") + "\\n")\n')
|
|
140
|
-
writeFileSync(cmd, '@echo off\r\n"' + process.execPath + '" "' + js + '"\r\n')
|
|
141
|
-
} catch (err) {
|
|
142
|
-
throw new Error('system-OpenSSH backend could not write its askpass helper: ' + ((err && err.message) || err))
|
|
143
|
-
}
|
|
144
|
-
return cmd
|
|
145
|
-
}
|
|
146
|
-
const sh = path.join(dir, 'askpass.sh')
|
|
147
|
-
try {
|
|
148
|
-
writeFileSync(sh, '#!/bin/sh\nprintf \'%s\\n\' "$DSH_REMOTE_SSH_SECRET"\n')
|
|
149
|
-
try { chmodSync(sh, 0o700) } catch { /* best effort on odd filesystems */ }
|
|
150
|
-
} catch (err) {
|
|
151
|
-
throw new Error('system-OpenSSH backend could not write its askpass helper: ' + ((err && err.message) || err))
|
|
152
|
-
}
|
|
153
|
-
return sh
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Env that hands the secret to the askpass helper (never argv, never a file). */
|
|
157
|
-
export function askpassEnv(secret) {
|
|
158
|
-
return {
|
|
159
|
-
...process.env,
|
|
160
|
-
SSH_ASKPASS: ensureAskpassHelper(),
|
|
161
|
-
SSH_ASKPASS_REQUIRE: 'force',
|
|
162
|
-
DISPLAY: process.env.DISPLAY || ':0',
|
|
163
|
-
DSH_REMOTE_SSH_SECRET: String(secret),
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Is `ssh` runnable? Used to fail with an actionable message, not ENOENT. */
|
|
168
|
-
export function sshBinaryAvailable(binary) {
|
|
169
|
-
if (binary.includes('/') || binary.includes('\\')) {
|
|
170
|
-
try { accessSync(binary, fsConstants.X_OK); return true } catch { return false }
|
|
171
|
-
}
|
|
172
|
-
const exts = process.platform === 'win32'
|
|
173
|
-
? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean)
|
|
174
|
-
: ['']
|
|
175
|
-
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
176
|
-
if (!dir) continue
|
|
177
|
-
for (const ext of exts) {
|
|
178
|
-
try { accessSync(path.join(dir, binary + ext), fsConstants.X_OK); return true } catch { /* keep looking */ }
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return false
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Options shared by every `ssh` invocation for this target.
|
|
186
|
-
*
|
|
187
|
-
* Deliberately NOT passed: PreferredAuthentications / GSSAPIAuthentication /
|
|
188
|
-
* PubkeyAcceptedKeyTypes / IdentitiesOnly-from-the-alias / ProxyJump-from-the-
|
|
189
|
-
* alias. Those must come from the user's `~/.ssh/config` — overriding them is
|
|
190
|
-
* precisely what would break the enterprise setups this backend exists for.
|
|
191
|
-
*
|
|
192
|
-
* @param {object} config - the pool config.
|
|
193
|
-
* @param {object} [opts]
|
|
194
|
-
* @param {boolean} [opts.forSecret] - true when a secret will be offered via
|
|
195
|
-
* askpass, which means BatchMode must be OFF (BatchMode forbids every
|
|
196
|
-
* interactive prompt, askpass included) and the prompt count bounded instead.
|
|
197
|
-
*/
|
|
198
|
-
export function baseSshArgs(config, { forSecret = false } = {}) {
|
|
199
|
-
const args = []
|
|
200
|
-
if (config.port && Number(config.port) !== 22) args.push('-p', String(Number(config.port)))
|
|
201
|
-
if (config.username) args.push('-l', String(config.username))
|
|
202
|
-
if (config.privateKeyPath) args.push('-i', String(config.privateKeyPath))
|
|
203
|
-
// An explicit jump host — the same shape the native backend's `proxy` takes.
|
|
204
|
-
// A jump host that comes from ~/.ssh/config needs nothing here.
|
|
205
|
-
const proxy = config.proxy
|
|
206
|
-
if (proxy && proxy.host) {
|
|
207
|
-
const user = proxy.username ? `${proxy.username}@` : ''
|
|
208
|
-
const port = proxy.port && Number(proxy.port) !== 22 ? `:${Number(proxy.port)}` : ''
|
|
209
|
-
args.push('-J', `${user}${proxy.host}${port}`)
|
|
210
|
-
}
|
|
211
|
-
// An explicit known_hosts file (tests keep loopback keys OUT of the user's
|
|
212
|
-
// real ~/.ssh/known_hosts with this; ssh ignores $HOME on some platforms).
|
|
213
|
-
// Checking strength is unchanged — StrictHostKeyChecking below still applies.
|
|
214
|
-
if (config.knownHostsFile) args.push('-o', `UserKnownHostsFile=${config.knownHostsFile}`)
|
|
215
|
-
args.push(...hostKeyArgs(config))
|
|
216
|
-
const connectSecs = Math.max(1, Math.round((Number(config.connectTimeoutMs) || 15000) / 1000))
|
|
217
|
-
args.push('-o', `ConnectTimeout=${connectSecs}`, '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3')
|
|
218
|
-
// Without a configured secret we must not hang on an interactive prompt:
|
|
219
|
-
// BatchMode makes ssh fail fast instead, and leaves key/agent/GSSAPI intact.
|
|
220
|
-
if (!forSecret) args.push('-o', 'BatchMode=yes')
|
|
221
|
-
else args.push('-o', 'NumberOfPasswordPrompts=1')
|
|
222
|
-
return args
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* One live system-OpenSSH connection.
|
|
227
|
-
*
|
|
228
|
-
* This deliberately mimics the ssh2 Client surface the rest of the plugin
|
|
229
|
-
* consumes — `forwardOut` plus a reverse-forward capability — so
|
|
230
|
-
* ForwardManager/WebAttach work unchanged. It is an EventEmitter because the
|
|
231
|
-
* native pool's client is one and callers attach listeners to it.
|
|
232
|
-
*/
|
|
233
|
-
export class OpenSshConnection extends EventEmitter {
|
|
234
|
-
constructor(pool, { sshBinary, secret }) {
|
|
235
|
-
super()
|
|
236
|
-
this.pool = pool
|
|
237
|
-
this.sshBinary = sshBinary
|
|
238
|
-
this.secret = secret
|
|
239
|
-
this.closed = false
|
|
240
|
-
/** Live child processes to reap on close. */
|
|
241
|
-
this.children = new Set()
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
shortDescription() {
|
|
245
|
-
return `${this.pool.destination()} via ${this.sshBinary}`
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
spawnSsh(extraArgs, { tty = false } = {}) {
|
|
249
|
-
if (this.closed) throw new Error('ssh connection closed')
|
|
250
|
-
const args = [
|
|
251
|
-
...baseSshArgs(this.pool._argConfig(), { forSecret: !!this.secret }),
|
|
252
|
-
...extraArgs,
|
|
253
|
-
]
|
|
254
|
-
const child = spawn(this.sshBinary, args, {
|
|
255
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
256
|
-
env: this.secret ? askpassEnv(this.secret) : undefined,
|
|
257
|
-
})
|
|
258
|
-
this.children.add(child)
|
|
259
|
-
const reap = () => this.children.delete(child)
|
|
260
|
-
child.on('close', reap)
|
|
261
|
-
child.on('error', reap)
|
|
262
|
-
if (tty) { /* no pty support through the CLI without a tty helper */ }
|
|
263
|
-
return child
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Local forward: carry bytes to <destHost>:<destPort> as seen FROM the
|
|
268
|
-
* remote, through `ssh -W`.
|
|
269
|
-
*
|
|
270
|
-
* @returns {boolean} true synchronously, so capability probes can be sync.
|
|
271
|
-
*/
|
|
272
|
-
forwardOut(_srcHost, _srcPort, destHost, destPort, cb) {
|
|
273
|
-
let child
|
|
274
|
-
try {
|
|
275
|
-
child = this.spawnSsh(['-W', `${destHost}:${destPort}`, '--', this.pool.destination()])
|
|
276
|
-
} catch (err) {
|
|
277
|
-
setImmediate(() => cb(err))
|
|
278
|
-
return true
|
|
279
|
-
}
|
|
280
|
-
// `ssh -W` reports failures on stderr and then exits; surface that as the
|
|
281
|
-
// channel error so the caller destroys its socket instead of hanging.
|
|
282
|
-
let stderr = ''
|
|
283
|
-
let handed = false
|
|
284
|
-
const channel = new Duplex({
|
|
285
|
-
read() {},
|
|
286
|
-
write(chunk, _enc, done) { try { child.stdin.write(chunk, done) } catch (e) { done(e) } },
|
|
287
|
-
final(done) { try { child.stdin.end(done) } catch { done() } },
|
|
288
|
-
destroy(err, done) { try { child.kill() } catch {} ; done(err) },
|
|
289
|
-
})
|
|
290
|
-
channel.close = () => { try { child.kill() } catch {} }
|
|
291
|
-
child.stderr.on('data', (d) => { stderr += d })
|
|
292
|
-
child.stdout.on('data', (d) => channel.push(d))
|
|
293
|
-
child.stdout.on('end', () => channel.push(null))
|
|
294
|
-
child.on('error', (e) => { if (!handed) { handed = true; cb(e); return } channel.destroy(e) })
|
|
295
|
-
child.on('close', (code) => {
|
|
296
|
-
if (handed) { channel.push(null); return }
|
|
297
|
-
handed = true
|
|
298
|
-
if (code === 0) { cb(null, channel); return }
|
|
299
|
-
cb(new Error(`ssh -W to ${destHost}:${destPort} failed (exit ${code})${stderr ? ': ' + stderr.trim() : ''}`))
|
|
300
|
-
})
|
|
301
|
-
return true
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Tells ForwardManager that the CLI-specific reverse path is available. */
|
|
305
|
-
get supportsReverseForward() { return true }
|
|
306
|
-
|
|
307
|
-
/**
|
|
308
|
-
* Reverse forward: ask the REMOTE to listen on <bindHost>:<listenPort> and
|
|
309
|
-
* carry connections to <targetHost>:<targetPort>. Unlike ssh2's two-phase
|
|
310
|
-
* `forwardIn` + `tcpip`, the `ssh -R` CLI takes the final target as an
|
|
311
|
-
* argument, so nothing has to be resolved lazily.
|
|
312
|
-
*
|
|
313
|
-
* @returns {Promise<{child: object, close: Function}>}
|
|
314
|
-
*/
|
|
315
|
-
reverseForward({ bindHost = '127.0.0.1', listenPort, targetHost = '127.0.0.1', targetPort }) {
|
|
316
|
-
return new Promise((resolve, reject) => {
|
|
317
|
-
const spec = `${bindHost}:${listenPort}:${targetHost}:${targetPort}`
|
|
318
|
-
let child
|
|
319
|
-
try {
|
|
320
|
-
child = this.spawnSsh(['-N', '-o', 'ExitOnForwardFailure=yes', '-R', spec, '--', this.pool.destination()])
|
|
321
|
-
} catch (err) { return reject(err) }
|
|
322
|
-
let stderr = ''
|
|
323
|
-
let settled = false
|
|
324
|
-
const fail = (err) => {
|
|
325
|
-
if (settled) return
|
|
326
|
-
settled = true
|
|
327
|
-
clearTimeout(timer)
|
|
328
|
-
try { child.kill() } catch {}
|
|
329
|
-
reject(err)
|
|
330
|
-
}
|
|
331
|
-
child.stderr.on('data', (d) => {
|
|
332
|
-
stderr += d
|
|
333
|
-
// Nothing prints on success; an error before the grace window is the
|
|
334
|
-
// only signal `ssh -N` gives that the forward did not take.
|
|
335
|
-
if (/warning|error|failed|refused|denied|cannot|not permitted|unable/i.test(String(d))) {
|
|
336
|
-
fail(new Error(`reverse forward ${spec} failed: ${stderr.trim()}`))
|
|
337
|
-
}
|
|
338
|
-
})
|
|
339
|
-
child.on('error', fail)
|
|
340
|
-
// `ssh -N` stays in the foreground; a process that outlives the grace
|
|
341
|
-
// window means the remote listener was accepted.
|
|
342
|
-
const timer = setTimeout(() => {
|
|
343
|
-
if (settled) return
|
|
344
|
-
settled = true
|
|
345
|
-
resolve({ child, close: () => { try { child.kill() } catch {} } })
|
|
346
|
-
}, 700)
|
|
347
|
-
child.on('close', (code) => {
|
|
348
|
-
if (settled) return
|
|
349
|
-
settled = true
|
|
350
|
-
clearTimeout(timer)
|
|
351
|
-
reject(new Error(`reverse forward ${spec} exited early (${code})${stderr ? ': ' + stderr.trim() : ''}`))
|
|
352
|
-
})
|
|
353
|
-
})
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** Superseded by reverseForward() (the CLI needs the target up front). */
|
|
357
|
-
forwardIn(_host, _port, cb) {
|
|
358
|
-
setImmediate(() => cb(new Error(
|
|
359
|
-
'the system-OpenSSH backend opens reverse forwards with `ssh -R`, which needs the target up front — use reverseForward()',
|
|
360
|
-
)))
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
close() {
|
|
364
|
-
this.closed = true
|
|
365
|
-
for (const child of [...this.children]) {
|
|
366
|
-
try { child.kill() } catch {}
|
|
367
|
-
}
|
|
368
|
-
this.children.clear()
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* A system-OpenSSH pool: the same public surface as lib/pool.js's SshPool,
|
|
374
|
-
* backed by the local `ssh` CLI. Callers cannot tell the two apart — see
|
|
375
|
-
* test/openssh-backend.test.js, which drives both through one shared contract.
|
|
376
|
-
*/
|
|
377
|
-
export class SystemOpenSshPool {
|
|
378
|
-
constructor(config, opts = {}) {
|
|
379
|
-
this.config = config
|
|
380
|
-
this._knownHostsFile = opts.knownHostsFile
|
|
381
|
-
this.client = null
|
|
382
|
-
this.connecting = null
|
|
383
|
-
this.proxyPool = null
|
|
384
|
-
// Generational token, exactly as the native pool: a stale in-flight connect
|
|
385
|
-
// must never hand this pool a connection to an old host.
|
|
386
|
-
this.epoch = 0
|
|
387
|
-
this.platform = 'unknown'
|
|
388
|
-
this.gitBashPath = ''
|
|
389
|
-
this.shellMode = 'native'
|
|
390
|
-
this._detecting = null
|
|
391
|
-
this.passwordResolver = null
|
|
392
|
-
this.onReady = null
|
|
393
|
-
this.onCloseHook = null
|
|
394
|
-
/** The persistent `ssh -s ... sftp` session (kept across calls). */
|
|
395
|
-
this._sftpSession = null
|
|
396
|
-
this._sftpChild = null
|
|
397
|
-
/** Memoized askpass secret for this target (null = not resolved yet). */
|
|
398
|
-
this._secret = null
|
|
399
|
-
this._secretPromise = null
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* The config baseSshArgs should see. `opts.knownHostsFile` (a path, or a
|
|
404
|
-
* function returning one, exactly like the native pool) used to be stored
|
|
405
|
-
* and silently dropped — so a custom known_hosts never reached the CLI, and
|
|
406
|
-
* tests had no way to keep loopback keys out of the user's real file.
|
|
407
|
-
* Resolved per call so a function value is honored as late as the native
|
|
408
|
-
* backend resolves it.
|
|
409
|
-
*/
|
|
410
|
-
_argConfig() {
|
|
411
|
-
const kh = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
|
|
412
|
-
return kh ? { ...this.config, knownHostsFile: kh } : this.config
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/** The destination OpenSSH should connect to (an alias passes through). */
|
|
416
|
-
destination() {
|
|
417
|
-
// A bare alias resolves through ~/.ssh/config inside ssh itself, so it is
|
|
418
|
-
// handed over untouched; user/port ride on `-l`/`-p` when recorded.
|
|
419
|
-
return String(this.config.host || '')
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
|
|
423
|
-
const base = previous || this.config
|
|
424
|
-
const wanted = []
|
|
425
|
-
if (host !== undefined) wanted.push(['host', String(host)])
|
|
426
|
-
if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
|
|
427
|
-
if (username !== undefined) wanted.push(['username', String(username)])
|
|
428
|
-
if (password !== undefined && password !== null) wanted.push(['password', String(password)])
|
|
429
|
-
if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
|
|
430
|
-
if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
|
|
431
|
-
if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
|
|
432
|
-
if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
|
|
433
|
-
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
434
|
-
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
435
|
-
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
436
|
-
// Issue #51: kept in step with the native pool's setTarget — the two
|
|
437
|
-
// backends share one contract (see test/issue51-openssh-backend.test.js), so
|
|
438
|
-
// a field one of them diffs must not be silently ignored by the other.
|
|
439
|
-
if (backend !== undefined) wanted.push(['backend', String(backend)])
|
|
440
|
-
if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
|
|
441
|
-
const differs = ([k, v]) => (k === 'proxy'
|
|
442
|
-
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
443
|
-
: base[k] !== v)
|
|
444
|
-
if (wanted.some(differs)) {
|
|
445
|
-
for (const [k, v] of wanted) this.config[k] = v
|
|
446
|
-
this.platform = 'unknown'
|
|
447
|
-
this.gitBashPath = ''
|
|
448
|
-
this.shellMode = 'native'
|
|
449
|
-
this._detecting = null
|
|
450
|
-
this.close()
|
|
451
|
-
}
|
|
452
|
-
return this
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
/**
|
|
456
|
-
* The secret to offer askpass, resolved once per target.
|
|
457
|
-
*
|
|
458
|
-
* `connect()` no longer has to run first: `exec`/`sftp` call this themselves,
|
|
459
|
-
* so a pool that is only ever used for file listing still authenticates.
|
|
460
|
-
*/
|
|
461
|
-
_resolveSecret() {
|
|
462
|
-
if (this._secret !== null) return Promise.resolve(this._secret)
|
|
463
|
-
if (this._secretPromise) return this._secretPromise
|
|
464
|
-
const epoch = this.epoch
|
|
465
|
-
this._secretPromise = (async () => {
|
|
466
|
-
let secret = this.config.password || ''
|
|
467
|
-
if (!secret && this.passwordResolver) {
|
|
468
|
-
try { secret = (await this.passwordResolver()) || '' } catch { /* fall through */ }
|
|
469
|
-
}
|
|
470
|
-
if (!secret) secret = this.config.passphrase || ''
|
|
471
|
-
// A target switch invalidates the answer; leave the memo for the new one.
|
|
472
|
-
if (this.epoch === epoch) this._secret = String(secret || '')
|
|
473
|
-
this._secretPromise = null
|
|
474
|
-
return String(secret || '')
|
|
475
|
-
})()
|
|
476
|
-
return this._secretPromise
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** Validate the environment; there is no socket to hold open. */
|
|
480
|
-
connect() {
|
|
481
|
-
if (this.client) return Promise.resolve(this.client)
|
|
482
|
-
if (this.connecting) return this.connecting
|
|
483
|
-
const epoch = this.epoch
|
|
484
|
-
const pending = this._doConnect(epoch)
|
|
485
|
-
this.connecting = pending
|
|
486
|
-
const clear = () => { if (this.epoch === epoch && this.connecting === pending) this.connecting = null }
|
|
487
|
-
pending.then(clear, clear)
|
|
488
|
-
return pending
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async _doConnect(epoch) {
|
|
492
|
-
const isCurrent = () => this.epoch === epoch
|
|
493
|
-
const sshBinary = resolveSshBinary(this.config)
|
|
494
|
-
if (!this.config.host) throw new Error('no host configured')
|
|
495
|
-
if (!sshBinaryAvailable(sshBinary)) {
|
|
496
|
-
throw new Error(
|
|
497
|
-
`the system-OpenSSH backend needs a \`${sshBinary}\` executable on PATH`
|
|
498
|
-
+ ' — install OpenSSH, or set the backend back to "native"',
|
|
499
|
-
)
|
|
500
|
-
}
|
|
501
|
-
const secret = await this._resolveSecret()
|
|
502
|
-
if (!isCurrent()) throw new Error('ssh target changed during connect')
|
|
503
|
-
// Native semantics are "connect() throws when the SSH connection fails", and
|
|
504
|
-
// a probe also gives the settings page a real latency number. The CLI has no
|
|
505
|
-
// socket to keep, so this is the only point where the target is proven.
|
|
506
|
-
const probe = await this._runSsh(['--', this.destination(), 'true'], { timeoutMs: this.config.connectTimeoutMs || 15000 })
|
|
507
|
-
if (!isCurrent()) throw new Error('ssh target changed during connect')
|
|
508
|
-
if (probe.code !== 0) {
|
|
509
|
-
throw new Error(`ssh failed (exit ${probe.code})${String(probe.stderr || '').trim() ? ': ' + String(probe.stderr).trim() : ''}`)
|
|
510
|
-
}
|
|
511
|
-
void secret
|
|
512
|
-
const conn = new OpenSshConnection(this, { sshBinary, secret })
|
|
513
|
-
this.client = conn
|
|
514
|
-
if (this.onReady) { try { this.onReady(conn) } catch {} }
|
|
515
|
-
return conn
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Run one `ssh` invocation and collect its result.
|
|
520
|
-
*
|
|
521
|
-
* @param {string[]} extraArgs - arguments appended after the shared base.
|
|
522
|
-
* @param {object} [opts] - `timeoutMs`, `signal` (AbortSignal), `stdin`,
|
|
523
|
-
* `onData(channel, chunk)` — a streaming tap the native backend has had
|
|
524
|
-
* since issue #70. A background job's value is its LIVE output, which the
|
|
525
|
-
* job ring must receive as it arrives; a backend that only returns the
|
|
526
|
-
* accumulated text loses every byte the caller stopped waiting for
|
|
527
|
-
* (issue #80). Tap errors are not command errors, so the call is guarded.
|
|
528
|
-
* @returns {Promise<{code: number|null, signal: string|null, stdout: string, stderr: string}>}
|
|
529
|
-
*/
|
|
530
|
-
async _runSsh(extraArgs, opts = {}) {
|
|
531
|
-
if (!this.config.host) throw new Error('no host configured')
|
|
532
|
-
const secret = await this._resolveSecret()
|
|
533
|
-
const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
|
|
534
|
-
const sshBinary = resolveSshBinary(this.config)
|
|
535
|
-
const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), ...extraArgs]
|
|
536
|
-
return new Promise((resolve) => {
|
|
537
|
-
let child
|
|
538
|
-
try {
|
|
539
|
-
child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
|
|
540
|
-
} catch (err) {
|
|
541
|
-
return resolve({ code: -1, signal: 'SPAWN_FAILED', stdout: '', stderr: String((err && err.message) || err) })
|
|
542
|
-
}
|
|
543
|
-
let stdout = ''
|
|
544
|
-
let stderr = ''
|
|
545
|
-
let settled = false
|
|
546
|
-
let exitCode = null
|
|
547
|
-
let exitSignal = null
|
|
548
|
-
const cap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
549
|
-
const onData = typeof opts.onData === 'function' ? opts.onData : null
|
|
550
|
-
const cleanupAbort = () => { if (opts.signal && onAbort) opts.signal.removeEventListener('abort', onAbort) }
|
|
551
|
-
let onAbort = null
|
|
552
|
-
const finish = () => {
|
|
553
|
-
if (settled) return
|
|
554
|
-
settled = true
|
|
555
|
-
clearTimeout(timer)
|
|
556
|
-
cleanupAbort()
|
|
557
|
-
resolve({
|
|
558
|
-
code: exitCode,
|
|
559
|
-
signal: exitSignal,
|
|
560
|
-
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
561
|
-
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
562
|
-
})
|
|
563
|
-
}
|
|
564
|
-
const killHard = () => { try { setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 800).unref?.() } catch {} }
|
|
565
|
-
const timer = setTimeout(() => {
|
|
566
|
-
if (settled) return
|
|
567
|
-
exitCode = -1
|
|
568
|
-
exitSignal = 'TIMEOUT'
|
|
569
|
-
try { child.kill() } catch {}
|
|
570
|
-
killHard()
|
|
571
|
-
finish()
|
|
572
|
-
}, timeoutMs)
|
|
573
|
-
if (opts.signal) {
|
|
574
|
-
if (opts.signal.aborted) {
|
|
575
|
-
try { child.kill() } catch {}
|
|
576
|
-
exitCode = -1; exitSignal = 'ABORTED'
|
|
577
|
-
return finish()
|
|
578
|
-
}
|
|
579
|
-
onAbort = () => {
|
|
580
|
-
if (settled) return
|
|
581
|
-
exitCode = -1; exitSignal = 'ABORTED'
|
|
582
|
-
try { child.kill() } catch {}
|
|
583
|
-
killHard()
|
|
584
|
-
finish()
|
|
585
|
-
}
|
|
586
|
-
opts.signal.addEventListener('abort', onAbort, { once: true })
|
|
587
|
-
}
|
|
588
|
-
// The tap fires even past the local cap, exactly like the native
|
|
589
|
-
// backend's: a promoted job's ring has its own bounds and must keep
|
|
590
|
-
// receiving output the truncated return value no longer holds.
|
|
591
|
-
child.stdout.on('data', (d) => {
|
|
592
|
-
if (stdout.length < cap) stdout += d
|
|
593
|
-
if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
|
|
594
|
-
})
|
|
595
|
-
child.stderr.on('data', (d) => {
|
|
596
|
-
if (stderr.length < cap) stderr += d
|
|
597
|
-
if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
|
|
598
|
-
})
|
|
599
|
-
child.on('error', (e) => {
|
|
600
|
-
if (settled) return
|
|
601
|
-
stderr += String((e && e.message) || e)
|
|
602
|
-
exitCode = -1
|
|
603
|
-
finish()
|
|
604
|
-
})
|
|
605
|
-
child.on('close', (code, sig) => {
|
|
606
|
-
if (settled) return
|
|
607
|
-
exitCode = code
|
|
608
|
-
exitSignal = sig || null
|
|
609
|
-
finish()
|
|
610
|
-
})
|
|
611
|
-
try { child.stdin.end(opts.stdin === undefined || opts.stdin === null ? undefined : String(opts.stdin)) } catch {}
|
|
612
|
-
})
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
616
|
-
detect() {
|
|
617
|
-
if (this.platform !== 'unknown') return Promise.resolve()
|
|
618
|
-
if (this._detecting) return this._detecting
|
|
619
|
-
this._detecting = this._detect().finally(() => { this._detecting = null })
|
|
620
|
-
return this._detecting
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
async _detect() {
|
|
624
|
-
let res
|
|
625
|
-
try {
|
|
626
|
-
res = await this._runSsh(['--', this.destination(), 'cmd /c ver'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
627
|
-
} catch {
|
|
628
|
-
this.platform = 'unknown'
|
|
629
|
-
this.shellMode = 'native'
|
|
630
|
-
return
|
|
631
|
-
}
|
|
632
|
-
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
633
|
-
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
634
|
-
this.platform = 'windows'
|
|
635
|
-
await this._resolveGitBash()
|
|
636
|
-
return
|
|
637
|
-
}
|
|
638
|
-
try {
|
|
639
|
-
const u = await this._runSsh(['--', this.destination(), 'uname -s'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
640
|
-
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
641
|
-
this.platform = 'windows'
|
|
642
|
-
await this._resolveGitBash()
|
|
643
|
-
return
|
|
644
|
-
}
|
|
645
|
-
} catch { /* inconclusive — fall through to posix */ }
|
|
646
|
-
this.platform = 'posix'
|
|
647
|
-
this.shellMode = 'native'
|
|
648
|
-
this.gitBashPath = ''
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
async _resolveGitBash() {
|
|
652
|
-
const cfg = String(this.config.shell || '').trim()
|
|
653
|
-
const exists = async (p) => {
|
|
654
|
-
const r = await this._runSsh(['--', this.destination(), `cmd /c if exist "${p}" (echo Y) else (echo N)`], { timeoutMs: 8000 })
|
|
655
|
-
return /Y/.test(String(r.stdout || ''))
|
|
656
|
-
}
|
|
657
|
-
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
658
|
-
if (await exists(cfg)) { this.gitBashPath = cfg; this.shellMode = 'git-bash'; return }
|
|
659
|
-
}
|
|
660
|
-
if (cfg === 'native') { this.shellMode = 'native'; this.gitBashPath = ''; return }
|
|
661
|
-
try {
|
|
662
|
-
const r = await this._runSsh(['--', this.destination(), 'cmd /c where bash'], { timeoutMs: 8000 })
|
|
663
|
-
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
664
|
-
if (first) { this.gitBashPath = first; this.shellMode = 'git-bash'; return }
|
|
665
|
-
} catch { /* fall through to the candidate list */ }
|
|
666
|
-
const candidates = [
|
|
667
|
-
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
668
|
-
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
669
|
-
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
670
|
-
]
|
|
671
|
-
for (const c of candidates) {
|
|
672
|
-
if (await exists(c)) { this.gitBashPath = c; this.shellMode = 'git-bash'; return }
|
|
673
|
-
}
|
|
674
|
-
this.shellMode = 'git-bash'
|
|
675
|
-
this.gitBashPath = ''
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
/** Run one remote command (Windows + Git Bash is wrapped exactly as native). */
|
|
679
|
-
exec(command, timeoutMsOrOpts) {
|
|
680
|
-
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
681
|
-
return this.detect().then(() => {
|
|
682
|
-
const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
|
|
683
|
-
if (this.platform === 'windows' && this.gitBashPath) {
|
|
684
|
-
// Pipe the script to `bash -s` over stdin: no quoting round-trip, so
|
|
685
|
-
// quotes/backslashes/newlines survive verbatim (same as the native pool).
|
|
686
|
-
return this._runSsh(['--', this.destination(), `"${this.gitBashPath}" -s`], { timeoutMs, signal: opts.signal, stdin: String(command), onData: opts.onData })
|
|
687
|
-
}
|
|
688
|
-
return this._runSsh(['--', this.destination(), String(command)], { timeoutMs, signal: opts.signal, onData: opts.onData })
|
|
689
|
-
})
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
/** Open a fresh `ssh -s <dest> sftp` session and its ssh2 SFTP engine. */
|
|
693
|
-
async _openSftpChannel() {
|
|
694
|
-
const Sftp = loadSftpEngine()
|
|
695
|
-
const secret = await this._resolveSecret()
|
|
696
|
-
const sshBinary = resolveSshBinary(this.config)
|
|
697
|
-
const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), '-s', this.destination(), 'sftp']
|
|
698
|
-
const child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
|
|
699
|
-
|
|
700
|
-
// ssh2's SFTP engine only reaches `_protocol` for channelData/channelClose,
|
|
701
|
-
// and reads `_remoteIdentRaw` to pick its packet sizes.
|
|
702
|
-
//
|
|
703
|
-
// ## Flow control, the subtle part
|
|
704
|
-
//
|
|
705
|
-
// Over real SSH the engine's send window is topped up by CHANNEL_WINDOW_ADJUST
|
|
706
|
-
// messages from the peer. On a raw `ssh -s sftp` pipe THERE ARE NO SUCH
|
|
707
|
-
// MESSAGES — the local ssh owns the SSH-layer window and the server's grants,
|
|
708
|
-
// and all we have is a byte pipe with OS-level backpressure. So the engine's
|
|
709
|
-
// window has to be mapped onto the pipe's writability:
|
|
710
|
-
//
|
|
711
|
-
// • a write the pipe accepts → more will fit;
|
|
712
|
-
// • the pipe fills (`stdin.write` returns false) → clamp the window so the
|
|
713
|
-
// engine buffers the remainder instead of queueing it all in memory;
|
|
714
|
-
// • the pipe drains → grant the window back and let the engine flush.
|
|
715
|
-
//
|
|
716
|
-
// The trap, and why the constants below are what they are: ssh2 arms its
|
|
717
|
-
// flush callback (`_waitWindow` / `_chunkcb = drainBuffer`) ONLY from inside
|
|
718
|
-
// its own subtraction, at the moment that subtraction takes the window to
|
|
719
|
-
// exactly 0. So the window must be handed over in EXACT MULTIPLES of
|
|
720
|
-
// packetSize and then left alone: the engine's own arithmetic then lands on 0
|
|
721
|
-
// during normal operation and arms the flush callback we depend on.
|
|
722
|
-
//
|
|
723
|
-
// Do NOT "help" by clamping the window to 0 from inside channelData when the
|
|
724
|
-
// pipe reports itself full. That sets the window to 0 AFTER the subtraction
|
|
725
|
-
// has already happened, so the callback is never armed and the buffered
|
|
726
|
-
// remainder is never flushed — the transfer deadlocks with a full window.
|
|
727
|
-
// (Measured: a 4 MB upload stopped dead after 32 KB, with `_buffer.length`
|
|
728
|
-
// stuck at 1 and no callback pending.) The pipe's own `drain` event is the
|
|
729
|
-
// only place the window is refilled.
|
|
730
|
-
//
|
|
731
|
-
// Also tempting-but-wrong: adding each received packet's length to the
|
|
732
|
-
// window. That happens to work for a DOWNLOAD (the peer's data is the
|
|
733
|
-
// window) and deadlocks an upload, because a write's acknowledgements are
|
|
734
|
-
// tiny status packets while each send consumes ~32 KB.
|
|
735
|
-
//
|
|
736
|
-
// See scripts/integration-openssh-large.mjs, which is what caught all of this.
|
|
737
|
-
const PACKET = 32768
|
|
738
|
-
const GRANT = PACKET * 8 // 256 KB per burst, and a multiple of packetSize
|
|
739
|
-
const chan = { sftp: null }
|
|
740
|
-
const fakeClient = {
|
|
741
|
-
_protocol: {
|
|
742
|
-
_remoteIdentRaw: Buffer.from('SSH-2.0-OpenSSH'),
|
|
743
|
-
// Hand the bytes to the pipe and nothing else. Backpressure is expressed
|
|
744
|
-
// through the engine's own window draining (see above); the OS pipe
|
|
745
|
-
// buffer plus Node's write buffer absorb the in-flight remainder.
|
|
746
|
-
channelData(_id, data) { try { child.stdin.write(data) } catch { /* close handles it */ } },
|
|
747
|
-
channelClose() { try { child.stdin.end() } catch {} },
|
|
748
|
-
},
|
|
749
|
-
}
|
|
750
|
-
const chanInfo = {
|
|
751
|
-
type: 'sftp',
|
|
752
|
-
incoming: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
|
|
753
|
-
outgoing: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
|
|
754
|
-
}
|
|
755
|
-
const sftp = new Sftp(fakeClient, chanInfo, {})
|
|
756
|
-
chan.sftp = sftp
|
|
757
|
-
|
|
758
|
-
// The pipe has room again: grant the window back and let the engine flush
|
|
759
|
-
// whatever it buffered while the pipe was full.
|
|
760
|
-
const onStdinDrain = () => {
|
|
761
|
-
if (!chan.sftp) return
|
|
762
|
-
chan.sftp.outgoing.window = GRANT
|
|
763
|
-
if (chan.sftp._waitWindow) {
|
|
764
|
-
chan.sftp._waitWindow = false
|
|
765
|
-
const cb = chan.sftp._chunkcb
|
|
766
|
-
chan.sftp._chunkcb = undefined
|
|
767
|
-
if (typeof cb === 'function') cb.call(chan.sftp)
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
child.stdin.on('drain', onStdinDrain)
|
|
771
|
-
|
|
772
|
-
return new Promise((resolve, reject) => {
|
|
773
|
-
let stderr = ''
|
|
774
|
-
let settled = false
|
|
775
|
-
const done = (err) => {
|
|
776
|
-
if (settled) return
|
|
777
|
-
settled = true
|
|
778
|
-
clearTimeout(timer)
|
|
779
|
-
if (err) { try { child.kill() } catch {} ; reject(err) } else resolve({ child, sftp })
|
|
780
|
-
}
|
|
781
|
-
const timer = setTimeout(() => {
|
|
782
|
-
done(new Error(`sftp session to ${this.destination()} timed out${stderr ? ': ' + stderr.trim() : ''}`))
|
|
783
|
-
}, this.config.commandTimeoutMs)
|
|
784
|
-
child.stderr.on('data', (d) => { stderr += d })
|
|
785
|
-
sftp.on('error', (e) => done(new Error('sftp session failed: ' + ((e && e.message) || e))))
|
|
786
|
-
sftp.on('ready', () => done(null))
|
|
787
|
-
child.on('error', (e) => done(e))
|
|
788
|
-
child.on('close', (code) => done(new Error(`ssh sftp exited (${code})${stderr ? ': ' + stderr.trim() : ''}`)))
|
|
789
|
-
// Inbound is simpler: the local ssh already did the SSH-level window
|
|
790
|
-
// accounting before handing us these bytes, so they just go straight in.
|
|
791
|
-
child.stdout.on('data', (d) => { sftp.push(d) })
|
|
792
|
-
child.stdout.on('end', () => { try { sftp.push(null) } catch { /* already gone */ } })
|
|
793
|
-
sftp._init()
|
|
794
|
-
})
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
/**
|
|
798
|
-
* A persistent SFTP session, kept across calls: the picker and the explorer
|
|
799
|
-
* issue one listing per interaction, and re-handshaking each time would be
|
|
800
|
-
* visibly slow. A session that dies is dropped so the next call reconnects.
|
|
801
|
-
*/
|
|
802
|
-
sftp() {
|
|
803
|
-
if (this._sftpSession) return this._sftpSession
|
|
804
|
-
const epoch = this.epoch
|
|
805
|
-
const session = this._openSftpChannel().then(({ child, sftp }) => {
|
|
806
|
-
const drop = () => {
|
|
807
|
-
if (this._sftpSession === session) this._sftpSession = null
|
|
808
|
-
if (this._sftpChild === child) this._sftpChild = null
|
|
809
|
-
}
|
|
810
|
-
child.on('close', drop)
|
|
811
|
-
sftp.on('end', drop)
|
|
812
|
-
sftp.on('error', drop)
|
|
813
|
-
// A target switch during the handshake must not adopt the old host's
|
|
814
|
-
// session (the same generational guard connect() uses).
|
|
815
|
-
if (this.epoch !== epoch) {
|
|
816
|
-
try { child.kill() } catch {}
|
|
817
|
-
throw new Error('ssh target changed while opening sftp')
|
|
818
|
-
}
|
|
819
|
-
this._sftpChild = child
|
|
820
|
-
return buildSftpSurface(sftp, {
|
|
821
|
-
commandTimeoutMs: this.config.commandTimeoutMs,
|
|
822
|
-
onTimeout: () => {
|
|
823
|
-
// A timed-out SFTP session is dead: kill the child and drop the cached
|
|
824
|
-
// session so the next call re-handshakes instead of queueing behind the
|
|
825
|
-
// wedged one (issue #65).
|
|
826
|
-
try { child.kill() } catch { /* already gone */ }
|
|
827
|
-
drop()
|
|
828
|
-
},
|
|
829
|
-
})
|
|
830
|
-
})
|
|
831
|
-
// Never cache a rejected open: the next call must retry rather than get the
|
|
832
|
-
// same error back forever (a failed handshake is usually transient).
|
|
833
|
-
session.catch(() => { if (this._sftpSession === session) this._sftpSession = null })
|
|
834
|
-
this._sftpSession = session
|
|
835
|
-
return session
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
invalidate() {
|
|
839
|
-
this.epoch++
|
|
840
|
-
const client = this.client
|
|
841
|
-
this.client = null
|
|
842
|
-
this.connecting = null
|
|
843
|
-
this._secret = null
|
|
844
|
-
this._secretPromise = null
|
|
845
|
-
if (client) { try { client.close() } catch {} }
|
|
846
|
-
this._dropSftpSession()
|
|
847
|
-
if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
|
|
848
|
-
if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
_dropSftpSession() {
|
|
852
|
-
const session = this._sftpSession
|
|
853
|
-
this._sftpSession = null
|
|
854
|
-
if (this._sftpChild) {
|
|
855
|
-
try { this._sftpChild.kill() } catch {}
|
|
856
|
-
this._sftpChild = null
|
|
857
|
-
}
|
|
858
|
-
// Swallow the rejection: nobody awaits a session we are tearing down.
|
|
859
|
-
if (session && typeof session.catch === 'function') session.catch(() => {})
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
close() {
|
|
863
|
-
this.epoch++
|
|
864
|
-
const client = this.client
|
|
865
|
-
this.client = null
|
|
866
|
-
this.connecting = null
|
|
867
|
-
this._secret = null
|
|
868
|
-
this._secretPromise = null
|
|
869
|
-
if (client) { try { client.close() } catch {} }
|
|
870
|
-
this._dropSftpSession()
|
|
871
|
-
if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
|
|
872
|
-
if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
/** Pick the pool class for a config: 'openssh' backend, else the native pool. */
|
|
877
|
-
export function isSystemOpenSshBackend(config = {}) {
|
|
878
|
-
return String(config.backend || '').toLowerCase() === 'openssh'
|
|
879
|
-
}
|
|
1
|
+
// dsh-remote — the "system OpenSSH" SSH backend (issue #51).
|
|
2
|
+
//
|
|
3
|
+
// ## Why this exists
|
|
4
|
+
//
|
|
5
|
+
// The default backend (lib/pool.js) speaks SSH itself through ssh2, so it can
|
|
6
|
+
// only use the authentication methods ssh2 implements: password, private key,
|
|
7
|
+
// ssh-agent and keyboard-interactive. Enterprise hosts that only allow
|
|
8
|
+
// GSSAPI/Kerberos are unreachable, even though `ssh <alias>` in the user's own
|
|
9
|
+
// terminal works — the local Kerberos ticket (kinit) is simply not something
|
|
10
|
+
// ssh2 knows how to present.
|
|
11
|
+
//
|
|
12
|
+
// This backend delegates the transport to the LOCAL `ssh` executable, so every
|
|
13
|
+
// `~/.ssh/config` feature the user already relies on applies: gssapi-with-mic,
|
|
14
|
+
// ProxyJump, ProxyCommand, ControlPath, Host aliases, IdentityFile,
|
|
15
|
+
// PKCS11Provider, Match blocks… Anything OpenSSH can do, this backend can do,
|
|
16
|
+
// because OpenSSH is the one doing it.
|
|
17
|
+
//
|
|
18
|
+
// ## How each primitive is implemented
|
|
19
|
+
//
|
|
20
|
+
// exec → `ssh <opts> -- <dest> <command>` (one process per call)
|
|
21
|
+
// sftp → `ssh <opts> -s <dest> sftp` + ssh2's OWN SFTP protocol engine
|
|
22
|
+
// driven over the resulting raw byte stream
|
|
23
|
+
// forwardOut → `ssh <opts> -W <host>:<port> <dest>` (one process per tunnel)
|
|
24
|
+
// reverse → `ssh <opts> -N -R <bind>:<port>:<h>:<p> <dest>`
|
|
25
|
+
//
|
|
26
|
+
// The SFTP one deserves a note, because it is the reason this backend did not
|
|
27
|
+
// have to reimplement file transfer: `ssh -s <dest> sftp` on stdio is a PLAIN
|
|
28
|
+
// SFTP v3 byte stream (verified against a real host — the subsystem replies
|
|
29
|
+
// with its VERSION packet and nothing else on stdout). ssh2 already ships a
|
|
30
|
+
// complete, battle-tested SFTP implementation that speaks exactly that protocol
|
|
31
|
+
// over a duplex stream, so this module gives it a minimal `_protocol` shim and
|
|
32
|
+
// hands it the child process's stdio. File transfer therefore has the same
|
|
33
|
+
// semantics — and the same large-packet optimisations — as the native backend.
|
|
34
|
+
//
|
|
35
|
+
// ## Trade-offs (documented, not hidden)
|
|
36
|
+
//
|
|
37
|
+
// • spawn-per-operation: unlike the native pool there is no long-lived
|
|
38
|
+
// connection multiplexed across commands, so each `exec`/`forwardOut` pays
|
|
39
|
+
// a fresh SSH handshake. That is inherent to delegating to the `ssh` CLI:
|
|
40
|
+
// Windows OpenSSH rejects ControlMaster ("getsockname failed: Not a
|
|
41
|
+
// socket", measured), and a portable multiplexing layer would be a second
|
|
42
|
+
// implementation of what the native backend already does. The SFTP path IS
|
|
43
|
+
// kept persistent, because the workspace picker and the explorer hammer it.
|
|
44
|
+
// • the passphrase/password prompt: OpenSSH asks for both through the same
|
|
45
|
+
// askpass channel, so when a secret is configured this backend offers it
|
|
46
|
+
// for whichever prompt appears (password first, then passphrase). A host
|
|
47
|
+
// needing two DIFFERENT secrets is not representable here — use the native
|
|
48
|
+
// backend for that.
|
|
49
|
+
// • reverse forwards need their target up front (the CLI takes it as an
|
|
50
|
+
// argument), so they are exposed as `reverseForward()` and ForwardManager
|
|
51
|
+
// prefers it when present.
|
|
52
|
+
// • host keys are checked by ssh itself against the user's own known_hosts,
|
|
53
|
+
// not against the plugin's TOFU store: making `ssh` use a different trust
|
|
54
|
+
// anchor per machine is exactly the kind of override that breaks the
|
|
55
|
+
// enterprise setups this backend exists to serve. `hostKeyMode` maps onto
|
|
56
|
+
// StrictHostKeyChecking instead.
|
|
57
|
+
|
|
58
|
+
import { spawn } from 'node:child_process'
|
|
59
|
+
import { accessSync, chmodSync, constants as fsConstants, mkdirSync, writeFileSync } from 'node:fs'
|
|
60
|
+
import { createRequire } from 'node:module'
|
|
61
|
+
import { EventEmitter } from 'node:events'
|
|
62
|
+
import { homedir, tmpdir } from 'node:os'
|
|
63
|
+
import path from 'node:path'
|
|
64
|
+
import { Duplex } from 'node:stream'
|
|
65
|
+
import { buildSftpSurface } from './sftp-surface.js'
|
|
66
|
+
import { truncate } from './paths.js'
|
|
67
|
+
|
|
68
|
+
// The SFTP protocol engine lives at lib/protocol/SFTP.js inside ssh2 and is not
|
|
69
|
+
// part of its public API. Creating it through createRequire keeps this module
|
|
70
|
+
// ESM-clean while still reaching it; if a future ssh2 moves it, this fails with
|
|
71
|
+
// an actionable message instead of a bare MODULE_NOT_FOUND at connect time.
|
|
72
|
+
const require_ = createRequire(import.meta.url)
|
|
73
|
+
let SftpEngine = null
|
|
74
|
+
export function loadSftpEngine() {
|
|
75
|
+
if (SftpEngine) return SftpEngine
|
|
76
|
+
try {
|
|
77
|
+
const entry = require_.resolve('ssh2')
|
|
78
|
+
const sftpPath = entry.replace(/lib[\\/]index\.js$/, 'lib/protocol/SFTP.js')
|
|
79
|
+
SftpEngine = require_(sftpPath).SFTP
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new Error("system-OpenSSH backend could not load ssh2's SFTP engine: " + ((err && err.message) || err))
|
|
82
|
+
}
|
|
83
|
+
if (typeof SftpEngine !== 'function') {
|
|
84
|
+
throw new Error("system-OpenSSH backend found no SFTP engine in this ssh2 install")
|
|
85
|
+
}
|
|
86
|
+
return SftpEngine
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The `ssh` executable this backend drives (config override wins). */
|
|
90
|
+
export function resolveSshBinary(config = {}) {
|
|
91
|
+
return String(config.sshBinary || '').trim() || 'ssh'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Path of the user's own known_hosts. Exposed for the UI; the backend itself
|
|
96
|
+
* never points ssh at a different file (see the header).
|
|
97
|
+
*/
|
|
98
|
+
export function defaultKnownHostsPath() {
|
|
99
|
+
return path.join(homedir(), '.ssh', 'known_hosts')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Translate `hostKeyMode` into OpenSSH's StrictHostKeyChecking.
|
|
104
|
+
*
|
|
105
|
+
* `accept-new` is ssh's own default on modern OpenSSH and matches the plugin's
|
|
106
|
+
* TOFU default; `verify` refuses unknown hosts; `off` accepts anything.
|
|
107
|
+
*/
|
|
108
|
+
export function hostKeyArgs(config = {}) {
|
|
109
|
+
const mode = config.hostKeyMode === 'verify' || config.hostKeyMode === 'off' ? config.hostKeyMode : 'accept-new'
|
|
110
|
+
const strict = mode === 'verify' ? 'yes' : mode === 'off' ? 'no' : 'accept-new'
|
|
111
|
+
return ['-o', `StrictHostKeyChecking=${strict}`]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where the generated askpass helper lives. One stable directory per user, so
|
|
116
|
+
* the helper is written once and reused; the helper carries NO secret (it reads
|
|
117
|
+
* the secret from the environment), which is why writing it to disk is safe.
|
|
118
|
+
*/
|
|
119
|
+
export function askpassDir() {
|
|
120
|
+
return path.join(tmpdir(), 'dsh-remote-askpass')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ensure an askpass helper exists and return its path.
|
|
125
|
+
*
|
|
126
|
+
* The helper prints $DSH_REMOTE_SSH_SECRET. Passing the secret through the
|
|
127
|
+
* environment (rather than as an argument or a file) keeps it out of `ps`,
|
|
128
|
+
* argv and the filesystem. The Windows helper is a .cmd that invokes Node via
|
|
129
|
+
* `process.execPath`, because `@echo %VAR%` would mangle secrets containing cmd
|
|
130
|
+
* metacharacters (&, %, ^ …) and a bare `echo` is not a shell we control.
|
|
131
|
+
*/
|
|
132
|
+
export function ensureAskpassHelper() {
|
|
133
|
+
const dir = askpassDir()
|
|
134
|
+
try { mkdirSync(dir, { recursive: true }) } catch { /* already there */ }
|
|
135
|
+
if (process.platform === 'win32') {
|
|
136
|
+
const js = path.join(dir, 'askpass.js')
|
|
137
|
+
const cmd = path.join(dir, 'askpass.cmd')
|
|
138
|
+
try {
|
|
139
|
+
writeFileSync(js, 'process.stdout.write((process.env.DSH_REMOTE_SSH_SECRET || "") + "\\n")\n')
|
|
140
|
+
writeFileSync(cmd, '@echo off\r\n"' + process.execPath + '" "' + js + '"\r\n')
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new Error('system-OpenSSH backend could not write its askpass helper: ' + ((err && err.message) || err))
|
|
143
|
+
}
|
|
144
|
+
return cmd
|
|
145
|
+
}
|
|
146
|
+
const sh = path.join(dir, 'askpass.sh')
|
|
147
|
+
try {
|
|
148
|
+
writeFileSync(sh, '#!/bin/sh\nprintf \'%s\\n\' "$DSH_REMOTE_SSH_SECRET"\n')
|
|
149
|
+
try { chmodSync(sh, 0o700) } catch { /* best effort on odd filesystems */ }
|
|
150
|
+
} catch (err) {
|
|
151
|
+
throw new Error('system-OpenSSH backend could not write its askpass helper: ' + ((err && err.message) || err))
|
|
152
|
+
}
|
|
153
|
+
return sh
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Env that hands the secret to the askpass helper (never argv, never a file). */
|
|
157
|
+
export function askpassEnv(secret) {
|
|
158
|
+
return {
|
|
159
|
+
...process.env,
|
|
160
|
+
SSH_ASKPASS: ensureAskpassHelper(),
|
|
161
|
+
SSH_ASKPASS_REQUIRE: 'force',
|
|
162
|
+
DISPLAY: process.env.DISPLAY || ':0',
|
|
163
|
+
DSH_REMOTE_SSH_SECRET: String(secret),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Is `ssh` runnable? Used to fail with an actionable message, not ENOENT. */
|
|
168
|
+
export function sshBinaryAvailable(binary) {
|
|
169
|
+
if (binary.includes('/') || binary.includes('\\')) {
|
|
170
|
+
try { accessSync(binary, fsConstants.X_OK); return true } catch { return false }
|
|
171
|
+
}
|
|
172
|
+
const exts = process.platform === 'win32'
|
|
173
|
+
? String(process.env.PATHEXT || '.EXE;.CMD;.BAT').split(';').filter(Boolean)
|
|
174
|
+
: ['']
|
|
175
|
+
for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
|
|
176
|
+
if (!dir) continue
|
|
177
|
+
for (const ext of exts) {
|
|
178
|
+
try { accessSync(path.join(dir, binary + ext), fsConstants.X_OK); return true } catch { /* keep looking */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Options shared by every `ssh` invocation for this target.
|
|
186
|
+
*
|
|
187
|
+
* Deliberately NOT passed: PreferredAuthentications / GSSAPIAuthentication /
|
|
188
|
+
* PubkeyAcceptedKeyTypes / IdentitiesOnly-from-the-alias / ProxyJump-from-the-
|
|
189
|
+
* alias. Those must come from the user's `~/.ssh/config` — overriding them is
|
|
190
|
+
* precisely what would break the enterprise setups this backend exists for.
|
|
191
|
+
*
|
|
192
|
+
* @param {object} config - the pool config.
|
|
193
|
+
* @param {object} [opts]
|
|
194
|
+
* @param {boolean} [opts.forSecret] - true when a secret will be offered via
|
|
195
|
+
* askpass, which means BatchMode must be OFF (BatchMode forbids every
|
|
196
|
+
* interactive prompt, askpass included) and the prompt count bounded instead.
|
|
197
|
+
*/
|
|
198
|
+
export function baseSshArgs(config, { forSecret = false } = {}) {
|
|
199
|
+
const args = []
|
|
200
|
+
if (config.port && Number(config.port) !== 22) args.push('-p', String(Number(config.port)))
|
|
201
|
+
if (config.username) args.push('-l', String(config.username))
|
|
202
|
+
if (config.privateKeyPath) args.push('-i', String(config.privateKeyPath))
|
|
203
|
+
// An explicit jump host — the same shape the native backend's `proxy` takes.
|
|
204
|
+
// A jump host that comes from ~/.ssh/config needs nothing here.
|
|
205
|
+
const proxy = config.proxy
|
|
206
|
+
if (proxy && proxy.host) {
|
|
207
|
+
const user = proxy.username ? `${proxy.username}@` : ''
|
|
208
|
+
const port = proxy.port && Number(proxy.port) !== 22 ? `:${Number(proxy.port)}` : ''
|
|
209
|
+
args.push('-J', `${user}${proxy.host}${port}`)
|
|
210
|
+
}
|
|
211
|
+
// An explicit known_hosts file (tests keep loopback keys OUT of the user's
|
|
212
|
+
// real ~/.ssh/known_hosts with this; ssh ignores $HOME on some platforms).
|
|
213
|
+
// Checking strength is unchanged — StrictHostKeyChecking below still applies.
|
|
214
|
+
if (config.knownHostsFile) args.push('-o', `UserKnownHostsFile=${config.knownHostsFile}`)
|
|
215
|
+
args.push(...hostKeyArgs(config))
|
|
216
|
+
const connectSecs = Math.max(1, Math.round((Number(config.connectTimeoutMs) || 15000) / 1000))
|
|
217
|
+
args.push('-o', `ConnectTimeout=${connectSecs}`, '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3')
|
|
218
|
+
// Without a configured secret we must not hang on an interactive prompt:
|
|
219
|
+
// BatchMode makes ssh fail fast instead, and leaves key/agent/GSSAPI intact.
|
|
220
|
+
if (!forSecret) args.push('-o', 'BatchMode=yes')
|
|
221
|
+
else args.push('-o', 'NumberOfPasswordPrompts=1')
|
|
222
|
+
return args
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One live system-OpenSSH connection.
|
|
227
|
+
*
|
|
228
|
+
* This deliberately mimics the ssh2 Client surface the rest of the plugin
|
|
229
|
+
* consumes — `forwardOut` plus a reverse-forward capability — so
|
|
230
|
+
* ForwardManager/WebAttach work unchanged. It is an EventEmitter because the
|
|
231
|
+
* native pool's client is one and callers attach listeners to it.
|
|
232
|
+
*/
|
|
233
|
+
export class OpenSshConnection extends EventEmitter {
|
|
234
|
+
constructor(pool, { sshBinary, secret }) {
|
|
235
|
+
super()
|
|
236
|
+
this.pool = pool
|
|
237
|
+
this.sshBinary = sshBinary
|
|
238
|
+
this.secret = secret
|
|
239
|
+
this.closed = false
|
|
240
|
+
/** Live child processes to reap on close. */
|
|
241
|
+
this.children = new Set()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
shortDescription() {
|
|
245
|
+
return `${this.pool.destination()} via ${this.sshBinary}`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
spawnSsh(extraArgs, { tty = false } = {}) {
|
|
249
|
+
if (this.closed) throw new Error('ssh connection closed')
|
|
250
|
+
const args = [
|
|
251
|
+
...baseSshArgs(this.pool._argConfig(), { forSecret: !!this.secret }),
|
|
252
|
+
...extraArgs,
|
|
253
|
+
]
|
|
254
|
+
const child = spawn(this.sshBinary, args, {
|
|
255
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
256
|
+
env: this.secret ? askpassEnv(this.secret) : undefined,
|
|
257
|
+
})
|
|
258
|
+
this.children.add(child)
|
|
259
|
+
const reap = () => this.children.delete(child)
|
|
260
|
+
child.on('close', reap)
|
|
261
|
+
child.on('error', reap)
|
|
262
|
+
if (tty) { /* no pty support through the CLI without a tty helper */ }
|
|
263
|
+
return child
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Local forward: carry bytes to <destHost>:<destPort> as seen FROM the
|
|
268
|
+
* remote, through `ssh -W`.
|
|
269
|
+
*
|
|
270
|
+
* @returns {boolean} true synchronously, so capability probes can be sync.
|
|
271
|
+
*/
|
|
272
|
+
forwardOut(_srcHost, _srcPort, destHost, destPort, cb) {
|
|
273
|
+
let child
|
|
274
|
+
try {
|
|
275
|
+
child = this.spawnSsh(['-W', `${destHost}:${destPort}`, '--', this.pool.destination()])
|
|
276
|
+
} catch (err) {
|
|
277
|
+
setImmediate(() => cb(err))
|
|
278
|
+
return true
|
|
279
|
+
}
|
|
280
|
+
// `ssh -W` reports failures on stderr and then exits; surface that as the
|
|
281
|
+
// channel error so the caller destroys its socket instead of hanging.
|
|
282
|
+
let stderr = ''
|
|
283
|
+
let handed = false
|
|
284
|
+
const channel = new Duplex({
|
|
285
|
+
read() {},
|
|
286
|
+
write(chunk, _enc, done) { try { child.stdin.write(chunk, done) } catch (e) { done(e) } },
|
|
287
|
+
final(done) { try { child.stdin.end(done) } catch { done() } },
|
|
288
|
+
destroy(err, done) { try { child.kill() } catch {} ; done(err) },
|
|
289
|
+
})
|
|
290
|
+
channel.close = () => { try { child.kill() } catch {} }
|
|
291
|
+
child.stderr.on('data', (d) => { stderr += d })
|
|
292
|
+
child.stdout.on('data', (d) => channel.push(d))
|
|
293
|
+
child.stdout.on('end', () => channel.push(null))
|
|
294
|
+
child.on('error', (e) => { if (!handed) { handed = true; cb(e); return } channel.destroy(e) })
|
|
295
|
+
child.on('close', (code) => {
|
|
296
|
+
if (handed) { channel.push(null); return }
|
|
297
|
+
handed = true
|
|
298
|
+
if (code === 0) { cb(null, channel); return }
|
|
299
|
+
cb(new Error(`ssh -W to ${destHost}:${destPort} failed (exit ${code})${stderr ? ': ' + stderr.trim() : ''}`))
|
|
300
|
+
})
|
|
301
|
+
return true
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Tells ForwardManager that the CLI-specific reverse path is available. */
|
|
305
|
+
get supportsReverseForward() { return true }
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Reverse forward: ask the REMOTE to listen on <bindHost>:<listenPort> and
|
|
309
|
+
* carry connections to <targetHost>:<targetPort>. Unlike ssh2's two-phase
|
|
310
|
+
* `forwardIn` + `tcpip`, the `ssh -R` CLI takes the final target as an
|
|
311
|
+
* argument, so nothing has to be resolved lazily.
|
|
312
|
+
*
|
|
313
|
+
* @returns {Promise<{child: object, close: Function}>}
|
|
314
|
+
*/
|
|
315
|
+
reverseForward({ bindHost = '127.0.0.1', listenPort, targetHost = '127.0.0.1', targetPort }) {
|
|
316
|
+
return new Promise((resolve, reject) => {
|
|
317
|
+
const spec = `${bindHost}:${listenPort}:${targetHost}:${targetPort}`
|
|
318
|
+
let child
|
|
319
|
+
try {
|
|
320
|
+
child = this.spawnSsh(['-N', '-o', 'ExitOnForwardFailure=yes', '-R', spec, '--', this.pool.destination()])
|
|
321
|
+
} catch (err) { return reject(err) }
|
|
322
|
+
let stderr = ''
|
|
323
|
+
let settled = false
|
|
324
|
+
const fail = (err) => {
|
|
325
|
+
if (settled) return
|
|
326
|
+
settled = true
|
|
327
|
+
clearTimeout(timer)
|
|
328
|
+
try { child.kill() } catch {}
|
|
329
|
+
reject(err)
|
|
330
|
+
}
|
|
331
|
+
child.stderr.on('data', (d) => {
|
|
332
|
+
stderr += d
|
|
333
|
+
// Nothing prints on success; an error before the grace window is the
|
|
334
|
+
// only signal `ssh -N` gives that the forward did not take.
|
|
335
|
+
if (/warning|error|failed|refused|denied|cannot|not permitted|unable/i.test(String(d))) {
|
|
336
|
+
fail(new Error(`reverse forward ${spec} failed: ${stderr.trim()}`))
|
|
337
|
+
}
|
|
338
|
+
})
|
|
339
|
+
child.on('error', fail)
|
|
340
|
+
// `ssh -N` stays in the foreground; a process that outlives the grace
|
|
341
|
+
// window means the remote listener was accepted.
|
|
342
|
+
const timer = setTimeout(() => {
|
|
343
|
+
if (settled) return
|
|
344
|
+
settled = true
|
|
345
|
+
resolve({ child, close: () => { try { child.kill() } catch {} } })
|
|
346
|
+
}, 700)
|
|
347
|
+
child.on('close', (code) => {
|
|
348
|
+
if (settled) return
|
|
349
|
+
settled = true
|
|
350
|
+
clearTimeout(timer)
|
|
351
|
+
reject(new Error(`reverse forward ${spec} exited early (${code})${stderr ? ': ' + stderr.trim() : ''}`))
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Superseded by reverseForward() (the CLI needs the target up front). */
|
|
357
|
+
forwardIn(_host, _port, cb) {
|
|
358
|
+
setImmediate(() => cb(new Error(
|
|
359
|
+
'the system-OpenSSH backend opens reverse forwards with `ssh -R`, which needs the target up front — use reverseForward()',
|
|
360
|
+
)))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
close() {
|
|
364
|
+
this.closed = true
|
|
365
|
+
for (const child of [...this.children]) {
|
|
366
|
+
try { child.kill() } catch {}
|
|
367
|
+
}
|
|
368
|
+
this.children.clear()
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* A system-OpenSSH pool: the same public surface as lib/pool.js's SshPool,
|
|
374
|
+
* backed by the local `ssh` CLI. Callers cannot tell the two apart — see
|
|
375
|
+
* test/openssh-backend.test.js, which drives both through one shared contract.
|
|
376
|
+
*/
|
|
377
|
+
export class SystemOpenSshPool {
|
|
378
|
+
constructor(config, opts = {}) {
|
|
379
|
+
this.config = config
|
|
380
|
+
this._knownHostsFile = opts.knownHostsFile
|
|
381
|
+
this.client = null
|
|
382
|
+
this.connecting = null
|
|
383
|
+
this.proxyPool = null
|
|
384
|
+
// Generational token, exactly as the native pool: a stale in-flight connect
|
|
385
|
+
// must never hand this pool a connection to an old host.
|
|
386
|
+
this.epoch = 0
|
|
387
|
+
this.platform = 'unknown'
|
|
388
|
+
this.gitBashPath = ''
|
|
389
|
+
this.shellMode = 'native'
|
|
390
|
+
this._detecting = null
|
|
391
|
+
this.passwordResolver = null
|
|
392
|
+
this.onReady = null
|
|
393
|
+
this.onCloseHook = null
|
|
394
|
+
/** The persistent `ssh -s ... sftp` session (kept across calls). */
|
|
395
|
+
this._sftpSession = null
|
|
396
|
+
this._sftpChild = null
|
|
397
|
+
/** Memoized askpass secret for this target (null = not resolved yet). */
|
|
398
|
+
this._secret = null
|
|
399
|
+
this._secretPromise = null
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The config baseSshArgs should see. `opts.knownHostsFile` (a path, or a
|
|
404
|
+
* function returning one, exactly like the native pool) used to be stored
|
|
405
|
+
* and silently dropped — so a custom known_hosts never reached the CLI, and
|
|
406
|
+
* tests had no way to keep loopback keys out of the user's real file.
|
|
407
|
+
* Resolved per call so a function value is honored as late as the native
|
|
408
|
+
* backend resolves it.
|
|
409
|
+
*/
|
|
410
|
+
_argConfig() {
|
|
411
|
+
const kh = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
|
|
412
|
+
return kh ? { ...this.config, knownHostsFile: kh } : this.config
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The destination OpenSSH should connect to (an alias passes through). */
|
|
416
|
+
destination() {
|
|
417
|
+
// A bare alias resolves through ~/.ssh/config inside ssh itself, so it is
|
|
418
|
+
// handed over untouched; user/port ride on `-l`/`-p` when recorded.
|
|
419
|
+
return String(this.config.host || '')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
|
|
423
|
+
const base = previous || this.config
|
|
424
|
+
const wanted = []
|
|
425
|
+
if (host !== undefined) wanted.push(['host', String(host)])
|
|
426
|
+
if (port !== undefined && Number(port)) wanted.push(['port', Number(port)])
|
|
427
|
+
if (username !== undefined) wanted.push(['username', String(username)])
|
|
428
|
+
if (password !== undefined && password !== null) wanted.push(['password', String(password)])
|
|
429
|
+
if (privateKeyPath !== undefined) wanted.push(['privateKeyPath', String(privateKeyPath)])
|
|
430
|
+
if (passphrase !== undefined) wanted.push(['passphrase', String(passphrase)])
|
|
431
|
+
if (workspace !== undefined) wanted.push(['workspace', String(workspace)])
|
|
432
|
+
if (useAgent !== undefined) wanted.push(['useAgent', !!useAgent])
|
|
433
|
+
if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
|
|
434
|
+
if (proxy !== undefined) wanted.push(['proxy', proxy])
|
|
435
|
+
if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
|
|
436
|
+
// Issue #51: kept in step with the native pool's setTarget — the two
|
|
437
|
+
// backends share one contract (see test/issue51-openssh-backend.test.js), so
|
|
438
|
+
// a field one of them diffs must not be silently ignored by the other.
|
|
439
|
+
if (backend !== undefined) wanted.push(['backend', String(backend)])
|
|
440
|
+
if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
|
|
441
|
+
const differs = ([k, v]) => (k === 'proxy'
|
|
442
|
+
? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
|
|
443
|
+
: base[k] !== v)
|
|
444
|
+
if (wanted.some(differs)) {
|
|
445
|
+
for (const [k, v] of wanted) this.config[k] = v
|
|
446
|
+
this.platform = 'unknown'
|
|
447
|
+
this.gitBashPath = ''
|
|
448
|
+
this.shellMode = 'native'
|
|
449
|
+
this._detecting = null
|
|
450
|
+
this.close()
|
|
451
|
+
}
|
|
452
|
+
return this
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* The secret to offer askpass, resolved once per target.
|
|
457
|
+
*
|
|
458
|
+
* `connect()` no longer has to run first: `exec`/`sftp` call this themselves,
|
|
459
|
+
* so a pool that is only ever used for file listing still authenticates.
|
|
460
|
+
*/
|
|
461
|
+
_resolveSecret() {
|
|
462
|
+
if (this._secret !== null) return Promise.resolve(this._secret)
|
|
463
|
+
if (this._secretPromise) return this._secretPromise
|
|
464
|
+
const epoch = this.epoch
|
|
465
|
+
this._secretPromise = (async () => {
|
|
466
|
+
let secret = this.config.password || ''
|
|
467
|
+
if (!secret && this.passwordResolver) {
|
|
468
|
+
try { secret = (await this.passwordResolver()) || '' } catch { /* fall through */ }
|
|
469
|
+
}
|
|
470
|
+
if (!secret) secret = this.config.passphrase || ''
|
|
471
|
+
// A target switch invalidates the answer; leave the memo for the new one.
|
|
472
|
+
if (this.epoch === epoch) this._secret = String(secret || '')
|
|
473
|
+
this._secretPromise = null
|
|
474
|
+
return String(secret || '')
|
|
475
|
+
})()
|
|
476
|
+
return this._secretPromise
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Validate the environment; there is no socket to hold open. */
|
|
480
|
+
connect() {
|
|
481
|
+
if (this.client) return Promise.resolve(this.client)
|
|
482
|
+
if (this.connecting) return this.connecting
|
|
483
|
+
const epoch = this.epoch
|
|
484
|
+
const pending = this._doConnect(epoch)
|
|
485
|
+
this.connecting = pending
|
|
486
|
+
const clear = () => { if (this.epoch === epoch && this.connecting === pending) this.connecting = null }
|
|
487
|
+
pending.then(clear, clear)
|
|
488
|
+
return pending
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async _doConnect(epoch) {
|
|
492
|
+
const isCurrent = () => this.epoch === epoch
|
|
493
|
+
const sshBinary = resolveSshBinary(this.config)
|
|
494
|
+
if (!this.config.host) throw new Error('no host configured')
|
|
495
|
+
if (!sshBinaryAvailable(sshBinary)) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`the system-OpenSSH backend needs a \`${sshBinary}\` executable on PATH`
|
|
498
|
+
+ ' — install OpenSSH, or set the backend back to "native"',
|
|
499
|
+
)
|
|
500
|
+
}
|
|
501
|
+
const secret = await this._resolveSecret()
|
|
502
|
+
if (!isCurrent()) throw new Error('ssh target changed during connect')
|
|
503
|
+
// Native semantics are "connect() throws when the SSH connection fails", and
|
|
504
|
+
// a probe also gives the settings page a real latency number. The CLI has no
|
|
505
|
+
// socket to keep, so this is the only point where the target is proven.
|
|
506
|
+
const probe = await this._runSsh(['--', this.destination(), 'true'], { timeoutMs: this.config.connectTimeoutMs || 15000 })
|
|
507
|
+
if (!isCurrent()) throw new Error('ssh target changed during connect')
|
|
508
|
+
if (probe.code !== 0) {
|
|
509
|
+
throw new Error(`ssh failed (exit ${probe.code})${String(probe.stderr || '').trim() ? ': ' + String(probe.stderr).trim() : ''}`)
|
|
510
|
+
}
|
|
511
|
+
void secret
|
|
512
|
+
const conn = new OpenSshConnection(this, { sshBinary, secret })
|
|
513
|
+
this.client = conn
|
|
514
|
+
if (this.onReady) { try { this.onReady(conn) } catch {} }
|
|
515
|
+
return conn
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Run one `ssh` invocation and collect its result.
|
|
520
|
+
*
|
|
521
|
+
* @param {string[]} extraArgs - arguments appended after the shared base.
|
|
522
|
+
* @param {object} [opts] - `timeoutMs`, `signal` (AbortSignal), `stdin`,
|
|
523
|
+
* `onData(channel, chunk)` — a streaming tap the native backend has had
|
|
524
|
+
* since issue #70. A background job's value is its LIVE output, which the
|
|
525
|
+
* job ring must receive as it arrives; a backend that only returns the
|
|
526
|
+
* accumulated text loses every byte the caller stopped waiting for
|
|
527
|
+
* (issue #80). Tap errors are not command errors, so the call is guarded.
|
|
528
|
+
* @returns {Promise<{code: number|null, signal: string|null, stdout: string, stderr: string}>}
|
|
529
|
+
*/
|
|
530
|
+
async _runSsh(extraArgs, opts = {}) {
|
|
531
|
+
if (!this.config.host) throw new Error('no host configured')
|
|
532
|
+
const secret = await this._resolveSecret()
|
|
533
|
+
const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
|
|
534
|
+
const sshBinary = resolveSshBinary(this.config)
|
|
535
|
+
const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), ...extraArgs]
|
|
536
|
+
return new Promise((resolve) => {
|
|
537
|
+
let child
|
|
538
|
+
try {
|
|
539
|
+
child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
|
|
540
|
+
} catch (err) {
|
|
541
|
+
return resolve({ code: -1, signal: 'SPAWN_FAILED', stdout: '', stderr: String((err && err.message) || err) })
|
|
542
|
+
}
|
|
543
|
+
let stdout = ''
|
|
544
|
+
let stderr = ''
|
|
545
|
+
let settled = false
|
|
546
|
+
let exitCode = null
|
|
547
|
+
let exitSignal = null
|
|
548
|
+
const cap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
549
|
+
const onData = typeof opts.onData === 'function' ? opts.onData : null
|
|
550
|
+
const cleanupAbort = () => { if (opts.signal && onAbort) opts.signal.removeEventListener('abort', onAbort) }
|
|
551
|
+
let onAbort = null
|
|
552
|
+
const finish = () => {
|
|
553
|
+
if (settled) return
|
|
554
|
+
settled = true
|
|
555
|
+
clearTimeout(timer)
|
|
556
|
+
cleanupAbort()
|
|
557
|
+
resolve({
|
|
558
|
+
code: exitCode,
|
|
559
|
+
signal: exitSignal,
|
|
560
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
561
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
const killHard = () => { try { setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 800).unref?.() } catch {} }
|
|
565
|
+
const timer = setTimeout(() => {
|
|
566
|
+
if (settled) return
|
|
567
|
+
exitCode = -1
|
|
568
|
+
exitSignal = 'TIMEOUT'
|
|
569
|
+
try { child.kill() } catch {}
|
|
570
|
+
killHard()
|
|
571
|
+
finish()
|
|
572
|
+
}, timeoutMs)
|
|
573
|
+
if (opts.signal) {
|
|
574
|
+
if (opts.signal.aborted) {
|
|
575
|
+
try { child.kill() } catch {}
|
|
576
|
+
exitCode = -1; exitSignal = 'ABORTED'
|
|
577
|
+
return finish()
|
|
578
|
+
}
|
|
579
|
+
onAbort = () => {
|
|
580
|
+
if (settled) return
|
|
581
|
+
exitCode = -1; exitSignal = 'ABORTED'
|
|
582
|
+
try { child.kill() } catch {}
|
|
583
|
+
killHard()
|
|
584
|
+
finish()
|
|
585
|
+
}
|
|
586
|
+
opts.signal.addEventListener('abort', onAbort, { once: true })
|
|
587
|
+
}
|
|
588
|
+
// The tap fires even past the local cap, exactly like the native
|
|
589
|
+
// backend's: a promoted job's ring has its own bounds and must keep
|
|
590
|
+
// receiving output the truncated return value no longer holds.
|
|
591
|
+
child.stdout.on('data', (d) => {
|
|
592
|
+
if (stdout.length < cap) stdout += d
|
|
593
|
+
if (onData) { try { onData('stdout', d) } catch { /* tap errors are not command errors */ } }
|
|
594
|
+
})
|
|
595
|
+
child.stderr.on('data', (d) => {
|
|
596
|
+
if (stderr.length < cap) stderr += d
|
|
597
|
+
if (onData) { try { onData('stderr', d) } catch { /* tap errors are not command errors */ } }
|
|
598
|
+
})
|
|
599
|
+
child.on('error', (e) => {
|
|
600
|
+
if (settled) return
|
|
601
|
+
stderr += String((e && e.message) || e)
|
|
602
|
+
exitCode = -1
|
|
603
|
+
finish()
|
|
604
|
+
})
|
|
605
|
+
child.on('close', (code, sig) => {
|
|
606
|
+
if (settled) return
|
|
607
|
+
exitCode = code
|
|
608
|
+
exitSignal = sig || null
|
|
609
|
+
finish()
|
|
610
|
+
})
|
|
611
|
+
try { child.stdin.end(opts.stdin === undefined || opts.stdin === null ? undefined : String(opts.stdin)) } catch {}
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
616
|
+
detect() {
|
|
617
|
+
if (this.platform !== 'unknown') return Promise.resolve()
|
|
618
|
+
if (this._detecting) return this._detecting
|
|
619
|
+
this._detecting = this._detect().finally(() => { this._detecting = null })
|
|
620
|
+
return this._detecting
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async _detect() {
|
|
624
|
+
let res
|
|
625
|
+
try {
|
|
626
|
+
res = await this._runSsh(['--', this.destination(), 'cmd /c ver'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
627
|
+
} catch {
|
|
628
|
+
this.platform = 'unknown'
|
|
629
|
+
this.shellMode = 'native'
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
633
|
+
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
634
|
+
this.platform = 'windows'
|
|
635
|
+
await this._resolveGitBash()
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
const u = await this._runSsh(['--', this.destination(), 'uname -s'], { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
640
|
+
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
641
|
+
this.platform = 'windows'
|
|
642
|
+
await this._resolveGitBash()
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
} catch { /* inconclusive — fall through to posix */ }
|
|
646
|
+
this.platform = 'posix'
|
|
647
|
+
this.shellMode = 'native'
|
|
648
|
+
this.gitBashPath = ''
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async _resolveGitBash() {
|
|
652
|
+
const cfg = String(this.config.shell || '').trim()
|
|
653
|
+
const exists = async (p) => {
|
|
654
|
+
const r = await this._runSsh(['--', this.destination(), `cmd /c if exist "${p}" (echo Y) else (echo N)`], { timeoutMs: 8000 })
|
|
655
|
+
return /Y/.test(String(r.stdout || ''))
|
|
656
|
+
}
|
|
657
|
+
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
658
|
+
if (await exists(cfg)) { this.gitBashPath = cfg; this.shellMode = 'git-bash'; return }
|
|
659
|
+
}
|
|
660
|
+
if (cfg === 'native') { this.shellMode = 'native'; this.gitBashPath = ''; return }
|
|
661
|
+
try {
|
|
662
|
+
const r = await this._runSsh(['--', this.destination(), 'cmd /c where bash'], { timeoutMs: 8000 })
|
|
663
|
+
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
664
|
+
if (first) { this.gitBashPath = first; this.shellMode = 'git-bash'; return }
|
|
665
|
+
} catch { /* fall through to the candidate list */ }
|
|
666
|
+
const candidates = [
|
|
667
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
668
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
669
|
+
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
670
|
+
]
|
|
671
|
+
for (const c of candidates) {
|
|
672
|
+
if (await exists(c)) { this.gitBashPath = c; this.shellMode = 'git-bash'; return }
|
|
673
|
+
}
|
|
674
|
+
this.shellMode = 'git-bash'
|
|
675
|
+
this.gitBashPath = ''
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Run one remote command (Windows + Git Bash is wrapped exactly as native). */
|
|
679
|
+
exec(command, timeoutMsOrOpts) {
|
|
680
|
+
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
681
|
+
return this.detect().then(() => {
|
|
682
|
+
const timeoutMs = opts.timeoutMs || this.config.commandTimeoutMs
|
|
683
|
+
if (this.platform === 'windows' && this.gitBashPath) {
|
|
684
|
+
// Pipe the script to `bash -s` over stdin: no quoting round-trip, so
|
|
685
|
+
// quotes/backslashes/newlines survive verbatim (same as the native pool).
|
|
686
|
+
return this._runSsh(['--', this.destination(), `"${this.gitBashPath}" -s`], { timeoutMs, signal: opts.signal, stdin: String(command), onData: opts.onData })
|
|
687
|
+
}
|
|
688
|
+
return this._runSsh(['--', this.destination(), String(command)], { timeoutMs, signal: opts.signal, onData: opts.onData })
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Open a fresh `ssh -s <dest> sftp` session and its ssh2 SFTP engine. */
|
|
693
|
+
async _openSftpChannel() {
|
|
694
|
+
const Sftp = loadSftpEngine()
|
|
695
|
+
const secret = await this._resolveSecret()
|
|
696
|
+
const sshBinary = resolveSshBinary(this.config)
|
|
697
|
+
const args = [...baseSshArgs(this._argConfig(), { forSecret: !!secret }), '-s', this.destination(), 'sftp']
|
|
698
|
+
const child = spawn(sshBinary, args, { stdio: ['pipe', 'pipe', 'pipe'], env: secret ? askpassEnv(secret) : undefined })
|
|
699
|
+
|
|
700
|
+
// ssh2's SFTP engine only reaches `_protocol` for channelData/channelClose,
|
|
701
|
+
// and reads `_remoteIdentRaw` to pick its packet sizes.
|
|
702
|
+
//
|
|
703
|
+
// ## Flow control, the subtle part
|
|
704
|
+
//
|
|
705
|
+
// Over real SSH the engine's send window is topped up by CHANNEL_WINDOW_ADJUST
|
|
706
|
+
// messages from the peer. On a raw `ssh -s sftp` pipe THERE ARE NO SUCH
|
|
707
|
+
// MESSAGES — the local ssh owns the SSH-layer window and the server's grants,
|
|
708
|
+
// and all we have is a byte pipe with OS-level backpressure. So the engine's
|
|
709
|
+
// window has to be mapped onto the pipe's writability:
|
|
710
|
+
//
|
|
711
|
+
// • a write the pipe accepts → more will fit;
|
|
712
|
+
// • the pipe fills (`stdin.write` returns false) → clamp the window so the
|
|
713
|
+
// engine buffers the remainder instead of queueing it all in memory;
|
|
714
|
+
// • the pipe drains → grant the window back and let the engine flush.
|
|
715
|
+
//
|
|
716
|
+
// The trap, and why the constants below are what they are: ssh2 arms its
|
|
717
|
+
// flush callback (`_waitWindow` / `_chunkcb = drainBuffer`) ONLY from inside
|
|
718
|
+
// its own subtraction, at the moment that subtraction takes the window to
|
|
719
|
+
// exactly 0. So the window must be handed over in EXACT MULTIPLES of
|
|
720
|
+
// packetSize and then left alone: the engine's own arithmetic then lands on 0
|
|
721
|
+
// during normal operation and arms the flush callback we depend on.
|
|
722
|
+
//
|
|
723
|
+
// Do NOT "help" by clamping the window to 0 from inside channelData when the
|
|
724
|
+
// pipe reports itself full. That sets the window to 0 AFTER the subtraction
|
|
725
|
+
// has already happened, so the callback is never armed and the buffered
|
|
726
|
+
// remainder is never flushed — the transfer deadlocks with a full window.
|
|
727
|
+
// (Measured: a 4 MB upload stopped dead after 32 KB, with `_buffer.length`
|
|
728
|
+
// stuck at 1 and no callback pending.) The pipe's own `drain` event is the
|
|
729
|
+
// only place the window is refilled.
|
|
730
|
+
//
|
|
731
|
+
// Also tempting-but-wrong: adding each received packet's length to the
|
|
732
|
+
// window. That happens to work for a DOWNLOAD (the peer's data is the
|
|
733
|
+
// window) and deadlocks an upload, because a write's acknowledgements are
|
|
734
|
+
// tiny status packets while each send consumes ~32 KB.
|
|
735
|
+
//
|
|
736
|
+
// See scripts/integration-openssh-large.mjs, which is what caught all of this.
|
|
737
|
+
const PACKET = 32768
|
|
738
|
+
const GRANT = PACKET * 8 // 256 KB per burst, and a multiple of packetSize
|
|
739
|
+
const chan = { sftp: null }
|
|
740
|
+
const fakeClient = {
|
|
741
|
+
_protocol: {
|
|
742
|
+
_remoteIdentRaw: Buffer.from('SSH-2.0-OpenSSH'),
|
|
743
|
+
// Hand the bytes to the pipe and nothing else. Backpressure is expressed
|
|
744
|
+
// through the engine's own window draining (see above); the OS pipe
|
|
745
|
+
// buffer plus Node's write buffer absorb the in-flight remainder.
|
|
746
|
+
channelData(_id, data) { try { child.stdin.write(data) } catch { /* close handles it */ } },
|
|
747
|
+
channelClose() { try { child.stdin.end() } catch {} },
|
|
748
|
+
},
|
|
749
|
+
}
|
|
750
|
+
const chanInfo = {
|
|
751
|
+
type: 'sftp',
|
|
752
|
+
incoming: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
|
|
753
|
+
outgoing: { id: 0, window: GRANT, packetSize: PACKET, state: 'open' },
|
|
754
|
+
}
|
|
755
|
+
const sftp = new Sftp(fakeClient, chanInfo, {})
|
|
756
|
+
chan.sftp = sftp
|
|
757
|
+
|
|
758
|
+
// The pipe has room again: grant the window back and let the engine flush
|
|
759
|
+
// whatever it buffered while the pipe was full.
|
|
760
|
+
const onStdinDrain = () => {
|
|
761
|
+
if (!chan.sftp) return
|
|
762
|
+
chan.sftp.outgoing.window = GRANT
|
|
763
|
+
if (chan.sftp._waitWindow) {
|
|
764
|
+
chan.sftp._waitWindow = false
|
|
765
|
+
const cb = chan.sftp._chunkcb
|
|
766
|
+
chan.sftp._chunkcb = undefined
|
|
767
|
+
if (typeof cb === 'function') cb.call(chan.sftp)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
child.stdin.on('drain', onStdinDrain)
|
|
771
|
+
|
|
772
|
+
return new Promise((resolve, reject) => {
|
|
773
|
+
let stderr = ''
|
|
774
|
+
let settled = false
|
|
775
|
+
const done = (err) => {
|
|
776
|
+
if (settled) return
|
|
777
|
+
settled = true
|
|
778
|
+
clearTimeout(timer)
|
|
779
|
+
if (err) { try { child.kill() } catch {} ; reject(err) } else resolve({ child, sftp })
|
|
780
|
+
}
|
|
781
|
+
const timer = setTimeout(() => {
|
|
782
|
+
done(new Error(`sftp session to ${this.destination()} timed out${stderr ? ': ' + stderr.trim() : ''}`))
|
|
783
|
+
}, this.config.commandTimeoutMs)
|
|
784
|
+
child.stderr.on('data', (d) => { stderr += d })
|
|
785
|
+
sftp.on('error', (e) => done(new Error('sftp session failed: ' + ((e && e.message) || e))))
|
|
786
|
+
sftp.on('ready', () => done(null))
|
|
787
|
+
child.on('error', (e) => done(e))
|
|
788
|
+
child.on('close', (code) => done(new Error(`ssh sftp exited (${code})${stderr ? ': ' + stderr.trim() : ''}`)))
|
|
789
|
+
// Inbound is simpler: the local ssh already did the SSH-level window
|
|
790
|
+
// accounting before handing us these bytes, so they just go straight in.
|
|
791
|
+
child.stdout.on('data', (d) => { sftp.push(d) })
|
|
792
|
+
child.stdout.on('end', () => { try { sftp.push(null) } catch { /* already gone */ } })
|
|
793
|
+
sftp._init()
|
|
794
|
+
})
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* A persistent SFTP session, kept across calls: the picker and the explorer
|
|
799
|
+
* issue one listing per interaction, and re-handshaking each time would be
|
|
800
|
+
* visibly slow. A session that dies is dropped so the next call reconnects.
|
|
801
|
+
*/
|
|
802
|
+
sftp() {
|
|
803
|
+
if (this._sftpSession) return this._sftpSession
|
|
804
|
+
const epoch = this.epoch
|
|
805
|
+
const session = this._openSftpChannel().then(({ child, sftp }) => {
|
|
806
|
+
const drop = () => {
|
|
807
|
+
if (this._sftpSession === session) this._sftpSession = null
|
|
808
|
+
if (this._sftpChild === child) this._sftpChild = null
|
|
809
|
+
}
|
|
810
|
+
child.on('close', drop)
|
|
811
|
+
sftp.on('end', drop)
|
|
812
|
+
sftp.on('error', drop)
|
|
813
|
+
// A target switch during the handshake must not adopt the old host's
|
|
814
|
+
// session (the same generational guard connect() uses).
|
|
815
|
+
if (this.epoch !== epoch) {
|
|
816
|
+
try { child.kill() } catch {}
|
|
817
|
+
throw new Error('ssh target changed while opening sftp')
|
|
818
|
+
}
|
|
819
|
+
this._sftpChild = child
|
|
820
|
+
return buildSftpSurface(sftp, {
|
|
821
|
+
commandTimeoutMs: this.config.commandTimeoutMs,
|
|
822
|
+
onTimeout: () => {
|
|
823
|
+
// A timed-out SFTP session is dead: kill the child and drop the cached
|
|
824
|
+
// session so the next call re-handshakes instead of queueing behind the
|
|
825
|
+
// wedged one (issue #65).
|
|
826
|
+
try { child.kill() } catch { /* already gone */ }
|
|
827
|
+
drop()
|
|
828
|
+
},
|
|
829
|
+
})
|
|
830
|
+
})
|
|
831
|
+
// Never cache a rejected open: the next call must retry rather than get the
|
|
832
|
+
// same error back forever (a failed handshake is usually transient).
|
|
833
|
+
session.catch(() => { if (this._sftpSession === session) this._sftpSession = null })
|
|
834
|
+
this._sftpSession = session
|
|
835
|
+
return session
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
invalidate() {
|
|
839
|
+
this.epoch++
|
|
840
|
+
const client = this.client
|
|
841
|
+
this.client = null
|
|
842
|
+
this.connecting = null
|
|
843
|
+
this._secret = null
|
|
844
|
+
this._secretPromise = null
|
|
845
|
+
if (client) { try { client.close() } catch {} }
|
|
846
|
+
this._dropSftpSession()
|
|
847
|
+
if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
|
|
848
|
+
if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
_dropSftpSession() {
|
|
852
|
+
const session = this._sftpSession
|
|
853
|
+
this._sftpSession = null
|
|
854
|
+
if (this._sftpChild) {
|
|
855
|
+
try { this._sftpChild.kill() } catch {}
|
|
856
|
+
this._sftpChild = null
|
|
857
|
+
}
|
|
858
|
+
// Swallow the rejection: nobody awaits a session we are tearing down.
|
|
859
|
+
if (session && typeof session.catch === 'function') session.catch(() => {})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
close() {
|
|
863
|
+
this.epoch++
|
|
864
|
+
const client = this.client
|
|
865
|
+
this.client = null
|
|
866
|
+
this.connecting = null
|
|
867
|
+
this._secret = null
|
|
868
|
+
this._secretPromise = null
|
|
869
|
+
if (client) { try { client.close() } catch {} }
|
|
870
|
+
this._dropSftpSession()
|
|
871
|
+
if (this.proxyPool) { try { this.proxyPool.close() } catch {} ; this.proxyPool = null }
|
|
872
|
+
if (this.onCloseHook) { try { this.onCloseHook() } catch {} }
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** Pick the pool class for a config: 'openssh' backend, else the native pool. */
|
|
877
|
+
export function isSystemOpenSshBackend(config = {}) {
|
|
878
|
+
return String(config.backend || '').toLowerCase() === 'openssh'
|
|
879
|
+
}
|