自建 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 开个公网入口"。但这在现实中几乎都走不通:
- 端口转发:需要公网 IP + 路由器控制权,家用宽带大多是大内网 NAT,没有公网 IP。
- 把 daemon 直接暴露公网:daemon 跑着你的 agent、能执行命令、能读文件,把它裸暴露在公网上是不可接受的风险面。
- 反向代理:HTTP 反代管不到 WebSocket 的双向长连接语义,而且还是绕不开"daemon 在 NAT 后无法被主动连"这个根本约束。
根本约束只有一句话:NAT 后的设备无法被外部主动发起连接,只能维持它自己主动发起的连接。 所以正确方向是让 daemon 主动"拨出去",再让手机也"拨出去",两边在一个公网中转点会合。这个中转点就是 relay。
relay 的工作原理
手机 App ──────────────────────┐
▼
[ paseo-relay ]
▲
本机 daemon ──────────────────────┘
(两者都主动出站拨 relay)
relay 有三个 WebSocket 角色(见 paseo-relay 的协议说明):
| 角色 | query 参数 | 说明 |
|---|---|---|
| daemon control | role=server&v=2 | 每 daemon 一条,接收 connect/disconnect 事件 |
| daemon data | role=server&connectionId=<c> | 每条 client 连接一条,转发加密帧 |
| client | role=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"这种最常见部署不友好:
- 官方 relay 的域名是已知的、确定走 TLS,自动开
wss://没问题。 - 自建 relay 有两种合法用法:直接拿
paseo-relay监听一个端口跑明文ws://(内网/测试场景),或者前面挂 nginx 跑wss://。daemon 无法从host:port推断你属于哪一种,不臆断、默认走明文是更保守的默认值。 - 但 README 的示例配置恰好没体现这个开关,照抄就会踩。
修复:显式打开 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://
}
}
}
两个字段分工不同,缺一不可:
useTls:daemon 自己出站拨 relay(role=server)时用的协议。publicUseTls:daemon 生成给手机的 pairing offer 里写入的值,决定手机拨 relay 时用什么协议。只设useTls不设publicUseTls,daemon 连上了但手机还会用ws://失败。
完整的取值优先级链(高 → 低):
| 来源 | enabled | endpoint | useTls |
|---|---|---|---|
| CLI flag | --no-relay | — | --relay-use-tls |
| 环境变量 | PASEO_RELAY_ENABLED | PASEO_RELAY_ENDPOINT | PASEO_RELAY_USE_TLS |
| config.json | relay.enabled | relay.endpoint | relay.useTls |
| 默认 | true | 官方 relay | 仅官方 relay 为 true |
结论:自建 relay 只要前面挂了 TLS(nginx/Caddy/Cloudflare),就必须显式设useTls: true和publicUseTls: 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 是为"跳板 → 目标"这种两跳拓扑而生,对上层完全透明。
小结
- 架构本质:NAT 穿透只能靠 daemon 主动出站,relay 是会合点;安全靠端到端加密,relay 零知识、不可信。
- 部署要点:relay 听本地,nginx 前置 TLS,明文 ws 不准上公网。
- 最大的坑:自建 relay 默认
useTls=false(源码里只对官方 relay 自动开 TLS),照 README 抄会ws://撞 443 报 400。解法是 config 里显式useTls: true+publicUseTls: true。 - 旁支:SSH 被 DPI 拦,ProxyJump 是最干净的绕过。
把这些写进部署文档时记得把 useTls 那两条标成"必填"——下一个照文档配的人(包括三个月后的自己)会感谢这个标注。