@linxin666/dsh-ssh 0.3.19 → 0.3.21

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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * OpenSSH-compatible ProxyCommand transport: run the command with the user's
3
+ * shell and expose its stdio as a Duplex that ssh2 accepts as `sock`.
4
+ *
5
+ * The command comes verbatim from the user's own 0600 store file and runs with
6
+ * the DSH host process's privileges — the same trust the `ssh(1)` client gives
7
+ * the identical line in `~/.ssh/config`.
8
+ */
9
+
10
+ import { spawn, type ChildProcess } from 'node:child_process'
11
+ import { Duplex } from 'node:stream'
12
+
13
+ /** Token source for one host: the values %h / %p / %r / %n expand to. */
14
+ export interface ProxyTarget {
15
+ host: string
16
+ port: number
17
+ user: string
18
+ /** The name the user addresses this host by (OpenSSH's %n). */
19
+ alias: string
20
+ }
21
+
22
+ /** Captured stderr budget carried into the failure message (characters). */
23
+ const MAX_STDERR = 4096
24
+
25
+ /** How long a killed proxy command may linger before SIGKILL (ms). */
26
+ const KILL_GRACE_MS = 2_000
27
+
28
+ /** Expand the OpenSSH ProxyCommand tokens; unknown %X sequences stay verbatim. */
29
+ export function expandProxyTokens(command: string, target: ProxyTarget): string {
30
+ return command.replace(/%[hprn%]/g, token => {
31
+ switch (token) {
32
+ case '%h': return target.host
33
+ case '%p': return String(target.port)
34
+ case '%r': return target.user
35
+ case '%n': return target.alias
36
+ default: return '%'
37
+ }
38
+ })
39
+ }
40
+
41
+ /** One spawn specification: the user's shell plus the platform quoting. */
42
+ interface SpawnSpec {
43
+ file: string
44
+ args: string[]
45
+ /** cmd.exe needs the command quoted verbatim (Node's own shell recipe). */
46
+ windowsVerbatimArguments?: boolean
47
+ }
48
+
49
+ /**
50
+ * The shell OpenSSH itself would use on this platform: the user's shell with
51
+ * `-c` on POSIX, cmd.exe on Windows (where the whole command must stay one
52
+ * verbatim argument, exactly as Node's own `shell: true` does it).
53
+ */
54
+ function spawnSpec(command: string): SpawnSpec {
55
+ if (process.platform === 'win32') {
56
+ return {
57
+ file: process.env.ComSpec ?? 'cmd.exe',
58
+ args: ['/d', '/s', '/c', '"' + command + '"'],
59
+ windowsVerbatimArguments: true,
60
+ }
61
+ }
62
+ return { file: process.env.SHELL ?? '/bin/sh', args: ['-c', command] }
63
+ }
64
+
65
+ /** One running proxy command: its transport plus the process behind it. */
66
+ export interface ProxyCommandProcess {
67
+ /** The duplex transport handed to ssh2 as `sock`. */
68
+ stream: Duplex
69
+ /** The spawned shell; killing the stream kills it (and its process group). */
70
+ child: ChildProcess
71
+ }
72
+
73
+ /**
74
+ * Start one ProxyCommand and bridge its stdio into a Duplex.
75
+ *
76
+ * Failure modes are reported through the stream: a spawn error, or an exit
77
+ * before the SSH handshake finished, destroys it with the exit status and the
78
+ * tail of the command's stderr instead of letting ssh2 wait for its own
79
+ * `readyTimeout`.
80
+ */
81
+ export function startProxyCommand(rawCommand: string, target: ProxyTarget): ProxyCommandProcess {
82
+ const command = expandProxyTokens(rawCommand, target)
83
+ const spec = spawnSpec(command)
84
+ const child = spawn(spec.file, spec.args, {
85
+ stdio: ['pipe', 'pipe', 'pipe'],
86
+ windowsHide: true,
87
+ ...(spec.windowsVerbatimArguments === true ? { windowsVerbatimArguments: true } : {}),
88
+ // POSIX only: own process group, so a chained client
89
+ // (`ssh -W %h:%p bastion`) dies with the transport instead of leaking.
90
+ ...(process.platform === 'win32' ? {} : { detached: true }),
91
+ })
92
+
93
+ let stderr = ''
94
+ child.stderr?.on('data', (chunk: Buffer) => {
95
+ stderr = (stderr + chunk.toString('utf8')).slice(-MAX_STDERR)
96
+ })
97
+
98
+ let killed = false
99
+ const kill = (): void => {
100
+ if (killed) return
101
+ killed = true
102
+ const pid = child.pid
103
+ const group = process.platform !== 'win32' && pid !== undefined
104
+ try {
105
+ if (group) process.kill(-pid, 'SIGTERM')
106
+ else child.kill()
107
+ } catch { /* already gone */ }
108
+ const escalate = setTimeout(() => {
109
+ try {
110
+ if (group) process.kill(-pid, 'SIGKILL')
111
+ else child.kill('SIGKILL')
112
+ } catch { /* already gone */ }
113
+ }, KILL_GRACE_MS)
114
+ escalate.unref()
115
+ }
116
+
117
+ function formatExitError(code: number | null, signal: NodeJS.Signals | null, stderrDetail: string): Error {
118
+ const status = signal !== null ? 'signal ' + signal : 'code ' + String(code ?? 'unknown')
119
+ const detail = stderrDetail.trim()
120
+ return new Error('ProxyCommand exited (' + status + ')' + (detail === '' ? '' : ': ' + detail))
121
+ }
122
+
123
+ const stream = new Duplex({
124
+ read() {
125
+ child.stdout?.resume()
126
+ },
127
+ write(chunk, _encoding, callback) {
128
+ const stdin = child.stdin
129
+ if (stream.destroyed || stdin === null || stdin.destroyed) {
130
+ callback(new Error('ProxyCommand stdin is already closed'))
131
+ return
132
+ }
133
+ stdin.write(chunk, (err) => {
134
+ if (!err) {
135
+ callback()
136
+ return
137
+ }
138
+ if ((child.exitCode !== null && child.exitCode !== 0) || child.signalCode !== null) {
139
+ const exitErr = formatExitError(child.exitCode, child.signalCode, stderr)
140
+ fail(exitErr)
141
+ callback(exitErr)
142
+ return
143
+ }
144
+ let resolved = false
145
+ const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
146
+ if (resolved) return
147
+ resolved = true
148
+ clearTimeout(timer)
149
+ const exitErr = (code !== null && code !== 0) || signal !== null
150
+ ? formatExitError(code, signal, stderr)
151
+ : new Error('ProxyCommand transport write failed: ' + err.message)
152
+ fail(exitErr)
153
+ callback(exitErr)
154
+ }
155
+ const timer = setTimeout(() => {
156
+ if (resolved) return
157
+ resolved = true
158
+ child.removeListener('exit', onExit)
159
+ const transportErr = new Error('ProxyCommand transport write failed: ' + err.message)
160
+ fail(transportErr)
161
+ callback(transportErr)
162
+ }, 200)
163
+ timer.unref()
164
+ child.once('exit', onExit)
165
+ })
166
+ },
167
+ final(callback) {
168
+ const stdin = child.stdin
169
+ if (stdin === null || stdin.destroyed) {
170
+ callback()
171
+ return
172
+ }
173
+ stdin.end((err?: Error | null) => {
174
+ if (err) callback(new Error('ProxyCommand transport close failed: ' + err.message))
175
+ else callback()
176
+ })
177
+ },
178
+ destroy(error, callback) {
179
+ kill()
180
+ callback(error)
181
+ },
182
+ })
183
+
184
+ // ssh2 attaches its own error handling once it owns the socket; this listener
185
+ // only guarantees an 'error' emitted before that never reaches the host
186
+ // process as an unhandled event.
187
+ stream.on('error', () => { /* surfaced through the ssh2 client */ })
188
+
189
+ let failed = false
190
+ const fail = (error: Error): void => {
191
+ if (failed || stream.destroyed) return
192
+ failed = true
193
+ stream.destroy(error)
194
+ }
195
+
196
+ // The transport only reports a clean EOF once BOTH the stdout pipe ended and
197
+ // the process exited successfully: a command that dies (bastion client down,
198
+ // typo, refused connection) must surface its own exit status and stderr
199
+ // instead of the generic "connection lost before handshake" ssh2 produces
200
+ // for a socket that simply closed.
201
+ let stdoutEnded = false
202
+ let exited = false
203
+ const finishIfClean = (): void => {
204
+ if (exited && stdoutEnded && !failed && !stream.destroyed) stream.push(null)
205
+ }
206
+
207
+ child.stdout?.on('data', (chunk: Buffer) => {
208
+ if (stream.push(chunk) === false) child.stdout?.pause()
209
+ })
210
+ child.stdout?.on('end', () => {
211
+ stdoutEnded = true
212
+ finishIfClean()
213
+ })
214
+ child.stdout?.on('error', (error: Error) => {
215
+ fail(new Error('ProxyCommand stdout error: ' + error.message))
216
+ })
217
+ child.stdin?.on('error', (error: Error) => {
218
+ // A broken pipe (EPIPE) on stdin occurs naturally when the proxy command
219
+ // exits early or closes its standard input. Prevent unhandled EventEmitter errors.
220
+ if ((child.exitCode !== null && child.exitCode !== 0) || child.signalCode !== null) {
221
+ fail(formatExitError(child.exitCode, child.signalCode, stderr))
222
+ return
223
+ }
224
+ const timer = setTimeout(() => {
225
+ fail(new Error('ProxyCommand transport error: ' + error.message))
226
+ }, 200)
227
+ timer.unref()
228
+ child.once('exit', (code, signal) => {
229
+ clearTimeout(timer)
230
+ if ((code !== null && code !== 0) || signal !== null) {
231
+ fail(formatExitError(code, signal, stderr))
232
+ }
233
+ })
234
+ })
235
+ child.on('error', (error: Error) => {
236
+ fail(new Error('ProxyCommand could not start: ' + error.message))
237
+ })
238
+ child.on('exit', (code, signal) => {
239
+ exited = true
240
+ if ((code !== null && code !== 0) || signal !== null) {
241
+ fail(formatExitError(code, signal, stderr))
242
+ return
243
+ }
244
+ finishIfClean()
245
+ })
246
+
247
+ return { stream, child }
248
+ }
package/src/index.ts CHANGED
@@ -64,7 +64,7 @@ const DEFAULT_ANNOUNCE = false
64
64
  const SECTION_ORDER = 150
