mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
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:
@ -75,6 +75,9 @@ type PluginStageCleaner interface {
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
// kernelCtl 是内核重启/关机通道(装配时注入,可 nil)。
|
||||
// 只对声明了该能力的内置插件可见(目前是 WebUI)。
|
||||
kernelCtl sdk.KernelControlAPI
|
||||
mu sync.RWMutex
|
||||
plugins map[string]sdk.Plugin
|
||||
instances []sdk.Plugin
|
||||
@ -228,15 +231,48 @@ func (r *Registry) SetPluginDir(dir string) { r.
|
||||
func (r *Registry) SetDataDir(dir string) { r.dataDir = dir }
|
||||
func (r *Registry) SetLuaVM(vm *luaVM.VM) { r.lua = vm }
|
||||
func (r *Registry) SetBaseAPIKey(key string) { r.baseKey = key }
|
||||
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
|
||||
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
|
||||
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
|
||||
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
|
||||
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp }
|
||||
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup }
|
||||
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
|
||||
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
|
||||
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
|
||||
|
||||
// kernelControlAllowed 决定某个内置插件能否拿到内核重启/关机通道。
|
||||
//
|
||||
// ★ 为什么必须白名单,而不是"有就给":
|
||||
//
|
||||
// 这条通道能让**整个服务停下来**(关机后不会自己回来)。
|
||||
// 内置插件是编译进内核的,但它们的代码同样可能被改坏或有 bug;
|
||||
// 把这种能力发给每一个内置插件,等于整个攻击面 = 全部内置插件。
|
||||
//
|
||||
// 目前只给 WebUI:它是有登录鉴权的管理界面,用户已经决定信任它。
|
||||
// 将来要开放给别的插件,必须逐个评估(它有没有鉴权、会不会误触发)。
|
||||
var kernelControlAllowed = map[string]bool{
|
||||
"webui": true,
|
||||
}
|
||||
|
||||
// SetKernelControl 注入内核重启/关机通道(由 cmd/homed 装配时调用)。
|
||||
//
|
||||
// ★ 为何用 setter 而不是构造参数:internal/plugin 不能 import cmd/homed
|
||||
//
|
||||
// (那是反向依赖,且 cmd 包不可被 import)。用接口 + setter 打破方向。
|
||||
func (r *Registry) SetKernelControl(k sdk.KernelControlAPI) { r.kernelCtl = k }
|
||||
|
||||
// kernelControlFor 按插件名决定是否授予内核控制通道。
|
||||
//
|
||||
// 未在白名单里的插件拿到 nil —— SDK 侧 KernelControl() 返回 nil,
|
||||
// 调用方(插件自己)必须判 nil 并如实告知用户"当前环境不支持"。
|
||||
func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI {
|
||||
if r.kernelCtl == nil || !kernelControlAllowed[name] {
|
||||
return nil
|
||||
}
|
||||
return r.kernelCtl
|
||||
}
|
||||
|
||||
func (r *Registry) SetToolRegistrar(fn sdk.ToolRegistrar) { r.regTool = fn }
|
||||
func (r *Registry) SetStageRegistrar(fn sdk.StageRegistrar) { r.regStage = fn }
|
||||
func (r *Registry) SetAPIRegistrar(fn sdk.APIRegistrar) { r.regAPI = fn }
|
||||
func (r *Registry) SetToolCleaner(tc PluginToolCleaner) { r.toolCleaner = tc }
|
||||
func (r *Registry) SetStatusProvider(sp sdk.StatusAPI) { r.status = sp }
|
||||
func (r *Registry) SetSupervisor(sup sdk.SupervisorAPI) { r.sup = sup }
|
||||
func (r *Registry) SetTracker(trk *tracker.Tracker) { r.trk = trk }
|
||||
func (r *Registry) SetConfig(cfg *types.Config) { r.cfg = cfg }
|
||||
func (r *Registry) SetStageHost(sh sdk.ToolSource) { r.stageHost = sh }
|
||||
|
||||
// SetDeviceAuthQuery 注入"设备是否已授权"的查询(D4)。
|
||||
//
|
||||
@ -410,6 +446,7 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK {
|
||||
RegOutputUnreg: regOutputUnreg,
|
||||
RegInput: regInput,
|
||||
PluginMgr: r,
|
||||
KernelControl: r.kernelControlFor(name),
|
||||
|
||||
Status: r.status,
|
||||
Supervisor: r.sup,
|
||||
|
||||
Reference in New Issue
Block a user