Skip to main content
Glama
LianXia233

router-ssh-mcp

by LianXia233
README.md
# router-ssh-mcp

通过 SSH 管理路由器(OpenWrt / ImmortalWrt / 通用 Linux)上服务的 **MCP(Model Context Protocol)服务器**。
上层模型可以列出服务、查询状态、读取日志,并在二次确认后执行启动 / 停止 / 重启,全程受命令白名单约束;此外提供一个**默认关闭**的高危 `run_shell` 能力,在显式开启并二次确认后可于远端执行任意 shell 命令,用于白名单工具覆盖不到的排障场景。

---

## 1. 能力概览

| 能力 | 说明 |
| --- | --- |
| 多 init 系统 | 自动探测 `procd`(OpenWrt)、`systemd`、`sysvinit`,也可手动指定 |
| 连接复用 | 进程内复用单条 SSH 连接,带 keepalive 与断线重连 |
| 只读 / 写入分离 | 4 个只读工具 + 3 个写入工具 + 1 个高危 shell 工具,通过 MCP annotations 标注 |
| 写入二次确认 | 写入工具必须传 `confirm=<服务名>`,否则返回 `CONFIRMATION_REQUIRED` |
| 命令白名单 | 所有命令以 argv 序列下发,不经 shell;服务名、路径、行数均做严格校验 |
| 可选 shell 能力 | `run_shell` 默认关闭;开启后需二次确认,并默认拦截 rm -rf / mkfs / dd 等高危命令 |
| 结构化输出 | 统一返回 `ok/tool/host/duration_ms/data/error`,并附带 MCP structuredContent |
| 结构化错误 | 连接、认证、超时、服务不存在等均映射为稳定错误码 + 修复建议 |

---

## 2. 目录结构

```
router-ssh-mcp/
├── pyproject.toml               # 依赖与 console script 入口
├── config.example.json          # 配置文件示例(不含真实口令)
├── .env.example                 # 环境变量示例
├── src/router_mcp/
│   ├── config.py                # 配置加载(默认值 < 配置文件 < 环境变量)
│   ├── errors.py                # 错误码与结构化异常
│   ├── commands.py              # 命令白名单与 argv 模板(安全核心)
│   ├── ssh_pool.py              # SSH 连接复用 / 保活 / 超时重连 / 错误归类
│   ├── controller.py            # 编排层:安全策略、二次确认、工具语义
│   ├── models.py                # pydantic 返回模型
│   ├── server.py                # MCP 服务器与工具注册
│   ├── _compat.py               # mcp 1.x / 2.x 兼容层
│   └── backends/
│       ├── base.py              # 后端抽象与公共能力(启动时间推算)
│       ├── initd.py             # /etc/init.d 通用实现(sysvinit)
│       ├── procd.py             # OpenWrt ubus + procd
│       └── systemd.py           # systemctl + journalctl
├── scripts/
│   ├── selftest.py              # 真实设备自测(只读 + 可选写入演练)
│   └── mcp_smoke.py             # stdio 协议级冒烟测试
└── tests/                       # 离线单元测试 + MCP 层端到端测试
```

调用链:`MCP 工具 → controller(安全策略/确认) → backends(init 适配) → commands(白名单 argv) → ssh_pool(连接复用) → asyncssh`。

---

## 3. 环境要求与依赖

| 项目 | 要求 |
| --- | --- |
| Python | >= 3.10(CI 验证 3.11 / 3.12 / 3.13) |
| mcp | >= 2.0, < 3(SDK,代码内含 1.x 兼容路径) |
| asyncssh | >= 2.14(纯 Python SSH,自带 crypto,不需要系统 ssh 客户端) |
| pydantic | >= 2.7(结构化输出模型) |