65
65
 
66
66
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
67
- export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥与 ProxyJump 跳板机;Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
67
+ export const SSH_GUIDANCE = '本机已安装 dsh-ssh 插件(DSH 远程 SSH 运维):侧边栏「SSH」入口;在 dsh-web 插件全家桶仓库(packages/dsh-ssh)统一维护。能力:主机配置存 $DSH_HOME/dsh-ssh.json(默认 ~/.dsh)(可从 ~/.ssh/config 导入);持久连接池复用长连接(空闲 30 分钟自动断开);ssh_list 列出主机、ssh_exec 执行远程命令、ssh_upload/ssh_download 传输文件、ssh_tunnel 本地端口转发(访问远程数据库/内网服务)、ssh_cluster 按 aliases/environment/tags 至少一种非空 selector 筛选后集群并发执行;支持密钥/密码/ssh-agent 认证、passphrase 密钥、ProxyJump 跳板机(别名或 [user@]host[:port] 地址)与 OpenSSH 语义的 ProxyCommand(跳过堡垒机客户端场景,只能由用户在 GUI 配置,agent 不可写);Web 终端走 WebSocket。限制:主机操作由用户在 GUI 中配置后 agent 方可使用;密码以明文存在用户主目录私有文件(权限 0600);命令输出原样返回、可能含敏感信息;断线重连可能重放非幂等命令;传输/执行消耗真实远程资源,先确认再操作。路径区分:本机(dsh host)上的文件与命令一律用本地工具(read / write / edit / bash),ssh_* 工具只针对远程主机上的路径。用户提到「SSH / 远程服务器 / 服务器操作 / 跳板机 / 隧道 / 部署 / 上传下载」时即指本插件,请据此协作。'
68
68
 
