手机远程操作本机终端:Remote Claude Mobile Console 的设计与公网部署

一个自托管的单用户终端桥接系统:手机浏览器通过 WebSocket 连接到 Windows 电脑上的真实终端。
它不绑定任何特定工具——Claude Code、git、npm、docker、ssh、python 等一切 CLI 程序都可以通过它操作,Claude Code 只是最典型的场景之一。
本文介绍整体设计、消息协议、鉴权体系,以及基于 Laravel 管理员网关 + Nginx auth_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.statusterminal.outputterminal.exitapproval.requestchoice.requesterror
  • 手机可发送:terminal.input_textterminal.input_rawterminal.resizeapproval.responsechoice.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: stdoutstderr` 与时间戳
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/ 启动流程:

  1. 生成 8 位数字配对码;
  2. 携带设备号(主机名)、项目名(当前目录名)、版本号注册到 /ws/agent
  3. 收到 agent.start 后调用 pty.spawn 拉起终端 shell(默认 cmd.exe,检测到 PowerShell 时自动附加 -NoLogo -NoProfile);
  4. term.onData 将终端输出打包为 terminal.output 上行;
  5. 手机发来的原始字节直接写入 PTY,因此控制键可以透传。

由于它操作的是通用 PTY,而不是某个特定程序的接口,因此可以连接任意 CLI 工具:claudegitnpmsshdockerpython 等均可远程操作。项目名取自 Agent 启动时的当前目录,便于在多项目间区分会话。

6. 身份认证体系

中继内部采用三层校验:

  1. 管理员密码/api/login 登录,bcrypt 校验,失败限流;
  2. 手机 token:登录成功后签发,/ws/phone?token= 每次连接校验,默认 1 小时有效;
  3. 配对码: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 判定管理员 → 放行后代理至 FRP

7.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 而非 Nginxauth_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 网络,服务名 nginxphp),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   # → manifest

8. 本地运行

初始化与启动由脚本封装:

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_PORT87**中继服务监听端口
WEB_BASE/remote/PWA 构建 base path,与 Nginx location 对应
PUBLIC_REMOTE_URLhttps://example.com/remote/(示例默认值,部署时替换真实域名)远程模式打印给手机的地址
ADMIN_PASSWORD_HASH必填管理员密码的 bcrypt hash
SESSION_SECRET必填不小于 32 位的随机密钥
PAIR_CODE_TTL_MS300000配对码有效期(5 分钟)
PHONE_RECONNECT_TTL_MS3600000token 有效期。注意 .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 单测;
  • 单用户设计,无多租户隔离。

本文由 fmujie 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论

召唤看板娘