keepbx.cn
← 目录

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。
PVE 宿主 HDMI 看板的分层结构 左列是主链路:手机或电脑打开控制页,控制服务把地址写进状态文件并结束当前 Chromium 进程组;kiosk-loop 读状态文件重新拉起 Chromium;cage 合成器把画面送到 HDMI。右列是切换、恢复、封锁与投放四个关键点。底部是验证方法与三个数字。 手机 / 电脑浏览器 打开控制页,点预设或填一个地址 HTTP · token 在路径 kiosk-control Python 标准库 · 单端口 · 0 依赖 写 URL · killpg 状态文件 当前地址 / PID / 投放 / 黑屏 循环读取状态 kiosk-loop 读地址 → 起浏览器 → 退出后重起 setsid · 进程组 cage + Chromium --kiosk tty7 · Wayland · 不切 VT HDMI-A-1 · 1080p 客厅电视 只负责显示;电源只能单向关 四个关键点 ① 切换:只结束进程组 cage 不重启,HDMI 不重新协商,黑屏 1~2 s。 重启服务则会重新协商;iframe 还会被 X-Frame-Options / CSP 挡住。 ② 恢复:进程组与单例锁 只杀主进程会留下 profile 单例锁,循环空转 而页面不起。修法:setsid + killpg, 退出后等 1.5 s,启动前清锁。 ③ 封锁:三层各管一段 keyd 在 evdev 层吞快捷键;cage 去掉 -s 断掉 VT 切换;扩展与托管策略管右键、 缩放、DevTools。--kiosk 只改外观。 ④ 投放:把屏幕交还宿主 chvt 1 显示 PVE 控制台;flag 持久化, 开机自启跳过;polkit 只授权启停一个 unit。 电视电源仍要遥控器,另有全黑页兜底。 验证:uinput 注入按键与鼠标事件 + grim 截图比对; 连续 6 次切换全部 running、PID 每次更新;黑屏页 186 B。 PVE 宿主 HDMI 看板的链路与四个关键点 竖版示意:手机或电脑打开控制页,控制服务写入状态文件并结束浏览器进程组;kiosk-loop 重新拉起 Chromium;cage 把画面送到电视。下方是切换、恢复、封锁、投放四个关键点的一句话版本。 手机 / 电脑浏览器 打开控制页,点预设或填地址 HTTP · token 在路径 kiosk-control Python 标准库 · 单端口 · 0 依赖 写 URL · killpg 状态文件 地址 / PID / 投放 / 黑屏 循环读取状态 kiosk-loop 读地址 → 起浏览器 → 退出后重起 setsid · 进程组 cage + Chromium --kiosk tty7 · Wayland · 不切 VT HDMI-A-1 · 1080p 客厅电视 只负责显示;电源只能单向关 四个关键点 ① 切换只结束进程组,cage 不重启,HDMI 不重新协商。 ② 恢复:setsid + killpg + 等 1.5 s + 启动前清锁。 ③ 封锁:keyd(evdev)/ cage 无 -s / 扩展与策略。 ④ 投放:chvt 1 + flag 持久化 + polkit 授权;黑页兜底。 验证:uinput 注入 + grim 截图;6 次切换 PID 每次更新。
图 1:左列是从控制页到状态文件、循环、合成器,最后到电视的主链路;右列是切换、恢复、封锁与投放这四个后来反复打磨的关键点。窄屏换用竖版,配色跟随昼夜主题。

设计上只有一条原则:每层只做一件事。切页只替换浏览器那一层,不惊动下面的合成器。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 被占用,启动即退出。于是形成「循环在跑、页面不动」的状态,看起来像黑屏,其实循环一直在工作。

修法后来固定成四条:

  1. 用 setsid 起 Chromium,让它成为独立进程组;
  2. 控制服务杀的时候对整组先发 TERM,5 秒内没退再发 KILL;
  3. Chromium 退出后,循环等 1.5 秒再拉下一次,给子进程和文件锁收尾;
  4. 每次启动前,如果确认没有残留进程,就清掉 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 协助整理,事实与结论经作者核对。