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

@ -140,6 +140,35 @@
>
EN
</button>
<!-- 内核控制:重启 / 关机。
放在导航底部而不是页面里,是因为它们作用于**整个服务**,
不属于任何一个标签页。 -->
<button
class="theme-btn"
id="kernel-restart-btn"
onclick="kernelRestart()"
title="重启内核(服务约断线数秒后自动重连)"
aria-label="重启内核"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
<path d="M21 3v6h-6" />
</svg>
</button>
<button
class="theme-btn"
id="kernel-shutdown-btn"
onclick="kernelShutdown()"
title="关闭内核(需人工 systemctl start 才能再启动)"
aria-label="关闭内核"
>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 3v9" />
<path d="M6.3 6.3a8 8 0 1 0 11.4 0" />
</svg>
</button>
<button
class="btn btn-ghost btn-sm"
onclick="logout()"

View File

@ -118,10 +118,12 @@ type Handler struct {
knowledge sdk.KnowledgeAPI
tracker sdk.TrackerAPI
settings sdk.SettingsAPI
pluginMgr sdk.PluginManager
status sdk.StatusAPI
term sdk.TerminalAPI
llm sdk.LLMAPI
// kernelCtl 是内核重启/关机通道。nil = 该部署没接(端点会明确报不可用)。
kernelCtl sdk.KernelControlAPI
pluginMgr sdk.PluginManager
status sdk.StatusAPI
term sdk.TerminalAPI
llm sdk.LLMAPI
// hostPort 是 webui 实际监听的 ":port"(用于推导服务入口链接)。
hostPort string
@ -183,6 +185,8 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
st sdk.StatusAPI
term sdk.TerminalAPI
llm sdk.LLMAPI
// kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。
kc sdk.KernelControlAPI
)
if s != nil {
sup, mem, idx = s.Supervisor(), s.Memory(), s.Indexer()
@ -191,6 +195,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
se, pm = s.Settings(), s.PluginMgr()
st, llm = s.Status(), s.LLM()
term = s.Terminal()
kc = s.KernelControl()
}
h := &Handler{
sdk: s,
@ -204,6 +209,7 @@ func NewHandler(s *sdk.PluginSDK) *Handler {
knowledge: ks,
tracker: tr,
settings: se,
kernelCtl: kc,
pluginMgr: pm,
status: st,
term: term,
@ -474,6 +480,12 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/login", h.handleLogin)
mux.HandleFunc("/api/v1/logout", h.handleLogout)
mux.HandleFunc("/api/v1/status", h.requireAPI(h.handleStatus))
// 内核控制:重启 / 关机。
//
// 都要鉴权:能停服务的人必须是已认证的管理者。这两个端点比"读状态"
// 危险一个量级 —— 关机之后只能 ssh 上去才能恢复。
mux.HandleFunc("/api/v1/kernel/restart", h.requireAPI(h.handleKernelRestart))
mux.HandleFunc("/api/v1/kernel/shutdown", h.requireAPI(h.handleKernelShutdown))
mux.HandleFunc("/api/v1/agents", h.requireAPI(h.handleAgents))
mux.HandleFunc("/api/v1/agents/", h.requireAPI(h.handleAgentByID))
mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory))

View File

@ -0,0 +1,102 @@
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})
}