这台机器上原本只跑着 PVE 和一个 RouterOS CHR 软路由。今天要做的事很清楚:让客户在网页上点一下,就拿到一台能直接 SSH / RDP 登录的 VPS。听起来是"调几个 API"的活,实际做下来,真正的难点全在三个地方——cloud-init 没给 root 留门、一次硬关机把客户机的 sshd 写死、以及微软官方安装 ISO 那堵 UDF 的墙。这篇把整条链路、每一个坑的实测证据和最终验证结果都记下来,配 6 张图。
一、目标与总体架构
需求是一句话:做一个 Web 控制端,用户开户后能自主开通 VPS。先做核心链路——选系统(Debian 12 或 Windows Server 2019)、自动开通、配好网络、能 SSH / RDP 直连。
架构上有一条贯穿始终的原则:客户机全部活在 RouterOS 的内网里,宿主机不直接暴露客户机端口。
分层解释一下:
- 控制端:跑在宿主上的
/opt/vps,监听:8080。里面是webapp.py(HTTP 路由 + WebSocket 升级)、vpsctl.py(开通引擎)、vpsdb.py(SQLite)、config.py(全部可调参数)。 - Proxmox VE 8.4.21:负责
qm clone克隆客户机、qm shutdown优雅关机、qm monitor ... screendump抓真实画面。 - RouterOS CHR 6.49.23(VM100):一台软件路由。
ether1桥到br0拿 WAN103.148.59.36/24,ether2桥到内部网桥vmbr1做 LAN192.168.88.1/24。端口映射(dstnat)和 DHCP 静态租约都在它身上。 - 客户机池:VMID
1000–1999、内网 IP192.168.88.100–240、公网端口20000–29999。 - 金镜像:Debian 12 是 VMID 9000,Windows Server 2019 是 VMID 9001。客户每一次开通都是
qm clone,不是重新装系统。
103.148.59.36),而不是宿主的 103.148.59.35。因为客户机在 ROS 内网,所有端口映射天然走 ROS 的 dstnat,宿主机上不需要、也不应该出现客户机的端口。
二、控制端:为什么坚持只用 Python 标准库
选型上有一个硬约束:宿主上 python3 xxx.py 直接能跑,迁移到新机器不用装任何环境。
所以整个控制端只用 http.server + sqlite3 + socket,没有一个第三方包。包括后来手写的 WebSocket 服务端、RouterOS API 客户端,全是标准库。代价是要自己处理一些底层细节(比如 WebSocket 的掩码解码、扩展长度、分片重组),收益是这套东西可以整体拷到任何一台装了 Python3 的机器上就活。
控制端要跟 RouterOS 打交道,这里有一个坑:RouterOS 6.49 的 API 只吃 plain 登录——就是 /login 后面直接带 =name= 和 =password=。老式的 challenge/md5 客户端会报 invalid user name or password (6),即使密码完全正确。这个报错信息非常误导人,第一反应一定是去查密码。
三、第一堵墙:cloud-init 不给 root 密码登录留门
第一版跑通之后,客户的 SSH 就是登不上——只提供 publickey,root 密码怎么都不通。
查下来,根因是 PVE 生成的 cloud-init user-data 里不含 ssh_pwauth,于是 cloud-init 按默认值写了一个文件:
/etc/ssh/sshd_config.d/50-cloud-init.conf
PasswordAuthentication no验证方法很直接——把 PVE 生成的 cloud-init 配置盘转出来看内容:
qemu-img convert -O raw <cloudinit盘> ci.raw
isoinfo -i ci.raw -x '/USER_DATA.;1'解法是在模板里放一份 cloud.cfg.d/98-vps-pwauth.cfg,写上 ssh_pwauth: true。
顺着这条线还牵出两个连带问题:
- Debian 12 的
PermitRootLogin prohibit-password是未注释的,必须显式改成yes,光放开密码认证还不够。 sshd_config.d的加载顺序有个反直觉的地方:同名参数 sshd 取的是"第一个出现的值",而不是最后一个。所以我们自建的文件名必须排在50-cloud-init.conf之前——项目里用的是10-vps-panel.conf。
cloud-init status --wait。因为 cloud-init 会重建 SSH 主机密钥并重启 sshd,不等它跑完就交付,客户登录会撞上重启窗口,表现为连接超时约 20 秒。
四、两个真 BUG:一次硬关机把客户机的 sshd 写死
写了 tools/e2e_test.py(22 项端到端,从外网视角驱动面板 API 跑完整生命周期),第一次跑出来 20/22。
失败项只有两个:「开机 → SSH 恢复」和「重启 → SSH 恢复」。
这里有一个非常关键的反证:重装(= 新克隆 = 首次启动)的 SSH 是成功的。也就是说问题不在"克隆出来的机器",而在"第二次启动"。
顺着往下挖,得到一条完整的崩溃链:
逐条对应实测证据:
qm stop是硬关机,等同拔电。它把 guest 的/var/lib/cloud/data/previous-hostname写坏了——实测文件内容退化成 9 字节裸文本e2e-test\n,缺了 JSON 该有的引号。- 第二次开机,cloud-init 读这个坏文件,
json.loads抛JSONDecodeError,崩在cc_set_hostname.py:111的load_json(load_file(prev_fn))。 cloud-init-local.service和cloud-init.service双双崩溃。要命的是模板当时设了 cloud-init 的ssh_deletekeys: true——这个选项的含义是"每次开机先删掉 host key,再重建"。- cloud-init 崩了,host key 就停在"已删除、未重建"的状态。实测
/etc/ssh/ssh_host_{rsa,ecdsa,ed25519}_key全是 0 字节。 - sshd 起不来。日志是
sshd: no hostkeys available -- exiting。现象很特别:ping 得通,但 SSH 是Connection refused(不是 timeout)。
- 模板侧:去掉 cloud-init 的
ssh_deletekeys配置,改成独立的 systemd 服务vps-ssh-hostkeys.service——Before=ssh.service+ConditionPathExists=!/etc/ssh/ssh_host_ed25519_key+ExecStart=/usr/bin/ssh-keygen -A。让 sshd 完全不依赖 cloud-init。 - 控制端侧:
stop_vps/restart_vps从qm stop改成qm shutdown --timeout 90(优雅关机),超时才兜底硬停。
顺手记下一个好用的诊断二分法:SSH 报Connection refused= guest 内部根本没在监听(去查 sshd / host key / cloud-init);报timeout= 网络或端口映射问题。这一刀切下去,排障范围立刻减半。
五、把 VNC 监控做进面板
面板里要有「VNC 监控」——在网页上直接看客户机屏幕,不装任何客户端。链路是:
浏览器 noVNC --WebSocket--> 面板 :8080 --AF_UNIX--> /var/run/qemu-server/<vmid>.vnc --> QEMU协议层 9/9、浏览器 14/14 全通过之后,我一度以为这块最省事。实际上它贡献了本轮最多的反直觉坑:
- npm 上的
@novnc/novnc是 Babel 转译的 CommonJS(require/exports),浏览器<script type="module">根本加载不了。必须用 GitHub 源码包里的core/+vendor/(原生 ESM)。而且源码包里core/inflator.js用../vendor/pako/...引用,所以core/和vendor/必须保持平级。 - PVE 的 QEMU VNC 是 root-only 的 unix socket(
srwxr-x--- root root)。所以面板进程必须以 root 跑——vps-panel.service故意不写User=就是为了这个。 - VNC 的字节边界不等于 WebSocket 的消息边界(这条最坑,卡了一整轮)。QEMU 会把"安全类型计数 + 类型表"塞进同一条 WS 消息发出来(
\x01\x02)。如果按"一条消息 = 一个字段"去读,就会在读安全类型列表时超时;而服务端日志明明显示guest->浏览器 14 B已经发出去了。正解是客户端维护字节流缓冲,按协议字节数取。 - noVNC 的
connect事件 ≠ 画面到了。它只代表 RFB 握手完成,第一帧还要再飞一会儿。等"已连接"就截图,必然拿到 noVNC 刚铺的黑底。判据要用「canvas 上出现非黑像素」。 - 云镜像的 VGA 控制台默认全黑。Debian cloud image 只把 getty 挂在
ttyS0(串口),VGA 上什么都没有。
qm monitor <vmid>里执行screendump /tmp/s.ppm能导出 QEMU 眼里的屏幕。这是判断"客户机根本没输出"还是"VNC 有问题"的决定性证据(PPM 是 P6 格式,用 zlib 手写十几行就能转 PNG)。- 面板日志里的
浏览器->guest N B / guest->浏览器 N B是排障第一手数据:哪边是 0,问题就在哪边。
六、Windows Server 2019 模板:UDF 这堵墙
Windows 这条线的目标是"开箱即用":默认开远程桌面(但不关防火墙)、计算机名互不冲突、克隆出来就能用。
最初的想法很自然:把 autounattend.xml 用 xorriso -map 塞进官方安装盘根目录,全程只用一个光驱。实测走不通,而且是四堵独立的墙:
- 微软官方盘是"纯 UDF"盘。
isoinfo -f在官方 ISO 上只列出 1 个文件:/README.TXT。真实内容全在 UDF 里,而 xorriso/libisofs 读的是 ISO9660 结构——所以它认为这张盘是空的,-commit直接报No image modifications pending,产物根本没生成。 install.wim超过 4 GiB。官方盘实测 7.6 GiB,而 ISO9660 的单文件上限就是 4 GiB——就算读得出来也写不回去。- xorriso 1.5.4 没有
-udf开关(报Not a known command: '-udf')。只有-as mkisofs兼容模式认这个选项,而那个模式必须从目录树生成,做不了-indev/-outdev复制。 - 官方盘带隐藏的 El-Torito EFI 镜像,xorriso 自己就警告
image modify ... may lead to bad results,而-boot_image any keep正是 boot image patching。
所以记住一条:验任何一张 Windows 盘,先跑 isoinfo -f -i x.iso | wc -l。只列出 1 个文件 = 纯 UDF,别想重打包。
最终方案是"官方盘原样 + 两件外挂":
- ① 官方安装 ISO,一个字节都不改(这样才能核对官方 SHA1)。
- ②
vps-aux.iso,自己用genisoimage从零造的辅助盘:脚本 +firstboot.ps1+ qemu-ga MSI + vioser 驱动。因为是纯 ISO9660、无 UDF、无隐藏 El-Torito,天然没有重打包的坑。 - ③
unattend.img,1.44 MB 软盘镜像,里面就一个A:\autounattend.xml——应答文件只能走这里。
PVE 这一侧还有个坑:PVE 8.4 没有 --fda / --fdb(翻源码可以看到 QemuServer.pm:851 的 #fda => 'file', 是被注释掉的)。挂软驱只能走 --args '-drive file=...,if=floppy,format=raw,readonly=on'。而清理时必须连 --delete args 一起删——软驱没有设备槽位,漏了它,软盘镜像一删 VM 就起不来。
Windows 侧还踩了这些:
- 计算机名靠 SMBIOS 序列号带进去,不做 sysprep。每台序列号不同,名字天然不冲突。这里有个硬坑:PVE
--smbios1的字符串字段必须是 Base64,而且必须带base64=1。不 Base64 会被 schema 拒;只 Base64 不带base64=1则走历史兼容分支原样透传,客户机看到的序列号是 Base64 文本本身,开机任务正则匹配不上,改名静默失效。 - 磁盘和网卡用 SATA + e1000e,两者都是 Windows 收件箱驱动,安装期完全不依赖 virtio 注入。
qm guest exec对参数的引号处理不可靠(最终是把 argv 拼成一条命令行给CreateProcess)。复杂 PowerShell 会被拆散,改用-EncodedCommand+ Base64(UTF-16LE)。- PowerShell 5.1 按系统 ANSI 代码页解码无 BOM 的
.ps1。中文客户机上是 CP936,UTF-8 中文会变乱码并产生游离引号。所以 Windows 侧的.ps1必须纯 ASCII,.cmd必须 CRLF + ASCII。 - 模板封样时必须关掉自动登录。unattend 开的
AutoAdminLogon会把模板口令明文留在注册表的DefaultPassword里,而克隆机的密码是每台另设的——不关就是每次开机一次注定失败的登录。同时要删掉C:\Windows\Panther\Unattend.xml这些 Setup 留下的应答文件缓存。
- 来历与授权。网上流行的 DD 镜像多是匿名第三方重打包的,这类盘被塞后门/挖矿、内置 KMS 破解工具是常见事故。商业售卖用它,等于把客户数据和自身合规章押给匿名作者。官方 ISO 路线可以核 SHA1,这条证据链是 DD 镜像给不了的。
- 架构不匹配。那些镜像常标死
_UEFI或for_AliYun,而 PVE 这边走的是 seabios;要跑 UEFI 镜像得挂 OVMF + EFI 变量盘并改 boot 顺序,不是 dd 一下的事。 - 省不掉的部分才是大头。DD 出来的每台机计算机名、口令、SID 全同,改名/改密/扩盘一样要接上去。复杂度的重心在这里,不在安装。
qm clone,与 DD 是同一层次的东西。
七、qemu-guest-agent 的隐性依赖
模板建好之后遇到一个很典型的"看起来都对但就是不通":
症状是 qm agent ping 永远超时,但 vps-setup.log 明明写着 qemu-ga install exit = 0、服务也 pin 到了 auto-start,qemu-ga.exe 也确实在。
根因是:qemu-guest-agent 与宿主通信走的是 virtio-serial 端口,而 Windows 没有这个设备的收件箱驱动。我们刻意选 SATA + e1000e 来实现"克隆即用",结果把 agent 的通道漏掉了——MSI 能装、服务能起,但 agent 永远不会应答,因为那个端口压根不存在。
判据很简单:看 C:\Windows\System32\drivers\vioser.sys 在不在。不在 = 通道不通。
修法是从 virtio-win.iso 里取 vioserial/2k19/amd64/ 下的 inf/sys/cat,用 pnputil /add-driver <inf> /install 装上。这是唯一必须注入的 virtio 驱动,磁盘和网卡继续用收件箱驱动,两者不冲突。
顺带记一个很好用的补丁手法——宿主能直接读写 guest 磁盘,用来给"已经装完但缺东西"的 VM 打补丁,省掉一次 40 分钟重装:
apt install -y ntfs-3g # 宿主默认没有 NTFS 驱动
losetup -P -f --show /var/lib/vz/images/9001/vm-9001-disk-0.raw # -> /dev/loop0p2 就是 C:
mount -t ntfs-3g /dev/loop0p2 /mnt/win # 不加 ro 才是读写八、客户说"鼠标抖动":两个前端根因
上线试用后客户反馈:Windows 的 VNC 界面鼠标抖动厉害、不流畅。
第一反应是怀疑带宽或缩放,但逐层插桩之后发现:跟带宽、缩放、指针设备全都无关。是两个前端缺陷叠加。
根因 A(主因):VNC 画面上根本没有鼠标光标,用户是在盲操作。
QEMU 只在光标形状发生变化时才下发 RichCursor。也就是说,后连上来的客户端永远拿不到"当前光标是什么"。实测(在宿主上直连 QEMU 的 VNC unix socket,自己解析帧描述符):
- 注入 5 次指针移动 → 累计 0 字节,而且整场连接里
-239 RichCursor/-240 XCursor/-232 PointerPos一次都没出现过; - 对照组:注入一次按键 → 165628 字节 / 90 个矩形,证明注入通道本身是通的;
- 再抓
screendump:箭头光标确实画在客户机的帧缓冲里,位置正好是注入点——证明指针真的动了。
_cursorImage 一直是 RFB.cursors.none(w = h = 0,全透明),而它的 util/cursor.js 里 change() 遇到 w === 0 会直接 clear(),clear() 写的是 this._target.style.cursor = 'none'。结果就是 VNC 画面上完全没有鼠标指针,用户只能凭手感点——体感就是"抖得厉害、不流畅"。
修法是打开 noVNC 自带的兜底开关 rfb.showDotCursor = true(它只在服务端光标全透明时生效,真收到光标会自动切回去,没有副作用),并把内置那个 3×3 的小点换成自己画的 20×20 标准箭头。
根因 B:抓标题栏往画面方向拖,窗口纹丝不动。
novnc/core/rfb.js 的画布鼠标处理里有 ev.stopPropagation()。而拖动监听挂在 window 的冒泡阶段,指针一旦划过 VNC 画面,事件链就断在画布上。
这个 bug 的现象特别有迷惑性:抓标题栏往下拖(一定会划过画面)不动;往旁边拖、把指针拖出画面反而能动;拖右下角把手也能缩放(因为拖出去之后指针就在画布外了)。
修法是把拖动/缩放监听改挂 window 的捕获阶段(addEventListener(..., true))。捕获阶段先于目标元素执行,stopPropagation() 拦不住它。注意解除监听时也必须带 true,否则摘不掉、鼠标会一直拖着窗口跑。
这一轮还顺手修掉一个测试工具自身的缺陷:panel_browser_check.py 原来会真的提交开通请求,跑一次就在宿主上克隆一台 40 GB 的 Windows。现在改成拦截 POST /api/vps/create 并伪造响应——请求体照旧记录、验证点一个不少,但不再有副作用。
一句教训:测试套件不许有副作用。 一个"回归验证"工具如果在验证过程中改变了被测系统的状态,它本身就是一个 bug 源。
九、结果与遗留
三套端到端回归,全部以"外网视角 + 真实动作"判定,不用端口探测代替交付验证:
其中 Windows 的唯一"就绪"判据是公网侧真实完成一次 RDP X.224 握手(收到 0xd0),不是"3389 端口开着"——因为 RouterOS 的 dstnat 一建好端口立刻就通,哪怕虚机根本没起来。
客户侧的实际感知是:
Debian 38 秒、Windows 100 秒开一台。这是"克隆"的代价,不是"装系统"的代价。
还没做的
- 面板是明文 HTTP + 明文
ws://,上线前必须加 TLS / 反向代理。noVNC 自己都在控制台里喊requires a secure context (TLS)。 - 无计费/支付;管理密码写在 systemd 的 Environment 里,这个要挪走。
/opt/vps/admin_key被注入到每台客户机的 root,等同后门,上线前必须评估要不要保留。- 目前只映射了 SSH 端口,真实售卖应该给客户一段端口。
还留着没榨的优化空间
- 把客户机壁纸换成纯色。默认照片壁纸 Tight 压不动,实测整屏首绘 826 KB(原始 3 MB,压缩率仅 27.5%)。换纯色能掉一个数量级,控制台基本"秒出画面"。注意登录/锁屏界面用的是
C:\Windows\Web\Screen\下的图,跟桌面壁纸不是同一处。 - noVNC 的画质/压缩参数。默认
qualityLevel = 6/compressionLevel = 2,对应伪编码-32+n/-256+n,QEMU 认这两个值。 - noVNC 是 ES module 按需加载、没打包,首开要拉几十个小文件(实测 headless 下 6~8 秒)。捆一下能明显改善。
最后:这轮最值钱的三条经验
- 交付判据必须是"真实业务动作",不是"端口开着"。 端口能通不代表服务能登;RDP 端口 open 不代表虚机能用。判据越接近客户真实体验,越能挡住"看起来好了"的假通过。
- 优雅关机不是可选项。
qm stop等同拔电,会写坏 guest 里那些"写了一半"的状态文件。这类损坏不会立刻暴露,而是下一次开机才炸——所以"重装成功但开机失败"这种诡异现象,第一件事就是去查上一次是怎么关机的。 - 遇到"说不清哪里不对"的体验问题,先做证据链,别先猜。 "鼠标抖动"这种描述,凭直觉会去查带宽和缩放;而把三个方向(服务端发了什么字节 / 客户机帧缓冲里有什么 / 浏览器里 noVNC 的状态是什么)的证据摆在一起,根因是光标根本不可见——四个字就能说清的事,猜一天也猜不到。






💬 评论 (0)
还没有评论,来抢沙发吧。