三端一键安装脚本(server / agent / client)
三端一键安装脚本(server / agent / client)
Title
feat(install): 三平台 × 三角色一键安装脚本(.sh / .ps1)与 user 模式 node identity 修复
Target branch
main(实现分支 codex/one-click-install)。
Summary
为 server / agent / client 三个角色各提供一条命令的交互式一键安装,覆盖 「下载 → SHA256 校验 → 安装二进制 → 渲染配置 → 配置校验 → 注册服务 → 启动 → 自检摘要」, 并支持升级、回滚与卸载。
架构是「入口薄、共享库厚」:tm_main(PowerShell 侧 Invoke-TmMain)用模板方法固定 8 个阶段,
三个入口只声明角色问答、配置渲染与安装后动作。下载、校验、缓存、交互、渲染、服务生命周期
全部集中在 tunnelmesh-install-common.sh / .ps1,服务模板复用仓库既有的单一来源
(deploy/systemd/、新增 deploy/systemd-user/、deploy/macos/、deploy/windows/)。
顺带修复一个被 agent-only 测试掩盖的阻塞缺陷:init-node-id 的持久化路径此前硬编码为
/var/lib/tunnelmesh/node-id,非 root 不可写,导致 server 在 Linux 默认 user 模式下必然以
退出码 6 安装失败。新增 --node-id-path 标志后,一键脚本与 systemd user 单元都把它指向
用户可写的状态目录。
User impact
- 新增面向用户的安装入口:Linux/macOS
install-<role>.sh,Windowsinstall-<role>.ps1。 根README.md的 Quick start → Install 改为一键安装优先,scripts/install.sh降级为「只要二进制、不注册服务」的补充路径(行为未变)。 - 支持五种调用形态:先落盘再执行(推荐)、
bash -c "$(curl ...)"、curl | bash、curl | bash -s -- <args>、Windows& ([scriptblock]::Create((irm <url>))) <args>。 两种一行式的已知失效模式(curl 失败静默返回 0、bash -c吃掉第一个参数当$0)写进了--help与文档。 - Linux 默认用户级安装,不需要 root;
--mode system才走系统级单元并要求 root。 - 重复运行即升级且默认保留现有配置;
--reconfigure才重新生成,覆盖前自动备份。 tunnelmesh-server新增--node-id-path持久化标志(默认值不变,向后兼容)。
API, schema, and configuration impact
- HTTP API:无变更。不涉及
docs/api/openapi.yaml。 - 数据库 Schema:无变更。不涉及
migrations/、SchemaVersion与增量迁移链。 - 协议 frame:无变更。
- CLI:新增
tunnelmesh-server --node-id-path <file>(agent/client 也接受该持久化标志但用不到, 因为它是 root 命令的持久化 flag)。默认值仍是config.DefaultNodeIDPath,既有部署行为不变。 - 部署产物:
- 新增
deploy/systemd-user/tunnelmesh-{server,agent,client}.service,占位符__BINARY__、__CONFIG__、__ENV_FILE__、__STATE_DIR__;server 单元的init-node-id与run都带--node-id-path __STATE_DIR__/node-id。 deploy/macos/tunnelmesh.plist新增__ENVIRONMENT__占位符(渲染为EnvironmentVariablesdict,无敏感值时渲染为空串)。deploy/windows/tunnelmesh-service.xml新增__ENV_BLOCK__占位符(渲染为<env>元素, 无敏感值时为空串)。deploy/systemd/tunnelmesh-agent.service新增可选EnvironmentFile=-/etc/tunnelmesh/agent.env(前缀-:缺文件不阻塞启动,向后兼容)。agent.token的 YAML 键是yaml:"-",只能走环境变量。deploy/install/macos-install.sh与windows-install.ps1把新占位符渲染为空,行为不变。
- 新增
- 发布归档:Linux 归档新增
deploy/systemd-user/;所有平台归档新增deploy/install/oneclick/(入口 + 共享库 +winsw-checksums.txt)。*_test.go与testdata/由scripts/build-release.sh在打包前删除,不进归档。 - 配置渲染:生成的 YAML 只含非敏感键;敏感值落
~/.config/tunnelmesh/<role>.env(0600)、/etc/tunnelmesh/<role>.env(0640root:<运行组>)、渲染后的 LaunchAgent plist(0600)或 WinSW 服务 XML 的<env>块(ACL 收紧)。
Security impact
- 不提供明文 token 参数:
.sh无--token,.ps1无-Token(oneclick_scripts_test.go用\[string\]\$Token\b正则守护)。敏感值只走隐藏输入、TUNNELMESH_*环境变量、--token-file、--secret-env-file(后两者校验文件权限为 0600/0400/0640)。 - 摘要与日志中的敏感值一律掩码(前 4 位 +
****);TestNoHardcodedSecrets现在同时扫描.sh与.ps1,脚本内无任何真实凭据。 - 归档必须通过同 Release 目录的
SHA256SUMS校验后才解压;缓存命中后仍重新校验, 不匹配即删除缓存条目并重下。校验文件名与「sha256 + 两个空格 + 归档名」格式与scripts/install.sh共用同一份契约(TestReleaseContractMatchesLegacyInstaller守护)。 - WinSW 只下载
winsw-checksums.txt中已登记校验和的版本;无登记条目时中止(退出码 4) 并给出手工步骤,不做「先下载再信任」。 - 从 raw 下载的共享库必须满足「HTTP 成功、非空、首行是
#!/usr/bin/env bash」,否则退出码 4。 - 不放宽任何既有安全策略:非 loopback 监听仍要求显式同意(
TM_ONECLICK_ALLOW_REMOTE=1) 且auth_mode非none;agent metadata 名称在交互期即校验敏感词;server 默认监听127.0.0.1,TLS 交给 Nginx。 --node-id-path修复同时收窄了权限面:user 模式不再需要写/var/lib/tunnelmesh, systemd user 单元的ProtectSystem=full+ReadWritePaths=<state>得以成立。- 卸载保留配置、env、密钥与数据目录,并逐条打印保留路径(
TestUninstallKeepsConfiguration守护)。
Tests run
全部在本机(macOS,bash 3.2.57)执行,退出码均为 0:
go test ./... -count=1 # 全量,无失败
go test -race ./... # 全量,无失败
go test -race ./deploy/... ./internal/cli/... -count=1 # 本次改动范围,含新增用例
go vet ./...
git diff --check
bash -n deploy/install/oneclick/*.sh deploy/install/*.sh scripts/*.sh
sed 's/^__ENVIRONMENT__$//' deploy/macos/tunnelmesh.plist | plutil -lint -
TM_ONECLICK_E2E=1 go test ./deploy/install/oneclick -count=1 -run TestOneClick -v
python3 scripts/gen_doc_index.py # 幂等,无残留 diff
E2E 结果:
--- PASS: TestOneClickInstallUpgradeUninstall (2.18s) # agent:安装 → 升级 → 卸载
--- PASS: TestOneClickServerUserModeInstall (2.06s) # server:非 root user 模式(回归守护)
TDD 红/绿证据(--node-id-path 修复):三条新用例先全部 RED,实现后全部 GREEN。
root_internal_test.go:102: init-node-id --node-id-path: unknown flag: --node-id-path
install_templates_test.go:55: ../systemd-user/tunnelmesh-server.service must point init-node-id at the user-writable state dir
testdata/run_tests.sh: 1 failure(s) # validate/node-id-path
手工验证(本地 HTTP 镜像模拟 raw 源 + go build 出的真实二进制 + --archive):
| 场景 | 命令形态 | 结果 |
|---|---|---|
| 入口单独落盘、共享库走 raw 回退下载 | bash install-agent.sh --help |
退出码 0,打印用法 |
| Homebrew 风格命令替换 | /bin/bash -c "$(curl -fsSL <url>)" install-agent --help |
退出码 0 |
Homebrew 风格 + 离线归档 + --yes |
/bin/bash -c "$(curl -fsSL <url>)" install-agent --archive <tar.gz> --yes ... |
退出码 0;SHA256SUMS 校验通过;二进制 0755、YAML/env 0600;token 掩码为 e2e-****,stdout/stderr 均无明文 |
| 管道 + 传参 | curl -fsSL <url> \| bash -s -- --help |
退出码 0 |
管道 + 离线归档 + --yes |
curl -fsSL <url> \| bash -s -- --archive <tar.gz> --yes ... |
退出码 0,摘要正常,无 token 泄露 |
| 非法版本格式 | ... --version 1.2.3 |
退出码 2 |
篡改 SHA256SUMS |
... --archive <tar.gz> --yes |
退出码 5,打印 expected/actual 两个哈希 |
无 tty 且未 --yes、缺 Server URL |
TM_ONECLICK_YES=1 ... |
退出码 3,点名缺失项 |
bash -c "$(curl 失败)" |
404 URL | 退出码 0 且无任何输出 → 文档已写明该失效模式 |
| server cluster + MySQL(stdin 喂答案) | TM_ONECLICK_ALLOW_STDIN=1 ... <<EOF |
退出码 0;mode: cluster、driver: 'mysql';DSN 只在 server.env(0600),不在 YAML;node-id 落在状态目录 |
server local + SQLite(--yes) |
TM_ONECLICK_YES=1 ... --no-service |
退出码 0;mode: local、driver: 'sqlite'、http_addr: '127.0.0.1:8080' |
| 卸载 | --uninstall |
退出码 0;二进制删除、配置与 env 保留并逐条打印 |
未验证项(环境限制,非本次改动引入):
- PowerShell 语法与真实 Windows 安装未验证:本机无
pwsh/powershell,.ps1只有静态契约断言 (TestPowerShellContract、TestPowerShellEntryBootstrapIsIdentical、TestPowerShellEntriesStayThin、TestPowerShellEntryHelp)。需在 Windows 上实际执行一次三角色安装与-Uninstall, 并确认渲染出的*-service.xml中arguments、workingdirectory、logpath、<env>与预期一致。 winsw-checksums.txt无已登记条目:本环境api.github.com与raw.githubusercontent.com可达,但github.comRelease 下载超时,无法取得 WinSW 官方资产算校验和。补录前 Windows 自动获取 WinSW 的分支会主动中止并提示-WinSW <本地路径>;补录步骤写在winsw-checksums.txt头部与docs/deployment/windows-service.md。- 真实 GitHub Release 下载链路未验证:同上原因。下载/校验/缓存逻辑由
testdata/bin/curl桩 + fixture 归档在函数级与 E2E 覆盖,但「真的从 github.com 拉一个 tag」需要在有外网的机器上跑一次bash deploy/install/oneclick/install-agent.sh --version <tag> --no-service --yes --server-url ... --agent-id ...。
文档一致性核对(Task 12 Step 2):
grep -o -- '--[a-z-]\+' docs/deployment/oneclick-install.md | sort -u > /tmp/doc-flags.txt
grep -o -- '--[a-z-]\+' deploy/install/oneclick/tunnelmesh-install-common.sh | sort -u > /tmp/impl-flags.txt
comm -23 /tmp/doc-flags.txt /tmp/impl-flags.txt
唯一输出是 --token,且它出现在「刻意不提供 --token <value>」这句否定说明里,不是可用参数。
反向 comm -13 的输出全部是非安装器 flag:curl 的
--fail/--silent/--show-error/--location/--max-time/--output/--head/--header、
launchctl 的 --home/--home-dir/--shell、systemctl 的 --system/--no-pager/--lines、
二进制的 --config/--node-id-path、admin regenerate-credentials --confirm,
以及测试专用的 --print,均为刻意不在安装文档中罗列。
Release steps
- 合并到
main。本次不含数据库迁移,无需停机、无需备份窗口。 - 正常发布流程打 tag:
cd web && npm ci && npm run build→scripts/build-release.sh。 归档会自动带上deploy/install/oneclick/与(Linux)deploy/systemd-user/。 - 发布后在有外网的机器上补录 WinSW 校验和到
deploy/install/oneclick/winsw-checksums.txt(v2.12.0 amd64 <sha256> WinSW-x64.exe), 跑go test ./deploy/install/oneclick -run TestWinSWChecksumPolicy -count=1,单独提交。 - 发布后在 Windows 上跑一次
install-agent.ps1与-Uninstall,在 Linux 上跑一次 真实install-server.sh --mode system,把结果补记到本文件。 raw.githubusercontent.com/.../main/deploy/install/oneclick/*在合并后立即可用; 需要锁版本的用户改用TM_ONECLICK_REF=<tag>并把 URL 里的main换成同一 tag。
Rollback steps
- 本次不含数据库迁移,回滚只需
git revert对应提交并重新发布,无需数据处理。 - 模板占位符是向后兼容的,但
deploy/macos/tunnelmesh.plist与deploy/windows/tunnelmesh-service.xml必须与各自的渲染脚本同时回滚: 旧版macos-install.sh/windows-install.ps1在新模板上运行会把__ENVIRONMENT__/__ENV_BLOCK__原样留在渲染结果里。两者是一个原子变更。 internal/cli/root.go的--node-id-path与deploy/systemd-user/tunnelmesh-server.service也必须同时回滚:user 单元依赖该标志,去掉标志后 user 模式的 server 会退回/var/lib/tunnelmesh权限失败。system 单元与既有部署不受影响,可独立保留。deploy/systemd/tunnelmesh-agent.service的EnvironmentFile=-前缀保证缺文件不阻塞,可独立回滚。- 已用一键脚本装好的机器不受仓库回滚影响;下线执行
install-<role>.sh --uninstall(保留配置与数据)。 - 发布归档布局变化只影响下一个 tag;已发布的 Release 不可变,无需处理。
- 5 分钟止损方案:撤回 tag 指向(或把
latest指回上一个 Release), 并在文档里临时改回scripts/install.sh的三行安装命令。
Reviewer focus
tm_run_validate与deploy/systemd-user/tunnelmesh-server.service的--node-id-path取值是否都等于tm_default_state_dir/__STATE_DIR__;system 单元刻意不改, 确认这个非对称是有意的(system 模式下/var/lib/tunnelmesh由 root+前缀写入)。- 三个
.sh与三个.ps1入口的 bootstrap 片段是否逐字节一致(两个测试分别守护);.ps1的 raw 回退额外下载winsw-checksums.txt,否则共享库落到临时目录后Get-TmWinSW找不到登记表。 Install-TmService现在优先用解压出的归档里的deploy\windows\tunnelmesh-service.xml, 只在仓库内直接跑脚本时才回退到共享模块旁的相对路径 —— 确认「模板与二进制同版本」这条成立。- bash 3.2 兼容性:
grep -n 'declare -A\|mapfile\|readarray\||&\|&>>\|declare -n'无输出; 空数组展开一律${arr[@]+"${arr[@]}"}。 - 敏感值路径:
tm_secret_value的优先级(secret-env-file > token-file > env > 交互)、--yes下缺值即退出码 3、env 文件用双引号转义(systemd 单引号串没有转义机制)。 - 文档 flag 面与实现是否一致(上面的
comm核对);docs/deployment/oneclick-install.md的默认路径表与tm_default_*的返回值逐条对照。 - server 角色目前没有
--http-addr/--storage-driver之类的非敏感 flag, 无人值守集群部署只能靠 stdin 喂答案或装完改 YAML。设计规格 §11 曾假设这些 flag 存在, 实现按 YAGNI 收窄,文档已如实描述;如果后续要补,应作为独立变更并同步更新规格。
Integration status
- 分支
codex/one-click-install,基线main(6d3ddec)。 - 计划:docs/superpowers/plans/2026-09-23-one-click-install-scripts.md
- 规格:docs/superpowers/specs/2026-09-23-one-click-install-scripts-design.md
- 用户文档:docs/deployment/oneclick-install.md
- Task 0–12 全部完成;上述三项「未验证项」因实现环境无
pwsh、无法访问github.comRelease 下载而遗留,需在具备条件的机器上补做并回填本文件。