feat(webui+guard): 内核重启/关机按钮,指令走心跳复用既有协议

## 需求

WebUI 加关机与重启按钮,内核提供对应内部接口。有些状态变更只能靠重启
生效(换 LLM 源、换主密钥),没有这条通道时用户只能 ssh 上去敲 systemctl。

## 设计:复用心跳,不新开通道(用户指出的方向)

guard 与 worker 之间已经有一条现成的、每 5s 一次的单向通报(心跳文件,
内容原本是 `t=<unix>`,只用来刷 mtime)。用户点按钮本质就是"我要你在
下一次心跳时知道这件事"——再开 unix socket / 信号 / 临时文件,只会多出
一套要防重、防丢、防竞态的机制。

    WebUI 点按钮
      → POST /api/v1/kernel/{restart,shutdown}
      → sdk.KernelControlAPI(只投递,立即返回)
      → 下一次心跳把 `cmd=restart|shutdown reason=...` 写进 <data>/heartbeat
      → 重启:worker 清理 → exit 42 → guard 重拉(**既有机制,零改动**)
        关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
              → systemctl stop → 不再拉起

★ 关机为什么必须由 guard 做:
  unit 是 Restart=always,guard 自己 return 会被 systemd 立刻拉起
  (用户点了"关机"、几秒后服务又活了)。必须显式 systemctl stop。
  而不让 worker 自己停:它是被守护方,且**只有 guard 知道这次该不该重拉**。

★ 指令必须一次性消费:guard 读完立即原子重写文件清除 cmd 行,
  否则每次心跳都读到同一条 ⇒ 无限重启/关机。worker 侧还要检查
  "指令写过但文件里没了" ⇒ 已被消费 ⇒ 清本地 pending,否则又写回去。

## 顺带修的真缺陷:心跳文件无限增长

生产实测 **14.6MB**(纯 O_APPEND,约 70 天)。心跳的唯一职责是刷 mtime,
历史行没有任何读者 ⇒ 每次 consume 要整读 14.6MB(实测 27ms)。
改为 O_TRUNC 滚动写:只保留当前一行。真机验证 15,322,164 → 13 字节。
consume 也加了尺寸守卫(大文件只读尾部 8KB)。

## 安全:能力分级 + 白名单(两道)

1. proc 桥的 method 能力表:新增 `CapKernelControl` 一档。
   ★ 判据 `TestCapability_AllMethodsClassified` 当场报出
   "未登记 ⇒ 会按 CapCore 放行,绕过权限检查" —— 那意味着任何插件都能关机。
2. 内置插件走 `SDKConfig.KernelControl`,第一版我写成"有就发",
   等于全部内置插件都能关机。改为按名字白名单(目前只有 webui)。

## 判据(7 条)

心跳:解析 / 消费后必须清除 / worker 不得写回已消费指令 / 未消费时必须续写
能力:只有白名单插件拿得到 / 未装配时返回 nil 不 panic / SDK nil 安全
心跳文件:不得无限增长 / 大文件只读尾部且读后收敛

变异 6/6 咬住。全量 53 包 ok。

## UI

导航底栏两个图标按钮(与主题/语言/退出并列):重启有确认框 + 轮询等它
回来并自动刷新;关机有**二次确认且写清后果**(必须人工 systemctl start)。
两者都先把响应写回浏览器并 Flush,再让内核退出 —— 否则用户看到的是
connection reset,无法区分"正在重启"和"真出故障了"。
This commit is contained in:
JianFeeeee
2026-10-17 21:18:07 +08:00
parent 134cd860b6
commit 198811253a
18 changed files with 960 additions and 19 deletions

View File

@ -0,0 +1,64 @@
package main
// 内核控制通道的实现:把 WebUI 的"重启/关机"请求转成心跳指令。
//
// ## 完整链路(与心跳复用,不新开通道)
//
// WebUI 点按钮
// → POST /api/v1/kernel/{restart,shutdown}
// → sdk.KernelControlAPI.RequestKernel*(本文件实现,只投递不等待)
// → requestHeartbeatCmd():把指令放进 worker 内存的单槽队列
// → 下一次心跳(≤5s)把 `cmd=` 行写进 <data>/heartbeat
// → ① 重启:worker 走正常清理 → exit 42 → guard 重拉(既有语义,零改动)
// ② 关机:guard 在 workerCleanExit 分支读到 cmd=shutdown
// → systemctl stop → 不再拉起
//
// ## ★ 为什么不用"worker 自己调 systemctl stop"
//
// worker 是被守护方。它自己停服务等于在父进程眼皮底下自杀,而且
// systemd 可能先把这次退出记成一次失败。更根本的是:
// **只有 guard 知道这次退出该不该重拉** —— 决策该集中在一个地方。
//
// ## 重启为什么几乎零改动
//
// `exitRestartRequested=42` 与 guard 的 `workerRestartRequested` 分支早已存在
// 且生产验证过(热更新就走这条路)。我们只是给"用户点按钮"也接到同一个出口。
import (
"fmt"
)
// kernelControlImpl 实现 sdk.KernelControlAPI。
type kernelControlImpl struct {
// restartCh 是内核主循环的重启通道(既有机制,supervisor 也用)。
restartCh chan<- struct{}
}
// RequestKernelRestart 请求热重启。
//
// 走**既有**的 restartCh(与 supervisor.Restart 同一条路):
// worker 清理 → exit 42 → guard 用当前二进制重拉,不计失败轮次。
func (k *kernelControlImpl) RequestKernelRestart(reason string) error {
if k.restartCh == nil {
return fmt.Errorf("内核重启通道未装配")
}
select {
case k.restartCh <- struct{}{}:
return nil
default:
// 已有待处理的重启请求 ⇒ 目标一致,视为成功(幂等)
return nil
}
}
// RequestKernelShutdown 请求关机。
//
// ★ 与重启的区别:关机**不能**复用 restartCh(那会被 guard 重拉)。
// 必须让 guard 在 worker 退出后知道"这次别再起了" ⇒ 写心跳指令。
//
// 这里只投递;真正的写入在 worker 的下一次心跳(≤5s),
// 因此本方法立即返回 —— 调用方(HTTP handler)需要的是"请求已受理"。
func (k *kernelControlImpl) RequestKernelShutdown(reason string) error {
requestHeartbeatCmd(hbCmdShutdown, reason)
return nil
}