我有个自己写的小工具(cfssh),用 Cloudflare Tunnel + Access 让别人电脑上的 SSH 能被我远程连进去。某天我这边的
ssh突然只剩一句websocket: bad handshake,连不上了。这篇记两件事:这句报错背后的几种可能,怎么用几条
curl把问题一刀切到"是对端隧道掉了、还是对端 sshd 没起";以及这次的真正原因(QUIC 连接超时断开后没自己恢复),和我据此给工具加的自动重连。文中的域名、令牌、公网地址一律用占位符,只保留可复用的结构。
一、TL;DR
报错:
$ ssh -o ProxyCommand="cloudflared access ssh --hostname %h --service-token-id '<id>' --service-token-secret '<secret>'" <用户>@<tunnel域名> websocket: bad handshake Connection closed by UNKNOWN port 65535bad handshake只说明cloudflared access想建的那条 websocket 没被接受,不告诉你是哪一段断的。要看 HTTP 状态码:带着同一个 service token 直接curl那个主机名。这次依次看到
403→530 (error code: 1033)→502,对应"没带 token"→“token 通过了,但没有任何 cloudflared 连在这个隧道上”→“隧道连上了,但后面的 sshd 没应答”。真正原因:对端那台 Windows 上的 cloudflared 在某一刻
failed to dial to edge with quic: timeout: no recent network activity,QUIC 连接断了没自己恢复。手动停止再启动之后恢复。我中途判断错过一次(见第四节)。
修复思路:别把协议写死成 HTTP/2,而是检测到连接全断就自动重启 cloudflared,并轮换协议。Mac 上做了真实断线测试;在那台 Windows 上的真实断线测试,到写这篇时没有跑完。
二、先把状态码问出来
websocket: bad handshake 来自 cloudflared access ssh 本身——它先向 Cloudflare 边缘发一个 websocket 升级请求,边缘不接受就是这句。所以要绕开 ssh,直接拿同样的凭据发一个普通 HTTPS 请求,看边缘回什么:
# 不带 token
curl -sS -o /dev/null -w '%{http_code}\n' https://<tunnel域名>
# 带 service token
curl -sS -D - \
-H 'CF-Access-Client-Id: <id>' \
-H 'CF-Access-Client-Secret: <secret>' \
https://<tunnel域名> | head -40
我看到的结果和含义:
| 请求 | 状态 | 含义 |
|---|---|---|
| DNS 解析 | 正常,解析到 Cloudflare 的地址 | 域名没问题 |
| 不带 token | 403 | Access 拦下了,符合预期 |
| 带 service token | 530,error code: 1033 | token 通过了 Access,但这个隧道现在没有任何 cloudflared 连接器在线 |
| (过一阵再试)带 token | 502 | 连接器回来了,边缘把请求转给了它,但它连不上自己后面的服务(这里是 sshd) |
这一步已经排除了两类常见怀疑:
- 不是 token 或 Access 策略的问题:换成带 token 后返回从 403 变成了 530,说明已经过了 Access 这一关。
- 不是本机代理的问题:我 shell 里只设了
no_proxy,没有http_proxy。
所以第一次判断是:对端的 cloudflared 没在线(530/1033)。
三、状态码变了:1033 → 502
隔了一会儿我再 curl,状态码变成了 502。这说明对端的 cloudflared 已经重新连上了 Cloudflare,卡点往后挪了一段:请求到了对端,但对端连不上它后面的 ssh://localhost:<端口>。
这个阶段 AI 给我的下一步是去 Windows 上查 Get-Service sshd、端口是否在监听、Zero Trust 里这条 hostname 的 service 是不是写成了 ssh://localhost:<端口>。后来的事实证明,这组怀疑方向没错,但原因不是它。
四、真正的原因:日志里的 QUIC 超时,以及我判断失误的地方
我在对端机器上有一个 VNC 窗口,让 AI 用 computer-use 去翻对端 cfssh 的日志。日志里:
- 约 19:40(对端本地时间)有一串
failed to dial to edge with quic: timeout: no recent network activity——这台机器和 Cloudflare 之间的 QUIC 连接超时断了。这时隧道不在线,边缘回 530/1033,也就是我看到的bad handshake。 - 19:42:53,有人在对端界面上点了"停止",又"启动"(AI 从日志推断是我,我当时确实在 VNC 里操作过,但具体哪一下我没逐条核对)。
- 19:43:10,sshd 开始监听
127.0.0.1:<端口>;19:43:12,隧道重新连上,后面的网络自检(QUIC 和 HTTP/2 两种)全部 PASS。
我是在 19:46 才从 Mac 重新测的:隧道通了,对端 sshd 也回了 SSH 握手,只是我用了 BatchMode,停在 Permission denied (publickey,password,keyboard-interactive),没法输密码——这对"链路已通"是够的。
所以我之前测到的 502,很可能就是赶上了 19:42–19:43 那次重启的空窗:cloudflared 先连上了,sshd 还没起来。“sshd 没开"是我顺着 502 推出来的猜测,被日志证伪了——真正的原因在更早的一步:QUIC 断了没自己恢复。
两条教训:
- 状态码会随着对端状态变,一次读数不等于结论。530 → 502 的变化本身就是信息:它告诉你对端在恢复,而不是一个新的故障。
- 能看到对端日志就先看日志,别只靠外面的状态码推断。
五、修复:别写死协议,检测到断线就重启并轮换协议
日志里一个细节:QUIC 和 HTTP/2 的连通性检查都是 PASS,但断开的恰恰是 QUIC 那条。这类 UDP 长连接在一些家宽/运营商环境里不太稳,所以第一反应是"把 cloudflared 改成走 HTTP/2”(启动参数 --protocol http2)。
我在自己的 Mac 上先验证了一下:
- 强制
--protocol http2:所有连接都报TLS handshake with edge error: EOF,完全连不上。 - 默认的 QUIC:一秒就连上。
也就是说哪种协议可用取决于对方的网络,写死任何一种都会"修好这台、弄坏那台"。所以做成了:
- 第一次启动和以前一样,用 cloudflared 默认的方式;
- 程序盯着 cloudflared 的状态,全部连接断开超过 60 秒,或者 cloudflared 进程自己退出了,就自动重启它,每次重启轮换协议(先 HTTP/2,不行再换回 QUIC,依此类推);
- 两次重启之间有间隔,且逐次翻倍(10 秒、20 秒、40 秒……最长 5 分钟),避免 cloudflared 一启动就退出时疯狂重启。
判断"连接断了没有"我用了两种来源:
- 解析 cloudflared 的日志关键字——能用,但如果某条连接断开时打的日志不在我匹配的几个关键字里,就会一直以为它还连着,永远不触发重启。
- 读 cloudflared 自带的 metrics 服务的
/ready:日志里能看到它监听在127.0.0.1:20241,/ready直接返回当前在线的连接数。在对端 Windows 上实际请求,返回readyConnections: 4。主判断用它,日志匹配作补充。
六、测试:Mac 上真断了一次
我在 Mac 上启动工具,经隧道 SSH 进来(回显正常),然后手动 kill 掉 cloudflared 模拟断线:
- 60 秒后,工具改用 HTTP/2 重连——我这台 Mac 的网络下 HTTP/2 连不上(TLS 握手失败),符合预期;
- 继续等,又过 60 秒换回 QUIC,3 秒内重新连上;
- 再 SSH 一次,正常。
这次测试顺带抓出一个 macOS 专属的 bug:cloudflared 被 kill 之后,工具本该立刻重启它,却一直等到 60 秒的断线计时到期才动。 原因是进程退出后、父进程回收(wait)之前,它是个僵尸进程,而原来用来判断"进程还活着吗"的 kill(pid, 0) 对僵尸进程也返回成功。
修法是改成读进程的真实状态,僵尸算已退出:
- 用真实的 cloudflared 实测:修复后 0.1 秒发现进程退出;
- 不修复:过了 10 秒仍然被当成活着;
- 新增的单元测试:没有修复时失败,有修复时通过。
Windows 上判断进程存活的方式本来就没这个问题,没有改。
七、还没验证的
- 对端 Windows 上的"真实断线→自动恢复"我没有测完。 我计划的做法是临时用防火墙规则拦住 cloudflared 的出站,约 100 秒后自动删规则,看界面会不会显示"重连中"、会不会自动重启恢复,但在写这篇的时候这个测试没有跑完。
- 第一次往对端推新版时,新逻辑有 bug,对端启动后隧道没起来,是换回旧版的备份才恢复的。AI 事后的修法是让"较新的注册信息可以覆盖
/ready返回的 0",大意是启动初期第一次/ready的读数不可信;我只看了它的提交说明,没有自己复现。这说明这种健康检查的初始窗口最好用真机跑一遍,光单元测试不够。 - 我也没有去查为什么那次 QUIC 会超时(对端的网络环境我不清楚),只是让工具能从中恢复。
八、可复用的清单
遇到 cloudflared access ssh 报 websocket: bad handshake:
# 1. 同一个主机名,不带 token,应该是 403(Access 在工作)
curl -sS -o /dev/null -w '%{http_code}\n' https://<tunnel域名>
# 2. 带 service token,看真实状态码
curl -sS -D - -H 'CF-Access-Client-Id: <id>' -H 'CF-Access-Client-Secret: <secret>' https://<tunnel域名> | head -40
# 530 / 1033 → 隧道没有在线的连接器:去对端看 cloudflared 的进程和日志
# 502 → 连接器在线,但它后面的服务(sshd)没应答:去对端看 sshd 和 ingress 的 service
# 403 → 连 Access 都没过:token 或策略的问题
# 3. 状态码可能随对端恢复而变化:隔几十秒再测一次,对比两次结果
# 4. 能看到对端日志就先看日志:
# failed to dial to edge with quic: timeout → QUIC 断了
另外一条和排障无关但值得提:我把带明文 service token secret 的 ssh 命令整条贴进了聊天,AI 多次提醒我它已经出现在聊天记录和终端历史里,应该去 Zero Trust 里把这枚 token 轮换掉。命令里带 secret 的话,历史记录就是泄漏面。