节点出问题时,最快的处置方式是能看到那台机器的桌面。传统做法是装 VNC 客户端、配端口转发、记一堆密码。我们的做法是浏览器里点一下。
看起来简单,实际要解决的问题不少。这篇文章拆开讲。
为什么不是简单的端口映射
直接的思路是:把节点的 5900 端口暴露出来,浏览器连上去。但这条路走不通。
第一,节点不开放入站端口(这是我们的设计原则),所以外部根本连不到 5900。
第二,即使能连,VNC 的裸协议没有加密。密码和画面都是明文,走公网等于公开。
第三,浏览器不认 VNC 协议。要做桥接,把 VNC 的 RFB 协议转成浏览器能处理的 WebSocket 数据流。
实际的数据路径
从点击「连接远程桌面」到看到画面,中间经过这些环节:
签发一次性票据。 点击时前端向服务端要一个 ticket。这个 ticket 绑定了节点 ID、用户 ID 和一个短期有效期。服务端会先校验这个用户是否有权访问这个节点(本人的节点、分销商名下的节点、或管理员)。
服务端通知节点启动转发。 通过已经建立的长连接,服务端把「开始 VNC 转发」指令推给节点。节点侧启动一个本地代理,连到 127.0.0.1:5900。
数据双向流动。 客户端(前端页面里的 noVNC 库)把 VNC 协议数据封装成 WS 消息发出去;服务端转给对应节点;节点解封装后写进本地 5900;返回的数据沿反方向回来。
票据消费即失效。 ticket 用一次就作废,有效期只有 90 秒。这样即使有人截获了 URL,也来不及用。
几个容易踩的坑
VNC 数据不能走普通 HTTP 通道。 必须走 WebSocket,且服务端和 nginx 都要正确转发 Upgrade 头。漏了这一步会表现为"连接建立成功但一直黑屏"。
票据的存储必须两端一致。 我们最初把票据写进缓存时用了框架的封装格式,读的时候用了裸 Redis 命令——读出来的值结构不匹配,永远是 undefined,表现为"票据无效"。这类问题很难从日志看出来。
前端必须在 DOM 真挂载后才能初始化 VNC 会话。 用 useRef 拿容器时,弹窗刚打开的那一帧可能还是 null。用 useState 配合 callback ref,确保元素真的在页面上了再连。
协议库在生产构建里可能被错误打包。 noVNC 的默认导出在开发环境是构造函数,在生产构建里可能被 CommonJS 互操作包成 {default: RFB},直接 new 会报 "not a constructor"。要手动解包。
关于延迟
实测从点击到看到第一帧大约一两百毫秒,取决于网络路径。物理延迟改不了(跨地区就是有 RTT),但有几处可以优化:
- 关掉 VNC 的压缩(压缩省带宽但增加 CPU 和延迟,本地网络下不划算)
- 开启 TCP_NODELAY,避免小包被 Nagle 算法攒着(VNC 的控制帧只有几字节,攒 40 毫秒很致命)
- 不启用分辨率自适应(会触发重新协商,反而更慢)
安全上的几条底线
- 每次连接都要重新校验权限,不依赖"之前连过"。
- 会话有超时,长时间无操作自动断开。
- 每次会话的发起人、持续时间、目标节点都记录在审计日志里。
- 票据绑定用户,不能跨用户使用。
远程桌面是很强的能力——它等于给了对方这台机器的操作权。能力越强,边界越要划清楚。
—— Pi Admin X 团队