### Linux / macOS

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e .          # 开发模式
# 或:pip install .
```

### Windows(PowerShell 7)

```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -e .
```

开发额外依赖:`pip install -e ".[dev]"`(pytest、pytest-asyncio)。

---

## 4. 配置

优先级:**默认值 < JSON 配置文件 < 环境变量 < 命令行参数**。

> **连接参数完全可自定义**:目标路由器 IP、登录账号、密码或私钥均由你按实际设备填写,三种方式任意组合。本仓库所有示例中的 `192.168.1.1` 仅为占位默认地址,请替换为你自己的路由器地址。密码与令牌**切勿提交进仓库**(`config.json` / `.env` 已在 `.gitignore` 中忽略)。

配置文件查找顺序:`--config` → `$ROUTER_MCP_CONFIG` → `./router-mcp.json` → `./config.json` → `~/.config/router-mcp/config.json`。

### 4.1 环境变量

| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `ROUTER_MCP_HOST` | `192.168.1.1` | SSH 主机 |
| `ROUTER_MCP_PORT` | `22` | SSH 端口 |
| `ROUTER_MCP_USER` | `root` | SSH 用户名 |
| `ROUTER_MCP_PASSWORD` | — | 密码(与 `KEY_PATH` 至少提供一个) |
| `ROUTER_MCP_KEY_PATH` | — | 私钥路径 |
| `ROUTER_MCP_KEY_PASSPHRASE` | — | 私钥口令;未设置时会尝试用 `PASSWORD` 解密私钥 |
| `ROUTER_MCP_INIT_SYSTEM` | `auto` | `auto` / `procd` / `systemd` / `sysvinit` |
| `ROUTER_MCP_CONNECT_TIMEOUT` | `8` | 连接超时(秒) |
| `ROUTER_MCP_COMMAND_TIMEOUT` | `12` | 单条命令超时(秒) |
| `ROUTER_MCP_KEEPALIVE_INTERVAL` | `15` | 保活间隔(秒) |
| `ROUTER_MCP_KEEPALIVE_COUNT` | `3` | 连续保活失败次数阈值 |
| `ROUTER_MCP_MAX_RECONNECT_ATTEMPTS` | `2` | 命令失败重连重试次数 |
| `ROUTER_MCP_MAX_LOG_LINES` | `500` | 单次日志读取上限 |
| `ROUTER_MCP_REQUIRE_CONFIRM` | `true` | 写入操作是否要求二次确认 |
| `ROUTER_MCP_ALLOWED_SERVICES` | 空 | 服务白名单,逗号分隔;为空表示不限制 |
| `ROUTER_MCP_DENIED_SERVICES` | `network,firewall,system,boot,done` | 禁止写入的高危服务 |
| `ROUTER_MCP_STRICT_HOST_KEY` | `false` | 是否校验 known_hosts(路由器重装后建议临时关闭) |
| `ROUTER_MCP_KNOWN_HOSTS` | — | known_hosts 路径(仅在严格模式下生效) |
| `ROUTER_MCP_ALLOW_SHELL` | `false` | 是否启用高危的 `run_shell` 任意命令能力 |
| `ROUTER_MCP_SHELL_REQUIRE_CONFIRM` | `true` | `run_shell` 是否要求二次确认(confirm 须等于命令本身) |
| `ROUTER_MCP_SHELL_DENY_UNSAFE` | `true` | 是否拦截高危命令(rm -rf / mkfs / dd / 写 /dev/* / fork 炸弹 / 管道进 shell) |
| `ROUTER_MCP_LOG_LEVEL` | `WARNING` | asyncssh 日志级别,排障时设 `DEBUG` |

### 4.2 配置文件

复制 `config.example.json` 为 `config.json`(已在 `.gitignore` 中忽略,避免口令入库):

```json
{
  "host": "192.168.1.1",
  "username": "root",
  "password": "",
  "init_system": "auto",
  "security": {
    "require_confirmation": true,
    "allowed_services": [],
    "denied_services": ["network", "firewall"]
  }
}
```

---

## 5. 启动

### 5.1 直接运行

```bash
# stdio(默认,供 MCP 客户端拉起)
ROUTER_MCP_HOST=192.168.1.1 ROUTER_MCP_PASSWORD=xxx python -m router_mcp

# 等价的 console script
ROUTER_MCP_HOST=192.168.1.1 ROUTER_MCP_PASSWORD=xxx router-ssh-mcp

# 命令行覆盖参数
router-ssh-mcp --host 192.168.1.1 --user root --key-path ~/.ssh/id_ed25519

