@linxin666/dsh-ssh 0.1.1
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 +75 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +15638 -0
- package/lib/index.js +3297 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +72 -0
- package/lib/types/client/index.d.ts +37 -0
- package/lib/types/client/locales.d.ts +137 -0
- package/lib/types/client/mount.d.ts +12 -0
- package/lib/types/client/panel/ClusterTab.d.ts +7 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/SshPanel.d.ts +13 -0
- package/lib/types/client/panel/TerminalTab.d.ts +11 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
- package/lib/types/client/panel/controller.d.ts +22 -0
- package/lib/types/client/panel/helpers.d.ts +14 -0
- package/lib/types/client/panel/xterm.css.d.ts +2 -0
- package/lib/types/client/sidebar-entry.d.ts +24 -0
- package/lib/types/engine.d.ts +135 -0
- package/lib/types/index.d.ts +40 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/protocol.d.ts +189 -0
- package/lib/types/routes.d.ts +29 -0
- package/lib/types/store.d.ts +54 -0
- package/lib/types/tools.d.ts +18 -0
- package/package.json +106 -0
- package/src/client/api.ts +363 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +68 -0
- package/src/client/locales.ts +284 -0
- package/src/client/mount.tsx +83 -0
- package/src/client/panel/ClusterTab.tsx +123 -0
- package/src/client/panel/HostFormDialog.tsx +221 -0
- package/src/client/panel/HostsTab.tsx +166 -0
- package/src/client/panel/SshPanel.tsx +77 -0
- package/src/client/panel/TerminalTab.tsx +176 -0
- package/src/client/panel/TransferTab.tsx +232 -0
- package/src/client/panel/TunnelsTab.tsx +177 -0
- package/src/client/panel/controller.ts +48 -0
- package/src/client/panel/helpers.ts +26 -0
- package/src/client/panel/panel.module.css +833 -0
- package/src/client/panel/xterm.css.ts +2 -0
- package/src/client/sidebar-entry.ts +123 -0
- package/src/engine.ts +780 -0
- package/src/index.ts +155 -0
- package/src/invariant.ts +4 -0
- package/src/protocol.ts +188 -0
- package/src/routes.ts +540 -0
- package/src/store.ts +344 -0
- package/src/tools.ts +371 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH engine: a per-alias persistent connection pool (ssh2) with
|
|
3
|
+
* multi-hop jump support, command execution, PTY shells, SFTP transfers,
|
|
4
|
+
* local port-forward tunnels and cluster execution — the DSH counterpart of
|
|
5
|
+
* ssh-skill's daemon + scripts, living entirely in the host process.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, type Server as NetServer } from 'node:net'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs'
|
|
10
|
+
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
11
|
+
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
|
|
12
|
+
import type { ClusterResult, ExecResult, SshHostEntry, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts'
|
|
13
|
+
import { expandHome, type HostStore } from './store.ts'
|
|
14
|
+
|
|
15
|
+
/** Default engine knobs. */
|
|
16
|
+
export interface EngineOptions {
|
|
17
|
+
/** Connections idle longer than this are closed (ms). */
|
|
18
|
+
idleTimeoutMs?: number
|
|
19
|
+
/** SSH handshake timeout (ms). */
|
|
20
|
+
connectTimeoutMs?: number
|
|
21
|
+
/** Keepalive ping interval (ms). */
|
|
22
|
+
keepaliveIntervalMs?: number
|
|
23
|
+
/** Cap on captured stdout/stderr bytes per exec (ms). */
|
|
24
|
+
maxOutputBytes?: number
|
|
25
|
+
/** Default exec timeout (ms). */
|
|
26
|
+
defaultExecTimeoutMs?: number
|
|
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
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The engine. Owns the pool, tunnels, and all operations. One instance per
|
|
165
|
+
* plugin apply; dispose() closes every connection.
|
|
166
|
+
*/
|
|
167
|
+
export class SshEngine {
|
|
168
|
+
private readonly store: HostStore
|
|
169
|
+
private readonly opts: Required<EngineOptions>
|
|
170
|
+
private readonly pool = new Map<string, PoolRecord>()
|
|
171
|
+
private readonly tunnels = new Map<string, TunnelRecord>()
|
|
172
|
+
private sweepTimer: NodeJS.Timeout | undefined
|
|
173
|
+
private nextTunnelId = 1
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @param store - the host config store.
|
|
177
|
+
* @param options - engine knobs (defaults applied).
|
|
178
|
+
*/
|
|
179
|
+
constructor(store: HostStore, options?: EngineOptions) {
|
|
180
|
+
this.store = store
|
|
181
|
+
this.opts = { ...DEFAULTS, ...options }
|
|
182
|
+
this.sweepTimer = setInterval(() => this.sweep(), Math.max(10_000, this.opts.idleTimeoutMs / 4))
|
|
183
|
+
this.sweepTimer.unref?.()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------- config
|
|
187
|
+
|
|
188
|
+
/** Secret-free host list (filtered by the optional query). */
|
|
189
|
+
list(query?: string): SshHostSummary[] {
|
|
190
|
+
const needle = query?.trim().toLowerCase()
|
|
191
|
+
return this.store.list()
|
|
192
|
+
.filter(entry => needle === undefined || needle === ''
|
|
193
|
+
|| entry.alias.toLowerCase().includes(needle)
|
|
194
|
+
|| (entry.description ?? '').toLowerCase().includes(needle)
|
|
195
|
+
|| entry.host.toLowerCase().includes(needle)
|
|
196
|
+
|| entry.tags.some(tag => tag.toLowerCase().includes(needle)))
|
|
197
|
+
.map(entry => this.store.summarize(entry))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One host summary by alias. */
|
|
201
|
+
find(alias: string): SshHostSummary | undefined {
|
|
202
|
+
const entry = this.store.find(alias)
|
|
203
|
+
return entry === undefined ? undefined : this.store.summarize(entry)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// -------------------------------------------------------------- pool
|
|
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
|
|
328
|
+
|
|
329
|
+
/** Run one command on `alias` (reusing the pooled connection). */
|
|
330
|
+
async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
|
|
331
|
+
const started = Date.now()
|
|
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
|
+
})
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Run one command against many hosts concurrently. */
|
|
393
|
+
async cluster(options: {
|
|
394
|
+
command: string
|
|
395
|
+
aliases?: string[]
|
|
396
|
+
environment?: string
|
|
397
|
+
tags?: string[]
|
|
398
|
+
timeoutMs?: number
|
|
399
|
+
maxWorkers?: number
|
|
400
|
+
}): Promise<ClusterResult[]> {
|
|
401
|
+
let targets = this.store.list()
|
|
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
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// -------------------------------------------------------------- shell
|
|
435
|
+
|
|
436
|
+
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
437
|
+
async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
|
|
438
|
+
const entry = this.store.find(alias)
|
|
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
|
+
})
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// -------------------------------------------------------------- sftp
|
|
483
|
+
|
|
484
|
+
/** Upload one local file (or directory tree) to a remote path. */
|
|
485
|
+
async upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number; files: number }> {
|
|
486
|
+
// Remote paths must be absolute: the mkdir chain and fastPut must agree
|
|
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, async (client) => {
|
|
494
|
+
const sftp = await this.sftp(client)
|
|
495
|
+
const stat = statSync(local)
|
|
496
|
+
let files: string[]
|
|
497
|
+
if (stat.isDirectory()) {
|
|
498
|
+
if (!recursive) throw new Error(`'${localPath}' is a directory — enable recursive upload`)
|
|
499
|
+
files = walkLocalDir(local)
|
|
500
|
+
await this.ensureRemoteDir(sftp, remotePath)
|
|
501
|
+
} else {
|
|
502
|
+
files = ['']
|
|
503
|
+
await this.ensureRemoteDir(sftp, dirname(remotePath))
|
|
504
|
+
}
|
|
505
|
+
let bytes = 0
|
|
506
|
+
for (const rel of files) {
|
|
507
|
+
const src = rel === '' ? local : join(local, rel)
|
|
508
|
+
// Remote paths always use forward slashes; normalize any OS separators.
|
|
509
|
+
const remoteRel = rel.split(/[\\/]/).join('/')
|
|
510
|
+
const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
|
|
511
|
+
await this.fastPut(sftp, src, dst, onProgress)
|
|
512
|
+
bytes += statSync(src).size
|
|
513
|
+
}
|
|
514
|
+
return { bytes, files: files.length }
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Download one remote file to a local path. */
|
|
519
|
+
async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number }> {
|
|
520
|
+
return this.withClient(alias, async (client) => {
|
|
521
|
+
const sftp = await this.sftp(client)
|
|
522
|
+
const stat = await new Promise<{ isDirectory: () => boolean }>((resolve, reject) => {
|
|
523
|
+
sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
524
|
+
})
|
|
525
|
+
if (stat.isDirectory()) {
|
|
526
|
+
throw new Error(`'${remotePath}' is a directory — directory download is not supported yet (download individual files)`)
|
|
527
|
+
}
|
|
528
|
+
const local = resolvePath(localPath)
|
|
529
|
+
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
|
|
530
|
+
await this.fastGet(sftp, remotePath, local, onProgress)
|
|
531
|
+
return { bytes: statSync(local).size }
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** List a remote directory (file browser). */
|
|
536
|
+
async ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]> {
|
|
537
|
+
return this.withClient(alias, async (client) => {
|
|
538
|
+
const sftp = await this.sftp(client)
|
|
539
|
+
return await new Promise((resolve, reject) => {
|
|
540
|
+
sftp.readdir(path, (error, list) => {
|
|
541
|
+
if (error !== undefined) {
|
|
542
|
+
reject(error)
|
|
543
|
+
return
|
|
544
|
+
}
|
|
545
|
+
const entries: import('./protocol.ts').RemoteDirEntry[] = list.map(item => ({
|
|
546
|
+
name: item.filename,
|
|
547
|
+
type: item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other',
|
|
548
|
+
size: item.attrs.size,
|
|
549
|
+
mtimeMs: item.attrs.mtime * 1000,
|
|
550
|
+
mode: item.attrs.mode,
|
|
551
|
+
}))
|
|
552
|
+
resolve(entries)
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private sftp(client: Client): Promise<import('ssh2').SFTPWrapper> {
|
|
559
|
+
return new Promise((resolve, reject) => {
|
|
560
|
+
client.sftp((error, sftp) => error !== undefined ? reject(error) : resolve(sftp))
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
565
|
+
private ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void> {
|
|
566
|
+
return new Promise((resolve, reject) => {
|
|
567
|
+
const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
|
|
568
|
+
const walk = (index: number): void => {
|
|
569
|
+
if (index >= segments.length) {
|
|
570
|
+
resolve()
|
|
571
|
+
return
|
|
572
|
+
}
|
|
573
|
+
const current = '/' + segments.slice(0, index + 1).join('/')
|
|
574
|
+
sftp.stat(current, (statError) => {
|
|
575
|
+
if (statError === undefined) {
|
|
576
|
+
walk(index + 1)
|
|
577
|
+
return
|
|
578
|
+
}
|
|
579
|
+
// Statting a missing path fails; mkdir it (idempotent because the
|
|
580
|
+
// stat check runs first — some sftp servers throw on EEXIST).
|
|
581
|
+
sftp.mkdir(current, (mkdirError) => {
|
|
582
|
+
if (mkdirError !== undefined) {
|
|
583
|
+
reject(mkdirError)
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
walk(index + 1)
|
|
587
|
+
})
|
|
588
|
+
})
|
|
589
|
+
}
|
|
590
|
+
walk(0)
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
595
|
+
return new Promise((resolve, reject) => {
|
|
596
|
+
let last = 0
|
|
597
|
+
let lastEmit = 0
|
|
598
|
+
const started = Date.now()
|
|
599
|
+
onProgress?.({ phase: 'transferring', file: dst, transferred: 0, total: statSync(src).size, percent: 0 })
|
|
600
|
+
sftp.fastPut(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
601
|
+
const now = Date.now()
|
|
602
|
+
// Throttle: high-speed links fire one callback per chunk; the UI only
|
|
603
|
+
// needs ~10 frames per second.
|
|
604
|
+
if (now - lastEmit < 100 && transferred < total) return
|
|
605
|
+
lastEmit = now
|
|
606
|
+
const elapsed = (now - started) / 1000
|
|
607
|
+
onProgress?.({
|
|
608
|
+
phase: 'transferring',
|
|
609
|
+
file: dst,
|
|
610
|
+
transferred,
|
|
611
|
+
total,
|
|
612
|
+
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
613
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
614
|
+
})
|
|
615
|
+
last = transferred
|
|
616
|
+
} }, (error) => {
|
|
617
|
+
if (error !== undefined) {
|
|
618
|
+
onProgress?.({ phase: 'error', file: dst, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
619
|
+
reject(error)
|
|
620
|
+
} else {
|
|
621
|
+
onProgress?.({ phase: 'done', file: dst, transferred: statSync(src).size, total: statSync(src).size, percent: 100 })
|
|
622
|
+
resolve()
|
|
623
|
+
}
|
|
624
|
+
})
|
|
625
|
+
})
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
629
|
+
return new Promise((resolve, reject) => {
|
|
630
|
+
let last = 0
|
|
631
|
+
let lastEmit = 0
|
|
632
|
+
const started = Date.now()
|
|
633
|
+
sftp.fastGet(src, dst, { concurrency: this.opts.sftpConcurrency, step: (transferred: number, _chunk: number, total: number) => {
|
|
634
|
+
const now = Date.now()
|
|
635
|
+
if (now - lastEmit < 100 && transferred < total) return
|
|
636
|
+
lastEmit = now
|
|
637
|
+
const elapsed = (now - started) / 1000
|
|
638
|
+
onProgress?.({
|
|
639
|
+
phase: 'transferring',
|
|
640
|
+
file: src,
|
|
641
|
+
transferred,
|
|
642
|
+
total,
|
|
643
|
+
percent: total > 0 ? Math.round((transferred / total) * 1000) / 10 : 0,
|
|
644
|
+
speedBps: elapsed > 0 ? Math.round((transferred - last) / elapsed) : undefined,
|
|
645
|
+
})
|
|
646
|
+
last = transferred
|
|
647
|
+
} }, (error) => {
|
|
648
|
+
if (error !== undefined) {
|
|
649
|
+
onProgress?.({ phase: 'error', file: src, transferred: 0, total: 0, percent: 0, error: String(error) })
|
|
650
|
+
reject(error)
|
|
651
|
+
} else {
|
|
652
|
+
onProgress?.({ phase: 'done', file: src, transferred: statSync(dst).size, total: statSync(dst).size, percent: 100 })
|
|
653
|
+
resolve()
|
|
654
|
+
}
|
|
655
|
+
})
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// ------------------------------------------------------------- tunnel
|
|
660
|
+
|
|
661
|
+
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
662
|
+
async startTunnel(alias: string, options: { remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
|
|
663
|
+
if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) {
|
|
664
|
+
throw new Error('remotePort must be an integer in 1..65535')
|
|
665
|
+
}
|
|
666
|
+
if (options.localPort !== undefined && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) {
|
|
667
|
+
throw new Error('localPort must be an integer in 1..65535')
|
|
668
|
+
}
|
|
669
|
+
const entry = this.store.find(alias)
|
|
670
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
|
|
671
|
+
const remoteHost = options.remoteHost ?? '127.0.0.1'
|
|
672
|
+
const id = `tun-${this.nextTunnelId++}`
|
|
673
|
+
const info: TunnelInfo = {
|
|
674
|
+
id,
|
|
675
|
+
alias,
|
|
676
|
+
localPort: 0,
|
|
677
|
+
remoteHost,
|
|
678
|
+
remotePort: options.remotePort,
|
|
679
|
+
state: 'connecting',
|
|
680
|
+
startedAt: Date.now(),
|
|
681
|
+
}
|
|
682
|
+
const record = await this.acquire(alias)
|
|
683
|
+
const client = record.client
|
|
684
|
+
const sockets = new Set<import('node:net').Socket>()
|
|
685
|
+
const server = createServer((socket) => {
|
|
686
|
+
sockets.add(socket)
|
|
687
|
+
socket.on('close', () => { sockets.delete(socket) })
|
|
688
|
+
client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
|
|
689
|
+
if (error !== undefined) {
|
|
690
|
+
socket.destroy()
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
// Both ends of the pipe can die independently; destroy the pair so an
|
|
694
|
+
// unhandled 'error' event can never crash the host process.
|
|
695
|
+
const destroy = (): void => {
|
|
696
|
+
try { socket.destroy() } catch { /* gone */ }
|
|
697
|
+
try { stream.close() } catch { /* gone */ }
|
|
698
|
+
}
|
|
699
|
+
stream.on('error', destroy)
|
|
700
|
+
socket.on('error', destroy)
|
|
701
|
+
stream.on('close', destroy)
|
|
702
|
+
socket.on('close', destroy)
|
|
703
|
+
stream.pipe(socket).pipe(stream)
|
|
704
|
+
})
|
|
705
|
+
})
|
|
706
|
+
try {
|
|
707
|
+
await new Promise<void>((resolve, reject) => {
|
|
708
|
+
server.once('error', reject)
|
|
709
|
+
server.listen(options.localPort ?? 0, '127.0.0.1', () => {
|
|
710
|
+
server.removeListener('error', reject)
|
|
711
|
+
resolve()
|
|
712
|
+
})
|
|
713
|
+
})
|
|
714
|
+
} catch (error) {
|
|
715
|
+
// Roll back: never leave an unpinned orphan connection behind.
|
|
716
|
+
if (!record.pinned && record.inFlight === 0) this.disposeRecord(alias, record)
|
|
717
|
+
throw error
|
|
718
|
+
}
|
|
719
|
+
record.pinned = true
|
|
720
|
+
const address = server.address()
|
|
721
|
+
info.localPort = typeof address === 'object' && address !== null ? address.port : 0
|
|
722
|
+
info.state = 'forwarding'
|
|
723
|
+
this.tunnels.set(id, { info, server, alias, sockets })
|
|
724
|
+
return info
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** All active tunnels. */
|
|
728
|
+
listTunnels(): TunnelInfo[] {
|
|
729
|
+
return [...this.tunnels.values()].map(tunnel => ({ ...tunnel.info }))
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** Stop one tunnel (closes the listener, live sockets, and the pinned connection). */
|
|
733
|
+
stopTunnel(id: string): boolean {
|
|
734
|
+
const tunnel = this.tunnels.get(id)
|
|
735
|
+
if (tunnel === undefined) return false
|
|
736
|
+
this.tunnels.delete(id)
|
|
737
|
+
try { tunnel.server.close() } catch { /* already closed */ }
|
|
738
|
+
for (const socket of tunnel.sockets) {
|
|
739
|
+
try { socket.destroy() } catch { /* already closed */ }
|
|
740
|
+
}
|
|
741
|
+
tunnel.sockets.clear()
|
|
742
|
+
this.disposeRecord(tunnel.alias)
|
|
743
|
+
return true
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Stop all tunnels (optionally for one alias). */
|
|
747
|
+
stopAllTunnels(alias?: string): number {
|
|
748
|
+
let count = 0
|
|
749
|
+
for (const [id, tunnel] of [...this.tunnels]) {
|
|
750
|
+
if (alias === undefined || tunnel.alias === alias) {
|
|
751
|
+
this.stopTunnel(id)
|
|
752
|
+
count += 1
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return count
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ------------------------------------------------------------- misc
|
|
759
|
+
|
|
760
|
+
/** Probe connectivity: connect, run `true`, close. */
|
|
761
|
+
async test(alias: string): Promise<TestResult> {
|
|
762
|
+
const started = Date.now()
|
|
763
|
+
try {
|
|
764
|
+
const result = await this.exec(alias, 'true', 10_000)
|
|
765
|
+
return result.success
|
|
766
|
+
? { ok: true, latencyMs: result.durationMs }
|
|
767
|
+
: { ok: false, latencyMs: result.durationMs, error: `remote exit code ${result.exitCode}` }
|
|
768
|
+
} catch (error) {
|
|
769
|
+
return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Close every pooled connection and tunnel. */
|
|
774
|
+
dispose(): void {
|
|
775
|
+
if (this.sweepTimer !== undefined) clearInterval(this.sweepTimer)
|
|
776
|
+
for (const id of [...this.tunnels.keys()]) this.stopTunnel(id)
|
|
777
|
+
for (const alias of [...this.pool.keys()]) this.disposeRecord(alias)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|