dsh-remote 0.8.42 → 0.8.45

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/lib/pool.js CHANGED
@@ -3,11 +3,173 @@ import ssh2 from 'ssh2'
3
3
  import { readFileSync } from 'node:fs'
4
4
  import { homedir } from 'node:os'
5
5
  import path from 'node:path'
6
- import { toSftpPath, truncate } from './paths.js'
6
+ import { truncate } from './paths.js'
7
7
  import { createHostKeyGuard } from './hostkey.js'
8
+ import { buildSftpSurface } from './sftp-surface.js'
9
+ import { SystemOpenSshPool, isSystemOpenSshBackend } from './openssh.js'
8
10
 
9
11
  const { Client } = ssh2
10
12
 
13
+ /**
14
+ * Build the pool for a config.
15
+ *
16
+ * Two backends exist (issue #51): the native one below, which speaks SSH itself
17
+ * via ssh2, and the "system OpenSSH" one (lib/openssh.js), which delegates to
18
+ * the local `ssh` executable so GSSAPI/Kerberos and the rest of the user's
19
+ * `~/.ssh/config` are honoured. Both expose one identical surface, so every
20
+ * caller can keep using `new SshPool(...)`-shaped code — this factory only has
21
+ * to decide which class to instantiate.
22
+ */
23
+ export function createPool(config, opts) {
24
+ return isSystemOpenSshBackend(config)
25
+ ? new SystemOpenSshPool(config, opts)
26
+ : new SshPool(config, opts)
27
+ }
28
+
29
+ /**
30
+ * Run one `forwardOut` and resolve with the resulting channel.
31
+ *
32
+ * ssh2 reports a dead connection by THROWING synchronously —
33
+ * `if (!this._sock || !isWritable(this._sock)) throw new Error('Not connected')`
34
+ * (ssh2/lib/client.js:1398-1400) — and never calls the callback, so a
35
+ * callback-only error path cannot guard this call (issue #54). Two further
36
+ * consequences justify a shared helper rather than an inline try/catch:
37
+ *
38
+ * • inside a `new Promise(executor)` a bare throw rejects nothing: it becomes
39
+ * an unhandled REJECTION and leaves the promise pending forever;
40
+ * • called from a `net.Server` connection handler the throw escapes to the
41
+ * process top level and kills the host.
42
+ *
43
+ * The system-OpenSSH backend (lib/openssh.js) throws the same way when its
44
+ * connection is already closed, so this covers both transports.
45
+ *
46
+ * @param {object} client - an ssh2 Client or OpenSshConnection.
47
+ * @param {object} spec - `srcHost`/`srcPort`/`dstHost`/`dstPort`.
48
+ * @returns {Promise<object>} the forwarded channel / duplex stream.
49
+ */
50
+ export function forwardOutChannel(client, { srcHost = '127.0.0.1', srcPort = 0, dstHost, dstPort }) {
51
+ return new Promise((resolve, reject) => {
52
+ // `reject` is called at most once: a callback that fires after a successful
53
+ // throw-path rejection would be a no-op, which Promise guarantees.
54
+ try {
55
+ client.forwardOut(srcHost, srcPort, dstHost, dstPort, (err, channel) => (err ? reject(err) : resolve(channel)))
56
+ } catch (err) {
57
+ reject(err)
58
+ }
59
+ })
60
+ }
61
+
62
+ /**
63
+ * The ACTIVE pool: one long-lived object whose concrete implementation can be
64
+ * swapped when the current machine's transport backend changes.
65
+ *
66
+ * Why a delegating facade instead of just constructing the right class once:
67
+ * `backend` is a PER-MACHINE field (an enterprise Kerberos host needs
68
+ * `openssh` while every other machine stays on `native`), but the plugin keeps
69
+ * exactly one active pool for its whole lifetime and hangs `passwordResolver` /
70
+ * `onReady` / `onCloseHook` off it once at startup. Replacing the object
71
+ * wholesale on every machine switch would silently drop those hooks — the port
72
+ * forwards and keychain passwords would stop working after the first switch.
73
+ *
74
+ * So the facade owns the hooks and the shared `config` object, and forwards
75
+ * them to whichever concrete pool is right for the current config. Everything
76
+ * else is a straight delegation, which is why callers cannot tell the
77
+ * difference (and why the class is deliberately not part of the public story).
78
+ */
79
+ export class ActivePool {
80
+ constructor(config, opts = {}) {
81
+ this.config = config
82
+ this.opts = opts
83
+ // The hooks are assigned by the plugin AFTER construction, so they are
84
+ // stored here and re-applied to every delegate this facade ever creates.
85
+ this._passwordResolver = null
86
+ this._onReady = null
87
+ this._onCloseHook = null
88
+ this._delegate = null
89
+ this._sync()
90
+ }
91
+
92
+ /** Which concrete class the current config asks for. */
93
+ _wantsSystemOpenSsh() {
94
+ return isSystemOpenSshBackend(this.config)
95
+ }
96
+
97
+ _delegateIsCurrent() {
98
+ if (!this._delegate) return false
99
+ const isOpenssh = this._delegate instanceof SystemOpenSshPool
100
+ return isOpenssh === this._wantsSystemOpenSsh()
101
+ }
102
+
103
+ /** Create (or replace) the delegate so it matches the current config. */
104
+ _sync() {
105
+ if (this._delegateIsCurrent()) return this._delegate
106
+ const previous = this._delegate
107
+ const next = createPool(this.config, this.opts)
108
+ next.passwordResolver = this._passwordResolver
109
+ next.onReady = this._onReady
110
+ next.onCloseHook = this._onCloseHook
111
+ this._delegate = next
112
+ // Tear the old backend down LAST, and never let a teardown error mask the
113
+ // new pool: the connection it was holding belongs to the old host anyway.
114
+ if (previous) { try { previous.close() } catch { /* already gone */ } }
115
+ return next
116
+ }
117
+
118
+ get delegate() { return this._sync() }
119
+
120
+ // ── hooks: stored here, mirrored onto every delegate ──────────────────────
121
+ get passwordResolver() { return this._passwordResolver }
122
+ set passwordResolver(fn) {
123
+ this._passwordResolver = fn
124
+ if (this._delegate) this._delegate.passwordResolver = fn
125
+ }
126
+ get onReady() { return this._onReady }
127
+ set onReady(fn) {
128
+ this._onReady = fn
129
+ if (this._delegate) this._delegate.onReady = fn
130
+ }
131
+ get onCloseHook() { return this._onCloseHook }
132
+ set onCloseHook(fn) {
133
+ this._onCloseHook = fn
134
+ if (this._delegate) this._delegate.onCloseHook = fn
135
+ }
136
+
137
+ // ── pass-through state the plugin reads directly ──────────────────────────
138
+ get client() { return this._delegate ? this._delegate.client : null }
139
+ // Assignable because the plugin's own tests arm a fake live connection on the
140
+ // active pool; a getter-only facade would make that assignment a silent
141
+ // no-op in sloppy mode and a TypeError in the strict-mode ESM tests.
142
+ set client(v) { if (this._sync()) this._delegate.client = v }
143
+ get connecting() { return this._delegate ? this._delegate.connecting : null }
144
+ get proxyPool() { return this._delegate ? this._delegate.proxyPool : null }
145
+ get epoch() { return this._delegate ? this._delegate.epoch : 0 }
146
+ get platform() { return this._delegate ? this._delegate.platform : 'unknown' }
147
+ set platform(v) { if (this._sync()) this._delegate.platform = v }
148
+ get gitBashPath() { return this._delegate ? this._delegate.gitBashPath : '' }
149
+ set gitBashPath(v) { if (this._sync()) this._delegate.gitBashPath = v }
150
+ get shellMode() { return this._delegate ? this._delegate.shellMode : 'native' }
151
+ set shellMode(v) { if (this._sync()) this._delegate.shellMode = v }
152
+
153
+ // ── delegated operations ──────────────────────────────────────────────────
154
+ /** Re-point at a new target, switching backend first if the machine asks. */
155
+ setTarget(fields, previous) {
156
+ // The backend may have changed with the machine, so apply that part of the
157
+ // request to `config` BEFORE picking the delegate: _sync() chooses the class
158
+ // from config.backend, and the old backend must not be handed a host it can
159
+ // no longer reach. The delegate then receives the whole request as usual.
160
+ if (fields && fields.backend !== undefined) this.config.backend = String(fields.backend)
161
+ if (fields && fields.sshBinary !== undefined) this.config.sshBinary = String(fields.sshBinary)
162
+ const d = this._sync()
163
+ return d.setTarget(fields, previous)
164
+ }
165
+ connect() { return this.delegate.connect() }
166
+ exec(cmd, opts) { return this.delegate.exec(cmd, opts) }
167
+ sftp() { return this.delegate.sftp() }
168
+ detect() { return this.delegate.detect() }
169
+ invalidate() { return this.delegate.invalidate() }
170
+ close() { return this.delegate.close() }
171
+ }
172
+
11
173
  export class SshPool {
12
174
  constructor(config, opts = {}) {
13
175
  this.config = config
@@ -41,7 +203,7 @@ export class SshPool {
41
203
  return p
42
204
  }
43
205
 
44
- setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }, previous) {
206
+ setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode, backend, sshBinary }, previous) {
45
207
  // Collect the requested changes first: an UNCHANGED target is a no-op. The
46
208
  // old behavior closed the live connection and re-ran platform detection
47
209
  // even for an identical target, so every picker keystroke
@@ -68,6 +230,10 @@ export class SshPool {
68
230
  if (keyboardInteractive !== undefined) wanted.push(['keyboardInteractive', !!keyboardInteractive])
69
231
  if (proxy !== undefined) wanted.push(['proxy', proxy])
70
232
  if (hostKeyMode !== undefined) wanted.push(['hostKeyMode', String(hostKeyMode)])
233
+ // Issue #51: the transport backend is part of the target identity — changing
234
+ // it must close the live connection exactly like changing the host does.
235
+ if (backend !== undefined) wanted.push(['backend', String(backend)])
236
+ if (sshBinary !== undefined) wanted.push(['sshBinary', String(sshBinary)])
71
237
  const differs = ([k, v]) => (k === 'proxy'
72
238
  ? JSON.stringify(base[k] ?? null) !== JSON.stringify(v ?? null)
73
239
  : base[k] !== v)
@@ -129,9 +295,11 @@ export class SshPool {
129
295
  }, { knownHostsFile: this._knownHostsFile })
130
296
  const pclient = await this.proxyPool.connect()
131
297
  if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
132
- sock = await new Promise((res, rej) => {
133
- pclient.forwardOut('127.0.0.1', 0, this.config.host, this.config.port, (e, ch) => (e ? rej(new Error('proxy forward to target failed: ' + ((e && e.message) || e))) : res(ch)))
134
- })
298
+ try {
299
+ sock = await forwardOutChannel(pclient, { dstHost: this.config.host, dstPort: this.config.port })
300
+ } catch (e) {
301
+ throw new Error('proxy forward to target failed: ' + ((e && e.message) || e))
302
+ }
135
303
  } catch (err) {
136
304
  return fail(err)
137
305
  }
