Skip to main content
Glama
loop-killer

NAS Serial Console MCP Server

by loop-killer
README.md
# NAS Serial Console MCP Server

> 通过 **Model Context Protocol (MCP)** 远程接管 NAS 物理串口控制台 —— 一条在设备彻底失联时仍可用的应急通道。

[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![MCP](https://img.shields.io/badge/Protocol-MCP-6E56CF)](https://modelcontextprotocol.io/)
[![Cloudflare](https://img.shields.io/badge/Cloudflare-Zero%20Trust-F38020?logo=cloudflare&logoColor=white)](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