跳转到内容

自动更新:基于 lode 的共享自更新(草案)

状态:草案,未批准(2026-10-12)。 本页是计划,不是现状描述,留给各仓库补充与决定;§6 的问题回答之前不开工。依据的提交:lode v0.0.3(6a6d1a4)、ns main(a05b6f5)、nsgw main(aa94aa5)、nsd main(6bce222)、nsshared next(09879c8,v0.6.0 之后)。

事项结论
目标ns、nsgw、nsd 的二进制自更新用同一套实现,而不是每个仓库各写一份
底层使用 lode 的 lode-core(SelfUpdate),以 git tag 依赖(crates.io 上的同名包属于别的项目)
ns 的发布清单已定:ns 改为发布 lode 兼容的 lode/v1 清单,不再使用 NSD 下发的 JWS release-target 清单
放在哪里未定,见 §5 与 §6 第 1 问

lode_core::self_update::SelfUpdate 把正在运行的可执行文件替换为它自己经过校验的新版本。lode 仓库的 docs/embedding.md 是嵌入说明。

能力接口
更新源Source::GitHub(owner/repo) 或 Source::Manifest(url)(原生 lode/v1)
信任.trusted_keys(...),签名始终强制;ed25519、ECDSA P-256/P-384
防冻结.require_manifest(Signed | Fresh):整份清单签名,加签名的过期时间;过期判断没有时钟容差
channel 与凭据.channel(name);.header("Name: ${VAR}"),只发给更新源自己的主机
只读检查check() 返回 Option<Available { version, entry }>;entry 中的 notes、published_at 不在签名范围内
两步更新fetch() 返回 Fetched { dir, binary }(已校验、已解压),再 install(&fetched);apply() 一步完成。调用方可以在两步之间从 dir 取其他文件,也可以只用 binary 自己替换
替换方式新文件放在原文件旁边,先运行 --version 确认版本,Unix 上原子 rename;Windows 上把旧文件改名移到一旁,启动时调用 remove_replaced(&exe) 清理
第二份副本.alias(path)
错误SelfUpdateError 枚举,每种失败一个变体;is_untouched() 区分”本机未改动”和需要修复的 Alias、Displaced

lode 明确不做、需要调用方自己做的:

  • 备份旧版本,等新版本重启后稳定运行再删除,否则回滚
  • 持久化的版本下限:lode 只拿 channel 最新版和当前运行的版本比较,.version(id) 可以降级
  • 更新锁,安装路径的可写性和属主检查
  • 服务(systemd)的检测与重启
  • 异步接口:HTTP 用的是同步阻塞的 ureq

依赖代次与 nsshared 一致(ed25519-dalek 3、sha2 0.11、base64 0.23、getrandom 0.4),rustls 使用 aws-lc-rs。

组件部署形态现有更新方式与本方案的关系
ns主机上的 CLI 与服务(Linux、macOS、Windows),可能由 systemd 管理自带 crates/updater(约 7k 行,含测试)直接使用者
nsgw只有容器(Alpine + musl 静态 nsgw-server)无,换镜像容器内自更新没有意义(容器重建即还原);只有出现裸二进制部署时才需要
nsd只有容器(compose)无,换镜像同 nsgw

ns 现有 updater 的职责(迁移时要逐项保留或明确放弃):

  1. 从已注册的 NSD 取更新源(Admin 源地址加环境 ID),在内存中拼出清单地址
  2. 用编译期写入的根密钥校验 JWS 清单;检查过期时间(容差 5 分钟),并按环境维护只增不减的 revision 下限
  3. 下载固定版本的二进制,先保留备份,再校验装好的文件的 sha256 和 --version
  4. 重启 systemd 服务并要求它保持运行,否则回滚
  5. 更新锁(lease);预检可写性、属主和运行中的实例(Quick、宿主 App)
  6. Windows 上一并安装按哈希锁定的 Wintun DLL
  7. 错误映射到 Fluent 文案;保留旧的安装脚本路径作为兼容

容器部署的 nsgw、nsd 如果需要自动更新,也可以把 lode 本身作为容器的 ENTRYPOINT(监管、自动更新、失败回滚),不需要任何代码,但这一方式覆盖不了主机上的 ns。

在 lode 之上写一层薄封装,把每个使用方都需要、而 lode 留给调用方的部分做一次:

部分负责方
由 NSD 下发的更新源、编译期 trusted keys、require_manifest(Fresh)、各平台资产命名封装
把阻塞调用包成 async(spawn_blocking)封装
更新锁,可写性、属主预检封装
持久化版本下限;拒绝未明确强制的降级封装
备份 → fetch → 替换 → --version → 重启服务 → 观察稳定 → 失败回滚封装
systemd 服务检测与重启封装
启动时 remove_replaced封装
运行中实例检测(Quick、宿主 App)、附带文件(Wintun DLL)、对用户显示的文案调用方,通过 trait 实现

