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:
Max Shcheglov
2026-09-01 10:54:20 +07:00
commit b889154bb3
39 changed files with 10418 additions and 0 deletions
+28
View File
@@ -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/
+15
View File
@@ -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.
+624
View File
@@ -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
![NPM Version](https://img.shields.io/npm/v/%40fangjunjie%2Fssh-mcp-server?label=%40fangjunjie%2Fssh-mcp-server)
![GitHub forks](https://img.shields.io/github/forks/classfang/ssh-mcp-server)
![GitHub Repo stars](https://img.shields.io/github/stars/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-closed/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-closed/classfang/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
[![Star History Chart](https://api.star-history.com/chart?repos=classfang/ssh-mcp-server&type=date&legend=top-left&sealed_token=ndORao73xOZgyX7IvlIIOynMoeEP5Ds9YAG-zOfMMBlNepLdP3e7T7k9K94X8TdvuxplN5DXLolbF9jFFsYDD-1V0V8HO6B3swaPOvJaonKeiFNdAuWsXg)](https://www.star-history.com/?type=date&legend=top-left&repos=classfang%2Fssh-mcp-server)
+618
View File
@@ -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
![NPM Version](https://img.shields.io/npm/v/%40fangjunjie%2Fssh-mcp-server?label=%40fangjunjie%2Fssh-mcp-server)
![GitHub forks](https://img.shields.io/github/forks/classfang/ssh-mcp-server)
![GitHub Repo stars](https://img.shields.io/github/stars/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-closed/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr/classfang/ssh-mcp-server)
![GitHub Issues or Pull Requests](https://img.shields.io/github/issues-pr-closed/classfang/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
[![Star History Chart](https://api.star-history.com/chart?repos=classfang/ssh-mcp-server&type=date&legend=top-left&sealed_token=ndORao73xOZgyX7IvlIIOynMoeEP5Ds9YAG-zOfMMBlNepLdP3e7T7k9K94X8TdvuxplN5DXLolbF9jFFsYDD-1V0V8HO6B3swaPOvJaonKeiFNdAuWsXg)](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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+46
View File
@@ -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/**/*"
]
}
+33
View File
@@ -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!");
+23
View File
@@ -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);
}
+166
View File
@@ -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 / usernameport=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"]
}
}
}
```
+483
View File
@@ -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;
}
}
+4
View File
@@ -0,0 +1,4 @@
/**
* Export all configurations
*/
export * from './server.js';
+21
View File
@@ -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(),
};
+133
View File
@@ -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"
);
});
}
}
}
+58
View File
@@ -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));
+114
View File
@@ -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
+87
View File
@@ -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,
};
}
}
);
}
+130
View File
@@ -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,
};
}
},
);
}
+16
View File
@@ -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);
}
+65
View File
@@ -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),
},
],
};
},
);
}
+87
View File
@@ -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,
};
}
}
);
}
+47
View File
@@ -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);
}
}
+60
View File
@@ -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;
}
+236
View File
@@ -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;
}
+304
View File
@@ -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;
}
+43
View File
@@ -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
View File
@@ -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`
+95
View File
@@ -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/);
});
});
+645
View File
@@ -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');
});
});
});
+267
View File
@@ -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/);
});
});
});
+36
View File
@@ -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'),
);
});
+206
View File
@@ -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 });
}
});
});
+68
View File
@@ -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);
});
});
+62
View File
@@ -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',
);
});
+310
View File
@@ -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
+159
View File
@@ -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);
});
});
+15
View File
@@ -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"]
}