dsh-remote 0.8.18 → 0.8.21
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/README.md +13 -15
- package/README.zh.md +18 -12
- package/lib/binding.js +69 -0
- package/lib/client.js +382 -137
- package/lib/hostkey.js +60 -0
- package/lib/index.js +104 -924
- package/lib/pool.js +511 -0
- package/lib/remote-fs.js +143 -0
- package/lib/routes-fs.js +221 -0
- package/lib/search.js +59 -1
- package/lib/sync.js +15 -10
- package/package.json +2 -2
package/lib/pool.js
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
// Persistent SSH/SFTP connection pool (one identity → one live client).
|
|
2
|
+
import ssh2 from 'ssh2'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { homedir } from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { toSftpPath, truncate } from './paths.js'
|
|
7
|
+
import { createHostKeyGuard } from './hostkey.js'
|
|
8
|
+
|
|
9
|
+
const { Client } = ssh2
|
|
10
|
+
|
|
11
|
+
export class SshPool {
|
|
12
|
+
constructor(config, opts = {}) {
|
|
13
|
+
this.config = config
|
|
14
|
+
this._knownHostsFile = opts.knownHostsFile
|
|
15
|
+
this.client = null
|
|
16
|
+
this.connecting = null
|
|
17
|
+
this.proxyPool = null
|
|
18
|
+
// Generational token: bumped on every target change / close so a stale
|
|
19
|
+
// in-flight connect can never hand this pool a connection to an old host.
|
|
20
|
+
this.epoch = 0
|
|
21
|
+
/** Auto-detected remote platform: unknown | windows | posix (per target). */
|
|
22
|
+
this.platform = 'unknown'
|
|
23
|
+
/** Resolved Git Bash bash.exe path on Windows remotes ('' when none). */
|
|
24
|
+
this.gitBashPath = ''
|
|
25
|
+
/** Resolved terminal strategy: native | git-bash. */
|
|
26
|
+
this.shellMode = 'native'
|
|
27
|
+
/** In-flight platform detection promise (cached). */
|
|
28
|
+
this._detecting = null
|
|
29
|
+
/** Optional async resolver for a machine-stored (keychain) password. */
|
|
30
|
+
this.passwordResolver = null
|
|
31
|
+
/** Optional hook called with the live client after a successful connect. */
|
|
32
|
+
this.onReady = null
|
|
33
|
+
/** Optional hook called when the pool closes. */
|
|
34
|
+
this.onCloseHook = null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
resolveKeyPath() {
|
|
38
|
+
const p = this.config.privateKeyPath
|
|
39
|
+
if (!p) return ''
|
|
40
|
+
if (p.startsWith('~/') || p === '~') return path.join(homedir(), p.slice(1))
|
|
41
|
+
return p
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setTarget({ host, port, username, password, privateKeyPath, passphrase, workspace, useAgent, keyboardInteractive, proxy, hostKeyMode }) {
|
|
45
|
+
if (host !== undefined) this.config.host = String(host)
|
|
46
|
+
if (port !== undefined && Number(port)) this.config.port = Number(port)
|
|
47
|
+
if (username !== undefined) this.config.username = String(username)
|
|
48
|
+
if (password !== undefined && password !== null) this.config.password = String(password)
|
|
49
|
+
if (privateKeyPath !== undefined) this.config.privateKeyPath = String(privateKeyPath)
|
|
50
|
+
if (passphrase !== undefined) this.config.passphrase = String(passphrase)
|
|
51
|
+
if (workspace !== undefined) this.config.workspace = String(workspace)
|
|
52
|
+
if (useAgent !== undefined) this.config.useAgent = !!useAgent
|
|
53
|
+
if (keyboardInteractive !== undefined) this.config.keyboardInteractive = !!keyboardInteractive
|
|
54
|
+
if (proxy !== undefined) this.config.proxy = proxy
|
|
55
|
+
if (hostKeyMode !== undefined) this.config.hostKeyMode = String(hostKeyMode)
|
|
56
|
+
// the new target may be a different OS — re-detect on the next command
|
|
57
|
+
this.platform = 'unknown'
|
|
58
|
+
this.gitBashPath = ''
|
|
59
|
+
this.shellMode = 'native'
|
|
60
|
+
this._detecting = null
|
|
61
|
+
this.close()
|
|
62
|
+
return this
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
connect() {
|
|
66
|
+
if (this.client) return Promise.resolve(this.client)
|
|
67
|
+
if (this.connecting) return this.connecting
|
|
68
|
+
const epoch = this.epoch
|
|
69
|
+
const pending = this._doConnect(epoch)
|
|
70
|
+
this.connecting = pending
|
|
71
|
+
const clear = () => {
|
|
72
|
+
if (this.epoch === epoch && this.connecting === pending) this.connecting = null
|
|
73
|
+
}
|
|
74
|
+
pending.then(clear, clear)
|
|
75
|
+
return pending
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async _doConnect(epoch) {
|
|
79
|
+
const isCurrent = () => this.epoch === epoch
|
|
80
|
+
const knownHostsFile = typeof this._knownHostsFile === 'function' ? this._knownHostsFile() : this._knownHostsFile
|
|
81
|
+
const guard = createHostKeyGuard(this.config, knownHostsFile)
|
|
82
|
+
const client = new Client()
|
|
83
|
+
let settled = false
|
|
84
|
+
const fail = (err) => {
|
|
85
|
+
if (settled) return
|
|
86
|
+
settled = true
|
|
87
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
88
|
+
throw guard.lastError ? new Error(guard.lastError) : err
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Proxy jump: SSH to the bastion first, then tunnel to the target through it.
|
|
92
|
+
let sock = null
|
|
93
|
+
const proxyCfg = this.config.proxy
|
|
94
|
+
if (proxyCfg && proxyCfg.host) {
|
|
95
|
+
try {
|
|
96
|
+
this.proxyPool = new SshPool({
|
|
97
|
+
...this.config,
|
|
98
|
+
host: proxyCfg.host,
|
|
99
|
+
port: Number(proxyCfg.port) || 22,
|
|
100
|
+
username: proxyCfg.username || this.config.username || 'root',
|
|
101
|
+
password: proxyCfg.password || '',
|
|
102
|
+
privateKeyPath: proxyCfg.privateKeyPath || '',
|
|
103
|
+
passphrase: proxyCfg.passphrase || '',
|
|
104
|
+
proxy: undefined,
|
|
105
|
+
}, { knownHostsFile: this._knownHostsFile })
|
|
106
|
+
const pclient = await this.proxyPool.connect()
|
|
107
|
+
if (!isCurrent()) throw new Error('ssh target changed during proxy connect')
|
|
108
|
+
sock = await new Promise((res, rej) => {
|
|
109
|
+
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)))
|
|
110
|
+
})
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return fail(err)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const rejectOnce = (err) => {
|
|
118
|
+
if (settled) return
|
|
119
|
+
settled = true
|
|
120
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
121
|
+
reject(guard.lastError ? new Error(guard.lastError) : err)
|
|
122
|
+
}
|
|
123
|
+
client.on('ready', () => {
|
|
124
|
+
if (settled) return
|
|
125
|
+
settled = true
|
|
126
|
+
if (!isCurrent()) {
|
|
127
|
+
try { client.end() } catch {}
|
|
128
|
+
reject(new Error('ssh target changed during connect'))
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
this.client = client
|
|
132
|
+
resolve(client)
|
|
133
|
+
if (this.onReady) { try { this.onReady(client) } catch {} }
|
|
134
|
+
})
|
|
135
|
+
client.on('error', (e) => rejectOnce(e))
|
|
136
|
+
client.on('close', () => {
|
|
137
|
+
if (isCurrent() && this.client === client) this.client = null
|
|
138
|
+
rejectOnce(new Error('ssh connection closed'))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const buildOpts = async () => {
|
|
142
|
+
const opts = {
|
|
143
|
+
host: this.config.host,
|
|
144
|
+
port: this.config.port,
|
|
145
|
+
username: this.config.username,
|
|
146
|
+
readyTimeout: this.config.connectTimeoutMs,
|
|
147
|
+
keepaliveInterval: 15000,
|
|
148
|
+
keepaliveCountMax: 3,
|
|
149
|
+
hostVerifier: (key) => guard.verifier(key),
|
|
150
|
+
}
|
|
151
|
+
if (sock) opts.sock = sock
|
|
152
|
+
if (this.config.useAgent) {
|
|
153
|
+
const sockPath = process.env.SSH_AUTH_SOCK
|
|
154
|
+
if (sockPath) opts.agent = sockPath
|
|
155
|
+
}
|
|
156
|
+
let password = this.config.password || ''
|
|
157
|
+
if (!password && this.passwordResolver) {
|
|
158
|
+
try { password = (await this.passwordResolver()) || '' } catch {}
|
|
159
|
+
}
|
|
160
|
+
if (password) {
|
|
161
|
+
opts.password = password
|
|
162
|
+
opts.tryKeyboard = true
|
|
163
|
+
} else if (this.config.keyboardInteractive && !this.config.privateKeyPath) {
|
|
164
|
+
opts.tryKeyboard = true
|
|
165
|
+
}
|
|
166
|
+
if (this.config.privateKeyPath) {
|
|
167
|
+
const keyPath = this.resolveKeyPath()
|
|
168
|
+
if (!keyPath) {
|
|
169
|
+
throw new Error('no credentials: set a password or a privateKeyPath to connect')
|
|
170
|
+
}
|
|
171
|
+
let key
|
|
172
|
+
try {
|
|
173
|
+
key = readFileSync(keyPath)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new Error(`cannot read private key "${keyPath}": ${err && err.message}`)
|
|
176
|
+
}
|
|
177
|
+
opts.privateKey = key
|
|
178
|
+
opts.passphrase = this.config.passphrase || undefined
|
|
179
|
+
} else if (!password && !opts.agent) {
|
|
180
|
+
throw new Error('no credentials: set a password, a privateKeyPath, or enable useAgent to connect')
|
|
181
|
+
}
|
|
182
|
+
return opts
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
buildOpts().then(
|
|
186
|
+
(opts) => {
|
|
187
|
+
if (opts.tryKeyboard) {
|
|
188
|
+
client.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
|
189
|
+
finish(prompts.map(() => this.config.password || ''))
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
client.connect(opts)
|
|
193
|
+
},
|
|
194
|
+
(err) => rejectOnce(err),
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Detect the remote platform + locate Git Bash once; cached per target. */
|
|
200
|
+
detect() {
|
|
201
|
+
if (this.platform !== 'unknown') return Promise.resolve()
|
|
202
|
+
if (this._detecting) return this._detecting
|
|
203
|
+
this._detecting = this._detect().finally(() => {
|
|
204
|
+
this._detecting = null
|
|
205
|
+
})
|
|
206
|
+
return this._detecting
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async _detect() {
|
|
210
|
+
let res
|
|
211
|
+
try {
|
|
212
|
+
// `cmd /c ver` works under cmd.exe, PowerShell AND Git Bash (all print
|
|
213
|
+
// "Microsoft Windows …"); on POSIX hosts `cmd` simply doesn't exist.
|
|
214
|
+
res = await this._execRaw('cmd /c ver', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
215
|
+
} catch (err) {
|
|
216
|
+
this.platform = 'unknown'
|
|
217
|
+
this.shellMode = 'native'
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
const out = String(res.stdout || '') + '\n' + String(res.stderr || '')
|
|
221
|
+
if (res.code === 0 && /microsoft windows/i.test(out)) {
|
|
222
|
+
this.platform = 'windows'
|
|
223
|
+
await this._resolveGitBash()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
// inconclusive — probe for a Git-Bash/MSYS remote (uname prints MINGW64_NT…)
|
|
227
|
+
try {
|
|
228
|
+
const u = await this._execRaw('uname -s', { timeoutMs: Math.min(this.config.commandTimeoutMs, 8000) })
|
|
229
|
+
if (/mingw|msys|cygwin/i.test(String(u.stdout || ''))) {
|
|
230
|
+
this.platform = 'windows'
|
|
231
|
+
await this._resolveGitBash()
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
} catch {}
|
|
235
|
+
this.platform = 'posix'
|
|
236
|
+
this.shellMode = 'native'
|
|
237
|
+
this.gitBashPath = ''
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** On a Windows remote, locate Git Bash (config path → PATH → common installs). */
|
|
241
|
+
async _resolveGitBash() {
|
|
242
|
+
const cfg = String(this.config.shell || '').trim()
|
|
243
|
+
if (cfg && cfg !== 'git-bash' && cfg !== 'native') {
|
|
244
|
+
if (await this._cmdExists(cfg)) {
|
|
245
|
+
this.gitBashPath = cfg
|
|
246
|
+
this.shellMode = 'git-bash'
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (cfg === 'native') {
|
|
251
|
+
this.shellMode = 'native'
|
|
252
|
+
this.gitBashPath = ''
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
const r = await this._execRaw('cmd /c where bash', { timeoutMs: 8000 })
|
|
257
|
+
const first = String(r.stdout || '').trim().split(/\r?\n/)[0].trim()
|
|
258
|
+
if (first) {
|
|
259
|
+
this.gitBashPath = first
|
|
260
|
+
this.shellMode = 'git-bash'
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
} catch {}
|
|
264
|
+
const candidates = [
|
|
265
|
+
'C:\\Program Files\\Git\\bin\\bash.exe',
|
|
266
|
+
'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
|
|
267
|
+
...(process.env.LOCALAPPDATA ? [process.env.LOCALAPPDATA + '\\Programs\\Git\\bin\\bash.exe'] : []),
|
|
268
|
+
'%LOCALAPPDATA%\\Programs\\Git\\bin\\bash.exe',
|
|
269
|
+
]
|
|
270
|
+
for (const c of candidates) {
|
|
271
|
+
if (await this._cmdExists(c)) {
|
|
272
|
+
this.gitBashPath = c
|
|
273
|
+
this.shellMode = 'git-bash'
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
this.shellMode = 'git-bash' // wanted but not found — commands fall back to raw
|
|
278
|
+
this.gitBashPath = ''
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async _cmdExists(p) {
|
|
282
|
+
try {
|
|
283
|
+
const r = await this._execRaw(`cmd /c if exist "${p}" (echo Y) else (echo N)`, { timeoutMs: 8000 })
|
|
284
|
+
return /Y/.test(String(r.stdout || ''))
|
|
285
|
+
} catch {
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Run one remote command; resolves { code, signal, stdout, stderr }.
|
|
291
|
+
* On Windows remotes with Git Bash the script is piped to `bash -s` over the
|
|
292
|
+
* exec-channel stdin — no shell quoting round-trip, so any content (quotes,
|
|
293
|
+
* backslashes, newlines) survives verbatim. */
|
|
294
|
+
exec(command, timeoutMsOrOpts) {
|
|
295
|
+
const opts = timeoutMsOrOpts && typeof timeoutMsOrOpts === 'object' ? timeoutMsOrOpts : { timeoutMs: timeoutMsOrOpts }
|
|
296
|
+
return this.detect().then(() => {
|
|
297
|
+
if (this.platform === 'windows' && this.gitBashPath) {
|
|
298
|
+
const script = String(command)
|
|
299
|
+
return this._execRaw(`"${this.gitBashPath}" -s`, { ...opts, timeoutMs: opts.timeoutMs || this.config.commandTimeoutMs }, (stream) => {
|
|
300
|
+
try { stream.end(script) } catch {}
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
return this._execRaw(command, opts)
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Raw exec (no detection / no wrapper). Optional stdinWriter(stream) feeds
|
|
308
|
+
* the remote process stdin (used by the Git Bash `-s` mode). */
|
|
309
|
+
_execRaw(command, opts, stdinWriter) {
|
|
310
|
+
const timeoutMs = (opts && opts.timeoutMs) || this.config.commandTimeoutMs
|
|
311
|
+
return this.connect().then(
|
|
312
|
+
(client) =>
|
|
313
|
+
new Promise((resolve, reject) => {
|
|
314
|
+
let retried = false
|
|
315
|
+
const runOn = (c) => {
|
|
316
|
+
const execOpts = {}
|
|
317
|
+
if (opts && opts.pty) execOpts.pty = true
|
|
318
|
+
if (opts && opts.env && typeof opts.env === 'object') execOpts.env = opts.env
|
|
319
|
+
c.exec(command, execOpts, (err, stream) => {
|
|
320
|
+
if (err) {
|
|
321
|
+
// Channel-open failure — or a session termination — usually
|
|
322
|
+
// means the pooled connection died server-side (idle timeout /
|
|
323
|
+
// network reset) while keepalive hadn't noticed. Drop it and
|
|
324
|
+
// retry ONCE on a fresh connection.
|
|
325
|
+
if (!retried && /channel open failure|open failed|unexpected .* session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
326
|
+
retried = true
|
|
327
|
+
this.invalidate()
|
|
328
|
+
return this.connect().then(
|
|
329
|
+
(fresh) => runOn(fresh),
|
|
330
|
+
(e2) => reject(new Error('ssh exec failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
return reject(new Error('ssh exec failed: ' + ((err && err.message) || err)))
|
|
334
|
+
}
|
|
335
|
+
let stdout = ''
|
|
336
|
+
let stderr = ''
|
|
337
|
+
let settled = false
|
|
338
|
+
let exitCode = null
|
|
339
|
+
let exitSignal = null
|
|
340
|
+
const hardCap = Math.max(this.config.maxOutputChars * 4, 1024 * 1024)
|
|
341
|
+
const settle = () => {
|
|
342
|
+
if (settled) return
|
|
343
|
+
settled = true
|
|
344
|
+
clearTimeout(timer)
|
|
345
|
+
resolve({
|
|
346
|
+
code: exitCode,
|
|
347
|
+
signal: exitSignal,
|
|
348
|
+
stdout: truncate(stdout, this.config.maxOutputChars),
|
|
349
|
+
stderr: truncate(stderr, this.config.maxOutputChars),
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
const timer = setTimeout(() => {
|
|
353
|
+
if (settled) return
|
|
354
|
+
exitCode = -1
|
|
355
|
+
exitSignal = 'TIMEOUT'
|
|
356
|
+
// Kill the remote command (SIGTERM) rather than just dropping the
|
|
357
|
+
// channel, so a runaway process cannot keep running and holding
|
|
358
|
+
// the SSH connection after we've given up on its output.
|
|
359
|
+
try {
|
|
360
|
+
if (typeof stream.signal === 'function') stream.signal('SIGTERM')
|
|
361
|
+
} catch {}
|
|
362
|
+
const hardClose = setTimeout(() => {
|
|
363
|
+
try { stream.close() } catch {}
|
|
364
|
+
}, 800)
|
|
365
|
+
if (typeof hardClose.unref === 'function') hardClose.unref()
|
|
366
|
+
settle()
|
|
367
|
+
}, timeoutMs)
|
|
368
|
+
stream.on('close', (code, signal) => {
|
|
369
|
+
if (settled) return
|
|
370
|
+
exitCode = code
|
|
371
|
+
exitSignal = signal
|
|
372
|
+
settle()
|
|
373
|
+
})
|
|
374
|
+
stream.on('data', (d) => {
|
|
375
|
+
if (stdout.length < hardCap) stdout += d
|
|
376
|
+
})
|
|
377
|
+
stream.stderr.on('data', (d) => {
|
|
378
|
+
if (stderr.length < hardCap) stderr += d
|
|
379
|
+
})
|
|
380
|
+
stream.on('error', (e) => {
|
|
381
|
+
if (settled) return
|
|
382
|
+
settled = true
|
|
383
|
+
clearTimeout(timer)
|
|
384
|
+
reject(new Error('ssh stream error: ' + ((e && e.message) || e)))
|
|
385
|
+
})
|
|
386
|
+
if (stdinWriter) {
|
|
387
|
+
try { stdinWriter(stream) } catch {}
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
runOn(client)
|
|
392
|
+
}),
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Resolve a promisified SFTP client. All paths normalized via toSftpPath(). */
|
|
397
|
+
sftp() {
|
|
398
|
+
return this.connect().then(
|
|
399
|
+
(client) =>
|
|
400
|
+
new Promise((resolve, reject) => {
|
|
401
|
+
let retried = false
|
|
402
|
+
const runOn = (c) => {
|
|
403
|
+
c.sftp((err, sftp) => {
|
|
404
|
+
if (err) {
|
|
405
|
+
// Same dead-connection recovery as exec(): a channel open
|
|
406
|
+
// failure — or a session termination (remote closed the
|
|
407
|
+
// SFTP subchannel, e.g. transient network blip / sshd idle
|
|
408
|
+
// drop) — means the pooled connection is stale: drop it and
|
|
409
|
+
// retry ONCE on a fresh connection.
|
|
410
|
+
if (!retried && /channel open failure|open failed|unexpected sftp session termination|session termination|disconnect/i.test(String((err && err.message) || err))) {
|
|
411
|
+
retried = true
|
|
412
|
+
this.invalidate()
|
|
413
|
+
return this.connect().then(
|
|
414
|
+
(fresh) => runOn(fresh),
|
|
415
|
+
(e2) => reject(new Error('ssh sftp failed (reconnect): ' + ((e2 && e2.message) || e2))),
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
return reject(new Error('ssh sftp failed: ' + ((err && err.message) || err)))
|
|
419
|
+
}
|
|
420
|
+
const withTimeout = (fn) => (...args) =>
|
|
421
|
+
new Promise((r2, j2) => {
|
|
422
|
+
const timer = setTimeout(() => j2(new Error('sftp operation timed out')), this.config.commandTimeoutMs)
|
|
423
|
+
const done = (e, v) => {
|
|
424
|
+
clearTimeout(timer)
|
|
425
|
+
e ? j2(e) : r2(v)
|
|
426
|
+
}
|
|
427
|
+
try { fn(...args, done) } catch (e) { clearTimeout(timer); j2(e) }
|
|
428
|
+
})
|
|
429
|
+
const P = (p) => toSftpPath(p)
|
|
430
|
+
resolve({
|
|
431
|
+
readdir: (dir) => withTimeout((d, cb) => sftp.readdir(d, cb))(P(dir)),
|
|
432
|
+
stat: (p) => withTimeout((d, cb) => sftp.stat(d, cb))(P(p)),
|
|
433
|
+
lstat: (p) => withTimeout((d, cb) => sftp.lstat(d, cb))(P(p)),
|
|
434
|
+
mkdir: (dir) => withTimeout((d, cb) => sftp.mkdir(d, cb))(P(dir)),
|
|
435
|
+
rmdir: (dir) => withTimeout((d, cb) => sftp.rmdir(d, cb))(P(dir)),
|
|
436
|
+
unlink: (p) => withTimeout((d, cb) => sftp.unlink(d, cb))(P(p)),
|
|
437
|
+
rename: (p, d) => withTimeout((a, b, cb) => sftp.rename(a, b, cb))(P(p), P(d)),
|
|
438
|
+
realpath: (p) => withTimeout((d, cb) => sftp.realpath(d, cb))(P(p)),
|
|
439
|
+
readFile: (p) => withTimeout((d, cb) => sftp.readFile(d, cb))(P(p)),
|
|
440
|
+
writeFile: (p, data) => withTimeout((d, data2, cb) => sftp.writeFile(d, data2, cb))(P(p), data),
|
|
441
|
+
fastGet: (p, lp) => withTimeout((d, l, cb) => sftp.fastGet(d, l, cb))(P(p), lp),
|
|
442
|
+
fastPut: (lp, p) => withTimeout((l, d, cb) => sftp.fastPut(l, d, cb))(lp, P(p)),
|
|
443
|
+
// Range read for oversized previews: never downloads the whole file.
|
|
444
|
+
readPartial: (p, offset, length) => withTimeout((d, off, len, cb) => {
|
|
445
|
+
sftp.open(d, 'r', (e, handle) => {
|
|
446
|
+
if (e) return cb(e)
|
|
447
|
+
const buf = Buffer.alloc(len)
|
|
448
|
+
sftp.read(handle, buf, 0, len, off, (e2, bytesRead, data) => {
|
|
449
|
+
sftp.close(handle, () => {})
|
|
450
|
+
if (e2) return cb(e2)
|
|
451
|
+
cb(null, Buffer.isBuffer(data) ? data.subarray(0, bytesRead) : buf.subarray(0, bytesRead))
|
|
452
|
+
})
|
|
453
|
+
})
|
|
454
|
+
})(P(p), offset, length),
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
runOn(client)
|
|
459
|
+
}),
|
|
460
|
+
)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Drop the cached client and force a fresh connection on the next call.
|
|
465
|
+
* Called when a channel open fails (e.g. "Channel open failure: open
|
|
466
|
+
* failed") — the pooled SSH connection is usually dead server-side while
|
|
467
|
+
* keepalive has not yet noticed, and reusing it keeps failing. The epoch
|
|
468
|
+
* bump orphans any in-flight connect; the client is ended so ssh2 frees
|
|
469
|
+
* its sockets.
|
|
470
|
+
*/
|
|
471
|
+
invalidate() {
|
|
472
|
+
this.epoch++
|
|
473
|
+
const client = this.client
|
|
474
|
+
this.client = null
|
|
475
|
+
const pending = this.connecting
|
|
476
|
+
this.connecting = null
|
|
477
|
+
if (pending && typeof pending.catch === 'function') {
|
|
478
|
+
try { pending.catch(() => {}) } catch {}
|
|
479
|
+
}
|
|
480
|
+
if (this.proxyPool) {
|
|
481
|
+
try { this.proxyPool.close() } catch {}
|
|
482
|
+
this.proxyPool = null
|
|
483
|
+
}
|
|
484
|
+
if (client) {
|
|
485
|
+
try { client.end() } catch {}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
close() {
|
|
490
|
+
this.epoch++
|
|
491
|
+
const client = this.client
|
|
492
|
+
this.client = null
|
|
493
|
+
const pending = this.connecting
|
|
494
|
+
this.connecting = null
|
|
495
|
+
if (pending && typeof pending.catch === 'function') {
|
|
496
|
+
try { pending.catch(() => {}) } catch {}
|
|
497
|
+
}
|
|
498
|
+
if (this.proxyPool) {
|
|
499
|
+
try { this.proxyPool.close() } catch {}
|
|
500
|
+
this.proxyPool = null
|
|
501
|
+
}
|
|
502
|
+
if (client) {
|
|
503
|
+
try {
|
|
504
|
+
client.end()
|
|
505
|
+
} catch {}
|
|
506
|
+
}
|
|
507
|
+
if (this.onCloseHook) {
|
|
508
|
+
try { this.onCloseHook() } catch {}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
package/lib/remote-fs.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Shared remote SFTP helpers used by both rw_* tools and the sidebar HTTP routes.
|
|
2
|
+
import { joinRemotePath, normalizeRemotePath } from './paths.js'
|
|
3
|
+
|
|
4
|
+
/** Structured listing: name + type + size + mtime + mode (SFTP protocol-level). */
|
|
5
|
+
export async function listDirStructured(sftp, p) {
|
|
6
|
+
const target = normalizeRemotePath(p || '/')
|
|
7
|
+
let list
|
|
8
|
+
try {
|
|
9
|
+
list = await sftp.readdir(target)
|
|
10
|
+
} catch (err) {
|
|
11
|
+
const msg = String((err && err.message) || err)
|
|
12
|
+
if (/no such file|not found|does not exist|ENOENT/i.test(msg)) {
|
|
13
|
+
return { path: target, items: [], missing: true }
|
|
14
|
+
}
|
|
15
|
+
throw new Error('browse failed: ' + msg)
|
|
16
|
+
}
|
|
17
|
+
const items = []
|
|
18
|
+
const symIdx = []
|
|
19
|
+
for (const e of list) {
|
|
20
|
+
const name = String(e.filename)
|
|
21
|
+
if (name === '.' || name === '..' || !name) continue
|
|
22
|
+
const a = e.attrs || {}
|
|
23
|
+
let type
|
|
24
|
+
if (a.isSymbolicLink && a.isSymbolicLink()) {
|
|
25
|
+
type = 'symlink'
|
|
26
|
+
symIdx.push(items.length)
|
|
27
|
+
} else if (a.isDirectory && a.isDirectory()) {
|
|
28
|
+
type = 'dir'
|
|
29
|
+
} else {
|
|
30
|
+
type = 'file'
|
|
31
|
+
}
|
|
32
|
+
items.push({
|
|
33
|
+
type,
|
|
34
|
+
name,
|
|
35
|
+
size: typeof a.size === 'number' ? a.size : 0,
|
|
36
|
+
mtime: typeof a.mtime === 'number' ? a.mtime : 0,
|
|
37
|
+
mode: typeof a.mode === 'number' ? a.mode.toString(8) : '',
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
if (symIdx.length) {
|
|
41
|
+
await Promise.all(symIdx.map(async (i) => {
|
|
42
|
+
const full = joinRemotePath(target, items[i].name)
|
|
43
|
+
try {
|
|
44
|
+
const st = await sftp.lstat(full)
|
|
45
|
+
items[i].type = st && st.isDirectory && st.isDirectory() ? 'dir' : 'file'
|
|
46
|
+
} catch { /* degrade to file */ }
|
|
47
|
+
}))
|
|
48
|
+
}
|
|
49
|
+
return { path: target, items }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Windows drive letters as display-form dir entries ("This PC" root view). */
|
|
53
|
+
export async function listRemoteDrives(sshPool) {
|
|
54
|
+
let letters = []
|
|
55
|
+
try {
|
|
56
|
+
const res = await sshPool.exec('cmd /c fsutil fsinfo drives', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
|
|
57
|
+
letters = [...String(res.stdout || '').matchAll(/([a-zA-Z]):\\?/g)].map((mm) => mm[1].toUpperCase())
|
|
58
|
+
} catch {}
|
|
59
|
+
if (!letters.length) {
|
|
60
|
+
try {
|
|
61
|
+
const res = await sshPool.exec('ls -d /[a-z] 2>/dev/null', { timeoutMs: Math.min(sshPool.config.commandTimeoutMs || 8000, 8000) })
|
|
62
|
+
letters = String(res.stdout || '')
|
|
63
|
+
.split(/\s+/)
|
|
64
|
+
.map((s) => s.replace(/^\/+|\/+$/g, ''))
|
|
65
|
+
.filter((s) => /^[a-zA-Z]$/.test(s))
|
|
66
|
+
.map((s) => s.toUpperCase())
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
return [...new Set(letters)].sort().map((l) => ({ name: l + ':\\', path: l + ':\\', type: 'dir', drive: true, size: 0, mtime: 0 }))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Recursive remote delete (bounded): unlink files bottom-up, then rmdir. */
|
|
73
|
+
export async function removeRemoteTree(sftp, p, maxFiles = 2000) {
|
|
74
|
+
let removed = 0
|
|
75
|
+
const walk = async (dir) => {
|
|
76
|
+
if (removed >= maxFiles) return
|
|
77
|
+
let entries = []
|
|
78
|
+
try { entries = (await sftp.readdir(dir)) || [] } catch { return }
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
if (removed >= maxFiles) return
|
|
81
|
+
const name = String(e.filename)
|
|
82
|
+
if (name === '.' || name === '..') continue
|
|
83
|
+
const fp = joinRemotePath(dir, name)
|
|
84
|
+
const isDir = !!(e.attrs && e.attrs.isDirectory && e.attrs.isDirectory())
|
|
85
|
+
if (isDir) await walk(fp)
|
|
86
|
+
else {
|
|
87
|
+
try { await sftp.unlink(fp); removed++ } catch {}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
try { await sftp.rmdir(dir); removed++ } catch { /* already gone or non-empty */ }
|
|
91
|
+
}
|
|
92
|
+
await walk(p)
|
|
93
|
+
return removed
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function looksBinary(buf) {
|
|
97
|
+
const n = Math.min(buf.length, 8192)
|
|
98
|
+
for (let i = 0; i < n; i++) if (buf[i] === 0) return true
|
|
99
|
+
return false
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read a remote file for the sidebar/editor. Oversized files use a range read
|
|
104
|
+
* (`readPartial`) so we never `fastGet` the whole blob onto disk.
|
|
105
|
+
*/
|
|
106
|
+
export async function previewRemoteFile(sftp, target, opts = {}) {
|
|
107
|
+
const {
|
|
108
|
+
maxBytes = 256 * 1024,
|
|
109
|
+
encoding = 'utf-8',
|
|
110
|
+
decodeBuf = (buf) => buf.toString('utf8'),
|
|
111
|
+
} = opts
|
|
112
|
+
const cap = Math.min(Math.max(Number(maxBytes) || 256 * 1024, 1024), 2 * 1024 * 1024)
|
|
113
|
+
const st = await sftp.stat(target)
|
|
114
|
+
const mtime = st && typeof st.mtime === 'number' ? st.mtime : 0
|
|
115
|
+
const size = st && typeof st.size === 'number' ? st.size : 0
|
|
116
|
+
let buf
|
|
117
|
+
let truncated = false
|
|
118
|
+
if (size > cap) {
|
|
119
|
+
truncated = true
|
|
120
|
+
if (typeof sftp.readPartial === 'function') {
|
|
121
|
+
buf = await sftp.readPartial(target, 0, cap)
|
|
122
|
+
} else {
|
|
123
|
+
const full = await sftp.readFile(target)
|
|
124
|
+
buf = full.subarray(0, cap)
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
buf = await sftp.readFile(target)
|
|
128
|
+
}
|
|
129
|
+
if (looksBinary(buf)) {
|
|
130
|
+
return {
|
|
131
|
+
ok: true, binary: true, size, mtime,
|
|
132
|
+
head: buf.slice(0, Math.min(buf.length, 4096)).toString('base64'),
|
|
133
|
+
truncated,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
let content = decodeBuf(buf, encoding).replace(/\r\n/g, '\n')
|
|
137
|
+
if (!truncated && content.length > cap) {
|
|
138
|
+
truncated = true
|
|
139
|
+
content = content.slice(0, cap)
|
|
140
|
+
}
|
|
141
|
+
if (truncated) content += `\n…[truncated: ${Math.max(0, size - cap)} more bytes]`
|
|
142
|
+
return { ok: true, binary: false, content, truncated, size, mtime }
|
|
143
|
+
}
|