69
69
  /**
70
70
  * Mount the SSH engine, routes, tools, and announcement.
package/src/protocol.ts CHANGED
@@ -35,6 +35,13 @@ export interface SshHostEntry {
35
35
  }
36
36
  /** Jump chain: local aliases connected through in order (ProxyJump). */
37
37
  proxyJump: string[]
38
+ /**
39
+ * OpenSSH-compatible ProxyCommand: a shell command whose stdio becomes the
40
+ * connection transport. Tokens %h (host) / %p (port) / %r (user) / %n (alias)
41
+ * expand before exec and %% is a literal percent; an empty value or 'none'
42
+ * means "no proxy command". Mutually exclusive with a non-empty proxyJump.
43
+ */
44
+ proxyCommand?: string
38
45
  /** Free-form note. */
39
46
  description?: string
40
47
  /** Deployment environment label (development / production / ...). */
@@ -57,6 +64,8 @@ export interface SshHostSummary {
57
64
  /** Whether the key path exists on the host machine (key auth only). */
58
65
  keyReady: boolean
59
66
  proxyJump: string[]
67
+ /** Configured ProxyCommand, echoed to the browser so the form can edit it. */
68
+ proxyCommand?: string
60
69
  description?: string
61
70
  environment?: string
62
71
  tags: string[]
@@ -142,19 +151,34 @@ export interface HostPayload {
142
151
  */
143
152
  auth?: SshHostEntry['auth']
144
153
  proxyJump?: string[]
154
+ /**
155
+ * ProxyCommand to store. An empty string or 'none' clears a stored value;
156
+ * omitting the key leaves it untouched (PATCH semantics).
157
+ */
158
+ proxyCommand?: string
145
159
  description?: string
146
160
  environment?: string
147
161
  tags?: string[]
148
162
  location?: string
149
163
  }
150
164
 
165
+ /** Why one ssh_config Host block was not imported. */
166
+ export type ImportSkipReason = 'wildcard' | 'existing' | 'match' | 'invalid'
167
+
168
+ /** One skipped ssh_config block and the reason it could not be imported. */
169
+ export interface ImportSkipBlock {
170
+ /** Host pattern (or '`Match …`' label) that was skipped. */
171
+ name: string
172
+ reason: ImportSkipReason
173
+ }
174
+
151
175
  /** Import outcome from ~/.ssh/config. */
152
176
  export interface ImportResult {
153
177
  parsed: number
154
178
  added: number
155
179
  skipped: number
156
- /** Aliases that failed to map (wildcard patterns, missing HostName, ...). */
157
- skippedNames: string[]
180
+ /** Every skipped block with its reason, in scan order and deduplicated by name. */
181
+ skippedBlocks: ImportSkipBlock[]
158
182
  }
159
183
 
160
184
  /** JSON error body used by every route. */
package/src/routes.ts CHANGED
@@ -136,7 +136,7 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
136
136
  // without this the pool would keep running commands on the old
137
137
  // host/credentials until the idle sweep (up to 30 min later).
138
138
  const patch = body as Record<string, unknown>
139
- if (['host', 'port', 'user', 'auth', 'proxyJump'].some(key => patch[key] !== undefined)) {
139
+ if (['host', 'port', 'user', 'auth', 'proxyJump', 'proxyCommand'].some(key => patch[key] !== undefined)) {
140
140
  engine.dropAlias(alias)
141
141
  }
142
142
  writeJson(res, 200, { host: store.summarize(entry) })
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Minimal `~/.ssh/config` reader for the one-shot host import: Host blocks with
3
+ * `Include` expansion and explicit skip reasons. Values stay raw strings; the
4
+ * store maps them onto a HostPayload.
5
+ *
6
+ * Deliberate simplifications (documented in the package README):
7
+ * - `Match` blocks are skipped — their options are conditional and must never
8
+ * merge into the Host block above them.
9
+ * - A multi-pattern `Host a b` line only exposes its first pattern.
10
+ * - Include pathnames accept `~`, globs, and several whitespace-separated
11
+ * pathnames, but not environment variables, `%` tokens, or quoting.
12
+ * - Relative Include pathnames resolve against the directory of the config
13
+ * file the import started from (OpenSSH: `~/.ssh` for a user config).
14
+ * - A file that already contributed lines is never read twice, which doubles
15
+ * as the Include cycle guard.
16
+ */
17
+
18
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { dirname, isAbsolute, join } from 'node:path'
21
+ import type { ImportSkipBlock } from './protocol.ts'
22
+
23
+ /** One parsed Host block. */
24
+ export interface SshBlock {
25
+ /** Raw Host line value (may hold several whitespace-separated patterns). */
26
+ pattern: string
27
+ /** Lowercased option key/value pairs; the last occurrence of a key wins. */
28
+ props: Record<string, string>
29
+ }
30
+
31
+ /** One config file read, with every Include expanded in place. */
32
+ export interface SshConfigRead {
33
+ blocks: SshBlock[]
34
+ skipped: ImportSkipBlock[]
35
+ }
36
+
37
+ /** Include recursion guard: a cycle or a runaway chain stops here. */
38
+ const MAX_INCLUDE_DEPTH = 16
39
+
40
+ /** The only line shape ssh_config knows: `Keyword value...` (case-insensitive). */
41
+ const LINE_RE = /^([A-Za-z0-9_-]+)\s+(.+)$/
42
+
43
+ /** Expand a leading `~` the way OpenSSH does for user configuration files. */
44
+ function expandTilde(path: string): string {
45
+ if (path === '~') return homedir()
46
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
47
+ return path
48
+ }
49
+
50
+ /** Turn one glob segment into a matcher (`*` and `?` only). */
51
+ function segmentMatcher(segment: string): RegExp {
52
+ const escaped = segment.replace(/[.+^${}()|[\]\\]/g, '\\$&')
53
+ return new RegExp('^' + escaped.replace(/\*/g, '.*').replace(/\?/g, '.') + '$')
54
+ }
55
+
56
+ /**
57
+ * Expand a segment list against the filesystem. Wildcards match whole segments
58
+ * only; matches are sorted so a glob import stays deterministic.
59
+ */
60
+ function globSegments(prefix: string, segments: string[]): string[] {
61
+ const [head, ...rest] = segments
62
+ if (head === undefined) return [prefix]
63
+ if (!/[*?]/.test(head)) return globSegments(join(prefix, head), rest)
64
+ let names: string[]
65
+ try {
66
+ names = readdirSync(prefix)
67
+ } catch {
68
+ return []
69
+ }
70
+ const matcher = segmentMatcher(head)
71
+ return names.filter(name => matcher.test(name)).sort().flatMap(name => globSegments(join(prefix, name), rest))
72
+ }
73
+
74
+ /** Resolve one Include pathname into the existing regular files it names. */
75
+ function resolveInclude(spec: string, baseDir: string): string[] {
76
+ const expanded = expandTilde(spec)
77
+ const full = isAbsolute(expanded) ? expanded : join(baseDir, expanded)
78
+ if (!/[*?]/.test(full)) return existsSync(full) ? [full] : []
79
+ const root = /^([A-Za-z]:[\\/]|[\\/])/.exec(full)?.[1] ?? ''
80
+ const segments = full.slice(root.length).split(/[\\/]+/).filter(part => part !== '')
81
+ const files = globSegments(root === '' ? '.' : root, segments)
82
+ return files.filter(path => {
83
+ try {
84
+ return statSync(path).isFile()
85
+ } catch {
86
+ return false
87
+ }
88
+ })
89
+ }
90
+
91
+ /** Read one file into lines, splicing every `Include` in place. */
92
+ function readLines(file: string, baseDir: string, visited: Set<string>, depth: number): string[] {
93
+ if (depth > MAX_INCLUDE_DEPTH) return []
94
+ let key: string
95
+ try {
96
+ key = realpathSync(file)
97
+ } catch {
98
+ return []
99
+ }
100
+ if (visited.has(key)) return []
101
+ visited.add(key)
102
+ let text: string
103
+ try {
104
+ text = readFileSync(key, 'utf8')
105
+ } catch {
106
+ return []
107
+ }
108
+ const out: string[] = []
109
+ for (const raw of text.split(/\r?\n/)) {
110
+ const match = LINE_RE.exec(raw.trim())
111
+ if (match === null || match[1].toLowerCase() !== 'include') {
112
+ out.push(raw)
113
+ continue
114
+ }
115
+ for (const spec of match[2].trim().split(/\s+/).filter(part => part !== '')) {
116
+ for (const included of resolveInclude(spec, baseDir)) {
117
+ out.push(...readLines(included, baseDir, visited, depth + 1))
118
+ }
119
+ }
120
+ }
121
+ return out
122
+ }
123
+
124
+ /**
125
+ * Parse one ssh_config file (Include expanded) into Host blocks plus the
126
+ * `Match` blocks that were deliberately skipped.
127
+ */
128
+ export function readSshConfigBlocks(configPath: string): SshConfigRead {
129
+ const blocks: SshBlock[] = []
130
+ const skipped: ImportSkipBlock[] = []
131
+ const lines = readLines(configPath, dirname(configPath), new Set<string>(), 0)
132
+ let current: SshBlock | undefined
133
+ for (const raw of lines) {
134
+ const match = LINE_RE.exec(raw.trim())
135
+ if (match === null) continue
136
+ const key = match[1].toLowerCase()
137
+ const value = match[2].trim()
138
+ if (key === 'host') {
139
+ current = { pattern: value, props: {} }
140
+ blocks.push(current)
141
+ continue
142
+ }
143
+ if (key === 'match') {
144
+ // Everything below a Match line is conditional; attributing it to the
145
+ // Host block above would import a wrong HostName / ProxyCommand.
146
+ skipped.push({ name: 'Match ' + value, reason: 'match' })
147
+ current = undefined
148
+ continue
149
+ }
150
+ if (current !== undefined) current.props[key] = value
151
+ }
152
+ return { blocks, skipped }
153
+ }
package/src/store.ts CHANGED
@@ -11,7 +11,8 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSyn
11
11
  import { homedir } from 'node:os'
12
12
  import { dirname, join, resolve } from 'node:path'
13
13
  import { dshHome } from './dsh-home.ts'
14
- import type { HostPayload, ImportResult, SshHostEntry, SshHostSummary } from './protocol.ts'
14
+ import type { HostPayload, ImportResult, ImportSkipBlock, ImportSkipReason, SshHostEntry, SshHostSummary } from './protocol.ts'
15
+ import { readSshConfigBlocks } from './ssh-config.ts'
15
16
 
16
17
  /** File format version. */
17
18
  const FORMAT_VERSION = 1
@@ -57,6 +58,17 @@ export function validateHostPayload(payload: unknown): string | undefined {
57
58
  if (p.proxyJump !== undefined && (!Array.isArray(p.proxyJump) || p.proxyJump.some(x => typeof x !== 'string' || x === ''))) {
58
59
  return 'proxyJump must be an array of alias strings'
59
60
  }
61
+ if (p.proxyCommand !== undefined && typeof p.proxyCommand !== 'string') {
62
+ return 'proxyCommand must be a string when provided'
63
+ }
64
+ // OpenSSH resolves ProxyCommand and ProxyJump by "whichever appears first in
65
+ // the config"; a stored entry has no order, so the combination is rejected
66
+ // instead of silently picking one transport.
67
+ const proxyCommand = typeof p.proxyCommand === 'string' ? normalizeProxyCommand(p.proxyCommand) : undefined
68
+ const proxyJump = Array.isArray(p.proxyJump) ? p.proxyJump as string[] : []
69
+ if (proxyCommand !== undefined && proxyJump.length > 0) {
70
+ return 'proxyCommand and proxyJump cannot be combined — pick one transport per host'
71
+ }
60
72
  if (p.tags !== undefined && (!Array.isArray(p.tags) || p.tags.some(x => typeof x !== 'string'))) {
61
73
  return 'tags must be an array of strings'
62
74
  }
@@ -117,6 +129,7 @@ export class HostStore {
117
129
  auth: entry.auth.kind,
118
130
  keyReady,
119
131
  proxyJump: [...entry.proxyJump],
132
+ ...(entry.proxyCommand !== undefined ? { proxyCommand: entry.proxyCommand } : {}),
120
133
  // Optional fields are spread conditionally: the tool bridge rejects
121
134
  // undefined-valued properties as non-lossless JSON.
122
135
  ...(entry.description !== undefined ? { description: entry.description } : {}),
@@ -153,6 +166,7 @@ export class HostStore {
153
166
  agentPath: payload.auth.kind === 'agent' ? normalizeAgentPath(payload.auth.agentPath) : undefined,
154
167
  },
155
168
  proxyJump: [...(payload.proxyJump ?? [])],
169
+ proxyCommand: normalizeProxyCommand(payload.proxyCommand),
156
170
  description: payload.description?.trim() || undefined,
157
171
  environment: payload.environment?.trim() || undefined,
158
172
  tags: [...(payload.tags ?? [])].map(tag => tag.trim()).filter(tag => tag !== ''),
@@ -184,6 +198,17 @@ export class HostStore {
184
198
  if (patch.proxyJump !== undefined && (!Array.isArray(patch.proxyJump) || patch.proxyJump.some(x => typeof x !== 'string' || x === ''))) {
185
199
  throw new Error('proxyJump must be an array of alias strings')
186
200
  }
201
+ if (patch.proxyCommand !== undefined && typeof patch.proxyCommand !== 'string') {
202
+ throw new Error('proxyCommand must be a string when provided')
203
+ }
204
+ // The mutual exclusion is checked on the MERGED view: a patch that only
205
+ // adds one transport must not slip past a stored value of the other.
206
+ const mergedProxyCommand = patch.proxyCommand !== undefined
207
+ ? normalizeProxyCommand(patch.proxyCommand)
208
+ : entry.proxyCommand
209
+ if (mergedProxyCommand !== undefined && (patch.proxyJump ?? entry.proxyJump).length > 0) {
210
+ throw new Error('proxyCommand and proxyJump cannot be combined — pick one transport per host')
211
+ }
187
212
  if (patch.tags !== undefined && (!Array.isArray(patch.tags) || patch.tags.some(x => typeof x !== 'string'))) {
188
213
  throw new Error('tags must be an array of strings')
189
214
  }
@@ -220,6 +245,7 @@ export class HostStore {
220
245
  }
221
246
  }
222
247
  if (patch.proxyJump !== undefined) entry.proxyJump = [...patch.proxyJump]
248
+ if (patch.proxyCommand !== undefined) entry.proxyCommand = mergedProxyCommand
223
249
  if (patch.description !== undefined) entry.description = patch.description.trim() || undefined
224
250
  if (patch.environment !== undefined) entry.environment = patch.environment.trim() || undefined
225
251
  if (patch.tags !== undefined) entry.tags = [...patch.tags].map(tag => tag.trim()).filter(tag => tag !== '')
@@ -240,55 +266,41 @@ export class HostStore {
240
266
 
241
267
  /**
242
268
  * Import hosts from `~/.ssh/config`: Host blocks with a single non-wildcard
243
- * pattern and a HostName become entries (key auth via IdentityFile, jump
244
- * hosts via ProxyJump). Existing aliases are skipped.
269
+ * pattern become entries (a missing HostName falls back to the pattern
270
+ * itself; key auth via IdentityFile; jump hosts via ProxyJump; ProxyCommand
271
+ * kept verbatim). `Include` files are expanded, and `Match` blocks,
272
+ * wildcard patterns, and aliases that already exist are skipped with a
273
+ * reason instead of silently disappearing.
245
274
  * @returns import statistics.
246
275
  */
247
276
  importFromSshConfig(): ImportResult {
248
277
  // Per-run statistics: a later import must not report earlier runs' skips.
249
- this.skippedNames = new Set<string>()
278
+ this.skippedBlocks = []
250
279
  const configPath = this.sshConfigOverride ?? sshConfigPath()
251
- if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedNames: [] }
252
- const lines = readFileSync(configPath, 'utf8').split(/\r?\n/)
253
- const blocks: { pattern: string; props: Record<string, string> }[] = []
254
- let current: { pattern: string; props: Record<string, string> } | undefined
255
- const skip = (name: string, seen: Set<string>): void => {
256
- if (name !== '' && !seen.has(name)) {
257
- seen.add(name)
258
- this.skippedNames.add(name)
259
- }
260
- }
261
- for (const raw of lines) {
262
- const line = raw.trim()
263
- if (line === '' || line.startsWith('#')) continue
264
- const match = /^([A-Za-z0-9_\-]+)\s+(.+)$/.exec(line)
265
- if (match === null) continue
266
- const key = match[1].toLowerCase()
267
- const value = match[2].trim()
268
- if (key === 'host') {
269
- current = { pattern: value, props: {} }
270
- blocks.push(current)
271
- } else if (current !== undefined) {
272
- current.props[key] = value
273
- }
280
+ if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0, skippedBlocks: [] }
281
+ const { blocks, skipped } = readSshConfigBlocks(configPath)
282
+ this.skippedBlocks = [...skipped]
283
+ const skip = (name: string, reason: ImportSkipReason): void => {
284
+ if (name === '') return
285
+ if (this.skippedBlocks.some(block => block.name === name)) return
286
+ this.skippedBlocks.push({ name, reason })
274
287
  }
275
288
  let added = 0
276
289
  for (const block of blocks) {
277
290
  const pattern = block.pattern.split(/\s+/)[0]
278
291
  if (pattern.includes('*') || pattern.includes('?')) {
279
- skip(pattern, this.skippedNames)
292
+ skip(pattern, 'wildcard')
280
293
  continue
281
294
  }
282
- const hostName = block.props.hostname
283
- if (hostName === undefined || hostName === '') {
284
- skip(pattern, this.skippedNames)
285
- continue
286
- }
287
- const existing = this.list().some(entry => entry.alias === pattern)
288
- if (existing) {
289
- skip(pattern, this.skippedNames)
295
+ if (this.list().some(entry => entry.alias === pattern)) {
296
+ skip(pattern, 'existing')
290
297
  continue
291
298
  }
299
+ // A block without HostName is a usable OpenSSH entry: the Host pattern
300
+ // itself is the hostname.
301
+ const hostName = block.props.hostname !== undefined && block.props.hostname !== ''
302
+ ? block.props.hostname
303
+ : pattern
292
304
  const payload: HostPayload = {
293
305
  alias: pattern,
294
306
  host: hostName,
@@ -309,6 +321,7 @@ export class HostStore {
309
321
  proxyJump: block.props.proxyjump !== undefined
310
322
  ? block.props.proxyjump.split(',').map(hop => hop.trim()).filter(hop => hop !== '')
311
323
  : [],
324
+ proxyCommand: block.props.proxycommand,
312
325
  description: block.props.description,
313
326
  environment: block.props.environment,
314
327
  tags: (block.props.tags ?? '').split(',').map(tag => tag.trim()).filter(tag => tag !== ''),
@@ -318,14 +331,14 @@ export class HostStore {
318
331
  this.create(payload)
319
332
  added += 1
320
333
  } catch {
321
- // Unusable entry (bad alias grammar etc.) — count as skipped.
322
- skip(pattern, this.skippedNames)
334
+ // Unusable entry (bad alias grammar, conflicting transports, ...).
335
+ skip(pattern, 'invalid')
323
336
  }
324
337
  }
325
- return { parsed: blocks.length, added, skipped: this.skippedNames.size, skippedNames: [...this.skippedNames] }
338
+ return { parsed: blocks.length, added, skipped: this.skippedBlocks.length, skippedBlocks: [...this.skippedBlocks] }
326
339
  }
327
340
 
328
- private skippedNames = new Set<string>()
341
+ private skippedBlocks: ImportSkipBlock[] = []
329
342
 
330
343
  /**
331
344
  * Last parsed store keyed by file identity. list/find ride every acquire
@@ -387,6 +400,17 @@ export function normalizeAgentPath(agentPath: string | undefined): string | unde
387
400
  return expandHome(trimmed)
388
401
  }
389
402
 
403
+ /**
404
+ * Normalize a ProxyCommand for storage: trim, and treat an empty value or
405
+ * OpenSSH's `none` keyword as "no proxy command" (an explicit clear).
406
+ */
407
+ export function normalizeProxyCommand(raw: string | undefined): string | undefined {
408
+ const trimmed = raw?.trim()
409
+ if (trimmed === undefined || trimmed === '') return undefined
410
+ if (trimmed.toLowerCase() === 'none') return undefined
411
+ return trimmed
412
+ }
413
+
390
414
  /** Expand a leading `~` in a filesystem path. */
391
415
  export function expandHome(path: string): string {
392
416
  if (path === '~') return homedir()