自建 Paseo Relay:让手机连上内网 AI Agent 的两次踩坑

Paseo 是一个在本机跑 daemon、用手机 / 桌面 / CLI 作为 client 去控制 AI coding agent 的工具。它的核心架构是 daemon 负责生命周期与状态,所有 client(含手机)只是 WebSocket 客户端。daemon 默认监听 127.0.0.1:6767——这在本地很安全,但带来一个直接问题:当我在外面用手机想连家里的 daemon 时,连不上。

官方提供了一个托管 relay 来解决这个问题,但很多人(包括我)更愿意自建。本文记录自建 relay 的完整过程,以及其中两个非显而易见的坑——第二个坑的根因要翻到源码里才看得清。

一、先理解架构:为什么是 relay,而不是"直接连"

手机要连 daemon,直觉方案是"给 daemon 开个公网入口"。但这在现实中几乎都走不通:

根本约束只有一句话:NAT 后的设备无法被外部主动发起连接,只能维持它自己主动发起的连接。 所以正确方向是让 daemon 主动"拨出去",再让手机也"拨出去",两边在一个公网中转点会合。这个中转点就是 relay。

relay 的工作原理

手机 App ──────────────────────┐
                                 ▼
                          [ paseo-relay ]
                                 ▲
本机 daemon ──────────────────────┘
        (两者都主动出站拨 relay)

relay 有三个 WebSocket 角色(见 paseo-relay 的协议说明):

角色query 参数说明
daemon controlrole=server&v=2每 daemon 一条,接收 connect/disconnect 事件
daemon datarole=server&connectionId=<c>每条 client 连接一条,转发加密帧
clientrole=client[&connectionId=<c>]手机侧 socket

为什么 relay 不需要认证,却仍然是安全的

这是整个设计里最精妙的一点。relay 没有任何鉴权——任何人都能连。但 daemon 和手机之间的流量是 E2E 端到端加密的(XSalsa20-Poly1305 / NaCl box),密钥由两端在配对时用 ECDH 协商。relay 能看到的只有 IP、时间、包大小、session ID,永远看不到内容,也无法伪造消息

这叫 零知识中继(zero-knowledge relay)。它的安全模型把 relay 定位成"完全不可信的哑管道"——你可以把它部署在任何 VPS 上,哪怕这台机器被入侵,也不会泄露你的 agent 数据。这是一个很好的设计示范:把信任边界从"传输节点"收缩到"通信两端",中间链路无论怎么腐化都不影响机密性。

二、部署 relay:nginx 做 TLS 前置

relay 本身监听 127.0.0.1:8411,不直接暴露。前面挂一层 nginx 在 443 做 TLS 终结 + WebSocket 反代。为什么一定要 TLS?README 写得很直白:

Always terminate TLS upstream; do not expose the relay over plain HTTP.

明文 ws:// 会让中间设备(运营商、防火墙、DPI)看到完整的 WebSocket 流量特征,TLS 是基本卫生。典型部署:

# relay 进程:只听本地
paseo-relay --addr 127.0.0.1:8411

# nginx:443 TLS + WebSocket 反代到 8411
server {
    listen 443 ssl http2;
    server_name relay.example.com;
    ssl_certificate     /path/to/fullchain.pem;
    ssl_certificate_key /path/to/privkey.pem;

    location /ws {
        proxy_pass http://127.0.0.1:8411;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400s;   # WebSocket 长连接,别让 nginx 主动断
    }
}

部署完先自测:curl https://relay.example.com/health 应返回 {"status":"ok","version":"..."}

三、接入 daemon:第一个坑只是热身

daemon 侧的配置在 ~/.paseo/config.json。照 README 抄一份:

{
  "daemon": {
    "relay": {
      "enabled": true,
      "endpoint": "relay.example.com:443",
      "publicEndpoint": "relay.example.com:443"
    }
  }
}

重启 daemon(paseo daemon stop && paseo daemon start)。如果你运气好,到这里就通了。我没有——日志开始持续刷这个:

四、第二个坑(核心):自建 relay 默认不开 TLS

{"level":40,"module":"relay-transport",
 "err":{"message":"Unexpected server response: 400"},
 "url":"ws://relay.example.com:443/ws?serverId=srv_XX&role=server&v=2",
 "msg":"relay_error"}

注意 URL 里的协议:ws://,明文。可我连的是 443——那是 nginx 的 TLS 端口。daemon 用明文 WebSocket 去敲一个 TLS 端口,nginx 在 TLS 握手阶段就拒绝了,返回 400 Bad Request

奇怪的地方在于:我配置里写的是 relay.example.com:443,443 这个端口按常识就该走 TLS,daemon 为什么不自动用 wss://

五、根因:翻进 resolveRelayConfig 才看清默认值逻辑

