NAS Serial Console MCP Server
by loop-killer
README.md
# NAS Serial Console MCP Server
> 通过 **Model Context Protocol (MCP)** 远程接管 NAS 物理串口控制台 —— 一条在设备彻底失联时仍可用的应急通道。
[](https://nodejs.org/)
[](https://modelcontextprotocol.io/)
[](https://www.cloudflare.com/zero-trust/)
---
## 问题背景
NAS 设备在系统崩溃、内核 panic 或网络配置错误时会**彻底失联**——SSH 连不上、Web 管理界面打不开、ping 不通。此时唯一的救援通道是**物理串口控制台**,但维护者往往不在设备旁边。
更麻烦的是,接入路径上的**软路由不允许安装任何额外软件**(零安装约束),常规的 VPN / 反向代理方案全部不可用。
**目标**:在软路由零改动的前提下,建立一条可从公网安全接入的串口应急通道,并把它封装成 **LLM 智能体可以直接调用的工具**——让 AI 能在无人值守时完成救援。
---
## 架构
```
┌─────────────────┐
│ MCP Server │ 本地 PC(不受软路由零安装约束)
│ (Node.js) │
└────────┬────────┘
│ wss://your-tunnel.example.com/ws
│ + CF-Access-Client-Id / CF-Access-Client-Secret
▼
┌─────────────────┐
│ Cloudflare │ Service Token 机器身份认证(唯一门禁)
│ Access + Tunnel │
└────────┬────────┘
▼
┌─────────────────┐
│ ttyd │ 软路由本地 127.0.0.1:7682(WebSocket 终端)
│ (软路由) │ ⚠️ 零安装:仅使用软路由自带组件
└────────┬────────┘
▼
┌─────────────────┐
│ serial-bridge │ /dev/ttyS1
│ │ ── 母对母交叉线 ──
└────────┬────────┘
▼
┌─────────────────┐
│ NAS ttyS0 │ 115200 8N1
└─────────────────┘
```
**设计要点**
| 决策 | 理由 |
|---|---|
| MCP Server 跑在**主 PC** 而非软路由 | 绕开"软路由零安装"硬约束,同时获得完整的 Node.js 运行时 |
| 使用 Cloudflare **Service Token** 而非用户密码 | 机器身份认证,无交互、可轮换、可吊销;ttyd 本地认证已移除,CF Access 成为唯一门禁 |
| 全程 WebSocket 二进制帧 | ttyd 1.7.3 的协议要求(见下方「协议逆向」) |
| 串口连接独立于 NAS 生命周期 | NAS 重启不打断桥接,仅需 `serial_reset` 同步状态 |
---
## 协议逆向(ttyd 1.7.3)
`ttyd-client.js` 中的协议实现**经活体实例抓包验证**,与 ttyd 官方前端源码一致:
1. `GET /token` → `{"token":"..."}`
(ttyd 1.7.3 返回的实为 `-c` 凭据本身;未配置 `-c` 时为空串,WS 侧 `AuthToken` 校验同步失效)
2. 连接 `ws(s)://host/ws`,子协议 `tty`,请求头携带:
- `CF-Access-Client-Id` / `CF-Access-Client-Secret`(经 Cloudflare Access)
- `Authorization: Basic base64(user:pass)`(ttyd 本地认证)
3. 连接建立后,发送**二进制帧**初始化 JSON:`{"AuthToken":"<token>","columns":120,"rows":40}`
4. 服务端**二进制帧首字节**为类型判别符:
- `'0'` (0x30) = 输出数据
- `'1'` (0x31) = 设置标题
- `'2'` (0x32) = 应用偏好(JSON)
5. 客户端输入:二进制帧 = `'0'` (0x30) + 原始输入字节
> ⚠️ **关键坑**:初始化与输入**都必须用二进制帧**(`TextEncoder` 编码)。用文本帧发送输入会被 ttyd 静默忽略——这是本项目调试耗时最久的问题。
---
## 兼容性处理
### 逐字符节流(`charThrottleMs`)
旧版 **ash** shell 的串口行处理存在字符丢失缺陷,整帧直发会导致命令截断。解决方案是**逐字符节流发送**,模拟人工输入速率。
| 桥接实现 | 推荐值 | 说明 |
|---|---|---|
| `tio`(现役) | `0` | 整帧直发,性能最优 |
| 旧版 `ash` | `10–20` | 逐字符节流,规避字符丢失 |
### BREAK 信号字节序列(`breakBytesHex`)
不同桥接实现对 BREAK 的定义不同,需按实现配置:
| 桥接实现 | 值 | 含义 |
|---|---|---|
| `tio`(现役) | `1462` | `Ctrl-T b` |
| 旧版 `ash` | `1b9c1b9c` | 历史魔数 |
BREAK 用于 NAS **完全卡死**(console 无响应)时触发内核 **Magic SysRq**,随后在 5 秒窗口内发送 `REISUB` 序列,完成「杀进程 → 落盘 → 只读重挂 → 重启」。
---
## 快速开始
```bash
npm install
cp config.example.json config.json
# 编辑 config.json,填入 Cloudflare Service Token
```
### 配置项
| 字段 | 说明 |
|---|---|
| `baseUrl` | 隧道公网入口(如 `https://your-tunnel.example.com`),或内网测试用 `http://127.0.0.1:7682` |
| `cfClientId` / `cfClientSecret` | Cloudflare Access Service Token(CF 后台生成) |
| `columns` / `rows` | 终端尺寸,默认 `120x40` |
| `charThrottleMs` | 逐字符节流间隔;`tio` 桥设 `0` |
| `breakBytesHex` | BREAK 键字节序列(hex) |
| `username` / `password` | ttyd 本地认证,**可留空**(CF Access 为唯一门禁) |
| `nasUsername` / `nasPassword` | (可选)NAS 登录凭据,供 Agent 救急时自动登录 |
### 内网测试
若软路由可达但 Cloudflare 未配好,用 SSH 端口转发临时映射:
```bash
ssh -L 7682:127.0.0.1:7682 root@<软路由IP>
# baseUrl 填 http://127.0.0.1:7682,cfClientId/cfClientSecret 留空
```
---
## MCP 工具
| 工具 | 功能 |
|---|---|
| `read_serial_output` | 读取自上次读取以来的新增输出(原始文本,无 OCR 误差) |
| `send_serial_command` | 发送一行命令(自动补 `\r`) |
| `send_raw_key` | 发送特殊按键:`enter` `ctrl+c` `ctrl+d` `ctrl+z` `esc` `tab` `backspace` `up` `down` `left` `right` `home` `end` `space` `break` |
| `serial_reset` | NAS 重启后重建连接、清空旧缓冲 |
### 注册到 MCP 客户端
```json
{
"mcpServers": {
"nas-serial": {
"command": "node",
"args": ["/绝对路径/mcp-server/index.js"],
"cwd": "/绝对路径/mcp-server"
}
}
}
```
注册后工具名为 `mcp__nas-serial__read_serial_output` 等。
---
## 救急工作流
```
1. read_serial_output → 看到 getty 登录提示
2. send_serial_command("root") → 提示 Password:
3. send_serial_command("<pw>") → 进入 shell
4. send_serial_command("sync") → 必要时 reboot
【NAS 卡死】
5. send_raw_key("ctrl+c") → 中断当前进程,再诊断
【NAS 完全卡死,console 无响应】
6. send_raw_key("break") → 触发内核 Magic SysRq
5 秒窗口内发送 REISUB 序列(e s u b)
→ 杀进程 → 落盘 → 只读重挂 → 重启
```
---
## 安全说明
- **凭据绝不入库**:`config.json` 已加入 `.gitignore`(文件权限 `600`)。仓库中仅提供 `config.example.json` 占位模板
- **零信任接入**:Cloudflare Access Service Token 为唯一门禁,无公网 IP、无端口映射暴露
- **单客户端连接**:ttyd `max_clients=1`,避免多个会话争抢串口字符(人类占用网页终端时 MCP 会连接失败,稍后重试即可)
- **最小依赖**:软路由侧零安装,不引入新的攻击面
---
## 项目结构
```
mcp-server/
├── index.js # MCP Server 主体:工具定义、会话管理、串口交互
├── ttyd-client.js # ttyd 1.7.3 WebSocket 协议客户端
├── config.example.json # 配置模板(占位符)
├── config.json # 实际配置(gitignored,含凭据)
└── package.json
```
**依赖**:`@modelcontextprotocol/sdk` · `ws` · `zod`
---
## 已知限制
- 依赖 ttyd 1.7.3 的协议实现,升级 ttyd 可能需要适配
- `charThrottleMs > 0` 时输入吞吐降低(旧 shell 兼容代价)
- 单一串口设备对应单一 MCP Server 实例
---
## 许可
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues