← 返回首页

PVE + RouterOS CHR 自建 VPS 自助开通系统:从 Web 开户到 Debian / Windows 双系统交付全记录

这台机器上原本只跑着 PVE 和一个 RouterOS CHR 软路由。今天要做的事很清楚:让客户在网页上点一下,就拿到一台能直接 SSH / RDP 登录的 VPS。听起来是"调几个 API"的活,实际做下来,真正的难点全在三个地方——cloud-init 没给 root 留门、一次硬关机把客户机的 sshd 写死、以及微软官方安装 ISO 那堵 UDF 的墙。这篇把整条链路、每一个坑的实测证据和最终验证结果都记下来,配 6 张图。

一、目标与总体架构

需求是一句话:做一个 Web 控制端,用户开户后能自主开通 VPS。先做核心链路——选系统(Debian 12 或 Windows Server 2019)、自动开通、配好网络、能 SSH / RDP 直连。

架构上有一条贯穿始终的原则:客户机全部活在 RouterOS 的内网里,宿主机不直接暴露客户机端口。

VPS 自助开通系统总体架构:浏览器 / 控制端 / PVE + 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 拿 WAN 103.148.59.36/24,ether2 桥到内部网桥 vmbr1 做 LAN 192.168.88.1/24。端口映射(dstnat)和 DHCP 静态租约都在它身上。
  • 客户机池:VMID 1000–1999、内网 IP 192.168.88.100–240、公网端口 20000–29999。
  • 金镜像:Debian 12 是 VMID 9000,Windows Server 2019 是 VMID 9001。客户每一次开通都是 qm clone,不是重新装系统。
