feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控

三条主线,由 e2e 实测驱动。

## 一、差分 OTA(internal/update/delta.go)

实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。

用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N
峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。

真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。

三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果
sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 ——
差分是优化,不是可用性前提。

接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta
匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需
PREVIOUS_HOMED),delta 进 SHA256SUMS。

## 二、安装形态分派(internal/update/install.go + channel.go)

内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm
形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade
覆盖回旧版,或 dpkg 拒绝后续升级)。

新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/
client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的
info 目录、容器标记文件),不做路径推断。

三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。

## 三、事件总线统一受安全监控(internal/events + security)

用户指正:「eventbus 上所有事件都要接受安全模块的监控」。

此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的
一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus,
任何插件都能绕过策略发任意事件(含伪造内核事件)。

现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events
↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的
BusHandle。内核身份免授权,但载荷照样过凭据闸门。

白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者
(webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于
能伪造 agent 说的话。

## e2e 暴露并修掉的三个真实回归

	[bus] subscribe denied: pluginmgr type=update_request
	[bus] subscribe denied: skillmgr type=skill_detected

前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅
(安全边界在发布侧,订阅只是「听得到」)。

## 顺带的止血修复

- 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该
  flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久
  无法回滚。
- CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。
- restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次
  自愈机会)。
- 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」
  会因 digest 不匹配崩溃循环)。
- homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名
  漂移);改用内核已有的 restartCh → exit 42 语义。
- homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下
  静默失效,该路径从未工作过);改用 internal/config。
- 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里
  完全没有落实降权)。
- rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。

## 判据

新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、
执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
This commit is contained in:
JianFeeeee
2026-10-29 09:54:55 +08:00
parent 7f68d2b1bf
commit ba281a855a
42 changed files with 4897 additions and 272 deletions

195
internal/update/channel.go Normal file
View File

@ -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")
}