mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
feat(systemd,webui,security): 数据目录一致性接线 + 内嵌主密钥注入
## 1. 数据目录一致性(原为死代码) `internal/systemd/sync.go` 能编译但**从未接线** —— 没有任何地方调用它。 内核有**两处**数据目录来源且不等价: -data <dir> ← 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 里表示整个匹配)与 `|`(分隔符)。
This commit is contained in:
142
internal/systemd/sync_test.go
Normal file
142
internal/systemd/sync_test.go
Normal file
@ -0,0 +1,142 @@
|
||||
package systemd
|
||||
|
||||
// sync.go 的判据:数据目录一致性检测与修复命令生成。
|
||||
//
|
||||
// ## 为什么需要(用户 2026-10-17 指出)
|
||||
//
|
||||
// 内核有**两处**数据目录来源,且不等价:
|
||||
//
|
||||
// -data <dir> ← 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user