@linxin666/dsh-ssh 0.1.13 → 0.1.15
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/client.js +26 -4
- package/lib/client.js.map +1 -1
- package/lib/index.js +660 -598
- package/lib/types/client/panel/TunnelsTab.d.ts +10 -0
- package/lib/types/engine/cluster.d.ts +17 -0
- package/lib/types/engine/connection-pool.d.ts +81 -0
- package/lib/types/engine/pty.d.ts +32 -0
- package/lib/types/engine/sftp.d.ts +20 -0
- package/lib/types/engine/tunnel.d.ts +32 -0
- package/lib/types/engine.d.ts +18 -79
- package/package.json +1 -1
- package/src/client/panel/TerminalTab.tsx +10 -3
- package/src/client/panel/TunnelsTab.tsx +31 -4
- package/src/engine/cluster.ts +51 -0
- package/src/engine/connection-pool.ts +307 -0
- package/src/engine/pty.ts +74 -0
- package/src/engine/sftp.ts +235 -0
- package/src/engine/tunnel.ts +125 -0
- package/src/engine.ts +45 -687
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection pool: per-alias persistent ssh2 connections with multi-hop jump
|
|
3
|
+
* support, the acquire / dispose / sweep lifecycle, and the pooled exec path.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { Client, type ConnectConfig } from 'ssh2'
|
|
8
|
+
import type { ExecResult, SshHostEntry } from '../protocol.ts'
|
|
9
|
+
import { expandHome, type HostStore } from '../store.ts'
|
|
10
|
+
|
|
11
|
+
/** Default engine knobs. */
|
|
12
|
+
export interface EngineOptions {
|
|
13
|
+
/** Connections idle longer than this are closed (ms). */
|
|
14
|
+
idleTimeoutMs?: number
|
|
15
|
+
/** SSH handshake timeout (ms). */
|
|
16
|
+
connectTimeoutMs?: number
|
|
17
|
+
/** Keepalive ping interval (ms). */
|
|
18
|
+
keepaliveIntervalMs?: number
|
|
19
|
+
/** Cap on captured stdout/stderr bytes per exec (ms). */
|
|
20
|
+
maxOutputBytes?: number
|
|
21
|
+
/** Default exec timeout (ms). */
|
|
22
|
+
defaultExecTimeoutMs?: number
|
|
23
|
+
/** Default cluster concurrency. */
|
|
24
|
+
defaultMaxWorkers?: number
|
|
25
|
+
/** SFTP concurrent channel count for transfers. */
|
|
26
|
+
sftpConcurrency?: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Default engine knobs (applied when an option is omitted). */
|
|
30
|
+
export const DEFAULTS: Required<EngineOptions> = {
|
|
31
|
+
idleTimeoutMs: 30 * 60_000,
|
|
32
|
+
connectTimeoutMs: 15_000,
|
|
33
|
+
keepaliveIntervalMs: 15_000,
|
|
34
|
+
maxOutputBytes: 2 * 1024 * 1024,
|
|
35
|
+
defaultExecTimeoutMs: 60_000,
|
|
36
|
+
defaultMaxWorkers: 8,
|
|
37
|
+
sftpConcurrency: 8,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** One pooled connection record. */
|
|
41
|
+
export interface PoolRecord {
|
|
42
|
+
client: Client
|
|
43
|
+
/** Jump-chain clients kept alive under the target. */
|
|
44
|
+
hops: Client[]
|
|
45
|
+
idleAt: number
|
|
46
|
+
/** Pinned connections (tunnels) are never swept. */
|
|
47
|
+
pinned: boolean
|
|
48
|
+
broken: boolean
|
|
49
|
+
/** Operations currently running on this connection (sweep guard). */
|
|
50
|
+
inFlight: number
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The slice of the engine the pool and exec paths need. The host class
|
|
55
|
+
* (engine.ts facade) satisfies this structurally.
|
|
56
|
+
*/
|
|
57
|
+
export interface PoolEngine {
|
|
58
|
+
readonly store: HostStore
|
|
59
|
+
readonly opts: Required<EngineOptions>
|
|
60
|
+
readonly pool: Map<string, PoolRecord>
|
|
61
|
+
readonly acquireQueue: Map<string, Promise<PoolRecord>>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
65
|
+
export function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig {
|
|
66
|
+
const config: ConnectConfig = {
|
|
67
|
+
host: entry.host,
|
|
68
|
+
port: entry.port,
|
|
69
|
+
username: entry.user,
|
|
70
|
+
readyTimeout: 15_000,
|
|
71
|
+
keepaliveInterval: 15_000,
|
|
72
|
+
keepaliveCountMax: 3,
|
|
73
|
+
}
|
|
74
|
+
if (sock !== undefined) config.sock = sock
|
|
75
|
+
if (entry.auth.kind === 'password') {
|
|
76
|
+
config.password = entry.auth.password
|
|
77
|
+
} else {
|
|
78
|
+
const keyPath = entry.auth.keyPath === undefined ? undefined : expandHome(entry.auth.keyPath)
|
|
79
|
+
if (keyPath === undefined || !existsSync(keyPath)) {
|
|
80
|
+
throw new Error('private key not found: ' + (entry.auth.keyPath ?? '(unset)'))
|
|
81
|
+
}
|
|
82
|
+
config.privateKey = readFileSync(keyPath, 'utf8')
|
|
83
|
+
if (entry.auth.passphrase !== undefined && entry.auth.passphrase !== '') {
|
|
84
|
+
config.passphrase = entry.auth.passphrase
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return config
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
91
|
+
function connectClient(config: ConnectConfig): Promise<Client> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const client = new Client()
|
|
94
|
+
let settled = false
|
|
95
|
+
client.once('ready', () => {
|
|
96
|
+
if (settled) return
|
|
97
|
+
settled = true
|
|
98
|
+
resolve(client)
|
|
99
|
+
})
|
|
100
|
+
client.once('error', (error) => {
|
|
101
|
+
if (settled) return
|
|
102
|
+
settled = true
|
|
103
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
104
|
+
})
|
|
105
|
+
try {
|
|
106
|
+
client.connect(config)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!settled) {
|
|
109
|
+
settled = true
|
|
110
|
+
reject(error instanceof Error ? error : new Error(String(error)))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Cap captured output at the configured byte budget (marks truncation). */
|
|
117
|
+
export function appendOutput(target: { text: string; truncated: boolean }, chunk: Buffer, maxBytes: number): void {
|
|
118
|
+
if (target.truncated) return
|
|
119
|
+
if (target.text.length + chunk.length > maxBytes) {
|
|
120
|
+
let cut = chunk.toString('utf8').slice(0, maxBytes - target.text.length)
|
|
121
|
+
// Never split a surrogate pair at the cut boundary.
|
|
122
|
+
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1)
|
|
123
|
+
target.text += cut + '…[output truncated]'
|
|
124
|
+
target.truncated = true
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
target.text += chunk.toString('utf8')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build one full jump chain for an entry: hop clients connected through in
|
|
132
|
+
* order, each forwarding a stream to the next destination, ending with the
|
|
133
|
+
* target client. Shared by the pool and standalone shell sessions.
|
|
134
|
+
*/
|
|
135
|
+
export async function connectChain(engine: PoolEngine, entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
|
|
136
|
+
const hops: Client[] = []
|
|
137
|
+
let sock: ConnectConfig['sock']
|
|
138
|
+
const chain = entry.proxyJump
|
|
139
|
+
for (let index = 0; index < chain.length; index += 1) {
|
|
140
|
+
const hopAlias = chain[index]
|
|
141
|
+
const hop = engine.store.find(hopAlias)
|
|
142
|
+
if (hop === undefined) {
|
|
143
|
+
for (const client of hops) client.end()
|
|
144
|
+
throw new Error('proxyJump alias \'' + hopAlias + '\' not found — create it first')
|
|
145
|
+
}
|
|
146
|
+
const hopClient = await connectClient(buildConnectConfig(hop, sock))
|
|
147
|
+
hops.push(hopClient)
|
|
148
|
+
const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : undefined
|
|
149
|
+
const nextHost = next !== undefined ? next.host : entry.host
|
|
150
|
+
const nextPort = next !== undefined ? next.port : entry.port
|
|
151
|
+
sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
|
|
152
|
+
hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
|
|
153
|
+
if (error !== undefined) {
|
|
154
|
+
for (const client of hops) client.end()
|
|
155
|
+
reject(error)
|
|
156
|
+
} else {
|
|
157
|
+
resolve(stream)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const client = await connectClient(buildConnectConfig(entry, sock))
|
|
164
|
+
return { client, hops }
|
|
165
|
+
} catch (error) {
|
|
166
|
+
for (const client of hops) client.end()
|
|
167
|
+
throw error
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Connect (or reuse) the pooled chain for one alias; pins nothing. */
|
|
172
|
+
export async function acquire(engine: PoolEngine, alias: string): Promise<PoolRecord> {
|
|
173
|
+
const pending = engine.acquireQueue.get(alias)
|
|
174
|
+
if (pending !== undefined) return pending
|
|
175
|
+
const task = doAcquire(engine, alias)
|
|
176
|
+
engine.acquireQueue.set(alias, task)
|
|
177
|
+
try {
|
|
178
|
+
return await task
|
|
179
|
+
} finally {
|
|
180
|
+
if (engine.acquireQueue.get(alias) === task) engine.acquireQueue.delete(alias)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function doAcquire(engine: PoolEngine, alias: string): Promise<PoolRecord> {
|
|
185
|
+
const entry = engine.store.find(alias)
|
|
186
|
+
if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
|
|
187
|
+
const { client, hops } = await connectChain(engine, entry)
|
|
188
|
+
const record: PoolRecord = { client, hops, idleAt: Date.now(), pinned: false, broken: false, inFlight: 0 }
|
|
189
|
+
client.on('error', () => { record.broken = true })
|
|
190
|
+
client.on('close', () => { record.broken = true })
|
|
191
|
+
engine.pool.set(alias, record)
|
|
192
|
+
return record
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Tear down one alias's record. When `record` is given and no longer the
|
|
197
|
+
* pooled record for the alias (a concurrent acquire replaced it), nothing
|
|
198
|
+
* is torn down — the connection belongs to someone else now.
|
|
199
|
+
*/
|
|
200
|
+
export function disposeRecord(engine: PoolEngine, alias: string, record?: PoolRecord): void {
|
|
201
|
+
const current = engine.pool.get(alias)
|
|
202
|
+
if (record !== undefined && current !== record) return
|
|
203
|
+
if (current === undefined) return
|
|
204
|
+
engine.pool.delete(alias)
|
|
205
|
+
try { current.client.end() } catch { /* already closed */ }
|
|
206
|
+
for (const hop of current.hops) {
|
|
207
|
+
try { hop.end() } catch { /* already closed */ }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
212
|
+
export function sweepPool(engine: PoolEngine): void {
|
|
213
|
+
const cutoff = Date.now() - engine.opts.idleTimeoutMs
|
|
214
|
+
for (const [alias, record] of engine.pool) {
|
|
215
|
+
if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) {
|
|
216
|
+
disposeRecord(engine, alias, record)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run `fn` with a live client for `alias`, reconnecting (up to the
|
|
223
|
+
* attempt budget) when the connection broke mid-flight.
|
|
224
|
+
*/
|
|
225
|
+
export async function withClient<T>(engine: PoolEngine, alias: string, fn: (client: Client) => Promise<T>, attempts = 3): Promise<T> {
|
|
226
|
+
let lastError: unknown
|
|
227
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
228
|
+
let record = engine.pool.get(alias)
|
|
229
|
+
if (record === undefined || record.broken) {
|
|
230
|
+
if (record !== undefined) disposeRecord(engine, alias, record)
|
|
231
|
+
record = await acquire(engine, alias)
|
|
232
|
+
}
|
|
233
|
+
record.idleAt = Date.now()
|
|
234
|
+
record.inFlight += 1
|
|
235
|
+
try {
|
|
236
|
+
const result = await fn(record.client)
|
|
237
|
+
record.idleAt = Date.now()
|
|
238
|
+
return result
|
|
239
|
+
} finally {
|
|
240
|
+
record.inFlight -= 1
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Run one command on `alias` (reusing the pooled connection). */
|
|
247
|
+
export async function execCommand(engine: PoolEngine, alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
|
|
248
|
+
const started = Date.now()
|
|
249
|
+
const budget = timeoutMs !== undefined && timeoutMs > 0 ? timeoutMs : engine.opts.defaultExecTimeoutMs
|
|
250
|
+
return withClient(engine, alias, async (client) => {
|
|
251
|
+
return await new Promise<ExecResult>((resolve, reject) => {
|
|
252
|
+
client.exec(command, (error, stream) => {
|
|
253
|
+
if (error !== undefined) {
|
|
254
|
+
reject(error)
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
const stdout = { text: '', truncated: false }
|
|
258
|
+
const stderr = { text: '', truncated: false }
|
|
259
|
+
let timedOut = false
|
|
260
|
+
let settled = false
|
|
261
|
+
const finish = (): void => {
|
|
262
|
+
if (settled) return
|
|
263
|
+
settled = true
|
|
264
|
+
clearTimeout(timer)
|
|
265
|
+
resolve({
|
|
266
|
+
success: false,
|
|
267
|
+
exitCode: null,
|
|
268
|
+
timedOut,
|
|
269
|
+
stdout: stdout.text,
|
|
270
|
+
stderr: stderr.text,
|
|
271
|
+
durationMs: Date.now() - started,
|
|
272
|
+
error: timedOut ? 'command timed out after ' + budget + ' ms' : undefined,
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
const timer = setTimeout(() => {
|
|
276
|
+
timedOut = true
|
|
277
|
+
try { stream.signal('KILL') } catch { /* channel gone */ }
|
|
278
|
+
try { stream.close() } catch { /* channel gone */ }
|
|
279
|
+
// Hard deadline: settle now even if the peer never acks the
|
|
280
|
+
// channel close (the stream 'close' handler is then a no-op).
|
|
281
|
+
finish()
|
|
282
|
+
}, budget)
|
|
283
|
+
stream.on('data', (chunk: Buffer) => appendOutput(stdout, chunk, engine.opts.maxOutputBytes))
|
|
284
|
+
stream.stderr.on('data', (chunk: Buffer) => appendOutput(stderr, chunk, engine.opts.maxOutputBytes))
|
|
285
|
+
stream.on('close', (code: number | null) => {
|
|
286
|
+
if (settled) return
|
|
287
|
+
settled = true
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
resolve({
|
|
290
|
+
success: code === 0 && !timedOut,
|
|
291
|
+
exitCode: code,
|
|
292
|
+
timedOut,
|
|
293
|
+
stdout: stdout.text,
|
|
294
|
+
stderr: stderr.text,
|
|
295
|
+
durationMs: Date.now() - started,
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
stream.on('error', (streamError: Error) => {
|
|
299
|
+
if (settled) return
|
|
300
|
+
settled = true
|
|
301
|
+
clearTimeout(timer)
|
|
302
|
+
reject(streamError)
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY shell sessions for the web terminal: a standalone (non-pooled)
|
|
3
|
+
* ssh2 connection with a long-lived shell channel, resize, and pausable
|
|
4
|
+
* output delivery.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Client } from 'ssh2'
|
|
8
|
+
import { connectChain, type PoolEngine } from './connection-pool.ts'
|
|
9
|
+
|
|
10
|
+
/** A live PTY shell session. */
|
|
11
|
+
export interface ShellSession {
|
|
12
|
+
/** Assign to receive remote output. */
|
|
13
|
+
onData?: (data: Buffer) => void
|
|
14
|
+
/** Assign to be notified when the channel closes. */
|
|
15
|
+
onExit?: (code: number | null, error?: string) => void
|
|
16
|
+
/** Write raw input to the shell. */
|
|
17
|
+
send(data: string): void
|
|
18
|
+
/** Resize the remote PTY. */
|
|
19
|
+
resize(cols: number, rows: number): void
|
|
20
|
+
/** Close the session and its channel. */
|
|
21
|
+
close(): void
|
|
22
|
+
/** Pause remote output delivery (transport backpressure). */
|
|
23
|
+
pause(): void
|
|
24
|
+
/** Resume remote output delivery. */
|
|
25
|
+
resume(): void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Open a PTY shell session for the web terminal (standalone connection).
|
|
30
|
+
* The shell is a long-lived exclusive stream: it uses its own connection so
|
|
31
|
+
* closing it can never tear down a pooled exec/tunnel sharing the alias.
|
|
32
|
+
*/
|
|
33
|
+
export async function openShell(engine: PoolEngine, alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
|
|
34
|
+
const entry = engine.store.find(alias)
|
|
35
|
+
if (entry === undefined) throw new Error('alias \'' + alias + '\' not found — add it first')
|
|
36
|
+
const { client, hops } = await connectChain(engine, entry)
|
|
37
|
+
return await new Promise<ShellSession>((resolve, reject) => {
|
|
38
|
+
client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (error, stream) => {
|
|
39
|
+
if (error !== undefined) {
|
|
40
|
+
try { client.end() } catch { /* closed */ }
|
|
41
|
+
for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
|
|
42
|
+
reject(error)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
let tornDown = false
|
|
46
|
+
const teardown = (): void => {
|
|
47
|
+
if (tornDown) return
|
|
48
|
+
tornDown = true
|
|
49
|
+
try { client.end() } catch { /* closed */ }
|
|
50
|
+
for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
|
|
51
|
+
}
|
|
52
|
+
const session: ShellSession = {
|
|
53
|
+
send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
|
|
54
|
+
resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
|
|
55
|
+
close: () => {
|
|
56
|
+
try { stream.close() } catch { /* channel gone */ }
|
|
57
|
+
teardown()
|
|
58
|
+
},
|
|
59
|
+
pause: () => { try { stream.pause() } catch { /* channel gone */ } },
|
|
60
|
+
resume: () => { try { stream.resume() } catch { /* channel gone */ } },
|
|
61
|
+
}
|
|
62
|
+
stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
|
|
63
|
+
stream.on('close', (code: number | null) => {
|
|
64
|
+
teardown()
|
|
65
|
+
session.onExit?.(code)
|
|
66
|
+
})
|
|
67
|
+
stream.on('error', (streamError: Error) => {
|
|
68
|
+
teardown()
|
|
69
|
+
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
|
|
70
|
+
})
|
|
71
|
+
resolve(session)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SFTP transfers: upload (file or recursive tree), single-file download, and
|
|
3
|
+
* remote directory listing. Every channel is opened once per operation and
|
|
4
|
+
* released exactly once so sshd's MaxSessions cap is never exhausted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
8
|
+
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
9
|
+
import { Client, type SFTPWrapper } from 'ssh2'
|
|
10
|
+
import type { RemoteDirEntry, TransferProgress } from '../protocol.ts'
|
|
11
|
+
import { withClient, type PoolEngine } from './connection-pool.ts'
|
|
12
|
+
|
|
13
|
+
/** Walk a local directory, collecting relative paths of every file. */
|
|
14
|
+
export function walkLocalDir(root: string): string[] {
|
|
15
|
+
const files: string[] = []
|
|
16
|
+
const visit = (dir: string): void => {
|
|
17
|
+
for (const name of readdirSync(dir)) {
|
|
18
|
+
const full = join(dir, name)
|
|
19
|
+
const stat = statSync(full)
|
|
20
|
+
if (stat.isDirectory()) visit(full)
|
|
21
|
+
else if (stat.isFile()) files.push(relative(root, full))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
visit(root)
|
|
25
|
+
return files
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Upload one local file (or directory tree) to a remote path. */
|
|
29
|
+
export async function upload(
|
|
30
|
+
engine: PoolEngine,
|
|
31
|
+
alias: string,
|
|
32
|
+
localPath: string,
|
|
33
|
+
remotePath: string,
|
|
34
|
+
recursive: boolean,
|
|
35
|
+
onProgress?: (progress: TransferProgress) => void,
|
|
36
|
+
): Promise<{ bytes: number; files: number }> {
|
|
37
|
+
// Remote paths must be absolute: the mkdir chain and fastPut must agree
|
|
38
|
+
// on one resolution (relative paths previously created dirs at the root).
|
|
39
|
+
if (!remotePath.startsWith('/')) {
|
|
40
|
+
throw new Error('remotePath must be an absolute path (got \'' + remotePath + '\')')
|
|
41
|
+
}
|
|
42
|
+
const local = resolvePath(localPath)
|
|
43
|
+
if (!existsSync(local)) throw new Error('local path not found: \'' + localPath + '\'')
|
|
44
|
+
return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
|
|
45
|
+
const stat = statSync(local)
|
|
46
|
+
let files: string[]
|
|
47
|
+
if (stat.isDirectory()) {
|
|
48
|
+
if (!recursive) throw new Error('\'' + localPath + '\' is a directory — enable recursive upload')
|
|
49
|
+
files = walkLocalDir(local)
|
|
50
|
+
await ensureRemoteDir(sftp, remotePath)
|
|
51
|
+
} else {
|
|
52
|
+
files = ['']
|
|
53
|
+
await ensureRemoteDir(sftp, dirname(remotePath))
|
|
54
|
+
}
|
|
55
|
+
let bytes = 0
|
|
56
|
+
for (const rel of files) {
|
|
57
|
+
const src = rel === '' ? local : join(local, rel)
|
|
58
|
+
// Remote paths always use forward slashes; normalize any OS separators.
|
|
59
|
+
const remoteRel = rel.split(/[\\/]/).join('/')
|
|
60
|
+
const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
|
|
61
|
+
await fastPut(sftp, src, dst, engine.opts.sftpConcurrency, onProgress)
|
|
62
|
+
bytes += statSync(src).size
|
|
63
|
+
}
|
|
64
|
+
return { bytes, files: files.length }
|
|
65
|
+
}))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Download one remote file to a local path. */
|
|
69
|
+
export async function download(
|
|
70
|
+
engine: PoolEngine,
|
|
71
|
+
alias: string,
|
|
72
|
+
remotePath: string,
|
|
73
|
+
localPath: string,
|
|
74
|
+
onProgress?: (progress: TransferProgress) => void,
|
|
75
|
+
): Promise<{ bytes: number }> {
|
|
76
|
+
return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
|
|
77
|
+
const stat = await new Promise<{ isDirectory: () => boolean }>((resolve, reject) => {
|
|
78
|
+
sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
79
|
+
})
|
|
80
|
+
if (stat.isDirectory()) {
|
|
81
|
+
throw new Error('\'' + remotePath + '\' is a directory — directory download is not supported yet (download individual files)')
|
|
82
|
+
}
|
|
83
|
+
const local = resolvePath(localPath)
|
|
84
|
+
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
|
|
85
|
+
await fastGet(sftp, remotePath, local, engine.opts.sftpConcurrency, onProgress)
|
|
86
|
+
return { bytes: statSync(local).size }
|
|
87
|
+
}))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** List a remote directory (file browser). */
|
|
91
|
+
export async function ls(engine: PoolEngine, alias: string, path: string): Promise<RemoteDirEntry[]> {
|
|
92
|
+
return withClient(engine, alias, (client) => withSftp(client, async (sftp) => {
|
|
93
|
+
return await new Promise((resolve, reject) => {
|
|
94
|
+
sftp.readdir(path, (error, list) => {
|
|
95
|
+
if (error !== undefined) {
|
|
96
|
+
reject(error)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const entries: RemoteDirEntry[] = list.map(item => ({
|
|
100
|
+
name: item.filename,
|
|
101
|
+
type: item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other',
|
|
102
|
+
size: item.attrs.size,
|
|
103
|
+
mtimeMs: item.attrs.mtime * 1000,
|
|
104
|
+
mode: item.attrs.mode,
|
|
105
|
+
}))
|
|
106
|
+
resolve(entries)
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
}))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Open one SFTP channel, run the operation, and release the channel exactly
|
|
114
|
+
* once when the operation settles (success or error). ssh2 keeps each
|
|
115
|
+
* subsystem channel open until end(); without this, every transfer leaks a
|
|
116
|
+
* channel until sshd's MaxSessions cap makes all later opens fail.
|
|
117
|
+
*/
|
|
118
|
+
async function withSftp<T>(client: Client, run: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
|
|
119
|
+
const sftp = await sftpChannel(client)
|
|
120
|
+
let ended = false
|
|
121
|
+
const endOnce = (): void => {
|
|
122
|
+
if (ended) return
|
|
123
|
+
ended = true
|
|
124
|
+
try { sftp.end() } catch { /* channel already closed */ }
|
|
125
|
+
}
|
|
126
|
+
// The channel can also close underneath us (peer reset, timeout); the
|
|
127
|
+
// guard makes the finally below a no-op instead of ending it twice.
|
|
128
|
+
sftp.once('close', endOnce)
|
|
129
|
+
try {
|
|
130
|
+
return await run(sftp)
|
|
131
|
+
} finally {
|
|
132
|
+
endOnce()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function sftpChannel(client: Client): Promise<SFTPWrapper> {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
client.sftp((error, sftp) => error !== undefined ? reject(error) : resolve(sftp))
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
143
|
+
function ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
|
|
146
|
+
const walk = (index: number): void => {
|
|
147
|
+
if (index >= segments.length) {
|
|
148
|
+
resolve()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const current = '/' + segments.slice(0, index + 1).join('/')
|
|
152
|
+
sftp.stat(current, (statError) => {
|
|
153
|
+
if (statError === undefined) {
|
|
154
|
+
walk(index + 1)
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
// Statting a missing path fails; mkdir it (idempotent because the
|
|
158
|
+
// stat check runs first — some sftp servers throw on EEXIST).
|
|
159
|
+
sftp.mkdir(current, (mkdirError) => {
|
|
160
|
+
if (mkdirError !== undefined) {
|
|
161
|
+
reject(mkdirError)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
walk(index + 1)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
walk(0)
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function fastPut(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
let last = 0
|
|
175
|
+
let lastEmit = 0
|
|
176
|
+
const started = Date.now()
|
|
177
|
+
onProgress?.({ phase: 'transferring', file: dst, transferred: 0, total: statSync(src).size, percent: 0 })
|
|
178
|
+
sftp.fastPut(src, dst, { concurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
179
|
+
const now = Date.now()
|
|
180
|
+
// Throttle: high-speed links fire one callback per chunk; the UI only
|
|
181
|
+
// needs ~10 frames per second.
|
|
182
|
+
if (now - lastEmit < 100 && transferred < total) return
|
|
183
|
+
lastEmit = now
|
|
184
|
+
const elapsed = (now - started) / 1000
|
|
185
|
+
onProgress?.({
|
|
186
|
+
phase: 'transferring',
|
|
187
|
+
file: dst,
|
|
188
|
+
transferred,
|
|
189
|
+
total,
|
|
190
|
+
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
191
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
192
|
+
})
|
|
193
|
+
last = transferred
|
|
194
|
+
} }, (error) => {
|
|
195
|
+
if (error !== undefined) {
|
|
196
|
+
onProgress?.({ phase: 'error', file: dst, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
197
|
+
reject(error)
|
|
198
|
+
} else {
|
|
199
|
+
onProgress?.({ phase: 'done', file: dst, transferred: statSync(src).size, total: statSync(src).size, percent: 100 })
|
|
200
|
+
resolve()
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function fastGet(sftp: SFTPWrapper, src: string, dst: string, concurrency: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
let last = 0
|
|
209
|
+
let lastEmit = 0
|
|
210
|
+
const started = Date.now()
|
|
211
|
+
sftp.fastGet(src, dst, { concurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
212
|
+
const now = Date.now()
|
|
213
|
+
if (now - lastEmit < 100 && transferred < total) return
|
|
214
|
+
lastEmit = now
|
|
215
|
+
const elapsed = (now - started) / 1000
|
|
216
|
+
onProgress?.({
|
|
217
|
+
phase: 'transferring',
|
|
218
|
+
file: src,
|
|
219
|
+
transferred,
|
|
220
|
+
total,
|
|
221
|
+
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
222
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
223
|
+
})
|
|
224
|
+
last = transferred
|
|
225
|
+
} }, (error) => {
|
|
226
|
+
if (error !== undefined) {
|
|
227
|
+
onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
228
|
+
reject(error)
|
|
229
|
+
} else {
|
|
230
|
+
onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
|
|
231
|
+
resolve()
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
}
|