Files
HomeAgent/internal/plugins/webui/kernel_control.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

103 lines
3.2 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 webui
// 内核重启 / 关机的 HTTP 端点。
//
// ## 为什么这两个端点必须在 WebUI 里
//
// 有些状态变更只能靠重启内核生效(换 LLM 源、换主密钥、装配期配置)。
// 没有这两个按钮时,用户只能自己 ssh 上去敲 systemctl —— 而 WebUI
// 是大多数人唯一的界面。
//
// ## 断线是**预期行为**,不是故障
//
// 两个动作都会让内核进程消失:
// · 重启:guard 用当前二进制重拉,断线几秒
// · 关机:systemd 不再拉起,断线到人工启动
//
// ⇒ 必须**先把响应写回浏览器**,再让内核退出。否则用户看到的是
// "点了一下,连接被重置",无法区分"正在重启"和"真出故障了"。
import (
"encoding/json"
"net/http"
"strings"
"time"
)
// handleKernelRestart 处理 POST /api/v1/kernel/restart
func (h *Handler) handleKernelRestart(w http.ResponseWriter, r *http.Request) {
h.kernelControl(w, r, "restart")
}
// handleKernelShutdown 处理 POST /api/v1/kernel/shutdown
func (h *Handler) handleKernelShutdown(w http.ResponseWriter, r *http.Request) {
h.kernelControl(w, r, "shutdown")
}
func (h *Handler) kernelControl(w http.ResponseWriter, r *http.Request, action string) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "只接受 POST")
return
}
kc := h.kernelCtl
if kc == nil {
// 明确说"不可用"而不是静默失败:用户点按钮没反应会以为界面坏了
writeJSONError(w, http.StatusServiceUnavailable,
"当前部署没有接入内核控制通道(该构建未启用,或插件未声明 kernel-control 能力)")
return
}
var body struct {
Reason string `json:"reason"`
}
// body 可为空(curl 直接 POST 不带内容)
_ = json.NewDecoder(r.Body).Decode(&body)
reason := strings.TrimSpace(body.Reason)
if reason == "" {
reason = "WebUI 操作"
}
var err error
if action == "restart" {
err = kc.RequestKernelRestart(reason)
} else {
err = kc.RequestKernelShutdown(reason)
}
if err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
// ★ 先把响应写回去(Flush),再让内核消失。
//
// 不 Flush 的话响应可能还在 net/http 的缓冲里,进程就退出了 ⇒
// 浏览器收到的是 connection reset,用户以为出错了。
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
msg := map[string]interface{}{
"status": "ok",
"action": action,
"reason": reason,
}
if action == "restart" {
msg["message"] = "内核正在重启(约数秒)。页面稍后会自动重连。"
msg["reconnect_hint_ms"] = 8000
} else {
msg["message"] = "内核正在关闭。需要人工执行 systemctl start 才能再启动。"
}
_ = json.NewEncoder(w).Encode(msg)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// 给浏览器一点时间收包,再让内核真退出
time.Sleep(300 * time.Millisecond)
}
// writeJSONError 是 handler 里统一的错误输出(保持与其它端点一致)。
func writeJSONError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}