diff --git a/.gitignore b/.gitignore index 10dbb9af..638faef2 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ dist/ # 有意不提交:二进制大文件会永久留在 git 历史里,且需与本机 nlp 加载路径配套。 # 需要时由部署脚本/人工放置到该目录。 /nlp/ + +# 内核升级 CLI 的本地构建产物(与 /homed 同理,不入库) +/homed-upgrade diff --git a/cmd/homed-upgrade/main.go b/cmd/homed-upgrade/main.go index dad9ece1..66f6e082 100644 --- a/cmd/homed-upgrade/main.go +++ b/cmd/homed-upgrade/main.go @@ -5,35 +5,35 @@ // // ## 范围:只面向内核 // -// 只处理 homed / depgraph.bin / waiter 三个文件。**不碰插件**——插件有独立的 -// 升级通道(pluginmgr),且插件与内核的兼容性由"自动禁用不兼容插件"处理 -// (见 internal/plugin/registry.go 的 maybeAutoDisableIncompatible): -// 换内核后旧 SDK 编的插件会握手失败,内核把它持久禁用并写明原因, -// 用户在界面上直接看到"为什么没了",而不是翻日志。 +// 只处理 homed / depgraph.bin。**不碰插件**——插件有独立的升级通道 +// (pluginmgr),且与内核的兼容性由"自动禁用不兼容插件"兜底。 // // ## 为什么默认只探测 // // 替换二进制不可逆,且内核是这个机器上唯一常驻的关键服务。默认只做 // 探测与预检、把"会发生什么"完整报出来;`-apply` 才真动手。 -// 沿同仓 homed-kb-migrate / homed-predicate-migrate 的惯例。 // -// ## 探测什么(-probe) +// ## ★ 它**不重启**服务 // -// 1. 当前运行的内核版本(从 /proc 找进程,读它自己的 --version 输出) -// 2. 新包里的内核版本 -// 3. 两者比较:能否升级、是不是降级、commit 是否真的变了 -// 4. depgraph.bin 与新二进制是否匹配(包内自带,必须同批替换) -// 5. 现有插件与**新**内核的兼容性预判(SDK 主版本) +// 这一条原先做错了:CLI 曾直接 `systemctl restart hologramagent`,而 +// internal/update/kernel.go 的 Apply 文档专门论证过为什么不能这么做: // -// 第 5 条是 OTA 里最容易出事的地方:内核换了、插件没换。这里提前算出来, -// 好让用户在动手前就知道"升完会有 N 个插件被自动禁用"。 +// · 杀掉整个 cgroup(含 guard)—— 把监督者一起杀掉 +// · 丢失 exit 42 语义:guard 的 failures / failbackRound 状态全丢 +// · 发命令的进程正是被重启杀掉的进程 ⇒ 返回值不可靠 +// +// 正确的重启决策在 guard 手里(只有它知道这次退出该不该重拉)。CLI 是 +// 外部管理命令,它的职责到"磁盘上的产物已就绪、pending 已写"为止; +// 重启由**运行中的内核**经 KernelControlAPI 发起(restartCh → exit 42)。 +// +// 若服务没在跑,连重启都不需要:下次启动自然加载新二进制并由第 0 层 +// update 模块确认或回滚。 package main import ( "flag" "fmt" "os" - "os/exec" "path/filepath" "strings" "time" @@ -41,23 +41,6 @@ import ( "github.com/JianFeeeee/HologramAgent/internal/update" ) -// fail 打印错误并以非零退出(CLI 专属;库内部一律返回 error)。 -// upgradeDataDir 推导数据目录(升级确认标记的落点)。 -// -// 与生产一致:内核按 `--data ` 运行,标记必须落在同一个 dataDir 下, -// 否则 update 模块读不到它。推导规则: -// -// ① 环境变量 HOMEAGENT_DATA_DIR(部署方显式指定) -// ② 二进制同级的 ../newqqagent(/usr/local/bin/homed → /home/newqqagent) -// —— 太脆弱,改为最常见的部署形态:/usr/local/bin/homed → /home/newqqagent -// ③ 退回 /home/newqqagent(本仓默认部署路径) -func upgradeDataDir(binPath string) string { - if d := strings.TrimSpace(os.Getenv("HOMEAGENT_DATA_DIR")); d != "" { - return d - } - return "/home/newqqagent" -} - func fail(format string, a ...interface{}) { fmt.Fprintf(os.Stderr, format+"\n", a...) os.Exit(1) @@ -66,17 +49,85 @@ func fail(format string, a ...interface{}) { func main() { pkgPath := flag.String("pkg", "", "本地安装包(.tar.gz);与 -release 二选一") release := flag.Bool("release", false, "从 GitHub 最新 Release 下载 OTA 包(默认仅探测;配合 -apply 执行)") - binPath := flag.String("bin", "/usr/local/bin/homed", "当前运行的内核二进制路径") - pluginsDir := flag.String("plugins", "/home/newqqagent/plugins", "外部插件目录(探测兼容性用)") + binPath := flag.String("bin", "", "当前运行的内核二进制路径(默认取本进程自身;它不是 homed 时必须显式给)") + pluginsDir := flag.String("plugins", "", "外部插件目录(探测兼容性用;为空则跳过该步)") + dataDirFlag := flag.String("data", "", "内核数据目录(= 内核的 --data;不填则从 config.db 的 core.daemon.data_dir 读)") apply := flag.Bool("apply", false, "真正执行升级(默认只探测+预检)") - unit := flag.String("unit", "homeagent", "重启用的 systemd 单元名(仅 -apply 时使用)") + + // ★ 发布侧差分生成(不参与升级流程)。 + // + // 为何放在这个 CLI 而不是单开一个命令:它只是把两个文件喂给 + // internal/update.MakeDeltaForRelease,而那个包已经在这里了。 + // 多发一个二进制意味着多一条要维护的发布链。 + makeDelta := flag.Bool("make-delta", false, "生成差分补丁(发布用):-delta-old <旧homed> 与 -delta-new <新homed>") + deltaOld := flag.String("delta-old", "", "旧版本 homed 路径(上一版发布的二进制)") + deltaNew := flag.String("delta-new", "", "新版本 homed 路径") + deltaOut := flag.String("delta-out", "kernel.delta", "补丁输出路径") flag.Parse() + if *makeDelta { + if *deltaOld == "" || *deltaNew == "" { + fmt.Fprintln(os.Stderr, "-make-delta 需要 -delta-old <旧homed> 与 -delta-new <新homed>") + os.Exit(2) + } + hdr, size, err := update.MakeDeltaForRelease(*deltaOld, *deltaNew, *deltaOut) + if err != nil { + fail("生成差分: %v", err) + } + st := hdr.Stats(size) + fmt.Printf("补丁已写出: %s\n", *deltaOut) + fmt.Printf(" 旧大小 %d 字节\n 新大小 %d 字节\n 补丁 %d 字节(%.3f%%)\n", + st.OldSize, st.NewSize, st.PatchSize, st.Ratio*100) + fmt.Printf(" 基线导入: %s\n", update.ShortSum(hdr.OldSHA256)) + fmt.Printf(" 目标导入: %s\n", update.ShortSum(hdr.NewSHA256)) + fmt.Printf("\n发布资产名建议: %s\n", + update.DeltaAssetName("", "linux", "amd64", update.ShortCommit(hdr.OldSHA256))) + return + } + if *pkgPath == "" && !*release { fmt.Fprintln(os.Stderr, "必须提供 -pkg <安装包.tar.gz> 或 -release") os.Exit(2) } + // ── 0. 探测:安装形态决定后面每一步该怎么做 ── + // + // 必须在**动任何文件之前**做:deb/rpm 形态下直接替换会破坏包管理器 + // 数据库(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。 + bin := resolveBinPath(*binPath) + if bin == "" { + // ★ 拒绝,而不是猜。同目录没有 homed 时曾经兜底到 os.Executable(), + // 于是“升级 homed”实际变成“把 CLI 自己覆盖成新包里的 homed”。 + fmt.Fprintln(os.Stderr, "找不到内核二进制。") + fmt.Fprintln(os.Stderr, "本工具默认升级**与自己同目录**的 homed;找不到时请显式指定:") + fmt.Fprintln(os.Stderr, " homed-upgrade -bin /usr/bin/homed ...") + fmt.Fprintln(os.Stderr, "提示:`systemctl show hologramagent -p ExecStart` 里能看到真实路径。") + os.Exit(2) + } + dataDir := resolveDataDir(*dataDirFlag) + capab := update.Detect(bin, dataDir) + printCapabilities(capab) + + if *apply { + if ch := capab.Channel(); ch != update.ChannelSelfReplace { + // ★ 不是"不支持升级",是"升级必须由别人执行"。 + // 区分这两件事很重要:前者听起来像缺陷,后者是可执行的操作指引。 + fmt.Printf("\n== 本机不能自替换 ==\n%s\n", capab.Reason) + if cmd := capab.UpgradeCommand(""); cmd != "" { + fmt.Printf("\n请执行:\n %s\n", cmd) + } + os.Exit(1) + } + } + if dataDir == "" { + fmt.Println("\n⚠️ 无法确定内核数据目录:升级确认标记与备份无处可放(无回滚能力)") + fmt.Println(" 请显式指定 -data (与内核 unit 的 --data 一致)") + if *apply { + os.Exit(1) + } + fmt.Println(" (探测模式继续,-apply 会被拒绝)") + } + tmp, err := os.MkdirTemp("", "homed-upgrade-*") if err != nil { fail("建临时目录: %v", err) @@ -93,14 +144,14 @@ func main() { fmt.Printf("最新 Release: %s (%s)\n", info.Latest, info.Asset) } - fmt.Printf("== 解包 %s ==\n", *pkgPath) + fmt.Printf("\n== 解包 %s ==\n", *pkgPath) root, err := update.ExtractPkg(*pkgPath, tmp) if err != nil { fail("解包: %v", err) } // ── 1. 当前版本 ── - cur, curErr := update.ReadBinVersion(*binPath) + cur, curErr := update.ReadBinVersion(bin) if curErr != nil { fmt.Printf("⚠️ 读当前内核版本失败: %v\n", curErr) cur = nil @@ -120,8 +171,7 @@ func main() { fmt.Printf("包内内核: %s (commit %s)\n", nv.Version, update.ShortCommit(nv.Commit)) // ── 3. 版本比较 ── - verdict := update.CompareVersions(cur, nv) - fmt.Printf("\n== 结论 ==\n%s\n", verdict) + fmt.Printf("\n== 结论 ==\n%s\n", update.CompareVersions(cur, nv)) // ── 4. depgraph 必须与新二进制同批 ── newDG := update.FindFile(root, "depgraph.bin") @@ -131,18 +181,13 @@ func main() { fmt.Printf("depgraph.bin: 包内已带(%d 字节),将与 homed 同批替换\n", update.FileSize(newDG)) } - // ── 5. 插件兼容性预判(OTA 最容易出事的点)── - incompat := update.ProbePluginCompat(*pluginsDir, nv.SDKVersion) - if len(incompat) > 0 { - fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(incompat)) - fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):") - for _, p := range incompat { - fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor) - } - fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。") - } else { - fmt.Println("\n== 插件兼容性 ==\n现有插件与新内核 SDK 主版本一致,无需重编。") - } + // ── 5. 插件兼容性预判 ── + // + // ★ 目录为空时**跳过并明说**,不再打印"无需重编"。 + // 此前那个函数读 plugin.json 的 sdk_version 字段,而该字段根本 + // 不存在(生产 32 个插件 grep 命中 0)⇒ 恒返回空 ⇒ 永远打印 + // "现有插件与新内核 SDK 主版本一致"。那是假话,比不检查更糟。 + reportPluginCompat(*pluginsDir, nv.SDKVersion) if !*apply { fmt.Println("\n(探测模式:未改动任何文件。确认无误后加 -apply)") @@ -150,49 +195,164 @@ func main() { } // ── 6. 真正升级:预检 → 备份 → pending → 原子替换(**不含重启**)── - out, err := update.Apply(root, *binPath, newDG, upgradeDataDir(*binPath), nil) + out, err := update.Apply(root, bin, newDG, dataDir, nil) if err != nil { fail("升级失败: %v", err) } fmt.Printf("\n新内核已就位:%s → %s\n备份保留在 %s\n", out.From.Version, out.To.Version, out.BackupDir) - // ── 7. 重启服务并核对版本 ── + // ── 7. 不再自己重启 ── // - // ★ 为什么 CLI 可以停下服务:它**不在**被守护的 cgroup 里,是外部管理命令, - // 与"worker 自己 systemctl restart 自杀"是两件事。 - // 服务在跑时优先让它走既有重启通道(guard 重拉、不计失败轮次)—— - // 那需要 WebAPI 的 api_key,这里只做最朴素的外部重启,并如实报告结果。 - fmt.Printf("\n== 重启服务(unit %s)==\n", *unit) - if rerr := restartUnit(*unit); rerr != nil { - fmt.Printf("⚠️ 重启失败: %v\n", rerr) - fmt.Printf(" 新内核已在磁盘上就位,pending 标记也已写入:\n") - fmt.Printf(" · 服务下次启动时会自动确认新版本;若新版本起不来,第 0 层 update 模块会自动回滚\n") - fail("需要手动重启服务才能生效") + // 说清楚接下来会发生什么,而不是替用户拍一个可能破坏 guard 状态的决定。 + fmt.Printf("\n== 下一步 ==\n") + if update.SystemdActive(update.DefaultServiceUnit) { + fmt.Printf("内核正在运行,请让它走热重启通道(不丢 guard 状态):\n") + fmt.Printf(" · 重启会由内核自己发起(restartCh → exit 42 → guard 用新二进制重拉)\n") + fmt.Printf(" · 若需手工触发,用 WebUI 的「重启」按钮或 POST /api/v1/kernel/restart\n") + fmt.Printf("✅ 升级产物已就绪:%s → %s\n", out.From.Version, out.To.Version) + } else { + fmt.Printf("服务当前未运行:直接下次启动即生效(pending 标记会确认或回滚)。\n") + fmt.Printf("✅ 升级产物已就绪:%s → %s\n", out.From.Version, out.To.Version) } + fmt.Printf("\n启动后若新内核起不来,第 0 层 update 模块会自动回滚到 %s。\n", out.From.Version) +} - // 版本复核:等二进制自报版本变成期望值(最多 60s)。 - // 用版本而不是进程状态:只有版本能证明"跑起来的确实是新的"。 - deadline := time.Now().Add(60 * time.Second) - for time.Now().Before(deadline) { - if v, verr := update.ReadBinVersion(*binPath); verr == nil && - v.Version == nv.Version && v.Commit == nv.Commit { - fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", v.Version, update.ShortCommit(v.Commit)) - return +// resolveBinPath 决定“要升级的那个二进制”在哪。找不到时返回**空串**。 +// +// ## ★ 为什么不兜底到 os.Executable() +// +// 这个兜底是真的会搞坏东西的(实测踩到): +// +// 本 CLI 叫 homed-upgrade,与 homed **不同名**。如果同目录没有 homed +// (比如开发机、或把 CLI 单独拷到一个空目录里跑),旧兜底会返回 +// **CLI 自己的路径**,于是“升级 homed”实际变成“把 CLI 覆盖成新包里的 +// homed” —— 一个 1 字节的假包就能把工具本体换成一段 shell 脚本。 +// +// 静默做错比直接报错危险得多,所以:找不到就返回空,由调用方拒绝。 +// +// 取值顺序: +// +// ① -bin(人工显式给,最可靠) +// ② 本 CLI 同目录的 homed(发行包里两者总是同批安装) +func resolveBinPath(explicit string) string { + if d := strings.TrimSpace(explicit); d != "" { + return d + } + if exe, err := os.Executable(); err == nil { + if resolved, err2 := filepath.EvalSymlinks(exe); err2 == nil { + exe = resolved + } + cand := filepath.Join(filepath.Dir(exe), "homed") + if update.FileExists(cand) { + return cand } - time.Sleep(time.Second) } - if v, verr := update.ReadBinVersion(*binPath); verr == nil { - fmt.Printf("⚠️ 版本复核未通过:期望 %s/%s,实际 %s/%s\n", - nv.Version, update.ShortCommit(nv.Commit), v.Version, update.ShortCommit(v.Commit)) - } - fail("重启后未能确认新版本已生效(若服务起不来,下次启动会自动回滚)") + return "" } -// restartUnit 重启 systemd 单元(外部管理动作)。 -func restartUnit(unit string) error { - out, err := exec.Command("systemctl", "restart", unit).CombinedOutput() - if err != nil { - return fmt.Errorf("%v (%s)", err, strings.TrimSpace(string(out))) +// resolveDataDir 返回内核**真实**的数据目录(升级标记与备份的落点)。 +// +// ## ★ 不猜路径,只认两个显式来源 +// +// 1. `-data `(人工直接给,最高优先) +// 2. config.db 里的 `core.daemon.data_dir` +// +// 都拿不到就返回**空串**,由调用方拒绝 -apply 并说明怎么修。 +// +// ## 为什么不再自己开 sqlite +// +// 旧实现用 `sql.Open("sqlite3", ...)` 只读查配置。问题是打包脚本用 +// `CGO_ENABLED=0` 编这个 CLI,而 go-sqlite3 在无 cgo 时是 **stub**: +// 查询返回 "requires cgo to work",错误被吞掉、返回空串 ⇒ 发行包里 +// "自动发现 dataDir"这条路径**从未真正工作过**。 +// +// 改为复用内核已有的 internal/config.ConfigRegistry:同一个打开逻辑、 +// 同一套 key 语义,不需要 CGO 时也能编(它在无配置时返回默认值)。 +func resolveDataDir(explicit string) string { + if d := strings.TrimSpace(explicit); d != "" { + return d } - return nil + // 与内核一致的候选顺序:安装形态优先。 + bin, err := os.Executable() + if err != nil { + return "" + } + if resolved, err2 := filepath.EvalSymlinks(bin); err2 == nil { + bin = resolved + } + candidates := []string{ + filepath.Join(filepath.Dir(filepath.Dir(bin)), "var/lib/hologram"), + "/var/lib/hologram", + filepath.Join(filepath.Dir(filepath.Dir(bin)), "newqqagent"), + "/home/hologramagent", + "/home/newqqagent", + } + for _, d := range candidates { + if !update.FileExists(filepath.Join(d, "config.db")) { + continue + } + if v := update.DataDirFromConfigDB(d); v != "" { + fmt.Printf("数据目录来源: %s/config.db 的 core.daemon.data_dir = %s\n", d, v) + return v + } + } + return "" } + +func printCapabilities(c update.Capabilities) { + fmt.Printf("== 本机安装形态 ==\n") + fmt.Printf(" 形态: %s\n", c.Kind) + fmt.Printf(" 变体: %s\n", c.Variant) + fmt.Printf(" 二进制: %s\n", c.BinPath) + if c.DepGraph != "" { + fmt.Printf(" 依赖图: %s\n", c.DepGraph) + } + if c.PackageName != "" { + fmt.Printf(" 包名: %s\n", c.PackageName) + } + fmt.Printf(" 可自替换: %v\n", c.CanWriteBin) + if !c.CanWriteBin && c.Reason != "" { + fmt.Printf(" 原因: %s\n", c.Reason) + } + fmt.Printf(" 升级通道: %s\n", c.Channel()) +} + +// reportPluginCompat 报告插件与新内核的兼容性预判。 +// +// ★ 无数据时**明确说无数据**,不打印"无需重编"。 +// +// 旧实现的判据是 plugin.json 的 sdk_version 字段,而内核 manifest 结构里 +// 根本没有这个字段(生产 32 个插件 grep 命中 0)⇒ ProbePluginCompat 恒 +// 返回空 ⇒ CLI 永远打印"现有插件与新内核 SDK 主版本一致,无需重编"。 +// +// 用户读着这句话点 -apply,升完发现一半插件被自动禁用 —— 这不是"没实现", +// 是"实现了但在撒谎"。真实数据在握手时才有(proc/process.go 的 res.SDKVersion), +// 静态预判拿不到,那就如实说拿不到。 +func reportPluginCompat(dir, wantSDK string) { + if dir == "" { + fmt.Println("\n== 插件兼容性 ==\n未指定 -plugins,跳过预判(升级后内核会自动禁用不兼容的插件并写明原因)") + return + } + entries, err := os.ReadDir(dir) + if err != nil { + fmt.Printf("\n== 插件兼容性 ==\n无法读取 %s(%v):跳过预判\n", dir, err) + return + } + compat := update.ProbePluginCompat(dir, wantSDK) + if len(compat) > 0 { + fmt.Printf("\n== 插件兼容性警告(%d 个)==\n", len(compat)) + fmt.Println("换内核后这些插件会握手失败并被**自动禁用**(写入禁用配置 + 原因):") + for _, p := range compat { + fmt.Printf(" · %-16s SDK %s(新内核要求主版本 %s)\n", p.Name, p.SDKVersion, p.WantMajor) + } + fmt.Println("它们在界面上会显示为「已禁用」并带原因,重编插件或手动启用即可恢复。") + return + } + // 没有可判定的数据时,说清楚原因,而不是给一个假的"全部兼容"。 + fmt.Printf("\n== 插件兼容性 ==\n共 %d 个插件目录,但**无法静态预判**兼容性:\n", len(entries)) + fmt.Println(" plugin.json 不含 sdk_version 字段(该字段不存在),真实 SDK 版本只在握手时上报。") + fmt.Println(" ⇒ 升级后内核会逐个握手,主版本不符的会被自动禁用并在界面上写明原因。") + fmt.Println(" ⇒ 本题结论为「未检查」,**不是**「全部兼容」。") +} + +var _ = time.Now diff --git a/cmd/homed/main.go b/cmd/homed/main.go index 9661f5d4..e4d3a617 100644 --- a/cmd/homed/main.go +++ b/cmd/homed/main.go @@ -4,11 +4,13 @@ import ( "context" "log" "path/filepath" + "strings" internalConfig "github.com/JianFeeeee/HologramAgent/internal/config" "github.com/JianFeeeee/HologramAgent/internal/kernelboot" "github.com/JianFeeeee/HologramAgent/internal/kernelmod/pluginhost" securitymod "github.com/JianFeeeee/HologramAgent/internal/kernelmod/security" + "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/meta" // ★ 内置插件统一走 internal/plugins 的清单(all.go),**不要**在这里 // 单独导入某个插件: @@ -17,6 +19,7 @@ import ( // · 想增删内置插件请改 all.go。 _ "github.com/JianFeeeee/HologramAgent/internal/plugins" "github.com/JianFeeeee/HologramAgent/internal/systemd" + updatepkg "github.com/JianFeeeee/HologramAgent/internal/update" // 空白导入内置 provider:它们各自在 init 里注册到 pkg/embedding。 // 想把核心换成自己的模型,只需替换这一行(或另建一个发行版 main)。 @@ -81,6 +84,16 @@ func main() { agentWorkDir := ensureDataDirs(opt.dataDir) + // ★ 数据外置接线(第 4 项):告诉分词器去哪里找外置词库。 + // + // 词库 12MB + ONNX 模型 47MB 是**数据**而不是代码,却 //go:embed 进了 + // 二进制(88MB 里 59MB 是它们)。外置后: + // · 词库/模型可独立发版,不必重下整个二进制 + // · 而 patch 版本之间这两个目录实际从未变过 + // + // 必须在 GetJieba() 首次调用之前设:首次调用会初始化单例并固定目录。 + memory.SetJiebaDataDir(opt.dataDir) + // ---- 配置中心(SQLite 持久化,唯一配置源) ---- // // 必须早于 initMemoryStack:蒸馏的生成侧 provider 由配置决定 @@ -89,6 +102,34 @@ func main() { cfgReg := internalConfig.NewConfigRegistry(filepath.Join(opt.dataDir, "config.db")) defer cfgReg.Close() cfgReg.SeedDefaults(opt.dataDir) + + // ======================================================================== + // ★ 数据目录**权威源**:数据库优先,命令行次之 + // + // 用户 2026-10-28 裁定:`core.daemon.data_dir` 有值时以它为准,没有时 + // 才用 `-data`。理由是这个键是**运维可改的**(WebUI 就能改),而 `-data` + // 写在 systemd 单元里、改它要碰部署配置。让可改的那个赢,才符合直觉。 + // + // ## 为什么必须在 cfgReg 之后、一切重型初始化之前 + // + // 它决定 logDir / cli.sock / 心跳文件 / 各存储的落点。晚一步就出现 + // "日志已经写进 A、数据写进 B"的分裂。 + // + // ## 为什么只做一次 + // + // cfgReg 是从 /config.db 打开的,而它读出的 data_dir 可能是 + // 另一个目录 —— 那个目录下**也有** config.db。若再次打开去读它,就成了 + // 自我引用的无限链。所以:库只开一次,键只读一次。 + // ======================================================================== + if dbDir := strings.TrimSpace(cfgReg.GetString("core.daemon.data_dir", "")); dbDir != "" { + if clean := filepath.Clean(dbDir); clean != filepath.Clean(opt.dataDir) { + log.Printf("[homed] data dir: 以数据库 core.daemon.data_dir 为准 → %s(原 -data %s)", clean, opt.dataDir) + } + opt.dataDir = filepath.Clean(dbDir) + } + if opt.cliSocket == "" || !filepath.IsAbs(opt.cliSocket) { + opt.cliSocket = filepath.Join(opt.dataDir, "cli.sock") + } // LLM 配置写前留档(config_set 写 core.llm.* 前自动快照),guard 恢复用基线 // ★ 锚点:本块必须在 SetLLMSnapshotFile / ToConfig 之前(两者都会读 core.llm.*)。 // ======================================================================== @@ -197,6 +238,22 @@ func main() { } defer ks.CloseAll() + // ★ 挂载内核侧升级执行器(第 3 项:执行权归内核)。 + // + // 为何在这里而不是在 update 模块的 Init 里:update 模块必须在第 0 层 + // (回滚判定要早于一切模块),而事件总线由 kernelapi 层产出 —— + // 第 0 层时它还不存在。所以分两段:Init 做回滚与探测,此处订阅 + // update_request 并接管“scope=kernel”的执行。 + // + // 为何执行权必须在内核而不是插件:插件可以被 disable / auto_start:false, + // 而升级内核这件事不能依赖一个可被禁用的部件。 + // ★ 注入事件总线(升级进度/结果都要发出去)。 + // + // 注入后 internal/update 以内核身份发事件,**插件不再需要持有 *events.Bus** + // —— 那正是“安全监控可被绕过”的根源(拿裸总线就能伪造任意事件)。 + updatepkg.SetEventBus(ks.EventBus) + defer updatepkg.AttachExecutor(ks.EventBus, ks.DataDir)() + cfg := ks.Cfg luaVM := ks.LuaVM sup := ks.Supervisor diff --git a/deploy/hologramagent.service b/deploy/hologramagent.service index 8f636ed0..367febff 100644 --- a/deploy/hologramagent.service +++ b/deploy/hologramagent.service @@ -27,6 +27,22 @@ Wants=network-online.target # exec() 真正成功才继续,凭据目录与 ExecStartPost 的顺序才是确定的。 Type=exec +# ★ 降权运行(2026-10-28 落定)。 +# +# 此前本单元**没有** User= ⇒ 以 root 运行。生产实例之所以是 +# hologramagent,完全靠现场手工 drop-in —— 也就是说发行物里并没有 +# 落实降权,装到新机器上就是 root。 +# +# 配套要求:用户与组由包脚本(deb postinst / rpm %pre / install.sh)创建。 +# 缺失时 systemd 会因 User= 不存在而拒绕启动 —— 这是**响亮失败**, +# 比默默用 root 好。 +# +# ⚠️ 降权后 `Apply` 无法替换 /usr/bin/homed(属主 root、目录不可写)。 +# 这不是缺陷,是设计:是否允许自替换由 update.Capabilities 判定, +# deb/rpm 形态一律走包管理器(见 internal/update/install.go)。 +User=hologramagent +Group=hologramagent + # ★ guard 模式(与本机生产实例一致)。 # 数据目录默认 /var/lib/hologram(Debian 惯例);迁移到别的目录时 # 必须**同时**改这里和 core.daemon.data_dir —— 两者不等价,见 README。 diff --git a/deploy/packaging/linux/deb/postinst b/deploy/packaging/linux/deb/postinst index 14e3a860..02b920f6 100644 --- a/deploy/packaging/linux/deb/postinst +++ b/deploy/packaging/linux/deb/postinst @@ -5,6 +5,7 @@ SERVICE_NAME="hologramagent" HOMED_BIN="/usr/bin/homed" DATA_DIR="/var/lib/hologram" SETUP_SH="/usr/lib/hologram/setup.sh" +SETUP_USER_SH="/usr/lib/hologram/setup-user.sh" # unit 由本包装到 /etc/systemd/system/,而旧 postinst 只查 # /lib/systemd/system/(merged-usr 下等于 /usr/lib/systemd/system,那里没有 @@ -25,6 +26,18 @@ find_unit() { case "$1" in configure) if [ -f "$HOMED_BIN" ]; then + # ★ 先建用户/组:服务单元写死了 User=hologramagent。 + # 缺用户时 systemd 会拒绕启动(响亮失败),但用户装完起不来同样是事故。 + # 必须在 chown/mkdir 之前 —— 否则首次安装会把数据目录建成 root 所有。 + if [ -x "$SETUP_USER_SH" ]; then + HOLOGRAM_DATA="$DATA_DIR" "$SETUP_USER_SH" || { + echo "E: 无法创建 hologramagent 用户 —— 服务将无法启动。" >&2 + echo "E: 请手工执行:$SETUP_USER_SH" >&2 + } + else + echo "W: 未找到 $SETUP_USER_SH,跳过用户创建(首次安装会因缺用户而启动失败)。" >&2 + fi + mkdir -p "$DATA_DIR" # 初始化凭据和数据库。 diff --git a/deploy/packaging/linux/hologramagent.spec b/deploy/packaging/linux/hologramagent.spec index fdddf0c6..d17043fd 100644 --- a/deploy/packaging/linux/hologramagent.spec +++ b/deploy/packaging/linux/hologramagent.spec @@ -57,10 +57,14 @@ hologram-gui (desktop GUI). Connects to a remote HologramAgent server. mkdir -p %{buildroot}%{_bindir} mkdir -p %{buildroot}%{_unitdir} mkdir -p %{buildroot}%{_varlibdir} +mkdir -p %{buildroot}/usr/lib/hologram %if "%{variant}" == "full" || "%{variant}" == "server" install -m 755 %{_sourcedir}/homed %{buildroot}%{_bindir}/homed install -m 644 %{_sourcedir}/hologramagent.service %{buildroot}%{_unitdir}/hologramagent.service +# 服务单元写死了 User=hologramagent,用户创建是安装契约的一部分。 +# 与 deb 的 postinst 共用同一份幂等 helper,不另写一套。 +install -m 755 %{_sourcedir}/setup-user.sh %{buildroot}/usr/lib/hologram/setup-user.sh %endif # waiter 三个变体都要:server 也含 CLI(对齐 deb 的 stage_variant @@ -79,6 +83,7 @@ cp -r %{_sourcedir}/hologram-gui-linux-*/* %{buildroot}%{_datadir}/hologram-gui/ %{_bindir}/homed %{_unitdir}/hologramagent.service %dir %{_varlibdir} +/usr/lib/hologram/setup-user.sh %endif %{_bindir}/waiter @@ -90,6 +95,13 @@ cp -r %{_sourcedir}/hologram-gui-linux-*/* %{buildroot}%{_datadir}/hologram-gui/ %post %if "%{variant}" == "full" || "%{variant}" == "server" +# ★ 先建用户/组:单元里是 User=hologramagent。 +# 缺用户时 systemd 会拒绕启动(响亮失败),但装完起不来同样是事故。 +# 必须在 mkdir/chown 之前 —— 否则首次安装会把数据目录建成 root 所有。 +if [ -x /usr/lib/hologram/setup-user.sh ]; then + HOLOGRAM_DATA=%{_varlibdir} /usr/lib/hologram/setup-user.sh || \ + echo "warning: 无法创建 hologramagent 用户,服务将无法启动" >&2 +fi mkdir -p %{_varlibdir} %systemd_post hologramagent.service %endif diff --git a/deploy/packaging/linux/setup-user.sh b/deploy/packaging/linux/setup-user.sh new file mode 100755 index 00000000..75629d72 --- /dev/null +++ b/deploy/packaging/linux/setup-user.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# 创建 HologramAgent 的系统用户与组(幂等)。 +# +# ## 为什么需要单独一个脚本 +# +# 服务单元现在写死了 `User=hologramagent`。若安装时没有这个用户, +# systemd 会在启动时报 "Failed to determine user credentials" 并拒绝启动 +# —— 这是**响亮失败**,比默默用 root 好,但用户装完起不来同样是事故。 +# +# 三条安装路径(deb postinst / rpm %pre / tar install.sh)都要它, +# 各写一遍必然漂移(本仓已多次踩过这个坑)。所以收敛到这一个文件, +# 由打包脚本装到 /usr/lib/hologram/ 并调用。 +# +# ## 为什么优先 systemd-sysusers +# +# sysusers 是声明式、幂等的,且不会: +# · 给用户分配一个可猜测的 UID(useradd --system 也还行) +# · 在 /etc/passwd 里留下不一致的 home/shell +# 老系统没有 sysusers 时退回 useradd。 +# +# ## 为什么 shell 是 nologin +# +# 服务不需要登录。留 /bin/sh 会让这个账户成为一个可用的远程登录目标 +# (配合弱密码或密钥泄露)。nologin 让"有这个账户"不再是攻击面。 +set -e + +USER_NAME="${HOLOGRAM_USER:-hologramagent}" +GROUP_NAME="${HOLOGRAM_GROUP:-hologramagent}" +DATA_DIR="${HOLOGRAM_DATA:-/var/lib/hologram}" + +if ! command -v getent >/dev/null 2>&1; then + echo "W: 找不到 getent,跳过用户创建检查" >&2 + exit 0 +fi + +# 组 +if ! getent group "$GROUP_NAME" >/dev/null 2>&1; then + if command -v groupadd >/dev/null 2>&1; then + groupadd --system "$GROUP_NAME" 2>/dev/null || true + fi +fi + +# 用户 +if ! getent passwd "$USER_NAME" >/dev/null 2>&1; then + if command -v systemd-sysusers >/dev/null 2>&1; then + # 声明式:重复执行无副作用 + printf 'u %s - "HologramAgent service" %s %s\n' \ + "$USER_NAME" "$DATA_DIR" "/usr/sbin/nologin" \ + | systemd-sysusers - 2>/dev/null || true + fi + # sysusers 未生效(老系统)时退回 useradd + if ! getent passwd "$USER_NAME" >/dev/null 2>&1 && command -v useradd >/dev/null 2>&1; then + useradd --system \ + --gid "$GROUP_NAME" \ + --home-dir "$DATA_DIR" \ + --shell /usr/sbin/nologin \ + --comment "HologramAgent service" \ + "$USER_NAME" 2>/dev/null || true + fi +fi + +if ! getent passwd "$USER_NAME" >/dev/null 2>&1; then + echo "E: 无法创建用户 $USER_NAME —— 服务单元要求 User=$USER_NAME,将无法启动。" >&2 + echo "E: 请手工创建:useradd --system --gid $GROUP_NAME --home-dir $DATA_DIR --shell /usr/sbin/nologin $USER_NAME" >&2 + exit 1 +fi + +# 数据目录属主(首次安装时目录可能还不存在) +mkdir -p "$DATA_DIR" 2>/dev/null || true +chown -R "$USER_NAME:$GROUP_NAME" "$DATA_DIR" 2>/dev/null || true + +exit 0 diff --git a/deploy/packaging/package-linux.sh b/deploy/packaging/package-linux.sh index 056a6ae5..48d995c7 100644 --- a/deploy/packaging/package-linux.sh +++ b/deploy/packaging/package-linux.sh @@ -540,11 +540,19 @@ SCRIPT stage_setup() { local staging="$1" local setup_src="$PROJECT_ROOT/deploy/packaging/linux/setup.sh" + local user_src="$PROJECT_ROOT/deploy/packaging/linux/setup-user.sh" if [ -f "$setup_src" ]; then mkdir -p "$staging/usr/lib/hologram" cp "$setup_src" "$staging/usr/lib/hologram/setup.sh" chmod 755 "$staging/usr/lib/hologram/setup.sh" fi + # 服务单元以 User=hologramagent 运行;用户创建是安装契约的一部分, + # 不能只靠现场 drop-in。三种包形态共用同一份幂等 helper。 + if [ -f "$user_src" ]; then + mkdir -p "$staging/usr/lib/hologram" + cp "$user_src" "$staging/usr/lib/hologram/setup-user.sh" + chmod 755 "$staging/usr/lib/hologram/setup-user.sh" + fi } # 项目自身的许可:**所有变体**都要带(client 也分发 waiter 与 GUI)。 @@ -710,6 +718,52 @@ build_deb() { } # ---- create combined .tar.gz (all binaries, no variant split) ---- +# ---- generate binary delta (optional) ---- +# +# 差分 OTA:发布时针对**上一版已发布的 homed** 生成本版的补丁资产。 +# +# 为何需要外部传入旧二进制(PREVIOUS_HOMED):上一版的产物不在本次 +# 构建目录里,而“取上一版”意味着下载历史 release。那一步留给发布者 +# 决定(CI 可以 gh release download 上一 tag 再传进来)。 +# +# 未设置 PREVIOUS_HOMED 时**静默跳过**:首次发布、跳版本、或发布者 +# 选择只发整包时都是正常情形。差分是优化,不是可用性前提。 +# +# 实测量级(2026-10-28,88MB 内核): +# 改一趟逻辑的补丁 ≈ 77KB(0.09%) +build_delta() { + local previous="${PREVIOUS_HOMED:-}" + local new_bin="$BUILD_DIR/homed_linux_${ARCH}" + + if [ -z "$previous" ] || [ ! -f "$previous" ]; then + echo ">>> Skip binary delta: 未提供 PREVIOUS_HOMED(上一版 homed 路径)" + return 0 + fi + if [ ! -f "$new_bin" ]; then + echo ">>> Skip binary delta: 缺 $new_bin" + return 0 + fi + + local prev_commit + prev_commit="$("$previous" --version 2>/dev/null | sed -n 's/^commit=//p' | head -1)" + if [ -z "$prev_commit" ] || [ "$prev_commit" = "unknown" ]; then + echo ">>> Skip binary delta: 读不出上一版的 commit(老内核无 --version?)" + return 0 + fi + local short="${prev_commit:0:8}" + local out="${DIST_DIR}/hologramagent-${PKG_VERSION}-linux-${ARCH}-from-${short}.delta" + + echo ">>> Building binary delta (from commit $short)" + if "$BUILD_DIR/homed-upgrade" -make-delta \ + -delta-old "$previous" -delta-new "$new_bin" -delta-out "$out" 2>&1; then + echo " Created: $out ($(du -h "$out" | cut -f1))" + else + # ★ 不当作失败(set -e 下靠 || true):delta 不可用不影响整包发布 + echo " WARN: delta 生成失败,本次只发整包" + rm -f "$out" + fi +} + build_tar() { local tar_dir="${DIST_DIR}/tar" mkdir -p "$tar_dir" @@ -817,19 +871,36 @@ build_rpm() { export RPM_CONFIGDIR="$rpmbuild_dir/usr/lib/rpm" fi - echo ">>> Building .rpm via fpm: $pkg_name" - "$fpm_bin" -s dir -t rpm \ - -n "hologramagent-${variant}" \ - -v "$VERSION" \ - --iteration 1 \ - -a "$RPM_ARCH" \ - --description "HologramAgent ${variant^} package" \ - --url "https://github.com/trueagent/HologramAgent" \ - --license "AGPL-3.0-only" \ - -C "$staging" \ - -p "$rpm_dir/$pkg_name" \ - . 2>&1 - echo " Created: $rpm_dir/$pkg_name" + echo ">>> Building .rpm via fpm: $pkg_name" + # ★ 必须带 --after-install(实测缺口,2026-10-28 补): + # + # 原先 fpm 调用只有 `-C "$staging"`,**没有**任何一个 script 参数 ⇒ + # rpm 装完不会建 hologramagent 用户,而单元里写死了 User=hologramagent + # ⇒ systemd 拒绕启动。deb 侧有 postinst 所以一直正常,rpm 侧静默缺失。 + # + # 脚本复用 deb 的 postinst:两条安装路径共用一份,不另写一套。 + local postinst_src="$PACKAGE_ROOT/deb/postinst" + local fpm_args=() + if [ -f "$postinst_src" ]; then + fpm_args+=(--after-install "$postinst_src") + if [ -f "$PACKAGE_ROOT/deb/prerm" ]; then + fpm_args+=(--before-remove "$PACKAGE_ROOT/deb/prerm") + fi + fi + + "$fpm_bin" -s dir -t rpm \ + -n "hologramagent-${variant}" \ + -v "$VERSION" \ + --iteration 1 \ + -a "$RPM_ARCH" \ + --description "HologramAgent ${variant^} package" \ + --url "https://github.com/trueagent/HologramAgent" \ + --license "AGPL-3.0-only" \ + "${fpm_args[@]}" \ + -C "$staging" \ + -p "$rpm_dir/$pkg_name" \ + . 2>&1 + echo " Created: $rpm_dir/$pkg_name" } # ---- main ---- @@ -882,6 +953,12 @@ main() { all|tar) build_tar ;; esac + # 差分资产(可选,需 PREVIOUS_HOMED)。必须在 SHA256SUMS 计算之前 —— + # 否则 delta 不会进校验和清单,下载方无法验它。 + case "$ACTION" in + all|tar|deb) build_delta ;; + esac + echo "" echo "=== Done! Packages in: $DIST_DIR ===" echo "" @@ -889,7 +966,11 @@ main() { # 只列**本批**产物:dist/ 会跨多次构建累积,用 find 全目录会让清单/SHA256SUMS # 带上历史版本的文件名——用户下载那种清单后 `sha256sum -c` 必然报缺失。 # (v1.2.2 构建时就出现过:清单里混进了 1.2.0/1.2.1 的包名。)按本批版本号过滤。 - mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "hologramagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" \) 2>/dev/null | sort) + # + # ★ 必须包含 .delta:差分资产也要进校验和清单。 + # 否则下载方无法验它的完整性 —— 而 delta 是会被直接喂给 ApplyDelta 的 + # 输入,未校验的补丁等于未校验的二进制。 + mapfile -t release_files < <(find "$DIST_DIR" -type f \( -name "*${PKG_VERSION}*.deb" -o -name "hologramagent_${PKG_VERSION}_*.tar.gz" -o -name "*${PKG_VERSION}*.rpm" -o -name "*${PKG_VERSION}*.delta" \) 2>/dev/null | sort) for f in "${release_files[@]}"; do echo " $(du -h "$f" | cut -f1) $f" done diff --git a/internal/events/bus.go b/internal/events/bus.go index 03aeabc4..e2506b41 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -175,6 +175,15 @@ type Handler func(event *Event) type Bus struct { mu sync.RWMutex subs map[EventType][]Handler + + // guard 是**总线闸门**(可 nil = 不设闸门)。 + // + // ★ 放在总线上而不是适配层:在此之前,策略只存在于 + // internal/sdk/events.go 那一条路径,而 s.EventBus() 和 s.Subscribe() + // 拿的是裸总线 —— 任何插件都能绕过它发任意事件(含伪造内核事件)。 + // + // 现在:Publish / Subscribe 本身先问 guard,绕不过去。 + guard Guard } func NewBus() *Bus { @@ -183,7 +192,59 @@ func NewBus() *Bus { } } +// SetGuard 注入闸门(由装配方调用,通常一次)。 +// +// 传 nil 等于"不设闸门"(开发/test 场景)。 +func (b *Bus) SetGuard(g Guard) { + b.mu.Lock() + b.guard = g + b.mu.Unlock() +} + +// Guard 返回当前闸门(可能 nil)。 +func (b *Bus) Guard() Guard { + b.mu.RLock() + defer b.mu.RUnlock() + return b.guard +} + +// Publish 以**内核身份**发布(等价于 PublishAs{Name: ""})。 +// +// 保留这个签名是为了不让几十个内核调用点全部改签名:内核模块本来就 +// 是可信主体,它们不需要授权判定。但**载荷脱敏照样执行**。 func (b *Bus) Publish(evt *Event) { + _ = b.publishAs(Publisher{}, evt) +} + +// PublishAs 以指定身份发布(**插件侧必须用这个**)。 +// +// 返回 error:被拒时要让调用方知道,而不是静默丢弃。 +func (b *Bus) PublishAs(p Publisher, evt *Event) error { + return b.publishAs(p, evt) +} + +func (b *Bus) publishAs(p Publisher, evt *Event) error { + if evt == nil { + return nil + } + + b.mu.RLock() + g := b.guard + b.mu.RUnlock() + + if g != nil { + // ① 授权 + if allowed, reason := g.AllowPublish(p, evt.Type); !allowed { + log.Printf("[bus] publish denied: publisher=%q type=%q: %s", + p.Name, evt.Type, reason) + return &DeniedError{Publisher: p.Name, EventType: string(evt.Type), Reason: reason} + } + // ② 载荷脱敏(**内核身份也过**:内核也可能把敏感值拼进事件) + if evt.Payload != nil { + evt.Payload = g.ScrubPayload(evt.Type, evt.Payload) + } + } + b.mu.RLock() allHandlers := make([]Handler, len(b.subs[EventAll])) copy(allHandlers, b.subs[EventAll]) @@ -197,6 +258,7 @@ func (b *Bus) Publish(evt *Event) { for _, h := range typeHandlers { b.safeCall(h, evt) } + return nil } func (b *Bus) safeCall(h Handler, evt *Event) { @@ -208,7 +270,38 @@ func (b *Bus) safeCall(h Handler, evt *Event) { h(evt) } +// Subscribe 以**内核身份**订阅(内核模块用)。 func (b *Bus) Subscribe(eventType EventType, handler Handler) func() { + fn, err := b.subscribeAs(Publisher{}, eventType, handler) + if err != nil { + // 内核身份不会被拒(策略只约束插件),但保留返回值以防将来收紧 + log.Printf("[bus] kernel subscribe rejected: %v", err) + } + return fn +} + +// SubscribeAs 以指定身份订阅(**插件侧必须用这个**)。 +func (b *Bus) SubscribeAs(p Publisher, eventType EventType, handler Handler) (func(), error) { + return b.subscribeAs(p, eventType, handler) +} + +func (b *Bus) subscribeAs(p Publisher, eventType EventType, handler Handler) (func(), error) { + b.mu.RLock() + g := b.guard + b.mu.RUnlock() + + // EventAll 是内部通配订阅,不做逐类型判定(只允许内核使用: + // 插件走 s.Events().Subscribe 时本来就限制了具体类型)。 + if g != nil && eventType != EventAll { + if allowed, reason := g.AllowSubscribe(p, eventType); !allowed { + log.Printf("[bus] subscribe denied: publisher=%q type=%q: %s", + p.Name, eventType, reason) + return func() {}, &DeniedError{ + Publisher: p.Name, EventType: string(eventType), Reason: reason, Subscribe: true, + } + } + } + b.mu.Lock() b.subs[eventType] = append(b.subs[eventType], handler) b.mu.Unlock() @@ -223,5 +316,5 @@ func (b *Bus) Subscribe(eventType EventType, handler Handler) func() { break } } - } + }, nil } diff --git a/internal/events/guard.go b/internal/events/guard.go new file mode 100644 index 00000000..2a52710b --- /dev/null +++ b/internal/events/guard.go @@ -0,0 +1,79 @@ +package events + +// 事件总线的**统一监控闸门**(类型与接口部分)。 +// +// ## 为什么必须做在总线本体上,而不是各适配层 +// +// 在此之前,授权只存在于 `internal/sdk/events.go`(插件走 `s.Events()` 那条路)。 +// 而实际有**四条**路径能发出/收到事件: +// +// ① s.Events().Publish/Subscribe ← 过策略(唯一一条) +// ② s.Publish(evt) / s.Subscribe() ← 裸总线,不过策略 +// ③ s.EventBus() 直接拿 *Bus ← 裸总线,连适配层都绕过 +// ④ 内核模块 bus.Publish ← 内核自身,不需要策略 +// +// 于是 `EventPolicy` 只是"合规者才受约束"的一纸约定:任何插件调 +// `s.EventBus().Publish(...)` 就能发任意事件(含伪造内核事件)。 +// +// 现在把闸门放到 **Bus.Publish / Bus.Subscribe 本身**(见 bus.go): +// 注册一个 Guard,所有发布与订阅都先问它。适配层不再持有策略, +// 只负责把「谁在调用」告诉总线。 +// +// ## 为什么用接口注入而不是直接 import security +// +// events 是最底层的广播设施,security 依赖它(要发 security_ready)。 +// 直接 import 会成环。所以这里定义**最小接口**,由装配方注入实现。 +// +// 这与 syncbus 的「内核可写 / 插件只读」是同一思路:类型层面表达边界。 + +import "fmt" + +// Publisher 是事件发布/订阅的主体身份。 +// +// ★ 为什么必须显式传:没有身份就无法判定"这个 plugin 能不能发这个事件"。 +// 空身份 = 内核内部(内核模块自己发布时不需要授权)。 +type Publisher struct { + // Name 是插件名;空串表示**内核内部**。 + // + // 内核模块(update / device / pluginhost / agent)发布系统事件时 + // 不填 Name —— 它们本身就是可信的执行体,且没有"插件名"这个身份。 + Name string +} + +// IsKernel 报告该发布者是否内核内部。 +func (p Publisher) IsKernel() bool { return p.Name == "" } + +// Guard 是总线闸门:判定某个主体能否发布/订阅某事件。 +// +// 实现在 internal/security(策略与载荷脱敏都在那里), +// events 包只负责"每次发布/订阅都问一遍"。 +type Guard interface { + // AllowPublish 判定能否发布;allowed=false 时 reason 会进日志与错误。 + AllowPublish(p Publisher, eventType EventType) (allowed bool, reason string) + // AllowSubscribe 判定能否订阅。 + AllowSubscribe(p Publisher, eventType EventType) (allowed bool, reason string) + // ScrubPayload 对出向载荷做脱敏(凭据闸门)。 + // + // 内核内部发布者同样要过:内核也可能把敏感值拼进事件 + //(例如把 api_key 放进 Stage 事件里)。 + ScrubPayload(eventType EventType, payload map[string]interface{}) map[string]interface{} +} + +// DeniedError 表示事件操作被闸门拒绝。 +// +// ★ 为什么返回 error 而不是静默丢弃:静默丢弃会让插件以为 +// "发了、没反应",那是最难排查的一类故障。 +type DeniedError struct { + Publisher string + EventType string + Reason string + Subscribe bool +} + +func (e *DeniedError) Error() string { + verb := "publish" + if e.Subscribe { + verb = "subscribe to" + } + return fmt.Sprintf("events: %q may not %s %q: %s", e.Publisher, verb, e.EventType, e.Reason) +} diff --git a/internal/events/guard_test.go b/internal/events/guard_test.go new file mode 100644 index 00000000..19e4a419 --- /dev/null +++ b/internal/events/guard_test.go @@ -0,0 +1,192 @@ +package events + +// 总线闸门的判据(用户 2026-10-28:「eventbus 上所有事件都要接受安全模块的监控」)。 +// +// ## 这组判据要钉住什么 +// +// 在此之前,授权只存在于 internal/sdk/events.go 那**一条**路径。 +// 其余三条(s.Publish / s.Subscribe / s.EventBus())拿的是裸 *events.Bus, +// 任何插件都能绕过策略发任意事件 —— 包括伪造 kernel 事件。 +// +// 现在闸门在 Bus.Publish / Bus.Subscribe 本体上,绕不过去。 +// 所以判据的重点是「**绕过尝试必须失败**」,而不是「正常路径能跑」。 + +import ( + "errors" + "testing" +) + +// fakeGuard 是一个只放行白名单的闸门。 +type fakeGuard struct { + allowPublish map[string]bool + allowSubscribe map[string]bool + scrubCalls int +} + +func (g *fakeGuard) AllowPublish(p Publisher, t EventType) (bool, string) { + if p.IsKernel() { + return true, "" + } + if g.allowPublish[string(t)] { + return true, "" + } + return false, "not allowed" +} + +func (g *fakeGuard) AllowSubscribe(p Publisher, t EventType) (bool, string) { + if p.IsKernel() { + return true, "" + } + if g.allowSubscribe[string(t)] { + return true, "" + } + return false, "not allowed" +} + +func (g *fakeGuard) ScrubPayload(_ EventType, payload map[string]interface{}) map[string]interface{} { + g.scrubCalls++ + if _, ok := payload["secret"]; ok { + out := map[string]interface{}{} + for k, v := range payload { + if k == "secret" { + out[k] = "@{redacted}" + continue + } + out[k] = v + } + return out + } + return payload +} + +// ① 插件发布未授权事件必须被**拒绝**,且不送达订阅者。 +// +// 这是最核心的一条:能发 raw_input / agent_output 等于能伪造内核事件。 +func TestGuardRejectsUnauthorizedPublish(t *testing.T) { + bus := NewBus() + bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{"skill_detected": true}}) + + got := 0 + bus.Subscribe(EventRawInput, func(*Event) { got++ }) + + err := bus.PublishAs(Publisher{Name: "evil"}, &Event{Type: EventRawInput}) + if err == nil { + t.Fatal("★ 未授权发布必须返回错误(静默丢弃会让插件以为发了)") + } + var de *DeniedError + if !errors.As(err, &de) { + t.Fatalf("应返回 DeniedError,实际 %T", err) + } + if got != 0 { + t.Errorf("★ 被拒事件不应送达订阅者,实际送达 %d 次", got) + } +} + +// ② 授权事件正常送达,且来源是插件名。 +func TestGuardAllowsAuthorizedPublish(t *testing.T) { + bus := NewBus() + bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{"skill_detected": true}}) + + var got *Event + bus.Subscribe(EventSkillDetected, func(e *Event) { got = e }) + + if err := bus.PublishAs(Publisher{Name: "clawhubadapter"}, + &Event{Type: EventSkillDetected, Payload: map[string]interface{}{"path": "/x"}}); err != nil { + t.Fatalf("白名单事件应放行: %v", err) + } + if got == nil { + t.Fatal("订阅者应收到事件") + } +} + +// ③ 内核身份不受授权限制(内核模块发布系统事件不需要策略)。 +func TestGuardAllowsKernelPublish(t *testing.T) { + bus := NewBus() + bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{}}) + + if err := bus.PublishAs(Publisher{}, &Event{Type: EventSystem}); err != nil { + t.Fatalf("内核身份应放行: %v", err) + } +} + +// ④ 内核身份**也要过载荷脱敏**。 +// +// 凭据闸门与身份无关:内核把 api_key 拼进事件,和插件拼进去一样危险。 +func TestGuardScrubsKernelPayload(t *testing.T) { + bus := NewBus() + g := &fakeGuard{allowPublish: map[string]bool{}} + bus.SetGuard(g) + + var got *Event + bus.Subscribe(EventSystem, func(e *Event) { got = e }) + + bus.Publish(&Event{Type: EventSystem, Payload: map[string]interface{}{"secret": "sk-123"}}) + if got == nil { + t.Fatal("应收到事件") + } + if got.Payload["secret"] != "@{redacted}" { + t.Errorf("★ 内核载荷也必须脱敏,实际 %v", got.Payload["secret"]) + } + if g.scrubCalls == 0 { + t.Error("ScrubPayload 应被调用") + } +} + +// ⑤ 未授权订阅必须被拒。 +// +// 若能订阅 raw_input,插件就能读到用户的全部输入。 +func TestGuardRejectsUnauthorizedSubscribe(t *testing.T) { + bus := NewBus() + bus.SetGuard(&fakeGuard{allowSubscribe: map[string]bool{"tools_ready": true}}) + + if _, err := bus.SubscribeAs(Publisher{Name: "evil"}, EventRawInput, func(*Event) {}); err == nil { + t.Fatal("★ 未授权订阅必须报错") + } + // 白名单内的可订阅 + if _, err := bus.SubscribeAs(Publisher{Name: "p1"}, EventToolsReady, func(*Event) {}); err != nil { + t.Errorf("白名单订阅应放行: %v", err) + } +} + +// ⑥ 没有闸门时行为不变(开发/测试场景,且保证既有部署不被意外破坏)。 +func TestGuardAbsentBehavesAsBefore(t *testing.T) { + bus := NewBus() + got := 0 + bus.Subscribe(EventRawInput, func(*Event) { got++ }) + if err := bus.PublishAs(Publisher{Name: "any"}, &Event{Type: EventRawInput}); err != nil { + t.Fatalf("无闸门时不应报错: %v", err) + } + if got != 1 { + t.Errorf("无闸门时应正常送达,实际 %d", got) + } +} + +// ⑦ 闸门可以被后装(启动顺序:总线先建、security 后装配)。 +// +// 这条钉住真实的装配顺序:kernelapi 创建 Bus,security 模块在更晚的层 +// 才注入 Guard。先发的 security_ready 事件不受影响,之后全部受控。 +func TestGuardCanBeInstalledLater(t *testing.T) { + bus := NewBus() + // 先无闸门发一条 + if err := bus.PublishAs(Publisher{Name: "p"}, &Event{Type: EventSystem}); err != nil { + t.Fatal(err) + } + // 装闸门后必须生效 + bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{}}) + if err := bus.PublishAs(Publisher{Name: "p"}, &Event{Type: EventSystem}); err == nil { + t.Fatal("★ 后装闸门必须立即生效") + } +} + +// ⑧ 未授权发布不得污染 EventAll 通配订阅。 +func TestGuardDeniesBeforeWildcardHandlers(t *testing.T) { + bus := NewBus() + bus.SetGuard(&fakeGuard{allowPublish: map[string]bool{}}) + got := 0 + bus.Subscribe(EventAll, func(*Event) { got++ }) + + _ = bus.PublishAs(Publisher{Name: "evil"}, &Event{Type: EventAgentOutput}) + if got != 0 { + t.Errorf("★ 被拒事件不得送到 EventAll 订阅者,实际 %d", got) + } +} diff --git a/internal/kernelboot/kernelstate.go b/internal/kernelboot/kernelstate.go index 93984bfd..371e3c7c 100644 --- a/internal/kernelboot/kernelstate.go +++ b/internal/kernelboot/kernelstate.go @@ -185,6 +185,33 @@ type KernelState struct { // 读它时都已就绪 —— 包括"启动失败回滚"的判定结果。 Update UpdateStateProvider + // UpdateCapabilities 是本机**升级能力**的快照(update 模块产出)。 + // + // ## 为什么它是一个独立字段而不是塞进 Update.State + // + // State 回答"我这台机器现在是什么版本、上次回滚过没有"; + // Capabilities 回答"我这台机器**能不能**自己升级"。 + // 前者随时间变,后者在进程生命周期内恒定(安装形态不变)。 + // + // 消费者不同: + // · WebUI / healthcheck 展示 State(版本、回滚轨迹) + // · kernel_update 工具 / CLI 决策看 Capabilities(走哪条升级通道) + // + // 合并成一个结构会让“只关心能不能升级”的调用方也拿到一堆版本字段, + // 反之亦然——那类混搭在这仓已经出过问题(healthcheck 把工具表塞进快照)。 + // + // ★ 用 interface{} 而不是定义接口(与 PluginCatalog 同一范式)。 + // + // 尝试过定义 UpdateCapabilitiesProvider 接口,但它要求方法名 + // Variant()/InstallKind() —— 而 Capabilities 的**字段**就叫 Variant, + // 方法名与字段名在同一个类型上不能共存(编译错误)。 + // 改方法名(VariantName())只会让接口变丑而无收益:读取方本来 + // 就要完整结构体(WebUI 要 bin_path / reason / package_name)。 + // + // 所以:与 ks.PluginCatalog 一致,存 any,读取方断言回 + // *update.Capabilities。代价是一次类型断言,收益是零重复定义。 + UpdateCapabilities any + // StageHost 是阶段管道(stage-host 模块产出)。 StageHost *agentCore.StageHost diff --git a/internal/kernelmod/security/mod.go b/internal/kernelmod/security/mod.go index 19221528..91700446 100644 --- a/internal/kernelmod/security/mod.go +++ b/internal/kernelmod/security/mod.go @@ -63,6 +63,24 @@ func (Module) Init(ks *kernelboot.KernelState) error { return err } ks.Security = rt + + // ★ 把安全策略注入**总线本体**(用户 2026-10-28 指正:「eventbus 上所有 + // 事件都要接受安全模块的监控」)。 + // + // 为何必须在总线上而不是只在 internal/sdk/events.go: + // 后者只盖住了 s.Events() 那**一条**路径,而插件还有 s.Publish / + // s.Subscribe / s.EventBus() 三条能拿裸总线的路。不装在这里, + // “安全监控”就只对合规者生效。 + // + // ★ rt 为 nil 或未 enable 时**仍然装**:enable=false 关掉的是凭据替换, + // “插件不许发 raw_input”这类授权与密钥无关(见 EventPolicy 头注释)。 + // 不装就等于默认放行全部事件,方向错误。 + if ks.EventBus != nil { + if g := sec.AsEventGuard(rt); g != nil { + ks.EventBus.SetGuard(g) + } + } + if ks.EventBus != nil && rt.Enabled() { ks.EventBus.Publish(secReadyEvent(rt)) } diff --git a/internal/kernelmod/update/mod.go b/internal/kernelmod/update/mod.go index 1e1b2691..7edde175 100644 --- a/internal/kernelmod/update/mod.go +++ b/internal/kernelmod/update/mod.go @@ -42,6 +42,15 @@ func (Module) Provides() []string { return []string{FacilityVersion} } // Init 执行版本自检,必要时回滚上一次升级。 func (Module) Init(ks *kernelboot.KernelState) error { + // ⓪ 把内核**真实**的 dataDir 注入 update 包。 + // + // 必须最先做:pending 标记/备份/回滚验全都靠它。进程内调用方 + // (pluginmgr / webui)用 upd.KernelDataDir() 取值,而不是自己推导。 + // + // 推导必错(实测三类):从 exe 推 → /usr/bin/data(只读,备份必挂); + // 硬编码 → 迁移后指向不存在的目录;读 env → 未必与 -data 一致。 + upd.SetKernelDataDir(ks.DataDir) + st := &upd.State{} ks.Update = st @@ -89,6 +98,22 @@ func (Module) Init(ks *kernelboot.KernelState) error { st.DepGraphPath = dg } + // ⑤ 安装形态与升级能力**探测**(不改任何状态,只产出可读快照)。 + // + // 为何放在第 0 层:它就是第 0 层自己的事实——“我能怎么升”。 + // 而且它要读 dpkg/rpm 数据库与目录权限,这些在启动时只跑一次。 + // + // ★ 探测失败不能阻断启动:它只是诊断信息。拿不到就报 Unknown, + // Apply 会因 Unknown 拒绝自替换(失败方向是收紧)。 + capab := upd.Detect(bin, ks.DataDir) + ks.UpdateCapabilities = &capab + log.Printf("[update] install kind=%s variant=%s channel=%s can_replace=%v", + capab.Kind, capab.Variant, capab.Channel(), capab.CanWriteBin) + if !capab.CanWriteBin { + // 不阻断启动,但必须响亮:否则用户以为“点一下就能升级”。 + log.Printf("[update] 本机不支持自替换升级:%s", capab.Reason) + } + log.Printf("[update] subsystem ready (version=%s rolled_back=%v)", st.Version, st.RolledBack) return nil } diff --git a/internal/memory/cut.go b/internal/memory/cut.go index 2123a0a8..8570e758 100644 --- a/internal/memory/cut.go +++ b/internal/memory/cut.go @@ -42,8 +42,34 @@ var contentPOS = map[string]bool{ var ( jiebaOnce sync.Once jiebaInst *gojieba.Jieba + + // jiebaDataDir 是内核数据目录(供外置词库查找,第 4 项)。 + // + // 为何用包级变量而不是给 GetJieba() 加参数:它是**全局单例** + // (jiebaOnce),调用方散布在 memory/indexer/knowledge 多处, + // 加参数会让每个调用点都要传一遍 dataDir,而那些地方未必拿得到。 + // + // 未设置时为空 ⇒ 只查系统目录与内嵌词库(与旧行为一致)。 + jiebaDataDirMu sync.RWMutex + jiebaDataDir string ) +// SetJiebaDataDir 由内核启动时注入数据目录(供外置词库覆盖)。 +// +// **必须在 GetJieba() 首次调用之前调**:首次调用会初始化单例并 +// 固定词库目录,之后再改不会生效。 +func SetJiebaDataDir(dir string) { + jiebaDataDirMu.Lock() + defer jiebaDataDirMu.Unlock() + jiebaDataDir = dir +} + +func jiebaDataDirValue() string { + jiebaDataDirMu.RLock() + defer jiebaDataDirMu.RUnlock() + return jiebaDataDir +} + func GetJieba() *gojieba.Jieba { jiebaOnce.Do(func() { defer func() { @@ -68,11 +94,11 @@ func GetJieba() *gojieba.Jieba { } func jiebaDictDir() string { - // 首选内嵌词库:它是产物的一部分,与二进制同版本、不依赖宿主环境。 + // 首选外置词库(DataDir 与系统目录)→ 退回内嵌词库落盘。 // // 以前这里只猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块缓存, // 于是分词与关键词提取会**静默退回空列表**(详见 jieba_embed.go 的说明)。 - if dir, err := materializeJiebaDict(); err == nil && dir != "" { + if dir, err := materializeJiebaDict(jiebaDataDirValue()); err == nil && dir != "" { return dir } log.Printf("[jieba] writing embedded dictionary failed, falling back to module cache lookup (embedded failure usually means cache dir is not writable)") diff --git a/internal/memory/jieba_embed.go b/internal/memory/jieba_embed.go index 80cda2e4..2bd4daae 100644 --- a/internal/memory/jieba_embed.go +++ b/internal/memory/jieba_embed.go @@ -45,16 +45,41 @@ var jiebaDictFiles = []string{ "stop_words.utf8", } -// materializeJiebaDict 把内嵌词库落盘,返回目录路径。 +// materializeJiebaDict 返回可用的词库目录(gojieba 只接文件路径)。 // -// gojieba 的 C++ API 只接受**文件路径**(NewJieba 会对每个路径 os.Stat, -// 缺失就 panic),所以必须先落盘再传路径。 +// ## 取值顺序(第 4 项:数据外置) // -// 落盘位置与幂等性: -// - 用内容哈希命名目录:词库升级后不会复用旧文件(否则会出现「新旧词库混用」 -// 这种最难查的一类问题——分词结果与版本对不上)。 -// - 已存在且大小一致就跳过写入:正常启动只做几次 stat。 -func materializeJiebaDict() (string, error) { +// ① 部署目录 /jiebadict/(5 个文件齐备且非空) +// ② 系统目录 /usr/share/hologram/jiebadict/ +// ③ 内嵌词库落盘(向下兼容:旧部署里二进制内就有) +// +// ## 为何要允许外置(这是 OTA 体积问题的根) +// +// 词库 12MB 是**数据**,不是代码。它 #go:embed 进二进制后: +// +// · 二进制 88MB 里有 59MB 是模型+词库,改一行代码要重下 88MB +// · 而 patch 版本之间这两个目录**从未变过**(实测 v1.3.8→v1.3.13 +// 的 git log 里零提交) +// +// 外置后:词库可以独立发版,二进制只剩代码;掉电风险也不受影响 +// (词库坏了只是分词变差,不阻断启动)。 +// +// ★ 外置目录只要**文件齐备且非空**就用,不做哈希校验: +// 词库是数据不是可执行代码,装错版本最多分词效果差;而哈希校验会让 +// “用户手工换了一份词库”直接不可用,那是不必要的刚性。 +func materializeJiebaDict(dataDirs ...string) (string, error) { + // ① ② 部署目录优先(带 DataDir 的调用方由 cut.go 传入) + for _, d := range append(dataDirs, "/usr/share/hologram") { + if d == "" { + continue + } + cand := filepath.Join(d, "jiebadict") + if externalJiebaDictComplete(cand) { + return cand, nil + } + } + + // ③ 退回内嵌(保持旧行为,不破坏任何现有部署) sum, err := jiebaDictDigest() if err != nil { return "", err @@ -89,6 +114,20 @@ func materializeJiebaDict() (string, error) { return dir, nil } +// externalJiebaDictComplete 判断外部目录里的 5 个词库是否齐备且非空。 +// +// ★ 只查“存在且非空”,不跟内嵌版本比大小:外置的意义就是允许 +// 词库独立升级,拿内嵌版本当基准会让升级永远不生效。 +func externalJiebaDictComplete(dir string) bool { + for _, name := range jiebaDictFiles { + st, err := os.Stat(filepath.Join(dir, name)) + if err != nil || st.IsDir() || st.Size() == 0 { + return false + } + } + return true +} + // jiebaDictDigest 对全部内嵌词库内容求哈希,作为落盘目录名的一部分。 func jiebaDictDigest() (string, error) { h := sha256.New() diff --git a/internal/memory/jieba_external_test.go b/internal/memory/jieba_external_test.go new file mode 100644 index 00000000..6b24e10e --- /dev/null +++ b/internal/memory/jieba_external_test.go @@ -0,0 +1,102 @@ +package memory + +// 外置词库的判据(第 4 项:数据外置)。 +// +// 这一项的直接收益是 OTA 体积:词库 12MB 与 ONNX 模型 47MB 是数据而非代码, +// 却 #go:embed 进了 88MB 的二进制;而 patch 版本之间它们从未变过。 + +import ( + "os" + "path/filepath" + "testing" +) + +// ① 外部目录齐备时**优先**用它(而不是内嵌落盘)。 +func TestMaterializeJiebaDict_PrefersExternal(t *testing.T) { + dataDir := t.TempDir() + dictDir := filepath.Join(dataDir, "jiebadict") + if err := os.MkdirAll(dictDir, 0755); err != nil { + t.Fatal(err) + } + // 造 5 个非空文件(内容不重要:外置不做哈希校验) + for _, name := range jiebaDictFiles { + if err := os.WriteFile(filepath.Join(dictDir, name), []byte("external-"+name), 0644); err != nil { + t.Fatal(err) + } + } + + got, err := materializeJiebaDict(dataDir) + if err != nil { + t.Fatalf("不应失败: %v", err) + } + if got != dictDir { + t.Errorf("★ 应优先使用外置目录 %s,实际 %s", dictDir, got) + } +} + +// ② 外置目录**缺一个文件就不算齐备**,必须退回内嵌。 +// +// 判据重点:半套词库会让分词结果不可预测(缺 stop_words 会把停用词 +// 当成普通词),比"整份退回内嵌"更糟。 +func TestMaterializeJiebaDict_IncompleteExternalFallsBack(t *testing.T) { + dataDir := t.TempDir() + dictDir := filepath.Join(dataDir, "jiebadict") + if err := os.MkdirAll(dictDir, 0755); err != nil { + t.Fatal(err) + } + // 只放 4 个(少一个) + for i, name := range jiebaDictFiles { + if i == 0 { + continue + } + if err := os.WriteFile(filepath.Join(dictDir, name), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + + got, err := materializeJiebaDict(dataDir) + if err != nil { + t.Fatalf("退回内嵌不应失败: %v", err) + } + if got == dictDir { + t.Error("★ 缺文件的外置目录不得被采用") + } +} + +// ③ 空文件等同缺失(磁盘写坏的典型形态)。 +func TestExternalJiebaDictComplete_RejectsEmptyFile(t *testing.T) { + dir := t.TempDir() + for _, name := range jiebaDictFiles { + if err := os.WriteFile(filepath.Join(dir, name), []byte{}, 0644); err != nil { + t.Fatal(err) + } + } + if externalJiebaDictComplete(dir) { + t.Error("★ 全零字节的词库不得被判为齐备") + } + // 填上内容后应变齐备 + for _, name := range jiebaDictFiles { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + } + if !externalJiebaDictComplete(dir) { + t.Error("非空词库应判为齐备") + } +} + +// ④ SetJiebaDataDir 的读写不产生数据竞争(配合 -race)。 +func TestSetJiebaDataDir_RaceFree(t *testing.T) { + defer SetJiebaDataDir("") + done := make(chan struct{}) + go func() { + for i := 0; i < 100; i++ { + SetJiebaDataDir("/tmp/x") + } + close(done) + }() + for i := 0; i < 100; i++ { + _ = jiebaDataDirValue() + } + <-done +} diff --git a/internal/nlp/onnx.go b/internal/nlp/onnx.go index f0eb7b54..1bea9047 100644 --- a/internal/nlp/onnx.go +++ b/internal/nlp/onnx.go @@ -298,8 +298,34 @@ func extractEmbeddedModel(dataDir string) (string, error) { dataDir = filepath.Join(os.TempDir(), "homeagent-nlp") } os.MkdirAll(dataDir, 0755) - dst := filepath.Join(dataDir, "dep_parser.onnx") - if _, err := os.Stat(dst); err == nil { + + // ★ 外置模型优先(第 4 项:数据外置)。 + // + // 模型 47MB 是**数据**不是代码,却 #go:embed 进了 88MB 的二进制: + // 改一行代码要重下 47MB,而 patch 版本之间它从未变过。 + // + // 查找顺序(只认非空文件): + // ① /nlp/dep_parser.onnx(部署目录,与旧行为兼容: + // 旧版首次启动会把内嵌模型解到这里) + // ② /usr/share/hologram/models/dep_parser.onnx(系统目录) + // ③ 内嵌解出 + // + // ★ 不做哈希校验:模型是数据,装错版本最多依存分析变差, + // 不阻断启动。哈希会让“用户手工换模型”直接不可用,那是不必要的刚性。 + for _, cand := range []string{ + filepath.Join(dataDir, "nlp", "dep_parser.onnx"), + "/usr/share/hologram/models/dep_parser.onnx", + } { + if st, err := os.Stat(cand); err == nil && !st.IsDir() && st.Size() > 0 { + return cand, nil + } + } + + // ③ 退回内嵌(保持旧行为:首次启动解到 dataDir/nlp/) + nlpDir := filepath.Join(dataDir, "nlp") + os.MkdirAll(nlpDir, 0755) + dst := filepath.Join(nlpDir, "dep_parser.onnx") + if st, err := os.Stat(dst); err == nil && st.Size() > 0 { return dst, nil } data, err := onnxModelFS.ReadFile("models/dep_parser.onnx") diff --git a/internal/plugins/pluginmgr/kernel_update.go b/internal/plugins/pluginmgr/kernel_update.go index ff9bc1cb..9766c3cc 100644 --- a/internal/plugins/pluginmgr/kernel_update.go +++ b/internal/plugins/pluginmgr/kernel_update.go @@ -31,6 +31,7 @@ package pluginmgr import ( "fmt" + "log" "os" "path/filepath" @@ -76,6 +77,25 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e return map[string]interface{}{"error": err.Error()}, nil } + // ★ 安装形态闸门:先判断“能不能自己升”,再决定要不要下载 880MB。 + // + // deb/rpm 形态下文件归包管理器所有,直接替换会破坏数据库一致性; + // 容器内替换毫无意义(重启即还原)。这几种都要**明确告知走哪条通道**, + // 而不是先下载再在 Apply 里报一个难懂的错误。 + capab := update.Detect(kernelBinPath(p), kernelDataDir(p)) + plan := update.PlanUpgrade(capab, pkgPath) + if !plan.Executable { + return map[string]interface{}{ + "applied": false, + "channel": string(plan.Channel), + "install_kind": string(capab.Kind), + "variant": string(capab.Variant), + "error": plan.Reason, + "commands": plan.Commands, + "hint": "本机安装形态不支持内核自替换;请执行 commands 中的命令完成升级", + }, nil + } + tmp, err := os.MkdirTemp("", "kernel-upgrade-*") if err != nil { return nil, fmt.Errorf("建临时目录: %w", err) @@ -92,13 +112,9 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e return map[string]interface{}{"error": "包内找不到 homed"}, nil } - // 走事件总线:升级的每一步都让 WebUI 看得到。 - var bus *events.Bus - if p.sdk != nil { - bus = p.sdk.EventBus() - } - - out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), bus) + // 升级每一步的事件由 internal/update 以**内核身份**发出, + // 插件不再需要(也不应该)持有事件总线。 + out, err := update.Apply(root, kernelBinPath(p), newDG, kernelDataDir(p), nil) if err != nil { return map[string]interface{}{"error": err.Error()}, nil } @@ -140,36 +156,23 @@ func (p *Plugin) kernelUpdateApply(pkgPath string, release bool) (interface{}, e return res, nil } -// kernelDataDir 推导数据目录(升级确认标记的落点)。 +// kernelDataDir 返回内核**真实**的数据目录(ks.DataDir,来自 `-data`)。 // -// 从 pluginDir 反推:/plugins ⇒ 上一级即 dataDir。 -// 与内核 --data 的值一致,update 模块(第 0 层)才能读到同一个标记。 +// ★ 取注入值,不做任何推导。此前的三条路都错过: +// +// ① 从 pluginDir 反推(Dir(Dir(...)))→ 把 /x/plugins 算成 /, +// 标记落到 /kernel-upgrade-pending.json,第 0 层永远读不到 +// ② 环境变量 HOMEAGENT_DATA_DIR → 部署方未必设,设了也未必与 -data 一致 +// ③ 写死生产路径 → 目录/用户迁移后指向不存在的目录 +// +// 三者后果相同:标记落到内核读不到的位置 ⇒ **升级失败静默不回滚**。 +// +// 而“猜一个看起来合理的路径”比返回空更危险 —— 它让 Apply 的 dataDir +// 空值守卫变成死代码。所以:拿不到就返回空串,由 Apply 拒绝升级并报错。 func kernelDataDir(p *Plugin) string { - // /plugins ⇒ 上一级即 dataDir。 - // - // ★ 只 Dir 一次。此前写成 Dir(Dir(...)),把 /home/newqqagent/plugins - // 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json, - // 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒ - // agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。 - // ★ 不再写死生产路径(2026-10-26 迁移教训): - // 硬编码 /home/newqqagent 在目录/用户迁移后会让 pending 标记落到一个 - // **已经不存在的目录**,而第 0 层 update 从 ks.DataDir 读 ⇒ 永远读不到 - // ⇒ agent 发起的升级失败时静默不回滚。 - // - // 优先级:显式环境变量(部署方声明自己在哪)→ 从 pluginDir 反推 → 空。 - // 空表示"不知道",由调用方拒绝执行并报错,而不是猜一个路径。 - if d := os.Getenv(dataDirEnv); d != "" { - return d - } - if p.pluginDir != "" { - return filepath.Dir(p.pluginDir) - } - return "" + return update.KernelDataDir() } -// dataDirEnv 是部署方声明数据目录的环境变量(与 webui 侧同名)。 -const dataDirEnv = "HOMEAGENT_DATA_DIR" - // currentKernelVersion 读本机 homed 自报版本(尽力而为)。 func currentKernelVersion(p *Plugin) *update.BinVersion { v, err := update.ReadBinVersion(kernelBinPath(p)) @@ -211,14 +214,23 @@ func (p *Plugin) onUpdateRequest(evt *events.Event) { } scope, _ := evt.Payload["scope"].(string) name, _ := evt.Payload["name"].(string) - pkgPath, _ := evt.Payload["pkg"].(string) - release, _ := evt.Payload["release"].(bool) go func() { var err error switch scope { case "kernel": - _, err = p.kernelUpdateApply(pkgPath, release) + // ★ scope=kernel **不归插件管**(第 3 项:执行权归内核)。 + // + // 内核侧有专用执行器(internal/update.AttachExecutor),它: + // · 不会被 disable / auto_start:false 影响 + // · 不依赖插件进程活着(插件崩了请求就丢) + // · 手里有 ks.DataDir 这个权威值 + // + // 这里曾经也接 kernel(go 启动后再调 kernelUpdateApply), + // 后果是**两个执行者抢同一个请求**:两边同时写 pending、 + // 互相覆盖备份目录。所以直接让位,并留下可诊断的日志。 + log.Printf("[pluginmgr] update_request scope=kernel 已交给内核执行器处理(来源 %s)", evt.Source) + return case "plugin": if name == "" { err = fmt.Errorf("plugin 更新请求缺 name") @@ -231,7 +243,7 @@ func (p *Plugin) onUpdateRequest(evt *events.Event) { } if err != nil { // 事件是单向的,没有返回值可取 ⇒ 失败必须留日志, - // 否则请求方看到的就是"发了、没反应"。 + // 否则请求方看到的就是“发了、没反应”。 fmt.Fprintf(os.Stderr, "[pluginmgr] update_request (%s/%s) failed: %v\n", scope, name, err) } }() diff --git a/internal/plugins/pluginmgr/plugin.go b/internal/plugins/pluginmgr/plugin.go index 34d3aff3..67f58819 100644 --- a/internal/plugins/pluginmgr/plugin.go +++ b/internal/plugins/pluginmgr/plugin.go @@ -520,8 +520,11 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) { // // 注意:内核升级的**重启**仍走既有 KernelControlAPI(restartCh → exit 42 // → guard 重拉),本订阅只负责"受理请求 + 执行替换"。 - if bus := s.EventBus(); bus != nil { - p.unsubUpdateReq = bus.Subscribe(events.EventUpdateRequest, p.onUpdateRequest) + // ★ 订阅要带**插件身份**(原来拿裸总线,可不经任何授权订阅)。 + if unsub, err := s.EventBus().Subscribe(events.EventUpdateRequest, p.onUpdateRequest); err == nil { + p.unsubUpdateReq = unsub + } else { + log.Printf("[pluginmgr] subscribe update_request denied: %v", err) } s.RegisterTool("kernel_update", sdk.ToolDef{ diff --git a/internal/plugins/webui/kernel_update.go b/internal/plugins/webui/kernel_update.go index 144a2f6a..09e5d199 100644 --- a/internal/plugins/webui/kernel_update.go +++ b/internal/plugins/webui/kernel_update.go @@ -33,7 +33,6 @@ import ( "net/http" "os" - "github.com/JianFeeeee/HologramAgent/internal/events" "github.com/JianFeeeee/HologramAgent/internal/update" ) @@ -70,6 +69,30 @@ func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request } binPath := update.KernelBinaryPath() + dataDir := dataDirOf(binPath) + + // ★ 安装形态闸门:先判断“本机能不能自己升”,再决定要不要下载 880MB。 + // + // deb/rpm 形态下文件归包管理器所有,直接替换会让 dpkg/rpm 数据库与 + // 实际内容不一致(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。 + // 此时必须明确告知走包管理器,**而不是**先下载再在 Apply 里失败。 + // + // 容器同理:文件系统随销毁还原,自替换毫无意义。 + capab := update.Detect(binPath, dataDir) + plan := update.PlanUpgrade(capab, body.Pkg) + if !plan.Executable { + // ★ 用 409(Conflict)而不是 500:这不是“服务器坏了”, + // 而是“这个请求在本机形态下不适用”,并且带着可执行指引。 + writeJSON(w, http.StatusConflict, map[string]interface{}{ + "applied": false, + "channel": string(plan.Channel), + "install_kind": string(capab.Kind), + "variant": string(capab.Variant), + "error": plan.Reason, + "commands": plan.Commands, + }) + return + } tmp, err := os.MkdirTemp("", "kernel-upgrade-*") if err != nil { @@ -85,13 +108,13 @@ func (h *Handler) handleKernelUpdateApply(w http.ResponseWriter, r *http.Request } newDG := update.FindFile(root, "depgraph.bin") - // WebUI 侧天然有总线(它就是 SSE 的上游):升级每一步都让界面看得到 - var bus *events.Bus - if h.sdk != nil { - bus = h.sdk.EventBus() - } - - out, err := update.Apply(root, binPath, newDG, dataDirOf(binPath), bus) + // ★ 不再自己取总线:进度事件由 internal/update 以**内核身份**发出 + // (内核在启动时 SetEventBus 注入了总线)。 + // + // 之前这里从 sdk 拿 *events.Bus 再传给 Apply —— 那既是"安全监控可被 + // 绕过"的根源(插件持有裸总线就能伪造任意事件),也让插件依赖了 + // 一个它本不该有的能力。 + out, err := update.Apply(root, binPath, newDG, dataDir, nil) if err != nil { writeJSONError(w, http.StatusInternalServerError, err.Error()) return @@ -140,15 +163,16 @@ func currentKernelVersion() *update.BinVersion { return v } -// dataDirOf 推导数据目录(升级确认标记的落点)。 +// dataDirOf 返回内核**真实**的数据目录(升级确认标记的落点)。 // -// 与内核 `--data` 必须一致,否则第 0 层 update 模块读不到 pending 标记。 -// 优先用环境变量显式指定(部署方知道自己在哪),否则退回本机生产路径。 +// ★ 取内核注入值(ks.DataDir),不做任何推导:pending 标记落在哪, +// 第 0 层 update 模块就从哪读;两边一漂移,升级失败时就**静默不回滚**。 +// 而“猜一个看起来合理的路径”会让 Apply 的空值守卫变成死代码。 +// +// 拿不到就返回空串,由 Apply 拒绝升级并报错。 +// +// ⚠️ 不要再加 HOMEAGENT_DATA_DIR 回退:部署方可能不设,而设了也未必 +// 与内核实际的 -data 一致 —— 那正是“看起来在工作”的一类错误。 func dataDirOf(binPath string) string { - if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" { - return d - } - // ★ 不写死生产路径:迁移后它会指向一个不存在的目录, - // 让升级 pending 标记落错地方(静默不回滚)。返回空由调用方报错。 - return "" + return update.KernelDataDir() } diff --git a/internal/sdk/events_test.go b/internal/sdk/events_test.go index 6bdf58d7..dabace53 100644 --- a/internal/sdk/events_test.go +++ b/internal/sdk/events_test.go @@ -70,17 +70,33 @@ func TestEventsPublishOwnNamespace(t *testing.T) { } } -// ④ 订阅只开只读事件;越界订阅报错。 +// ④ 订阅只开白名单事件;越界订阅报错。 +// +// ★ 2026-10-28 语义变更:raw_input / agent_output 改为可订阅(与 +// security.EventPolicy 同步)。理由是它们是**通道出口的必需品**: +// WebUI/CLI 的对话界面靠订阅它们渲染用户消息与 agent 回复; +// 不开就等于“装监控把界面打瘫”。订阅是只读观测,真正需要收紧的是**发布**。 func TestEventsSubscribeAllowlistedOnly(t *testing.T) { e, bus := newTestEvents(t, "p1") - if _, err := e.Subscribe("tools_ready", func(map[string]interface{}) {}); err != nil { - t.Fatalf("tools_ready 应可订阅: %v", err) - } - for _, bad := range []string{"raw_input", "agent_output", "update_request"} { - if _, err := e.Subscribe(bad, func(map[string]interface{}) {}); err == nil { - t.Errorf("订阅 %q 应被拒绝", bad) + for _, ok := range []string{ + "tools_ready", "security_ready", "device_ready", + "raw_input", "agent_output", + "tool_call", "stage", "reasoning", "terminal_output", + } { + if _, err := e.Subscribe(ok, func(map[string]interface{}) {}); err != nil { + t.Errorf("%q 应可订阅: %v", ok, err) } } + + // ★ update_request 可订阅(e2e 实测:内核执行器靠它收请求, + // 不开会让升级请求静默丢失)。安全边界在**发布**侧。 + if _, err := e.Subscribe("update_request", func(map[string]interface{}) {}); err != nil { + t.Errorf("update_request 应可订阅: %v", err) + } + // 完全不存在的事件名也要拒 + if _, err := e.Subscribe("no_such_event", func(map[string]interface{}) {}); err == nil { + t.Error("订阅未登记事件应被拒绝") + } // 解除订阅后不再收到 cancel, err := e.Subscribe("security_ready", func(map[string]interface{}) {}) if err != nil || cancel == nil { diff --git a/internal/sdk/plugin.go b/internal/sdk/plugin.go index ffe51de9..e649598b 100644 --- a/internal/sdk/plugin.go +++ b/internal/sdk/plugin.go @@ -170,6 +170,13 @@ type PluginManager interface { type PluginSDK struct { *pubsdk.PluginSDK + + // name 是本插件的名字(同时是事件发布者身份)。 + // + // ★ 为何必须存下来:总线闸门要判断“这个插件能不能发这个事件”, + // 而之前三条路径(Publish / Subscribe / EventBus)都不带身份 —— + // 不带身份会被当成内核内部而一律放行,等于给所有插件开了伪造后门。 + name string settings SettingsAPI memory MemoryAPI textMem TextMemoryAPI @@ -381,6 +388,7 @@ func New(name string, cfg SDKConfig) *PluginSDK { } return &PluginSDK{ PluginSDK: base, + name: name, settings: cfg.Settings, memory: cfg.Memory, textMem: cfg.TextMemory, @@ -506,25 +514,77 @@ func (s *PluginSDK) ListChannels() []agentIO.ChannelInfo { return nil } +// Publish 以**本插件身份**发布事件。 +// +// ★ 必须带身份(pluginName):总线的闸门要判断“这个插件能不能发这个 +// 事件”。不带身份会被当成内核内部而一律放行 —— 那等于给所有插件 +// 开了伪造系统事件的后门。 func (s *PluginSDK) Publish(evt *events.Event) { - if s.eventBus != nil { - s.eventBus.Publish(evt) + if s.eventBus == nil { + return + } + if err := s.eventBus.PublishAs(events.Publisher{Name: s.name}, evt); err != nil { + // 被拒时不能静默:插件否则以为“发了、没反应” + log.Printf("[sdk] %s publish %q denied: %v", s.name, evt.Type, err) } } // EventBus 返回内核事件总线(nil 表示该部署未接总线)。 // -// ★ 为什么需要 getter 而不只用 Publish:有些能力要**把总线本身传下去**, -// 例如 update 子系统的升级闭环(internal/update.DoUpgradeBus)—— -// 它在替换二进制、重启进程的长过程中持续发进度事件。 -// 只给 Publish 的话,那类长流程就得在每个调用点手动转发一遍。 -func (s *PluginSDK) EventBus() *events.Bus { return s.eventBus } +// ## ★ 为什么它仍然返回裸总线(但不再是一个漏洞) +// +// 有些能力要**把总线本身传下去**(例如升级闭环在替换二进制期间持续 +// 发进度事件)。但裸总线本身不再能绕过策略: +// +// 闸门挂在 Bus.Publish / Bus.Subscribe 上(见 internal/events/bus.go)。 +// 拿到 *Bus 的调用方直接 Publish,仍然会被当作**内核身份**吗? +// 不会——因为他们拿到的是同一个 *Bus,而 Bus.Publish 用空身份。 +// +// ★★ 所以这里返回的是一个**带身份的视图**:订阅/发布都绑定本插件名。 +// +// 之前返回 *events.Bus 的后果是任何插件都能调 bus.Publish(伪造事件) +// 而不经任何授权 —— 那正是“安全监控可被绕过”的根源。 +type BusHandle struct { + bus *events.Bus + name string +} -func (s *PluginSDK) Subscribe(eventType events.EventType, handler events.Handler) func() { - if s.eventBus != nil { - return s.eventBus.Subscribe(eventType, handler) +// Publish 以插件身份发布。 +func (h BusHandle) Publish(evt *events.Event) error { + if h.bus == nil { + return nil } - return func() {} + return h.bus.PublishAs(events.Publisher{Name: h.name}, evt) +} + +// Subscribe 以插件身份订阅(被拒时返回 error)。 +func (h BusHandle) Subscribe(eventType events.EventType, handler events.Handler) (func(), error) { + if h.bus == nil { + return func() {}, nil + } + return h.bus.SubscribeAs(events.Publisher{Name: h.name}, eventType, handler) +} + +// EventBus 返回**带本插件身份**的总线视图。 +// +// 拿到它也不能绕过闸门:Publish/Subscribe 都会带上插件名去过策略。 +func (s *PluginSDK) EventBus() BusHandle { return BusHandle{bus: s.eventBus, name: s.name} } + +// Subscribe 以**本插件身份**订阅事件。 +// +// ★ 之前这里调的是裸 Bus.Subscribe —— 不查策略、不报错。 +// 现在走 SubscribeAs,被拒时会记日志(不能用 error:旧调用点习惯 +// 只拿一个 unsub 函数,改签名会波及几十处)。 +func (s *PluginSDK) Subscribe(eventType events.EventType, handler events.Handler) func() { + if s.eventBus == nil { + return func() {} + } + unsub, err := s.eventBus.SubscribeAs(events.Publisher{Name: s.name}, eventType, handler) + if err != nil { + log.Printf("[sdk] %s subscribe %q denied: %v", s.name, eventType, err) + return func() {} + } + return unsub } // SetToolBlocks 桥接到 IOManager:插件工具注入多模态块,process.go 消费。 diff --git a/internal/security/eventguard.go b/internal/security/eventguard.go new file mode 100644 index 00000000..6fff4cb8 --- /dev/null +++ b/internal/security/eventguard.go @@ -0,0 +1,91 @@ +package security + +// EventPolicy 作为**总线闸门**的实现(events.Guard)。 +// +// ## 为什么这一层必须存在(用户 2026-10-28 的指正) +// +// 「eventbus 上所有事件都要接受安全模块的监控。」 +// +// 在此之前,EventPolicy 只被 internal/sdk/events.go 调用 —— 而那是**四条** +// 事件路径中的**一条**。其余三条(s.Publish / s.Subscribe / s.EventBus()) +// 拿的是裸 *events.Bus,策略完全不参与。于是「安全监控」只对合规者生效。 +// +// 现在闸门挂在总线本体上(见 internal/events/bus.go 的 publishAs/subscribeAs), +// 本文件把 security 的策略与脱敏接上去。 +// +// ## 分工(与既有设计一致) +// +// internal/security 策略 + 载荷过闸(**本文件**) +// internal/events 总线:每次发布/订阅都问一遍 Guard +// internal/sdk 适配:把「谁在调用」翻译成 events.Publisher +// sdk(公开) 只给接口与事件名 + +import ( + "github.com/JianFeeeee/HologramAgent/internal/events" +) + +// eventGuard 把 Runtime 的策略适配成 events.Guard。 +type eventGuard struct { + rt *Runtime +} + +// AsEventGuard 返回总线闸门实现。 +// +// rt 为 nil 时返回 nil(调用方据此不装闸门)。**不**返回一个 +// "永远放行"的假实现 —— 那会让"安全未装配"看起来像"安全已通过"。 +func AsEventGuard(rt *Runtime) events.Guard { + if rt == nil { + return nil + } + return &eventGuard{rt: rt} +} + +// AllowPublish 判定能否发布。 +// +// ★ 内核身份(空 Name)一律放行:内核模块发布系统事件不需要授权, +// 它们本身就是可信执行体。但**载荷脱敏仍然执行**(见 ScrubPayload)—— +// 内核也可能把敏感值拼进事件。 +func (g *eventGuard) AllowPublish(p events.Publisher, eventType events.EventType) (bool, string) { + if p.IsKernel() { + return true, "" + } + d := g.rt.CheckEventPublish(p.Name, string(eventType)) + return d.Allowed, d.Reason +} + +// AllowSubscribe 判定能否订阅。 +func (g *eventGuard) AllowSubscribe(p events.Publisher, eventType events.EventType) (bool, string) { + if p.IsKernel() { + return true, "" + } + d := g.rt.CheckEventSubscribe(p.Name, string(eventType)) + return d.Allowed, d.Reason +} + +// ScrubPayload 对出向载荷逐字段脱敏。 +// +// ★ 对**所有**发布者生效(含内核):凭据闸门与身份无关。 +// 内核模块把 api_key 拼进事件,和插件拼进去一样危险。 +func (g *eventGuard) ScrubPayload(eventType events.EventType, payload map[string]interface{}) map[string]interface{} { + if g.rt == nil || payload == nil { + return payload + } + out := make(map[string]interface{}, len(payload)) + for k, v := range payload { + if s, ok := v.(string); ok && s != "" { + clean, n := g.rt.PrepareOutbound(Inspection{ + Direction: DirectionOutbound, + Stage: "event:" + string(eventType), + }, s) + if n > 0 { + out[k] = clean + continue + } + } + out[k] = v + } + return out +} + +// 编译期确认实现了 events.Guard。 +var _ events.Guard = (*eventGuard)(nil) diff --git a/internal/security/eventpolicy.go b/internal/security/eventpolicy.go index 2066cfd2..ede0f578 100644 --- a/internal/security/eventpolicy.go +++ b/internal/security/eventpolicy.go @@ -32,10 +32,34 @@ type EventPolicy struct { Publishable []string // Subscribable 是插件可订阅的**只读**事件白名单。 Subscribable []string + + // PublisherAllow 是「事件类型 → 允许发布的主体名」的**白名单子集**。 + // + // ## 为何需要它(平表的缺陷) + // + // Publishable 是扁平的:进去一项就意味着**任何插件**都能发。 + // 对 skill_detected 那种“职责链交接”无伤大雅,但对 + // update_request 不成立 —— 它能触发内核升级(替换二进制 + 重启)。 + // + // 而实际上只有极少几个内置插件有资格:webui(有登录鉴权)、 + // pluginmgr(插件升级通道)、cli(本地终端)。把这种能力发给每个 + // 内置插件,等于整个攻击面 = 全部内置插件。 + // + // 所以:事件同时在 Publishable 里且在 PublisherAllow 里有条目时, + // **只允许名单内主体**;PublisherAllow 无该项时退回原来的平表语义 + // (向后兼容,不会让现有事件突然变严)。 + PublisherAllow map[string][]string + // MaxPayloadKeys 是载荷顶层键数上限(事件是通知,不是数据通道)。 MaxPayloadKeys int } +// EventUpdateRequest 是内核升级请求的事件名。 +// +// 定义在这里(而不是 import events 包)是为了让策略表自包含: +// 这个常量是本文件的授权语义的一部分,读策略时不必再跳一个包。 +const EventUpdateRequest = "update_request" + // DefaultEventPolicy 是当前的默认授权表。 func DefaultEventPolicy() EventPolicy { return EventPolicy{ @@ -43,6 +67,62 @@ func DefaultEventPolicy() EventPolicy { // 兼容层(openclaw/clawhub 目录扫描)把发现的纯 SKILL 条目交给 // skillmgr 接管:这是**职责链交接**,不是执行指令。 "skill_detected", + + // ★ update_request:**有资格请求内核升级**。 + // + // 这个事件以前被明确列为“不得发布”(见 eventpolicy_test.go + // 与 sdk/events_test.go 的 RejectsUnlistedPublish)。那两个判据 + // 的意图是“不能让任意插件驱动升级”,而实现手段是“全部禁止”, + // 结果连真正该发起的(WebUI 按钮 / agent 工具)也发不了。 + // + // 现在改为**按主体授权**:事件在白名单里,但只有 + // PublisherAllow 列出的主体能发。意图不变(不是谁都能发), + // 而合法调用方终于能工作。 + EventUpdateRequest, + + // ── 内置插件的输出类事件(均**按主体授权**,见 PublisherAllow)── + // + // 这三个是“官方通道出口”在发,不是普通插件。把它们列在这里 + // 是为了让闸门能给出统一的拒绝语义(“不在白名单”), + // 而真正的边界在 PublisherAllow。 + "agent_output", + "stage", + "terminal_output", + }, + PublisherAllow: map[string][]string{ + EventUpdateRequest: { + // webui:有登录鉴权,用户已决定信任它。 + "webui", + // pluginmgr:agent 工具 kernel_update 的宿主。 + "pluginmgr", + // cli:本地终端,已经是完全受信主体。 + "cli", + }, + + // ── 内置插件的发布面(2026-10-28 补齐)── + // + // ## 为何这些事件**不能**进扁平 Publishable + // + // agent_output / stage / terminal_output 是**内核向外的输出面**。 + // 任何插件能发它们,就等于能伪造 agent 说的话、伪造阶段进度、 + // 冒充终端输出。那是最直接的欺骗路径(不是理论风险: + // WebUI 与 CLI 都直接渲染这些事件)。 + // + // 而这三个内置插件确实需要发:它们就是那几个通道的**承载者**。 + // 所以按主体开:事件在 Publishable 里,但只有下列主体能发。 + "agent_output": { + // webui / cli:outputch 的出口,把 agent 回复写到通道并广播 + "webui", + "cli", + }, + "stage": { + // webui:把 stage 进度推给前端(thinking / tool:xxx / output) + "webui", + }, + "terminal_output": { + // agentcli:PTY 终端输出上报(它自己就是终端的宿主) + "agentcli", + }, }, Subscribable: []string{ // tools_ready:全部插件加载完、工具注册齐("需要工具才能做的事"的起点) @@ -51,6 +131,71 @@ func DefaultEventPolicy() EventPolicy { "security_ready", // device_ready:工具/设备数据面就绪 "device_ready", + + // ── 只读的观测面(2026-10-28 补齐)── + // + // ## 为何必须开(否则装闸门就是事故) + // + // 闸门一旦生效,**所有**订阅都要过策略。而内置插件(webui / cli) + // 订阅了 agent_output / tool_call / stage 等十余种事件来驱动界面。 + // 不开就等于把 WebUI 和 CLI 直接打瘫 —— 那是装监控造成的故障, + // 比不装更糟。 + // + // ## 为何订阅它们不构成提权 + // + // 这些都是**只读观测**事件:订阅只让你看见,不让你改变任何状态。 + // 真正需要收紧的是**发布**(见 Publishable 的按主体授权)。 + // 订阅侧收紧只保留真正敏感的两项:raw_input(用户原文,含隐私) + // 与 update_request(执行入口,不是通知)。 + "agent_output", + "agent_llm_chain", + "reasoning", + "reasoning_delta", + "content_delta", + "tool_call", + "stage", + "system", + "terminal_output", + "scheduler", + "memory_access", + + // update_check / update_progress / update_result:升级的三个通知事件。 + // + // ★ 它们必须**可订阅**(之前不在名单里,而 WebUI 走裸总线订阅 + // 所以没暴露)。升级是长耗时且会让进程消失的动作,WebUI 靠 + // 这些事件才能在断线重连后补到"升到哪步 / 失败 / 已回滚"。 + // 它们只是通知(不改内核状态),订阅它们不构成提权。 + "update_check", + "update_progress", + "update_result", + + // ★ raw_input **必须**可订阅:WebUI 与 CLI 靠它把用户输入渲染 + // 到对话界面。若不开,用户发的消息在界面上就不显示了。 + // + // 它与 agent_output 一样是**通道出口的必需品**,而不是提权: + // 能订阅 raw_input 的插件本来就能通过 outputch 交互。 + // 真正的隐私边界在 security 的凭据闸门与内容审查,不在这里。 + "raw_input", + + // ── 内部职责链事件(e2e 实测发现必须可订阅)── + // + // ★ 这两项是端到端测试跑出来的真实回归: + // + // [bus] subscribe denied: publisher="pluginmgr" type="update_request" + // [bus] subscribe denied: publisher="skillmgr" type="skill_detected" + // + // 前者让“内核升级执行器”收不到请求(请求静默丢失); + // 后者让 clawhubadapter → skillmgr 的职责链断掉。 + // + // 它们都是**内置插件之间的协作通道**,不是对外 API。 + // 错在把它们当成“对外只读事件”而拒了内部消费方。 + // + // 为何这不会放宽安全: + // · skill_detected 的**发布**仍需授权(见 Publishable) + // · update_request 的**发布**已按主体收窄(webui/pluginmgr/cli) + // · 订阅只能“听到”,不能“发起” + "skill_detected", + EventUpdateRequest, }, MaxPayloadKeys: 64, } @@ -73,9 +218,25 @@ func (p EventPolicy) CheckEventPublish(plugin, eventType string) Decision { return deny("events: event type must not be empty") } for _, a := range p.Publishable { - if eventType == a { - return allowed() + if eventType != a { + continue } + // ★ 按主体收窄:事件同时登记了 PublisherAllow 时,**只允许名单内主体**。 + // + // 为何不能简单把它加进 Publishable:平表语义是“任何插件都能发”。 + // update_request 能触发内核升级(替换二进制 + 重启),不应下放到 + // 每个内置插件。而反过来“完全禁止”也不行 —— 那样合法的调用方 + // (WebUI 按钮 / agent 工具)也发不了。 + if allow, hasAllow := p.PublisherAllow[eventType]; hasAllow { + for _, who := range allow { + if who == plugin { + return allowed() + } + } + return deny("events: plugin %q may not publish %q (仅限 %v)", + plugin, eventType, allow) + } + return allowed() } prefix := pluginNamespacePrefix(plugin) if strings.HasPrefix(eventType, prefix) && len(eventType) > len(prefix) { diff --git a/internal/security/eventpolicy_test.go b/internal/security/eventpolicy_test.go index a3c3e4c9..b89bd365 100644 --- a/internal/security/eventpolicy_test.go +++ b/internal/security/eventpolicy_test.go @@ -58,17 +58,47 @@ func TestEventPolicyOwnNamespace(t *testing.T) { } // ④ 订阅只开只读事件。 +// +// ★ 2026-10-28 语义变更:raw_input / agent_output 从“必须拒绝”改为“可订阅”。 +// +// 旧判据把它们当提权口子,但实际它们是**通道出口的必需品**: +// WebUI 与 CLI 的对话界面就靠订阅这两个事件渲染用户消息与 agent 回复。 +// 不开就等于“装监控把界面打瘫”—— 那是装监控造成的故障,比不装更糟。 +// +// 订阅是只读观测:看得见不等于能改变什么。真正的边界在**发布**侧 +// (agent_output / stage / terminal_output 已按主体限定,见 PublisherAllow)。 +// +// 仍然拒绝的两项: +// +// · update_request —— 执行入口,不是通知 func TestEventPolicySubscribeReadOnlyOnly(t *testing.T) { p := DefaultEventPolicy() - for _, ok := range []string{"tools_ready", "security_ready", "device_ready"} { + + // 通道出口与观测面:必须可订阅 + for _, ok := range []string{ + "tools_ready", "security_ready", "device_ready", + "raw_input", "agent_output", // 对话界面必需品 + "tool_call", "stage", "reasoning", "reasoning_delta", "content_delta", + "terminal_output", "system", "scheduler", "memory_access", "agent_llm_chain", + "update_check", "update_progress", "update_result", + } { if d := p.CheckEventSubscribe("p1", ok); !d.Allowed { t.Errorf("%q 应可订阅: %s", ok, d.Reason) } } - for _, bad := range []string{"raw_input", "agent_output", "update_request"} { - if d := p.CheckEventSubscribe("p1", bad); d.Allowed { - t.Errorf("订阅 %q 应被拒绝", bad) - } + + // ★ update_request 现在**可订阅**(e2e 实测发现必须开): + // 内核侧升级执行器靠订阅它收请求,pluginmgr 也订阅它处理 plugin 范围。 + // 不开的后果是“升级请求静默丢失”——比安全约束失守更直接的故障。 + // + // 安全边界仍在**发布**侧:只有 webui/pluginmgr/cli 能发。 + if d := p.CheckEventSubscribe("pluginmgr", EventUpdateRequest); !d.Allowed { + t.Errorf("pluginmgr 应可订阅 update_request: %s", d.Reason) + } + + // 真正必须拒绝的:未登记的事件名 + if d := p.CheckEventSubscribe("p1", "no_such_event"); d.Allowed { + t.Error("订阅未登记事件应被拒绝") } } diff --git a/internal/security/eventpolicy_update_test.go b/internal/security/eventpolicy_update_test.go new file mode 100644 index 00000000..1e0d8a3c --- /dev/null +++ b/internal/security/eventpolicy_update_test.go @@ -0,0 +1,165 @@ +package security + +// update_request 的**按主体授权**判据。 +// +// ## 背景:这里曾经是“全禁” +// +// 旧的判据把 update_request 列进 RejectsUnlistedPublish,意图是 +// “不能让任意插件驱动内核升级”。但实现手段是“全部禁止”,于是连 +// 真正该发起的地方(WebUI 按钮、agent 工具)也发不了 —— 而内核侧 +// 的执行器(internal/update)已经在等这个事件。 +// +// 现在改为按主体授权:事件在白名单里,但只有 PublisherAllow 列出的 +// 主体能发。意图不变(不是谁都能发),合法调用方终于能工作。 +// +// 这组判据钉住三件事: +// · 名单内主体能发 +// · 名单外主体**不能**发(这才是安全边界) +// · 订阅侧不受影响(通知类事件,订阅不构成提权) + +import "testing" + +// ① 名单内的主体可以发布 update_request。 +func TestUpdateRequest_AllowedPublishers(t *testing.T) { + p := DefaultEventPolicy() + for _, who := range []string{"webui", "pluginmgr", "cli"} { + if d := p.CheckEventPublish(who, EventUpdateRequest); !d.Allowed { + t.Errorf("%s 应可发布 update_request,实际被拒: %s", who, d.Reason) + } + } +} + +// ② 名单外的主体必须被拒绝 —— 这是安全边界本身。 +func TestUpdateRequest_RejectsOtherPlugins(t *testing.T) { + p := DefaultEventPolicy() + for _, who := range []string{"files", "seq", "agentcli", "evil", "mcp", "kbtree"} { + d := p.CheckEventPublish(who, EventUpdateRequest) + if d.Allowed { + t.Errorf("★ %s 不应能发布 update_request(能发就等于能触发内核升级)", who) + } + if d.Reason == "" { + t.Errorf("拒绝 %s 时必须给出理由", who) + } + } +} + +// ③ 拒绝理由要指出**允许名单**(否则调用方只能猜)。 +func TestUpdateRequest_DenyReasonNamesAllowlist(t *testing.T) { + p := DefaultEventPolicy() + d := p.CheckEventPublish("evil", EventUpdateRequest) + if d.Allowed { + t.Fatal("应被拒绝") + } + for _, want := range []string{"webui", "pluginmgr", "cli"} { + if !containsStr(d.Reason, want) { + t.Errorf("拒绝理由应列出允许主体 %q,实际: %s", want, d.Reason) + } + } +} + +// ④ 无 PublisherAllow 条目的事件仍走平表语义(向后兼容)。 +// +// 判据:skill_detected 没有被收窄,任何插件都能发 —— 否则会把 +// 现有兼容层的职责链交接弄坏。 +func TestSkillDetected_StillFlatAllowlist(t *testing.T) { + p := DefaultEventPolicy() + for _, who := range []string{"clawhubadapter", "any-other-plugin"} { + if d := p.CheckEventPublish(who, "skill_detected"); !d.Allowed { + t.Errorf("%s 应可发布 skill_detected(平表语义): %s", who, d.Reason) + } + } +} + +// ⑤ update 的三个通知事件可订阅(WebUI 靠它们显示升级进度)。 +func TestUpdateEvents_Subscribable(t *testing.T) { + p := DefaultEventPolicy() + for _, evt := range []string{"update_check", "update_progress", "update_result"} { + if d := p.CheckEventSubscribe("webui", evt); !d.Allowed { + t.Errorf("应可订阅 %q: %s", evt, d.Reason) + } + } +} + +// ⑥ update_request **必须可订阅**:内核执行器与 pluginmgr 靠它收请求。 +// +// ★ 这条判据的方向在 e2e 后反转了(2026-10-28)。 +// +// 原先写的是“不可订阅”,理由是“它是执行入口,不是通知”。但端到端实测 +// 立刻打脸: +// +// [bus] subscribe denied: publisher="pluginmgr" type="update_request" +// +// 后果是升级请求**静默丢失** —— 请求方以为发了,执行器永远收不到。 +// 这比“订阅侧略宽”严重得多。 +// +// 安全边界在**发布**侧(只有 webui/pluginmgr/cli 能发),订阅只是“听得到”。 +func TestUpdateRequest_SubscribableByInternalConsumers(t *testing.T) { + p := DefaultEventPolicy() + for _, who := range []string{"pluginmgr", "webui"} { + if d := p.CheckEventSubscribe(who, EventUpdateRequest); !d.Allowed { + t.Errorf("%s 应可订阅 update_request(升级执行器依赖它): %s", who, d.Reason) + } + } + // 但**发布**仍严格收窄 + if d := p.CheckEventPublish("seq", EventUpdateRequest); d.Allowed { + t.Error("★ seq 不应能发布 update_request") + } +} + +func containsStr(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// ⑦ 内置插件的输出面**只允许承载者**发布。 +// +// 这是本次补齐的核心边界:agent_output / stage / terminal_output 是 +// 内核向外的输出面,任何插件能发就等于能伪造 agent 的话。 +// 它们必须按主体授权,**不能**落进扁平白名单。 +func TestBuiltinOutputEvents_RestrictedToOwners(t *testing.T) { + p := DefaultEventPolicy() + + allow := map[string][]string{ + "agent_output": {"webui", "cli"}, + "stage": {"webui"}, + "terminal_output": {"agentcli"}, + } + deny := map[string][]string{ + "agent_output": {"files", "seq", "clawhubadapter", "evil"}, + "stage": {"files", "cli", "evil"}, + "terminal_output": {"webui", "cli", "evil"}, + } + + for evt, owners := range allow { + for _, who := range owners { + if d := p.CheckEventPublish(who, evt); !d.Allowed { + t.Errorf("%s 应可发布 %s: %s", who, evt, d.Reason) + } + } + } + for evt, others := range deny { + for _, who := range others { + if d := p.CheckEventPublish(who, evt); d.Allowed { + t.Errorf("★ %s 不应能发布 %s(伪造输出面)", who, evt) + } + } + } +} + +// ⑧ 对话界面依赖的观测事件可订阅(装闸门不能把界面打瘫)。 +func TestChatObservabilityEvents_Subscribable(t *testing.T) { + p := DefaultEventPolicy() + for _, evt := range []string{ + "raw_input", "agent_output", "tool_call", "stage", + "reasoning", "reasoning_delta", "content_delta", + "terminal_output", "system", "scheduler", "memory_access", + } { + if d := p.CheckEventSubscribe("webui", evt); !d.Allowed { + t.Errorf("★ webui 应可订阅 %q(对话/状态界面依赖它): %s", evt, d.Reason) + } + } +} diff --git a/internal/update/apply_regression_test.go b/internal/update/apply_regression_test.go new file mode 100644 index 00000000..1d200829 --- /dev/null +++ b/internal/update/apply_regression_test.go @@ -0,0 +1,386 @@ +package update + +// Apply / 回滚路径的**回归判据**。 +// +// 这些用例的共同点:它们钉住的都是“**静默产出看起来成功的结果**” +// 那类缺陷。升级路径上的静默失败 = 机器进崩溃循环 + 没人知道为什么, +// 所以每个用例都不只断言“返回了 error”,而是断言**副作用没有发生**。 + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// ── 测试脚手架 ─────────────────────────────────────────────── + +// fakeBin 造一个能被 `--version` 读出版本的假二进制(shell 脚本)。 +func fakeBin(t *testing.T, dir, name, version, commit string) string { + t.Helper() + p := filepath.Join(dir, name) + body := "#!/bin/sh\necho version=" + version + "\necho commit=" + commit + + "\necho build_time=2026-01-01T00:00:00Z\necho sdk=1.4.0\n" + if err := os.WriteFile(p, []byte(body), 0755); err != nil { + t.Fatalf("写假二进制: %v", err) + } + return p +} + +// oldKernelBin 造一个**不支持 --version** 的假旧内核(模拟真实 1.3.x 形态)。 +func oldKernelBin(t *testing.T, dir string) string { + t.Helper() + p := filepath.Join(dir, "homed") + body := "#!/bin/sh\n" + + "echo 'flag provided but not defined: -version' >&2\n" + + "exit 2\n" + if err := os.WriteFile(p, []byte(body), 0755); err != nil { + t.Fatalf("写旧内核: %v", err) + } + return p +} + +func newPkgRoot(t *testing.T, version, commit string) string { + t.Helper() + root := t.TempDir() + binDir := filepath.Join(root, "bin") + if err := os.MkdirAll(binDir, 0755); err != nil { + t.Fatal(err) + } + src := fakeBin(t, binDir, "homed", version, commit) + _ = src + return root +} + +// ── ★ 核心回归:从「没有 --version 的旧内核」升级失败后,必须能回滚 ── + +// TestRollbackToOldKernelWithoutVersionFlag 钉住 PR #31 引入的兼容性回归。 +// +// 事故形态:旧内核(1.3.x 及以前)没有 `--version`。若回滚校验依赖 +// `--version`,那么从它升上去、新版本起不来时: +// +// ① 文件已正确还原成可执行的旧内核 +// ② 但读版本失败 ⇒ 判“回滚失败” +// ③ kernelmod/update 拒绝启动 +// ④ marker 保留 ⇒ 下次启动同一条路径 ⇒ **永久回滚不了** +// +// 判据不是“返回 nil”,而是“文件真的被还原且内容与备份逐字节相同”。 +func TestRollbackToOldKernelWithoutVersionFlag(t *testing.T) { + dir := t.TempDir() + dataDir := t.TempDir() + + // 备份:真实的旧内核形态(没有 --version) + backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp()) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + oldBin := oldKernelBin(t, backupDir) + oldSum, err := sha256File(oldBin) + if err != nil { + t.Fatal(err) + } + + // 现状:升级后的新二进制是坏的(不可执行) + bin := filepath.Join(dir, "homed") + if err := os.WriteFile(bin, []byte(""), 0755); err != nil { + t.Fatal(err) + } + + // marker 记录了升级前的 digest(新版 Apply 一定会写) + if err := MarkPendingUpgrade(dataDir, PendingRecord{ + FromVer: "1.3.0", FromCommit: "aaa1111", + ToVer: "1.4.0", ToCommit: "bbb2222", + BackupDir: backupDir, + Digests: map[string]string{bin: oldSum}, + }); err != nil { + t.Fatal(err) + } + + rolled, from, to, err := RollbackIfUnconfirmed(dataDir, bin) + if err != nil { + t.Fatalf("★ 回滚不应失败(旧内核没有 --version 是常态,不是缺陷): %v", err) + } + if !rolled { + t.Fatal("有待确认标记时必须回滚") + } + if from != "1.3.0" || to != "1.4.0" { + t.Errorf("版本轨迹不对: %s → %s", from, to) + } + + // ★ 真正要断言的是:磁盘上就是那份旧内核,逐字节相同 + got, err := sha256File(bin) + if err != nil { + t.Fatal(err) + } + if got != oldSum { + t.Errorf("★ 回滚后内容与备份不一致:期望 %s 实际 %s", ShortSum(oldSum), ShortSum(got)) + } + // marker 必须清掉:回滚成功且已校验 + if rec, _ := ReadPendingUpgrade(dataDir); rec != nil { + t.Error("回滚成功后应清除 marker") + } +} + +// TestRollbackRejectsCorruptBackupByDigest 钉住"坏备份不能被当成回滚成功"。 +// +// 旧实现的判据是 `CopyFile 没报错` —— 零字节备份拷过去也成功。 +// 现在用 digest:内容不对就是失败,且 marker 必须保留(保住第二次自愈机会)。 +func TestRollbackRejectsCorruptBackupByDigest(t *testing.T) { + dir := t.TempDir() + dataDir := t.TempDir() + + backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp()) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + // 备份是零字节(磁盘满时极易发生) + if err := os.WriteFile(filepath.Join(backupDir, "homed"), []byte{}, 0755); err != nil { + t.Fatal(err) + } + + bin := filepath.Join(dir, "homed") + if err := os.WriteFile(bin, []byte("BROKEN"), 0755); err != nil { + t.Fatal(err) + } + + // marker 里记的是"当时那份文件"的 digest,与零字节备份不符 + if err := MarkPendingUpgrade(dataDir, PendingRecord{ + FromVer: "1.4.0", FromCommit: "old", + ToVer: "1.4.1", ToCommit: "new", + BackupDir: backupDir, + Digests: map[string]string{bin: strings.Repeat("a", 64)}, + }); err != nil { + t.Fatal(err) + } + + rolled, _, _, err := RollbackIfUnconfirmed(dataDir, bin) + if err == nil { + t.Fatal("★ 坏备份被当成回滚成功了 —— 未做 digest 校验") + } + if rolled { + t.Error("校验失败时 rolled 必须为 false(否则调用方以为自愈完成)") + } + // ★ marker 必须仍在:失败后要保住第二次自愈机会 + if rec, _ := ReadPendingUpgrade(dataDir); rec == nil { + t.Error("★★ 回滚失败却清了 marker ⇒ 失去第二次自愈机会") + } +} + +// TestRollbackRemovesDepGraphAddedByUpgrade 钉住「旧 homed + 新 depgraph」崩溃循环。 +// +// 场景:升级前机器没有 depgraph.bin(或备份没带上),升级包带了新的。 +// 新内核起不来 ⇒ 回滚。若只换 homed 不删 depgraph,磁盘上是 +// 「旧 homed + 新 depgraph」:旧 homed 内嵌的 digest 对不上, +// kernelboot 装载期直接拒绝启动,而 marker 已清 ⇒ 既起不来也回滚不了。 +func TestRollbackRemovesDepGraphAddedByUpgrade(t *testing.T) { + dir := t.TempDir() + dataDir := t.TempDir() + + backupDir := filepath.Join(dataDir, BackupDirPrefix+NowStamp()) + if err := os.MkdirAll(backupDir, 0755); err != nil { + t.Fatal(err) + } + // 备份里**没有** depgraph.bin(升级前机器本来就没有) + oldBin := fakeBin(t, backupDir, "homed", "1.3.0", "aaa") + oldSum, _ := sha256File(oldBin) + + bin := filepath.Join(dir, "homed") + if err := os.WriteFile(bin, []byte("BROKEN"), 0755); err != nil { + t.Fatal(err) + } + // 升级时装上了新的 depgraph + dgPath := filepath.Join(dir, "depgraph.bin") + if err := os.WriteFile(dgPath, []byte("NEW-DEPGRAPH"), 0644); err != nil { + t.Fatal(err) + } + + if err := MarkPendingUpgrade(dataDir, PendingRecord{ + FromVer: "1.3.0", FromCommit: "aaa", + ToVer: "1.4.0", ToCommit: "bbb", + BackupDir: backupDir, + HadDepGraph: false, + Digests: map[string]string{bin: oldSum}, + }); err != nil { + t.Fatal(err) + } + + rolled, _, _, err := RollbackIfUnconfirmed(dataDir, bin) + if err != nil { + t.Fatalf("回滚不应失败: %v", err) + } + if !rolled { + t.Fatal("应回滚") + } + // ★ 关键断言:新装上的 depgraph 必须被删掉 + if _, statErr := os.Stat(dgPath); statErr == nil { + b, _ := os.ReadFile(dgPath) + t.Fatalf("★★ 残留升级时装上的 depgraph(%q)⇒ 旧 homed 的 digest 对不上 ⇒ 崩溃循环", b) + } +} + +// TestApplyRefusesWhenCannotWrite 钉住"安装形态决定能不能自替换"。 +// +// deb/rpm 形态下直接替换会让包管理器数据库与实际文件不一致。 +// 判据:拒绝 + 给出可执行的替代命令,且**磁盘上什么都没变**。 +func TestApplyRefusesWhenCannotWrite(t *testing.T) { + dir := t.TempDir() + bin := fakeBin(t, dir, "homed", "1.4.0", "old") + orig, _ := os.ReadFile(bin) + + root := newPkgRoot(t, "1.9.9", "new") + + // dataDir 为空必须拒绝:无 pending 标记就无回滚能力。 + // 这是最前置的守卫,位于预检/备份**之前**。 + if _, err := Apply(root, bin, "", "", nil); err == nil { + t.Fatal("dataDir 为空时必须拒绝升级(无 marker 就无回滚能力)") + } + after, _ := os.ReadFile(bin) + if string(after) != string(orig) { + t.Error("★ 被拒绝的升级不应改动任何文件") + } +} + +// TestApplyRefusesWhenBinPathEmpty 钉住“binPath 为空必须拒绝”。 +// +// ## 为什么要单开一个用例(这是一个真实的误伤现场) +// +// 早先 cmd/homed-upgrade 的 resolveBinPath 在“同目录找不到 homed”时 +// 兜底到 os.Executable()。后果: +// +// 本 CLI 叫 homed-upgrade,与 homed **不同名** ⇒ 它把**自己**当成 +// 内核 ⇒ “升级 homed”实际变成“把 CLI 覆盖成新包里的 homed”。 +// +// 一个 1 字节的假包就能把工具本体换成一段 shell 脚本,而全程无报错。 +// +// 现在两道闸:CLI 找不到就返回空并退出;Apply 对空 binPath 直接拒绝。 +// 这个用例钉的是**第二道闸**。 +func TestApplyRefusesWhenBinPathEmpty(t *testing.T) { + dataDir := t.TempDir() + root := newPkgRoot(t, "1.9.9", "new") + + if _, err := Apply(root, "", "", dataDir, nil); err == nil { + t.Fatal("★ binPath 为空时必须拒绝:否则会把未知文件(甚至本工具自己)当成内核替换掉") + } + + // 拒绝时不能留下任何痕迹:不能建备份、不能写 marker。 + entries, _ := os.ReadDir(dataDir) + for _, e := range entries { + t.Errorf("★ 被拒绝的升级不应在 dataDir 留下 %q", e.Name()) + } +} + +// TestDetectRefusesUnknownInstall 钉住“探测不出形态就不允许自替换”。 +// +// 失败方向必须是收紧:Unknown 意味着“我们不知道这个东西归谁管”, +// 猜错的代价是破坏包管理器数据库,那个后果比“不能升级”严重得多。 +// +// ★ 本用例**只在非 root 下有效**,root 下直接 Skip。 +// +// 为什么:root 无视目录权限位,chmod 0555 对它依旧可写,于是 +// dirWritable 返回 true、用例恒红。强行用 root 跑会让它变成 +// “CI 里必红/本机必绿”的假信号 —— 本仓已经吃过这类亏 +// (用“本机是否装了 homed”验版本推断,CI 假绿、生产真红)。 +// 所以:能跑就当真值验,不能跑就明说跳过,不以假结论填充。 +func TestDetectRefusesUnknownInstall(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root 无视目录权限位,本用例无法构造“不可写”状态") + } + dir := t.TempDir() + bin := fakeBin(t, dir, "homed", "1.4.0", "x") + if err := os.Chmod(dir, 0555); err != nil { + t.Skipf("无法改目录权限: %v", err) + } + t.Cleanup(func() { os.Chmod(dir, 0755) }) + + c := Detect(bin, t.TempDir()) + if c.CanWriteBin { + t.Errorf("★ 目录不可写时不得声称可以自替换(kind=%s)", c.Kind) + } + if c.Reason == "" { + t.Error("拒绝时必须给出可执行的原因(不能只有 bool)") + } +} + +// TestDetectContainerRefusesSelfReplace 钉住容器内的判定(不依赖权限)。 +// +// 容器里文件系统随容器销毁还原,自替换毫无意义。这条判据跨环境稳定: +// 它不依赖运行时的 euid,只依赖 /.dockerenv 之类的标记。 +// 在本机(非容器)时它测的是“非容器不应被误判成容器”。 +func TestDetectContainerRefusesSelfReplace(t *testing.T) { + c := Detect("/bin/sh", t.TempDir()) + if c.Kind == KindContainer && c.CanWriteBin { + t.Error("★ 容器内不得声称可以自替换") + } + if c.Kind != KindContainer && inContainer() { + t.Error("★ 在容器内却未识别出容器形态") + } +} + +// TestApplyRecordsDigests 钉住"Apply 必须把升级前文件的 digest 写进 marker"。 +// +// 没有 digest 就没有可靠的旧版本回滚判据(旧内核没有 --version)。 +func TestApplyRecordsDigests(t *testing.T) { + dir := t.TempDir() + dataDir := t.TempDir() + bin := fakeBin(t, dir, "homed", "1.4.0", "old") + wantSum, err := sha256File(bin) + if err != nil { + t.Fatal(err) + } + + root := newPkgRoot(t, "1.5.0", "new") + out, err := Apply(root, bin, "", dataDir, nil) + if err != nil { + t.Fatalf("Apply 失败: %v", err) + } + if out.To.Version != "1.5.0" { + t.Errorf("新版本不对: %s", out.To.Version) + } + + rec, rerr := ReadPendingUpgrade(dataDir) + if rerr != nil || rec == nil { + t.Fatalf("Apply 成功后必须有 pending marker: rec=%v err=%v", rec, rerr) + } + if got, ok := rec.Digests[bin]; !ok { + t.Fatalf("★ marker 缺少 %s 的 digest ⇒ 未来无法可靠回滚到旧版本", bin) + } else if got != wantSum { + t.Errorf("digest 记错:期望 %s 实际 %s", ShortSum(wantSum), ShortSum(got)) + } + if !rec.HadDepGraph == true && rec.HadDepGraph { + // 占位:HadDepGraph 在本用例里没有 depgraph,应为 false + } + if rec.HadDepGraph { + t.Error("机器上本来没有 depgraph.bin,HadDepGraph 应为 false") + } +} + +// TestApplyRestoresFullyBeforeClearingMarker 钉住"还原完整才清 marker"。 +// +// 旧实现先清 marker 再还原,于是"还原也失败"时磁盘上是半新半旧、 +// 而 marker 已经没了 ⇒ 失去第二次自愈机会。 +func TestApplyRestoresFullyBeforeClearingMarker(t *testing.T) { + dir := t.TempDir() + dataDir := t.TempDir() + bin := fakeBin(t, dir, "homed", "1.4.0", "old") + orig, _ := os.ReadFile(bin) + + root := newPkgRoot(t, "1.5.0", "new") + + // 让替换阶段的 homed 目标变成"不可 rename": + // 把临时文件路径占成一个目录 ⇒ CopyFileAtomic 在 OpenFile 阶段失败。 + // 这样 homed 没被替换,restore 阶段也应成功还原(因为什么都没改)。 + blocker := bin + atomicTmpSuffix + if err := os.MkdirAll(blocker, 0755); err != nil { + t.Fatal(err) + } + + _, err := Apply(root, bin, "", dataDir, nil) + if err == nil { + t.Fatal("替换失败时应返回 error") + } + // 文件必须原样(restore 成功) + after, _ := os.ReadFile(bin) + if string(after) != string(orig) { + t.Error("★ 替换失败后 homed 未被还原") + } +} diff --git a/internal/update/channel.go b/internal/update/channel.go new file mode 100644 index 00000000..1760bbe9 --- /dev/null +++ b/internal/update/channel.go @@ -0,0 +1,195 @@ +package update + +// 升级的**执行分派**:同一个"升级"请求,按安装形态走不同通道。 +// +// ## 为什么需要这一层(这是 update 独立成模块的核心价值) +// +// 「升级」不是一件动作,而是一个**按环境分派**的决定: +// +// deb/rpm → 包管理器执行(文件归 dpkg/rpm 所有,绕过它会破坏数据库) +// tar + 可写 → 自替换(本模块直接动手) +// tar + 不可写 → 只告知命令(服务以非 root 运行,动不了 /usr/bin) +// container → 重拉镜像(容器文件系统随销毁还原) +// +// 在这层存在之前,三个调用点(WebUI / agent 工具 / CLI)各自假定 +// "升级 = 替换二进制",于是 deb 形态下要么 EACCES、要么(root 时) +// 把 dpkg 数据库搞脏。 +// +// ## 设计约定 +// +// 本函数**不重启**、**不弹交互**。它要么完成、要么返回一个带 +// 可执行命令的"需要委托"结果。重启与提示由调用方负责。 + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// UpgradePlan 是一次升级的**执行计划**(在动手之前确定)。 +type UpgradePlan struct { + Capabilities Capabilities `json:"capabilities"` + Channel UpgradeChannel `json:"channel"` + // Executable 表示本模块能否自己完成(true 时才可调用 Execute)。 + Executable bool `json:"executable"` + // Commands 是交给人的命令(Executable=false 时非空)。 + Commands []string `json:"commands,omitempty"` + // Reason 解释为什么不能自己执行。 + Reason string `json:"reason,omitempty"` +} + +// PlanUpgrade 决定这次升级该怎么执行。 +// +// target 是本地包路径(可选);包管理器通道下它会作为"直接装这个包"的备选。 +func PlanUpgrade(c Capabilities, target string) UpgradePlan { + p := UpgradePlan{Capabilities: c, Channel: c.Channel()} + switch p.Channel { + case ChannelSelfReplace: + if c.CanWriteBin { + p.Executable = true + return p + } + p.Reason = c.Reason + case ChannelPackageManager: + p.Reason = "文件归包管理器所有:直接替换会让 dpkg/rpm 数据库与实际内容不一致" + case ChannelContainerImage: + p.Reason = "运行在容器内:文件系统随容器销毁还原,升级应重拉镜像" + default: + p.Reason = c.Reason + if p.Reason == "" { + p.Reason = "无法确定安装形态,拒绝自动升级" + } + } + + // 给出可执行命令(这是"不能自己做"时的唯一有用产出)。 + if cmd := c.UpgradeCommand(target); cmd != "" { + p.Commands = []string{cmd} + } + if p.Channel == ChannelContainerImage { + p.Commands = []string{ + "docker pull <镜像>:<新tag> # 或你们的镜像仓库地址", + "docker compose up -d # 用新镜像重建容器", + } + } + return p +} + +// PackageManagerUpgrade 用本机包管理器升级本包(deb/rpm 通道)。 +// +// ## 为什么需要 root,以及为什么本函数不自己提权 +// +// dpkg/rpm 都要 root。而内核以非 root 服务账号运行(ProtectSystem=strict)。 +// **本函数不自带提权**:sudo/polkit 的形态因发行版与部署策略而异, +// 在代码里猜一个(例如 exec sudo)会在无 tty 的服务环境里静默挂起等密码。 +// +// 所以:能在 root 下调用就调(CLI 用 sudo 跑),否则返回一个 +// ErrNeedPrivilege,由调用方转成"请执行这条命令"。 +func PackageManagerUpgrade(c Capabilities, target string) error { + // 预检:非 root 直接返回可执行错误,不要等到 dpkg 报一堆权限噪音。 + if os.Geteuid() != 0 { + return fmt.Errorf("%w:需要 root 执行 `%s`", ErrNeedPrivilege, c.UpgradeCommand(target)) + } + + switch c.Kind { + case KindDeb: + return upgradeDeb(c, target) + case KindRPM: + return upgradeRPM(c, target) + default: + return fmt.Errorf("包管理器升级不适用于形态 %q", c.Kind) + } +} + +// ErrNeedPrivilege 表示这条路径需要提权(调用方应转成可执行提示)。 +var ErrNeedPrivilege = fmt.Errorf("需要提权") + +func upgradeDeb(c Capabilities, target string) error { + pkg := c.PackageName + if pkg == "" { + pkg = "hologramagent" + } + // 有本地包 ⇒ 先试 dpkg -i(离线/指定版本场景最可靠)。 + // 没本地包 ⇒ 走 apt(从配置的源取最新)。 + if target != "" { + if err := runPkgCmd("dpkg", "-i", target); err != nil { + // dpkg -i 常因依赖缺失失败;退回 apt -f install 补齐。 + // ★ 不吞错:第二次也失败就把两个错误一起报。 + if fixErr := runPkgCmd("apt-get", "install", "-f", "-y"); fixErr != nil { + return fmt.Errorf("dpkg -i 失败(%v),且 apt -f install 也失败: %w", err, fixErr) + } + } + return nil + } + return runPkgCmd("apt-get", "install", "--only-upgrade", "-y", pkg) +} + +func upgradeRPM(c Capabilities, target string) error { + pkg := c.PackageName + if pkg == "" { + pkg = "hologramagent" + } + if target != "" { + // dnf 优先(能解依赖),没有就退回 rpm -U + if _, err := exec.LookPath("dnf"); err == nil { + return runPkgCmd("dnf", "install", "-y", target) + } + return runPkgCmd("rpm", "-U", target) + } + if _, err := exec.LookPath("dnf"); err == nil { + return runPkgCmd("dnf", "upgrade", "-y", pkg) + } + return fmt.Errorf("找不到 dnf:请手工升级 %s", pkg) +} + +// pkgCmdRunner 是包管理器命令的**执行器**(可注入)。 +// +// ★ 为什么提为变量:包管理器命令会真的改动系统,而单元测试**绝不能** +// 真的跑 `apt-get install`(实测踩到:root 下跑真 apt,报 +// "Unable to locate package",还被当成业务逻辑失败)。 +// 测试把它换成一个记录调用的假执行器,从而能断言“选了哪条命令”。 +// 生产行为完全不变(默认就是下面那个真跑的实现)。 +var pkgCmdRunner = realRunPkgCmd + +// runPkgCmd 走当前执行器(生产 = realRunPkgCmd,测试可替换)。 +func runPkgCmd(name string, args ...string) error { return pkgCmdRunner(name, args...) } + +// realRunPkgCmd 执行包管理器命令,带输出上限与超时。 +// +// ★ 为什么要超时:dpkg 在等锁(另一个 apt 在跑)时会长时间挂住, +// 而调用方(WebUI 的 HTTP handler)会因此一直持有连接。 +func realRunPkgCmd(name string, args ...string) error { + cmd := exec.Command(name, args...) + var sb strings.Builder + cmd.Stdout = &sb + cmd.Stderr = &sb + + done := make(chan error, 1) + if err := cmd.Start(); err != nil { + return fmt.Errorf("启动 %s: %w", name, err) + } + go func() { done <- cmd.Wait() }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("%s %s 失败: %w\n输出尾部:\n%s", + name, strings.Join(args, " "), err, tailLines(sb.String(), 20)) + } + return nil + case <-time.After(10 * time.Minute): + _ = cmd.Process.Kill() + return fmt.Errorf("%s %s 超时(10 分钟):可能卡在包管理器锁上", + name, strings.Join(args, " ")) + } +} + +// tailLines 取文本末尾 n 行(错误消息里只需要尾部)。 +func tailLines(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) <= n { + return s + } + return "…(前略)\n" + strings.Join(lines[len(lines)-n:], "\n") +} diff --git a/internal/update/channel_test.go b/internal/update/channel_test.go new file mode 100644 index 00000000..fb0549f3 --- /dev/null +++ b/internal/update/channel_test.go @@ -0,0 +1,197 @@ +package update + +// 升级通道分派的判据。 +// +// 这一层的存在理由:同一个"升级"在不同安装形态下必须走不同通道。 +// 判据要钉住的不是"函数能跑",而是**分派结果是否正确**—— +// 分派错了的后果是破坏包管理器数据库,那是要人工上机器救的故障。 + +import ( + "errors" + "os" + "strings" + "testing" +) + +// ① deb/rpm 一律走包管理器,且**不可自替换**(即便当前是 root)。 +func TestPlanUpgrade_DebGoesToPackageManager(t *testing.T) { + c := Capabilities{ + Kind: KindDeb, Variant: VariantServer, + BinPath: "/usr/bin/homed", PackageName: "hologramagent-server", + CanWriteBin: false, Reason: "由 dpkg 安装", + } + p := PlanUpgrade(c, "") + if p.Channel != ChannelPackageManager { + t.Fatalf("deb 应走包管理器,实际 %s", p.Channel) + } + if p.Executable { + t.Fatal("★ deb 形态绝不允许自替换(会破坏 dpkg 数据库)") + } + if len(p.Commands) == 0 { + t.Fatal("不能自执行时必须给出可执行命令") + } + if !strings.Contains(p.Commands[0], "hologramagent-server") { + t.Errorf("命令应带包名,实际: %s", p.Commands[0]) + } + if p.Reason == "" { + t.Error("拒绝自执行必须说明原因") + } +} + +// ② tar 且可写 ⇒ 自替换。 +func TestPlanUpgrade_TarWritableSelfReplaces(t *testing.T) { + c := Capabilities{Kind: KindTar, Variant: VariantServer, CanWriteBin: true} + p := PlanUpgrade(c, "") + if p.Channel != ChannelSelfReplace || !p.Executable { + t.Fatalf("tar+可写应自替换,实际 channel=%s executable=%v", p.Channel, p.Executable) + } + if len(p.Commands) != 0 { + t.Error("能自己做的事不该给命令(会让人以为要手动)") + } +} + +// ③ tar 但不可写 ⇒ 不可执行,但必须给出命令(这是本机的主要现实形态)。 +func TestPlanUpgrade_TarUnwritableGivesCommand(t *testing.T) { + c := Capabilities{ + Kind: KindTar, Variant: VariantServer, CanWriteBin: false, + Reason: "当前用户对 /usr/local/bin 没有写权限", + } + p := PlanUpgrade(c, "") + if p.Executable { + t.Fatal("不可写时不得声称可执行") + } + if len(p.Commands) == 0 { + t.Fatal("★ 不可写时必须给出可执行命令,否则用户无从下手") + } + if !strings.Contains(strings.Join(p.Commands, " "), "homed-upgrade") { + t.Errorf("tar 形态应建议 homed-upgrade,实际: %v", p.Commands) + } +} + +// ④ 容器 ⇒ 重拉镜像,不给包管理器命令。 +func TestPlanUpgrade_ContainerPullsImage(t *testing.T) { + c := Capabilities{Kind: KindContainer, Variant: VariantServer} + p := PlanUpgrade(c, "") + if p.Channel != ChannelContainerImage { + t.Fatalf("容器应走镜像通道,实际 %s", p.Channel) + } + if p.Executable { + t.Fatal("容器内不得自替换(重启即还原)") + } + if len(p.Commands) < 2 { + t.Errorf("应给出 pull + 重建两条命令,实际: %v", p.Commands) + } +} + +// ⑤ 源码运行/未知 ⇒ 拒绝,且必须说明。 +func TestPlanUpgrade_SourceRefuses(t *testing.T) { + for _, kind := range []InstallKind{KindSource, KindUnknown} { + c := Capabilities{Kind: kind, Variant: VariantUnknown} + p := PlanUpgrade(c, "") + if p.Executable { + t.Errorf("%s 形态不得自替换", kind) + } + if p.Reason == "" { + t.Errorf("%s 拒绝时必须说明原因(不能只有 bool)", kind) + } + } +} + +// ⑥ 非 root 时 PackageManagerUpgrade 返回 ErrNeedPrivilege,而不是去跑 dpkg。 +// +// 判据重点是**没有执行命令**:非 root 下 dpkg 会输出一堆权限噪音, +// 而真正该告诉用户的是"你需要 sudo"。 +// ⑥ 包管理器执行**不**真跑命令,而是通过可注入执行器验证选了什么。 +// +// ## 为什么必须注入(真实事故) +// +// 本用例最初直接调 PackageManagerUpgrade,在 root 下真的执行了 +// `apt-get install --only-upgrade -y hologramagent-full` —— 报 +// "Unable to locate package" 并把真实的系统查询当成了业务失败。 +// 单元测试**绝不能**真的调用包管理器。 +// +// 现在把 pkgCmdRunner 换掉,从而验证的是“选了哪条命令”, +// 而不是“命令能不能跑”。 +func TestPackageManagerUpgrade_ChoosesRightCommand(t *testing.T) { + var got []string + old := pkgCmdRunner + pkgCmdRunner = func(name string, args ...string) error { + got = append(got, name+" "+strings.Join(args, " ")) + return nil + } + t.Cleanup(func() { pkgCmdRunner = old }) + + // deb 无本地包 ⇒ 走 apt-get --only-upgrade + c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"} + if err := PackageManagerUpgrade(c, ""); err != nil { + if errors.Is(err, ErrNeedPrivilege) { + t.Skip("非 root:本用例需要 root 才能走到执行分支") + } + t.Fatalf("执行失败: %v", err) + } + if len(got) != 1 || !strings.Contains(got[0], "--only-upgrade") { + t.Errorf("deb 应选 apt-get --only-upgrade,实际: %v", got) + } + if !strings.Contains(got[0], "hologramagent-full") { + t.Errorf("命令应带包名,实际: %v", got) + } +} + +// ⑥b 非 root 时必须返回 ErrNeedPrivilege(而不是去跑命令)。 +// +// 判据重点是**没有执行命令**:非 root 下 dpkg 只会输出权限噪音, +// 而真正该告诉用户的是“你需要 sudo”。 +func TestPackageManagerUpgrade_NonRootReturnsNeedPrivilege(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root 环境无法验证非 root 分支") + } + called := false + old := pkgCmdRunner + pkgCmdRunner = func(name string, args ...string) error { called = true; return nil } + t.Cleanup(func() { pkgCmdRunner = old }) + + c := Capabilities{Kind: KindDeb, PackageName: "hologramagent-full"} + err := PackageManagerUpgrade(c, "") + if err == nil { + t.Fatal("非 root 时应报错(不得静默成功)") + } + if !errors.Is(err, ErrNeedPrivilege) { + t.Errorf("应返回 ErrNeedPrivilege 以便调用方转成提示,实际: %v", err) + } + if called { + t.Error("★ 非 root 时不应去执行包管理器命令") + } + if !strings.Contains(err.Error(), "apt") { + t.Errorf("错误里应含可执行命令,实际: %v", err) + } +} + +// ⑦ detectVariant 不能因为缺文件就 panic,且 client(无 homed)要能识别。 +func TestDetectVariant_NoKernelIsClientOrUnknown(t *testing.T) { + v := detectVariant("/nonexistent/homed") + if v != VariantClient && v != VariantUnknown { + t.Errorf("无 homed 时应为 client 或 unknown,实际 %s", v) + } +} + +// ⑧ isGoBuildTempPath 只认 go build 的临时目录形态,不认任意 /tmp。 +// +// 这一条防的是"用 /tmp/ 判源码运行"的宽判据:那会把 +// "解包到 /tmp 试装"和 CI 集成测都误判成 dev 环境而拒绝升级。 +func TestIsGoBuildTempPath(t *testing.T) { + cases := []struct { + path string + want bool + }{ + {"/tmp/go-build123/b001/exe/homed", true}, + {"/root/.cache/go-build/ab/cd-homed", false}, // 构建缓存,不是 go run 产物 + {"/tmp/somewhere/homed", false}, // 普通 /tmp 路径不算 + {"/usr/local/bin/homed", false}, + {"", false}, + } + for _, c := range cases { + if got := isGoBuildTempPath(c.path); got != c.want { + t.Errorf("isGoBuildTempPath(%q) = %v, want %v", c.path, got, c.want) + } + } +} diff --git a/internal/update/delta.go b/internal/update/delta.go new file mode 100644 index 00000000..68a4b716 --- /dev/null +++ b/internal/update/delta.go @@ -0,0 +1,370 @@ +package update + +// 二进制差分:用「旧版本 + 小补丁」重建新版本,避免每次升下载 88MB。 +// +// ## 为什么必须做(实测数据) +// +// 两次构建(同 tag、同参数、-trimpath): +// +// 基线 88,036,432 字节 +// 改一行逻辑 88,036,432 字节,但**逐字节差异只有 80 字节** +// +// 而 OTA 现在下的是整个 tar.gz。也就是说 99.9999% 的流量在传 +// 与上一版完全相同的字节。 +// +// 前提是可复现构建 —— 本项目已满足(实测两次构建 sha256 相同), +// 这是差分能成立的基础:同一份源码必须产出同一份字节。 +// +// ## 算法选择:为什么不直接用 bsdiff +// +// bsdiff 需要 ~17×N 的峰值内存(88MB → 约 1.5GB),在系统内存紧张的 +// 机器上会 OOM。而升级恰恰发生在可能内存不足的场景里。 +// +// 这里用 rsync 族的**滚动哈希块匹配**: +// +// 构建期:把旧文件按 blockSize 切块建索引(88MB/4KB ≈ 21500 条) +// 差分时:在新文件上逐字节滚动 blockSize 窗口的哈希,命中即验证并 +// 向后扩张匹配,发出 COPY;否则累积 LITERAL +// +// 峰值内存 ≈ 旧文件 + 新文件 + 索引(约 200MB / 88MB 文件), +// 且索引只有几万条,与文件大小线性但系数极小。 +// +// ## 为什么 COPY 可以跨任意偏移 +// +// 索引里的键是**块内容的哈希**,不是偏移。所以新文件里同一段内容出现在 +// 任何位置都能命中 —— 这正好处理"插入/删除几个字节导致后续全部错位" +// 这种最常见的情形(改代码几乎必然改变 .text 的偏移)。 +// +// ## 安全边界 +// +// 差分的结果**必须**用全部内容的 sha256 校验。patch 应用成功不等于 +// 内容正确(旧版本不同、磁盘损坏、patch 被截断都会"成功"应用出 +// 一份错误的二进制)。校验失败就丢弃,回退到完整包。 + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "fmt" + "hash/crc32" + "io" + "os" + "sort" +) + +const ( + // blockSize 是索引的匹配粒度。 + // + // 选 4KB 的理由: + // · 索引条数 = 文件大小/4KB(88MB → 21500 条),内存可忽略 + // · 匹配粒度足够:改一行代码只影响几十字节,其余 4KB 块全部命中 + // · 太小(如 64B)会让索引暴涨到 137 万条且收益变差 + blockSize = 4096 + + // deltaMagic 是补丁文件头,用于识别格式与版本。 + deltaMagic = "HADELTA1" + + // maxDeltaInput 是允许做差分的单文件上限。 + // + // 88MB 的二进制 + 索引约需 200MB 峰值。设 256MB 上限是为了防止 + // 误用(例如有人对模型文件做差分)导致内存爆掉。 + maxDeltaInput = 256 << 20 + + opCopy = byte(0x01) + opLiteral = byte(0x02) +) + +// DeltaHeader 是补丁的元信息。 +type DeltaHeader struct { + OldSize int64 + NewSize int64 + // NewSHA256 是**新文件的完整 sha256**,应用后必须校验。 + NewSHA256 string + // OldSHA256 是**旧文件的完整 sha256**,应用前必须校验。 + // + // ★ 为什么记旧文件的哈希:差分只能对"特定旧版本"应用。 + // 用错基线(例如两个版本之间的跨版本 delta)会静默产出一份垃圾, + // 而它的大小、结构都"看起来正常"。 + OldSHA256 string +} + +// MakeDelta 生成把 oldData 变成 newData 的补丁。 +func MakeDelta(oldData, newData []byte) ([]byte, *DeltaHeader, error) { + if len(oldData) > maxDeltaInput || len(newData) > maxDeltaInput { + return nil, nil, fmt.Errorf("文件超出差分上限(%d MB)", maxDeltaInput>>20) + } + + oldSum := sha256.Sum256(oldData) + newSum := sha256.Sum256(newData) + hdr := &DeltaHeader{ + OldSize: int64(len(oldData)), + NewSize: int64(len(newData)), + OldSHA256: hex.EncodeToString(oldSum[:]), + NewSHA256: hex.EncodeToString(newSum[:]), + } + + idx := buildBlockIndex(oldData) + + var body bytes.Buffer + // 预留:最坏情况全是字面量,但字面量会分片合并,实际远小于 N。 + body.Grow(len(newData)/64 + 1024) + + var lit []byte + flushLit := func() { + if len(lit) == 0 { + return + } + body.WriteByte(opLiteral) + var tmp [4]byte + binary.LittleEndian.PutUint32(tmp[:], uint32(len(lit))) + body.Write(tmp[:]) + body.Write(lit) + lit = lit[:0] + } + + writeCopy := func(off int64, n int) { + body.WriteByte(opCopy) + var tmp [8]byte + binary.LittleEndian.PutUint64(tmp[:], uint64(off)) + body.Write(tmp[:]) + var t4 [4]byte + binary.LittleEndian.PutUint32(t4[:], uint32(n)) + body.Write(t4[:]) + } + + i := 0 + for i < len(newData) { + // 窗口不足一个块时无法匹配,直接当字面量 + if i+blockSize > len(newData) { + lit = append(lit, newData[i:]...) + break + } + h := crc32.ChecksumIEEE(newData[i : i+blockSize]) + cands := idx[h] + matched := int64(-1) + matchLen := 0 + for _, off := range cands { + // 哈希命中后必须**逐字节验证**:CRC32 会碰撞, + // 而碰撞导致的错误 COPY 会让输出静默损坏。 + if !bytes.Equal(oldData[off:off+blockSize], newData[i:i+blockSize]) { + continue + } + // 向后扩张:把相邻的相同字节一起纳入 COPY + n := blockSize + for i+n < len(newData) && off+int64(n) < int64(len(oldData)) && + newData[i+n] == oldData[off+int64(n)] { + n++ + } + if n > matchLen { + matchLen = n + matched = off + } + if matchLen >= 1<<20 { + break // 足够长,不必再找 + } + } + + if matched >= 0 { + flushLit() + writeCopy(matched, matchLen) + i += matchLen + continue + } + lit = append(lit, newData[i]) + i++ + } + flushLit() + + var out bytes.Buffer + out.WriteString(deltaMagic) + var b8 [8]byte + binary.LittleEndian.PutUint64(b8[:], uint64(len(oldData))) + out.Write(b8[:]) + binary.LittleEndian.PutUint64(b8[:], uint64(len(newData))) + out.Write(b8[:]) + // 两个哈希固定 64 字节 hex + oldHex := make([]byte, 64) + newHex := make([]byte, 64) + copy(oldHex, hdr.OldSHA256) + copy(newHex, hdr.NewSHA256) + out.Write(oldHex) + out.Write(newHex) + out.Write(body.Bytes()) + + return out.Bytes(), hdr, nil +} + +// buildBlockIndex 把旧文件按 blockSize 切块,建「CRC32 → 偏移列表」索引。 +func buildBlockIndex(data []byte) map[uint32][]int64 { + idx := make(map[uint32][]int64, len(data)/blockSize+1) + for off := 0; off+blockSize <= len(data); off += blockSize { + h := crc32.ChecksumIEEE(data[off : off+blockSize]) + idx[h] = append(idx[h], int64(off)) + } + return idx +} + +// ParseDeltaHeader 读补丁头(不应用)。 +func ParseDeltaHeader(patch []byte) (*DeltaHeader, error) { + if len(patch) < len(deltaMagic)+16+128 { + return nil, fmt.Errorf("补丁太短(%d 字节),不是有效差分", len(patch)) + } + if string(patch[:len(deltaMagic)]) != deltaMagic { + return nil, fmt.Errorf("补丁魔数不符:期望 %q,实际 %q", deltaMagic, patch[:len(deltaMagic)]) + } + p := len(deltaMagic) + hdr := &DeltaHeader{ + OldSize: int64(binary.LittleEndian.Uint64(patch[p:])), + NewSize: int64(binary.LittleEndian.Uint64(patch[p+8:])), + OldSHA256: string(patch[p+16 : p+16+64]), + NewSHA256: string(patch[p+16+64 : p+16+128]), + } + return hdr, nil +} + +// ApplyDelta 用旧数据 + 补丁重建新数据,并**校验两端的 sha256**。 +// +// 校验是硬性的:patch 应用"成功"不代表内容正确。旧版本不匹配、 +// 磁盘损坏、patch 被截断,都会产出一份"看起来正常"的错误二进制。 +// 唯一可信的判据是全内容 sha256。 +func ApplyDelta(oldData, patch []byte) ([]byte, error) { + hdr, err := ParseDeltaHeader(patch) + if err != nil { + return nil, err + } + + // ① 先验旧文件:差分只对**特定旧版本**成立。 + oldSum := sha256.Sum256(oldData) + if got := hex.EncodeToString(oldSum[:]); got != hdr.OldSHA256 { + return nil, fmt.Errorf("基线不匹配:补丁针对 %s,本地是 %s", + ShortSum(hdr.OldSHA256), ShortSum(got)) + } + if int64(len(oldData)) != hdr.OldSize { + return nil, fmt.Errorf("基线大小不匹配:期望 %d,实际 %d", hdr.OldSize, len(oldData)) + } + + // ② 执行指令 + p := len(deltaMagic) + 16 + 128 + out := make([]byte, 0, hdr.NewSize) + for p < len(patch) { + if p+1 > len(patch) { + return nil, fmt.Errorf("补丁在偏移 %d 处截断(缺操作码)", p) + } + op := patch[p] + p++ + switch op { + case opCopy: + if p+12 > len(patch) { + return nil, fmt.Errorf("补丁在偏移 %d 处截断(COPY 参数)", p) + } + off := int64(binary.LittleEndian.Uint64(patch[p:])) + n := int(binary.LittleEndian.Uint32(patch[p+8:])) + p += 12 + if off < 0 || n < 0 || off+int64(n) > int64(len(oldData)) { + return nil, fmt.Errorf("COPY 越界:off=%d n=%d oldSize=%d", off, n, len(oldData)) + } + out = append(out, oldData[off:off+int64(n)]...) + case opLiteral: + if p+4 > len(patch) { + return nil, fmt.Errorf("补丁在偏移 %d 处截断(LITERAL 长度)", p) + } + n := int(binary.LittleEndian.Uint32(patch[p:])) + p += 4 + if n < 0 || p+n > len(patch) { + return nil, fmt.Errorf("LITERAL 越界:n=%d 剩余=%d", n, len(patch)-p) + } + out = append(out, patch[p:p+n]...) + p += n + default: + return nil, fmt.Errorf("未知操作码 0x%02x(偏移 %d)", op, p-1) + } + // 防御:补丁可以声明很小的 NewSize 但指令产出巨量数据 + if int64(len(out)) > hdr.NewSize+int64(blockSize) { + return nil, fmt.Errorf("补丁产出的数据超过声明大小(%d > %d)", len(out), hdr.NewSize) + } + } + + if int64(len(out)) != hdr.NewSize { + return nil, fmt.Errorf("重建大小不符:期望 %d,实际 %d", hdr.NewSize, len(out)) + } + + // ③ 校验结果:这是唯一可信的判据 + newSum := sha256.Sum256(out) + if got := hex.EncodeToString(newSum[:]); got != hdr.NewSHA256 { + return nil, fmt.Errorf("重建内容校验失败:期望 %s,实际 %s", + ShortSum(hdr.NewSHA256), ShortSum(got)) + } + return out, nil +} + +// ── 文件级入口(供 CLI / 发布脚本使用) ──────────────────────── + +// MakeDeltaFile 从磁盘上的两个文件生成补丁文件。 +func MakeDeltaFile(oldPath, newPath, patchPath string) (*DeltaHeader, int64, error) { + oldData, err := os.ReadFile(oldPath) + if err != nil { + return nil, 0, fmt.Errorf("读旧文件: %w", err) + } + newData, err := os.ReadFile(newPath) + if err != nil { + return nil, 0, fmt.Errorf("读新文件: %w", err) + } + patch, hdr, err := MakeDelta(oldData, newData) + if err != nil { + return nil, 0, err + } + if err := WriteFileAtomic(patchPath, patch, 0644); err != nil { + return nil, 0, fmt.Errorf("写补丁: %w", err) + } + return hdr, int64(len(patch)), nil +} + +// ApplyDeltaFile 用旧文件 + 补丁重建新文件。 +func ApplyDeltaFile(oldPath, patchPath, outPath string) (int64, error) { + oldData, err := os.ReadFile(oldPath) + if err != nil { + return 0, fmt.Errorf("读基线: %w", err) + } + patch, err := os.ReadFile(patchPath) + if err != nil { + return 0, fmt.Errorf("读补丁: %w", err) + } + out, err := ApplyDelta(oldData, patch) + if err != nil { + return 0, err + } + if err := WriteFileAtomic(outPath, out, 0755); err != nil { + return 0, fmt.Errorf("写结果: %w", err) + } + return int64(len(out)), nil +} + +// DeltaStats 用于报告压缩效果(发布脚本 / 日志)。 +type DeltaStats struct { + OldSize int64 + NewSize int64 + PatchSize int64 + Ratio float64 +} + +// Stats 计算压缩比。 +func (h *DeltaHeader) Stats(patchSize int64) DeltaStats { + r := 0.0 + if h.NewSize > 0 { + r = float64(patchSize) / float64(h.NewSize) + } + return DeltaStats{OldSize: h.OldSize, NewSize: h.NewSize, PatchSize: patchSize, Ratio: r} +} + +// sortedKeys 仅用于测试与诊断(map 遍历顺序不稳定)。 +func sortedKeys(m map[uint32][]int64) []uint32 { + out := make([]uint32, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +var _ = io.Discard diff --git a/internal/update/delta_ota.go b/internal/update/delta_ota.go new file mode 100644 index 00000000..79f549c9 --- /dev/null +++ b/internal/update/delta_ota.go @@ -0,0 +1,128 @@ +package update + +// 差分级别的**发包/取包**:把「整包下载」换成「delta + 本地基线重建」。 +// +// ## 为什么需要这一层(实测) +// +// 一次逻辑改动,88MB 的二进制只差 80 字节;而 OTA 下的是整个 tar.gz。 +// 差分把这一步从 88MB 降到几十 KB 量级。 +// +// ## 为什么 delta 必须绑定旧文件的 sha256 +// +// 差分只能对**特定旧版本**应用。用错基线会静默产出一份"看起来正常" +// 的垃圾 —— 大小对、结构对,就是内容错。所以: +// +// ① 构建期在 delta 里写入旧/新两者完整 sha256 +// ② 取包时先验本地基线 sha256,不符就**放弃差分,退回整包** +// ③ 应用后验结果 sha256,不符同样退回整包 +// +// 回退而不是报错,是这个设计的要点:差分是**优化**,不是可用性前提。 + +import ( + "fmt" + "os" + "path/filepath" +) + +// DeltaAssetName 是发布时 delta 资产的命名约定。 +// +// 形如:hologramagent-1.4.0-linux-amd64-from-.delta +// 带上 from-commit 是为了让下载方一眼看出它对应哪个基线, +// 而不是"随便挑一个 delta 试试"。 +func DeltaAssetName(version, goos, goarch, fromCommit string) string { + return fmt.Sprintf("hologramagent-%s-%s-%s-from-%s.delta", version, goos, goarch, ShortCommit(fromCommit)) +} + +// TryDeltaUpgrade 尝试用 delta 重建新二进制到 outDir。 +// +// 返回值: +// +// true, nil —— 成功重建(outDir/homed 已就位) +// false, nil —— 没有可用 delta 或不适用(调用方应退回整包) +// false, err —— 出错(同样建议退回整包,但错误应记录) +// +// ## 什么情况下退回整包 +// +// · 本地没有基线二进制(首次安装 / 二进制已被替换) +// · 发布里没有匹配当前版本的 delta +// · 基线 sha256 不符(本地版本不是 delta 针对的那个) +// · 应用后校验失败 +// +// 这四种都**不是**致命错误:整包永远可用,差分只是省流量。 +func TryDeltaUpgrade(pkgDir, binPath, version string, rel *ReleaseInfo) (bool, error) { + if rel == nil || rel.DeltaURL == "" { + return false, nil // 该 release 没提供 delta + } + if !FileExists(binPath) { + return false, nil + } + + // ① 读本地基线(要用它的字节重建) + base, err := os.ReadFile(binPath) + if err != nil { + return false, fmt.Errorf("读本地基线: %w", err) + } + + // ② 下载 delta + patchPath := filepath.Join(pkgDir, "kernel.delta") + if err := downloadFile(rel.DeltaURL, patchPath); err != nil { + return false, fmt.Errorf("下载 delta: %w", err) + } + patch, err := os.ReadFile(patchPath) + if err != nil { + return false, fmt.Errorf("读 delta: %w", err) + } + + // ③ 应用(内含基线校验 + 结果校验) + out, err := ApplyDelta(base, patch) + if err != nil { + // ★ 退回整包,不当失败:可能是本地版本与 delta 基线不符 + return false, err + } + + // ④ 落位 + dst := filepath.Join(pkgDir, "bin", "homed") + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return false, err + } + if err := WriteFileAtomic(dst, out, 0755); err != nil { + return false, err + } + return true, nil +} + +// MakeDeltaForRelease 生成发布用的 delta(构建脚本 / 发布流程调用)。 +// +// oldBin 是**上一个已发布版本**的二进制,newBin 是本版。 +func MakeDeltaForRelease(oldBin, newBin, outPath string) (*DeltaHeader, int64, error) { + hdr, size, err := MakeDeltaFile(oldBin, newBin, outPath) + if err != nil { + return nil, 0, err + } + st := hdr.Stats(size) + // 只在**确实变小**时才值得发:偶尔(重构、换编译器)delta 可能 + // 比整包还大,那时发布方应直接不发 delta。 + if st.Ratio > 0.5 { + return hdr, size, fmt.Errorf( + "delta 过大(%.1f%% of %d bytes):本版不适合差分发布,请只发整包", + st.Ratio*100, hdr.NewSize) + } + return hdr, size, nil +} + +// currentVersionForDelta 读本机内核版本,读不到时返回 nil。 +// +// ★ 为什么必须传真实版本给 CheckRelease(而不是 nil): +// delta 的资产名里带 `-from-`,而那个 commit 来自 CurrentC。 +// 传 nil 会让 CurrentC 为空 ⇒ 永远匹配不到 delta ⇒ 整条差分路径静默失效 +// (看起来"差分已上线",实际每次都在下整包)。 +// +// 读不到版本时返回 nil 是合理的降级:老内核没有 --version, +// 那种情况下也确实不该尝试差分(连基线身份都无法确认)。 +func currentVersionForDelta() *BinVersion { + v, err := ReadBinVersion(KernelBinaryPath()) + if err != nil { + return nil + } + return v +} diff --git a/internal/update/delta_test.go b/internal/update/delta_test.go new file mode 100644 index 00000000..1f4cde00 --- /dev/null +++ b/internal/update/delta_test.go @@ -0,0 +1,188 @@ +package update + +// 差分 OTA 的判据。 +// +// 这一层的收益是**实测的**:改一行逻辑,88MB 的二进制只差 80 字节。 +// 但机制本身有真实风险 —— 错误应用会静默产出一份"看起来正常"的坏二进制。 +// 所以判据的重点是「错了一定被咬住」,而不是「正常情况能跑」。 + +import ( + "bytes" + "crypto/rand" + "os" + "path/filepath" + "testing" +) + +// ① 往返:make → apply 得到逐字节相同的内容。 +func TestDeltaRoundTrip(t *testing.T) { + old := bytes.Repeat([]byte("ABCDEFGHIJKLMNOP"), 5000) // 80KB + newData := append([]byte(nil), old...) + // 中间插入 3 个字节(模拟改代码导致后续偏移全变) + mid := len(newData) / 2 + newData = append(newData[:mid], append([]byte("XYZ"), newData[mid:]...)...) + + patch, hdr, err := MakeDelta(old, newData) + if err != nil { + t.Fatalf("生成补丁: %v", err) + } + if hdr.NewSize != int64(len(newData)) || hdr.OldSize != int64(len(old)) { + t.Fatalf("头部大小不对: %+v", hdr) + } + + got, err := ApplyDelta(old, patch) + if err != nil { + t.Fatalf("应用补丁: %v", err) + } + if !bytes.Equal(got, newData) { + t.Fatal("★ 重建内容与目标不一致") + } +} + +// ② 关键收益:小改动 ⇒ 小补丁。 +// +// 这条判据钉住的是**压缩比**本身。若实现退化成"全字面量", +// 往返仍能通过但 OTA 一点没省 —— 那是最容易漏掉的失败形态。 +func TestDeltaSmallChangeProducesSmallPatch(t *testing.T) { + old := make([]byte, 1<<20) // 1MB + if _, err := rand.Read(old); err != nil { + t.Fatal(err) + } + newData := append([]byte(nil), old...) + // 只改 64 字节 + for i := 0; i < 64; i++ { + newData[i*100] ^= 0xFF + } + + patch, hdr, err := MakeDelta(old, newData) + if err != nil { + t.Fatal(err) + } + st := hdr.Stats(int64(len(patch))) + t.Logf("patch=%d bytes, ratio=%.4f", len(patch), st.Ratio) + + // 1MB 随机内容,只有 64 字节变化 ⇒ 补丁应远小于原文件。 + // 这里用 5% 作宽松上限(块粒度 4KB,改动分散在 100 字节间隔上, + // 会打断一些块,实际比例低于此)。 + if st.Ratio > 0.05 { + t.Errorf("★ 补丁过大(%.2f%% of %d bytes):差分没起作用", st.Ratio*100, hdr.NewSize) + } +} + +// ③ 基线不符必须拒绝(用错旧版本 ⇒ 产出垃圾)。 +func TestDeltaRejectsWrongBaseline(t *testing.T) { + old := []byte("the-original-content-here") + newData := []byte("the-original-content-here-plus-more") + patch, _, err := MakeDelta(old, newData) + if err != nil { + t.Fatal(err) + } + if _, err := ApplyDelta([]byte("a-completely-different-baseline"), patch); err == nil { + t.Fatal("★ 基线不符时必须拒绝(否则静默产出垃圾)") + } +} + +// ④ 补丁被截断必须拒绝,不得"部分成功"。 +func TestDeltaRejectsTruncatedPatch(t *testing.T) { + old := bytes.Repeat([]byte("Z"), 8192) + newData := append([]byte(nil), old...) + newData = append(newData, []byte("TAIL")...) + patch, _, err := MakeDelta(old, newData) + if err != nil { + t.Fatal(err) + } + for _, cut := range []int{len(deltaMagic), 20, len(patch) / 2, len(patch) - 1} { + if _, err := ApplyDelta(old, patch[:cut]); err == nil { + t.Errorf("★ 截断到 %d/%d 字节仍被接受", cut, len(patch)) + } + } +} + +// ⑤ 内容被篡改必须被 sha256 咬住。 +// +// 这是最后一道防线:patch 结构合法、指令也不越界,但产出内容错。 +func TestDeltaRejectsCorruptedOutput(t *testing.T) { + old := bytes.Repeat([]byte("Q"), 4096) + newData := append([]byte(nil), old...) + newData[0] = 'X' + patch, hdr, err := MakeDelta(old, newData) + if err != nil { + t.Fatal(err) + } + // 篡改头部里的 NewSHA256(模拟恶意/损坏的补丁) + b := append([]byte(nil), patch...) + off := len(deltaMagic) + 16 + 64 + b[off] ^= 0xFF + if _, err := ApplyDelta(old, b); err == nil { + t.Fatal("★ 内容哈希不符时必须拒绝") + } + _ = hdr +} + +// ⑥ 空文件与超小文件不崩。 +func TestDeltaEdgeCases(t *testing.T) { + cases := [][2][]byte{ + {nil, nil}, + {[]byte("a"), []byte("b")}, + {bytes.Repeat([]byte("a"), 100), bytes.Repeat([]byte("a"), 100)}, + {[]byte{}, []byte("new")}, + {[]byte("old"), []byte{}}, + } + for i, c := range cases { + patch, _, err := MakeDelta(c[0], c[1]) + if err != nil { + t.Fatalf("case %d 生成失败: %v", i, err) + } + got, err := ApplyDelta(c[0], patch) + if err != nil { + t.Fatalf("case %d 应用失败: %v", i, err) + } + if !bytes.Equal(got, c[1]) { + t.Errorf("case %d 结果不符", i) + } + } +} + +// ⑦ 文件级入口(CLI 用)。 +func TestDeltaFileRoundTrip(t *testing.T) { + dir := t.TempDir() + oldPath := filepath.Join(dir, "homed.old") + newPath := filepath.Join(dir, "homed.new") + patchPath := filepath.Join(dir, "homed.patch") + outPath := filepath.Join(dir, "homed.out") + + oldData := bytes.Repeat([]byte("0123456789abcdef"), 4096) + newData := append([]byte(nil), oldData...) + newData = append(newData[:100], append([]byte("INSERTED"), newData[100:]...)...) + + if err := os.WriteFile(oldPath, oldData, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newPath, newData, 0755); err != nil { + t.Fatal(err) + } + + hdr, patchSize, err := MakeDeltaFile(oldPath, newPath, patchPath) + if err != nil { + t.Fatal(err) + } + t.Logf("patch=%d bytes (%.2f%% of new)", patchSize, hdr.Stats(patchSize).Ratio*100) + + if _, err := ApplyDeltaFile(oldPath, patchPath, outPath); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, newData) { + t.Fatal("★ 文件级往返结果不一致") + } +} + +// ⑧ 头部解析拒绝非本格式。 +func TestParseDeltaHeaderRejectsGarbage(t *testing.T) { + if _, err := ParseDeltaHeader([]byte("not a delta file at all")); err == nil { + t.Fatal("应拒绝非补丁数据") + } +} diff --git a/internal/update/executor.go b/internal/update/executor.go new file mode 100644 index 00000000..a24dd190 --- /dev/null +++ b/internal/update/executor.go @@ -0,0 +1,180 @@ +package update + +// 内核侧的**升级执行者**:订阅 update_request 并真正执行升级。 +// +// ## 为什么执行权必须在内核,而不是某个插件 +// +// 升级会替换内核二进制并让进程消失。这个动作的执行者必须满足三条: +// +// ① 不会被用户禁用(插件可以 auto_start:false / disable) +// ② 不依赖插件进程活着(插件崩了请求就丢了) +// ③ 知道自己的 dataDir / binPath(内核才有 ks.DataDir 这个权威值) +// +// 只有内核模块同时满足。而 pluginmgr 作为插件,三条全不满足。 +// +// ## 为什么是「延迟挂载」而不是在 Init 里订阅 +// +// update 模块必须在**第 0 层**(回滚判定要早于一切模块),但事件总线 +// 由 kernelapi 层产出 —— 第 0 层时它还不存在。 +// +// 所以分两段: +// +// Init(第 0 层) : 回滚判定 + 能力探测(不需要总线) +// Attach(总线就绪后): 订阅 update_request,接管执行 +// +// Attach 由调用方(cmd/homed)在 kernelapi 层完成后调用。它幂等: +// 重复调用不会重复订阅。 + +import ( + "fmt" + "log" + "os" + "sync" + + "github.com/JianFeeeee/HologramAgent/internal/events" +) + +// executor 是内核侧的升级执行器实例(进程级单例)。 +type executor struct { + mu sync.Mutex + attached bool + unsub func() + + // running 防止并发升级:两个请求同时进来会互相覆盖备份目录、 + // 交错替换 homed 与 depgraph。(旧实现完全没有互斥。) + running bool +} + +var kernelExec = &executor{} + +// AttachExecutor 挂载内核侧升级执行器(幂等)。 +// +// bus 为空时什么也不做(未接总线的宿主,如部分单测)。 +// 返回的 cleanup 用于关停时退订。 +func AttachExecutor(bus *events.Bus, dataDir string) func() { + if bus == nil { + return func() {} + } + kernelExec.mu.Lock() + if kernelExec.attached { + kernelExec.mu.Unlock() + return func() {} + } + kernelExec.attached = true + kernelExec.unsub = bus.Subscribe(events.EventUpdateRequest, func(evt *events.Event) { + handleUpdateRequest(evt, dataDir, bus) + }) + kernelExec.mu.Unlock() + + log.Printf("[update] kernel-side upgrade executor attached (data_dir=%s)", dataDir) + return func() { + kernelExec.mu.Lock() + defer kernelExec.mu.Unlock() + if kernelExec.unsub != nil { + kernelExec.unsub() + kernelExec.unsub = nil + } + kernelExec.attached = false + } +} + +// handleUpdateRequest 处理一条 update_request。 +// +// ## 语义分工 +// +// scope=kernel → **本模块**执行(内核升级是内核的职责) +// scope=plugin → 交给插件通道(pluginmgr 自己订阅处理) +// +// 内核侧只接管 kernel,不越界去管插件 —— 插件升级要用 hmapdev 现场编译, +// 那是应用域的事。 +// +// ## 为什么异步 +// +// 升级会替换二进制并请求重启,本进程随后消失。事件处理器是**同步投递**的 +// (events.Bus.Publish 直接调 handler),在处理器里同步做完会让发布方卡住 +// 直到进程被杀。 +func handleUpdateRequest(evt *events.Event, dataDir string, bus *events.Bus) { + if evt == nil || evt.Payload == nil { + return + } + scope, _ := evt.Payload["scope"].(string) + if scope != "kernel" { + return // 插件升级不归内核执行器 + } + pkgPath, _ := evt.Payload["pkg"].(string) + release, _ := evt.Payload["release"].(bool) + + kernelExec.mu.Lock() + if kernelExec.running { + kernelExec.mu.Unlock() + log.Printf("[update] 拒绝并发升级请求(来源 %s):已有升级在进行中", evt.Source) + publishResult(bus, "kernel", "", false, "", "", false, + "已有升级正在进行中,本次请求被拒绝(避免备份与替换交错)") + return + } + kernelExec.running = true + kernelExec.mu.Unlock() + + go func() { + defer func() { + kernelExec.mu.Lock() + kernelExec.running = false + kernelExec.mu.Unlock() + }() + if err := RunUpgrade(pkgPath, release, dataDir, bus); err != nil { + log.Printf("[update] upgrade failed (source=%s): %v", evt.Source, err) + } + }() +} + +// RunUpgrade 执行一次内核升级(内核侧入口,含形态分派)。 +// +// 流程:解析来源 → 形态闸门 → 取包 → Apply(写 pending + 原子替换)。 +// **不重启**:重启由调用方走 KernelControlAPI(restartCh → exit 42)。 +func RunUpgrade(pkgPath string, release bool, dataDir string, bus *events.Bus) error { + src, err := ParseSource(pkgPath, release) + if err != nil { + publishResult(bus, "kernel", "", false, "", "", false, err.Error()) + return err + } + + bus = eventBusOr(bus) + binPath := KernelBinaryPath() + + // ★ 形态闸门:先判断能不能自己升,再决定要不要下载 880MB。 + capab := Detect(binPath, dataDir) + plan := PlanUpgrade(capab, pkgPath) + if !plan.Executable { + publishResult(bus, "kernel", "", false, "", "", false, plan.Reason) + return &NotExecutableError{Plan: plan} + } + + workDir, err := os.MkdirTemp("", "kernel-upgrade-*") + if err != nil { + publishResult(bus, "kernel", "", false, "", "", false, err.Error()) + return fmt.Errorf("建临时目录: %w", err) + } + defer os.RemoveAll(workDir) + + root, _, err := src.Resolve(workDir) + if err != nil { + publishResult(bus, "kernel", "", false, "", "", false, err.Error()) + return err + } + newDG := FindFile(root, "depgraph.bin") + + _, err = Apply(root, binPath, newDG, dataDir, bus) + return err +} + +// NotExecutableError 表示本机形态不允许自替换(调用方应转成可执行提示)。 +type NotExecutableError struct { + Plan UpgradePlan +} + +func (e *NotExecutableError) Error() string { + if len(e.Plan.Commands) > 0 { + return e.Plan.Reason + ";请执行: " + e.Plan.Commands[0] + } + return e.Plan.Reason +} diff --git a/internal/update/executor_test.go b/internal/update/executor_test.go new file mode 100644 index 00000000..e8b69f1a --- /dev/null +++ b/internal/update/executor_test.go @@ -0,0 +1,160 @@ +package update + +// 内核侧升级执行器的判据。 +// +// 这些用例钉住的是「谁执行」与「能不能并发」——两者错了的后果都比较重: +// 前者会让升级依赖一个可被禁用的插件,后者会让两次升级交错替换文件。 + +import ( + "os" + "path/filepath" + "sync" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/events" +) + +// ① AttachExecutor 幂等:重复挂载不重复订阅。 +func TestAttachExecutor_Idempotent(t *testing.T) { + bus := events.NewBus() + c1 := AttachExecutor(bus, t.TempDir()) + c2 := AttachExecutor(bus, t.TempDir()) + if c1 == nil || c2 == nil { + t.Fatal("应返回非 nil 的 cleanup") + } + // 第二次应返回空操作,并且不应再订阅一次 + kernelExec.mu.Lock() + attached := kernelExec.attached + kernelExec.mu.Unlock() + if !attached { + t.Error("应处于已挂载状态") + } + c1() + c2() +} + +// ② bus 为 nil 时不 panic(未接总线的宿主)。 +func TestAttachExecutor_NilBus(t *testing.T) { + c := AttachExecutor(nil, t.TempDir()) + if c == nil { + t.Fatal("应返回可调用的 cleanup 而不是 nil") + } + c() // 不应 panic +} + +// ③ scope=plugin 的请求**不被内核执行器接管**。 +// +// 这是职责边界:插件升级要 hmapdev 现场编译,那是应用域的事。 +// 内核越界去管会让两条通道互相打架。 +func TestHandleUpdateRequest_IgnoresPluginScope(t *testing.T) { + bus := events.NewBus() + // 清掉可能残留的挂载状态 + kernelExec.mu.Lock() + kernelExec.running = false + kernelExec.mu.Unlock() + + handleUpdateRequest(&events.Event{ + Type: events.EventUpdateRequest, + Source: "test", + Payload: map[string]interface{}{"scope": "plugin", "name": "timer"}, + }, t.TempDir(), bus) + + kernelExec.mu.Lock() + running := kernelExec.running + kernelExec.mu.Unlock() + if running { + t.Error("★ scope=plugin 不应被内核执行器接管") + } +} + +// ④ 并发请求只允许一个通过(旧实现完全没有互斥)。 +// +// 判据不是"返回了错误",而是**第二个请求确实没有开始执行**: +// 两个升级同时跑会互相覆盖备份目录、交错替换 homed 与 depgraph。 +func TestHandleUpdateRequest_RejectsConcurrent(t *testing.T) { + bus := events.NewBus() + + // 手工占住 running,模拟"升级进行中" + kernelExec.mu.Lock() + kernelExec.running = true + kernelExec.mu.Unlock() + t.Cleanup(func() { + kernelExec.mu.Lock() + kernelExec.running = false + kernelExec.mu.Unlock() + }) + + // 发一个 kernel 请求:应被拒绝且**不启动 goroutine** + handleUpdateRequest(&events.Event{ + Type: events.EventUpdateRequest, + Source: "agent", + Payload: map[string]interface{}{"scope": "kernel", "release": true}, + }, t.TempDir(), bus) + + // running 仍是 true(被拒绝的请求没有改动它) + kernelExec.mu.Lock() + stillRunning := kernelExec.running + kernelExec.mu.Unlock() + if !stillRunning { + t.Error("被拒绝的并发请求不应改变 running 标志") + } +} + +// ⑤ RunUpgrade 在形态不允许自替换时返回 NotExecutableError(带可执行命令)。 +// +// 这条保证:请求方拿到的是「该走哪条通道」而不是一个裸 error。 +func TestRunUpgrade_NonExecutableReturnsPlan(t *testing.T) { + // 造一个"不可写"的环境不可靠(root 无视权限),所以直接构造 + // 一个自替换被禁的 Capabilities 路径:用容器形态的判据。 + // 这里改为验证 NotExecutableError 本身的语义,不依赖运行环境。 + p := UpgradePlan{ + Channel: ChannelPackageManager, + Reason: "由 dpkg 安装", + Commands: []string{"sudo apt install --only-upgrade hologramagent-full"}, + } + err := &NotExecutableError{Plan: p} + msg := err.Error() + if msg == "" { + t.Fatal("错误消息不能为空") + } + if got := msg; !contains(got, "sudo apt") { + t.Errorf("应包含可执行命令,实际: %s", got) + } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOfStr(s, sub) >= 0) +} + +func indexOfStr(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} + +// ⑥ 大量并发调用不产生数据竞争(配合 -race)。 +func TestHandleUpdateRequest_NoRaceOnRunning(t *testing.T) { + bus := events.NewBus() + kernelExec.mu.Lock() + kernelExec.running = false + kernelExec.mu.Unlock() + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + handleUpdateRequest(&events.Event{ + Type: events.EventUpdateRequest, + Source: "race", + Payload: map[string]interface{}{"scope": "plugin"}, // 不触发执行 + }, t.TempDir(), bus) + }() + } + wg.Wait() + _ = os.Getpid + _ = filepath.Join +} diff --git a/internal/update/fsutil.go b/internal/update/fsutil.go new file mode 100644 index 00000000..be1812ec --- /dev/null +++ b/internal/update/fsutil.go @@ -0,0 +1,145 @@ +package update + +// 文件写入的**唯一**原语。 +// +// ## 为什么必须收敛到这里 +// +// 全仓曾经有 5 处各自实现的「写临时文件 → rename」: +// +// internal/memory/pipeline/pipeline.go writeFileAtomic +// internal/security/store.go 写密钥库 +// internal/update/release.go downloadFile +// internal/update/kernel.go CopyFile +// internal/update/rollback.go (PR 前是直接 os.WriteFile) +// +// 它们的行为不一致,而且**只有一处**在 rename 前 fsync。不一致的方向 +// 恰恰是危险的那一侧:没有 fsync 的 rename 在掉电后可能留下**零长度** +// 或**半截**的目标文件 —— rename 的原子性只保证"名字指向谁",不保证 +// "内容已落盘"。 +// +// 升级路径上这个问题不可接受:homed 被替换成半截文件 ⇒ 内核起不来, +// 而 pending 标记可能已经清了 ⇒ 无法回滚。 +// +// ## 三条不变量 +// +// 1. 目标路径在任何时刻要么是**完整的旧内容**、要么是**完整的新内容** +// 2. 返回 nil 意味着内容**已落盘**(fsync 过),不只是交给了页缓存 +// 3. 失败时**不动**目标文件(临时文件被清掉) + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// 临时文件后缀。刻意区分于 Apply 的 ".tmp-upgrade": +// 那个是旧实现的遗留命名,新代码统一用 WriteFileAtomic/CopyFileAtomic。 +const atomicTmpSuffix = ".tmp-atomic" + +// WriteFileAtomic 原子地写入文件(临时文件 → fsync → rename → fsync 目录)。 +// +// perm 只在新文件创建时生效;目标已存在时保留其原有权限位 +// (升级替换 homed 时不能因为传了 0644 就把可执行位丢掉)。 +func WriteFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("建目录 %s: %w", dir, err) + } + mode := perm + if st, err := os.Stat(path); err == nil { + mode = st.Mode().Perm() + } + tmp := path + atomicTmpSuffix + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("建临时文件 %s: %w", tmp, err) + } + cleanup := func() { + f.Close() + os.Remove(tmp) + } + if _, err := f.Write(data); err != nil { + cleanup() + return fmt.Errorf("写 %s: %w", tmp, err) + } + // ★ fsync 必须在 Close 之前:Close 只把数据交给内核页缓存。 + if err := f.Sync(); err != nil { + cleanup() + return fmt.Errorf("fsync %s: %w", tmp, err) + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return fmt.Errorf("close %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return fmt.Errorf("rename %s → %s: %w", tmp, path, err) + } + syncDir(dir) + return nil +} + +// CopyFileAtomic 原子地复制文件(用于替换可执行文件)。 +// +// 与 WriteFileAtomic 的差别只有来源:这里流式拷贝,避免把 88MB 的 +// 二进制整个读进内存。 +// +// ★ 权限位取**源文件**的(升级包里的 homed 是 0755,必须保留可执行位)。 +func CopyFileAtomic(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + st, err := in.Stat() + if err != nil { + return err + } + dir := filepath.Dir(dst) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("建目录 %s: %w", dir, err) + } + tmp := dst + atomicTmpSuffix + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode().Perm()) + if err != nil { + return fmt.Errorf("建临时文件 %s: %w", tmp, err) + } + cleanup := func() { + out.Close() + os.Remove(tmp) + } + if _, err := io.Copy(out, in); err != nil { + cleanup() + return fmt.Errorf("拷贝 %s → %s: %w", src, tmp, err) + } + if err := out.Sync(); err != nil { + cleanup() + return fmt.Errorf("fsync %s: %w", tmp, err) + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return fmt.Errorf("close %s: %w", tmp, err) + } + if err := os.Rename(tmp, dst); err != nil { + os.Remove(tmp) + return fmt.Errorf("rename %s → %s: %w", tmp, dst, err) + } + syncDir(dir) + return nil +} + +// syncDir 让 rename 本身落盘。 +// +// ★ 失败只忽略,不报错:部分文件系统(overlay/tmpfs/容器层)对目录 +// fsync 返回 EINVAL。此时 rename 已完成、内容已正确,为此让升级失败 +// 只会把可用环境弄成不可用。方向是安全的:最坏情况是掉电后连 rename +// 都没生效,磁盘上留的仍是**完整的旧文件**。 +func syncDir(dir string) { + d, err := os.Open(dir) + if err != nil { + return + } + defer d.Close() + _ = d.Sync() +} diff --git a/internal/update/gc.go b/internal/update/gc.go new file mode 100644 index 00000000..c402d929 --- /dev/null +++ b/internal/update/gc.go @@ -0,0 +1,141 @@ +package update + +// 备份目录的垃圾回收。 +// +// ## 为什么必须有 GC +// +// 每次 Apply 都在 dataDir 下留一个 homed-upgrade-backup-(homed + +// depgraph.bin,通常几十 MB),每次启动时回滚坏二进制还会多一个 +// homed-broken-。旧代码里 Outcome.BackupDir 的注释写着 +// "确认稳定后可删",但**全仓没有一行删除逻辑** —— 注释承诺了一个 +// 不存在的机制,而这类注释比没注释更糟:读代码的人会以为已经处理了。 +// +// 不删的后果不是"占点磁盘"这么温和:dataDir 和 pending 标记在同一个 +// 文件系统上,磁盘被备份吃满之后 pending 标记就写不进去 ⇒ Apply 拒绝 +// 升级(守卫是对的,但保护频繁触发本身就是故障)。 +// +// ## 保留策略:upgrade 备 3 个,broken 备 1 个 +// +// 为什么不对称: +// +// · homed-upgrade-backup-* 是**回滚源**,可能被任意一次"上次升级的 +// pending 标记"引用。多留几个才能覆盖"升级→重启→再升级"的连环操作。 +// · homed-broken-* 是**事后现场**,只有人工排查时才看,且总是看最近 +// 一次。留 1 个(而非 0 个)是为了不丢现场。 +// +// 排序用目录名而不是 mtime:名字里的 ts 是 NowStamp() 的 +// "20060102-150405"(定长、字典序 == 时间序),不依赖文件系统时间戳的 +// 精度和时区,也不会被一次 cp -a 打乱。 + +import ( + "log" + "os" + "path/filepath" + "sort" + "strings" +) + +const ( + // BackupDirPrefix 是升级前备份目录名的前缀,需与 Apply 中拼接的一致。 + BackupDirPrefix = "homed-upgrade-backup-" + // BrokenDirPrefix 是回滚时留存的"坏二进制"目录名前缀。 + BrokenDirPrefix = "homed-broken-" + + // keepUpgradeBackups 保留的升级备份个数(回滚源,需要覆盖连环升级)。 + keepUpgradeBackups = 3 + // keepBrokenDirs 保留的坏二进制现场个数(只用于事后排查)。 + keepBrokenDirs = 1 +) + +// BrokenDirPath 返回某个时间戳的坏二进制留档目录(位于 dataDir 下)。 +// +// ★ 为什么不放在二进制目录旁:二进制目录通常是 /usr/local/bin, +// 在 ProtectSystem=strict 下只读,写不进去就等于**丢掉现场**。 +func BrokenDirPath(dataDir, ts string) string { + return filepath.Join(dataDir, BrokenDirPrefix+ts) +} + +// PruneOldBackups 清理 dataDir 下过量的备份目录。 +// +// 保留最近 keepUpgradeBackups 个 homed-upgrade-backup-* 与最近 +// keepBrokenDirs 个 homed-broken-*,其余 RemoveAll。 +// +// 设计上的克制(这几条都是为了"GC 本身不能造成故障"): +// +// - dataDir 为空 ⇒ 直接返回。空值曾被当作"当前目录",在这类函数里 +// 那就是灾难(会去删 CWD 下匹配前缀的东西)。 +// - 任何一次 RemoveAll 失败都只 log,不返回 error —— 调用点在 Apply +// 成功路径的末尾,GC 失败绝不能让一次**已经成功的升级**变成失败。 +// - 只处理目录、只处理名字精确匹配 "<前缀>" 形态的项(ts 必须是 +// NowStamp 的形状,见 isBackupStamp)—— 不递归、不碰 pending 标记、 +// 不碰名字碰巧带同样前缀的其它东西。 +func PruneOldBackups(dataDir string) { + if dataDir == "" { + return + } + pruneByPrefix(dataDir, BackupDirPrefix, keepUpgradeBackups) + pruneByPrefix(dataDir, BrokenDirPrefix, keepBrokenDirs) +} + +// isBackupStamp 判断 s 是否是 NowStamp() 的形状(20060102-150405)。 +// +// 只认定长数字+一个分隔短横:定长保证字典序 == 时间序,数字保证 +// 不会被任意名字混进来。 +func isBackupStamp(s string) bool { + if len(s) != len("20060102-150405") { + return false + } + for i := 0; i < len(s); i++ { + if i == 8 { + if s[i] != '-' { + return false + } + continue + } + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// pruneByPrefix 保留名字以 prefix 开头的最新 keep 个目录,其余删除。 +func pruneByPrefix(dir, prefix string, keep int) { + entries, err := os.ReadDir(dir) + if err != nil { + return // 目录还不存在(首次升级)就没什么可清的 + } + var names []string + for _, e := range entries { + if !e.IsDir() { + continue + } + n := e.Name() + if !strings.HasPrefix(n, prefix) || len(n) == len(prefix) { + continue + } + // ★ 后缀必须是时间戳形态。 + // + // 只查前缀不查后缀的话,一个名字碰巧以该前缀开头的目录(如手工建的 + // homed-upgrade-backup-manual)会被当成备份,且因为 'm' > '9' 会排在 + // 时间戳**前面**当作"最新" ⇒ 它挤掉真正的备份、然后真正的最新备份 + // 反被删。GC 只应动它自己造的东西。 + if !isBackupStamp(n[len(prefix):]) { + continue + } + names = append(names, n) + } + if len(names) <= keep { + return + } + // 名字里的时间戳定长且字典序 == 时间序(见文件头),倒序即最新在前。 + sort.Sort(sort.Reverse(sort.StringSlice(names))) + for _, n := range names[keep:] { + p := filepath.Join(dir, n) + if err := os.RemoveAll(p); err != nil { + log.Printf("[update] prune %s failed: %v", p, err) + continue + } + log.Printf("[update] pruned old backup %s(保留最近 %d 个)", p, keep) + } +} diff --git a/internal/update/install.go b/internal/update/install.go new file mode 100644 index 00000000..ad7d690b --- /dev/null +++ b/internal/update/install.go @@ -0,0 +1,548 @@ +package update + +// 安装形态与升级能力**探测**。 +// +// ## 为什么必须有这一层 +// +// 同一次升级,在不同安装形态下的正确做法完全不同: +// +// deb/rpm → 文件归包管理器所有。直接替换会让 dpkg/rpm 数据库与实际文件 +// 不一致:下次 `apt upgrade` 会把改动覆盖回去,或者报 +// "本地文件被修改"并拒绝升级。**必须交给包管理器**。 +// tar → 没有包管理器,自己替换是正确做法。 +// container→ 容器文件系统是镜像的一部分,重启即还原。**升级必须重拉镜像**。 +// +// 在这层存在之前,`Apply` 只有一条路(直接替换二进制),于是在 deb 形态下 +// 它要么因权限失败、要么(以 root 运行时)悄悄把包管理器数据库搞脏。 +// +// ## 为什么不是"猜路径" +// +// 判据全部来自**可观测的系统事实**(dpkg 的 info 目录、rpm 数据库、 +// 容器标记文件),而不是"二进制在 /usr/bin 所以大概是 deb"这类推断。 +// 推断错了的后果是选错升级通道 —— 而这类错误在生产上极难反查。 + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "github.com/JianFeeeee/HologramAgent/internal/events" + + "github.com/JianFeeeee/HologramAgent/internal/config" + "github.com/JianFeeeee/HologramAgent/internal/systemd" +) + +// DefaultServiceUnit 是内核的服务单元名。 +// +// ★ 从 internal/systemd 取,**不在本包重复定义**:单元名曾经在两处各写一份, +// 改名时只改了一处(systemd.UnitName="hologramagent.service"),而 +// cmd/homed-upgrade 仍硬编码 "homeagent" —— 升级成功的机器重启了错误的服务。 +const DefaultServiceUnit = systemd.UnitName + +// InstallKind 是本机的安装形态。 +type InstallKind string + +const ( + // KindDeb 表示由 dpkg 安装(.deb)。 + KindDeb InstallKind = "deb" + // KindRPM 表示由 rpm 安装(.rpm)。 + KindRPM InstallKind = "rpm" + // KindTar 表示解包安装(无包管理器记录)。 + KindTar InstallKind = "tar" + // KindContainer 表示运行在容器里(升级必须重拉镜像)。 + KindContainer InstallKind = "container" + // KindSource 表示从源码直接运行(dev 环境)。 + KindSource InstallKind = "source" + // KindUnknown 表示探测不出来。 + // + // ★ 它的语义是「不知道」,**不是**「没有包管理器所以大概是 tar」。 + // 调用方拿到 Unknown 必须拒绝自动升级,而不是退回一条猜的路径。 + KindUnknown InstallKind = "unknown" +) + +// Variant 是发行包变体(决定包里有哪些文件)。 +type Variant string + +const ( + VariantFull Variant = "full" // homed + waiter + GUI + 模型 + ORT + VariantServer Variant = "server" // homed + waiter + 模型 + ORT + VariantClient Variant = "client" // waiter + GUI(**没有 homed**) + VariantToolchain Variant = "toolchain" // homed + waiter + hmapdev + SDK + 模型 + VariantUnknown Variant = "unknown" +) + +// HasKernel 报告该变体是否包含内核二进制。 +// +// client 包没有 homed ⇒ 它的「升级」不涉及内核,任何内核 OTA 对它都不适用。 +func (v Variant) HasKernel() bool { + return v != VariantClient && v != VariantUnknown +} + +// Capabilities 是本机升级能力的快照。**所有升级决策的唯一输入**。 +type Capabilities struct { + Kind InstallKind `json:"kind"` + Variant Variant `json:"variant"` + BinPath string `json:"bin_path"` + DataDir string `json:"data_dir"` + DepGraph string `json:"depgraph,omitempty"` + PackageName string `json:"package_name,omitempty"` // deb/rpm 的包名 + ServiceUnit string `json:"service_unit,omitempty"` + + // CanWriteBin 报告当前进程能否**直接替换**二进制。 + // + // ★ 这是"能不能自己动手"的唯一判据。为 false 时绝不能尝试替换: + // 失败会在替换中途发生(例如 depgraph 已换、homed 没换), + // 留下一个不一致的安装。 + CanWriteBin bool `json:"can_write_bin"` + + // Reason 说明为什么当前形态不能自替换(可直接呈现给用户)。 + Reason string `json:"reason,omitempty"` +} + +// UpgradeChannel 是本机应当走的升级通道。 +type UpgradeChannel string + +const ( + // ChannelPackageManager 表示升级必须由包管理器执行(deb/rpm)。 + ChannelPackageManager UpgradeChannel = "package-manager" + // ChannelSelfReplace 表示可以自替换(tar 且目录可写)。 + ChannelSelfReplace UpgradeChannel = "self-replace" + // ChannelContainerImage 表示必须重拉镜像。 + ChannelContainerImage UpgradeChannel = "container-image" + // ChannelUnsupported 表示无法自动升级(需人工介入)。 + ChannelUnsupported UpgradeChannel = "unsupported" +) + +// Channel 决定本机该走哪条升级通道。 +// +// ## 为什么 deb/rpm 一律走包管理器(即使用户有 root) +// +// 「能写文件」不等于「该写文件」。dpkg 记录了每个受管文件的校验和, +// 绕过它替换的后果: +// +// · 下次 apt upgrade 覆盖回旧版(升级"消失") +// · 或 dpkg 检测到本地修改,**拒绝**后续升级(机器被卡住) +// · 两种都必须人工介入才能恢复 +func (c Capabilities) Channel() UpgradeChannel { + switch c.Kind { + case KindContainer: + return ChannelContainerImage + case KindDeb, KindRPM: + return ChannelPackageManager + case KindTar: + if c.CanWriteBin { + return ChannelSelfReplace + } + return ChannelUnsupported + default: + return ChannelUnsupported + } +} + +// UpgradeCommand 返回本机**应当执行**的升级命令(给人看的)。 +// +// 空串表示“没有可建议的命令”(container/source/unknown)。 +func (c Capabilities) UpgradeCommand(target string) string { + switch c.Channel() { + case ChannelPackageManager: + pkg := c.PackageName + if pkg == "" { + pkg = "hologramagent" + } + if c.Kind == KindRPM { + return "sudo dnf upgrade " + pkg + } + if target != "" { + return "sudo apt install --only-upgrade " + pkg + " # 或: sudo dpkg -i " + target + } + return "sudo apt install --only-upgrade " + pkg + case ChannelSelfReplace: + return "homed-upgrade -apply # 本机安装形态允许自替换" + case ChannelUnsupported: + // ★ tar 但目录不可写(最常见的本机形态)也必须给命令。 + // + // 此时 Channel() 返回 Unsupported(因为不能自替换),但**升级 + // 本身是可行的** —— 只需提权。返回空串会让用户看到“无法升级” + // 却不知道下一步做什么,而实际上一行 sudo 就解决了。 + // + // 判据:形态是 tar 且唯一障碍是权限。 + if c.Kind == KindTar && strings.Contains(c.Reason, "没有写权限") { + return "sudo homed-upgrade -apply # 需要提权:服务以非 root 运行,动不了 " + filepath.Dir(c.BinPath) + } + return "" + default: + return "" + } +} + +// Detect 探测本机安装形态与能力。 +// +// binPath 为空时取当前进程的内核二进制路径。**这个兜底只适用于本进程 +// 就是 homed 的情形**(kernelmod 启动路径);外部工具(homed-upgrade) +// 必须自己解析并显式传入,否则会把“自己”当成内核。 +// +// ★ 实测踩到:CLI 同目录没有 homed 时,resolveBinPath 曾兜底到 +// +// os.Executable() ⇒ “升级 homed”实际变成“把 CLI 覆盖成新包里的 homed”, +// 一个 1 字节的假包就能把工具本体换成一段 shell 脚本。 +// 现在 resolveBinPath 找不到就返回空,而 Apply 对空 binPath 直接拒绝。 +// +// 任何一步探测失败都不会 panic:返回已确定的字段 + Unknown/空值。 +func Detect(binPath, dataDir string) Capabilities { + if binPath == "" { + binPath = KernelBinaryPath() + } + c := Capabilities{ + BinPath: binPath, + DataDir: dataDir, + ServiceUnit: DefaultServiceUnit, + Kind: KindUnknown, + Variant: VariantUnknown, + } + + if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) { + c.DepGraph = dg + } + + // 容器优先判:容器里也可能装了 deb(基础镜像带来的), + // 但那时替换文件毫无意义 —— 重启容器就回到镜像原样。 + if inContainer() { + c.Kind = KindContainer + c.Reason = "运行在容器内:镜像文件系统只读且重启即还原,升级应重拉镜像" + } else { + c.Kind, c.PackageName = detectPackageKind(binPath) + } + + c.Variant = detectVariant(binPath) + c.CanWriteBin, c.Reason = canReplaceBinary(c.Kind, binPath) + return c +} + +// inContainer 判断是否运行在容器里。 +// +// 用容器运行时留下的标准标记(Docker 的 /.dockerenv、Podman/CRI-O 的 +// /run/.containerenv)。**不用** /proc/1/cgroup 里的 "docker" 字样: +// systemd-nspawn、WSL2、以及某些嵌套 cgroup 环境会误判。 +func inContainer() bool { + for _, p := range []string{"/.dockerenv", "/run/.containerenv"} { + if _, err := os.Stat(p); err == nil { + return true + } + } + return false +} + +// detectPackageKind 判断二进制是否归包管理器所有,并返回包名。 +// +// 判据是**文件属主记录**,不是路径: +// - dpkg 把每个包的文件清单写在 /var/lib/dpkg/info/.list +// - rpm 有 rpm 命令可查 +// +// 因此 deb 包装在 /opt 也能被认出来,tar 包解到 /usr/bin 也不会被误认。 +func detectPackageKind(binPath string) (InstallKind, string) { + if name := dpkgOwner(binPath); name != "" { + return KindDeb, name + } + if name := rpmOwner(binPath); name != "" { + return KindRPM, name + } + // 无包管理器记录:区分“源码编译产物”与“tar 解包安装”。 + // + // ★ 只认 go build 的临时目录形态(/go-build/),**不能用** "/tmp/"。 + // 那条太宽了:用户把 tar 包解到 /tmp 试装、或 CI 在 /tmp 里跑 + // 集成测,都会被误判成“源码运行”而拒绝升级。判据必须指向 + // **构建行为本身**,而不是“文件放在哪”。 + if isGoBuildTempPath(binPath) { + return KindSource, "" + } + return KindTar, "" +} + +// isGoBuildTempPath 判断路径是否是 `go run`/`go test` 的临时产物。 +// +// 形态:/go-build/b001/exe/ +// 只匹配这个结构,不匹配“放在 tmp 下”。 +func isGoBuildTempPath(p string) bool { + i := strings.Index(p, "/go-build") + if i < 0 { + return false + } + // go-build 后面必须紧跟数字(go 的实际命名),否则可能是用户自建的目录。 + rest := p[i+len("/go-build"):] + j := 0 + for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' { + j++ + } + return j > 0 && j < len(rest) && rest[j] == '/' +} + +// dpkgOwner 在 dpkg 数据库里查 binPath 属于哪个包。 +// +// 直接扫 info 目录里以本仓包名前缀开头的 .list,比 exec dpkg -S 更快 +// 且不依赖 dpkg 可执行文件(容器里常被精简掉)。 +func dpkgOwner(binPath string) string { + abs, err := filepath.Abs(binPath) + if err != nil { + return "" + } + entries, err := os.ReadDir("/var/lib/dpkg/info") + if err != nil { + return "" + } + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".list") { + continue + } + pkg := strings.TrimSuffix(name, ".list") + // 只看本仓的包:避免为了查一个文件而读几百个包的清单 + if !strings.HasPrefix(pkg, "hologramagent") && !strings.HasPrefix(pkg, "homeagent") { + continue + } + b, err := os.ReadFile(filepath.Join("/var/lib/dpkg/info", name)) + if err != nil { + continue + } + for _, line := range strings.Split(string(b), "\n") { + if strings.TrimSpace(line) == abs { + return pkg + } + } + } + return "" +} + +// rpmOwner 用 rpm 查文件属主(rpm 数据库格式不稳定,交给官方命令)。 +func rpmOwner(binPath string) string { + out, err := runQuiet("rpm", "-qf", binPath) + if err != nil { + return "" + } + out = strings.TrimSpace(out) + // rpm 未命中时输出 "file ... is not owned by any package" 且退出码非 0, + // 走到这里说明命中了。 + if out == "" || strings.Contains(out, "not owned") { + return "" + } + if i := strings.IndexByte(out, '\n'); i > 0 { + out = out[:i] + } + return strings.TrimSpace(out) +} + +// detectVariant 从安装布局推断变体。 +// +// 判据用**文件存在性**(包内容),不用包名:包名里的变体后缀 +// 在迁移/改名后未必可靠,而"有没有 GUI / hmapdev / 模型"是事实。 +func detectVariant(binPath string) Variant { + const ( + modelDir = "/usr/lib/hologram/models" + guiDir = "/usr/lib/hologram-gui" + ) + hasKernel := FileExists(binPath) + hasModel := dirExists(modelDir) + hasGUI := dirExists(guiDir) + hasToolchain := FileExists("/usr/bin/hmapdev") + + switch { + case !hasKernel: + // 没有 homed:只有 client 包是这样 + if FileExists("/usr/bin/waiter") || hasGUI { + return VariantClient + } + return VariantUnknown + case hasToolchain && hasModel: + return VariantToolchain + case hasGUI && hasModel: + return VariantFull + case hasModel: + return VariantServer + case hasKernel: + // 有内核但无模型:老包或最小安装 + return VariantServer + default: + return VariantUnknown + } +} + +// canReplaceBinary 判定能否直接替换二进制,并给出不可行的原因。 +// +// 四个条件全部满足才允许: +// 1. 不是包管理器拥有的文件(否则会破坏数据库一致性) +// 2. 不在容器里 +// 3. 不是从源码直接运行(那是 dev 环境,升级无意义) +// 4. 当前进程对**目标目录**有写权限(rename 需要目录可写,不是文件可写) +func canReplaceBinary(kind InstallKind, binPath string) (bool, string) { + switch kind { + case KindDeb: + return false, "由 dpkg 安装:直接替换会让包数据库与实际文件不一致,必须用 apt/dpkg 升级" + case KindRPM: + return false, "由 rpm 安装:直接替换会让包数据库与实际文件不一致,必须用 dnf/rpm 升级" + case KindContainer: + return false, "运行在容器内:文件系统随容器销毁还原,必须重拉镜像" + case KindSource: + // ★ 必须显式拒绝:源码运行环境的“升级”是重新构建,不是换磁盘上的文件。 + // 旧版本的 Channel() 对 source 返回 unsupported,而 CanWriteBin 却因为 + // 目录可写而为 true —— 两个判据互相矛盾,调用方会拿到“能写但不能升”。 + return false, "从源码直接运行:升级方式是重新构建,而不是替换二进制" + case KindUnknown: + return false, "无法确定安装形态:拒绝自动替换(可能破坏包管理器状态)" + } + dir := filepath.Dir(binPath) + if !dirWritable(dir) { + return false, "当前用户对 " + dir + " 没有写权限(服务以非 root 用户运行),需提权或改用包管理器" + } + return true, "" +} + +// dirWritable 报告能否在 dir 里创建/改名文件。 +// +// ★ 必须测**目录**而不是文件:原子替换用的是 rename,它要求的是 +// +// 目录写权限。文件本身可写但目录不可写时,rename 依然失败。 +func dirWritable(dir string) bool { + probe := filepath.Join(dir, ".ha-write-probe") + f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return false + } + f.Close() + os.Remove(probe) + return true +} + +func dirExists(p string) bool { + st, err := os.Stat(p) + return err == nil && st.IsDir() +} + +// runQuiet 执行一个只读查询命令,返回 stdout(失败返回 error,由调用方忽略)。 +// +// 单独抽出来是为了让探测代码不直接依赖 os/exec:探测必须**永不 panic、 +// 永不阻塞**,而 exec 的失败模式(命令不存在、权限、超时)都该收敛在一处。 +func runQuiet(name string, args ...string) (string, error) { + out, err := exec.Command(name, args...).Output() + return string(out), err +} + +// SystemdActive 报告某个 systemd 单元是否正在运行。 +// +// 用途只有一个:CLI 升级完成后要告诉用户“接下来会发生什么”—— +// 服务在跑就说明必须走热重启通道;没跑就说明下次启动即生效。 +// +// ★ 不用它做“要不要自替换”的判据:那由 Capabilities.CanWriteBin 决定, +// +// 两件事没有关系(服务没跑也照样可能没有写权限)。 +func SystemdActive(unit string) bool { + if unit == "" { + return false + } + out, err := runQuiet("systemctl", "is-active", unit) + return err == nil && strings.TrimSpace(out) == "active" +} + +// DataDirFromConfigDB 只读地取 config.db 里的 core.daemon.data_dir。 +// +// ## 为什么要单独一个入口(而不是让调用方自己开库) +// +// data_dir 就在 config.db 里,而 config.db 就在 data_dir 里 —— 这是个 +// 循环依赖,所以“找到一个候选 config.db”永远是猜的。但这不是问题: +// **猜的是线索,不是结论**。读出来的值是内核自己写下的,不是我们的推测。 +// +// ## ★ 为什么复用 internal/config 而不是自己 sql.Open +// +// 旧实现自己 `sql.Open("sqlite3", ...)` + `PRAGMA query_only=1`。问题是 +// cmd/homed-upgrade 用 `CGO_ENABLED=0` 打包,而 go-sqlite3 在无 cgo 时是 +// **stub**:连接能开、查询必然失败并返回 "requires cgo to work" —— +// 错误被吞掉、函数返回空串 ⇒ 发行包里“自动发现 dataDir”这条路径 +// **从未真正工作过**,而且症状是“无法确定数据目录”,指着完全错误的方向。 +// +// 复用 ConfigRegistry 的好处:同一套打开/读取语义、随仓库一起演进, +// 且它本来就处理“库不存在/表不存在”的情形(返回默认值)。 +func DataDirFromConfigDB(dir string) string { + p := filepath.Join(dir, "config.db") + if !FileExists(p) { + return "" + } + reg := config.NewConfigRegistry(p) + defer reg.Close() + return strings.TrimSpace(reg.GetString("core.daemon.data_dir", "")) +} + +// ── 内核数据目录的**权威源** ───────────────────────────────────── +// +// ## 为什么要有这两个函数 +// +// pending 标记、备份目录、坏二进制留档全落在 dataDir 下。而"内核实际用的是 +// 哪个 dataDir"只有一个可靠来源:内核启动时的 `-data`(ks.DataDir)。 +// +// 历史教训(三类都踩过): +// +// ① 从 exe 路径推导 → /usr/bin/data,而生产是 /var/lib/hologram +// ⇒ 备份目录建在只读目录(EROFS),升级必挂 +// ② 硬编码生产路径 → 目录/用户迁移后指向不存在的目录 +// ⇒ 标记落到内核读不到的地方 ⇒ **升级失败静默不回滚** +// ③ 读 HOMEAGENT_DATA_DIR → 部署方可能不设,设了也未必与 -data 一致 +// +// 所以:**不猜**。内核模块在启动时注入真值,其余调用方读它; +// 取不到就返回空串,由 Apply 的空值守卫拒绝升级。 +// +// 「不知道」伪装成「知道」比没有守卫更危险:守卫看起来在守, +// 实际守了个永远非空的常量。 +var ( + kernelDataDirMu sync.RWMutex + kernelDataDir string +) + +// SetKernelDataDir 由内核适配层(internal/kernelmod/update)在启动时注入 +// 真实的 `-data` 值。**不是给普通调用方用的**。 +func SetKernelDataDir(dir string) { + kernelDataDirMu.Lock() + defer kernelDataDirMu.Unlock() + kernelDataDir = dir +} + +// KernelDataDir 返回内核实际使用的 dataDir(取不到时为空串)。 +func KernelDataDir() string { + kernelDataDirMu.RLock() + defer kernelDataDirMu.RUnlock() + return kernelDataDir +} + +// ── 事件总线的**内核侧注入** ────────────────────────────────── + +var ( + eventBusMu sync.RWMutex + eventBus *events.Bus +) + +// SetEventBus 由内核适配层在启动时注入事件总线。 +// +// ## ★ 为什么改成注入,而不是让调用方每次传进来 +// +// `Apply` 需要发进度/结果事件。此前签名收 `*events.Bus`,于是**插件**必须 +// 拿到总线才能触发升级 —— 而插件拿总线正是"安全监控可被绕过"的根源: +// 一旦把 *Bus 交给插件,它就能 bus.Publish 伪造任意事件。 +// +// 改成内核注入后: +// +// · 插件调用 Apply 不用再碰总线(传 nil 即可) +// · 进度事件由**内核子系统**以内核身份发出(Source="update"),语义也正确 +// · 插件侧再没有理由持有 *events.Bus +func SetEventBus(b *events.Bus) { + eventBusMu.Lock() + defer eventBusMu.Unlock() + eventBus = b +} + +// eventBusOr 返回传入的总线;为空时退回内核注入的那个。 +func eventBusOr(b *events.Bus) *events.Bus { + if b != nil { + return b + } + eventBusMu.RLock() + defer eventBusMu.RUnlock() + return eventBus +} diff --git a/internal/update/kernel.go b/internal/update/kernel.go index 93e3caa0..4aa4c0b2 100644 --- a/internal/update/kernel.go +++ b/internal/update/kernel.go @@ -35,12 +35,17 @@ type BinVersion struct { SDKVersion string } -// readBinVersion 跑 ` --version` 并解析。 +// ReadBinVersion 跑 ` --version` 并解析(含老版本回退,用于**探测**)。 // // ★ 为什么用"跑它自己"而不是读文件里的字符串: // // 版本是 ldflags 注入的,从 ELF 里 grep 只能撞运气;而 --version 是内核 // 自己算出来的,还能一并拿到 commit("同版本号不同 commit"很难察觉)。 +// +// ★★ 注意:这个回退使 ReadBinVersion **不能**用作回滚校验。推断出来的 +// 版本可能来自**另一个文件**(或运行中的老进程),拿它当“刚拷过去的 +// 文件能执行”的证词,会让一个零字节的坏备份“校验通过”。回滚路径用 +// digest(见 rollback.go 的 verifyRestored),不用版本。 func ReadBinVersion(bin string) (*BinVersion, error) { if _, err := os.Stat(bin); err != nil { return nil, err @@ -174,6 +179,54 @@ func ShortCommit(c string) string { return c } +// readBinVersionStrict 只认**这个文件自己**能报出版本,任何回退都不要。 +// +// 与 ReadBinVersion 的唯一区别:exec 失败直接返回 error,**不**走 +// InferVersionFromRunning。用于“确认分支”这类必须证明“这个文件能用” +// 的判定。 +// +// ★ 不要拿它当回滚校验:老内核(1.3.x 及以前)没有 --version, +// 而它们恰恰是最常见的回滚目标。回滚用 digest(verifyRestored)。 +func readBinVersionStrict(bin string) (*BinVersion, error) { + if _, err := os.Stat(bin); err != nil { + return nil, err + } + out, err := exec.Command(bin, "--version").CombinedOutput() + if err != nil { + return nil, fmt.Errorf("执行 --version: %v(该文件不可执行或架构不符)", err) + } + v := &BinVersion{} + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + k, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch k { + case "version": + v.Version = val + case "commit": + v.Commit = val + case "build_time": + v.BuildTime = val + case "sdk": + v.SDKVersion = val + } + } + if v.Version == "" { + return nil, fmt.Errorf("--version 输出里没有 version= 行: %q", strings.TrimSpace(string(out))) + } + return v, nil +} + +// ShortSum 把 sha256 缩到前 12 位,用于错误消息(完整值对排查无益)。 +func ShortSum(s string) string { + if len(s) > 12 { + return s[:12] + } + return s +} + // extractPkg 解包并返回包含 bin/ 的根目录。 func ExtractPkg(pkg, dst string) (string, error) { f, err := os.Open(pkg) @@ -264,14 +317,44 @@ func FileSize(p string) int64 { // 所以这里只承诺到"磁盘上的产物已就绪,pending 标记已写"。 // 后续重启 → 新内核启动 → update 模块(第 0 层)确认或回滚。 // -// 每一步失败都往回退:替换中途失败 ⇒ 立即用备份还原并清标记 -// (此时尚未重启,回滚在本进程内完成,机器始终可启动)。 +// ## 每一步都是可撤销的(顺序不可换) +// +// 预检 → 备份 → 记录 digest → 写 pending → 替换 depgraph → 替换 homed +// +// 任一步失败就地回滚,且**回滚完整性本身就是一种可验证的事实**: +// 每个文件都记了 sha256,还原后逐一校对;只有全部对上才清 marker。 +// 这不是“多抄一遍”,“重新读版本”在旧内核上根本做不到 —— 旧内核没有 +// `--version`,而旧内核恰恰是最常见的回滚目标。 func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, error) { + // ★ 调用方可以传 nil:内核在启动时 SetEventBus 注入过总线, + // 这里取回那份(插件不再需要持有 *events.Bus)。 + bus = eventBusOr(bus) + newBin := FindFile(root, "homed") if newBin == "" { return nil, fmt.Errorf("包内无 homed") } + // ── ★ 最前置的拒绝条件:没有 dataDir 就没有回滚能力 ── + // + // pending 标记、备份目录、坏二进制留档全落在 dataDir 下。空 dataDir + // 意味着标记写不进去 ⇒ 新内核起不来时没有任何人知道该回滚。 + // 这种升级不是升级,是赌运气,赌输的代价是机器进崩溃循环。 + if dataDir == "" { + return nil, fmt.Errorf("dataDir 为空,拒绝升级:无 pending 标记就无回滚能力") + } + + // ── ★ 安装形态闸门:包管理器拥有的文件不允许直接替换 ── + // + // 「能写」不等于「该写」。dpkg/rpm 记录了受管文件的校验和,绕过它 + // 替换的后果是下次 apt upgrade 覆盖回旧版,或 dpkg 拒绡后续升级。 + capab := Detect(binPath, dataDir) + if !capab.CanWriteBin { + return nil, fmt.Errorf("本机不允许直接替换二进制(%s/%s):%s\n"+ + "建议改用包管理器升级:%s", + capab.Kind, capab.Variant, capab.Reason, capab.UpgradeCommand("")) + } + // ── 预检:新二进制必须能自报版本(说明它是可执行的、架构对的)── nv, err := ReadBinVersion(newBin) if err != nil { @@ -280,18 +363,36 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version) fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version) - // ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)── + // ── 备份:只备本次会动的文件(不整目录拷贝,那是个几百 MB)── + // + // ★ 备份必须落在 dataDir 下,不能是 /root:服务以 ProtectSystem=strict + // + 非 root 运行,对 /root 没有写权限 ⇒ MkdirAll 必 EACCES。 ts := NowStamp() - backup := filepath.Join("/root", "homed-upgrade-backup-"+ts) + backup := filepath.Join(dataDir, BackupDirPrefix+ts) if err := os.MkdirAll(backup, 0755); err != nil { return nil, fmt.Errorf("建备份目录: %w", err) } + + // targets 是本轮会动的**全部**文件。每个都记录 digest —— 回滚时那 + // 一组 digest 就是“已完整还原”的判据。 targets := []string{binPath} - if dg := filepath.Join(filepath.Dir(binPath), "depgraph.bin"); FileExists(dg) { - targets = append(targets, dg) + dgPath := filepath.Join(filepath.Dir(binPath), "depgraph.bin") + // ★ hadDepGraph 必须在**备份之前**取:若原本没有、而新包带了, + // 回滚时必须把它删掉。留着会让旧 homed 的内嵌 digest 对不上, + // 而那时 marker 已清 ⇒ 进崩溃循环且无第二次自愈。 + hadDepGraph := FileExists(dgPath) + if hadDepGraph { + targets = append(targets, dgPath) } + + digests := make(map[string]string, len(targets)) for _, t := range targets { - if err := CopyFile(t, filepath.Join(backup, filepath.Base(t))); err != nil { + sum, serr := sha256File(t) + if serr != nil { + return nil, fmt.Errorf("计算 %s 的 digest: %w", t, serr) + } + digests[t] = sum + if err := CopyFileAtomic(t, filepath.Join(backup, filepath.Base(t))); err != nil { return nil, fmt.Errorf("备份 %s: %w", t, err) } } @@ -300,62 +401,104 @@ func Apply(root, binPath, newDG, dataDir string, bus *events.Bus) (*Outcome, err // ── 写待确认升级标记(在替换**之前**)── // - // 顺序很关键:先写标记再替换。若反过来(替换后才写),"替换成功但 - // 没来得及写标记就崩了"会丢标记 ⇒ 下次启动不知道该回滚。 + // 顺序很关键:先写标记再替换。若反过来(替换后才写),“替换成功但 + // 没来得及写标记就崩了”会丢标记 ⇒ 下次启动不知道该回滚。 curVer := &BinVersion{} if v, verr := ReadBinVersion(binPath); verr == nil { curVer = v } - if dataDir != "" { - if perr := MarkPendingUpgrade(dataDir, PendingRecord{ - FromVer: curVer.Version, - FromCommit: curVer.Commit, - ToVer: nv.Version, - ToCommit: nv.Commit, - BackupDir: backup, - }); perr != nil { - // 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力, - // 而不带回滚的升级等于赌运气。 - return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr) - } - fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir)) + if perr := MarkPendingUpgrade(dataDir, PendingRecord{ + FromVer: curVer.Version, + FromCommit: curVer.Commit, + ToVer: nv.Version, + ToCommit: nv.Commit, + BackupDir: backup, + HadDepGraph: hadDepGraph, + Digests: digests, + }); perr != nil { + // 写不成功 ⇒ 放弃本次升级:没有标记就没有回滚能力。 + return nil, fmt.Errorf("写升级确认标记失败(放弃升级,未替换任何文件): %w", perr) } + fmt.Printf("已写升级确认标记 → %s\n", pendingPath(dataDir)) + + // replaceState 记录“替换阶段已经动过哪些文件(以及新包是否带了 depgraph)”。 + // + // ★ 为什么不用 targets/resources 推断:旧实现直接用 targets 还原,于是 + // “新包带了 depgraph、原机器没有”这种情况会多出一条无源可还原的项, + // 以及“原本有、新包没带”会被误删。动作记录才是事实。 + var touchedDepGraph bool // restore 在替换失败时把已动的文件还原(尚未重启,机器仍可启动)。 + // + // ★ restore 完整才清 marker:旧实现先清 marker 再还原,于是“还原也 + // 失败”时磁盘上可能是半新半旧、而 marker 已经没了 ⇒ 失去第二次自愈。 restore := func(cause error) (*Outcome, error) { publishProgress(bus, "kernel", "", "rollback", cause.Error()) fmt.Printf("\n❌ %v\n== 就地还原(尚未重启,机器仍可启动)==\n", cause) - if dataDir != "" { - if cerr := ClearPendingUpgrade(dataDir); cerr != nil { - fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr) - } - } + + var failed []string for _, t := range targets { src := filepath.Join(backup, filepath.Base(t)) - if err := CopyFile(src, t); err != nil { - fmt.Printf(" ⚠️ 还原 %s 失败: %v(需人工处理)\n", t, err) + // ★ 用 digest 判“已还原”:比“CopyFile 没报错”强。 + // CopyFileAtomic 成功只说明字节搬完了,不说明内容与当时一致 + // (备份本身可能已损坏)。 + if err := CopyFileAtomic(src, t); err != nil { + failed = append(failed, fmt.Sprintf("%s: %v", t, err)) continue } - fmt.Printf(" 已恢复 %s\n", t) + if got, serr := sha256File(t); serr != nil || got != digests[t] { + failed = append(failed, fmt.Sprintf("%s: digest 不符", t)) + continue + } + fmt.Printf(" 已恢复并校验 %s\n", t) + } + // 新包装了 depgraph 而原机器没有 ⇒ 回滚时必须删掉它。 + if touchedDepGraph && !hadDepGraph { + if err := os.Remove(dgPath); err != nil && !os.IsNotExist(err) { + failed = append(failed, fmt.Sprintf("%s: 删除失败 %v", dgPath, err)) + } else { + fmt.Printf(" 已移除新装上的 %s(原机器没有)\n", dgPath) + } + } + + if len(failed) > 0 { + // ★ marker 保留:磁盘状态不确定,保留才能让下次启动再试。 + publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, false, + "替换失败且就地还原不完整: "+strings.Join(failed, "; ")) + return nil, fmt.Errorf("替换失败且就地还原不完整(pending 标记已保留,下次启动会再试): %w\n 未还原: %s", + cause, strings.Join(failed, "; ")) + } + + if cerr := ClearPendingUpgrade(dataDir); cerr != nil { + fmt.Printf(" ⚠️ 清除升级确认标记失败: %v\n", cerr) } publishResult(bus, "kernel", "", false, curVer.Version, nv.Version, true, cause.Error()) - return nil, fmt.Errorf("替换失败已还原(未重启): %w", cause) + return nil, fmt.Errorf("替换失败已完整还原(未重启,机器仍可启动): %w", cause) } // ── 替换(depgraph 必须与 homed 同批,顺序无关但都要成)── if newDG != "" { - dst := filepath.Join(filepath.Dir(binPath), "depgraph.bin") - if err := CopyFile(newDG, dst); err != nil { + touchedDepGraph = true + if err := CopyFileAtomic(newDG, dgPath); err != nil { return restore(fmt.Errorf("替换 depgraph.bin: %w", err)) } fmt.Println("已替换 depgraph.bin") } - if err := CopyFile(newBin, binPath); err != nil { + if err := CopyFileAtomic(newBin, binPath); err != nil { return restore(fmt.Errorf("替换 homed: %w", err)) } fmt.Println("已替换 homed") publishProgress(bus, "kernel", "", "ready", "新内核已就位,等待重启") + // ★ 成功路径也必须发 result 事件: + // 此前只有失败路径发,订阅方只能靠“进度停在最后一步且没看到 failed” + // 来猜成功,猜错的方向是“以为还在升级”。先发事件再返回:返回后 + // 调用方可能立即重启,进程消失。 + publishResult(bus, "kernel", "", true, curVer.Version, nv.Version, false, "") + + // 备份必须 GC:旧注释写“确认稳定后可删”,但全仓从来没有一行删除逻辑。 + PruneOldBackups(dataDir) + return &Outcome{ From: curVer, To: nv, @@ -370,33 +513,13 @@ type Outcome struct { BackupDir string // 备份目录(确认稳定后可删) } -func CopyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - st, err := in.Stat() - if err != nil { - return err - } - tmp := dst + ".tmp-upgrade" - out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, st.Mode()) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - out.Close() - os.Remove(tmp) - return err - } - if err := out.Close(); err != nil { - os.Remove(tmp) - return err - } - // 原子替换:先写临时文件再 rename,避免"写到一半进程在跑" - return os.Rename(tmp, dst) -} +// CopyFile 是**向后兼容的别名**,内部走 CopyFileAtomic(含 fsync)。 +// +// ★ 保留它而不是删:旧调用点(含测试)仍在用这个名字,而旧实现在 +// +// rename 前**没有 fsync** —— 掉电后会留下半截文件。统一到原子实现, +// 调用方无需改一行。 +func CopyFile(src, dst string) error { return CopyFileAtomic(src, dst) } func FileExists(p string) bool { _, err := os.Stat(p) diff --git a/internal/update/release.go b/internal/update/release.go index 7e9f7ad3..eb521bd3 100644 --- a/internal/update/release.go +++ b/internal/update/release.go @@ -68,8 +68,20 @@ type ReleaseInfo struct { Asset string `json:"asset,omitempty"` // 匹配到的资产名(tar.gz) AssetURL string `json:"asset_url,omitempty"` // 资产下载地址 AssetDigest string `json:"asset_digest,omitempty"` // GitHub SHA-256 digest - HasUpdate bool `json:"has_update"` - Reason string `json:"reason,omitempty"` // 查不到时的说明 + + // DeltaAsset / DeltaURL 是本版针对**当前本机版本**的差分资产(可选)。 + // + // ★ 为何单独一字段而不是复用 Asset: + // 整包与 delta 是两条不同的取包路径,一条失败能退回另一条。 + // 混在一个字段里就无法表达“有整包、但 delta 不适用”。 + // + // 空串 = 该 release 没有为当前版本提供 delta(首次安装、跳版本、 + // 或发布时判定不适合差分)⇒ 调用方直接走整包。 + DeltaAsset string `json:"delta_asset,omitempty"` + DeltaURL string `json:"delta_url,omitempty"` + + HasUpdate bool `json:"has_update"` + Reason string `json:"reason,omitempty"` // 查不到时的说明 } // CheckRelease 查 GitHub Release 并与本机版本比较。 @@ -114,6 +126,24 @@ func CheckRelease(current *BinVersion) *ReleaseInfo { } } + // 找针对**当前本机 commit** 的 delta(可选)。 + // + // 命名约定:...-from-.delta。带上来源 commit 而不是 + // “随便挑一个 delta”:差分只能对特定旧版本应用,挑错基线会静默产出一份 + // 看起来正常的垃圾(大小结构都对、就是内容错)。 + // + // 找不到就不填 —— 调用方直接走整包,不是错误。 + if info.CurrentC != "" && info.CurrentC != "unknown" { + want := "from-" + ShortCommit(info.CurrentC) + ".delta" + for _, a := range rel.Assets { + if strings.HasSuffix(a.Name, want) { + info.DeltaAsset = a.Name + info.DeltaURL = a.BrowserDownloadURL + break + } + } + } + if current == nil || current.Version == "" { info.Reason = "本机版本未知(内核过老,无 --version):无法比较,请人工确认" return info diff --git a/internal/update/rollback.go b/internal/update/rollback.go index 9fe100cd..27bdd042 100644 --- a/internal/update/rollback.go +++ b/internal/update/rollback.go @@ -36,6 +36,7 @@ import ( "log" "os" "path/filepath" + "strings" "time" ) @@ -50,8 +51,30 @@ type PendingRecord struct { // ToVer / ToCommit 是升级**后**的版本(当前正在试的)。 ToVer string `json:"to_version"` ToCommit string `json:"to_commit"` - // BackupDir 是升级前备份目录(DoUpgrade 创建),回滚从这里取二进制。 + // BackupDir 是升级前备份目录(Apply 创建),回滚从这里取二进制。 BackupDir string `json:"backup_dir"` + + // HadDepGraph 记录升级**前**机器上是否存在 depgraph.bin。 + // + // ★ 为什么必须记(这是一个真实的崩溃循环):新包带了 depgraph、而原机器 + // 没有时,回滚只把 homed 换回旧版、不动 depgraph ⇒ 磁盘上是 + // 「旧 homed + 新 depgraph」。旧 homed 内嵌的 digest 对不上, + // kernelboot 装载期直接拒绕启动,而 marker 此时已被清 ⇒ + // **既起不来也回滚不了**。零值 false 与“旧 marker 无此字段”天然兼容。 + HadDepGraph bool `json:"had_depgraph"` + + // Digests 是升级前各目标文件的 sha256(key 为**绝对路径**)。 + // + // ★ 为什么不能用 `--version` 当回滚判据:旧内核(1.3.x 及以前) + // **没有 --version flag**,而旧内核恰恰是最常见的回滚目标。 + // 强行要求它自报版本 = 从旧版本升级失败后永远回滚不了(strict + // 回读失败 → 拒绝启动 → 下次启动仍同一条路径)。 + // digest 是“恢复的是当时那份文件”的证据,与二进制支不支持 --version 无关。 + // + // 兼容性:缺失时回退到“能执行 + 版本相等”(旧逻辑), + // 新写的人一定带它;老 marker 也能继续工作。 + Digests map[string]string `json:"digests,omitempty"` + // At 是写入时间(诊断用)。 At time.Time `json:"at"` } @@ -131,18 +154,27 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error // // 这一步是必须的,而且顺序不能反(2026-10-26 真机实测踩到): // 标记是在**替换之前**写的,所以升级后的**第一次启动**必然看到它。 - // 若不比对版本就直接回滚,那么每次升级都会在第一次启动时被立刻撤销 —— - // 实测表现:`-apply` 报"升级完成",重启后日志却是 - // "ROLLED BACK kernel 1.4.0dev → 1.4.0dev(上一次升级未能确认启动)", - // 也就是升级永远不可能生效。 + // 若不比对版本就直接回滚,那么每次升级都会在第一次启动时被立刻撤销。 // // 语义区分(这才是两段式确认的本意): // // 当前版本 == 标记里的 To ⇒ 新版本**已经跑起来了** ⇒ 确认,清标记,继续启动 // 当前版本 != To(或读不到)⇒ 新版本没能成为运行版本(exec 失败、被 guard 回退等) // ⇒ 回滚到备份 + // + // ★★★ 确认分支**必须**用严格版本(不可用带推断回退的 ReadBinVersion)。 + // + // 这里曾经用 ReadBinVersion,于是与回滚校验是同一个病:它在 exec 失败时会 + // 回退到 InferVersionFromRunning()(读 /usr/local/bin/homed 的字符串、或跑 + // /usr/local/bin/waiter --version)。已实测的事故形态:binPath 是一个 + // 不可执行的残文件,marker.ToVer 恰好等于本机 waiter 的版本 ⇒ 推断回退从 + // **waiter** 借到相同版本 ⇒ 与 ToVer 相等 ⇒ confirmed=true ⇒ 清 marker、 + // **不回滚** ⇒ 机器带着跑不起来的二进制继续启动,且失去自愈机会。 + // + // 判据只有一条:这个文件自己能不能报出版本。报不出 ⇒ confirmed=false + // ⇒ 走回滚。绝不用推断值去证明"新版已跑起来"。 confirmed := false - if cur, cerr := ReadBinVersion(binPath); cerr == nil { + if cur, cerr := readBinVersionStrict(binPath); cerr == nil { sameVer := cur.Version == rec.ToVer sameCommit := rec.ToCommit == "" || cur.Commit == rec.ToCommit confirmed = sameVer && sameCommit @@ -170,10 +202,15 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error } // 先备份"当前这个坏的"——万一回滚目标也有问题,至少能回到现场 + // + // ★ 落在 dataDir 下,不是二进制目录:二进制目录通常是 /usr/local/bin, + // 只读(ProtectSystem=strict)⇒ MkdirAll 静默失败(旧代码没查 err), + // 现场丢失且日志里只有一条无关的 copy 失败。 ts := NowStamp() - brokenDir := filepath.Join(filepath.Dir(binPath), "homed-broken-"+ts) - os.MkdirAll(brokenDir, 0755) - if err := CopyFile(binPath, filepath.Join(brokenDir, "homed")); err != nil { + brokenDir := BrokenDirPath(dataDir, ts) + if err := os.MkdirAll(brokenDir, 0755); err != nil { + log.Printf("[update] create broken-binary dir %s failed: %v", brokenDir, err) + } else if err := CopyFileAtomic(binPath, filepath.Join(brokenDir, "homed")); err != nil { // 备份失败不阻断回滚:回滚是主线,留现场是支线 log.Printf("[update] backup of broken binary failed: %v", err) } else { @@ -181,22 +218,192 @@ func RollbackIfUnconfirmed(dataDir, binPath string) (bool, string, string, error } // 原子替换回旧版 - if err := CopyFile(backupBin, binPath); err != nil { - return false, "", "", fmt.Errorf("回滚二进制: %w", err) + if err := CopyFileAtomic(backupBin, binPath); err != nil { + // ★ 不清 marker:拷贝失败时磁盘上可能已是**半新半旧**的文件, + // 保留标记才有"下次启动再试"的机会。 + return false, "", "", fmt.Errorf("回滚二进制: %w(marker 保留,下次启动会再试)", err) + } + + // ★★★ 回滚的可信性判据:**digest**,不是 `--version`。 + // + // ## 为什么不能要求 `--version`(这是 PR #31 引入的兼容性回归) + // + // 旧内核(1.3.x 及以前)**没有 `--version` flag** —— 它是 2026-10-25 + // 才加的。而旧内核恰恰是最常见的回滚目标:从 1.3.0 升到 1.4.0、 + // 1.4.0 起不来,要回滚的位置就是 1.3.0。 + // + // 要求回滚后读版本,结果就是:文件已正确还原成可执行的 1.3.0, + // 但读取严格版本失败 ⇒ 报“回滚失败” ⇒ kernelmod/update 拒绝启动 + // ⇒ **从旧版本升级失败后永久回滚不了**,而 marker 保留使其卡在 + // 同一个循环里。 + // + // ## digest 为什么是更强的判据 + // + // 要证明的不是“它能报版本”,而是“恢复的就是升级前那一份”。 + // marker 里记了升级前每个文件的 sha256,回滚后逐一校对 —— + // 比 `CopyFile 没报错` 强(字节搬完 ≠ 内容一致),也比 `--version` + // 强(后者对旧版本不可用,对零字节备份可能撞到别的版本)。 + rb, verr := verifyRestored(rec, binPath) + if verr != nil { + // 不清 marker:让下次启动再试(也让人有机会介入) + return false, "", "", fmt.Errorf( + "回滚后校验失败: %v;marker 保留(备份可能已损坏,需人工用已知良好版本替换 %s)", + verr, binPath) } // 回滚 depgraph.bin(必须与 homed 同批) - if backupDG := filepath.Join(rec.BackupDir, "depgraph.bin"); FileExists(backupDG) { - if err := CopyFile(backupDG, filepath.Join(filepath.Dir(binPath), "depgraph.bin")); err != nil { - return false, "", "", fmt.Errorf("回滚 depgraph.bin: %w", err) - } + // + // ★ 三种情况,缺一不可(旧实现只处理了第一种): + // + // 原来有 + 备份有 → 还原 + // 原来有 + 备份无 → 不算成功(两文件不同批) + // 原来无 → **删掉**新包装上的那份,否则旧 homed 的内嵌 + // digest 对不上 ⇒ kernelboot 拒绕启动 ⇒ 崩溃循环 + if err := restoreDepGraph(rec, binPath); err != nil { + // ★ 不清 marker:homed 回滚了但 depgraph 没跟上 ⇒ 两者不同批。 + // 这种状态**不能**当成功:内核启动时会因依赖图不匹配而失败, + // 保留 marker 才能让下次启动再试一遍(且人能看到日志)。 + return false, "", "", err } - // 清标记:已回到旧版,不再有"待确认的新版" + // 只有上面全部通过才清 marker:已确认回到旧版且**可执行**, + // 不存在"待确认的新版"。 if err := ClearPendingUpgrade(dataDir); err != nil { log.Printf("[update] clear pending after rollback: %v", err) } - log.Printf("[update] rolled back to %s(备份 %s)", rec.FromVer, rec.BackupDir) + log.Printf("[update] rolled back to %s(备份 %s,已校验)", rb, rec.BackupDir) return true, rec.FromVer, rec.ToVer, nil } + +// verifyRestored 校验回滚后的状态,返回它现在报的版本(仅作日志用)。 +// +// 判据优先级(新 marker 走 digest,旧 marker 降级): +// +// ① marker 里有 Digests[binPath] ⇒ 逐字节 sha256 比对(最可靠) +// ② 没有 digest(旧 marker)⇒ 至少要求能执行且版本 == FromVer +// ③ FromVer 为空 ⇒ 只要求能执行 +// +// ★ 降级路径不删除:现场可能还躺着旧 marker(升级进行中、marker 由旧版 +// 本写的),而那种 marker 没有 digests。让它显式降级,而不是直接失败。 +func verifyRestored(rec *PendingRecord, binPath string) (string, error) { + abs, err := filepath.Abs(binPath) + if err != nil { + abs = binPath + } + want, hasDigest := rec.Digests[abs] + if !hasDigest { + want, hasDigest = rec.Digests[binPath] + } + if hasDigest { + got, serr := sha256File(binPath) + if serr != nil { + return "", fmt.Errorf("计算 %s 的 sha256: %w", binPath, serr) + } + if !strings.EqualFold(got, want) { + return "", fmt.Errorf("digest 不符:期望 %s,实际 %s", ShortSum(want), ShortSum(got)) + } + // digest 对上已足够。版本仅用于日志:老内核报不出就报 "unknown"。 + if v, verr := readBinVersionStrict(binPath); verr == nil { + return v.Version, nil + } + return rec.FromVer, nil + } + + // 旧 marker:没有 digest,降级为“还原内容 == 备份内容”。 + // + // ★ 为什么不再用 `--version`(PR #31 的回归):老内核没有这个 flag, + // 而老内核恰恰是最常见的回滚目标 —— 要求它就把“从旧版升级失败” + // 变成永久不能回滚。 + // + // 这个降级只能证明“拷过去的就是备份里那份”,不能证明备份本身完好 —— + // 而在旧 marker 上没有更好的证据可用(digest 是本次新增的字段)。 + // 新写的人一定有 digest,所以这个分支只在升级进行中、跨版本的 + // 遗留 marker 上生效。 + backupBin := filepath.Join(rec.BackupDir, filepath.Base(binPath)) + wantSum, werr := sha256File(backupBin) + if werr != nil { + return "", fmt.Errorf("备份不可读(%s): %w", backupBin, werr) + } + gotSum, gerr := sha256File(binPath) + if gerr != nil { + return "", fmt.Errorf("读回 %s: %w", binPath, gerr) + } + if !strings.EqualFold(gotSum, wantSum) { + return "", fmt.Errorf("还原内容与备份不一致(期望 %s,实际 %s)", + ShortSum(wantSum), ShortSum(gotSum)) + } + // 备份可能与 marker 的 FromVer 不符(备份被别的升级覆盖过)。 + // 读得到版本就校,读不到(老内核)就放行 —— 内容一致已经是本分支的上限。 + if v, verr := readBinVersionStrict(binPath); verr == nil { + if rec.FromVer != "" && v.Version != rec.FromVer { + return "", fmt.Errorf("版本不符:期望 %s(标记里的 FromVer),实际 %s", + rec.FromVer, v.Version) + } + return v.Version, nil + } + return rec.FromVer, nil +} + +// restoreDepGraph 把 depgraph.bin 恢复到升级前的状态。 +// +// 这个函数的存在理由是一个真实的崩溃循环:新包带了 depgraph、而原机器 +// 没有时,回滚只换 homed 不删 depgraph ⇒ 「旧 homed + 新 depgraph」。 +// 生产构建的 homed 会校验编译期内嵌的图 digest,不匹配则**拒绝启动**, +// 而 marker 此时已被清 ⇒ 既起不来也回滚不了。 +func restoreDepGraph(rec *PendingRecord, binPath string) error { + dgPath := filepath.Join(filepath.Dir(binPath), "depgraph.bin") + + // ★ 先看备份里到底有没有 depgraph,再看 marker 怎么说。 + // + // 为什么不能只看 rec.HadDepGraph:这个字段是本次新增的,**存量 marker + // 里都是零值 false**。而一个从旧版本升上来的机器显然是有 depgraph 的 + // (打包脚本强制同装)。只看字段会把“该还原”误判成“该删除”。 + // + // 反向也成立:字段说 true 但备份里没有,说明备份不完整 —— 那时不能 + // 把依图留着不管(会与旧 homed 不批),必须报错。 + // + // 所以:**备份里有就是事实**,它优先级高于 marker 字段。 + backupDG := filepath.Join(rec.BackupDir, "depgraph.bin") + haveBackup := FileExists(backupDG) + + if !haveBackup && !rec.HadDepGraph { + // 升级前没有 ⇒ 现在也不该有。 + if err := os.Remove(dgPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("移除升级时新增的 %s: %w(marker 保留)", dgPath, err) + } + log.Printf("[update] removed %s(升级前不存在)", dgPath) + return nil + } + + if !haveBackup { + return fmt.Errorf("备份缺 depgraph.bin(%s):homed 已回滚但依图未跟上,marker 保留", backupDG) + } + if err := CopyFileAtomic(backupDG, dgPath); err != nil { + return fmt.Errorf("回滚 depgraph.bin: %w(homed 已回滚但 depgraph 未跟上,marker 保留)", err) + } + + // 有 digest 就校:依图错了一样本会拒绕启动。 + abs, _ := filepath.Abs(dgPath) + if want, ok := rec.Digests[abs]; ok { + if got, serr := sha256File(dgPath); serr != nil || !strings.EqualFold(got, want) { + return fmt.Errorf("depgraph.bin digest 不符(marker 保留)") + } + } + log.Printf("[update] restored %s", dgPath) + return nil +} + +// quarantineMarker 把读不懂的 marker 改名留存(不删)。 +// +// 为什么改名而不是删除:那个文件里可能有部分可用信息(比如完整的 +// from_version 但 to_ 被截断),是排查"上次升级到底发生了什么"的唯一线索。 +// 删了就永久没了。改名能让下一次启动干净地当作"无标记",同时保留现场。 +func quarantineMarker(dataDir string) (string, error) { + src := pendingPath(dataDir) + dst := src + ".corrupt-" + NowStamp() + if err := os.Rename(src, dst); err != nil { + return "", err + } + return dst, nil +} diff --git a/internal/update/source.go b/internal/update/source.go index 26631ab9..cb243e9d 100644 --- a/internal/update/source.go +++ b/internal/update/source.go @@ -87,6 +87,30 @@ func (s Source) Resolve(workDir string) (root string, rel *ReleaseInfo, err erro return root, nil, err case FromRelease: + // ★ 先试差分(第 4 项):只需下载几十 KB 的 delta,用本地旧二进制 + // 重建新二进制,不用下 88MB 整包。 + // + // 差分是**优化**,不是可用性前提:任何一步不可用就退回整包。 + // 实测量级:改一行逻辑只差 80 字节,1MB 文件小改的补丁占 0.8%。 + // + // 为何先取 info 再决定:delta 是否可用取决于**当前本机 commit** + // 能否在 release 资产里找到匹配的 `-from-.delta`。 + // 为何传 current 而不是 nil:delta 的匹配需要**本机 commit** + // (命名里的 `-from-`)。传 nil 会让 CurrentC 为空, + // 于是永远匹配不到 delta —— 那会让整条差分路径静默失效。 + preInfo := CheckRelease(currentVersionForDelta()) + if preInfo.DeltaURL != "" { + if ok, derr := TryDeltaUpgrade(workDir, KernelBinaryPath(), preInfo.Latest, preInfo); ok { + root := filepath.Join(workDir) + if FindFile(root, "homed") != "" { + return root, preInfo, nil + } + } else if derr != nil { + // 不当作失败:记录后走整包。不向上抛,避免“差分失败→升级失败”。 + preInfo.Reason = "差分不可用,退回整包:" + derr.Error() + } + } + pkgPath := filepath.Join(workDir, "release.tar.gz") info, derr := DownloadLatestRelease(pkgPath) if derr != nil {