@tiphareth/dsh-hardssh 0.1.2 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +30 -0
- package/README.md +49 -25
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +307 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +18 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +58 -25
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +137 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { Client, type ConnectConfig } from 'ssh2'
|
|
3
|
+
import { ConnectionPool, type SshConnectionService } from './pool.ts'
|
|
4
|
+
import { holdLeaseUntilSettled, type ClientLease } from './lease.ts'
|
|
5
|
+
import { BoundedUtf8Output } from '../exec/output.ts'
|
|
6
|
+
import {
|
|
7
|
+
HostKeyMismatchError,
|
|
8
|
+
HostKeyPolicy,
|
|
9
|
+
HostKeyUnknownError,
|
|
10
|
+
type HostKeyCheck,
|
|
11
|
+
type KnownHostsStore,
|
|
12
|
+
} from '../known-hosts.ts'
|
|
13
|
+
import { expandHome, type HostStore } from '../store.ts'
|
|
14
|
+
import type { ClusterResult, ExecResult, SshHostEntry, SshHostSummary, TestResult } from '../protocol.ts'
|
|
15
|
+
import type { HostStoreView } from '../../core.ts'
|
|
16
|
+
|
|
17
|
+
export interface SshInvalidateOptions {
|
|
18
|
+
/**
|
|
19
|
+
* Also invalidate hosts whose ProxyJump chain depends, directly or
|
|
20
|
+
* transitively, on the changed alias.
|
|
21
|
+
*/
|
|
22
|
+
includeDependents?: boolean
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* drain: reject/reconnect subsequent acquisitions, allow existing leases
|
|
26
|
+
* to complete before closing their transport.
|
|
27
|
+
* force: close the current transport immediately.
|
|
28
|
+
*/
|
|
29
|
+
mode?: 'drain' | 'force'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Default engine knobs. */
|
|
33
|
+
export interface EngineOptions {
|
|
34
|
+
/** Connections idle longer than this are closed (ms). */
|
|
35
|
+
idleTimeoutMs?: number
|
|
36
|
+
/** SSH handshake timeout (ms). */
|
|
37
|
+
connectTimeoutMs?: number
|
|
38
|
+
/** Keepalive ping interval (ms). */
|
|
39
|
+
keepaliveIntervalMs?: number
|
|
40
|
+
/** Cap on captured stdout/stderr bytes per exec (ms). */
|
|
41
|
+
maxOutputBytes?: number
|
|
42
|
+
/** Default exec timeout (ms). */
|
|
43
|
+
defaultExecTimeoutMs?: number
|
|
44
|
+
/** Default cluster concurrency. */
|
|
45
|
+
defaultMaxWorkers?: number
|
|
46
|
+
/** SFTP concurrent channel count for transfers. */
|
|
47
|
+
sftpConcurrency?: number
|
|
48
|
+
/** Deadline for opening an SFTP subsystem channel. */
|
|
49
|
+
sftpOpenTimeoutMs?: number
|
|
50
|
+
/** Per-request SFTP callback deadline. */
|
|
51
|
+
sftpOperationTimeoutMs?: number
|
|
52
|
+
/** Whole-file read/write stream inactivity deadline. */
|
|
53
|
+
sftpReadTimeoutMs?: number
|
|
54
|
+
/** Maximum bytes buffered by readFile before it aborts. */
|
|
55
|
+
maxReadFileBytes?: number
|
|
56
|
+
/** Upload/download inactivity deadline, reset by progress. */
|
|
57
|
+
sftpTransferIdleTimeoutMs?: number
|
|
58
|
+
/** Absolute budget for one recursive rm traversal. */
|
|
59
|
+
sftpRecursiveRmTimeoutMs?: number
|
|
60
|
+
/**
|
|
61
|
+
* Optional server-host-key algorithm whitelist (e.g. ['ssh-ed25519']).
|
|
62
|
+
* Single authoritative source: connection building reads it from these
|
|
63
|
+
* options (EngineDeps deliberately has no duplicate).
|
|
64
|
+
*/
|
|
65
|
+
hostKeyAlgorithms?: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Optional engine dependencies for host-key TOFU and secret resolution.
|
|
70
|
+
* All fields are optional and their absence preserves the pre-security
|
|
71
|
+
* behavior exactly (plaintext inline auth, no host verification) — the
|
|
72
|
+
* existing tests and call sites keep working unchanged.
|
|
73
|
+
*/
|
|
74
|
+
export interface EngineDeps {
|
|
75
|
+
/** Known-hosts trust store; when set, connections require a trusted host key. */
|
|
76
|
+
knownHosts?: KnownHostsStore
|
|
77
|
+
/** Fingerprint check policy (defaults to a HostKeyPolicy over knownHosts). */
|
|
78
|
+
hostKeyPolicy?: HostKeyPolicy
|
|
79
|
+
/**
|
|
80
|
+
* Secret resolution for one entry. Absent: read `password`/`passphrase`
|
|
81
|
+
* inline from the entry (plaintext store / test compatibility).
|
|
82
|
+
*/
|
|
83
|
+
resolveSecrets?: (entry: SshHostEntry) => Promise<ResolvedAuthDeps>
|
|
84
|
+
/** Final output/error redactor (typically the unlocked credential vault). */
|
|
85
|
+
redactOutput?: (text: string) => string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The resolved-auth shape passed into connect config building (vault-aware). */
|
|
89
|
+
export interface ResolvedAuthDeps {
|
|
90
|
+
kind: SshHostEntry['auth']['kind']
|
|
91
|
+
keyPath?: string
|
|
92
|
+
password?: string
|
|
93
|
+
passphrase?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Thrown when a connection needs a password/passphrase that is not yet
|
|
98
|
+
* available in this session (secretStorage='none' and the user hasn't entered
|
|
99
|
+
* it yet). The GUI intercepts this and prompts for the credential, then
|
|
100
|
+
* injects it via engine.setSessionPassword and retries.
|
|
101
|
+
*/
|
|
102
|
+
export class NeedsPasswordError extends Error {
|
|
103
|
+
/** Which secret the connection needs: 'password' or 'passphrase'. */
|
|
104
|
+
readonly secret: 'password' | 'passphrase'
|
|
105
|
+
constructor(alias: string, secret: 'password' | 'passphrase') {
|
|
106
|
+
super(`SSH 连接 '${alias}' 需要${secret === 'password' ? '密码' : '密钥口令'},请先输入一次(在该连接存活期内复用,连接池回收后需重新输入;不会保存)`)
|
|
107
|
+
this.name = 'NeedsPasswordError'
|
|
108
|
+
this.secret = secret
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const DEFAULTS: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> = {
|
|
113
|
+
idleTimeoutMs: 30 * 60_000,
|
|
114
|
+
connectTimeoutMs: 15_000,
|
|
115
|
+
keepaliveIntervalMs: 15_000,
|
|
116
|
+
maxOutputBytes: 2 * 1024 * 1024,
|
|
117
|
+
defaultExecTimeoutMs: 60_000,
|
|
118
|
+
defaultMaxWorkers: 8,
|
|
119
|
+
sftpConcurrency: 8,
|
|
120
|
+
sftpOpenTimeoutMs: 15_000,
|
|
121
|
+
sftpOperationTimeoutMs: 15_000,
|
|
122
|
+
sftpReadTimeoutMs: 60_000,
|
|
123
|
+
maxReadFileBytes: 32 * 1024 * 1024,
|
|
124
|
+
sftpTransferIdleTimeoutMs: 60_000,
|
|
125
|
+
sftpRecursiveRmTimeoutMs: 60_000,
|
|
126
|
+
hostKeyAlgorithms: undefined,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* How much an operation may be retried:
|
|
131
|
+
* - never: one acquisition + one operation attempt.
|
|
132
|
+
* - connect-only: connection acquisition may be retried, but once the
|
|
133
|
+
* operation function starts it is invoked at most once (default).
|
|
134
|
+
* - idempotent: the operation may also be retried until it calls
|
|
135
|
+
* markCommitted() (i.e. while the exec channel is still opening).
|
|
136
|
+
*
|
|
137
|
+
* SFTP operations must never use 'idempotent': they have no commit point, so
|
|
138
|
+
* a replay after a mid-flight timeout would duplicate a remote write.
|
|
139
|
+
*/
|
|
140
|
+
export type RetryPolicy = 'never' | 'connect-only' | 'idempotent'
|
|
141
|
+
|
|
142
|
+
/** Options for a one-shot remote command. */
|
|
143
|
+
export interface ExecOptions {
|
|
144
|
+
timeoutMs?: number
|
|
145
|
+
retry?: RetryPolicy
|
|
146
|
+
signal?: AbortSignal
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Internal options for withClient(). */
|
|
150
|
+
export interface WithClientOptions {
|
|
151
|
+
/** Total acquire+operation attempt budget (default 3, capped at 1 for 'never'). */
|
|
152
|
+
attempts?: number
|
|
153
|
+
retryPolicy?: RetryPolicy
|
|
154
|
+
signal?: AbortSignal
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Lets an operation declare the point after which replay is unsafe. */
|
|
158
|
+
export interface OperationControl {
|
|
159
|
+
markCommitted(): void
|
|
160
|
+
/**
|
|
161
|
+
* The caller's abort signal for THIS operation (undefined = not cancellable).
|
|
162
|
+
* Operations that can cancel their own request (`exec` closing its channel,
|
|
163
|
+
* an SFTP read stream) listen here, so an abort does not have to retire the
|
|
164
|
+
* whole shared transport out from under concurrent holders.
|
|
165
|
+
*/
|
|
166
|
+
readonly signal?: AbortSignal
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface HostKeyOutcome {
|
|
170
|
+
alias: string
|
|
171
|
+
check: HostKeyCheck
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Half-open channels tolerated on one pooled transport before it is retired.
|
|
176
|
+
* Deliberately well below OpenSSH's default `MaxSessions` (10) so the budget is
|
|
177
|
+
* never actually exhausted.
|
|
178
|
+
*/
|
|
179
|
+
export const MAX_LEAKED_CHANNELS = 4
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the ssh-agent socket to offer to ssh2 (zero-input key auth,
|
|
183
|
+
* VSCode-style): `$SSH_AUTH_SOCK` — the standard OpenSSH agent socket (also
|
|
184
|
+
* exported by Git for Windows' ssh-agent and WSL). Deliberately NOT probing
|
|
185
|
+
* named pipes (Pageant / Windows OpenSSH agent): an absent pipe makes ssh2's
|
|
186
|
+
* agent query stall the whole handshake until readyTimeout instead of
|
|
187
|
+
* falling through to the next method. Keep Pageant compatibility for a
|
|
188
|
+
* future explicit opt-in. An agent that yields no keys makes ssh2 fall
|
|
189
|
+
* through to the configured methods (privateKey → password), so enabling it
|
|
190
|
+
* when a socket is present is safe. Exported for tests.
|
|
191
|
+
*/
|
|
192
|
+
export function sshAgentConfig(): string | undefined {
|
|
193
|
+
const sock = process.env.SSH_AUTH_SOCK
|
|
194
|
+
if (sock !== undefined && sock.trim() !== '') return sock
|
|
195
|
+
return undefined
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Detect whether an OpenSSH/PEM private key file is passphrase-encrypted.
|
|
199
|
+
* OpenSSH-format keys keep the cipher/kdf strings in PLAINTEXT inside the
|
|
200
|
+
* base64 payload ('bcrypt' kdf ⇒ encrypted, 'none' ⇒ plain); PEM keys carry
|
|
201
|
+
* "Proc-Type: 4,ENCRYPTED". Used to prompt for a missing passphrase. */
|
|
202
|
+
function keyNeedsPassphrase(keyPath: string): boolean {
|
|
203
|
+
try {
|
|
204
|
+
const text = readFileSync(keyPath, 'utf8')
|
|
205
|
+
if (/Proc-Type:\s*4,ENCRYPTED/i.test(text)) return true
|
|
206
|
+
if (text.includes('OPENSSH PRIVATE KEY')) {
|
|
207
|
+
const base64 = text.replace(/-----[^-]*-----/g, '').replace(/\s+/g, '')
|
|
208
|
+
const header = Buffer.from(base64, 'base64').toString('latin1', 0, 512)
|
|
209
|
+
return header.includes('bcrypt')
|
|
210
|
+
}
|
|
211
|
+
return false
|
|
212
|
+
} catch {
|
|
213
|
+
return false
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Build the ssh2 connect config for one entry (key read from disk). The
|
|
218
|
+
* timeout/keepalive knobs come from EngineOptions so they actually take
|
|
219
|
+
* effect instead of being hard-coded. Exported for tests. */
|
|
220
|
+
export function buildConnectConfig(
|
|
221
|
+
entry: SshHostEntry,
|
|
222
|
+
options: Pick<Required<EngineOptions>, 'connectTimeoutMs' | 'keepaliveIntervalMs'>,
|
|
223
|
+
sock?: ConnectConfig['sock'],
|
|
224
|
+
buildContext: {
|
|
225
|
+
hostKeyPolicy?: HostKeyPolicy
|
|
226
|
+
hostKeyAlgorithms?: string[]
|
|
227
|
+
/** Writes the verified/refused outcome back to the caller's capture slot. */
|
|
228
|
+
setOutcome?: (value: HostKeyOutcome) => void
|
|
229
|
+
/** Vault-resolved authentication (overrides entry.auth secrets). */
|
|
230
|
+
authOverride?: ResolvedAuthDeps
|
|
231
|
+
} = {},
|
|
232
|
+
): ConnectConfig {
|
|
233
|
+
const config: ConnectConfig = {
|
|
234
|
+
host: entry.host,
|
|
235
|
+
port: entry.port,
|
|
236
|
+
username: entry.user,
|
|
237
|
+
readyTimeout: options.connectTimeoutMs,
|
|
238
|
+
keepaliveInterval: options.keepaliveIntervalMs,
|
|
239
|
+
keepaliveCountMax: 3,
|
|
240
|
+
}
|
|
241
|
+
if (sock !== undefined) config.sock = sock
|
|
242
|
+
const agent = sshAgentConfig()
|
|
243
|
+
if (agent !== undefined) config.agent = agent
|
|
244
|
+
if (buildContext.hostKeyPolicy !== undefined) {
|
|
245
|
+
config.hostVerifier = (serverKey: Buffer) => {
|
|
246
|
+
const check = buildContext.hostKeyPolicy!.check(entry.alias, serverKey, { host: entry.host, port: entry.port })
|
|
247
|
+
buildContext.setOutcome?.({ alias: entry.alias, check })
|
|
248
|
+
return check.kind === 'trusted'
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (buildContext.hostKeyAlgorithms !== undefined && buildContext.hostKeyAlgorithms.length > 0) {
|
|
252
|
+
config.algorithms = { serverHostKey: buildContext.hostKeyAlgorithms as import('ssh2').ServerHostKeyAlgorithm[] }
|
|
253
|
+
}
|
|
254
|
+
const auth = buildContext.authOverride
|
|
255
|
+
?? { kind: entry.auth.kind, keyPath: entry.auth.keyPath, password: entry.auth.password, passphrase: entry.auth.passphrase }
|
|
256
|
+
if (auth.kind === 'password') {
|
|
257
|
+
config.password = auth.password
|
|
258
|
+
} else {
|
|
259
|
+
const keyPath = auth.keyPath === undefined ? undefined : expandHome(auth.keyPath)
|
|
260
|
+
if (keyPath !== undefined && keyPath !== '' && existsSync(keyPath)) {
|
|
261
|
+
config.privateKey = readFileSync(keyPath, 'utf8')
|
|
262
|
+
if (auth.passphrase !== undefined && auth.passphrase !== '') {
|
|
263
|
+
config.passphrase = auth.passphrase
|
|
264
|
+
}
|
|
265
|
+
} else if (agent === undefined) {
|
|
266
|
+
// No key file AND no agent to fall back on — fail before the
|
|
267
|
+
// handshake with a precise message instead of a generic auth failure.
|
|
268
|
+
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)`)
|
|
269
|
+
}
|
|
270
|
+
// Else: the key path is unset or missing but an agent is available —
|
|
271
|
+
// leave privateKey unset so ssh2 authenticates from the agent's keys
|
|
272
|
+
// (zero input, the VSCode Remote-SSH way).
|
|
273
|
+
}
|
|
274
|
+
return config
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Connect one ssh2 client (resolve on ready, reject on error/close). A hard
|
|
279
|
+
* `timeoutMs` bounds the WHOLE connect phase: ssh2's own `readyTimeout` only
|
|
280
|
+
* starts ticking after the TCP socket is up, so a SYN-level hang (filtered
|
|
281
|
+
* port, dead route, half-open middlebox) would otherwise stall the promise
|
|
282
|
+
* forever — which hangs every caller (exec, openShell, tunnels). On timeout
|
|
283
|
+
* the socket is destroyed and the promise rejects.
|
|
284
|
+
*
|
|
285
|
+
* When `context` carries a captured host-key outcome from a prior
|
|
286
|
+
* `hostVerifier` refusal, the generic error is rewritten into a typed
|
|
287
|
+
* HostKeyUnknownError / HostKeyMismatchError so callers and the GUI can
|
|
288
|
+
* surface the fingerprint directly.
|
|
289
|
+
*/
|
|
290
|
+
function connectClient(
|
|
291
|
+
config: ConnectConfig,
|
|
292
|
+
timeoutMs: number,
|
|
293
|
+
context: { outcome?: HostKeyOutcome | undefined } = {},
|
|
294
|
+
signal?: AbortSignal,
|
|
295
|
+
): Promise<Client> {
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const client = new Client()
|
|
298
|
+
let settled = false
|
|
299
|
+
let onAbort = (): void => {}
|
|
300
|
+
const timer = setTimeout(() => {
|
|
301
|
+
if (settled) return
|
|
302
|
+
settled = true
|
|
303
|
+
signal?.removeEventListener('abort', onAbort)
|
|
304
|
+
const err = new Error(`SSH connect to ${config.host}:${config.port} (${config.username}) timed out after ${timeoutMs} ms`)
|
|
305
|
+
try { client.destroy() } catch { /* already closed */ }
|
|
306
|
+
reject(err)
|
|
307
|
+
}, timeoutMs)
|
|
308
|
+
timer.unref?.()
|
|
309
|
+
const settle = (fn: () => void): void => {
|
|
310
|
+
if (settled) return
|
|
311
|
+
settled = true
|
|
312
|
+
clearTimeout(timer)
|
|
313
|
+
signal?.removeEventListener('abort', onAbort)
|
|
314
|
+
fn()
|
|
315
|
+
}
|
|
316
|
+
const fail = (raw: Error): void => settle(() => {
|
|
317
|
+
// A Client that never reached ready is owned entirely by this attempt;
|
|
318
|
+
// do not rely on ssh2 to close it after auth/handshake failure.
|
|
319
|
+
try { client.destroy() } catch { /* already closed */ }
|
|
320
|
+
reject(rewriteHostKeyError(raw, context.outcome))
|
|
321
|
+
})
|
|
322
|
+
onAbort = (): void => {
|
|
323
|
+
const error = signal?.reason instanceof Error ? signal.reason : Object.assign(new Error('SSH connect aborted'), { name: 'AbortError' })
|
|
324
|
+
fail(error)
|
|
325
|
+
}
|
|
326
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
327
|
+
if (signal?.aborted === true) {
|
|
328
|
+
onAbort()
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
client.once('ready', () => settle(() => resolve(client)))
|
|
332
|
+
// PERMANENT listener (not `once`): a Client that already settled its
|
|
333
|
+
// connect promise must never emit an unattended 'error' (e.g. the
|
|
334
|
+
// network drops right after the pooled connection was handed over, or a
|
|
335
|
+
// jump hop dies) — an unhandled 'error' on the EventEmitter CRASHES the
|
|
336
|
+
// whole node process. Post-ready errors are the pool's job (breakRecord);
|
|
337
|
+
// here we only reject while the connect is still in flight.
|
|
338
|
+
client.on('error', (error) => {
|
|
339
|
+
if (settled) return
|
|
340
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
341
|
+
})
|
|
342
|
+
// A server that drops the socket before 'ready' (e.g. during auth or a
|
|
343
|
+
// failed acquire) emits 'close' without 'error' — fail fast instead of
|
|
344
|
+
// waiting out the whole connect timeout.
|
|
345
|
+
client.once('close', () => fail(
|
|
346
|
+
new Error(`SSH connection to ${config.host}:${config.port} (${config.username}) closed before ready`),
|
|
347
|
+
))
|
|
348
|
+
try {
|
|
349
|
+
client.connect(config)
|
|
350
|
+
} catch (error) {
|
|
351
|
+
fail(error instanceof Error ? error : new Error(String(error)))
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Rewrite a raw connect failure into a typed host-key error when the
|
|
357
|
+
* hostVerifier refused the server key (unknown or mismatch). */
|
|
358
|
+
function rewriteHostKeyError(raw: Error, outcome: HostKeyOutcome | undefined): Error {
|
|
359
|
+
if (outcome?.check.kind === 'unknown') {
|
|
360
|
+
return new HostKeyUnknownError(outcome.alias, outcome.check.fingerprintSha256)
|
|
361
|
+
}
|
|
362
|
+
if (outcome?.check.kind === 'mismatch') {
|
|
363
|
+
return new HostKeyMismatchError(outcome.alias, outcome.check.expected, outcome.check.actual)
|
|
364
|
+
}
|
|
365
|
+
return raw
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Host-side hooks the connection manager needs but must not own. Injected by
|
|
370
|
+
* the facade so every resource keeps exactly one dispose point and the manager
|
|
371
|
+
* never imports the facade back (no cycle).
|
|
372
|
+
*/
|
|
373
|
+
export interface ConnectionManagerAccess {
|
|
374
|
+
/** A pooled client is being torn down — drop its cached SFTP channel. */
|
|
375
|
+
onClientDisposed?(client: Client, error: Error): void
|
|
376
|
+
/** Vault-aware output guard applied before session-secret masking. */
|
|
377
|
+
redactOutput?(text: string): string
|
|
378
|
+
/**
|
|
379
|
+
* The one-command runner (the facade's `exec`). Injected rather than
|
|
380
|
+
* reimplemented here so cluster() fans out through the SAME method surface
|
|
381
|
+
* callers/spies observe on the engine, and `test` reuses it verbatim.
|
|
382
|
+
*/
|
|
383
|
+
executor?: (alias: string, command: string, options?: ExecOptions) => Promise<ExecResult>
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Owns the pooled SSH connections and everything layered on them: per-alias
|
|
388
|
+
* ProxyJump connect chains, session-scoped secrets and output redaction, the
|
|
389
|
+
* retry/replay policy, and the command surface (exec/cluster/test).
|
|
390
|
+
*
|
|
391
|
+
* Dispose order lives in the facade: tunnels, standalone terminal transports
|
|
392
|
+
* and SFTP channels are closed first, so a resource is always closed before
|
|
393
|
+
* the transport that carries it.
|
|
394
|
+
*/
|
|
395
|
+
export class ConnectionManager {
|
|
396
|
+
private readonly store: HostStoreView
|
|
397
|
+
private readonly opts: Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'>
|
|
398
|
+
private readonly access: ConnectionManagerAccess
|
|
399
|
+
private readonly executor: (alias: string, command: string, options?: ExecOptions) => Promise<ExecResult>
|
|
400
|
+
private readonly connectionPool: SshConnectionService
|
|
401
|
+
private readonly deps: EngineDeps
|
|
402
|
+
private readonly hostKeyPolicy: HostKeyPolicy | undefined
|
|
403
|
+
/**
|
|
404
|
+
* Session-scoped secrets (secretStorage='none'): keyed by alias, populated
|
|
405
|
+
* by the GUI on first connect, used by connectChain's resolve step, and
|
|
406
|
+
* cleared on dispose. Never persisted.
|
|
407
|
+
*/
|
|
408
|
+
private readonly sessionPasswords = new Map<string, { password?: string; passphrase?: string }>()
|
|
409
|
+
/**
|
|
410
|
+
* alias → every secret value registered for it in this process, including
|
|
411
|
+
* ones replaced by a newer value. Released with the alias's connection, so a
|
|
412
|
+
* rotated password stays redacted while the old connection can still echo it.
|
|
413
|
+
*/
|
|
414
|
+
private readonly secretHistory = new Map<string, Set<string>>()
|
|
415
|
+
private readonly sensitiveOutputs = new Set<string>()
|
|
416
|
+
/** Per-alias count of channels that never acknowledged close after a timeout. */
|
|
417
|
+
private readonly leakedChannels = new Map<string, number>()
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param store - the host config store.
|
|
421
|
+
* @param options - engine knobs (defaults applied after validation).
|
|
422
|
+
* @param deps - optional security deps (host-key TOFU, secret resolution).
|
|
423
|
+
* Absent → pre-security behavior (inline auth, no host verification).
|
|
424
|
+
* @param access - optional host-side hooks (SFTP cache eviction, redactor).
|
|
425
|
+
*/
|
|
426
|
+
constructor(
|
|
427
|
+
store: HostStoreView,
|
|
428
|
+
options?: EngineOptions,
|
|
429
|
+
deps?: EngineDeps,
|
|
430
|
+
access: ConnectionManagerAccess = {},
|
|
431
|
+
) {
|
|
432
|
+
this.store = store
|
|
433
|
+
this.opts = { ...DEFAULTS, ...options }
|
|
434
|
+
this.access = access
|
|
435
|
+
this.executor = access.executor ?? (async () => { throw new Error('SSH executor is not wired') })
|
|
436
|
+
this.deps = deps ?? {}
|
|
437
|
+
this.hostKeyPolicy = this.deps.hostKeyPolicy
|
|
438
|
+
?? (this.deps.knownHosts !== undefined ? new HostKeyPolicy(this.deps.knownHosts) : undefined)
|
|
439
|
+
this.connectionPool = new ConnectionPool({
|
|
440
|
+
idleTimeoutMs: this.opts.idleTimeoutMs,
|
|
441
|
+
connect: async (alias, signal) => {
|
|
442
|
+
const entry = this.store.find(alias)
|
|
443
|
+
if (entry === undefined) throw new Error(`alias '${alias}' not found — add it first`)
|
|
444
|
+
return await this.connectChain(entry, signal)
|
|
445
|
+
},
|
|
446
|
+
onDispose: (client) => {
|
|
447
|
+
this.access.onClientDisposed?.(
|
|
448
|
+
client,
|
|
449
|
+
new Error('SSH connection disposed while SFTP was active'),
|
|
450
|
+
)
|
|
451
|
+
},
|
|
452
|
+
// The session password lives exactly as long as the connection it was
|
|
453
|
+
// entered for: retiring the transport drops it, so an idle sweep cannot
|
|
454
|
+
// leave the credential usable for the rest of the process.
|
|
455
|
+
onRetire: (alias) => { this.forgetSessionPassword(alias) },
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Validate the engine knobs once, so the connection and SFTP components
|
|
460
|
+
* read one frozen source of truth. */
|
|
461
|
+
static resolveOptions(options?: EngineOptions): Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> {
|
|
462
|
+
const resolved = { ...DEFAULTS, ...options }
|
|
463
|
+
for (const [name, value] of Object.entries({
|
|
464
|
+
sftpOpenTimeoutMs: resolved.sftpOpenTimeoutMs,
|
|
465
|
+
sftpOperationTimeoutMs: resolved.sftpOperationTimeoutMs,
|
|
466
|
+
sftpReadTimeoutMs: resolved.sftpReadTimeoutMs,
|
|
467
|
+
sftpTransferIdleTimeoutMs: resolved.sftpTransferIdleTimeoutMs,
|
|
468
|
+
sftpRecursiveRmTimeoutMs: resolved.sftpRecursiveRmTimeoutMs,
|
|
469
|
+
})) {
|
|
470
|
+
if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
|
|
471
|
+
throw new Error(`${name} must be a positive representable timer duration`)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (!Number.isSafeInteger(resolved.maxReadFileBytes) || resolved.maxReadFileBytes <= 0) {
|
|
475
|
+
throw new Error('maxReadFileBytes must be a positive safe integer')
|
|
476
|
+
}
|
|
477
|
+
return resolved
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Aliases with a live pooled transport right now (for connection-state
|
|
481
|
+
* indicators — the GUI badge colors bound workspaces by it). */
|
|
482
|
+
connectedAliases(): string[] {
|
|
483
|
+
return this.connectionPool.liveAliases()
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Record one channel that never acknowledged its close after a hard timeout.
|
|
488
|
+
*
|
|
489
|
+
* ssh2 cannot cancel a request, so a peer that ignores KILL + close leaves a
|
|
490
|
+
* half-open channel counted against the server's `MaxSessions` budget. Once
|
|
491
|
+
* enough of them accumulate the pooled transport is DRAINED — not force-
|
|
492
|
+
* closed — so concurrent holders finish their work while new operations open
|
|
493
|
+
* a fresh generation (the pool reaps the old record when its last lease is
|
|
494
|
+
* released).
|
|
495
|
+
*
|
|
496
|
+
* The counter is per alias and is decremented again by
|
|
497
|
+
* {@link noteChannelClosed} when a late acknowledgement finally arrives, so a
|
|
498
|
+
* merely slow peer cannot accumulate its way to a retirement.
|
|
499
|
+
*
|
|
500
|
+
* @param alias - the host the channel belonged to.
|
|
501
|
+
* @returns true when the transport was retired.
|
|
502
|
+
*/
|
|
503
|
+
noteLeakedChannel(alias: string): boolean {
|
|
504
|
+
const next = (this.leakedChannels.get(alias) ?? 0) + 1
|
|
505
|
+
this.leakedChannels.set(alias, next)
|
|
506
|
+
if (next < MAX_LEAKED_CHANNELS) return false
|
|
507
|
+
console.warn(`[dsh-hardssh] SSH connection '${alias}' left ${next} channels unacknowledged after timeouts; draining the pooled transport to protect the server session budget`)
|
|
508
|
+
this.leakedChannels.delete(alias)
|
|
509
|
+
this.invalidate(alias, { mode: 'drain' })
|
|
510
|
+
return true
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** A timed-out channel finally acknowledged its close: un-count it. */
|
|
514
|
+
noteChannelClosed(alias: string): void {
|
|
515
|
+
const current = this.leakedChannels.get(alias)
|
|
516
|
+
if (current === undefined) return
|
|
517
|
+
if (current <= 1) this.leakedChannels.delete(alias)
|
|
518
|
+
else this.leakedChannels.set(alias, current - 1)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Pooled connections owned by this manager (the tunnel component's narrow
|
|
522
|
+
* access seam, and the public `connections` getter on the facade). */
|
|
523
|
+
get connections(): SshConnectionService {
|
|
524
|
+
return this.connectionPool
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** The validated engine knobs. The facade's exec reads its output cap and
|
|
528
|
+
* default budget here, so defaults keep exactly one source of truth. */
|
|
529
|
+
get resolvedOptions(): Required<Omit<EngineOptions, 'hostKeyAlgorithms'>> & Pick<EngineOptions, 'hostKeyAlgorithms'> {
|
|
530
|
+
return this.opts
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------- session secrets
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Provide a secret for `alias` for THIS session only (never persisted).
|
|
537
|
+
* Used by the GUI when a connection needs a password/passphrase under
|
|
538
|
+
* secretStorage='none'. Once set, pooled connections reuse it until the
|
|
539
|
+
* session ends or clearSessionSecrets() is called.
|
|
540
|
+
*/
|
|
541
|
+
setSessionPassword(alias: string, secret: { password?: string; passphrase?: string }): void {
|
|
542
|
+
this.sessionPasswords.set(alias, secret)
|
|
543
|
+
// Keep every value ever registered for this alias, not just the newest: a
|
|
544
|
+
// REPLACED password may still be echoed by the connection that was opened
|
|
545
|
+
// with it, and dropping it from the leak guard at that moment would leak it.
|
|
546
|
+
// The history is released when the alias's connection is retired.
|
|
547
|
+
const history = this.secretHistory.get(alias) ?? new Set<string>()
|
|
548
|
+
for (const value of [secret.password, secret.passphrase]) {
|
|
549
|
+
if (value !== undefined && value !== '') history.add(value)
|
|
550
|
+
}
|
|
551
|
+
this.secretHistory.set(alias, history)
|
|
552
|
+
this.rebuildSensitiveOutputs()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Read the session secret for one alias (undefined = not provided yet). */
|
|
556
|
+
getSessionPassword(alias: string): { password?: string; passphrase?: string } | undefined {
|
|
557
|
+
return this.sessionPasswords.get(alias)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Drop ONE alias's session secret. Called when its pooled connection is
|
|
562
|
+
* retired: the credential is tied to the connection's lifetime, so an idle
|
|
563
|
+
* sweep must not leave it usable for the rest of the process.
|
|
564
|
+
*/
|
|
565
|
+
forgetSessionPassword(alias: string): void {
|
|
566
|
+
const hadSecret = this.sessionPasswords.delete(alias)
|
|
567
|
+
const hadHistory = this.secretHistory.delete(alias)
|
|
568
|
+
if (hadSecret || hadHistory) this.rebuildSensitiveOutputs()
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Drop every session secret (e.g. on secretStorage change / lock). */
|
|
572
|
+
clearSessionSecrets(): void {
|
|
573
|
+
this.sessionPasswords.clear()
|
|
574
|
+
this.secretHistory.clear()
|
|
575
|
+
this.rebuildSensitiveOutputs()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Recompute the redaction set from every secret still in force, including
|
|
579
|
+
* values replaced by a newer one on the same alias (their connection may
|
|
580
|
+
* still be alive), so dropping one alias never un-redacts another's. */
|
|
581
|
+
private rebuildSensitiveOutputs(): void {
|
|
582
|
+
this.sensitiveOutputs.clear()
|
|
583
|
+
for (const history of this.secretHistory.values()) {
|
|
584
|
+
for (const value of history) this.sensitiveOutputs.add(value)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/** Redact credentials known to this session and the optional vault guard. */
|
|
588
|
+
redact(text: string): string {
|
|
589
|
+
let output = this.access.redactOutput?.(text) ?? this.deps.redactOutput?.(text) ?? text
|
|
590
|
+
for (const value of [...this.sensitiveOutputs].sort((a, b) => b.length - a.length)) {
|
|
591
|
+
output = output.split(value).join('[REDACTED]')
|
|
592
|
+
}
|
|
593
|
+
return output
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** Secret-free host list (filtered by the optional query). */
|
|
597
|
+
list(query?: string): SshHostSummary[] {
|
|
598
|
+
const needle = query?.trim().toLowerCase()
|
|
599
|
+
return this.store.list()
|
|
600
|
+
.filter(entry => needle === undefined || needle === ''
|
|
601
|
+
|| entry.alias.toLowerCase().includes(needle)
|
|
602
|
+
|| (entry.description ?? '').toLowerCase().includes(needle)
|
|
603
|
+
|| entry.host.toLowerCase().includes(needle)
|
|
604
|
+
|| entry.tags.some(tag => tag.toLowerCase().includes(needle)))
|
|
605
|
+
.map(entry => this.store.summarize(entry))
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** One host summary by alias. */
|
|
609
|
+
find(alias: string): SshHostSummary | undefined {
|
|
610
|
+
const entry = this.store.find(alias)
|
|
611
|
+
return entry === undefined ? undefined : this.store.summarize(entry)
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Retire the pooled connection for one alias.
|
|
615
|
+
*
|
|
616
|
+
* ConnectionPool knows nothing about HostStore or ProxyJump configuration
|
|
617
|
+
* (every target owns its complete jump chain, no hop records are shared),
|
|
618
|
+
* so dependent-host expansion belongs here: with includeDependents the
|
|
619
|
+
* transitive reverse ProxyJump closure of `alias` is invalidated too.
|
|
620
|
+
*/
|
|
621
|
+
invalidate(alias: string, options: SshInvalidateOptions = {}): void {
|
|
622
|
+
const aliases = new Set<string>([alias])
|
|
623
|
+
|
|
624
|
+
if (options.includeDependents === true) {
|
|
625
|
+
const entries = this.store.list()
|
|
626
|
+
// Fixed-point scan: host counts are small, simpler and more reliable
|
|
627
|
+
// than maintaining a second dependency index.
|
|
628
|
+
let changed = true
|
|
629
|
+
while (changed) {
|
|
630
|
+
changed = false
|
|
631
|
+
for (const entry of entries) {
|
|
632
|
+
if (aliases.has(entry.alias)) continue
|
|
633
|
+
if (!entry.proxyJump.some(hopAlias => aliases.has(hopAlias))) continue
|
|
634
|
+
aliases.add(entry.alias)
|
|
635
|
+
changed = true
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const targetAlias of aliases) {
|
|
641
|
+
// Dependents are already expanded above; the pool itself has no
|
|
642
|
+
// ProxyJump topology.
|
|
643
|
+
this.connectionPool.invalidate(targetAlias, {
|
|
644
|
+
includeDependents: false,
|
|
645
|
+
mode: options.mode,
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Run `fn` with a live client for `alias`.
|
|
651
|
+
*
|
|
652
|
+
* Acquisition retry and operation replay are deliberately separate:
|
|
653
|
+
* - never: one acquire + one operation attempt.
|
|
654
|
+
* - connect-only: acquire may be retried, but fn is invoked at most once —
|
|
655
|
+
* once fn starts, the remote may already have observed the request, so
|
|
656
|
+
* replay could duplicate non-idempotent work.
|
|
657
|
+
* - idempotent: fn may be retried until it calls control.markCommitted()
|
|
658
|
+
* (exec marks this when the server accepted the channel).
|
|
659
|
+
*
|
|
660
|
+
* A failed operation retires the transport via lease.markBroken(); the
|
|
661
|
+
* lease is always released before the next acquire attempt.
|
|
662
|
+
*/
|
|
663
|
+
async withClient<T>(
|
|
664
|
+
alias: string,
|
|
665
|
+
fn: (client: Client, control: OperationControl) => Promise<T>,
|
|
666
|
+
options: WithClientOptions = {},
|
|
667
|
+
): Promise<T> {
|
|
668
|
+
const retryPolicy = options.retryPolicy ?? 'connect-only'
|
|
669
|
+
const configuredAttempts = options.attempts ?? 3
|
|
670
|
+
if (!Number.isInteger(configuredAttempts) || configuredAttempts < 1) {
|
|
671
|
+
throw new Error('withClient attempts must be a positive integer')
|
|
672
|
+
}
|
|
673
|
+
const maxAttempts = retryPolicy === 'never' ? 1 : configuredAttempts
|
|
674
|
+
let lastError: unknown
|
|
675
|
+
|
|
676
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
677
|
+
let lease: ClientLease
|
|
678
|
+
|
|
679
|
+
// Acquisition sits OUTSIDE the operation try: an acquire failure means
|
|
680
|
+
// fn never ran, so no remote work was submitted and retrying is safe.
|
|
681
|
+
try {
|
|
682
|
+
lease = await this.connectionPool.acquire(alias, { kind: 'operation', signal: options.signal })
|
|
683
|
+
} catch (error) {
|
|
684
|
+
lastError = error
|
|
685
|
+
if (options.signal?.aborted === true || retryPolicy === 'never' || attempt === maxAttempts) {
|
|
686
|
+
throw error instanceof Error ? error : new Error(String(error))
|
|
687
|
+
}
|
|
688
|
+
continue
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
let committed = false
|
|
692
|
+
const control: OperationControl = {
|
|
693
|
+
markCommitted: (): void => { committed = true },
|
|
694
|
+
signal: options.signal,
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// The lease is released when the OPERATION settles, never when the caller
|
|
698
|
+
// stops waiting. Releasing on abort used to make `holdsOnlyLease()`
|
|
699
|
+
// report "nobody else is using this connection" while fn was still in
|
|
700
|
+
// flight, so a later abort in another session would end the shared
|
|
701
|
+
// transport under it — killing the very request the first abort had just
|
|
702
|
+
// tried to protect.
|
|
703
|
+
try {
|
|
704
|
+
let operation: Promise<T>
|
|
705
|
+
try {
|
|
706
|
+
operation = Promise.resolve(fn(lease.client, control))
|
|
707
|
+
} catch (error) {
|
|
708
|
+
lease.release()
|
|
709
|
+
throw error
|
|
710
|
+
}
|
|
711
|
+
// Detach: the release (and the rejection of an abandoned operation)
|
|
712
|
+
// belongs to the operation's own lifetime.
|
|
713
|
+
const tracked = holdLeaseUntilSettled(lease, operation)
|
|
714
|
+
void tracked.catch(() => undefined)
|
|
715
|
+
if (options.signal === undefined) return await tracked
|
|
716
|
+
const signal = options.signal
|
|
717
|
+
return await new Promise<T>((resolve, reject) => {
|
|
718
|
+
let settled = false
|
|
719
|
+
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
|
|
720
|
+
const onAbort = (): void => {
|
|
721
|
+
if (settled) return
|
|
722
|
+
settled = true
|
|
723
|
+
cleanup()
|
|
724
|
+
const error = signal.reason instanceof Error ? signal.reason : Object.assign(new Error('SSH operation aborted'), { name: 'AbortError' })
|
|
725
|
+
// The operation cancels its OWN request through control.signal
|
|
726
|
+
// (exec closes its channel; SFTP streams abort where ssh2 allows).
|
|
727
|
+
// The transport is retired only when this lease is the LAST holder:
|
|
728
|
+
// ending a shared pooled client would destroy unrelated concurrent
|
|
729
|
+
// exec/SFTP/tunnel work on the same alias. The lease itself is
|
|
730
|
+
// still held until the operation settles (see holdLeaseUntilSettled),
|
|
731
|
+
// so that judgement stays truthful.
|
|
732
|
+
if (lease.holdsOnlyLease()) {
|
|
733
|
+
lease.markBroken(error)
|
|
734
|
+
try { lease.client.end() } catch { /* already closed */ }
|
|
735
|
+
}
|
|
736
|
+
reject(error)
|
|
737
|
+
}
|
|
738
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
739
|
+
if (signal.aborted) {
|
|
740
|
+
onAbort()
|
|
741
|
+
return
|
|
742
|
+
}
|
|
743
|
+
tracked.then(
|
|
744
|
+
value => {
|
|
745
|
+
if (settled) return
|
|
746
|
+
settled = true
|
|
747
|
+
cleanup()
|
|
748
|
+
resolve(value)
|
|
749
|
+
},
|
|
750
|
+
error => {
|
|
751
|
+
if (settled) return
|
|
752
|
+
settled = true
|
|
753
|
+
cleanup()
|
|
754
|
+
reject(error)
|
|
755
|
+
},
|
|
756
|
+
)
|
|
757
|
+
})
|
|
758
|
+
} catch (error) {
|
|
759
|
+
lastError = error
|
|
760
|
+
// A mid-flight failure usually means the connection died silently
|
|
761
|
+
// (the 'error'/'close' event may not have fired yet). Retire this
|
|
762
|
+
// generation so the next attempt reconnects; the pool reaps the
|
|
763
|
+
// record once every lease is released.
|
|
764
|
+
//
|
|
765
|
+
// An abort is NOT a transport failure: the abort handler above already
|
|
766
|
+
// scoped its damage (per-request cancellation, transport retired only
|
|
767
|
+
// when this was the last holder), so poisoning the shared generation
|
|
768
|
+
// here would break every other holder for no reason. The check keys on
|
|
769
|
+
// the SIGNAL, not on `error.name`: callers abort with reasons like
|
|
770
|
+
// `abort(new Error('cancel'))`, whose name is not 'AbortError'.
|
|
771
|
+
const aborted = options.signal?.aborted === true
|
|
772
|
+
if (!aborted) lease.markBroken(error)
|
|
773
|
+
|
|
774
|
+
const mayReplay = retryPolicy === 'idempotent'
|
|
775
|
+
&& !committed
|
|
776
|
+
&& attempt < maxAttempts
|
|
777
|
+
if (!mayReplay) {
|
|
778
|
+
throw error instanceof Error ? error : new Error(String(error))
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// No `finally { lease.release() }`: the release is owned by the operation.
|
|
782
|
+
// Reaching the next iteration means this attempt is over; its operation
|
|
783
|
+
// has settled (or was abandoned on abort and will release itself).
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError))
|
|
787
|
+
}
|
|
788
|
+
/** Resolve an entry's authentication for one connect: session password
|
|
789
|
+
* table first (secretStorage='none'), then deps.resolveSecrets (vault),
|
|
790
|
+
* then the inline store entry; when a password/passphrase is required but
|
|
791
|
+
* unavailable, throw NeedsPasswordError for the GUI to prompt. */
|
|
792
|
+
async resolveEntryAuth(entry: SshHostEntry): Promise<ResolvedAuthDeps | undefined> {
|
|
793
|
+
const session = this.sessionPasswords.get(entry.alias)
|
|
794
|
+
const sessionOverride = session !== undefined
|
|
795
|
+
? {
|
|
796
|
+
kind: entry.auth.kind,
|
|
797
|
+
keyPath: entry.auth.keyPath,
|
|
798
|
+
password: session.password,
|
|
799
|
+
passphrase: session.passphrase,
|
|
800
|
+
} satisfies ResolvedAuthDeps
|
|
801
|
+
: undefined
|
|
802
|
+
if (sessionOverride !== undefined) return sessionOverride
|
|
803
|
+
|
|
804
|
+
let resolved: ResolvedAuthDeps | undefined
|
|
805
|
+
if (this.deps.resolveSecrets !== undefined) {
|
|
806
|
+
resolved = await this.deps.resolveSecrets(entry).catch((error: unknown) => {
|
|
807
|
+
throw error instanceof Error ? error : new Error(String(error))
|
|
808
|
+
})
|
|
809
|
+
} else {
|
|
810
|
+
// Inline fallback (plaintext store / tests).
|
|
811
|
+
const auth = entry.auth
|
|
812
|
+
if (auth.kind === 'password') {
|
|
813
|
+
resolved = { kind: 'password', password: auth.password }
|
|
814
|
+
} else {
|
|
815
|
+
resolved = auth.passphrase !== undefined && auth.passphrase !== ''
|
|
816
|
+
? { kind: 'key', keyPath: auth.keyPath, passphrase: auth.passphrase }
|
|
817
|
+
: { kind: 'key', keyPath: auth.keyPath }
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Credential gate: a password-kind host without a secret must surface
|
|
822
|
+
// NEEDS_PASSWORD (GUI dialog), NOT a raw ssh2 auth failure; an encrypted
|
|
823
|
+
// key whose passphrase is missing must surface NEEDS_PASSPHRASE.
|
|
824
|
+
if (resolved?.kind === 'password' && (resolved.password === undefined || resolved.password === '')) {
|
|
825
|
+
throw new NeedsPasswordError(entry.alias, 'password')
|
|
826
|
+
}
|
|
827
|
+
if (resolved?.kind === 'key' && resolved.passphrase === undefined && resolved.keyPath !== undefined) {
|
|
828
|
+
const keyPath = expandHome(resolved.keyPath)
|
|
829
|
+
if (keyPath !== '' && existsSync(keyPath) && keyNeedsPassphrase(keyPath)) {
|
|
830
|
+
throw new NeedsPasswordError(entry.alias, 'passphrase')
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return resolved
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Build one full jump chain for an entry: hop clients connected through in
|
|
837
|
+
* order, each forwarding a stream to the next destination, ending with the
|
|
838
|
+
* target client. Shared by the pool and standalone shell sessions.
|
|
839
|
+
*/
|
|
840
|
+
async connectChain(entry: SshHostEntry, signal?: AbortSignal): Promise<{ client: Client; hops: Client[] }> {
|
|
841
|
+
const hops: Client[] = []
|
|
842
|
+
let sock: ConnectConfig['sock']
|
|
843
|
+
const chain = entry.proxyJump
|
|
844
|
+
// Defensive cycle guard: the store validates on create/update, but the
|
|
845
|
+
// JSON file can be hand-edited — a loop here would open hop connections
|
|
846
|
+
// forever. Follow the live store's full hop graph from this entry.
|
|
847
|
+
const walked = new Set<string>()
|
|
848
|
+
const walk = (alias: string, path: string[]): void => {
|
|
849
|
+
const at = path.indexOf(alias)
|
|
850
|
+
if (at >= 0) throw new Error(`proxyJump cycle detected: ${[...path.slice(at), alias].join(' -> ')}`)
|
|
851
|
+
if (walked.has(alias)) return
|
|
852
|
+
walked.add(alias)
|
|
853
|
+
const hopEntry = this.store.find(alias)
|
|
854
|
+
if (hopEntry === undefined) return
|
|
855
|
+
for (const next of hopEntry.proxyJump) walk(next, [...path, alias])
|
|
856
|
+
}
|
|
857
|
+
walk(entry.alias, [])
|
|
858
|
+
try {
|
|
859
|
+
for (let index = 0; index < chain.length; index += 1) {
|
|
860
|
+
signal?.throwIfAborted()
|
|
861
|
+
const hopAlias = chain[index]
|
|
862
|
+
const hop = this.store.find(hopAlias)
|
|
863
|
+
if (hop === undefined) {
|
|
864
|
+
for (const client of hops) client.end()
|
|
865
|
+
throw new Error(`proxyJump alias '${hopAlias}' not found —create it first`)
|
|
866
|
+
}
|
|
867
|
+
const hopOutcome: { outcome?: HostKeyOutcome | undefined } = {}
|
|
868
|
+
const hopResolved = await this.resolveEntryAuth(hop)
|
|
869
|
+
const hopClient = await connectClient(
|
|
870
|
+
buildConnectConfig(hop, this.opts, sock, {
|
|
871
|
+
hostKeyPolicy: this.hostKeyPolicy,
|
|
872
|
+
hostKeyAlgorithms: this.opts.hostKeyAlgorithms,
|
|
873
|
+
authOverride: hopResolved,
|
|
874
|
+
setOutcome: (value) => { hopOutcome.outcome = value },
|
|
875
|
+
}),
|
|
876
|
+
this.opts.connectTimeoutMs,
|
|
877
|
+
hopOutcome,
|
|
878
|
+
signal,
|
|
879
|
+
)
|
|
880
|
+
hops.push(hopClient)
|
|
881
|
+
const next = index + 1 < chain.length ? this.store.find(chain[index + 1]) : undefined
|
|
882
|
+
const nextHost = next !== undefined ? next.host : entry.host
|
|
883
|
+
const nextPort = next !== undefined ? next.port : entry.port
|
|
884
|
+
sock = await new Promise<ConnectConfig['sock']>((resolve, reject) => {
|
|
885
|
+
// forwardOut has no cancel API: bound the hop-channel open so a dead
|
|
886
|
+
// or half-open jump host cannot hang connectChain forever. On timeout
|
|
887
|
+
// the whole hop chain is torn down (mirrors the error branch).
|
|
888
|
+
let settled = false
|
|
889
|
+
const onAbort = (): void => {
|
|
890
|
+
if (settled) return
|
|
891
|
+
settled = true
|
|
892
|
+
clearTimeout(timer)
|
|
893
|
+
for (const client of hops) client.end()
|
|
894
|
+
reject(signal?.reason instanceof Error ? signal.reason : Object.assign(new Error('proxyJump forwardOut aborted'), { name: 'AbortError' }))
|
|
895
|
+
}
|
|
896
|
+
const timer = setTimeout(() => {
|
|
897
|
+
if (settled) return
|
|
898
|
+
settled = true
|
|
899
|
+
signal?.removeEventListener('abort', onAbort)
|
|
900
|
+
for (const client of hops) client.end()
|
|
901
|
+
reject(new Error(`proxyJump forwardOut on '${hopAlias}' timed out after ${this.opts.connectTimeoutMs} ms (target ${nextHost}:${nextPort})`))
|
|
902
|
+
}, this.opts.connectTimeoutMs)
|
|
903
|
+
timer.unref?.()
|
|
904
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
905
|
+
if (signal?.aborted === true) {
|
|
906
|
+
onAbort()
|
|
907
|
+
return
|
|
908
|
+
}
|
|
909
|
+
hopClient.forwardOut('127.0.0.1', 0, nextHost, nextPort, (error, stream) => {
|
|
910
|
+
if (settled) {
|
|
911
|
+
// Late arrival after the timeout: the chain is being torn down;
|
|
912
|
+
// close any channel that finally opened.
|
|
913
|
+
if (stream !== undefined) {
|
|
914
|
+
try { stream.close() } catch { /* already closed */ }
|
|
915
|
+
}
|
|
916
|
+
return
|
|
917
|
+
}
|
|
918
|
+
settled = true
|
|
919
|
+
clearTimeout(timer)
|
|
920
|
+
signal?.removeEventListener('abort', onAbort)
|
|
921
|
+
if (error !== undefined) {
|
|
922
|
+
for (const client of hops) client.end()
|
|
923
|
+
reject(error)
|
|
924
|
+
} else {
|
|
925
|
+
resolve(stream)
|
|
926
|
+
}
|
|
927
|
+
})
|
|
928
|
+
})
|
|
929
|
+
}
|
|
930
|
+
const targetOutcome: { outcome?: HostKeyOutcome | undefined } = {}
|
|
931
|
+
// Resolve the entry's authentication (session password table first,
|
|
932
|
+
// then vault / inline store). The hostVerifier still runs on the raw
|
|
933
|
+
// server key first, so a secret is never sent to an unverified host.
|
|
934
|
+
const resolvedAuth = await this.resolveEntryAuth(entry)
|
|
935
|
+
const client = await connectClient(
|
|
936
|
+
buildConnectConfig(entry, this.opts, sock, {
|
|
937
|
+
hostKeyPolicy: this.hostKeyPolicy,
|
|
938
|
+
hostKeyAlgorithms: this.opts.hostKeyAlgorithms,
|
|
939
|
+
authOverride: resolvedAuth,
|
|
940
|
+
setOutcome: (value) => { targetOutcome.outcome = value },
|
|
941
|
+
}),
|
|
942
|
+
this.opts.connectTimeoutMs,
|
|
943
|
+
targetOutcome,
|
|
944
|
+
signal,
|
|
945
|
+
)
|
|
946
|
+
return { client, hops }
|
|
947
|
+
} catch (error) {
|
|
948
|
+
for (const client of hops) client.end()
|
|
949
|
+
throw error
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/**
|
|
953
|
+
* Run one command against many hosts concurrently.
|
|
954
|
+
*
|
|
955
|
+
* Per-host failures carry the typed, secret-free fields declared on
|
|
956
|
+
* `ClusterResult` (`code`, and `secret` / `hostKeyFingerprint` / `expected` /
|
|
957
|
+
* `actual` where applicable) alongside the legacy `error` string, so
|
|
958
|
+
* automation can branch on a stable code and the GUI can open the right
|
|
959
|
+
* credential/fingerprint dialog without parsing a localized message.
|
|
960
|
+
*/
|
|
961
|
+
async cluster(options: {
|
|
962
|
+
command: string
|
|
963
|
+
aliases?: string[]
|
|
964
|
+
environment?: string
|
|
965
|
+
tags?: string[]
|
|
966
|
+
timeoutMs?: number
|
|
967
|
+
maxWorkers?: number
|
|
968
|
+
/** Aborts every per-host run when the caller disconnects. */
|
|
969
|
+
signal?: AbortSignal
|
|
970
|
+
}): Promise<ClusterResult[]> {
|
|
971
|
+
let targets = this.store.list()
|
|
972
|
+
if (options.aliases !== undefined && options.aliases.length > 0) {
|
|
973
|
+
// Explicit target lists are safety-sensitive: reject the WHOLE batch
|
|
974
|
+
// before executing anything when any alias is unknown. Silently
|
|
975
|
+
// filtering a typo could otherwise report partial production work as a
|
|
976
|
+
// successful complete run.
|
|
977
|
+
const byAlias = new Map(targets.map(entry => [entry.alias, entry]))
|
|
978
|
+
const unknown = options.aliases.filter(alias => !byAlias.has(alias))
|
|
979
|
+
if (unknown.length > 0) {
|
|
980
|
+
throw new Error(`alias '${unknown.join("', '")}' not found — no cluster commands were executed`)
|
|
981
|
+
}
|
|
982
|
+
// Preserve the caller's alias order (store order is not contractual).
|
|
983
|
+
targets = options.aliases.map(alias => byAlias.get(alias)!)
|
|
984
|
+
}
|
|
985
|
+
if (options.environment !== undefined && options.environment !== '') {
|
|
986
|
+
targets = targets.filter(entry => entry.environment === options.environment)
|
|
987
|
+
}
|
|
988
|
+
if (options.tags !== undefined && options.tags.length > 0) {
|
|
989
|
+
// ALL semantics (matches the ssh_cluster tool description).
|
|
990
|
+
targets = targets.filter(entry => options.tags!.every(tag => entry.tags.includes(tag)))
|
|
991
|
+
}
|
|
992
|
+
if (targets.length === 0) return []
|
|
993
|
+
if (options.maxWorkers !== undefined && (!Number.isInteger(options.maxWorkers) || options.maxWorkers < 1)) {
|
|
994
|
+
throw new Error('maxWorkers must be a positive integer')
|
|
995
|
+
}
|
|
996
|
+
const workers = Math.min(this.opts.defaultMaxWorkers, options.maxWorkers ?? this.opts.defaultMaxWorkers, targets.length)
|
|
997
|
+
// Pre-sized slots keep the result order aligned with the target order
|
|
998
|
+
// regardless of which host finishes first.
|
|
999
|
+
const results = new Array<ClusterResult>(targets.length)
|
|
1000
|
+
const queue = targets.map((entry, index) => ({ entry, index }))
|
|
1001
|
+
const run = async (): Promise<void> => {
|
|
1002
|
+
while (queue.length > 0) {
|
|
1003
|
+
const { entry, index } = queue.shift()!
|
|
1004
|
+
try {
|
|
1005
|
+
const result = await this.executor(entry.alias, options.command, { timeoutMs: options.timeoutMs, signal: options.signal })
|
|
1006
|
+
results[index] = {
|
|
1007
|
+
alias: entry.alias,
|
|
1008
|
+
ok: result.success,
|
|
1009
|
+
exitCode: result.exitCode,
|
|
1010
|
+
timedOut: result.timedOut,
|
|
1011
|
+
stdout: result.stdout,
|
|
1012
|
+
stderr: result.stderr,
|
|
1013
|
+
durationMs: result.durationMs,
|
|
1014
|
+
// A timed-out command ran (and was killed), so it has no failure
|
|
1015
|
+
// code beyond the timeout itself; surface that as a typed code so
|
|
1016
|
+
// automation does not have to infer it from `timedOut`.
|
|
1017
|
+
...(result.success || result.timedOut !== true ? {} : { code: 'TIMEOUT' as const }),
|
|
1018
|
+
}
|
|
1019
|
+
} catch (error) {
|
|
1020
|
+
results[index] = this.clusterFailure(entry.alias, error)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
await Promise.all(Array.from({ length: workers }, () => run()))
|
|
1025
|
+
return results
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* Classify one per-host cluster failure. Keeps the legacy `error` string for
|
|
1030
|
+
* backward compatibility while adding the typed reason: a NeedsPassword /
|
|
1031
|
+
* host-key failure is interactive in the GUI, and automation needs a stable
|
|
1032
|
+
* code. The message is redacted because connect/resolve errors can embed a
|
|
1033
|
+
* credential; the added fields are fingerprints and enum values only.
|
|
1034
|
+
*/
|
|
1035
|
+
clusterFailure(alias: string, error: unknown): ClusterResult {
|
|
1036
|
+
const message = this.redact(error instanceof Error ? error.message : String(error))
|
|
1037
|
+
const base: ClusterResult = { alias, ok: false, error: message }
|
|
1038
|
+
if (error instanceof NeedsPasswordError) {
|
|
1039
|
+
return { ...base, code: 'NEEDS_PASSWORD', secret: error.secret }
|
|
1040
|
+
}
|
|
1041
|
+
if (error instanceof HostKeyUnknownError) {
|
|
1042
|
+
return { ...base, code: 'HOST_KEY_UNKNOWN', hostKeyFingerprint: error.fingerprintSha256 }
|
|
1043
|
+
}
|
|
1044
|
+
if (error instanceof HostKeyMismatchError) {
|
|
1045
|
+
return { ...base, code: 'HOST_KEY_MISMATCH', expected: error.expected, actual: error.actual }
|
|
1046
|
+
}
|
|
1047
|
+
if (error instanceof Error && error.name === 'AbortError') return { ...base, code: 'ABORTED' }
|
|
1048
|
+
if (/alias '.*' not found/.test(message)) return { ...base, code: 'ALIAS_NOT_FOUND' }
|
|
1049
|
+
if (/timed? ?out|timeout/i.test(message)) return { ...base, code: 'TIMEOUT' }
|
|
1050
|
+
return { ...base, code: 'ERROR' }
|
|
1051
|
+
}
|
|
1052
|
+
/** Probe connectivity: connect, run `true`, close. Typed errors the GUI
|
|
1053
|
+
* must react to (host-key TOFU, session password) are NOT flattened into
|
|
1054
|
+
* a plain message — callers (routes → panel / workspace gate) key their
|
|
1055
|
+
* interactive dialogs on the typed error. Everything else (unreachable,
|
|
1056
|
+
* timeout, auth failure) returns a failed result. */
|
|
1057
|
+
async test(alias: string, signal?: AbortSignal): Promise<TestResult> {
|
|
1058
|
+
const started = Date.now()
|
|
1059
|
+
try {
|
|
1060
|
+
// `true` is idempotent — allow channel-open retries, never replays
|
|
1061
|
+
// once the server accepted the channel.
|
|
1062
|
+
const result = await this.executor(alias, 'true', { timeoutMs: 10_000, retry: 'idempotent', signal })
|
|
1063
|
+
return result.success
|
|
1064
|
+
? { ok: true, latencyMs: result.durationMs }
|
|
1065
|
+
: { ok: false, latencyMs: result.durationMs, error: `remote exit code ${result.exitCode}` }
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
if (error instanceof NeedsPasswordError || error instanceof HostKeyUnknownError || error instanceof HostKeyMismatchError) {
|
|
1068
|
+
throw error
|
|
1069
|
+
}
|
|
1070
|
+
return { ok: false, latencyMs: Date.now() - started, error: this.redact(error instanceof Error ? error.message : String(error)) }
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** Close every pooled connection and wipe the in-memory session password
|
|
1075
|
+
* table. The facade calls this LAST, after tunnels, terminal transports and
|
|
1076
|
+
* SFTP channels are already closed (secrets are never persisted anywhere). */
|
|
1077
|
+
disposeSensitive(): void {
|
|
1078
|
+
this.connectionPool.invalidateAll()
|
|
1079
|
+
// Drops the session credentials AND the leak-guard matching material, so
|
|
1080
|
+
// no revealed secret outlives the engine.
|
|
1081
|
+
this.clearSessionSecrets()
|
|
1082
|
+
}
|
|
1083
|
+
}
|