其中一条决策值得单说:客户看到的公网入口用的是 RouterOS 的 WAN IP(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 按默认值写了一个文件:

TEXT
/etc/ssh/sshd_config.d/50-cloud-init.conf
  PasswordAuthentication no

验证方法很直接——把 PVE 生成的 cloud-init 配置盘转出来看内容:

BASH
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。
最后定了一条规矩:交付判定不能用"端口通了",必须用真实 SSH 密码登录 + cloud-init status --wait。因为 cloud-init 会重建 SSH 主机密钥并重启 sshd,不等它跑完就交付,客户登录会撞上重启窗口,表现为连接超时约 20 秒。

四、两个真 BUG:一次硬关机把客户机的 sshd 写死

写了 tools/e2e_test.py(22 项端到端,从外网视角驱动面板 API 跑完整生命周期),第一次跑出来 20/22。

失败项只有两个:「开机 → SSH 恢复」和「重启 → SSH 恢复」。

这里有一个非常关键的反证:重装(= 新克隆 = 首次启动)的 SSH 是成功的。也就是说问题不在"克隆出来的机器",而在"第二次启动"。

顺着往下挖,得到一条完整的崩溃链:

cloud-init 崩溃链:一次 qm stop 硬关机如何让 sshd 起不来,以及两处修复

逐条对应实测证据:

  1. qm stop 是硬关机,等同拔电。它把 guest 的 /var/lib/cloud/data/previous-hostname 写坏了——实测文件内容退化成 9 字节裸文本 e2e-test\n,缺了 JSON 该有的引号。
  2. 第二次开机,cloud-init 读这个坏文件,json.loads 抛 JSONDecodeError,崩在 cc_set_hostname.py:111 的 load_json(load_file(prev_fn))。
  3. cloud-init-local.service 和 cloud-init.service 双双崩溃。要命的是模板当时设了 cloud-init 的 ssh_deletekeys: true——这个选项的含义是"每次开机先删掉 host key,再重建"。
  4. cloud-init 崩了,host key 就停在"已删除、未重建"的状态。实测 /etc/ssh/ssh_host_{rsa,ecdsa,ed25519}_key 全是 0 字节。
  5. 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(优雅关机),超时才兜底硬停。
重烘焙模板 9000 之后回归 22/22,而且模板烘焙耗时从 11 分钟降到 3 分 36 秒。
顺手记下一个好用的诊断二分法:SSH 报 Connection refused = guest 内部根本没在监听(去查 sshd / host key / cloud-init);报 timeout = 网络或端口映射问题。这一刀切下去,排障范围立刻减半。

五、把 VNC 监控做进面板

面板里要有「VNC 监控」——在网页上直接看客户机屏幕,不装任何客户端。链路是:

TEXT
浏览器 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 塞进官方安装盘根目录,全程只用一个光驱。实测走不通,而且是四堵独立的墙:

Windows Server 2019 无人值守安装:重打包官方 ISO 的四堵墙 与 三件套通路

  • 微软官方盘是"纯 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 一个预装好的 Windows RAW 镜像,几分钟搞定"。查证下来不采用,有三条硬理由:
  • 来历与授权。网上流行的 DD 镜像多是匿名第三方重打包的,这类盘被塞后门/挖矿、内置 KMS 破解工具是常见事故。商业售卖用它,等于把客户数据和自身合规章押给匿名作者。官方 ISO 路线可以核 SHA1,这条证据链是 DD 镜像给不了的。
  • 架构不匹配。那些镜像常标死 _UEFI 或 for_AliYun,而 PVE 这边走的是 seabios;要跑 UEFI 镜像得挂 OVMF + EFI 变量盘并改 boot 顺序,不是 dd 一下的事。
  • 省不掉的部分才是大头。DD 出来的每台机计算机名、口令、SID 全同,改名/改密/扩盘一样要接上去。复杂度的重心在这里,不在安装。
而且时间账也算得清:DD 镜像省的是"第 2 台起",第一次照样要下载几个 GB + 解压 + 转换 + 导入,比走安装还慢;而第 2 台起我们本来就走 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 分钟重装:

BASH
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 界面鼠标抖动厉害、不流畅。

第一反应是怀疑带宽或缩放,但逐层插桩之后发现:跟带宽、缩放、指针设备全都无关。是两个前端缺陷叠加。

VNC 鼠标抖动根因:证据(指针动但服务端 0 字节)与光标不可见的因果链

根因 A(主因):VNC 画面上根本没有鼠标光标,用户是在盲操作。

QEMU 只在光标形状发生变化时才下发 RichCursor。也就是说,后连上来的客户端永远拿不到"当前光标是什么"。实测(在宿主上直连 QEMU 的 VNC unix socket,自己解析帧描述符):

  • 注入 5 次指针移动 → 累计 0 字节,而且整场连接里 -239 RichCursor / -240 XCursor / -232 PointerPos 一次都没出现过;
  • 对照组:注入一次按键 → 165628 字节 / 90 个矩形,证明注入通道本身是通的;
  • 再抓 screendump:箭头光标确实画在客户机的帧缓冲里,位置正好是注入点——证明指针真的动了。
于是 noVNC 的 _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 源。

九、结果与遗留

三套端到端回归,全部以"外网视角 + 真实动作"判定,不用端口探测代替交付验证:

三套端到端回归结果:47/47、22/22、21/21 全通过

其中 Windows 的唯一"就绪"判据是公网侧真实完成一次 RDP X.224 握手(收到 0xd0),不是"3389 端口开着"——因为 RouterOS 的 dstnat 一建好端口立刻就通,哪怕虚机根本没起来。

客户侧的实际感知是:

开通耗时对比:一次性母盘成本 vs 客户感知的克隆成本

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 秒)。捆一下能明显改善。

最后:这轮最值钱的三条经验

  1. 交付判据必须是"真实业务动作",不是"端口开着"。 端口能通不代表服务能登;RDP 端口 open 不代表虚机能用。判据越接近客户真实体验,越能挡住"看起来好了"的假通过。
  2. 优雅关机不是可选项。 qm stop 等同拔电,会写坏 guest 里那些"写了一半"的状态文件。这类损坏不会立刻暴露,而是下一次开机才炸——所以"重装成功但开机失败"这种诡异现象,第一件事就是去查上一次是怎么关机的。
  3. 遇到"说不清哪里不对"的体验问题,先做证据链,别先猜。 "鼠标抖动"这种描述,凭直觉会去查带宽和缩放;而把三个方向(服务端发了什么字节 / 客户机帧缓冲里有什么 / 浏览器里 noVNC 的状态是什么)的证据摆在一起,根因是光标根本不可见——四个字就能说清的事,猜一天也猜不到。

💬 评论 (0)

今日还可评论 3 条

还没有评论,来抢沙发吧。