自动更新:基于 lode 的共享自更新(草案)
状态:草案,未批准(2026-10-12)。 本页是计划,不是现状描述,留给各仓库补充与决定;§6 的问题回答之前不开工。依据的提交:lode
v0.0.3(6a6d1a4)、nsmain(a05b6f5)、nsgwmain(aa94aa5)、nsdmain(6bce222)、nssharednext(09879c8,v0.6.0之后)。
1. 目标与已定事项
Section titled “1. 目标与已定事项”| 事项 | 结论 |
|---|---|
| 目标 | ns、nsgw、nsd 的二进制自更新用同一套实现,而不是每个仓库各写一份 |
| 底层 | 使用 lode 的 lode-core(SelfUpdate),以 git tag 依赖(crates.io 上的同名包属于别的项目) |
| ns 的发布清单 | 已定:ns 改为发布 lode 兼容的 lode/v1 清单,不再使用 NSD 下发的 JWS release-target 清单 |
| 放在哪里 | 未定,见 §5 与 §6 第 1 问 |
2. lode v0.0.3 提供什么
Section titled “2. lode v0.0.3 提供什么”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。
3. 各组件现状
Section titled “3. 各组件现状”| 组件 | 部署形态 | 现有更新方式 | 与本方案的关系 |
|---|---|---|---|
| ns | 主机上的 CLI 与服务(Linux、macOS、Windows),可能由 systemd 管理 | 自带 crates/updater(约 7k 行,含测试) | 直接使用者 |
| nsgw | 只有容器(Alpine + musl 静态 nsgw-server) | 无,换镜像 | 容器内自更新没有意义(容器重建即还原);只有出现裸二进制部署时才需要 |
| nsd | 只有容器(compose) | 无,换镜像 | 同 nsgw |
ns 现有 updater 的职责(迁移时要逐项保留或明确放弃):
- 从已注册的 NSD 取更新源(Admin 源地址加环境 ID),在内存中拼出清单地址
- 用编译期写入的根密钥校验 JWS 清单;检查过期时间(容差 5 分钟),并按环境维护只增不减的 revision 下限
- 下载固定版本的二进制,先保留备份,再校验装好的文件的 sha256 和
--version - 重启 systemd 服务并要求它保持运行,否则回滚
- 更新锁(lease);预检可写性、属主和运行中的实例(Quick、宿主 App)
- Windows 上一并安装按哈希锁定的 Wintun DLL
- 错误映射到 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>;步骤:
- 回答 §6 的问题,结论写回本页。
- 先写文档:清单约定(资产命名、channel、过期周期、密钥格式)、状态文件布局、更新时序。
- 实现封装,用本地清单服务器测试(lode 的离线 embed 示例就是这种做法),发布。
- ns 迁移(在 ns 仓库跟踪):
ns update改用封装,Wintun 与运行中实例检测放进 ns 的UpdateHost,删除signed_release、download与安装脚本路径。 - 发布流水线(在 ns 仓库跟踪):用
lode-cli给资产和清单签名,在过期前重签,在 NSD 下发的地址提供清单。 - nsgw、nsd 只在出现裸二进制部署时接入。
5. 放在哪里
Section titled “5. 放在哪里”| 选项 | 优点 | 缺点 |
|---|---|---|
nsshared 新增独立 crate nsshared-update | 所有二进制组件共用一套更新时序;独立 crate 不会给 nsshared-proto 的使用方带进 ureq、tar、zip | 目前只有 ns 一个使用方;lode 每次变化都要 nsshared 发版,再由各组件升级 |
| 先写在 ns 里,出现第二个使用方再搬到 nsshared | lode 仍是 0.0.x 时没有跨仓库发版 | 如果 nsgw、nsd 在搬迁前就需要,会重复一份 |
各组件直接调用 SelfUpdate | 没有封装层 | 备份、回滚、版本下限、锁在每个组件各写一遍 |
| 容器用 lode 作为 ENTRYPOINT | 不写代码即有监管、更新、回滚 | 覆盖不了主机上的 ns,也覆盖不了 Windows |
nsshared 的范围规则是”两个及以上组件使用的通用协议与工具”,处于维护期,新契约以新模块或新 crate 的形式在 minor 版本加入,先写文档(见 nsshared)。按目前的部署形态只有 ns 一个使用方,所以第 1 问的答案决定放在哪里。
6. 待决问题
Section titled “6. 待决问题”- 第二个使用方:nsgw 或 nsd 会不会以裸二进制形态发布(systemd、安装脚本)?不会的话,先写在 ns 里。
- 清单地址与环境:ns 现在按环境从 NSD 取清单地址。每个环境是否各有一份
lode/v1清单?版本下限是否仍按环境记录? - 有效期:
--expires-in取多少?谁负责重签?签名密钥在线,还是定时任务重签?lode 判断过期没有时钟容差,发布方提前多久重签? - 更新说明的签名:ns 现在显示的更新说明是签过名的,lode 的
notes不签。接受这一点,还是请 lode 把 notes 纳入清单签名? - 已安装的老客户端:老 ns 只认 JWS 清单。NSD 在一段时间内同时提供两种格式,还是先用旧格式发一个内置新更新器的过渡版本?
- 密钥:沿用现在的 ed25519 根密钥(转成
<key_id>:<base64>),还是换新密钥? - 是否有更新的判断:lode 比较 semver,ns 现在比较发布的 sha256。改为只看 semver,并要求每个构建的
--version都输出合法 semver? - HTTP 与 TLS:
ureq与reqwest并存;ureq是否像reqwest一样读取代理环境变量(未验证)?nsgw 接入时会同时链接ring与aws-lc-rs。 - lode 的稳定性:lode 还在 0.0.x,
v0.0.3改动了SelfUpdate的接口。是否接受按版本锁定、只为升级 lode 而发版?
- lode 接口变化会引发封装层发版,以及各组件的第二次升级。
- 使用
Fresh后,旧清单在过期前仍可被重放,客户端会停留在旧版本直到过期;有效期越长防护越弱,越短越依赖可靠的重签,重签中断会让所有更新停止。 - 重启后回滚依赖服务检测;不受管理的进程在重启前一直运行旧版本。
- Windows 上可执行文件与附带文件不是原子替换;
Displaced会让安装路径上暂时没有可执行文件。 - 不再比较 sha256 后,
ns update --check对本地构建的判断会改变。