顺着 relay-transport 这个 module 名去翻 @getpaseo/server 的代码,定位到 resolveRelayConfig。TLS 的判定逻辑是:

function resolveRelayConfig(input) {
    const endpoint = input.env.PASEO_RELAY_ENDPOINT
        ?? input.persisted.daemon?.relay?.endpoint
        ?? DEFAULT_RELAY_ENDPOINT;

    const useTls = input.cliRelayUseTls
        ?? resolveTlsFromEnv(
               input.env.PASEO_RELAY_USE_TLS,
               input.persisted.daemon?.relay?.useTls,
               endpoint === DEFAULT_RELAY_ENDPOINT   // ← 关键
           );
    ...
}

最后一行是全部真相:默认只有当 endpoint === DEFAULT_RELAY_ENDPOINT(即官方托管 relay)时,useTls 才为 true 自建 relay 的 endpoint 不等于官方默认值,于是 useTls 默认 false → 走 ws:// → 撞 443 → 400。

这其实是个合理的设计选择,只是对"自建 relay + nginx TLS"这种最常见部署不友好:

修复:显式打开 TLS

config 的 relay schema(zod,strict)允许的字段是 enabled / endpoint / publicEndpoint / useTls / publicUseTls。补两个字段:

{
  "daemon": {
    "relay": {
      "enabled": true,
      "endpoint": "relay.example.com:443",
      "publicEndpoint": "relay.example.com:443",
      "useTls": true,          // daemon → relay 走 wss://
      "publicUseTls": true     // 手机配对 offer 也带 useTls,手机 → relay 走 wss://
    }
  }
}

两个字段分工不同,缺一不可:

完整的取值优先级链(高 → 低):

来源enabledendpointuseTls
CLI flag--no-relay--relay-use-tls
环境变量PASEO_RELAY_ENABLEDPASEO_RELAY_ENDPOINTPASEO_RELAY_USE_TLS
config.jsonrelay.enabledrelay.endpointrelay.useTls
默认true官方 relay仅官方 relay 为 true
结论:自建 relay 只要前面挂了 TLS(nginx/Caddy/Cloudflare),就必须显式设 useTls: truepublicUseTls: true。这是 README 没写、但照抄必踩的一条。

六、验证与配对

改完重启 daemon,做双向验证:

# 1) 本机 daemon 日志:应出现 info 级 relay_control_connected,且无 400
grep relay_ ~/.paseo/daemon.log | tail

# 2) relay 服务器侧:应出现 server-control connected,serverId 与本机 daemon 一致
journalctl -u paseo-relay -n 5 --no-pager

两边对上之后,生成本机配对码:

paseo daemon pair   # 打印二维码 + pairing link

解码那个 offer,能看到 relay 段已经带上了正确的 TLS 标志:

{
  "v": 2,
  "serverId": "srv_XX",
  "daemonPublicKeyB64": "...",      // ECDH 公钥,配对用
  "relay": {
    "endpoint": "relay.example.com:443",
    "useTls": true                  // ← publicUseTls 生效,手机也会走 wss
  }
}

手机 App 扫码,走 ECDH 完成端到端密钥协商,之后所有流量经 relay 零知识转发。daemon 一直在 NAT 后面,从未被主动连过——这正是这套架构能工作的原因。


附录:relay 服务器的 SSH 管理通道被 DPI 拦了怎么办

整个过程里还有个前置插曲:我要 SSH 到 relay 服务器做部署,却发现端口 TCP 是通的,但 SSH 握手起不来——典型的 DPI(深度包检测)按协议特征拦截。

诊断只需一步,看服务端回不回 SSH banner:

# 端口通
nc -zv relay.example.com 2222   # succeeded

# 但抓不到 SSH banner → 中间设备在 TCP 建立后 reset 了会话
echo "" | nc -w3 relay.example.com 2222 | head    # 空

解法是 SSH ProxyJump:找一台能直达目标的跳板机,让 SSH 经跳板转发。本质是把"被 DPI 盯着的那一跳"藏进跳板的 SSH 加密隧道里——本地出去的只有到跳板的 SSH 流量,DPI 看不到目标地址,也看不到被拦端口的任何握手特征。

# ~/.ssh/config
Host relay-jump
    HostName 198.51.100.10          # 一台可达的跳板机
    Port 1022
    User deploy

Host my-relay
    HostName relay.example.com      # 真正的目标
    Port 2222
    User root
    ProxyJump relay-jump            # 经跳板的 direct-tcpip 转发

之后 ssh my-relay 一条命令直达,scp / rsync / git over ssh 全部透明走链路,下游工具不用改任何东西。这比手写 ssh -L 端口转发干净得多——ProxyJump 是为"跳板 → 目标"这种两跳拓扑而生,对上层完全透明。

小结

把这些写进部署文档时记得把 useTls 那两条标成"必填"——下一个照文档配的人(包括三个月后的自己)会感谢这个标注。