From 40de84facc302b45b03462b650d3bcb61a32e1f6 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 18 Oct 2026 13:51:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(systemd,webui,security):=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9B=AE=E5=BD=95=E4=B8=80=E8=87=B4=E6=80=A7=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=20+=20=E5=86=85=E5=B5=8C=E4=B8=BB=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. 数据目录一致性(原为死代码) `internal/systemd/sync.go` 能编译但**从未接线** —— 没有任何地方调用它。 内核有**两处**数据目录来源且不等价: -data ← systemd 单元 → memory/graph.db、插件数据根 core.daemon.data_dir ← 数据库 → memory/text、documents、knowledge 不一致时数据**分裂**:图记忆在 A、文本/知识在 B,检索跨目录、备份会漏。 生产当前两者一致所以没暴露,但 WebUI 允许改 DB 里的值而**无任何提示**。 接线三处: · cmd/homed/main.go 启动时检查,不一致则打告警 + 内联修复命令 · GET /api/v1/system/data-dir 返回诊断(只在真不一致时给 sync_command) · 判据 6 条(InSync/Clean/SyncCommand 内联 sed/备份/daemon-reload/drop-in) ★ 只告警**不改**:静默改 systemd 单元比不提示更危险 —— 改错了服务 起不来,而用户不知道是谁改的。命令里已内联 sed 替换,人复制执行即可。 ## 2. 内嵌主密钥(方案 C) build.sh 支持 `HA_EMBEDDED_KEY_HEX`(64 位 hex)经 -ldflags 注入。 从**环境变量**读而不是写进文件:本仓有两个公开远端,64 位 hex 一旦 写进被跟踪的文件就永久留在 git 历史里。 未设时不注入、不报错:开发机与 CI 本来就不该带生产密钥。 设了但格式不对则**中断构建**(`return 1`)—— 静默产出一个没有内嵌钥的 二进制,会让"防丢失"这个目的悄悄失效。 实测(真机):磁盘上**没有** security.key 时,仅靠 embedded 密钥即可 Submit + 二次加载解密成功;日志确认 `密钥来源 embedded (构建期注入,指纹 0db8e9c5)`。 ## 判据第一版的两个错误(记下来) · 没给 UnitPath 就断言"必须含 sed -i" —— 实际 sed 行只在 UnitPath 非空时生成,判据误报。 · 断言 `/` 必须转义 —— 但 sed 分隔符是 `|` 不是 `/`。`/` 无需转义; 真正必须转义的是 `&`(sed 里表示整个匹配)与 `|`(分隔符)。 --- cmd/homed/main.go | 24 ++++ deploy/packaging/build.sh | 27 ++++- internal/plugins/webui/datadir_status.go | 82 +++++++++++++ internal/plugins/webui/handler.go | 2 + internal/systemd/sync_test.go | 142 +++++++++++++++++++++++ 5 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 internal/plugins/webui/datadir_status.go create mode 100644 internal/systemd/sync_test.go diff --git a/cmd/homed/main.go b/cmd/homed/main.go index ba4bd101..0bf5f386 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -15,6 +15,7 @@ import ( _ "github.com/JianFeeeee/HomeAgent/internal/plugins/kbtree" _ "github.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr" "github.com/JianFeeeee/HomeAgent/internal/security" + "github.com/JianFeeeee/HomeAgent/internal/systemd" // 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。 // 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。 @@ -204,6 +205,29 @@ func main() { cfgReg.SetLLMSnapshotFile(filepath.Join(opt.dataDir, "llm_snapshot.json")) ks.Cfg = cfgReg.ToConfig() + // ★ 数据目录一致性检查(2026-10-18 接线)。 + // + // 内核有**两处**数据目录来源,且不等价: + // -data ← systemd 单元,决定 ks.DataDir + // core.daemon.data_dir ← 数据库,决定 ks.Cfg.Daemon.DataDir + // + // 不一致时数据会**分裂**:图记忆(memory/graph.db)在 -data 那个目录, + // 而文本/知识/文档在 DB 那个目录 ⇒ 检索跨目录、备份会漏。 + // 生产当前两者一致,所以从没暴露;但 WebUI 允许改 core.daemon.data_dir + // 而**没有任何提示**,用户改完重启就会发现"数据丢了"。 + // + // 这里只告警 + 给出可直接粘贴的修复命令,**不改任何东西**: + // 静默改 systemd 单元比不提示更危险(改错了服务起不来,而用户不知道 + // 是谁改的)。修复由人执行 —— 命令里已内联 sed 替换。 + if cdir := ks.Cfg.Daemon.DataDir; cdir != "" && cdir != opt.dataDir { + st := systemd.State{RuntimeDataDir: opt.dataDir, ConfigDataDir: cdir} + log.Printf("[homed] ⚠️ 数据目录不一致(数据会分裂):") + log.Printf("[homed] -data = %s(图记忆、插件 Settings.DataDir)", opt.dataDir) + log.Printf("[homed] DB data_dir = %s(文本/知识/文档)", cdir) + log.Printf("[homed] 修复命令(勿手改单元,生产用 drop-in 覆盖 ExecStart):") + log.Printf("[homed] %s", st.SyncCommand(cdir)) + } + // 装配全部内核模块(缺依赖/环/重名 ⇒ 这里就报错,不必等运行期)。 // // ★ 插件调度已**归位到内核模块**(`internal/kernelmod/pluginhost`, diff --git a/deploy/packaging/build.sh b/deploy/packaging/build.sh index 51c0eaeb..41f5f3df 100644 --- a/deploy/packaging/build.sh +++ b/deploy/packaging/build.sh @@ -210,9 +210,34 @@ build_homed() { "$_graphgen" -out "$BUILD_DIR/depgraph.bin" echo " [graph] digest=$_graph_hash" + # ★ 内嵌主密钥(用户 2026-10-16 方案 C:字面内嵌,防丢失)。 + # + # 密钥从**环境变量**读,不落仓库:本仓有两个公开远端 + # (gitcode / github),把 64 位 hex 写进任何被跟踪的文件都会 + # 永久留在 git 历史里。 + # + # HA_EMBEDDED_KEY_HEX=<64 位 hex> deploy/packaging/build.sh + # + # 未设时**不注入**(行为与以前完全一致):不报错、不警告 —— + # 因为开发机构建与 CI 本来就不应该带生产密钥。但产物会因此 + # 没有内嵌钥,生产打包时**必须**显式给。 + local _embedkey="${HA_EMBEDDED_KEY_HEX:-}" + local _keyflag="" + if [ -n "$_embedkey" ]; then + case "$_embedkey" in + ????????????????????????????????????????????????????????????????) + # 64 字符:形状像 32 字节 hex + _keyflag=" -X github.com/JianFeeeee/HomeAgent/internal/security.embeddedKeyHex=$_embedkey" + echo " [security] 内嵌主密钥已注入(64 位 hex)" ;; + *) + echo " error: HA_EMBEDDED_KEY_HEX 必须是 64 位 hex(当前 ${#_embedkey} 字符)" >&2 + return 1 ;; + esac + fi + CGO_ENABLED=1 CC="$_cc" "$GO" build -buildvcs=false -trimpath -installsuffix dynlink \ ${_tagargs[@]+"${_tagargs[@]}"} \ - -ldflags "$LDFLAGS -X github.com/JianFeeeee/HomeAgent/internal/kernelboot.EmbeddedGraphHash=$_graph_hash" -o "$out" ./cmd/homed/ + -ldflags "$LDFLAGS -X github.com/JianFeeeee/HomeAgent/internal/kernelboot.EmbeddedGraphHash=$_graph_hash$_keyflag" -o "$out" ./cmd/homed/ echo " OK ($(file "$out" | sed 's/.*: //') | $(du -h "$out" | cut -f1))" } diff --git a/internal/plugins/webui/datadir_status.go b/internal/plugins/webui/datadir_status.go new file mode 100644 index 00000000..5ac537af --- /dev/null +++ b/internal/plugins/webui/datadir_status.go @@ -0,0 +1,82 @@ +package webui + +// 数据目录一致性端点。 +// +// ## 为什么需要它 +// +// 内核有**两处**数据目录来源,且不等价: +// +// -data ← systemd 单元,决定 ks.DataDir +// core.daemon.data_dir ← 数据库,决定 ks.Cfg.Daemon.DataDir +// +// 不一致时数据会**分裂**:图记忆(memory/graph.db)在 -data 那个目录, +// 而文本/知识/文档在 DB 那个目录 ⇒ 检索跨目录、备份会漏。 +// +// 生产当前两者一致,所以从没暴露;但 WebUI 允许改 core.daemon.data_dir +// 而**没有任何提示** —— 用户改完重启就会发现"数据丢了"。 +// +// ## 只读 + 给命令,绝不自动改 +// +// 本端点只返回诊断与**可直接粘贴执行的修复命令**(里面已内联 sed 替换)。 +// 不代用户改 systemd 单元:静默改单元比不提示更危险 —— 改错了服务起不来, +// 而用户不知道是谁改的。修复必须由人执行。 + +import ( + "net/http" + + "github.com/JianFeeeee/HomeAgent/internal/systemd" +) + +// dataDirs 取两个数据目录来源。 +// +// runtime ← SettingsAPI.DataDir()(= -data,systemd 单元给的那个) +// config ← ConfigAPI.Get().Daemon.DataDir(= DB 里 core.daemon.data_dir) +// +// 任一不可用就返回空串 —— 调用方据此判断"无法判定",而不是拿空串 +// 去比出"不一致"的假警报。 +func (h *Handler) dataDirs() (runtimeDir, configDir string) { + if h.settings != nil { + runtimeDir = h.settings.DataDir() + } + if h.config != nil { + if cfg := h.config.Get(); cfg != nil { + configDir = cfg.Daemon.DataDir + } + } + return runtimeDir, configDir +} + +// handleDataDirStatus 处理 GET /api/v1/system/data-dir +// +// 返回: +// +// runtime_dir 当前进程实际用的(-data) +// config_dir 数据库里 core.daemon.data_dir +// in_sync 两者是否一致 +// describe 人读的一句话说明 +// sync_command 不一致时的修复命令(内联 sed,可直接复制执行) +func (h *Handler) handleDataDirStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "只接受 GET") + return + } + runtimeDir, configDir := h.dataDirs() + st := systemd.State{ + RuntimeDataDir: runtimeDir, + ConfigDataDir: configDir, + } + out := map[string]interface{}{ + "runtime_dir": runtimeDir, + "config_dir": configDir, + "in_sync": st.InSync(), + "describe": st.Describe(), + "under_systemd": systemd.IsUnderSystemd(), + } + if !st.InSync() { + // 只在真正不一致时给命令 —— 一致时给命令会让人以为该做点什么 + out["sync_command"] = st.SyncCommand(configDir) + out["warning"] = "数据目录不一致:图记忆在 runtime_dir,文本/知识/文档在 config_dir。" + + "不修复则检索跨目录、备份会漏。请执行 sync_command(勿手改单元,生产用 drop-in 覆盖 ExecStart)。" + } + writeJSON(w, http.StatusOK, out) +} diff --git a/internal/plugins/webui/handler.go b/internal/plugins/webui/handler.go index e7af5f7c..7f04458e 100644 --- a/internal/plugins/webui/handler.go +++ b/internal/plugins/webui/handler.go @@ -486,6 +486,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // 危险一个量级 —— 关机之后只能 ssh 上去才能恢复。 mux.HandleFunc("/api/v1/kernel/restart", h.requireAPI(h.handleKernelRestart)) mux.HandleFunc("/api/v1/kernel/shutdown", h.requireAPI(h.handleKernelShutdown)) + // 数据目录一致性(两处来源不等价,不一致会数据分裂;见 datadir_status.go) + mux.HandleFunc("/api/v1/system/data-dir", h.requireAPI(h.handleDataDirStatus)) 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)) diff --git a/internal/systemd/sync_test.go b/internal/systemd/sync_test.go new file mode 100644 index 00000000..7572f044 --- /dev/null +++ b/internal/systemd/sync_test.go @@ -0,0 +1,142 @@ +package systemd + +// sync.go 的判据:数据目录一致性检测与修复命令生成。 +// +// ## 为什么需要(用户 2026-10-17 指出) +// +// 内核有**两处**数据目录来源,且不等价: +// +// -data ← systemd 单元,决定 ks.DataDir → memory/graph.db +// core.daemon.data_dir ← 数据库 → memory/text、documents、knowledge +// +// 不一致时**数据分裂**:图记忆在 A、文本/知识/文档在 B,检索跨目录、备份会漏。 +// +// 用户还明确要求:「指令应当直接包含替换部分而不是让用户手动修改」 +// ⇒ SyncCommand 必须是**可直接粘贴执行的 sed 原地替换**, +// 而不是"请把 -data 改成 xxx"。 +// +// 这条要求不是风格偏好:生产用 drop-in 覆盖 ExecStart, +// 手工改主单元**完全无效**,而用户不会知道这一点。 + +import ( + "regexp" + "strings" + "testing" +) + +// ★★★ 判据:一致/不一致的判定。 +func TestInSync(t *testing.T) { + cases := []struct { + runtime, config string + want bool + why string + }{ + {"/a/b", "/a/b", true, "完全相同"}, + {"/a/b/", "/a/b", true, "尾斜杠不算不一致(Clean 后相同)"}, + {"/a/b", "/a/c", false, "不同目录"}, + {"", "/a/b", true, "runtime 未知时不报警(宁可漏,不误报)"}, + {"/a/b", "", true, "config 未知时不报警"}, + } + for _, c := range cases { + st := State{RuntimeDataDir: c.runtime, ConfigDataDir: c.config} + if got := st.InSync(); got != c.want { + t.Errorf("InSync(%q, %q) = %v,应为 %v(%s)", + c.runtime, c.config, got, c.want, c.why) + } + } +} + +// ★★★ 判据:SyncCommand 必须内联替换,不能让用户手工编辑。 +func TestSyncCommandInlinesReplacement(t *testing.T) { + // 必须给 UnitPath:真实环境下 Check() 会解析出来。 + // 第一版没给,于是 sed 行不生成 —— 判据误报"没有内联替换"。 + st := State{ + RuntimeDataDir: "/old/dir", + ConfigDataDir: "/new/dir", + UnitPath: "/etc/systemd/system/homeagent.service", + } + cmd := st.SyncCommand("/new/dir") + if cmd == "" { + t.Fatal("不一致时应给出修复命令") + } + + // ① 必须含 sed 原地替换(内联目标目录),而不是"请编辑文件" + if !regexp.MustCompile(`sed -i`).MatchString(cmd) { + t.Fatalf("★ 修复命令没有内联 sed -i 替换:\n%s\n"+ + "用户明确要求「指令应当直接包含替换部分而不是让用户手动修改」", cmd) + } + if !strings.Contains(cmd, "/new/dir") { + t.Fatalf("★ 修复命令里没有出现目标目录 /new/dir:\n%s", cmd) + } + + // ② 必须备份(改坏了要能回滚) + if !strings.Contains(cmd, ".bak") { + t.Fatalf("★ 修复命令没有备份原文件:\n%s", cmd) + } + + // ③ 必须 daemon-reload(否则改了单元也不生效) + if !strings.Contains(cmd, "daemon-reload") { + t.Fatalf("★ 修复命令没有 systemctl daemon-reload:\n%s", cmd) + } + + // ④ 必须真的重启服务(否则新目录不生效) + if !strings.Contains(cmd, "restart") && !strings.Contains(cmd, "start") { + t.Fatalf("★ 修复命令没有重启服务:\n%s", cmd) + } +} + +// ★★★ 判据:drop-in 也必须改(生产用 drop-in 覆盖 ExecStart,改主单元无效)。 +func TestSyncCommandCoversDropIns(t *testing.T) { + st := State{ + RuntimeDataDir: "/old", + ConfigDataDir: "/new", + UnitPath: "/etc/systemd/system/homeagent.service", + DropIns: []string{"/etc/systemd/system/homeagent.service.d/override.conf"}, + } + cmd := st.SyncCommand("/new") + if !strings.Contains(cmd, "override.conf") { + t.Fatalf("★ 修复命令没有处理 drop-in —— 生产用 drop-in 覆盖 ExecStart,"+ + "只改主单元**完全无效**,而用户不会知道这一点:\n%s", cmd) + } +} + +// ★★★ 判据:目标目录为空时不给命令(避免生成一条把 -data 改成空串的命令)。 +func TestSyncCommandEmptyTargetIsNoop(t *testing.T) { + st := State{RuntimeDataDir: "/a", ConfigDataDir: ""} + if got := st.SyncCommand(""); got != "" { + t.Fatalf("目标目录为空时应返回空命令,实际:\n%s", got) + } +} + +// ★★★ 判据:sed 替换串里 sed 的**元字符**必须转义,否则生成坏命令。 +// +// 注意分隔符是 `|`(见 SyncCommand),所以 `/` **不需**转义。 +// 第一版判据断言 `/` 必须转义 —— 那是错的(分隔符是 | 而非 /)。 +func TestSedEscapeRepl(t *testing.T) { + // & 在 sed 替换段里表示"整个匹配" ⇒ 必须转义,否则目录里的 & 会被替换成匹配内容 + if got := sedEscapeRepl("/a&b"); !strings.Contains(got, `\&`) { + t.Errorf("& 未转义(sed 会把它当成整个匹配):%q", got) + } + // | 是分隔符本身 ⇒ 必须转义 + if got := sedEscapeRepl("/a|b"); !strings.Contains(got, `\|`) { + t.Errorf("分隔符 | 未转义:%q", got) + } + // 反斜杠要双写 + if got := sedEscapeRepl(`/a\b`); !strings.Contains(got, `\\`) { + t.Errorf("反斜杠未双写:%q", got) + } +} + +// ★★★ 判据:Describe 在不一致时给出两个具体路径,一致时为空。 +func TestDescribe(t *testing.T) { + ok := State{RuntimeDataDir: "/same", ConfigDataDir: "/same"} + if got := ok.Describe(); got != "" { + t.Errorf("一致时 Describe 应为空,实际:%s", got) + } + + bad := State{RuntimeDataDir: "/a", ConfigDataDir: "/b"} + d := bad.Describe() + if !strings.Contains(d, "/a") || !strings.Contains(d, "/b") { + t.Fatalf("Describe 必须给出两个具体路径:\n%s", d) + } +}