@tiphareth/dsh-hardssh 0.1.2 → 0.2.2
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/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/ssh/engine.ts
CHANGED
|
@@ -1,168 +1,104 @@
|
|
|
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
4
|
* local port-forward tunnels and cluster execution —the DSH counterpart of
|
|
5
5
|
* ssh-skill's daemon + scripts, living entirely in the host process.
|
|
6
|
+
*
|
|
7
|
+
* This module stays the public entry point (its export surface and the
|
|
8
|
+
* SshEngine class identity are load-bearing: production keys caches on the
|
|
9
|
+
* engine object in a WeakMap). The implementation lives in focused internal
|
|
10
|
+
* components, each owning one resource family:
|
|
11
|
+
*
|
|
12
|
+
* connection/manager.ts pooled connections, connect chains, secrets,
|
|
13
|
+
* retry/replay policy, exec/cluster/test, redaction
|
|
14
|
+
* sftp/service.ts SFTP subsystem channel cache + every SFTP operation
|
|
15
|
+
* terminal/service.ts standalone PTY shell / streaming exec transports
|
|
16
|
+
* tunnel/service.ts local listeners, forwarded sockets, tunnel leases
|
|
17
|
+
*
|
|
18
|
+
* Dependency direction is one-way (sftp/terminal/tunnel → connection); no
|
|
19
|
+
* component imports this facade back, so there is no cycle.
|
|
6
20
|
*/
|
|
7
21
|
|
|
8
|
-
import
|
|
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'
|
|
22
|
+
import type { Writable } from 'node:stream'
|
|
12
23
|
import { BoundedUtf8Output } from './exec/output.ts'
|
|
13
|
-
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
import type { ClusterResult, ExecResult, SshHostEntry, SshHostSummary, TestResult, TransferProgress, TunnelInfo } from './protocol.ts'
|
|
17
|
-
import { expandHome, type HostStore } from './store.ts'
|
|
18
|
-
import type { HostStoreView } from '../core.ts'
|
|
24
|
+
// POSIX single-quoting for the server-budget wrapper below.
|
|
25
|
+
import { shellQuote } from '../shell.ts'
|
|
26
|
+
import type { SshConnectionService } from './connection/pool.ts'
|
|
19
27
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
type
|
|
24
|
-
type
|
|
25
|
-
} from './
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* transitively, on the changed alias.
|
|
32
|
-
*/
|
|
33
|
-
includeDependents?: boolean
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* drain: reject/reconnect subsequent acquisitions, allow existing leases
|
|
37
|
-
* to complete before closing their transport.
|
|
38
|
-
* force: close the current transport immediately.
|
|
39
|
-
*/
|
|
40
|
-
mode?: 'drain' | 'force'
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Default engine knobs. */
|
|
44
|
-
export interface EngineOptions {
|
|
45
|
-
/** Connections idle longer than this are closed (ms). */
|
|
46
|
-
idleTimeoutMs?: number
|
|
47
|
-
/** SSH handshake timeout (ms). */
|
|
48
|
-
connectTimeoutMs?: number
|
|
49
|
-
/** Keepalive ping interval (ms). */
|
|
50
|
-
keepaliveIntervalMs?: number
|
|
51
|
-
/** Cap on captured stdout/stderr bytes per exec (ms). */
|
|
52
|
-
maxOutputBytes?: number
|
|
53
|
-
/** Default exec timeout (ms). */
|
|
54
|
-
defaultExecTimeoutMs?: number
|
|
55
|
-
/** Default cluster concurrency. */
|
|
56
|
-
defaultMaxWorkers?: number
|
|
57
|
-
/** SFTP concurrent channel count for transfers. */
|
|
58
|
-
sftpConcurrency?: number
|
|
59
|
-
/** Optional server-host-key algorithm whitelist (e.g. ['ssh-ed25519']). */
|
|
60
|
-
hostKeyAlgorithms?: string[]
|
|
61
|
-
}
|
|
28
|
+
ConnectionManager,
|
|
29
|
+
type EngineDeps,
|
|
30
|
+
type EngineOptions,
|
|
31
|
+
type ExecOptions,
|
|
32
|
+
type SshInvalidateOptions,
|
|
33
|
+
} from './connection/manager.ts'
|
|
34
|
+
import { SftpService } from './sftp/service.ts'
|
|
35
|
+
import { TerminalService } from './terminal/service.ts'
|
|
36
|
+
import { TunnelService } from './tunnel/service.ts'
|
|
37
|
+
import type { SshHostSummary, TransferProgress, TunnelInfo } from './protocol.ts'
|
|
38
|
+
import type { HostStoreView } from '../core.ts'
|
|
62
39
|
|
|
63
40
|
/**
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* existing tests and call sites keep working unchanged.
|
|
41
|
+
* How long a timed-out channel gets to acknowledge its close before it counts
|
|
42
|
+
* as half-open. Peers that honor KILL/close finish in milliseconds; only a
|
|
43
|
+
* genuinely stuck peer (or a dropped transport) leaves the channel open.
|
|
68
44
|
*/
|
|
69
|
-
|
|
70
|
-
/** Known-hosts trust store; when set, connections require a trusted host key. */
|
|
71
|
-
knownHosts?: KnownHostsStore
|
|
72
|
-
/** Fingerprint check policy (defaults to a HostKeyPolicy over knownHosts). */
|
|
73
|
-
hostKeyPolicy?: HostKeyPolicy
|
|
74
|
-
/**
|
|
75
|
-
* Secret resolution for one entry. Absent: read `password`/`passphrase`
|
|
76
|
-
* inline from the entry (plaintext store / test compatibility).
|
|
77
|
-
*/
|
|
78
|
-
resolveSecrets?: (entry: SshHostEntry) => Promise<ResolvedAuthDeps>
|
|
79
|
-
/** Optional server-host-key algorithm whitelist override. */
|
|
80
|
-
hostKeyAlgorithms?: string[]
|
|
81
|
-
}
|
|
45
|
+
const CHANNEL_CLOSE_GRACE_MS = 5_000
|
|
82
46
|
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
password?: string
|
|
88
|
-
passphrase?: string
|
|
89
|
-
}
|
|
47
|
+
/** Extra seconds the SERVER-side budget gets over the client one, so the
|
|
48
|
+
* client's own deadline normally fires first (with its `timedOut` result) and
|
|
49
|
+
* `timeout` only becomes the janitor that reaps the process group. */
|
|
50
|
+
const SERVER_BUDGET_SLACK_SECONDS = 1
|
|
90
51
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
* available in this session (secretStorage='none' and the user hasn't entered
|
|
94
|
-
* it yet). The GUI intercepts this and prompts for the credential, then
|
|
95
|
-
* injects it via engine.setSessionPassword and retries.
|
|
96
|
-
*/
|
|
97
|
-
export class NeedsPasswordError extends Error {
|
|
98
|
-
/** Which secret the connection needs: 'password' or 'passphrase'. */
|
|
99
|
-
readonly secret: 'password' | 'passphrase'
|
|
100
|
-
constructor(alias: string, secret: 'password' | 'passphrase') {
|
|
101
|
-
super(`SSH 连接 '${alias}' 需要${secret === 'password' ? '密码' : '密钥口令'},请先输入一次(本会话内复用,不会保存)`)
|
|
102
|
-
this.name = 'NeedsPasswordError'
|
|
103
|
-
this.secret = secret
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const DEFAULTS: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> = {
|
|
108
|
-
idleTimeoutMs: 30 * 60_000,
|
|
109
|
-
connectTimeoutMs: 15_000,
|
|
110
|
-
keepaliveIntervalMs: 15_000,
|
|
111
|
-
maxOutputBytes: 2 * 1024 * 1024,
|
|
112
|
-
defaultExecTimeoutMs: 60_000,
|
|
113
|
-
defaultMaxWorkers: 8,
|
|
114
|
-
sftpConcurrency: 8,
|
|
115
|
-
hostKeyAlgorithms: undefined,
|
|
116
|
-
}
|
|
52
|
+
/** Seconds `timeout` waits after SIGTERM before it escalates to SIGKILL. */
|
|
53
|
+
const SERVER_KILL_GRACE_SECONDS = 2
|
|
117
54
|
|
|
118
55
|
/**
|
|
119
|
-
*
|
|
120
|
-
* - never: one acquisition + one operation attempt.
|
|
121
|
-
* - connect-only: connection acquisition may be retried, but once the
|
|
122
|
-
* operation function starts it is invoked at most once (default).
|
|
123
|
-
* - idempotent: the operation may also be retried until it calls
|
|
124
|
-
* markCommitted() (i.e. while the exec channel is still opening).
|
|
56
|
+
* Enforce an exec budget ON THE SERVER as well as in the client.
|
|
125
57
|
*
|
|
126
|
-
*
|
|
127
|
-
* a
|
|
58
|
+
* The client-side deadline only stops WAITING: measured against a real host
|
|
59
|
+
* (a CentOS 7 host running OpenSSH 7.4), a timed-out exec left BOTH the wrapper
|
|
60
|
+
* shell and its child running — `bash -c sleep 30` plus `sleep 30` were still
|
|
61
|
+
* alive 20s later — because ssh2's channel `signal` request was not delivered
|
|
62
|
+
* to the remote process, and closing the channel does not kill it either.
|
|
63
|
+
* Wrapping the command in coreutils `timeout` moves the deadline INSIDE the
|
|
64
|
+
* session, which does reap the whole process group (verified on the same host:
|
|
65
|
+
* exit 124 within the budget, zero orphans).
|
|
66
|
+
*
|
|
67
|
+
* The wrapper degrades to running the command verbatim when the server has no
|
|
68
|
+
* `timeout` (busybox or a minimal image), so no host becomes unusable.
|
|
69
|
+
*
|
|
70
|
+
* @param command - the caller's command, run unchanged inside the wrapper.
|
|
71
|
+
* @param budgetMs - the client-side budget this mirrors.
|
|
72
|
+
* @returns the command to hand to the remote shell.
|
|
128
73
|
*/
|
|
129
|
-
export
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
74
|
+
export function wrapCommandWithServerBudget(command: string, budgetMs: number): string {
|
|
75
|
+
if (!Number.isFinite(budgetMs) || budgetMs <= 0) return command
|
|
76
|
+
const seconds = Math.ceil(budgetMs / 1000) + SERVER_BUDGET_SLACK_SECONDS
|
|
77
|
+
const quoted = shellQuote(command)
|
|
78
|
+
const shell = '"${SHELL:-/bin/sh}"'
|
|
79
|
+
return 'if command -v timeout >/dev/null 2>&1; then '
|
|
80
|
+
+ `timeout -k ${SERVER_KILL_GRACE_SECONDS} ${seconds} ${shell} -c ${quoted}; `
|
|
81
|
+
+ `else ${command}; fi`
|
|
135
82
|
}
|
|
136
83
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const SFTP_READ_TIMEOUT_MS = 60_000
|
|
156
|
-
/** A fresh SSH channel (shell/exec) must open within this budget; a dead or
|
|
157
|
-
* half-open connection would otherwise leave the open promise hanging. */
|
|
158
|
-
const CHANNEL_OPEN_TIMEOUT_MS = 10_000
|
|
159
|
-
/** Symlink stat batch width — parallelized so a dir full of links (conda /
|
|
160
|
-
* venv bin, node_modules/.bin) costs a handful of round-trips, not N. ssh2's
|
|
161
|
-
* SFTP window pipelines requests, so one batch ≈ one round-trip. */
|
|
162
|
-
const SYMLINK_STAT_BATCH = 64
|
|
163
|
-
/** A local tunnel socket must obtain its SSH forward channel within this
|
|
164
|
-
* budget; half-open transports can otherwise leave the socket hanging. */
|
|
165
|
-
const TUNNEL_FORWARD_TIMEOUT_MS = 10_000
|
|
84
|
+
// Public surface kept on this path: these declarations live in the connection
|
|
85
|
+
// component (single source of truth) and are re-exported here so every
|
|
86
|
+
// existing `from './engine.ts'` / `from '.../engine.ts'` import keeps working.
|
|
87
|
+
export {
|
|
88
|
+
buildConnectConfig,
|
|
89
|
+
DEFAULTS,
|
|
90
|
+
NeedsPasswordError,
|
|
91
|
+
sshAgentConfig,
|
|
92
|
+
} from './connection/manager.ts'
|
|
93
|
+
export type {
|
|
94
|
+
ConnectionManager,
|
|
95
|
+
EngineDeps,
|
|
96
|
+
EngineOptions,
|
|
97
|
+
ExecOptions,
|
|
98
|
+
ResolvedAuthDeps,
|
|
99
|
+
RetryPolicy,
|
|
100
|
+
SshInvalidateOptions,
|
|
101
|
+
} from './connection/manager.ts'
|
|
166
102
|
|
|
167
103
|
/** A live PTY shell session. */
|
|
168
104
|
export interface ShellSession {
|
|
@@ -190,307 +126,105 @@ export interface ShellSession {
|
|
|
190
126
|
* input burst. Used by the subprocess capability seam's remote provider.
|
|
191
127
|
*/
|
|
192
128
|
export interface ExecSession extends ShellSession {
|
|
129
|
+
/** Native ssh2 channel input. Using its real Writable contract preserves
|
|
130
|
+
* bytes, backpressure, errors and finish semantics for subprocess stdin. */
|
|
131
|
+
readonly stdin: Writable
|
|
193
132
|
/** Assign to receive the remote stderr stream. */
|
|
194
133
|
onErrData?: (data: Buffer) => void
|
|
195
134
|
/** Send an SSH signal (e.g. 'TERM', 'KILL') to the remote process. */
|
|
196
135
|
signal(name: string): void
|
|
197
|
-
/**
|
|
136
|
+
/** Compatibility half-close for non-streaming callers. */
|
|
198
137
|
end(data?: string): void
|
|
199
138
|
}
|
|
200
139
|
|
|
201
|
-
/** One active tunnel record (server + its connection lease + live sockets). */
|
|
202
|
-
interface TunnelRecord {
|
|
203
|
-
info: TunnelInfo
|
|
204
|
-
server: NetServer
|
|
205
|
-
alias: string
|
|
206
|
-
lease: ClientLease
|
|
207
|
-
sockets: Set<import('node:net').Socket>
|
|
208
|
-
/** Registered for BOTH Client 'error' and 'close'; cleanup removes both. */
|
|
209
|
-
clientFailureHandler: (error?: unknown) => void
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** One host-key verification outcome captured during a connect attempt (used
|
|
213
|
-
* to rewrite the generic handshake failure into a typed host-key error). */
|
|
214
|
-
interface HostKeyOutcome {
|
|
215
|
-
alias: string
|
|
216
|
-
check: HostKeyCheck
|
|
217
|
-
}
|
|
218
|
-
|
|
219
140
|
/**
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
* exported by Git for Windows' ssh-agent and WSL). Deliberately NOT probing
|
|
223
|
-
* named pipes (Pageant / Windows OpenSSH agent): an absent pipe makes ssh2's
|
|
224
|
-
* agent query stall the whole handshake until readyTimeout instead of
|
|
225
|
-
* falling through to the next method. Keep Pageant compatibility for a
|
|
226
|
-
* future explicit opt-in. An agent that yields no keys makes ssh2 fall
|
|
227
|
-
* through to the configured methods (privateKey → password), so enabling it
|
|
228
|
-
* when a socket is present is safe. Exported for tests.
|
|
229
|
-
*/
|
|
230
|
-
export function sshAgentConfig(): string | undefined {
|
|
231
|
-
const sock = process.env.SSH_AUTH_SOCK
|
|
232
|
-
if (sock !== undefined && sock.trim() !== '') return sock
|
|
233
|
-
return undefined
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
/** Detect whether an OpenSSH/PEM private key file is passphrase-encrypted.
|
|
237
|
-
* OpenSSH-format keys keep the cipher/kdf strings in PLAINTEXT inside the
|
|
238
|
-
* base64 payload ('bcrypt' kdf ⇒ encrypted, 'none' ⇒ plain); PEM keys carry
|
|
239
|
-
* "Proc-Type: 4,ENCRYPTED". Used to prompt for a missing passphrase. */
|
|
240
|
-
function keyNeedsPassphrase(keyPath: string): boolean {
|
|
241
|
-
try {
|
|
242
|
-
const text = readFileSync(keyPath, 'utf8')
|
|
243
|
-
if (/Proc-Type:\s*4,ENCRYPTED/i.test(text)) return true
|
|
244
|
-
if (text.includes('OPENSSH PRIVATE KEY')) {
|
|
245
|
-
const base64 = text.replace(/-----[^-]*-----/g, '').replace(/\s+/g, '')
|
|
246
|
-
const header = Buffer.from(base64, 'base64').toString('latin1', 0, 512)
|
|
247
|
-
return header.includes('bcrypt')
|
|
248
|
-
}
|
|
249
|
-
return false
|
|
250
|
-
} catch {
|
|
251
|
-
return false
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/** Build the ssh2 connect config for one entry (key read from disk). The
|
|
256
|
-
* timeout/keepalive knobs come from EngineOptions so they actually take
|
|
257
|
-
* effect instead of being hard-coded. Exported for tests. */
|
|
258
|
-
export function buildConnectConfig(
|
|
259
|
-
entry: SshHostEntry,
|
|
260
|
-
options: Pick<Required<EngineOptions>, 'connectTimeoutMs' | 'keepaliveIntervalMs'>,
|
|
261
|
-
sock?: ConnectConfig['sock'],
|
|
262
|
-
buildContext: {
|
|
263
|
-
hostKeyPolicy?: HostKeyPolicy
|
|
264
|
-
hostKeyAlgorithms?: string[]
|
|
265
|
-
/** Writes the verified/refused outcome back to the caller's capture slot. */
|
|
266
|
-
setOutcome?: (value: HostKeyOutcome) => void
|
|
267
|
-
/** Vault-resolved authentication (overrides entry.auth secrets). */
|
|
268
|
-
authOverride?: ResolvedAuthDeps
|
|
269
|
-
} = {},
|
|
270
|
-
): ConnectConfig {
|
|
271
|
-
const config: ConnectConfig = {
|
|
272
|
-
host: entry.host,
|
|
273
|
-
port: entry.port,
|
|
274
|
-
username: entry.user,
|
|
275
|
-
readyTimeout: options.connectTimeoutMs,
|
|
276
|
-
keepaliveInterval: options.keepaliveIntervalMs,
|
|
277
|
-
keepaliveCountMax: 3,
|
|
278
|
-
}
|
|
279
|
-
if (sock !== undefined) config.sock = sock
|
|
280
|
-
const agent = sshAgentConfig()
|
|
281
|
-
if (agent !== undefined) config.agent = agent
|
|
282
|
-
if (buildContext.hostKeyPolicy !== undefined) {
|
|
283
|
-
config.hostVerifier = (serverKey: Buffer) => {
|
|
284
|
-
const check = buildContext.hostKeyPolicy!.check(entry.alias, serverKey)
|
|
285
|
-
buildContext.setOutcome?.({ alias: entry.alias, check })
|
|
286
|
-
return check.kind === 'trusted'
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
if (buildContext.hostKeyAlgorithms !== undefined && buildContext.hostKeyAlgorithms.length > 0) {
|
|
290
|
-
config.algorithms = { serverHostKey: buildContext.hostKeyAlgorithms as import('ssh2').ServerHostKeyAlgorithm[] }
|
|
291
|
-
}
|
|
292
|
-
const auth = buildContext.authOverride
|
|
293
|
-
?? { kind: entry.auth.kind, keyPath: entry.auth.keyPath, password: entry.auth.password, passphrase: entry.auth.passphrase }
|
|
294
|
-
if (auth.kind === 'password') {
|
|
295
|
-
config.password = auth.password
|
|
296
|
-
} else {
|
|
297
|
-
const keyPath = auth.keyPath === undefined ? undefined : expandHome(auth.keyPath)
|
|
298
|
-
if (keyPath !== undefined && keyPath !== '' && existsSync(keyPath)) {
|
|
299
|
-
config.privateKey = readFileSync(keyPath, 'utf8')
|
|
300
|
-
if (auth.passphrase !== undefined && auth.passphrase !== '') {
|
|
301
|
-
config.passphrase = auth.passphrase
|
|
302
|
-
}
|
|
303
|
-
} else if (agent === undefined) {
|
|
304
|
-
// No key file AND no agent to fall back on — fail before the
|
|
305
|
-
// handshake with a precise message instead of a generic auth failure.
|
|
306
|
-
throw new Error(`private key not found: '${auth.keyPath ?? '(unset)'}' and no ssh-agent is available (set SSH_AUTH_SOCK, or configure a key path)`)
|
|
307
|
-
}
|
|
308
|
-
// Else: the key path is unset or missing but an agent is available —
|
|
309
|
-
// leave privateKey unset so ssh2 authenticates from the agent's keys
|
|
310
|
-
// (zero input, the VSCode Remote-SSH way).
|
|
311
|
-
}
|
|
312
|
-
return config
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* Connect one ssh2 client (resolve on ready, reject on error/close). A hard
|
|
317
|
-
* `timeoutMs` bounds the WHOLE connect phase: ssh2's own `readyTimeout` only
|
|
318
|
-
* starts ticking after the TCP socket is up, so a SYN-level hang (filtered
|
|
319
|
-
* port, dead route, half-open middlebox) would otherwise stall the promise
|
|
320
|
-
* forever — which hangs every caller (exec, openShell, tunnels). On timeout
|
|
321
|
-
* the socket is destroyed and the promise rejects.
|
|
141
|
+
* The SSH engine. Owns the connection pool, tunnels, standalone terminal
|
|
142
|
+
* transports and SFTP channels. One instance per plugin apply.
|
|
322
143
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* surface the fingerprint directly.
|
|
327
|
-
*/
|
|
328
|
-
function connectClient(
|
|
329
|
-
config: ConnectConfig,
|
|
330
|
-
timeoutMs: number,
|
|
331
|
-
context: { outcome?: HostKeyOutcome | undefined } = {},
|
|
332
|
-
): Promise<Client> {
|
|
333
|
-
return new Promise((resolve, reject) => {
|
|
334
|
-
const client = new Client()
|
|
335
|
-
let settled = false
|
|
336
|
-
const timer = setTimeout(() => {
|
|
337
|
-
if (settled) return
|
|
338
|
-
settled = true
|
|
339
|
-
const err = new Error(`SSH connect to ${config.host}:${config.port} (${config.username}) timed out after ${timeoutMs} ms`)
|
|
340
|
-
try { client.destroy() } catch { /* already closed */ }
|
|
341
|
-
reject(err)
|
|
342
|
-
}, timeoutMs)
|
|
343
|
-
timer.unref?.()
|
|
344
|
-
const settle = (fn: () => void): void => {
|
|
345
|
-
if (settled) return
|
|
346
|
-
settled = true
|
|
347
|
-
clearTimeout(timer)
|
|
348
|
-
fn()
|
|
349
|
-
}
|
|
350
|
-
client.once('ready', () => settle(() => resolve(client)))
|
|
351
|
-
client.once('error', (error) => {
|
|
352
|
-
const raw = error instanceof Error ? error : new Error(String(error))
|
|
353
|
-
settle(() => reject(rewriteHostKeyError(raw, context.outcome)))
|
|
354
|
-
})
|
|
355
|
-
// A server that drops the socket before 'ready' (e.g. during auth or a
|
|
356
|
-
// failed acquire) emits 'close' without 'error' — fail fast instead of
|
|
357
|
-
// waiting out the whole connect timeout.
|
|
358
|
-
client.once('close', () => settle(() => reject(rewriteHostKeyError(
|
|
359
|
-
new Error(`SSH connection to ${config.host}:${config.port} (${config.username}) closed before ready`),
|
|
360
|
-
context.outcome,
|
|
361
|
-
))))
|
|
362
|
-
try {
|
|
363
|
-
client.connect(config)
|
|
364
|
-
} catch (error) {
|
|
365
|
-
const raw = error instanceof Error ? error : new Error(String(error))
|
|
366
|
-
settle(() => reject(rewriteHostKeyError(raw, context.outcome)))
|
|
367
|
-
}
|
|
368
|
-
})
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** Rewrite a raw connect failure into a typed host-key error when the
|
|
372
|
-
* hostVerifier refused the server key (unknown or mismatch). */
|
|
373
|
-
function rewriteHostKeyError(raw: Error, outcome: HostKeyOutcome | undefined): Error {
|
|
374
|
-
if (outcome?.check.kind === 'unknown') {
|
|
375
|
-
return new HostKeyUnknownError(outcome.alias, outcome.check.fingerprintSha256)
|
|
376
|
-
}
|
|
377
|
-
if (outcome?.check.kind === 'mismatch') {
|
|
378
|
-
return new HostKeyMismatchError(outcome.alias, outcome.check.expected, outcome.check.actual)
|
|
379
|
-
}
|
|
380
|
-
return raw
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function walkLocalDir(root: string): string[] {
|
|
384
|
-
const files: string[] = []
|
|
385
|
-
const visit = (dir: string): void => {
|
|
386
|
-
for (const name of readdirSync(dir)) {
|
|
387
|
-
const full = join(dir, name)
|
|
388
|
-
const stat = statSync(full)
|
|
389
|
-
if (stat.isDirectory()) visit(full)
|
|
390
|
-
else if (stat.isFile()) files.push(relative(root, full))
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
visit(root)
|
|
394
|
-
return files
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* The engine. Owns the pool, tunnels, and all operations. One instance per
|
|
399
|
-
* plugin apply; dispose() closes every connection.
|
|
144
|
+
* Kept as the public facade over ConnectionManager/TunnelService/
|
|
145
|
+
* TerminalService/SftpService: every method here is a thin delegation, so the
|
|
146
|
+
* class identity (and therefore WeakMap cache keys in production) is stable.
|
|
400
147
|
*/
|
|
401
148
|
export class SshEngine {
|
|
402
|
-
private readonly
|
|
403
|
-
private readonly
|
|
404
|
-
private readonly
|
|
405
|
-
|
|
406
|
-
* One cached SFTP subsystem channel per live client. `Client.sftp()` opens a
|
|
407
|
-
* NEW subsystem channel on every call and OpenSSH caps open sessions per
|
|
408
|
-
* connection (MaxSessions, default 10) —reopening SFTP per operation lets
|
|
409
|
-
* channels pile up on the pooled long-lived connection until listing/reading
|
|
410
|
-
* fails intermittently. Caching one channel per client fixes that; the pool
|
|
411
|
-
* drops the cache via onDispose when a connection is torn down.
|
|
412
|
-
*/
|
|
413
|
-
private readonly sftpChannels = new Map<Client, Promise<import('ssh2').SFTPWrapper>>()
|
|
414
|
-
private readonly connectionPool: SshConnectionService
|
|
415
|
-
private readonly deps: EngineDeps
|
|
416
|
-
private readonly hostKeyPolicy: HostKeyPolicy | undefined
|
|
417
|
-
private nextTunnelId = 1
|
|
418
|
-
/**
|
|
419
|
-
* Session-scoped secrets (secretStorage='none'): keyed by alias, populated
|
|
420
|
-
* by the GUI on first connect, used by connectChain's resolve step, and
|
|
421
|
-
* cleared on dispose. Never persisted.
|
|
422
|
-
*/
|
|
423
|
-
private readonly sessionPasswords = new Map<string, { password?: string; passphrase?: string }>()
|
|
149
|
+
private readonly manager: ConnectionManager
|
|
150
|
+
private readonly sftpService: SftpService
|
|
151
|
+
private readonly tunnelService: TunnelService
|
|
152
|
+
private readonly terminalService: TerminalService
|
|
424
153
|
|
|
425
154
|
/**
|
|
426
155
|
* @param store - the host config store.
|
|
427
|
-
* @param options - engine knobs (defaults applied).
|
|
156
|
+
* @param options - engine knobs (defaults applied after validation).
|
|
428
157
|
* @param deps - optional security deps (host-key TOFU, secret resolution).
|
|
429
158
|
* Absent → pre-security behavior (inline auth, no host verification).
|
|
430
159
|
*/
|
|
431
160
|
constructor(store: HostStoreView, options?: EngineOptions, deps?: EngineDeps) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
161
|
+
// One frozen source of truth for timer/concurrency knobs.
|
|
162
|
+
const resolved = ConnectionManager.resolveOptions(options)
|
|
163
|
+
// The pool is constructed inside the manager, which is why it takes the
|
|
164
|
+
// SFTP eviction hook rather than reaching for the SFTP service itself.
|
|
165
|
+
this.manager = new ConnectionManager(store, resolved, deps, {
|
|
166
|
+
onClientDisposed: (client, error) => { this.sftpService?.onClientDisposed(client, error) },
|
|
167
|
+
redactOutput: text => deps?.redactOutput?.(text) ?? text,
|
|
168
|
+
// Bound so cluster()/test() fan out through the same engine.exec
|
|
169
|
+
// identity that callers observe (and tests can spy on).
|
|
170
|
+
executor: (alias, command, execOptions) => execOptions === undefined
|
|
171
|
+
? this.exec(alias, command)
|
|
172
|
+
: this.exec(alias, command, execOptions),
|
|
173
|
+
})
|
|
174
|
+
this.sftpService = new SftpService(
|
|
175
|
+
{
|
|
176
|
+
acquire: (alias, acquireOptions) => this.manager.connections.acquire(alias, acquireOptions),
|
|
177
|
+
withClient: (alias, fn, withOptions) => this.manager.withClient(alias, fn, withOptions),
|
|
443
178
|
},
|
|
444
|
-
|
|
445
|
-
|
|
179
|
+
resolved,
|
|
180
|
+
)
|
|
181
|
+
this.tunnelService = new TunnelService({
|
|
182
|
+
connections: this.manager.connections,
|
|
183
|
+
findEntry: alias => store.find(alias),
|
|
184
|
+
})
|
|
185
|
+
this.terminalService = new TerminalService({
|
|
186
|
+
connectStandalone: async (alias) => {
|
|
187
|
+
const entry = store.find(alias)
|
|
188
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found —add it first`)
|
|
189
|
+
return await this.manager.connectChain(entry)
|
|
446
190
|
},
|
|
447
191
|
})
|
|
448
192
|
}
|
|
449
193
|
|
|
450
194
|
// ---------------------------------------------------------- session secrets
|
|
451
195
|
|
|
452
|
-
/**
|
|
453
|
-
* Provide a secret for `alias` for THIS session only (never persisted).
|
|
454
|
-
* Used by the GUI when a connection needs a password/passphrase under
|
|
455
|
-
* secretStorage='none'. Once set, pooled connections reuse it until the
|
|
456
|
-
* session ends or clearSessionSecrets() is called.
|
|
457
|
-
*/
|
|
196
|
+
/** Provide a secret for `alias` for THIS session only (never persisted). */
|
|
458
197
|
setSessionPassword(alias: string, secret: { password?: string; passphrase?: string }): void {
|
|
459
|
-
this.
|
|
198
|
+
this.manager.setSessionPassword(alias, secret)
|
|
460
199
|
}
|
|
461
200
|
|
|
462
201
|
/** Read the session secret for one alias (undefined = not provided yet). */
|
|
463
202
|
getSessionPassword(alias: string): { password?: string; passphrase?: string } | undefined {
|
|
464
|
-
return this.
|
|
203
|
+
return this.manager.getSessionPassword(alias)
|
|
465
204
|
}
|
|
466
205
|
|
|
467
206
|
/** Drop every session secret (e.g. on secretStorage change / lock). */
|
|
468
207
|
clearSessionSecrets(): void {
|
|
469
|
-
this.
|
|
208
|
+
this.manager.clearSessionSecrets()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Redact credentials known to this session and the optional vault guard. */
|
|
212
|
+
redact(text: string): string {
|
|
213
|
+
return this.manager.redact(text)
|
|
470
214
|
}
|
|
471
215
|
|
|
472
216
|
// ---------------------------------------------------------------- config
|
|
473
217
|
|
|
474
218
|
/** Secret-free host list (filtered by the optional query). */
|
|
475
219
|
list(query?: string): SshHostSummary[] {
|
|
476
|
-
|
|
477
|
-
return this.store.list()
|
|
478
|
-
.filter(entry => needle === undefined || needle === ''
|
|
479
|
-
|| entry.alias.toLowerCase().includes(needle)
|
|
480
|
-
|| (entry.description ?? '').toLowerCase().includes(needle)
|
|
481
|
-
|| entry.host.toLowerCase().includes(needle)
|
|
482
|
-
|| entry.tags.some(tag => tag.toLowerCase().includes(needle)))
|
|
483
|
-
.map(entry => this.store.summarize(entry))
|
|
220
|
+
return this.manager.list(query)
|
|
484
221
|
}
|
|
485
222
|
|
|
486
223
|
/** One host summary by alias. */
|
|
487
224
|
find(alias: string): SshHostSummary | undefined {
|
|
488
|
-
|
|
489
|
-
return entry === undefined ? undefined : this.store.summarize(entry)
|
|
225
|
+
return this.manager.find(alias)
|
|
490
226
|
}
|
|
491
227
|
|
|
492
|
-
// -------------------------------------------------------------- pool
|
|
493
|
-
|
|
494
228
|
/**
|
|
495
229
|
* Shared connection/lease service.
|
|
496
230
|
*
|
|
@@ -499,281 +233,41 @@ export class SshEngine {
|
|
|
499
233
|
* separately owned pool.
|
|
500
234
|
*/
|
|
501
235
|
get connections(): SshConnectionService {
|
|
502
|
-
return this.
|
|
236
|
+
return this.manager.connections
|
|
503
237
|
}
|
|
504
238
|
|
|
505
239
|
/** Aliases with a live pooled transport right now (for connection-state
|
|
506
240
|
* indicators — the GUI badge colors bound workspaces by it). */
|
|
507
241
|
connectedAliases(): string[] {
|
|
508
|
-
return this.
|
|
242
|
+
return this.manager.connectedAliases()
|
|
509
243
|
}
|
|
510
244
|
|
|
511
|
-
/**
|
|
512
|
-
* Retire the pooled connection for one alias.
|
|
513
|
-
*
|
|
514
|
-
* ConnectionPool knows nothing about HostStore or ProxyJump configuration
|
|
515
|
-
* (every target owns its complete jump chain, no hop records are shared),
|
|
516
|
-
* so dependent-host expansion belongs here: with includeDependents the
|
|
517
|
-
* transitive reverse ProxyJump closure of `alias` is invalidated too.
|
|
518
|
-
*/
|
|
245
|
+
/** Retire the pooled connection for one alias (optionally its dependents). */
|
|
519
246
|
invalidate(alias: string, options: SshInvalidateOptions = {}): void {
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
if (options.includeDependents === true) {
|
|
523
|
-
const entries = this.store.list()
|
|
524
|
-
// Fixed-point scan: host counts are small, simpler and more reliable
|
|
525
|
-
// than maintaining a second dependency index.
|
|
526
|
-
let changed = true
|
|
527
|
-
while (changed) {
|
|
528
|
-
changed = false
|
|
529
|
-
for (const entry of entries) {
|
|
530
|
-
if (aliases.has(entry.alias)) continue
|
|
531
|
-
if (!entry.proxyJump.some(hopAlias => aliases.has(hopAlias))) continue
|
|
532
|
-
aliases.add(entry.alias)
|
|
533
|
-
changed = true
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
for (const targetAlias of aliases) {
|
|
539
|
-
// Dependents are already expanded above; the pool itself has no
|
|
540
|
-
// ProxyJump topology.
|
|
541
|
-
this.connectionPool.invalidate(targetAlias, {
|
|
542
|
-
includeDependents: false,
|
|
543
|
-
mode: options.mode,
|
|
544
|
-
})
|
|
545
|
-
}
|
|
247
|
+
this.manager.invalidate(alias, options)
|
|
546
248
|
}
|
|
547
249
|
|
|
548
250
|
/**
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
* - connect-only: acquire may be retried, but fn is invoked at most once —
|
|
554
|
-
* once fn starts, the remote may already have observed the request, so
|
|
555
|
-
* replay could duplicate non-idempotent work.
|
|
556
|
-
* - idempotent: fn may be retried until it calls control.markCommitted()
|
|
557
|
-
* (exec marks this when the server accepted the channel).
|
|
558
|
-
*
|
|
559
|
-
* A failed operation retires the transport via lease.markBroken(); the
|
|
560
|
-
* lease is always released before the next acquire attempt.
|
|
251
|
+
* Record one channel that never acknowledged close after a hard timeout.
|
|
252
|
+
* Exposed for the exec grace path and for diagnostics/tests.
|
|
253
|
+
* @param alias - the host the channel belonged to.
|
|
254
|
+
* @returns true when the pooled transport was drained.
|
|
561
255
|
*/
|
|
562
|
-
|
|
563
|
-
alias
|
|
564
|
-
fn: (client: Client, control: OperationControl) => Promise<T>,
|
|
565
|
-
options: WithClientOptions = {},
|
|
566
|
-
): Promise<T> {
|
|
567
|
-
const retryPolicy = options.retryPolicy ?? 'connect-only'
|
|
568
|
-
const configuredAttempts = options.attempts ?? 3
|
|
569
|
-
if (!Number.isInteger(configuredAttempts) || configuredAttempts < 1) {
|
|
570
|
-
throw new Error('withClient attempts must be a positive integer')
|
|
571
|
-
}
|
|
572
|
-
const maxAttempts = retryPolicy === 'never' ? 1 : configuredAttempts
|
|
573
|
-
let lastError: unknown
|
|
574
|
-
|
|
575
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
576
|
-
let lease: ClientLease
|
|
577
|
-
|
|
578
|
-
// Acquisition sits OUTSIDE the operation try: an acquire failure means
|
|
579
|
-
// fn never ran, so no remote work was submitted and retrying is safe.
|
|
580
|
-
try {
|
|
581
|
-
lease = await this.connectionPool.acquire(alias, { kind: 'operation' })
|
|
582
|
-
} catch (error) {
|
|
583
|
-
lastError = error
|
|
584
|
-
if (retryPolicy === 'never' || attempt === maxAttempts) {
|
|
585
|
-
throw error instanceof Error ? error : new Error(String(error))
|
|
586
|
-
}
|
|
587
|
-
continue
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
let committed = false
|
|
591
|
-
const control: OperationControl = {
|
|
592
|
-
markCommitted: (): void => { committed = true },
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
try {
|
|
596
|
-
return await fn(lease.client, control)
|
|
597
|
-
} catch (error) {
|
|
598
|
-
lastError = error
|
|
599
|
-
// A mid-flight failure usually means the connection died silently
|
|
600
|
-
// (the 'error'/'close' event may not have fired yet). Retire this
|
|
601
|
-
// generation so the next attempt reconnects; the pool reaps the
|
|
602
|
-
// record once every lease is released.
|
|
603
|
-
lease.markBroken(error)
|
|
604
|
-
|
|
605
|
-
const mayReplay = retryPolicy === 'idempotent'
|
|
606
|
-
&& !committed
|
|
607
|
-
&& attempt < maxAttempts
|
|
608
|
-
if (!mayReplay) {
|
|
609
|
-
throw error instanceof Error ? error : new Error(String(error))
|
|
610
|
-
}
|
|
611
|
-
} finally {
|
|
612
|
-
// release before the next iteration: if markBroken drained the
|
|
613
|
-
// record and this was its last lease, release() tears the transport
|
|
614
|
-
// down so the next acquire opens a fresh generation.
|
|
615
|
-
lease.release()
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
256
|
+
noteLeakedChannel(alias: string): boolean {
|
|
257
|
+
return this.manager.noteLeakedChannel(alias)
|
|
620
258
|
}
|
|
621
259
|
|
|
622
|
-
/**
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
* unavailable, throw NeedsPasswordError for the GUI to prompt. */
|
|
626
|
-
private async resolveEntryAuth(entry: SshHostEntry): Promise<ResolvedAuthDeps | undefined> {
|
|
627
|
-
const session = this.sessionPasswords.get(entry.alias)
|
|
628
|
-
const sessionOverride = session !== undefined
|
|
629
|
-
? {
|
|
630
|
-
kind: entry.auth.kind,
|
|
631
|
-
keyPath: entry.auth.keyPath,
|
|
632
|
-
password: session.password,
|
|
633
|
-
passphrase: session.passphrase,
|
|
634
|
-
} satisfies ResolvedAuthDeps
|
|
635
|
-
: undefined
|
|
636
|
-
if (sessionOverride !== undefined) return sessionOverride
|
|
637
|
-
|
|
638
|
-
let resolved: ResolvedAuthDeps | undefined
|
|
639
|
-
if (this.deps.resolveSecrets !== undefined) {
|
|
640
|
-
resolved = await this.deps.resolveSecrets(entry).catch((error: unknown) => {
|
|
641
|
-
throw error instanceof Error ? error : new Error(String(error))
|
|
642
|
-
})
|
|
643
|
-
} else {
|
|
644
|
-
// Inline fallback (plaintext store / tests).
|
|
645
|
-
const auth = entry.auth
|
|
646
|
-
if (auth.kind === 'password') {
|
|
647
|
-
resolved = { kind: 'password', password: auth.password }
|
|
648
|
-
} else {
|
|
649
|
-
resolved = auth.passphrase !== undefined && auth.passphrase !== ''
|
|
650
|
-
? { kind: 'key', keyPath: auth.keyPath, passphrase: auth.passphrase }
|
|
651
|
-
: { kind: 'key', keyPath: auth.keyPath }
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
// Credential gate: a password-kind host without a secret must surface
|
|
656
|
-
// NEEDS_PASSWORD (GUI dialog), NOT a raw ssh2 auth failure; an encrypted
|
|
657
|
-
// key whose passphrase is missing must surface NEEDS_PASSPHRASE.
|
|
658
|
-
if (resolved?.kind === 'password' && (resolved.password === undefined || resolved.password === '')) {
|
|
659
|
-
throw new NeedsPasswordError(entry.alias, 'password')
|
|
660
|
-
}
|
|
661
|
-
if (resolved?.kind === 'key' && resolved.passphrase === undefined && resolved.keyPath !== undefined) {
|
|
662
|
-
const keyPath = expandHome(resolved.keyPath)
|
|
663
|
-
if (keyPath !== '' && existsSync(keyPath) && keyNeedsPassphrase(keyPath)) {
|
|
664
|
-
throw new NeedsPasswordError(entry.alias, 'passphrase')
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
return resolved
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* Build one full jump chain for an entry: hop clients connected through in
|
|
672
|
-
* order, each forwarding a stream to the next destination, ending with the
|
|
673
|
-
* target client. Shared by the pool and standalone shell sessions.
|
|
674
|
-
*/
|
|
675
|
-
private async connectChain(entry: SshHostEntry): Promise<{ client: Client; hops: Client[] }> {
|
|
676
|
-
const hops: Client[] = []
|
|
677
|
-
let sock: ConnectConfig['sock']
|
|
678
|
-
const chain = entry.proxyJump
|
|
679
|
-
// Defensive cycle guard: the store validates on create/update, but the
|
|
680
|
-
// JSON file can be hand-edited — a loop here would open hop connections
|
|
681
|
-
// forever. Follow the live store's full hop graph from this entry.
|
|
682
|
-
const walked = new Set<string>()
|
|
683
|
-
const walk = (alias: string, path: string[]): void => {
|
|
684
|
-
const at = path.indexOf(alias)
|
|
685
|
-
if (at >= 0) throw new Error(`proxyJump cycle detected: ${[...path.slice(at), alias].join(' -> ')}`)
|
|
686
|
-
if (walked.has(alias)) return
|
|
687
|
-
walked.add(alias)
|
|
688
|
-
const hopEntry = this.store.find(alias)
|
|
689
|
-
if (hopEntry === undefined) return
|
|
690
|
-
for (const next of hopEntry.proxyJump) walk(next, [...path, alias])
|
|
691
|
-
}
|
|
692
|
-
walk(entry.alias, [])
|
|
693
|
-
for (let index = 0; index < chain.length; index += 1) {
|
|
694
|
-
const hopAlias = chain[index]
|
|
695
|
-
const hop = this.store.find(hopAlias)
|
|
696
|
-
if (hop === undefined) {
|
|
697
|
-
for (const client of hops) client.end()
|
|
698
|
-
throw new Error(`proxyJump alias '${hopAlias}' not found —create it first`)
|
|
699
|
-
}
|
|
700
|
-
const hopOutcome: { outcome?: HostKeyOutcome | undefined } = {}
|
|
701
|
-
const hopResolved = await this.resolveEntryAuth(hop)
|
|
702
|
-
const hopClient = await connectClient(
|
|
703
|
-
buildConnectConfig(hop, this.opts, sock, {
|
|
704
|
-
hostKeyPolicy: this.hostKeyPolicy,
|
|
705
|
-
hostKeyAlgorithms: this.deps.hostKeyAlgorithms,
|
|
706
|
-
authOverride: hopResolved,
|
|
707
|
-
setOutcome: (value) => { hopOutcome.outcome = value },
|
|
708
|
-
}),
|
|
709
|
-
this.opts.connectTimeoutMs,
|
|
710
|
-
hopOutcome,
|
|
711
|
-
)
|
|
712
|
-
hops.push(hopClient)
|
|
713
|
-
const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : undefined
|
|
714
|
-
const nextHost = next !== undefined ? next.host : entry.host
|
|
715
|
-
const nextPort = next !== undefined ? next.port : entry.port
|
|
716
|
-
sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
|
|
717
|
-
// forwardOut has no cancel API: bound the hop-channel open so a dead
|
|
718
|
-
// or half-open jump host cannot hang connectChain forever. On timeout
|
|
719
|
-
// the whole hop chain is torn down (mirrors the error branch).
|
|
720
|
-
let settled = false
|
|
721
|
-
const timer = setTimeout(() => {
|
|
722
|
-
if (settled) return
|
|
723
|
-
settled = true
|
|
724
|
-
for (const client of hops) client.end()
|
|
725
|
-
reject(new Error(`proxyJump forwardOut on '${hopAlias}' timed out after ${this.opts.connectTimeoutMs} ms (target ${nextHost}:${nextPort})`))
|
|
726
|
-
}, this.opts.connectTimeoutMs)
|
|
727
|
-
timer.unref?.()
|
|
728
|
-
hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
|
|
729
|
-
if (settled) {
|
|
730
|
-
// Late arrival after the timeout: the chain is being torn down;
|
|
731
|
-
// close any channel that finally opened.
|
|
732
|
-
if (stream !== undefined) {
|
|
733
|
-
try { stream.close() } catch { /* already closed */ }
|
|
734
|
-
}
|
|
735
|
-
return
|
|
736
|
-
}
|
|
737
|
-
settled = true
|
|
738
|
-
clearTimeout(timer)
|
|
739
|
-
if (error !== undefined) {
|
|
740
|
-
for (const client of hops) client.end()
|
|
741
|
-
reject(error)
|
|
742
|
-
} else {
|
|
743
|
-
resolve(stream)
|
|
744
|
-
}
|
|
745
|
-
})
|
|
746
|
-
})
|
|
747
|
-
}
|
|
748
|
-
try {
|
|
749
|
-
const targetOutcome: { outcome?: HostKeyOutcome | undefined } = {}
|
|
750
|
-
// Resolve the entry's authentication (session password table first,
|
|
751
|
-
// then vault / inline store). The hostVerifier still runs on the raw
|
|
752
|
-
// server key first, so a secret is never sent to an unverified host.
|
|
753
|
-
const resolvedAuth = await this.resolveEntryAuth(entry)
|
|
754
|
-
const client = await connectClient(
|
|
755
|
-
buildConnectConfig(entry, this.opts, sock, {
|
|
756
|
-
hostKeyPolicy: this.hostKeyPolicy,
|
|
757
|
-
hostKeyAlgorithms: this.deps.hostKeyAlgorithms,
|
|
758
|
-
authOverride: resolvedAuth,
|
|
759
|
-
setOutcome: (value) => { targetOutcome.outcome = value },
|
|
760
|
-
}),
|
|
761
|
-
this.opts.connectTimeoutMs,
|
|
762
|
-
targetOutcome,
|
|
763
|
-
)
|
|
764
|
-
return { client, hops }
|
|
765
|
-
} catch (error) {
|
|
766
|
-
for (const client of hops) client.end()
|
|
767
|
-
throw error
|
|
768
|
-
}
|
|
260
|
+
/** A late channel close: un-count one half-open channel for this alias. */
|
|
261
|
+
noteChannelClosed(alias: string): void {
|
|
262
|
+
this.manager.noteChannelClosed(alias)
|
|
769
263
|
}
|
|
770
264
|
|
|
771
265
|
// --------------------------------------------------------------- exec
|
|
772
266
|
|
|
773
267
|
/** Run one command on `alias` (reusing the pooled connection). */
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
268
|
+
exec(alias: string, command: string, timeoutMs?: number): Promise<import('./protocol.ts').ExecResult>
|
|
269
|
+
exec(alias: string, command: string, options: ExecOptions): Promise<import('./protocol.ts').ExecResult>
|
|
270
|
+
exec(alias: string, command: string, optionsOrTimeout?: ExecOptions | number): Promise<import('./protocol.ts').ExecResult> {
|
|
777
271
|
// Backward compatible: exec(alias, command, timeoutMs) ===
|
|
778
272
|
// exec(alias, command, { timeoutMs, retry: 'connect-only' }).
|
|
779
273
|
const options: ExecOptions = typeof optionsOrTimeout === 'number'
|
|
@@ -781,34 +275,48 @@ export class SshEngine {
|
|
|
781
275
|
: { ...optionsOrTimeout, retry: optionsOrTimeout?.retry ?? 'connect-only' }
|
|
782
276
|
|
|
783
277
|
const started = Date.now()
|
|
784
|
-
const budget = options.timeoutMs !== undefined && options.timeoutMs > 0
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
278
|
+
const budget = options.timeoutMs !== undefined && options.timeoutMs > 0
|
|
279
|
+
? options.timeoutMs
|
|
280
|
+
: this.manager.resolvedOptions.defaultExecTimeoutMs
|
|
281
|
+
// The client deadline below only stops waiting; this makes the SERVER stop
|
|
282
|
+
// the command too, so a timeout cannot leave orphan processes behind.
|
|
283
|
+
const remoteCommand = wrapCommandWithServerBudget(command, budget)
|
|
284
|
+
return this.manager.withClient(alias, async (client, control) => {
|
|
285
|
+
return await new Promise<import('./protocol.ts').ExecResult>((resolve, reject) => {
|
|
286
|
+
client.exec(remoteCommand, (error, stream) => {
|
|
788
287
|
if (error !== undefined) {
|
|
789
288
|
// The channel never opened: only an explicit 'idempotent'
|
|
790
289
|
// caller may replay this window (the server may or may not
|
|
791
290
|
// have seen the request).
|
|
792
|
-
reject(error)
|
|
291
|
+
reject(new Error(this.redact(error.message), { cause: error }))
|
|
793
292
|
return
|
|
794
293
|
}
|
|
795
294
|
// The server accepted the channel — the command may already be
|
|
796
295
|
// running. From here the command is NEVER replayed.
|
|
797
296
|
control.markCommitted()
|
|
798
|
-
const stdout = new BoundedUtf8Output(this.
|
|
799
|
-
const stderr = new BoundedUtf8Output(this.
|
|
297
|
+
const stdout = new BoundedUtf8Output(this.manager.resolvedOptions.maxOutputBytes)
|
|
298
|
+
const stderr = new BoundedUtf8Output(this.manager.resolvedOptions.maxOutputBytes)
|
|
800
299
|
let timedOut = false
|
|
801
300
|
let settled = false
|
|
301
|
+
let channelClosed = false
|
|
302
|
+
let leakCounted = false
|
|
303
|
+
let graceTimer: NodeJS.Timeout | undefined
|
|
304
|
+
/** Set by onAbort below; declared first so settle paths can detach it. */
|
|
305
|
+
let onAbort: (() => void) | undefined
|
|
306
|
+
const detachAbort = (): void => {
|
|
307
|
+
if (onAbort !== undefined) control.signal?.removeEventListener('abort', onAbort)
|
|
308
|
+
}
|
|
802
309
|
const finish = (): void => {
|
|
803
310
|
if (settled) return
|
|
804
311
|
settled = true
|
|
805
312
|
clearTimeout(timer)
|
|
313
|
+
detachAbort()
|
|
806
314
|
resolve({
|
|
807
315
|
success: false,
|
|
808
316
|
exitCode: null,
|
|
809
317
|
timedOut,
|
|
810
|
-
stdout: stdout.finish(),
|
|
811
|
-
stderr: stderr.finish(),
|
|
318
|
+
stdout: this.redact(stdout.finish()),
|
|
319
|
+
stderr: this.redact(stderr.finish()),
|
|
812
320
|
durationMs: Date.now() - started,
|
|
813
321
|
error: timedOut ? `command timed out after ${budget} ms` : undefined,
|
|
814
322
|
})
|
|
@@ -820,19 +328,49 @@ export class SshEngine {
|
|
|
820
328
|
// Hard deadline: settle now even if the peer never acks the
|
|
821
329
|
// channel close (the stream 'close' handler is then a no-op).
|
|
822
330
|
finish()
|
|
331
|
+
// ...but keep watching for that ack: a peer that ignores close
|
|
332
|
+
// leaves a half-open channel against the server's MaxSessions
|
|
333
|
+
// budget. Count it (the manager drains the transport once enough
|
|
334
|
+
// accumulate) instead of leaking silently.
|
|
335
|
+
graceTimer = setTimeout(() => {
|
|
336
|
+
if (channelClosed) return
|
|
337
|
+
leakCounted = true
|
|
338
|
+
this.manager.noteLeakedChannel(alias)
|
|
339
|
+
}, CHANNEL_CLOSE_GRACE_MS)
|
|
340
|
+
graceTimer.unref?.()
|
|
823
341
|
}, budget)
|
|
342
|
+
// Per-request cancellation: ssh2 cannot cancel a request, but it CAN
|
|
343
|
+
// close this one channel. Doing that here keeps a caller abort from
|
|
344
|
+
// having to retire the whole pooled transport (and thus every
|
|
345
|
+
// concurrent operation on the same alias). The manager still owns the
|
|
346
|
+
// rejected promise; this listener only stops the remote work.
|
|
347
|
+
onAbort = (): void => {
|
|
348
|
+
try { stream.signal('KILL') } catch { /* channel gone */ }
|
|
349
|
+
try { stream.close() } catch { /* channel gone */ }
|
|
350
|
+
finish()
|
|
351
|
+
}
|
|
352
|
+
if (control.signal !== undefined) {
|
|
353
|
+
if (control.signal.aborted) onAbort()
|
|
354
|
+
else control.signal.addEventListener('abort', onAbort, { once: true })
|
|
355
|
+
}
|
|
824
356
|
stream.on('data', (chunk: Buffer) => stdout.append(chunk))
|
|
825
357
|
stream.stderr.on('data', (chunk: Buffer) => stderr.append(chunk))
|
|
826
358
|
stream.on('close', (code: number | null) => {
|
|
359
|
+
channelClosed = true
|
|
360
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
361
|
+
// A late acknowledgement un-counts the channel: a merely slow peer
|
|
362
|
+
// must not accumulate its way to a transport retirement.
|
|
363
|
+
if (leakCounted) this.manager.noteChannelClosed(alias)
|
|
827
364
|
if (settled) return
|
|
828
365
|
settled = true
|
|
829
366
|
clearTimeout(timer)
|
|
367
|
+
detachAbort()
|
|
830
368
|
resolve({
|
|
831
369
|
success: code === 0 && !timedOut,
|
|
832
370
|
exitCode: code,
|
|
833
371
|
timedOut,
|
|
834
|
-
stdout: stdout.finish(),
|
|
835
|
-
stderr: stderr.finish(),
|
|
372
|
+
stdout: this.redact(stdout.finish()),
|
|
373
|
+
stderr: this.redact(stderr.finish()),
|
|
836
374
|
durationMs: Date.now() - started,
|
|
837
375
|
})
|
|
838
376
|
})
|
|
@@ -840,147 +378,36 @@ export class SshEngine {
|
|
|
840
378
|
if (settled) return
|
|
841
379
|
settled = true
|
|
842
380
|
clearTimeout(timer)
|
|
381
|
+
detachAbort()
|
|
843
382
|
reject(streamError)
|
|
844
383
|
})
|
|
845
384
|
})
|
|
846
385
|
})
|
|
847
|
-
}, { attempts: 3, retryPolicy: options.retry })
|
|
386
|
+
}, { attempts: 3, retryPolicy: options.retry, signal: options.signal })
|
|
848
387
|
}
|
|
849
388
|
|
|
850
|
-
/**
|
|
851
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Run one command against many hosts concurrently. Per-host failures carry
|
|
391
|
+
* the typed, secret-free fields declared on `ClusterResult`.
|
|
392
|
+
*/
|
|
393
|
+
cluster(options: {
|
|
852
394
|
command: string
|
|
853
395
|
aliases?: string[]
|
|
854
396
|
environment?: string
|
|
855
397
|
tags?: string[]
|
|
856
398
|
timeoutMs?: number
|
|
857
399
|
maxWorkers?: number
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
const byAlias = new Map(targets.map(entry => [entry.alias, entry]))
|
|
863
|
-
targets = options.aliases
|
|
864
|
-
.map(alias => byAlias.get(alias))
|
|
865
|
-
.filter((entry): entry is SshHostEntry => entry !== undefined)
|
|
866
|
-
}
|
|
867
|
-
if (options.environment !== undefined && options.environment !== '') {
|
|
868
|
-
targets = targets.filter(entry => entry.environment === options.environment)
|
|
869
|
-
}
|
|
870
|
-
if (options.tags !== undefined && options.tags.length > 0) {
|
|
871
|
-
// ALL semantics (matches the ssh_cluster tool description).
|
|
872
|
-
targets = targets.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
|
|
873
|
-
}
|
|
874
|
-
if (targets.length === 0) return []
|
|
875
|
-
if (options.maxWorkers !== undefined && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) {
|
|
876
|
-
throw new Error('maxWorkers must be a positive integer')
|
|
877
|
-
}
|
|
878
|
-
const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length)
|
|
879
|
-
// Pre-sized slots keep the result order aligned with the target order
|
|
880
|
-
// regardless of which host finishes first.
|
|
881
|
-
const results = new Array<ClusterResult>(targets.length)
|
|
882
|
-
const queue = targets.map((entry, index) => ({ entry, index }))
|
|
883
|
-
const run = async (): Promise<void> => {
|
|
884
|
-
while (queue.length > 0) {
|
|
885
|
-
const { entry, index } = queue.shift()!
|
|
886
|
-
try {
|
|
887
|
-
const result = await this.exec(entry.alias, options.command, options.timeoutMs)
|
|
888
|
-
results[index] = { alias: entry.alias, ok: result.success, exitCode: result.exitCode, timedOut: result.timedOut, stdout: result.stdout, stderr: result.stderr, durationMs: result.durationMs }
|
|
889
|
-
} catch (error) {
|
|
890
|
-
results[index] = { alias: entry.alias, ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
await Promise.all(Array.from({ length: workers }, () => run()))
|
|
895
|
-
return results
|
|
400
|
+
/** Aborts every per-host run when the caller disconnects. */
|
|
401
|
+
signal?: AbortSignal
|
|
402
|
+
}): Promise<import('./protocol.ts').ClusterResult[]> {
|
|
403
|
+
return this.manager.cluster(options)
|
|
896
404
|
}
|
|
897
405
|
|
|
898
406
|
// -------------------------------------------------------------- shell
|
|
899
407
|
|
|
900
|
-
/**
|
|
901
|
-
* Open one standalone channel on its own connection (never a pooled one).
|
|
902
|
-
* Shared by openShell/openExec (P1-18): alias lookup, jump chain,
|
|
903
|
-
* channel-open timeout, idempotent teardown, and late-callback cleanup live
|
|
904
|
-
* here, so the two public methods keep only their session-specific assembly.
|
|
905
|
-
*/
|
|
906
|
-
private async openStandaloneChannel(
|
|
907
|
-
alias: string,
|
|
908
|
-
open: (client: Client, callback: (error: Error | undefined, stream?: ClientChannel) => void) => void,
|
|
909
|
-
): Promise<{ stream: ClientChannel; teardown: () => void }> {
|
|
910
|
-
const entry = this.store.find(alias)
|
|
911
|
-
if (entry === undefined) throw new Error(`alias '${alias}' not found —add it first`)
|
|
912
|
-
const { client, hops } = await this.connectChain(entry)
|
|
913
|
-
return await new Promise<{ stream: ClientChannel; teardown: () => void }>((resolve, reject) => {
|
|
914
|
-
let settled = false
|
|
915
|
-
let tornDown = false
|
|
916
|
-
const teardown = (): void => {
|
|
917
|
-
if (tornDown) return
|
|
918
|
-
tornDown = true
|
|
919
|
-
try { client.end() } catch { /* closed */ }
|
|
920
|
-
for (const hop of hops) { try { hop.end() } catch { /* closed */ } }
|
|
921
|
-
}
|
|
922
|
-
const timer = setTimeout(() => {
|
|
923
|
-
if (settled) return
|
|
924
|
-
settled = true
|
|
925
|
-
teardown()
|
|
926
|
-
reject(new Error(`channel on '${alias}' did not open within ${CHANNEL_OPEN_TIMEOUT_MS} ms`))
|
|
927
|
-
}, CHANNEL_OPEN_TIMEOUT_MS)
|
|
928
|
-
timer.unref?.()
|
|
929
|
-
open(client, (error, stream) => {
|
|
930
|
-
if (settled) {
|
|
931
|
-
// Late arrival after the timeout: the connection is being torn
|
|
932
|
-
// down; close any channel that finally opened instead of leaking it.
|
|
933
|
-
if (stream !== undefined) {
|
|
934
|
-
try { stream.close() } catch { /* already closed */ }
|
|
935
|
-
}
|
|
936
|
-
teardown()
|
|
937
|
-
return
|
|
938
|
-
}
|
|
939
|
-
settled = true
|
|
940
|
-
clearTimeout(timer)
|
|
941
|
-
if (error !== undefined) {
|
|
942
|
-
teardown()
|
|
943
|
-
reject(error)
|
|
944
|
-
return
|
|
945
|
-
}
|
|
946
|
-
if (stream === undefined) {
|
|
947
|
-
teardown()
|
|
948
|
-
reject(new Error(`channel on '${alias}' opened without a stream`))
|
|
949
|
-
return
|
|
950
|
-
}
|
|
951
|
-
resolve({ stream, teardown })
|
|
952
|
-
})
|
|
953
|
-
})
|
|
954
|
-
}
|
|
955
|
-
|
|
956
408
|
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
// closing it can never tear down a pooled exec/tunnel sharing the alias.
|
|
960
|
-
const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
|
|
961
|
-
client.shell({ term: 'xterm-256color', cols: size.cols, rows: size.rows }, callback)
|
|
962
|
-
})
|
|
963
|
-
const session: ShellSession = {
|
|
964
|
-
send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
|
|
965
|
-
resize: (cols, rows) => { try { stream.setWindow(rows, cols, rows, cols) } catch { /* channel gone */ } },
|
|
966
|
-
signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
|
|
967
|
-
close: () => {
|
|
968
|
-
try { stream.close() } catch { /* channel gone */ }
|
|
969
|
-
teardown()
|
|
970
|
-
},
|
|
971
|
-
pause: () => { try { stream.pause() } catch { /* channel gone */ } },
|
|
972
|
-
resume: () => { try { stream.resume() } catch { /* channel gone */ } },
|
|
973
|
-
}
|
|
974
|
-
stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
|
|
975
|
-
stream.on('close', (code: number | null) => {
|
|
976
|
-
teardown()
|
|
977
|
-
session.onExit?.(code)
|
|
978
|
-
})
|
|
979
|
-
stream.on('error', (streamError: Error) => {
|
|
980
|
-
teardown()
|
|
981
|
-
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
|
|
982
|
-
})
|
|
983
|
-
return session
|
|
409
|
+
openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
|
|
410
|
+
return this.terminalService.openShell(alias, size)
|
|
984
411
|
}
|
|
985
412
|
|
|
986
413
|
/**
|
|
@@ -988,352 +415,76 @@ export class SshEngine {
|
|
|
988
415
|
* Like the PTY shell, the channel rides its own connection so closing it
|
|
989
416
|
* can never tear down a pooled exec/tunnel sharing the alias.
|
|
990
417
|
*/
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
client.exec(command, callback)
|
|
994
|
-
})
|
|
995
|
-
const session: ExecSession = {
|
|
996
|
-
send: (data) => { try { stream.write(data) } catch { /* channel gone */ } },
|
|
997
|
-
end: (data) => {
|
|
998
|
-
try {
|
|
999
|
-
if (data !== undefined && data !== '') stream.write(data)
|
|
1000
|
-
stream.end()
|
|
1001
|
-
} catch { /* channel gone */ }
|
|
1002
|
-
},
|
|
1003
|
-
resize: () => { /* exec channels have no PTY */ },
|
|
1004
|
-
signal: (name) => { try { stream.signal(name) } catch { /* channel gone */ } },
|
|
1005
|
-
close: () => {
|
|
1006
|
-
try { stream.close() } catch { /* channel gone */ }
|
|
1007
|
-
teardown()
|
|
1008
|
-
},
|
|
1009
|
-
pause: () => { try { stream.pause() } catch { /* channel gone */ } },
|
|
1010
|
-
resume: () => { try { stream.resume() } catch { /* channel gone */ } },
|
|
1011
|
-
}
|
|
1012
|
-
stream.on('data', (chunk: Buffer) => { session.onData?.(chunk) })
|
|
1013
|
-
stream.stderr.on('data', (chunk: Buffer) => { session.onErrData?.(chunk) })
|
|
1014
|
-
stream.on('close', (code: number | null) => {
|
|
1015
|
-
teardown()
|
|
1016
|
-
session.onExit?.(code)
|
|
1017
|
-
})
|
|
1018
|
-
stream.on('error', (streamError: Error) => {
|
|
1019
|
-
teardown()
|
|
1020
|
-
session.onExit?.(null, streamError instanceof Error ? streamError.message : String(streamError))
|
|
1021
|
-
})
|
|
1022
|
-
return session
|
|
418
|
+
openExec(alias: string, command: string): Promise<ExecSession> {
|
|
419
|
+
return this.terminalService.openExec(alias, command)
|
|
1023
420
|
}
|
|
1024
421
|
|
|
1025
422
|
// -------------------------------------------------------------- sftp
|
|
1026
423
|
|
|
1027
|
-
/** Upload one local file (or directory tree) to a remote path. */
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
if (!remotePath.startsWith('/')) {
|
|
1031
|
-
throw new Error(`remotePath must be an absolute path (got '${remotePath}')`)
|
|
1032
|
-
}
|
|
1033
|
-
const local = resolvePath(localPath)
|
|
1034
|
-
if (!existsSync(local)) throw new Error(`local path not found: '${localPath}'`)
|
|
1035
|
-
return this.withClient(alias, async (client) => {
|
|
1036
|
-
const sftp = await this.sftpFor(client)
|
|
1037
|
-
const stat = statSync(local)
|
|
1038
|
-
let files: string[]
|
|
1039
|
-
if (stat.isDirectory()) {
|
|
1040
|
-
if (!recursive) throw new Error(`'${localPath}' is a directory —enable recursive upload`)
|
|
1041
|
-
files = walkLocalDir(local)
|
|
1042
|
-
await this.ensureRemoteDir(sftp, remotePath)
|
|
1043
|
-
} else {
|
|
1044
|
-
files = ['']
|
|
1045
|
-
await this.ensureRemoteDir(sftp, dirname(remotePath))
|
|
1046
|
-
}
|
|
1047
|
-
let bytes = 0
|
|
1048
|
-
for (const rel of files) {
|
|
1049
|
-
const src = rel === '' ? local : join(local, rel)
|
|
1050
|
-
// Remote paths always use forward slashes; normalize any OS separators.
|
|
1051
|
-
const remoteRel = rel.split(/[\\/]/).join('/')
|
|
1052
|
-
const dst = rel === '' ? remotePath : remotePath.replace(/\/$/, '') + '/' + remoteRel
|
|
1053
|
-
await this.fastPut(sftp, src, dst, onProgress)
|
|
1054
|
-
bytes += statSync(src).size
|
|
1055
|
-
}
|
|
1056
|
-
return { bytes, files: files.length }
|
|
1057
|
-
})
|
|
424
|
+
/** Upload one local file (or directory tree) to a remote path. */
|
|
425
|
+
upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number; files: number }> {
|
|
426
|
+
return this.sftpService.upload(alias, localPath, remotePath, recursive, onProgress, signal)
|
|
1058
427
|
}
|
|
1059
428
|
|
|
1060
429
|
/** Download one remote file to a local path. */
|
|
1061
|
-
|
|
1062
|
-
return this.
|
|
1063
|
-
const sftp = await this.sftpFor(client)
|
|
1064
|
-
const stats = await new Promise<import('ssh2').Stats>((resolve, reject) => {
|
|
1065
|
-
sftp.stat(remotePath, (error, result) => error !== undefined ? reject(error) : resolve(result))
|
|
1066
|
-
})
|
|
1067
|
-
if (stats.isDirectory()) {
|
|
1068
|
-
throw new Error(`'${remotePath}' is a directory —directory download is not supported yet (download individual files)`)
|
|
1069
|
-
}
|
|
1070
|
-
const local = resolvePath(localPath)
|
|
1071
|
-
if (!existsSync(dirname(local))) mkdirSync(dirname(local), { recursive: true })
|
|
1072
|
-
await this.fastGet(sftp, remotePath, local, stats.size, onProgress)
|
|
1073
|
-
return { bytes: statSync(local).size }
|
|
1074
|
-
})
|
|
430
|
+
download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void, signal?: AbortSignal): Promise<{ bytes: number }> {
|
|
431
|
+
return this.sftpService.download(alias, remotePath, localPath, onProgress, signal)
|
|
1075
432
|
}
|
|
1076
433
|
|
|
1077
434
|
/** List a remote directory (file browser). Bounded by a timeout so a
|
|
1078
435
|
* stalled SFTP request fails instead of leaving the file tree spinning. */
|
|
1079
|
-
|
|
1080
|
-
return this.
|
|
1081
|
-
const sftp = await this.sftpFor(client)
|
|
1082
|
-
// readdir/stat requests have no per-request cancel in ssh2; the timeout
|
|
1083
|
-
// only stops the caller. withClient marks the lease broken and the
|
|
1084
|
-
// release() (single holder) reaps the transport, so a hung request
|
|
1085
|
-
// dies with the connection instead of lingering.
|
|
1086
|
-
return this.withTimeout(
|
|
1087
|
-
(async () => {
|
|
1088
|
-
const list = await new Promise<Array<{ filename: string; attrs: import('ssh2').Stats }>>((resolve, reject) => {
|
|
1089
|
-
sftp.readdir(path, (error, items) => error !== undefined ? reject(error) : resolve(items))
|
|
1090
|
-
})
|
|
1091
|
-
return this.classifyEntries(sftp, path, list)
|
|
1092
|
-
})(),
|
|
1093
|
-
SFTP_OP_TIMEOUT_MS,
|
|
1094
|
-
`remote ls timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${path}`,
|
|
1095
|
-
)
|
|
1096
|
-
})
|
|
436
|
+
ls(alias: string, path: string, signal?: AbortSignal): Promise<import('./protocol.ts').RemoteDirEntry[]> {
|
|
437
|
+
return this.sftpService.ls(alias, path, signal)
|
|
1097
438
|
}
|
|
1098
439
|
|
|
1099
|
-
/**
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
* `realpath` execs. A path that cannot resolve (dangling symlink, vanished
|
|
1103
|
-
* entry) fails the whole batch — callers treat an unresolvable listing as
|
|
1104
|
-
* an error rather than silently using an uncanonical path.
|
|
1105
|
-
*/
|
|
1106
|
-
async realpaths(alias: string, remotePaths: readonly string[]): Promise<string[]> {
|
|
1107
|
-
if (remotePaths.length === 0) return []
|
|
1108
|
-
return this.withClient(alias, async (client) => {
|
|
1109
|
-
const sftp = await this.sftpFor(client)
|
|
1110
|
-
const results = new Array<string>(remotePaths.length)
|
|
1111
|
-
for (let start = 0; start < remotePaths.length; start += SYMLINK_STAT_BATCH) {
|
|
1112
|
-
const batch = remotePaths.slice(start, start + SYMLINK_STAT_BATCH)
|
|
1113
|
-
const resolved = await Promise.all(batch.map((path) => this.withTimeout(
|
|
1114
|
-
new Promise<string>((resolve, reject) => {
|
|
1115
|
-
sftp.realpath(path, (error, canonical) => error !== undefined ? reject(error) : resolve(canonical))
|
|
1116
|
-
}),
|
|
1117
|
-
SFTP_OP_TIMEOUT_MS,
|
|
1118
|
-
`remote realpath timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${path}`,
|
|
1119
|
-
)))
|
|
1120
|
-
for (let i = 0; i < batch.length; i += 1) results[start + i] = resolved[i]!
|
|
1121
|
-
}
|
|
1122
|
-
return results
|
|
1123
|
-
})
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
/**
|
|
1127
|
-
* Classify readdir entries, following symlinks so a link to a directory
|
|
1128
|
-
* (e.g. AutoDL's /root/autodl-tmp) lists as a directory instead of 'other'.
|
|
1129
|
-
* Symlinks are stat'd in PARALLEL batches: serializing them turns a conda /
|
|
1130
|
-
* venv bin full of links into N round-trips (seconds to tens of seconds on a
|
|
1131
|
-
* slow link) — batching keeps it to a handful of round-trips. The whole pass
|
|
1132
|
-
* is bounded by ls()'s timeout.
|
|
1133
|
-
*/
|
|
1134
|
-
private async classifyEntries(
|
|
1135
|
-
sftp: import('ssh2').SFTPWrapper,
|
|
1136
|
-
dirPath: string,
|
|
1137
|
-
list: Array<{ filename: string; attrs: import('ssh2').Stats }>,
|
|
1138
|
-
): Promise<import('./protocol.ts').RemoteDirEntry[]> {
|
|
1139
|
-
const resolved = new Array<'dir' | 'file' | 'other' | null>(list.length).fill(null)
|
|
1140
|
-
const linkIndexes = list
|
|
1141
|
-
.map((item, index) => (item.attrs.isSymbolicLink() ? index : -1))
|
|
1142
|
-
.filter((index) => index >= 0)
|
|
1143
|
-
const base = dirPath.replace(/\/+$/, '')
|
|
1144
|
-
for (let start = 0; start < linkIndexes.length; start += SYMLINK_STAT_BATCH) {
|
|
1145
|
-
const batch = linkIndexes.slice(start, start + SYMLINK_STAT_BATCH)
|
|
1146
|
-
await Promise.all(batch.map(async (index) => {
|
|
1147
|
-
try {
|
|
1148
|
-
const stats = await new Promise<import('ssh2').Stats>((res, rej) => {
|
|
1149
|
-
sftp.stat(`${base}/${list[index].filename}`, (statError, stats) => statError !== undefined ? rej(statError) : res(stats))
|
|
1150
|
-
})
|
|
1151
|
-
resolved[index] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : 'other'
|
|
1152
|
-
} catch {
|
|
1153
|
-
resolved[index] = 'other' // dangling link
|
|
1154
|
-
}
|
|
1155
|
-
}))
|
|
1156
|
-
}
|
|
1157
|
-
return list.map((item, index): import('./protocol.ts').RemoteDirEntry => {
|
|
1158
|
-
let type: 'dir' | 'file' | 'other' = item.attrs.isDirectory() ? 'dir' : item.attrs.isFile() ? 'file' : 'other'
|
|
1159
|
-
if (type === 'other' && item.attrs.isSymbolicLink()) type = resolved[index] ?? 'other'
|
|
1160
|
-
return { name: item.filename, type, size: item.attrs.size, mtimeMs: item.attrs.mtime * 1000, mode: item.attrs.mode }
|
|
1161
|
-
})
|
|
440
|
+
/** Resolve many remote paths to their canonical form in one SFTP pass. */
|
|
441
|
+
realpaths(alias: string, remotePaths: readonly string[], signal?: AbortSignal): Promise<string[]> {
|
|
442
|
+
return this.sftpService.realpaths(alias, remotePaths, signal)
|
|
1162
443
|
}
|
|
1163
444
|
|
|
1164
445
|
/** Stat one remote path (file browser / conflict checks). Bounded by a timeout. */
|
|
1165
|
-
|
|
1166
|
-
return this.
|
|
1167
|
-
const sftp = await this.sftpFor(client)
|
|
1168
|
-
const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), SFTP_OP_TIMEOUT_MS, `remote stat timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${remotePath}`)
|
|
1169
|
-
return {
|
|
1170
|
-
type: attrs.isDirectory() ? 'dir' : attrs.isFile() ? 'file' : 'other',
|
|
1171
|
-
size: attrs.size,
|
|
1172
|
-
mtimeMs: attrs.mtime * 1000,
|
|
1173
|
-
mode: attrs.mode,
|
|
1174
|
-
}
|
|
1175
|
-
})
|
|
446
|
+
stat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> {
|
|
447
|
+
return this.sftpService.stat(alias, remotePath, signal)
|
|
1176
448
|
}
|
|
1177
449
|
|
|
1178
450
|
/**
|
|
1179
451
|
* Lstat one remote path without following the final symlink. Returns
|
|
1180
452
|
* undefined when the path is absent (the fs seam's lstat contract).
|
|
1181
453
|
*/
|
|
1182
|
-
|
|
1183
|
-
return this.
|
|
1184
|
-
const sftp = await this.sftpFor(client)
|
|
1185
|
-
try {
|
|
1186
|
-
const attrs = await new Promise<import('ssh2').Stats>((resolve, reject) => {
|
|
1187
|
-
sftp.lstat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
1188
|
-
})
|
|
1189
|
-
return {
|
|
1190
|
-
type: attrs.isSymbolicLink() ? 'symlink' : attrs.isDirectory() ? 'directory' : attrs.isFile() ? 'file' : 'other',
|
|
1191
|
-
size: attrs.size,
|
|
1192
|
-
mtimeMs: attrs.mtime * 1000,
|
|
1193
|
-
mode: attrs.mode,
|
|
1194
|
-
}
|
|
1195
|
-
} catch (error) {
|
|
1196
|
-
const code = String((error as { code?: unknown }).code ?? '')
|
|
1197
|
-
if (/NO_SUCH_FILE|ENOENT|no such file|does not exist/i.test(`${code} ${String(error)}`)) return undefined
|
|
1198
|
-
throw error
|
|
1199
|
-
}
|
|
1200
|
-
})
|
|
454
|
+
lstat(alias: string, remotePath: string, signal?: AbortSignal): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtimeMs: number; mode: number } | undefined> {
|
|
455
|
+
return this.sftpService.lstat(alias, remotePath, signal)
|
|
1201
456
|
}
|
|
1202
457
|
|
|
1203
458
|
/**
|
|
1204
459
|
* Open a remote file read stream (the fs seam's streamText). The returned
|
|
1205
460
|
* stream must be consumed or destroyed; the pooled connection stays busy
|
|
1206
|
-
* for the stream's lifetime
|
|
1207
|
-
*/
|
|
1208
|
-
/**
|
|
1209
|
-
* Open a remote file read stream (the fs seam's streamText). The returned
|
|
1210
|
-
* stream must be consumed or destroyed; the pooled connection stays busy
|
|
1211
|
-
* for the stream's lifetime (P0-10: a 'stream' lease, released on
|
|
461
|
+
* for the stream's lifetime (a 'stream' lease, released on
|
|
1212
462
|
* end/close/error/destroy — not when this function returns).
|
|
1213
463
|
*/
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
let lastError: unknown
|
|
1217
|
-
|
|
1218
|
-
// Preserve withClient's connect-only behavior: acquisition is safe to
|
|
1219
|
-
// retry because no SFTP operation has started until a lease is obtained.
|
|
1220
|
-
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
1221
|
-
try {
|
|
1222
|
-
lease = await this.connectionPool.acquire(alias, { kind: 'stream' })
|
|
1223
|
-
break
|
|
1224
|
-
} catch (error) {
|
|
1225
|
-
lastError = error
|
|
1226
|
-
if (attempt === 3) {
|
|
1227
|
-
throw error instanceof Error ? error : new Error(String(error))
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
// The loop either obtained a lease or threw on its final attempt.
|
|
1233
|
-
if (lease === undefined) {
|
|
1234
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
try {
|
|
1238
|
-
const sftp = await this.sftpFor(lease.client)
|
|
1239
|
-
const stream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
|
|
1240
|
-
|
|
1241
|
-
let released = false
|
|
1242
|
-
const release = (): void => {
|
|
1243
|
-
if (released) return
|
|
1244
|
-
released = true
|
|
1245
|
-
// Drop the other terminal listeners so the lease closure is not
|
|
1246
|
-
// retained after, e.g., 'end' fires before 'close'.
|
|
1247
|
-
stream.removeListener('end', release)
|
|
1248
|
-
stream.removeListener('close', release)
|
|
1249
|
-
stream.removeListener('error', release)
|
|
1250
|
-
lease.release()
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
stream.once('end', release)
|
|
1254
|
-
stream.once('close', release)
|
|
1255
|
-
stream.once('error', release)
|
|
1256
|
-
|
|
1257
|
-
// Node Readable.destroy() normally emits 'close', but ssh2's SFTP
|
|
1258
|
-
// stream is outside our control — release synchronously as a fallback
|
|
1259
|
-
// even if the implementation suppresses 'close'.
|
|
1260
|
-
const originalDestroy = stream.destroy
|
|
1261
|
-
stream.destroy = function destroy(error?: Error): typeof stream {
|
|
1262
|
-
try {
|
|
1263
|
-
return originalDestroy.call(this, error) as typeof stream
|
|
1264
|
-
} finally {
|
|
1265
|
-
release()
|
|
1266
|
-
}
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
return stream
|
|
1270
|
-
} catch (error) {
|
|
1271
|
-
// Covers both caching/opening the SFTP subsystem and a synchronous
|
|
1272
|
-
// createReadStream failure; no stream escaped, ownership ends here.
|
|
1273
|
-
lease.release()
|
|
1274
|
-
throw error
|
|
1275
|
-
}
|
|
464
|
+
readStream(alias: string, remotePath: string, signal?: AbortSignal): Promise<import('node:stream').Readable> {
|
|
465
|
+
return this.sftpService.readStream(alias, remotePath, signal)
|
|
1276
466
|
}
|
|
1277
467
|
|
|
1278
468
|
/**
|
|
1279
469
|
* Read one remote file fully into memory (text or binary) with its mtime.
|
|
1280
470
|
* The workspace plugin's text gate (UTF-8 + size caps) lives on its caller.
|
|
1281
471
|
*/
|
|
1282
|
-
|
|
1283
|
-
return this.
|
|
1284
|
-
const sftp = await this.sftpFor(client)
|
|
1285
|
-
const attrs = await this.withTimeout(this.sftpStat(sftp, remotePath), SFTP_OP_TIMEOUT_MS, `remote stat timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${remotePath}`)
|
|
1286
|
-
if (attrs.isDirectory()) throw new Error(`'${remotePath}' is a directory`)
|
|
1287
|
-
const chunks: Buffer[] = []
|
|
1288
|
-
let readStream: import('node:stream').Readable | undefined
|
|
1289
|
-
await this.withTimeout(new Promise<void>((resolve, reject) => {
|
|
1290
|
-
readStream = sftp.createReadStream(remotePath) as unknown as import('node:stream').Readable
|
|
1291
|
-
readStream.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
|
1292
|
-
readStream.on('error', (error: Error) => reject(error))
|
|
1293
|
-
readStream.on('end', () => resolve())
|
|
1294
|
-
}), SFTP_READ_TIMEOUT_MS, `remote read timed out after ${SFTP_READ_TIMEOUT_MS}ms: ${remotePath}`, () => {
|
|
1295
|
-
// Abort the transfer: an un-destroyed stream keeps pulling data into
|
|
1296
|
-
// `chunks` (and holding an SFTP channel) after the caller timed out.
|
|
1297
|
-
try { readStream?.destroy() } catch { /* already closed */ }
|
|
1298
|
-
})
|
|
1299
|
-
return { content: Buffer.concat(chunks), mtime: attrs.mtime * 1000, size: attrs.size }
|
|
1300
|
-
})
|
|
472
|
+
readFile(alias: string, remotePath: string, maxBytes?: number, signal?: AbortSignal): Promise<{ content: Buffer; mtime: number; size: number }> {
|
|
473
|
+
return this.sftpService.readFile(alias, remotePath, maxBytes, signal)
|
|
1301
474
|
}
|
|
1302
475
|
|
|
1303
476
|
/**
|
|
1304
477
|
* Write one remote file from memory (parents are created). When
|
|
1305
478
|
* `expectedMtime` is given, a stat-then-write conflict check throws before
|
|
1306
|
-
* any byte is written (the GUI and
|
|
1307
|
-
* overwrite protection).
|
|
479
|
+
* any byte is written (overwrite protection for the GUI and workspace tools).
|
|
1308
480
|
*/
|
|
1309
|
-
|
|
1310
|
-
return this.
|
|
1311
|
-
const sftp = await this.sftpFor(client)
|
|
1312
|
-
await this.ensureRemoteDir(sftp, dirname(remotePath))
|
|
1313
|
-
if (expectedMtime !== undefined) {
|
|
1314
|
-
const attrs = await this.sftpStat(sftp, remotePath)
|
|
1315
|
-
const current = attrs.mtime * 1000
|
|
1316
|
-
if (current !== expectedMtime) {
|
|
1317
|
-
throw new Error(`mtime conflict: remote mtime ${current} != expected ${expectedMtime}`)
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
await new Promise<void>((resolve, reject) => {
|
|
1321
|
-
const stream = sftp.createWriteStream(remotePath)
|
|
1322
|
-
stream.on('error', (error: Error) => reject(error))
|
|
1323
|
-
stream.on('close', () => resolve())
|
|
1324
|
-
stream.end(content)
|
|
1325
|
-
})
|
|
1326
|
-
const attrs = await this.sftpStat(sftp, remotePath)
|
|
1327
|
-
return { mtime: attrs.mtime * 1000 }
|
|
1328
|
-
})
|
|
481
|
+
writeFile(alias: string, remotePath: string, content: Buffer, expectedMtime?: number, signal?: AbortSignal): Promise<{ mtime: number }> {
|
|
482
|
+
return this.sftpService.writeFile(alias, remotePath, content, expectedMtime, signal)
|
|
1329
483
|
}
|
|
1330
484
|
|
|
1331
485
|
/** Create a remote directory chain (mkdir -p semantics). */
|
|
1332
|
-
|
|
1333
|
-
return this.
|
|
1334
|
-
const sftp = await this.sftpFor(client)
|
|
1335
|
-
await this.ensureRemoteDir(sftp, remotePath)
|
|
1336
|
-
})
|
|
486
|
+
mkdir(alias: string, remotePath: string, signal?: AbortSignal): Promise<void> {
|
|
487
|
+
return this.sftpService.mkdir(alias, remotePath, signal)
|
|
1337
488
|
}
|
|
1338
489
|
|
|
1339
490
|
/**
|
|
@@ -1345,384 +496,31 @@ export class SshEngine {
|
|
|
1345
496
|
* recursed into — the old stat/readdir-attr check could delete the link
|
|
1346
497
|
* target's contents.
|
|
1347
498
|
*/
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
if (remotePath === '' || normalized === '' || normalized === '/') {
|
|
1351
|
-
throw new Error(`refusing to delete root path '${remotePath}'`)
|
|
1352
|
-
}
|
|
1353
|
-
return this.withClient(alias, async (client) => {
|
|
1354
|
-
const sftp = await this.sftpFor(client)
|
|
1355
|
-
const attrs = await this.sftpLstat(sftp, remotePath)
|
|
1356
|
-
if (attrs.isSymbolicLink() || !attrs.isDirectory()) {
|
|
1357
|
-
// Symlink or plain file: unlink only, never follow the link.
|
|
1358
|
-
await new Promise<void>((resolve, reject) => {
|
|
1359
|
-
sftp.unlink(remotePath, (error) => error !== undefined ? reject(error) : resolve())
|
|
1360
|
-
})
|
|
1361
|
-
return
|
|
1362
|
-
}
|
|
1363
|
-
if (!recursive) throw new Error(`'${remotePath}' is a directory —pass recursive: true`)
|
|
1364
|
-
const remove = async (dir: string): Promise<void> => {
|
|
1365
|
-
const list = await new Promise<Array<{ filename: string }>>((resolve, reject) => {
|
|
1366
|
-
sftp.readdir(dir, (error, entries) => error !== undefined ? reject(error) : resolve(entries))
|
|
1367
|
-
})
|
|
1368
|
-
for (const entry of list) {
|
|
1369
|
-
const child = dir.replace(/\/+$/, '') + '/' + entry.filename
|
|
1370
|
-
// lstat every child: readdir attrs may misreport a symlink as a
|
|
1371
|
-
// directory, and recursing into the link target is the exact
|
|
1372
|
-
// deletion hazard we must avoid.
|
|
1373
|
-
const childAttrs = await this.sftpLstat(sftp, child)
|
|
1374
|
-
if (childAttrs.isSymbolicLink() || !childAttrs.isDirectory()) {
|
|
1375
|
-
await new Promise<void>((resolve, reject) => {
|
|
1376
|
-
sftp.unlink(child, (error) => error !== undefined ? reject(error) : resolve())
|
|
1377
|
-
})
|
|
1378
|
-
} else {
|
|
1379
|
-
await remove(child)
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
await new Promise<void>((resolve, reject) => {
|
|
1383
|
-
sftp.rmdir(dir, (error) => error !== undefined ? reject(error) : resolve())
|
|
1384
|
-
})
|
|
1385
|
-
}
|
|
1386
|
-
await remove(remotePath)
|
|
1387
|
-
})
|
|
499
|
+
rm(alias: string, remotePath: string, recursive = false, signal?: AbortSignal): Promise<void> {
|
|
500
|
+
return this.sftpService.rm(alias, remotePath, recursive, signal)
|
|
1388
501
|
}
|
|
1389
502
|
|
|
1390
503
|
/** Rename / move a remote path (mv semantics, same filesystem). */
|
|
1391
|
-
|
|
1392
|
-
return this.
|
|
1393
|
-
const sftp = await this.sftpFor(client)
|
|
1394
|
-
await new Promise<void>((resolve, reject) => {
|
|
1395
|
-
sftp.rename(fromPath, toPath, (error) => error !== undefined ? reject(error) : resolve())
|
|
1396
|
-
})
|
|
1397
|
-
})
|
|
1398
|
-
}
|
|
1399
|
-
|
|
1400
|
-
/** Reject a promise after `ms` (unref'd so it never keeps the process alive).
|
|
1401
|
-
* `onTimeout` (when given) runs right before the rejection: ssh2 SFTP
|
|
1402
|
-
* requests have no cancel API, so callers that hold an abort handle (e.g.
|
|
1403
|
-
* a read stream) destroy it here — otherwise the underlying transfer would
|
|
1404
|
-
* keep running (and, for reads, keep buffering) after the caller was told
|
|
1405
|
-
* it timed out. */
|
|
1406
|
-
private withTimeout<T>(promise: Promise<T>, ms: number, message: string, onTimeout?: () => void): Promise<T> {
|
|
1407
|
-
return new Promise<T>((resolve, reject) => {
|
|
1408
|
-
const timer = setTimeout(() => {
|
|
1409
|
-
try { onTimeout?.() } catch { /* best-effort abort */ }
|
|
1410
|
-
reject(new Error(message))
|
|
1411
|
-
}, ms)
|
|
1412
|
-
timer.unref?.()
|
|
1413
|
-
promise.then(
|
|
1414
|
-
(value) => { clearTimeout(timer); resolve(value) },
|
|
1415
|
-
(error) => { clearTimeout(timer); reject(error) },
|
|
1416
|
-
)
|
|
1417
|
-
})
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
/** Stat wrapper (one SFTP stat call). */
|
|
1421
|
-
private sftpStat(sftp: import('ssh2').SFTPWrapper, remotePath: string): Promise<import('ssh2').Stats> {
|
|
1422
|
-
return new Promise((resolve, reject) => {
|
|
1423
|
-
sftp.stat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
1424
|
-
})
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
/** Lstat wrapper (does NOT follow symlinks — the deletion safety gate). */
|
|
1428
|
-
private sftpLstat(sftp: import('ssh2').SFTPWrapper, remotePath: string): Promise<import('ssh2').Stats> {
|
|
1429
|
-
return new Promise((resolve, reject) => {
|
|
1430
|
-
sftp.lstat(remotePath, (error, stats) => error !== undefined ? reject(error) : resolve(stats))
|
|
1431
|
-
})
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
/**
|
|
1435
|
-
* The (cached) SFTP channel for a pooled client. `Client.sftp()` opens a new
|
|
1436
|
-
* subsystem channel per call, so this memoizes one channel per live client;
|
|
1437
|
-
* when the channel closes the cache entry is dropped so the next call opens
|
|
1438
|
-
* SFTP on the replacement connection. Failed opens are also evicted so a
|
|
1439
|
-
* transient channel failure can be retried.
|
|
1440
|
-
*/
|
|
1441
|
-
private sftpFor(client: Client): Promise<import('ssh2').SFTPWrapper> {
|
|
1442
|
-
const cached = this.sftpChannels.get(client)
|
|
1443
|
-
if (cached !== undefined) return cached
|
|
1444
|
-
const pending = new Promise<import('ssh2').SFTPWrapper>((resolve, reject) => {
|
|
1445
|
-
client.sftp((error, sftp) => {
|
|
1446
|
-
if (error !== undefined) {
|
|
1447
|
-
this.sftpChannels.delete(client)
|
|
1448
|
-
reject(error)
|
|
1449
|
-
return
|
|
1450
|
-
}
|
|
1451
|
-
sftp.on('close', () => { this.sftpChannels.delete(client) })
|
|
1452
|
-
sftp.on('error', () => { this.sftpChannels.delete(client) })
|
|
1453
|
-
resolve(sftp)
|
|
1454
|
-
})
|
|
1455
|
-
})
|
|
1456
|
-
this.sftpChannels.set(client, pending)
|
|
1457
|
-
return pending
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
/** Create a remote directory chain (stat-then-mkdir per segment). */
|
|
1461
|
-
private async ensureRemoteDir(sftp: import('ssh2').SFTPWrapper, remote: string): Promise<void> {
|
|
1462
|
-
const segments = remote.replace(/^\/+/, '').split('/').filter(segment => segment !== '')
|
|
1463
|
-
for (let index = 0; index < segments.length; index += 1) {
|
|
1464
|
-
const current = '/' + segments.slice(0, index + 1).join('/')
|
|
1465
|
-
// Stat-then-mkdir: a missing path fails the stat, and mkdir is
|
|
1466
|
-
// idempotent because the stat check runs first (some sftp servers
|
|
1467
|
-
// throw on EEXIST). Any stat error is treated as "not there", which
|
|
1468
|
-
// matches the previous recursive behavior.
|
|
1469
|
-
const exists = await this.withTimeout(
|
|
1470
|
-
this.sftpStat(sftp, current),
|
|
1471
|
-
SFTP_OP_TIMEOUT_MS,
|
|
1472
|
-
`remote stat timed out after ${SFTP_OP_TIMEOUT_MS}ms: ${current}`,
|
|
1473
|
-
).then(() => true, () => false)
|
|
1474
|
-
if (exists) continue
|
|
1475
|
-
await new Promise<void>((resolve, reject) => {
|
|
1476
|
-
sftp.mkdir(current, (error) => error !== undefined ? reject(error) : resolve())
|
|
1477
|
-
})
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
private fastPut(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
1482
|
-
return new Promise((resolve, reject) => {
|
|
1483
|
-
const tracker = createTransferProgressTracker(dst, statSync(src).size, onProgress)
|
|
1484
|
-
sftp.fastPut(src, dst, {
|
|
1485
|
-
concurrency: this.opts.sftpConcurrency,
|
|
1486
|
-
step: (transferred: number, _chunk: number, total: number) => tracker.step(transferred, total),
|
|
1487
|
-
}, (error) => {
|
|
1488
|
-
if (error !== undefined) {
|
|
1489
|
-
tracker.fail(error)
|
|
1490
|
-
reject(error)
|
|
1491
|
-
} else {
|
|
1492
|
-
tracker.done()
|
|
1493
|
-
resolve()
|
|
1494
|
-
}
|
|
1495
|
-
})
|
|
1496
|
-
})
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
private fastGet(sftp: import('ssh2').SFTPWrapper, src: string, dst: string, initialTotal: number, onProgress?: (progress: TransferProgress) => void): Promise<void> {
|
|
1500
|
-
return new Promise((resolve, reject) => {
|
|
1501
|
-
const tracker = createTransferProgressTracker(src, initialTotal, onProgress)
|
|
1502
|
-
sftp.fastGet(src, dst, {
|
|
1503
|
-
concurrency: this.opts.sftpConcurrency,
|
|
1504
|
-
step: (transferred: number, _chunk: number, total: number) => tracker.step(transferred, total),
|
|
1505
|
-
}, (error) => {
|
|
1506
|
-
if (error !== undefined) {
|
|
1507
|
-
tracker.fail(error)
|
|
1508
|
-
reject(error)
|
|
1509
|
-
} else {
|
|
1510
|
-
tracker.done()
|
|
1511
|
-
resolve()
|
|
1512
|
-
}
|
|
1513
|
-
})
|
|
1514
|
-
})
|
|
504
|
+
rename(alias: string, fromPath: string, toPath: string, signal?: AbortSignal): Promise<void> {
|
|
505
|
+
return this.sftpService.rename(alias, fromPath, toPath, signal)
|
|
1515
506
|
}
|
|
1516
507
|
|
|
1517
508
|
// ------------------------------------------------------------- tunnel
|
|
1518
509
|
|
|
1519
|
-
/** Remove one tunnel's transport-failure listener (both events). */
|
|
1520
|
-
private removeTunnelClientFailureListener(tunnel: TunnelRecord): void {
|
|
1521
|
-
const client = tunnel.lease.client
|
|
1522
|
-
client.removeListener('error', tunnel.clientFailureHandler)
|
|
1523
|
-
client.removeListener('close', tunnel.clientFailureHandler)
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
/**
|
|
1527
|
-
* Mark every tunnel sharing a failed physical SSH client as failed. Failed
|
|
1528
|
-
* records stay in this.tunnels so listTunnels() exposes the terminal state
|
|
1529
|
-
* and stopTunnel() remains the single place that deletes + releases.
|
|
1530
|
-
*/
|
|
1531
|
-
private markTunnelsFailedForClient(client: Client, _error?: unknown): void {
|
|
1532
|
-
for (const tunnel of this.tunnels.values()) {
|
|
1533
|
-
if (tunnel.lease.client !== client) continue
|
|
1534
|
-
tunnel.info.state = 'failed'
|
|
1535
|
-
this.removeTunnelClientFailureListener(tunnel)
|
|
1536
|
-
try { tunnel.server.close() } catch { /* never listened or already closed */ }
|
|
1537
|
-
for (const socket of tunnel.sockets) {
|
|
1538
|
-
try { socket.destroy() } catch { /* peer already gone */ }
|
|
1539
|
-
}
|
|
1540
|
-
tunnel.sockets.clear()
|
|
1541
|
-
}
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
/** Start a local port-forward tunnel (listens on 127.0.0.1 only). */
|
|
1545
510
|
async startTunnel(alias: string, options: { remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
|
|
1546
|
-
|
|
1547
|
-
throw new Error('remotePort must be an integer in 1..65535')
|
|
1548
|
-
}
|
|
1549
|
-
if (options.localPort !== undefined && (!Number.isInteger(options.localPort) || options.localPort < 1 || options.localPort > 65535)) {
|
|
1550
|
-
throw new Error('localPort must be an integer in 1..65535')
|
|
1551
|
-
}
|
|
1552
|
-
const entry = this.store.find(alias)
|
|
1553
|
-
if (entry === undefined) throw new Error(`alias '${alias}' not found —add it first`)
|
|
1554
|
-
const remoteHost = options.remoteHost ?? '127.0.0.1'
|
|
1555
|
-
const id = `tun-${this.nextTunnelId++}`
|
|
1556
|
-
const info: TunnelInfo = {
|
|
1557
|
-
id,
|
|
1558
|
-
alias,
|
|
1559
|
-
localPort: 0,
|
|
1560
|
-
remoteHost,
|
|
1561
|
-
remotePort: options.remotePort,
|
|
1562
|
-
state: 'connecting',
|
|
1563
|
-
startedAt: Date.now(),
|
|
1564
|
-
}
|
|
1565
|
-
const lease = await this.connectionPool.acquire(alias, { kind: 'tunnel' })
|
|
1566
|
-
const client = lease.client
|
|
1567
|
-
const sockets = new Set<import('node:net').Socket>()
|
|
1568
|
-
|
|
1569
|
-
const server = createServer((socket) => {
|
|
1570
|
-
sockets.add(socket)
|
|
1571
|
-
|
|
1572
|
-
let forwardFinished = false
|
|
1573
|
-
let forwardTimer: NodeJS.Timeout | undefined
|
|
1574
|
-
|
|
1575
|
-
const abandonForward = (): void => {
|
|
1576
|
-
if (forwardFinished) return
|
|
1577
|
-
forwardFinished = true
|
|
1578
|
-
if (forwardTimer !== undefined) clearTimeout(forwardTimer)
|
|
1579
|
-
}
|
|
1580
|
-
socket.once('close', abandonForward)
|
|
1581
|
-
|
|
1582
|
-
forwardTimer = setTimeout(() => {
|
|
1583
|
-
if (forwardFinished) return
|
|
1584
|
-
forwardFinished = true
|
|
1585
|
-
// forwardOut cannot be cancelled through ssh2; destroy the local side
|
|
1586
|
-
// now, and the late callback will close any channel that arrives.
|
|
1587
|
-
try {
|
|
1588
|
-
socket.destroy(new Error(`SSH tunnel forward timed out after ${TUNNEL_FORWARD_TIMEOUT_MS}ms`))
|
|
1589
|
-
} catch { /* local peer already gone */ }
|
|
1590
|
-
}, TUNNEL_FORWARD_TIMEOUT_MS)
|
|
1591
|
-
forwardTimer.unref?.()
|
|
1592
|
-
|
|
1593
|
-
client.forwardOut('127.0.0.1', 0, remoteHost, options.remotePort, (error, stream) => {
|
|
1594
|
-
// Timeout / local disconnect / tunnel stop may have happened while
|
|
1595
|
-
// forwardOut was pending — never attach a late channel.
|
|
1596
|
-
if (forwardFinished || socket.destroyed) {
|
|
1597
|
-
if (forwardTimer !== undefined) clearTimeout(forwardTimer)
|
|
1598
|
-
forwardFinished = true
|
|
1599
|
-
if (stream !== undefined) {
|
|
1600
|
-
try { stream.close() } catch { /* late channel already closed */ }
|
|
1601
|
-
}
|
|
1602
|
-
return
|
|
1603
|
-
}
|
|
1604
|
-
forwardFinished = true
|
|
1605
|
-
if (forwardTimer !== undefined) clearTimeout(forwardTimer)
|
|
1606
|
-
if (error !== undefined) {
|
|
1607
|
-
socket.destroy()
|
|
1608
|
-
return
|
|
1609
|
-
}
|
|
1610
|
-
// Both ends of the pipe can die independently; destroy the pair so an
|
|
1611
|
-
// unhandled 'error' event can never crash the host process.
|
|
1612
|
-
const destroy = (): void => {
|
|
1613
|
-
try { socket.destroy() } catch { /* gone */ }
|
|
1614
|
-
try { stream.close() } catch { /* gone */ }
|
|
1615
|
-
}
|
|
1616
|
-
stream.on('error', destroy)
|
|
1617
|
-
socket.on('error', destroy)
|
|
1618
|
-
stream.on('close', destroy)
|
|
1619
|
-
socket.on('close', destroy)
|
|
1620
|
-
stream.pipe(socket).pipe(stream)
|
|
1621
|
-
})
|
|
1622
|
-
})
|
|
1623
|
-
|
|
1624
|
-
let rejectStart: ((reason?: unknown) => void) | undefined
|
|
1625
|
-
|
|
1626
|
-
const clientFailureHandler = (error?: unknown): void => {
|
|
1627
|
-
this.markTunnelsFailedForClient(client, error)
|
|
1628
|
-
// If the transport dies while server.listen() is still pending, reject
|
|
1629
|
-
// instead of returning a failed tunnel as a success.
|
|
1630
|
-
rejectStart?.(error instanceof Error
|
|
1631
|
-
? error
|
|
1632
|
-
: new Error(`SSH connection '${alias}' closed while starting tunnel`))
|
|
1633
|
-
}
|
|
1634
|
-
|
|
1635
|
-
const tunnel: TunnelRecord = { info, server, alias, lease, sockets, clientFailureHandler }
|
|
1636
|
-
|
|
1637
|
-
// Register before listen() so a transport failure during the async listen
|
|
1638
|
-
// window can find and fail this record.
|
|
1639
|
-
this.tunnels.set(id, tunnel)
|
|
1640
|
-
client.once('error', clientFailureHandler)
|
|
1641
|
-
client.once('close', clientFailureHandler)
|
|
1642
|
-
|
|
1643
|
-
try {
|
|
1644
|
-
await new Promise<void>((resolve, reject) => {
|
|
1645
|
-
let settled = false
|
|
1646
|
-
const resolveOnce = (): void => {
|
|
1647
|
-
if (settled) return
|
|
1648
|
-
settled = true
|
|
1649
|
-
rejectStart = undefined
|
|
1650
|
-
server.removeListener('error', rejectOnce)
|
|
1651
|
-
resolve()
|
|
1652
|
-
}
|
|
1653
|
-
const rejectOnce = (error: unknown): void => {
|
|
1654
|
-
if (settled) return
|
|
1655
|
-
settled = true
|
|
1656
|
-
rejectStart = undefined
|
|
1657
|
-
server.removeListener('error', rejectOnce)
|
|
1658
|
-
reject(error)
|
|
1659
|
-
}
|
|
1660
|
-
rejectStart = rejectOnce
|
|
1661
|
-
server.once('error', rejectOnce)
|
|
1662
|
-
server.listen(options.localPort ?? 0, '127.0.0.1', resolveOnce)
|
|
1663
|
-
})
|
|
1664
|
-
} catch (error) {
|
|
1665
|
-
// Full rollback for listen failure or transport failure during start.
|
|
1666
|
-
this.tunnels.delete(id)
|
|
1667
|
-
this.removeTunnelClientFailureListener(tunnel)
|
|
1668
|
-
try { server.close() } catch { /* never listened */ }
|
|
1669
|
-
for (const socket of sockets) {
|
|
1670
|
-
try { socket.destroy() } catch { /* already closed */ }
|
|
1671
|
-
}
|
|
1672
|
-
sockets.clear()
|
|
1673
|
-
lease.release()
|
|
1674
|
-
throw error
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
// The client-failure handler may have set failed right around listen
|
|
1678
|
-
// completion; do not overwrite that terminal state with 'forwarding'.
|
|
1679
|
-
if (info.state === 'failed') {
|
|
1680
|
-
this.tunnels.delete(id)
|
|
1681
|
-
this.removeTunnelClientFailureListener(tunnel)
|
|
1682
|
-
lease.release()
|
|
1683
|
-
throw new Error(`SSH connection '${alias}' closed while starting tunnel`)
|
|
1684
|
-
}
|
|
1685
|
-
|
|
1686
|
-
const address = server.address()
|
|
1687
|
-
info.localPort = typeof address === 'object' && address !== null ? address.port : 0
|
|
1688
|
-
info.state = 'forwarding'
|
|
1689
|
-
return info
|
|
511
|
+
return this.tunnelService.startTunnel(alias, options)
|
|
1690
512
|
}
|
|
1691
513
|
|
|
1692
|
-
/** All active tunnels. */
|
|
1693
514
|
listTunnels(): TunnelInfo[] {
|
|
1694
|
-
return
|
|
515
|
+
return this.tunnelService.listTunnels()
|
|
1695
516
|
}
|
|
1696
517
|
|
|
1697
|
-
/** Stop one tunnel (closes listener and live sockets, releases its lease). */
|
|
1698
518
|
stopTunnel(id: string): boolean {
|
|
1699
|
-
|
|
1700
|
-
if (tunnel === undefined) return false
|
|
1701
|
-
// Delete first so re-entrant stop calls and transport events cannot
|
|
1702
|
-
// process this tunnel twice.
|
|
1703
|
-
this.tunnels.delete(id)
|
|
1704
|
-
this.removeTunnelClientFailureListener(tunnel)
|
|
1705
|
-
try { tunnel.server.close() } catch { /* already closed by failure handler */ }
|
|
1706
|
-
for (const socket of tunnel.sockets) {
|
|
1707
|
-
try { socket.destroy() } catch { /* already closed */ }
|
|
1708
|
-
}
|
|
1709
|
-
tunnel.sockets.clear()
|
|
1710
|
-
// Release only THIS tunnel's lease — other tunnels (or operations) on the
|
|
1711
|
-
// same alias keep their own ownership of the pooled connection.
|
|
1712
|
-
tunnel.lease.release()
|
|
1713
|
-
return true
|
|
519
|
+
return this.tunnelService.stopTunnel(id)
|
|
1714
520
|
}
|
|
1715
521
|
|
|
1716
|
-
/** Stop all tunnels (optionally for one alias). */
|
|
1717
522
|
stopAllTunnels(alias?: string): number {
|
|
1718
|
-
|
|
1719
|
-
for (const [id, tunnel] of [...this.tunnels]) {
|
|
1720
|
-
if (alias === undefined || tunnel.alias === alias) {
|
|
1721
|
-
this.stopTunnel(id)
|
|
1722
|
-
count += 1
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
return count
|
|
523
|
+
return this.tunnelService.stopAllTunnels(alias)
|
|
1726
524
|
}
|
|
1727
525
|
|
|
1728
526
|
// ------------------------------------------------------------- misc
|
|
@@ -1732,30 +530,24 @@ export class SshEngine {
|
|
|
1732
530
|
* a plain message — callers (routes → panel / workspace gate) key their
|
|
1733
531
|
* interactive dialogs on the typed error. Everything else (unreachable,
|
|
1734
532
|
* timeout, auth failure) returns a failed result. */
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
try {
|
|
1738
|
-
// `true` is idempotent — allow channel-open retries, never replays
|
|
1739
|
-
// once the server accepted the channel.
|
|
1740
|
-
const result = await this.exec(alias, 'true', { timeoutMs: 10_000, retry: 'idempotent' })
|
|
1741
|
-
return result.success
|
|
1742
|
-
? { ok: true, latencyMs: result.durationMs }
|
|
1743
|
-
: { ok: false, latencyMs: result.durationMs, error: `remote exit code ${result.exitCode}` }
|
|
1744
|
-
} catch (error) {
|
|
1745
|
-
if (error instanceof NeedsPasswordError || error instanceof HostKeyUnknownError || error instanceof HostKeyMismatchError) {
|
|
1746
|
-
throw error
|
|
1747
|
-
}
|
|
1748
|
-
return { ok: false, latencyMs: Date.now() - started, error: error instanceof Error ? error.message : String(error) }
|
|
1749
|
-
}
|
|
533
|
+
test(alias: string, signal?: AbortSignal): Promise<import('./protocol.ts').TestResult> {
|
|
534
|
+
return this.manager.test(alias, signal)
|
|
1750
535
|
}
|
|
1751
536
|
|
|
1752
|
-
/**
|
|
1753
|
-
*
|
|
537
|
+
/**
|
|
538
|
+
* Close every resource this engine owns and wipe the in-memory session
|
|
539
|
+
* password table (secrets are never persisted anywhere).
|
|
540
|
+
*
|
|
541
|
+
* Order: tunnel → terminal → SFTP → connection, so a resource is always
|
|
542
|
+
* closed before the transport that carries it, and each resource has exactly
|
|
543
|
+
* one dispose point.
|
|
544
|
+
*/
|
|
1754
545
|
dispose(): void {
|
|
1755
|
-
|
|
1756
|
-
this.
|
|
1757
|
-
this.
|
|
1758
|
-
|
|
546
|
+
this.tunnelService.dispose()
|
|
547
|
+
this.terminalService.dispose()
|
|
548
|
+
this.sftpService.dispose(new Error('SSH engine disposed while SFTP was active'))
|
|
549
|
+
// Retires every pooled transport (whose onDispose hook drops the SFTP
|
|
550
|
+
// channel of that client) and then clears the session secrets.
|
|
551
|
+
this.manager.disposeSensitive()
|
|
1759
552
|
}
|
|
1760
553
|
}
|
|
1761
|
-
|