三端一键安装脚本 Implementation Plan
三端一键安装脚本 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为 server / agent / client 三个角色各提供一条命令的交互式一键安装脚本(Linux+macOS 用 .sh,Windows 用 .ps1),覆盖下载校验、安装、配置生成、服务注册、升级与卸载。
Architecture: 入口脚本薄、共享库厚。tm_main 用模板方法固定 8 个阶段(preflight → resolve&fetch → placement → install binary → configure → validate → register&start → verify&report),三个 .sh 入口只声明角色钩子(tm_role_prompts、tm_role_render_config、tm_role_post_install)。下载/校验/交互/渲染/服务注册全部在 tunnelmesh-install-common.sh;Windows 侧用 tunnelmesh-install-common.ps1 镜像同一套阶段与参数语义。服务托管复用仓库既有的单一模板来源:deploy/systemd/(system)、新增 deploy/systemd-user/(user)、deploy/macos/tunnelmesh.plist、deploy/windows/tunnelmesh-service.xml。
Tech Stack: Bash 3.2 兼容语法(macOS 自带 bash 3.2,禁止 declare -A 之外的 4.x 特性;实测 macOS bash 3.2 不支持关联数组,因此答案存储用 tm_ans_get/set 包装的普通变量命名空间,见 Task 3)、curl、tar、sha256sum/shasum、systemd(system + user)、launchd、PowerShell 5.1+、WinSW v2.12.0、Go 1.23+(契约测试与配置漂移测试)。
Spec: docs/superpowers/specs/2026-09-23-one-click-install-scripts-design.md
Global Constraints
- 仓库:
nnworld/TunnelMesh,分支codex/one-click-install,基线main。 - Release 契约:归档名
tunnelmesh-<version>-<goos>-<goarch>.tar.gz(Windows 为.zip),下载地址<base>/download/<version>/<archive>,校验文件名固定SHA256SUMS,条目格式为「sha256 + 两个空格 + 文件名」。 - 默认 base:
https://github.com/nnworld/TunnelMesh/releases;默认 raw base:https://raw.githubusercontent.com/nnworld/TunnelMesh;latest API:https://api.github.com/repos/nnworld/TunnelMesh/releases/latest。 - 版本格式:
^v[0-9]+\.[0-9]+\.[0-9]+$,不合法退出码 2。 - Linux 默认安装模式 user;
--mode system需 root。macOS 只有用户级 LaunchAgent。Windows 只有机器级服务。 - 敏感值(token、DSN、私钥、主密钥)绝不写入 YAML、模板、脚本或命令行参数;只走交互隐藏输入、
TUNNELMESH_*环境变量或--token-file/--secret-env-file。日志与摘要一律tm_mask。 - 退出码固定:0 成功、2 参数、3 preflight、4 下载、5 校验、6 配置校验、7 服务、8 卸载。
- 已存在的配置文件默认覆盖,覆盖前备份
<path>.bak-<UTC时间戳>,最多保留最近 3 份;--keep-config跳过生成;升级(重复运行)默认不重新生成配置,--reconfigure才重新生成。 - 每个
.sh必须set -euo pipefail,临时目录用mktemp -d且trap ... EXIT INT TERM清理。 - 单一模板来源:脚本不得内联生成第二份 unit/plist/XML 模板,只做占位符替换(
deploy/install/install_templates_test.go守护)。 - 文档与索引:新增记录后执行
python3 scripts/gen_doc_index.py;docs/README.md不得出现无入口的孤儿文档。 - 验证命令(每个 Task 结束前按需执行,最终必须全绿):
go test ./deploy/... -count=1、go test ./scripts/... -count=1、go test ./... -count=1、go test -race ./...、go vet ./...、git diff --check、bash -n deploy/install/oneclick/*.sh。 - 本环境网络限制:
api.github.com可达,github.comRelease 下载不可达。所有自动化测试走--archivefixture 或 PATH 中的curlstub;真实下载与 WinSW 校验和补录需在具备外网的机器上手工完成,结果记入 PR 描述。 - 提交需用户授权:执行本计划时在第一次 commit 前询问一次;获得授权后按各 Task 给出的提交信息提交,未授权则跳过全部 commit 步骤并在收尾报告中说明。
File Structure
新增
| 路径 | 职责 |
|---|---|
deploy/install/oneclick/tunnelmesh-install-common.sh |
Linux/macOS 共享库:常量、日志、平台探测、版本解析、下载校验与缓存、交互原语、渲染原语、服务生命周期、tm_main 编排 |
deploy/install/oneclick/install-server.sh |
server 入口:flag 透传 + 角色钩子(server 问答、YAML/env 渲染、init-node-id/doctor/admin bootstrap) |
deploy/install/oneclick/install-agent.sh |
agent 入口:角色钩子(server_url / agent id / token / metadata) |
deploy/install/oneclick/install-client.sh |
client 入口:角色钩子(server_url / token / tunnels 循环添加) |
deploy/install/oneclick/tunnelmesh-install-common.ps1 |
Windows 共享模块:与 .sh 同阶段、同参数语义 |
deploy/install/oneclick/install-server.ps1、install-agent.ps1、install-client.ps1 |
Windows 三角色入口 |
deploy/install/oneclick/winsw-checksums.txt |
WinSW <version> <arch> <sha256> <asset> pin 列表 + 更新流程说明 |
deploy/install/oneclick/oneclick_scripts_test.go |
Go 契约测试:文件存在性、bash -n、调用 bash 函数套件、入口薄壳约束、密钥硬编码扫描、模板占位符、Release 契约与 scripts/install.sh 双向一致 |
deploy/install/oneclick/oneclick_config_test.go |
配置漂移测试:渲染出的 YAML 必须能被 config.Load 解析且字段值正确 |
deploy/install/oneclick/oneclick_e2e_test.go |
TM_ONECLICK_E2E=1 门控的端到端冒烟:安装 → 升级 → 卸载 |
deploy/install/oneclick/testdata/run_tests.sh |
bash 函数级测试套件(由 Go 测试调用),含 assert_eq/assert_contains/assert_file_mode |
deploy/install/oneclick/testdata/bin/{curl,systemctl,launchctl,loginctl,plutil,runuser}_stub |
记录调用序列的 stub,通过 PATH 前置注入 |
deploy/install/oneclick/testdata/fixtures/SHA256SUMS.{ok,bad} |
校验和 fixture |
deploy/systemd-user/tunnelmesh-server.service、tunnelmesh-agent.service、tunnelmesh-client.service |
user 模式单元模板,占位符 __BINARY__、__CONFIG__、__ENV_FILE__、__STATE_DIR__ |
docs/deployment/oneclick-install.md |
一键安装用户文档(三平台 × 三角色矩阵、交互项、非交互示例、升级/回滚/卸载、镜像与离线、curl \| bash 信任模型、退出码排障表) |
docs/pull-requests/2026-09-23-one-click-install-scripts.md |
PR 记录 |
修改
| 路径 | 修改点 |
|---|---|
deploy/systemd/tunnelmesh-agent.service |
增加 EnvironmentFile=-/etc/tunnelmesh/agent.env(agent.token 的 YAML 键是 yaml:"-",只能走环境变量) |
deploy/macos/tunnelmesh.plist |
增加 __ENVIRONMENT__ 占位符(渲染为 EnvironmentVariables dict,空时渲染为空串) |
deploy/windows/tunnelmesh-service.xml |
增加 __ENV_BLOCK__ 占位符(渲染为 <env> 元素,空时渲染为空串) |
deploy/install/macos-install.sh |
渲染 __ENVIRONMENT__ 为空,行为不变 |
deploy/install/windows-install.ps1 |
渲染 __ENV_BLOCK__ 为空,行为不变 |
deploy/install/install_templates_test.go |
覆盖 deploy/systemd-user/ 三份模板与两个新占位符 |
scripts/build-release.sh |
归档阶段追加删除 testdata/;Linux 归档追加 deploy/systemd-user |
README.md |
Quick start → Install 改为一键安装优先 |
docs/README.md、deploy/README.md、docs/deployment/{binary-release,linux-systemd,macos-launchd,windows-service}.md |
同步文档 |
docs/superpowers/specs/README.md、docs/superpowers/plans/README.md、docs/pull-requests/README.md |
由 gen_doc_index.py 重建 |
Task 11: Windows PowerShell 一键安装
Files:
- Create:
deploy/install/oneclick/tunnelmesh-install-common.ps1 - Create:
deploy/install/oneclick/install-server.ps1、install-agent.ps1、install-client.ps1 - Create:
deploy/install/oneclick/winsw-checksums.txt - Modify:
deploy/install/oneclick/oneclick_scripts_test.go(静态契约断言) - Modify:
docs/deployment/windows-service.md
Interfaces:
- Consumes: 已在 Task 4 加好的
deploy/windows/tunnelmesh-service.xml的__ENV_BLOCK__占位符;deploy/install/windows-install.ps1与windows-uninstall.ps1的服务注册/卸载语义。 - Produces(
.ps1函数名即契约,静态测试按名断言):Resolve-TmVersion [-Version <string>]→vX.Y.Z(GitHub APItag_name→/releases/latest的Location回退)Get-TmPlatform→@{ GoOs; GoArch; ArchiveExt = 'zip' }($env:PROCESSOR_ARCHITECTURE映射AMD64→amd64、ARM64→arm64)Invoke-TmDownload -Url -OutFile(Invoke-WebRequest,失败抛异常 → 退出码 4)Test-TmChecksum -File -SumsFile -ArchiveName(Get-FileHash -Algorithm SHA256,不匹配 → 退出码 5)Get-TmCacheDir($env:LOCALAPPDATA\TunnelMesh\releases)、Get-TmCachePathRead-TmAnswer -Key -Prompt -Default、Read-TmBool、Read-TmChoice、Read-TmSecret(Read-Host -AsSecureString,-Yes下不读)Get-TmSecretValue -Key -Prompt -EnvName(-SecretEnvFile>-TokenFile> 环境变量 > 交互;-Yes且全空 → 退出码 3)Write-TmYaml -Role、Write-TmEnvBlock(渲染<env name= value= />,XML 转义与.sh侧一致)Get-TmWinSW -WinSWPath(本地路径优先;否则查winsw-checksums.txt,无登记条目则中止并打印手动步骤)Install-TmService -Role -Binary -Config -EnvBlock、Uninstall-TmRole -RoleInvoke-TmMain -Role -Args(8 阶段编排,与tm_main一一对应)- 每个入口的
param()块:-Version、-Yes、-Uninstall、-NoService、-NoStart、-NoEnable、-NoCache、-KeepConfig、-Reconfigure、-BaseUrl、-RawBaseUrl、-Archive、-TokenFile、-SecretEnvFile、-InstallDir、-ConfigDir、-StateDir、-ServerUrl、-AgentId、-Tunnel(可多次)、-WinSW
- Step 1: 写失败测试 —— 静态契约断言
.ps1 无法在 macOS/Linux 执行,与既有 windows-install.ps1 同样只做静态断言。追加到 oneclick_scripts_test.go:
var psEntries = []string{"install-server.ps1", "install-agent.ps1", "install-client.ps1"}
const psCommon = "tunnelmesh-install-common.ps1"
func TestPowerShellFilesExist(t *testing.T) {
for _, name := range append(append([]string{}, psEntries...), psCommon, "winsw-checksums.txt") {
if _, err := os.Stat(name); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
}
func TestPowerShellContract(t *testing.T) {
common := readFile(t, psCommon)
for _, want := range []string{
"$ErrorActionPreference = 'Stop'",
"Get-FileHash", "SHA256", "SHA256SUMS",
"Resolve-TmVersion", "Test-TmChecksum", "Get-TmWinSW", "Invoke-TmMain",
"Read-TmSecret", "-AsSecureString",
"releases/latest", "tag_name",
"__ENV_BLOCK__", `..\windows\tunnelmesh-service.xml`,
} {
if !strings.Contains(common, want) {
t.Errorf("%s is missing %q", psCommon, want)
}
}
for _, name := range psEntries {
body := readFile(t, name)
for _, want := range []string{
". $PSScriptRoot\\tunnelmesh-install-common.ps1",
"Invoke-TmMain", "[switch]$Yes", "[switch]$Uninstall", "[string]$Version",
"[string]$TokenFile", "[string]$SecretEnvFile", "[string[]]$Tunnel",
} {
if !strings.Contains(body, want) {
t.Errorf("%s is missing %q", name, want)
}
}
// 不得提供明文 token 参数:命令行参数会进 PowerShell 历史与进程列表。
if strings.Contains(body, "[string]$Token") {
t.Errorf("%s must not accept a plaintext -Token parameter", name)
}
}
}
func TestWinSWChecksumPolicy(t *testing.T) {
body := readFile(t, "winsw-checksums.txt")
for _, want := range []string{"# 格式:", "shasum -a 256", "WinSW-x64.exe"} {
if !strings.Contains(body, want) {
t.Errorf("winsw-checksums.txt is missing %q", want)
}
}
// 数据行必须是 4 列且 sha256 为 64 位十六进制,杜绝占位符被当成校验和。
for i, line := range strings.Split(body, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
fields := strings.Fields(trimmed)
if len(fields) != 4 {
t.Fatalf("winsw-checksums.txt:%d must have 4 fields, got %d", i+1, len(fields))
}
if !regexp.MustCompile(`^[0-9a-f]{64}$`).MatchString(fields[2]) {
t.Errorf("winsw-checksums.txt:%d has an invalid sha256 %q", i+1, fields[2])
}
}
common := readFile(t, psCommon)
if !strings.Contains(common, "winsw-checksums.txt") {
t.Errorf("%s must consult winsw-checksums.txt before downloading WinSW", psCommon)
}
}
Run: go test ./deploy/install/oneclick -run 'TestPowerShell|TestWinSW' -count=1
Expected: FAIL —— stat install-server.ps1: no such file or directory。
- Step 2: 写最小实现 ——
winsw-checksums.txt
# WinSW 校验和 pin 列表。
# 格式(4 列,空格分隔):<version> <arch> <sha256> <asset-name>
#
# 补录流程(必须在可访问 github.com Release 的机器上执行,然后把结果提交到本文件):
# curl -fsSL -o /tmp/WinSW-x64.exe \
# https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe
# shasum -a 256 /tmp/WinSW-x64.exe
# # 追加一行: v2.12.0 amd64 <上一步输出的 64 位十六进制> WinSW-x64.exe
# 并在 docs/deployment/windows-service.md 记录版本、日期与补录人。
#
# 安全策略:install-*.ps1 只自动下载本文件中已登记的版本;查不到条目时**中止**,
# 打印手动下载步骤,并提示用 -WinSW <本地路径> 指定。绝不静默信任未 pin 的二进制。
# 上游未发布 arm64 资产,Windows on ARM 统一使用 WinSW-x64.exe(x64 仿真)。
#
# 当前无已登记条目:本仓库的实现环境无法访问 github.com Release 下载,
# 补录步骤见 Task 12 Step 3 与 docs/deployment/windows-service.md。
- Step 3: 写最小实现 —— 共享模块与入口
tunnelmesh-install-common.ps1 按 Task 11 Interfaces 列出的函数实现,关键约束:
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 2.0
# 退出码与 .sh 侧完全一致,便于统一排障文档。
$script:TmExit = @{ Ok = 0; Usage = 2; Preflight = 3; Download = 4; Checksum = 5; Config = 6; Service = 7; Uninstall = 8 }
function Write-TmInfo { param([string]$Message) Write-Host "==> $Message" }
function Write-TmWarn { param([string]$Message) Write-Warning $Message }
function Stop-Tm { param([int]$Code, [string]$Message) Write-Error "ERROR: $Message"; exit $Code }
# 敏感值只经环境变量注入服务进程,绝不写进 YAML;渲染进 WinSW XML 的 <env> 块,
# 因为 WinSW 没有 EnvironmentFile 机制。渲染后对 XML 收紧 ACL。
function Write-TmEnvBlock {
param([hashtable]$Secrets)
$sb = New-Object System.Text.StringBuilder
foreach ($key in ($Secrets.Keys | Sort-Object)) {
[void]$sb.AppendLine((' <env name="{0}" value="{1}" />' -f (Convert-TmXml $key), (Convert-TmXml $Secrets[$key])))
}
$sb.ToString()
}
Get-TmWinSW 的核心判定(无登记条目即中止):
function Get-TmWinSW {
param([string]$WinSWPath, [string]$Version = 'v2.12.0', [string]$Arch = 'amd64')
if ($WinSWPath) {
if (-not (Test-Path -LiteralPath $WinSWPath)) { Stop-Tm $script:TmExit.Usage "WinSW not found: $WinSWPath" }
return (Resolve-Path -LiteralPath $WinSWPath).Path
}
$table = Join-Path $PSScriptRoot 'winsw-checksums.txt'
$row = Get-Content -LiteralPath $table |
Where-Object { $_ -notmatch '^\s*(#|$)' } |
ForEach-Object { $_ -split '\s+' } |
Where-Object { $_[0] -eq $Version -and $_[1] -eq $Arch } |
Select-Object -First 1
if (-not $row) {
Stop-Tm $script:TmExit.Download @"
WinSW $Version ($Arch) 未登记在 winsw-checksums.txt 中,拒绝自动下载。
请手工下载并用 -WinSW 指定路径:
https://github.com/winsw/winsw/releases/download/$Version/WinSW-x64.exe
或按 winsw-checksums.txt 头部的补录流程登记校验和后重试。
"@
}
$dest = Join-Path (Get-TmCacheDir) "WinSW-$Version-$Arch.exe"
Invoke-TmDownload -Url "https://github.com/winsw/winsw/releases/download/$Version/$($row[3])" -OutFile $dest
$actual = (Get-FileHash -LiteralPath $dest -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $row[2].ToLowerInvariant()) {
Remove-Item -Force $dest
Stop-Tm $script:TmExit.Checksum "WinSW checksum mismatch (expected $($row[2]), actual $actual)"
}
return $dest
}
三个入口的结构(以 agent 为例):
[CmdletBinding()]
param(
[string]$Version = 'latest',
[switch]$Yes, [switch]$Uninstall, [switch]$NoService, [switch]$NoStart, [switch]$NoEnable,
[switch]$NoCache, [switch]$KeepConfig, [switch]$Reconfigure,
[string]$BaseUrl, [string]$RawBaseUrl, [string]$Archive,
[string]$TokenFile, [string]$SecretEnvFile,
[string]$InstallDir = "$env:ProgramFiles\TunnelMesh",
[string]$ConfigDir = "$env:ProgramData\TunnelMesh",
[string]$StateDir = "$env:ProgramData\TunnelMesh",
[string]$ServerUrl, [string]$AgentId, [string[]]$Tunnel, [string]$WinSW
)
. $PSScriptRoot\tunnelmesh-install-common.ps1
Invoke-TmMain -Role agent -Bound $PSBoundParameters
Invoke-TmMain 内固定顺序:管理员校验(非管理员 → 退出码 3)→ 版本解析 → 下载/校验/解压(Expand-Archive)→ 安装二进制与配置目录 → 渲染 YAML 与 <env> 块 → tunnelmesh-<role>.exe --config <path> check-config → Get-TmWinSW + 渲染 XML + install/start → 状态与摘要。升级与卸载语义与 .sh 侧一致(.bak-<时间戳> 备份、保留配置)。
- Step 4: 运行测试确认通过
Run: go test ./deploy/install/oneclick -count=1
Expected: PASS(含 TestPowerShellFilesExist、TestPowerShellContract、TestWinSWChecksumPolicy)。
Run(有 pwsh 时):for f in deploy/install/oneclick/*.ps1; do pwsh -NoProfile -Command "\$null = [System.Management.Automation.Language.Parser]::ParseFile('$PWD/$f', [ref]\$null, [ref]\$err); if (\$err) { exit 1 }"; done
Expected: 全部退出码 0;无 pwsh 时跳过并在 PR 描述里记录「PowerShell 语法未在本地校验」。
- Step 5: 更新
docs/deployment/windows-service.md
新增小节:
## 一键安装
以管理员身份打开 PowerShell:
```powershell
irm https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.ps1 -OutFile install-agent.ps1
.\install-agent.ps1
等价的一行式(参数直接跟在后面,不需要占位名):
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.ps1))) -Yes -ServerUrl wss://tunnel.example.com/ws/client
脚本会:校验并下载 Release 归档 → 安装 tunnelmesh-<role>.exe 到 C:\Program Files\TunnelMesh
→ 渲染 C:\ProgramData\TunnelMesh\<role>.yaml → 把 token 等敏感值渲染进 WinSW 服务 XML 的
<env> 块并收紧 ACL → check-config → 注册并启动服务。
WinSW 由脚本自动获取,但只下载 winsw-checksums.txt 中已登记校验和的版本;没有登记条目时脚本
中止并提示用 -WinSW <本地路径>。补录条目(需在可访问 github.com Release 的机器上执行):
Invoke-WebRequest -OutFile $env:TEMP\WinSW-x64.exe `
https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe
(Get-FileHash $env:TEMP\WinSW-x64.exe -Algorithm SHA256).Hash.ToLowerInvariant()
# 把结果按「<version> <arch> <sha256> <asset-name>」追加到
# deploy/install/oneclick/winsw-checksums.txt 并提交
卸载:.\install-agent.ps1 -Uninstall -Yes(保留 C:\ProgramData\TunnelMesh 下的配置与日志)。
- [x] **Step 6: Commit(需授权)** —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
```bash
git add deploy/install/oneclick docs/deployment/windows-service.md
git commit -m "feat(install): add Windows one-click installers with pinned WinSW"
Task 12: PR 记录、全量验证与收尾
Files:
- Create:
docs/pull-requests/2026-09-23-one-click-install-scripts.md -
Regenerate:
docs/pull-requests/README.md(gen_doc_index.py) - Step 1: 全量验证
go test ./... -count=1
go test -race ./...
go vet ./...
go test ./deploy/... -count=1
TM_ONECLICK_E2E=1 go test ./deploy/install/oneclick -count=1
bash -n deploy/install/oneclick/*.sh deploy/install/*.sh scripts/*.sh
# 模板含 __ENVIRONMENT__ 占位符,未渲染时不是合法 XML;lint 的是「渲染为空」的结果。
sed 's/^__ENVIRONMENT__$//' deploy/macos/tunnelmesh.plist | plutil -lint -
git diff --check
Expected: 全部退出码 0。go test ./... 需要 Task 0 已生成 internal/server/web_dist,否则 internal/server 会以 pattern all:web_dist: no matching files found 编译失败——那是环境问题,不是本次改动引入的失败,但必须在本任务解决后才能宣布全绿。
本次不涉及前端源码,npm test/npm run build 只在 Task 0 用于生成嵌入产物;若 web/dist 与 internal/server/web_dist 不一致,scripts/verify-web-embed.sh 会失败,需重跑 cd web && npm run build。
- Step 2: 文档与实现一致性核对
逐条核对 docs/deployment/oneclick-install.md 与实现:
# flag 面:文档里出现的每个 --flag 都必须被 tm_parse_common_args 或角色钩子解析
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
Expected: 无输出(文档里不存在脚本不认识的 flag)。反向再跑一次 comm -13,输出里的每一项都必须是刻意不文档化的内部 flag,否则补文档。
退出码表逐条与 tm_die "$TM_EXIT_*" 的实际使用位置对照;路径表与 tm_default_* 的返回值对照。
- Step 3: WinSW 校验和补录(条件执行)
若当前机器可访问 github.com Release 下载:
curl -fsSL -o /tmp/WinSW-x64.exe https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW-x64.exe
shasum -a 256 /tmp/WinSW-x64.exe
把结果按 v2.12.0 amd64 <sha256> WinSW-x64.exe 追加到 deploy/install/oneclick/winsw-checksums.txt(删掉「当前无已登记条目」那三行注释),并执行 go test ./deploy/install/oneclick -run TestWinSWChecksumPolicy -count=1 确认 4 列格式与 64 位十六进制校验通过。
若不可访问(本实现环境即如此:api.github.com 可达、github.com Release 超时):保持文件无数据行,并在 PR 记录的「测试证据」里写明「WinSW 自动下载路径未验证,Windows 安装需 -WinSW 手动指定;补录步骤已写入 winsw-checksums.txt 头部与 docs/deployment/windows-service.md」。
- Step 4: 真实下载路径手工验证(条件执行) —— 未执行:实现环境可访问 api.github.com 但无法访问 github.com Release 下载;已在 PR 记录「未验证项」写明补做命令
在可访问 github.com 的机器上执行一次,并把输出摘要记入 PR:
bash deploy/install/oneclick/install-agent.sh --version v1.1.1 --no-service --yes \
--server-url wss://tunnel.example.com/ws/agent --agent-id agent-smoke
Expected: 解析版本 → 下载 → SHA256SUMS 校验通过 → 二进制落到 ~/.local/bin → check-config 因 token 占位失败并给出明确原因(退出码 3 或 6)。这一步验证的是下载与校验链路,不是配置正确性。
- Step 5: 写 PR 记录
创建 docs/pull-requests/2026-09-23-one-click-install-scripts.md,按 AGENTS.md 要求包含全部小节:标题、目标分支(main)、摘要、用户影响、API/Schema/配置影响、安全与授权影响、测试证据、发布步骤、回滚步骤、Reviewer 关注点、集成状态。其中必须写明:
- API/Schema 影响:无。不改任何 HTTP API、不改数据库 Schema、不改协议 frame,因此不涉及
migrations/、SchemaVersion与 OpenAPI。 - 配置影响:新增
deploy/systemd-user/三份模板与两个占位符(__ENVIRONMENT__、__ENV_BLOCK__);deploy/systemd/tunnelmesh-agent.service新增可选EnvironmentFile=-/etc/tunnelmesh/agent.env(缺文件不阻塞启动,向后兼容)。 - 安全影响:新增的敏感值载体是
~/.config/tunnelmesh/<role>.env(0600)、渲染后的 LaunchAgent plist(0600)、WinSW XML 的<env>块(ACL 收紧);不提供明文--token;curl | bash信任模型与缓解措施写入文档。 - 测试证据:贴上 Task 12 Step 1 的命令与结论,以及 Step 3/4 的执行情况(含「未验证」项与原因)。
- 回滚步骤:见下。
Run: python3 scripts/gen_doc_index.py && git status --short docs
-
Step 6: 回滚注意事项(写入 PR 记录)
- 本次改动不含数据库迁移,回滚只需
git revert对应提交并重新发布,无需数据处理。 - 模板占位符是向后兼容的:旧版
macos-install.sh/windows-install.ps1若在新模板上运行,会把__ENVIRONMENT__/__ENV_BLOCK__原样留在渲染结果里——因此回滚代码时必须同时回滚deploy/macos/tunnelmesh.plist与deploy/windows/tunnelmesh-service.xml,两者是一个原子变更。 deploy/systemd/tunnelmesh-agent.service的EnvironmentFile=-前缀保证缺文件不阻塞,可独立回滚。- 已用一键脚本装好的机器不受仓库回滚影响;如需下线,用户执行
install-<role>.sh --uninstall(保留配置与数据)。 -
发布归档布局变化(新增
deploy/install/oneclick/、deploy/systemd-user/)只影响下一个 tag,已发布的 Release 不可变,无需处理。 - Step 7: Commit(需授权) —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
git add docs/pull-requests
git commit -m "docs(install): record one-click installer pull request"
自查清单(执行者收尾前逐条勾选)
- 规格 §5.1 的五种调用形态都能跑通,其中
bash -c "$(curl ...)"与curl | bash至少各手工验证一次(--help与--archive --yes两条路径即可)。 bash --version为 3.2 时脚本可用:grep -n 'declare -A\|mapfile\|readarray\||&\|&>>' deploy/install/oneclick/*.sh无输出。grep -rn 'token\|password\|secret' deploy/install/oneclick/*.sh结果里没有真实凭据,只有键名、提示语与掩码逻辑。- 交互提示在 stdin 是管道时不会静默取默认值(无 tty 且未
--yes→ 退出码 3)。 - 升级不重新生成配置;
--reconfigure会先备份再覆盖;备份最多保留 3 份(配置)与 1 份(二进制)。 - 卸载保留配置、env 与数据目录,并在输出里逐条打印路径。
docs/README.md与deploy/README.md都能点到新文档,python3 scripts/gen_doc_index.py无 diff 残留。go test ./... -count=1、go test -race ./...、go vet ./...、git diff --check全绿。
Task 8: 端到端冒烟测试(env 门控)
Files:
- Create:
deploy/install/oneclick/oneclick_e2e_test.go - Modify:
deploy/install/oneclick/tunnelmesh-install-common.sh(tm_detect_service_manager增加可注入接缝) - Modify:
deploy/install/oneclick/testdata/run_tests.sh
Interfaces:
- Consumes:
tm_main全流程(Task 7)、testdata/bin/*stub(Task 6)、真实go build产物。 -
Produces:
TM_ONECLICK_SERVICE_MANAGER(覆盖服务管理器探测,供容器/CI 与测试使用)。 - Step 1: 写失败测试 —— 服务管理器注入接缝
追加到 testdata/run_tests.sh:
# 服务管理器可注入:容器与 CI 里 /run/systemd/system 常常不存在,
# 端到端测试需要显式指定管理器,否则只能覆盖 none 分支。
assert_eq "svc/inject-user" "systemd-user" "$(TM_ONECLICK_SERVICE_MANAGER=systemd-user tm_detect_service_manager_stdout)"
assert_eq "svc/inject-none" "none" "$(TM_ONECLICK_SERVICE_MANAGER=none tm_detect_service_manager_stdout)"
Run: go test ./deploy/install/oneclick -run TestShellFunctionSuite -count=1
Expected: FAIL —— tm_detect_service_manager_stdout: command not found。
- Step 2: 写最小实现
# tm_detect_service_manager_stdout:纯解析,便于测试;tm_detect_service_manager 在它之上打印警告。
tm_detect_service_manager_stdout() {
if [[ -n "${TM_ONECLICK_SERVICE_MANAGER:-}" ]]; then printf '%s\n' "$TM_ONECLICK_SERVICE_MANAGER"; return 0; fi
if [[ "$TM_NO_SERVICE" == "1" ]]; then printf 'none\n'; return 0; fi
case "$TM_OS_FAMILY" in
darwin) tm_have launchctl && printf 'launchd\n' || printf 'none\n' ;;
linux)
if [[ "$TM_MODE" == "system" ]] && tm_has_systemd; then printf 'systemd-system\n'
elif tm_has_systemd_user; then printf 'systemd-user\n'
else printf 'none\n'; fi ;;
*) printf 'none\n' ;;
esac
}
并把 Task 6 里的 tm_detect_service_manager 改成调用它(删除重复的 case 分支,只保留「none 时打印警告 + 赋值 TM_SERVICE_MANAGER」):
tm_detect_service_manager() {
TM_SERVICE_MANAGER="$(tm_detect_service_manager_stdout)"
if [[ "$TM_SERVICE_MANAGER" == "none" && "$TM_NO_SERVICE" != "1" ]]; then
tm_warn "未检测到可用的服务管理器,将只安装二进制与配置;前台启动命令见安装结束后的摘要"
fi
}
Run: go test ./deploy/install/oneclick -count=1
Expected: PASS。
- Step 3: 写失败测试 —— E2E
创建 deploy/install/oneclick/oneclick_e2e_test.go:
package oneclick
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// 端到端冒烟:用 go build 出的真实二进制打包成发布归档,再以 --archive 走完整流程。
// 服务管理命令由 testdata/bin 下的 stub 顶替,因此不会真的注册系统服务。
// 与 test/e2e/proxy-entry 一样用环境变量门控:TM_ONECLICK_E2E=1 才执行。
func TestOneClickInstallUpgradeUninstall(t *testing.T) {
if os.Getenv("TM_ONECLICK_E2E") != "1" {
t.Skip("set TM_ONECLICK_E2E=1 to run the one-click installer end-to-end smoke test")
}
bash, err := exec.LookPath("bash")
if err != nil {
t.Skipf("bash not available: %v", err)
}
root := t.TempDir() // 假的 $HOME
work := t.TempDir() // 构建与归档
stubs := filepath.Join("testdata", "bin")
absStubs, err := filepath.Abs(stubs)
if err != nil {
t.Fatalf("abs stubs: %v", err)
}
// 1) 真实二进制
binary := filepath.Join(work, "tunnelmesh-agent")
build := exec.Command("go", "build", "-o", binary, "../../cmd/tunnelmesh-agent")
build.Env = append(os.Environ(), "CGO_ENABLED=0")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("go build agent: %v\n%s", err, out)
}
// 2) 打包成与发布归档同构的 tar.gz(含服务模板)
archive := filepath.Join(work, "tunnelmesh-v9.9.9-e2e.tar.gz")
packArchive(t, archive, map[string]string{
"tunnelmesh-agent": binary,
"deploy/systemd-user/tunnelmesh-agent.service": filepath.Join("..", "..", "systemd-user", "tunnelmesh-agent.service"),
"deploy/systemd/tunnelmesh-agent.service": filepath.Join("..", "..", "systemd", "tunnelmesh-agent.service"),
"deploy/macos/tunnelmesh.plist": filepath.Join("..", "..", "macos", "tunnelmesh.plist"),
})
// 3) 安装
svcLog := filepath.Join(work, "svc.log")
env := []string{
"HOME=" + root,
"PATH=" + absStubs + string(os.PathListSeparator) + os.Getenv("PATH"),
"SVC_STUB_LOG=" + svcLog,
"TM_ONECLICK_ALLOW_STDIN=1",
"TM_ONECLICK_SERVICE_MANAGER=" + expectedManager(),
"TM_CACHE_DIR=" + filepath.Join(work, "cache"),
"TUNNELMESH_AGENT_TOKEN=e2e-secret-token-value",
"TM_ONECLICK_LIB=" + absLib(t),
}
install := runInstaller(t, bash, env, "--archive", archive, "--version", "v9.9.9", "--yes",
"--server-url", "wss://tunnel.example.com/ws/agent", "--agent-id", "agent-e2e", "--no-linger")
// 4) 断言落位与内容
assertFileMode(t, filepath.Join(root, ".local", "bin", "tunnelmesh-agent"), 0o755)
yamlBody := assertFileMode(t, filepath.Join(root, ".config", "tunnelmesh", "agent.yaml"), 0o600)
for _, want := range []string{"wss://tunnel.example.com/ws/agent", "agent-e2e", "server_url", "connections"} {
if !strings.Contains(yamlBody, want) {
t.Errorf("agent.yaml missing %q:\n%s", want, yamlBody)
}
}
if strings.Contains(yamlBody, "e2e-secret-token-value") {
t.Errorf("token leaked into YAML:\n%s", yamlBody)
}
envBody := assertFileMode(t, filepath.Join(root, ".config", "tunnelmesh", "agent.env"), 0o600)
if !strings.Contains(envBody, "TUNNELMESH_AGENT_TOKEN='e2e-secret-token-value'") {
t.Errorf("agent.env missing token entry:\n%s", envBody)
}
if strings.Contains(install.stdout, "e2e-secret-token-value") {
t.Errorf("token leaked into installer output:\n%s", install.stdout)
}
if !strings.Contains(install.stdout, "e2e-****") {
t.Errorf("summary did not mask the token:\n%s", install.stdout)
}
unitPath := unitPathFor(root)
assertFileExists(t, unitPath)
calls := readFile(t, svcLog)
for _, want := range expectedServiceCalls() {
if !strings.Contains(calls, want) {
t.Errorf("service call log missing %q:\n%s", want, calls)
}
}
// 5) 再跑一次 = 升级:保留配置、产生二进制备份
os.Truncate(svcLog, 0)
runInstaller(t, bash, env, "--archive", archive, "--version", "v9.9.9", "--yes",
"--server-url", "wss://tunnel.example.com/ws/agent", "--agent-id", "agent-e2e", "--no-linger")
backups, _ := filepath.Glob(filepath.Join(root, ".local", "bin", "tunnelmesh-agent.bak-*"))
if len(backups) != 1 {
t.Errorf("upgrade backups = %d, want 1: %v", len(backups), backups)
}
if body := readFile(t, filepath.Join(root, ".config", "tunnelmesh", "agent.yaml")); !strings.Contains(body, "agent-e2e") {
t.Errorf("upgrade rewrote configuration:\n%s", body)
}
// 6) 卸载:删二进制与服务定义,保留配置与数据
os.Truncate(svcLog, 0)
uninstall := runInstaller(t, bash, env, "--uninstall", "--yes")
if _, err := os.Stat(filepath.Join(root, ".local", "bin", "tunnelmesh-agent")); !os.IsNotExist(err) {
t.Errorf("binary still present after uninstall: %v", err)
}
if _, err := os.Stat(unitPath); !os.IsNotExist(err) {
t.Errorf("unit still present after uninstall: %v", err)
}
assertFileExists(t, filepath.Join(root, ".config", "tunnelmesh", "agent.yaml"))
assertFileExists(t, filepath.Join(root, ".config", "tunnelmesh", "agent.env"))
if !strings.Contains(uninstall.stdout, "配置与数据保留") {
t.Errorf("uninstall summary should state that config is retained:\n%s", uninstall.stdout)
}
}
func expectedManager() string {
if runtime.GOOS == "darwin" {
return "launchd"
}
return "systemd-user"
}
func unitPathFor(home string) string {
if runtime.GOOS == "darwin" {
return filepath.Join(home, "Library", "LaunchAgents", "com.tunnelmesh.agent.plist")
}
return filepath.Join(home, ".config", "systemd", "user", "tunnelmesh-agent.service")
}
func expectedServiceCalls() []string {
if runtime.GOOS == "darwin" {
return []string{"plutil -lint", "launchctl bootstrap", "launchctl kickstart -k"}
}
return []string{"systemctl --user daemon-reload", "systemctl --user enable tunnelmesh-agent.service", "systemctl --user restart tunnelmesh-agent.service"}
}
type runResult struct {
stdout string
stderr string
}
func runInstaller(t *testing.T, bash string, env []string, args ...string) runResult {
t.Helper()
cmd := exec.Command(bash, append([]string{filepath.Join("install-agent.sh")}, args...)...)
cmd.Env = env
var out, errBuf bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errBuf
if err := cmd.Run(); err != nil {
t.Fatalf("installer failed: %v\nstdout:\n%s\nstderr:\n%s", err, out.String(), errBuf.String())
}
return runResult{stdout: out.String(), stderr: errBuf.String()}
}
func absLib(t *testing.T) string {
t.Helper()
p, err := filepath.Abs(commonLib)
if err != nil {
t.Fatalf("abs lib: %v", err)
}
return p
}
func assertFileMode(t *testing.T, path string, want os.FileMode) string {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
if got := info.Mode().Perm(); got != want {
t.Errorf("%s mode = %o, want %o", path, got, want)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(body)
}
func assertFileExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err != nil {
t.Errorf("expected file %s: %v", path, err)
}
}
// packArchive 按发布归档布局打 tar.gz:成员名用正斜杠,二进制置 0755。
func packArchive(t *testing.T, dest string, members map[string]string) {
t.Helper()
out, err := os.Create(dest)
if err != nil {
t.Fatalf("create archive: %v", err)
}
defer out.Close()
gz := gzip.NewWriter(out)
defer gz.Close()
tw := tar.NewWriter(gz)
defer tw.Close()
for name, src := range members {
body, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read %s: %v", src, err)
}
mode := int64(0o644)
if strings.HasSuffix(name, "tunnelmesh-agent") && !strings.Contains(name, "/") {
mode = 0o755
}
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: mode, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil {
t.Fatalf("write header %s: %v", name, err)
}
if _, err := tw.Write(body); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
}
var _ = sha256.Sum256
var _ = hex.EncodeToString
末尾两行
var _ = ...只为占位,实现时删除并同步去掉crypto/sha256、encoding/hex两个 import——E2E 走--archive,不做校验和断言(校验路径由 Task 2 的函数级测试覆盖)。
- Step 4: 运行 E2E 确认通过
Run: TM_ONECLICK_E2E=1 go test ./deploy/install/oneclick -run TestOneClickInstallUpgradeUninstall -count=1 -v
Expected: PASS。首次运行会 go build agent 二进制(约 10–30s,有构建缓存)。
Run: go test ./deploy/install/oneclick -count=1
Expected: PASS 且 E2E 被 skip(TM_ONECLICK_E2E 未设置)。
- Step 5: Commit(需授权)
git add deploy/install/oneclick
git commit -m "test(install): cover one-click install, upgrade and uninstall end to end"
Task 9: 发布归档与产物一致性
Files:
- Modify:
scripts/build-release.sh - Modify:
scripts/install_script_test.go(新增归档布局断言)
Interfaces:
-
Produces: 归档内包含
deploy/install/oneclick/(不含testdata/、不含*_test.go),Linux 归档额外包含deploy/systemd-user/。 -
Step 1: 写失败测试
在 scripts/install_script_test.go 追加:
func TestBuildReleaseShipsOneClickInstallers(t *testing.T) {
data, err := os.ReadFile("build-release.sh")
if err != nil {
t.Fatalf("read build-release.sh: %v", err)
}
script := string(data)
for _, want := range []string{
"deploy/install", // oneclick/ 随 deploy/install 一起进归档
"deploy/systemd-user", // user 模式单元模板必须进 Linux 归档
`-name '*_test.go'`, // 既有:测试文件不发布
"-name testdata", // 新增:测试 fixture 不发布
} {
if !strings.Contains(script, want) {
t.Errorf("build-release.sh is missing %q", want)
}
}
}
Run: go test ./scripts -run TestBuildReleaseShipsOneClickInstallers -count=1
Expected: FAIL —— build-release.sh is missing "deploy/systemd-user" 与 "-name testdata"。
- Step 2: 写最小实现
scripts/build-release.sh 中 Linux 分支的 cp -R "$ROOT_DIR/deploy/systemd" "$stage/deploy/systemd" 之后追加:
cp -R "$ROOT_DIR/deploy/systemd-user" "$stage/deploy/systemd-user"
在既有 find "$stage/deploy" -name '*_test.go' -type f -delete 之后追加:
# testdata/ 是测试 fixture(含 curl/systemctl stub 与现场生成的归档),不是部署产物。
find "$stage/deploy" -type d -name testdata -prune -exec rm -rf {} +
- Step 3: 验证
Run: go test ./scripts -count=1 && bash -n scripts/build-release.sh
Expected: PASS,语法检查退出码 0。
Run(仅当 internal/server/web_dist 已由 Task 0 生成时可行):VERSION=v0.0.0-e2e ./scripts/build-release.sh && tar -tzf dist/v0.0.0-e2e/tunnelmesh-v0.0.0-e2e-darwin-arm64.tar.gz | grep -E 'oneclick|systemd-user|testdata'
Expected: 输出包含 deploy/install/oneclick/install-agent.sh、deploy/install/oneclick/tunnelmesh-install-common.sh、deploy/systemd-user/tunnelmesh-agent.service,且不含 testdata。
- Step 4: Commit(需授权)
git add scripts/build-release.sh scripts/install_script_test.go
git commit -m "build(release): ship one-click installers and user systemd templates"
Task 10: 文档与索引
Files:
- Create:
docs/deployment/oneclick-install.md - Modify:
README.md、docs/README.md、deploy/README.md、docs/deployment/binary-release.md、docs/deployment/linux-systemd.md、docs/deployment/macos-launchd.md - Regenerate:
docs/superpowers/specs/README.md、docs/superpowers/plans/README.md
Interfaces: 无代码接口;文档必须与脚本实际 flag、退出码、路径完全一致(Task 12 的一致性检查会逐条核对)。
- Step 1: 写
docs/deployment/oneclick-install.md
必须包含以下小节,且「安装命令」小节要把两种一行式调用形态都写出来:
# 一键安装脚本
三个角色各一条命令,交互式完成下载、校验、配置生成、服务注册与启动。
## 安装命令
推荐先下载再审阅(失败可见,且能先看脚本内容):
```sh
curl -fsSL https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh \
-o /tmp/tunnelmesh-install-agent.sh
less /tmp/tunnelmesh-install-agent.sh
/bin/bash /tmp/tunnelmesh-install-agent.sh
一行式(Homebrew 风格,命令替换):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh)"
一行式(管道):
curl -fsSL https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh | bash -s -- --version v1.1.1
bash -c "$(curl ...)"有一个失效模式:curl 失败时命令替换为空串,bash 什么都不执行却返回 0。 在脚本化场景请用curl ... -o file && /bin/bash file,或先set -o pipefail。 传参时注意bash -c '<script>' <name> [args...]的第一个参数会被当作$0吃掉, 因此要么写一个占位名,要么改用环境变量:TM_ONECLICK_YES=1 /bin/bash -c "$(curl -fsSL <url>)"。 ```
其余小节(标题固定,便于交叉引用):
## 平台与托管矩阵:复制规格 §6 的表格,补上「Windows 见 install-.ps1」。 ## 交互项:三角色各一张表,列「提示 / 默认值 / 对应 flag」,与tm_role_prompts逐项对齐。## 非交互安装(CI):给三条完整命令——--yes+--server-url+TUNNELMESH_AGENT_TOKEN环境变量;并解释为什么没有--token(进程列表与 shell 历史泄露),给出--token-file、--secret-env-file两种替代与其权限要求(0600/0400/0640)。## 安装到其它用户:user 模式--user <name>(需 root,脚本自动 re-exec)、system 模式--run-user <name>(drop-in 覆盖User=/Group=),各给一条完整命令。## 升级、回滚与卸载:重复运行即升级、保留配置、--reconfigure重新生成、.bak-<ts>自动回滚、--uninstall保留配置与数据。## 镜像源与离线安装:--base-url、--raw-base-url、TM_ONECLICK_REF、--archive、下载缓存位置与--no-cache。## 退出码与排障:0/2/3/4/5/6/7/8 逐条含义 + 每条的最可能原因与处置。## 信任模型:curl | bash的风险、SHA256SUMS 校验、raw与 Release 同源、如何离线审阅。
- Step 2: 更新
README.md的 Quick start → Install
把现有 scripts/install.sh 代码块替换为(保留「先下载再审阅」措辞,并列出两种一行式):
### Install
One command per role. Download the installer, review it, then run it:
```sh
curl --fail --silent --show-error --location \
https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh \
--output /tmp/tunnelmesh-install-agent.sh
less /tmp/tunnelmesh-install-agent.sh
/bin/bash /tmp/tunnelmesh-install-agent.sh
Prefer a one-liner? Both forms work:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh)"
curl -fsSL https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh | bash -s -- --version v1.1.1
The installer asks for the server URL and token, writes the configuration, registers a
systemd user unit (Linux), a LaunchAgent (macOS), or a WinSW service (Windows, use
install-agent.ps1), then starts and verifies it. Add --yes for unattended installs.
To install only the three binaries without service registration, use
scripts/install.sh (default ~/.local/bin). Full guide:
one-click install.
- [x] **Step 3: 更新 `docs/README.md` 与 `deploy/README.md`**
`docs/README.md` 部署清单里,在 `[Linux systemd 安装](deployment/linux-systemd.md)` 那一行**之前**插入:
```markdown
- [一键安装脚本](deployment/oneclick-install.md):三角色一条命令,交互/非交互、升级与卸载、镜像源与离线安装
deploy/README.md:目录表新增 install/oneclick/、systemd-user/ 两行;「模板约定」补 __ENVIRONMENT__、__ENV_BLOCK__、user 单元四占位符与 drop-in 规则;「发布包内容」表把 Linux 一列改成 deploy/install、deploy/systemd、deploy/systemd-user;「校验」代码块追加:
go test ./deploy/install/oneclick -count=1 # 一键安装脚本契约与函数级测试
TM_ONECLICK_E2E=1 go test ./deploy/install/oneclick -count=1 # 端到端:安装 → 升级 → 卸载
bash -n deploy/install/oneclick/*.sh
-
Step 4: 更新三篇部署文档
docs/deployment/binary-release.md:归档内容表补deploy/install/oneclick/(三角色入口 + 共享库 +winsw-checksums.txt)与deploy/systemd-user/;说明testdata/、*_test.go不进归档。docs/deployment/linux-systemd.md:开头加「推荐先用一键安装脚本」;补agent.env(EnvironmentFile=-/etc/tunnelmesh/agent.env);新增「用户级安装」小节(~/.config/systemd/user/、loginctl enable-linger、日志用journalctl --user);新增「drop-in 覆盖」小节(oneclick.conf何时生成、如何手工改)。-
docs/deployment/macos-launchd.md:补EnvironmentVariables渲染说明、plist 权限 0600、token 不写进模板仓库副本、卸载命令。 - Step 5: 重建索引并验证
Run: python3 scripts/gen_doc_index.py && git status --short docs
Expected: 只有四份 README.md 索引与新增文档发生变化,无其它 diff。
Run: grep -rn "oneclick-install.md" README.md docs/README.md deploy/README.md
Expected: 三处都有入口链接(避免孤儿文档)。
- Step 6: Commit(需授权) —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
git add README.md docs deploy/README.md
git commit -m "docs(install): document one-click installers and invocation forms"
Task 6: 安装模式判定与服务生命周期
Files:
- Modify:
deploy/install/oneclick/tunnelmesh-install-common.sh、testdata/run_tests.sh - Create:
deploy/install/oneclick/testdata/bin/systemctl、launchctl、loginctl、plutil(记录调用序列的 stub)
Interfaces:
- Consumes:
tm_ans_get/set、tm_die、tm_info、tm_warn、tm_have、TM_DEFAULT_INSTALL_MODE、渲染函数(Task 4)、TM_MODE/TM_TARGET_USER/TM_RUN_USER(Task 3)。 - Produces:
tm_euid(id -u,测试可重定义该函数注入身份)、tm_is_roottm_resolve_install_mode:按规格 §10 的四条规则设置TM_MODE、TM_TARGET_USER、TM_RUN_USER;规则 2 命中时打印一行说明;--mode system且非 root →tm_die 3tm_reexec_as_user <user> <args...>:runuser -u <user> --优先,回退sudo -u <user> -H;用TM_ONECLICK_REEXEC=1防环,已置位时直接tm_die 3tm_has_systemd、tm_has_systemd_user(后者检查systemctl --user是否可用,容器/WSL 里常缺失)tm_default_bin_dir、tm_default_config_dir、tm_default_state_dir、tm_binary_path <role>、tm_config_path <role>、tm_env_path <role>、tm_unit_path <role>(按TM_MODE与TM_OS_FAMILY返回路径)tm_service_install <role>、tm_service_enable <role>、tm_service_start <role>、tm_service_stop <role>、tm_service_remove <role>、tm_service_status <role>、tm_service_logs_hint <role>-
全局 TM_SERVICE_REGISTERED(0/1)、TM_SERVICE_MANAGER(systemd-systemsystemd-user launchd winsw none)
- Step 1: 写失败测试 —— 服务命令 stub
创建 deploy/install/oneclick/testdata/bin/systemctl(可执行;launchctl、loginctl、plutil 同构,只改程序名):
#!/usr/bin/env bash
# 服务管理 stub:把「程序名 + 全部参数」追加写入 $SVC_STUB_LOG,永远成功。
# plutil 需要 -lint 时同样只记录不校验(渲染结果由 run_tests.sh 单独断言)。
set -uo pipefail
printf '%s %s\n' "$(basename "$0")" "$*" >>"${SVC_STUB_LOG:-/dev/null}"
exit 0
- Step 2: 写失败测试 —— 追加到
testdata/run_tests.sh
# --- Task 6: 模式判定与服务生命周期 ---
SVCLOG="$TM_TMP6/svc.log"; TM_TMP6="$(mktemp -d)"; mkdir -p "$TM_TMP6"
export SVC_STUB_LOG="$SVCLOG"
export PATH="$HERE/bin:$PATH"
# 规则 1:非 root → user
tm_euid() { printf '1000\n'; }
TM_MODE="" TM_TARGET_USER="$(id -un)" SUDO_USER=""
out="$(tm_resolve_install_mode_stdout)"
assert_eq "mode/non-root" "user" "$out"
# 规则 2:root + SUDO_USER → user,目标用户取 SUDO_USER
tm_euid() { printf '0\n'; }
out="$(SUDO_USER=alice tm_resolve_install_mode_stdout)"
assert_eq "mode/sudo-user" "user" "$out"
assert_eq "mode/sudo-target" "alice" "$(SUDO_USER=alice tm_resolve_target_user)"
# 规则 3:root 无 SUDO_USER → system
out="$(SUDO_USER= tm_resolve_install_mode_stdout)"
assert_eq "mode/real-root" "system" "$out"
# 规则 4:显式 --mode system 但非 root → 退出码 3
tm_euid() { printf '1000\n'; }
assert_exit_sh 3 "mode/system-needs-root" "tm_euid() { printf '1000\n'; }; TM_MODE=system tm_resolve_install_mode"
# 显式 --mode user 时即使 root 也保持 user
tm_euid() { printf '0\n'; }
assert_eq "mode/explicit-user" "user" "$(SUDO_USER= TM_MODE=user tm_resolve_install_mode_stdout)"
# 路径推导
TM_MODE="user" TM_OS_FAMILY="linux" TM_TARGET_USER="$(id -un)" TM_BIN_DIR="" TM_CONFIG_DIR="" TM_STATE_DIR=""
assert_eq "path/user-binary" "$HOME/.local/bin/tunnelmesh-agent" "$(tm_binary_path agent)"
assert_eq "path/user-config" "$HOME/.config/tunnelmesh/agent.yaml" "$(tm_config_path agent)"
assert_eq "path/user-env" "$HOME/.config/tunnelmesh/agent.env" "$(tm_env_path agent)"
assert_eq "path/user-unit" "$HOME/.config/systemd/user/tunnelmesh-agent.service" "$(tm_unit_path agent)"
assert_eq "path/user-state" "$HOME/.local/share/tunnelmesh" "$(tm_default_state_dir)"
TM_MODE="system"
assert_eq "path/system-binary" "/usr/local/bin/tunnelmesh-agent" "$(TM_BIN_DIR= tm_binary_path agent)"
assert_eq "path/system-config" "/etc/tunnelmesh/agent.yaml" "$(TM_CONFIG_DIR= tm_config_path agent)"
assert_eq "path/system-state" "/var/lib/tunnelmesh-agent" "$(tm_default_state_dir)"
TM_MODE="user" TM_OS_FAMILY="darwin"
assert_eq "path/macos-unit" "$HOME/Library/LaunchAgents/com.tunnelmesh.agent.plist" "$(tm_unit_path agent)"
TM_MODE="user" TM_OS_FAMILY="linux"
# 服务生命周期:systemd user 调用序列
: >"$SVCLOG"
TM_SERVICE_MANAGER="systemd-user" tm_service_install agent
TM_SERVICE_MANAGER="systemd-user" tm_service_enable agent
TM_SERVICE_MANAGER="systemd-user" tm_service_start agent
assert_contains "svc/user-daemon-reload" "$(cat "$SVCLOG")" "systemctl --user daemon-reload"
assert_contains "svc/user-enable" "$(cat "$SVCLOG")" "systemctl --user enable tunnelmesh-agent.service"
assert_contains "svc/user-start" "$(cat "$SVCLOG")" "systemctl --user restart tunnelmesh-agent.service"
# 服务生命周期:systemd system 调用序列
: >"$SVCLOG"
TM_SERVICE_MANAGER="systemd-system" tm_service_install server
TM_SERVICE_MANAGER="systemd-system" tm_service_enable server
assert_contains "svc/system-daemon-reload" "$(cat "$SVCLOG")" "systemctl daemon-reload"
assert_contains "svc/system-enable" "$(cat "$SVCLOG")" "systemctl enable tunnelmesh-server.service"
# 服务生命周期:launchd 调用序列
: >"$SVCLOG"
TM_SERVICE_MANAGER="launchd" tm_service_install agent
TM_SERVICE_MANAGER="launchd" tm_service_start agent
assert_contains "svc/launchd-bootout" "$(cat "$SVCLOG")" "launchctl bootout gui/$(id -u)"
assert_contains "svc/launchd-bootstrap" "$(cat "$SVCLOG")" "launchctl bootstrap gui/$(id -u)"
assert_contains "svc/launchd-kickstart" "$(cat "$SVCLOG")" "launchctl kickstart -k gui/$(id -u)/com.tunnelmesh.agent"
# 无服务管理器:不失败,只标记未注册
: >"$SVCLOG"
TM_SERVICE_MANAGER="none" tm_service_install agent
assert_eq "svc/none-noop" "0" "$TM_SERVICE_REGISTERED"
assert_eq "svc/none-no-calls" "0" "$(wc -l <"$SVCLOG" | tr -d ' ')"
rm -rf "$TM_TMP6"
tm_resolve_install_mode_stdout与tm_resolve_target_user是为测试拆出的纯函数:前者解析后只打印TM_MODE,后者只打印TM_TARGET_USER。tm_resolve_install_mode= 两者 + 说明性输出。测试用「重定义tm_euid」注入身份,这是刻意的可测性接缝,生产代码里tm_euid只有一行id -u。
- Step 3: 运行测试确认失败
Run: go test ./deploy/install/oneclick -run TestShellFunctionSuite -count=1
Expected: FAIL —— tm_resolve_install_mode_stdout: command not found。
- Step 4: 写最小实现
# --- 身份与模式 ---
tm_euid() { id -u; }
tm_is_root() { [[ "$(tm_euid)" == "0" ]]; }
tm_resolve_target_user() {
if [[ -n "$TM_TARGET_USER" ]]; then printf '%s\n' "$TM_TARGET_USER"; return 0; fi
if tm_is_root && [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then
printf '%s\n' "$SUDO_USER"; return 0
fi
id -un
}
tm_resolve_install_mode_stdout() {
local mode="$TM_MODE"
if [[ -z "$mode" ]]; then
if [[ "$TM_OS_FAMILY" != "linux" ]]; then mode="user"
elif ! tm_is_root; then mode="$TM_DEFAULT_INSTALL_MODE"
elif [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" ]]; then mode="user"
else mode="system"; fi
fi
printf '%s\n' "$mode"
}
tm_resolve_install_mode() {
TM_MODE="$(tm_resolve_install_mode_stdout)"
if [[ "$TM_OS_FAMILY" != "linux" && "$TM_MODE" == "system" ]]; then
tm_warn "system 模式只在 Linux 生效;$(uname -s) 上使用用户级服务托管"
TM_MODE="user"
fi
if [[ "$TM_MODE" == "system" ]] && ! tm_is_root; then
tm_die "$TM_EXIT_PREFLIGHT" "--mode system requires root; re-run with sudo, or use the default user mode"
fi
TM_TARGET_USER="$(tm_resolve_target_user)"
if [[ -z "$TM_RUN_USER" ]]; then TM_RUN_USER="tunnelmesh"; fi
if tm_is_root && [[ -n "${SUDO_USER:-}" && "${SUDO_USER}" != "root" && "$TM_MODE" == "user" ]]; then
tm_info "检测到 sudo:将以 ${TM_TARGET_USER} 身份做用户级安装;需要系统级请加 --mode system"
fi
}
tm_reexec_as_user() { # <user> <args...>
local user="$1"; shift
[[ "${TM_ONECLICK_REEXEC:-0}" == "1" ]] && tm_die "$TM_EXIT_PREFLIGHT" "refusing to re-exec twice (TM_ONECLICK_REEXEC already set)"
[[ -n "$TM_ONECLICK_ENTRY" ]] || tm_die "$TM_EXIT_PREFLIGHT" "cannot re-exec: entry script path unknown (bash -c/管道调用请改用目标用户直接执行)"
if tm_have runuser; then
TM_ONECLICK_REEXEC=1 runuser -u "$user" -- "$TM_ONECLICK_ENTRY" "$@"
elif tm_have sudo; then
TM_ONECLICK_REEXEC=1 sudo -u "$user" -H "$TM_ONECLICK_ENTRY" "$@"
else
tm_die "$TM_EXIT_PREFLIGHT" "need runuser or sudo to install for another user"
fi
}
# --- 路径推导 ---
tm_default_bin_dir() {
if [[ -n "$TM_BIN_DIR" ]]; then printf '%s\n' "$TM_BIN_DIR"
elif [[ "$TM_OS_FAMILY" == "linux" && "$TM_MODE" == "system" ]]; then printf '/usr/local/bin\n'
else printf '%s/.local/bin\n' "$HOME"; fi
}
tm_default_config_dir() {
if [[ -n "$TM_CONFIG_DIR" ]]; then printf '%s\n' "$TM_CONFIG_DIR"
elif [[ "$TM_OS_FAMILY" == "linux" && "$TM_MODE" == "system" ]]; then printf '/etc/tunnelmesh\n'
else printf '%s/.config/tunnelmesh\n' "$HOME"; fi
}
tm_default_state_dir() {
if [[ -n "$TM_STATE_DIR" ]]; then printf '%s\n' "$TM_STATE_DIR"
elif [[ "$TM_OS_FAMILY" == "linux" && "$TM_MODE" == "system" ]]; then
case "$TM_ROLE" in server) printf '/var/lib/tunnelmesh\n' ;; *) printf '/var/lib/tunnelmesh-%s\n' "$TM_ROLE" ;; esac
else printf '%s/.local/share/tunnelmesh\n' "$HOME"; fi
}
tm_binary_path() { printf '%s/tunnelmesh-%s\n' "$(tm_default_bin_dir)" "$1"; }
tm_config_path() { printf '%s/%s.yaml\n' "$(tm_default_config_dir)" "$1"; }
tm_env_path() { printf '%s/%s.env\n' "$(tm_default_config_dir)" "$1"; }
tm_unit_path() {
case "$TM_SERVICE_MANAGER" in
systemd-user) printf '%s/.config/systemd/user/tunnelmesh-%s.service\n' "$HOME" "$1" ;;
systemd-system) printf '/etc/systemd/system/tunnelmesh-%s.service\n' "$1" ;;
launchd) printf '%s/Library/LaunchAgents/com.tunnelmesh.%s.plist\n' "$HOME" "$1" ;;
*) printf '%s\n' "" ;;
esac
}
# --- 服务管理器探测 ---
tm_has_systemd() { tm_have systemctl && [[ -d /run/systemd/system ]]; }
tm_has_systemd_user() { tm_has_systemd && systemctl --user show-environment >/dev/null 2>&1; }
tm_detect_service_manager() {
if [[ "$TM_NO_SERVICE" == "1" ]]; then TM_SERVICE_MANAGER="none"; return 0; fi
case "$TM_OS_FAMILY" in
darwin) tm_have launchctl && TM_SERVICE_MANAGER="launchd" || TM_SERVICE_MANAGER="none" ;;
linux)
if [[ "$TM_MODE" == "system" ]] && tm_has_systemd; then TM_SERVICE_MANAGER="systemd-system"
elif tm_has_systemd_user; then TM_SERVICE_MANAGER="systemd-user"
else TM_SERVICE_MANAGER="none"; fi ;;
*) TM_SERVICE_MANAGER="none" ;;
esac
if [[ "$TM_SERVICE_MANAGER" == "none" && "$TM_NO_SERVICE" != "1" ]]; then
tm_warn "未检测到可用的服务管理器,将只安装二进制与配置;前台启动命令见安装结束后的摘要"
fi
}
# --- 服务生命周期:所有调用都经过 tm_svc,测试用 PATH 前置的 stub 覆盖 ---
tm_svc() { "$@"; }
tm_service_install() { # <role>
local role="$1" unit
TM_SERVICE_REGISTERED=1
case "$TM_SERVICE_MANAGER" in
systemd-user) tm_svc systemctl --user daemon-reload ;;
systemd-system) tm_svc systemctl daemon-reload ;;
launchd)
unit="$(tm_unit_path "$role")"
tm_svc plutil -lint "$unit" || tm_die "$TM_EXIT_CONFIG" "plutil -lint failed for ${unit}"
tm_svc launchctl bootout "gui/$(id -u)" "$unit" >/dev/null 2>&1 || true
tm_svc launchctl bootstrap "gui/$(id -u)" "$(dirname "$unit")" \
|| tm_die "$TM_EXIT_SERVICE" "launchctl bootstrap failed for ${unit}" ;;
none) TM_SERVICE_REGISTERED=0 ;;
*) tm_die "$TM_EXIT_SERVICE" "unsupported service manager: $TM_SERVICE_MANAGER" ;;
esac
}
tm_service_enable() { # <role>
[[ "$TM_SERVICE_REGISTERED" == "1" ]] || return 0
case "$TM_SERVICE_MANAGER" in
systemd-user)
tm_svc systemctl --user enable "tunnelmesh-$1.service" || tm_die "$TM_EXIT_SERVICE" "systemctl --user enable failed"
if [[ "$TM_NO_LINGER" != "1" ]] && tm_have loginctl; then
tm_svc loginctl enable-linger "$TM_TARGET_USER" \
|| tm_warn "loginctl enable-linger 失败:注销后服务会被停止,可手工执行 loginctl enable-linger ${TM_TARGET_USER}"
fi ;;
systemd-system) tm_svc systemctl enable "tunnelmesh-$1.service" || tm_die "$TM_EXIT_SERVICE" "systemctl enable failed" ;;
launchd) : ;; # RunAtLoad + KeepAlive 已在 plist 内
esac
}
tm_service_start() { # <role>
[[ "$TM_SERVICE_REGISTERED" == "1" ]] || return 0
case "$TM_SERVICE_MANAGER" in
systemd-user) tm_svc systemctl --user restart "tunnelmesh-$1.service" || tm_die "$TM_EXIT_SERVICE" "start failed" ;;
systemd-system) tm_svc systemctl restart "tunnelmesh-$1.service" || tm_die "$TM_EXIT_SERVICE" "start failed" ;;
launchd) tm_svc launchctl kickstart -k "gui/$(id -u)/com.tunnelmesh.$1" || tm_die "$TM_EXIT_SERVICE" "kickstart failed" ;;
esac
}
tm_service_stop() { # <role>
case "$TM_SERVICE_MANAGER" in
systemd-user) tm_svc systemctl --user stop "tunnelmesh-$1.service" >/dev/null 2>&1 || true ;;
systemd-system) tm_svc systemctl stop "tunnelmesh-$1.service" >/dev/null 2>&1 || true ;;
launchd) tm_svc launchctl bootout "gui/$(id -u)/com.tunnelmesh.$1" >/dev/null 2>&1 || true ;;
esac
}
tm_service_remove() { # <role>
local unit; unit="$(tm_unit_path "$1")"
tm_service_stop "$1"
case "$TM_SERVICE_MANAGER" in
systemd-user) tm_svc systemctl --user disable "tunnelmesh-$1.service" >/dev/null 2>&1 || true; tm_svc systemctl --user daemon-reload || true ;;
systemd-system)
tm_svc systemctl disable "tunnelmesh-$1.service" >/dev/null 2>&1 || true
rm -f "/etc/systemd/system/tunnelmesh-$1.service.d/oneclick.conf"
rmdir "/etc/systemd/system/tunnelmesh-$1.service.d" 2>/dev/null || true
rm -f "/etc/systemd/system/tunnelmesh-$1.service"
tm_svc systemctl daemon-reload || true ;;
launchd) rm -f "$unit" ;;
esac
}
tm_service_status() { # <role>
case "$TM_SERVICE_MANAGER" in
systemd-user) tm_svc systemctl --user --no-pager --lines=10 status "tunnelmesh-$1.service" || true ;;
systemd-system) tm_svc systemctl --no-pager --lines=10 status "tunnelmesh-$1.service" || true ;;
launchd) tm_svc launchctl print "gui/$(id -u)/com.tunnelmesh.$1" || true ;;
none) tm_warn "未注册服务" ;;
esac
}
tm_service_logs_hint() {
case "$TM_SERVICE_MANAGER" in
systemd-user) printf 'journalctl --user -u tunnelmesh-%s.service -f\n' "$TM_ROLE" ;;
systemd-system) printf 'journalctl -u tunnelmesh-%s.service -f\n' "$TM_ROLE" ;;
launchd) printf 'tail -f %s/Library/Logs/tunnelmesh-%s.log\n' "$HOME" "$TM_ROLE" ;;
none) printf '%s --config %s run\n' "$(tm_binary_path "$TM_ROLE")" "$(tm_config_path "$TM_ROLE")" ;;
esac
}
- Step 5: 运行测试确认通过
Run: go test ./deploy/install/oneclick -count=1
Expected: PASS,新增 ok mode/*、ok path/*、ok svc/* 全绿。
- Step 6: Commit(需授权) —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
git add deploy/install/oneclick
git commit -m "feat(install): resolve install mode and drive service lifecycle"
Task 7: tm_main 编排与三个角色入口
Files:
- Modify:
deploy/install/oneclick/tunnelmesh-install-common.sh(tm_preflight、tm_install_binary、tm_run_validate、tm_rollback、tm_uninstall、tm_summary、tm_cleanup、tm_main、tm_usage) - Create:
deploy/install/oneclick/install-server.sh、install-agent.sh、install-client.sh - Modify:
deploy/install/oneclick/oneclick_scripts_test.go(入口薄壳与 flag 面断言)
Interfaces:
- Consumes: 前面全部 Task 的函数。
- Produces:
tm_cleanup(trap目标,删TM_WORKDIR与渲染临时文件)tm_preflight(tm_detect_platform→tm_tty_init→tm_entry_init→tm_resolve_install_mode→tm_detect_service_manager→tm_require_cmds)tm_install_binary <role>(原子替换 +.bak-<ts>备份,只留最近一份)tm_rollback_binary <role>tm_run_validate <role>(server 先init-node-id再check-config;其余只check-config;失败tm_die 6并回显原始错误)tm_uninstall <role>tm_summarytm_main "$@":tm_parse_common_args→tm_role_parse_args→ 卸载分支 / 安装分支(8 阶段)tm_usage:包含全部通用 flag 与五种调用形态示例(bash -c "$(curl ...)"与curl ... | bash -s --都必须在内)- 角色钩子(由各入口定义):
tm_role_prompts、tm_role_render_config、tm_role_post_install、tm_role_parse_args(可选,默认空实现)
- Step 1: 写失败测试 —— 入口薄壳与 flag 面
在 oneclick_scripts_test.go 追加:
// stripBootstrap 去掉三个入口里必须重复的引导片段:定位共享库这件事本身
// 无法放进共享库(鸡生蛋),因此允许重复,但要求逐字节一致(见下条测试)。
func stripBootstrap(body string) string {
const begin = "# --- bootstrap-begin ---"
const end = "# --- bootstrap-end ---"
i := strings.Index(body, begin)
j := strings.Index(body, end)
if i < 0 || j < 0 || j < i {
return body
}
return body[:i] + body[j+len(end):]
}
// TestEntryBootstrapIsIdentical 保证三份入口的引导片段不漂移。
func TestEntryBootstrapIsIdentical(t *testing.T) {
const begin = "# --- bootstrap-begin ---"
const end = "# --- bootstrap-end ---"
var want string
for _, name := range shellEntries {
body := readFile(t, name)
i, j := strings.Index(body, begin), strings.Index(body, end)
if i < 0 || j < 0 {
t.Fatalf("%s is missing the bootstrap markers", name)
}
block := body[i : j+len(end)]
if want == "" {
want = block
continue
}
if block != want {
t.Errorf("%s bootstrap block differs from %s; keep all three byte-identical", name, shellEntries[0])
}
}
for _, frag := range []string{"tm_load_lib", "TM_ONECLICK_REF", "tunnelmesh-install-common.sh"} {
if !strings.Contains(want, frag) {
t.Errorf("bootstrap block lost %q", frag)
}
}
}
// TestEntryScriptsStayThin 强制 DRY:入口脚本不得自带下载、校验或渲染逻辑,
// 只能通过共享库完成,否则三份实现会随时间漂移。
func TestEntryScriptsStayThin(t *testing.T) {
forbidden := []string{"SHA256SUMS", "releases/download", "api.github.com", "shasum", "sha256sum"}
for _, name := range shellEntries {
body := stripBootstrap(readFile(t, name))
for _, bad := range forbidden {
if strings.Contains(body, bad) {
t.Errorf("%s must not implement %q inline; put it in %s", name, bad, commonLib)
}
}
for _, want := range []string{"tm_load_lib", "tm_main \"$@\"", "tm_role_prompts", "tm_role_render_config", "tm_role_post_install", "set -euo pipefail"} {
if !strings.Contains(body, want) {
t.Errorf("%s is missing required hook %q", name, want)
}
}
}
}
func TestUsageDocumentsFlagsAndInvocationForms(t *testing.T) {
lib := readFile(t, commonLib)
flags := []string{"--version", "--yes", "--uninstall", "--mode", "--user", "--run-user", "--bin-dir",
"--config-dir", "--state-dir", "--archive", "--base-url", "--raw-base-url", "--no-service",
"--no-start", "--no-enable", "--no-linger", "--no-cache", "--keep-config", "--reconfigure",
"--token-file", "--secret-env-file", "--server-url", "--agent-id", "--tunnel"}
for _, f := range flags {
if !strings.Contains(lib, f) {
t.Errorf("%s does not document or parse %s", commonLib, f)
}
}
for _, form := range []string{`bash -c "$(curl`, `| bash -s --`, `TM_ONECLICK_YES=1`, `TM_ONECLICK_REF`, `/dev/tty`} {
if !strings.Contains(lib, form) {
t.Errorf("%s lost invocation-form support for %q", commonLib, form)
}
}
}
func TestUninstallKeepsConfiguration(t *testing.T) {
lib := readFile(t, commonLib)
if !strings.Contains(lib, "tm_uninstall") {
t.Fatal("missing tm_uninstall")
}
// 卸载必须保留配置与数据:不允许出现删除配置目录的实现。
for _, bad := range []string{"rm -rf /etc/tunnelmesh", "rm -rf \"$HOME/.config/tunnelmesh\"", "rm -rf \"$(tm_default_config_dir)\""} {
if strings.Contains(lib, bad) {
t.Errorf("%s must not delete configuration on uninstall (%q)", commonLib, bad)
}
}
}
- Step 2: 运行测试确认失败
Run: go test ./deploy/install/oneclick -run 'TestEntryScriptsStayThin' -count=1
Expected: FAIL —— open install-server.sh: no such file or directory。
- Step 3: 写最小实现 —— 共享库编排部分
tm_cleanup() {
[[ -n "${TM_WORKDIR:-}" && -d "$TM_WORKDIR" ]] && rm -rf -- "$TM_WORKDIR"
[[ -n "${TM_RENDER_TMP:-}" && -d "$TM_RENDER_TMP" ]] && rm -rf -- "$TM_RENDER_TMP"
return 0
}
tm_preflight() {
tm_detect_platform
tm_tty_init
tm_entry_init "${0:-}" "${BASH_SOURCE[0]:-}"
tm_resolve_install_mode
tm_detect_service_manager
tm_require_cmds curl tar awk sed grep head install mktemp date
TM_RENDER_TMP="$(mktemp -d)"
}
# tm_install_binary:install 到同目录临时名再 mv,避免替换期间进程读到半个文件。
tm_install_binary() { # <role>
local role="$1" src dest tmp ts
src="${TM_EXTRACT_DIR}/tunnelmesh-${role}"
[[ -x "$src" ]] || tm_die "$TM_EXIT_DOWNLOAD" "release archive is missing tunnelmesh-${role}"
dest="$(tm_binary_path "$role")"
mkdir -p "$(dirname "$dest")"
if [[ -e "$dest" ]]; then
ts="$(date -u +%Y%m%dT%H%M%SZ)"
cp -p -- "$dest" "${dest}.bak-${ts}"
ls -1t "${dest}".bak-* 2>/dev/null | tail -n +2 | while IFS= read -r old; do rm -f -- "$old"; done
TM_BINARY_BACKUP="${dest}.bak-${ts}"
else
TM_BINARY_BACKUP=""
fi
tmp="${dest}.tmp.$$"
install -m 0755 "$src" "$tmp"
mv -f -- "$tmp" "$dest"
tm_info "installed ${dest}"
}
tm_rollback_binary() { # <role>
local role="$1" dest; dest="$(tm_binary_path "$role")"
[[ -n "${TM_BINARY_BACKUP:-}" && -f "$TM_BINARY_BACKUP" ]] || return 0
tm_warn "回滚到 ${TM_BINARY_BACKUP}"
tm_service_stop "$role"
install -m 0755 "$TM_BINARY_BACKUP" "$dest"
tm_service_start "$role" || true
}
tm_run_validate() { # <role>
local role="$1" binary config
binary="$(tm_binary_path "$role")"; config="$(tm_config_path "$role")"
if [[ "$role" == "server" ]]; then
"$binary" --config "$config" init-node-id || tm_die "$TM_EXIT_CONFIG" "init-node-id failed"
fi
if ! "$binary" --config "$config" check-config; then
tm_die "$TM_EXIT_CONFIG" "check-config failed;请检查 ${config}(升级后 schema 不匹配见 docs/operations/schema-upgrades.md)"
fi
}
tm_write_role_config() { # <role>
local role="$1" config envfile yaml_tmp env_tmp mode_yaml mode_env
config="$(tm_config_path "$role")"; envfile="$(tm_env_path "$role")"
yaml_tmp="${TM_RENDER_TMP}/${role}.yaml"; env_tmp="${TM_RENDER_TMP}/${role}.env"
tm_env_clear
tm_role_render_config >"$yaml_tmp"
if [[ "$TM_KEEP_CONFIG" == "1" && -f "$config" ]]; then
tm_info "--keep-config:保留现有 ${config}"
else
if [[ "$TM_MODE" == "system" && "$TM_OS_FAMILY" == "linux" ]]; then mode_yaml=0640; else mode_yaml=0600; fi
tm_backup_and_overwrite "$config" "$mode_yaml" "$yaml_tmp"
tm_file_owner_set "$config" "$TM_CONFIG_OWNER" "$TM_CONFIG_GROUP"
fi
if [[ ${#TM_ENV_KEYS[@]} -gt 0 ]]; then
tm_render_env_file >"$env_tmp"
if [[ "$TM_MODE" == "system" && "$TM_OS_FAMILY" == "linux" ]]; then mode_env=0640; else mode_env=0600; fi
tm_backup_and_overwrite "$envfile" "$mode_env" "$env_tmp"
tm_file_owner_set "$envfile" "$TM_CONFIG_OWNER" "$TM_CONFIG_GROUP"
tm_info "wrote secrets to ${envfile} (mode 0${mode_env})"
fi
}
tm_register_service() { # <role>
local role="$1" unit template
unit="$(tm_unit_path "$role")"
case "$TM_SERVICE_MANAGER" in
systemd-user)
template="${TM_EXTRACT_DIR}/deploy/systemd-user/tunnelmesh-${role}.service"
[[ -f "$template" ]] || tm_die "$TM_EXIT_SERVICE" "release archive is missing ${template}"
mkdir -p "$(dirname "$unit")"
tm_render_systemd_user_unit "$role" "$template" "$(tm_binary_path "$role")" \
"$(tm_config_path "$role")" "$(tm_env_path "$role")" "$(tm_default_state_dir)" >"${TM_RENDER_TMP}/unit"
tm_write_file "$unit" 0644 "${TM_RENDER_TMP}/unit" ;;
systemd-system)
template="${TM_EXTRACT_DIR}/deploy/systemd/tunnelmesh-${role}.service"
[[ -f "$template" ]] || tm_die "$TM_EXIT_SERVICE" "release archive is missing ${template}"
tm_write_file "$unit" 0644 "$template"
if [[ "$TM_RUN_USER" != "tunnelmesh" || "$TM_BIN_DIR" != "" || "$TM_CONFIG_DIR" != "" || "$TM_STATE_DIR" != "" ]]; then
mkdir -p "/etc/systemd/system/tunnelmesh-${role}.service.d"
tm_render_systemd_dropin "$role" "$(tm_binary_path "$role")" "$(tm_config_path "$role")" \
"$(tm_env_path "$role")" "$(tm_default_state_dir)" "$TM_RUN_USER" "$TM_RUN_GROUP" \
>"${TM_RENDER_TMP}/dropin"
tm_write_file "/etc/systemd/system/tunnelmesh-${role}.service.d/oneclick.conf" 0644 "${TM_RENDER_TMP}/dropin"
fi ;;
launchd)
template="${TM_EXTRACT_DIR}/deploy/macos/tunnelmesh.plist"
[[ -f "$template" ]] || tm_die "$TM_EXIT_SERVICE" "release archive is missing ${template}"
mkdir -p "$(dirname "$unit")" "$HOME/Library/Logs"
tm_render_template "$template" \
"__ROLE__" "$role" "__HOME__" "$HOME" \
"__BINARY__" "$(tm_binary_path "$role")" "__CONFIG__" "$(tm_config_path "$role")" \
>"${TM_RENDER_TMP}/plist"
# __ENVIRONMENT__ 单独替换:它本身是多行 XML,不能走 sed。
TM_ENV_BLOCK="$(tm_render_plist_environment)"
tm_render_template "${TM_RENDER_TMP}/plist" "__ENVIRONMENT__" "$TM_ENV_BLOCK" >"${TM_RENDER_TMP}/plist.final"
tm_write_file "$unit" 0600 "${TM_RENDER_TMP}/plist.final" ;;
none) return 0 ;;
esac
}
tm_uninstall() { # <role>
local role="$1"
tm_service_remove "$role" || tm_die "$TM_EXIT_UNINSTALL" "failed to remove service for ${role}"
rm -f -- "$(tm_binary_path "$role")" "$(tm_binary_path "$role")".bak-*
tm_info "已卸载 tunnelmesh-${role} 的二进制与服务定义;配置与数据保留:"
printf ' %s\n %s\n %s\n' "$(tm_config_path "$role")" "$(tm_env_path "$role")" "$(tm_default_state_dir)"
}
tm_summary() { # <role> <version>
local role="$1" version="$2"
printf '\n==> TunnelMesh %s %s 安装完成\n' "$role" "$version"
printf ' 二进制: %s\n' "$(tm_binary_path "$role")"
printf ' 配置: %s\n' "$(tm_config_path "$role")"
[[ ${#TM_ENV_KEYS[@]} -gt 0 ]] && printf ' 敏感值: %s(%s)\n' "$(tm_env_path "$role")" "$(tm_env_masked_summary)"
printf ' 服务: %s\n' "${TM_SERVICE_MANAGER}"
printf ' 查看日志: %s\n' "$(tm_service_logs_hint)"
printf ' 卸载: 重新运行本脚本并加 --uninstall\n'
tm_role_post_install
}
tm_env_masked_summary() {
local i out=""
for i in "${!TM_ENV_KEYS[@]}"; do
out="${out}${TM_ENV_KEYS[$i]}=$(tm_mask "${TM_ENV_VALUES[$i]}") "
done
printf '%s' "${out% }"
}
tm_main() {
TM_REMAINING_ARGS=(); TM_TUNNEL_SPECS=()
tm_parse_common_args "$@"
tm_role_parse_args "${TM_REMAINING_ARGS[@]}"
trap tm_cleanup EXIT INT TERM
tm_preflight
tm_role_prompts
if [[ "$TM_UNINSTALL" == "1" ]]; then
tm_uninstall "$TM_ROLE"; exit "$TM_EXIT_OK"
fi
if [[ -n "$TM_ARCHIVE" ]]; then
tm_validate_version "$TM_VERSION" 2>/dev/null || TM_VERSION="$(tm_version_from_archive "$TM_ARCHIVE")"
TM_WORKDIR="$(mktemp -d)"; TM_EXTRACT_DIR="${TM_WORKDIR}/extract"
tm_verify_checksum "$TM_ARCHIVE" "${TM_WORKDIR}/SHA256SUMS" "$(basename "$TM_ARCHIVE")" 2>/dev/null || \
tm_warn "本地归档未提供 SHA256SUMS,跳过校验(--archive 模式)"
tm_extract_archive "$TM_ARCHIVE" "$TM_EXTRACT_DIR"
else
tm_resolve_version
tm_download_release "$TM_VERSION"
TM_EXTRACT_DIR="${TM_WORKDIR}/extract"
tm_extract_archive "$TM_ARCHIVE_PATH" "$TM_EXTRACT_DIR"
fi
local installed_version=""
if [[ -x "$(tm_binary_path "$TM_ROLE")" ]]; then
installed_version="$("$(tm_binary_path "$TM_ROLE")" --version 2>/dev/null | awk '{print $2}')"
tm_info "已安装 ${installed_version},目标 ${TM_VERSION}:按升级流程处理(保留配置)"
fi
mkdir -p "$(tm_default_state_dir)" "$(tm_default_config_dir)"
tm_install_binary "$TM_ROLE"
if [[ "$installed_version" == "" || "$TM_RECONFIGURE" == "1" ]]; then
tm_write_role_config "$TM_ROLE"
else
tm_info "升级:保留现有配置(需要重新生成请加 --reconfigure)"
fi
tm_run_validate "$TM_ROLE" || { tm_rollback_binary "$TM_ROLE"; exit "$TM_EXIT_CONFIG"; }
if [[ "$TM_NO_SERVICE" != "1" ]]; then
tm_register_service "$TM_ROLE"
tm_service_install "$TM_ROLE"
[[ "$TM_NO_ENABLE" == "1" ]] || tm_service_enable "$TM_ROLE"
if [[ "$TM_NO_START" == "1" ]]; then tm_info "--no-start:已注册但未启动"
else tm_service_start "$TM_ROLE" || { tm_rollback_binary "$TM_ROLE"; exit "$TM_EXIT_SERVICE"; }; fi
fi
tm_service_status "$TM_ROLE"
tm_summary "$TM_ROLE" "$TM_VERSION"
}
tm_version_from_archive <path>:从归档文件名tunnelmesh-<version>-<goos>-<goarch>.<ext>中切出<version>(basename→ 去掉前缀与后两段),用于--archive且未传--version的场景。实现约 5 行,必须有对应的run_tests.sh断言:assert_eq "archive-version" "v9.9.9" "$(tm_version_from_archive /tmp/tunnelmesh-v9.9.9-linux-amd64.tar.gz)"
- Step 4: 写最小实现 —— 三个入口脚本
deploy/install/oneclick/install-agent.sh(可执行):
#!/usr/bin/env bash
# TunnelMesh Agent 一键安装。设计与参数见 docs/deployment/oneclick-install.md。
# 本文件刻意保持薄:下载、校验、交互、渲染、服务注册全部在共享库里。
set -euo pipefail
TM_ROLE="agent"
# --- bootstrap-begin ---
# 定位共享库:TM_ONECLICK_LIB → 同目录 → raw 下载。
# `bash -c "$(curl ...)"` 与 `curl | bash` 下 $0/BASH_SOURCE 都不是真实路径,必须回退到下载。
# 三个入口的这段引导必须逐字节一致(TestEntryBootstrapIsIdentical 守护)。
_tm_self="${BASH_SOURCE[0]:-$0}"
_tm_dir=""
[[ -f "$_tm_self" ]] && _tm_dir="$(cd "$(dirname "$_tm_self")" && pwd)"
_tm_raw="${TM_RAW_BASE_URL:-https://raw.githubusercontent.com/nnworld/TunnelMesh}"
_tm_ref="${TM_ONECLICK_REF:-main}"
if [[ -n "${TM_ONECLICK_LIB:-}" && -f "${TM_ONECLICK_LIB}" ]]; then
_tm_lib="$TM_ONECLICK_LIB"
elif [[ -n "$_tm_dir" && -f "${_tm_dir}/tunnelmesh-install-common.sh" ]]; then
_tm_lib="${_tm_dir}/tunnelmesh-install-common.sh"
else
_tm_lib="$(mktemp -d)/tunnelmesh-install-common.sh"
curl --fail --silent --show-error --location --max-time 60 \
"${_tm_raw}/${_tm_ref}/deploy/install/oneclick/tunnelmesh-install-common.sh" --output "$_tm_lib" || {
echo "ERROR: cannot download tunnelmesh-install-common.sh from ${_tm_raw}/${_tm_ref}" >&2; exit 4; }
[[ -s "$_tm_lib" ]] || { echo "ERROR: downloaded installer library is empty" >&2; exit 4; }
[[ "$(head -n 1 "$_tm_lib")" == '#!/usr/bin/env bash' ]] || {
echo "ERROR: downloaded installer library is not a bash script" >&2; exit 4; }
fi
# shellcheck source=/dev/null
source "$_tm_lib"
tm_load_lib() { printf '%s\n' "$_tm_lib"; } # 供共享库内部与测试查询实际加载路径
tm_entry_init "$_tm_self" "$_tm_self"
# --- bootstrap-end ---
tm_role_parse_args() { :; }
tm_role_prompts() {
tm_ask server_url "Server WebSocket URL" ""
[[ -n "$(tm_ans_get server_url)" ]] || tm_die "$TM_EXIT_PREFLIGHT" "缺少 Server URL:交互输入,或 --server-url / TM_ONECLICK_YES=1 + --server-url"
tm_validate_ws_url "$(tm_ans_get server_url)" "/ws/agent"
tm_ask agent_id "Agent ID" "agent-$(hostname | tr 'A-Z' 'a-z' | tr -cs 'a-z0-9-' '-' | cut -c1-40)"
tm_secret_value agent_token "Agent service token" TUNNELMESH_AGENT_TOKEN
tm_ask conn_min "连接池最小连接数" "1"
tm_ask conn_max "连接池最大连接数" "1"
tm_ask instance_id "instance_id(留空自动生成并持久化)" ""
tm_ask_bool report_metadata "上报宿主机 metadata(来自 allowlist 的文件/环境变量)" "no"
if [[ "$(tm_ans_get report_metadata)" == "yes" ]]; then
tm_ask metadata_name "metadata 名称" "device_id"
tm_ask_choice metadata_source "metadata 来源" "file" file env
tm_ask metadata_path "文件路径(source=file 时)" "/etc/machine-id"
tm_ask metadata_key "环境变量名(source=env 时)" ""
tm_validate_metadata_name "$(tm_ans_get metadata_name)"
fi
}
tm_role_render_config() { tm_render_agent_yaml; }
tm_role_post_install() {
printf ' Agent ID: %s\n' "$(tm_ans_get agent_id)"
printf ' 下一步: 在管理后台为该 Agent 绑定 service token,然后执行 %s id\n' "$(tm_binary_path agent)"
}
tm_main "$@"
tm_validate_metadata_name <name>:字符集[A-Za-z0-9._-],且不得命中敏感词password|passphrase|token|secret|private.?key|api.?key|credential|authorization|cookie|dsn(与internal/metadata的 allowlist 规则一致),否则tm_die 2。实现放在共享库并配run_tests.sh断言:assert_exit_sh 0 "metadata/name-ok" "tm_validate_metadata_name device_id" assert_exit_sh 2 "metadata/name-sensitive" "tm_validate_metadata_name db_password" assert_exit_sh 2 "metadata/name-charset" "tm_validate_metadata_name 'bad name'"
deploy/install/oneclick/install-client.sh:文件头、TM_ROLE="client"、bootstrap 片段(与 agent 逐字节一致)、tm_main "$@" 都相同,只有角色钩子换成:
tm_role_prompts() {
tm_ask server_url "Server WebSocket URL" ""
[[ -n "$(tm_ans_get server_url)" ]] || tm_die "$TM_EXIT_PREFLIGHT" "缺少 Server URL:交互输入,或 --server-url"
tm_validate_ws_url "$(tm_ans_get server_url)" "/ws/client"
tm_secret_value client_token "Client service token" TUNNELMESH_CLIENT_TOKEN
tm_ask instance_id_path "instance_id 持久化路径" "$(tm_default_instance_id_path)"
if [[ ${#TM_TUNNEL_SPECS[@]} -eq 0 ]]; then
tm_ask_bool add_tunnel "现在添加本地转发(也可安装后用 --tunnel 或编辑配置)" "no"
while [[ "$(tm_ans_get add_tunnel)" == "yes" ]]; do
tm_ask t_name "转发名称" "tunnel-$(( ${#TM_TUNNEL_SPECS[@]} + 1 ))"
tm_ask_choice t_protocol "协议" "tcp" tcp udp http socks5
tm_ask t_listen "本地监听地址" "127.0.0.1:$((15432 + ${#TM_TUNNEL_SPECS[@]}))"
tm_ask t_agent "目标 Agent ID" ""
if [[ "$(tm_ans_get t_protocol)" != "socks5" ]]; then
tm_ask t_host "目标 host" ""
tm_ask t_port "目标 port" ""
else
tm_ans_set t_host ""; tm_ans_set t_port ""
fi
tm_validate_listen "$(tm_ans_get t_listen)"
TM_TUNNEL_SPECS+=("$(tm_ans_get t_name):$(tm_ans_get t_protocol):$(tm_ans_get t_listen):$(tm_ans_get t_agent):$(tm_ans_get t_host):$(tm_ans_get t_port)")
tm_ask_bool add_tunnel "继续添加下一条转发" "no"
done
fi
}
tm_role_render_config() { tm_render_client_yaml; }
tm_role_post_install() {
printf ' 本地转发: %s 条\n' "${#TM_TUNNEL_SPECS[@]}"
printf ' 查看状态: %s status\n' "$(tm_binary_path client)"
}
deploy/install/oneclick/install-server.sh 的角色钩子:
tm_role_prompts() {
tm_ask_choice mode "运行模式" "local" local cluster
tm_ask http_addr "HTTP 监听地址" "127.0.0.1:8080"
tm_ask dynamic_suffix "动态托管域名后缀" "apps.example.com"
if [[ "$(tm_ans_get mode)" == "cluster" ]]; then
tm_ans_set storage_driver "mysql"
tm_secret_value mysql_dsn "MySQL DSN(含账号密码)" TUNNELMESH_STORAGE_MYSQL_DSN
tm_ask_bool mysql_tls "MySQL 启用 TLS" "no"
tm_ask_choice registry_type "注册发现" "database" database etcd
[[ "$(tm_ans_get registry_type)" == "etcd" ]] && tm_ask registry_endpoints "etcd endpoints(逗号分隔)" "127.0.0.1:2379"
tm_ask_bool relay_enabled "启用 Server 节点间 relay" "no"
if [[ "$(tm_ans_get relay_enabled)" == "yes" ]]; then
tm_ask relay_listen "relay 监听地址" "0.0.0.0:9443"
tm_ask relay_endpoint "relay 对外 endpoint(留空自动推导)" ""
tm_ask relay_ca "relay CA 证书路径(留空为明文 relay)" ""
tm_ask relay_cert "relay 证书路径" ""
tm_ask relay_key "relay 私钥路径" ""
tm_ask relay_server_name "relay TLS ServerName" ""
tm_secret_value relay_node_token "server-node relay token" TUNNELMESH_SERVER_RELAY_NODE_TOKEN
fi
else
tm_ans_set storage_driver "sqlite"
tm_ask sqlite_path "SQLite 数据库路径" "$(tm_default_state_dir)/tunnelmesh.db"
tm_ans_set registry_type "database"
tm_ans_set relay_enabled "no"
fi
tm_ask_bool auto_init "自动建表/执行增量迁移" "yes"
tm_ask_bool webssh_enabled "启用浏览器 WebSSH/SFTP" "yes"
tm_ask_bool proxy_entry_enabled "启用 tp-* HTTP 代理入口" "no"
if [[ "$(tm_ans_get proxy_entry_enabled)" == "yes" ]]; then
tm_ask proxy_entry_listen "代理入口内部监听地址" "127.0.0.1:8089"
tm_ask proxy_entry_domain_suffix "代理入口域名后缀" ""
fi
tm_ask_bool tls_enabled "由本进程终止 TLS(否则交给 Nginx)" "no"
tm_ask allowed_hosts "Host 白名单(逗号分隔,留空不限制)" ""
tm_ask allowed_origins "Origin 白名单(逗号分隔)" ""
tm_ask_bool gen_identity_key "自动生成身份主密钥(SSO/MFA 与 token reveal 必需)" "yes"
if [[ "$(tm_ans_get gen_identity_key)" == "yes" ]]; then
tm_ans_set identity_key "$(tm_generate_secret_key)"
[[ "$(tm_ans_get mode)" == "cluster" ]] && tm_ans_set trace_signing_key "$(tm_generate_secret_key)"
fi
tm_ask_bool bootstrap_admin "安装后立即创建首个管理员" "yes"
}
tm_role_render_config() {
tm_render_server_yaml
# 敏感值只进 env 载体,绝不进 YAML。
[[ -n "$(tm_ans_get mysql_dsn)" ]] && tm_env_set TUNNELMESH_STORAGE_MYSQL_DSN "$(tm_ans_get mysql_dsn)"
[[ -n "$(tm_ans_get relay_node_token)" ]] && tm_env_set TUNNELMESH_SERVER_RELAY_NODE_TOKEN "$(tm_ans_get relay_node_token)"
[[ -n "$(tm_ans_get identity_key)" ]] && tm_env_set TUNNELMESH_TOKEN_ENCRYPTION_KEY "$(tm_ans_get identity_key)"
[[ -n "$(tm_ans_get trace_signing_key)" ]] && {
tm_env_set TUNNELMESH_TRACE_SIGNING_KEY "$(tm_ans_get trace_signing_key)"
tm_env_set TUNNELMESH_TOKEN_ENCRYPTION_KEY_ID "default"
}
}
tm_role_post_install() {
printf ' 管理后台: http://%s/\n' "$(tm_ans_get http_addr)"
if [[ "$(tm_ans_get bootstrap_admin)" == "yes" ]]; then
tm_info "创建首个管理员(凭据只输出到本终端,请立即修改)"
"$(tm_binary_path server)" --config "$(tm_config_path server)" admin bootstrap || \
tm_warn "admin bootstrap 失败,可稍后手工执行:tunnelmesh-server --config <path> admin regenerate-credentials --confirm"
fi
printf ' 健康检查: %s doctor\n' "$(tm_binary_path server)"
}
配套实现(共享库):
# tm_generate_secret_key:openssl 优先,回退 /dev/urandom;输出 base64 的 32 字节。
tm_generate_secret_key() {
if tm_have openssl; then openssl rand -base64 32
else head -c 32 /dev/urandom | base64 | tr -d '\n'; fi
}
# tm_default_instance_id_path:与配置模型的三平台默认值一致。
tm_default_instance_id_path() {
case "$TM_OS_FAMILY" in
darwin) printf '%s/Library/Application Support/TunnelMesh/client-instance-id\n' "$HOME" ;;
linux)
if [[ "$TM_MODE" == "system" ]]; then printf '/var/lib/tunnelmesh-client/client-instance-id\n'
else printf '%s/.local/share/tunnelmesh/client-instance-id\n' "$HOME"; fi ;;
esac
}
# tm_validate_listen <host:port>:非 loopback 监听必须显式确认并配认证。
tm_validate_listen() {
local listen="$1" host="${1%:*}"
[[ "$listen" =~ ^[^:]+:[0-9]+$ ]] || tm_die "$TM_EXIT_USAGE" "listen 必须是 host:port,收到 '$listen'"
tm_validate_port "${listen##*:}" "listen port"
case "$host" in
127.* | ::1 | localhost) return 0 ;;
esac
tm_warn "监听地址 ${listen} 不是 loopback:必须同时配置认证(auth_mode != none)"
tm_ask_bool allow_remote "允许非 loopback 监听" "no"
[[ "$(tm_ans_get allow_remote)" == "yes" ]] || tm_die "$TM_EXIT_USAGE" "已取消:非 loopback 监听需要显式同意"
tm_ask_choice auth_mode "认证方式" "password" password none
[[ "$(tm_ans_get auth_mode)" != "none" ]] || tm_die "$TM_EXIT_USAGE" "非 loopback 监听不允许 auth_mode=none"
}
- Step 5: 运行测试确认通过
Run: go test ./deploy/install/oneclick -count=1 与 bash -n deploy/install/oneclick/*.sh
Expected: PASS,TestEntryScriptsStayThin、TestUsageDocumentsFlagsAndInvocationForms、TestUninstallKeepsConfiguration 全绿。
- Step 6: 手工冒烟(本机 macOS,不联网)
bash deploy/install/oneclick/install-agent.sh --help
TM_ONECLICK_ALLOW_STDIN=1 printf 'wss://tunnel.example.com/ws/agent\nagent-test\n\n1\n1\n\nno\n' | \
bash deploy/install/oneclick/install-agent.sh --archive /tmp/tunnelmesh-v1.1.1-darwin-arm64.tar.gz --no-service --no-cache
Expected: --help 打印五种调用形态与全部 flag;第二条命令在 check-config 处因 token 缺失或网络不可达失败并给出明确原因(退出码 3 或 6),不静默成功。
- Step 7: Commit(需授权) —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
git add deploy/install/oneclick
git commit -m "feat(install): orchestrate one-click install, upgrade and uninstall"
Task 4: 渲染原语与服务模板
Files:
- Create:
deploy/systemd-user/tunnelmesh-server.service、tunnelmesh-agent.service、tunnelmesh-client.service - Modify:
deploy/macos/tunnelmesh.plist(新增__ENVIRONMENT__)、deploy/windows/tunnelmesh-service.xml(新增__ENV_BLOCK__)、deploy/systemd/tunnelmesh-agent.service(新增EnvironmentFile=-/etc/tunnelmesh/agent.env) - Modify:
deploy/install/macos-install.sh、deploy/install/windows-install.ps1(把新占位符渲染为空,行为不变) - Modify:
deploy/install/install_templates_test.go(覆盖新模板与新占位符) - Modify:
deploy/install/oneclick/tunnelmesh-install-common.sh、testdata/run_tests.sh
Interfaces:
- Consumes:
tm_die、退出码、tm_ans_get/set(Task 3)、tm_file_perms(Task 3)。 - Produces:
tm_yaml_quote <value>→ 单引号标量,内部'翻倍tm_env_clear、tm_env_set <KEY> <value>、tm_env_get <KEY>:用索引数组TM_ENV_KEYS/TM_ENV_VALUES累积(bash 3.2 无关联数组)tm_render_env_file→ stdout,首行固定注释 +KEY='value'行tm_xml_escape <value>(&→&、<→<、>→>、"→")tm_render_plist_environment→ stdout,无 env 时输出空串tm_render_winsw_env_block→ stdout,无 env 时输出空串tm_render_template <template-file> <placeholder> <value> [<placeholder> <value>...]→ stdout,字面替换(不用正则,避免路径里的\与&被吃掉)tm_render_systemd_user_unit <role> <template-file> <binary> <config> <env-file> <state-dir>→ stdouttm_render_systemd_dropin <role> <binary> <config> <env-file> <state-dir> <run-user> <run-group>→ stdouttm_write_file <dest> <mode> <content-file>、tm_backup_and_overwrite <dest> <mode> <content-file>(备份<dest>.bak-<UTC时间戳>,只保留最近 3 份)、tm_file_owner_set <path> <user> <group>
- Step 1: 写失败测试 —— 三份 user 单元模板
创建 deploy/systemd-user/tunnelmesh-agent.service:
[Unit]
Description=TunnelMesh Agent (user)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
EnvironmentFile=-__ENV_FILE__
WorkingDirectory=__STATE_DIR__
ExecStartPre=__BINARY__ --config __CONFIG__ check-config
ExecStart=__BINARY__ --config __CONFIG__ run
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=__STATE_DIR__
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal
SyslogIdentifier=tunnelmesh-agent
[Install]
WantedBy=default.target
deploy/systemd-user/tunnelmesh-client.service 与上面逐行相同,只把两处 tunnelmesh-agent/Agent 换成 tunnelmesh-client/Client。
deploy/systemd-user/tunnelmesh-server.service 在 ExecStartPre=... check-config 之前多一行 init-node-id(user 模式下 YAML 属于当前用户,可原地回写,不需要系统单元里的 + 提权前缀):
ExecStartPre=__BINARY__ --config __CONFIG__ init-node-id
ExecStartPre=__BINARY__ --config __CONFIG__ check-config
- Step 2: 写失败测试 —— 既有模板加占位符
deploy/macos/tunnelmesh.plist:在 <key>ProgramArguments</key> 之前插入一行占位符(渲染为空时 plist 结构不变):
<key>Label</key>
<string>com.tunnelmesh.__ROLE__</string>
__ENVIRONMENT__
<key>ProgramArguments</key>
deploy/windows/tunnelmesh-service.xml:在 <executable> 之前插入占位符:
<description>TunnelMesh __ROLE__ service</description>
__ENV_BLOCK__
<executable>__BINARY__</executable>
deploy/systemd/tunnelmesh-agent.service:在 WorkingDirectory=/var/lib/tunnelmesh-agent 之后插入(前缀 - 表示文件缺失不阻塞启动,与 server 单元一致):
EnvironmentFile=-/etc/tunnelmesh/agent.env
deploy/install/macos-install.sh:在现有 sed 链里追加一行,把占位符渲染为空:
-e "s|__ENVIRONMENT__||g" \
deploy/install/windows-install.ps1:在 $rendered = $template.Replace('__INSTALL_DIR__', $InstallDir) 之后追加:
$rendered = $rendered.Replace('__ENV_BLOCK__', '')
- Step 3: 写失败测试 —— Go 模板契约
在 deploy/install/install_templates_test.go 的 genericTemplates 里补占位符,并新增 user 单元断言:
var genericTemplates = map[string][]string{
"../macos/tunnelmesh.plist": {"__ROLE__", "__HOME__", "__BINARY__", "__CONFIG__", "__ENVIRONMENT__"},
"../windows/tunnelmesh-service.xml": {"__ROLE__", "__BINARY__", "__CONFIG__", "__INSTALL_DIR__", "__ENV_BLOCK__"},
}
// systemdUserUnits 是 user 模式模板:与系统单元不同,路径全部由占位符渲染,
// 因为用户级安装的二进制、配置、状态目录都在 $HOME 下且可被 --bin-dir 等覆盖。
func systemdUserUnit(role string) string { return "../systemd-user/tunnelmesh-" + role + ".service" }
func TestSystemdUserTemplatesCoverEveryRole(t *testing.T) {
for _, role := range roles {
data, err := os.ReadFile(systemdUserUnit(role))
if err != nil {
t.Fatalf("%s: %v", systemdUserUnit(role), err)
}
for _, want := range []string{
"__BINARY__", "__CONFIG__", "__ENV_FILE__", "__STATE_DIR__",
"--config", "run", "WantedBy=default.target", "tunnelmesh-" + role,
} {
if !bytes.Contains(data, []byte(want)) {
t.Errorf("%s does not contain %q", systemdUserUnit(role), want)
}
}
if role == "server" && !bytes.Contains(data, []byte("init-node-id")) {
t.Errorf("%s must run init-node-id before check-config", systemdUserUnit(role))
}
}
}
// TestAgentSystemUnitCanInjectToken agent.token 的 YAML 键是 `yaml:"-"`,
// 只能通过环境变量注入,因此系统单元必须有可选的 env 文件。
func TestAgentSystemUnitCanInjectToken(t *testing.T) {
data, err := os.ReadFile(systemdUnit("agent"))
if err != nil {
t.Fatalf("%v", err)
}
if !bytes.Contains(data, []byte("EnvironmentFile=-/etc/tunnelmesh/agent.env")) {
t.Errorf("agent system unit lost its optional EnvironmentFile")
}
}
Run: go test ./deploy/install -count=1
Expected: FAIL —— ../systemd-user/tunnelmesh-server.service: no such file or directory,以及 plist/XML/agent 单元的占位符断言失败。
- Step 4: 写失败测试 —— 渲染函数断言
追加到 testdata/run_tests.sh:
# --- Task 4: 渲染原语 ---
assert_eq "yaml/plain" "'abc'" "$(tm_yaml_quote abc)"
assert_eq "yaml/inner-quote" "'a''b'" "$(tm_yaml_quote "a'b")"
assert_eq "yaml/empty" "''" "$(tm_yaml_quote '')"
assert_eq "yaml/dsn" "'user:p@ss w/db?parseTime=true'" "$(tm_yaml_quote 'user:p@ss w/db?parseTime=true')"
tm_env_clear
tm_env_set TUNNELMESH_AGENT_TOKEN "tok'en"
tm_env_set TUNNELMESH_REGION "shanghai"
assert_eq "env/get" "shanghai" "$(tm_env_get TUNNELMESH_REGION)"
rendered_env="$(tm_render_env_file)"
assert_contains "env/header" "$rendered_env" "包含敏感值"
assert_contains "env/quoted-value" "$rendered_env" "TUNNELMESH_AGENT_TOKEN='tok'\"'\"'en'"
assert_contains "env/plain-value" "$rendered_env" "TUNNELMESH_REGION='shanghai'"
assert_eq "xml/escape" "a&b<c>d"e" "$(tm_xml_escape 'a&b<c>d"e')"
assert_eq "plist/empty-when-no-env" "" "$(tm_env_clear; tm_render_plist_environment)"
tm_env_set TUNNELMESH_AGENT_TOKEN "t0k"
plist_env="$(tm_render_plist_environment)"
assert_contains "plist/dict-open" "$plist_env" "<key>EnvironmentVariables</key>"
assert_contains "plist/key" "$plist_env" "<key>TUNNELMESH_AGENT_TOKEN</key>"
assert_contains "plist/value" "$plist_env" "<string>t0k</string>"
assert_contains "winsw/env" "$(tm_render_winsw_env_block)" '<env name="TUNNELMESH_AGENT_TOKEN" value="t0k" />'
tm_env_clear
unit="$(tm_render_systemd_user_unit agent "$ROOT/deploy/systemd-user/tunnelmesh-agent.service" \
"$HOME/.local/bin/tunnelmesh-agent" "$HOME/.config/tunnelmesh/agent.yaml" \
"$HOME/.config/tunnelmesh/agent.env" "$HOME/.local/share/tunnelmesh")"
assert_contains "unit/exec" "$unit" "ExecStart=$HOME/.local/bin/tunnelmesh-agent --config $HOME/.config/tunnelmesh/agent.yaml run"
assert_contains "unit/envfile" "$unit" "EnvironmentFile=-$HOME/.config/tunnelmesh/agent.env"
assert_contains "unit/state" "$unit" "ReadWritePaths=$HOME/.local/share/tunnelmesh"
assert_eq "unit/no-placeholder-left" "" "$(printf '%s' "$unit" | grep -o '__[A-Z_]*__' || true)"
dropin="$(tm_render_systemd_dropin server /opt/tm/tunnelmesh-server /etc/tunnelmesh/server.yaml /etc/tunnelmesh/server.env /var/lib/tunnelmesh tmuser tmgroup)"
assert_contains "dropin/user" "$dropin" "User=tmuser"
assert_contains "dropin/group" "$dropin" "Group=tmgroup"
assert_contains "dropin/clear-execstart" "$dropin" "ExecStart="
assert_contains "dropin/clear-execstartpre" "$dropin" "ExecStartPre="
assert_contains "dropin/init-node-id-root" "$dropin" "ExecStartPre=+/opt/tm/tunnelmesh-server --config /etc/tunnelmesh/server.yaml init-node-id"
assert_contains "dropin/exec" "$dropin" "ExecStart=/opt/tm/tunnelmesh-server --config /etc/tunnelmesh/server.yaml run"
# 覆盖前备份,只保留最近 3 份
bk="$TM_TMP4"; TM_TMP4="$(mktemp -d)"; mkdir -p "$TM_TMP4"
target="$TM_TMP4/agent.yaml"
for i in 1 2 3 4 5; do printf 'v%s\n' "$i" >"$TM_TMP4/content"; TM_BACKUP_TIMESTAMP="2026010${i}T000000Z" tm_backup_and_overwrite "$target" 0600 "$TM_TMP4/content"; done
assert_eq "backup/content" "v5" "$(cat "$target")"
assert_eq "backup/kept-3" "3" "$(ls "$target".bak-* | wc -l | tr -d ' ')"
assert_eq "backup/newest" "v4" "$(cat "$target.bak-20260105T000000Z" 2>/dev/null || cat "$target".bak-2026010*T000000Z | tail -1)"
assert_eq "backup/mode" "600" "$(tm_file_perms "$target")"
rm -rf "$TM_TMP4"
ROOT在套件开头定义为ROOT="$(cd "$HERE/../../.." && pwd)"(仓库根),用于定位deploy/systemd-user/。实现 Step 1 时把这一行加到run_tests.sh顶部。TM_BACKUP_TIMESTAMP是备份文件名的可注入点:默认取date -u +%Y%m%dT%H%M%SZ,测试里显式赋值以便断言轮转顺序。
- Step 5: 运行测试确认失败
Run: go test ./deploy/install/oneclick -run TestShellFunctionSuite -count=1 与 go test ./deploy/install -count=1
Expected: 两者均 FAIL —— 前者 tm_yaml_quote: command not found,后者 user 单元文件不存在。
- Step 6: 写最小实现
在 tunnelmesh-install-common.sh 追加:
# --- YAML / env / XML 渲染 ---
tm_yaml_quote() {
local v="$1"
printf "'%s'\n" "${v//\'/\'\'}"
}
TM_ENV_KEYS=() TM_ENV_VALUES=()
tm_env_clear() { TM_ENV_KEYS=(); TM_ENV_VALUES=(); }
tm_env_set() {
local i
for i in "${!TM_ENV_KEYS[@]}"; do
if [[ "${TM_ENV_KEYS[$i]}" == "$1" ]]; then TM_ENV_VALUES[$i]="$2"; return 0; fi
done
TM_ENV_KEYS+=("$1"); TM_ENV_VALUES+=("$2")
}
tm_env_get() {
local i
for i in "${!TM_ENV_KEYS[@]}"; do
[[ "${TM_ENV_KEYS[$i]}" == "$1" ]] && { printf '%s' "${TM_ENV_VALUES[$i]}"; return 0; }
done
return 0
}
tm_render_env_file() {
local i
printf '# 本文件由 TunnelMesh 一键安装脚本生成,包含敏感值,请勿提交版本库。\n'
printf '# 权限应保持在 0600(user 模式)或 0640 root:<运行组>(system 模式)。\n'
for i in "${!TM_ENV_KEYS[@]}"; do
printf '%s=%s\n' "${TM_ENV_KEYS[$i]}" "$(tm_yaml_quote "${TM_ENV_VALUES[$i]}" | tr -d '\n')"
done
}
tm_xml_escape() {
local v="$1"
v="${v//&/&}"; v="${v//</<}"; v="${v//>/>}"; v="${v//\"/"}"
printf '%s' "$v"
}
tm_render_plist_environment() {
[[ ${#TM_ENV_KEYS[@]} -eq 0 ]] && return 0
local i
printf ' <key>EnvironmentVariables</key>\n <dict>\n'
for i in "${!TM_ENV_KEYS[@]}"; do
printf ' <key>%s</key>\n <string>%s</string>\n' \
"$(tm_xml_escape "${TM_ENV_KEYS[$i]}")" "$(tm_xml_escape "${TM_ENV_VALUES[$i]}")"
done
printf ' </dict>\n'
}
tm_render_winsw_env_block() {
[[ ${#TM_ENV_KEYS[@]} -eq 0 ]] && return 0
local i
for i in "${!TM_ENV_KEYS[@]}"; do
printf ' <env name="%s" value="%s" />\n' \
"$(tm_xml_escape "${TM_ENV_KEYS[$i]}")" "$(tm_xml_escape "${TM_ENV_VALUES[$i]}")"
done
}
# tm_render_template 用逐字符字面替换而不是 sed:Windows 路径里的反斜杠、
# XML 里的 & 都会被 sed 当转义/反向引用吃掉(windows-install.ps1 里踩过同一个坑)。
tm_render_template() {
local template="$1"; shift
local content; content="$(cat "$template")"
while [[ $# -gt 0 ]]; do
local ph="$1" val="$2"; shift 2
content="${content//$ph/$val}"
done
printf '%s\n' "$content"
}
tm_render_systemd_user_unit() { # <role> <template> <binary> <config> <env-file> <state-dir>
local role="$1" template="$2"
tm_render_template "$template" \
"__BINARY__" "$3" "__CONFIG__" "$4" "__ENV_FILE__" "$5" "__STATE_DIR__" "$6"
}
# tm_render_systemd_dropin:系统模式下只在用户选择了非默认值时生成,
# 用 ExecStart=/ExecStartPre= 清空再重写的标准做法覆盖路径与账户,
# 不修改 deploy/systemd/ 里的原始单元(单一模板来源约定)。
tm_render_systemd_dropin() { # <role> <binary> <config> <env-file> <state-dir> <run-user> <run-group>
local role="$1" binary="$2" config="$3" envfile="$4" state="$5" user="$6" group="$7"
printf '# 由一键安装脚本生成:覆盖 %s 单元中与默认值不同的部分。\n' "tunnelmesh-${role}.service"
printf '# 手工修改请编辑本文件,不要改 /etc/systemd/system/tunnelmesh-%s.service。\n' "$role"
printf '[Service]\n'
printf 'User=%s\nGroup=%s\n' "$user" "$group"
printf 'WorkingDirectory=%s\nReadWritePaths=%s\n' "$state" "$state"
printf 'EnvironmentFile=-%s\n' "$envfile"
printf 'ExecStartPre=\nExecStart=\n'
if [[ "$role" == "server" ]]; then
printf 'ExecStartPre=+%s --config %s init-node-id\n' "$binary" "$config"
fi
printf 'ExecStartPre=%s --config %s check-config\n' "$binary" "$config"
printf 'ExecStart=%s --config %s run\n' "$binary" "$config"
}
# --- 落盘:先备份再覆盖,最多保留最近 3 份 ---
TM_BACKUP_TIMESTAMP=""
tm_backup_and_overwrite() { # <dest> <mode> <content-file>
local dest="$1" mode="$2" content="$3" ts oldest
mkdir -p "$(dirname "$dest")"
if [[ -e "$dest" ]]; then
ts="${TM_BACKUP_TIMESTAMP:-$(date -u +%Y%m%dT%H%M%SZ)}"
cp -p -- "$dest" "${dest}.bak-${ts}"
# shellcheck disable=SC2012 # ls -1t 用于按时间排序,find -printf 在 macOS 不可用
oldest="$(ls -1t "${dest}".bak-* 2>/dev/null | tail -n +4)"
if [[ -n "$oldest" ]]; then printf '%s\n' "$oldest" | while IFS= read -r f; do rm -f -- "$f"; done; fi
fi
install -m "$mode" "$content" "$dest"
}
tm_write_file() { # <dest> <mode> <content-file>
mkdir -p "$(dirname "$1")"
install -m "$2" "$3" "$1"
}
tm_file_owner_set() { # <path> <user> <group>
if [[ -n "$2" || -n "$3" ]]; then chown "${2:-}:${3:-}" "$1" 2>/dev/null || tm_warn "cannot chown $1 to ${2:-}:${3:-}"; fi
}
- Step 7: 运行测试确认通过
Run: go test ./deploy/install -count=1 → PASS;go test ./deploy/install/oneclick -count=1 → PASS(新增 ok yaml/*、ok env/*、ok plist/*、ok winsw/*、ok unit/*、ok dropin/*、ok backup/*)。
Run: bash -n deploy/install/macos-install.sh && plutil -lint deploy/macos/tunnelmesh.plist
Expected: 均退出码 0(占位符渲染为空后 plist 仍合法)。
- Step 8: Commit(需授权)
git add deploy/systemd-user deploy/systemd/tunnelmesh-agent.service deploy/macos/tunnelmesh.plist deploy/windows/tunnelmesh-service.xml deploy/install
git commit -m "feat(deploy): add user-mode systemd templates and env injection placeholders"
Task 5: 三角色配置渲染与配置漂移测试
Files:
- Modify:
deploy/install/oneclick/tunnelmesh-install-common.sh(追加三个角色的 YAML 渲染函数) - Create:
deploy/install/oneclick/testdata/render_yaml.sh(测试驱动,不进归档) - Create:
deploy/install/oneclick/oneclick_config_test.go - Modify:
deploy/install/oneclick/testdata/run_tests.sh
Interfaces:
- Consumes:
tm_yaml_quote、tm_env_set、tm_ans_get、tm_die、TM_TUNNEL_SPECS。 - Produces:
tm_render_server_yaml→ stdout(读tm_ans_get的mode、http_addr、dynamic_suffix、storage_driver、sqlite_path、mysql_tls、auto_init、registry_type、registry_endpoints、relay_enabled、relay_listen、relay_endpoint、relay_ca、relay_cert、relay_key、relay_server_name、webssh_enabled、proxy_entry_enabled、proxy_entry_listen、proxy_entry_domain_suffix、tls_enabled、allowed_hosts、allowed_origins;同时把TUNNELMESH_STORAGE_MYSQL_DSN、TUNNELMESH_SERVER_RELAY_NODE_TOKEN、TUNNELMESH_TOKEN_ENCRYPTION_KEY、TUNNELMESH_TOKEN_ENCRYPTION_KEY_ID、TUNNELMESH_TRACE_SIGNING_KEY写进tm_env_set)tm_render_agent_yaml→ stdout(server_url、agent_id、instance_id、conn_min、conn_max、metadata 三元组;token 写TUNNELMESH_AGENT_TOKEN)tm_render_client_yaml→ stdout(server_url、instance_id_path、TM_TUNNEL_SPECS;token 写TUNNELMESH_CLIENT_TOKEN)- 空值键一律不输出,避免覆盖配置模型的默认值
-
依赖的配置键名以
internal/config/config.go的yamltag 为唯一权威来源:mode、storage.{driver,auto_init,sqlite.path,mysql.tls}、registry.{type,endpoints}、node.id、server.{http_addr,dynamic_suffix,tcp_bridge.enabled,webssh.enabled,proxy_entry.{enabled,listen,domain_suffix},relay.{enabled,listen,endpoint,ca,cert,key,server_name}}、security.{allowed_hosts,allowed_origins}、tls.enabled、agent.{server_url,id,instance_id,connections.{min,max},metadata[].{name,source,path,key}}、client.{server_url,instance_id_path,tunnels[].{name,protocol,listen,agent_id,target_host,target_port,auth_mode,allow_remote,auth_url}}。 - Step 1: 写失败测试 —— 渲染驱动脚本
创建 deploy/install/oneclick/testdata/render_yaml.sh:
#!/usr/bin/env bash
# 测试驱动:把 key=value 形式的答案灌进 tm_ans_set,再调用角色渲染函数。
# 只被 oneclick_config_test.go 与 run_tests.sh 使用,不进发布归档。
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
source "${TM_ONECLICK_LIB:-$HERE/../tunnelmesh-install-common.sh}"
role="$1"; shift
TM_TUNNEL_SPECS=()
for kv in "$@"; do
case "$kv" in
tunnel=*) TM_TUNNEL_SPECS+=("${kv#tunnel=}") ;;
env=*) IFS='=' read -r _ k v <<<"$kv"; tm_env_set "$k" "$v" ;;
*) tm_ans_set "${kv%%=*}" "${kv#*=}" ;;
esac
done
case "$role" in
server) tm_render_server_yaml ;;
agent) tm_render_agent_yaml ;;
client) tm_render_client_yaml ;;
*) echo "unknown role: $role" >&2; exit 2 ;;
esac
- Step 2: 写失败测试 —— Go 配置漂移测试
创建 deploy/install/oneclick/oneclick_config_test.go:
package oneclick
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/tunnelmesh/tunnelmesh/internal/config"
)
// renderYAML 用测试驱动脚本渲染角色配置,返回文件路径。
// 这是防止「脚本渲染的键名与 internal/config 漂移」的权威手段:
// 渲染结果必须能被真实的 config.Load 解析,且字段值与输入一致。
func renderYAML(t *testing.T, role string, args ...string) string {
t.Helper()
bash, err := exec.LookPath("bash")
if err != nil {
t.Skipf("bash not available: %v", err)
}
cmd := exec.Command(bash, filepath.Join("testdata", "render_yaml.sh"), role)
cmd.Args = append(cmd.Args, args...)
cmd.Env = append(os.Environ(),
"TM_ONECLICK_LIB=tunnelmesh-install-common.sh",
"TM_ONECLICK_ALLOW_STDIN=1",
// token 只能来自环境变量;渲染 YAML 时不需要,但 config.Load 的
// agent/client 校验会用到,因此显式注入测试值。
"TUNNELMESH_AGENT_TOKEN=test-agent-token",
"TUNNELMESH_CLIENT_TOKEN=test-client-token",
)
out, err := cmd.Output()
if err != nil {
t.Fatalf("render %s yaml: %v", role, err)
}
path := filepath.Join(t.TempDir(), role+".yaml")
if err := os.WriteFile(path, out, 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
return path
}
func loadConfig(t *testing.T, path string) config.Config {
t.Helper()
cfg, err := config.Load(context.Background(), config.ConfigOptions{ConfigFile: path})
if err != nil {
t.Fatalf("config.Load(%s): %v", path, err)
}
return cfg
}
func TestServerLocalSQLiteConfigRenders(t *testing.T) {
path := renderYAML(t, "server",
"mode=local", "http_addr=127.0.0.1:8080", "dynamic_suffix=apps.example.com",
"storage_driver=sqlite", "sqlite_path=/var/tmp/tm/tunnelmesh.db", "auto_init=yes",
"registry_type=database", "webssh_enabled=yes", "proxy_entry_enabled=no", "tls_enabled=no")
cfg := loadConfig(t, path)
if cfg.Mode != "local" {
t.Errorf("mode = %q, want local", cfg.Mode)
}
if cfg.Storage.Driver != "sqlite" || !cfg.Storage.AutoInit {
t.Errorf("storage = %+v, want sqlite + auto_init", cfg.Storage)
}
if cfg.Storage.SQLite.Path != "/var/tmp/tm/tunnelmesh.db" {
t.Errorf("sqlite path = %q", cfg.Storage.SQLite.Path)
}
if cfg.Server.HTTPAddr != "127.0.0.1:8080" || cfg.Server.DynamicSuffix != "apps.example.com" {
t.Errorf("server = %+v", cfg.Server)
}
if !cfg.Server.WebSSH.Enabled || cfg.Server.ProxyEntry.Enabled || cfg.TLS.Enabled {
t.Errorf("feature flags wrong: webssh=%v proxy_entry=%v tls=%v",
cfg.Server.WebSSH.Enabled, cfg.Server.ProxyEntry.Enabled, cfg.TLS.Enabled)
}
if cfg.Registry.Type != "database" {
t.Errorf("registry = %q", cfg.Registry.Type)
}
}
func TestServerClusterMySQLRelayConfigRenders(t *testing.T) {
path := renderYAML(t, "server",
"mode=cluster", "http_addr=127.0.0.1:8080", "storage_driver=mysql", "auto_init=yes",
"mysql_tls=no", "registry_type=database", "relay_enabled=yes",
"relay_listen=0.0.0.0:9443", "relay_endpoint=server-1.internal.example.com:9443",
"allowed_hosts=tunnel.example.com", "allowed_origins=https://tunnel.example.com",
"env=TUNNELMESH_STORAGE_MYSQL_DSN=user:pw@tcp(db:3306)/tunnelmesh?parseTime=true",
"env=TUNNELMESH_SERVER_RELAY_NODE_TOKEN=node-token")
cfg := loadConfig(t, path)
if cfg.Mode != "cluster" || cfg.Storage.Driver != "mysql" {
t.Errorf("mode/storage = %q/%q", cfg.Mode, cfg.Storage.Driver)
}
if !cfg.Server.Relay.Enabled || cfg.Server.Relay.Listen != "0.0.0.0:9443" {
t.Errorf("relay = %+v", cfg.Server.Relay)
}
if cfg.Server.Relay.Endpoint != "server-1.internal.example.com:9443" {
t.Errorf("relay endpoint = %q", cfg.Server.Relay.Endpoint)
}
if len(cfg.Security.AllowedHosts) != 1 || cfg.Security.AllowedHosts[0] != "tunnel.example.com" {
t.Errorf("allowed_hosts = %v", cfg.Security.AllowedHosts)
}
if len(cfg.Security.AllowedOrigins) != 1 {
t.Errorf("allowed_origins = %v", cfg.Security.AllowedOrigins)
}
}
func TestAgentConfigRenders(t *testing.T) {
path := renderYAML(t, "agent",
"server_url=wss://tunnel.example.com/ws/agent", "agent_id=agent-devbox",
"conn_min=1", "conn_max=2", "metadata_name=device_id", "metadata_source=file", "metadata_path=/etc/machine-id")
cfg := loadConfig(t, path)
if cfg.Agent.ServerURL != "wss://tunnel.example.com/ws/agent" || cfg.Agent.ID != "agent-devbox" {
t.Errorf("agent = %+v", cfg.Agent)
}
if cfg.Agent.Connections.Min != 1 || cfg.Agent.Connections.Max != 2 {
t.Errorf("connections = %+v", cfg.Agent.Connections)
}
if len(cfg.Agent.Metadata) != 1 || cfg.Agent.Metadata[0].Name != "device_id" || cfg.Agent.Metadata[0].Source != "file" {
t.Errorf("metadata = %+v", cfg.Agent.Metadata)
}
}
func TestClientConfigRendersAllProtocols(t *testing.T) {
path := renderYAML(t, "client",
"server_url=wss://tunnel.example.com/ws/client",
"tunnel=pg:tcp:127.0.0.1:15432:agent-db:db.internal:5432",
"tunnel=dns:udp:127.0.0.1:15353:agent-net:10.0.0.53:53",
"tunnel=web:http:127.0.0.1:18080:agent-web:127.0.0.1:8080",
"tunnel=socks:socks5:127.0.0.1:10866:agent-a::")
cfg := loadConfig(t, path)
if cfg.Client.ServerURL != "wss://tunnel.example.com/ws/client" {
t.Errorf("client server_url = %q", cfg.Client.ServerURL)
}
want := []struct{ name, protocol, listen, agent, host string; port int }{
{"pg", "tcp", "127.0.0.1:15432", "agent-db", "db.internal", 5432},
{"dns", "udp", "127.0.0.1:15353", "agent-net", "10.0.0.53", 53},
{"web", "http", "127.0.0.1:18080", "agent-web", "127.0.0.1", 8080},
{"socks", "socks5", "127.0.0.1:10866", "agent-a", "", 0},
}
if len(cfg.Client.Tunnels) != len(want) {
t.Fatalf("tunnels = %d, want %d (%+v)", len(cfg.Client.Tunnels), len(want), cfg.Client.Tunnels)
}
for i, w := range want {
got := cfg.Client.Tunnels[i]
if got.Name != w.name || got.Protocol != w.protocol || got.ListenAddr != w.listen ||
got.AgentID != w.agent || got.TargetHost != w.host || got.TargetPort != w.port {
t.Errorf("tunnel[%d] = %+v, want %+v", i, got, w)
}
}
}
func TestRenderedConfigNeverContainsSecrets(t *testing.T) {
for _, role := range []string{"server", "agent", "client"} {
args := []string{"server_url=wss://tunnel.example.com/ws/" + role}
if role == "server" {
args = append(args, "mode=local", "storage_driver=sqlite", "sqlite_path=/var/tmp/tm/x.db",
"env=TUNNELMESH_STORAGE_MYSQL_DSN=user:pw@tcp(db:3306)/tm")
}
path := renderYAML(t, role, args...)
body := readFile(t, path)
for _, bad := range []string{"pw@tcp", "token:", "password", "TUNNELMESH_AGENT_TOKEN", "TUNNELMESH_CLIENT_TOKEN"} {
if strings.Contains(body, bad) {
t.Errorf("%s yaml leaked %q:\n%s", role, bad, body)
}
}
}
}
oneclick_config_test.go的 import 是context、os、os/exec、path/filepath、strings、testing与github.com/tunnelmesh/tunnelmesh/internal/config(module 名取自go.mod);readFile复用同包oneclick_scripts_test.go里的 helper,不要重复定义。
- Step 3: 运行测试确认失败
Run: go test ./deploy/install/oneclick -run 'Config|Rendered' -count=1
Expected: FAIL —— render agent yaml: exit status 127(tm_render_agent_yaml: command not found)。
- Step 4: 写最小实现
# --- 角色 YAML 渲染:只输出用户显式回答过的键,空值一律不写,避免覆盖配置模型默认值 ---
tm_yaml_line() { # <indent> <key> <value>
[[ -z "$3" ]] && return 0
printf '%s%s: %s\n' "$1" "$2" "$(tm_yaml_quote "$3")"
}
tm_yaml_bool() { # <indent> <key> <yes|no>
case "$3" in yes) printf '%s%s: true\n' "$1" "$2" ;; no) printf '%s%s: false\n' "$1" "$2" ;; esac
}
tm_yaml_list() { # <indent> <key> <comma-separated-values>
local indent="$1" key="$2" csv="$3" item
[[ -z "$csv" ]] && return 0
printf '%s%s:\n' "$indent" "$key"
IFS=',' read -r -a items <<<"$csv"
for item in "${items[@]}"; do
item="$(printf '%s' "$item" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
[[ -n "$item" ]] && printf '%s - %s\n' "$indent" "$(tm_yaml_quote "$item")"
done
}
tm_render_server_yaml() {
local a=tm_ans_get
printf '# 由 TunnelMesh 一键安装脚本生成。敏感值不在此文件,见同目录 env / plist / 服务 XML。\n'
printf 'mode: %s\n\n' "$($a mode)"
printf 'storage:\n'
tm_yaml_line " " "driver" "$($a storage_driver)"
tm_yaml_bool " " "auto_init" "$($a auto_init)"
if [[ "$($a storage_driver)" == "sqlite" ]]; then
printf ' sqlite:\n'; tm_yaml_line " " "path" "$($a sqlite_path)"
else
printf ' mysql:\n'; tm_yaml_bool " " "tls" "$($a mysql_tls)"
printf ' # dsn 由 TUNNELMESH_STORAGE_MYSQL_DSN 注入,不要写进本文件。\n'
fi
printf '\nregistry:\n'; tm_yaml_line " " "type" "$($a registry_type)"
tm_yaml_list " " "endpoints" "$($a registry_endpoints)"
printf '\nserver:\n'
tm_yaml_line " " "http_addr" "$($a http_addr)"
tm_yaml_line " " "dynamic_suffix" "$($a dynamic_suffix)"
printf ' tcp_bridge:\n enabled: true\n'
printf ' webssh:\n'; tm_yaml_bool " " "enabled" "$($a webssh_enabled)"
if [[ "$($a proxy_entry_enabled)" == "yes" ]]; then
printf ' proxy_entry:\n enabled: true\n'
tm_yaml_line " " "listen" "$($a proxy_entry_listen)"
tm_yaml_line " " "domain_suffix" "$($a proxy_entry_domain_suffix)"
fi
if [[ "$($a relay_enabled)" == "yes" ]]; then
printf ' relay:\n enabled: true\n'
tm_yaml_line " " "listen" "$($a relay_listen)"
tm_yaml_line " " "endpoint" "$($a relay_endpoint)"
tm_yaml_line " " "ca" "$($a relay_ca)"
tm_yaml_line " " "cert" "$($a relay_cert)"
tm_yaml_line " " "key" "$($a relay_key)"
tm_yaml_line " " "server_name" "$($a relay_server_name)"
printf ' # node_token 由 TUNNELMESH_SERVER_RELAY_NODE_TOKEN 注入。\n'
fi
printf '\nsecurity:\n'
tm_yaml_list " " "allowed_hosts" "$($a allowed_hosts)"
tm_yaml_list " " "allowed_origins" "$($a allowed_origins)"
printf '\ntls:\n'; tm_yaml_bool " " "enabled" "$($a tls_enabled)"
printf '\ndownloads:\n'; tm_yaml_line " " "github_repository" "$TM_REPOSITORY"
}
tm_render_agent_yaml() {
local a=tm_ans_get
printf '# 由 TunnelMesh 一键安装脚本生成。agent.token 的 YAML 键被标记为不可序列化,\n'
printf '# 只能通过 TUNNELMESH_AGENT_TOKEN 注入(见同目录 agent.env / plist / 服务 XML)。\n'
printf 'mode: local\n\nagent:\n'
tm_yaml_line " " "server_url" "$($a server_url)"
tm_yaml_line " " "id" "$($a agent_id)"
tm_yaml_line " " "instance_id" "$($a instance_id)"
printf ' connections:\n'
printf ' min: %s\n max: %s\n' "$($a conn_min)" "$($a conn_max)"
if [[ -n "$($a metadata_name)" ]]; then
printf ' metadata:\n - name: %s\n source: %s\n' \
"$(tm_yaml_quote "$($a metadata_name)")" "$(tm_yaml_quote "$($a metadata_source)")"
tm_yaml_line " " "path" "$($a metadata_path)"
tm_yaml_line " " "key" "$($a metadata_key)"
fi
}
tm_render_client_yaml() {
local a=tm_ans_get spec name protocol listen agent host port
printf '# 由 TunnelMesh 一键安装脚本生成。client.token 只能通过 TUNNELMESH_CLIENT_TOKEN 注入。\n'
printf 'mode: local\n\nclient:\n'
tm_yaml_line " " "server_url" "$($a server_url)"
tm_yaml_line " " "instance_id_path" "$($a instance_id_path)"
if [[ ${#TM_TUNNEL_SPECS[@]} -gt 0 ]]; then
printf ' tunnels:\n'
for spec in "${TM_TUNNEL_SPECS[@]}"; do
IFS='|' read -r name protocol listen agent host port <<<"$(tm_parse_tunnel_spec "$spec")"
printf ' - name: %s\n protocol: %s\n listen: %s\n agent_id: %s\n' \
"$(tm_yaml_quote "$name")" "$(tm_yaml_quote "$protocol")" "$(tm_yaml_quote "$listen")" "$(tm_yaml_quote "$agent")"
if [[ -n "$host" ]]; then
printf ' target_host: %s\n target_port: %s\n' "$(tm_yaml_quote "$host")" "$port"
fi
done
fi
}
- Step 5: 运行测试确认通过
Run: go test ./deploy/install/oneclick -count=1
Expected: PASS —— 4 个 Test*ConfigRenders* + TestRenderedConfigNeverContainsSecrets 全绿;说明渲染出的键名与 internal/config 完全对齐。
- Step 6: Commit(需授权) —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
git add deploy/install/oneclick
git commit -m "feat(install): render role configurations guarded by config.Load"
Task 3: 参数解析、交互原语与 tty 语义
Files:
- Modify:
deploy/install/oneclick/tunnelmesh-install-common.sh - Modify:
deploy/install/oneclick/testdata/run_tests.sh
Interfaces:
- Consumes:
tm_die、tm_info、tm_warn、退出码常量、TM_TTY(Task 1)、TM_ONECLICK_YES(Task 1)、tm_validate_version(Task 2)、TM_DEFAULT_INSTALL_MODE。 - Produces:
tm_ans_key <key>→TM_ANS_<KEY>(./-映射为_,大写;非法字符集直接tm_die 2)tm_ans_set <key> <value>、tm_ans_get <key>(bash 3.2 下用printf -v+eval实现,不用关联数组)tm_read_line <prompt> <default>、tm_read_secret <prompt>(均优先$TM_TTY,为空则回退 stdin)tm_require_input_channel:TM_YES=1、TM_TTY非空、[[ -t 0 ]]、TM_ONECLICK_ALLOW_STDIN=1四者全不满足时tm_die 3tm_ask <key> <prompt> <default>、tm_ask_bool <key> <prompt> <yes|no>、tm_ask_choice <key> <prompt> <default> <choice...>、tm_ask_secret <key> <prompt>(两次输入必须一致,最多重试 3 次)tm_secret_value <key> <prompt> <env-var-name>:优先级--secret-env-file中的值 >--token-file(仅 token 类) > 环境变量 > 交互输入;--yes且四者皆空时tm_die 3并指出缺失项tm_load_secret_env_file <path>:权限必须是 0600/0400/0640,否则tm_die 3;逐行KEY=VALUE存入TM_SECRET_<KEY>tm_validate_ws_url <url> <required-suffix>:必须^wss?://且以 suffix 结尾,否则tm_die 2tm_parse_tunnel_spec <name:protocol:listen:agent_id:target_host:target_port>→ 打印name|protocol|listen|agent|host|port;protocol ∈ {tcp,udp,http,socks5};socks5允许 host/port 为空;端口 1–65535;非法tm_die 2tm_parse_common_args "$@":解析全部通用 flag,未知参数tm_die 2;把 flag 值写进tm_ans_set,使tm_ask不再重复提问tm_usage(通用 help,含五种调用形态示例;角色可用tm_role_usage_extra追加)- 全局:
TM_YES、TM_UNINSTALL、TM_NO_SERVICE、TM_NO_START、TM_NO_ENABLE、TM_NO_LINGER、TM_KEEP_CONFIG、TM_RECONFIGURE、TM_MODE、TM_TARGET_USER、TM_RUN_USER、TM_CREATE_RUN_USER、TM_BIN_DIR、TM_CONFIG_DIR、TM_STATE_DIR、TM_ARCHIVE、TM_TOKEN_FILE、TM_SECRET_ENV_FILE、TM_WITH_ALL_BINARIES(不存在,见规格 §7 步骤 4)
- Step 1: 写失败测试
追加到 testdata/run_tests.sh(printf '\n%s: %d failure(s)\n' 之前):
# --- Task 3: 参数解析与交互原语 ---
export TM_ONECLICK_ALLOW_STDIN=1
tm_ans_set "server.url" "wss://a/ws/agent"
assert_eq "ans/roundtrip-dotted-key" "wss://a/ws/agent" "$(tm_ans_get server.url)"
tm_ans_set "agent-id" "agent-1"
assert_eq "ans/roundtrip-dash-key" "agent-1" "$(tm_ans_get agent-id)"
assert_eq "ans/missing-empty" "" "$(tm_ans_get nope)"
assert_exit_sh 2 "ans/illegal-key" "tm_ans_set 'bad key' v"
# --yes:全部取默认值,不读 stdin
assert_eq "ask/yes-default" "8080" "$(TM_YES=1 tm_ask_http_port)"
# flag/env 已给值时不覆盖
assert_eq "ask/preset-wins" "preset" "$(TM_YES=1 bash -c 'source "$TM_ONECLICK_LIB"; tm_ans_set demo preset; tm_ask demo "prompt" "default"; tm_ans_get demo')"
# 非 --yes:从 stdin 读;空行取默认
assert_eq "ask/stdin-value" "typed" "$(printf 'typed\n' | TM_YES=0 TM_TTY= tm_ask_stdio demo "prompt" "default")"
assert_eq "ask/stdin-empty-uses-default" "default" "$(printf '\n' | TM_YES=0 TM_TTY= tm_ask_stdio demo "prompt" "default")"
assert_eq "ask/bool-normalize-Y" "yes" "$(printf 'Y\n' | TM_YES=0 TM_TTY= tm_ask_bool_stdio demo "prompt" "no")"
assert_eq "ask/bool-normalize-empty" "no" "$(printf '\n' | TM_YES=0 TM_TTY= tm_ask_bool_stdio demo "prompt" "no")"
assert_eq "ask/choice-invalid-then-valid" "udp" "$(printf 'quic\nudp\n' | TM_YES=0 TM_TTY= tm_ask_choice_stdio demo "prompt" "tcp" tcp udp http socks5)"
# 没有输入通道时必须失败,不能静默取默认值
assert_exit_sh 3 "ask/no-channel-dies" "TM_YES=0 TM_TTY= TM_ONECLICK_ALLOW_STDIN= </dev/null tm_require_input_channel"
# URL 与隧道规格校验
assert_exit_sh 0 "url/ok-agent" "tm_validate_ws_url wss://tunnel.example.com/ws/agent /ws/agent"
assert_exit_sh 2 "url/bad-scheme" "tm_validate_ws_url https://tunnel.example.com/ws/agent /ws/agent"
assert_exit_sh 2 "url/bad-suffix" "tm_validate_ws_url wss://tunnel.example.com/ws/client /ws/agent"
assert_eq "tunnel/tcp" "pg|tcp|127.0.0.1:15432|agent-db|db.internal|5432" \
"$(tm_parse_tunnel_spec 'pg:tcp:127.0.0.1:15432:agent-db:db.internal:5432')"
assert_eq "tunnel/socks5-no-target" "s|socks5|127.0.0.1:10866|agent-a||" \
"$(tm_parse_tunnel_spec 's:socks5:127.0.0.1:10866:agent-a::')"
assert_exit_sh 2 "tunnel/bad-protocol" "tm_parse_tunnel_spec 'pg:quic:127.0.0.1:1:agent-db:host:5432'"
assert_exit_sh 2 "tunnel/bad-port" "tm_parse_tunnel_spec 'pg:tcp:127.0.0.1:99999:agent-db:host:5432'"
assert_exit_sh 2 "tunnel/missing-fields" "tm_parse_tunnel_spec 'pg:tcp'"
# 参数解析
assert_eq "args/version" "v1.2.3" "$(tm_parse_common_args_stdout --print version --version v1.2.3)"
assert_eq "args/yes-flag" "1" "$(tm_parse_common_args_stdout --print yes --yes)"
assert_eq "args/yes-env" "1" "$(TM_ONECLICK_YES=1 tm_parse_common_args_stdout --print yes)"
assert_eq "args/mode" "system" "$(tm_parse_common_args_stdout --print mode --mode system)"
assert_eq "args/base-url" "https://mirror.example/releases" "$(tm_parse_common_args_stdout --print base-url --base-url https://mirror.example/releases)"
assert_exit_sh 2 "args/unknown" "tm_parse_common_args_stdout --print version --bogus"
assert_exit_sh 2 "args/bad-mode" "tm_parse_common_args_stdout --print mode --mode root"
assert_exit_sh 2 "args/missing-value" "tm_parse_common_args_stdout --print version --version"
# secret env file 权限
secretfile="$TM_TMP3/agent.env"; TM_TMP3="$(mktemp -d)"; mkdir -p "$TM_TMP3"
printf "TUNNELMESH_AGENT_TOKEN='abc'\n" >"$secretfile"; chmod 0644 "$secretfile"
assert_exit_sh 3 "secret-file/insecure-perm" "tm_load_secret_env_file '$secretfile'"
chmod 0600 "$secretfile"
assert_eq "secret-file/value" "abc" "$(tm_load_secret_env_file "$secretfile"; tm_secret_from_loaded TUNNELMESH_AGENT_TOKEN)"
rm -rf "$TM_TMP3"
测试里出现的
tm_ask_stdio/tm_ask_bool_stdio/tm_ask_choice_stdio/tm_ask_http_port/tm_parse_common_args_stdout是为可测性拆出的纯函数:tm_ask= 「已有答案 → 返回;--yes→ 默认值;否则tm_ask_stdio」,tm_parse_common_args= 解析并写全局,tm_parse_common_args_stdout= 解析后打印关键结果供断言。实现时按这个拆分写,测试才能不依赖全局状态。tm_ask_http_port只是tm_ask_stdio http_port "HTTP 监听端口" "8080"的语义化包装,供 server 角色使用。
- Step 2: 运行测试确认失败
Run: go test ./deploy/install/oneclick -run TestShellFunctionSuite -count=1
Expected: FAIL —— tm_ans_set: command not found,failure(s) 计数 > 0。
- Step 3: 写最小实现
# --- 答案存储(bash 3.2 无关联数组,用变量名映射)---
tm_ans_key() {
case "$1" in
*[!A-Za-z0-9._-]* | "") tm_die "$TM_EXIT_USAGE" "invalid answer key: '$1'" ;;
esac
local mapped
mapped="$(printf '%s' "$1" | tr 'a-z.-' 'A-Z__')"
printf 'TM_ANS_%s\n' "$mapped"
}
tm_ans_set() { local k; k="$(tm_ans_key "$1")"; printf -v "$k" '%s' "$2"; }
tm_ans_get() { local k; k="$(tm_ans_key "$1")"; eval "printf '%s' \"\${$k-}\""; }
# --- 输入通道 ---
tm_require_input_channel() {
[[ "${TM_YES:-0}" == "1" ]] && return 0
[[ -n "${TM_TTY:-}" ]] && return 0
[[ -t 0 ]] && return 0
[[ "${TM_ONECLICK_ALLOW_STDIN:-0}" == "1" ]] && return 0
tm_die "$TM_EXIT_PREFLIGHT" "no interactive input channel: run with --yes (or TM_ONECLICK_YES=1) plus explicit flags, or execute the script from a terminal"
}
tm_read_line() { # <prompt> <default>
local prompt="$1" default="$2" reply=""
tm_require_input_channel
printf '==> %s [%s]: ' "$prompt" "$default"
if [[ -n "$TM_TTY" ]]; then IFS= read -r reply <"$TM_TTY" || reply=""
else IFS= read -r reply || reply=""; fi
printf '%s' "${reply:-$default}"
}
# tm_read_secret:优先 /dev/tty 并用 read -s 关闭回显;回退 stdin 时无法关回显,
# 因此显式警告一次,避免 token 被 CI 日志录下来。
tm_read_secret() { # <prompt>
local prompt="$1" reply=""
tm_require_input_channel
if [[ -n "$TM_TTY" ]]; then
printf '==> %s(输入不回显): ' "$prompt"
IFS= read -rs reply <"$TM_TTY" || reply=""
printf '\n'
else
tm_warn "no tty available; secret input will be echoed by the piped stdin"
IFS= read -r reply || reply=""
fi
printf '%s' "$reply"
}
# --- 交互原语(纯函数 *_stdio 版本便于测试;对外版本叠加「已有答案/默认值」语义)---
tm_ask_stdio() { local key="$1" prompt="$2" default="$3"; tm_read_line "$prompt" "$default"; }
tm_ask() { # <key> <prompt> <default>
local current; current="$(tm_ans_get "$1")"
if [[ -n "$current" ]]; then return 0; fi
if [[ "${TM_YES:-0}" == "1" ]]; then tm_ans_set "$1" "$3"; return 0; fi
tm_ans_set "$1" "$(tm_ask_stdio "$1" "$2" "$3")"
}
tm_normalize_bool() {
case "$(printf '%s' "$1" | tr 'A-Z' 'a-z')" in
y | yes | true | 1 | on) printf 'yes\n' ;;
n | no | false | 0 | off | "") printf 'no\n' ;;
*) return 1 ;;
esac
}
tm_ask_bool_stdio() { # <key> <prompt> <yes|no>
local reply attempt
for attempt in 1 2 3; do
reply="$(tm_read_line "$2 (y/n)" "$3")"
if tm_normalize_bool "$reply" >/dev/null; then tm_normalize_bool "$reply"; return 0; fi
tm_warn "请输入 y 或 n"
done
tm_die "$TM_EXIT_USAGE" "invalid yes/no answer after 3 attempts"
}
tm_ask_bool() {
local current; current="$(tm_ans_get "$1")"
[[ -n "$current" ]] && return 0
if [[ "${TM_YES:-0}" == "1" ]]; then tm_ans_set "$1" "$3"; return 0; fi
tm_ans_set "$1" "$(tm_ask_bool_stdio "$1" "$2" "$3")"
}
tm_ask_choice_stdio() { # <key> <prompt> <default> <choice...>
local key="$1" prompt="$2" default="$3"; shift 3
local choices=("$@") reply attempt ok c
for attempt in 1 2 3; do
reply="$(tm_read_line "$prompt (${choices[*]})" "$default")"
ok=0
for c in "${choices[@]}"; do [[ "$reply" == "$c" ]] && ok=1 && break; done
if [[ "$ok" == "1" ]]; then printf '%s\n' "$reply"; return 0; fi
tm_warn "不在可选值内:${choices[*]}"
done
tm_die "$TM_EXIT_USAGE" "invalid choice after 3 attempts"
}
tm_ask_choice() {
local current; current="$(tm_ans_get "$1")"
[[ -n "$current" ]] && return 0
if [[ "${TM_YES:-0}" == "1" ]]; then tm_ans_set "$1" "$3"; return 0; fi
tm_ans_set "$1" "$(tm_ask_choice_stdio "$1" "$2" "$3" "${@:4}")"
}
tm_ask_secret() { # <key> <prompt>
local first second attempt
for attempt in 1 2 3; do
first="$(tm_read_secret "$2")"
second="$(tm_read_secret "$2(再次输入确认)")"
if [[ "$first" == "$second" && -n "$first" ]]; then tm_ans_set "$1" "$first"; return 0; fi
tm_warn "两次输入不一致或为空,请重试"
done
tm_die "$TM_EXIT_USAGE" "secret confirmation failed after 3 attempts"
}
# --- 敏感值:文件 > 环境变量 > 交互;--yes 下缺值直接失败 ---
TM_SECRET_ENV_FILE="${TM_SECRET_ENV_FILE:-}"
TM_TOKEN_FILE="${TM_TOKEN_FILE:-}"
tm_secret_loaded_get() { local k; k="$(tm_ans_key "secret.$1")"; eval "printf '%s' \"\${$k-}\""; }
tm_secret_loaded_set() { local k; k="$(tm_ans_key "secret.$1")"; printf -v "$k" '%s' "$2"; }
tm_load_secret_env_file() { # <path>
local path="$1" perms line key value
[[ -f "$path" ]] || tm_die "$TM_EXIT_USAGE" "secret env file not found: $path"
perms="$(tm_file_perms "$path")"
case "$perms" in
600 | 400 | 640) ;;
*) tm_die "$TM_EXIT_PREFLIGHT" "secret env file must be 0600/0400/0640, got 0${perms}: $path" ;;
esac
while IFS= read -r line || [[ -n "$line" ]]; do
case "$line" in '' | \#*) continue ;; esac
key="${line%%=*}"; value="${line#*=}"
value="${value%\'}"; value="${value#\'}"
tm_secret_loaded_set "$key" "$value"
done <"$path"
}
tm_secret_value() { # <key> <prompt> <env-var-name>
local key="$1" prompt="$2" envname="$3" value=""
value="$(tm_secret_loaded_get "$envname")"
if [[ -z "$value" && -n "$TM_TOKEN_FILE" && "$key" == *token* ]]; then
[[ -f "$TM_TOKEN_FILE" ]] || tm_die "$TM_EXIT_USAGE" "token file not found: $TM_TOKEN_FILE"
value="$(head -n 1 "$TM_TOKEN_FILE")"
fi
[[ -n "$value" ]] || value="${!envname:-}"
if [[ -z "$value" ]]; then
if [[ "${TM_YES:-0}" == "1" ]]; then
tm_die "$TM_EXIT_PREFLIGHT" "missing required secret for '${key}': set ${envname}, pass --token-file/--secret-env-file, or drop --yes to type it"
fi
tm_ask_secret "$key" "$prompt"
value="$(tm_ans_get "$key")"
fi
tm_ans_set "$key" "$value"
}
tm_file_perms在 Task 4 与tm_write_file一起实现(stat -f '%Lp'/stat -c '%a'双分支)。Task 3 的测试会用到它,因此实现顺序上把tm_file_perms放在 Task 3 里落地,Task 4 复用——实现时就在本任务加上:tm_file_perms() { # <path> → 三位八进制 if stat -c '%a' "$1" >/dev/null 2>&1; then stat -c '%a' "$1"; else stat -f '%Lp' "$1"; fi }
参数解析与校验:
TM_YES="${TM_ONECLICK_YES:-0}"
TM_UNINSTALL=0 TM_NO_SERVICE=0 TM_NO_START=0 TM_NO_ENABLE=0 TM_NO_LINGER=0
TM_KEEP_CONFIG=0 TM_RECONFIGURE=0 TM_CREATE_RUN_USER=0
TM_MODE="" TM_TARGET_USER="" TM_RUN_USER="" TM_BIN_DIR="" TM_CONFIG_DIR="" TM_STATE_DIR=""
TM_VERSION="latest" TM_ARCHIVE=""
tm_validate_ws_url() { # <url> <required-suffix>
local url="$1" suffix="$2"
[[ "$url" =~ ^wss?://[^[:space:]]+$ ]] || tm_die "$TM_EXIT_USAGE" "server URL must start with ws:// or wss://: '$url'"
[[ "$url" == *"$suffix" ]] || tm_die "$TM_EXIT_USAGE" "server URL must end with ${suffix}: '$url'"
}
tm_parse_tunnel_spec() { # <name:protocol:listen:agent_id:target_host:target_port>
local spec="$1" name protocol listen agent host port
IFS=':' read -r name protocol listen agent host port <<<"$spec"
[[ -n "$name" && -n "$protocol" && -n "$listen" && -n "$agent" ]] \
|| tm_die "$TM_EXIT_USAGE" "tunnel spec needs name:protocol:listen:agent_id[:target_host:target_port]: '$spec'"
case "$protocol" in tcp | udp | http | socks5) ;; *) tm_die "$TM_EXIT_USAGE" "unknown protocol: '$protocol' (tcp|udp|http|socks5)" ;; esac
[[ "$listen" =~ ^[^:]+:[0-9]+$ ]] || tm_die "$TM_EXIT_USAGE" "listen must be host:port: '$listen'"
tm_validate_port "${listen##*:}" "listen port"
if [[ "$protocol" != "socks5" ]]; then
[[ -n "$host" && -n "$port" ]] || tm_die "$TM_EXIT_USAGE" "protocol ${protocol} requires target_host and target_port"
tm_validate_port "$port" "target port"
else
host=""; port=""
fi
printf '%s|%s|%s|%s|%s|%s\n' "$name" "$protocol" "$listen" "$agent" "$host" "$port"
}
tm_validate_port() { # <port> <label>
[[ "$1" =~ ^[0-9]+$ ]] || tm_die "$TM_EXIT_USAGE" "$2 must be numeric: '$1'"
if [[ "$1" -lt 1 || "$1" -gt 65535 ]]; then tm_die "$TM_EXIT_USAGE" "$2 out of range 1-65535: '$1'"; fi
}
# tm_parse_common_args:解析通用 flag。角色入口先调用它,剩余的 "$@" 交给 tm_role_parse_args。
tm_parse_common_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--version) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --version"; TM_VERSION="$2"; tm_ans_set version "$2"; shift 2 ;;
--base-url) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --base-url"; TM_BASE_URL="$2"; shift 2 ;;
--raw-base-url) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --raw-base-url"; TM_RAW_BASE_URL="$2"; shift 2 ;;
--github-token) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --github-token"; TM_GITHUB_TOKEN="$2"; shift 2 ;;
--archive) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --archive"; TM_ARCHIVE="$2"; shift 2 ;;
--mode) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --mode"
case "$2" in user | system) TM_MODE="$2" ;; *) tm_die "$TM_EXIT_USAGE" "invalid --mode: '$2' (user|system)" ;; esac; shift 2 ;;
--user) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --user"; TM_TARGET_USER="$2"; shift 2 ;;
--run-user) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --run-user"; TM_RUN_USER="$2"; shift 2 ;;
--create-run-user) TM_CREATE_RUN_USER=1; shift ;;
--bin-dir) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --bin-dir"; TM_BIN_DIR="$2"; shift 2 ;;
--config-dir) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --config-dir"; TM_CONFIG_DIR="$2"; shift 2 ;;
--state-dir) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --state-dir"; TM_STATE_DIR="$2"; shift 2 ;;
--token-file) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --token-file"; TM_TOKEN_FILE="$2"; shift 2 ;;
--secret-env-file) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --secret-env-file"; TM_SECRET_ENV_FILE="$2"; shift 2 ;;
--server-url) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --server-url"; tm_ans_set server_url "$2"; shift 2 ;;
--agent-id) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --agent-id"; tm_ans_set agent_id "$2"; shift 2 ;;
--tunnel) [[ $# -ge 2 ]] || tm_die "$TM_EXIT_USAGE" "missing value for --tunnel"; TM_TUNNEL_SPECS+=("$2"); shift 2 ;;
--yes) TM_YES=1; shift ;;
--uninstall) TM_UNINSTALL=1; shift ;;
--no-service) TM_NO_SERVICE=1; shift ;;
--no-start) TM_NO_START=1; shift ;;
--no-enable) TM_NO_ENABLE=1; shift ;;
--no-linger) TM_NO_LINGER=1; shift ;;
--no-cache) TM_NO_CACHE=1; shift ;;
--keep-config) TM_KEEP_CONFIG=1; shift ;;
--reconfigure) TM_RECONFIGURE=1; shift ;;
-h | --help) tm_usage; exit "$TM_EXIT_OK" ;;
--) shift; break ;;
*) tm_die "$TM_EXIT_USAGE" "unknown argument: $1(--help 查看用法)" ;;
esac
done
TM_REMAINING_ARGS=("$@")
}
# tm_parse_common_args_stdout [--print <field>] <args...>:解析后打印指定字段,供测试断言,
# 避免测试直接依赖全局变量。
tm_parse_common_args_stdout() {
local field="version"
if [[ "${1:-}" == "--print" ]]; then field="$2"; shift 2; fi
tm_parse_common_args "$@"
case "$field" in
version) printf '%s\n' "$TM_VERSION" ;;
yes) printf '%s\n' "$TM_YES" ;;
mode) printf '%s\n' "$TM_MODE" ;;
base-url) printf '%s\n' "$TM_BASE_URL" ;;
*) tm_die "$TM_EXIT_USAGE" "unknown --print field: $field" ;;
esac
}
TM_REMAINING_ARGS与TM_TUNNEL_SPECS是普通索引数组,需在tm_parse_common_args之前用TM_REMAINING_ARGS=()、TM_TUNNEL_SPECS=()初始化(set -u下引用未初始化数组会报错)。
- Step 4: 运行测试确认通过
Run: go test ./deploy/install/oneclick -run TestShellFunctionSuite -count=1
Expected: PASS,新增 ok ans/*、ok ask/*、ok url/*、ok tunnel/*、ok args/*、ok secret-file/* 全部通过,末行 0 failure(s)。
- Step 5: Commit(需授权)
git add deploy/install/oneclick
git commit -m "feat(install): add interactive prompts and argument parsing to one-click installer"
Task 2: 版本解析、下载、SHA256 校验与缓存
Files:
- Modify:
deploy/install/oneclick/tunnelmesh-install-common.sh - Modify:
deploy/install/oneclick/testdata/run_tests.sh - Create:
deploy/install/oneclick/testdata/bin/curl(stub) - Create:
deploy/install/oneclick/testdata/fixtures/releases__latest(API JSON)、testdata/fixtures/noapi/.gitkeep - Modify:
deploy/install/oneclick/oneclick_scripts_test.go
Interfaces:
- Consumes: Task 1 的
tm_die、tm_have、tm_info、退出码常量、tm_detect_arch、TM_VERSION_PATTERN、TM_DEFAULT_BASE_URL、TM_LATEST_API_URL、全局TM_GOOS/TM_GOARCH/TM_ARCHIVE_EXT。 - Produces:
tm_fetch <url> <output-path|->:脚本内唯一的 curl 内容出口,固定--fail --silent --show-error --location --max-time ${TM_HTTP_TIMEOUT:-120};TM_GITHUB_TOKEN非空且 URL 以https://api.github.com/开头时追加Authorization: Bearer;curl 非零退出即tm_die 4。tm_fetch_head <url>:curl --silent --show-error --head --max-time 30 <url>,输出响应头。tm_checksum_file <path>:sha256sum优先、回退shasum -a 256,输出 64 位十六进制。tm_verify_checksum <file> <sums-file> <archive-name>:期望值取grep " <archive-name>$";缺条目或不匹配tm_die 5并打印 expected/actual。tm_archive_name <version>→tunnelmesh-<version>-<TM_GOOS>-<TM_GOARCH>.<TM_ARCHIVE_EXT>。tm_cache_dir→${TM_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/tunnelmesh/releases}。tm_cache_lookup <archive-name> <sums-file>:命中且校验通过 → 打印缓存路径并返回 0;命中但损坏 → 删除条目返回 1;未命中返回 1。tm_validate_version <version>、tm_resolve_version_stdout、tm_resolve_version(写回TM_VERSION)。tm_download_release <version>:填充TM_WORKDIR、TM_SUMS_PATH、TM_ARCHIVE_PATH,完成校验并按需写缓存。tm_extract_archive <archive> <dest-dir>、tm_load_lib(三级回退定位/下载共享库)。- 全局:
TM_BASE_URL、TM_RAW_BASE_URL、TM_NO_CACHE、TM_CACHE_DIR、TM_GITHUB_TOKEN、TM_HTTP_TIMEOUT。
- Step 1: 写失败测试 —— curl stub
创建可执行文件 deploy/install/oneclick/testdata/bin/curl:
#!/usr/bin/env bash
# curl stub:只支持共享库使用的调用形态。
# curl <flags...> <url> [--output <path>|<path>|-] 取内容
# curl --head <flags...> <url> 取响应头
# 环境变量:
# CURL_STUB_FIXTURES fixture 目录;按 URL 末两段 <parent>__<name> 查找,回退到 <name>
# CURL_STUB_LOG 追加记录每个被请求的 URL(断言「缓存命中不再下载」)
# CURL_STUB_REDIRECT --head 请求返回的 Location 值(模拟 /releases/latest 302)
set -uo pipefail
fixtures="${CURL_STUB_FIXTURES:?CURL_STUB_FIXTURES must be set}"
log="${CURL_STUB_LOG:-/dev/null}"
url="" out="" head=0
args=("$@") i=0
while [[ $i -lt ${#args[@]} ]]; do
a="${args[$i]}"
case "$a" in
--output|-o) i=$((i + 1)); out="${args[$i]}" ;;
--head|-I) head=1 ;;
-*) ;;
*) url="$a" ;;
esac
i=$((i + 1))
done
printf '%s\n' "$url" >>"$log"
if [[ "$head" -eq 1 ]]; then
if [[ -n "${CURL_STUB_REDIRECT:-}" ]]; then
printf 'HTTP/1.1 302 Found\r\nLocation: %s\r\n\r\n' "$CURL_STUB_REDIRECT"
exit 0
fi
exit 22
fi
name="${url##*/}"
parent="$(basename "$(dirname "$url")")"
src="${fixtures}/${parent}__${name}"
[[ -f "$src" ]] || src="${fixtures}/${name}"
if [[ ! -f "$src" ]]; then
printf 'curl: (22) The requested URL returned error: 404\n' >&2
exit 22
fi
if [[ -n "$out" && "$out" != "-" ]]; then cp "$src" "$out"; else cat "$src"; fi
创建 deploy/install/oneclick/testdata/fixtures/releases__latest:
{"tag_name":"v9.9.9","name":"TunnelMesh v9.9.9"}
创建空目录标记 deploy/install/oneclick/testdata/fixtures/noapi/.gitkeep(空文件)。该目录用于「API 不可用 → 回退重定向」的用例:把 CURL_STUB_FIXTURES 指向它,API 请求 404,tm_fetch_head 才生效。
- Step 2: 写失败测试 —— 追加到
testdata/run_tests.sh
在 printf '\n%s: %d failure(s)\n' 之前插入:
# --- Task 2: 下载、校验、缓存 ---
# fixture 归档按本机架构现场生成,仓库里不提交二进制。
TM_TMP="$(mktemp -d)"
FIXDIR="$TM_TMP/fixtures"
mkdir -p "$FIXDIR" "$TM_TMP/cache" "$TM_TMP/bin"
cp "$HERE/fixtures/releases__latest" "$FIXDIR/releases__latest"
TM_GOOS="$(uname -s | tr 'A-Z' 'a-z')"
TM_GOARCH="$(tm_detect_arch "$(uname -m)")"
TM_ARCHIVE_EXT="tar.gz"
archive_name="$(tm_archive_name v9.9.9)"
stage="$TM_TMP/stage"; mkdir -p "$stage"
for role in server agent client; do
printf '#!/bin/sh\necho stub-%s\n' "$role" >"$stage/tunnelmesh-$role"
chmod +x "$stage/tunnelmesh-$role"
done
tar -czf "$FIXDIR/v9.9.9__${archive_name}" -C "$stage" tunnelmesh-server tunnelmesh-agent tunnelmesh-client
sum="$(tm_checksum_file "$FIXDIR/v9.9.9__${archive_name}")"
printf '%s %s\n' "$sum" "$archive_name" >"$FIXDIR/v9.9.9__SHA256SUMS"
printf '%s %s\n' "$(printf '0%.0s' 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64)" "$archive_name" >"$TM_TMP/SHA256SUMS.bad"
export PATH="$HERE/bin:$PATH"
export CURL_STUB_FIXTURES="$FIXDIR" TM_CACHE_DIR="$TM_TMP/cache"
assert_eq "archive-name" "tunnelmesh-v9.9.9-${TM_GOOS}-${TM_GOARCH}.tar.gz" "$archive_name"
assert_exit_sh 0 "checksum/ok" "TM_GOOS=$TM_GOOS TM_GOARCH=$TM_GOARCH TM_ARCHIVE_EXT=tar.gz tm_verify_checksum '$FIXDIR/v9.9.9__${archive_name}' '$FIXDIR/v9.9.9__SHA256SUMS' '$archive_name'"
assert_exit_sh 5 "checksum/mismatch" "tm_verify_checksum '$FIXDIR/v9.9.9__${archive_name}' '$TM_TMP/SHA256SUMS.bad' '$archive_name'"
assert_exit_sh 5 "checksum/missing-entry" "tm_verify_checksum '$FIXDIR/v9.9.9__${archive_name}' '$FIXDIR/v9.9.9__SHA256SUMS' 'not-in-sums.tar.gz'"
assert_exit_sh 2 "version/invalid" "tm_validate_version 'v1.2'"
assert_exit_sh 2 "version/no-prefix" "tm_validate_version '1.2.3'"
# latest 解析:API 优先
export CURL_STUB_LOG="$TM_TMP/log-api"
assert_eq "version/api" "v9.9.9" "$(TM_VERSION=latest tm_resolve_version_stdout)"
assert_contains "version/api-url" "$(cat "$CURL_STUB_LOG")" "api.github.com/repos/nnworld/TunnelMesh/releases/latest"
# latest 解析:API 404 时回退到 /releases/latest 的 302 Location
CURL_STUB_FIXTURES="$HERE/fixtures/noapi" CURL_STUB_LOG="$TM_TMP/log-redir" \
CURL_STUB_REDIRECT="https://github.com/nnworld/TunnelMesh/releases/tag/v9.9.9" \
resolved="$(TM_VERSION=latest tm_resolve_version_stdout)"
export CURL_STUB_FIXTURES="$FIXDIR"
assert_eq "version/redirect-fallback" "v9.9.9" "$resolved"
assert_contains "version/redirect-url" "$(cat "$TM_TMP/log-redir")" "/releases/latest"
# 缓存:首次下载 → 命中不再下载 → 损坏自动重下
export CURL_STUB_LOG="$TM_TMP/log-dl1"
TM_NO_CACHE=0 tm_download_release v9.9.9
assert_eq "cache/file-created" "yes" "$([[ -s "$TM_TMP/cache/$archive_name" ]] && echo yes)"
CURL_STUB_LOG="$TM_TMP/log-dl2" TM_NO_CACHE=0 TM_WORKDIR="" tm_download_release v9.9.9
assert_eq "cache/hit-no-archive-download" "0" "$(grep -c "download/v9.9.9/${archive_name}\$" "$TM_TMP/log-dl2" || true)"
printf 'corrupted' >"$TM_TMP/cache/$archive_name"
CURL_STUB_LOG="$TM_TMP/log-dl3" TM_NO_CACHE=0 tm_download_release v9.9.9
assert_eq "cache/corrupt-recovered" "yes" "$(tm_verify_checksum "$TM_TMP/cache/$archive_name" "$FIXDIR/v9.9.9__SHA256SUMS" "$archive_name" >/dev/null 2>&1 && echo yes)"
assert_contains "cache/corrupt-redownloaded" "$(cat "$TM_TMP/log-dl3")" "$archive_name"
CURL_STUB_LOG="$TM_TMP/log-dl4" TM_NO_CACHE=1 tm_download_release v9.9.9
assert_contains "cache/bypassed" "$(cat "$TM_TMP/log-dl4")" "download/v9.9.9/${archive_name}"
# 解压
extract="$TM_TMP/extract"
tm_extract_archive "$TM_TMP/cache/$archive_name" "$extract"
assert_eq "extract/agent-binary" "yes" "$([[ -x "$extract/tunnelmesh-agent" ]] && echo yes)"
rm -rf "$TM_TMP"
tm_download_release内部会mktemp -d覆盖TM_WORKDIR,但缓存命中分支不清理临时目录——由tm_main的trap cleanup EXIT INT TERM统一清理。测试里连续调用三次不会互相干扰,因为断言只看TM_TMP/cache与CURL_STUB_LOG。
- Step 3: 运行测试确认失败
Run: go test ./deploy/install/oneclick -run TestShellFunctionSuite -count=1
Expected: FAIL —— run_tests.sh: tm_archive_name: command not found,末行非 0 failure(s),Go 测试 Fatalf 并打印套件输出。
- Step 4: 写最小实现
在 tunnelmesh-install-common.sh 末尾追加:
# --- 下载、校验、缓存 ---
TM_HTTP_TIMEOUT="${TM_HTTP_TIMEOUT:-120}"
TM_BASE_URL="${TUNNELMESH_RELEASE_BASE_URL:-$TM_DEFAULT_BASE_URL}"
TM_RAW_BASE_URL="${TUNNELMESH_RAW_BASE_URL:-$TM_DEFAULT_RAW_BASE_URL}"
TM_GITHUB_TOKEN="${TUNNELMESH_GITHUB_TOKEN:-${TM_GITHUB_TOKEN:-}}"
TM_NO_CACHE="${TM_NO_CACHE:-0}"
TM_CACHE_DIR="${TM_CACHE_DIR:-}"
TM_WORKDIR="" TM_ARCHIVE_PATH="" TM_SUMS_PATH="" TM_EXTRACT_DIR=""
# tm_fetch 是唯一的 curl 内容出口,测试用 PATH 前置的 stub 覆盖它。
tm_fetch() {
local url="$1" out="$2"
local args=(--fail --silent --show-error --location --max-time "$TM_HTTP_TIMEOUT")
if [[ -n "$TM_GITHUB_TOKEN" && "$url" == https://api.github.com/* ]]; then
args+=(--header "Authorization: Bearer ${TM_GITHUB_TOKEN}")
fi
if [[ "$out" == "-" ]]; then
curl "${args[@]}" "$url" || tm_die "$TM_EXIT_DOWNLOAD" "download failed: ${url}"
else
curl "${args[@]}" "$url" --output "$out" || tm_die "$TM_EXIT_DOWNLOAD" "download failed: ${url}"
fi
}
tm_fetch_head() {
curl --silent --show-error --head --max-time 30 "$1"
}
tm_checksum_file() {
if tm_have sha256sum; then sha256sum "$1" | awk '{print $1}'; else shasum -a 256 "$1" | awk '{print $1}'; fi
}
tm_verify_checksum() { # <file> <sums-file> <archive-name>
local file="$1" sums="$2" name="$3" line expected actual
line="$(grep " ${name}\$" "$sums" || true)"
[[ -n "$line" ]] || tm_die "$TM_EXIT_CHECKSUM" "checksum entry not found for ${name}"
expected="${line%%[[:space:]]*}"
actual="$(tm_checksum_file "$file")"
if [[ "$expected" != "$actual" ]]; then
tm_die "$TM_EXIT_CHECKSUM" "checksum mismatch for ${name} (expected ${expected}, actual ${actual})"
fi
}
tm_archive_name() { printf 'tunnelmesh-%s-%s-%s.%s\n' "$1" "$TM_GOOS" "$TM_GOARCH" "$TM_ARCHIVE_EXT"; }
tm_cache_dir() {
if [[ -n "$TM_CACHE_DIR" ]]; then printf '%s\n' "$TM_CACHE_DIR"
else printf '%s/tunnelmesh/releases\n' "${XDG_CACHE_HOME:-$HOME/.cache}"; fi
}
tm_validate_version() {
[[ "$1" =~ $TM_VERSION_PATTERN ]] \
|| tm_die "$TM_EXIT_USAGE" "invalid version: expected vMAJOR.MINOR.PATCH, got '$1'"
}
tm_resolve_version_stdout() {
local tag=""
tag="$(tm_fetch "$TM_LATEST_API_URL" - 2>/dev/null | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 || true)"
if [[ -z "$tag" ]]; then
# API 配额耗尽或不可达时回退:/releases/latest 的 302 Location 里带 tag,不消耗 API 配额。
tag="$(tm_fetch_head "${TM_BASE_URL}/latest" 2>/dev/null | sed -n 's#^[Ll]ocation:.*\/tag\/\(v[0-9][^[:space:]]*\).*#\1#p' | head -n 1 || true)"
fi
[[ -n "$tag" ]] || tm_die "$TM_EXIT_DOWNLOAD" "cannot resolve latest version; pass --version vX.Y.Z explicitly"
printf '%s\n' "$tag"
}
tm_resolve_version() {
if [[ "$TM_VERSION" == "latest" ]]; then
TM_VERSION="$(tm_resolve_version_stdout)"
tm_info "resolved latest version: ${TM_VERSION}"
fi
tm_validate_version "$TM_VERSION"
}
# tm_cache_lookup 依赖「命令替换子 shell 里 tm_die 的 exit 只结束子 shell」这一隔离,
# 因此调用方必须写成 if cached="$(tm_cache_lookup ...)"; then ...,不要改成直接赋值。
tm_cache_lookup() { # <archive-name> <sums-file>
local name="$1" sums="$2" path
path="$(tm_cache_dir)/${name}"
[[ -f "$path" ]] || return 1
if tm_verify_checksum "$path" "$sums" "$name" 2>/dev/null; then
printf '%s\n' "$path"; return 0
fi
rm -f -- "$path"
return 1
}
tm_download_release() { # <version>
local version="$1" name cached
name="$(tm_archive_name "$version")"
TM_WORKDIR="$(mktemp -d)"
TM_SUMS_PATH="${TM_WORKDIR}/SHA256SUMS"
tm_fetch "${TM_BASE_URL}/download/${version}/SHA256SUMS" "$TM_SUMS_PATH"
if [[ "$TM_NO_CACHE" != "1" ]] && cached="$(tm_cache_lookup "$name" "$TM_SUMS_PATH")"; then
tm_info "using cached archive ${cached}"
TM_ARCHIVE_PATH="$cached"
return 0
fi
TM_ARCHIVE_PATH="${TM_WORKDIR}/${name}"
tm_info "downloading ${name}"
tm_fetch "${TM_BASE_URL}/download/${version}/${name}" "$TM_ARCHIVE_PATH"
tm_verify_checksum "$TM_ARCHIVE_PATH" "$TM_SUMS_PATH" "$name"
if [[ "$TM_NO_CACHE" != "1" ]]; then
mkdir -p "$(tm_cache_dir)"
cp -f -- "$TM_ARCHIVE_PATH" "$(tm_cache_dir)/${name}"
fi
}
tm_extract_archive() { # <archive> <dest-dir>
mkdir -p "$2"
case "$1" in
*.tar.gz) tar -xzf "$1" -C "$2" ;;
*.zip) unzip -q "$1" -d "$2" ;;
*) tm_die "$TM_EXIT_USAGE" "unsupported archive type: $1" ;;
esac
}
# tm_load_lib:三级回退定位共享库(见设计规格 §5)。入口脚本在 source 之前调用它,
# 因此这里不能依赖库内函数——只用 POSIX 内建与 curl。
tm_load_lib() { # <entry-dir> <ref>
local dir="$1" ref="${2:-main}" candidate out
if [[ -n "${TM_ONECLICK_LIB:-}" && -f "$TM_ONECLICK_LIB" ]]; then
printf '%s\n' "$TM_ONECLICK_LIB"; return 0
fi
candidate="${dir}/tunnelmesh-install-common.sh"
if [[ -n "$dir" && -f "$candidate" ]]; then printf '%s\n' "$candidate"; return 0; fi
out="$(mktemp -d)/tunnelmesh-install-common.sh"
curl --fail --silent --show-error --location --max-time 60 \
"${TM_RAW_BASE_URL:-https://raw.githubusercontent.com/nnworld/TunnelMesh}/${ref}/deploy/install/oneclick/tunnelmesh-install-common.sh" \
--output "$out" || return 4
[[ -s "$out" ]] || return 4
[[ "$(head -n 1 "$out")" == '#!/usr/bin/env bash' ]] || return 4
printf '%s\n' "$out"
}
- Step 5: 运行测试确认通过
Run: go test ./deploy/install/oneclick -run TestShellFunctionSuite -count=1
Expected: PASS,输出含 ok checksum/ok、ok checksum/mismatch、ok version/api、ok version/redirect-fallback、ok cache/hit-no-archive-download、ok cache/corrupt-recovered、ok cache/bypassed、ok extract/agent-binary,末行 0 failure(s)。
- Step 6: 追加 Release 契约一致性测试
在 oneclick_scripts_test.go 追加:
// TestReleaseContractMatchesLegacyInstaller 保证一键安装库与 scripts/install.sh
// 共享同一份 Release 契约,避免归档命名或 SHA256SUMS 格式悄悄分叉。
func TestReleaseContractMatchesLegacyInstaller(t *testing.T) {
legacy := readFile(t, filepath.Join("..", "..", "..", "scripts", "install.sh"))
lib := readFile(t, commonLib)
for _, want := range []string{"nnworld/TunnelMesh", "/releases", "/download/", "SHA256SUMS", "releases/latest"} {
if !strings.Contains(legacy, want) {
t.Errorf("scripts/install.sh lost contract fragment %q", want)
}
if !strings.Contains(lib, want) {
t.Errorf("%s lost contract fragment %q", commonLib, want)
}
}
for _, want := range []string{`grep " ${name}\$"`, "sha256sum", "shasum -a 256", "tunnelmesh-%s-%s-%s.%s"} {
if !strings.Contains(lib, want) {
t.Errorf("%s is missing contract requirement %q", commonLib, want)
}
}
}
// TestCommonLibIsBash32Compatible 守护 macOS /bin/bash 3.2 兼容性:
// 用户会照抄 Homebrew 的 `/bin/bash -c "$(curl ...)"`,而 macOS 自带 bash 是 3.2.57。
func TestCommonLibIsBash32Compatible(t *testing.T) {
lib := readFile(t, commonLib)
forbidden := []string{"declare -A", "mapfile", "readarray", "|&", "&>>", "declare -n"}
for _, bad := range forbidden {
if strings.Contains(lib, bad) {
t.Errorf("%s uses bash 4+ feature %q; macOS /bin/bash is 3.2", commonLib, bad)
}
}
}
Run: go test ./deploy/install/oneclick -count=1
Expected: PASS(Task 1 的 4 个测试 + 本任务 2 个测试)。
- Step 7: Commit(需授权) —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
git add deploy/install/oneclick
git commit -m "feat(install): download, verify and cache releases in one-click installer"
Task 1: 共享库骨架、日志原语与平台探测
Files:
- Create:
deploy/install/oneclick/tunnelmesh-install-common.sh - Create:
deploy/install/oneclick/testdata/run_tests.sh - Create:
deploy/install/oneclick/oneclick_scripts_test.go
Interfaces:
- Consumes: 无(第一个任务)。
- Produces:
- 常量
TM_EXIT_OK=0 TM_EXIT_USAGE=2 TM_EXIT_PREFLIGHT=3 TM_EXIT_DOWNLOAD=4 TM_EXIT_CHECKSUM=5 TM_EXIT_CONFIG=6 TM_EXIT_SERVICE=7 TM_EXIT_UNINSTALL=8 - 常量
TM_REPOSITORY="nnworld/TunnelMesh"、TM_DEFAULT_BASE_URL、TM_DEFAULT_RAW_BASE_URL、TM_LATEST_API_URL、TM_DEFAULT_INSTALL_MODE="user" tm_die <exit-code> <message...>(写 stderr,前缀ERROR:,以给定码退出)tm_warn <message...>(stderr,前缀WARNING:)、tm_info <message...>(stdout,前缀==>)tm_have <cmd>(0/1)、tm_require_cmds <cmd...>(缺失时tm_die 3)-
tm_detect_platform:读取uname -s/uname -m,设置全局TM_OS_FAMILY(linuxdarwin)、 TM_GOOS、TM_GOARCH、TM_ARCHIVE_EXT(tar.gz);不支持时tm_die 2 tm_detect_arch <machine>:纯函数,x86_64→amd64、aarch64|arm64→arm64,其它输出空并非零退出tm_mask <secret>:长度 ≤4 输出****,否则前 4 位 +****;空串输出(empty)tm_tty_init:设置全局TM_TTY——/dev/tty可打开时为其路径,否则空串(调用方回退 stdin)。所有交互读取都必须走它,因为curl | bash时 stdin 是管道tm_entry_init <argv0> <bash-source0>:设置TM_ONECLICK_ENTRY(入口脚本真实路径)与TM_ONECLICK_DIR(其所在目录);bash -c/管道下两者都取不到真实路径时置空,由调用方走 raw 下载回退- 全局
TM_ONECLICK_YES(1等价于--yes)、TM_ONECLICK_REF(共享库的 git ref,默认main)
- 常量
- Step 1: 写失败测试 —— bash 函数套件骨架
创建 deploy/install/oneclick/testdata/run_tests.sh:
#!/usr/bin/env bash
# 函数级测试套件:由 oneclick_scripts_test.go 调用,也可手工执行。
# 只测纯函数与可注入依赖的函数,不联网、不注册服务。
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="${TM_ONECLICK_LIB:-$HERE/../tunnelmesh-install-common.sh}"
# shellcheck source=/dev/null
source "$LIB"
FAILURES=0
assert_eq() { # assert_eq <name> <expected> <actual>
if [[ "$2" == "$3" ]]; then
printf 'ok %s\n' "$1"
else
printf 'FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3"
FAILURES=$((FAILURES + 1))
fi
}
assert_contains() { # assert_contains <name> <haystack> <needle>
case "$2" in
*"$3"*) printf 'ok %s\n' "$1" ;;
*) printf 'FAIL %s\n missing: %s\n in: %s\n' "$1" "$3" "$2"; FAILURES=$((FAILURES + 1)) ;;
esac
}
assert_exit_sh() { # assert_exit_sh <expected-code> <name> <shell-snippet>
# tm_die 直接 exit,被测片段必须跑在子 shell 里才能断言退出码而不终止套件。
local want="$1" name="$2" snippet="$3" got=0
TM_ONECLICK_LIB="$LIB" bash -c "source \"\$TM_ONECLICK_LIB\"; $snippet" >/dev/null 2>&1 || got=$?
assert_eq "$name" "$want" "$got"
}
# --- Task 1: 平台探测与掩码 ---
assert_eq "arch/x86_64" "amd64" "$(tm_detect_arch x86_64)"
assert_eq "arch/aarch64" "arm64" "$(tm_detect_arch aarch64)"
assert_eq "arch/arm64" "arm64" "$(tm_detect_arch arm64)"
assert_eq "arch/unknown-empty" "" "$(tm_detect_arch riscv64 || true)"
assert_eq "mask/long" "abcd****" "$(tm_mask abcdefgh)"
assert_eq "mask/short" "****" "$(tm_mask abc)"
assert_eq "mask/empty" "(empty)" "$(tm_mask '')"
assert_eq "exit-codes" "0 2 3 4 5 6 7 8" \
"$TM_EXIT_OK $TM_EXIT_USAGE $TM_EXIT_PREFLIGHT $TM_EXIT_DOWNLOAD $TM_EXIT_CHECKSUM $TM_EXIT_CONFIG $TM_EXIT_SERVICE $TM_EXIT_UNINSTALL"
assert_eq "default-mode" "user" "$TM_DEFAULT_INSTALL_MODE"
tm_tty_init
assert_contains "tty/value" "/dev/tty " "${TM_TTY} "
tm_entry_init "bash" "main"
assert_eq "entry/bash-c-empty" "" "$TM_ONECLICK_ENTRY"
tm_entry_init "$HERE/fixtures/fake-entry.sh" "$HERE/fixtures/fake-entry.sh"
assert_eq "entry/real-path" "$HERE/fixtures/fake-entry.sh" "$TM_ONECLICK_ENTRY"
tm_detect_platform
assert_contains "platform/family" "linux darwin" "$TM_OS_FAMILY"
assert_eq "platform/ext" "tar.gz" "$TM_ARCHIVE_EXT"
printf '\n%s: %d failure(s)\n' "$0" "$FAILURES"
[[ "$FAILURES" -eq 0 ]]
同一 Step 内创建 tm_entry_init 断言用的 fixture:
mkdir -p deploy/install/oneclick/testdata/fixtures
printf '#!/usr/bin/env bash\nexit 0\n' >deploy/install/oneclick/testdata/fixtures/fake-entry.sh
chmod +x deploy/install/oneclick/testdata/fixtures/fake-entry.sh
- Step 2: 写失败测试 —— Go 契约测试骨架
创建 deploy/install/oneclick/oneclick_scripts_test.go:
package oneclick
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
// shellEntries 是三个角色的入口脚本;commonLib 是它们唯一的共享实现来源。
var shellEntries = []string{"install-server.sh", "install-agent.sh", "install-client.sh"}
const commonLib = "tunnelmesh-install-common.sh"
func bashPath(t *testing.T) string {
t.Helper()
p, err := exec.LookPath("bash")
if err != nil {
t.Skipf("bash not available: %v", err)
}
return p
}
func readFile(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return string(data)
}
func TestShellFilesExistAndAreExecutable(t *testing.T) {
for _, name := range append(append([]string{}, shellEntries...), commonLib) {
info, err := os.Stat(name)
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if info.Mode().Perm()&0o111 == 0 {
t.Errorf("%s is not executable (mode %v)", name, info.Mode())
}
}
}
func TestShellSyntax(t *testing.T) {
bash := bashPath(t)
names := append(append([]string{}, shellEntries...), commonLib, filepath.Join("testdata", "run_tests.sh"))
for _, name := range names {
cmd := exec.Command(bash, "-n", name)
if out, err := cmd.CombinedOutput(); err != nil {
t.Errorf("bash -n %s: %v\n%s", name, err, out)
}
}
}
func TestShellFunctionSuite(t *testing.T) {
bash := bashPath(t)
cmd := exec.Command(bash, filepath.Join("testdata", "run_tests.sh"))
cmd.Env = append(os.Environ(), "TM_ONECLICK_LIB="+commonLib)
out, err := cmd.CombinedOutput()
t.Logf("run_tests.sh output:\n%s", out)
if err != nil {
t.Fatalf("function suite failed: %v", err)
}
}
func TestShellScriptsUseStrictMode(t *testing.T) {
for _, name := range append(append([]string{}, shellEntries...), commonLib) {
body := readFile(t, name)
for _, want := range []string{"set -euo pipefail", "#!/usr/bin/env bash"} {
if !strings.Contains(body, want) {
t.Errorf("%s is missing %q", name, want)
}
}
}
}
// TestNoHardcodedSecrets 扫描脚本里的字面量秘密。键名允许出现,
// 但不允许出现「键 = 非空字面量」形式的赋值。
func TestNoHardcodedSecrets(t *testing.T) {
pattern := regexp.MustCompile(`(?i)(token|secret|password|passwd|dsn|private_key)[\"']?\s*[:=]\s*[\"']?[A-Za-z0-9+/_\-]{8,}`)
allow := regexp.MustCompile(`(?i)(token|secret|password|dsn)_?(file|env|key_id|s)\b`)
files := append(append([]string{}, shellEntries...), commonLib)
for _, name := range files {
for i, line := range strings.Split(readFile(t, name), "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") {
continue
}
if pattern.MatchString(line) && !allow.MatchString(line) {
t.Errorf("%s:%d looks like a hardcoded secret: %s", name, i+1, trimmed)
}
}
}
}
- Step 3: 运行测试确认失败
Run: go test ./deploy/install/oneclick -run 'TestShell' -count=1
Expected: FAIL —— TestShellFilesExistAndAreExecutable 报 stat tunnelmesh-install-common.sh: no such file or directory(库文件尚未创建)。
- Step 4: 写最小实现 —— 共享库骨架
创建 deploy/install/oneclick/tunnelmesh-install-common.sh:
#!/usr/bin/env bash
# TunnelMesh 一键安装共享库(Linux / macOS)。
# 三个角色入口 install-{server,agent,client}.sh 只做参数透传与角色钩子,
# 下载、校验、交互、渲染、服务注册全部集中在这里,避免三份实现漂移。
# 本文件不得包含任何真实 token、密码、DSN 或私钥。
set -euo pipefail
# --- 退出码契约(docs/deployment/oneclick-install.md 的排障表以此为准)---
TM_EXIT_OK=0
TM_EXIT_USAGE=2
TM_EXIT_PREFLIGHT=3
TM_EXIT_DOWNLOAD=4
TM_EXIT_CHECKSUM=5
TM_EXIT_CONFIG=6
TM_EXIT_SERVICE=7
TM_EXIT_UNINSTALL=8
TM_REPOSITORY="nnworld/TunnelMesh"
TM_DEFAULT_BASE_URL="https://github.com/${TM_REPOSITORY}/releases"
TM_DEFAULT_RAW_BASE_URL="https://raw.githubusercontent.com/${TM_REPOSITORY}"
TM_LATEST_API_URL="https://api.github.com/repos/${TM_REPOSITORY}/releases/latest"
TM_LIB_RELATIVE_PATH="deploy/install/oneclick/tunnelmesh-install-common.sh"
# Linux 默认装到当前用户(systemd user unit);--mode system 才走 root + /etc/tunnelmesh。
TM_DEFAULT_INSTALL_MODE="user"
TM_VERSION_PATTERN='^v[0-9]+\.[0-9]+\.[0-9]+$'
# --- 日志原语:正常输出走 stdout,错误与警告走 stderr ---
tm_info() { printf '==> %s\n' "$*"; }
tm_warn() { printf 'WARNING: %s\n' "$*" >&2; }
tm_die() {
local code="$1"; shift
printf 'ERROR: %s\n' "$*" >&2
exit "$code"
}
tm_have() { command -v "$1" >/dev/null 2>&1; }
tm_require_cmds() {
local missing=()
local cmd
for cmd in "$@"; do
tm_have "$cmd" || missing+=("$cmd")
done
if [[ ${#missing[@]} -gt 0 ]]; then
tm_die "$TM_EXIT_PREFLIGHT" "missing required command(s): ${missing[*]}"
fi
}
# tm_detect_arch <uname -m 输出>:纯函数,便于测试;未知架构输出空并非零退出。
tm_detect_arch() {
case "$1" in
x86_64) printf 'amd64\n' ;;
aarch64 | arm64) printf 'arm64\n' ;;
*) return 1 ;;
esac
}
# tm_detect_platform:设置 TM_OS_FAMILY / TM_GOOS / TM_GOARCH / TM_ARCHIVE_EXT。
# 公网入口只有 HTTP/HTTPS/WebSocket,因此 Windows 归档是 zip,其余是 tar.gz。
tm_detect_platform() {
local kernel machine arch
kernel="$(uname -s)"
machine="$(uname -m)"
case "$kernel" in
Linux) TM_OS_FAMILY="linux"; TM_GOOS="linux"; TM_ARCHIVE_EXT="tar.gz" ;;
Darwin) TM_OS_FAMILY="darwin"; TM_GOOS="darwin"; TM_ARCHIVE_EXT="tar.gz" ;;
*) tm_die "$TM_EXIT_USAGE" "unsupported operating system: ${kernel} (Windows 请使用 install-<role>.ps1)" ;;
esac
arch="$(tm_detect_arch "$machine")" || tm_die "$TM_EXIT_USAGE" "unsupported architecture: ${machine}"
TM_GOARCH="$arch"
}
# tm_tty_init:交互提示统一从 /dev/tty 读,使 `curl | bash`(stdin 是管道)也能问答。
# 无 tty 时 TM_TTY 为空串,调用方回退 stdin;若同时没有 --yes,tm_ask 会以退出码 3 失败,
# 绝不静默地把所有答案取成默认值。
tm_tty_init() {
if [[ -r /dev/tty && -w /dev/tty ]] && : >/dev/tty 2>/dev/null; then
TM_TTY="/dev/tty"
else
TM_TTY=""
fi
}
# tm_entry_init <argv0> <BASH_SOURCE[0]>:解析入口脚本真实路径。
# `bash -c "$(curl ...)"` 下 $0 是 bash、BASH_SOURCE 是 main,两者都不是路径,
# 因此这里只接受「存在的普通文件」,否则留空让共享库走 raw 下载回退。
tm_entry_init() {
local candidate
TM_ONECLICK_ENTRY="" TM_ONECLICK_DIR=""
for candidate in "$2" "$1"; do
if [[ -n "$candidate" && -f "$candidate" ]]; then
TM_ONECLICK_ENTRY="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")"
TM_ONECLICK_DIR="$(dirname "$TM_ONECLICK_ENTRY")"
return 0
fi
done
return 0
}
TM_ONECLICK_YES="${TM_ONECLICK_YES:-0}"
TM_ONECLICK_REF="${TM_ONECLICK_REF:-main}"
# tm_mask <secret>:摘要与日志里只显示前 4 位。
tm_mask() {
local value="$1"
if [[ -z "$value" ]]; then
printf '(empty)\n'
elif [[ ${#value} -le 4 ]]; then
printf '****\n'
else
printf '%s****\n' "${value:0:4}"
fi
}
- Step 5: 运行测试确认通过
Run: go test ./deploy/install/oneclick -run 'TestShell|TestNoHardcoded' -count=1
Expected: PASS(4 个测试全绿;run_tests.sh 输出 12 行 ok)。
- Step 6: 既有测试不回归
Run: go test ./deploy/... ./scripts/... -count=1
Expected: PASS(deploy/install/install_templates_test.go 与 scripts/install_script_test.go 不受影响)。
- Step 7: Commit(需授权) —— 已按用户授权合并为收尾单次提交,提交信息见 PR 记录
git add deploy/install/oneclick/tunnelmesh-install-common.sh deploy/install/oneclick/oneclick_scripts_test.go deploy/install/oneclick/testdata/run_tests.sh
git commit -m "feat(install): scaffold one-click installer library"