接口草图(待完善):

pub struct UpdateConfig {
pub app: &'static str,
pub current_version: &'static str,
pub trusted_keys: &'static [&'static str],
pub manifest_url: String, // 来自 NSD
pub state_dir: PathBuf, // 锁与版本下限
pub service_unit: Option<String>,
}
pub trait UpdateHost {
/// 阻止更新或需要重启的运行中实例。
fn running(&self) -> Option<RunningInstance>;
/// 从已校验的发布中安装可执行文件之外的文件。
fn install_extras(&self, release_dir: &Path, exe_dir: &Path) -> Result<(), UpdateError>;
}
pub async fn check(cfg: &UpdateConfig) -> Result<Option<Available>, UpdateError>;
pub async fn apply(cfg: &UpdateConfig, host: &dyn UpdateHost) -> Result<Outcome, UpdateError>;

步骤:

  1. 回答 §6 的问题,结论写回本页。
  2. 先写文档:清单约定(资产命名、channel、过期周期、密钥格式)、状态文件布局、更新时序。
  3. 实现封装,用本地清单服务器测试(lode 的离线 embed 示例就是这种做法),发布。
  4. ns 迁移(在 ns 仓库跟踪):ns update 改用封装,Wintun 与运行中实例检测放进 ns 的 UpdateHost,删除 signed_release、download 与安装脚本路径。
  5. 发布流水线(在 ns 仓库跟踪):用 lode-cli 给资产和清单签名,在过期前重签,在 NSD 下发的地址提供清单。
  6. nsgw、nsd 只在出现裸二进制部署时接入。
选项优点缺点
nsshared 新增独立 crate nsshared-update所有二进制组件共用一套更新时序;独立 crate 不会给 nsshared-proto 的使用方带进 ureq、tar、zip目前只有 ns 一个使用方;lode 每次变化都要 nsshared 发版,再由各组件升级
先写在 ns 里,出现第二个使用方再搬到 nssharedlode 仍是 0.0.x 时没有跨仓库发版如果 nsgw、nsd 在搬迁前就需要,会重复一份
各组件直接调用 SelfUpdate没有封装层备份、回滚、版本下限、锁在每个组件各写一遍
容器用 lode 作为 ENTRYPOINT不写代码即有监管、更新、回滚覆盖不了主机上的 ns,也覆盖不了 Windows

nsshared 的范围规则是”两个及以上组件使用的通用协议与工具”,处于维护期,新契约以新模块或新 crate 的形式在 minor 版本加入,先写文档(见 nsshared)。按目前的部署形态只有 ns 一个使用方,所以第 1 问的答案决定放在哪里。

  1. 第二个使用方:nsgw 或 nsd 会不会以裸二进制形态发布(systemd、安装脚本)?不会的话,先写在 ns 里。
  2. 清单地址与环境:ns 现在按环境从 NSD 取清单地址。每个环境是否各有一份 lode/v1 清单?版本下限是否仍按环境记录?
  3. 有效期:--expires-in 取多少?谁负责重签?签名密钥在线,还是定时任务重签?lode 判断过期没有时钟容差,发布方提前多久重签?
  4. 更新说明的签名:ns 现在显示的更新说明是签过名的,lode 的 notes 不签。接受这一点,还是请 lode 把 notes 纳入清单签名?
  5. 已安装的老客户端:老 ns 只认 JWS 清单。NSD 在一段时间内同时提供两种格式,还是先用旧格式发一个内置新更新器的过渡版本?
  6. 密钥:沿用现在的 ed25519 根密钥(转成 <key_id>:<base64>),还是换新密钥?
  7. 是否有更新的判断:lode 比较 semver,ns 现在比较发布的 sha256。改为只看 semver,并要求每个构建的 --version 都输出合法 semver?
  8. HTTP 与 TLS:ureq 与 reqwest 并存;ureq 是否像 reqwest 一样读取代理环境变量(未验证)?nsgw 接入时会同时链接 ring 与 aws-lc-rs。
  9. lode 的稳定性:lode 还在 0.0.x,v0.0.3 改动了 SelfUpdate 的接口。是否接受按版本锁定、只为升级 lode 而发版?
  • lode 接口变化会引发封装层发版,以及各组件的第二次升级。
  • 使用 Fresh 后,旧清单在过期前仍可被重放,客户端会停留在旧版本直到过期;有效期越长防护越弱,越短越依赖可靠的重签,重签中断会让所有更新停止。
  • 重启后回滚依赖服务检测;不受管理的进程在重启前一直运行旧版本。
  • Windows 上可执行文件与附带文件不是原子替换;Displaced 会让安装路径上暂时没有可执行文件。
  • 不再比较 sha256 后,ns update --check 对本地构建的判断会改变。