@@ -513,58 +681,10 @@ export class SshPool {
513
681
  }
514
682
  return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
515
683
  }
516
- // A dead channel must fail fast. ssh2's own EOF handling only
517
- // rejects the requests that were pending at that moment
518
- // (SFTP.js cleanupRequests); every request issued afterwards is
519
- // registered in `_requests` and never answered. Without the flag
520
- // below each such call waits out the full commandTimeoutMs — so a
521
- // tree walk over N entries costs N × 2 × timeout, i.e. days on a
522
- // large directory (issue #44). Mark the channel dead on EOF/close
523
- // and reject immediately from then on.
524
- let dead = false
525
- const markDead = () => { dead = true }
526
- if (typeof sftp.on === 'function') {
527
- sftp.on('end', markDead)
528
- sftp.on('close', markDead)
529
- sftp.on('error', markDead)
530
- }
531
- const withTimeout = (fn) => (...args) =>
532
- new Promise((r2, j2) => {
533
- if (dead) return j2(new Error('sftp channel closed'))
534
- const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
535
- const finish = (e, v) => {
536
- clearTimeout(timer)
537
- e ? j2(e) : r2(v)
538
- }
539
- try { fn(...args, finish) } catch (e) { clearTimeout(timer); j2(e) }
540
- })
541
- const P = (p) => toSftpPath(p)
542
- resolve({
543
- readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
544
- stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
545
- lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
546
- mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
547
- rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
548
- unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
549
- rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
550
- realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
551
- readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
552
- writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
553
- fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
554
- fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
555
- // Range read for oversized previews: never downloads the whole file.
556
- readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
557
- sftp.open(d, 'r', (e, handle) => {
558
- if (e) return cb(e)
559
- const buf = Buffer.alloc(len)
560
- sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
561
- sftp.close(handle, () => {})
562
- if (e2) return cb(e2)
563
- cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
564
- })
565
- })
566
- })(P(p), offset, length),
567
- })
684
+ // The promisified surface (timeouts, dead-channel fail-fast,
685
+ // range reads) is shared verbatim with the system-OpenSSH
686
+ // backend — see lib/sftp-surface.js for the rules it encodes.
687
+ resolve(buildSftpSurface(sftp, { commandTimeoutMs: this.config.commandTimeoutMs }))
568
688
  })
