ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Windows 桌面应用自动更新链复盘:版本号三处不一致导致的静默拒绝安装

Windows 桌面应用自动更新链复盘:版本号三处不一致导致的静默拒绝安装 过去一年半我在维护一台 Windows 电脑上运行的桌面工具时前后遇到两次自动更新故障。这两次故障有个共同特征用户端没有任何提示客户端日志也不报错程序照常运行就是升不上去。用户描述统一是我点更新没反应或者更新完了还是老版本。这类问题比崩溃难查得多。崩溃至少有调用栈静默失败只有结果和预期不符这一个事实。我把整个排查过程、更新链的数据结构、校验机制的设计取舍以及后来补上的自动化发布脚本整理成这篇复盘。文中数值都是我实际抓到的命令和文件名也是真实使用的形态。图自动更新链的四个真相源必须一致## 更新链上其实有四个版本真相源在动手查问题之前我先画了一遍数据流。当时的想法很朴素客户端检查更新看见新版本就下载安装。画完才发现整条链路上记录当前该是什么版本的地方有四处其中任何一处写得不一样链条就会走偏。### 描述文件里的版本号安装包旁会生成一个描述文件用于描述本次发布的安装包信息。它是发布流程的产物和安装包同目录内容大致是这样yamlversion: 1.7.9files: - url: app-setup-1.7.9.exe sha512: 8f3c...省略 size: 118734912path: app-setup-1.7.9.exesha512: 8f3c...省略releaseDate: 2026-07-14T02:11:38.412Z这个文件的version字段被客户端用来判断服务端有没有更新的包。它由打包工具在构建结束时写入人工容易忘改。### 接口返回值、文件名、客户端内置版本号这三处除了描述文件客户端还会调用一个轻量的版本接口返回值长这样json{ version: 1.7.8, mandatory: false, notes: 修复若干问题, url: /releases/app-setup-1.7.8.exe}这个接口由另一套服务维护版本号是一个独立的配置项。它和描述文件里的version是两份互相不知道对方存在的数据谁也不会在对方变更时收到通知。这是第二处。第三处是app-setup-1.7.9.exe这个文件名本身。发布脚本按版本号拼文件名客户端下载时按 URL 拿文件如果文件名和描述文件里的path字段不一致结果就是下载 404 或者拿到旧文件。第四处是应用自身编译进去的版本号来自package.json的version字段并反映在关于页和技术支持要用的诊断信息里。这个值决定客户端认为自己是什么版本也决定它比对时用哪个基准。### 四者必须同时一致把四个源列出来之后规则就很清楚了package.json的版本、描述文件version、接口返回version、安装包文件名里的版本四处必须完全一致缺一处一致都会出问题。而且它们分布在三台不同的机器和三套不同的流程里开发机、构建流水线、配置服务。这就是隐患的根源。## 第一次事故描述文件改了接口没改故障出现的时间点是 7 月 15 日上午。前一晚发布了 1.7.9。### 从用户报障到锁定接口用户反馈集中在一句话“点检查更新转一圈就停了没反应。“我先把客户端日志拉回来看。检查更新这条路径的日志我写得很密正常情况下会看到七到八行输出但故障时的日志只有三行[update] check start[update] feed loaded, latest1.7.9[update] blocked: version mismatch第三行是我自己埋的兜底日志。它说明本地读到了 1.7.9但被某个校验挡住了而且挡住之后直接 return没有输出更详细的原因。这里暴露了我排查工具上的短板日志写了结论没写依据。我当时的头一个猜测是缓存。描述文件和安装包都走 CDN可能边缘节点缓存了旧的描述文件。于是我手动拉了一次描述文件bashcurl -s $RELEASE_HOST/releases/latest.yml | head -5返回的version是 1.7.9releaseDate是昨晚的时间戳2026-07-14T02:11:38.412Z缓存是新的。这个猜测被否掉了。第二个猜测是本地缓存。客户端会把上次读到的描述文件存到本地避免每次都请求。我把本地缓存目录翻了一遍文件时间和内容都是 1.7.9 对应的也排除了。排到这一步剩下的能产生version mismatch的地方就只有接口返回值了。我直接打接口bashcurl -s $RELEASE_HOST/api/update/check?platformwin32channelstable返回{version:1.7.8,...}。问题定位完成描述文件已经是 1.7.9接口还在报 1.7.8。客户端拿到两个不同的版本号判定为数据不一致”按 fail-closed 策略拒绝继续。### 为什么当时选择拒绝安装而不是取其中一个值事后有人问我为什么不干脆以接口为准或者以描述文件为准为什么要拒绝。我的理由是这两份数据出现在同一段逻辑里本来就互为佐证。如果它们不一致说明至少有一个流程出错了而我没有办法在客户端侧判断哪个是对的。假设我选接口为准用户会被引导去下载 1.7.9 的包而描述文件校验又是基于接口给的哈希那个哈希对应的是 1.7.8结果会是下载完校验不过、白白浪费流量。假设我选描述文件为准那么当描述文件被错误覆盖时用户会装到一个不该装的版本。拒绝的代价是这个版本推不下去但保证不会装错。这个取舍后面还会再讨论。修复动作与那次疏漏。修复只花了很短的时间把接口配置项的版本号改成 1.7.9重启配置服务客户端立刻恢复。用户那边下一次检查更新就正常了。但我在修复时又漏了一步。我改了配置就收工没有把接口版本号由谁负责同步这件事写进发布清单。于是两周后第二次事故发生了。这是整件事里我处理得较差的地方同样的根因犯了两次。## 校验机制正确但也带来排障成本要理解两次事故里拒绝安装这个动作从哪来得先讲这套校验是怎么设计的。它分为三层。### 哈希校验逐字节核对描述文件里同时有sha512和size两个字段。客户端下载完安装包后做两件事比对字节长度比对哈希值。哈希是逐字节算的安装包改动任何一个字节摘要都会变。长度这一层是为了尽早失败——大小不对时不用算完整个哈希直接判失败省掉几百毫秒。jsconst stat await fs.stat(tmpFile);if (stat.size ! expected.size) { throw new Error(size mismatch: got ${stat.size}, want ${expected.size});}const digest await sha512OfFile(tmpFile);if (digest ! expected.sha512) { throw new Error(hash mismatch);}### 签名校验裸公钥要套 SPKI 前缀哈希只能证明文件没在传输中损坏”不能证明这个描述文件是我发的。因为描述文件和安装包一起放在服务器上谁改了描述文件就能把哈希改成篡改后安装包的哈希哈希校验照样通过。于是我加了签名层。发布脚本用私钥给描述文件正文签名客户端内置公钥验签。私钥只在发布机上服务器上只有签名结果。这里踩了一个具体的坑。我把公钥以裸的 32 字节 Base64 形式存进客户端然后直接喂给标准库js// 这样会抛 ERR_OSSL_ASN1_WRONG_TAG 之类的错const key crypto.createPublicKey({ key: Buffer.from(rawBase64, base64), format: der, type: spki,});标准库不接受裸公钥它要的是带算法标识和参数结构的一段完整 DER 编码。Ed25519 的 SPKI 前缀是固定的 12 个字节302a300506032b6570032100。把它拼在 32 字节裸公钥前面整段才是合法的 SPKIjsconst SPKI_PREFIX Buffer.from(302a300506032b6570032100, hex);const spkiDer Buffer.concat([SPKI_PREFIX, Buffer.from(rawBase64, base64)]);const key crypto.createPublicKey({ key: spkiDer, format: der, type: spki });const ok crypto.verify(null, payload, key, Buffer.from(sigBase64, base64));这个前缀写死之后就没再出过问题。我把这段单独写了个小测试防止后来有人顺手简化掉它。### 篡改反向自证改一个字节必须验失败校验逻辑对不对光靠正常流程能通过是不够的因为一个永远返回 true 的校验函数也能让正常流程通过。所以我做了一组反向测试思路是主动破坏数据看校验是否失败。具体测了四种破坏方式翻转描述文件中的一个字符、把安装包末尾追加一个字节、把签名串中间的一个字符改掉、把描述文件里的size改成比真实值小 1。四种情况下校验都必须失败其中后两种会直接命中签名校验。bashnode scripts/verify-negative.js# flip-feed-char - rejected (signature invalid) ok# append-package-byte - rejected (size mismatch) ok# corrupt-signature - rejected (signature invalid) ok# shrink-size-field - rejected (signature invalid) ok这四条跑通之后我对校验层的信心才建立起来。后面第二次事故能被快速定位也依赖这套反向测试提供的确定性不是校验写错了是输入数据错了。## 第二次事故签名文件与安装包不同步第二次发生在 8 月 1 日发布 1.8.2。这一次影响面是全部客户端。### 现象比上次更严重上次是部分用户看到没反应这次是所有在线客户端全部拒绝更新而且日志里出现了明确的一行[update] signature verify failed: feed同样地用户端没有任何错误提示框。这个设计后来被讨论过很多次我在后面单独讲。### 定位过程先排除客户端问题因为上一个版本 1.8.1 更新是正常的客户端二进制里改动只涉及业务代码不涉及更新模块所以我倾向于怀疑发布侧数据。验证方法很简单从公网拉一遍描述文件和签名本地用同一个公钥跑一次验签。bashnode scripts/verify-feed.js --feed ./pulled/latest.yml# payload bytes: 412# signature bytes: 64# verify: FAILFAIL 说明数据侧确实错了。接着我核对签名的来源签名文件是发布脚本生成的脚本读取描述文件正文做签名写出latest.yml.sig。如果描述文件在签名之后又被改动过签名就会失效。我拿服务器上的描述文件和签名文件的修改时间对比bashstat -c %y %n latest.yml latest.yml.sig app-setup-1.8.2.exe# 2026-08-01 03:41:12 latest.yml.sig# 2026-08-01 03:44:07 latest.yml# 2026-08-01 03:40:55 app-setup-1.8.2.exe描述文件的时间戳比签名晚了将近 3 分钟。顺序清楚了签名先做描述文件后被改动签名自然对不上。### 根因发布流程里的两步被拆开了继续往下追改动描述文件的是发布脚本里的一个后置步骤它要往描述文件里补写发布说明字段。而签名步骤在上传阶段执行。两个步骤分别属于两个脚本中间隔了上传和等待 CDN 的时间所以出现了三分钟的窗口。根因和第一次事故是同一类同一个版本发布事实被拆到两个地方写且没有任何机制检查它们的先后顺序。第一次是描述文件和接口不一致这次是描述文件和签名不一致。修复动作是当晚补丁发布把签名步骤移到所有对描述文件的写操作之后并且加一条断言签名文件的生成时间必须晚于描述文件的修改时间否则流程直接失败。bashif [ $(stat -c %Y latest.yml.sig) -lt $(stat -c %Y latest.yml) ]; then echo FATAL: sig older than feed; exit 1fi## fail-closed 的取舍与它的排障代价两次事故里客户端的行为都是校验不过就不装。这个策略在工程上叫 fail-closed我选它是有明确理由的但它也确实带来了成本。### 为什么宁可拒绝也不冒险自动更新有一个特殊性质它是把新代码推送到用户机器上的通道。这个通道本身如果可以被绕过那么整条信任链就没有意义了。设想一下如果签名校验失败时客户端选择忽略签名继续装那么任何一个能改写描述文件的人都能给全部用户推任意程序。所以签名校验失败必须拒绝这一条没有商量空间。哈希校验失败也同理。哈希不对意味着下载的字节和我签过名的字节不一致可能是传输损坏也可能是中间人替换这两种情况都不应该继续安装。版本不一致这一条相对争议更大因为它的危害看起来只是装错版本而不是装恶意程序。但我还是坚持拒绝原因是它会掩盖真实的发布错误。如果客户端偷偷用其中一个版本继续服务端那份配错的版本号可能几个月都不会有人发现直到下一次出现更严重的后果。### 排障成本落在哪里代价体现在三个地方。一是用户端无感。拒绝安装是静默的用户只看到没有更新不会看到校验失败。这是刻意的错误提示如果暴露校验细节会给出攻击者有用的反馈但如果什么都不说用户和客服都拿不到线索。二是需要单独的诊断入口。为了弥补用户无感的问题我在设置页放了一个诊断功能用户点一下会输出一段文本包含本地版本、远端版本、描述文件哈希、签名校验结果这几项。排障时让用户把这段文本复制给我就能快速判断问题在哪一层。这个功能后来在两次事故的收尾阶段都派上了用场。三是必须自己造工具。因为拒绝路径在客户端侧被有意做得很安静服务端必须有对应的核查手段。这就是下一节的发布脚本。## 发布脚本的自动化一个命令更新四处第一次事故之后我写了一个发布脚本。第二次事故之后我把它补成了现在这样。它的目标是把四个真相源的写入收敛到一次原子操作里。### 一次写入四处并自查脚本的主流程大致是bashnode scripts/release.js --version 1.8.5 --channel stable# [1/6] build app ok 18.4s# [2/6] pack installer ok 41.2s 118734912 bytes# [3/6] write feed sign ok feed412 bytes sig64 bytes# [4/6] upload artifacts ok 6 files# [5/6] patch version api ok was1.8.4 now1.8.5# [6/6] verify from public ok sha512 match关键设计是第 5 步和第 6 步。第 5 步把接口版本号的更新纳入脚本接口不再是人工在配置后台点一遍的东西。脚本调用配置接口写入版本号写完之后立刻回读一次确认返回值是新版本不一致就退出并回滚到旧值。第 6 步是发布后校验从公网地址重新下载一次描述文件、签名、安装包用同一套校验逻辑核对真实字节。### 发布后从公网下载真实字节再核对这一步是我最看重的一步因为它在验证的不是我本地文件对不对而是用户实际会拿到什么。本地文件对但 CDN 上的文件不对这种情况完全可能发生上传中断、缓存串号、路径写错。手工检查通常只看本地目录看不到这一层。脚本会从公网 URL 拉取安装包的前若干块和完整哈希同时把描述文件、签名全部重下一遍跑一次和客户端完全相同的校验函数jsconst remote await downloadToTemp(url);const bytes await fs.stat(remote);assertEq(bytes.size, manifest.size, remote size);assertEq(await sha512OfFile(remote), manifest.sha512, remote sha512);assertTrue(verifyFeedSignature(pulledFeed, pulledSig), remote signature);这一步失败时脚本会打印差异明细并标记本次发布为未完成我不会再去点任何东西先把这个差异查清楚。### 清理旧版本包与控制保留数量发布脚本还有一个收尾步骤是删除过老的安装包避免存储一直涨。规则是保留最近 5 个版本的安装包和描述文件更早的删除。这个数字是权衡出来的留太少回滚时找不到可用的旧包留太多既占空间又没有实际用途。清理逻辑有个约束删除前必须确认待删除版本的描述文件和签名也一并删除不能只删安装包。曾出现过一次只删了安装包、描述文件还在的情况结果客户端的更新检查读到历史描述文件误判为有一个可选更新点进去是 404。这个 bug 因为保留策略只影响 5 个版本之前的记录表现得非常隐蔽。## 灰度与回滚保留上一个版本失败自动回退更新通道本身也要有退路。我在这块做了三件事。### 分通道发布接口按通道返回版本stable和beta。发布时先把新版本只写入beta通道自己在两台测试机上手动检查更新并安装。观察一天没有异常再写stable。这个做法在 1.8.2 那次之后变成硬性要求因为如果当时先发 beta签名问题会在小范围内被拦下来而不是打到全部用户。### 安装失败自动回退安装过程分两段下载到临时目录并校验、调用安装程序替换当前版本。第二段如果失败客户端会保持当前进程可用并回滚已经写入的文件。实现细节上安装前会把当前版本用到的关键文件按清单记录到一个备份目录键名是版本号。安装程序返回非零退出码时客户端从备份目录复制回原文件并重启jsconst code await runInstaller(setupPath, [/S, /D targetDir]);if (code ! 0) { await restoreFromBackup(backupDir, targetDir); log.warn(installer exit ${code}, rolled back to ${fallbackVersion});}这个回退逻辑在半年里只触发过两次两次都是磁盘空间不足导致安装程序报错。但它的存在让我在发布时心态完全不同最坏情况是更新失败不会变成程序打不开。### 保留上一个版本的完整包回退需要旧包可用所以服务器上必须保留上一版的安装包和它的描述文件、签名三件套齐全。这里有个容易忽略的点不能只保留安装包。回退流程里会用描述文件里的哈希和签名校验备份包的完整性如果描述文件被清了回退就只能跳过校验那这条退路本身就不可信了。## 复盘问题不在代码在多个真相源把两次事故和后来的几次小故障放在一起看共同点非常明确。### 版本号出现在越多地方出错概率越高我在纸上列了一遍更新链上出现版本号的位置一共七处package.json、构建产物的文件名、安装包的内部元信息、描述文件version、描述文件path与url、签名覆盖的正文、接口返回值另外还有一处是 CDN 上的 URL 路径里带的版本目录。七处分布在 4 个系统、3 台机器上。我把这七个位置列出来的时候反应是只要有一个位置是手工维护的它就一定会出错区别只是早出还是晚出。第一次事故是接口手工改、漏改了一次。第二次是描述文件被后置步骤改、签名没跟上。两次都不是代码逻辑写错两次都是同一个事实写在多个地方某处忘了同步。这跟我以前遇到的一类 bug 很像同一条业务规则在前后端各写一份校验两边慢慢漂移。解决办法从来不是下次记得同步而是把副本干掉或者加一个能自动发现漂移的检查。### 一致性检查清单现在每次发布前跑一遍发布前检查清单。这份清单是从两次事故的根因反推出来的共 9 项1.package.json的version与待发布版本号字符串完全一致包括没有多余的 v 前缀。2. 构建产物文件名里的版本号与package.json一致。3. 描述文件version与package.json一致。4. 描述文件path字段指向的文件名与磁盘上真实文件名一致。5. 描述文件size与安装包真实字节数一致误差 0 字节。6. 描述文件sha512与重新计算的摘要一致。7. 签名文件生成时间晚于描述文件修改时间。8. 接口返回值与描述文件version一致且回读确认过。9. 从公网重新下载三件套描述文件、签名、安装包通过同一套校验函数。这 9 项里第 5、6、7、8、9 项都是自动执行的。第 1 到 4 项属于本地检查也在脚本里做了断言。也就是说现在这 9 项没有一项依赖我的记性。关于静默这个设计我仍然保留。有同事建议过校验失败时至少弹一句更新失败请稍后重试。我保留了现在的设计但改了一处现在失败会在本地日志里记录完整的失败层级是签名层、哈希层、还是版本一致性层只是不弹窗。这样正常用户完全无感而我拿到诊断文本时可以一层一层往下切不需要让用户在界面里看到任何技术细节。这个平衡点是两次事故之后确定的。第一次事故我花了大约 40 分钟才定位到接口主要时间浪费在没有失败层级信息上。现在同样的问题从用户发来诊断文本到确认层级通常在 5 分钟内。日志层级一共三档feed表示描述文件自身的校验问题payload表示安装包字节的问题consistency表示多个版本号之间对不上。三档区分开以后看一眼关键词就知道该去查哪一侧的数据不用再从头复现整个更新流程。剩余的那一条经验。如果这套流程里只能留下一条经验我会留下这条**凡是同一个事实需要在两个地方写两遍的设计都要假设它们已经不一致了然后想办法让它不一致时立刻暴露。**这件事具体到更新链上就是签名时间断言和接口回读确认两处加起来不到 30 行代码但它们拦住的正是两次实际发生过的故障。我把这两处单独做了注释标记写明它们对应的故障日期避免后来接手的人在重构时觉得这一步多余而删掉。事实上这类断言被删的风险很高因为它们平时永远不报错看起来像是永远不会执行的死代码。## 相关实现文中这套自动更新链来自一台 Windows 电脑上运行的桌面工具它常驻托盘、按通道接收更新、用签名与哈希双重校验保证安装包未被篡改。整套机制是我在实际使用和发布过程中反复踩坑后逐步补齐的。关于它的其它模块拆解依赖瘦身、体积优化、构建约束另有专文可以在 dingdang.asiadingdang.asia
返回列表