@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.
- package/README.i18n.yaml +2 -2
- package/README.md +10 -4
- package/README.zh.md +10 -4
- package/lib/client.js +252 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +504 -52
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +9 -1
- package/lib/types/client/panel/TunnelsTab.d.ts +4 -2
- package/lib/types/client/panel-mount-core.d.ts +1 -1
- package/lib/types/client/sidebar-entry-core.d.ts +0 -15
- package/lib/types/engine/connection-pool.d.ts +20 -0
- package/lib/types/engine/proxy-command.d.ts +36 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/protocol.d.ts +24 -2
- package/lib/types/ssh-config.d.ts +34 -0
- package/lib/types/store.d.ts +11 -3
- package/lib/types/tools.d.ts +24 -0
- package/package.json +14 -14
- package/src/client/body-mutations.ts +133 -0
- package/src/client/locales.ts +18 -2
- package/src/client/panel/HostFormDialog.tsx +15 -0
- package/src/client/panel/HostsTab.tsx +37 -2
- package/src/client/panel/SshPanel.tsx +6 -2
- package/src/client/panel/TunnelsTab.tsx +50 -10
- package/src/client/panel/panel.module.css +27 -0
- package/src/client/panel-mount-core.ts +21 -13
- package/src/client/sidebar-entry-core.ts +10 -7
- package/src/engine/connection-pool.ts +122 -10
- package/src/engine/proxy-command.ts +248 -0
- package/src/index.ts +1 -1
- package/src/protocol.ts +26 -2
- package/src/routes.ts +1 -1
- package/src/ssh-config.ts +153 -0
- package/src/store.ts +64 -40
- package/src/tools.ts +35 -5
|
@@ -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
|
|
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
|
-
/**
|
|
157
|
-
|
|
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
|
|
244
|
-
*
|
|
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.
|
|
278
|
+
this.skippedBlocks = []
|
|
250
279
|
const configPath = this.sshConfigOverride ?? sshConfigPath()
|
|
251
|
-
if (!existsSync(configPath)) return { parsed: 0, added: 0, skipped: 0,
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
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,
|
|
292
|
+
skip(pattern, 'wildcard')
|
|
280
293
|
continue
|
|
281
294
|
}
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
322
|
-
skip(pattern,
|
|
334
|
+
// Unusable entry (bad alias grammar, conflicting transports, ...).
|
|
335
|
+
skip(pattern, 'invalid')
|
|
323
336
|
}
|
|
324
337
|
}
|
|
325
|
-
return { parsed: blocks.length, added, skipped: this.
|
|
338
|
+
return { parsed: blocks.length, added, skipped: this.skippedBlocks.length, skippedBlocks: [...this.skippedBlocks] }
|
|
326
339
|
}
|
|
327
340
|
|
|
328
|
-
private
|
|
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()
|