狸花云控制台的终端,不是 noVNC,也不是 PVE 自带的那个网页外壳。它是一条自己的 SSH 通道——浏览器里敲的每个字符,都是后端以普通用户身份连到容器里执行的结果,全程不碰 PVE 宿主的 root。
这不是审美选择,是被逼出来的。
为什么不给用户 noVNC
PVE 的网页终端确实现成:一个 vncwebsocket 接口,前端套个 noVNC 就能用。但它有三个绕不过去的前提:
1. 它要求 PVE 层面的有效 ticket。要让用户能开终端,就得给用户一个 PVE 身份——哪怕是个受限身份,也意味着他的权限边界从”我的容器”变成了”这台机器上的某个角色”。
2. 它的鉴权是按 PVE 走的,不是按订单走。用户 A 的容器和用户 B 的容器,在 PVE 眼里是两个 vmid,在我们眼里是两笔订单。中间那层对应关系,PVE 不知道。
3. 宿主机上多一个能执行 vmid 级操作的身份,就是多一个要审计的面。我们能不开就不开。
所以链路换成了这样:
浏览器 xterm.js
│ ws /api/pve/ws/term?token=xxx
▼
狸花云后端(session 校验 → vmid 归属校验 → 网段白名单)
│ ssh lihua@容器IP
▼
容器内的 bash -l
容器里预置了 lihua 用户和一把专用公钥,私钥只放服务端。用户能开终端的前提是”这台容器属于他”,不是”他在 PVE 里是什么角色”。 鉴权的锚点从机器挪到了订单,这一层的边界才算干净。
白名单的三种写法,和它的坑
终端能开到哪台机器,由一个逗号分隔的字符串控制:
allowTerminal: (vmid) => {
const raw = String(ssh.targets || '').trim()
if (raw === '') return // 空 = 全部放行
if (raw.includes('*') || raw.includes('/')) return // 已覆盖,不追加
const list = raw.split(',').map((x) => x.trim()).filter(Boolean)
if (!list.includes(String(vmid))) {
list.push(String(vmid)) // 新开的容器自动进名单
}
}
三种语义很省事,但也埋过坑:
| 配置值 | 含义 | 风险 |
|---|---|---|
| 空 | 全部放行 | 最宽松,适合单机自用 |
192.168.2.0/24 |
网段覆盖 | 语义正确,但依赖 IP 判断 |
201,202 |
具体 vmid 白名单 | 踩过一次:新容器不在名单里,全线报”终端未开放” |
那次故障的表现是「所有容器都提示未开放」,根因是有人把这里填成了单个 vmid。后来加了自动追加——新建容器时把它的号补进名单。默认值改不了运维的手滑,但可以让手滑的代价只影响一次。
断开不等于结束
这是整个模块里最费心的部分。用户切个标签页、网线抖一下、笔记本合盖,WebSocket 就断了。如果断线即杀进程,用户回来看到的是一片空白,vim 开着的东西全丢。
所以会话是按 ${vmid}:${user} 存活的:
const LIVE = new Map() // key: `${vmid}:${user}`
const IDLE_MS = 30 * 60 * 1000 // 断开后保留 30 分钟
const BUF_MAX = 512 * 1024 // 回放缓冲上限
拆开看是三条规则:
- 断开只解绑,不杀进程。
wireWs里的detach只做两件事:把rec.ws置空、挂一个 30 分钟的回收定时器。 - 重连时先回放,再接上。缓冲里存着这段时间容器的所有输出,重连时逐段补发,然后一条系统提示:
[已接回此前的会话,中断期间的输出已回放]。 - 缓冲是 FIFO,而且必须有上限。超过 512KB 就从头部丢,不然一条
yes或cat 大文件能把后端内存吃干净。
第三条比看着重要。放任缓冲区无限增长,一个用户执行 cat /dev/urandom 就足以把整个后端拖死——这是那种”平时永远不出现,出现时已经是事故”的问题。
回收也是两段式的:
rec.proc.kill('SIGTERM')
setTimeout(() => rec.proc.kill('SIGKILL'), 1500)
先讲道理,1.5 秒后不讲道理。SSH 客户端在忙的时候不理 SIGTERM,光靠礼貌会留下僵尸会话。
录像用 asciinema 格式,而不是录屏
会话可以录像,落盘是 .cast 文件,也就是 asciinema v2 格式:
{"version":2,"width":120,"height":32,"timestamp":...,"env":{...},"title":"user@容器#201"}
[0.123, "o", "\u001b[32mlihua@ct201\u001b[0m:~$ ls"]
[0.987, "o", "server dist docs\r\n"]
选它而不是 MP4,理由很实际:
- 体积。一段十分钟的操作,录屏几十兆,文本流几十 KB
- 可读。出事的时候要查的是”他敲了什么”,不是”屏幕长什么样”——
.cast直接 grep 就能搜命令 - 不用装依赖。播放器就是前端自己写的一个小回放器,文件本身也能被
asciinema play直接打开
单会话硬上限 8MB,超了就不再写。录像不是审计日志的替代品,它只负责回答”当时屏幕上是什么”。
五个踩过的坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 所有容器报”终端未开放” | 白名单填成了单个 vmid | 新建容器自动追加号 |
| 断开就丢会话 | 断线时连带 kill 了 ssh | 解绑不杀进程 + 30 分钟回收 |
| 重连后满屏乱码 | 回放顺序错乱、缺了 resize | 缓冲按段落存,重放前先补发 |
| 后端内存暴涨 | 回放缓冲无上限 | 512KB FIFO 截断 |
| 窗口变了但终端不换行 | 前端发了 resize,后端直接 return |
已知限制:只在建会话时 stty |
最后一条现在还没修。真要修,得给 SSH 加 PTY 分配并转发 window-change——工作量不小,而实际使用里用户很少在全屏终端里改窗口大小。知道它为什么不修,比假装它不存在好。
一点总结
这个模块最后是 470 行,没有一行在跟 PVE 说话。它做的事情可以一句话说完:确认这台容器归你,然后给你一条到它的 SSH。
我们后来回头看,凡是”顺手把用户权限接进运维系统”的地方,最后都要花更多时间把它拆出来。终端这一块从第一天就没往那个方向走,省下的是现在每一个新用户的审计面。