Files
HomeAgent/internal/update/channel.go
JianFeeeee ba281a855a 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 失败。
2026-10-29 09:54:55 +08:00

196 lines
7.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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