三端一键安装脚本设计
三端一键安装脚本设计
1. 背景与目标
当前安装路径分散在三处,且都需要用户先读懂文档再手工拼装:
scripts/install.sh:下载 Release、校验SHA256SUMS、安装三个二进制。不注册服务、不生成配置,且只支持 Linux/macOS。deploy/install/linux-install.sh、macos-install.sh、windows-install.ps1:注册服务,但要求调用方事先准备好配置文件,全部参数靠位置参数或 flag 传入。docs/deployment/*.md:手工步骤。
目标是提供每个角色一条命令的一键安装入口,交互式完成「选版本 → 下载校验 → 选安装模式与用户 → 生成配置 → 注册服务 → 启动 → 验证 → 输出后续步骤」,同时保留完全非交互的调用方式供 CI 与批量部署使用。
成功标准:一个从未读过本仓库文档的用户,在 Linux/macOS/Windows 上任一平台,用一条命令完成 agent 或 client 的安装并能连上 Server;server 安装完成后能拿到首个管理员凭据并打开后台。
2. 用户已确认的决策
| 编号 | 决策 | 选择 |
|---|---|---|
| 1 | Windows 覆盖方式 | A:3 个 .sh(Linux+macOS)+ 3 个配套 .ps1(Windows,含 WinSW 获取与校验) |
| 2 | Linux 服务托管模式 | A(由 C 修订):默认 user 模式——装到当前用户的 systemd user unit;--mode system 切系统模式(root + 专用运行账户 + /etc/tunnelmesh) |
| 3 | 「选择其它用户」语义 | C:两者都支持——user 模式可指定另一个普通用户;系统模式可指定服务运行账户 |
| 4 | 附加能力 | 全选:敏感值写独立 env 文件、--yes 非交互、--base-url 镜像源、重复运行=升级且提供 --uninstall |
默认安装到当前用户,与最初的需求描述一致。系统模式完整保留,通过 --mode system 选择;两种模式的路径差异见第 6 节,模式判定规则见第 10 节。
3. 范围
做:
- 三角色 × 三平台的一键安装、升级、卸载。
- 交互式配置生成(YAML)与敏感值注入(平台原生环境机制)。
- 服务注册:Linux systemd(user + system 两种)、macOS launchd LaunchAgent、Windows WinSW Service。
- 非交互模式、镜像源、离线归档安装、SHA256 校验、升级回滚。
- 配套文档与契约测试。
不做(YAGNI):
- 不做 Docker/Compose 一键安装(已有
docs/deployment/docker.md与两份 compose 文件)。 - 不做 macOS LaunchDaemon(root 级)模板;macOS 只有用户级 LaunchAgent,与现有
deploy/macos/tunnelmesh.plist及文档一致。 - 不做 Nginx/OpenResty/MySQL/etcd 的安装或初始化;server 集群模式只生成配置并校验连通性,依赖由运维准备。
- 不做
--purge(删除配置与数据);卸载一律保留配置和数据并打印路径,与现有脚本约定一致。 - 不做图形界面、不做多语言交互文案(中文交互 + 英文
--help,与仓库现状一致)。 - 不把一键脚本作为 Release 资产单独上传;脚本通过
raw.githubusercontent.com获取,且已随归档内的deploy/install/一起分发。
4. 交付物与目录布局
deploy/install/oneclick/
├── tunnelmesh-install-common.sh # Linux/macOS 共享库:平台探测、下载校验、交互、渲染、服务调度
├── install-server.sh # 入口(薄):定义角色差异 + 调用 tm_main
├── install-agent.sh
├── install-client.sh
├── tunnelmesh-install-common.ps1 # Windows 共享模块(dot-source)
├── install-server.ps1
├── install-agent.ps1
├── install-client.ps1
├── winsw-checksums.txt # WinSW 版本/架构 → SHA256 pin 与更新流程
├── oneclick_scripts_test.go # 契约测试(Go),不随归档发布
└── testdata/ # 测试 fixture 与 bash 函数级测试套件,不随归档发布
└── run_tests.sh # 由 oneclick_scripts_test.go 调用
deploy/systemd-user/
├── tunnelmesh-server.service # 新增:user 模式模板,占位符渲染
├── tunnelmesh-agent.service
└── tunnelmesh-client.service
设计约束:
- 入口脚本薄、共享库厚。下载、校验、平台探测、交互、渲染、服务注册全部在共享库;三个入口只声明角色差异(提示项、渲染函数、安装后验证)。避免三份 300 行脚本各自漂移(AGENTS.md DRY)。
- 模板方法模式:
tm_main固定 8 个阶段,角色通过tm_role_*钩子填充可变步骤。 testdata/与*_test.go不进发布归档:scripts/build-release.sh已删除*_test.go,需追加删除testdata/目录。winsw-checksums.txt与两份共享库必须进归档,因为归档内的一键脚本要能离线自洽运行。- 与
scripts/install.sh的关系:后者保留为「只装二进制、不注册服务」的轻量入口(README Quick start 现有引用,且被scripts/install_script_test.go守护),一键脚本不 source 它——它不在发布归档内,也没有交互与服务注册能力。两者共享同一份 Release 契约(归档命名tunnelmesh-<ver>-<goos>-<goarch>.<ext>与SHA256SUMS的两空格格式),该契约由oneclick_scripts_test.go与install_script_test.go双向断言,避免两处下载器悄悄分叉。这属于两处实现、契约测试兜底,未达 DRY 的三处抽象阈值。 - PowerShell 参数命名:
.ps1使用 PowerShell 惯例的-Version、-Yes、-Uninstall、-Mode、-User、-RunUser、-NoService、-NoStart、-NoEnable、-BaseUrl、-RawBaseUrl、-Archive、-TokenFile、-SecretEnvFile、-WinSW、-InstallDir、-ConfigDir、-StateDir、-ServerUrl、-AgentId、-Tunnel,语义与.sh的同名长参数一一对应。
4.1 对既有产物的修改
| 文件 | 修改 | 原因 |
|---|---|---|
deploy/systemd/tunnelmesh-agent.service |
增加 EnvironmentFile=-/etc/tunnelmesh/agent.env |
agent 单元当前没有 env 注入点,而 agent.token 的 YAML 键是 yaml:"-",只能走环境变量;server 已有同款可选 env 文件 |
deploy/macos/tunnelmesh.plist |
增加 __ENVIRONMENT__ 占位符(渲染为 EnvironmentVariables dict,无敏感值时渲染为空串) |
launchd 没有 EnvironmentFile,模板本身仍不含任何秘密 |
deploy/windows/tunnelmesh-service.xml |
增加 __ENV_BLOCK__ 占位符(渲染为若干 <env name= value=>,无敏感值时为空) |
WinSW 只能通过 XML 注入环境变量 |
deploy/install/macos-install.sh、windows-install.ps1 |
渲染新占位符为空 | 保持既有行为逐字节不变 |
deploy/install/install_templates_test.go |
覆盖新占位符与 deploy/systemd-user/ 三份模板 |
守护「每角色只有一份模板来源」 |
scripts/build-release.sh |
追加删除 stage/deploy/**/testdata |
测试 fixture 不发布 |
系统模式下若用户选择了非默认运行账户或非默认路径,一键脚本不修改 deploy/systemd/*.service,而是安装原单元 + 生成 drop-in:/etc/systemd/system/tunnelmesh-<role>.service.d/oneclick.conf,用 ExecStart=/ExecStartPre= 清空后重写的标准做法覆盖 User=、Group=、路径与 EnvironmentFile。默认值安装时不生成 drop-in,保证与今天的行为完全一致。
5. 获取与分发
# Linux / macOS(推荐先下载再审阅,再执行)
curl -fsSL https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh -o install-agent.sh
bash install-agent.sh
# 一行式
curl -fsSL https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.sh | bash -s -- --version v1.1.1
Windows:
irm https://raw.githubusercontent.com/nnworld/TunnelMesh/main/deploy/install/oneclick/install-agent.ps1 -OutFile install-agent.ps1
.\install-agent.ps1
5.1 支持的调用形态
一键脚本必须在下列五种调用方式下都能工作,差异集中在「$0/BASH_SOURCE 是否指向真实文件」与「stdin 是否为终端」两点:
| 形态 | 命令 | $0 |
stdin | 说明 |
|---|---|---|---|---|
| 下载后执行(推荐) | curl -fsSL <url> -o install-agent.sh && bash install-agent.sh |
真实路径 | 终端 | 可先审阅;共享库从同目录加载 |
| 管道执行 | curl -fsSL <url> \| bash |
bash |
管道 | 交互提示必须改读 /dev/tty |
| 管道 + 传参 | curl -fsSL <url> \| bash -s -- --version v1.1.1 |
同上 | 管道 | 参数写在 -- 之后 |
| 命令替换执行(Homebrew 风格) | /bin/bash -c "$(curl -fsSL <url>)" |
/bin/bash |
终端 | 交互式安装的首选一行命令;/bin/bash 在 macOS 上是 3.2.57,见下方兼容性要求 |
| 命令替换 + 传参 | /bin/bash -c "$(curl -fsSL <url>)" install-agent --yes --server-url wss://... |
install-agent |
终端 | 第一个参数被 bash 当作 $0 吃掉,必须写一个占位名;也可省略参数改用 TM_ONECLICK_YES=1 等环境变量 |
由此固定三条实现要求:
- 不得依赖
$0或BASH_SOURCE定位共享库。解析顺序见下,三级回退全部失败则退出码 4。 - 不得用
$0做自我重新执行(跨用户安装、sudo 降权场景需要 re-exec)。入口脚本启动时把自身真实路径写进TM_ONECLICK_ENTRY;bash -c/管道下取不到真实路径时,先把入口副本写入临时目录再以该副本 re-exec。 - 交互提示统一从
/dev/tty读取(见第 8 节),使curl \| bash与bash -c都能正常问答。 - 必须兼容 bash 3.2(macOS
/bin/bash至今是 3.2.57,而用户会照抄 Homebrew 那行/bin/bash -c)。禁用:关联数组declare -A、${var,,}/${var^^}、mapfile/readarray、&>>、|&、namerefdeclare -n。答案存储用tm_ans_set/tm_ans_get(printf -v+ 变量名映射),集合用普通索引数组。 --yes也可由环境变量触发:TM_ONECLICK_YES=1。命令替换形态下传参要先写一个占位$0,环境变量更省事,CI 里也更稳。
bash -c "$(curl ...)" 有一个必须写进文档的失效模式:curl 失败时命令替换结果是空串,bash 什么都不执行却返回 0,用户会以为装好了。因此文档把 curl -fsSL <url> -o /tmp/tm-install.sh && /bin/bash /tmp/tm-install.sh 作为推荐写法(失败可见、可先审阅),一行式写法紧随其后并标注这个风险;curl \| bash 同样需要先落盘或显式检查 curl 退出码。
PowerShell 对应形态:.\install-agent.ps1(推荐,先 irm -OutFile 落盘再审阅)、& ([scriptblock]::Create((irm <url>))) -Yes ...(等价于 bash -c,参数直接跟在后面,不需要占位名);不推荐 iex (irm <url>)——无法传参,且会把脚本展开到当前作用域。
共享库解析顺序(bash -c 与管道执行时 $0 不是真实路径,必须有回退):
TM_ONECLICK_LIB环境变量指定的路径;- 入口脚本同目录下的
tunnelmesh-install-common.sh(本地 clone 或解压归档后); - 从
raw基址下载:<raw-base>/<ref>/deploy/install/oneclick/tunnelmesh-install-common.sh,ref取TM_ONECLICK_REF(未设置则main)。
ref 刻意不跟随 --version:服务模板(deploy/systemd/、deploy/systemd-user/、deploy/macos/、deploy/windows/)一律从已校验的发布归档里取,天然与所装版本一致;只有共享库走 raw。若让共享库也跟 tag,反而会出现「main 的入口脚本 + v1.1.1 的库」这种错配。需要整体锁版本时用 TM_ONECLICK_REF=v1.1.1,并把入口 URL 里的 main 换成同一 tag。
下载得到的共享库必须满足:HTTP 成功、非空、首行是 #!/usr/bin/env bash,否则中止(退出码 4)。
版本解析顺序:
--version vX.Y.Z显式指定(格式校验^v[0-9]+\.[0-9]+\.[0-9]+$);- GitHub API
releases/latest的tag_name,可选--github-token/TUNNELMESH_GITHUB_TOKEN提高配额; - API 失败时回退到
GET <base>/latest的Location重定向头解析 tag(不受 API 配额限制); - 全部失败则报错并提示显式传
--version。
镜像与离线:
--base-url/TUNNELMESH_RELEASE_BASE_URL:替换https://github.com/nnworld/TunnelMesh/releases,用于内网镜像;--raw-base-url/TUNNELMESH_RAW_BASE_URL:替换https://raw.githubusercontent.com/nnworld/TunnelMesh;--archive <path>:跳过下载,直接用本地归档安装(气隙环境,也是测试的主要入口)。
归档与校验:沿用 scripts/install.sh 已验证的逻辑——同时下载归档与 SHA256SUMS,按 grep " <archive>$" 取期望值,sha256sum(缺失时 shasum -a 256)比对,不匹配立即中止(退出码 5)并打印期望/实际值。Windows 用 Get-FileHash -Algorithm SHA256 比对同一份 SHA256SUMS。
6. 平台矩阵与托管方式
| 平台 | 归档 | 默认托管 | 可选托管 | 提权要求 |
|---|---|---|---|---|
| Linux amd64/arm64 | .tar.gz |
systemd user 单元(当前用户):二进制 ~/.local/bin/,配置 ~/.config/tunnelmesh/,状态 ~/.local/share/tunnelmesh/,自动 loginctl enable-linger |
--mode system:systemd system 单元,运行账户 tunnelmesh(缺失则创建系统用户),配置 /etc/tunnelmesh/,状态 /var/lib/tunnelmesh* |
user 不需 root;system 需 root |
| macOS amd64/arm64 | .tar.gz |
launchd LaunchAgent(当前用户),配置 ~/.config/tunnelmesh/,状态 ~/.local/share/tunnelmesh/,日志 ~/Library/Logs/ |
--no-service:只装二进制与配置 |
不需要 root;以 root 调用且存在 SUDO_USER 时自动降权到该用户 |
| Windows amd64/arm64 | .zip |
WinSW 包装的 Windows 服务,安装目录 C:\Program Files\TunnelMesh,配置 C:\ProgramData\TunnelMesh\ |
-NoService:只装二进制与配置 |
必须管理员 PowerShell |
无 systemd 的环境(容器、WSL1、Alpine openrc):检测失败时不中止,退化为「安装二进制 + 生成配置」,打印等价的前台启动命令并以退出码 0 结束,同时在摘要里标注「未注册服务」。
7. 安装流程(8 个阶段)
tm_main 固定顺序,--yes 只是把每个 tm_ask* 换成默认值/传入值:
- preflight:解析参数 → 探测 OS/arch/归档后缀 → 检查必需命令(
curl tar awk sed grep install mktemp,systemd 侧另需systemctl)→ 判定 EUID 与SUDO_USER→ 检测既有安装(二进制--version输出、服务是否存在)→ 决定是「全新安装」「升级」还是「卸载」。 - resolve & fetch:解析版本 → 命中本地缓存或下载归档与
SHA256SUMS→ 校验 → 解压到mktemp -d,trap ... EXIT INT TERM清理。缓存目录 Linux/macOS 为${XDG_CACHE_HOME:-$HOME/.cache}/tunnelmesh/releases/,Windows 为$env:LOCALAPPDATA\TunnelMesh\releases\,按归档名存放;缓存命中后仍必须重新校验 SHA256,校验不通过就删除该缓存条目并重新下载。--no-cache绕过缓存,--archive绕过下载与缓存。缓存的意义是同一台机器先后跑install-agent.sh与install-client.sh时只下载一次。 - placement:解析安装模式(规则见第 10 节)、目标用户/运行账户、二进制目录、配置目录、状态目录;system 模式下按需创建系统用户与目录并设置属主。
- install binary:只安装本角色的二进制(
install-agent.sh只装tunnelmesh-agent,install-client.sh只装tunnelmesh-client);同机需要两个角色就跑两个脚本,共享缓存不会重复下载。install -m 0755写到同目录临时名再mv原子替换;已存在的旧二进制备份为<bin>.bak-<UTC时间戳>,只保留最近一份(写入前删除更早的备份)。 - configure:角色钩子
tm_role_prompts收集答案 →tm_role_render_config渲染 YAML 与 env 文件。目标文件已存在时直接覆盖,覆盖前自动备份为<path>.bak-<UTC时间戳>,最多保留最近 3 份;--keep-config跳过整个 configure 阶段沿用现有文件(第 6 阶段仍会校验它)。 - validate:
<binary> --config <yaml> check-config;server 追加init-node-id(在写单元前执行,保证 YAML 里node.id已回写)。失败即中止(退出码 6),不注册服务。 - register & start:渲染并安装 unit/plist/WinSW XML →
daemon-reload/bootstrap/install→ 按选择enable与start。 - verify & report:读取服务状态与最近日志 → server 跑
doctor、可选admin bootstrap;agent 提示tunnelmesh-agent id与后台绑定;client 跑status→ 打印结构化摘要(版本、路径、服务名、掩码后的敏感项、下一步命令、卸载命令)。
升级路径只跑 1、2、4、6、7、8:不重新生成配置,沿用磁盘上现有的 YAML 与 env 文件——这是「重复运行=升级、保留配置」的既定语义,避免升级冲掉手工调优过的参数;确实要重新走问答并覆盖时显式加 --reconfigure,覆盖同样先备份。第 6 或第 7 步失败时自动回滚——恢复 .bak-<ts> 二进制、重启服务、以退出码 7 报告失败原因。
8. 交互项与默认值
所有交互项都有确定默认值,--yes 下全部取默认或取显式传入值;缺必填项(如 agent token)时直接失败并指出缺失的 flag/env,不静默降级。
提示一律从 /dev/tty 读取:tm_ask* 内部统一 read -r ... <"$TM_TTY",TM_TTY 在启动时解析为 /dev/tty(可打开时),否则为空串并回退 stdin。原因是 curl | bash 与 bash install.sh < file 下 stdin 不是终端,直接 read 会立刻 EOF 并把全部答案取成默认值——那会让管道调用静默装出错误配置。没有可用 tty 且未指定 --yes 时以退出码 3 失败,并提示改用 --yes + 显式参数,绝不静默走默认值。隐藏输入(token、DSN、密码)用 read -rs 且同样走 TM_TTY,输入两次校验一致;PowerShell 侧用 Read-Host -AsSecureString(-Yes 下不读)。
8.1 通用
| 提示 | 默认 | flag / env |
|---|---|---|
| 使用最新版本 vX.Y.Z? | 是 | --version |
| 安装模式(仅 Linux) | user(判定规则见第 10 节) |
--mode user\|system |
| 目标用户(user 模式) | 当前用户;root 调用且存在 SUDO_USER 时取该用户 |
--user <name> |
| 运行账户(system 模式) | tunnelmesh,不存在时询问是否创建 |
--run-user <name>、--create-run-user |
| 二进制目录 | user ~/.local/bin;system /usr/local/bin;Windows C:\Program Files\TunnelMesh |
--bin-dir、-InstallDir |
| 配置目录 / 状态目录 | 见第 6 节 | --config-dir、--state-dir |
| 已有配置文件 | 覆盖(先备份,保留最近 3 份) | --keep-config、--reconfigure |
| 注册服务并立即启动 | 是 | --no-service、--no-start |
| 开机自启 | 是(user 模式含 linger) | --no-enable、--no-linger |
| 下载缓存 | 启用 | --no-cache |
| 镜像源 | GitHub | --base-url、--raw-base-url |
8.2 server
| 提示 | 默认 | 落地位置 |
|---|---|---|
| 运行模式 | local |
mode |
| HTTP 监听 | 127.0.0.1:8080 |
server.http_addr |
| 动态域名后缀 | apps.example.com |
server.dynamic_suffix |
| 存储驱动 | local→sqlite,cluster→mysql |
storage.driver |
| SQLite 路径 | <state>/tunnelmesh.db |
storage.sqlite.path |
| MySQL DSN | 无(隐藏输入,二次确认) | TUNNELMESH_STORAGE_MYSQL_DSN |
| MySQL TLS | false |
storage.mysql.tls |
| 自动建表/迁移 | true |
storage.auto_init |
| 注册发现 | database,cluster 可选 etcd(追问 endpoints) |
registry.type、registry.endpoints |
| 启用 relay | false;启用后追问 listen 0.0.0.0:9443、endpoint、mTLS 四件套路径 |
server.relay.*,node_token → TUNNELMESH_SERVER_RELAY_NODE_TOKEN |
| 启用 WebSSH | true |
server.webssh.enabled |
| 启用 tp-* 代理入口 | false;启用后追问 listen 与 domain_suffix |
server.proxy_entry.* |
| 原生 TLS | false(提示由 Nginx 终止) |
tls.* |
| Host / Origin 白名单 | 空(逗号分隔输入) | security.allowed_hosts、security.allowed_origins |
| 自动生成身份主密钥 | 是 | TUNNELMESH_TOKEN_ENCRYPTION_KEY(+ 集群时 TUNNELMESH_TOKEN_ENCRYPTION_KEY_ID、TUNNELMESH_TRACE_SIGNING_KEY) |
| 立即创建首个管理员 | 是 | 安装后执行 admin bootstrap,凭据只打印到终端 |
密钥自动生成优先用 openssl rand -base64 32,无 openssl 时读 /dev/urandom 并 base64;打印时只显示掩码。集群模式下额外提示:所有 Server 节点必须使用完全一致的主密钥与 trace 签名密钥,否则跨节点 SSO/traceroute 会间歇失败。
8.3 agent
| 提示 | 默认 | 落地位置 |
|---|---|---|
| Server URL | 无,必填,校验 ws:///wss:// 且以 /ws/agent 结尾 |
agent.server_url |
| Agent ID | agent-<hostname 规范化> |
agent.id |
| Agent token | 无,必填,隐藏输入 + 二次确认 | TUNNELMESH_AGENT_TOKEN |
| 连接池 min/max | 1/1 |
agent.connections.* |
| instance_id | 留空自动生成并持久化 | agent.instance_id(留空则不写该键) |
| 上报 metadata | 否;选是则逐项添加(name + source file\|env + path/key,最多 32 项) |
agent.metadata[] |
metadata 名称在交互期即校验字符集(字母数字与 . _ -)与敏感词(password/token/secret/private key/api key/credential/authorization/cookie/DSN),命中则拒绝该项并说明原因,避免装完才在启动日志里发现被 redact。
8.4 client
| 提示 | 默认 | 落地位置 |
|---|---|---|
| Server URL | 无,必填,校验以 /ws/client 结尾 |
client.server_url |
| Client token | 无,必填,隐藏输入 + 二次确认 | TUNNELMESH_CLIENT_TOKEN |
| instance_id / 路径 | 留空自动生成;路径按平台默认 | client.instance_id_path |
| 添加本地转发 | 否;选是则循环添加 0..n 条 | client.tunnels[] |
| └ 协议 | tcp,可选 udp/http/socks5 |
protocol |
| └ 本地监听 | 127.0.0.1:<下一个可用端口,从 15432 起递增> |
listen |
| └ Agent ID | 上一条的值 | agent_id |
| └ 目标 host/port | 无(socks5 跳过) |
target_host、target_port |
| └ 允许非 loopback | 否;选是则强制要求 auth_mode 非 none |
allow_remote、auth_mode、auth_url |
监听地址填非 loopback 时立即警告:必须同时配置认证,否则拒绝写入(与 docs/operations/client-configuration-examples.md 的安全约定一致)。
9. 配置渲染与敏感值注入
YAML 只包含非敏感键;agent.token 与 client.token 在配置模型里是 yaml:"-",根本无法写进 YAML,这从代码层面强制了「秘密走环境变量」的约定。
| 平台 | 非敏感配置 | 敏感值载体 | 权限 |
|---|---|---|---|
| Linux user | ~/.config/tunnelmesh/<role>.yaml |
~/.config/tunnelmesh/<role>.env(user 单元 EnvironmentFile=) |
均 0600 |
| Linux system | /etc/tunnelmesh/<role>.yaml |
/etc/tunnelmesh/<role>.env(systemd EnvironmentFile) |
YAML 0640 root:<group>;env 0640 root:<group>(沿用 client.env 现状) |
| macOS | ~/.config/tunnelmesh/<role>.yaml |
渲染进 LaunchAgent plist 的 EnvironmentVariables(0600,用户目录内) |
plist 0600 |
| Windows | C:\ProgramData\TunnelMesh\<role>.yaml |
渲染进 WinSW XML 的 <env> 元素 |
ACL 仅 Administrators + 服务账户,继承 ProgramData 保护 |
渲染规则:
- YAML 用行数组累加后一次写盘,标量统一单引号并对内嵌单引号做
'→''转义;空字符串键不输出(例如未启用 relay 时不写relay:段),避免与配置模型的默认值打架。 - env 文件为
KEY='value'形式,值内单引号按 POSIX 规则闭合转义;首行写「本文件由一键安装脚本生成,包含敏感值,请勿提交版本库」。 - macOS/Windows 的环境注入块由共享渲染函数产出,模板本身只有占位符,不含任何秘密(延续
deploy/README.md的模板约定)。 - 三处渲染函数的输出都要能被
config.Load成功解析(见第 15 节测试),杜绝键名漂移。
10. 服务注册细节
模式判定规则(Linux):--mode 显式指定时以其为准;未指定时按下列顺序判定,--yes 下同样适用且不提问:
- EUID ≠ 0 →
user; - EUID = 0 且
SUDO_USER存在且不是root→user,目标用户取SUDO_USER,并打印一行说明「检测到 sudo,将以身份做用户级安装;需要系统级请加 --mode system」; - EUID = 0 且没有
SUDO_USER(真实 root 登录、CI)→system; --mode system但 EUID ≠ 0 → 退出码 3,提示改用sudo。
Linux user(默认):渲染 deploy/systemd-user/tunnelmesh-<role>.service(占位符 __BINARY__、__CONFIG__、__ENV_FILE__、__STATE_DIR__)到 ~/.config/systemd/user/ → systemctl --user daemon-reload → enable --now → loginctl enable-linger <user>(否则注销后服务被杀,交互中说明并提供 --no-linger)。硬化项取用户单元可用子集:NoNewPrivileges、PrivateTmp、ProtectSystem=full、ReadWritePaths=__STATE_DIR__;不使用 ProtectHome=true(会隐藏自身配置目录)。
Linux system:安装 deploy/systemd/tunnelmesh-<role>.service 原文件 → 非默认项写 drop-in oneclick.conf → systemctl daemon-reload → enable/restart。server 的 init-node-id 由单元内 ExecStartPre=+ 以 root 执行,一键脚本在注册前也会先跑一次,确保 relay 证书 SAN 能与最终 node.id 对齐。
跨用户安装(决策 3):user 模式下 --user <name> 且当前是 root 时,脚本用 runuser -u <name> --(无 runuser 则 sudo -u <name> -H)重新执行自身并带上 TM_ONECLICK_REEXEC=1 防环;非 root 且目标用户不是当前用户时直接报错,提示改用 sudo。这样 $HOME、systemctl --user 的总线地址、文件属主全部自然正确,不需要逐条命令拼 XDG_RUNTIME_DIR。system 模式下的「其它用户」指服务运行账户,由 --run-user 指定并通过 drop-in 覆盖 User=/Group=。
macOS:渲染共享 plist 模板 → plutil -lint → launchctl bootout(忽略失败)→ bootstrap gui/$(id -u) → kickstart -k。
Windows:-WinSW <path> 指定本地包装器;未指定时按 winsw-checksums.txt 中 pin 的 SHA256 从官方 winsw/winsw Release 下载(amd64/arm64 统一使用 WinSW-x64.exe,arm64 靠 x64 仿真运行,文档注明),校验失败或无 pin 记录时中止并给出手动下载步骤。随后复用 deploy/windows/tunnelmesh-service.xml 渲染(新增 __ENV_BLOCK__)→ stop/uninstall(忽略失败)→ install → start。
winsw-checksums.txt 格式为 # 注释 + <version> <arch> <sha256> <asset-name>,文件头写明「只能在能访问 github.com Release 的机器上,下载后本地计算并追加」的更新流程。当前实现环境(macOS 沙箱)可达 api.github.com 但不可达 github.com Release 下载,因此首批 pin 值需在具备网络的机器上补录;补录前 Windows 一键安装会走「未 pin → 中止 + 手动步骤」这条已定义好的分支,不会出现静默信任。
11. 非交互模式与凭据传入
--yes(PowerShell -Yes)关闭全部交互。刻意不提供 --token <value>:命令行参数会进入 ps 输出与 shell 历史。敏感值只接受三种入口:
- 交互式隐藏输入(
read -rs+ 二次确认;PowerShellRead-Host -AsSecureString); - 环境变量:
TUNNELMESH_AGENT_TOKEN、TUNNELMESH_CLIENT_TOKEN、TUNNELMESH_STORAGE_MYSQL_DSN、TUNNELMESH_SERVER_RELAY_NODE_TOKEN、TUNNELMESH_TOKEN_ENCRYPTION_KEY、TUNNELMESH_TRACE_SIGNING_KEY; - 文件:
--token-file <path>、--secret-env-file <path>(KEY=VALUE行格式,读取后立即从内存丢弃,文件权限校验为 0600/0400,否则拒绝)。
其它非敏感参数都有对应 flag(--server-url、--agent-id、--mode、--http-addr、--dynamic-suffix、--storage-driver、--tunnel 可重复传入 name:protocol:listen:agent:host:port 等),因此完整无人值守安装可以只靠 flag + env 完成。摘要与日志中的敏感值一律 tm_mask(前 4 位 + ****)。
12. 升级、回滚、卸载
- 升级:重复执行同一脚本即升级。检测到已安装版本(
<binary> --version,格式<version> commit=<sha> built=<time>)后打印「当前 vX → 目标 vY」并要求确认;--yes下直接执行。停服务 → 原子替换二进制 →check-config→ 起服务;任一步失败自动恢复.bak-<ts>并重启,退出码 7。 - 降级:允许(
--version指定旧版),但 schema 已升级时 server 会自行拒绝启动;脚本在第 6 阶段把check-config的原始错误完整回显,并提示参阅docs/operations/schema-upgrades.md。不做自动降级数据库。 - 卸载:
--uninstall停服务 →disable→ 删除 unit/plist/drop-in/WinSW 服务 → 删除本脚本安装的二进制及其.bak-*备份;保留配置、env、数据目录与下载缓存,并逐条打印路径。Windows 复用windows-uninstall.ps1的语义。
13. 退出码与输出
| 码 | 含义 |
|---|---|
| 0 | 成功(含「无服务管理器,只装二进制」的退化成功) |
| 2 | 参数错误 / 版本格式非法 / 未知协议 |
| 3 | preflight 失败(缺命令、权限不足、目标用户不可用) |
| 4 | 下载失败(含共享库下载失败) |
| 5 | 校验失败(SHA256SUMS 缺条目或不匹配、WinSW pin 不匹配) |
| 6 | check-config / init-node-id / plutil -lint 失败 |
| 7 | 服务注册或启动失败(升级场景已完成回滚) |
| 8 | 卸载失败 |
所有输出走 stderr 的只有错误与警告;正常进度、提示与摘要走 stdout,便于 | tee 与 CI 日志采集。交互提示统一前缀 ==>,警告 WARNING:,错误 ERROR:。脚本不写自身日志文件,服务日志位置在摘要中给出(journalctl -u ... / journalctl --user -u ... / ~/Library/Logs/tunnelmesh-<role>.log / C:\Program Files\TunnelMesh\logs)。
14. 安全边界
- 脚本内不得出现任何真实 token、密码、DSN、私钥;契约测试用正则扫描
token|secret|password|dsn附近的字面量赋值,命中即失败。 - 归档必须通过
SHA256SUMS校验后才解压;SHA256SUMS与归档来自同一 Release 目录,走 HTTPS。文档明确写出curl | bash的信任模型与「先下载再审阅」的推荐做法。 - 生成的配置文件权限按第 9 节收紧;system 模式下 env 文件属组为运行账户所属组,user 模式下 0600。
- 不关闭任何既有安全校验:不生成
allow_remote: true且auth_mode: none的隧道;不建议用户把 Server 直接暴露公网(默认127.0.0.1监听 + 提示配 Nginx)。 - 不新增能力边界之外的东西:一键脚本不调用管理 API、不创建 service token(token 由用户从后台复制),不执行任何远程命令。
15. 测试策略(TDD)
先写失败测试,再写实现。四层:
- 契约测试(Go,始终运行) —
deploy/install/oneclick/oneclick_scripts_test.go:- 6 个入口 + 2 个共享库 +
winsw-checksums.txt存在且.sh可执行; - 每个
.sh含set -euo pipefail、trap、mktemp -d;每个入口引用共享库而不是自带下载逻辑(断言入口内不出现SHA256SUMS、releases/download等字样,强制 DRY); - 三角色 flag 面齐备(
--version、--yes、--uninstall、--base-url、--archive、--mode、--user、--run-user、--no-service、--token-file); - 敏感值处理断言:存在
read -rs、tm_mask,不存在--token形式的明文参数; - 密钥扫描:脚本内无硬编码秘密;
- 模板一致性:
deploy/systemd-user/三份模板含--config、run与四个占位符;macos/tunnelmesh.plist含__ENVIRONMENT__;windows/tunnelmesh-service.xml含__ENV_BLOCK__; bash -n语法检查所有.sh(bash 缺失时 skip 并打印原因)。
- 6 个入口 + 2 个共享库 +
- 函数级测试(bash 套件,由 Go 测试调用,始终运行) —
testdata/run_tests.sh:对纯函数做红/绿验证,包含tm_verify_checksum(fixture 造匹配与不匹配两种)、tm_yaml_quote、tm_render_env_file、tm_mask、tm_parse_tunnel_spec、tm_detect_platform的 arch 映射、tm_render_systemd_user_unit、tm_render_plist_environment、tm_render_winsw_env_block、tm_answer_default(--yes语义)、退出码映射、tm_resolve_install_mode(EUID/SUDO_USER四种组合与--mode显式值)、tm_cache_lookup(命中、校验失败回退重下、--no-cache)、tm_backup_and_overwrite(覆盖前备份、只保留最近 3 份、--keep-config跳过)。渲染类函数全部接受显式参数,不依赖uname,因此在 macOS 上也能覆盖 Linux 渲染路径。 - 配置漂移测试(Go,始终运行):用
--yes+ 环境变量驱动共享库渲染出四份 YAML(server local/sqlite、server cluster/mysql、agent、client 含 4 种协议隧道),写入t.TempDir(),再用config.Load解析并断言关键字段值。这是防止渲染键名与internal/config漂移的权威手段。 - E2E 冒烟(env-gated):
TM_ONECLICK_E2E=1时,go build出真实二进制 → 打成带SHA256SUMS的归档 fixture → 用--archive走完整流程 → 服务管理命令(systemctl/launchctl/loginctl)由testdata/bin/下的 stub 顶替并记录调用序列 → 断言二进制落位、YAML/env 内容与权限、单元/plist 渲染结果、调用序列、摘要内容;再跑一次同命令验证「升级」路径,最后--uninstall验证清理与「保留配置」。沿用仓库既有 env-gate 约定(对比TM_PROXY_E2E_NGINX=1)。
.ps1 无法在 macOS/Linux 执行,只做静态契约断言,并在 PR 描述里记录一次真实 Windows 手工验证结果(与现有 windows-install.ps1 的处理方式一致)。
实现环境的网络限制:本机可达 api.github.com,不可达 github.com Release 下载。因此真实下载路径(含 WinSW pin 值补录)需在具备外网的机器上手工验证一次并记入 PR;自动化测试全部走 --archive 与 PATH 中的 curl stub。
16. 文档与索引变更
- 新增
docs/deployment/oneclick-install.md:一条命令安装、三平台 × 三角色矩阵、交互项清单、非交互示例(含 CI)、升级/回滚/卸载、镜像源与离线安装、curl | bash信任模型、按退出码的排障表。 - 更新
docs/README.md(部署分类新增入口,避免孤儿文档)、deploy/README.md(目录表、模板约定新增占位符与 drop-in 规则、归档内容变化、校验命令)。 - 更新
docs/deployment/binary-release.md(归档内新增deploy/install/oneclick/与deploy/systemd-user/,testdata不发布)、linux-systemd.md(agent.env、user 模式、drop-in、linger)、macos-launchd.md(EnvironmentVariables渲染与 0600)、windows-service.md(WinSW 自动获取与校验、<env>块、一键脚本用法)。 - 更新根
README.md的 Quick start → Install:把一键安装作为首选路径(三角色三条命令 + Windows PowerShell 三条命令),scripts/install.sh降级为「只要二进制」的补充说明,并保留「先下载再审阅」的措辞。 - 新增 spec/plan/PR 记录后执行
python3 scripts/gen_doc_index.py重建四份索引。 - 全量验证:
go test ./... -count=1、go test -race ./...、go vet ./...、git diff --check;本次不涉及前端,无需npm验证。
17. 已知限制
- Windows arm64 使用 x64 版 WinSW(上游未发布 arm64 资产),依赖 x64 仿真。
- macOS 不提供 root 级 LaunchDaemon 托管;需要时按
docs/deployment/macos-launchd.md手工处理。 - Windows 没有「当前用户」托管形态:Windows 服务本质是机器级,一键脚本在 Windows 上始终以管理员注册服务(或
-NoService只装二进制与配置),不提供按用户的计划任务托管。 - 一键脚本不初始化 MySQL/etcd/Nginx,也不签发 relay mTLS 证书(继续用
scripts/gen-relay-certs.sh);server 集群模式只在check-config阶段暴露依赖问题。 winsw-checksums.txt的首批 pin 值依赖外网机器补录,补录前 Windows 走「中止 + 手动步骤」分支。