# SSE / Streamable HTTP(远程部署时使用)
router-ssh-mcp --transport streamable-http
```

### 5.2 接入 MCP 客户端

WorkBuddy(`~/.workbuddy/mcp.json`):

```json
{
  "mcpServers": {
    "router-ssh": {
      "description": "通过 SSH 管理路由器(OpenWrt/ImmortalWrt/systemd)服务的 MCP 连接器;只读工具可直接调用,写入操作需二次确认,run_shell 高危能力默认关闭。",
      "command": "python",
      "args": ["-m", "router_mcp"],
      "env": {
        "ROUTER_MCP_HOST": "192.168.1.1",
        "ROUTER_MCP_USER": "root",
        "ROUTER_MCP_PASSWORD": "你的密码"
      }
    }
  }
}
```

> 新增服务器后需在 WorkBuddy 的连接器管理页点击「信任」才会启用。

Claude Desktop / 其他支持 MCP 的客户端:`claude_desktop_config.json` 使用同样的 `mcpServers` 结构。

---

## 6. 工具清单

| 工具 | 类型 | 参数 | 说明 |
| --- | --- | --- | --- |
| `router_info` | 只读 | — | 连接健康、init 系统、设备信息、生效的安全策略 |
| `service_list` | 只读 | `name_filter?`, `running_only?`, `with_start_time?` | 列出服务及运行状态 |
| `service_status` | 只读 | `name` | 单服务详情(PID、启动时间、自启状态) |
| `service_logs` | 只读 | `name`, `lines=50` | 日志尾部(logread / journalctl) |
| `service_start` | 写入 | `name`, `confirm?` | 启动服务 |
| `service_stop` | 写入 | `name`, `confirm?` | 停止服务 |
| `service_restart` | 写入 | `name`, `confirm?` | 重启服务(非幂等) |
| `run_shell` | 高危 | `command`, `confirm?` | 在路由器上执行任意 shell 命令(默认关闭,需二次确认) |

### 6.1 成功返回示例

```json
{
  "ok": true,
  "tool": "service_status",
  "host": "192.168.1.1",
  "duration_ms": 412,
  "data": {
    "service": {
      "name": "dnsmasq",
      "running": true,
      "enabled": true,
      "pid": 2814,
      "started_at": "2026-09-10 09:00:12",
      "description": "/usr/sbin/dnsmasq -C /var/etc/dnsmasq.conf",
      "source": "procd"
    },
    "checked_at": "2026-09-10T09:21:03+08:00"
  },
  "error": null
}
```

### 6.2 需要二次确认时

```json
{
  "ok": false,
  "tool": "service_restart",
  "host": "192.168.1.1",
  "error": {
    "code": "CONFIRMATION_REQUIRED",
    "message": "写入操作 restart 需要二次确认",
    "hint": "确认无误后再次调用,并传入 confirm=\"dnsmasq\"",
    "details": {"service": "dnsmasq", "expected_confirm": "dnsmasq"}
  }
}
```

### 6.3 常见用法(场景示例)

**识别连接器本质(capability)**

`router_info` 的返回中包含机器可读的 `capability` 字段(固定为 `ssh-router-management`),上层软件可据此确认本连接器是「可通过 SSH 操作路由器」的 MCP 连接器,而非普通工具:

```json
{
  "ok": true,
  "tool": "router_info",
  "data": {
    "capability": "ssh-router-management",
    "init_system": "procd",
    "host": "192.168.1.1",
    "security": {
      "require_confirmation": true,
      "allow_shell": false,
      "denied_services": ["network", "firewall", "system", "boot", "done"]
    }
  }
}
```

**通过 run_shell 安装插件(OpenWrt / ImmortalWrt)**

服务管理类工具只覆盖已注册 init 脚本的服务,安装插件这类操作需走 `run_shell` 高危通道。先确保已设置 `ROUTER_MCP_ALLOW_SHELL=true` 启用该能力,再按「命令本身」做二次确认:

```bash
# 1) 刷新软件源并安装插件(OpenWrt 包管理器 opkg)
run_shell(
  command="opkg update && opkg install luci-app-sqm",
  confirm="opkg update && opkg install luci-app-sqm"
)

