@pyrokine/mcp-ssh 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,9 +8,14 @@ A comprehensive SSH MCP Server for AI assistants (Claude, Cursor, Windsurf, etc.
8
8
  [![Node](https://img.shields.io/badge/node-%3E%3D18-green.svg)](https://nodejs.org/)
9
9
  [![MCP](https://img.shields.io/badge/MCP-compatible-purple.svg)](https://modelcontextprotocol.io/)
10
10
 
11
+ ![Linux](https://img.shields.io/badge/Linux-tested-success)
12
+ ![macOS](https://img.shields.io/badge/macOS-untested-yellow)
13
+ ![Windows](https://img.shields.io/badge/Windows-untested-yellow)
14
+
11
15
  ## Features
12
16
 
13
17
  - **Multiple Authentication**: Password, SSH key, SSH agent
18
+ - **SSH Config Support**: Read `~/.ssh/config` with Host aliases, `Host *` inheritance, ProxyJump (`user@host:port`)
14
19
  - **Connection Management**: Connection pooling, keepalive, auto-reconnect
15
20
  - **Session Persistence**: Sessions info saved for reconnection
16
21
  - **Command Execution**:
@@ -19,6 +24,7 @@ A comprehensive SSH MCP Server for AI assistants (Claude, Cursor, Windsurf, etc.
19
24
  - `sudo` execution
20
25
  - `su` (switch user) execution - *run commands as different user*
21
26
  - Batch execution
27
+ - Parallel execution on multiple hosts
22
28
  - **Persistent PTY Sessions**: For long-running interactive commands (top, htop, tmux, vim, etc.)
23
29
  - Output buffering with polling read
24
30
  - Send keystrokes and commands
@@ -42,9 +48,17 @@ A comprehensive SSH MCP Server for AI assistants (Claude, Cursor, Windsurf, etc.
42
48
 
43
49
  ## Installation
44
50
 
51
+ ### npm (Recommended)
52
+
53
+ ```bash
54
+ npm install -g @pyrokine/mcp-ssh
55
+ ```
56
+
57
+ ### From Source
58
+
45
59
  ```bash
46
60
  git clone https://github.com/Pyrokine/claude-mcp-tools.git
47
- cd claude-mcp-tools/ssh
61
+ cd claude-mcp-tools/mcp-ssh
48
62
  npm install
49
63
  npm run build
50
64
  ```
@@ -72,16 +86,17 @@ Add to your MCP settings (e.g., `~/.claude/settings.json` or client-specific con
72
86
  }
73
87
  ```
74
88
 
75
- ## Available Tools (27 tools)
89
+ ## Available Tools (29 tools)
76
90
 
77
91
  ### Connection Management
78
92
 
79
93
  | Tool | Description |
80
94
  |------|-------------|
81
- | `ssh_connect` | Establish SSH connection with keepalive |
95
+ | `ssh_connect` | Establish SSH connection (supports ~/.ssh/config) |
82
96
  | `ssh_disconnect` | Close connection |
83
97
  | `ssh_list_sessions` | List active sessions |
84
98
  | `ssh_reconnect` | Reconnect a disconnected session |
99
+ | `ssh_config_list` | List hosts from ~/.ssh/config |
85
100
 
86
101
  ### Command Execution
87
102
 
@@ -91,6 +106,7 @@ Add to your MCP settings (e.g., `~/.claude/settings.json` or client-specific con
91
106
  | `ssh_exec_as_user` | Execute as different user (via `su`) |
92
107
  | `ssh_exec_sudo` | Execute with `sudo` |
93
108
  | `ssh_exec_batch` | Execute multiple commands sequentially |
109
+ | `ssh_exec_parallel` | Execute command on multiple hosts in parallel |
94
110
  | `ssh_quick_exec` | One-shot: connect, execute, disconnect |
95
111
 
96
112
  ### File Operations
@@ -128,12 +144,74 @@ Add to your MCP settings (e.g., `~/.claude/settings.json` or client-specific con
128
144
 
129
145
  ## Usage Examples
130
146
 
147
+ ### Using SSH Config (Recommended)
148
+
149
+ If you have hosts configured in `~/.ssh/config`:
150
+
151
+ ```
152
+ # List available hosts
153
+ ssh_config_list()
154
+
155
+ # Connect using config host name
156
+ ssh_connect(configHost="myserver")
157
+ ssh_exec(alias="myserver", command="uptime")
158
+
159
+ # Use custom config file path
160
+ ssh_connect(configHost="myserver", configPath="/custom/path/config")
161
+ ```
162
+
163
+ Supported SSH config features:
164
+ - `Host` with multiple aliases (e.g., `Host a b c`)
165
+ - `Host *` global defaults inheritance (first `Host *` block only)
166
+ - `HostName`, `User`, `Port`, `IdentityFile`
167
+ - `ProxyJump` with `user@host:port` format (first hop only)
168
+ - Explicit parameters override config values (e.g., `ssh_connect(configHost="x", user="override")`)
169
+
170
+ **Not supported** (skipped):
171
+ - `Include` directive
172
+ - `Match` blocks (entire block skipped until next `Host`)
173
+ - Wildcard patterns (e.g., `Host *.example.com`)
174
+
175
+ **Behavior notes**:
176
+ - Multiple `Host *` blocks: only first is used
177
+ - Duplicate Host definitions: `ssh_config_list` shows all, `ssh_connect` uses first
178
+ - IPv6 in ProxyJump: use bracket notation `[2001:db8::1]:22`
179
+
180
+ ### Parallel Execution on Multiple Hosts
181
+
182
+ Execute the same command on multiple connected hosts:
183
+
184
+ ```
185
+ 1. ssh_connect(configHost="server1")
186
+ 2. ssh_connect(configHost="server2")
187
+ 3. ssh_connect(configHost="server3")
188
+ 4. ssh_exec_parallel(aliases=["server1", "server2", "server3"], command="uptime")
189
+ ```
190
+
131
191
  ### Basic: Connect and Execute
132
192
 
133
193
  ```
134
- 1. ssh_connect(host="192.168.1.100", user="root", password="xxx", alias="myserver")
135
- 2. ssh_exec(alias="myserver", command="ls -la /home")
136
- 3. ssh_disconnect(alias="myserver")
194
+ ssh_connect(host="192.168.1.100", user="root", keyPath="/home/.ssh/id_rsa", alias="myserver")
195
+ ssh_exec(alias="myserver", command="ls -la /home")
196
+ ssh_disconnect(alias="myserver")
197
+ ```
198
+
199
+ ### Jump Host (Bastion)
200
+
201
+ Connect to internal server via jump host:
202
+
203
+ ```
204
+ ssh_connect(
205
+ host="10.0.0.5",
206
+ user="root",
207
+ keyPath="/home/.ssh/id_rsa",
208
+ alias="internal",
209
+ jumpHost={
210
+ host: "bastion.example.com",
211
+ user: "admin",
212
+ keyPath: "/home/.ssh/bastion_key"
213
+ }
214
+ )
137
215
  ```
138
216
 
139
217
  ### Switch User Execution (su)
@@ -331,14 +409,6 @@ mcp-ssh/
331
409
  └── README.md
332
410
  ```
333
411
 
334
- ## Roadmap
335
-
336
- - [ ] Dynamic port forwarding (SOCKS proxy)
337
- - [ ] SSH Agent forwarding
338
- - [ ] Command history and audit logging
339
- - [ ] Multi-host parallel execution
340
- - [ ] SSH config file (~/.ssh/config) auto-discovery
341
-
342
412
  ## Contributing
343
413
 
344
414
  Contributions are welcome! Please feel free to submit a Pull Request.
package/README_zh.md CHANGED
@@ -8,9 +8,14 @@
8
8
  [![Node](https://img.shields.io/badge/node-%3E%3D18-green.svg)](https://nodejs.org/)
9
9
  [![MCP](https://img.shields.io/badge/MCP-compatible-purple.svg)](https://modelcontextprotocol.io/)
10
10
 
11
+ ![Linux](https://img.shields.io/badge/Linux-tested-success)
12
+ ![macOS](https://img.shields.io/badge/macOS-untested-yellow)
13
+ ![Windows](https://img.shields.io/badge/Windows-untested-yellow)
14
+
11
15
  ## 功能特性
12
16
 
13
17
  - **多种认证方式**:密码、SSH 密钥、SSH Agent
18
+ - **SSH Config 支持**:读取 `~/.ssh/config`,支持 Host 多别名、`Host *` 继承、ProxyJump(`user@host:port` 格式)
14
19
  - **连接管理**:连接池复用、心跳保持、自动重连
15
20
  - **会话持久化**:会话信息保存,支持重连
16
21
  - **命令执行**:
@@ -19,6 +24,7 @@
19
24
  - `sudo` 执行
20
25
  - `su` 切换用户执行
21
26
  - 批量执行
27
+ - 多主机并行执行
22
28
  - **持久化 PTY 会话**:用于长时间运行的交互式命令(top、htop、tmux、vim 等)
23
29
  - 输出缓冲区,支持轮询读取
24
30
  - 发送按键和命令
@@ -42,9 +48,17 @@
42
48
 
43
49
  ## 安装
44
50
 
51
+ ### npm(推荐)
52
+
53
+ ```bash
54
+ npm install -g @pyrokine/mcp-ssh
55
+ ```
56
+
57
+ ### 从源码安装
58
+
45
59
  ```bash
46
60
  git clone https://github.com/Pyrokine/claude-mcp-tools.git
47
- cd claude-mcp-tools/ssh
61
+ cd claude-mcp-tools/mcp-ssh
48
62
  npm install
49
63
  npm run build
50
64
  ```
@@ -72,16 +86,17 @@ claude mcp add ssh -- node /path/to/mcp-ssh/dist/index.js
72
86
  }
73
87
  ```
74
88
 
75
- ## 可用工具(27 个)
89
+ ## 可用工具(29 个)
76
90
 
77
91
  ### 连接管理
78
92
 
79
93
  | 工具 | 描述 |
80
94
  |------|------|
81
- | `ssh_connect` | 建立 SSH 连接并保持心跳 |
95
+ | `ssh_connect` | 建立 SSH 连接(支持 ~/.ssh/config) |
82
96
  | `ssh_disconnect` | 关闭连接 |
83
97
  | `ssh_list_sessions` | 列出活跃会话 |
84
98
  | `ssh_reconnect` | 重新连接断开的会话 |
99
+ | `ssh_config_list` | 列出 ~/.ssh/config 中的 Host |
85
100
 
86
101
  ### 命令执行
87
102
 
@@ -91,6 +106,7 @@ claude mcp add ssh -- node /path/to/mcp-ssh/dist/index.js
91
106
  | `ssh_exec_as_user` | 以其他用户身份执行(通过 `su`) |
92
107
  | `ssh_exec_sudo` | 使用 `sudo` 执行 |
93
108
  | `ssh_exec_batch` | 批量执行多条命令 |
109
+ | `ssh_exec_parallel` | 在多台主机上并行执行命令 |
94
110
  | `ssh_quick_exec` | 一次性执行:连接、执行、断开 |
95
111
 
96
112
  ### 文件操作
@@ -128,12 +144,74 @@ claude mcp add ssh -- node /path/to/mcp-ssh/dist/index.js
128
144
 
129
145
  ## 使用示例
130
146
 
147
+ ### 使用 SSH Config(推荐)
148
+
149
+ 如果已在 `~/.ssh/config` 中配置了主机:
150
+
151
+ ```
152
+ # 列出可用主机
153
+ ssh_config_list()
154
+
155
+ # 使用配置的主机名连接
156
+ ssh_connect(configHost="myserver")
157
+ ssh_exec(alias="myserver", command="uptime")
158
+
159
+ # 使用自定义配置文件路径
160
+ ssh_connect(configHost="myserver", configPath="/custom/path/config")
161
+ ```
162
+
163
+ 支持的 SSH config 特性:
164
+ - `Host` 多别名(如 `Host a b c`)
165
+ - `Host *` 全局默认继承(仅第一个 `Host *` 块)
166
+ - `HostName`、`User`、`Port`、`IdentityFile`
167
+ - `ProxyJump`,支持 `user@host:port` 格式(仅第一跳)
168
+ - 显式参数优先于 config 值(如 `ssh_connect(configHost="x", user="覆盖值")`)
169
+
170
+ **不支持**(跳过):
171
+ - `Include` 指令
172
+ - `Match` 块(整个块跳过直到下一个 `Host`)
173
+ - 通配符模式(如 `Host *.example.com`)
174
+
175
+ **行为说明**:
176
+ - 多个 `Host *` 块:仅使用第一个
177
+ - 重复的 Host 定义:`ssh_config_list` 显示全部,`ssh_connect` 使用第一个
178
+ - ProxyJump 中的 IPv6:使用方括号格式 `[2001:db8::1]:22`
179
+
180
+ ### 多主机并行执行
181
+
182
+ 在多个已连接的主机上并行执行同一命令:
183
+
184
+ ```
185
+ 1. ssh_connect(configHost="server1")
186
+ 2. ssh_connect(configHost="server2")
187
+ 3. ssh_connect(configHost="server3")
188
+ 4. ssh_exec_parallel(aliases=["server1", "server2", "server3"], command="uptime")
189
+ ```
190
+
131
191
  ### 基础:连接和执行
132
192
 
133
193
  ```
134
- 1. ssh_connect(host="192.168.1.100", user="root", password="xxx", alias="myserver")
135
- 2. ssh_exec(alias="myserver", command="ls -la /home")
136
- 3. ssh_disconnect(alias="myserver")
194
+ ssh_connect(host="192.168.1.100", user="root", keyPath="/home/.ssh/id_rsa", alias="myserver")
195
+ ssh_exec(alias="myserver", command="ls -la /home")
196
+ ssh_disconnect(alias="myserver")
197
+ ```
198
+
199
+ ### 跳板机
200
+
201
+ 通过跳板机连接内网服务器:
202
+
203
+ ```
204
+ ssh_connect(
205
+ host="10.0.0.5",
206
+ user="root",
207
+ keyPath="/home/.ssh/id_rsa",
208
+ alias="internal",
209
+ jumpHost={
210
+ host: "bastion.example.com",
211
+ user: "admin",
212
+ keyPath: "/home/.ssh/bastion_key"
213
+ }
214
+ )
137
215
  ```
138
216
 
139
217
  ### 切换用户执行(su)
@@ -331,14 +409,6 @@ mcp-ssh/
331
409
  └── README.md
332
410
  ```
333
411
 
334
- ## 路线图
335
-
336
- - [ ] 动态端口转发(SOCKS 代理)
337
- - [ ] SSH Agent 转发
338
- - [ ] 命令历史和审计日志
339
- - [ ] 多主机并行执行
340
- - [ ] SSH 配置文件(~/.ssh/config)自动发现
341
-
342
412
  ## 贡献
343
413
 
344
414
  欢迎贡献!请随时提交 Pull Request。
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
18
18
  import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
19
19
  import { sessionManager } from './session-manager.js';
20
20
  import * as fileOps from './file-ops.js';
21
+ import { parseSSHConfig, parseProxyJump } from './ssh-config.js';
21
22
  // 创建 MCP Server
22
23
  const server = new Server({
23
24
  name: 'ssh-mcp-pro',
@@ -34,28 +35,43 @@ const tools = [
34
35
  name: 'ssh_connect',
35
36
  description: `建立 SSH 连接并保持会话。支持密码、密钥认证,支持跳板机。
36
37
 
38
+ 可通过 configHost 参数使用 ~/.ssh/config 中的配置,无需重复填写连接信息。
39
+ 支持 Host 多别名、Host * 全局默认继承、ProxyJump(user@host:port 格式)。
40
+
37
41
  示例:
38
- - 密码认证: ssh_connect(host="192.168.1.1", user="root", password="xxx")
42
+ - 使用 ssh config: ssh_connect(configHost="myserver")
39
43
  - 密钥认证: ssh_connect(host="192.168.1.1", user="root", keyPath="/home/.ssh/id_rsa")
40
- - 自定义别名: ssh_connect(..., alias="myserver")
41
- - 设置环境变量: ssh_connect(..., env={"LANG": "en_US.UTF-8"})`,
44
+ - 跳板机: ssh_connect(host="内网IP", user="root", keyPath="...", jumpHost={host:"跳板机IP", user:"root", keyPath:"..."})`,
42
45
  inputSchema: {
43
46
  type: 'object',
44
47
  properties: {
45
- host: { type: 'string', description: '服务器地址' },
46
- user: { type: 'string', description: '用户名' },
47
- password: { type: 'string', description: '密码(与 keyPath 二选一)' },
48
+ configHost: { type: 'string', description: '使用 ~/.ssh/config 中的 Host 配置(推荐)' },
49
+ configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
50
+ host: { type: 'string', description: '服务器地址(使用 configHost 时可省略)' },
51
+ user: { type: 'string', description: '用户名(使用 configHost 时可省略)' },
52
+ password: { type: 'string', description: '密码' },
48
53
  keyPath: { type: 'string', description: 'SSH 私钥路径' },
49
- port: { type: 'number', description: 'SSH 端口,默认 22', default: 22 },
50
- alias: { type: 'string', description: '连接别名(可选,用于后续引用)' },
54
+ port: { type: 'number', description: 'SSH 端口,默认 22' },
55
+ alias: { type: 'string', description: '连接别名(可选,默认使用 configHost 或 host)' },
51
56
  env: {
52
57
  type: 'object',
53
- description: '环境变量,如 {"LANG": "en_US.UTF-8"}',
58
+ description: '环境变量',
54
59
  additionalProperties: { type: 'string' },
55
60
  },
56
61
  keepaliveInterval: { type: 'number', description: '心跳间隔(毫秒),默认 30000' },
62
+ jumpHost: {
63
+ type: 'object',
64
+ description: '跳板机配置',
65
+ properties: {
66
+ host: { type: 'string', description: '跳板机地址' },
67
+ user: { type: 'string', description: '跳板机用户名' },
68
+ password: { type: 'string', description: '跳板机密码' },
69
+ keyPath: { type: 'string', description: '跳板机私钥路径' },
70
+ port: { type: 'number', description: '跳板机端口,默认 22' },
71
+ },
72
+ required: ['host', 'user'],
73
+ },
57
74
  },
58
- required: ['host', 'user'],
59
75
  },
60
76
  },
61
77
  {
@@ -480,6 +496,42 @@ rsync 可实现增量传输,对大目录同步效率更高。
480
496
  properties: {},
481
497
  },
482
498
  },
499
+ // ========== SSH Config ==========
500
+ {
501
+ name: 'ssh_config_list',
502
+ description: `列出 ~/.ssh/config 中配置的所有 Host。
503
+
504
+ 返回每个 Host 的配置信息(别名、地址、用户、端口、密钥路径等)。`,
505
+ inputSchema: {
506
+ type: 'object',
507
+ properties: {
508
+ configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
509
+ },
510
+ },
511
+ },
512
+ // ========== 批量执行 ==========
513
+ {
514
+ name: 'ssh_exec_parallel',
515
+ description: `在多个已连接的会话上并行执行同一命令。
516
+
517
+ 示例:
518
+ - ssh_exec_parallel(aliases=["server1", "server2"], command="uptime")
519
+
520
+ 返回每个主机的执行结果。`,
521
+ inputSchema: {
522
+ type: 'object',
523
+ properties: {
524
+ aliases: {
525
+ type: 'array',
526
+ items: { type: 'string' },
527
+ description: '连接别名列表',
528
+ },
529
+ command: { type: 'string', description: '要执行的命令' },
530
+ timeout: { type: 'number', description: '每个命令的超时(毫秒),默认 30000' },
531
+ },
532
+ required: ['aliases', 'command'],
533
+ },
534
+ },
483
535
  ];
484
536
  // 注册工具列表
485
537
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -493,20 +545,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
493
545
  switch (name) {
494
546
  // ========== 连接管理 ==========
495
547
  case 'ssh_connect': {
548
+ // 解析 configHost
549
+ let host = args.host;
550
+ let user = args.user;
551
+ let port = args.port;
552
+ let keyPath = args.keyPath;
553
+ const configPath = args.configPath;
554
+ let jumpHostResolved;
555
+ if (args.configHost) {
556
+ const allHosts = parseSSHConfig(configPath);
557
+ const hostConfig = allHosts.find(h => h.host === args.configHost);
558
+ if (!hostConfig) {
559
+ throw new Error(`Host '${args.configHost}' not found in SSH config`);
560
+ }
561
+ // 显式参数优先于 config 值
562
+ host = host || hostConfig.hostName || hostConfig.host;
563
+ user = user || hostConfig.user;
564
+ port = port || hostConfig.port;
565
+ keyPath = keyPath || hostConfig.identityFile;
566
+ // 解析 ProxyJump(支持 user@host:port 格式)
567
+ if (hostConfig.proxyJump) {
568
+ const parsed = parseProxyJump(hostConfig.proxyJump);
569
+ if (parsed) {
570
+ // 先尝试在 config 中查找对应的 Host
571
+ const jumpHostConfig = allHosts.find(h => h.host === parsed.host);
572
+ if (jumpHostConfig) {
573
+ // 使用 config 中的配置,但 parsed 的 user/port 优先
574
+ jumpHostResolved = {
575
+ host: jumpHostConfig.hostName || jumpHostConfig.host,
576
+ port: parsed.port || jumpHostConfig.port || 22,
577
+ username: parsed.user || jumpHostConfig.user || 'root',
578
+ privateKeyPath: jumpHostConfig.identityFile,
579
+ };
580
+ }
581
+ else {
582
+ // 直接使用 parsed 的值
583
+ jumpHostResolved = {
584
+ host: parsed.host,
585
+ port: parsed.port || 22,
586
+ username: parsed.user || 'root',
587
+ };
588
+ }
589
+ }
590
+ }
591
+ }
592
+ if (!host || !user) {
593
+ throw new Error('host and user are required (either directly or via configHost)');
594
+ }
595
+ // 手动指定的 jumpHost 优先级高于 ProxyJump
596
+ const jumpHostArg = args.jumpHost;
597
+ const jumpHost = jumpHostArg ? {
598
+ host: jumpHostArg.host,
599
+ port: jumpHostArg.port || 22,
600
+ username: jumpHostArg.user,
601
+ password: jumpHostArg.password,
602
+ privateKeyPath: jumpHostArg.keyPath,
603
+ } : jumpHostResolved;
496
604
  const alias = await sessionManager.connect({
497
- host: args.host,
498
- port: args.port || 22,
499
- username: args.user,
605
+ host,
606
+ port: port || 22,
607
+ username: user,
500
608
  password: args.password,
501
- privateKeyPath: args.keyPath,
502
- alias: args.alias,
609
+ privateKeyPath: keyPath,
610
+ alias: args.alias || args.configHost,
503
611
  env: args.env,
504
612
  keepaliveInterval: args.keepaliveInterval,
613
+ jumpHost,
505
614
  });
506
615
  result = {
507
616
  success: true,
508
617
  alias,
509
- message: `Connected to ${args.user}@${args.host}:${args.port || 22}`,
618
+ message: `Connected to ${user}@${host}:${port || 22}${jumpHost ? ' via jump host' : ''}`,
510
619
  };
511
620
  break;
512
621
  }
@@ -765,6 +874,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
765
874
  };
766
875
  break;
767
876
  }
877
+ // ========== SSH Config ==========
878
+ case 'ssh_config_list': {
879
+ const hosts = parseSSHConfig(args.configPath);
880
+ result = {
881
+ success: true,
882
+ count: hosts.length,
883
+ hosts: hosts.map(h => ({
884
+ host: h.host,
885
+ hostName: h.hostName,
886
+ user: h.user,
887
+ port: h.port,
888
+ identityFile: h.identityFile,
889
+ proxyJump: h.proxyJump,
890
+ })),
891
+ };
892
+ break;
893
+ }
894
+ // ========== 批量执行 ==========
895
+ case 'ssh_exec_parallel': {
896
+ const aliases = args.aliases;
897
+ const command = args.command;
898
+ const timeout = args.timeout;
899
+ const execPromises = aliases.map(async (alias) => {
900
+ try {
901
+ const execResult = await sessionManager.exec(alias, command, { timeout });
902
+ return {
903
+ alias,
904
+ success: execResult.success,
905
+ exitCode: execResult.exitCode,
906
+ stdout: execResult.stdout,
907
+ stderr: execResult.stderr,
908
+ duration: execResult.duration,
909
+ };
910
+ }
911
+ catch (err) {
912
+ return {
913
+ alias,
914
+ success: false,
915
+ error: err.message,
916
+ };
917
+ }
918
+ });
919
+ const results = await Promise.all(execPromises);
920
+ result = {
921
+ success: results.every(r => r.success),
922
+ total: aliases.length,
923
+ results,
924
+ };
925
+ break;
926
+ }
768
927
  default:
769
928
  throw new Error(`Unknown tool: ${name}`);
770
929
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * SSH Config Parser
3
+ *
4
+ * 解析 ~/.ssh/config 文件,提取 Host 配置
5
+ * 支持:
6
+ * - Host 多别名(Host a b c)
7
+ * - Host * 全局默认配置继承
8
+ * - ProxyJump 解析(支持 user@host:port 格式)
9
+ */
10
+ export interface SSHConfigHost {
11
+ host: string;
12
+ hostName?: string;
13
+ user?: string;
14
+ port?: number;
15
+ identityFile?: string;
16
+ proxyJump?: string;
17
+ }
18
+ /** ProxyJump 解析结果 */
19
+ export interface ParsedProxyJump {
20
+ user?: string;
21
+ host: string;
22
+ port?: number;
23
+ }
24
+ /**
25
+ * 解析 ProxyJump 字符串
26
+ * 支持格式:host, user@host, host:port, user@host:port, [ipv6]:port
27
+ * 注意:只解析第一跳,不支持逗号分隔的多跳链路
28
+ */
29
+ export declare function parseProxyJump(proxyJump: string): ParsedProxyJump | null;
30
+ /**
31
+ * 解析 SSH config 文件
32
+ * 支持 Host 多别名和 Host * 继承
33
+ * 跳过 Match 块(避免条件配置被误应用)
34
+ */
35
+ export declare function parseSSHConfig(configPath?: string): SSHConfigHost[];
36
+ /**
37
+ * 根据 Host 名称获取配置
38
+ */
39
+ export declare function getHostConfig(hostName: string, configPath?: string): SSHConfigHost | null;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * SSH Config Parser
3
+ *
4
+ * 解析 ~/.ssh/config 文件,提取 Host 配置
5
+ * 支持:
6
+ * - Host 多别名(Host a b c)
7
+ * - Host * 全局默认配置继承
8
+ * - ProxyJump 解析(支持 user@host:port 格式)
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import * as os from 'os';
13
+ /**
14
+ * 解析 host:port 或 [ipv6]:port
15
+ * 返回 { host, port },host 不含方括号
16
+ */
17
+ function parseHostPort(s) {
18
+ // IPv6 方括号格式: [addr]:port 或 [addr]
19
+ if (s.startsWith('[')) {
20
+ const closeBracket = s.indexOf(']');
21
+ if (closeBracket !== -1) {
22
+ const host = s.slice(1, closeBracket); // 去掉方括号
23
+ const rest = s.slice(closeBracket + 1);
24
+ if (rest.startsWith(':')) {
25
+ const parsedPort = parseInt(rest.slice(1), 10);
26
+ if (!isNaN(parsedPort)) {
27
+ return { host, port: parsedPort };
28
+ }
29
+ }
30
+ return { host };
31
+ }
32
+ }
33
+ // 检测裸 IPv6(多个冒号但无方括号):安全失败,当作 host-only
34
+ const colonCount = (s.match(/:/g) || []).length;
35
+ if (colonCount >= 2) {
36
+ return { host: s };
37
+ }
38
+ // 普通格式: host:port 或 host
39
+ const colonIndex = s.lastIndexOf(':');
40
+ if (colonIndex !== -1) {
41
+ const host = s.slice(0, colonIndex);
42
+ const portStr = s.slice(colonIndex + 1);
43
+ const parsedPort = parseInt(portStr, 10);
44
+ if (!isNaN(parsedPort)) {
45
+ return { host, port: parsedPort };
46
+ }
47
+ }
48
+ return { host: s };
49
+ }
50
+ /**
51
+ * 解析 ProxyJump 字符串
52
+ * 支持格式:host, user@host, host:port, user@host:port, [ipv6]:port
53
+ * 注意:只解析第一跳,不支持逗号分隔的多跳链路
54
+ */
55
+ export function parseProxyJump(proxyJump) {
56
+ if (!proxyJump) {
57
+ return null;
58
+ }
59
+ // 取第一跳(如果有逗号分隔)
60
+ const firstJump = proxyJump.split(',')[0].trim();
61
+ if (!firstJump) {
62
+ return null;
63
+ }
64
+ let user;
65
+ // 解析 user@... 格式
66
+ const atIndex = firstJump.indexOf('@');
67
+ if (atIndex !== -1) {
68
+ user = firstJump.slice(0, atIndex);
69
+ const rest = firstJump.slice(atIndex + 1);
70
+ const { host, port } = parseHostPort(rest);
71
+ return { user, host, port };
72
+ }
73
+ const { host, port } = parseHostPort(firstJump);
74
+ return { user, host, port };
75
+ }
76
+ /**
77
+ * 展开 ~ 路径
78
+ */
79
+ function expandTilde(filePath) {
80
+ if (filePath.startsWith('~')) {
81
+ return path.join(os.homedir(), filePath.slice(1));
82
+ }
83
+ return filePath;
84
+ }
85
+ /**
86
+ * 剥离行尾注释
87
+ * "value # comment" -> "value"
88
+ */
89
+ function stripInlineComment(value) {
90
+ // 查找不在引号内的 #
91
+ let inQuote = false;
92
+ let quoteChar = '';
93
+ for (let i = 0; i < value.length; i++) {
94
+ const ch = value[i];
95
+ if (!inQuote && (ch === '"' || ch === "'")) {
96
+ inQuote = true;
97
+ quoteChar = ch;
98
+ }
99
+ else if (inQuote && ch === quoteChar) {
100
+ inQuote = false;
101
+ }
102
+ else if (!inQuote && ch === '#') {
103
+ return value.slice(0, i).trim();
104
+ }
105
+ }
106
+ return value.trim();
107
+ }
108
+ /**
109
+ * 解析 SSH config 文件
110
+ * 支持 Host 多别名和 Host * 继承
111
+ * 跳过 Match 块(避免条件配置被误应用)
112
+ */
113
+ export function parseSSHConfig(configPath) {
114
+ const filePath = configPath || path.join(os.homedir(), '.ssh', 'config');
115
+ if (!fs.existsSync(filePath)) {
116
+ return [];
117
+ }
118
+ const content = fs.readFileSync(filePath, 'utf-8');
119
+ const lines = content.split('\n');
120
+ // 第一遍:收集所有配置块
121
+ const blocks = [];
122
+ let currentBlock = null;
123
+ let globalDefaults = {};
124
+ let inMatchBlock = false; // 跳过 Match 块
125
+ for (const line of lines) {
126
+ const trimmed = line.trim();
127
+ // 跳过空行和注释
128
+ if (!trimmed || trimmed.startsWith('#')) {
129
+ continue;
130
+ }
131
+ // 解析 key value(支持 = 和空格分隔)
132
+ const match = trimmed.match(/^(\S+)\s*[=\s]\s*(.+)$/);
133
+ if (!match) {
134
+ continue;
135
+ }
136
+ const [, key, rawValue] = match;
137
+ const keyLower = key.toLowerCase();
138
+ const value = stripInlineComment(rawValue);
139
+ if (keyLower === 'host') {
140
+ // Host 块开始,结束 Match 块
141
+ inMatchBlock = false;
142
+ // 保存上一个 block
143
+ if (currentBlock) {
144
+ blocks.push(currentBlock);
145
+ }
146
+ // Host 行可能有多个别名/模式,用空格分隔
147
+ const patterns = value.split(/\s+/).filter(p => p.length > 0);
148
+ currentBlock = { patterns, config: {} };
149
+ }
150
+ else if (keyLower === 'match') {
151
+ // Match 块开始,跳过直到下一个 Host
152
+ inMatchBlock = true;
153
+ // 保存当前 block(如果有)
154
+ if (currentBlock) {
155
+ blocks.push(currentBlock);
156
+ currentBlock = null;
157
+ }
158
+ }
159
+ else if (!inMatchBlock && currentBlock) {
160
+ // 解析配置项(不在 Match 块内)
161
+ switch (keyLower) {
162
+ case 'hostname':
163
+ currentBlock.config.hostName = value;
164
+ break;
165
+ case 'user':
166
+ currentBlock.config.user = value;
167
+ break;
168
+ case 'port':
169
+ currentBlock.config.port = parseInt(value, 10);
170
+ break;
171
+ case 'identityfile':
172
+ currentBlock.config.identityFile = expandTilde(value);
173
+ break;
174
+ case 'proxyjump':
175
+ currentBlock.config.proxyJump = value;
176
+ break;
177
+ }
178
+ }
179
+ }
180
+ // 保存最后一个 block
181
+ if (currentBlock && !inMatchBlock) {
182
+ blocks.push(currentBlock);
183
+ }
184
+ // 第二遍:提取 Host * 的全局默认配置
185
+ for (const block of blocks) {
186
+ if (block.patterns.length === 1 && block.patterns[0] === '*') {
187
+ globalDefaults = { ...block.config };
188
+ break;
189
+ }
190
+ }
191
+ // 第三遍:展开所有 Host,应用继承
192
+ const hosts = [];
193
+ for (const block of blocks) {
194
+ for (const pattern of block.patterns) {
195
+ // 跳过通配符模式(*, *.example.com 等)
196
+ if (pattern.includes('*') || pattern.includes('?')) {
197
+ continue;
198
+ }
199
+ // 合并配置:全局默认 + 当前块配置
200
+ const merged = {
201
+ host: pattern,
202
+ ...globalDefaults,
203
+ ...block.config,
204
+ };
205
+ hosts.push(merged);
206
+ }
207
+ }
208
+ return hosts;
209
+ }
210
+ /**
211
+ * 根据 Host 名称获取配置
212
+ */
213
+ export function getHostConfig(hostName, configPath) {
214
+ const hosts = parseSSHConfig(configPath);
215
+ return hosts.find(h => h.host === hostName) || null;
216
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyrokine/mcp-ssh",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A comprehensive SSH MCP Server for Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -25,7 +25,7 @@
25
25
  "license": "MIT",
26
26
  "repository": {
27
27
  "type": "git",
28
- "url": "https://github.com/user/ssh-mcp-pro"
28
+ "url": "https://github.com/Pyrokine/claude-mcp-tools.git"
29
29
  },
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "^1.25.3",
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  import { sessionManager } from './session-manager.js';
26
26
  import * as fileOps from './file-ops.js';
27
27
  import { ExecOptions, PtyOptions } from './types.js';
28
+ import { parseSSHConfig, getHostConfig, parseProxyJump, SSHConfigHost } from './ssh-config.js';
28
29
 
29
30
  // 创建 MCP Server
30
31
  const server = new Server(
@@ -46,28 +47,43 @@ const tools: Tool[] = [
46
47
  name: 'ssh_connect',
47
48
  description: `建立 SSH 连接并保持会话。支持密码、密钥认证,支持跳板机。
48
49
 
50
+ 可通过 configHost 参数使用 ~/.ssh/config 中的配置,无需重复填写连接信息。
51
+ 支持 Host 多别名、Host * 全局默认继承、ProxyJump(user@host:port 格式)。
52
+
49
53
  示例:
50
- - 密码认证: ssh_connect(host="192.168.1.1", user="root", password="xxx")
54
+ - 使用 ssh config: ssh_connect(configHost="myserver")
51
55
  - 密钥认证: ssh_connect(host="192.168.1.1", user="root", keyPath="/home/.ssh/id_rsa")
52
- - 自定义别名: ssh_connect(..., alias="myserver")
53
- - 设置环境变量: ssh_connect(..., env={"LANG": "en_US.UTF-8"})`,
56
+ - 跳板机: ssh_connect(host="内网IP", user="root", keyPath="...", jumpHost={host:"跳板机IP", user:"root", keyPath:"..."})`,
54
57
  inputSchema: {
55
58
  type: 'object',
56
59
  properties: {
57
- host: { type: 'string', description: '服务器地址' },
58
- user: { type: 'string', description: '用户名' },
59
- password: { type: 'string', description: '密码(与 keyPath 二选一)' },
60
+ configHost: { type: 'string', description: '使用 ~/.ssh/config 中的 Host 配置(推荐)' },
61
+ configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
62
+ host: { type: 'string', description: '服务器地址(使用 configHost 时可省略)' },
63
+ user: { type: 'string', description: '用户名(使用 configHost 时可省略)' },
64
+ password: { type: 'string', description: '密码' },
60
65
  keyPath: { type: 'string', description: 'SSH 私钥路径' },
61
- port: { type: 'number', description: 'SSH 端口,默认 22', default: 22 },
62
- alias: { type: 'string', description: '连接别名(可选,用于后续引用)' },
66
+ port: { type: 'number', description: 'SSH 端口,默认 22' },
67
+ alias: { type: 'string', description: '连接别名(可选,默认使用 configHost 或 host)' },
63
68
  env: {
64
69
  type: 'object',
65
- description: '环境变量,如 {"LANG": "en_US.UTF-8"}',
70
+ description: '环境变量',
66
71
  additionalProperties: { type: 'string' },
67
72
  },
68
73
  keepaliveInterval: { type: 'number', description: '心跳间隔(毫秒),默认 30000' },
74
+ jumpHost: {
75
+ type: 'object',
76
+ description: '跳板机配置',
77
+ properties: {
78
+ host: { type: 'string', description: '跳板机地址' },
79
+ user: { type: 'string', description: '跳板机用户名' },
80
+ password: { type: 'string', description: '跳板机密码' },
81
+ keyPath: { type: 'string', description: '跳板机私钥路径' },
82
+ port: { type: 'number', description: '跳板机端口,默认 22' },
83
+ },
84
+ required: ['host', 'user'],
85
+ },
69
86
  },
70
- required: ['host', 'user'],
71
87
  },
72
88
  },
73
89
  {
@@ -496,6 +512,44 @@ rsync 可实现增量传输,对大目录同步效率更高。
496
512
  properties: {},
497
513
  },
498
514
  },
515
+
516
+ // ========== SSH Config ==========
517
+ {
518
+ name: 'ssh_config_list',
519
+ description: `列出 ~/.ssh/config 中配置的所有 Host。
520
+
521
+ 返回每个 Host 的配置信息(别名、地址、用户、端口、密钥路径等)。`,
522
+ inputSchema: {
523
+ type: 'object',
524
+ properties: {
525
+ configPath: { type: 'string', description: 'SSH 配置文件路径(默认 ~/.ssh/config)' },
526
+ },
527
+ },
528
+ },
529
+
530
+ // ========== 批量执行 ==========
531
+ {
532
+ name: 'ssh_exec_parallel',
533
+ description: `在多个已连接的会话上并行执行同一命令。
534
+
535
+ 示例:
536
+ - ssh_exec_parallel(aliases=["server1", "server2"], command="uptime")
537
+
538
+ 返回每个主机的执行结果。`,
539
+ inputSchema: {
540
+ type: 'object',
541
+ properties: {
542
+ aliases: {
543
+ type: 'array',
544
+ items: { type: 'string' },
545
+ description: '连接别名列表',
546
+ },
547
+ command: { type: 'string', description: '要执行的命令' },
548
+ timeout: { type: 'number', description: '每个命令的超时(毫秒),默认 30000' },
549
+ },
550
+ required: ['aliases', 'command'],
551
+ },
552
+ },
499
553
  ];
500
554
 
501
555
  // 注册工具列表
@@ -513,20 +567,81 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
513
567
  switch (name) {
514
568
  // ========== 连接管理 ==========
515
569
  case 'ssh_connect': {
570
+ // 解析 configHost
571
+ let host = args.host as string | undefined;
572
+ let user = args.user as string | undefined;
573
+ let port = args.port as number | undefined;
574
+ let keyPath = args.keyPath as string | undefined;
575
+ const configPath = args.configPath as string | undefined;
576
+ let jumpHostResolved: { host: string; port: number; username: string; password?: string; privateKeyPath?: string } | undefined;
577
+
578
+ if (args.configHost) {
579
+ const allHosts = parseSSHConfig(configPath);
580
+ const hostConfig = allHosts.find(h => h.host === args.configHost);
581
+ if (!hostConfig) {
582
+ throw new Error(`Host '${args.configHost}' not found in SSH config`);
583
+ }
584
+ // 显式参数优先于 config 值
585
+ host = host || hostConfig.hostName || hostConfig.host;
586
+ user = user || hostConfig.user;
587
+ port = port || hostConfig.port;
588
+ keyPath = keyPath || hostConfig.identityFile;
589
+
590
+ // 解析 ProxyJump(支持 user@host:port 格式)
591
+ if (hostConfig.proxyJump) {
592
+ const parsed = parseProxyJump(hostConfig.proxyJump);
593
+ if (parsed) {
594
+ // 先尝试在 config 中查找对应的 Host
595
+ const jumpHostConfig = allHosts.find(h => h.host === parsed.host);
596
+ if (jumpHostConfig) {
597
+ // 使用 config 中的配置,但 parsed 的 user/port 优先
598
+ jumpHostResolved = {
599
+ host: jumpHostConfig.hostName || jumpHostConfig.host,
600
+ port: parsed.port || jumpHostConfig.port || 22,
601
+ username: parsed.user || jumpHostConfig.user || 'root',
602
+ privateKeyPath: jumpHostConfig.identityFile,
603
+ };
604
+ } else {
605
+ // 直接使用 parsed 的值
606
+ jumpHostResolved = {
607
+ host: parsed.host,
608
+ port: parsed.port || 22,
609
+ username: parsed.user || 'root',
610
+ };
611
+ }
612
+ }
613
+ }
614
+ }
615
+
616
+ if (!host || !user) {
617
+ throw new Error('host and user are required (either directly or via configHost)');
618
+ }
619
+
620
+ // 手动指定的 jumpHost 优先级高于 ProxyJump
621
+ const jumpHostArg = args.jumpHost as { host: string; user: string; password?: string; keyPath?: string; port?: number } | undefined;
622
+ const jumpHost = jumpHostArg ? {
623
+ host: jumpHostArg.host,
624
+ port: jumpHostArg.port || 22,
625
+ username: jumpHostArg.user,
626
+ password: jumpHostArg.password,
627
+ privateKeyPath: jumpHostArg.keyPath,
628
+ } : jumpHostResolved;
629
+
516
630
  const alias = await sessionManager.connect({
517
- host: args.host as string,
518
- port: (args.port as number) || 22,
519
- username: args.user as string,
631
+ host,
632
+ port: port || 22,
633
+ username: user,
520
634
  password: args.password as string | undefined,
521
- privateKeyPath: args.keyPath as string | undefined,
522
- alias: args.alias as string | undefined,
635
+ privateKeyPath: keyPath,
636
+ alias: (args.alias as string | undefined) || (args.configHost as string | undefined),
523
637
  env: args.env as Record<string, string> | undefined,
524
638
  keepaliveInterval: args.keepaliveInterval as number | undefined,
639
+ jumpHost,
525
640
  });
526
641
  result = {
527
642
  success: true,
528
643
  alias,
529
- message: `Connected to ${args.user}@${args.host}:${args.port || 22}`,
644
+ message: `Connected to ${user}@${host}:${port || 22}${jumpHost ? ' via jump host' : ''}`,
530
645
  };
531
646
  break;
532
647
  }
@@ -893,6 +1008,59 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
893
1008
  break;
894
1009
  }
895
1010
 
1011
+ // ========== SSH Config ==========
1012
+ case 'ssh_config_list': {
1013
+ const hosts = parseSSHConfig(args.configPath as string | undefined);
1014
+ result = {
1015
+ success: true,
1016
+ count: hosts.length,
1017
+ hosts: hosts.map(h => ({
1018
+ host: h.host,
1019
+ hostName: h.hostName,
1020
+ user: h.user,
1021
+ port: h.port,
1022
+ identityFile: h.identityFile,
1023
+ proxyJump: h.proxyJump,
1024
+ })),
1025
+ };
1026
+ break;
1027
+ }
1028
+
1029
+ // ========== 批量执行 ==========
1030
+ case 'ssh_exec_parallel': {
1031
+ const aliases = args.aliases as string[];
1032
+ const command = args.command as string;
1033
+ const timeout = args.timeout as number | undefined;
1034
+
1035
+ const execPromises = aliases.map(async (alias) => {
1036
+ try {
1037
+ const execResult = await sessionManager.exec(alias, command, { timeout });
1038
+ return {
1039
+ alias,
1040
+ success: execResult.success,
1041
+ exitCode: execResult.exitCode,
1042
+ stdout: execResult.stdout,
1043
+ stderr: execResult.stderr,
1044
+ duration: execResult.duration,
1045
+ };
1046
+ } catch (err: any) {
1047
+ return {
1048
+ alias,
1049
+ success: false,
1050
+ error: err.message,
1051
+ };
1052
+ }
1053
+ });
1054
+
1055
+ const results = await Promise.all(execPromises);
1056
+ result = {
1057
+ success: results.every(r => r.success),
1058
+ total: aliases.length,
1059
+ results,
1060
+ };
1061
+ break;
1062
+ }
1063
+
896
1064
  default:
897
1065
  throw new Error(`Unknown tool: ${name}`);
898
1066
  }
@@ -0,0 +1,264 @@
1
+ /**
2
+ * SSH Config Parser
3
+ *
4
+ * 解析 ~/.ssh/config 文件,提取 Host 配置
5
+ * 支持:
6
+ * - Host 多别名(Host a b c)
7
+ * - Host * 全局默认配置继承
8
+ * - ProxyJump 解析(支持 user@host:port 格式)
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import * as os from 'os';
14
+
15
+ export interface SSHConfigHost {
16
+ host: string; // Host 别名
17
+ hostName?: string; // 实际地址
18
+ user?: string; // 用户名
19
+ port?: number; // 端口
20
+ identityFile?: string; // 私钥路径
21
+ proxyJump?: string; // 跳板机(原始字符串,可能是 user@host:port 格式)
22
+ }
23
+
24
+ /** ProxyJump 解析结果 */
25
+ export interface ParsedProxyJump {
26
+ user?: string;
27
+ host: string;
28
+ port?: number;
29
+ }
30
+
31
+ /** 内部使用的配置块 */
32
+ interface ConfigBlock {
33
+ patterns: string[]; // Host 行的所有模式/别名
34
+ config: Omit<SSHConfigHost, 'host'>;
35
+ }
36
+
37
+ /**
38
+ * 解析 host:port 或 [ipv6]:port
39
+ * 返回 { host, port },host 不含方括号
40
+ */
41
+ function parseHostPort(s: string): { host: string; port?: number } {
42
+ // IPv6 方括号格式: [addr]:port 或 [addr]
43
+ if (s.startsWith('[')) {
44
+ const closeBracket = s.indexOf(']');
45
+ if (closeBracket !== -1) {
46
+ const host = s.slice(1, closeBracket); // 去掉方括号
47
+ const rest = s.slice(closeBracket + 1);
48
+ if (rest.startsWith(':')) {
49
+ const parsedPort = parseInt(rest.slice(1), 10);
50
+ if (!isNaN(parsedPort)) {
51
+ return { host, port: parsedPort };
52
+ }
53
+ }
54
+ return { host };
55
+ }
56
+ }
57
+
58
+ // 检测裸 IPv6(多个冒号但无方括号):安全失败,当作 host-only
59
+ const colonCount = (s.match(/:/g) || []).length;
60
+ if (colonCount >= 2) {
61
+ return { host: s };
62
+ }
63
+
64
+ // 普通格式: host:port 或 host
65
+ const colonIndex = s.lastIndexOf(':');
66
+ if (colonIndex !== -1) {
67
+ const host = s.slice(0, colonIndex);
68
+ const portStr = s.slice(colonIndex + 1);
69
+ const parsedPort = parseInt(portStr, 10);
70
+ if (!isNaN(parsedPort)) {
71
+ return { host, port: parsedPort };
72
+ }
73
+ }
74
+ return { host: s };
75
+ }
76
+
77
+ /**
78
+ * 解析 ProxyJump 字符串
79
+ * 支持格式:host, user@host, host:port, user@host:port, [ipv6]:port
80
+ * 注意:只解析第一跳,不支持逗号分隔的多跳链路
81
+ */
82
+ export function parseProxyJump(proxyJump: string): ParsedProxyJump | null {
83
+ if (!proxyJump) {
84
+ return null;
85
+ }
86
+
87
+ // 取第一跳(如果有逗号分隔)
88
+ const firstJump = proxyJump.split(',')[0].trim();
89
+ if (!firstJump) {
90
+ return null;
91
+ }
92
+
93
+ let user: string | undefined;
94
+
95
+ // 解析 user@... 格式
96
+ const atIndex = firstJump.indexOf('@');
97
+ if (atIndex !== -1) {
98
+ user = firstJump.slice(0, atIndex);
99
+ const rest = firstJump.slice(atIndex + 1);
100
+ const { host, port } = parseHostPort(rest);
101
+ return { user, host, port };
102
+ }
103
+
104
+ const { host, port } = parseHostPort(firstJump);
105
+ return { user, host, port };
106
+ }
107
+
108
+ /**
109
+ * 展开 ~ 路径
110
+ */
111
+ function expandTilde(filePath: string): string {
112
+ if (filePath.startsWith('~')) {
113
+ return path.join(os.homedir(), filePath.slice(1));
114
+ }
115
+ return filePath;
116
+ }
117
+
118
+ /**
119
+ * 剥离行尾注释
120
+ * "value # comment" -> "value"
121
+ */
122
+ function stripInlineComment(value: string): string {
123
+ // 查找不在引号内的 #
124
+ let inQuote = false;
125
+ let quoteChar = '';
126
+ for (let i = 0; i < value.length; i++) {
127
+ const ch = value[i];
128
+ if (!inQuote && (ch === '"' || ch === "'")) {
129
+ inQuote = true;
130
+ quoteChar = ch;
131
+ } else if (inQuote && ch === quoteChar) {
132
+ inQuote = false;
133
+ } else if (!inQuote && ch === '#') {
134
+ return value.slice(0, i).trim();
135
+ }
136
+ }
137
+ return value.trim();
138
+ }
139
+
140
+ /**
141
+ * 解析 SSH config 文件
142
+ * 支持 Host 多别名和 Host * 继承
143
+ * 跳过 Match 块(避免条件配置被误应用)
144
+ */
145
+ export function parseSSHConfig(configPath?: string): SSHConfigHost[] {
146
+ const filePath = configPath || path.join(os.homedir(), '.ssh', 'config');
147
+
148
+ if (!fs.existsSync(filePath)) {
149
+ return [];
150
+ }
151
+
152
+ const content = fs.readFileSync(filePath, 'utf-8');
153
+ const lines = content.split('\n');
154
+
155
+ // 第一遍:收集所有配置块
156
+ const blocks: ConfigBlock[] = [];
157
+ let currentBlock: ConfigBlock | null = null;
158
+ let globalDefaults: Omit<SSHConfigHost, 'host'> = {};
159
+ let inMatchBlock = false; // 跳过 Match 块
160
+
161
+ for (const line of lines) {
162
+ const trimmed = line.trim();
163
+
164
+ // 跳过空行和注释
165
+ if (!trimmed || trimmed.startsWith('#')) {
166
+ continue;
167
+ }
168
+
169
+ // 解析 key value(支持 = 和空格分隔)
170
+ const match = trimmed.match(/^(\S+)\s*[=\s]\s*(.+)$/);
171
+ if (!match) {
172
+ continue;
173
+ }
174
+
175
+ const [, key, rawValue] = match;
176
+ const keyLower = key.toLowerCase();
177
+ const value = stripInlineComment(rawValue);
178
+
179
+ if (keyLower === 'host') {
180
+ // Host 块开始,结束 Match 块
181
+ inMatchBlock = false;
182
+
183
+ // 保存上一个 block
184
+ if (currentBlock) {
185
+ blocks.push(currentBlock);
186
+ }
187
+
188
+ // Host 行可能有多个别名/模式,用空格分隔
189
+ const patterns = value.split(/\s+/).filter(p => p.length > 0);
190
+ currentBlock = { patterns, config: {} };
191
+ } else if (keyLower === 'match') {
192
+ // Match 块开始,跳过直到下一个 Host
193
+ inMatchBlock = true;
194
+ // 保存当前 block(如果有)
195
+ if (currentBlock) {
196
+ blocks.push(currentBlock);
197
+ currentBlock = null;
198
+ }
199
+ } else if (!inMatchBlock && currentBlock) {
200
+ // 解析配置项(不在 Match 块内)
201
+ switch (keyLower) {
202
+ case 'hostname':
203
+ currentBlock.config.hostName = value;
204
+ break;
205
+ case 'user':
206
+ currentBlock.config.user = value;
207
+ break;
208
+ case 'port':
209
+ currentBlock.config.port = parseInt(value, 10);
210
+ break;
211
+ case 'identityfile':
212
+ currentBlock.config.identityFile = expandTilde(value);
213
+ break;
214
+ case 'proxyjump':
215
+ currentBlock.config.proxyJump = value;
216
+ break;
217
+ }
218
+ }
219
+ }
220
+
221
+ // 保存最后一个 block
222
+ if (currentBlock && !inMatchBlock) {
223
+ blocks.push(currentBlock);
224
+ }
225
+
226
+ // 第二遍:提取 Host * 的全局默认配置
227
+ for (const block of blocks) {
228
+ if (block.patterns.length === 1 && block.patterns[0] === '*') {
229
+ globalDefaults = { ...block.config };
230
+ break;
231
+ }
232
+ }
233
+
234
+ // 第三遍:展开所有 Host,应用继承
235
+ const hosts: SSHConfigHost[] = [];
236
+
237
+ for (const block of blocks) {
238
+ for (const pattern of block.patterns) {
239
+ // 跳过通配符模式(*, *.example.com 等)
240
+ if (pattern.includes('*') || pattern.includes('?')) {
241
+ continue;
242
+ }
243
+
244
+ // 合并配置:全局默认 + 当前块配置
245
+ const merged: SSHConfigHost = {
246
+ host: pattern,
247
+ ...globalDefaults,
248
+ ...block.config,
249
+ };
250
+
251
+ hosts.push(merged);
252
+ }
253
+ }
254
+
255
+ return hosts;
256
+ }
257
+
258
+ /**
259
+ * 根据 Host 名称获取配置
260
+ */
261
+ export function getHostConfig(hostName: string, configPath?: string): SSHConfigHost | null {
262
+ const hosts = parseSSHConfig(configPath);
263
+ return hosts.find(h => h.host === hostName) || null;
264
+ }