@tiphareth/dsh-hardssh 0.1.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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17780 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +134 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +96 -0
- package/src/client/workspace-gate.ts +225 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SSH engine: a per-alias persistent connection pool (ssh2) with
|
|
3
|
+
* multi-hop jump support, command execution, PTY shells, SFTP transfers,
|
|
4
|
+
* local port-forward tunnels and cluster execution —the DSH counterpart of
|
|
5
|
+
* ssh-skill's daemon + scripts, living entirely in the host process.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, type Server as NetServer } from 'node:net'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, statSync, readdirSync } from 'node:fs'
|
|
10
|
+
import { dirname, join, relative, resolve as resolvePath } from 'node:path'
|
|
11
|
+
import { Client, type ClientChannel, type ConnectConfig } from 'ssh2'
|
|
12
|
+
import { BoundedUtf8Output } from './exec/output.ts'
|
|
13
|
+
import { ConnectionPool, type SshConnectionService } from './connection/pool.ts'
|
|
14
|
+
import type { ClientLease } from './connection/lease.ts'
|
|
15
|
+
import { createTransferProgressTracker } from './transfer/progress.ts'
|
|
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'
|
|
19
|
+
import {
|
|
20
|
+
HostKeyMismatchError,
|
|
21
|
+
HostKeyPolicy,
|
|
22
|
+
HostKeyUnknownError,
|
|
23
|
+
type HostKeyCheck,
|
|
24
|
+
type KnownHostsStore,
|
|
25
|
+
} from './known-hosts.ts'
|
|
26
|
+
|
|
27
|
+
/** Options used when retiring pooled connections after host configuration changes. */
|
|
28
|
+
export interface SshInvalidateOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Also invalidate hosts whose ProxyJump chain depends, directly or
|
|
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
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Optional engine dependencies for host-key TOFU and secret resolution.
|
|
65
|
+
* All fields are optional and their absence preserves the pre-security
|
|
66
|
+
* behavior exactly (plaintext inline auth, no host verification) — the
|
|
67
|
+
* existing tests and call sites keep working unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export interface EngineDeps {
|
|
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
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The resolved-auth shape passed into connect config building (vault-aware). */
|
|
84
|
+
export interface ResolvedAuthDeps {
|
|
85
|
+
kind: SshHostEntry['auth']['kind']
|
|
86
|
+
keyPath?: string
|
|
87
|
+
password?: string
|
|
88
|
+
passphrase?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Thrown when a connection needs a password/passphrase that is not yet
|
|
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
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* How much an operation may be retried:
|
|
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).
|
|
125
|
+
*
|
|
126
|
+
* SFTP operations must never use 'idempotent': they have no commit point, so
|
|
127
|
+
* a replay after a mid-flight timeout would duplicate a remote write.
|
|
128
|
+
*/
|
|
129
|
+
export type RetryPolicy = 'never' | 'connect-only' | 'idempotent'
|
|
130
|
+
|
|
131
|
+
/** Options for a one-shot remote command. */
|
|
132
|
+
export interface ExecOptions {
|
|
133
|
+
timeoutMs?: number
|
|
134
|
+
retry?: RetryPolicy
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Internal options for withClient(). */
|
|
138
|
+
interface WithClientOptions {
|
|
139
|
+
/** Total acquire+operation attempt budget (default 3, capped at 1 for 'never'). */
|
|
140
|
+
attempts?: number
|
|
141
|
+
retryPolicy?: RetryPolicy
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Lets an operation declare the point after which replay is unsafe. */
|
|
145
|
+
interface OperationControl {
|
|
146
|
+
markCommitted(): void
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** SFTP operations must never leave the file tree spinning forever: fail the
|
|
150
|
+
* request after this budget so a stalled channel (half-dead connection,
|
|
151
|
+
* unresponsive server) errors out instead of hanging the GUI. */
|
|
152
|
+
const SFTP_OP_TIMEOUT_MS = 15_000
|
|
153
|
+
/** Read whole remote files with a generous budget (slow links can take a
|
|
154
|
+
* while to stream large files). */
|
|
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
|
|
166
|
+
|
|
167
|
+
/** A live PTY shell session. */
|
|
168
|
+
export interface ShellSession {
|
|
169
|
+
/** Assign to receive remote output. */
|
|
170
|
+
onData?: (data: Buffer) => void
|
|
171
|
+
/** Assign to be notified when the channel closes. */
|
|
172
|
+
onExit?: (code: number | null, error?: string) => void
|
|
173
|
+
/** Write raw input to the shell. */
|
|
174
|
+
send(data: string): void
|
|
175
|
+
/** Resize the remote PTY. */
|
|
176
|
+
resize(cols: number, rows: number): void
|
|
177
|
+
/** Send an SSH signal (e.g. 'TERM', 'KILL') to the remote process group. */
|
|
178
|
+
signal(name: string): void
|
|
179
|
+
/** Close the session and its channel. */
|
|
180
|
+
close(): void
|
|
181
|
+
/** Pause remote output delivery (transport backpressure). */
|
|
182
|
+
pause(): void
|
|
183
|
+
/** Resume remote output delivery. */
|
|
184
|
+
resume(): void
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A live streaming exec channel (no PTY): separate stdout/stderr delivery,
|
|
189
|
+
* stdin writes, SSH signals (TERM/KILL), and an explicit end for the final
|
|
190
|
+
* input burst. Used by the subprocess capability seam's remote provider.
|
|
191
|
+
*/
|
|
192
|
+
export interface ExecSession extends ShellSession {
|
|
193
|
+
/** Assign to receive the remote stderr stream. */
|
|
194
|
+
onErrData?: (data: Buffer) => void
|
|
195
|
+
/** Send an SSH signal (e.g. 'TERM', 'KILL') to the remote process. */
|
|
196
|
+
signal(name: string): void
|
|
197
|
+
/** Write the final input burst and half-close stdin. */
|
|
198
|
+
end(data?: string): void
|
|
199
|
+
}
|
|
200
|
+
|
|
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
|
+
/**
|
|
220
|
+
* Resolve the ssh-agent socket to offer to ssh2 (zero-input key auth,
|
|
221
|
+
* VSCode-style): `$SSH_AUTH_SOCK` — the standard OpenSSH agent socket (also
|
|
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.
|
|
322
|
+
*
|
|
323
|
+
* When `context` carries a captured host-key outcome from a prior
|
|
324
|
+
* `hostVerifier` refusal, the generic error is rewritten into a typed
|
|
325
|
+
* HostKeyUnknownError / HostKeyMismatchError so callers and the GUI can
|
|
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.
|
|
400
|
+
*/
|
|
401
|
+
export class SshEngine {
|
|
402
|
+
private readonly store: HostStoreView
|
|
403
|
+
private readonly opts: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'>
|
|
404
|
+
private readonly tunnels = new Map<string, TunnelRecord>()
|
|
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 }>()
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param store - the host config store.
|
|
427
|
+
* @param options - engine knobs (defaults applied).
|
|
428
|
+
* @param deps - optional security deps (host-key TOFU, secret resolution).
|
|
429
|
+
* Absent → pre-security behavior (inline auth, no host verification).
|
|
430
|
+
*/
|
|
431
|
+
constructor(store: HostStoreView, options?: EngineOptions, deps?: EngineDeps) {
|
|
432
|
+
this.store = store
|
|
433
|
+
this.opts = { ...DEFAULTS, ...options }
|
|
434
|
+
this.deps = deps ?? {}
|
|
435
|
+
this.hostKeyPolicy = this.deps.hostKeyPolicy
|
|
436
|
+
?? (this.deps.knownHosts !== undefined ? new HostKeyPolicy(this.deps.knownHosts) : undefined)
|
|
437
|
+
this.connectionPool = new ConnectionPool({
|
|
438
|
+
idleTimeoutMs: this.opts.idleTimeoutMs,
|
|
439
|
+
connect: async (alias) => {
|
|
440
|
+
const entry = this.store.find(alias)
|
|
441
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
|
|
442
|
+
return await this.connectChain(entry)
|
|
443
|
+
},
|
|
444
|
+
onDispose: (client) => {
|
|
445
|
+
this.sftpChannels.delete(client)
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------- session secrets
|
|
451
|
+
|
|
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
|
+
*/
|
|
458
|
+
setSessionPassword(alias: string, secret: { password?: string; passphrase?: string }): void {
|
|
459
|
+
this.sessionPasswords.set(alias, secret)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Read the session secret for one alias (undefined = not provided yet). */
|
|
463
|
+
getSessionPassword(alias: string): { password?: string; passphrase?: string } | undefined {
|
|
464
|
+
return this.sessionPasswords.get(alias)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Drop every session secret (e.g. on secretStorage change / lock). */
|
|
468
|
+
clearSessionSecrets(): void {
|
|
469
|
+
this.sessionPasswords.clear()
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------- config
|
|
473
|
+
|
|
474
|
+
/** Secret-free host list (filtered by the optional query). */
|
|
475
|
+
list(query?: string): SshHostSummary[] {
|
|
476
|
+
const needle = query?.trim().toLowerCase()
|
|
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))
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** One host summary by alias. */
|
|
487
|
+
find(alias: string): SshHostSummary | undefined {
|
|
488
|
+
const entry = this.store.find(alias)
|
|
489
|
+
return entry === undefined ? undefined : this.store.summarize(entry)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// -------------------------------------------------------------- pool
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Shared connection/lease service.
|
|
496
|
+
*
|
|
497
|
+
* The returned service is owned by this engine. Consumers may acquire
|
|
498
|
+
* leases or invalidate individual aliases, but must not treat it as a
|
|
499
|
+
* separately owned pool.
|
|
500
|
+
*/
|
|
501
|
+
get connections(): SshConnectionService {
|
|
502
|
+
return this.connectionPool
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** Aliases with a live pooled transport right now (for connection-state
|
|
506
|
+
* indicators — the GUI badge colors bound workspaces by it). */
|
|
507
|
+
connectedAliases(): string[] {
|
|
508
|
+
return this.connectionPool.liveAliases()
|
|
509
|
+
}
|
|
510
|
+
|
|
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
|
+
*/
|
|
519
|
+
invalidate(alias: string, options: SshInvalidateOptions = {}): void {
|
|
520
|
+
const aliases = new Set<string>([alias])
|
|
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
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Run `fn` with a live client for `alias`.
|
|
550
|
+
*
|
|
551
|
+
* Acquisition retry and operation replay are deliberately separate:
|
|
552
|
+
* - never: one acquire + one operation attempt.
|
|
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.
|
|
561
|
+
*/
|
|
562
|
+
private async withClient<T>(
|
|
563
|
+
alias: string,
|
|
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))
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** Resolve an entry's authentication for one connect: session password
|
|
623
|
+
* table first (secretStorage='none'), then deps.resolveSecrets (vault),
|
|
624
|
+
* then the inline store entry; when a password/passphrase is required but
|
|
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
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// --------------------------------------------------------------- exec
|
|
772
|
+
|
|
773
|
+
/** Run one command on `alias` (reusing the pooled connection). */
|
|
774
|
+
async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult>
|
|
775
|
+
async exec(alias: string, command: string, options: ExecOptions): Promise<ExecResult>
|
|
776
|
+
async exec(alias: string, command: string, optionsOrTimeout?: ExecOptions | number): Promise<ExecResult> {
|
|
777
|
+
// Backward compatible: exec(alias, command, timeoutMs) ===
|
|
778
|
+
// exec(alias, command, { timeoutMs, retry: 'connect-only' }).
|
|
779
|
+
const options: ExecOptions = typeof optionsOrTimeout === 'number'
|
|
780
|
+
? { timeoutMs: optionsOrTimeout, retry: 'connect-only' }
|
|
781
|
+
: { ...optionsOrTimeout, retry: optionsOrTimeout?.retry ?? 'connect-only' }
|
|
782
|
+
|
|
783
|
+
const started = Date.now()
|
|
784
|
+
const budget = options.timeoutMs !== undefined && options.timeoutMs > 0 ? options.timeoutMs : this.opts.defaultExecTimeoutMs
|
|
785
|
+
return this.withClient(alias, async (client, control) => {
|
|
786
|
+
return await new Promise<ExecResult>((resolve, reject) => {
|
|
787
|
+
client.exec(command, (error, stream) => {
|
|
788
|
+
if (error !== undefined) {
|
|
789
|
+
// The channel never opened: only an explicit 'idempotent'
|
|
790
|
+
// caller may replay this window (the server may or may not
|
|
791
|
+
// have seen the request).
|
|
792
|
+
reject(error)
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
// The server accepted the channel — the command may already be
|
|
796
|
+
// running. From here the command is NEVER replayed.
|
|
797
|
+
control.markCommitted()
|
|
798
|
+
const stdout = new BoundedUtf8Output(this.opts.maxOutputBytes)
|
|
799
|
+
const stderr = new BoundedUtf8Output(this.opts.maxOutputBytes)
|
|
800
|
+
let timedOut = false
|
|
801
|
+
let settled = false
|
|
802
|
+
const finish = (): void => {
|
|
803
|
+
if (settled) return
|
|
804
|
+
settled = true
|
|
805
|
+
clearTimeout(timer)
|
|
806
|
+
resolve({
|
|
807
|
+
success: false,
|
|
808
|
+
exitCode: null,
|
|
809
|
+
timedOut,
|
|
810
|
+
stdout: stdout.finish(),
|
|
811
|
+
stderr: stderr.finish(),
|
|
812
|
+
durationMs: Date.now() - started,
|
|
813
|
+
error: timedOut ? `command timed out after ${budget} ms` : undefined,
|
|
814
|
+
})
|
|
815
|
+
}
|
|
816
|
+
const timer = setTimeout(() => {
|
|
817
|
+
timedOut = true
|
|
818
|
+
try { stream.signal('KILL') } catch { /* channel gone */ }
|
|
819
|
+
try { stream.close() } catch { /* channel gone */ }
|
|
820
|
+
// Hard deadline: settle now even if the peer never acks the
|
|
821
|
+
// channel close (the stream 'close' handler is then a no-op).
|
|
822
|
+
finish()
|
|
823
|
+
}, budget)
|
|
824
|
+
stream.on('data', (chunk: Buffer) => stdout.append(chunk))
|
|
825
|
+
stream.stderr.on('data', (chunk: Buffer) => stderr.append(chunk))
|
|
826
|
+
stream.on('close', (code: number | null) => {
|
|
827
|
+
if (settled) return
|
|
828
|
+
settled = true
|
|
829
|
+
clearTimeout(timer)
|
|
830
|
+
resolve({
|
|
831
|
+
success: code === 0 && !timedOut,
|
|
832
|
+
exitCode: code,
|
|
833
|
+
timedOut,
|
|
834
|
+
stdout: stdout.finish(),
|
|
835
|
+
stderr: stderr.finish(),
|
|
836
|
+
durationMs: Date.now() - started,
|
|
837
|
+
})
|
|
838
|
+
})
|
|
839
|
+
stream.on('error', (streamError: Error) => {
|
|
840
|
+
if (settled) return
|
|
841
|
+
settled = true
|
|
842
|
+
clearTimeout(timer)
|
|
843
|
+
reject(streamError)
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
})
|
|
847
|
+
}, { attempts: 3, retryPolicy: options.retry })
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Run one command against many hosts concurrently. */
|
|
851
|
+
async cluster(options: {
|
|
852
|
+
command: string
|
|
853
|
+
aliases?: string[]
|
|
854
|
+
environment?: string
|
|
855
|
+
tags?: string[]
|
|
856
|
+
timeoutMs?: number
|
|
857
|
+
maxWorkers?: number
|
|
858
|
+
}): Promise<ClusterResult[]> {
|
|
859
|
+
let targets = this.store.list()
|
|
860
|
+
if (options.aliases !== undefined && options.aliases.length > 0) {
|
|
861
|
+
// Preserve the caller's alias order (store order is not contractual).
|
|
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
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// -------------------------------------------------------------- shell
|
|
899
|
+
|
|
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
|
+
/** Open a PTY shell session for the web terminal (standalone connection). */
|
|
957
|
+
async openShell(alias: string, size: { cols: number; rows: number }): Promise<ShellSession> {
|
|
958
|
+
// The shell is a long-lived exclusive stream: use its own connection so
|
|
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
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Open a streaming exec channel (no PTY) for the remote subprocess seam.
|
|
988
|
+
* Like the PTY shell, the channel rides its own connection so closing it
|
|
989
|
+
* can never tear down a pooled exec/tunnel sharing the alias.
|
|
990
|
+
*/
|
|
991
|
+
async openExec(alias: string, command: string): Promise<ExecSession> {
|
|
992
|
+
const { stream, teardown } = await this.openStandaloneChannel(alias, (client, callback) => {
|
|
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
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// -------------------------------------------------------------- sftp
|
|
1026
|
+
|
|
1027
|
+
/** Upload one local file (or directory tree) to a remote path. */ async upload(alias: string, localPath: string, remotePath: string, recursive: boolean, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number; files: number }> {
|
|
1028
|
+
// Remote paths must be absolute: the mkdir chain and fastPut must agree
|
|
1029
|
+
// on one resolution (relative paths previously created dirs at the root).
|
|
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
|
+
})
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** Download one remote file to a local path. */
|
|
1061
|
+
async download(alias: string, remotePath: string, localPath: string, onProgress?: (progress: TransferProgress) => void): Promise<{ bytes: number }> {
|
|
1062
|
+
return this.withClient(alias, async (client) => {
|
|
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
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** List a remote directory (file browser). Bounded by a timeout so a
|
|
1078
|
+
* stalled SFTP request fails instead of leaving the file tree spinning. */
|
|
1079
|
+
async ls(alias: string, path: string): Promise<import('./protocol.ts').RemoteDirEntry[]> {
|
|
1080
|
+
return this.withClient(alias, async (client) => {
|
|
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
|
+
})
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* Resolve many remote paths to their canonical form in one SFTP pass
|
|
1101
|
+
* (P1-26): one lease + one batch of `sftp.realpath` calls instead of N
|
|
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
|
+
})
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** Stat one remote path (file browser / conflict checks). Bounded by a timeout. */
|
|
1165
|
+
async stat(alias: string, remotePath: string): Promise<{ type: 'dir' | 'file' | 'other'; size: number; mtimeMs: number; mode: number }> {
|
|
1166
|
+
return this.withClient(alias, async (client) => {
|
|
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
|
+
})
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Lstat one remote path without following the final symlink. Returns
|
|
1180
|
+
* undefined when the path is absent (the fs seam's lstat contract).
|
|
1181
|
+
*/
|
|
1182
|
+
async lstat(alias: string, remotePath: string): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other'; size: number; mtimeMs: number; mode: number } | undefined> {
|
|
1183
|
+
return this.withClient(alias, async (client) => {
|
|
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
|
+
})
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Open a remote file read stream (the fs seam's streamText). The returned
|
|
1205
|
+
* 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
|
|
1212
|
+
* end/close/error/destroy — not when this function returns).
|
|
1213
|
+
*/
|
|
1214
|
+
async readStream(alias: string, remotePath: string): Promise<import('node:stream').Readable> {
|
|
1215
|
+
let lease: ClientLease | undefined
|
|
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
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Read one remote file fully into memory (text or binary) with its mtime.
|
|
1280
|
+
* The workspace plugin's text gate (UTF-8 + size caps) lives on its caller.
|
|
1281
|
+
*/
|
|
1282
|
+
async readFile(alias: string, remotePath: string): Promise<{ content: Buffer; mtime: number; size: number }> {
|
|
1283
|
+
return this.withClient(alias, async (client) => {
|
|
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
|
+
})
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* Write one remote file from memory (parents are created). When
|
|
1305
|
+
* `expectedMtime` is given, a stat-then-write conflict check throws before
|
|
1306
|
+
* any byte is written (the GUI and the workspace tools use it for
|
|
1307
|
+
* overwrite protection).
|
|
1308
|
+
*/
|
|
1309
|
+
async writeFile(alias: string, remotePath: string, content: Buffer, expectedMtime?: number): Promise<{ mtime: number }> {
|
|
1310
|
+
return this.withClient(alias, async (client) => {
|
|
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
|
+
})
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/** Create a remote directory chain (mkdir -p semantics). */
|
|
1332
|
+
async mkdir(alias: string, remotePath: string): Promise<void> {
|
|
1333
|
+
return this.withClient(alias, async (client) => {
|
|
1334
|
+
const sftp = await this.sftpFor(client)
|
|
1335
|
+
await this.ensureRemoteDir(sftp, remotePath)
|
|
1336
|
+
})
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Remove a remote file or directory. Directories require `recursive: true`
|
|
1341
|
+
* and are walked depth-first (children first, then the directory itself).
|
|
1342
|
+
*
|
|
1343
|
+
* Deletion never follows symlinks: every node is classified with lstat, so
|
|
1344
|
+
* a symlink pointing at a directory is unlinked (only the link), never
|
|
1345
|
+
* recursed into — the old stat/readdir-attr check could delete the link
|
|
1346
|
+
* target's contents.
|
|
1347
|
+
*/
|
|
1348
|
+
async rm(alias: string, remotePath: string, recursive = false): Promise<void> {
|
|
1349
|
+
const normalized = remotePath.replace(/\/+$/, '')
|
|
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
|
+
})
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Rename / move a remote path (mv semantics, same filesystem). */
|
|
1391
|
+
async rename(alias: string, fromPath: string, toPath: string): Promise<void> {
|
|
1392
|
+
return this.withClient(alias, async (client) => {
|
|
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
|
+
})
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// ------------------------------------------------------------- tunnel
|
|
1518
|
+
|
|
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
|
+
async startTunnel(alias: string, options: { remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
|
|
1546
|
+
if (!Number.isInteger(options.remotePort) || options.remotePort < 1 || options.remotePort > 65535) {
|
|
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
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/** All active tunnels. */
|
|
1693
|
+
listTunnels(): TunnelInfo[] {
|
|
1694
|
+
return [...this.tunnels.values()].map(tunnel => ({ ...tunnel.info }))
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/** Stop one tunnel (closes listener and live sockets, releases its lease). */
|
|
1698
|
+
stopTunnel(id: string): boolean {
|
|
1699
|
+
const tunnel = this.tunnels.get(id)
|
|
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
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
/** Stop all tunnels (optionally for one alias). */
|
|
1717
|
+
stopAllTunnels(alias?: string): number {
|
|
1718
|
+
let count = 0
|
|
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
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// ------------------------------------------------------------- misc
|
|
1729
|
+
|
|
1730
|
+
/** Probe connectivity: connect, run `true`, close. Typed errors the GUI
|
|
1731
|
+
* must react to (host-key TOFU, session password) are NOT flattened into
|
|
1732
|
+
* a plain message — callers (routes → panel / workspace gate) key their
|
|
1733
|
+
* interactive dialogs on the typed error. Everything else (unreachable,
|
|
1734
|
+
* timeout, auth failure) returns a failed result. */
|
|
1735
|
+
async test(alias: string): Promise<TestResult> {
|
|
1736
|
+
const started = Date.now()
|
|
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
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/** Close every pooled connection and tunnel, and wipe the in-memory
|
|
1753
|
+
* session password table (secrets are never persisted anywhere). */
|
|
1754
|
+
dispose(): void {
|
|
1755
|
+
for (const id of [...this.tunnels.keys()]) this.stopTunnel(id)
|
|
1756
|
+
this.connectionPool.invalidateAll()
|
|
1757
|
+
this.sftpChannels.clear()
|
|
1758
|
+
this.sessionPasswords.clear()
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|