- 后端
- 网络安全
【免费下载链接】anubis
Weighs the soul of incoming HTTP requests to stop AI crawlers
mkmsi是 Anubis 仓库中一个专用的构建期工具:它把yeet打包流水线产出的 Windows zip 归档转换为可通过msiexec安装的 MSI 安装包,并在安装器中内置原生 Windows 服务注册、配置引导与升级处理。本文以 internal/cmd/mkmsi/README.md 为主线,结合工具全部源码与测试,讲清它的依赖陷阱、完整构建流水线、MSI 表级"外科手术"补丁、可复现性工程与配套的自动化验证。读完你将掌握这个工具从 zip 文件名解析到 MSI 交付的全链路工作原理,并能在自己的打包流程中复用它。
mkmsi 是什么:从 yeet zip 到 MSI 的桥梁
Anubis 的发布打包由yeet驱动(见 yeetfile.js),其中 Windows 平台会先构建出anubis-<version>-windows-<arch>.zip归档。mkmsi位于 internal/cmd/mkmsi/main.go,其职责正如源码首行注释所述:"turns the Windows zip that yeet builds into an MSI installer"。
它的核心工作方式非常直白:调用 GNOME msitools 工具链(wixl、wixl-heat、msibuild、msiinfo),以 run/windows/anubis.wxs 为安装包定义,把 zip 解包、补入配置模板、生成文档碎片,最终编译并修补出一个行为"得体"的 MSI。整个构建分为两大阶段:
- 准备阶段:解压 zip、生成配置文件与文档清单;
- 编译与修补阶段:
wixl编译.wxs生成 MSI,随后用msibuild对生成结果做多处表级修补——这些修补之所以必要,是因为 wixl 对.wxs中若干合法声明会静默丢弃或随机化,只有事后直接操作 MSI 数据库才能得到正确结果。
命令行接口
mkmsi暴露四个命令行参数(main.go):
| 参数 | 必填 | 默认值 | 含义 |
|---|---|---|---|
--zip | 是 | — | yeet 构建出的 Windows zip 路径 |
--out | 是 | — | 输出的 MSI 文件路径 |
--packaging-dir | 否 | run/windows | 存放anubis.wxs与配置模板的目录 |
--staging-dir | 否 | var/mkmsi | 暂存构建产物的工作目录(按架构分子目录,构建前会清空旧内容) |
--zip与--out缺一不可,缺失时程序直接以log.Fatal退出(main.go)。构建成功后,程序会把 MSI 的最终路径打印到标准输出,方便外层脚本(如 yeet)继续消费。
环境准备:msitools 依赖与版本陷阱
README 明确要求工具链版本为msitools 0.106 或更高,并把wixl、wixl-heat、msibuild、msiinfo四个可执行文件放在PATH上。macOS 与 Linux 上推荐用 Homebrew 安装:
brew install msitools为什么发行版自带的 msitools 通常不可用
README 特别警告了两类发行版陷阱:
- 包拆分:Debian 与 Ubuntu 把这四个工具拆进两个包——
msibuild与msiinfo在msitools包中,而wixl与wixl-heat在单独的wixl包中。只装其中一个包会导致mkmsi找不到工具而失败。 - 版本过老:Ubuntu 24.04 自带的 msitools 是 0.103。该版本在遇到
run/windows/anubis.wxs中的<Condition>元素时会直接中止,报错unhandled child Component node Condition。由于.wxs中的升级启动条件(WIX_UPGRADE_DETECTED)依赖这个元素,0.103 完全无法构建本项目。
工具缺失时的测试行为
有趣的是,mkmsi的测试对工具缺失采取了"本地跳过、CI 强制"的策略。msi_test.go 中requireTool在找不到wixl/wixl-heat/msiinfo/msibuild时跳过用例;而设置了环境变量MKMSI_REQUIRE_VERIFY=true的 CI 作业会把"跳过"升级为"失败",防止出现"声称验证了却什么都没验证"的假绿。这一机制源于一个历史教训:GitHub Actions 在每个 job 上都设置CI=true,如果直接拿CI做门禁,从不安装 msitools 的主 Go 工作流会在每个 PR 上误报失败。
与 yeet 集成:在打包流水线中调用 mkmsi
README 明确说明"这是设计给 yeet 在打包时运行的",并给出了 yeetfile.js 中的实际调用代码(仓库中已落地):
// Build MSI installers from the Windows zips that were just built. packages .flat() .filter((pkg) => pkg.includes("windows")) .filter((pkg) => pkg.endsWith(".zip")) .forEach((zip) => { const msiPath = zip.replace(/\.zip$/, ".msi"); $`go run ./internal/cmd/mkmsi --zip ${zip} --out ${msiPath}`; });这段代码从 yeet 的packages列表中筛出所有 Windows zip,然后对每个 zip 运行go run ./internal/cmd/mkmsi,把输出路径从.zip换为.msi。也就是说,mkmsi 本身不需要预先编译成独立二进制,直接以go run方式随打包流程执行即可。go.mod位于仓库根目录,internal/cmd/mkmsi是该模块内的一个标准package main。
构建流水线拆解:从 zip 文件名到 MSI
main.go中的build()函数完整串起了整条流水线(main.go):
- 从 zip 文件名解析版本与架构;
- 把版本转换为 MSI ProductVersion;
- 计算 ProductCode 与 PackageCode;
- 清空并重建按架构隔离的 staging 目录;
- 解压 zip;
- 生成配置模板(
etc/anubis.env、etc/anubis.yaml); - 生成文档目录的 wxs 碎片;
- 调用
wixl编译并做后处理修补。
第一步:从文件名解析版本与架构
zip 文件名必须匹配zipNameRe正则^anubis-(.+)-windows-(amd64|arm64)\.zip$(build.go)。versionFromZipName取中间的版本段,archFromZipName取amd64或arm64。文件名不符合时返回ErrBadVersion——注意anubis-1.26.2.zip(缺少平台后缀)也会被拒绝,确保"看不懂就失败"而不是产出错误安装包。
第二步:版本号到 MSI ProductVersion 的转换
MSI 的 ProductVersion 要求形如major.minor.build.revision的四段纯数字,其中major 与 minor 上限 255,build 与 revision 上限 65535。version.go中的msiVersion用锚定两端正则^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-pre(?P<pre>\d+))?(?:-(?P<commits>\d+)-g[0-9a-fA-F]+)?(?:-dev)?$解析 yeet 的版本串(version.go),转换规则如下:
| yeet 版本串示例 | 含义 | 转换结果 |
|---|---|---|
1.26.2 | 正式发布 | 1.26.2.0 |
v1.26.2 | 带 v 前缀 | 1.26.2.0 |
1.26.2-1-gec3cce8a-dev | dev 构建(落后 1 个提交) | 1.26.2.1 |
1.26.2-137-gdeadbee | dev 构建(落后 137 个提交) | 1.26.2.137 |
1.27.0-pre1 | 预发布 | 1.27.0.0 |
关键细节:预发布编号(-preN)没有对应的 MSI 版本段。MSI 判断升级只比较 major.minor.build 三段,无法表达"1.27.0-pre1 低于 1.27.0"。因此预发布与正式版共享同一个 ProductVersion,只能靠不同的 ProductCode 区分——所以 anubis.wxs 中声明了AllowSameVersionUpgrades="yes",保证在 1.27.0 之上安装 1.27.0-pre1(或反之)仍会正确触发升级移除旧版本,而不是两个产品同时注册互相损坏。dev 构建的提交数则落入 revision 段(Windows 比较版本时忽略该段,所以 dev 构建与其基准发布在安装器眼里"版本相同")。
范围检查同样严格:major=256、patch=65536、提交数超过 65535、以及任何一位数字溢出int都会返回ErrVersionOutOfRange;1.26(两段)、1.26.2.4(四段)、1.27.0-rc1(不认识的预发布类型)、1.27.0-pre(预发布缺编号)等一律ErrBadVersion(见 version_test.go 的完整用例表)。正则刻意锚定两端,避免1.26.2.4被前缀匹配成1.26.2.0这种"看似合理的错误答案"。
第三步:确定性 GUID 三件套
version.go中定义了三个 GUID 的生成策略:
- UpgradeCode(固定值):
92f5fc1f-f6f2-4bd1-bbef-79b8a46c665f,是 Anubis 家族的"族谱"标识,永远不允许改变——改了它,所有未来安装器都无法升级已有安装。 - ProductCode:以 UpgradeCode 为命名空间、对
version + "/" + arch做 UUIDv5 哈希(uuid.NewSHA1)。同一提交重建得到同一 GUID;不同版本、不同架构得到不同 GUID。架构参与哈希是必要的:amd64 与 arm64 包安装到同一 UpgradeCode 家族,若共享 ProductCode,Windows 会把它们当成同一个产品,装一个就静默卸载另一个。 - PackageCode:同样以 UpgradeCode 为命名空间,但哈希输入是
"packagecode/" + version + "/" + arch前缀串,与 ProductCode 的输入刻意区分,避免两个 GUID 撞车。这是对 MSI 惯例的有意偏离——标准做法要求每个物理 .msi 文件有唯一 PackageCode,但既然整套构建已可复现,再让 PackageCode 每次随机反而会误导人。
配置模板生成:安装器随附的etc目录
stageConfigTemplates(main.go)负责生成安装器携带的两个配置模板:
etc/anubis.env:直接复制 run/windows/anubis.env;etc/anubis.yaml:把 data/botPolicies.yaml(默认策略)与 run/windows/logging.yaml(Windows 日志配置块)按顺序拼接而成。
这两个文件随后会被安装到%ProgramFiles%\Techaro\Anubis\etc,由安装时的自定义动作复制到数据目录。模板中的__ANUBIS_DATA_DIR__占位符是特意留的:数据目录通常在C:\ProgramData\Techaro\Anubis,但%ProgramData%可被重定位,模板不写死路径,由运行时引导逻辑(见下文)写入真实值。
concatFiles与extractOne都做了同一件可复现性关键操作:把生成/解压文件的 mtime 恢复为 zip 条目的统一 mtime(build.go)。因为 wixl 构建的 CAB 归档会把每个文件的 mtime 嵌进去,若这些文件带着墙钟时间戳,即使输入 zip 完全一致,MSI 也会每次构建都不同。解压时还会做路径穿越防护(filepath.Clean+ 前缀校验),防止恶意 zip 条目写到 staging 目录之外。
文档碎片生成:对抗 wixl-heat 的随机目录 ID
zip 内含完整的docs文档树(yeet 从docs/docs复制而来),安装器要把它们装到C:\Program Files\Techaro\Anubis\doc。generateDocFragment(main.go)先把文档目录遍历出的文件列表排序,再通过 stdin 喂给wixl-heat,生成一个挂在DOCDIR目录引用下的doc-files.wxs碎片。
问题在于:wixl-heat 每次调用都会给目录随机生成dir<HEX>ID,即使输入树完全一致(源码注明是经验验证的结论),cmp<HEX>和fil<HEX>是稳定的,只有目录 ID 随机。为此 docfragment.go 实现了rewriteDirectoryIDs:用encoding/xml流式解析生成的碎片,按目录嵌套顺序累积Name属性组成相对路径,再用sha256前 16 字节转大写十六进制,派生形如dir<32位大写HEX>的确定性 ID。选择路径派生而非顺序编号是刻意的——顺序编号会在树中任意增删文件时打乱所有目录编号,而路径派生只影响真正移动过的目录。同时该函数会剔除冗余的xmlns声明,避免与编码器自动生成的命名空间声明冲突产生非法 XML。
wixl 编译与五类后处理修补
runWixl(main.go)调用wixl编译anubis.wxs与doc-files.wxs,通过-D传入 SourceDir、DocSourceDir、Win64、Version、ProductCode、InstallerVersion 等变量。InstallerVersion 按架构区分:amd64 用200(Windows Installer 引擎的长期基线),arm64 必须用500——因为 Arm64 包模板只在 Windows Installer 5.0 引入,声明过低版本可能导致目标机上被以"not supported by this processor type"拒绝。
编译完成后,需要依次执行五类msibuild修补,每一类都是 wixl 能力缺陷或非确定性的补偿:
1.checkWixlOutput:先信 stderr,别信退出码
这是贯穿全程的纪律(build.go):wixl 对不认识的属性只是往 stderr 写 CRITICAL 警告,然后照样以退出码 0 结束,产出一个悄悄丢了属性的安装包。所以每次调用 wixl/wixl-heat 后都必须先检查 stderr 是否含CRITICAL,命中即报ErrWixlDroppedAttribute。build_test.go中的TestCheckWixlOutput用捕获的样本 stderr 验证了这条纯字符串匹配逻辑。
2.secureInstallDir:让INSTALLDIR=覆盖真正生效
MSI 的安装过程会提升到系统上下文(deferred 阶段),届时命令行传入的INSTALLDIR=...若不在SecureCustomProperties属性中,就会被静默忽略。wixl 0.106 无法在.wxs中声明式完成这件事:Property/@Secure="yes"会被静默丢弃,手写<Property Id="SecureCustomProperties">又会与 wixl 为MajorUpgrade自动生成的行冲突、直接让构建中止。因此 secureInstallDir 在构建后用msibuild -q UPDATE把INSTALLDIR追加进SecureCustomProperties,再用msiinfo export读回验证——"写入后必须读回校验"是本文件对msibuild同样不信任的体现。
3.patchInstallerImages:替换 WiX 默认安装界面图片
MSI 的向导界面(WixUI_Minimal)默认带 WiX 的横幅与对话框图。真实 WiX 用<WixVariable Id="WixUIBannerBmp">覆盖,但 wixl 不支持;另一条路是把 wixl 的 ext/ui 树 vendor 进仓库,但那意味着把约 18 个 MS-RL 许可证的.wxs文件带进这个 MIT 项目。最终方案是直接用msibuild -a替换 MSI 中Binary.WixUI_Bmp_Banner与Binary.WixUI_Bmp_Dialog两个流(main.go),图片来自 run/windows/banner.bmp 与 run/windows/dialog.bmp。替换后仍用msiinfo extract把流抽回来比对字节长度,确认补丁真的落盘。
4.patchSummaryInfo:修正 Template 与 PackageCode
wixl 0.106 没有 arm64 目标(只认 intel/intel64/ia64),所以 arm64 包是先按 x64 编译、再把摘要信息流的 Template 从x64;1033修正为Arm64;1033。同时 PackageCode 会被 wixl 随机化,这里用msibuild -s统一写为前面算出的确定性 UUIDv5(大写加花括号格式)。
实现上有个精妙约束:msibuild -s每次调用会同时覆盖 Subject、Template、PackageCode 三个字段,所以全文件只保留这一个-s调用点,且每次都重申全部三个值,杜绝两个调用点互相覆盖的隐患。修正后同样用msiinfo suminfo读回 Template 与 "Revision number (UUID)" 校验。
5.patchEnvironmentID与patchCustomActionSequence:消除两个非确定性来源
- Environment 表主键:wixl 0.106 无视
.wxs里声明的Id="env_path",给 PATH 行分配随机 GUID。MSI 的 SQL 方言拒绝 UPDATE 主键列(实测报 "failed to execute query"),所以 patchEnvironmentID 采用"DELETE 后按固定主键重新 INSERT"的标准绕法,SQL 字符串经sqlEscape转义单引号。 - 自定义动作时序:patchCustomActionSequence 发现 wixl 对相对 Before/After 提示的解析不确定——同一份 byte 级一致的输入连跑八次,
SetAnubisExePath有时排到 1402(紧随 RemoveExistingProducts),有时排到 4001(紧随 InstallFiles)。两种排法都满足.wxs约束,wixl 退出码毫无信号。这里把两个动作钉死在InstallFiles的 Sequence 值 +1/+2 上(InstallFiles的值是读回来的而非硬编码),且只修正值、不动行的物理存储位置——后者实测不可通过 msibuild 的 SQL 接口移动,而 Windows Installer 按 Sequence 值执行动作,行序差异纯属外观残留。
安装包本体:anubis.wxs 解剖
run/windows/anubis.wxs 是安装包定义的核心,可概括为以下几点:
- 目录布局:
ProgramFiles64Folder\Techaro\Anubis下分bin(两个 exe)、etc(两个配置模板)、doc(文档树);组件全部标记Win64="yes",仅支持 64 位。 - 服务注册:
anubis.exe组件内嵌ServiceInstall,以NT SERVICE\Anubis虚拟账户运行,Start="demand"(手动启动)、ErrorControl="normal"、Vital="yes";ServiceControl配置为卸载时停止并删除服务。虚拟账户由 Windows 在装服务时自动创建、卸载时自动删除,无需管理密码。 - 升级语义:
MajorUpgrade开启AllowSameVersionUpgrades;另有cmp_svc_start_on_upgrade组件,其ServiceControl的启动请求被Condition>WIX_UPGRADE_DETECTED门控——只有检测到旧版本存在时才在安装时启动服务。这是对一个真实缺陷的修复:RemoveExistingProducts排在InstallServices之前,升级会先删旧服务再建新服务,若不显式请求启动,升级会静默报告成功而让 Anubis 处于停止状态;同时全新安装因还没有配置与签名密钥,绝不能自动启动。 - PATH 写入:
cmp_path组件把[BINDIR]追加到系统PATH。 - 自定义动作:
SetAnubisExePath(immediate,把[BINDIR]anubis.exe写入属性)→BootstrapConfig(deferred、Impersonate="no",执行--windows-bootstrap-config,且仅在NOT REMOVE时运行)。deferred 动作读不到安装器属性,必须先由 immediate 动作把可执行路径固化到属性里。 - 界面与许可:
WixUI_Minimal向导集,EULA 对话框显示 run/windows/License.rtf。
运行时配置引导:--windows-bootstrap-config
安装器在文件落盘后调用的BootstrapConfig自定义动作,对应 cmd/anubis/service_windows.go 中 Windows-only 的--windows-bootstrap-config标志。其工作内容:
- 把
%ProgramFiles%\Techaro\Anubis\etc下的anubis.env与anubis.yaml复制到数据目录%ProgramData%\Techaro\Anubis(ProgramData环境变量未设置时拒绝猜测路径,防止filepath.Join把空值拼成相对路径 "Techaro\Anubis"); - 通过
SetNamedSecurityInfo以SID 字节形式给NT SERVICE\Anubis授予数据目录的 Modify 权限(读配置、写轮转日志、删除旧日志,但不含 WRITE_DAC/WRITE_OWNER,服务无法自我提权)。这里刻意不 shell 出icacls——icacls 会把 SID 反查成账户名,而引导发生在服务注册之前,LSA 无法映射尚未注册的服务 SID,会导致整个调用以错误 1332 失败; - 把引导诊断写入
anubis-bootstrap.log。原因是 msiexec 会丢弃自定义动作的全部日志输出,失败时只留下晦涩的 "Error 1603";同时引导失败必须返回成功退出码,否则同样会以 1603 掩盖真实错误。
服务启动后,早期 stderr 会被重定向到anubis-startup.log(服务进程没有可用 stderr),运行期日志则按logging.yaml写入anubis.log并自动轮转。管理员需在首次启动前设置ED25519_PRIVATE_KEY_HEX(64 位十六进制),否则 Anubis 每次重启都会重新生成随机签名密钥,使所有已签发挑战失效(run/windows/anubis.env)。
可复现性工程:让同一提交产出 byte 级一致的 MSI
README 坦言一个现实约束:由于 msitools 与 MSI 格式的种种限制,当前无法让 MSI 构建完全可复现(该工具由 Claude Opus 5 作为正确性 fuzzer 生成,用于确保产物在 Windows 上行为正常)。但代码里为"尽可能可复现"做了大量工作,上述修补大多同时服务于正确性与可复现性。TestMSIReproducibleBuild(msi_test.go)把同一 zip 构建两次,要求除两个已知残留外完全 byte 一致:
- 摘要信息流的时间戳:
Created与Last saved由 wixl 按墙钟写入,而msibuild -s没有对应参数; - InstallExecuteSequence 表的物理行序:
SetAnubisExePath与BootstrapConfig行的存储位置不可移动(实测与 Action 字符串绑定),但执行顺序由 Sequence 值决定,行为无影响。
其余所有表与流(含 CAB 归档)都必须是精确字节相等。该测试先做字节级比较,不一致时才逐表、逐流比对以定位差异来源。
质量保障:测试矩阵与验证门禁
internal/cmd/mkmsi下三个测试文件构成完整验证体系:
- version_test.go:
msiVersion的约 20 组正反用例(含 pre 版本、dev 构建、范围溢出、畸形串),以及 ProductCode 的确定性、随版本/预发布/架构变化、拒绝坏版本四组断言; - build_test.go:
versionFromZipName解析与checkWixlOutput的 CRITICAL 识别; - msi_test.go:真实调用 wixl 链构建 MSI 后的表级断言,覆盖服务注册(
ServiceInstall/ServiceControl)、PATH 写入、自定义动作、WixUI_Minimal对话框集、INSTALLDIR 安全属性、arm64/amd64 的 Template 与 InstallerVersion 差异、安装器图片字节长度、License.rtf 与 LICENSE 同步、升级才启动服务、双构建可复现等。
这些集成测试需要 msitools 与 yeet 产出的 zip,因此在本地自动跳过(requireTool/findZip),仅当MKMSI_REQUIRE_VERIFY=true的 CI 作业(如 package-builds-stable/unstable 工作流的 "Verify MSI contents" 步骤)中才强制执行,构成"发布包必须过、日常开发不阻塞"的门禁。
实际使用:安装、自定义路径与静默部署
最终 MSI 供管理员在 Windows Server 上使用(docs/docs/admin/environments/windows.mdx),默认安装到C:\Program Files\Techaro\Anubis并注册名为Anubis的服务。由于 MSI 没有图形化选目录界面,指定安装位置需通过INSTALLDIR属性(该属性被secureInstallDir补丁保证在提权阶段仍然生效):
msiexec /i anubis-1.26.2-windows-amd64.msi INSTALLDIR="D:\Anubis"静默安装加上/qn:
msiexec /i anubis-1.26.2-windows-amd64.msi /qn INSTALLDIR="D:\Anubis"注意INSTALLDIR只影响程序文件位置,配置始终写入%ProgramData%\Techaro\Anubis。首次安装不会自动启动服务(等待管理员配置策略与签名密钥),配置完成后用Start-Service Anubis启动、Set-Service Anubis -StartupType Automatic设置开机自启;升级会短暂停服换新再重启,且不保留启动类型,升级后需重新执行Set-Service。
小结
mkmsi的价值不在于"能生成 MSI"这一结果,而在于它把 MSI 生态中一系列隐蔽陷阱显式化了:wixl 静默丢属性、随机化目录 ID 与 GUID、非确定性的动作时序、INSTALLDIR提权丢失、虚拟账户 ACL 前置授权、msiexec 吞日志等,每个都以"构建后读回校验 + 表级修补 + 可复现测试"三重手段兜底。如果你的项目也在用 msitools 从 zip 生成 Windows 安装包,这份代码是一个值得逐行研读的参考实现;而 Anubis 用户则可以直接把构建产物作为 Windows Server 上的标准安装体验。
- 后端
- 网络安全
【免费下载链接】anubis
Weighs the soul of incoming HTTP requests to stop AI crawlers
相关推荐
构建 ILSpy Windows 安装程序(MSI):从发布产物到 WiX 打包全流程指南
构建 ILSpy Windows 安装程序(MSI):从发布产物到 WiX 打包全流程指南 本指南以 ILSpy.Installer/README.md htt
逆向工程开发工具桌面应用Ruffle Windows MSI 安装包构建指南:使用 WiX 工具集为 ruffle_desktop 打包安装程序
Ruffle Windows MSI 安装包构建指南:使用 WiX 工具集为 ruffle_desktop 打包安装程序 本指南基于 Ruffle 仓库中的 d
音视频PowerToys 本地化构建产物如何打包进 MSI 安装包?
PowerToys 本地化构建产物如何打包进 MSI 安装包? PowerToys 的界面文案通过 resx / resw 资源文件和 lcl 翻译文件管理,流
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考