569
689
  } catch (e) {
570
690
  clearAttempt()
package/lib/registry.js CHANGED
@@ -80,6 +80,12 @@ export function applyMachine(config, m) {
80
80
  if (m.hostKeyMode) config.hostKeyMode = m.hostKeyMode
81
81
  if (typeof m.useAgent === 'boolean') config.useAgent = m.useAgent
82
82
  if (typeof m.keyboardInteractive === 'boolean') config.keyboardInteractive = m.keyboardInteractive
83
+ // Issue #51: the transport backend is PER MACHINE. An enterprise box that only
84
+ // accepts Kerberos must go through the system OpenSSH backend while every
85
+ // other machine can stay on the native one, so this cannot be a purely global
86
+ // default.
87
+ if (m.backend) config.backend = m.backend
88
+ if (typeof m.sshBinary === 'string') config.sshBinary = m.sshBinary
83
89
  config.proxy = m.proxy && m.proxy.host ? m.proxy : undefined
84
90
  }
85
91
 
@@ -0,0 +1,76 @@
1
+ // dsh-remote — the promisified SFTP surface every backend presents.
2
+ //
3
+ // Both SSH transports (the native ssh2 pool in lib/pool.js and the
4
+ // system-OpenSSH pool in lib/openssh.js) must expose the SAME object to
5
+ // callers: lib/sync.js, lib/search.js, lib/routes-fs.js, lib/remote-fs.js and
6
+ // the rw_* tools all consume this exact shape. Keeping one implementation
7
+ // matters more than it looks: the timeout, dead-channel and range-read rules
8
+ // below encode failures that were expensive to find (issue #44), and a second
9
+ // hand-copied version would drift away from the fixed one.
10
+ //
11
+ // The two rules that are easy to get wrong:
12
+ // • A dead channel must fail FAST. ssh2's own EOF handling only rejects the
13
+ // requests pending at that moment; every later request is registered and
14
+ // never answered, so each waits out the full commandTimeoutMs. Over a tree
15
+ // walk that is N × 2 × timeout — days on a large directory.
16
+ // • `readPartial` exists so an oversized preview never downloads the whole
17
+ // file to show its head.
18
+ import { toSftpPath } from './paths.js'
19
+
20
+ /**
21
+ * Wrap a live SFTP channel (ssh2's SFTP instance) in the promisified surface
22
+ * the plugin's callers expect.
23
+ *
24
+ * @param {object} sftp - an ssh2 SFTP channel (already negotiated/ready).
25
+ * @param {object} opts
26
+ * @param {number} opts.commandTimeoutMs - per-operation timeout.
27
+ * @returns {object} promisified sftp surface.
28
+ */
29
+ export function buildSftpSurface(sftp, { commandTimeoutMs }) {
30
+ // A dead channel must fail fast (see the header). ssh2's SFTP instance and the
31
+ // system-OpenSSH engine both emit these, so one hook covers both backends.
32
+ let dead = false
33
+ const markDead = () => { dead = true }
34
+ if (typeof sftp.on === 'function') {
35
+ sftp.on('end', markDead)
36
+ sftp.on('close', markDead)
37
+ sftp.on('error', markDead)
38
+ }
39
+ const withTimeout = (fn) => (...args) =>
40
+ new Promise((resolve, reject) => {
41
+ if (dead) return reject(new Error('sftp channel closed'))
42
+ const timer = setTimeout(() => reject(new Error('sftp operation timed out')), commandTimeoutMs)
43
+ const finish = (err, value) => {
44
+ clearTimeout(timer)
45
+ err ? reject(err) : resolve(value)
46
+ }
47
+ try { fn(...args, finish) } catch (err) { clearTimeout(timer); reject(err) }
48
+ })
49
+ const P = (p) => toSftpPath(p)
50
+ return {
51
+ readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
52
+ stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
53
+ lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
54
+ mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
55
+ rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
56
+ unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
57
+ rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
58
+ realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
59
+ readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
60
+ writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
61
+ fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
62
+ fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
63
+ // Range read for oversized previews: never downloads the whole file.
64
+ readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
65
+ sftp.open(d, 'r', (e, handle) => {
66
+ if (e) return cb(e)
67
+ const buf = Buffer.alloc(len)
68
+ sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
69
+ sftp.close(handle, () => {})
70
+ if (e2) return cb(e2)
71
+ cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
72
+ })
73
+ })
74
+ })(P(p), offset, length),
75
+ }
76
+ }