# 2) 安装后启用并随系统自启
run_shell(
  command="/etc/init.d/sqm enable && /etc/init.d/sqm start",
  confirm="/etc/init.d/sqm enable && /etc/init.d/sqm start"
)
```

> 经验证,`opkg update` / `opkg install <包名>` / 带 `&&` 的连写均不会被高危拦截列表误拦;被拦的只有 `rm -rf` / `mkfs` / `dd if=` / 写 `/dev/*` / fork 炸弹 / `curl ... | sh` 等模式。
> `run_shell` 以 **root** 身份在远端登录 shell 中执行,绕过服务白名单,仅应在可信网络内启用;每次执行都会写入审计日志(仅含命令本身,不含任何凭据)。

**run_shell 返回示例**

```json
{
  "ok": true,
  "tool": "run_shell",
  "data": {
    "raw_command": "opkg update && opkg install luci-app-sqm",
    "exit_status": 0,
    "stdout": "Installing luci-app-sqm (...)",
    "stderr": "",
    "host": "192.168.1.1",
    "reused_connection": true,
    "confirmed": true,
    "warning": "命令以 root 身份在路由器上执行,已绕过服务白名单;请确认输出不含敏感信息"
  }
}
```

---

## 7. 安全模型

1. **无 shell 拼接**:所有命令以 argv 列表经 exec 通道下发,全程不出现 `sh -c`。
2. **服务名白名单**:`^[A-Za-z0-9][A-Za-z0-9_.@+-]{0,63}$`,拒绝 `/`、空格、`;`、`|`、`&`、`$`、反引号、换行与 `..`。
3. **路径白名单**:可读路径限定为 `/proc/1/comm`、`/proc/uptime`、`/proc/stat`、`/etc/os-release`、`/etc/openwrt_release`、`/etc/init.d`、`/etc/rc.d`、`/run/systemd/system`。
4. **写入二次确认**:`confirm` 必须与目标服务名完全一致;可用 `ROUTER_MCP_REQUIRE_CONFIRM=false` 关闭(不推荐)。
5. **高危服务拒绝写入**:`network`、`firewall`、`system`、`boot`、`done` 默认只可读不可写(重启这些服务会断网或重启设备)。
6. **可选服务白名单**:设置 `ROUTER_MCP_ALLOWED_SERVICES` 后,白名单外的服务读写均被拒绝。
7. **只读 / 写入标注**:工具 annotations 标注 `readOnlyHint` / `destructiveHint` / `idempotentHint`,客户端可据此做权限提示。
8. **可选 shell 能力(高危,默认关闭)**:`run_shell` 必须显式设置 `ROUTER_MCP_ALLOW_SHELL=true` 才会出现并可调用;启用后命令仍经 `validate_shell_command` 校验,默认拦截 `rm -rf / mkfs / dd if= / 写入 /dev/* / fork 炸弹 / 管道进 shell` 等高危模式,且必须把 `confirm` 设为**命令本身的完整字符串**才会真正执行。shell 命令以 root 身份在远端登录 shell 中执行,绕过服务白名单——这是刻意保留的逃生通道,而非默认能力。
9. **口令不外泄**:`config.redacted()` 用于输出快照,日志与工具返回值中不含密码。

---

## 8. 错误码

| 错误码 | 触发场景 | 典型修复 |
| --- | --- | --- |
| `CONFIG_ERROR` | 缺少 host / 认证方式,配置文件非法 | 设置 `ROUTER_MCP_HOST` 与口令或私钥 |
| `INVALID_INPUT` | 服务名 / 行数 / 参数非法 | 使用 `service_list` 获取合法服务名 |
| `BLOCKED_COMMAND` | 路径或参数绕过白名单 | 属于实现缺陷,请提交 issue |
| `CONNECTION_FAILED` | TCP 不可达、连接被重置 | 检查 host/port、设备 SSH 服务与防火墙 |
| `AUTH_FAILED` | 用户名 / 密码 / 私钥错误 | 核对凭据;OpenWrt 默认用户为 `root` |
| `HOST_KEY_UNVERIFIED` | known_hosts 不匹配 | 更新 known_hosts 或临时关闭严格校验 |
| `COMMAND_TIMEOUT` | 命令超过 `COMMAND_TIMEOUT` | 调大超时,或检查设备负载 |
| `COMMAND_FAILED` | 命令返回非零退出码 | 查看返回中的 `stderr` 字段 |
| `SERVICE_NOT_FOUND` | 服务不存在 | 用 `service_list` 确认名称(注意大小写) |
| `PERMISSION_DENIED` | 命中黑名单 / 白名单外 / polkit 拒绝 | 调整安全策略或以 root 连接 |
| `CONFIRMATION_REQUIRED` | 写入操作缺少正确 confirm | 传入 `confirm="<服务名>"`;shell 则需 `confirm="<命令本身>"` |
| `SHELL_DISABLED` | 未开启 shell 能力却调用 `run_shell` | 设置 `ROUTER_MCP_ALLOW_SHELL=true` 启用(谨慎) |
| `UNSUPPORTED_ACTION` | 日志通道不可用(无 logread / journald) | 启用 logd 或 journald 持久化 |
| `BACKEND_ERROR` | 解析 ubus / systemctl 输出失败 | 检查系统版本兼容性 |

---

## 9. 本地自测步骤

共四层,从无依赖到真实设备逐级验证。

### 第 1 层:离线单元测试(无需设备、无需网络)

```bash
pip install -e ".[dev]"
python -m pytest -q
```

覆盖:命令白名单、配置优先级、procd/systemd 解析、确认机制、黑名单、超时映射、错误结构。

### 第 2 层:MCP 协议冒烟(验证客户端视角)

```bash
# 仅握手 + 列工具,不需要设备
python scripts/mcp_smoke.py

# 真实调用一次只读工具
ROUTER_MCP_HOST=192.168.1.1 ROUTER_MCP_USER=root ROUTER_MCP_PASSWORD=xxx \
  python scripts/mcp_smoke.py --call router_info

# 带参数调用
python scripts/mcp_smoke.py --call service_status --args '{"name": "dnsmasq"}'
```

预期输出:握手成功、8 个工具、读写标注正确;调用失败时返回 `ok=false` 与错误码。

### 第 3 层:真实设备自测(只读)

```bash
ROUTER_MCP_HOST=192.168.1.1 ROUTER_MCP_USER=root ROUTER_MCP_PASSWORD=xxx \
  python scripts/selftest.py
```

依次验证:SSH 连接与 init 探测 → 服务列表 → 单服务状态 → 日志读取 → 错误路径(服务不存在)→ 写入二次确认拦截,并输出表格化结果。加 `--json` 可查看原始返回。

### 第 4 层:写入演练(谨慎)

```bash
python scripts/selftest.py --host 192.168.1.1 --password xxx \
  --service dnsmasq --do-write
```

会真正执行一次 `restart`。建议先挑选无状态影响的服务(如 `dnsmasq`、`odhcpd`),不要在远程办公时段对 `network` / `firewall` 演练。

### 排障小贴士

| 现象 | 处理 |
| --- | --- |
| `AUTH_FAILED` | OpenWrt 默认禁止空密码;确认 dropbear 允许该用户登录 |
| `COMMAND_TIMEOUT` 频繁 | 设备 CPU 繁忙或网络丢包,调大 `ROUTER_MCP_COMMAND_TIMEOUT` |
| `logread` 无输出 | 确认 `logd` 在运行:`service_status(name="log")` |
| systemd 主机 `PERMISSION_DENIED` | 以 root 连接,或配置 polkit / sudo 规则 |
| 想看 SSH 握手细节 | 设置 `ROUTER_MCP_LOG_LEVEL=DEBUG`(日志仅写 stderr,不影响 stdio 协议) |

---

## 10. 已知限制

- 单条 SSH 连接复用,写入操作串行执行;高并发场景建议为每个设备起独立进程。
- OpenWrt 的启动时间依赖 `/proc/<pid>/stat` 与 `/proc/stat` 的 `btime`,容器或受限命名空间内可能拿不到。
- `service_list` 默认不填充 `started_at`(避免对每个服务额外发起命令),需要时传 `with_start_time=true` 或调用 `service_status`。
- systemd 主机上 `enabled` 判定包含 `static` 状态(视为自启)。
- 默认 `run_shell` 不可用(刻意的安全设计);仅当用户显式设置 `ROUTER_MCP_ALLOW_SHELL=true` 后才提供任意命令执行能力。该通道仍受高危拦截列表与强制二次确认约束,但本质上以 root 身份运行,请仅在可信网络内启用。

---

## 11. 许可证

MIT

TDQS

A4.1/5.0

Scored across 8 tools

Disambiguation5/5

Each tool has a clearly distinct role: router-level info, service list/status/logs, three explicit service actions, and shell execution. The service_list/service_status pair is well-differentiated as bulk listing vs single-service lookup.

Naming Consistency4/5

The service_* family follows a predictable and readable pattern, and all names use snake_case. Minor deviations exist: router_info is noun-style rather than verb_noun, and service_logs uses a noun instead of a verb like service_log.

Tool Count5/5

Eight tools is well-scoped for an SSH-based router management server. The set covers inspection, logs, and service lifecycle operations without bloating the surface.

Completeness4/5

Core service operations—list, status, logs, start, stop, restart—are covered, plus router health info and shell fallback. A notable minor gap is the lack of first-class service enable/disable operations, since the enabled state is reported but cannot be changed through the main tools.

Maintenance

ActivityMaintained
ResponsivenessNo issues