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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

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

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

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