@linxin666/dsh-ssh 0.1.14 → 0.1.16
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 +675 -607
- 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 +83 -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 +318 -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
package/src/engine.ts
CHANGED
|
@@ -1,176 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The SSH engine: a per-alias persistent connection pool (ssh2) with
|
|
2
|
+
* The SSH engine facade: a per-alias persistent connection pool (ssh2) with
|
|
3
3
|
* multi-hop jump support, command execution, PTY shells, SFTP transfers,
|
|
4
|
-
* local port-forward tunnels and cluster execution
|
|
5
|
-
*
|
|
4
|
+
* local port-forward tunnels and cluster execution. The heavy lifting lives
|
|
5
|
+
* in the engine/ modules (connection-pool, pty, sftp, tunnel, cluster); this
|
|
6
|
+
* class composes them behind one new SshEngine instance per plugin apply.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/** Default cluster concurrency. */
|
|
28
|
-
defaultMaxWorkers?: number
|
|
29
|
-
/** SFTP concurrent channel count for transfers. */
|
|
30
|
-
sftpConcurrency?: number
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const DEFAULTS: Required<EngineOptions> = {
|
|
34
|
-
idleTimeoutMs: 30 * 60_000,
|
|
35
|
-
connectTimeoutMs: 15_000,
|
|
36
|
-
keepaliveIntervalMs: 15_000,
|
|
37
|
-
maxOutputBytes: 2 * 1024 * 1024,
|
|
38
|
-
defaultExecTimeoutMs: 60_000,
|
|
39
|
-
defaultMaxWorkers: 8,
|
|
40
|
-
sftpConcurrency: 8,
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** One pooled connection record. */
|
|
44
|
-
interface PoolRecord {
|
|
45
|
-
client: Client
|
|
46
|
-
/** Jump-chain clients kept alive under the target. */
|
|
47
|
-
hops: Client[]
|
|
48
|
-
idleAt: number
|
|
49
|
-
/** Pinned connections (tunnels) are never swept. */
|
|
50
|
-
pinned: boolean
|
|
51
|
-
broken: boolean
|
|
52
|
-
/** Operations currently running on this connection (sweep guard). */
|
|
53
|
-
inFlight: number
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** A live PTY shell session. */
|
|
57
|
-
export interface ShellSession {
|
|
58
|
-
/** Assign to receive remote output. */
|
|
59
|
-
onData?: (data: Buffer) => void
|
|
60
|
-
/** Assign to be notified when the channel closes. */
|
|
61
|
-
onExit?: (code: number | null, error?: string) => void
|
|
62
|
-
/** Write raw input to the shell. */
|
|
63
|
-
send(data: string): void
|
|
64
|
-
/** Resize the remote PTY. */
|
|
65
|
-
resize(cols: number, rows: number): void
|
|
66
|
-
/** Close the session and its channel. */
|
|
67
|
-
close(): void
|
|
68
|
-
/** Pause remote output delivery (transport backpressure). */
|
|
69
|
-
pause(): void
|
|
70
|
-
/** Resume remote output delivery. */
|
|
71
|
-
resume(): void
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** One active tunnel record (server + pinned client + live sockets). */
|
|
75
|
-
interface TunnelRecord {
|
|
76
|
-
info: TunnelInfo
|
|
77
|
-
server: NetServer
|
|
78
|
-
alias: string
|
|
79
|
-
sockets: Set<import('node:net').Socket>
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Build the ssh2 connect config for one entry (key read from disk). */
|
|
83
|
-
function buildConnectConfig(entry: SshHostEntry, sock?: ConnectConfig['sock']): ConnectConfig {
|
|
84
|
-
const config: ConnectConfig = {
|
|
85
|
-
host: entry.host,
|
|
86
|
-
port: entry.port,
|
|
87
|
-
username: entry.user,
|
|
88
|
-
readyTimeout: 15_000,
|
|
89
|
-
keepaliveInterval: 15_000,
|
|
90
|
-
keepaliveCountMax: 3,
|
|
91
|
-
}
|
|
92
|
-
if (sock !== undefined) config.sock = sock
|
|
93
|
-
if (entry.auth.kind === 'password') {
|
|
94
|
-
config.password = entry.auth.password
|
|
95
|
-
} else {
|
|
96
|
-
const keyPath = entry.auth.keyPath === undefined ? undefined : expandHome(entry.auth.keyPath)
|
|
97
|
-
if (keyPath === undefined || !existsSync(keyPath)) {
|
|
98
|
-
throw new Error(`private key not found: '${entry.auth.keyPath ?? '(unset)'}'`)
|
|
99
|
-
}
|
|
100
|
-
config.privateKey = readFileSync(keyPath, 'utf8')
|
|
101
|
-
if (entry.auth.passphrase !== undefined && entry.auth.passphrase !== '') {
|
|
102
|
-
config.passphrase = entry.auth.passphrase
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return config
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Connect one ssh2 client (resolve on ready, reject on error/close). */
|
|
109
|
-
function connectClient(config: ConnectConfig): Promise<Client> {
|
|
110
|
-
return new Promise((resolve, reject) => {
|
|
111
|
-
const client = new Client()
|
|
112
|
-
let settled = false
|
|
113
|
-
client.once('ready', () => {
|
|
114
|
-
if (settled) return
|
|
115
|
-
settled = true
|
|
116
|
-
resolve(client)
|
|
117
|
-
})
|
|
118
|
-
client.once('error', (error) => {
|
|
119
|
-
if (settled) return
|
|
120
|
-
settled = true
|
|
121
|
-
reject(error instanceof Error ? error : new Error(String(error)))
|
|
122
|
-
})
|
|
123
|
-
try {
|
|
124
|
-
client.connect(config)
|
|
125
|
-
} catch (error) {
|
|
126
|
-
if (!settled) {
|
|
127
|
-
settled = true
|
|
128
|
-
reject(error instanceof Error ? error : new Error(String(error)))
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Cap captured output at the configured byte budget (marks truncation). */
|
|
135
|
-
function appendOutput(target: { text: string; truncated: boolean }, chunk: Buffer, maxBytes: number): void {
|
|
136
|
-
if (target.truncated) return
|
|
137
|
-
if (target.text.length + chunk.length > maxBytes) {
|
|
138
|
-
let cut = chunk.toString('utf8').slice(0, maxBytes - target.text.length)
|
|
139
|
-
// Never split a surrogate pair at the cut boundary.
|
|
140
|
-
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1)
|
|
141
|
-
target.text += cut + '…[output truncated]'
|
|
142
|
-
target.truncated = true
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
target.text += chunk.toString('utf8')
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Walk a local directory, collecting relative paths of every file. */
|
|
149
|
-
function walkLocalDir(root: string): string[] {
|
|
150
|
-
const files: string[] = []
|
|
151
|
-
const visit = (dir: string): void => {
|
|
152
|
-
for (const name of readdirSync(dir)) {
|
|
153
|
-
const full = join(dir, name)
|
|
154
|
-
const stat = statSync(full)
|
|
155
|
-
if (stat.isDirectory()) visit(full)
|
|
156
|
-
else if (stat.isFile()) files.push(relative(root, full))
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
visit(root)
|
|
160
|
-
return files
|
|
161
|
-
}
|
|
9
|
+
import type { ClusterResult, ExecResult, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts'
|
|
10
|
+
import type { HostStore } from './store.ts'
|
|
11
|
+
import {
|
|
12
|
+
DEFAULTS,
|
|
13
|
+
execCommand,
|
|
14
|
+
disposeRecord,
|
|
15
|
+
sweepPool,
|
|
16
|
+
type EngineOptions,
|
|
17
|
+
type PoolRecord,
|
|
18
|
+
} from './engine/connection-pool.ts'
|
|
19
|
+
import { openShell, type ShellSession } from './engine/pty.ts'
|
|
20
|
+
import { download, ls, upload } from './engine/sftp.ts'
|
|
21
|
+
import { listTunnels, startTunnel, stopAllTunnels, stopTunnel, type TunnelRecord } from './engine/tunnel.ts'
|
|
22
|
+
import { cluster } from './engine/cluster.ts'
|
|
23
|
+
|
|
24
|
+
export type { EngineOptions } from './engine/connection-pool.ts'
|
|
25
|
+
export type { ShellSession } from './engine/pty.ts'
|
|
26
|
+
export type { PoolRecord } from './engine/connection-pool.ts'
|
|
27
|
+
export type { TunnelRecord } from './engine/tunnel.ts'
|
|
162
28
|
|
|
163
29
|
/**
|
|
164
30
|
* The engine. Owns the pool, tunnels, and all operations. One instance per
|
|
165
31
|
* plugin apply; dispose() closes every connection.
|
|
166
32
|
*/
|
|
167
33
|
export class SshEngine {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
34
|
+
readonly store: HostStore
|
|
35
|
+
readonly opts: Required<EngineOptions>
|
|
36
|
+
readonly pool = new Map<string, PoolRecord>()
|
|
37
|
+
readonly acquireQueue = new Map<string, Promise<PoolRecord>>()
|
|
38
|
+
readonly tunnels = new Map<string, TunnelRecord>()
|
|
39
|
+
nextTunnelId = 1
|
|
172
40
|
private sweepTimer: NodeJS.Timeout | undefined
|
|
173
|
-
private nextTunnelId = 1
|
|
174
41
|
|
|
175
42
|
/**
|
|
176
43
|
* @param store - the host config store.
|
|
@@ -179,7 +46,7 @@ export class SshEngine {
|
|
|
179
46
|
constructor(store: HostStore, options?: EngineOptions) {
|
|
180
47
|
this.store = store
|
|
181
48
|
this.opts = { ...DEFAULTS, ...options }
|
|
182
|
-
this.sweepTimer = setInterval(() => this
|
|
49
|
+
this.sweepTimer = setInterval(() => sweepPool(this), Math.max(10_000, this.opts.idleTimeoutMs / 4))
|
|
183
50
|
this.sweepTimer.unref?.()
|
|
184
51
|
}
|
|
185
52
|
|
|
@@ -203,190 +70,11 @@ export class SshEngine {
|
|
|
203
70
|
return entry === undefined ? undefined : this.store.summarize(entry)
|
|
204
71
|
}
|
|
205
72
|
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Run `fn` with a live client for `alias`, reconnecting (up to the
|
|
210
|
-
* attempt budget) when the connection broke mid-flight.
|
|
211
|
-
*/
|
|
212
|
-
private async withClient<T>(alias: string, fn: (client: Client) => Promise<T>, attempts = 3): Promise<T> {
|
|
213
|
-
let lastError: unknown
|
|
214
|
-
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
215
|
-
let record = this.pool.get(alias)
|
|
216
|
-
if (record === undefined || record.broken) {
|
|
217
|
-
if (record !== undefined) this.disposeRecord(alias, record)
|
|
218
|
-
record = await this.acquire(alias)
|
|
219
|
-
}
|
|
220
|
-
record.idleAt = Date.now()
|
|
221
|
-
record.inFlight += 1
|
|
222
|
-
try {
|
|
223
|
-
const result = await fn(record.client)
|
|
224
|
-
record.idleAt = Date.now()
|
|
225
|
-
return result
|
|
226
|
-
} finally {
|
|
227
|
-
record.inFlight -= 1
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Build one full jump chain for an entry: hop clients connected through in
|
|
235
|
-
* order, each forwarding a stream to the next destination, ending with the
|
|
236
|
-
* target client. Shared by the pool and standalone shell sessions.
|
|
237
|
-
*/
|
|
238
|
-
private async connectChain(entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
|
|
239
|
-
const hops: Client[] = []
|
|
240
|
-
let sock: ConnectConfig['sock']
|
|
241
|
-
const chain = entry.proxyJump
|
|
242
|
-
for (let index = 0; index < chain.length; index += 1) {
|
|
243
|
-
const hopAlias = chain[index]
|
|
244
|
-
const hop = this.store.find(hopAlias)
|
|
245
|
-
if (hop === undefined) {
|
|
246
|
-
for (const client of hops) client.end()
|
|
247
|
-
throw new Error(`proxyJump alias '${hopAlias}' not found — create it first`)
|
|
248
|
-
}
|
|
249
|
-
const hopClient = await connectClient(buildConnectConfig(hop, sock))
|
|
250
|
-
hops.push(hopClient)
|
|
251
|
-
const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : undefined
|
|
252
|
-
const nextHost = next !== undefined ? next.host : entry.host
|
|
253
|
-
const nextPort = next !== undefined ? next.port : entry.port
|
|
254
|
-
sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
|
|
255
|
-
hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
|
|
256
|
-
if (error !== undefined) {
|
|
257
|
-
for (const client of hops) client.end()
|
|
258
|
-
reject(error)
|
|
259
|
-
} else {
|
|
260
|
-
resolve(stream)
|
|
261
|
-
}
|
|
262
|
-
})
|
|
263
|
-
})
|
|
264
|
-
}
|
|
265
|
-
try {
|
|
266
|
-
const client = await connectClient(buildConnectConfig(entry, sock))
|
|
267
|
-
return { client, hops }
|
|
268
|
-
} catch (error) {
|
|
269
|
-
for (const client of hops) client.end()
|
|
270
|
-
throw error
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** In-flight acquire promises, deduped per alias (concurrent first use). */
|
|
275
|
-
private readonly acquireQueue = new Map<string, Promise<PoolRecord>>()
|
|
276
|
-
|
|
277
|
-
/** Connect (or reuse) the pooled chain for one alias; pins nothing. */
|
|
278
|
-
private async acquire(alias: string): Promise<PoolRecord> {
|
|
279
|
-
const pending = this.acquireQueue.get(alias)
|
|
280
|
-
if (pending !== undefined) return pending
|
|
281
|
-
const task = this.doAcquire(alias)
|
|
282
|
-
this.acquireQueue.set(alias, task)
|
|
283
|
-
try {
|
|
284
|
-
return await task
|
|
285
|
-
} finally {
|
|
286
|
-
if (this.acquireQueue.get(alias) === task) this.acquireQueue.delete(alias)
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private async doAcquire(alias: string): Promise<PoolRecord> {
|
|
291
|
-
const entry = this.store.find(alias)
|
|
292
|
-
if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
|
|
293
|
-
const { client, hops } = await this.connectChain(entry)
|
|
294
|
-
const record: PoolRecord = { client, hops, idleAt: Date.now(), pinned: false, broken: false, inFlight: 0 }
|
|
295
|
-
client.on('error', () => { record.broken = true })
|
|
296
|
-
client.on('close', () => { record.broken = true })
|
|
297
|
-
this.pool.set(alias, record)
|
|
298
|
-
return record
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Tear down one alias's record. When `record` is given and no longer the
|
|
303
|
-
* pooled record for the alias (a concurrent acquire replaced it), nothing
|
|
304
|
-
* is torn down — the connection belongs to someone else now.
|
|
305
|
-
*/
|
|
306
|
-
private disposeRecord(alias: string, record?: PoolRecord): void {
|
|
307
|
-
const current = this.pool.get(alias)
|
|
308
|
-
if (record !== undefined && current !== record) return
|
|
309
|
-
if (current === undefined) return
|
|
310
|
-
this.pool.delete(alias)
|
|
311
|
-
try { current.client.end() } catch { /* already closed */ }
|
|
312
|
-
for (const hop of current.hops) {
|
|
313
|
-
try { hop.end() } catch { /* already closed */ }
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/** Close connections idle beyond the threshold (skips pinned and in-flight). */
|
|
318
|
-
private sweep(): void {
|
|
319
|
-
const cutoff = Date.now() - this.opts.idleTimeoutMs
|
|
320
|
-
for (const [alias, record] of this.pool) {
|
|
321
|
-
if (!record.pinned && record.inFlight === 0 && record.idleAt < cutoff) {
|
|
322
|
-
this.disposeRecord(alias, record)
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// --------------------------------------------------------------- exec
|
|
73
|
+
// ------------------------------------------------------------ exec
|
|
328
74
|
|
|
329
75
|
/** Run one command on `alias` (reusing the pooled connection). */
|
|
330
76
|
async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
|
|
331
|
-
|
|
332
|
-
const budget = timeoutMs !== undefined && timeoutMs > 0 ? timeoutMs : this.opts.defaultExecTimeoutMs
|
|
333
|
-
return this.withClient(alias, async (client) => {
|
|
334
|
-
return await new Promise<ExecResult>((resolve, reject) => {
|
|
335
|
-
client.exec(command, (error, stream) => {
|
|
336
|
-
if (error !== undefined) {
|
|
337
|
-
reject(error)
|
|
338
|
-
return
|
|
339
|
-
}
|
|
340
|
-
const stdout = { text: '', truncated: false }
|
|
341
|
-
const stderr = { text: '', truncated: false }
|
|
342
|
-
let timedOut = false
|
|
343
|
-
let settled = false
|
|
344
|
-
const finish = (): void => {
|
|
345
|
-
if (settled) return
|
|
346
|
-
settled = true
|
|
347
|
-
clearTimeout(timer)
|
|
348
|
-
resolve({
|
|
349
|
-
success: false,
|
|
350
|
-
exitCode: null,
|
|
351
|
-
timedOut,
|
|
352
|
-
stdout: stdout.text,
|
|
353
|
-
stderr: stderr.text,
|
|
354
|
-
durationMs: Date.now() - started,
|
|
355
|
-
error: timedOut ? `command timed out after ${budget} ms` : undefined,
|
|
356
|
-
})
|
|
357
|
-
}
|
|
358
|
-
const timer = setTimeout(() => {
|
|
359
|
-
timedOut = true
|
|
360
|
-
try { stream.signal('KILL') } catch { /* channel gone */ }
|
|
361
|
-
try { stream.close() } catch { /* channel gone */ }
|
|
362
|
-
// Hard deadline: settle now even if the peer never acks the
|
|
363
|
-
// channel close (the stream 'close' handler is then a no-op).
|
|
364
|
-
finish()
|
|
365
|
-
}, budget)
|
|
366
|
-
stream.on('data', (chunk: Buffer) => appendOutput(stdout, chunk, this.opts.maxOutputBytes))
|
|
367
|
-
stream.stderr.on('data', (chunk: Buffer) => appendOutput(stderr, chunk, this.opts.maxOutputBytes))
|
|
368
|
-
stream.on('close', (code: number | null) => {
|
|
369
|
-
if (settled) return
|
|
370
|
-
settled = true
|
|
371
|
-
clearTimeout(timer)
|
|
372
|
-
resolve({
|
|
373
|
-
success: code === 0 && !timedOut,
|
|
374
|
-
exitCode: code,
|
|
375
|
-
timedOut,
|
|
376
|
-
stdout: stdout.text,
|
|
377
|
-
stderr: stderr.text,
|
|
378
|
-
durationMs: Date.now() - started,
|
|
379
|
-
})
|
|
380
|
-
})
|
|
381
|
-
stream.on('error', (streamError: Error) => {
|
|
382
|
-
if (settled) return
|
|
383
|
-
settled = true
|
|
384
|
-
clearTimeout(timer)
|
|
385
|
-
reject(streamError)
|
|
386
|
-
})
|
|
387
|
-
})
|
|
388
|
-
})
|
|
389
|
-
})
|
|
77
|
+
return execCommand(this, alias, command, timeoutMs)
|
|
390
78
|
}
|
|
391
79
|
|
|
392
80
|
/** Run one command against many hosts concurrently. */
|
|
@@ -398,382 +86,53 @@ export class SshEngine {
|
|
|
398
86
|
timeoutMs?: number
|
|
399
87
|
maxWorkers?: number
|
|
400
88
|
}): Promise<ClusterResult[]> {
|
|
401
|
-
|
|
402
|
-
if (options.aliases !== undefined && options.aliases.length > 0) {
|
|
403
|
-
targets = targets.filter(entry => options.aliases!.includes(entry.alias))
|
|
404
|
-
}
|
|
405
|
-
if (options.environment !== undefined && options.environment !== '') {
|
|
406
|
-
targets = targets.filter(entry => entry.environment === options.environment)
|
|
407
|
-
}
|
|
408
|
-
if (options.tags !== undefined && options.tags.length > 0) {
|
|
409
|
-
// ALL semantics (matches the ssh_cluster tool description).
|
|
410
|
-
targets = targets.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
|
|
411
|
-
}
|
|
412
|
-
if (targets.length === 0) return []
|
|
413
|
-
if (options.maxWorkers !== undefined && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) {
|
|
414
|
-
throw new Error('maxWorkers must be a positive integer')
|
|
415
|
-
}
|
|
416
|
-
const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length)
|
|
417
|
-
const results: ClusterResult[] = []
|
|
418
|
-
const queue = [...targets]
|
|
419
|
-
const run = async (): Promise<void> => {
|
|
420
|
-
while (queue.length > 0) {
|
|
421
|
-
const entry = queue.shift()!
|
|
422
|
-
try {
|
|
423
|
-
const result = await this.exec(entry.alias, options.command, options.timeoutMs)
|
|
424
|
-
results.push({ alias: entry.alias, ok: result.success, exitCode: result.exitCode, timedOut: result.timedOut, stdout: result.stdout, stderr: result.stderr, durationMs: result.durationMs })
|
|
425
|
-
} catch (error) {
|
|
426
|
-
results.push({ alias: entry.alias, ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
await Promise.all(Array.from({ length: workers }, () => run()))
|
|
431
|
-
return results
|
|
89
|
+
return cluster(this, options)
|
|
432
90
|
}
|
|
433
91
|
|
|
434
92
|
// -------------------------------------------------------------- shell
|
|
435
93
|
|
|
436
94
|
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
437
95
|
async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
|
|
438
|
-
|
|
439
|
-
if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
|
|
440
|
-
// The shell is a long-lived exclusive stream: use its own connection so
|
|
441
|
-
// closing it can never tear down a pooled exec/tunnel sharing the alias.
|
|
442
|
-
const { client, hops } = await this.connectChain(entry)
|
|
443
|
-
return await new Promise<ShellSession>((resolve, reject) => {
|
|
444
|
-
client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, (error, stream) => {
|
|
445
|
-
if (error !== undefined) {
|
|
446
|
-
try { client.end() } catch { /* closed */ }
|
|
447
|
-
for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
|
|
448
|
-
reject(error)
|
|
449
|
-
return
|
|
450
|
-
}
|
|
451
|
-
let tornDown = false
|
|
452
|
-
const teardown = (): void => {
|
|
453
|
-
if (tornDown) return
|
|
454
|
-
tornDown = true
|
|
455
|
-
try { client.end() } catch { /* closed */ }
|
|
456
|
-
for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
|
|
457
|
-
}
|
|
458
|
-
const session: ShellSession = {
|
|
459
|
-
send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
|
|
460
|
-
resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
|
|
461
|
-
close: () => {
|
|
462
|
-
try { stream.close() } catch { /* channel gone */ }
|
|
463
|
-
teardown()
|
|
464
|
-
},
|
|
465
|
-
pause: () => { try { stream.pause() } catch { /* channel gone */ } },
|
|
466
|
-
resume: () => { try { stream.resume() } catch { /* channel gone */ } },
|
|
467
|
-
}
|
|
468
|
-
stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
|
|
469
|
-
stream.on('close', (code: number | null) => {
|
|
470
|
-
teardown()
|
|
471
|
-
session.onExit?.(code)
|
|
472
|
-
})
|
|
473
|
-
stream.on('error', (streamError: Error) => {
|
|
474
|
-
teardown()
|
|
475
|
-
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
|
|
476
|
-
})
|
|
477
|
-
resolve(session)
|
|
478
|
-
})
|
|
479
|
-
})
|
|
96
|
+
return openShell(this, alias, size)
|
|
480
97
|
}
|
|
481
98
|
|
|
482
99
|
// -------------------------------------------------------------- sftp
|
|
483
100
|
|
|
484
101
|
/** Upload one local file (or directory tree) to a remote path. */
|
|
485
102
|
async upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number; files: number }> {
|
|
486
|
-
|
|
487
|
-
// on one resolution (relative paths previously created dirs at the root).
|
|
488
|
-
if (!remotePath.startsWith('/')) {
|
|
489
|
-
throw new Error(`remotePath must be an absolute path (got '${remotePath}')`)
|
|
490
|
-
}
|
|
491
|
-
const local = resolvePath(localPath)
|
|
492
|
-
if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`)
|
|
493
|
-
return this.withClient(alias, (client) => this.withSftp(client, async (sftp) => {
|
|
494
|
-
const stat = statSync(local)
|
|
495
|
-
let files: string[]
|
|
496
|
-
if (stat.isDirectory()) {
|
|
497
|
-
if (!recursive) throw new Error(`'${localPath}' is a directory — enable recursive upload`)
|
|
498
|
-
files = walkLocalDir(local)
|
|
499
|
-
await this.ensureRemoteDir(sftp, remotePath)
|
|
500
|
-
} else {
|
|
501
|
-
files = ['']
|
|
502
|
-
await this.ensureRemoteDir(sftp, dirname(remotePath))
|
|
503
|
-
}
|
|
504
|
-
let bytes = 0
|
|
505
|
-
for (const rel of files) {
|
|
506
|
-
const src = rel === '' ? local : join(local, rel)
|
|
507
|
-
// Remote paths always use forward slashes; normalize any OS separators.
|
|
508
|
-
const remoteRel = rel.split(/[\\/]/).join('/')
|
|
509
|
-
const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
|
|
510
|
-
await this.fastPut(sftp, src, dst, onProgress)
|
|
511
|
-
bytes += statSync(src).size
|
|
512
|
-
}
|
|
513
|
-
return { bytes, files: files.length }
|
|
514
|
-
}))
|
|
103
|
+
return upload(this, alias, localPath, remotePath, recursive, onProgress)
|
|
515
104
|
}
|
|
516
105
|
|
|
517
106
|
/** Download one remote file to a local path. */
|
|
518
107
|
async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number }> {
|
|
519
|
-
return this
|
|
520
|
-
const stat = await new Promise<{ isDirectory: () => boolean }>((resolve, reject) => {
|
|
521
|
-
sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
522
|
-
})
|
|
523
|
-
if (stat.isDirectory()) {
|
|
524
|
-
throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`)
|
|
525
|
-
}
|
|
526
|
-
const local = resolvePath(localPath)
|
|
527
|
-
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
|
|
528
|
-
await this.fastGet(sftp, remotePath, local, onProgress)
|
|
529
|
-
return { bytes: statSync(local).size }
|
|
530
|
-
}))
|
|
108
|
+
return download(this, alias, remotePath, localPath, onProgress)
|
|
531
109
|
}
|
|
532
110
|
|
|
533
111
|
/** List a remote directory (file browser). */
|
|
534
112
|
async ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]> {
|
|
535
|
-
return this
|
|
536
|
-
return await new Promise((resolve, reject) => {
|
|
537
|
-
sftp.readdir(path, (error, list) => {
|
|
538
|
-
if (error !== undefined) {
|
|
539
|
-
reject(error)
|
|
540
|
-
return
|
|
541
|
-
}
|
|
542
|
-
const entries: import('./protocol.ts').RemoteDirEntry[] = list.map(item => ({
|
|
543
|
-
name: item.filename,
|
|
544
|
-
type: item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other',
|
|
545
|
-
size: item.attrs.size,
|
|
546
|
-
mtimeMs: item.attrs.mtime * 1000,
|
|
547
|
-
mode: item.attrs.mode,
|
|
548
|
-
}))
|
|
549
|
-
resolve(entries)
|
|
550
|
-
})
|
|
551
|
-
})
|
|
552
|
-
}))
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Open one SFTP channel, run the operation, and release the channel exactly
|
|
557
|
-
* once when the operation settles (success or error). ssh2 keeps each
|
|
558
|
-
* subsystem channel open until end(); without this, every transfer leaks a
|
|
559
|
-
* channel until sshd's MaxSessions cap makes all later opens fail.
|
|
560
|
-
*/
|
|
561
|
-
private async withSftp<T>(client: Client, run: (sftp: SFTPWrapper) => Promise<T>): Promise<T> {
|
|
562
|
-
const sftp = await this.sftp(client)
|
|
563
|
-
let ended = false
|
|
564
|
-
const endOnce = (): void => {
|
|
565
|
-
if (ended) return
|
|
566
|
-
ended = true
|
|
567
|
-
try { sftp.end() } catch { /* channel already closed */ }
|
|
568
|
-
}
|
|
569
|
-
// The channel can also close underneath us (peer reset, timeout); the
|
|
570
|
-
// guard makes the finally below a no-op instead of ending it twice.
|
|
571
|
-
sftp.once('close', endOnce)
|
|
572
|
-
try {
|
|
573
|
-
return await run(sftp)
|
|
574
|
-
} finally {
|
|
575
|
-
endOnce()
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
private sftp(client: Client): Promise<SFTPWrapper> {
|
|
580
|
-
return new Promise((resolve, reject) => {
|
|
581
|
-
client.sftp((error, sftp) => error !== undefined ? reject(error) : resolve(sftp))
|
|
582
|
-
})
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
586
|
-
private ensureRemoteDir(sftp: SFTPWrapper, remote: string): Promise<void> {
|
|
587
|
-
return new Promise((resolve, reject) => {
|
|
588
|
-
const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
|
|
589
|
-
const walk = (index: number): void => {
|
|
590
|
-
if (index >= segments.length) {
|
|
591
|
-
resolve()
|
|
592
|
-
return
|
|
593
|
-
}
|
|
594
|
-
const current = '/' + segments.slice(0, index + 1).join('/')
|
|
595
|
-
sftp.stat(current, (statError) => {
|
|
596
|
-
if (statError === undefined) {
|
|
597
|
-
walk(index + 1)
|
|
598
|
-
return
|
|
599
|
-
}
|
|
600
|
-
// Statting a missing path fails; mkdir it (idempotent because the
|
|
601
|
-
// stat check runs first — some sftp servers throw on EEXIST).
|
|
602
|
-
sftp.mkdir(current, (mkdirError) => {
|
|
603
|
-
if (mkdirError !== undefined) {
|
|
604
|
-
reject(mkdirError)
|
|
605
|
-
return
|
|
606
|
-
}
|
|
607
|
-
walk(index + 1)
|
|
608
|
-
})
|
|
609
|
-
})
|
|
610
|
-
}
|
|
611
|
-
walk(0)
|
|
612
|
-
})
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
private fastPut(sftp: SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
616
|
-
return new Promise((resolve, reject) => {
|
|
617
|
-
let last = 0
|
|
618
|
-
let lastEmit = 0
|
|
619
|
-
const started = Date.now()
|
|
620
|
-
onProgress?.({ phase: 'transferring', file: dst, transferred: 0, total: statSync(src).size, percent: 0 })
|
|
621
|
-
sftp.fastPut(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
622
|
-
const now = Date.now()
|
|
623
|
-
// Throttle: high-speed links fire one callback per chunk; the UI only
|
|
624
|
-
// needs ~10 frames per second.
|
|
625
|
-
if (now - lastEmit < 100 && transferred < total) return
|
|
626
|
-
lastEmit = now
|
|
627
|
-
const elapsed = (now - started) / 1000
|
|
628
|
-
onProgress?.({
|
|
629
|
-
phase: 'transferring',
|
|
630
|
-
file: dst,
|
|
631
|
-
transferred,
|
|
632
|
-
total,
|
|
633
|
-
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
634
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
635
|
-
})
|
|
636
|
-
last = transferred
|
|
637
|
-
} }, (error) => {
|
|
638
|
-
if (error !== undefined) {
|
|
639
|
-
onProgress?.({ phase: 'error', file: dst, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
640
|
-
reject(error)
|
|
641
|
-
} else {
|
|
642
|
-
onProgress?.({ phase: 'done', file: dst, transferred: statSync(src).size, total: statSync(src).size, percent: 100 })
|
|
643
|
-
resolve()
|
|
644
|
-
}
|
|
645
|
-
})
|
|
646
|
-
})
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
private fastGet(sftp: SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
650
|
-
return new Promise((resolve, reject) => {
|
|
651
|
-
let last = 0
|
|
652
|
-
let lastEmit = 0
|
|
653
|
-
const started = Date.now()
|
|
654
|
-
sftp.fastGet(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
655
|
-
const now = Date.now()
|
|
656
|
-
if (now - lastEmit < 100 && transferred < total) return
|
|
657
|
-
lastEmit = now
|
|
658
|
-
const elapsed = (now - started) / 1000
|
|
659
|
-
onProgress?.({
|
|
660
|
-
phase: 'transferring',
|
|
661
|
-
file: src,
|
|
662
|
-
transferred,
|
|
663
|
-
total,
|
|
664
|
-
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
665
|
-
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
666
|
-
})
|
|
667
|
-
last = transferred
|
|
668
|
-
} }, (error) => {
|
|
669
|
-
if (error !== undefined) {
|
|
670
|
-
onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
671
|
-
reject(error)
|
|
672
|
-
} else {
|
|
673
|
-
onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
|
|
674
|
-
resolve()
|
|
675
|
-
}
|
|
676
|
-
})
|
|
677
|
-
})
|
|
113
|
+
return ls(this, alias, path)
|
|
678
114
|
}
|
|
679
115
|
|
|
680
116
|
// ------------------------------------------------------------- tunnel
|
|
681
117
|
|
|
682
118
|
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
683
119
|
async startTunnel(alias: string, options: { remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
|
|
684
|
-
|
|
685
|
-
throw new Error('remotePort must be an integer in 1..65535')
|
|
686
|
-
}
|
|
687
|
-
if (options.localPort !== undefined && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) {
|
|
688
|
-
throw new Error('localPort must be an integer in 1..65535')
|
|
689
|
-
}
|
|
690
|
-
const entry = this.store.find(alias)
|
|
691
|
-
if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
|
|
692
|
-
const remoteHost = options.remoteHost ?? '127.0.0.1'
|
|
693
|
-
const id = `tun-${this.nextTunnelId++}`
|
|
694
|
-
const info: TunnelInfo = {
|
|
695
|
-
id,
|
|
696
|
-
alias,
|
|
697
|
-
localPort: 0,
|
|
698
|
-
remoteHost,
|
|
699
|
-
remotePort: options.remotePort,
|
|
700
|
-
state: 'connecting',
|
|
701
|
-
startedAt: Date.now(),
|
|
702
|
-
}
|
|
703
|
-
const record = await this.acquire(alias)
|
|
704
|
-
const client = record.client
|
|
705
|
-
const sockets = new Set<import('node:net').Socket>()
|
|
706
|
-
const server = createServer((socket) => {
|
|
707
|
-
sockets.add(socket)
|
|
708
|
-
socket.on('close', () => { sockets.delete(socket) })
|
|
709
|
-
client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
|
|
710
|
-
if (error !== undefined) {
|
|
711
|
-
socket.destroy()
|
|
712
|
-
return
|
|
713
|
-
}
|
|
714
|
-
// Both ends of the pipe can die independently; destroy the pair so an
|
|
715
|
-
// unhandled 'error' event can never crash the host process.
|
|
716
|
-
const destroy = (): void => {
|
|
717
|
-
try { socket.destroy() } catch { /* gone */ }
|
|
718
|
-
try { stream.close() } catch { /* gone */ }
|
|
719
|
-
}
|
|
720
|
-
stream.on('error', destroy)
|
|
721
|
-
socket.on('error', destroy)
|
|
722
|
-
stream.on('close', destroy)
|
|
723
|
-
socket.on('close', destroy)
|
|
724
|
-
stream.pipe(socket).pipe(stream)
|
|
725
|
-
})
|
|
726
|
-
})
|
|
727
|
-
try {
|
|
728
|
-
await new Promise<void>((resolve, reject) => {
|
|
729
|
-
server.once('error', reject)
|
|
730
|
-
server.listen(options.localPort ?? 0, '127.0.0.1', () => {
|
|
731
|
-
server.removeListener('error', reject)
|
|
732
|
-
resolve()
|
|
733
|
-
})
|
|
734
|
-
})
|
|
735
|
-
} catch (error) {
|
|
736
|
-
// Roll back: never leave an unpinned orphan connection behind.
|
|
737
|
-
if (!record.pinned && record.inFlight === 0) this.disposeRecord(alias, record)
|
|
738
|
-
throw error
|
|
739
|
-
}
|
|
740
|
-
record.pinned = true
|
|
741
|
-
const address = server.address()
|
|
742
|
-
info.localPort = typeof address === 'object' && address !== null ? address.port : 0
|
|
743
|
-
info.state = 'forwarding'
|
|
744
|
-
this.tunnels.set(id, { info, server, alias, sockets })
|
|
745
|
-
return info
|
|
120
|
+
return startTunnel(this, alias, options)
|
|
746
121
|
}
|
|
747
122
|
|
|
748
123
|
/** All active tunnels. */
|
|
749
124
|
listTunnels(): TunnelInfo[] {
|
|
750
|
-
return
|
|
125
|
+
return listTunnels(this)
|
|
751
126
|
}
|
|
752
127
|
|
|
753
128
|
/** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
|
|
754
129
|
stopTunnel(id: string): boolean {
|
|
755
|
-
|
|
756
|
-
if (tunnel === undefined) return false
|
|
757
|
-
this.tunnels.delete(id)
|
|
758
|
-
try { tunnel.server.close() } catch { /* already closed */ }
|
|
759
|
-
for (const socket of tunnel.sockets) {
|
|
760
|
-
try { socket.destroy() } catch { /* already closed */ }
|
|
761
|
-
}
|
|
762
|
-
tunnel.sockets.clear()
|
|
763
|
-
this.disposeRecord(tunnel.alias)
|
|
764
|
-
return true
|
|
130
|
+
return stopTunnel(this, id)
|
|
765
131
|
}
|
|
766
132
|
|
|
767
133
|
/** Stop all tunnels (optionally for one alias). */
|
|
768
134
|
stopAllTunnels(alias?: string): number {
|
|
769
|
-
|
|
770
|
-
for (const [id, tunnel] of [...this.tunnels]) {
|
|
771
|
-
if (alias === undefined || tunnel.alias === alias) {
|
|
772
|
-
this.stopTunnel(id)
|
|
773
|
-
count += 1
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
return count
|
|
135
|
+
return stopAllTunnels(this, alias)
|
|
777
136
|
}
|
|
778
137
|
|
|
779
138
|
// ------------------------------------------------------------- misc
|
|
@@ -785,7 +144,7 @@ export class SshEngine {
|
|
|
785
144
|
const result = await this.exec(alias, 'true', 10_000)
|
|
786
145
|
return result.success
|
|
787
146
|
? { ok: true, latencyMs: result.durationMs }
|
|
788
|
-
: { ok: false, latencyMs: result.durationMs, error:
|
|
147
|
+
: { ok: false, latencyMs: result.durationMs, error: 'remote exit code ' + result.exitCode }
|
|
789
148
|
} catch (error) {
|
|
790
149
|
return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
|
|
791
150
|
}
|
|
@@ -794,8 +153,7 @@ export class SshEngine {
|
|
|
794
153
|
/** Close every pooled connection and tunnel. */
|
|
795
154
|
dispose(): void {
|
|
796
155
|
if (this.sweepTimer !== undefined) clearInterval(this.sweepTimer)
|
|
797
|
-
for (const id of [...this.tunnels.keys()])
|
|
798
|
-
for (const alias of [...this.pool.keys()])
|
|
156
|
+
for (const id of [...this.tunnels.keys()]) stopTunnel(this, id)
|
|
157
|
+
for (const alias of [...this.pool.keys()]) disposeRecord(this, alias)
|
|
799
158
|
}
|
|
800
|
-
}
|
|
801
|
-
|
|
159
|
+
}
|