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

View File

@ -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-<oldCommit8>.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-<commit8>`,而那个 commit 来自 CurrentC。
// 传 nil 会让 CurrentC 为空 ⇒ 永远匹配不到 delta ⇒ 整条差分路径静默失效
// (看起来"差分已上线",实际每次都在下整包)。
//
// 读不到版本时返回 nil 是合理的降级:老内核没有 --version,
// 那种情况下也确实不该尝试差分(连基线身份都无法确认)。
func currentVersionForDelta() *BinVersion {
v, err := ReadBinVersion(KernelBinaryPath())
if err != nil {
return nil
}
return v
}