起因:一台不让装软件的电脑
场景很具体:人不在家,需要摸一下家里那台一直开着的 Windows。
正常解法是装个 VNC 客户端,输 IP、输密码、连上。问题是:
- 别人的电脑不让装东西。公司机、网吧机、朋友的笔记本,装个 exe 要么没权限,要么心里过不去。
- 手机可以装,但换设备就得再装一遍。而且为了偶尔看一眼桌面,专门装一个常年后台的 App,权限还得给一堆。
- 有的环境只给你一个浏览器。就这样。
那浏览器能不能直接连 VNC?答案是"协议上不行,加一层就行",而这一层早就有人写好了,就是 noVNC。
为什么浏览器连不上 VNC
VNC 用的协议叫 RFB(Remote Framebuffer),设计得非常省:服务端不传画面截图,只传"屏幕上哪个矩形变了、这块矩形的新像素是什么",客户端解码后自己画。所以它在窄带宽下也能动,这是它活了二十多年的原因。
问题是它对浏览器不友好——它跑在裸 TCP 上。而浏览器能拿到的只有两样东西:
| 浏览器有 | 浏览器没有 |
|---|---|
WebSocket(走 HTTP 握手、按帧封装) | 裸 TCP socket |
fetch / XHR(请求-响应,不能双向流) | UDP |
没有任何一个浏览器 API 能让你"连上 5900 端口然后收发二进制流"。
于是中间必须站一个翻译:左边接 WebSocket,右边连本机 TCP,双向转发。这就是 websockify——它最早叫 wsproxy,本来就是 noVNC 项目的一部分,后来才独立成姊妹项目。
三层结构长这样:
| 层 | 跑在哪 | 干什么 |
|---|---|---|
| noVNC(前端) | 浏览器 | 拿到 WebSocket 字节流 → 解 RFB → 画到 canvas |
| websockify(桥) | 家里那台机器的某个端口 | WebSocket ↔ TCP,顺手还能当静态文件服务器 |
| VNC Server | 127.0.0.1:5900 | 抓屏、算差异、发矩形 |
用户视角只有一条:打开一个网址,输密码,看到桌面。 中间三层他都不知道。
装:一半是静态文件,一半是个 pip 包
noVNC 的二进制性几乎为零——它就是一堆 HTML + JS。release 包解开就能用,不需要 node、不需要构建。目录里那些 devDependencies: "latest" 是给改它源码的人跑 lint 和 karma 测试的,跟你部署没关系。
解压后关键的是这几样:
index.html—— 完整界面的主页面(带设置面板、缩放、剪贴板按钮)vnc_lite.html—— 单文件精简版,自带样式,只有一条顶栏app/core/vendor/—— 主页面依赖的代码、样式、翻译和第三方库
只有 vnc_lite.html 是真正单文件的,把 index.html 单独拷出去是打不开的,别问我怎么知道的。
websockify 那边是 Python 的,一行:
pip install websockify
装完就有了 websockify 命令(Windows 上是 websockify.exe,落在 Python 的 Scripts\ 里)。
最朴素的一条命令
假设家里那台机器上已经跑着一个监听 5900 的 VNC 服务端(TightVNC / UltraVNC / RealVNC / TigerVNC 都行,随便挑),那么:
websockify 5901 127.0.0.1:5900 --web .
拆开看:
5901—— websockify 自己监听的端口,这是给浏览器用的 WebSocket 端口,不是 VNC 端口127.0.0.1:5900—— 真正的 VNC 目标,写 localhost 意味着"只允许本机被代理"(安全上是对的,别改成 0.0.0.0)--web .—— 顺便把当前目录当静态站点发出去,于是 HTTP 和 WebSocket 走同一个端口。省事,防火墙也只开一个。
然后浏览器打开 http://<这台机器的IP>:5901/index.html,点 Connect,输 VNC 密码,桌面就出来了。
我实际在跑的那个 bat
命令行窗口一关服务就没了,家里那台机器也不会有人天天看着。所以实际用的是个 bat,做三件事:启动不留黑框、挂了 300 秒自己爬起来、强制 SSL。
run.bat:
@echo off
cd /D "D:\1\MySelf\novnc"
if "%1" == "h" goto begin
mshta vbscript:createobject("wscript.shell").run("%~nx0 h",0)(window.close)&&exit
:begin
:: need: pip install websockify
websockify.exe 5901 127.0.0.1:5900 --web . --cert=server.pem --file-only --ssl-only
::restart
TIMEOUT /T 300 /NOBREAK
goto begin
stop.bat:
@echo off
taskkill /f /im websockify.exe
taskkill /f /im cmd.exe
pause
逐条说:
if "%1" == "h"这段是自我重启。双击时不带参数,走第二行mshta,用 VBScript 把自己以run(..., 0)启动——那个0就是"窗口不显示"。启动完立刻exit,于是你看到的黑框自己关了,真正干活的进程在后台。带h参数再进来时跳到:begin,不再递归。cd /D切到 noVNC 的目录,因为后面的--web .是相对当前工作目录的。--cert=server.pem—— 证书。这里没给--key,说明私钥也合并在同一个 pem 里了,Python 的 ssl 支持这种单文件格式。拆成 crt + key 两个文件也行,那就得补--key=。--ssl-only—— 拒绝非加密连接。不想要 SSL 就别写它,但见下面证书那一节,手机端你会后悔。TIMEOUT /T 300+goto begin—— 最朴素的守护:websockify 退出(崩溃、断网、证书过期加载失败)就等 5 分钟再来。没有服务、没有计划任务、没有 NSSM,5 分钟粒度的自愈。stop.bat直接按进程名杀,因为隐藏窗口模式下你根本找不到那个窗口,这是唯一方便的停法。
--file-only:一个特别好骗人的参数名
我一开始以为它是"证书只用这一个文件"。不是。
它的真实含义是:关掉 web 服务器对目录的列表功能。
差别很大。--web . 打开的是一个静态文件服务器,默认访问一个没有 index.html 的目录(比如 /app/、/docs/)时,它会把目录里的文件名全列出来给你点。noVNC 目录里躺着 server.pem(含私钥)、run.bat、.gitignore、docs/、tests/——这些全在同一个 --web 根下。加不加 --file-only,决定的是"别人能不能顺手把你私钥下载走"。
所以这个参数不是可选项,是必须加。名字起得确实容易误解。
证书:为什么最后还是要 wss
一开始我用的是自签证书(openssl req -new -x509 -days 365 -nodes -out server.pem -keyout server.pem),本地跑通了,结果一到手机上体验稀烂:地址栏一片红、要连点两次"继续前往"、noVNC 的连接还偶尔直接失败。iOS 那边更麻烦,自签还得手动去"关于本机"里信任描述文件。
换成正经验签的证书之后就安静了。手上正好有个域名在 Let’s Encrypt 上续着,把 fullchain 和私钥合并进 server.pem 就行:
cat fullchain.pem privkey.pem > server.pem
两件事顺带解决了:
- 混合内容。如果哪天你把 noVNC 反代到一个 https 的站点后面,页面是 https、WebSocket 还是
ws://,浏览器会直接拦掉,控制台报 mixed content。必须是wss://。 - 剪贴板。浏览器的
navigator.clipboard只在安全上下文(https / localhost)下可用。http 页面里 noVNC 的粘贴按钮点了没反应,不是 bug。
顺带提醒:Let’s Encrypt 的证书 90 天一续,续完要把新证书合并进 server.pem 并重启 websockify。证书过期那天,一切表现都像服务端挂了——浏览器连不上、日志没动静、蓝框一直转。
用起来是什么感觉
- 手机:noVNC 支持触屏手势模拟鼠标(单指移动、双指滚轮、长按右键),横屏之后勉强能干点正事。它不是"手机办公"的方案,但"远程点一下某个按钮/看一眼状态"完全够。
- 剪贴板:支持完整 Unicode 双向复制粘贴,中文不乱码。这点比某些原生客户端还好。
- 缩放:
Scale之后整个桌面塞进视口,小屏幕上不至于要拖动。 - URL 参数直连:这是我最常用的一个。noVNC 接受 query string 控制行为:
https://<你的域名>:5901/index.html?autoconnect=1&host=<域名>&port=5901&encrypt=1&password=<VNC密码>
autoconnect=1 打开就连,encrypt=1 走 wss,password= 连密码都带上。存成手机桌面书签,点开就是桌面,一个多余动作都没有。
(密码写在 URL 里会进历史记录,公用设备上别这么干。自己手机无所谓。)
浏览器版本倒不是问题,官方给的下限是 Chrome 64 / Firefox 79 / Safari 13.4 / Edge 79——都是五六年前的版本了。它被 OpenStack、OpenNebula、ThinLinc 这些正经产品嵌了好多年,稳定性不用怀疑。
安全:VNC 密码不是一堵墙
这一节必须单列,因为最容易出事。
经典 VNC 认证的密码只取前 8 个字符。 这是协议里的历史包袱(早期用 DES,密钥就 8 字节),不是你设错了。所以 VNC 密码的正确心态是"防手滑",不是"防人猜"。8 位、无大小写要求、通常还是个纯数字或单词——暴力破解的成本低到可以忽略。
noVNC 本身支持一堆更硬的认证方式(VeNCrypt Plain、RealVNC 的 RSA-AES、UltraVNC 的 MSLogonII、Apple 的 Diffie-Hellman),但这些要求服务端也支持。家里随手装的 VNC 服务端大多只给了经典的 VNC 认证。
所以在"密码靠不住"的前提下,边界要靠外面这几层:
| 做法 | 说明 |
|---|---|
| 别把 5901 直接裸在公网 | 最省事的做法是套一层内网穿透,公网只露一个临时端口(我另一篇写的 frp 就是干这个的) |
用 --ssl-only | 至少别让密码明文飞出去 |
| 反代加一层认证 | nginx/Caddy 在前面挡 Basic Auth 或 OAuth,noVNC 只在内部可达 |
| 安全组放行固定 IP | 出差/公司网络出口固定的话,这条最有效 |
| 别用 5900 当公网端口 | 全网扫描器对 5900/5901 的字典攻击是常态,换随机高位端口能挡掉九成噪音 |
顺带一句:--web . 把整个 noVNC 目录都发出去了,这也是为什么上一条的 --file-only 不能省。更干净的部署是把要发的文件单独拷到一个 web/ 目录,根上只留 index.html、vnc_lite.html、app/、core/、vendor/。
踩到的几个坑
1. --web . 是相对路径,cd 错了就 404。 上面那个 bat 里 cd /D "D:\1\MySelf\novnc" ——这个目录后来被我搬进了 2025\ 子目录,bat 里的路径没跟着改。表现是:websockify 起来了,端口也通,但浏览器打开是 404 或者空白页;因为是隐藏窗口模式,你连报错都看不见。这种"服务活着、内容 404"的错,排查起来比进程直接挂掉更烦。改成绝对路径,或者干脆在 bat 里判断一下 cd 是否成功。
2. 5901 不是 VNC 端口。 它是一个 WebSocket + HTTP 端口。用原生 VNC 客户端去连 5901 一定失败,别拿它当"穿透出来的 VNC 端口"用。想两者都要,就再开一个 5900 出去(或者走 frp 的 TCP 转发)。
3. 一个端口干两件事是有前提的。 --web 复用端口靠的是 websockify 自己解析 HTTP 请求、区分 WebSocket 升级和普通 GET。中间如果加了一层只认普通 HTTP 的反向代理(比如某些默认配置的 nginx),WebSocket 的 Upgrade 头会被吞掉,于是页面能打开、连接一直失败——这时要在代理上加 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";。
4. 端口和防火墙。 --web 复用端口意味着只需要开一个端口。但如果前面套了 frp 或者云安全组,记得那条隧道/规则跟着 websockify 的端口走,不是 5900。
值不值
这套东西的全部成本:一个解压即用的静态目录,一个 pip install,一个十几行的 bat。没有许可费(noVNC 是 MPL 2.0,库部分是 BSD),没有账号,没有设备数限制,跑在自己机器上。
代价是:它是一个"能用"的方案,不是一个"产品"。没有会话管理、没有多用户、没有审计日志,画质由 VNC 服务端决定,弱网下会糊。以及那个永远绕不开的前提——里面那层 VNC 密码只算 8 位,真正的门在你自己加的那几层上。
但对"在外面临时摸一下家里电脑"这个需求,它的解法和需求一样朴素:给个网址,浏览器打开,就是桌面。 装都不用装,这大概就是它存在的全部意义。