Files
HomeAgent/internal/plugin/kernel_control_gate_test.go
JianFeeeee 198811253a 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,无法区分"正在重启"和"真出故障了"。
2026-10-17 21:18:07 +08:00

90 lines
2.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package plugin
// 内核控制通道的**授权边界**判据(2026-10-17)。
//
// ## 为什么这条判据必须存在
//
// 重启/关机通道能让**整个服务停下来**,而且关机后不会自己回来
// (guard 显式 systemctl stop 压制了 Restart=always)。
//
// 我做这个功能时第一版是"有就发":`KernelControl: r.kernelCtl` 无条件
// 传给**所有**内置插件。那意味着任何一个内置插件(包括将来新加的)
// 都能一键关掉整个服务 —— 攻击面 = 全部内置插件。
//
// ⇒ 必须按插件名白名单授予,且这条边界要有判据钉住。
import (
"context"
"testing"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
// fakeKernelCtl 记录收到的请求(判据只关心"能不能拿到/调用")。
type fakeKernelCtl struct {
restartCalls int
shutdownCalls int
}
func (f *fakeKernelCtl) RequestKernelRestart(reason string) error {
f.restartCalls++
return nil
}
func (f *fakeKernelCtl) RequestKernelShutdown(reason string) error {
f.shutdownCalls++
return nil
}
// ★★★ 判据:只有白名单里的插件能拿到内核控制通道。
func TestKernelControlOnlyForAllowlistedPlugins(t *testing.T) {
r := NewRegistry()
fake := &fakeKernelCtl{}
r.SetKernelControl(fake)
if got := r.kernelControlFor("webui"); got == nil {
t.Fatal("★ webui 应拿到内核控制通道(它是有鉴权的管理界面)")
}
// 典型的内置插件都不该拿到
for _, name := range []string{"cmd", "files", "memo", "healthcheck", "pluginmgr", "timer"} {
if got := r.kernelControlFor(name); got != nil {
t.Fatalf("★ 内置插件 %q 拿到了内核控制通道 —— "+
"任何插件都能关机整个服务,攻击面失控", name)
}
}
// 未知/外部插件名
if got := r.kernelControlFor("some_third_party"); got != nil {
t.Fatal("★ 未登记插件拿到了内核控制通道")
}
}
// ★★★ 判据:内核未装配控制通道时,白名单插件也只拿到 nil(不 panic)。
func TestKernelControlNilWhenNotWired(t *testing.T) {
r := NewRegistry()
// 故意不 SetKernelControl(模拟非 systemd 环境/未启用)
if got := r.kernelControlFor("webui"); got != nil {
t.Fatal("★ 通道未装配时不该返回非 nil")
}
}
// ★★★ 判据:SDK 侧 KernelControl() 未注入时返回 nil,且调用方判 nil 不 panic。
func TestSDKKernelControlIsNilSafe(t *testing.T) {
s := sdk.New("probe", sdk.SDKConfig{})
if kc := s.KernelControl(); kc != nil {
t.Fatal("未注入时 KernelControl() 应为 nil")
}
// 注入后能拿到
fake := &fakeKernelCtl{}
s2 := sdk.New("probe2", sdk.SDKConfig{KernelControl: fake})
if s2.KernelControl() == nil {
t.Fatal("注入后应能拿到")
}
if err := s2.KernelControl().RequestKernelRestart("t"); err != nil {
t.Fatalf("调用失败: %v", err)
}
if fake.restartCalls != 1 {
t.Fatalf("fake 未收到请求(%d)", fake.restartCalls)
}
}
var _ = context.Background