feat: add dynamic per-call SSH connections and secret redaction
- execute-command/upload/download accept host/port/username/password inline to open an ephemeral connection for a single call - add redactSecret/redactSecrets to mask credentials in logs and errors - logger log()/handleError() accept optional secrets[] for redaction - add SSH_CONFIG_MISSING error code and --password-from-env CLI flag - dynamic-mode startup: server runs with no static config
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# 依赖目录
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# 构建输出
|
||||||
|
build/
|
||||||
|
|
||||||
|
# 操作系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 编辑器和IDE文件
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
memo.md
|
||||||
|
|
||||||
|
# claude
|
||||||
|
.claude/
|
||||||
|
.mcp.json
|
||||||
|
docs
|
||||||
|
|
||||||
|
# codex
|
||||||
|
.codex/
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
ISC License
|
||||||
|
|
||||||
|
Copyright (c) 2025 junki.cn
|
||||||
|
|
||||||
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
|
copyright notice and this permission notice appear in all copies.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||||
|
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||||
|
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||||
|
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||||
|
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
@@ -0,0 +1,624 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="images/ssh-mcp-server-logo-v2.png" alt="ssh-mcp-server logo" width="220">
|
||||||
|
|
||||||
|
# ssh-mcp-server
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
基于 SSH 的 MCP (Model Context Protocol) 服务器,允许通过 MCP 协议远程执行 SSH 命令。
|
||||||
|
|
||||||
|
[English Document](README_EN.md) | 中文文档
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📝 项目介绍
|
||||||
|
|
||||||
|
ssh-mcp-server 是一个桥接工具,可以让 AI 助手等支持 MCP 协议的应用通过标准化接口执行远程 SSH 命令。这使得 AI 助手能够安全地操作远程服务器,执行命令并获取结果,而无需直接暴露 SSH 凭据给 AI 模型。
|
||||||
|
|
||||||
|
💬 如有任何问题,欢迎加入微信群交流:
|
||||||
|
|
||||||
|
<img src="images/wechat.jpg" alt="wechat" width="220">
|
||||||
|
|
||||||
|
## ✨ 功能亮点
|
||||||
|
|
||||||
|
- **🔒 安全连接**:支持多种安全的 SSH 连接方式,包括密码认证和私钥认证(支持带密码的私钥)
|
||||||
|
- **🛡️ 命令安全控制**:通过灵活的黑白名单机制,精确控制允许执行的命令范围,防止危险操作
|
||||||
|
- **🔄 标准化接口**:符合 MCP 协议规范,与支持该协议的 AI 助手无缝集成
|
||||||
|
- **🚇 双传输模式**:同时支持 `exec` 和 `shell` 两种 transport,兼容直连主机与堡垒机或跳板机场景
|
||||||
|
- **📂 文件传输**:支持双向文件传输功能,可上传本地文件到服务器或从服务器下载文件
|
||||||
|
- **🔑 凭据隔离**:SSH 凭据完全在本地管理,不会暴露给 AI 模型,增强安全性
|
||||||
|
- **🚀 即用即走**:使用 NPX 可直接运行,无需全局安装,方便快捷
|
||||||
|
|
||||||
|
## 📦 开源仓库
|
||||||
|
|
||||||
|
GitHub:[https://github.com/classfang/ssh-mcp-server](https://github.com/classfang/ssh-mcp-server)
|
||||||
|
|
||||||
|
NPM: [https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server](https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server)
|
||||||
|
|
||||||
|
## 🛠️ 工具列表
|
||||||
|
|
||||||
|
| 工具 | 名称 | 描述 |
|
||||||
|
|---------|-----------|----------|
|
||||||
|
| execute-command | 命令执行工具 | 在远程服务器上执行 SSH 命令并获取执行结果 |
|
||||||
|
| upload | 文件上传工具 | 将本地文件上传到远程服务器指定位置 |
|
||||||
|
| download | 文件下载工具 | 从远程服务器下载文件到本地指定位置 |
|
||||||
|
| list-servers | 服务器列表工具 | 列出所有可用SSH服务器配置 |
|
||||||
|
|
||||||
|
## 📚 使用方法
|
||||||
|
|
||||||
|
### 0. 🤖 通过 AI Skill 快速配置(推荐)
|
||||||
|
|
||||||
|
如果你使用支持 skill 的 AI 编程助手(如 Claude Code),可以直接使用内置的 **ssh-mcp-helper** skill 通过交互式问答完成安装和配置,无需手动编辑 JSON 文件。
|
||||||
|
|
||||||
|
**使用方式:**
|
||||||
|
|
||||||
|
1. 从本仓库 `skills/` 目录安装该 skill
|
||||||
|
2. 告诉你的 AI 助手:"帮我配置 ssh-mcp-server" 或 "给 Cursor 加一个 SSH MCP 连接"
|
||||||
|
3. skill 会逐步引导你:检查 Node.js 环境 → 选择 MCP 客户端 → 选择认证方式 → 收集连接参数 → 生成并写入配置
|
||||||
|
|
||||||
|
该 skill 支持下文所有场景(账号密码、私钥、SSH config 复用、SOCKS 代理、堡垒机、多连接、2FA、命令限制等),并自动生成格式正确的配置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
下面的章节按从简单到复杂的顺序排列,最简单的入门方式就是用账号密码连接服务器。直接复制对应场景下的 `mcp.json` 配置到你的 MCP 客户端即可使用。
|
||||||
|
|
||||||
|
> **⚠️ 重要提示**:在 MCP 配置文件中,每个命令行参数和其值必须是 `args` 数组中的独立元素。不要用空格将它们连接在一起。例如,使用 `"--host", "192.168.1.1"` 而不是 `"--host 192.168.1.1"`。
|
||||||
|
|
||||||
|
### 1. 🔑 账号密码(最简单)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 🔐 账号 + 私钥
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--privateKey", "~/.ssh/id_rsa"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 🔏 带密码的私钥
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--privateKey", "~/.ssh/id_rsa",
|
||||||
|
"--passphrase", "pwd123456"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 📋 复用 `~/.ssh/config`
|
||||||
|
|
||||||
|
如果你已经在 `~/.ssh/config` 配置了主机别名,服务器会自动从中读取连接参数,`mcp.json` 里就不用再写一遍。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "myserver"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
假设你的 `~/.ssh/config` 包含:
|
||||||
|
|
||||||
|
```
|
||||||
|
Host myserver
|
||||||
|
HostName 192.168.1.1
|
||||||
|
Port 22
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/id_rsa
|
||||||
|
```
|
||||||
|
|
||||||
|
你也可以指定自定义的 SSH 配置文件路径:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "myserver",
|
||||||
|
"--ssh-config-file", "/path/to/custom/ssh_config"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:命令行参数优先级高于 SSH 配置值。例如,如果你指定了 `--port 2222`,它会覆盖 SSH 配置中的端口。
|
||||||
|
|
||||||
|
### 5. 🌐 通过代理连接
|
||||||
|
|
||||||
|
当目标主机只能通过代理访问时,可使用 `--proxy` 配置 SOCKS5、HTTP 或 HTTPS 代理。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--proxy", "http://username:password@proxy-host:proxy-port"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
支持的 URL 格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
socks://username:password@proxy-host:1080
|
||||||
|
socks5://username:password@proxy-host:1080
|
||||||
|
http://username:password@proxy-host:8080
|
||||||
|
https://username:password@proxy-host:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 和 HTTPS 代理通过 `CONNECT` 方法建立到 SSH 服务的隧道,用户名和密码使用 Basic 代理认证。HTTP、HTTPS 未填写端口时分别默认使用 `80`、`443`;SOCKS5 必须填写端口。HTTPS 代理证书使用 Node.js 默认信任链进行验证。
|
||||||
|
|
||||||
|
原有 `socksProxy` 配置和 `--socksProxy` 参数继续兼容,但只接受 `socks://` 和 `socks5://`。不要同时配置 `proxy` 和 `socksProxy`。
|
||||||
|
|
||||||
|
### 6. 📝 使用命令白名单 / 黑名单
|
||||||
|
|
||||||
|
通过 `--whitelist` 和 `--blacklist` 限制服务器允许执行的命令范围。多个模式之间用逗号分隔,每个模式都是一个正则表达式。**生产环境强烈建议配置**。
|
||||||
|
|
||||||
|
白名单示例(仅允许只读型查看命令):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--whitelist", "^ls( .*)?,^cat .*,^df.*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
黑名单示例(屏蔽危险命令):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--blacklist", "^rm .*,^shutdown.*,^reboot.*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:如果同时指定了白名单和黑名单,系统会先检查命令是否在白名单中,再检查是否在黑名单中,命令必须同时通过两项检查才能被执行。
|
||||||
|
|
||||||
|
### 7. 🧩 使用命令模板包裹命令
|
||||||
|
|
||||||
|
`commandTemplate` 会把每条执行的命令套进一个模板里,适合切换用户(`su`)、放进容器、或经过跳板机的场景。当命令会作为 shell 参数传入时使用 `<quotedCommand>`,需要原样插入时使用 `<command>`;模板会**在目录 `cd` 拼接之后**应用,因此整个 `cd ... && <实际命令>` 都会被包裹起来。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "10.0.0.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "deploy",
|
||||||
|
"--password", "xxx",
|
||||||
|
"--command-template", "su root -c <quotedCommand>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
当指定目录为 `/data` 执行 `ls /app` 时,实际发送的命令是:
|
||||||
|
|
||||||
|
```
|
||||||
|
su root -c 'cd -- '\''/data'\'' && ls /app'
|
||||||
|
```
|
||||||
|
|
||||||
|
其他常见模板:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sudo bash -c <quotedCommand>
|
||||||
|
docker exec -i mycontainer sh -c <quotedCommand>
|
||||||
|
ssh jumphost <quotedCommand>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 🚇 堡垒机 / 跳板机(`transportMode: shell`)
|
||||||
|
|
||||||
|
`transportMode` 默认是 `exec`。出现下面这些情况时,应该切换到 `shell`:
|
||||||
|
|
||||||
|
- SSH 登录成功,但 `exec` 执行命令失败
|
||||||
|
- 远端必须等登录 banner、profile、环境初始化完成后才能正常执行命令
|
||||||
|
- 连接目标本质上是堡垒机或只暴露交互式 shell 的设备
|
||||||
|
|
||||||
|
两者差异:
|
||||||
|
|
||||||
|
- `exec`:支持 `execute-command`、`upload`、`download`
|
||||||
|
- `shell`:命令通过持久 shell 会话串行执行,内部带命令队列;但**不支持** `upload` / `download`,因为该模式下禁用了 SFTP
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "bastion.example.com",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "ops",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--transport-mode", "shell",
|
||||||
|
"--shell-ready-timeout", "15000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
JSON 配置文件中还可以通过 `shellCommandTimeoutMs` 覆盖 shell 模式下单条命令的默认超时。
|
||||||
|
|
||||||
|
### 9. 🔐 多因素认证(2FA / MFA)
|
||||||
|
|
||||||
|
当 SSH 服务器要求多因素认证(密码 + 私钥 + 2FA 验证码)时启用 `tryKeyboard`。密码和私钥会自动提供;对于非密码提示,请在连接前通过服务端环境变量 `SSH_MCP_2FA_CODE` 提供验证码。
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "example.com",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "user",
|
||||||
|
"--password", "your_password",
|
||||||
|
"--privateKey", "/path/to/key",
|
||||||
|
"--try-keyboard"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**认证流程:**
|
||||||
|
1. 私钥认证(如果提供)
|
||||||
|
2. 密码认证(如果提供)
|
||||||
|
3. 键盘交互式认证通过 `SSH_MCP_2FA_CODE` 提供 2FA 验证码
|
||||||
|
|
||||||
|
### 10. 🧩 多 SSH 连接配置
|
||||||
|
|
||||||
|
需要在同一个 MCP server 里同时管理多个 SSH 目标时,给每个连接命名,调用时通过 `connectionName` 选择。共有三种配置方式:
|
||||||
|
|
||||||
|
#### 📄 方式一:使用配置文件(推荐)
|
||||||
|
|
||||||
|
创建 JSON 配置文件(例如 `ssh-config.json`):
|
||||||
|
|
||||||
|
**数组格式:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"port": 22,
|
||||||
|
"username": "alice",
|
||||||
|
"password": "{abc=P100s0}",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808",
|
||||||
|
"commandTimeoutMs": 120000,
|
||||||
|
"maxOutputBytes": 10485760
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bastion",
|
||||||
|
"host": "9.9.9.9",
|
||||||
|
"port": 22,
|
||||||
|
"username": "ops",
|
||||||
|
"password": "pwd123456",
|
||||||
|
"transportMode": "shell",
|
||||||
|
"shellReadyTimeoutMs": 15000,
|
||||||
|
"shellCommandTimeoutMs": 45000,
|
||||||
|
"connectionTimeoutMs": 30000,
|
||||||
|
"keepaliveIntervalMs": 10000,
|
||||||
|
"keepaliveCountMax": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prod",
|
||||||
|
"host": "5.6.7.8",
|
||||||
|
"port": 22,
|
||||||
|
"username": "bob",
|
||||||
|
"password": "yyy",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "secure-server",
|
||||||
|
"host": "secure.example.com",
|
||||||
|
"port": 22,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "your_password",
|
||||||
|
"privateKey": "/path/to/private/key",
|
||||||
|
"tryKeyboard": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**对象格式:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"port": 22,
|
||||||
|
"username": "alice",
|
||||||
|
"password": "{abc=P100s0}",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808",
|
||||||
|
"commandTimeoutMs": 120000,
|
||||||
|
"maxOutputBytes": 10485760
|
||||||
|
},
|
||||||
|
"bastion": {
|
||||||
|
"host": "9.9.9.9",
|
||||||
|
"port": 22,
|
||||||
|
"username": "ops",
|
||||||
|
"password": "pwd123456",
|
||||||
|
"transportMode": "shell",
|
||||||
|
"shellReadyTimeoutMs": 15000,
|
||||||
|
"shellCommandTimeoutMs": 45000
|
||||||
|
},
|
||||||
|
"prod": {
|
||||||
|
"host": "5.6.7.8",
|
||||||
|
"port": 22,
|
||||||
|
"username": "bob",
|
||||||
|
"password": "yyy",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然后使用 `--config-file` 参数:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--config-file", "ssh-config.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🔧 方式二:使用 JSON 格式的 --ssh 参数
|
||||||
|
|
||||||
|
可以直接传递 JSON 格式的配置字符串:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--ssh", "{\"name\":\"dev\",\"host\":\"1.2.3.4\",\"port\":22,\"username\":\"alice\",\"password\":\"{abc=P100s0}\",\"socksProxy\":\"socks://127.0.0.1:10808\"}",
|
||||||
|
"--ssh", "{\"name\":\"bastion\",\"host\":\"9.9.9.9\",\"port\":22,\"username\":\"ops\",\"password\":\"pwd123456\",\"transportMode\":\"shell\",\"shellReadyTimeoutMs\":15000}",
|
||||||
|
"--ssh", "{\"name\":\"prod\",\"host\":\"5.6.7.8\",\"port\":22,\"username\":\"bob\",\"password\":\"yyy\",\"socksProxy\":\"socks://127.0.0.1:10808\"}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 📝 方式三:旧格式逗号分隔(向后兼容)
|
||||||
|
|
||||||
|
对于密码中不包含特殊字符的简单情况,仍可使用旧格式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @fangjunjie/ssh-mcp-server \
|
||||||
|
--ssh "name=dev,host=1.2.3.4,port=22,user=alice,password=xxx" \
|
||||||
|
--ssh "name=prod,host=5.6.7.8,port=22,user=bob,password=yyy"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠️ 注意**:旧格式在处理包含特殊字符(如 `=`、`,`、`{`、`}`)的密码时可能会有问题。如果密码包含特殊字符,请使用方式一或方式二。
|
||||||
|
|
||||||
|
在MCP工具调用时,通过 `connectionName` 参数指定目标连接名称,未指定时使用默认连接。
|
||||||
|
|
||||||
|
示例(在prod连接上执行命令):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "execute-command",
|
||||||
|
"params": {
|
||||||
|
"cmdString": "ls -al",
|
||||||
|
"connectionName": "prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
示例(带超时选项的命令执行):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "execute-command",
|
||||||
|
"params": {
|
||||||
|
"cmdString": "ping -c 10 127.0.0.1",
|
||||||
|
"connectionName": "prod",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⏱️ 命令执行超时
|
||||||
|
|
||||||
|
`execute-command` 工具支持超时选项,防止命令无限期挂起:
|
||||||
|
|
||||||
|
- **timeout**: 单次调用的命令执行超时时间(毫秒,可选);传入时会覆盖连接配置,未传入时使用对应连接配置或其 30000ms 默认值
|
||||||
|
- 在 JSON 配置文件里为单个连接设置 `commandTimeoutMs`,可以改掉这个默认值,避免每次调用都手动传 `timeout`(`exec` 模式)
|
||||||
|
- `shell` 模式对应的配置项是 `shellCommandTimeoutMs`
|
||||||
|
- 调用参数里的 `timeout` 始终优先于上面两个配置项
|
||||||
|
- 连接默认启用 SSH keepalive(`keepaliveIntervalMs`: 10000,`keepaliveCountMax`: 3),并使用 `connectionTimeoutMs` 限制连接建立时间
|
||||||
|
- SFTP 打开和传输操作使用 `sftpTimeoutMs` 控制超时(默认 300000ms)
|
||||||
|
- 错误响应现在包含稳定的 `code`、`message`、`retriable` 字段,便于上层 Agent 处理
|
||||||
|
|
||||||
|
这对于像 `ping`、`tail -f` 或其他可能阻塞执行的长时间运行进程特别有用。
|
||||||
|
|
||||||
|
### 📦 命令输出限制
|
||||||
|
|
||||||
|
会限制单条命令捕获的 `stdout` 和 `stderr` 总量,避免大文件或无限输出耗尽 MCP server 内存:
|
||||||
|
|
||||||
|
- 在 JSON 连接配置中使用 `maxOutputBytes` 设置上限,默认值为 `10485760`(10 MiB)
|
||||||
|
- `maxOutputBytes` 必须是非负整数;设置为 `0` 可禁用限制,但不建议对不受信任的命令禁用
|
||||||
|
- 输出超过限制时,远端命令会被中止,工具返回 `OUTPUT_LIMIT_EXCEEDED` 错误和已经捕获的截断输出,不会把中止的命令误报为成功
|
||||||
|
- 当 `pty` 为 `false` 时,成功命令写入 `stderr` 的警告或进度信息会保留在 `[stderr]` 区段中
|
||||||
|
- `exec` 与 `shell` 两种模式都会应用该限制。区别在于 `exec` 模式只关闭该命令的通道,而 `shell` 模式的通道由该连接上的所有命令共用、远端在中止后仍会继续写入,因此会断开连接(与 shell 模式命令超时的处理一致)
|
||||||
|
|
||||||
|
### 🗂️ 列出所有SSH服务器
|
||||||
|
|
||||||
|
可以通过MCP工具 `list-servers` 获取所有可用的SSH服务器配置:
|
||||||
|
|
||||||
|
调用示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "list-servers",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "dev", "host": "1.2.3.4", "port": 22, "username": "alice" },
|
||||||
|
{ "name": "prod", "host": "5.6.7.8", "port": 22, "username": "bob" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚙️ 命令行选项参考
|
||||||
|
|
||||||
|
```text
|
||||||
|
选项:
|
||||||
|
--config-file JSON 配置文件路径(推荐用于多服务器配置)
|
||||||
|
--ssh-config-file SSH 配置文件路径(默认: ~/.ssh/config)
|
||||||
|
--ssh SSH 连接配置(可以是 JSON 字符串或旧格式)
|
||||||
|
-h, --host SSH 服务器主机地址或 SSH 配置中的别名
|
||||||
|
-p, --port SSH 服务器端口
|
||||||
|
-u, --username SSH 用户名
|
||||||
|
-w, --password SSH 密码
|
||||||
|
-k, --privateKey SSH 私钥文件路径
|
||||||
|
-P, --passphrase 私钥密码(如果有的话)
|
||||||
|
-a, --agent SSH agent socket 路径
|
||||||
|
--try-keyboard 启用键盘交互式认证以支持 2FA/MFA(默认: false)
|
||||||
|
-W, --whitelist 命令白名单,以逗号分隔的正则表达式
|
||||||
|
-B, --blacklist 命令黑名单,以逗号分隔的正则表达式
|
||||||
|
--proxy 代理地址,支持 SOCKS5、HTTP 和 HTTPS
|
||||||
|
-s, --socksProxy 旧版 SOCKS5 代理地址(兼容参数)
|
||||||
|
--allowed-local-paths upload/download 允许访问的额外本地路径,逗号分隔
|
||||||
|
--allowed-remote-paths SFTP upload/download 允许访问的远端路径(POSIX 绝对路径),逗号分隔
|
||||||
|
--transport-mode SSH transport 模式: exec 或 shell(默认: exec)
|
||||||
|
--shell-ready-timeout shell 就绪探测超时,单位毫秒(默认: 10000)
|
||||||
|
--command-template 命令模板;shell 参数用 <quotedCommand>,原样插入用 <command>
|
||||||
|
--pty 为命令执行分配伪终端(默认: true)
|
||||||
|
--pre-connect 启动时预连接所有配置的 SSH 服务器
|
||||||
|
--version, -v 打印包版本
|
||||||
|
--help 打印帮助信息
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ 安全注意事项
|
||||||
|
|
||||||
|
该服务器提供了在远程服务器上执行命令和传输文件的强大功能。为确保安全使用,请注意以下几点:
|
||||||
|
|
||||||
|
- **命令白名单**:*强烈建议* 使用 `--whitelist` 选项来限制可执行的命令集合。如果没有白名单,任何命令都可以在远程服务器上执行,这可能带来重大的安全风险。
|
||||||
|
- **私钥安全**:服务器会将 SSH 私钥读入内存。请确保运行 `ssh-mcp-server` 的机器是安全的。不要将服务器暴露给不受信任的网络。
|
||||||
|
- **拒绝服务攻击 (DoS)**:服务器没有内置的速率限制。攻击者可能通过向服务器发送大量连接请求或大文件传输来发起 DoS 攻击。建议在具有速率限制功能的防火墙或反向代理后面运行服务器。
|
||||||
|
- **路径遍历**:服务器内置了对本地文件系统路径遍历攻击的保护。但是,仍然需要注意在 `upload` 和 `download` 命令中使用的路径。
|
||||||
|
- **本地传输范围**:默认仅允许访问当前工作目录。只有在明确可信时,才建议通过 `--allowed-local-paths` 或配置文件中的 `allowedLocalPaths` 放宽范围。
|
||||||
|
- **远端传输范围**:SFTP upload/download 仅接受绝对 POSIX 路径。未配置 `allowedRemotePaths`(或 `--allowed-remote-paths`)时,任意远端路径都允许,但启动时会打印警告。强烈建议显式配置 `allowedRemotePaths` 白名单,避免模型被 prompt 注入后读写 `~/.ssh/authorized_keys`、`/etc/sshd_config` 之类敏感文件。
|
||||||
|
|
||||||
|
## 🌟 Star 历史
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/?type=date&legend=top-left&repos=classfang%2Fssh-mcp-server)
|
||||||
+618
@@ -0,0 +1,618 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="images/ssh-mcp-server-logo-v2.png" alt="ssh-mcp-server logo" width="220">
|
||||||
|
|
||||||
|
# ssh-mcp-server
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
SSH-based MCP (Model Context Protocol) server that allows remote execution of SSH commands via the MCP protocol.
|
||||||
|
|
||||||
|
English Document | [中文文档](README.md)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 📝 Project Overview
|
||||||
|
|
||||||
|
ssh-mcp-server is a bridging tool that enables AI assistants and other applications supporting the MCP protocol to execute remote SSH commands through a standardized interface. This allows AI assistants to safely operate remote servers, execute commands, and retrieve results without directly exposing SSH credentials to AI models.
|
||||||
|
|
||||||
|
## ✨ Key Features
|
||||||
|
|
||||||
|
- **🔒 Secure Connections**: Supports multiple secure SSH connection methods, including password authentication and private key authentication (with passphrase support)
|
||||||
|
- **🛡️ Command Security Control**: Precisely control the range of allowed commands through flexible blacklist and whitelist mechanisms to prevent dangerous operations
|
||||||
|
- **🔄 Standardized Interface**: Complies with MCP protocol specifications for seamless integration with AI assistants supporting the protocol
|
||||||
|
- **🚇 Dual Transport Modes**: Supports both `exec` and `shell` transport modes for direct SSH hosts and bastion or jump-host scenarios
|
||||||
|
- **📂 File Transfer**: Supports bidirectional file transfers, uploading local files to servers or downloading files from servers
|
||||||
|
- **🔑 Credential Isolation**: SSH credentials are managed entirely locally and never exposed to AI models, enhancing security
|
||||||
|
- **🚀 Ready to Use**: Can be run directly using NPX without global installation, making it convenient and quick to deploy
|
||||||
|
|
||||||
|
## 📦 Open Source Repository
|
||||||
|
|
||||||
|
GitHub: [https://github.com/classfang/ssh-mcp-server](https://github.com/classfang/ssh-mcp-server)
|
||||||
|
|
||||||
|
NPM: [https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server](https://www.npmjs.com/package/@fangjunjie/ssh-mcp-server)
|
||||||
|
|
||||||
|
## 🛠️ Tools List
|
||||||
|
|
||||||
|
| Tool | Name | Description |
|
||||||
|
|---------|-----------|----------|
|
||||||
|
| execute-command | Command Execution Tool | Execute SSH commands on remote servers and get results |
|
||||||
|
| upload | File Upload Tool | Upload local files to specified locations on remote servers |
|
||||||
|
| download | File Download Tool | Download files from remote servers to local specified locations |
|
||||||
|
| list-servers | List Servers Tool | List all available SSH server configurations |
|
||||||
|
|
||||||
|
## 📚 Usage
|
||||||
|
|
||||||
|
### 0. 🤖 Quick Setup via AI Skill (Recommended)
|
||||||
|
|
||||||
|
If you are using an AI coding assistant that supports skills (such as Claude Code), you can use the built-in **ssh-mcp-helper** skill to complete the installation and configuration interactively — no need to manually edit JSON files.
|
||||||
|
|
||||||
|
**How to use:**
|
||||||
|
|
||||||
|
1. Install the skill from this repository's `skills/` directory
|
||||||
|
2. Tell your AI assistant: "Help me set up ssh-mcp-server" or "Configure SSH MCP for my remote server"
|
||||||
|
3. The skill will guide you step by step: check Node.js environment → choose MCP client → select authentication method → collect connection parameters → generate and write configuration
|
||||||
|
|
||||||
|
The skill supports all scenarios covered below (password, private key, SSH config reuse, SOCKS proxy, bastion hosts, multi-connection, 2FA, command restrictions, etc.) and automatically produces correctly formatted configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The sections below are arranged from the simplest entry point (username + password) to more advanced scenarios. Pick the case that matches yours and copy the `mcp.json` snippet directly into your MCP client configuration.
|
||||||
|
|
||||||
|
> **⚠️ Important**: In MCP configuration files, each command line argument and its value must be separate elements in the `args` array. Do NOT combine them with spaces. For example, use `"--host", "192.168.1.1"` instead of `"--host 192.168.1.1"`.
|
||||||
|
|
||||||
|
### 1. 🔑 Username + Password (simplest)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 🔐 Username + Private Key
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--privateKey", "~/.ssh/id_rsa"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 🔏 Private Key with Passphrase
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--privateKey", "~/.ssh/id_rsa",
|
||||||
|
"--passphrase", "pwd123456"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 📋 Reuse `~/.ssh/config`
|
||||||
|
|
||||||
|
If you already have a host alias in `~/.ssh/config`, the server reads connection parameters directly from it — no need to repeat them in `mcp.json`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "myserver"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Assuming your `~/.ssh/config` contains:
|
||||||
|
|
||||||
|
```
|
||||||
|
Host myserver
|
||||||
|
HostName 192.168.1.1
|
||||||
|
Port 22
|
||||||
|
User root
|
||||||
|
IdentityFile ~/.ssh/id_rsa
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also specify a custom SSH config file path:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "myserver",
|
||||||
|
"--ssh-config-file", "/path/to/custom/ssh_config"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Command-line parameters take precedence over SSH config values. For example, if you specify `--port 2222`, it will override the port from SSH config.
|
||||||
|
|
||||||
|
### 5. 🌐 Connecting Through a Proxy
|
||||||
|
|
||||||
|
When the target host is only reachable through a proxy, use `--proxy` with a SOCKS5, HTTP, or HTTPS proxy.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--proxy", "http://username:password@proxy-host:proxy-port"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported URL formats:
|
||||||
|
|
||||||
|
```text
|
||||||
|
socks://username:password@proxy-host:1080
|
||||||
|
socks5://username:password@proxy-host:1080
|
||||||
|
http://username:password@proxy-host:8080
|
||||||
|
https://username:password@proxy-host:8443
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP and HTTPS proxies use the `CONNECT` method to tunnel to the SSH server, with optional Basic proxy authentication. HTTP and HTTPS default to ports `80` and `443`; SOCKS5 requires an explicit port. HTTPS proxy certificates are verified using the default Node.js trust store.
|
||||||
|
|
||||||
|
The existing `socksProxy` configuration and `--socksProxy` option remain supported for backward compatibility, but only accept `socks://` and `socks5://`. Do not configure both `proxy` and `socksProxy`.
|
||||||
|
|
||||||
|
### 6. 📝 Restricting Commands With Whitelist / Blacklist
|
||||||
|
|
||||||
|
Use `--whitelist` and `--blacklist` to limit which commands the server is allowed to run. Patterns are comma-separated regular expressions. **Strongly recommended** for any production use.
|
||||||
|
|
||||||
|
Whitelist example (only allow read-only inspection commands):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--whitelist", "^ls( .*)?,^cat .*,^df.*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Blacklist example (block destructive commands):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--blacklist", "^rm .*,^shutdown.*,^reboot.*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: If both whitelist and blacklist are specified, the command must pass both checks (whitelist first, then blacklist) to be executed.
|
||||||
|
|
||||||
|
### 7. 🧩 Wrapping Commands With a Template
|
||||||
|
|
||||||
|
`commandTemplate` wraps every executed command in a template — useful for switching user via `su`, running inside a container, or jumping through another host. Use `<quotedCommand>` when the command is passed as a shell argument, or `<command>` for raw insertion. The template is applied **after** the working-directory `cd` is prepended, so the entire `cd ... && <actual command>` chain gets wrapped.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "10.0.0.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "deploy",
|
||||||
|
"--password", "xxx",
|
||||||
|
"--command-template", "su root -c <quotedCommand>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Executing `ls /app` with directory `/data` actually sends:
|
||||||
|
|
||||||
|
```
|
||||||
|
su root -c 'cd -- '\''/data'\'' && ls /app'
|
||||||
|
```
|
||||||
|
|
||||||
|
Other useful templates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sudo bash -c <quotedCommand>
|
||||||
|
docker exec -i mycontainer sh -c <quotedCommand>
|
||||||
|
ssh jumphost <quotedCommand>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 🚇 Bastion / Jump Host (`transportMode: shell`)
|
||||||
|
|
||||||
|
`transportMode` defaults to `exec`. Switch to `shell` when:
|
||||||
|
|
||||||
|
- SSH login succeeds but `exec` command execution fails
|
||||||
|
- The remote side requires shell startup scripts, banners, or environment initialization first
|
||||||
|
- The target effectively exposes only an interactive shell (bastion hosts, jump hosts, network devices)
|
||||||
|
|
||||||
|
Behavior differences:
|
||||||
|
|
||||||
|
- `exec`: supports `execute-command`, `upload`, and `download`
|
||||||
|
- `shell`: runs commands through a persistent shell session with an internal command queue, but does **not** support `upload` / `download` because SFTP is unavailable in this mode
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "bastion.example.com",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "ops",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--transport-mode", "shell",
|
||||||
|
"--shell-ready-timeout", "15000"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In JSON config files you can also set `shellCommandTimeoutMs` to override the default per-command timeout for shell-backed connections.
|
||||||
|
|
||||||
|
### 9. 🔐 Multi-Factor Authentication (2FA / MFA)
|
||||||
|
|
||||||
|
When the SSH server requires multi-factor authentication (password + private key + 2FA verification code), enable `tryKeyboard`. The password and private key are auto-supplied. For non-password prompts, set `SSH_MCP_2FA_CODE` in the server environment before connecting.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "example.com",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "user",
|
||||||
|
"--password", "your_password",
|
||||||
|
"--privateKey", "/path/to/key",
|
||||||
|
"--try-keyboard"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authentication flow:**
|
||||||
|
1. Private key authentication (if provided)
|
||||||
|
2. Password authentication (if provided)
|
||||||
|
3. Keyboard-interactive for 2FA code via `SSH_MCP_2FA_CODE`
|
||||||
|
|
||||||
|
### 10. 🧩 Managing Multiple SSH Connections
|
||||||
|
|
||||||
|
When you need to expose more than one SSH target through the same MCP server, register them under unique connection names and select the target at call time via `connectionName`. There are three ways to configure them:
|
||||||
|
|
||||||
|
#### 📄 Method 1: Using Config File (Recommended)
|
||||||
|
|
||||||
|
Create a JSON configuration file (e.g., `ssh-config.json`):
|
||||||
|
|
||||||
|
**Array Format:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"port": 22,
|
||||||
|
"username": "alice",
|
||||||
|
"password": "{abc=P100s0}",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808",
|
||||||
|
"commandTimeoutMs": 120000,
|
||||||
|
"maxOutputBytes": 10485760
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bastion",
|
||||||
|
"host": "9.9.9.9",
|
||||||
|
"port": 22,
|
||||||
|
"username": "ops",
|
||||||
|
"password": "pwd123456",
|
||||||
|
"transportMode": "shell",
|
||||||
|
"shellReadyTimeoutMs": 15000,
|
||||||
|
"shellCommandTimeoutMs": 45000,
|
||||||
|
"connectionTimeoutMs": 30000,
|
||||||
|
"keepaliveIntervalMs": 10000,
|
||||||
|
"keepaliveCountMax": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "prod",
|
||||||
|
"host": "5.6.7.8",
|
||||||
|
"port": 22,
|
||||||
|
"username": "bob",
|
||||||
|
"password": "yyy",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "secure-server",
|
||||||
|
"host": "secure.example.com",
|
||||||
|
"port": 22,
|
||||||
|
"username": "admin",
|
||||||
|
"password": "your_password",
|
||||||
|
"privateKey": "/path/to/private/key",
|
||||||
|
"tryKeyboard": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Object Format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dev": {
|
||||||
|
"host": "1.2.3.4",
|
||||||
|
"port": 22,
|
||||||
|
"username": "alice",
|
||||||
|
"password": "{abc=P100s0}",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808",
|
||||||
|
"commandTimeoutMs": 120000,
|
||||||
|
"maxOutputBytes": 10485760
|
||||||
|
},
|
||||||
|
"bastion": {
|
||||||
|
"host": "9.9.9.9",
|
||||||
|
"port": 22,
|
||||||
|
"username": "ops",
|
||||||
|
"password": "pwd123456",
|
||||||
|
"transportMode": "shell",
|
||||||
|
"shellReadyTimeoutMs": 15000,
|
||||||
|
"shellCommandTimeoutMs": 45000
|
||||||
|
},
|
||||||
|
"prod": {
|
||||||
|
"host": "5.6.7.8",
|
||||||
|
"port": 22,
|
||||||
|
"username": "bob",
|
||||||
|
"password": "yyy",
|
||||||
|
"socksProxy": "socks://127.0.0.1:10808"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then use the `--config-file` parameter:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--config-file", "ssh-config.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 🔧 Method 2: Using JSON Format with --ssh Parameter
|
||||||
|
|
||||||
|
You can pass JSON-formatted configuration strings directly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--ssh", "{\"name\":\"dev\",\"host\":\"1.2.3.4\",\"port\":22,\"username\":\"alice\",\"password\":\"{abc=P100s0}\",\"socksProxy\":\"socks://127.0.0.1:10808\"}",
|
||||||
|
"--ssh", "{\"name\":\"bastion\",\"host\":\"9.9.9.9\",\"port\":22,\"username\":\"ops\",\"password\":\"pwd123456\",\"transportMode\":\"shell\",\"shellReadyTimeoutMs\":15000}",
|
||||||
|
"--ssh", "{\"name\":\"prod\",\"host\":\"5.6.7.8\",\"port\":22,\"username\":\"bob\",\"password\":\"yyy\",\"socksProxy\":\"socks://127.0.0.1:10808\"}"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 📝 Method 3: Legacy Comma-Separated Format (Backward Compatible)
|
||||||
|
|
||||||
|
For simple cases without special characters in passwords, you can still use the legacy format:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @fangjunjie/ssh-mcp-server \
|
||||||
|
--ssh "name=dev,host=1.2.3.4,port=22,user=alice,password=xxx" \
|
||||||
|
--ssh "name=prod,host=5.6.7.8,port=22,user=bob,password=yyy"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **⚠️ Note**: The legacy format may have issues with passwords containing special characters like `=`, `,`, `{`, `}`. Use Method 1 or Method 2 for passwords with special characters.
|
||||||
|
|
||||||
|
In MCP tool calls, specify the connection name via the `connectionName` parameter. If omitted, the default connection is used.
|
||||||
|
|
||||||
|
Example (execute command on 'prod' connection):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "execute-command",
|
||||||
|
"params": {
|
||||||
|
"cmdString": "ls -al",
|
||||||
|
"connectionName": "prod"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example (execute command with timeout options):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "execute-command",
|
||||||
|
"params": {
|
||||||
|
"cmdString": "ping -c 10 127.0.0.1",
|
||||||
|
"connectionName": "prod",
|
||||||
|
"timeout": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⏱️ Command Execution Timeout
|
||||||
|
|
||||||
|
The `execute-command` tool supports timeout options to prevent commands from hanging indefinitely:
|
||||||
|
|
||||||
|
- **timeout**: Per-call command execution timeout in milliseconds (optional); when provided, it overrides the connection setting, otherwise the connection setting or its 30000ms default is used
|
||||||
|
- Set `commandTimeoutMs` per connection in the JSON config file to change that default, so callers do not have to pass `timeout` on every call (`exec` mode)
|
||||||
|
- The `shell` mode equivalent is `shellCommandTimeoutMs`
|
||||||
|
- A `timeout` passed with the call always takes precedence over both settings
|
||||||
|
- Connections use SSH keepalives by default (`keepaliveIntervalMs`: 10000, `keepaliveCountMax`: 3) and respect `connectionTimeoutMs` for connection setup
|
||||||
|
- SFTP open and transfer operations respect `sftpTimeoutMs` (default 300000ms)
|
||||||
|
- Error responses include stable `code`, `message`, and `retriable` fields for easier agent-side handling
|
||||||
|
|
||||||
|
This is particularly useful for commands like `ping`, `tail -f`, or other long-running processes that might block execution.
|
||||||
|
|
||||||
|
### 📦 Command Output Limit
|
||||||
|
|
||||||
|
The combined captured `stdout` and `stderr` for each command is limited to protect the MCP server from large files or unbounded output:
|
||||||
|
|
||||||
|
- Set `maxOutputBytes` in a JSON connection configuration; the default is `10485760` bytes (10 MiB)
|
||||||
|
- `maxOutputBytes` must be a non-negative integer; `0` disables the limit, which is not recommended for untrusted commands
|
||||||
|
- When output exceeds the limit, the remote command is aborted and the tool returns an `OUTPUT_LIMIT_EXCEEDED` error with the captured, truncated output instead of reporting success
|
||||||
|
- With `pty: false`, warnings and progress written to `stderr` by successful commands are preserved in a `[stderr]` section
|
||||||
|
- The limit applies to both `exec` and `shell` mode. `exec` mode closes just that command's channel, whereas the `shell` channel is shared by every command on the connection and the remote keeps writing after an abort, so the connection is dropped instead — the same way a shell mode command timeout behaves
|
||||||
|
|
||||||
|
### 🗂️ List All SSH Servers
|
||||||
|
|
||||||
|
You can use the MCP tool `list-servers` to get all available SSH server configurations:
|
||||||
|
|
||||||
|
Example call:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tool": "list-servers",
|
||||||
|
"params": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "dev", "host": "1.2.3.4", "port": 22, "username": "alice" },
|
||||||
|
{ "name": "prod", "host": "5.6.7.8", "port": 22, "username": "bob" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### ⚙️ Command Line Options Reference
|
||||||
|
|
||||||
|
```text
|
||||||
|
Options:
|
||||||
|
--config-file JSON configuration file path (recommended for multiple servers)
|
||||||
|
--ssh-config-file SSH config file path (default: ~/.ssh/config)
|
||||||
|
--ssh SSH connection configuration (can be JSON string or legacy format)
|
||||||
|
-h, --host SSH server host address or alias from SSH config
|
||||||
|
-p, --port SSH server port
|
||||||
|
-u, --username SSH username
|
||||||
|
-w, --password SSH password
|
||||||
|
-k, --privateKey SSH private key file path
|
||||||
|
-P, --passphrase Private key passphrase (if any)
|
||||||
|
-a, --agent SSH agent socket path
|
||||||
|
--try-keyboard Enable keyboard-interactive authentication for 2FA/MFA (default: false)
|
||||||
|
-W, --whitelist Command whitelist, comma-separated regular expressions
|
||||||
|
-B, --blacklist Command blacklist, comma-separated regular expressions
|
||||||
|
--proxy Proxy URL supporting SOCKS5, HTTP, and HTTPS
|
||||||
|
-s, --socksProxy Legacy SOCKS5 proxy URL
|
||||||
|
--allowed-local-paths Additional allowed local paths for upload/download, comma-separated
|
||||||
|
--allowed-remote-paths Allowed remote (POSIX, absolute) paths for SFTP upload/download, comma-separated
|
||||||
|
--transport-mode SSH transport mode: exec or shell (default: exec)
|
||||||
|
--shell-ready-timeout Shell readiness probe timeout in milliseconds (default: 10000)
|
||||||
|
--command-template Command template, use <quotedCommand> for shell arguments or <command> for raw insertion
|
||||||
|
--pty Allocate pseudo-tty for command execution (default: true)
|
||||||
|
--pre-connect Pre-connect to all configured SSH servers on startup
|
||||||
|
--version, -v Print package version
|
||||||
|
--help Print this help message
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛡️ Security Considerations
|
||||||
|
|
||||||
|
This server provides powerful capabilities to execute commands and transfer files on remote servers. To ensure it is used securely, please consider the following:
|
||||||
|
|
||||||
|
- **Command Whitelisting**: It is *strongly recommended* to use the `--whitelist` option to restrict the set of commands that can be executed. Without a whitelist, any command can be executed on the remote server, which can be a significant security risk.
|
||||||
|
- **Private Key Security**: The server reads the SSH private key into memory. Ensure that the machine running the `ssh-mcp-server` is secure. Do not expose the server to untrusted networks.
|
||||||
|
- **Denial of Service (DoS)**: The server does not have built-in rate limiting. An attacker could potentially launch a DoS attack by flooding the server with connection requests or large file transfers. It is recommended to run the server behind a firewall or reverse proxy with rate-limiting capabilities.
|
||||||
|
- **Path Traversal**: The server has built-in protection against path traversal attacks on the local filesystem. However, it is still important to be mindful of the paths used in `upload` and `download` commands.
|
||||||
|
- **Local Transfer Scope**: By default, local file transfers are restricted to the current working directory. Use `--allowed-local-paths` or `allowedLocalPaths` in config only for explicitly trusted directories.
|
||||||
|
- **Remote Transfer Scope**: SFTP upload/download accepts only absolute POSIX paths. If `allowedRemotePaths` (or `--allowed-remote-paths`) is not configured, any remote path is accepted and the server prints a startup warning. Configure `allowedRemotePaths` to whitelist a small set of remote directories; this is strongly recommended to prevent prompt-injection-driven reads or writes of files like `~/.ssh/authorized_keys` or `/etc/sshd_config`.
|
||||||
|
|
||||||
|
## 🌟 Star History
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
[](https://www.star-history.com/?type=date&legend=top-left&repos=classfang%2Fssh-mcp-server)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 422 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@fangjunjie/ssh-mcp-server",
|
||||||
|
"version": "1.9.0",
|
||||||
|
"description": "SSH-based MCP Server (基于 SSH 的 MCP 服务器)",
|
||||||
|
"main": "build/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/classfang/ssh-mcp-server.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/classfang/ssh-mcp-server/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/classfang/ssh-mcp-server#readme",
|
||||||
|
"bin": {
|
||||||
|
"ssh-mcp-server": "build/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node scripts/run-tests.js",
|
||||||
|
"test:watch": "node --test --watch test/**/*.test.js",
|
||||||
|
"build": "node scripts/build.js",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"ssh",
|
||||||
|
"mcp",
|
||||||
|
"server",
|
||||||
|
"cli"
|
||||||
|
],
|
||||||
|
"author": "Junki",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.27.0",
|
||||||
|
"socks": "^2.8.7",
|
||||||
|
"ssh2": "^1.17.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { chmodSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const rootDir = join(__dirname, "..");
|
||||||
|
const buildFile = join(rootDir, "build", "index.js");
|
||||||
|
|
||||||
|
// Run TypeScript compiler
|
||||||
|
console.log("Building TypeScript...");
|
||||||
|
execSync("tsc", { stdio: "inherit", cwd: rootDir });
|
||||||
|
|
||||||
|
// Make executable on Unix-like systems (Linux, macOS, etc.)
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
chmodSync(buildFile, 0o755);
|
||||||
|
console.log("Made build/index.js executable");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"Warning: Could not set executable permissions:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("Skipping chmod on Windows");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Build complete!");
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试运行器
|
||||||
|
* 使用 Node.js 内置的测试框架运行所有测试
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
console.log('🧪 运行测试...\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync('node scripts/build.js', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: new URL('..', import.meta.url).pathname
|
||||||
|
});
|
||||||
|
execSync('node --test test/**/*.test.js', {
|
||||||
|
stdio: 'inherit',
|
||||||
|
cwd: new URL('..', import.meta.url).pathname
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
---
|
||||||
|
name: ssh-mcp-helper
|
||||||
|
description: Use when 用户希望安装、配置或新增 ssh-mcp-server 的 MCP 连接(如「帮我装一下 ssh-mcp-server」「给 Cursor/Claude Code 配置 SSH MCP」「在已有 MCP 里加一台远程主机」「ssh-mcp-server 的 mcp.json 怎么写」)。技能通过逐步问答收集主机、认证、传输模式、命令限制等参数,并把生成的 mcpServers JSON 片段写入对应客户端的配置文件。
|
||||||
|
---
|
||||||
|
|
||||||
|
# ssh-mcp-helper
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
帮助用户通过交互式问答完成 `@fangjunjie/ssh-mcp-server` 的安装预检与 MCP 客户端配置。技能本身**不代替用户输入凭据**,而是逐项确认认证方式、连接参数与安全策略,最终产出可直接写入 MCP 客户端配置文件的 `mcpServers` JSON 片段。
|
||||||
|
|
||||||
|
**核心准则:** 所有可枚举的选项(MCP 客户端类型、认证方式、传输模式、是/否开关)必须使用 AskUserQuestion 让用户选择;只有不可枚举的输入(host、用户名、私钥路径、密码、自定义白名单正则等)才允许自由文本提问。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
- 用户明确要安装/配置/新增 ssh-mcp-server
|
||||||
|
- 用户提到为 Cursor / Claude Code / Cline / Continue 等客户端添加 SSH MCP
|
||||||
|
- 用户希望在已有 `mcpServers` 中追加一台 SSH 主机
|
||||||
|
- 用户问「ssh-mcp-server 的 mcp.json 怎么写」
|
||||||
|
|
||||||
|
## 何时不使用
|
||||||
|
|
||||||
|
- 用户要修改 ssh-mcp-server 源码 → 直接编辑代码,不进入向导
|
||||||
|
- 用户只是想跑某条 SSH 命令 → 直接调用已存在的 ssh-mcp-server 工具
|
||||||
|
- 用户在问 SSH 协议本身的概念 → 解释即可,无需走流程
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph ssh_mcp_helper {
|
||||||
|
"0. 前置环境检查" [shape=box];
|
||||||
|
"1. 选择 MCP 客户端" [shape=box];
|
||||||
|
"2. 单台 vs 多台" [shape=diamond];
|
||||||
|
"3. 选择认证方式" [shape=box];
|
||||||
|
"4. 询问连接参数" [shape=box];
|
||||||
|
"5. 询问高级选项" [shape=box];
|
||||||
|
"6. 生成 JSON 片段" [shape=box];
|
||||||
|
"7. 合并写入配置" [shape=box];
|
||||||
|
"8. 提示重启与验证" [shape=doublecircle];
|
||||||
|
|
||||||
|
"0. 前置环境检查" -> "1. 选择 MCP 客户端";
|
||||||
|
"1. 选择 MCP 客户端" -> "2. 单台 vs 多台";
|
||||||
|
"2. 单台 vs 多台" -> "3. 选择认证方式" [label="单台"];
|
||||||
|
"2. 单台 vs 多台" -> "3. 选择认证方式" [label="多台 → 写 ssh-config.json"];
|
||||||
|
"3. 选择认证方式" -> "4. 询问连接参数";
|
||||||
|
"4. 询问连接参数" -> "5. 询问高级选项";
|
||||||
|
"5. 询问高级选项" -> "6. 生成 JSON 片段";
|
||||||
|
"6. 生成 JSON 片段" -> "7. 合并写入配置";
|
||||||
|
"7. 合并写入配置" -> "8. 提示重启与验证";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 0:前置环境检查
|
||||||
|
- 运行 `node -v` 与 `npx --version` 确认本机有 Node.js(推荐 v18+)
|
||||||
|
- 缺失则先提示用户安装 Node.js,再继续后续步骤
|
||||||
|
|
||||||
|
### Step 1:选择 MCP 客户端(AskUserQuestion 多选一)
|
||||||
|
|
||||||
|
| 客户端 | 默认配置位置 |
|
||||||
|
|---|---|
|
||||||
|
| Claude Code(全局) | `~/.claude.json` 的 `mcpServers` 字段 |
|
||||||
|
| Claude Code(项目级) | 项目根 `.mcp.json` |
|
||||||
|
| Cursor | `~/.cursor/mcp.json` |
|
||||||
|
| Cline / Continue / 其他 | 让用户提供具体路径 |
|
||||||
|
|
||||||
|
### Step 2:单台 vs 多台(AskUserQuestion 二选一)
|
||||||
|
- **单台**:直接使用命令行参数(`--host` 等)
|
||||||
|
- **多台**:生成 `ssh-config.json` 并使用 `--config-file`
|
||||||
|
|
||||||
|
### Step 3:选择认证方式(AskUserQuestion 多选一)
|
||||||
|
- `password` — 账号 + 密码
|
||||||
|
- `privateKey` — 账号 + 私钥;再用 AskUserQuestion 确认是否带 passphrase
|
||||||
|
- `ssh-config` — 复用 `~/.ssh/config` 中的 Host 别名(只需 `--host <alias>`,可选 `--ssh-config-file`)
|
||||||
|
- `ssh-agent` — 使用 `--agent` 指向 socket
|
||||||
|
- `2fa` — 密码 + 私钥 + 键盘交互,追加 `--try-keyboard`
|
||||||
|
|
||||||
|
### Step 4:连接参数(自由文本)
|
||||||
|
- host / port / username(port=22 可省略)
|
||||||
|
- 按 Step 3 的结果追问密码、私钥路径、passphrase、agent socket 等
|
||||||
|
|
||||||
|
### Step 5:高级选项(每项独立用 AskUserQuestion 询问是/否)
|
||||||
|
1. SOCKS 代理:是 → 追问 `--socksProxy` 字符串
|
||||||
|
2. 命令白名单:是 → 追问逗号分隔正则(**生产环境强烈建议开启**)
|
||||||
|
3. 命令黑名单:是 → 追问逗号分隔正则
|
||||||
|
4. 命令模板:是 → 追问含 `<command>` 占位符的模板
|
||||||
|
5. 传输模式:默认 `exec`;若用户标记目标为堡垒机/跳板机,改 `shell` 并追问 `--shell-ready-timeout`
|
||||||
|
6. 路径白名单:是 → 追问 `--allowed-local-paths` / `--allowed-remote-paths`
|
||||||
|
7. 启动时预连接:是 → 追加 `--pre-connect`
|
||||||
|
|
||||||
|
### Step 6:生成 JSON 片段
|
||||||
|
装配规则:
|
||||||
|
- `command` 固定为 `"npx"`
|
||||||
|
- `args` 第一项 `"-y"`,第二项 `"@fangjunjie/ssh-mcp-server"`
|
||||||
|
- **每个命令行参数与值必须是 args 数组中独立的两个元素**,绝不能写成 `"--host 192.168.1.1"`
|
||||||
|
- 多连接场景:把每个连接写入 `ssh-config.json`(数组或对象格式皆可),客户端配置里只放 `--config-file <绝对路径>`
|
||||||
|
|
||||||
|
### Step 7:合并写入配置
|
||||||
|
- 先用 Read 读取目标 JSON 配置文件
|
||||||
|
- 合并到既存 `mcpServers` 下;若存在同名 key,**先 AskUserQuestion 让用户选择覆盖 / 改名 / 取消**
|
||||||
|
- 写入前把最终片段展示给用户确认
|
||||||
|
- 写入后输出该配置文件的绝对路径
|
||||||
|
|
||||||
|
### Step 8:收尾
|
||||||
|
- 提示用户重启对应 MCP 客户端使配置生效
|
||||||
|
- 给出验证方式:调用 `list-servers`,或对该连接执行 `execute-command "whoami"`
|
||||||
|
|
||||||
|
## 速查表
|
||||||
|
|
||||||
|
| 场景 | 关键参数 |
|
||||||
|
|---|---|
|
||||||
|
| 账号密码 | `--host --port --username --password` |
|
||||||
|
| 私钥(可带 passphrase) | `--host --port --username --privateKey [--passphrase]` |
|
||||||
|
| 复用 ssh config 别名 | `--host <alias>` (+可选 `--ssh-config-file`) |
|
||||||
|
| SOCKS 代理 | `--socksProxy socks://user:pwd@host:port` |
|
||||||
|
| 堡垒机 / 跳板机 | `--transport-mode shell --shell-ready-timeout 15000` |
|
||||||
|
| 多连接 | `--config-file /abs/path/ssh-config.json` |
|
||||||
|
| 2FA / MFA | `--try-keyboard`(搭配密码 + 私钥) |
|
||||||
|
| 命令白名单 | `--whitelist "^ls( .*)?,^cat .*"` |
|
||||||
|
| 命令黑名单 | `--blacklist "^rm .*,^shutdown.*"` |
|
||||||
|
| 命令模板 | `--command-template "su root -c '<command>'"` |
|
||||||
|
| 路径白名单 | `--allowed-local-paths` / `--allowed-remote-paths` |
|
||||||
|
|
||||||
|
## 常见坑
|
||||||
|
|
||||||
|
- ❌ 把 `"--host 192.168.1.1"` 当作一个 args 元素 → ✅ 拆成两个元素 `"--host", "192.168.1.1"`
|
||||||
|
- ❌ 密码含 `{ } = ,` 等字符却用旧式 `--ssh "name=...,password=..."` → ✅ 改用 `--config-file` 或 JSON 形式 `--ssh`
|
||||||
|
- ❌ `shell` 模式下还想用 `upload`/`download` → 该模式禁用 SFTP,需切回 `exec`
|
||||||
|
- ❌ 直接覆盖用户既有 `mcpServers` 中的同名 key → 必须先读后合并,覆盖前显式确认
|
||||||
|
- ❌ 直连生产环境却未配置 `--whitelist` / `--blacklist` → 必须主动提醒安全风险
|
||||||
|
- ❌ 把私钥内容粘进配置 → 配置里应填**私钥文件路径**,凭据留在本地
|
||||||
|
|
||||||
|
## 输出示例
|
||||||
|
|
||||||
|
最简单的账号密码场景产出:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@fangjunjie/ssh-mcp-server",
|
||||||
|
"--host", "192.168.1.1",
|
||||||
|
"--port", "22",
|
||||||
|
"--username", "root",
|
||||||
|
"--password", "pwd123456",
|
||||||
|
"--whitelist", "^ls( .*)?,^cat .*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
多连接场景产出 `ssh-config.json` + 简化的客户端配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"ssh-mcp-server": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@fangjunjie/ssh-mcp-server", "--config-file", "/abs/path/ssh-config.json"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { SSHConfig, SshConnectionConfigMap, ParsedArgs } from "../models/types.js";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import os from "os";
|
||||||
|
import { lookupSshConfig } from "../utils/ssh-config-parser.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command line argument parser class
|
||||||
|
*/
|
||||||
|
export class CommandLineParser {
|
||||||
|
private static readonly DEFAULT_TRANSPORT_MODE: SSHConfig["transportMode"] = "exec";
|
||||||
|
private static readonly DEFAULT_SHELL_READY_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
private static parseBoolean(value: unknown): boolean | undefined {
|
||||||
|
if (value === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (normalized === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseTransportMode(
|
||||||
|
value: unknown,
|
||||||
|
): SSHConfig["transportMode"] | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === "exec" || value === "shell") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`transportMode must be either 'exec' or 'shell', got: ${String(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseTimeout(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
): number | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed =
|
||||||
|
typeof value === "number" ? value : parseInt(String(value), 10);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`${fieldName} must be a positive number, got: ${String(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseMaxOutputBytes(value: unknown): number | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = typeof value === "number" ? value : Number(String(value));
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||||
|
throw new Error(
|
||||||
|
`maxOutputBytes must be a non-negative integer, got: ${String(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse command line arguments
|
||||||
|
*/
|
||||||
|
public static parseArgs(): ParsedArgs {
|
||||||
|
const { values, positionals } = parseArgs({
|
||||||
|
args: process.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
"config-file": { type: "string" },
|
||||||
|
"ssh-config-file": { type: "string" },
|
||||||
|
ssh: { type: "string", multiple: true },
|
||||||
|
// Compatible with single connection legacy parameters
|
||||||
|
host: { type: "string", short: "h" },
|
||||||
|
port: { type: "string", short: "p" },
|
||||||
|
username: { type: "string", short: "u" },
|
||||||
|
password: { type: "string", short: "w" },
|
||||||
|
privateKey: { type: "string", short: "k" },
|
||||||
|
passphrase: { type: "string", short: "P" },
|
||||||
|
agent: { type: "string", short: "a" },
|
||||||
|
"password-from-env": { type: "string" },
|
||||||
|
whitelist: { type: "string", short: "W" },
|
||||||
|
blacklist: { type: "string", short: "B" },
|
||||||
|
proxy: { type: "string" },
|
||||||
|
socksProxy: { type: "string", short: "s" },
|
||||||
|
"allowed-local-paths": { type: "string" },
|
||||||
|
"allowed-remote-paths": { type: "string" },
|
||||||
|
"transport-mode": { type: "string" },
|
||||||
|
"shell-ready-timeout": { type: "string" },
|
||||||
|
"command-template": { type: "string" },
|
||||||
|
pty: { type: "boolean" },
|
||||||
|
"try-keyboard": { type: "boolean" },
|
||||||
|
"pre-connect": { type: "boolean" },
|
||||||
|
},
|
||||||
|
allowPositionals: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configMap: SshConnectionConfigMap = {};
|
||||||
|
|
||||||
|
// Priority 1: Load from config file if specified
|
||||||
|
if (values["config-file"]) {
|
||||||
|
const configFilePath = path.resolve(values["config-file"]);
|
||||||
|
if (!fs.existsSync(configFilePath)) {
|
||||||
|
throw new Error(`Config file not found: ${configFilePath}`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const configContent = fs.readFileSync(configFilePath, "utf-8");
|
||||||
|
const fileConfig = JSON.parse(configContent);
|
||||||
|
|
||||||
|
// Support both array format and object format
|
||||||
|
if (Array.isArray(fileConfig)) {
|
||||||
|
// Array format: [{name: "dev", host: "...", ...}, ...]
|
||||||
|
for (const config of fileConfig) {
|
||||||
|
if (!config.name || !config.host || !config.port || !config.username) {
|
||||||
|
throw new Error("Each config in array must include name, host, port, username");
|
||||||
|
}
|
||||||
|
configMap[config.name] = this.normalizeConfig(config);
|
||||||
|
}
|
||||||
|
} else if (typeof fileConfig === "object" && fileConfig !== null) {
|
||||||
|
// Object format: {"dev": {host: "...", ...}, "prod": {...}}
|
||||||
|
for (const [name, config] of Object.entries(fileConfig)) {
|
||||||
|
const normalizedConfig = this.normalizeConfig(config as any);
|
||||||
|
normalizedConfig.name = name;
|
||||||
|
configMap[name] = normalizedConfig;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error("Config file must contain an array or object of SSH configurations");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof SyntaxError) {
|
||||||
|
throw new Error(`Invalid JSON in config file: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Parse --ssh parameters (only if no config file was loaded)
|
||||||
|
if (Object.keys(configMap).length === 0) {
|
||||||
|
const sshParams: string[] = Array.isArray(values.ssh)
|
||||||
|
? values.ssh
|
||||||
|
: values.ssh
|
||||||
|
? [values.ssh]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const sshStr of sshParams) {
|
||||||
|
let conf: SSHConfig;
|
||||||
|
|
||||||
|
// Try to parse as JSON first
|
||||||
|
if (sshStr.trim().startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const jsonConfig = JSON.parse(sshStr);
|
||||||
|
conf = this.normalizeConfig(jsonConfig);
|
||||||
|
if (!conf.name) {
|
||||||
|
throw new Error("JSON config must include 'name' field");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Invalid JSON format in --ssh parameter: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to legacy comma-separated format for backward compatibility
|
||||||
|
conf = this.parseLegacySshFormat(sshStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conf.name || !conf.host || !conf.port || !conf.username) {
|
||||||
|
throw new Error("Each --ssh must include name, host, port, username");
|
||||||
|
}
|
||||||
|
configMap[conf.name] = conf;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Compatible with single connection legacy parameters
|
||||||
|
if (Object.keys(configMap).length === 0) {
|
||||||
|
const host = values.host || positionals[0];
|
||||||
|
|
||||||
|
// 尝试从 SSH config 读取配置
|
||||||
|
let sshConfigEntry = null;
|
||||||
|
if (host) {
|
||||||
|
try {
|
||||||
|
sshConfigEntry = lookupSshConfig(host, values["ssh-config-file"]);
|
||||||
|
} catch (err) {
|
||||||
|
// 显式指定配置文件但读取失败时抛错
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const portStr = values.port || positionals[1] || sshConfigEntry?.port?.toString() || "22";
|
||||||
|
const username = values.username || positionals[2] || sshConfigEntry?.user;
|
||||||
|
const password =
|
||||||
|
values.password ||
|
||||||
|
(values["password-from-env"]
|
||||||
|
? (process.env[values["password-from-env"]] ?? undefined)
|
||||||
|
: undefined) ||
|
||||||
|
positionals[3];
|
||||||
|
const privateKey = values.privateKey || sshConfigEntry?.identityFile;
|
||||||
|
const passphrase = values.passphrase || process.env.SSH_MCP_PASSPHRASE;
|
||||||
|
const resolvedAgent = values.agent !== undefined
|
||||||
|
? values.agent
|
||||||
|
: !password && !privateKey
|
||||||
|
? process.env.SSH_AUTH_SOCK
|
||||||
|
: undefined;
|
||||||
|
const whitelist = values.whitelist;
|
||||||
|
const blacklist = values.blacklist;
|
||||||
|
const allowedLocalPaths = values["allowed-local-paths"];
|
||||||
|
const allowedRemotePaths = values["allowed-remote-paths"];
|
||||||
|
const commandTemplate = values["command-template"];
|
||||||
|
const pty = values.pty;
|
||||||
|
const tryKeyboard = values["try-keyboard"];
|
||||||
|
|
||||||
|
// 实际连接地址:优先使用 SSH config 的 HostName
|
||||||
|
const actualHost = sshConfigEntry?.hostName || host;
|
||||||
|
|
||||||
|
// No connection args provided at all: run in dynamic mode where each
|
||||||
|
// tool call may supply host/username/password inline. An empty config
|
||||||
|
// is valid and lets the MCP server start without any pre-saved server.
|
||||||
|
if (!actualHost) {
|
||||||
|
return {
|
||||||
|
configs: configMap,
|
||||||
|
preConnect: values["pre-connect"] === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!portStr || !username || (!password && !privateKey && !resolvedAgent)) {
|
||||||
|
throw new Error(
|
||||||
|
"Missing required parameters, need to provide host, port, username and password, private key or agent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = parseInt(portStr, 10);
|
||||||
|
if (isNaN(port)) {
|
||||||
|
throw new Error("Port must be a valid number");
|
||||||
|
}
|
||||||
|
|
||||||
|
configMap["default"] = this.normalizeConfig({
|
||||||
|
name: "default",
|
||||||
|
host: actualHost,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
agent: resolvedAgent,
|
||||||
|
proxy: values.proxy,
|
||||||
|
socksProxy: values.socksProxy,
|
||||||
|
pty: pty !== undefined ? pty : undefined,
|
||||||
|
tryKeyboard: tryKeyboard !== undefined ? tryKeyboard : undefined,
|
||||||
|
transportMode: values["transport-mode"],
|
||||||
|
shellReadyTimeoutMs: values["shell-ready-timeout"],
|
||||||
|
commandTemplate,
|
||||||
|
commandWhitelist: whitelist
|
||||||
|
? whitelist
|
||||||
|
.split(",")
|
||||||
|
.map((pattern) => pattern.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
commandBlacklist: blacklist
|
||||||
|
? blacklist
|
||||||
|
.split(",")
|
||||||
|
.map((pattern) => pattern.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
allowedLocalPaths: allowedLocalPaths
|
||||||
|
? allowedLocalPaths
|
||||||
|
.split(",")
|
||||||
|
.map((allowedPath) => allowedPath.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
allowedRemotePaths: allowedRemotePaths
|
||||||
|
? allowedRemotePaths
|
||||||
|
.split(",")
|
||||||
|
.map((allowedPath) => allowedPath.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configs: configMap,
|
||||||
|
preConnect: values["pre-connect"] === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse legacy comma-separated format: name=dev,host=1.2.3.4,port=22,user=alice,password=xxx
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private static parseLegacySshFormat(sshStr: string): SSHConfig {
|
||||||
|
const conf: any = {};
|
||||||
|
const parts = sshStr.split(",");
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
// Only split on the first '=' to handle values containing '='
|
||||||
|
const equalIndex = part.indexOf("=");
|
||||||
|
if (equalIndex > 0) {
|
||||||
|
const k = part.substring(0, equalIndex).trim();
|
||||||
|
const v = part.substring(equalIndex + 1).trim();
|
||||||
|
if (k && v) {
|
||||||
|
conf[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = parseInt(conf.port, 10);
|
||||||
|
if (isNaN(port)) {
|
||||||
|
throw new Error(
|
||||||
|
`Port for connection ${conf.name || "unknown"} must be a valid number`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeConfig(conf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize SSH config object to ensure proper types and structure
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private static normalizeConfig(config: any): SSHConfig {
|
||||||
|
const port = typeof config.port === "number"
|
||||||
|
? config.port
|
||||||
|
: parseInt(config.port, 10);
|
||||||
|
|
||||||
|
if (isNaN(port)) {
|
||||||
|
throw new Error(`Port must be a valid number, got: ${config.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: config.name,
|
||||||
|
host: config.host,
|
||||||
|
port,
|
||||||
|
username: config.username || config.user,
|
||||||
|
password: config.password,
|
||||||
|
privateKey: config.privateKey
|
||||||
|
? this.normalizeLocalPath(String(config.privateKey))
|
||||||
|
: undefined,
|
||||||
|
passphrase: config.passphrase || process.env.SSH_MCP_PASSPHRASE,
|
||||||
|
agent: config.agent,
|
||||||
|
algorithms: config.algorithms,
|
||||||
|
proxy: config.proxy,
|
||||||
|
socksProxy: config.socksProxy,
|
||||||
|
pty: this.parseBoolean(config.pty),
|
||||||
|
tryKeyboard: this.parseBoolean(config.tryKeyboard),
|
||||||
|
transportMode:
|
||||||
|
this.parseTransportMode(config.transportMode) ||
|
||||||
|
this.DEFAULT_TRANSPORT_MODE,
|
||||||
|
shellReadyTimeoutMs:
|
||||||
|
this.parseTimeout(
|
||||||
|
config.shellReadyTimeoutMs,
|
||||||
|
"shellReadyTimeoutMs",
|
||||||
|
) || this.DEFAULT_SHELL_READY_TIMEOUT_MS,
|
||||||
|
shellCommandTimeoutMs: this.parseTimeout(
|
||||||
|
config.shellCommandTimeoutMs,
|
||||||
|
"shellCommandTimeoutMs",
|
||||||
|
),
|
||||||
|
commandTimeoutMs: this.parseTimeout(
|
||||||
|
config.commandTimeoutMs,
|
||||||
|
"commandTimeoutMs",
|
||||||
|
),
|
||||||
|
connectionTimeoutMs: this.parseTimeout(
|
||||||
|
config.connectionTimeoutMs,
|
||||||
|
"connectionTimeoutMs",
|
||||||
|
),
|
||||||
|
sftpTimeoutMs: this.parseTimeout(config.sftpTimeoutMs, "sftpTimeoutMs"),
|
||||||
|
maxOutputBytes: this.parseMaxOutputBytes(config.maxOutputBytes),
|
||||||
|
keepaliveIntervalMs: this.parseTimeout(
|
||||||
|
config.keepaliveIntervalMs,
|
||||||
|
"keepaliveIntervalMs",
|
||||||
|
),
|
||||||
|
keepaliveCountMax: this.parseTimeout(
|
||||||
|
config.keepaliveCountMax,
|
||||||
|
"keepaliveCountMax",
|
||||||
|
),
|
||||||
|
commandWhitelist: Array.isArray(config.commandWhitelist)
|
||||||
|
? config.commandWhitelist
|
||||||
|
: config.whitelist
|
||||||
|
? typeof config.whitelist === "string"
|
||||||
|
? config.whitelist.split("|").map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
: config.whitelist
|
||||||
|
: undefined,
|
||||||
|
commandBlacklist: Array.isArray(config.commandBlacklist)
|
||||||
|
? config.commandBlacklist
|
||||||
|
: config.blacklist
|
||||||
|
? typeof config.blacklist === "string"
|
||||||
|
? config.blacklist.split("|").map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
: config.blacklist
|
||||||
|
: undefined,
|
||||||
|
allowedLocalPaths: Array.isArray(config.allowedLocalPaths)
|
||||||
|
? config.allowedLocalPaths
|
||||||
|
.map((allowedPath: unknown) =>
|
||||||
|
this.normalizeLocalPath(String(allowedPath)),
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
: typeof config.allowedLocalPaths === "string"
|
||||||
|
? config.allowedLocalPaths
|
||||||
|
.split("|")
|
||||||
|
.map((allowedPath: string) =>
|
||||||
|
this.normalizeLocalPath(allowedPath.trim()),
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
allowedRemotePaths: Array.isArray(config.allowedRemotePaths)
|
||||||
|
? config.allowedRemotePaths
|
||||||
|
.map((allowedPath: unknown) =>
|
||||||
|
this.normalizeRemotePath(String(allowedPath)),
|
||||||
|
)
|
||||||
|
: typeof config.allowedRemotePaths === "string"
|
||||||
|
? config.allowedRemotePaths
|
||||||
|
.split("|")
|
||||||
|
.map((allowedPath: string) =>
|
||||||
|
this.normalizeRemotePath(allowedPath.trim()),
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined,
|
||||||
|
commandTemplate: this.parseCommandTemplate(config.commandTemplate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static parseCommandTemplate(
|
||||||
|
value: unknown,
|
||||||
|
): string | undefined {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = String(value);
|
||||||
|
if (!template.includes("<command>") && !template.includes("<quotedCommand>")) {
|
||||||
|
throw new Error(
|
||||||
|
`commandTemplate must contain '<command>' or '<quotedCommand>' placeholder, got: ${template}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizeLocalPath(localPath: string): string {
|
||||||
|
return path.resolve(this.expandHomePath(localPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static expandHomePath(localPath: string): string {
|
||||||
|
if (localPath === "~") {
|
||||||
|
return os.homedir();
|
||||||
|
}
|
||||||
|
if (localPath.startsWith("~/")) {
|
||||||
|
return path.join(os.homedir(), localPath.slice(2));
|
||||||
|
}
|
||||||
|
return localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizeRemotePath(remotePath: string): string {
|
||||||
|
if (!remotePath) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!path.posix.isAbsolute(remotePath)) {
|
||||||
|
throw new Error(
|
||||||
|
`allowedRemotePaths entries must be absolute POSIX paths, got: ${remotePath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized = path.posix.normalize(remotePath);
|
||||||
|
if (normalized.length > 1 && normalized.endsWith("/")) {
|
||||||
|
return normalized.slice(0, -1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Export all configurations
|
||||||
|
*/
|
||||||
|
export * from './server.js';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
function readPackageVersion(): string {
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
|
||||||
|
) as { version?: unknown };
|
||||||
|
|
||||||
|
if (typeof packageJson.version !== "string") {
|
||||||
|
throw new Error("package.json must include a string version");
|
||||||
|
}
|
||||||
|
|
||||||
|
return packageJson.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Server configuration
|
||||||
|
*/
|
||||||
|
export const SERVER_CONFIG = {
|
||||||
|
name: "ssh-mcp-server",
|
||||||
|
version: readPackageVersion(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
||||||
|
import { CommandLineParser } from "../cli/command-line-parser.js";
|
||||||
|
import { Logger } from "../utils/logger.js";
|
||||||
|
import { registerAllTools } from "../tools/index.js";
|
||||||
|
import { SERVER_CONFIG } from "../config/server.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Server class
|
||||||
|
*/
|
||||||
|
export class SshMcpServer {
|
||||||
|
private server: McpServer;
|
||||||
|
private sshManager: SSHConnectionManager;
|
||||||
|
private shutdownHandlersRegistered = false;
|
||||||
|
private shutdownPromise?: Promise<void>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.server = new McpServer(SERVER_CONFIG);
|
||||||
|
|
||||||
|
this.sshManager = SSHConnectionManager.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register tools
|
||||||
|
*/
|
||||||
|
private registerTools(): void {
|
||||||
|
registerAllTools(this.server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shutdown(reason: string, exitCode?: number): Promise<void> {
|
||||||
|
if (!this.shutdownPromise) {
|
||||||
|
this.shutdownPromise = (async () => {
|
||||||
|
Logger.log(`Received ${reason}, shutting down SSH MCP server...`, "info");
|
||||||
|
|
||||||
|
this.sshManager.disconnect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.server.close();
|
||||||
|
} catch (error) {
|
||||||
|
Logger.log(
|
||||||
|
`Failed to close MCP server cleanly: ${(error as Error).message}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.shutdownPromise;
|
||||||
|
|
||||||
|
if (exitCode !== undefined) {
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerShutdownHandlers(): void {
|
||||||
|
if (this.shutdownHandlersRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignal = (signal: NodeJS.Signals) => {
|
||||||
|
void this.shutdown(signal, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.once("SIGINT", handleSignal);
|
||||||
|
process.once("SIGTERM", handleSignal);
|
||||||
|
process.stdin.resume();
|
||||||
|
process.stdin.once("end", () => void this.shutdown("stdin end", 0));
|
||||||
|
process.stdin.once("close", () => void this.shutdown("stdin close", 0));
|
||||||
|
|
||||||
|
this.shutdownHandlersRegistered = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the server
|
||||||
|
*/
|
||||||
|
public async run(): Promise<void> {
|
||||||
|
// Initialize SSH configuration
|
||||||
|
const parsedArgs = CommandLineParser.parseArgs();
|
||||||
|
this.sshManager.setConfig(parsedArgs.configs);
|
||||||
|
this.registerShutdownHandlers();
|
||||||
|
|
||||||
|
// Register tools before accepting MCP requests.
|
||||||
|
this.registerTools();
|
||||||
|
|
||||||
|
// Create transport instance and connect.
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await this.server.connect(transport);
|
||||||
|
|
||||||
|
Logger.log("MCP server connection established");
|
||||||
|
|
||||||
|
// Security warning
|
||||||
|
const allConfigs = Object.values(parsedArgs.configs);
|
||||||
|
if (
|
||||||
|
allConfigs.some(
|
||||||
|
(c) => !c.commandWhitelist || c.commandWhitelist.length === 0
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Logger.log(
|
||||||
|
"WARNING: Running without a command whitelist is strongly discouraged. Please configure a whitelist to restrict the commands that can be executed.",
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
allConfigs.some(
|
||||||
|
(c) =>
|
||||||
|
(c.transportMode || "exec") === "exec" &&
|
||||||
|
(!c.allowedRemotePaths || c.allowedRemotePaths.length === 0)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Logger.log(
|
||||||
|
"WARNING: Running without allowedRemotePaths is strongly discouraged. SFTP upload/download can read or write any path on the remote server. Configure allowedRemotePaths to restrict the SFTP surface.",
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-connect to all servers if flag is set
|
||||||
|
if (parsedArgs.preConnect) {
|
||||||
|
Logger.log("Pre-connecting to all configured SSH servers...", "info");
|
||||||
|
void this.sshManager
|
||||||
|
.connectAll()
|
||||||
|
.then(() => {
|
||||||
|
Logger.log("Successfully pre-connected to all SSH servers", "info");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
Logger.log(
|
||||||
|
`Warning: Some SSH connections failed during pre-connect: ${(error as Error).message}`,
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { SshMcpServer } from "./core/mcp-server.js";
|
||||||
|
import { SERVER_CONFIG } from "./config/server.js";
|
||||||
|
import { Logger } from "./utils/logger.js";
|
||||||
|
|
||||||
|
const HELP_TEXT = `Usage: ssh-mcp-server [options] [host port username password]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--config-file <path> Load SSH server configs from a JSON file
|
||||||
|
--ssh-config-file <path> Read host aliases from SSH config (default: ~/.ssh/config)
|
||||||
|
--ssh <config> Add an SSH config as JSON or legacy key=value pairs (repeatable)
|
||||||
|
-h, --host <host> SSH host or SSH config alias for single-host mode
|
||||||
|
-p, --port <port> SSH port for single-host mode
|
||||||
|
-u, --username <name> SSH username for single-host mode
|
||||||
|
-w, --password <password> SSH password for single-host mode
|
||||||
|
--password-from-env <name> Read the SSH password for single-host mode from the named environment variable (avoids exposing it in process args)
|
||||||
|
-k, --privateKey <path> SSH private key path for single-host mode
|
||||||
|
-P, --passphrase <passphrase> SSH private key passphrase
|
||||||
|
-a, --agent <path> SSH agent socket path or pageant on Windows
|
||||||
|
-W, --whitelist <patterns> Command whitelist regexes, comma-separated
|
||||||
|
-B, --blacklist <patterns> Command blacklist regexes, comma-separated
|
||||||
|
--proxy <url> Proxy URL (SOCKS5, HTTP, or HTTPS)
|
||||||
|
-s, --socksProxy <url> Legacy SOCKS5 proxy URL
|
||||||
|
--allowed-local-paths <paths> Extra allowed local paths, comma-separated
|
||||||
|
--allowed-remote-paths <paths> Allowed remote POSIX absolute paths, comma-separated
|
||||||
|
--transport-mode <mode> SSH transport mode: exec or shell (default: exec)
|
||||||
|
--shell-ready-timeout <ms> Shell readiness probe timeout (default: 10000)
|
||||||
|
--command-template <template> Wrap commands with <command> or <quotedCommand>
|
||||||
|
--pty Allocate pseudo-tty for exec mode commands (default: true)
|
||||||
|
--try-keyboard Enable keyboard-interactive authentication
|
||||||
|
--pre-connect Pre-connect to all SSH servers on startup
|
||||||
|
--version, -v Print package version
|
||||||
|
--help Print this help message`;
|
||||||
|
|
||||||
|
function hasArg(...names: string[]): boolean {
|
||||||
|
return process.argv.slice(2).some((arg) => names.includes(arg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main program entry
|
||||||
|
*/
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
if (hasArg("--help")) {
|
||||||
|
console.log(HELP_TEXT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasArg("--version", "-v")) {
|
||||||
|
console.log(SERVER_CONFIG.version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sshMcpServer = new SshMcpServer();
|
||||||
|
await sshMcpServer.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => Logger.handleError(error, "【SSH MCP Server Error】", true));
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import type { Algorithms } from "ssh2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH connection configuration interface
|
||||||
|
*/
|
||||||
|
export interface SSHConfig {
|
||||||
|
name?: string; // Connection name, optional, compatible with single connection
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
passphrase?: string;
|
||||||
|
agent?: string; // SSH agent for authentication (use 'pageant' for Windows Pageant)
|
||||||
|
tryKeyboard?: boolean; // Enable keyboard-interactive authentication. Password prompts use `password`; non-password prompts (e.g. OTP) use the SSH_MCP_2FA_CODE env var. Default: false
|
||||||
|
commandWhitelist?: string[]; // Command whitelist (array of regex strings)
|
||||||
|
commandBlacklist?: string[]; // Command blacklist (array of regex strings)
|
||||||
|
proxy?: string; // Proxy URL supporting SOCKS5, HTTP, and HTTPS
|
||||||
|
socksProxy?: string; // Legacy SOCKS5-only proxy URL
|
||||||
|
algorithms?: Algorithms; // Custom SSH algorithms (kex, cipher, serverHostKey, hmac, compress)
|
||||||
|
pty?: boolean; // Allocate pseudo-tty for command execution, default: true
|
||||||
|
allowedLocalPaths?: string[]; // Allowed local paths for upload/download
|
||||||
|
allowedRemotePaths?: string[]; // Allowed remote paths for SFTP upload/download (POSIX, absolute)
|
||||||
|
transportMode?: "exec" | "shell"; // SSH transport mode, default: exec
|
||||||
|
shellReadyTimeoutMs?: number; // Shell readiness probe timeout, default: 10000ms
|
||||||
|
shellCommandTimeoutMs?: number; // Shell command timeout override, default: 30000ms
|
||||||
|
commandTimeoutMs?: number; // Exec command timeout override, default: 30000ms
|
||||||
|
connectionTimeoutMs?: number; // SSH connection and handshake timeout, default: 30000ms
|
||||||
|
sftpTimeoutMs?: number; // SFTP open and transfer timeout, default: 300000ms
|
||||||
|
maxOutputBytes?: number; // Max captured bytes per command (stdout+stderr) before aborting, default: 10485760; set 0 to disable
|
||||||
|
keepaliveIntervalMs?: number; // SSH keepalive interval, default: 10000ms
|
||||||
|
keepaliveCountMax?: number; // Unanswered keepalive packets before disconnect, default: 3
|
||||||
|
commandTemplate?: string; // Command template, use <quotedCommand> for shell arguments or <command> for raw insertion
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiple SSH connection configuration Map
|
||||||
|
*/
|
||||||
|
export type SshConnectionConfigMap = Record<string, SSHConfig>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection parameters that may be supplied per tool call instead of being
|
||||||
|
* read from static configuration. When these are present, a fresh SSH client
|
||||||
|
* is established on the fly for that single operation.
|
||||||
|
*/
|
||||||
|
export interface DynamicSSHParams {
|
||||||
|
host: string;
|
||||||
|
port?: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
privateKey?: string;
|
||||||
|
passphrase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log levels
|
||||||
|
*/
|
||||||
|
export type LogLevel = "info" | "error" | "debug";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System status information
|
||||||
|
*/
|
||||||
|
export interface ServerStatus {
|
||||||
|
reachable: boolean;
|
||||||
|
hostname?: string;
|
||||||
|
ipAddresses?: string[];
|
||||||
|
osName?: string;
|
||||||
|
osVersion?: string;
|
||||||
|
kernelVersion?: string;
|
||||||
|
uptime?: string;
|
||||||
|
diskSpace?: {
|
||||||
|
free: string;
|
||||||
|
total: string;
|
||||||
|
};
|
||||||
|
drives?: Array<{
|
||||||
|
device: string;
|
||||||
|
mountPoint: string;
|
||||||
|
total: string;
|
||||||
|
used: string;
|
||||||
|
free: string;
|
||||||
|
usagePercent: string;
|
||||||
|
filesystem?: string;
|
||||||
|
}>;
|
||||||
|
memory?: {
|
||||||
|
free: string;
|
||||||
|
total: string;
|
||||||
|
};
|
||||||
|
cpu?: {
|
||||||
|
name?: string;
|
||||||
|
usage?: string;
|
||||||
|
};
|
||||||
|
gpus?: Array<{
|
||||||
|
name: string;
|
||||||
|
usage?: string;
|
||||||
|
path?: string;
|
||||||
|
}>;
|
||||||
|
processes?: {
|
||||||
|
running: number;
|
||||||
|
threads: number;
|
||||||
|
};
|
||||||
|
services?: {
|
||||||
|
running: number;
|
||||||
|
installed: number;
|
||||||
|
};
|
||||||
|
lastUpdated?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed command line arguments result
|
||||||
|
*/
|
||||||
|
export interface ParsedArgs {
|
||||||
|
configs: SshConnectionConfigMap;
|
||||||
|
preConnect: boolean;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
||||||
|
import { Logger } from "../utils/logger.js";
|
||||||
|
import { toToolError } from "../utils/tool-error.js";
|
||||||
|
import { redactSecrets } from "../utils/redact.js";
|
||||||
|
import { DynamicSSHParams } from "../models/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register file download tool
|
||||||
|
*/
|
||||||
|
export function registerDownloadTool(server: McpServer): void {
|
||||||
|
const sshManager = SSHConnectionManager.getInstance();
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"download",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Download file from connected server. Either specify connectionName for a " +
|
||||||
|
"pre-configured host, or provide host+username to connect on the fly.",
|
||||||
|
inputSchema: {
|
||||||
|
remotePath: z.string().describe("Remote path"),
|
||||||
|
localPath: z.string().describe("Local path"),
|
||||||
|
connectionName: z.string().optional().describe("Pre-configured SSH connection name (optional, default is 'default')"),
|
||||||
|
host: z.string().optional().describe("Dynamic target host (IP or hostname)"),
|
||||||
|
port: z.number().optional().describe("Dynamic target SSH port (default: 22)"),
|
||||||
|
username: z.string().optional().describe("Dynamic target SSH username"),
|
||||||
|
password: z.string().optional().describe("Dynamic target SSH password"),
|
||||||
|
privateKey: z.string().optional().describe("Dynamic target SSH private key file path or PEM contents"),
|
||||||
|
passphrase: z.string().optional().describe("Passphrase for the dynamic target private key"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
remotePath,
|
||||||
|
localPath,
|
||||||
|
connectionName,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
}) => {
|
||||||
|
const isDynamic = Boolean(host && username);
|
||||||
|
const secrets = [password, passphrase];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: string;
|
||||||
|
if (isDynamic) {
|
||||||
|
const params: DynamicSSHParams = {
|
||||||
|
host: host as string,
|
||||||
|
port,
|
||||||
|
username: username as string,
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
};
|
||||||
|
result = await sshManager.downloadDynamic(params, remotePath, localPath);
|
||||||
|
} else {
|
||||||
|
result = await sshManager.download(remotePath, localPath, connectionName);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result }],
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const toolError = toToolError(error, "UNKNOWN_ERROR");
|
||||||
|
const safeMessage = redactSecrets(toolError.message, secrets);
|
||||||
|
Logger.handleError(toolError, "Failed to download file", false, secrets);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
code: toolError.code,
|
||||||
|
message: safeMessage,
|
||||||
|
retriable: toolError.retriable,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
||||||
|
import { Logger } from "../utils/logger.js";
|
||||||
|
import { toToolError } from "../utils/tool-error.js";
|
||||||
|
import { redactSecrets } from "../utils/redact.js";
|
||||||
|
import { DynamicSSHParams } from "../models/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register execute command tool
|
||||||
|
*/
|
||||||
|
export function registerExecuteCommandTool(server: McpServer): void {
|
||||||
|
const sshManager = SSHConnectionManager.getInstance();
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"execute-command",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Execute command on connected server and get output result. " +
|
||||||
|
"Either specify connectionName for a pre-configured host, or provide host+username " +
|
||||||
|
"(plus password/privateKey) to connect on the fly.",
|
||||||
|
inputSchema: {
|
||||||
|
cmdString: z.string().describe("Command to execute"),
|
||||||
|
directory: z.string().optional().describe("Working directory for command execution"),
|
||||||
|
connectionName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Pre-configured SSH connection name (optional, default is 'default'). When provided, the static host is used.",
|
||||||
|
),
|
||||||
|
timeout: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Command execution timeout in milliseconds (optional; defaults to 30000ms)",
|
||||||
|
),
|
||||||
|
host: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Dynamic target host (IP or hostname). When provided, an ephemeral connection is opened for this call only.",
|
||||||
|
),
|
||||||
|
port: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Dynamic target SSH port (default: 22)"),
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Dynamic target SSH username"),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Dynamic target SSH password"),
|
||||||
|
privateKey: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Dynamic target SSH private key file path or PEM contents"),
|
||||||
|
passphrase: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Passphrase for the dynamic target private key"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
cmdString,
|
||||||
|
directory,
|
||||||
|
connectionName,
|
||||||
|
timeout,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
}) => {
|
||||||
|
const isDynamic = Boolean(host && username);
|
||||||
|
const secrets = [password, passphrase];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: string;
|
||||||
|
if (isDynamic) {
|
||||||
|
const params: DynamicSSHParams = {
|
||||||
|
host: host as string,
|
||||||
|
port,
|
||||||
|
username: username as string,
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
};
|
||||||
|
result = await sshManager.executeCommandDynamic(
|
||||||
|
params,
|
||||||
|
cmdString,
|
||||||
|
directory,
|
||||||
|
{ timeout },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = await sshManager.executeCommand(
|
||||||
|
cmdString,
|
||||||
|
directory,
|
||||||
|
connectionName,
|
||||||
|
{ timeout },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result }],
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const toolError = toToolError(error, "UNKNOWN_ERROR");
|
||||||
|
const safeMessage = redactSecrets(toolError.message, secrets);
|
||||||
|
Logger.handleError(toolError, "Failed to execute command", false, secrets);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
code: toolError.code,
|
||||||
|
message: safeMessage,
|
||||||
|
retriable: toolError.retriable,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { registerExecuteCommandTool } from "./execute-command.js";
|
||||||
|
import { registerUploadTool } from "./upload.js";
|
||||||
|
import { registerDownloadTool } from "./download.js";
|
||||||
|
import { registerListServersTool } from "./list-servers.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all tools
|
||||||
|
* @param server MCP server instance
|
||||||
|
*/
|
||||||
|
export function registerAllTools(server: McpServer): void {
|
||||||
|
registerExecuteCommandTool(server);
|
||||||
|
registerUploadTool(server);
|
||||||
|
registerDownloadTool(server);
|
||||||
|
registerListServersTool(server);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
||||||
|
|
||||||
|
type ServerInfo = ReturnType<SSHConnectionManager["getAllServerInfos"]>[number];
|
||||||
|
|
||||||
|
export function formatServerList(servers: ServerInfo[]): string {
|
||||||
|
if (servers.length === 0) {
|
||||||
|
return "No SSH servers configured.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = servers.map((server) => {
|
||||||
|
const parts = [
|
||||||
|
`[${server.connected ? "connected" : "disconnected"}] ${server.name}`,
|
||||||
|
`${server.username}@${server.host}:${server.port}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (server.status?.hostname) {
|
||||||
|
parts.push(`hostname=${server.status.hostname}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status?.osName) {
|
||||||
|
parts.push(`os=${server.status.osName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.status?.lastUpdated) {
|
||||||
|
parts.push(`updated=${server.status.lastUpdated}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" | ");
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
"Configured SSH servers:",
|
||||||
|
...summary,
|
||||||
|
"",
|
||||||
|
"Raw JSON:",
|
||||||
|
// Not indented: the pretty printed form is roughly 40% larger for the same
|
||||||
|
// data, and this output is fed to a model rather than read as a document.
|
||||||
|
JSON.stringify(servers),
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register list-servers tool
|
||||||
|
*/
|
||||||
|
export function registerListServersTool(server: McpServer): void {
|
||||||
|
server.registerTool(
|
||||||
|
"list-servers",
|
||||||
|
{
|
||||||
|
description: "List all available SSH server configurations",
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const sshManager = SSHConnectionManager.getInstance();
|
||||||
|
const servers = sshManager.getAllServerInfos();
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: formatServerList(servers),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { SSHConnectionManager } from "../services/ssh-connection-manager.js";
|
||||||
|
import { Logger } from "../utils/logger.js";
|
||||||
|
import { toToolError } from "../utils/tool-error.js";
|
||||||
|
import { redactSecrets } from "../utils/redact.js";
|
||||||
|
import { DynamicSSHParams } from "../models/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register file upload tool
|
||||||
|
*/
|
||||||
|
export function registerUploadTool(server: McpServer): void {
|
||||||
|
const sshManager = SSHConnectionManager.getInstance();
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"upload",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Upload file to connected server. Either specify connectionName for a " +
|
||||||
|
"pre-configured host, or provide host+username to connect on the fly.",
|
||||||
|
inputSchema: {
|
||||||
|
localPath: z.string().describe("Local path"),
|
||||||
|
remotePath: z.string().describe("Remote path"),
|
||||||
|
connectionName: z.string().optional().describe("Pre-configured SSH connection name (optional, default is 'default')"),
|
||||||
|
host: z.string().optional().describe("Dynamic target host (IP or hostname)"),
|
||||||
|
port: z.number().optional().describe("Dynamic target SSH port (default: 22)"),
|
||||||
|
username: z.string().optional().describe("Dynamic target SSH username"),
|
||||||
|
password: z.string().optional().describe("Dynamic target SSH password"),
|
||||||
|
privateKey: z.string().optional().describe("Dynamic target SSH private key file path or PEM contents"),
|
||||||
|
passphrase: z.string().optional().describe("Passphrase for the dynamic target private key"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
localPath,
|
||||||
|
remotePath,
|
||||||
|
connectionName,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
}) => {
|
||||||
|
const isDynamic = Boolean(host && username);
|
||||||
|
const secrets = [password, passphrase];
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: string;
|
||||||
|
if (isDynamic) {
|
||||||
|
const params: DynamicSSHParams = {
|
||||||
|
host: host as string,
|
||||||
|
port,
|
||||||
|
username: username as string,
|
||||||
|
password,
|
||||||
|
privateKey,
|
||||||
|
passphrase,
|
||||||
|
};
|
||||||
|
result = await sshManager.uploadDynamic(params, localPath, remotePath);
|
||||||
|
} else {
|
||||||
|
result = await sshManager.upload(localPath, remotePath, connectionName);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: result }],
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const toolError = toToolError(error, "UNKNOWN_ERROR");
|
||||||
|
const safeMessage = redactSecrets(toolError.message, secrets);
|
||||||
|
Logger.handleError(toolError, "Failed to upload file", false, secrets);
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
code: toolError.code,
|
||||||
|
message: safeMessage,
|
||||||
|
retriable: toolError.retriable,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
}],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { LogLevel } from '../models/types.js';
|
||||||
|
import { redactSecrets } from './redact.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logger class
|
||||||
|
*/
|
||||||
|
export class Logger {
|
||||||
|
/**
|
||||||
|
* Log a message
|
||||||
|
* Note: All logging goes to stderr to avoid interfering with MCP stdio protocol (which uses stdout)
|
||||||
|
* @param secrets Optional list of secrets to redact from the message before writing.
|
||||||
|
*/
|
||||||
|
public static log(
|
||||||
|
message: string,
|
||||||
|
level: LogLevel = "info",
|
||||||
|
secrets: Array<string | undefined | null> = [],
|
||||||
|
): void {
|
||||||
|
const safeMessage = secrets.length > 0 ? redactSecrets(message, secrets) : message;
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${safeMessage}`;
|
||||||
|
|
||||||
|
// Always write to stderr to avoid corrupting MCP protocol messages on stdout
|
||||||
|
process.stderr.write(formattedMessage + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle error
|
||||||
|
* @param secrets Optional list of secrets to redact before logging and returning.
|
||||||
|
*/
|
||||||
|
public static handleError(
|
||||||
|
error: unknown,
|
||||||
|
prefix: string = "",
|
||||||
|
exit: boolean = false,
|
||||||
|
secrets: Array<string | undefined | null> = [],
|
||||||
|
): string {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const fullMessage = prefix ? `${prefix}: ${errorMessage}` : errorMessage;
|
||||||
|
|
||||||
|
Logger.log(fullMessage, "error", secrets);
|
||||||
|
|
||||||
|
if (exit) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redactSecrets(fullMessage, secrets);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Secret redaction utilities.
|
||||||
|
*
|
||||||
|
* The dynamic-connection mode accepts credentials (password / private key /
|
||||||
|
* passphrase) directly in each tool call. Those values must never leak into
|
||||||
|
* logs or error messages returned to the model. Use `redactSecret()` anywhere
|
||||||
|
* a potentially sensitive string is logged or turned into an error message.
|
||||||
|
*
|
||||||
|
* Redaction is deliberately strict about short values: a 1-3 character secret
|
||||||
|
* is a substring of too much normal text to mask reliably, so it is only
|
||||||
|
* replaced when it appears as a whole word on its own token. Longer secrets
|
||||||
|
* are replaced anywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MIN_WHOLE_TOKEN_SECRET_LENGTH = 4;
|
||||||
|
const REDACTED = "[REDACTED]";
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace every occurrence of `secret` in `text` with `[REDACTED]`.
|
||||||
|
*
|
||||||
|
* - Empty strings and strings shorter than 2 characters are ignored.
|
||||||
|
* - Secrets of 2-3 characters are only masked when they stand alone as a whole
|
||||||
|
* token, to avoid destroying common short words that coincidentally match.
|
||||||
|
* - Longer secrets are masked everywhere (including inside larger words).
|
||||||
|
*/
|
||||||
|
export function redactSecret(text: string, secret: string | undefined | null): string {
|
||||||
|
if (!text || !secret) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = secret.trim();
|
||||||
|
if (trimmed.length < 2) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
const escaped = escapeRegExp(trimmed);
|
||||||
|
if (trimmed.length < MIN_WHOLE_TOKEN_SECRET_LENGTH) {
|
||||||
|
return text.replace(
|
||||||
|
new RegExp(`(^|[^A-Za-z0-9])${escaped}([^A-Za-z0-9]|$)`, "g"),
|
||||||
|
`$1${REDACTED}$2`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.split(escaped).join(REDACTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact many secrets at once.
|
||||||
|
*/
|
||||||
|
export function redactSecrets(text: string, secrets: Array<string | undefined | null>): string {
|
||||||
|
let result = text;
|
||||||
|
for (const secret of secrets) {
|
||||||
|
result = redactSecret(result, secret);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
interface SshConfigEntry {
|
||||||
|
hostName?: string;
|
||||||
|
user?: string;
|
||||||
|
port?: number;
|
||||||
|
identityFile?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HostBlock {
|
||||||
|
patterns: string[];
|
||||||
|
config: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找 SSH 配置文件中指定主机别名的配置
|
||||||
|
* @param hostAlias 主机别名
|
||||||
|
* @param configFilePath 配置文件路径,默认为 ~/.ssh/config
|
||||||
|
* @returns 解析后的配置项,未找到返回 null
|
||||||
|
*/
|
||||||
|
export function lookupSshConfig(
|
||||||
|
hostAlias: string,
|
||||||
|
configFilePath?: string
|
||||||
|
): SshConfigEntry | null {
|
||||||
|
const configPath = configFilePath || path.join(os.homedir(), '.ssh', 'config');
|
||||||
|
|
||||||
|
// 默认路径不存在时静默返回 null
|
||||||
|
if (!configFilePath && !fs.existsSync(configPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式指定路径不存在时抛错
|
||||||
|
if (configFilePath && !fs.existsSync(configPath)) {
|
||||||
|
throw new Error(`SSH config file not found: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = parseConfigFile(configPath, new Set());
|
||||||
|
return matchHost(hostAlias, blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 SSH 配置文件
|
||||||
|
*/
|
||||||
|
function parseConfigFile(filePath: string, visited: Set<string>): HostBlock[] {
|
||||||
|
// 防止循环引用
|
||||||
|
const realPath = fs.realpathSync(filePath);
|
||||||
|
if (visited.has(realPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
visited.add(realPath);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const blocks: HostBlock[] = [];
|
||||||
|
let currentBlock: HostBlock | null = null;
|
||||||
|
|
||||||
|
for (let line of lines) {
|
||||||
|
// 移除注释和前后空白
|
||||||
|
const commentIndex = line.indexOf('#');
|
||||||
|
if (commentIndex !== -1) {
|
||||||
|
line = line.substring(0, commentIndex);
|
||||||
|
}
|
||||||
|
line = line.trim();
|
||||||
|
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// 解析 Include 指令
|
||||||
|
if (line.toLowerCase().startsWith('include ')) {
|
||||||
|
if (currentBlock) {
|
||||||
|
blocks.push(currentBlock);
|
||||||
|
currentBlock = null;
|
||||||
|
}
|
||||||
|
const pattern = line.substring(8).trim();
|
||||||
|
const includePaths = expandIncludePath(pattern, path.dirname(filePath));
|
||||||
|
for (const includePath of includePaths) {
|
||||||
|
if (fs.existsSync(includePath)) {
|
||||||
|
blocks.push(...parseConfigFile(includePath, visited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 Host 行
|
||||||
|
if (line.toLowerCase().startsWith('host ')) {
|
||||||
|
if (currentBlock) {
|
||||||
|
blocks.push(currentBlock);
|
||||||
|
}
|
||||||
|
const hostPatterns = line.substring(5).trim().split(/\s+/);
|
||||||
|
currentBlock = {
|
||||||
|
patterns: hostPatterns,
|
||||||
|
config: new Map()
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析配置项
|
||||||
|
if (!currentBlock) {
|
||||||
|
currentBlock = {
|
||||||
|
patterns: ['*'],
|
||||||
|
config: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceIndex = line.search(/\s/);
|
||||||
|
if (spaceIndex !== -1) {
|
||||||
|
const key = line.substring(0, spaceIndex).toLowerCase();
|
||||||
|
const value = line.substring(spaceIndex + 1).trim();
|
||||||
|
|
||||||
|
// 只保存第一次出现的值(SSH first-match-wins)
|
||||||
|
if (!currentBlock.config.has(key)) {
|
||||||
|
currentBlock.config.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBlock) {
|
||||||
|
blocks.push(currentBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展开 Include 路径(支持 ~ 和通配符)
|
||||||
|
*/
|
||||||
|
function expandIncludePath(pattern: string, baseDir: string): string[] {
|
||||||
|
// 展开 ~
|
||||||
|
if (pattern.startsWith('~/')) {
|
||||||
|
pattern = path.join(os.homedir(), pattern.substring(2));
|
||||||
|
} else if (pattern.startsWith('~')) {
|
||||||
|
// ~user 形式不支持,直接返回空
|
||||||
|
return [];
|
||||||
|
} else if (!path.isAbsolute(pattern)) {
|
||||||
|
// 相对路径相对于配置文件所在目录
|
||||||
|
pattern = path.join(baseDir, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 glob 展开通配符
|
||||||
|
try {
|
||||||
|
// Node.js 22+ 支持 fs.globSync
|
||||||
|
if (typeof fs.globSync === 'function') {
|
||||||
|
return fs.globSync(pattern);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// glob 失败时静默跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:无通配符时直接返回
|
||||||
|
if (!pattern.includes('*') && !pattern.includes('?')) {
|
||||||
|
return [pattern];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 匹配主机别名
|
||||||
|
*/
|
||||||
|
function matchHost(hostAlias: string, blocks: HostBlock[]): SshConfigEntry | null {
|
||||||
|
const result: SshConfigEntry = {};
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
const matched = hostBlockMatches(hostAlias, block.patterns);
|
||||||
|
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
// first-match-wins:只取第一个匹配到的值
|
||||||
|
if (!result.hostName && block.config.has('hostname')) {
|
||||||
|
result.hostName = block.config.get('hostname');
|
||||||
|
}
|
||||||
|
if (!result.user && block.config.has('user')) {
|
||||||
|
result.user = block.config.get('user');
|
||||||
|
}
|
||||||
|
if (!result.port && block.config.has('port')) {
|
||||||
|
const portStr = block.config.get('port');
|
||||||
|
const portNum = parseInt(portStr!, 10);
|
||||||
|
if (!isNaN(portNum)) {
|
||||||
|
result.port = portNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result.identityFile && block.config.has('identityfile')) {
|
||||||
|
result.identityFile = expandTilde(block.config.get('identityfile')!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(result).length > 0 ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostBlockMatches(hostAlias: string, patterns: string[]): boolean {
|
||||||
|
let positiveMatch = false;
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const isNegated = pattern.startsWith('!');
|
||||||
|
const patternBody = isNegated ? pattern.slice(1) : pattern;
|
||||||
|
if (!patternBody) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostPatternMatches(hostAlias, patternBody)) {
|
||||||
|
if (isNegated) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
positiveMatch = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return positiveMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostPatternMatches(hostAlias: string, pattern: string): boolean {
|
||||||
|
if (pattern === '*') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regexSource = pattern
|
||||||
|
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||||
|
.replace(/\*/g, '.*')
|
||||||
|
.replace(/\?/g, '.');
|
||||||
|
|
||||||
|
return new RegExp(`^${regexSource}$`).test(hostAlias);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展开路径中的 ~
|
||||||
|
*/
|
||||||
|
function expandTilde(filePath: string): string {
|
||||||
|
if (filePath.startsWith('~/')) {
|
||||||
|
return path.join(os.homedir(), filePath.substring(2));
|
||||||
|
}
|
||||||
|
if (filePath === '~') {
|
||||||
|
return os.homedir();
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { ServerStatus } from "../models/types.js";
|
||||||
|
import { Logger } from "./logger.js";
|
||||||
|
|
||||||
|
type StatusCommandRunner = (
|
||||||
|
command: string,
|
||||||
|
connectionName: string,
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
type StatusCommandAuthorizer = (
|
||||||
|
command: string,
|
||||||
|
connectionName: string,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join the probes into a single remote command, each result introduced by a
|
||||||
|
* marker line.
|
||||||
|
*
|
||||||
|
* Running them separately costs one SSH channel per probe — open, pty request,
|
||||||
|
* exec and close, several round trips each — plus a remote shell per probe. In
|
||||||
|
* shell transport it is worse still: the per-connection queue serialises them,
|
||||||
|
* so the first command the user issues after connecting waits behind all of
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* Every probe is wrapped so that a missing tool or a non-zero exit cannot
|
||||||
|
* abort the rest, and the whole script ends successfully.
|
||||||
|
*/
|
||||||
|
function buildStatusScript(
|
||||||
|
commands: Record<string, string>,
|
||||||
|
marker: string,
|
||||||
|
): string {
|
||||||
|
const probes = Object.entries(commands).map(
|
||||||
|
([field, command]) =>
|
||||||
|
`printf '\\n${marker}${field}\\n'; { ${command}; } 2>/dev/null`,
|
||||||
|
);
|
||||||
|
return `${probes.join("; ")}; true`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStatusScriptOutput(
|
||||||
|
output: string,
|
||||||
|
marker: string,
|
||||||
|
): Map<string, string> {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const normalized = output.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
// Splitting on a capturing group yields [before, field, value, field, ...].
|
||||||
|
const segments = normalized.split(new RegExp(`\\n?${marker}(\\w+)\\n`));
|
||||||
|
|
||||||
|
for (let index = 1; index < segments.length; index += 2) {
|
||||||
|
values.set(segments[index], (segments[index + 1] ?? "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect system status information from remote server
|
||||||
|
*/
|
||||||
|
export async function collectSystemStatus(
|
||||||
|
runCommand: StatusCommandRunner,
|
||||||
|
connectionName: string,
|
||||||
|
isCommandAllowed: StatusCommandAuthorizer = () => true,
|
||||||
|
): Promise<ServerStatus> {
|
||||||
|
const status: ServerStatus = {
|
||||||
|
reachable: true,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Collected in a single remote command; see buildStatusScript.
|
||||||
|
const commands = {
|
||||||
|
hostname: "hostname",
|
||||||
|
ipAddresses: "ip -o addr show | awk '{print $4}' | grep -v '^127\\.' | cut -d'/' -f1",
|
||||||
|
osName: "uname -s",
|
||||||
|
osVersion: "cat /etc/os-release 2>/dev/null | grep '^PRETTY_NAME=' | cut -d'=' -f2 | tr -d '\"' || uname -o",
|
||||||
|
kernelVersion: "uname -r",
|
||||||
|
uptime: "uptime -p 2>/dev/null || uptime | awk -F'up ' '{print $2}' | awk -F',' '{print $1}'",
|
||||||
|
diskSpace: "df -h / | tail -1 | awk '{print \"free:\" $4 \" total:\" $2}'",
|
||||||
|
memory: "free -h | grep '^Mem:' | awk '{print \"free:\" $7 \" total:\" $2}'",
|
||||||
|
cpuName: "sh -c '(lscpu 2>/dev/null | grep \"^Model name:\" | cut -d\":\" -f2 | xargs || cat /proc/cpuinfo 2>/dev/null | grep \"model name\" | head -1 | cut -d\":\" -f2 | xargs || echo \"$(nproc 2>/dev/null || echo '\''?'\'')-core $(uname -m 2>/dev/null || echo '\''unknown'\'') processor\") || true'",
|
||||||
|
cpuUsage: "top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/' | awk '{print 100 - $1}'",
|
||||||
|
gpus: "sh -c '(nvidia-smi --query-gpu=name,utilization.gpu --format=csv,noheader,nounits 2>/dev/null | while IFS=\",\" read -r name usage; do echo \"NVIDIA|${name}|${usage}\"; done || lspci | grep -iE \"vga|3d|display\" | while read -r line; do gpu_name=$(echo \"$line\" | cut -d\":\" -f3 | xargs); echo \"OTHER|${gpu_name}|\"; done) || true'",
|
||||||
|
gpuPaths: "ls -1 /dev/dri/card* 2>/dev/null | sort -V || echo ''",
|
||||||
|
drives: "df -h | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|overlay|shfs|rootfs)$/ && $6 !~ /^(\\/dev|\\/run|\\/sys|\\/proc|\\/boot|\\/usr|\\/lib)$/ && $6 != \"\" {print $1\"|\"$2\"|\"$3\"|\"$4\"|\"$5\"|\"$6}'",
|
||||||
|
// Old gpuPaths: "sh -c '(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>/dev/null | head -1 || rocm-smi --showuse 2>/dev/null | grep -i \"GPU use\" | head -1 | awk \"{print \\$NF}\" | tr -d \"%\" || radeontop -l 1 -d - 2>/dev/null | tail -1 | sed -n \"s/.*gpu \\([0-9.]*\\)%.*/\\1/p\" || intel_gpu_top -l 1 -o - 2>/dev/null | tail -1 | awk \"{print \\$NF}\" | tr -d \"%\" || echo \"N/A\") || echo \"N/A\"'",
|
||||||
|
processes: "ps aux | wc -l",
|
||||||
|
threads: "ps -eLf | wc -l",
|
||||||
|
servicesRunning: "systemctl list-units --type=service --state=running 2>/dev/null | wc -l || service --status-all 2>/dev/null | grep running | wc -l || echo '0'",
|
||||||
|
servicesInstalled: "systemctl list-unit-files --type=service 2>/dev/null | wc -l || ls /etc/init.d/ 2>/dev/null | wc -l || echo '0'",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate each probe before batching. Validating only the combined script
|
||||||
|
// would allow one whitelist match to authorize every command in it.
|
||||||
|
const allowedCommands = Object.fromEntries(
|
||||||
|
Object.entries(commands).filter(([, command]) =>
|
||||||
|
isCommandAllowed(command, connectionName),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute the allowed probes and collect results.
|
||||||
|
const marker = `__MCP_FIELD_${Math.random().toString(16).slice(2, 10)}_`;
|
||||||
|
let values = new Map<string, string>();
|
||||||
|
try {
|
||||||
|
if (Object.keys(allowedCommands).length === 0) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
values = parseStatusScriptOutput(
|
||||||
|
await runCommand(
|
||||||
|
buildStatusScript(allowedCommands, marker),
|
||||||
|
connectionName,
|
||||||
|
),
|
||||||
|
marker,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// A rejected command (an unreachable host, a command whitelist that does
|
||||||
|
// not admit the probe) leaves every field unset, the same as when the
|
||||||
|
// probes ran separately and each failed on its own.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse results
|
||||||
|
const readField = (field: keyof typeof commands): string =>
|
||||||
|
values.get(field) ?? "";
|
||||||
|
|
||||||
|
const hostnameValue = readField("hostname");
|
||||||
|
const ipAddressesValue = readField("ipAddresses");
|
||||||
|
const osNameValue = readField("osName");
|
||||||
|
const osVersionValue = readField("osVersion");
|
||||||
|
const kernelVersionValue = readField("kernelVersion");
|
||||||
|
const uptimeValue = readField("uptime");
|
||||||
|
const diskSpaceValue = readField("diskSpace");
|
||||||
|
const memoryValue = readField("memory");
|
||||||
|
const cpuNameValue = readField("cpuName");
|
||||||
|
const cpuUsageValue = readField("cpuUsage");
|
||||||
|
const gpusValue = readField("gpus");
|
||||||
|
const gpuPathsValue = readField("gpuPaths");
|
||||||
|
const drivesValue = readField("drives");
|
||||||
|
const processesValue = readField("processes");
|
||||||
|
const threadsValue = readField("threads");
|
||||||
|
const servicesRunningValue = readField("servicesRunning");
|
||||||
|
const servicesInstalledValue = readField("servicesInstalled");
|
||||||
|
|
||||||
|
if (hostnameValue) {
|
||||||
|
status.hostname = hostnameValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ipAddressesValue) {
|
||||||
|
status.ipAddresses = ipAddressesValue
|
||||||
|
.split("\n")
|
||||||
|
.filter((ip) => ip.trim() && !ip.includes("127.0.0.1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (osNameValue) {
|
||||||
|
status.osName = osNameValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (osVersionValue) {
|
||||||
|
status.osVersion = osVersionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kernelVersionValue) {
|
||||||
|
status.kernelVersion = kernelVersionValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uptimeValue) {
|
||||||
|
status.uptime = uptimeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diskSpaceValue) {
|
||||||
|
const diskMatch = diskSpaceValue.match(/free:(\S+)\s+total:(\S+)/);
|
||||||
|
if (diskMatch) {
|
||||||
|
status.diskSpace = {
|
||||||
|
free: diskMatch[1],
|
||||||
|
total: diskMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (memoryValue) {
|
||||||
|
const memMatch = memoryValue.match(/free:(\S+)\s+total:(\S+)/);
|
||||||
|
if (memMatch) {
|
||||||
|
status.memory = {
|
||||||
|
free: memMatch[1],
|
||||||
|
total: memMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle CPU name
|
||||||
|
if (cpuNameValue && cpuNameValue.trim()) {
|
||||||
|
status.cpu = {
|
||||||
|
name: cpuNameValue.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.cpu && cpuUsageValue && cpuUsageValue !== "N/A") {
|
||||||
|
status.cpu.usage = `${parseFloat(cpuUsageValue).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle GPUs
|
||||||
|
if (gpusValue && gpusValue.trim()) {
|
||||||
|
const gpuPaths: string[] = [];
|
||||||
|
if (gpuPathsValue) {
|
||||||
|
gpuPaths.push(...gpuPathsValue.split("\n").filter((p) => p.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpuLines = gpusValue.split("\n").filter((line) => line.trim());
|
||||||
|
const gpus: Array<{ name: string; usage?: string; path?: string }> = [];
|
||||||
|
|
||||||
|
gpuLines.forEach((line, index) => {
|
||||||
|
const parts = line.split("|");
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const name = parts[1].trim();
|
||||||
|
const usage = parts[2]?.trim();
|
||||||
|
|
||||||
|
if (name && name !== "N/A") {
|
||||||
|
const gpu: { name: string; usage?: string; path?: string } = {
|
||||||
|
name: name,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (usage && usage.trim() !== "" && usage.trim() !== "N/A" && !isNaN(parseFloat(usage.trim()))) {
|
||||||
|
gpu.usage = `${parseFloat(usage.trim()).toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign path if available
|
||||||
|
if (gpuPaths[index]) {
|
||||||
|
gpu.path = gpuPaths[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
gpus.push(gpu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (gpus.length > 0) {
|
||||||
|
status.gpus = gpus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle drives
|
||||||
|
if (drivesValue && drivesValue.trim()) {
|
||||||
|
const driveLines = drivesValue.split("\n").filter((line) => line.trim());
|
||||||
|
const drives: Array<{
|
||||||
|
device: string;
|
||||||
|
mountPoint: string;
|
||||||
|
total: string;
|
||||||
|
used: string;
|
||||||
|
free: string;
|
||||||
|
usagePercent: string;
|
||||||
|
filesystem?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
driveLines.forEach((line) => {
|
||||||
|
const parts = line.split("|");
|
||||||
|
if (parts.length >= 6) {
|
||||||
|
const device = parts[0].trim();
|
||||||
|
const total = parts[1].trim();
|
||||||
|
const used = parts[2].trim();
|
||||||
|
const free = parts[3].trim();
|
||||||
|
const usagePercent = parts[4].trim();
|
||||||
|
const mountPoint = parts[5].trim();
|
||||||
|
|
||||||
|
if (device && mountPoint) {
|
||||||
|
drives.push({
|
||||||
|
device,
|
||||||
|
mountPoint,
|
||||||
|
total,
|
||||||
|
used,
|
||||||
|
free,
|
||||||
|
usagePercent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (drives.length > 0) {
|
||||||
|
status.drives = drives;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processesValue || threadsValue) {
|
||||||
|
const processCount = parseInt(processesValue || "0", 10) - 1; // Subtract header line
|
||||||
|
const threadCount = parseInt(threadsValue || "0", 10) - 1; // Subtract header line
|
||||||
|
status.processes = {
|
||||||
|
running: Math.max(0, processCount),
|
||||||
|
threads: Math.max(0, threadCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servicesRunningValue || servicesInstalledValue) {
|
||||||
|
const runningCount = parseInt(servicesRunningValue || "0", 10) - 1; // Subtract header line
|
||||||
|
const installedCount = parseInt(servicesInstalledValue || "0", 10) - 1; // Subtract header line
|
||||||
|
status.services = {
|
||||||
|
running: Math.max(0, runningCount),
|
||||||
|
installed: Math.max(0, installedCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.log(
|
||||||
|
`Failed to collect system status for [${connectionName}]: ${(error as Error).message}`,
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
status.reachable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export type ToolErrorCode =
|
||||||
|
| "COMMAND_VALIDATION_FAILED"
|
||||||
|
| "COMMAND_EXECUTION_ERROR"
|
||||||
|
| "OUTPUT_LIMIT_EXCEEDED"
|
||||||
|
| "COMMAND_TIMEOUT"
|
||||||
|
| "SSH_CONNECTION_FAILED"
|
||||||
|
| "SSH_CONNECTION_TIMEOUT"
|
||||||
|
| "SSH_AUTHENTICATION_MISSING"
|
||||||
|
| "SSH_CONFIG_MISSING"
|
||||||
|
| "LOCAL_PATH_NOT_ALLOWED"
|
||||||
|
| "REMOTE_PATH_NOT_ALLOWED"
|
||||||
|
| "LOCAL_FILE_READ_FAILED"
|
||||||
|
| "LOCAL_FILE_WRITE_FAILED"
|
||||||
|
| "OPERATION_TIMEOUT"
|
||||||
|
| "SFTP_ERROR"
|
||||||
|
| "UNSUPPORTED_IN_SHELL_MODE"
|
||||||
|
| "UNKNOWN_ERROR";
|
||||||
|
|
||||||
|
export class ToolError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: ToolErrorCode,
|
||||||
|
message: string,
|
||||||
|
public readonly retriable: boolean = false,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ToolError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toToolError(
|
||||||
|
error: unknown,
|
||||||
|
fallbackCode: ToolErrorCode,
|
||||||
|
): ToolError {
|
||||||
|
if (error instanceof ToolError) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new ToolError(fallbackCode, error.message, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ToolError(fallbackCode, String(error), false);
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
# 测试文档
|
||||||
|
|
||||||
|
本项目使用 Node.js 内置的测试框架进行单元测试和集成测试。
|
||||||
|
|
||||||
|
## 测试结构
|
||||||
|
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── ssh-config-parser.test.js # SSH 配置解析器测试
|
||||||
|
├── command-line-parser.test.js # 命令行参数解析器测试
|
||||||
|
├── ssh-connection-manager.test.js # SSH 连接管理器测试
|
||||||
|
├── integration.test.js # 集成测试
|
||||||
|
└── fixtures/ # 测试数据(自动生成)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行测试
|
||||||
|
|
||||||
|
### 运行所有测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### 监听模式(开发时使用)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
监听模式会在文件变化时自动重新运行测试。
|
||||||
|
|
||||||
|
### 运行单个测试文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test test/ssh-config-parser.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试覆盖范围
|
||||||
|
|
||||||
|
### 1. SSH Config Parser 测试
|
||||||
|
|
||||||
|
测试 `src/utils/ssh-config-parser.ts` 的功能:
|
||||||
|
|
||||||
|
- ✅ 基本 Host 别名解析
|
||||||
|
- ✅ 多别名 Host 行(`Host a b c`)
|
||||||
|
- ✅ 通配符匹配(`Host *.example.com`)
|
||||||
|
- ✅ `Host *` 默认值 fallback
|
||||||
|
- ✅ `Include` 指令支持
|
||||||
|
- ✅ 路径展开(`~` 和相对路径)
|
||||||
|
- ✅ First-match-wins 语义
|
||||||
|
- ✅ 错误处理(文件不存在等)
|
||||||
|
|
||||||
|
### 2. Command Line Parser 测试
|
||||||
|
|
||||||
|
测试 `src/cli/command-line-parser.ts` 的功能:
|
||||||
|
|
||||||
|
- ✅ JSON 配置文件解析(对象和数组格式)
|
||||||
|
- ✅ `--ssh` 参数解析(JSON 和旧格式)
|
||||||
|
- ✅ 单连接模式(命令行参数和位置参数)
|
||||||
|
- ✅ SSH config 集成
|
||||||
|
- ✅ 参数优先级(命令行 > SSH config)
|
||||||
|
- ✅ 命令白名单和黑名单
|
||||||
|
- ✅ 其他选项(`--pty`, `--pre-connect`, `--proxy`, `--socksProxy`)
|
||||||
|
- ✅ 错误处理
|
||||||
|
|
||||||
|
### 3. SSH Connection Manager 测试
|
||||||
|
|
||||||
|
测试 `src/services/ssh-connection-manager.ts` 的功能:
|
||||||
|
|
||||||
|
- ✅ 配置管理(初始化、获取配置)
|
||||||
|
- ✅ 命令验证(白名单、黑名单、正则表达式)
|
||||||
|
- ✅ 连接状态管理
|
||||||
|
- ✅ 多服务器支持
|
||||||
|
|
||||||
|
### 4. 集成测试
|
||||||
|
|
||||||
|
端到端测试完整流程:
|
||||||
|
|
||||||
|
- ✅ 从命令行参数到连接管理器的完整流程
|
||||||
|
- ✅ 从 SSH config 到连接管理器的完整流程
|
||||||
|
- ✅ 多服务器配置场景
|
||||||
|
- ✅ 错误处理(无效配置、缺少字段等)
|
||||||
|
|
||||||
|
## 编写新测试
|
||||||
|
|
||||||
|
### 测试文件命名
|
||||||
|
|
||||||
|
测试文件应该以 `.test.js` 结尾,并放在 `test/` 目录下。
|
||||||
|
|
||||||
|
### 测试示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { describe, it, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
|
describe('功能模块名称', () => {
|
||||||
|
before(() => {
|
||||||
|
// 测试前的准备工作
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
// 测试后的清理工作
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('子功能', () => {
|
||||||
|
it('应该做某事', () => {
|
||||||
|
// 测试代码
|
||||||
|
assert.strictEqual(1 + 1, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该处理错误情况', () => {
|
||||||
|
assert.throws(() => {
|
||||||
|
throw new Error('测试错误');
|
||||||
|
}, /测试错误/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试最佳实践
|
||||||
|
|
||||||
|
1. **独立性**:每个测试应该独立运行,不依赖其他测试的状态
|
||||||
|
2. **清理**:使用 `after` 钩子清理测试创建的临时文件和资源
|
||||||
|
3. **描述性**:测试名称应该清楚地描述测试的内容
|
||||||
|
4. **覆盖边界情况**:测试正常情况、边界情况和错误情况
|
||||||
|
5. **使用 fixtures**:将测试数据放在 `test/fixtures/` 目录下
|
||||||
|
|
||||||
|
## CI/CD 集成
|
||||||
|
|
||||||
|
测试可以轻松集成到 CI/CD 流程中:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# GitHub Actions 示例
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试测试
|
||||||
|
|
||||||
|
### 使用 Node.js 调试器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --inspect-brk --test test/ssh-config-parser.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 Chrome 中打开 `chrome://inspect` 进行调试。
|
||||||
|
|
||||||
|
### 查看详细输出
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test --test-reporter=tap test/**/*.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 测试失败但没有详细错误信息?
|
||||||
|
|
||||||
|
A: 使用 `--test-reporter=spec` 查看详细输出:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --test --test-reporter=spec test/**/*.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何跳过某个测试?
|
||||||
|
|
||||||
|
A: 使用 `it.skip()`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it.skip('暂时跳过的测试', () => {
|
||||||
|
// 测试代码
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Q: 如何只运行某个测试?
|
||||||
|
|
||||||
|
A: 使用 `it.only()`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
it.only('只运行这个测试', () => {
|
||||||
|
// 测试代码
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
提交 PR 时,请确保:
|
||||||
|
|
||||||
|
1. 所有测试通过:`npm test`
|
||||||
|
2. 新功能有对应的测试
|
||||||
|
3. 测试覆盖了正常情况和边界情况
|
||||||
|
4. 代码编译通过:`npm run build`
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SERVER_CONFIG } from '../build/config/server.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const rootDir = path.resolve(__dirname, '..');
|
||||||
|
const entrypoint = path.join(rootDir, 'build', 'index.js');
|
||||||
|
const packageJson = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')
|
||||||
|
);
|
||||||
|
|
||||||
|
const expectedHelpOptions = [
|
||||||
|
'--config-file <path>',
|
||||||
|
'--ssh-config-file <path>',
|
||||||
|
'--ssh <config>',
|
||||||
|
'-h, --host <host>',
|
||||||
|
'-p, --port <port>',
|
||||||
|
'-u, --username <name>',
|
||||||
|
'-w, --password <password>',
|
||||||
|
'-k, --privateKey <path>',
|
||||||
|
'-P, --passphrase <passphrase>',
|
||||||
|
'-a, --agent <path>',
|
||||||
|
'-W, --whitelist <patterns>',
|
||||||
|
'-B, --blacklist <patterns>',
|
||||||
|
'--proxy <url>',
|
||||||
|
'-s, --socksProxy <url>',
|
||||||
|
'--allowed-local-paths <paths>',
|
||||||
|
'--allowed-remote-paths <paths>',
|
||||||
|
'--transport-mode <mode>',
|
||||||
|
'--shell-ready-timeout <ms>',
|
||||||
|
'--command-template <template>',
|
||||||
|
'--pty',
|
||||||
|
'--try-keyboard',
|
||||||
|
'--pre-connect',
|
||||||
|
'--version, -v',
|
||||||
|
'--help',
|
||||||
|
];
|
||||||
|
|
||||||
|
function runCli(args) {
|
||||||
|
return spawnSync(process.execPath, [entrypoint, ...args], {
|
||||||
|
cwd: rootDir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CLI info flags', () => {
|
||||||
|
it('keeps the MCP server version in sync with package metadata', () => {
|
||||||
|
assert.strictEqual(SERVER_CONFIG.version, packageJson.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints package version and exits successfully for --version', () => {
|
||||||
|
const result = runCli(['--version']);
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout.trim(), packageJson.version);
|
||||||
|
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints package version and exits successfully for -v', () => {
|
||||||
|
const result = runCli(['-v']);
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.strictEqual(result.stdout.trim(), packageJson.version);
|
||||||
|
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints help and exits successfully for --help', () => {
|
||||||
|
const result = runCli(['--help']);
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.match(result.stdout, /Usage: ssh-mcp-server/);
|
||||||
|
for (const option of expectedHelpOptions) {
|
||||||
|
assert.ok(
|
||||||
|
result.stdout.includes(option),
|
||||||
|
`Expected help output to include ${option}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles info flags before normal config parsing', () => {
|
||||||
|
const missingConfigPath = path.join(rootDir, 'missing-cli-info-config.json');
|
||||||
|
const result = runCli(['--config-file', missingConfigPath, '--help']);
|
||||||
|
|
||||||
|
assert.strictEqual(result.status, 0);
|
||||||
|
assert.match(result.stdout, /Usage: ssh-mcp-server/);
|
||||||
|
assert.doesNotMatch(result.stderr, /Config file not found/);
|
||||||
|
assert.doesNotMatch(result.stderr, /Unknown option/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
import { describe, it, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { CommandLineParser } from '../build/cli/command-line-parser.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
|
||||||
|
describe('Command Line Parser', () => {
|
||||||
|
let originalArgv;
|
||||||
|
let fixturesDir;
|
||||||
|
let testConfigPath;
|
||||||
|
let testSshConfigPath;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
originalArgv = process.argv;
|
||||||
|
|
||||||
|
// 创建测试配置文件
|
||||||
|
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-cli-test-'));
|
||||||
|
|
||||||
|
testConfigPath = path.join(fixturesDir, 'test-config.json');
|
||||||
|
testSshConfigPath = path.join(fixturesDir, 'test-ssh-config');
|
||||||
|
|
||||||
|
// 创建 JSON 配置文件
|
||||||
|
fs.writeFileSync(testConfigPath, JSON.stringify({
|
||||||
|
dev: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'devuser',
|
||||||
|
password: 'devpass'
|
||||||
|
},
|
||||||
|
prod: {
|
||||||
|
host: '10.0.0.50',
|
||||||
|
port: 22,
|
||||||
|
username: 'produser',
|
||||||
|
privateKey: '~/.ssh/prod_key'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 创建 SSH 配置文件
|
||||||
|
fs.writeFileSync(testSshConfigPath, `
|
||||||
|
Host testhost
|
||||||
|
HostName 172.16.0.1
|
||||||
|
Port 2222
|
||||||
|
User testuser
|
||||||
|
IdentityFile ~/.ssh/test_key
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
fs.rmSync(fixturesDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('配置文件解析', () => {
|
||||||
|
it('应该正确解析 JSON 配置文件(对象格式)', () => {
|
||||||
|
process.argv = ['node', 'test', '--config-file', testConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||||
|
assert.strictEqual(result.configs.dev.host, '192.168.1.100');
|
||||||
|
assert.strictEqual(result.configs.dev.username, 'devuser');
|
||||||
|
assert.strictEqual(result.configs.prod.privateKey, path.join(os.homedir(), '.ssh', 'prod_key'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析 JSON 配置文件(数组格式)', () => {
|
||||||
|
const arrayConfigPath = path.join(fixturesDir, 'array-config.json');
|
||||||
|
fs.writeFileSync(arrayConfigPath, JSON.stringify([
|
||||||
|
{
|
||||||
|
name: 'server1',
|
||||||
|
host: '1.2.3.4',
|
||||||
|
port: 22,
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'server2',
|
||||||
|
host: '5.6.7.8',
|
||||||
|
port: 2222,
|
||||||
|
username: 'user2',
|
||||||
|
privateKey: '~/.ssh/key2'
|
||||||
|
}
|
||||||
|
]));
|
||||||
|
|
||||||
|
process.argv = ['node', 'test', '--config-file', arrayConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||||
|
assert.strictEqual(result.configs.server1.host, '1.2.3.4');
|
||||||
|
assert.strictEqual(result.configs.server2.port, 2222);
|
||||||
|
|
||||||
|
fs.unlinkSync(arrayConfigPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该保留 JSON 配置中的 SSH algorithms', () => {
|
||||||
|
const algorithmsConfigPath = path.join(fixturesDir, 'algorithms-config.json');
|
||||||
|
const algorithms = {
|
||||||
|
serverHostKey: { append: ['ssh-rsa'] },
|
||||||
|
hmac: ['hmac-sha1', 'hmac-md5']
|
||||||
|
};
|
||||||
|
fs.writeFileSync(algorithmsConfigPath, JSON.stringify({
|
||||||
|
legacy: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'legacy-user',
|
||||||
|
password: 'legacy-pass',
|
||||||
|
algorithms
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', algorithmsConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result.configs.legacy.algorithms, algorithms);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(algorithmsConfigPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该保留并校验 JSON 配置中的 maxOutputBytes', () => {
|
||||||
|
const outputLimitConfigPath = path.join(fixturesDir, 'output-limit-config.json');
|
||||||
|
fs.writeFileSync(outputLimitConfigPath, JSON.stringify({
|
||||||
|
limited: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'limited-user',
|
||||||
|
password: 'limited-pass',
|
||||||
|
maxOutputBytes: 2048
|
||||||
|
},
|
||||||
|
unlimited: {
|
||||||
|
host: '192.168.1.101',
|
||||||
|
port: 22,
|
||||||
|
username: 'unlimited-user',
|
||||||
|
password: 'unlimited-pass',
|
||||||
|
maxOutputBytes: 0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', outputLimitConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.limited.maxOutputBytes, 2048);
|
||||||
|
assert.strictEqual(result.configs.unlimited.maxOutputBytes, 0);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(outputLimitConfigPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应从配置文件解析 commandTimeoutMs', () => {
|
||||||
|
const timeoutConfigPath = path.join(fixturesDir, 'command-timeout-config.json');
|
||||||
|
fs.writeFileSync(timeoutConfigPath, JSON.stringify({
|
||||||
|
slow: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'slow-user',
|
||||||
|
password: 'slow-pass',
|
||||||
|
commandTimeoutMs: 180000
|
||||||
|
},
|
||||||
|
plain: {
|
||||||
|
host: '192.168.1.101',
|
||||||
|
port: 22,
|
||||||
|
username: 'plain-user',
|
||||||
|
password: 'plain-pass'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', timeoutConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.slow.commandTimeoutMs, 180000);
|
||||||
|
assert.strictEqual(result.configs.plain.commandTimeoutMs, undefined);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(timeoutConfigPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无效的 commandTimeoutMs 应抛出错误', () => {
|
||||||
|
const invalidTimeoutPath = path.join(fixturesDir, 'invalid-command-timeout-config.json');
|
||||||
|
fs.writeFileSync(invalidTimeoutPath, JSON.stringify({
|
||||||
|
invalid: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'invalid-user',
|
||||||
|
password: 'invalid-pass',
|
||||||
|
commandTimeoutMs: 0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', invalidTimeoutPath];
|
||||||
|
assert.throws(
|
||||||
|
() => CommandLineParser.parseArgs(),
|
||||||
|
/commandTimeoutMs must be a positive number/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(invalidTimeoutPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无效的 maxOutputBytes 应抛出错误', () => {
|
||||||
|
const invalidConfigPath = path.join(fixturesDir, 'invalid-output-limit-config.json');
|
||||||
|
fs.writeFileSync(invalidConfigPath, JSON.stringify({
|
||||||
|
invalid: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'invalid-user',
|
||||||
|
password: 'invalid-pass',
|
||||||
|
maxOutputBytes: -1
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', invalidConfigPath];
|
||||||
|
assert.throws(
|
||||||
|
() => CommandLineParser.parseArgs(),
|
||||||
|
/maxOutputBytes must be a non-negative integer/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(invalidConfigPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配置文件不存在时应抛出错误', () => {
|
||||||
|
process.argv = ['node', 'test', '--config-file', '/nonexistent/config.json'];
|
||||||
|
assert.throws(() => {
|
||||||
|
CommandLineParser.parseArgs();
|
||||||
|
}, /not found/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('动态模式(无连接参数)', () => {
|
||||||
|
it('没有任何连接参数时应返回空配置以允许按调用动态连接', () => {
|
||||||
|
process.argv = ['node', 'test'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
assert.deepStrictEqual(result.configs, {});
|
||||||
|
assert.strictEqual(result.preConnect, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('只传 --pre-connect 而无连接参数时同样返回空配置', () => {
|
||||||
|
process.argv = ['node', 'test', '--pre-connect'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
assert.deepStrictEqual(result.configs, {});
|
||||||
|
assert.strictEqual(result.preConnect, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('--ssh 参数解析', () => {
|
||||||
|
it('应该正确解析 JSON 格式的 --ssh 参数', () => {
|
||||||
|
const sshJson = JSON.stringify({
|
||||||
|
name: 'test',
|
||||||
|
host: '1.2.3.4',
|
||||||
|
port: 22,
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpass',
|
||||||
|
transportMode: 'shell',
|
||||||
|
shellReadyTimeoutMs: 15000,
|
||||||
|
maxOutputBytes: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
process.argv = ['node', 'test', '--ssh', sshJson];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.test.host, '1.2.3.4');
|
||||||
|
assert.strictEqual(result.configs.test.username, 'testuser');
|
||||||
|
assert.strictEqual(result.configs.test.transportMode, 'shell');
|
||||||
|
assert.strictEqual(result.configs.test.shellReadyTimeoutMs, 15000);
|
||||||
|
assert.strictEqual(result.configs.test.maxOutputBytes, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析旧格式的 --ssh 参数', () => {
|
||||||
|
process.argv = ['node', 'test', '--ssh', 'name=legacy,host=1.2.3.4,port=22,user=legacyuser,password=legacypass'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.legacy.host, '1.2.3.4');
|
||||||
|
assert.strictEqual(result.configs.legacy.username, 'legacyuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持多个 --ssh 参数', () => {
|
||||||
|
const ssh1 = JSON.stringify({ name: 'server1', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
|
||||||
|
const ssh2 = JSON.stringify({ name: 'server2', host: '2.2.2.2', port: 22, username: 'user2', password: 'pass2' });
|
||||||
|
|
||||||
|
process.argv = ['node', 'test', '--ssh', ssh1, '--ssh', ssh2];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||||
|
assert.strictEqual(result.configs.server1.host, '1.1.1.1');
|
||||||
|
assert.strictEqual(result.configs.server2.host, '2.2.2.2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('单连接模式(旧格式)', () => {
|
||||||
|
it('应该正确解析命令行参数', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'testuser', '--password', 'testpass'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.host, '1.2.3.4');
|
||||||
|
assert.strictEqual(result.configs.default.port, 22);
|
||||||
|
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||||
|
assert.strictEqual(result.configs.default.password, 'testpass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析位置参数', () => {
|
||||||
|
process.argv = ['node', 'test', '1.2.3.4', '22', 'testuser', 'testpass'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.host, '1.2.3.4');
|
||||||
|
assert.strictEqual(result.configs.default.port, 22);
|
||||||
|
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||||
|
assert.strictEqual(result.configs.default.password, 'testpass');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持私钥认证', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'testuser', '--privateKey', '~/.ssh/id_rsa'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.privateKey, path.join(os.homedir(), '.ssh', 'id_rsa'));
|
||||||
|
assert.strictEqual(result.configs.default.password, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('显式 password 存在时不应注入环境 SSH agent', () => {
|
||||||
|
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||||
|
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--username', 'testuser', '--password', 'testpass'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.password, 'testpass');
|
||||||
|
assert.strictEqual(result.configs.default.agent, undefined);
|
||||||
|
} finally {
|
||||||
|
if (originalSshAuthSock === undefined) {
|
||||||
|
delete process.env.SSH_AUTH_SOCK;
|
||||||
|
} else {
|
||||||
|
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('显式 privateKey 存在时不应注入环境 SSH agent', () => {
|
||||||
|
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||||
|
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--username', 'testuser', '--privateKey', '~/.ssh/id_rsa'];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.privateKey, path.join(os.homedir(), '.ssh', 'id_rsa'));
|
||||||
|
assert.strictEqual(result.configs.default.agent, undefined);
|
||||||
|
} finally {
|
||||||
|
if (originalSshAuthSock === undefined) {
|
||||||
|
delete process.env.SSH_AUTH_SOCK;
|
||||||
|
} else {
|
||||||
|
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺少必需参数时应抛出错误', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4'];
|
||||||
|
assert.throws(() => {
|
||||||
|
CommandLineParser.parseArgs();
|
||||||
|
}, /Missing required parameters/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SSH Config 集成', () => {
|
||||||
|
it('SSH config 缺少 Port 和 IdentityFile 时应使用默认端口和 SSH agent', () => {
|
||||||
|
const minimalSshConfigPath = path.join(fixturesDir, 'minimal-ssh-config');
|
||||||
|
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||||
|
fs.writeFileSync(minimalSshConfigPath, `
|
||||||
|
Host minimalhost
|
||||||
|
HostName 172.16.0.2
|
||||||
|
User minimaluser
|
||||||
|
`);
|
||||||
|
process.env.SSH_AUTH_SOCK = '/tmp/test-ssh-agent.sock';
|
||||||
|
process.argv = ['node', 'test', '--host', 'minimalhost', '--ssh-config-file', minimalSshConfigPath];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.host, '172.16.0.2');
|
||||||
|
assert.strictEqual(result.configs.default.port, 22);
|
||||||
|
assert.strictEqual(result.configs.default.username, 'minimaluser');
|
||||||
|
assert.strictEqual(result.configs.default.agent, '/tmp/test-ssh-agent.sock');
|
||||||
|
} finally {
|
||||||
|
if (originalSshAuthSock === undefined) {
|
||||||
|
delete process.env.SSH_AUTH_SOCK;
|
||||||
|
} else {
|
||||||
|
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||||
|
}
|
||||||
|
fs.unlinkSync(minimalSshConfigPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('命令行 port 和 agent 应覆盖 SSH config 默认值', () => {
|
||||||
|
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||||
|
process.env.SSH_AUTH_SOCK = '/tmp/environment-agent.sock';
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = [
|
||||||
|
'node', 'test', '--host', 'testhost', '--port', '3333', '--agent', '/tmp/explicit-agent.sock',
|
||||||
|
'--ssh-config-file', testSshConfigPath,
|
||||||
|
];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.port, 3333);
|
||||||
|
assert.strictEqual(result.configs.default.agent, '/tmp/explicit-agent.sock');
|
||||||
|
} finally {
|
||||||
|
if (originalSshAuthSock === undefined) {
|
||||||
|
delete process.env.SSH_AUTH_SOCK;
|
||||||
|
} else {
|
||||||
|
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该从 SSH config 读取连接参数', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', 'testhost', '--ssh-config-file', testSshConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.host, '172.16.0.1');
|
||||||
|
assert.strictEqual(result.configs.default.port, 2222);
|
||||||
|
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||||
|
assert.ok(
|
||||||
|
result.configs.default.privateKey.endsWith(path.join('.ssh', 'test_key')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('命令行参数应覆盖 SSH config 值', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', 'testhost', '--port', '3333', '--ssh-config-file', testSshConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.port, 3333); // 覆盖
|
||||||
|
assert.strictEqual(result.configs.default.host, '172.16.0.1'); // 从 SSH config
|
||||||
|
assert.strictEqual(result.configs.default.username, 'testuser'); // 从 SSH config
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持 SSH config 别名 + 密码认证', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', 'testhost', '--password', 'mypass', '--ssh-config-file', testSshConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.password, 'mypass');
|
||||||
|
assert.strictEqual(result.configs.default.host, '172.16.0.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('命令白名单和黑名单', () => {
|
||||||
|
it('应该正确解析命令白名单', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--whitelist', 'ls,cat,grep'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result.configs.default.commandWhitelist, ['ls', 'cat', 'grep']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析命令黑名单', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--blacklist', 'rm,shutdown,reboot'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result.configs.default.commandBlacklist, ['rm', 'shutdown', 'reboot']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('其他选项', () => {
|
||||||
|
it('默认 transportMode 应为 exec', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.transportMode, 'exec');
|
||||||
|
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析 shell transport 相关选项', () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'test',
|
||||||
|
'--host', '1.2.3.4',
|
||||||
|
'--port', '22',
|
||||||
|
'--username', 'user',
|
||||||
|
'--password', 'pass',
|
||||||
|
'--transport-mode', 'shell',
|
||||||
|
'--shell-ready-timeout', '15000'
|
||||||
|
];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.transportMode, 'shell');
|
||||||
|
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析 --pty 选项', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--pty'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.pty, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析配置文件中的字符串 false pty', () => {
|
||||||
|
const ptyConfigPath = path.join(fixturesDir, 'pty-config.json');
|
||||||
|
fs.writeFileSync(ptyConfigPath, JSON.stringify({
|
||||||
|
dev: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'devuser',
|
||||||
|
password: 'devpass',
|
||||||
|
pty: 'false'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
process.argv = ['node', 'test', '--config-file', ptyConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.dev.pty, false);
|
||||||
|
|
||||||
|
fs.unlinkSync(ptyConfigPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析 --pre-connect 选项', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--pre-connect'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.preConnect, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析 SOCKS 代理', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--socksProxy', 'socks://proxy:1080'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.socksProxy, 'socks://proxy:1080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析通用代理', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--proxy', 'http://proxy:8080'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.proxy, 'http://proxy:8080');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析 allowed local paths', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-local-paths', './tmp,~/.ssh'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(result.configs.default.allowedLocalPaths));
|
||||||
|
assert.strictEqual(result.configs.default.allowedLocalPaths.length, 2);
|
||||||
|
assert.ok(result.configs.default.allowedLocalPaths.every((entry) => path.isAbsolute(entry)));
|
||||||
|
assert.strictEqual(result.configs.default.allowedLocalPaths[1], path.join(os.homedir(), '.ssh'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析 allowed remote paths', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-remote-paths', '/var/log,/home/ops/inbox/'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(
|
||||||
|
result.configs.default.allowedRemotePaths,
|
||||||
|
['/var/log', '/home/ops/inbox']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相对的 allowedRemotePaths 条目应抛出错误', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', '22', '--username', 'user', '--password', 'pass', '--allowed-remote-paths', 'var/log'];
|
||||||
|
assert.throws(() => CommandLineParser.parseArgs(), /absolute POSIX/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析配置文件中的 commandTemplate', () => {
|
||||||
|
const templateConfigPath = path.join(fixturesDir, 'template-config.json');
|
||||||
|
fs.writeFileSync(templateConfigPath, JSON.stringify({
|
||||||
|
dev: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'devuser',
|
||||||
|
password: 'devpass',
|
||||||
|
commandTemplate: "su root -c '<command>'"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
process.argv = ['node', 'test', '--config-file', templateConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.dev.commandTemplate, "su root -c '<command>'");
|
||||||
|
|
||||||
|
fs.unlinkSync(templateConfigPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持 commandTemplate 的 <quotedCommand> 占位符', () => {
|
||||||
|
const templateConfigPath = path.join(fixturesDir, 'quoted-template-config.json');
|
||||||
|
fs.writeFileSync(templateConfigPath, JSON.stringify({
|
||||||
|
dev: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'devuser',
|
||||||
|
password: 'devpass',
|
||||||
|
commandTemplate: "su root -c <quotedCommand>"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
process.argv = ['node', 'test', '--config-file', templateConfigPath];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.dev.commandTemplate, "su root -c <quotedCommand>");
|
||||||
|
|
||||||
|
fs.unlinkSync(templateConfigPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('commandTemplate 缺少 <command> 占位符时应抛出错误', () => {
|
||||||
|
const badConfigPath = path.join(fixturesDir, 'bad-template-config.json');
|
||||||
|
fs.writeFileSync(badConfigPath, JSON.stringify({
|
||||||
|
dev: {
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'devuser',
|
||||||
|
password: 'devpass',
|
||||||
|
commandTemplate: "su root -c 'missing placeholder'"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
process.argv = ['node', 'test', '--config-file', badConfigPath];
|
||||||
|
assert.throws(() => CommandLineParser.parseArgs(), /<command>/);
|
||||||
|
|
||||||
|
fs.unlinkSync(badConfigPath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('优先级测试', () => {
|
||||||
|
it('配置文件应优先于 --ssh 参数', () => {
|
||||||
|
const sshJson = JSON.stringify({ name: 'test', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
|
||||||
|
process.argv = ['node', 'test', '--config-file', testConfigPath, '--ssh', sshJson];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
// 应该只有配置文件中的服务器
|
||||||
|
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||||
|
assert.ok(result.configs.dev);
|
||||||
|
assert.ok(result.configs.prod);
|
||||||
|
assert.ok(!result.configs.test);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('--ssh 参数应优先于单连接模式', () => {
|
||||||
|
const sshJson = JSON.stringify({ name: 'test', host: '1.1.1.1', port: 22, username: 'user1', password: 'pass1' });
|
||||||
|
process.argv = ['node', 'test', '--ssh', sshJson, '--host', '2.2.2.2', '--port', '22', '--username', 'user2', '--password', 'pass2'];
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(Object.keys(result.configs).length, 1);
|
||||||
|
assert.strictEqual(result.configs.test.host, '1.1.1.1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { describe, it, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { CommandLineParser } from '../build/cli/command-line-parser.js';
|
||||||
|
import { SSHConnectionManager } from '../build/services/ssh-connection-manager.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
let fixturesDir;
|
||||||
|
|
||||||
|
describe('集成测试', () => {
|
||||||
|
let originalArgv;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
originalArgv = process.argv;
|
||||||
|
|
||||||
|
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-integration-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
fs.rmSync(fixturesDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('端到端场景', () => {
|
||||||
|
it('应该能够从命令行参数创建完整配置并传递给连接管理器', () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'test',
|
||||||
|
'--host', '192.168.1.100',
|
||||||
|
'--port', '22',
|
||||||
|
'--username', 'testuser',
|
||||||
|
'--password', 'testpass',
|
||||||
|
'--whitelist', 'ls,cat,grep',
|
||||||
|
'--blacklist', 'rm,shutdown'
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.host, '192.168.1.100');
|
||||||
|
assert.strictEqual(result.configs.default.port, 22);
|
||||||
|
assert.strictEqual(result.configs.default.username, 'testuser');
|
||||||
|
assert.deepStrictEqual(result.configs.default.commandWhitelist, ['ls', 'cat', 'grep']);
|
||||||
|
assert.deepStrictEqual(result.configs.default.commandBlacklist, ['rm', 'shutdown']);
|
||||||
|
|
||||||
|
// 验证可以用这个配置初始化连接管理器
|
||||||
|
const manager = SSHConnectionManager.getInstance();
|
||||||
|
manager.setConfig(result.configs);
|
||||||
|
|
||||||
|
const config = manager.getConfig('default');
|
||||||
|
assert.strictEqual(config.host, '192.168.1.100');
|
||||||
|
assert.strictEqual(config.username, 'testuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够从命令行参数生成 shell transport 配置', () => {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'test',
|
||||||
|
'--host', '192.168.1.110',
|
||||||
|
'--port', '22',
|
||||||
|
'--username', 'shelluser',
|
||||||
|
'--password', 'shellpass',
|
||||||
|
'--transport-mode', 'shell',
|
||||||
|
'--shell-ready-timeout', '18000'
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
assert.strictEqual(result.configs.default.transportMode, 'shell');
|
||||||
|
assert.strictEqual(result.configs.default.shellReadyTimeoutMs, 18000);
|
||||||
|
|
||||||
|
const manager = SSHConnectionManager.getInstance();
|
||||||
|
manager.setConfig(result.configs);
|
||||||
|
|
||||||
|
const config = manager.getConfig('default');
|
||||||
|
assert.strictEqual(config.transportMode, 'shell');
|
||||||
|
assert.strictEqual(config.shellReadyTimeoutMs, 18000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够从 SSH config 创建完整配置', () => {
|
||||||
|
const tempSshConfig = path.join(fixturesDir, 'integration-ssh-config');
|
||||||
|
fs.writeFileSync(tempSshConfig, [
|
||||||
|
'Host integration-test',
|
||||||
|
' HostName 192.168.1.200',
|
||||||
|
' Port 2222',
|
||||||
|
' User integrationuser',
|
||||||
|
' IdentityFile ~/.ssh/integration_key',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'test',
|
||||||
|
'--host', 'integration-test',
|
||||||
|
'--ssh-config-file', tempSshConfig
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
|
||||||
|
assert.strictEqual(result.configs.default.host, '192.168.1.200');
|
||||||
|
assert.strictEqual(result.configs.default.port, 2222);
|
||||||
|
assert.strictEqual(result.configs.default.username, 'integrationuser');
|
||||||
|
assert.ok(result.configs.default.privateKey.includes('integration_key'));
|
||||||
|
|
||||||
|
// 验证可以用这个配置初始化连接管理器
|
||||||
|
const manager = SSHConnectionManager.getInstance();
|
||||||
|
manager.setConfig(result.configs);
|
||||||
|
|
||||||
|
const config = manager.getConfig('default');
|
||||||
|
assert.strictEqual(config.host, '192.168.1.200');
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tempSshConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够处理多服务器配置', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'multi-server-config.json');
|
||||||
|
fs.writeFileSync(tempConfig, JSON.stringify({
|
||||||
|
server1: {
|
||||||
|
host: '192.168.1.1',
|
||||||
|
port: 22,
|
||||||
|
username: 'user1',
|
||||||
|
password: 'pass1',
|
||||||
|
commandWhitelist: ['^ls', '^cat']
|
||||||
|
},
|
||||||
|
server2: {
|
||||||
|
host: '192.168.1.2',
|
||||||
|
port: 2222,
|
||||||
|
username: 'user2',
|
||||||
|
privateKey: '~/.ssh/key2',
|
||||||
|
commandBlacklist: ['^rm', '^shutdown']
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', tempConfig];
|
||||||
|
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
assert.strictEqual(Object.keys(result.configs).length, 2);
|
||||||
|
|
||||||
|
const manager = SSHConnectionManager.getInstance();
|
||||||
|
manager.setConfig(result.configs);
|
||||||
|
|
||||||
|
// 验证配置列表
|
||||||
|
const allInfos = manager.getAllServerInfos();
|
||||||
|
assert.strictEqual(allInfos.length, 2);
|
||||||
|
|
||||||
|
const server1 = allInfos.find(i => i.name === 'server1');
|
||||||
|
assert.ok(server1);
|
||||||
|
assert.strictEqual(server1.host, '192.168.1.1');
|
||||||
|
assert.strictEqual(server1.connected, false);
|
||||||
|
|
||||||
|
const server2 = allInfos.find(i => i.name === 'server2');
|
||||||
|
assert.ok(server2);
|
||||||
|
assert.strictEqual(server2.port, 2222);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该能够从配置文件生成 shell transport 配置', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'shell-server-config.json');
|
||||||
|
fs.writeFileSync(tempConfig, JSON.stringify({
|
||||||
|
shellbox: {
|
||||||
|
host: '192.168.1.20',
|
||||||
|
port: 22,
|
||||||
|
username: 'shelluser',
|
||||||
|
password: 'shellpass',
|
||||||
|
transportMode: 'shell',
|
||||||
|
shellReadyTimeoutMs: 12000
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', tempConfig];
|
||||||
|
|
||||||
|
const result = CommandLineParser.parseArgs();
|
||||||
|
assert.strictEqual(result.configs.shellbox.transportMode, 'shell');
|
||||||
|
assert.strictEqual(result.configs.shellbox.shellReadyTimeoutMs, 12000);
|
||||||
|
|
||||||
|
const manager = SSHConnectionManager.getInstance();
|
||||||
|
manager.setConfig(result.configs);
|
||||||
|
|
||||||
|
const config = manager.getConfig('shellbox');
|
||||||
|
assert.strictEqual(config.transportMode, 'shell');
|
||||||
|
assert.strictEqual(config.shellReadyTimeoutMs, 12000);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('错误处理', () => {
|
||||||
|
it('应该正确处理无效的 JSON 配置文件', () => {
|
||||||
|
const invalidConfig = path.join(fixturesDir, 'invalid-config.json');
|
||||||
|
fs.writeFileSync(invalidConfig, '{ invalid json }');
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', invalidConfig];
|
||||||
|
assert.throws(() => {
|
||||||
|
CommandLineParser.parseArgs();
|
||||||
|
}, /Invalid JSON/);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(invalidConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理缺少必需字段的配置', () => {
|
||||||
|
const incompleteConfig = path.join(fixturesDir, 'incomplete-config.json');
|
||||||
|
fs.writeFileSync(incompleteConfig, JSON.stringify({
|
||||||
|
server1: {
|
||||||
|
host: '192.168.1.1'
|
||||||
|
// 缺少 port, username 等必需字段
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.argv = ['node', 'test', '--config-file', incompleteConfig];
|
||||||
|
assert.throws(() => {
|
||||||
|
CommandLineParser.parseArgs();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(incompleteConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理不存在的配置文件', () => {
|
||||||
|
process.argv = ['node', 'test', '--config-file', '/nonexistent/config.json'];
|
||||||
|
assert.throws(() => {
|
||||||
|
CommandLineParser.parseArgs();
|
||||||
|
}, /not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理缺少认证参数的情况', () => {
|
||||||
|
const emptySshConfig = path.join(fixturesDir, 'empty-ssh-config');
|
||||||
|
const originalSshAuthSock = process.env.SSH_AUTH_SOCK;
|
||||||
|
fs.writeFileSync(emptySshConfig, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
delete process.env.SSH_AUTH_SOCK;
|
||||||
|
process.argv = [
|
||||||
|
'node',
|
||||||
|
'test',
|
||||||
|
'--host', '1.2.3.4',
|
||||||
|
'--port', '22',
|
||||||
|
'--username', 'user',
|
||||||
|
'--ssh-config-file', emptySshConfig
|
||||||
|
];
|
||||||
|
assert.throws(() => {
|
||||||
|
CommandLineParser.parseArgs();
|
||||||
|
}, /Missing required parameters/);
|
||||||
|
} finally {
|
||||||
|
if (originalSshAuthSock === undefined) {
|
||||||
|
delete process.env.SSH_AUTH_SOCK;
|
||||||
|
} else {
|
||||||
|
process.env.SSH_AUTH_SOCK = originalSshAuthSock;
|
||||||
|
}
|
||||||
|
fs.unlinkSync(emptySshConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理无效的端口号', () => {
|
||||||
|
process.argv = ['node', 'test', '--host', '1.2.3.4', '--port', 'abc', '--username', 'user', '--password', 'pass'];
|
||||||
|
assert.throws(() => {
|
||||||
|
CommandLineParser.parseArgs();
|
||||||
|
}, /Port must be a valid number/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
test('does not load SSH dependencies when importing the connection manager', () => {
|
||||||
|
const managerUrl = pathToFileURL(
|
||||||
|
path.resolve('build/services/ssh-connection-manager.js'),
|
||||||
|
).href;
|
||||||
|
const loader = `data:text/javascript,${encodeURIComponent(`
|
||||||
|
export async function resolve(specifier, context, nextResolve) {
|
||||||
|
if (specifier === 'ssh2' || specifier === 'socks') {
|
||||||
|
throw new Error('eager SSH dependency: ' + specifier);
|
||||||
|
}
|
||||||
|
return nextResolve(specifier, context);
|
||||||
|
}
|
||||||
|
`)}`;
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--experimental-loader',
|
||||||
|
loader,
|
||||||
|
'--input-type=module',
|
||||||
|
'--eval',
|
||||||
|
`await import(${JSON.stringify(managerUrl)});`,
|
||||||
|
],
|
||||||
|
{ cwd: path.resolve('.'), encoding: 'utf8', timeout: 10_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.status,
|
||||||
|
0,
|
||||||
|
[result.error?.stack, result.stderr, result.stdout].filter(Boolean).join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as net from 'node:net';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const rootDir = path.join(__dirname, '..');
|
||||||
|
const entrypoint = path.join(rootDir, 'build', 'index.js');
|
||||||
|
|
||||||
|
function waitForOutput(stream, pattern, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let output = '';
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error(`Timed out waiting for ${pattern}. Output:\n${output}`));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const onData = (chunk) => {
|
||||||
|
output += chunk.toString();
|
||||||
|
if (pattern.test(output)) {
|
||||||
|
cleanup();
|
||||||
|
resolve(output);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
stream.off('data', onData);
|
||||||
|
};
|
||||||
|
|
||||||
|
stream.on('data', onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForRunning(child, delayMs = 200) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
|
assert.strictEqual(child.exitCode, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit(child, timeoutMs = 5000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
reject(new Error('Process did not exit before timeout'));
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({ code, signal });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTempConfig() {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-lifecycle-'));
|
||||||
|
const configPath = path.join(tmpDir, 'config.json');
|
||||||
|
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({
|
||||||
|
test: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 22,
|
||||||
|
username: 'test',
|
||||||
|
password: 'test',
|
||||||
|
commandWhitelist: ['^echo'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { tmpDir, configPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreConnectConfig(port) {
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-lifecycle-'));
|
||||||
|
const configPath = path.join(tmpDir, 'config.json');
|
||||||
|
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({
|
||||||
|
test: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
username: 'test',
|
||||||
|
password: 'test',
|
||||||
|
connectionTimeoutMs: 10000,
|
||||||
|
commandWhitelist: ['^echo'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { tmpDir, configPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnServer(configPath) {
|
||||||
|
const child = spawn(process.execPath, [entrypoint, '--config-file', configPath], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let closed = false;
|
||||||
|
return {
|
||||||
|
child,
|
||||||
|
closeInput: () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
child.stdin.end();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MCP server lifecycle', () => {
|
||||||
|
// Windows has no POSIX signals: child.kill('SIGTERM') maps to
|
||||||
|
// TerminateProcess, so the handler under test never runs and the exit is
|
||||||
|
// always reported as signalled. The stdin path below covers shutdown there,
|
||||||
|
// and is what MCP clients actually use.
|
||||||
|
it('exits after SIGTERM even when stdin remains open', { skip: process.platform === 'win32' && 'no POSIX signals on Windows' }, async () => {
|
||||||
|
const { tmpDir, configPath } = createTempConfig();
|
||||||
|
const { child, closeInput } = spawnServer(configPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForRunning(child, 1500);
|
||||||
|
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
const result = await waitForExit(child);
|
||||||
|
|
||||||
|
assert.strictEqual(result.signal, null);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
closeInput();
|
||||||
|
} catch {}
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits after stdin is closed', async () => {
|
||||||
|
const { tmpDir, configPath } = createTempConfig();
|
||||||
|
const { child, closeInput } = spawnServer(configPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitForRunning(child);
|
||||||
|
|
||||||
|
closeInput();
|
||||||
|
const result = await waitForExit(child);
|
||||||
|
|
||||||
|
assert.strictEqual(result.signal, null);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
closeInput();
|
||||||
|
} catch {}
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts initialize requests while pre-connect is still pending', async () => {
|
||||||
|
const handshakeServer = net.createServer();
|
||||||
|
const sockets = new Set();
|
||||||
|
handshakeServer.on('connection', (socket) => {
|
||||||
|
sockets.add(socket);
|
||||||
|
// Killing the child resets this connection instead of closing it
|
||||||
|
// cleanly on some platforms; an unhandled 'error' would fail the test.
|
||||||
|
socket.on('error', () => {});
|
||||||
|
socket.on('close', () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
handshakeServer.once('error', reject);
|
||||||
|
handshakeServer.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = handshakeServer.address();
|
||||||
|
assert.ok(address && typeof address !== 'string');
|
||||||
|
const { tmpDir, configPath } = createPreConnectConfig(address.port);
|
||||||
|
const child = spawn(process.execPath, [entrypoint, '--config-file', configPath, '--pre-connect'], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.stdin.write(`${JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 61,
|
||||||
|
method: 'initialize',
|
||||||
|
params: {
|
||||||
|
protocolVersion: '2025-11-25',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'lifecycle-test', version: '1.0.0' },
|
||||||
|
},
|
||||||
|
})}\n`);
|
||||||
|
|
||||||
|
const output = await waitForOutput(child.stdout, /"id":61/, 1250);
|
||||||
|
assert.match(output, /"result"/);
|
||||||
|
} finally {
|
||||||
|
child.stdin.end();
|
||||||
|
if (child.exitCode === null) {
|
||||||
|
const exitPromise = waitForExit(child);
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
await exitPromise;
|
||||||
|
}
|
||||||
|
for (const socket of sockets) socket.destroy();
|
||||||
|
await new Promise((resolve) => handshakeServer.close(resolve));
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { formatServerList } from '../build/tools/list-servers.js';
|
||||||
|
|
||||||
|
describe('List Servers Tool', () => {
|
||||||
|
it('没有配置时应返回友好提示', () => {
|
||||||
|
assert.strictEqual(formatServerList([]), 'No SSH servers configured.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应返回可读摘要和原始 JSON', () => {
|
||||||
|
const output = formatServerList([
|
||||||
|
{
|
||||||
|
name: 'dev',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'root',
|
||||||
|
connected: true,
|
||||||
|
status: {
|
||||||
|
reachable: true,
|
||||||
|
hostname: 'dev-box',
|
||||||
|
osName: 'Linux',
|
||||||
|
lastUpdated: '2026-04-02T12:00:00.000Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(output, /Configured SSH servers:/);
|
||||||
|
assert.match(output, /\[connected\] dev \| root@192.168.1.100:22/);
|
||||||
|
assert.match(output, /hostname=dev-box/);
|
||||||
|
assert.match(output, /Raw JSON:/);
|
||||||
|
assert.match(output, /"name":"dev"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('原始 JSON 不缩进,且仍可解析回等价对象', () => {
|
||||||
|
const servers = [
|
||||||
|
{
|
||||||
|
name: 'dev',
|
||||||
|
host: '192.168.1.100',
|
||||||
|
port: 22,
|
||||||
|
username: 'root',
|
||||||
|
connected: true,
|
||||||
|
status: {
|
||||||
|
reachable: true,
|
||||||
|
hostname: 'dev-box',
|
||||||
|
osName: 'Linux',
|
||||||
|
drives: [
|
||||||
|
{
|
||||||
|
device: '/dev/sda1',
|
||||||
|
mountPoint: '/',
|
||||||
|
total: '512G',
|
||||||
|
used: '380G',
|
||||||
|
free: '106G',
|
||||||
|
usagePercent: '78%',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lastUpdated: '2026-04-02T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const rawJson = formatServerList(servers).split('\nRaw JSON:\n')[1];
|
||||||
|
|
||||||
|
assert.deepStrictEqual(JSON.parse(rawJson), servers);
|
||||||
|
// 缩进换行是这里的主要体积来源,压缩后应只剩一行
|
||||||
|
assert.ok(!rawJson.includes('\n'));
|
||||||
|
assert.ok(rawJson.length < JSON.stringify(servers, null, 2).length * 0.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { redactSecret, redactSecrets } from '../build/utils/redact.js';
|
||||||
|
import { SSHConnectionManager } from '../build/services/ssh-connection-manager.js';
|
||||||
|
|
||||||
|
test('redactSecret masks long secrets everywhere', () => {
|
||||||
|
const text = 'login with hunter2hunter2 then done';
|
||||||
|
assert.strictEqual(redactSecret(text, 'hunter2'), 'login with [REDACTED][REDACTED] then done');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactSecret masks long secret even mid-word', () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
redactSecret('password: secretvalue and secretvalueagain', 'secretvalue'),
|
||||||
|
'password: [REDACTED] and [REDACTED]again',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactSecret masks short secrets only as whole tokens', () => {
|
||||||
|
// 2-3 char secrets are only replaced when they stand alone
|
||||||
|
const text = 'the cat sat on a mat';
|
||||||
|
assert.strictEqual(redactSecret(text, 'cat'), 'the [REDACTED] sat on a mat');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactSecret ignores short secret inside a word', () => {
|
||||||
|
// "cat" inside "catastrophe" must not be destroyed
|
||||||
|
assert.strictEqual(redactSecret('a catastrophe', 'cat'), 'a catastrophe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactSecret ignores empty and 1-char secrets', () => {
|
||||||
|
assert.strictEqual(redactSecret('abc', ''), 'abc');
|
||||||
|
assert.strictEqual(redactSecret('abc', 'a'), 'abc');
|
||||||
|
assert.strictEqual(redactSecret('abc', undefined), 'abc');
|
||||||
|
assert.strictEqual(redactSecret('abc', null), 'abc');
|
||||||
|
assert.strictEqual(redactSecret('', 'secret'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactSecret escapes regex special characters', () => {
|
||||||
|
const text = 'tokens like a.b and aXb';
|
||||||
|
assert.strictEqual(redactSecret(text, 'a.b'), 'tokens like [REDACTED] and aXb');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactSecrets redacts many secrets at once', () => {
|
||||||
|
const text = 'user=alice pass=topsecret key=pLskey-end';
|
||||||
|
assert.strictEqual(
|
||||||
|
redactSecrets(text, ['topsecret', 'pLskey']),
|
||||||
|
'user=alice pass=[REDACTED] key=[REDACTED]-end',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redactSecret returns original when no match', () => {
|
||||||
|
const text = 'nothing sensitive here';
|
||||||
|
assert.strictEqual(redactSecret(text, 'nope'), text);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dynamic mode config requires host and username', async () => {
|
||||||
|
const manager = SSHConnectionManager.getInstance();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => manager.executeCommandDynamic({ username: 'root' }, 'id'),
|
||||||
|
(err) => err?.code === 'SSH_CONFIG_MISSING',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { describe, it, before, after } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { lookupSshConfig } from '../build/utils/ssh-config-parser.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
let fixturesDir;
|
||||||
|
|
||||||
|
describe('SSH Config Parser', () => {
|
||||||
|
let testConfigPath;
|
||||||
|
let testConfigWithIncludePath;
|
||||||
|
let includedConfigPath;
|
||||||
|
let originalHome;
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
originalHome = process.env.HOME;
|
||||||
|
fixturesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-mcp-config-test-'));
|
||||||
|
|
||||||
|
testConfigPath = path.join(fixturesDir, 'ssh-config-basic');
|
||||||
|
testConfigWithIncludePath = path.join(fixturesDir, 'ssh-config-include');
|
||||||
|
includedConfigPath = path.join(fixturesDir, 'ssh-config-included');
|
||||||
|
|
||||||
|
// 基本测试配置
|
||||||
|
fs.writeFileSync(testConfigPath, [
|
||||||
|
'# 多别名测试',
|
||||||
|
'Host dev staging',
|
||||||
|
' HostName 192.168.1.100',
|
||||||
|
' Port 2222',
|
||||||
|
' User devuser',
|
||||||
|
' IdentityFile ~/.ssh/dev_key',
|
||||||
|
'',
|
||||||
|
'# 单别名测试',
|
||||||
|
'Host prod',
|
||||||
|
' HostName 10.0.0.50',
|
||||||
|
' User produser',
|
||||||
|
' IdentityFile ~/.ssh/prod_key',
|
||||||
|
'',
|
||||||
|
'# 通配符测试',
|
||||||
|
'Host *.example.com',
|
||||||
|
' User wildcarduser',
|
||||||
|
' Port 2200',
|
||||||
|
'',
|
||||||
|
'# 全局默认值',
|
||||||
|
'Host *',
|
||||||
|
' Port 22',
|
||||||
|
' User defaultuser',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
// 被包含的配置文件
|
||||||
|
fs.writeFileSync(includedConfigPath, [
|
||||||
|
'Host included-host',
|
||||||
|
' HostName 172.16.0.1',
|
||||||
|
' Port 3333',
|
||||||
|
' User includeduser',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
// 带 Include 的配置文件
|
||||||
|
fs.writeFileSync(testConfigWithIncludePath, [
|
||||||
|
`Include ${includedConfigPath}`,
|
||||||
|
'',
|
||||||
|
'Host main-host',
|
||||||
|
' HostName 192.168.1.1',
|
||||||
|
' User mainuser',
|
||||||
|
'',
|
||||||
|
'Host *',
|
||||||
|
' Port 22',
|
||||||
|
].join('\n'));
|
||||||
|
});
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
fs.rmSync(fixturesDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('基本功能', () => {
|
||||||
|
it('应该正确解析单个 Host 别名', () => {
|
||||||
|
const config = lookupSshConfig('prod', testConfigPath);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.hostName, '10.0.0.50');
|
||||||
|
assert.strictEqual(config.user, 'produser');
|
||||||
|
assert.strictEqual(config.identityFile, path.join(os.homedir(), '.ssh', 'prod_key'));
|
||||||
|
assert.strictEqual(config.port, 22); // 从 Host * fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析多别名 Host 行 - dev', () => {
|
||||||
|
const config = lookupSshConfig('dev', testConfigPath);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.hostName, '192.168.1.100');
|
||||||
|
assert.strictEqual(config.port, 2222);
|
||||||
|
assert.strictEqual(config.user, 'devuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确解析多别名 Host 行 - staging', () => {
|
||||||
|
const config = lookupSshConfig('staging', testConfigPath);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.hostName, '192.168.1.100');
|
||||||
|
assert.strictEqual(config.port, 2222);
|
||||||
|
assert.strictEqual(config.user, 'devuser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持通配符匹配', () => {
|
||||||
|
const config = lookupSshConfig('server.example.com', testConfigPath);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.user, 'wildcarduser');
|
||||||
|
assert.strictEqual(config.port, 2200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该使用 Host * 作为默认值', () => {
|
||||||
|
const config = lookupSshConfig('unknown-host', testConfigPath);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.user, 'defaultuser');
|
||||||
|
assert.strictEqual(config.port, 22);
|
||||||
|
assert.strictEqual(config.hostName, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('通配符匹配应转义正则特殊字符', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'special-pattern-config');
|
||||||
|
fs.writeFileSync(tempConfig, [
|
||||||
|
'Host host[1]',
|
||||||
|
' User literaluser',
|
||||||
|
'',
|
||||||
|
'Host *',
|
||||||
|
' User defaultuser',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const literalConfig = lookupSshConfig('host[1]', tempConfig);
|
||||||
|
const regexLikeConfig = lookupSshConfig('host1', tempConfig);
|
||||||
|
|
||||||
|
assert.ok(literalConfig);
|
||||||
|
assert.strictEqual(literalConfig.user, 'literaluser');
|
||||||
|
assert.ok(regexLikeConfig);
|
||||||
|
assert.strictEqual(regexLikeConfig.user, 'defaultuser');
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该支持 Host 中的否定模式', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'negated-pattern-config');
|
||||||
|
fs.writeFileSync(tempConfig, [
|
||||||
|
'Host *.example.com !blocked.example.com',
|
||||||
|
' User wildcarduser',
|
||||||
|
'',
|
||||||
|
'Host *',
|
||||||
|
' User defaultuser',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const allowedConfig = lookupSshConfig('app.example.com', tempConfig);
|
||||||
|
const blockedConfig = lookupSshConfig('blocked.example.com', tempConfig);
|
||||||
|
|
||||||
|
assert.ok(allowedConfig);
|
||||||
|
assert.strictEqual(allowedConfig.user, 'wildcarduser');
|
||||||
|
assert.ok(blockedConfig);
|
||||||
|
assert.strictEqual(blockedConfig.user, 'defaultuser');
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Include 指令', () => {
|
||||||
|
it('应该正确处理 Include 指令', () => {
|
||||||
|
const config = lookupSshConfig('included-host', testConfigWithIncludePath);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.hostName, '172.16.0.1');
|
||||||
|
assert.strictEqual(config.port, 3333);
|
||||||
|
assert.strictEqual(config.user, 'includeduser');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该在 Include 后继续解析主配置文件', () => {
|
||||||
|
const config = lookupSshConfig('main-host', testConfigWithIncludePath);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.hostName, '192.168.1.1');
|
||||||
|
assert.strictEqual(config.user, 'mainuser');
|
||||||
|
assert.strictEqual(config.port, 22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该静默跳过不存在的 Include 文件', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'temp-include-config');
|
||||||
|
fs.writeFileSync(tempConfig, [
|
||||||
|
'Include /nonexistent/path/config',
|
||||||
|
'',
|
||||||
|
'Host test',
|
||||||
|
' HostName 1.2.3.4',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const config = lookupSshConfig('test', tempConfig);
|
||||||
|
assert.ok(config, '应该返回非 null 的配置');
|
||||||
|
assert.strictEqual(config.hostName, '1.2.3.4');
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('边界情况', () => {
|
||||||
|
it('默认配置文件不存在时应返回 null', () => {
|
||||||
|
const fakeHome = fs.mkdtempSync(path.join(fixturesDir, 'fake-home-'));
|
||||||
|
process.env.HOME = fakeHome;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = lookupSshConfig('any-host');
|
||||||
|
assert.strictEqual(config, null);
|
||||||
|
} finally {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
fs.rmSync(fakeHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('显式指定的配置文件不存在时应抛出错误', () => {
|
||||||
|
assert.throws(() => {
|
||||||
|
lookupSshConfig('any-host', '/nonexistent/config');
|
||||||
|
}, /not found/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未找到匹配的 Host 时应返回 null', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'empty-config');
|
||||||
|
fs.writeFileSync(tempConfig, '# Empty config\n');
|
||||||
|
|
||||||
|
const config = lookupSshConfig('any-host', tempConfig);
|
||||||
|
assert.strictEqual(config, null);
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确展开 ~ 路径', () => {
|
||||||
|
const config = lookupSshConfig('dev', testConfigPath);
|
||||||
|
assert.ok(config);
|
||||||
|
assert.ok(config.identityFile.startsWith(os.homedir()));
|
||||||
|
assert.ok(!config.identityFile.includes('~'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理注释行', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'comment-config');
|
||||||
|
fs.writeFileSync(tempConfig, [
|
||||||
|
'# 这是注释',
|
||||||
|
'Host test',
|
||||||
|
' HostName 1.2.3.4 # 行内注释',
|
||||||
|
' Port 2222',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const config = lookupSshConfig('test', tempConfig);
|
||||||
|
assert.ok(config);
|
||||||
|
assert.strictEqual(config.hostName, '1.2.3.4');
|
||||||
|
assert.strictEqual(config.port, 2222);
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('应该正确处理空白行和缩进', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'whitespace-config');
|
||||||
|
fs.writeFileSync(tempConfig, [
|
||||||
|
'',
|
||||||
|
' Host test ',
|
||||||
|
' HostName 1.2.3.4 ',
|
||||||
|
'',
|
||||||
|
' Port 2222 ',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const config = lookupSshConfig('test', tempConfig);
|
||||||
|
assert.ok(config);
|
||||||
|
assert.strictEqual(config.hostName, '1.2.3.4');
|
||||||
|
assert.strictEqual(config.port, 2222);
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('First-match-wins 语义', () => {
|
||||||
|
it('应该使用第一个匹配的值', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'priority-config');
|
||||||
|
fs.writeFileSync(tempConfig, [
|
||||||
|
'Host test',
|
||||||
|
' Port 2222',
|
||||||
|
' User firstuser',
|
||||||
|
'',
|
||||||
|
'Host test',
|
||||||
|
' Port 3333',
|
||||||
|
' User seconduser',
|
||||||
|
'',
|
||||||
|
'Host *',
|
||||||
|
' Port 22',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const config = lookupSshConfig('test', tempConfig);
|
||||||
|
assert.ok(config);
|
||||||
|
assert.strictEqual(config.port, 2222);
|
||||||
|
assert.strictEqual(config.user, 'firstuser');
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('特定 Host 的值应优先于 Host *', () => {
|
||||||
|
const tempConfig = path.join(fixturesDir, 'specific-priority-config');
|
||||||
|
fs.writeFileSync(tempConfig, [
|
||||||
|
'Host specific',
|
||||||
|
' Port 2222',
|
||||||
|
'',
|
||||||
|
'Host *',
|
||||||
|
' Port 22',
|
||||||
|
' User globaluser',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const config = lookupSshConfig('specific', tempConfig);
|
||||||
|
assert.ok(config);
|
||||||
|
assert.strictEqual(config.port, 2222); // 来自 Host specific
|
||||||
|
assert.strictEqual(config.user, 'globaluser'); // 来自 Host *
|
||||||
|
|
||||||
|
fs.unlinkSync(tempConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { collectSystemStatus } from '../build/utils/status-collector.js';
|
||||||
|
|
||||||
|
const PROBE_PATTERN = /printf '\\n(__MCP_FIELD_\w+_)(\w+)\\n'/g;
|
||||||
|
|
||||||
|
/** Field names in the order the script probes them, plus the shared marker. */
|
||||||
|
function readProbes(script) {
|
||||||
|
const probes = [...script.matchAll(PROBE_PATTERN)];
|
||||||
|
return {
|
||||||
|
marker: probes[0]?.[1],
|
||||||
|
fields: probes.map((probe) => probe[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in for the remote shell: emits the marker line for every probe the
|
||||||
|
* script declares, followed by whatever `values` supplies for it.
|
||||||
|
*/
|
||||||
|
function fakeRemote(values, { lineEnding = '\n' } = {}) {
|
||||||
|
return (script) => {
|
||||||
|
const { marker, fields } = readProbes(script);
|
||||||
|
const output = fields
|
||||||
|
.map((field) => `${marker}${field}${lineEnding}${values[field] ?? ''}`)
|
||||||
|
.join(lineEnding);
|
||||||
|
return Promise.resolve(lineEnding + output);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('status collector', () => {
|
||||||
|
it('所有探针合并为一条单行命令', async () => {
|
||||||
|
const scripts = [];
|
||||||
|
await collectSystemStatus((script) => {
|
||||||
|
scripts.push(script);
|
||||||
|
return Promise.resolve('');
|
||||||
|
}, 'dev');
|
||||||
|
|
||||||
|
assert.strictEqual(scripts.length, 1);
|
||||||
|
// 多行脚本会被 commandTemplate 的引号包裹破坏
|
||||||
|
assert.ok(!scripts[0].includes('\n'));
|
||||||
|
// 每个探针都被隔离,单个失败不影响其余,且整体以成功退出
|
||||||
|
assert.ok(scripts[0].endsWith('; true'));
|
||||||
|
|
||||||
|
const { fields } = readProbes(scripts[0]);
|
||||||
|
assert.ok(fields.includes('hostname'));
|
||||||
|
assert.ok(fields.includes('servicesInstalled'));
|
||||||
|
assert.strictEqual(new Set(fields).size, fields.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('按 marker 还原各字段', async () => {
|
||||||
|
const status = await collectSystemStatus(
|
||||||
|
fakeRemote({
|
||||||
|
hostname: 'web-01',
|
||||||
|
osName: 'Linux',
|
||||||
|
kernelVersion: '6.1.0',
|
||||||
|
memory: 'free:2.1G total:7.7G',
|
||||||
|
processes: '181',
|
||||||
|
threads: '901',
|
||||||
|
}),
|
||||||
|
'dev',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(status.reachable, true);
|
||||||
|
assert.strictEqual(status.hostname, 'web-01');
|
||||||
|
assert.strictEqual(status.osName, 'Linux');
|
||||||
|
assert.strictEqual(status.kernelVersion, '6.1.0');
|
||||||
|
assert.deepStrictEqual(status.memory, { free: '2.1G', total: '7.7G' });
|
||||||
|
// 解析时会各减去一行表头
|
||||||
|
assert.deepStrictEqual(status.processes, { running: 180, threads: 900 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 多行字段是 CRLF 归一化真正起作用的地方:逐行拆分的值不会再单独 trim,
|
||||||
|
// 残留的 \r 会直接进入结果。
|
||||||
|
it('pty 的 CRLF 输出同样能解析', async () => {
|
||||||
|
const status = await collectSystemStatus(
|
||||||
|
fakeRemote(
|
||||||
|
{
|
||||||
|
hostname: 'web-02',
|
||||||
|
ipAddresses: '10.0.0.7\r\n192.168.1.5',
|
||||||
|
drives: '/dev/sda1|50G|20G|30G|40%|/',
|
||||||
|
},
|
||||||
|
{ lineEnding: '\r\n' },
|
||||||
|
),
|
||||||
|
'dev',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(status.hostname, 'web-02');
|
||||||
|
assert.deepStrictEqual(status.ipAddresses, ['10.0.0.7', '192.168.1.5']);
|
||||||
|
assert.deepStrictEqual(status.drives, [
|
||||||
|
{
|
||||||
|
device: '/dev/sda1',
|
||||||
|
total: '50G',
|
||||||
|
used: '20G',
|
||||||
|
free: '30G',
|
||||||
|
usagePercent: '40%',
|
||||||
|
mountPoint: '/',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('空探针不影响其它字段', async () => {
|
||||||
|
const status = await collectSystemStatus(
|
||||||
|
fakeRemote({ hostname: 'web-03', ipAddresses: '', osName: 'Linux' }),
|
||||||
|
'dev',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(status.hostname, 'web-03');
|
||||||
|
assert.strictEqual(status.osName, 'Linux');
|
||||||
|
assert.strictEqual(status.ipAddresses, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 命令白名单会拒绝这条探针脚本;那种情况下字段留空即可,
|
||||||
|
// 不能把主机报成不可达。
|
||||||
|
it('命令被拒绝时字段留空但仍视为可达', async () => {
|
||||||
|
const status = await collectSystemStatus(
|
||||||
|
() => Promise.reject(new Error('Command validation failed')),
|
||||||
|
'dev',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(status.reachable, true);
|
||||||
|
assert.strictEqual(status.hostname, undefined);
|
||||||
|
assert.ok(status.lastUpdated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('先逐条授权探针,再只批量执行允许的命令', async () => {
|
||||||
|
const scripts = [];
|
||||||
|
const recordingRemote = fakeRemote({ hostname: 'allowed-host' });
|
||||||
|
const status = await collectSystemStatus(
|
||||||
|
(script) => {
|
||||||
|
scripts.push(script);
|
||||||
|
return recordingRemote(script);
|
||||||
|
},
|
||||||
|
'dev',
|
||||||
|
(command) => command === 'hostname',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(status.hostname, 'allowed-host');
|
||||||
|
assert.strictEqual(scripts.length, 1);
|
||||||
|
assert.match(scripts[0], /hostname/);
|
||||||
|
assert.ok(!scripts[0].includes('uname -s'));
|
||||||
|
assert.ok(!scripts[0].includes('cat /etc/os-release'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('没有获准探针时不执行远端脚本', async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const status = await collectSystemStatus(
|
||||||
|
async () => {
|
||||||
|
calls += 1;
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
'dev',
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(calls, 0);
|
||||||
|
assert.strictEqual(status.reachable, true);
|
||||||
|
assert.strictEqual(status.hostname, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "Node16",
|
||||||
|
"moduleResolution": "Node16",
|
||||||
|
"outDir": "./build",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user