PVE 宿主的 HDMI 看板:切页、封锁与一条走不通的电源路
这块看板的目标一句话:一台没有桌面的 PVE(Proxmox VE)宿主,把主板核显的 HDMI 接到客厅电视上,固定显示一个网页;看哪个页面,从手机或电脑上换。
显示本身不难。宿主有 tty,有核显,装一个合成器(compositor)和浏览器,画面就出来了。真正花时间的是另外三处边界:切页时 HDMI 不能重新协商,否则电视会黑一下甚至重新握手;键盘鼠标在客厅,不能被误触带到浏览器设置或 PVE 控制台;电视的电源,宿主其实管不了。
这三件事决定了整套东西的形状。下面按约束、分层、关键实现、结果、取舍与边界来写。文中数字除特别说明外,均为截至 2026-10 的实测。
约束
先把边界列清楚:
- 宿主没有桌面环境,只有文本终端;没有 X11 会话,Wayland 合成器要自己起。
- 独显整卡直通给一台 VM,宿主用不了它的 HDMI,只剩主板核显这一路输出。
- 要看的不全是自己写的页面,有登录页,也有控制台;不少页面带
X-Frame-Options或内容安全策略(CSP)的frame-ancestors,不能当 iframe 嵌。 - 现场无人值守:电视挂在客厅,键鼠偶尔有人碰,不能用「人工关掉多余窗口」兜底。
- 只有一个 HDMI 输出,本地没有第二块屏;出问题只能远程处理。
- 电视是一台 1080p 的 Android 电视,开关机不归宿主管。
这些约束会一直复现到后面的每个选择。约束变了,方案就该重写,而不是继续加开关。
分层
整套东西分五层:
- 控制面:一个带 token 的小网页,把「换哪个地址」「开不开投放」变成一次 HTTP 请求。
- 状态文件:当前地址、Chromium 的 PID、投放开关的 flag、黑屏前后的地址,都落在文件里。
- 循环:
kiosk-loop读地址,起 Chromium;Chromium 退出后用新地址再起一次。 - 合成器与浏览器:
cage加 Chromium--kiosk,跑在tty7上。 - 输出:核显的
HDMI-A-1,1920×1080。
设计上只有一条原则:每层只做一件事。切页只替换浏览器那一层,不惊动下面的合成器。cage 在一次会话里一直占着核显的输出(DRM master),HDMI 链路是通的;Chromium 换一次,电视只当页面变了。
一、切页:不重启合成器
一次切页由两段组成。控制服务校验地址、把它原子地写进状态文件、对当前 Chromium 的进程组发 TERM(5 秒内没退干净再补 KILL);kiosk-loop 的 wait 返回后先等 1.5 秒,给子进程和文件锁收尾,再回到循环顶部读新地址、清锁,拉起新的 Chromium。cage 全程没有退出,HDMI 也就没有重新协商。从旧窗口消失到新页面出现,屏幕黑约 1~2 秒。
同样的事如果换成重启整个服务,代价会大不少:cage 会跟着重启,HDMI 重新协商;而 cage 对 SIGTERM 不敏感,这台机器上实测 systemd 要等 10 秒才补 SIGKILL(systemd 上游默认是 90 秒,这里为什么是 10 秒当时没有追)。把 TimeoutStopSec 调到 3 秒之后,一次完整的停仍要约 3~4 秒。对一块日常要切好几次的看板,这个差别足够决定方案。
iframe 看起来更省事,把目标页面套进一个外壳页就行。但不少页面用 X-Frame-Options 或 CSP 明确拒绝被嵌,PVE 自己的界面就套不进去。剩下的路是 Chromium 远程调试(CDP),代价是多开一个调试端口、多一套连接状态要维护,只为避免一次重启,不值得。
登录态也留住了。profile 目录不动,换页只是换地址,cookie 还在;需要重新登录的情况,后面会讲。
二、最常踩的坑:单例锁
第一次做切换时遇到的不是黑屏,而是「黑屏并且再也不回来」。
现象是控制页显示 Chromium 未运行,cage 和 kiosk-loop 都活着,循环在 wait 与 sleep 之间空转,就是不出现新窗口。去状态目录看,profile 里的 SingletonLock 指向一个已经退出的 PID。
判断下来:上一次只杀了主进程,zygote 和子进程还把 profile 的单例锁(singleton lock)留在原地;新 Chromium 起来后认为 profile 被占用,启动即退出。于是形成「循环在跑、页面不动」的状态,看起来像黑屏,其实循环一直在工作。
修法后来固定成四条:
- 用
setsid起 Chromium,让它成为独立进程组; - 控制服务杀的时候对整组先发 TERM,5 秒内没退再发 KILL;
- Chromium 退出后,循环等 1.5 秒再拉下一次,给子进程和文件锁收尾;
- 每次启动前,如果确认没有残留进程,就清掉
SingletonLock、SingletonSocket、SingletonCookie。
# kiosk-loop 的循环骨架(去掉了默认地址与图形相关参数)
while :; do
URL=$(head -n 1 "$URL_FILE")
# 上一轮可能是被强杀的:先确认进程没了,再清单例锁
pgrep -u kiosk -f "$PROFILE" >/dev/null || \
rm -f "$PROFILE/SingletonLock" \
"$PROFILE/SingletonSocket" \
"$PROFILE/SingletonCookie"
setsid chromium --kiosk --ozone-platform=wayland \
--user-data-dir="$PROFILE" "$URL" >>"$LOG_FILE" 2>&1 &
pid=$!
printf '%s\n' "$pid" >"$PID_FILE"
wait "$pid" # Chromium 退出,回到循环顶部重读地址
sleep 1.5 # 给子进程和 profile 锁留出收尾时间
done
这几行的顺序有讲究:先确认没有残留进程,再清锁;清锁必须发生在 setsid 启动之前,否则新进程会先撞上旧锁。1.5 秒不是精确值,只是够用——给 zygote 和子进程留出收起 profile、让文件锁真正释放的时间。
另外,Chromium 的 stderr 追加到一个日志文件;kiosk-loop 每次启动时检查一次,超过 5 MiB 就把旧日志轮转掉,不再开 --v=1,避免刷爆。下次崩溃可以直接翻原因。
验证是连续切 6 次,6 次都返回 running,PID 每次更新,进程组 ID 等于主进程 PID;日志里只剩 ALSA 与 VA-API 的无害告警。
三、--kiosk 只改外观
看板一开始只用 Chromium 的 --kiosk。它让窗口没有地址栏、没有标签页,看起来就是一块全屏页面。但实测下来,它改的只是外观:
| 操作 | 加固前的实测结果 |
|---|---|
Ctrl+T / Ctrl+N |
退出 kiosk 外观,露出带标签页、地址栏和搜索框的普通浏览器窗口 |
Ctrl+W / Ctrl+Shift+Q |
关掉页面,kiosk-loop 过 3~5 秒重开,屏幕闪断 |
Ctrl+Alt+F1 |
切到 PVE 文本控制台,键盘可以直接登录宿主 |
Ctrl+滚轮 / Ctrl+Plus / Ctrl+Minus |
缩放到 150%,而且 per-origin 缩放会写进 profile,重启后还在 |
这张表(Chromium 155.0.8059.79)说明:托管策略(managed policy)和页面脚本都拦不住浏览器保留快捷键,因为它们在页面这一层,而快捷键在更下面。要拦,得去输入层。
四、三层封锁
封锁分三层,各管一段。
第一层是 keyd 2.5.0,在 evdev 层把键位映射成 noop,Chromium 根本收不到。拦的范围包括 Ctrl+T/N/W/Q、Ctrl+Shift+* 一组、Alt+* 一组、Ctrl+Alt+F1..F12、F1..F12、Shift+F10,以及键盘上的 search、www、home 这类浏览器专用键。
# kiosk.conf 节选:在输入层吞掉浏览器保留快捷键
# 完整配置还有 [main]、[shift]、[alt]、[control+shift] 等段
[ids]
*
[control]
# 新窗口 / 新标签 / 关闭 / 退出
t = noop
n = noop
w = noop
q = noop
# 地址栏、搜索、打印、保存、查看源码
l = noop
e = noop
p = noop
u = noop
[control+alt]
# 内核 VT 切换:防止逃到 PVE 文本控制台
f1 = noop
f2 = noop
f3 = noop
f4 = noop
[ids] * 表示所有键盘。这是一把双刃剑:以后插一把新键盘也会被同样过滤,正常打字不受影响,只是被列出的组合键会被一并吞掉;它也会 grab 一些热键设备,实测没有副作用。
第二层是合成器。cage 启动时去掉 -s。-s 允许虚拟终端(VT)切换,去掉以后,即使 keyd 被停掉,Ctrl+Alt+F1 也不会把电视切到 PVE 控制台;控制页自己用的 chvt 不依赖它。
第三层是浏览器。扩展在 window 的 capture 阶段对 contextmenu、auxclick、Ctrl+滚轮、dragstart、drop 和带修饰键的 click 做 preventDefault 加 stopPropagation;后台脚本监听导航和 onZoomChange,把缩放压回 100%。缩放有两层记录,只拦 Ctrl+滚轮 不够,历史遗留的放大还写在 profile 里。托管策略关掉开发者工具、打印、下载、隐身模式、搜索建议,并把 chrome://、view-source:、file:// 一类入口排掉;启动参数补上 --disable-pinch 与 --overscroll-history-navigation=0。
验证没有靠手感。测试时向 /dev/uinput 注入按键和鼠标事件,再用 grim 截图与基线比对(右键那一项在游戏页上做到了逐字节相同),最后切到真实页面复测。加固后的结果:右键没有菜单;Ctrl+滚轮 恒为 100%;Ctrl+T、Ctrl+W 之后 PID 不变、页面不动;Ctrl+Alt+F1..F12 之后 fgconsole 仍是 7,而且单独停掉 keyd 再测仍然不切——说明合成器那一层独立生效。正常打字、页面里的按键交互不受影响;页面自己实现的右键菜单会被一并拦掉,页面内的部分 Ctrl 组合键也会被吞。
逃生通道是 keyd 官方的 panic sequence:物理键盘依次按 Backspace、Escape、Enter,keyd 退出,键盘恢复原样;之后可以从控制页关掉投放,进 PVE 控制台。这套设计假设客厅里的人是家人,不是带着 root 权限的攻击者。
五、控制面:一个 token,一个端口
控制面是一个 Python 标准库写的单文件服务:ThreadingHTTPServer,零第三方依赖,只开一个端口(IPv4),不做公网映射。token 是 URL 的第一段,也就是一个能力 URL(capability URL)——拿到 URL 就等于拿到能力,没有用户名密码,也没有会话。
因此有几条纪律。token 文件权限 600,只有 kiosk 用户可读;日志里把 token 替换成 <token>;不开 TLS,只在可信局域网使用,不做公网映射;换 token 就是换文件加重启服务,旧 URL 立即失效。
输入侧只接受 http 与 https、必须有非空的主机段(netloc)、长度受限、首尾空白会被去掉、中间含空白直接拒绝,所以 javascript:、file:、data: 这类地址进不了状态文件。状态文件用临时文件写好再 rename,读的一侧不会看到半截地址。
控制面和显示面是解耦的。投放关掉以后,kiosk.service 停了,控制服务还在,页面随时能把它开回来。
六、投放开关:把屏幕交还给宿主
投放开关要解决的是「把电视还给宿主」。关闭时:停 kiosk.service,再 chvt 1,把前台虚拟终端切到 PVE 文本控制台;启用时反过来,start 加 chvt 7。
状态持久化靠一个 flag 文件加 systemd 的 ConditionPathExists=!:flag 存在时,开机自启的条件不成立,服务直接跳过。按这个条件逻辑,关掉投放再重启宿主,屏幕仍应是 PVE 控制台,需要从控制页重新打开——这条只验证了「手动 start 被跳过」,重启场景没有真的走过。关闭期间当前地址会被保留,重新启用就回到原来那一页。
权限是这件事里最容易被做坏的部分。控制服务以 kiosk 用户运行,保留 NoNewPrivileges=yes,没有 sudo。它需要启停一个 systemd unit,于是用 polkit 写一条规则:动作是 org.freedesktop.systemd1.manage-units、unit 是 kiosk.service、subject 是 kiosk 用户时返回 YES,其余不放行。相比给一个免密 sudo,这条规则的授权范围窄得多——它只能动这一个 unit。
实测:关闭后 fgconsole 是 1,kiosk.service 进入 failed(inactive)状态;关闭请求本身要约 3~4 秒,因为 cage 不吃 SIGTERM,要等 TimeoutStopSec=3 之后被 SIGKILL。systemd 会把这次停记录成 failed,这是既有行为,不影响功能——flag 在那里,Restart=always 也拉不起来。
七、电视电源:一条走不通的路
原本的计划里还有一步:让宿主顺手把电视也关了。这一步没走通,而且失败得很干净。
能做的只有单向关:通过电视的调试通道发一个待机指令,屏幕会灭。但待机之后,网络协议栈跟着停了。ping 还能通——那是无线网卡在回链路层与 ICMP——TCP 却全部连不上。这条后来写进了排查习惯:ping 通不代表活着。
想把它叫醒,试过三条路。网络唤醒(WoL)的魔术包连发 75 秒,没有反应;把 HDMI 信号断掉再接通,电视不醒;宿主的核显输出上没有 CEC(Consumer Electronics Control)适配器,虽然电视那一侧支持 CEC,源端缺一块硬件,联动就无从谈起。厂商固件把待机唤醒方式设成了红外,没有 root 改不了;设置里也没有「关屏但系统不休眠」这一档,这条路在本机固件上不存在。
所以加了一个全黑页兜底。控制页上点一下,当前地址换成控制服务自己返回的 186 B 纯黑 HTML,电视保持开机、HDMI 保持有信号,画面变黑,随时能从手机上的控制页恢复。实测截图是 1920×1080 全黑,6121 B。它不省电,背光还亮着;它解决的是「不想让屏幕亮着,又不想去拿遥控器」这个很具体的需求。
真关机仍然要遥控器。这一点没有绕过去,也不打算绕:再往下就是加硬件,比如 CEC 适配器、红外发射器或者智能插座,那是另一个成本级别。
结果
| 项目 | 数字 |
|---|---|
| 切页黑屏(只换 Chromium) | 约 1~2 秒 |
| 整服务停一次 | 约 3~4 秒(TimeoutStopSec=3) |
| 重启服务 | 比只换浏览器多一次 cage 重启与 HDMI 重新协商 |
cage 对 SIGTERM |
不敏感;这台机器实测 systemd 等 10 秒才 SIGKILL |
| 连续切换 | 6 次全部 running,PID 每次更新 |
| 黑页 HTML | 186 B |
| 全黑截图(1920×1080) | 6121 B |
| 旧 profile 目录 | 287 MB |
| Chromium 日志轮转阈值 | 5 MiB |
| 第三方 Python 依赖 | 0 |
以上是 2026-10 在 PVE 9.2.20、kernel 7.0.14-17-pve、Chromium 155.0.8059.79、keyd 2.5.0、PipeWire 1.4.2 与 WirePlumber 0.5.8 上的实测。版本会变,这些数字只对这一次负责。
取舍与坑
最险的一次不是切页失败,是 profile 坏了。连着改扩展、反复用切换接口强杀 Chromium 之后,新进程启动约 1.5 秒就退出,没有 coredump,日志里只有几条 Fontconfig 与销毁无效条目的报错;循环不停重开,屏幕一直是黑的。处理办法很朴素:停服务,把 profile 目录整个挪走(旧的 287 MB),再启动让它重建,代价是看板上的登录态要重新登一次。以后的规矩是:改扩展或策略之后,优先整服务重启一次,而不是连续切页。
音频是另一条容易忘的线。两个原因叠在一起:启动参数里还带着 --mute-audio,宿主又没有声音服务,Chromium 只能退回 ALSA,HDMI 就没声。后来装了 PipeWire 与 WirePlumber 的用户级服务,给 kiosk 用户开 linger,让它在没有登录会话时也能起;每次拉起 Chromium 之前,脚本重新选 HDMI sink、取消静音、把音量设到 100%。电视自己的音量与静音是独立的一层,软件侧 100% 不代表客厅有声音。
看板上的页面不全是只读的。宿主的无线键鼠还在输入 seat 上,看板如果停在一个带按钮的配置页,误触就可能改掉规则。要更稳,可以加只读代理或遮罩,或者干脆把鼠标拔掉;这次没有做,只是记下来。
单屏也是取舍:只有一个 HDMI 输出,出问题时要么 SSH,要么从另一台设备打开控制页。这也是为什么控制服务必须独立于 kiosk.service 存在——屏幕黑了,控制页还得能开。
复用宿主而不是另买一台小机器,是另一个取舍。宿主 24 小时开着、核显空着、HDMI 线已经在电视旁边;再加一块板子或迷你主机,贵的不在钱,在多一个要维护、要更新、要记得它在哪的设备。代价是看板与宿主绑定:宿主升级内核、重启或出故障,看板一起停;看板的浏览器更新、扩展失效,也归宿主管。能接受这个绑定,才适合复用。
适用边界
这套东西适合:一台没有桌面的宿主,一块平时不看、偶尔要换页的屏,一个可信的局域网,以及一个能接受「关机靠遥控器」的使用者。
不适合:需要多屏;需要在不可信的网络上远程控制(没有 TLS,token 在 URL 里);需要真正控制电视电源;或者需要防住一个有物理键盘、会查文档的攻击者。这里的键盘封锁是防误操作与防逃逸的设计,panic sequence 本身就是给维护者留的门。
还有一条没有验证:开机自启只做了 enable 与手动 start,没有真的重启宿主走一遍。如果显示器在启动时没通电,cage 找不到 connected 的输出,可能黑屏,靠 Restart=always 重试——这是设计里的兜底,不是实测结论。
最后是版本,上面已列过,只说明截至 2026-10 的行为。Chromium 的保留快捷键与策略项会变,真要用,先把「不加固能逃到什么程度」自己量一遍。
结尾
回头看,这块看板难的不是让它亮起来,是让它一直亮着,并且不容易被按走。合成器与浏览器拆成两层,切页只换浏览器;进程组与单例锁处理好,页面才不会卡在「循环在跑、屏幕不动」;键盘封锁放在 evdev,是因为快捷键根本不经过页面;电视电源那条路走不通,就老实做一块黑屏,把遥控器留给需要它的时候。
这些都不是聪明的设计,只是把每个约束照顾到。
初稿由 AI 协助整理,事实与结论经作者核对。