手机远程操作本机终端:Remote Claude Mobile Console 的设计与公网部署
一个自托管的单用户终端桥接系统:手机浏览器通过 WebSocket 连接到 Windows 电脑上的真实终端。
它不绑定任何特定工具——Claude Code、git、npm、docker、ssh、python 等一切 CLI 程序都可以通过它操作,Claude Code 只是最典型的场景之一。
本文介绍整体设计、消息协议、鉴权体系,以及基于 Laravel 管理员网关 + Nginxauth_request+ FRP 内网穿透的公网部署方案。
1. 项目定位
Remote Claude Mobile Console 解决的是一个具体问题:终端会话运行在本机 Windows 电脑上,但使用者不在电脑前,需要通过手机继续操作。
常见替代方案的局限:
- 远程桌面类工具(如 TeamViewer)适合图形界面操作,但为了执行一条命令启动整个远程桌面,成本过高;
- 在线终端托管服务需要把代码与数据经过第三方服务器,不适合自托管场景;
- 自行搭建 SSH 通道依赖公网 IP 或额外隧道,且手机端缺少专用的终端交互界面。
因此该项目选择自托管方案:终端始终运行在本机,公网侧只负责页面托管、身份认证与消息中继。手机端与终端之间不存在直接连接,所有指令都经过中继服务的协议校验。
项目定位为单用户、自托管工具,不做多租户隔离,服务端状态保存在内存中。
2. 整体架构
手机浏览器
│ HTTPS / WSS
▼
公网入口(云服务器)
│ /remote/ → Laravel 网关 → storage/app/remote 静态 PWA
│ /remote-api/* → Nginx auth_request → Laravel 判定管理员
│ → proxy_pass 到 host.docker.internal:<84**>
▼
frps(云服务器,host 网络,bind_port=<74**>)
│ FRP 隧道(frpc 经 74** 接入,remote_port=<84**>)
▼
本机 Fastify server(127.0.0.1:<87**>)—— 登录 / 配对 / WebSocket 中继
│ ws://localhost:<87**>/ws/agent
▼
agent-node(node-pty)
▼
cmd / PowerShell —— 在此运行任意 CLI 程序核心设计原则:中继服务不运行终端,终端由本机 Agent 运行。中继服务只负责三件事——验证手机身份、撮合手机与 Agent 配对、在两端转发白名单内的消息。手机不直接接触裸终端,所有指令必须通过协议校验。
仓库由五个模块组成:
| 目录 | 职责 |
|---|---|
server/ | Fastify 中继服务:登录、配对、WebSocket 转发、断线恢复 |
web/ | React + Vite 手机端 PWA,使用 xterm.js 渲染终端 |
agent-node/ | node-pty 驱动的本机 Agent,负责拉起终端进程 |
shared/ | 手机 / 中继 / Agent 三方共享的 TypeScript 协议定义 |
launcher/ | PyQt5 桌面启动器,封装依赖安装、配置生成与启停 |
3. 中继服务设计
server/ 基于 Fastify 与 @fastify/websocket,入站消息使用 Zod 校验。公开接口如下:
| 接口 | 作用 |
|---|---|
GET /health | 存活检查 |
GET /status | 诊断信息:在线 Agent 数、可配对 Agent 数、会话数等 |
POST /api/login | 管理员密码登录,签发手机 token |
GET /ws/agent | 本机 Agent 注册(携带配对码、设备号、项目名、版本号) |
GET /ws/phone | 手机连接(携带 token,支持配对 / 列出会话 / 恢复会话) |
登录与限流
- 密码以 bcrypt hash 形式保存在配置中(
ADMIN_PASSWORD_HASH),登录时执行bcrypt.compare校验; - 按客户端 IP 记录失败次数,5 分钟内失败 10 次返回 429。由于服务部署在反向代理之后,限流键优先取
X-Forwarded-For首段,避免所有请求被识别为代理地址; - 登录成功签发 48 位随机 token,默认有效期 1 小时(配置项
PHONE_RECONNECT_TTL_MS)。
消息类型白名单
Agent 与手机的消息类型均采用白名单校验,白名单之外的类型直接返回 unsupported_message:
- Agent 可发送:
session.status、terminal.output、terminal.exit、approval.request、choice.request、error; - 手机可发送:
terminal.input_text、terminal.input_raw、terminal.resize、approval.response、choice.response。
断线恢复
手机切换后台再返回时 WebSocket 可能断开,终端画面也会丢失。解决方案:中继为每个会话维护 recentEvents 环形缓冲(默认最近 100 条事件)。手机重连后发送 phone.resume,中继按顺序补发缓冲内容,再接入实时流。
该设计以内存为代价换取实现简单,服务重启后 token、配对码与可恢复会话全部丢失,这对单用户自托管场景是可接受的取舍。
4. 消息协议
shared/protocol.ts 是三方消息的唯一事实来源,中继服务再以 Zod 校验一次,防止协议版本漂移。
Agent → 手机(中继转发并补充 sessionId):
| 类型 | 用途 | |
|---|---|---|
session.status | 状态机:pairing / connected / running / phone_disconnected / agent_disconnected / exited / error | |
terminal.output | 终端输出流,含 `stream: stdout | stderr` 与时间戳 |
terminal.exit | 终端退出码 | |
approval.request | 请求手机批准或拒绝某个操作,带风险等级 low/medium/high | |
choice.request | 请求手机选择一个选项 |
手机 → Agent:
| 类型 | 用途 |
|---|---|
terminal.input_text | 文本命令加回车 |
terminal.input_raw | 原始字节输入,用于 Ctrl+C 等控制键透传 |
terminal.resize | 终端尺寸同步 |
approval.response | 批准或拒绝,Agent 端翻译为向终端写入 y / n |
choice.response | 选项值,翻译为写入对应文本加回车 |
一个值得注意的设计:approval.response 在 Agent 端翻译为普通的键盘输入。协议无需感知上层工具(Claude Code 或其他 CLI)的存在,审批流程对 Agent 而言就是一次输入,天然解耦。
5. Agent 实现
agent-node/ 启动流程:
- 生成 8 位数字配对码;
- 携带设备号(主机名)、项目名(当前目录名)、版本号注册到
/ws/agent; - 收到
agent.start后调用pty.spawn拉起终端 shell(默认cmd.exe,检测到 PowerShell 时自动附加-NoLogo -NoProfile); term.onData将终端输出打包为terminal.output上行;- 手机发来的原始字节直接写入 PTY,因此控制键可以透传。
由于它操作的是通用 PTY,而不是某个特定程序的接口,因此可以连接任意 CLI 工具:claude、git、npm、ssh、docker、python 等均可远程操作。项目名取自 Agent 启动时的当前目录,便于在多项目间区分会话。
6. 身份认证体系
中继内部采用三层校验:
- 管理员密码:
/api/login登录,bcrypt 校验,失败限流; - 手机 token:登录成功后签发,
/ws/phone?token=每次连接校验,默认 1 小时有效; - 配对码:8 位数字,默认 5 分钟有效,且一次性消费——配对成功即从存储中删除,同一配对码不可重复使用。
token 证明"手机已通过管理员登录",配对码证明"目标是本机这台 Agent",两者同时满足才建立会话。
7. 公网部署:Laravel 网关 + Nginx auth_request + FRP
7.1 为什么在公网入口加一层 Laravel
中继服务的密码登录只能保护 API 层,静态页面本身是裸露的。若将前端构建产物直接放入公网静态目录,任何人都能下载页面代码并尝试连接 /remote-api。
服务器上已有带用户体系与角色权限的 Laravel 后台,因此将 Laravel 作为公网大门:remote 的页面与 API 全部置于登录之后。
/remote → Laravel → storage/app/remote → 管理员放行,返回静态 PWA
/remote-api/* → Nginx auth_request /remote-auth → Laravel 判定管理员 → 放行后代理至 FRP7.2 Laravel 侧实现
路由定义(routes/web.php):
Route::get('/remote-auth', 'RemoteController@auth');
Route::get('/remote', 'RemoteController@index');
Route::get('/remote/{path}', 'RemoteController@asset')->where('path', '.*');控制器守卫逻辑(节选):
if (!Auth::check()) {
$key = 'remote-auth:' . $request->ip();
if ($limiter->tooManyAttempts($key, 10)) {
// 401/429 + Retry-After;auth_request 场景额外带 X-Remote-Auth-Status
}
$limiter->hit($key, 60);
return response('', 401);
}
if (!Auth::user()->hasRole('Administer')) {
return response('', 403);
}
return null; // 通过设计要点:
- 限流放在 Laravel 而非 Nginx:
auth_request子请求模式下 Nginx 无法对 PHP 业务限流,因此未登录 IP 每分钟最多探测 10 次,超过返回 429 并携带Retry-After; - 静态文件不放入
public/:构建产物位于storage/app/remote,由控制器鉴权后以response()->file()返回。若放入public/remote会被 Nginx 直接暴露,绕过 Laravel 鉴权; - 静态文件服务带路径穿越防护:剥离前导斜杠、拒绝
..,realpath()归一化后校验结果必须位于 base 目录内,再按扩展名白名单映射 MIME 类型(html/js/css/json/webmanifest/wasm 等)。
7.3 Nginx 配置
服务器上的 Nginx 与 PHP 均为 Docker 容器(compose 网络,服务名 nginx、php),frps 容器使用 host 网络。
/etc/nginx/conf.d/front.conf 中与 remote 相关的段落:
server {
listen 443 ssl http2 default_server;
server_name s***.f*****.cn;
ssl_certificate /etc/letsencrypt/live/s***.f*****.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/s***.f*****.cn/privkey.pem;
root /var/www/front/public;
index index.php index.html;
# PWA 的 API 与 WebSocket:先经 Laravel 鉴权,再代理至 FRP 远程端口
location ^~ /remote-api/ {
auth_request /remote-auth;
proxy_pass http://host.docker.internal:<84**>/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# auth_request 子请求:internal 不对外暴露,直接转发同网络 PHP-FPM
location = /remote-auth {
internal;
include fastcgi_params;
fastcgi_pass php:<90**>;
fastcgi_pass_request_body off;
fastcgi_param REQUEST_METHOD GET;
fastcgi_param CONTENT_LENGTH "";
fastcgi_param CONTENT_TYPE "";
fastcgi_param SCRIPT_FILENAME /var/www/front/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param DOCUMENT_URI /index.php;
fastcgi_param REQUEST_URI /remote-auth;
fastcgi_param HTTP_COOKIE $http_cookie;
fastcgi_param HTTP_HOST $http_host;
fastcgi_param HTTPS on;
fastcgi_param SERVER_PORT 443;
}
# 静态页面全部交由 Laravel,鉴权后从 storage/app/remote 返回
location = /remote {
return 301 /remote/;
}
location ^~ /remote/ {
try_files /__not_exists__ /index.php?$query_string;
}
}实现要点:
proxy_pass末尾的/必须保留:它会剥离/remote-api/前缀,使中继服务收到干净的/status、/api/login、/ws/phone。若省略,请求路径携带前缀导致路由 404;proxy_buffering off关闭响应缓冲,保证终端输出的流式推送不被 Nginx 缓存;fastcgi_pass使用 compose 服务名而非容器 IP,容器重建后无需修改配置;/remote/通过try_files /__not_exists__ /index.php强制走 Laravel 路由,静态页面同样必须经过鉴权;- 80 端口 server 块仅处理
/.well-known/acme-challenge/与 301 跳转 HTTPS。
7.4 FRP 服务端(frps)配置
frps 以镜像 f***/frps:0.48.0 运行,host 网络,配置位于宿主机 /opt/frps-docker/data/frps.ini:
[common]
tls_enable = true
bind_addr = 0.0.0.0
bind_port = <74**> # frpc 接入端口(TLS)
dashboard_port = <64**> # Web 控制台端口
dashboard_user = *** #
dashboard_pwd = *** #
log_file = /home/frp/frps.log
log_level = info
log_max_days = 3
token = *** # frpc 连接认证 token
max_pool_count = 50
tcp_mux = true需要澄清的一点:frps 侧不定义端口映射,remote_port=<84**> → local_port=<87**> 由本机 frpc 配置。frpc 在线时 frps 才监听该远程端口,frpc 断开后对应端口随即不可访问——排查"公网 401 变成 500"时应首先检查这一环。
另外,host 网络模式下 docker ps 不显示端口映射,需用 ss -tlnp 确认 frps 实际监听的端口。
7.5 链路验证
# 1) 底层:frpc 在线时,云服务器上应能直连本机中继
curl http://127.0.0.1:<84**>/status # → 200 JSON
# 2) 公网:未登录访问 API 应被网关拦截
curl -k -i https://s***.f*****.cn/remote-api/status
# → 401(未认证)/ 429(被限流)/ 403(非管理员),不应返回 500
# 3) PWA 资源
curl -k https://s***.f*****.cn/remote/manifest.json # → manifest8. 本地运行
初始化与启动由脚本封装:
setup-env.cmd # 安装依赖并交互式生成 .env(含密码 hash 与 session secret)
start-local.cmd # 启动三个窗口:server / web / agent随后在浏览器打开脚本输出的地址,输入管理员密码,再输入 Agent 窗口打印的配对码。
公网模式增加 FRP:
npm --prefix web run build # 产物上传至 Laravel 的 storage/app/remote
start-remote.cmd # 启动三个窗口:FRP / server / agent.env 主要配置项:
| 变量 | 默认值 | 说明 |
|---|---|---|
REMOTE_PORT | 87** | 中继服务监听端口 |
WEB_BASE | /remote/ | PWA 构建 base path,与 Nginx location 对应 |
PUBLIC_REMOTE_URL | https://example.com/remote/(示例默认值,部署时替换真实域名) | 远程模式打印给手机的地址 |
ADMIN_PASSWORD_HASH | 必填 | 管理员密码的 bcrypt hash |
SESSION_SECRET | 必填 | 不小于 32 位的随机密钥 |
PAIR_CODE_TTL_MS | 300000 | 配对码有效期(5 分钟) |
PHONE_RECONNECT_TTL_MS | 3600000 | token 有效期。注意 .env.example 标注 1 小时,config.ts 代码内默认实为 10 分钟,建议在 .env 显式配置 |
另外提供一个 PyQt5 桌面启动器,面向不熟悉命令行的用户:依赖安装、hash 与 secret 生成、服务启停、日志查看均在一个窗口内完成,并可通过 launcher/build-exe.cmd 打包为可执行文件。
9. 现状与限制
已实现的能力:
- 手机端实时查看终端输出、输入命令、透传控制键;
- Claude Code 等工具的审批与选项请求可直接在手机端处理;
- 短时间断线自动恢复;
- 公网链路完整:Laravel 管理员网关、Nginx 子请求鉴权、FRP 隧道。
当前限制:
- 状态全部保存在内存,服务重启后 token、配对码与可恢复会话丢失;
- 配对码使用
Math.random()生成,公网环境建议替换为加密安全随机源; - web 端尚无测试用例,
server/的存储模块已有 vitest 单测; - 单用户设计,无多租户隔离。

还不快抢沙发