Commit 85b2d526 by jaysurplus@126.com

docs(iteration): 归档项目迭代 V0.4.2 - 路由模式与 TUI 完善

parent 4fa3be35
# Tailscale 路由管理器
`scripts/tailscale/tailscale-route-manager.sh` 用于在 QArm 目标 Linux 设备上安装 Tailscale,并将设备配置为以下局域网的 subnet router:
`scripts/tailscale/tailscale-route-manager.sh` 用于在 QArm 目标 Linux 设备上安装 Tailscale,并管理两种互斥的子网路由模式。默认和推荐使用 4via6,为每台 QArm 的重复 IPv4 维护地址提供唯一入口:
- `192.168.11.0/24`
- `192.168.22.0/24`
- `192.168.11.201/32`(维护端口 `5800`)
- `192.168.22.202/32`(维护端口 `5800`)
4via6 模式下,每台 QArm 必须配置唯一的 `Site ID`。端口不属于 subnet route;应在 Tailnet Policy 中只允许维护人员访问生成的 4via6 地址的 TCP `5800`。仅旧 IPv4 模式用于兼容历史部署,不需要 Site ID,但重复网段场景只能由一台 QArm 发布。
## 功能
- 检测、下载并安装 Tailscale。
- 检测 `tailscaled` 和登录状态,必要时运行 `tailscale up`。
- 提供独立的 Tailscale 登录和退出登录操作;登录时可选择网页认证或 OAuth 自动注册,退出登录前需要确认。
- 使用 OAuth Client 在线读取可分配标签,在 TUI 中输入设备名称并多选标签,自动注册新设备。
- 注册名称默认从 `/opt/armaster/data/hw.json` 读取 `sn` 并生成 `<sn>-` 前缀,文件不可用时回退到系统 hostname。
- 每台设备动态创建 10 分钟有效、单次使用、非临时且预批准的 Auth Key。
- 启用 IPv4 forwarding,并在系统支持时启用 IPv6 forwarding。
- 使用 `tailscale set --advertise-routes=...` 发布两个固定网段。
- 识别路由的“已启用”“部分启用”和“已禁用”状态。
- 禁用时只移除本脚本管理的两个网段,保留设备上其他 advertised routes。
- 支持“仅 4via6(推荐)”和“仅旧 IPv4”两种互斥模式;切换前必须先禁用当前路由。
- 4via6 模式保存本设备唯一的 `Site ID`,使用 `tailscale debug via` 为两个 IPv4 `/32` 生成路由。
- 旧 IPv4 模式直接发布 `192.168.11.0/24`、`192.168.22.0/24`。
- 使用 `tailscale set --advertise-routes=...` 发布当前模式的受管路由,并移除另一模式的受管路由。
- 识别路由的“未配置”“已启用”“部分启用”和“已禁用”状态。
- 禁用时只移除本脚本管理的 4via6 和旧版 IPv4 路由,保留设备上其他 advertised routes。
- 仅在 forwarding 由本脚本修改时恢复原始状态。
- 优先使用支持逐段着色的 `dialog`,其次使用 `whiptail`,否则使用普通终端菜单。
- 对安装、运行、连接和 Route 状态使用统一的状态颜色。
- 状态页显示当前设备在 Tailscale 网络中的名称。
- 主菜单提供独立的“网络检测”,完整显示 `tailscale netcheck` 的结果并返回主页面。
## TUI 导航规则
- 主页面的“退出”是 TUI 正常结束的唯一入口。
- 安装、登录、启用、禁用和状态查看等分支完成后统一返回主页面。
- 安装、Site ID 配置、登录、退出登录、启用、禁用和状态查看等分支完成后统一返回主页面。
- 在分支流程中选择“否”、取消、关闭提示框或操作失败时,也会返回主页面。
- 在 `whiptail` 或 `dialog` 主菜单按 Esc 不会退出脚本,而是重新显示主页面。
- 在“是否现在运行 `tailscale up`”中选择 No,会取消本次启用并直接返回主页面。
- `dialog`/`whiptail` 下选择菜单操作后会立即显示中文处理中界面,网络检测、状态读取和 OAuth 请求期间不会短暂露出底层终端。
- 网页登录和首次安装器执行需要展示认证 URL 或安装日志,因此这两类明确的交互仍会进入终端;结束后返回主页面。
- `install`、`enable`、`disable` 等命令行直接调用模式不进入 TUI 循环,仍通过退出码向调用方报告结果。
- 状态页显示脚本版本,可用于确认目标设备是否仍在运行旧副本。
......@@ -49,6 +59,7 @@ sudo apt-get install -y dialog
## 支持环境
- Linux 目标设备。
- Tailscale `1.102.3` 或更新版本;这是 4via6 subnet router 当前的最低安全版本。
- Bash、Python 3、`sysctl`、`sed`、`awk`、`grep`、`sort` 和 `paste`。
- 安装阶段需要 HTTPS 网络访问和 `curl`。
- 自动补装 `curl` 时支持 `apt-get`、`dnf`、`yum` 或 `zypper`。
......@@ -78,10 +89,15 @@ sudo tailscale-route-manager
```bash
sudo tailscale-route-manager install
sudo tailscale-route-manager route-mode 4via6
sudo tailscale-route-manager site-id 101
sudo tailscale-route-manager login
sudo tailscale-route-manager logout
sudo --preserve-env=TS_OAUTH_CLIENT_ID,TS_OAUTH_CLIENT_SECRET \
tailscale-route-manager register
sudo tailscale-route-manager enable
tailscale-route-manager status
tailscale-route-manager netcheck
sudo tailscale-route-manager disable
```
......@@ -123,6 +139,8 @@ OAuth 自动注册适用于批量部署新的、尚未连接到 Tailscale 的 QA
默认 Provisioner 标签是 `tag:qarm-provisioner`。如 Tailnet 使用其他名称,可额外设置 `TS_OAUTH_OWNER_TAG`,并把它加入 `sudo --preserve-env`。TUI 会读取完整 `tagOwners`,但只展示该 Provisioner 直接拥有、OAuth Client 实际可以分配的标签。
当可分配标签中存在 `tag:qarm` 时,脚本会把它作为基础设备标签自动选中,用户只需按需增加其他标签。普通终端直接回车表示仅使用 `tag:qarm`,输入 `0` 取消。可通过 `TS_DEFAULT_DEVICE_TAG` 在启动脚本前覆盖默认标签名称。
Provisioner 和设备标签建议全部使用小写,例如 `tag:qarm`。Auth Key 标签与 `tailscale up --advertise-tags` 必须完全一致,避免混合大小写被控制端拒绝。
设备名称输入框会优先读取 `/opt/armaster/data/hw.json` 的 `sn`,例如默认显示 `CA2EUP-20261020-001-`,用户在末尾补充名称即可。脚本只读该文件;文件缺失、JSON 无效、SN 不符合 DNS 标签字符规则或没有后缀空间时,默认值回退到系统 hostname。
......@@ -131,6 +149,16 @@ Provisioner 和设备标签建议全部使用小写,例如 `tag:qarm`。Auth K
OAuth 注册仅用于新设备。已连接设备不会被脚本自动重新认证,避免意外改变设备身份、标签或现有配置。未提供 OAuth 环境变量时,启用路由仍保留原有浏览器登录流程。
## 登录与退出登录
主菜单中的“登录 Tailscale”会提供两种方式:选择“网页登录”时使用 `tailscale login` 打开标准登录流程;选择“OAuth 自动注册”时复用设备名称和标签选择流程,无需点击登录链接。设备已经连接时不会重复登录。主菜单仍保留“OAuth 注册新设备”快捷入口。
logout 后可以使用任一种方式重新认证。OAuth 方式按新设备流程创建登录身份,旧设备记录可能仍保留在 Tailscale 管理后台,需要管理员按实际情况清理。
“退出登录”使用 `tailscale logout`,会立即断开设备并使当前登录凭据失效,下次登录需要重新认证。
登录和退出登录只管理 Tailscale 身份状态。退出登录不会自动移除 advertised routes,也不会恢复 IPv4/IPv6 forwarding;如需清理本脚本管理的子网路由和转发状态,应先选择“禁用子网路由”。
## 安装安全边界
脚本不会使用 `curl | sh`。它会:
......@@ -142,17 +170,56 @@ OAuth 注册仅用于新设备。已连接设备不会被脚本自动重新认
SHA-256 仅用于本次文件识别;由于官方页面未在此流程中提供固定校验值,它不能替代 TLS、来源审阅或软件包签名验证。需要严格供应链验证时,应改用与目标发行版匹配的 Tailscale 官方软件仓库安装步骤。
## 首次启用
## Site ID 与 4via6
同一 Tailnet 中,每台 QArm 的 Site ID 必须唯一,允许范围为 `1-65535`。脚本不会根据 SN 猜测编号,也不会连接中心服务检查冲突;管理员需要在批量部署清单中分配编号。
执行 `enable` 后,脚本会发布:
可以在 TUI 中选择“配置 4via6 Site ID”,也可以非交互执行:
```bash
sudo tailscale-route-manager site-id 101
```
配置保存在:
```text
192.168.11.0/24,192.168.22.0/24
/var/lib/tailscale-route-manager.site
```
首次发布通常还需要在 [Tailscale Machines](https://login.tailscale.com/admin/machines) 中找到当前设备并批准 advertised routes。若 tailnet 已配置相应 `autoApprovers`,路由可能自动获批。
如果当前 Site ID 的路由仍在发布,脚本会拒绝修改编号。必须先禁用子网路由,避免遗留旧编号对应的 4via6 route。
## 路由模式
目标设备必须能够直接访问这两个局域网。发布成功只表示设备向 tailnet 声明路由,不代表后台已经批准,也不代表局域网链路已验证。
脚本只允许选择一种模式,不会同时发布 4via6 与旧 IPv4 路由:
- `4via6`:显示为“仅 4via6(推荐)”,需要 Site ID,适合多台 QArm 存在相同局域网地址的场景。
- `legacy`:显示为“仅旧 IPv4”,发布 `192.168.11.0/24`、`192.168.22.0/24`,不需要 Site ID。同一 tailnet 中多个 QArm 存在重复网段时,只能选择其中一台发布这些路由。
可以在 TUI 中选择“选择路由模式”,也可以执行:
```bash
sudo tailscale-route-manager route-mode 4via6
sudo tailscale-route-manager route-mode legacy
```
切换模式前必须先执行 `disable`。如果当前模式的任一路由仍在发布,脚本会拒绝切换。启用新模式时会移除另一模式中本脚本已知的受管路由,同时保留其他 advertised routes。
未包含 `ROUTE_MODE` 的 `0.4.0` 配置文件会继续按 `4via6` 处理。配置文件 `/var/lib/tailscale-route-manager.site` 现在同时保存路由模式和可选的 Site ID。
## 首次启用
在 4via6 模式下,假设本设备 Site ID 为 `101`,执行 `enable` 时等价于分别生成:
```bash
tailscale debug via 101 192.168.11.201/32
tailscale debug via 101 192.168.22.202/32
```
脚本将当前模式的路由合并到现有 advertised routes,同时移除另一模式的受管路由。设备上的其他路由保持不变。
首次发布通常还需要在 [Tailscale Machines](https://login.tailscale.com/admin/machines) 中找到当前设备并批准生成的 IPv6 advertised routes。若 tailnet 已配置相应 `autoApprovers`,路由可能自动获批。
目标 QArm 必须能够直接访问两个 IPv4 维护地址。发布成功只表示设备向 tailnet 声明路由,不代表后台已经批准,也不代表 `5800` 维护页面可用。
## 客户端访问
......@@ -164,6 +231,15 @@ sudo tailscale set --accept-routes=true
Windows、macOS、iOS 和 Android 通常会自动接受已批准的 subnet routes。
启用 MagicDNS 后,可以使用 Tailscale 自动生成的 4via6 名称。Site ID 为 `101` 时,访问形式为:
```text
http://192-168-11-201-via-101:5800
http://192-168-22-202-via-101:5800
```
其他 QArm 使用不同 Site ID,因此可以由多个维护人员同时访问,不会被选到另一台设备的重复 IPv4 subnet。
Tailscale subnet router 默认使用 SNAT,因此多数场景不需要在局域网路由器上增加到 `100.64.0.0/10` 的返回路由。本脚本不修改 SNAT 默认值。
## 系统变更与恢复
......@@ -172,10 +248,11 @@ Tailscale subnet router 默认使用 SNAT,因此多数场景不需要在局域
- `/etc/sysctl.d/99-tailscale-routes.conf`
- `/var/lib/tailscale-route-manager.state`
- `/var/lib/tailscale-route-manager.site`
状态文件记录脚本首次修改前的 IPv4/IPv6 forwarding 状态。执行 `disable` 时:
1. 从当前 advertised routes 中移除本脚本管理的两个网段。
1. 从当前 advertised routes 中移除本设备已知 Site ID 对应的 4via6 routes,以及旧版管理的两个 IPv4 `/24`。
2. 保留其他 advertised routes。
3. 删除带有本脚本标记的 sysctl 文件。
4. 恢复记录的 forwarding 状态并删除状态文件。
......@@ -206,11 +283,14 @@ shellcheck scripts/tailscale/tailscale-route-manager.sh
# 目标设备只读检查
scripts/tailscale/tailscale-route-manager.sh status
scripts/tailscale/tailscale-route-manager.sh netcheck
# 启用后检查
sudo tailscale-route-manager route-mode 4via6
sudo tailscale-route-manager site-id 101
sudo tailscale-route-manager enable
tailscale status
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
```
最后从另一台已加入同一 tailnet 的设备访问两个网段中的目标地址,确认端到端路由可用。
最后从另一台已加入同一 tailnet 的设备分别访问两个 `via-<site-id>` MagicDNS 名称的 `5800` 端口,确认端到端路由可用。
......@@ -25,7 +25,8 @@
- 下载并审阅 Tailscale 官方安装器后执行安装。
- 检查 `tailscaled`、登录状态和当前 advertised routes。
- 发布或移除 `192.168.11.0/24`、`192.168.22.0/24`,同时保留设备上其他 advertised routes。
- 管理互斥的“仅 4via6”和“仅旧 IPv4”模式。4via6 模式保存全 Tailnet 唯一的 Site ID,为两个维护地址生成路由;旧 IPv4 模式用于历史兼容。
- 启用任一模式时移除另一模式的受管路由,同时保留设备上其他 advertised routes。
- 在启用子网路由时配置 IP forwarding,在禁用时恢复脚本执行前的状态。
- 通过 `whiptail`、`dialog` 或普通终端提供中文单出口 TUI。
- 从 `/opt/armaster/data/hw.json` 只读获取 `sn` 作为设备名默认前缀,收集用户补充名称和标签选择,并调用 helper 完成 OAuth 自动注册;配置缺失或无效时回退到系统 hostname。
......@@ -36,6 +37,7 @@
- `/etc/sysctl.d/99-tailscale-routes.conf`:仅在脚本需要开启 forwarding 时创建,包含管理标记。
- `/var/lib/tailscale-route-manager.state`:记录 IPv4/IPv6 forwarding 的原始状态,不保存凭证。
- `/var/lib/tailscale-route-manager.site`:保存路由模式和可选的 Site ID,带管理标记且不保存凭证;旧配置缺少模式字段时默认为 4via6。
- OAuth Secret、Access Token 和 Auth Key:只在进程内或权限为 `0600` 的短期文件中存在,不属于持久状态。
主要执行流:
......@@ -44,13 +46,17 @@
主页面
-> 安装/检查 Tailscale
-> OAuth 注册(设备名称 + 在线标签多选)或浏览器登录
-> 选择互斥路由模式
-> 4via6:配置唯一 Site ID 并生成两个 IPv4 /32 对应的 routes
-> 旧 IPv4:使用两个历史 IPv4 /24 routes
-> 读取并合并 advertised routes
-> 移除非当前模式的受管 routes
-> 记录并开启 forwarding
-> 发布受管子网
-> 发布当前模式的受管 routes
-> 返回主页面
禁用受管路由
-> 从当前路由集合移除受管子网
-> 从当前路由集合移除受管 4via6 和旧版 IPv4 routes
-> 保留其他 advertised routes
-> 恢复 forwarding 原状态
-> 返回主页面
......
......@@ -4,5 +4,5 @@
| --- | --- | --- |
| Agent 治理骨架 | Done | AGENTS、workflow、rule、skill 与多 Agent 适配层已初始化 |
| 脚本安全约束 | Done | 已定义敏感信息、幂等、权限、危险操作与验证边界 |
| Tailscale Route Manager | Done (V0.3.2) | 支持 OAuth 批量注册、在线标签多选、短期密钥安全清理及基于 `hw.json` SN 的设备命名前缀;成功入网和多设备批量验收待现场复验 |
| Tailscale Route Manager | Done (V0.4.2) | 支持 OAuth 批量注册、默认 QArm 标签、SN 设备名前缀、登录/退出、网络检测,以及互斥的 4via6 与旧 IPv4 路由模式;多设备批量注册与 TUI 视觉效果仍待现场复验 |
| 其他 QArm 业务配置脚本 | Pending | 等待后续按真实目标平台和配置需求添加 |
# 迭代记录
## [V0.4.2] 4via6 路由模式与 TUI 完善 (Route Modes and TUI Polish)
- **功能**: 增加网页登录与 OAuth 登录选择、退出登录、默认 `tag:qarm`、Tailscale 网络名称和独立 `netcheck`;将重复 IPv4 维护地址升级为 Site ID 驱动的 4via6 路由,并保留互斥的旧 IPv4 兼容模式;为 TUI 分支增加处理中界面,减少操作间露出底层终端。
- **改动文件**:
- `scripts/tailscale/tailscale-route-manager.sh`: 实现登录状态操作、网络诊断、Site ID、路由模式互斥、受管路由保护、中文状态和 TUI 过渡。
- `tests/tailscale/test-route-manager.sh`: 覆盖路由模式切换、旧配置兼容、路由集合保护、登录状态、默认标签、网络名称、netcheck 与 TUI 过渡。
- `docs/tailscale/README.md`: 补充 4via6、旧 IPv4 模式、登录/退出、网络检测、TUI 行为、系统影响和人工验收说明。
- `meta/ARCHITECTURE.md`: 更新路由模式、Site ID 持久状态及启用/禁用数据流。
- `meta/REGRESSION.md`: 增加路由模式、网络诊断、登录状态和 TUI 过渡回归项。
- **影响**: 多台具有重复局域网地址的 QArm 可通过唯一 Site ID 提供独立维护入口,同时允许单设备继续使用旧 IPv4 subnet。`0.4.2` 已部署到 `192.168.81.118`,只读复验确认原 Site ID、4via6 routes 和 forwarding 保持不变;真实 TUI 视觉效果及多设备端到端访问仍需现场验收。
## [V0.3.2] OAuth 批量注册与设备命名 (OAuth Provisioning and Device Naming)
- **功能**: 新增 OAuth Client 自动注册链路,在线读取 Provisioner 可直接分配的标签,在中文 TUI 中输入设备名称并多选标签;动态创建短期一次性 Auth Key,安全清理临时凭证,并使用 `/opt/armaster/data/hw.json` 的 `sn` 作为设备名称默认前缀。
......
{
"project_name": "qarm_setup_scripts",
"version": "0.3.2",
"iteration_count": 4,
"last_update": "2026-10-23",
"version": "0.4.2",
"iteration_count": 5,
"last_update": "2026-10-25",
"stack_summary": [
"PowerShell",
"Shell",
......@@ -40,6 +40,11 @@
"tailscale_whiptail_width_safe": true,
"tailscale_dialog_color_ready": true,
"tailscale_oauth_registration_ready": true,
"tailscale_hardware_sn_prefix_ready": true
"tailscale_hardware_sn_prefix_ready": true,
"tailscale_login_logout_ready": true,
"tailscale_default_qarm_tag_ready": true,
"tailscale_network_diagnostics_ready": true,
"tailscale_route_modes_ready": true,
"tailscale_tui_transition_ready": true
}
}
......@@ -14,12 +14,22 @@
| 敏感信息检查 | 检查凭证、私钥、token、生产地址和未脱敏日志 | Ready |
| Tailscale 脚本 Bash 语法 | `bash -n scripts/tailscale/tailscale-route-manager.sh` | Ready |
| Tailscale 只读命令 | `tailscale-route-manager.sh help` 与 `status` 不修改系统 | Ready |
| 路由集合保护 | 启用时合并、禁用时仅移除两个 managed routes | Ready |
| 路由集合保护 | 启用时合并当前模式并移除另一模式;禁用时仅移除已知 4via6 与旧版受管 IPv4 routes | Ready |
| 路由模式互斥 | 仅支持 `4via6` 或 `legacy`;当前模式路由仍发布时拒绝切换;无模式字段的旧配置默认为 4via6 | Ready |
| 4via6 Site ID | 仅接受 `1-65535`;配置持久化;当前编号路由仍发布时拒绝换号 | Ready |
| 4via6 路由生成 | Site ID 与两个 IPv4 `/32` 生成稳定的独立路由;生成失败时不修改 forwarding 或 advertised routes | Ready |
| 旧版路由兼容 | legacy 模式无需 Site ID;发布两个 IPv4 `/24` 并移除已知 4via6 路由,同时保留无关路由 | Ready |
| Forwarding 恢复 | IPv4/IPv6 原始状态均能独立记录和恢复 | Pending |
| 目标设备端到端路由 | 从 tailnet 客户端访问两个 QArm 局域网 | Pending |
| Tailscale 状态颜色 | `dialog` 与交互终端显示彩色圆点和中文状态,`whiptail` 仅显示中文状态,重定向或 `NO_COLOR` 时为纯文本 | Ready |
| Tailscale 网络名称 | 已连接时状态页显示 `Self.HostName`;未连接时显示“未连接”,字段缺失时显示“未知” | Ready |
| Tailscale 网络检测 | 主菜单和 `netcheck` 命令显示完整检测输出;失败信息可见且 TUI 返回主页面 | Ready |
| Tailscale TUI 操作过渡 | `dialog`/`whiptail` 菜单分支在执行前显示处理中界面;窗口间不主动清屏暴露底层终端 | Ready |
| Whiptail 边框宽度 | 状态文本不得包含双列 emoji;右侧边框不因状态行发生错位 | Ready |
| Tailscale TUI 单出口 | 分支完成、取消、Esc 或失败后返回主页面;仅主页面“退出”结束 TUI | Ready |
| Tailscale 登录 | 未登录设备可执行 `tailscale login`;已登录设备不重复操作;完成后返回主页面 | Ready |
| Tailscale 登录方式 | TUI 可选择网页登录或 OAuth 自动注册;取消选择返回主页面;OAuth 快捷入口继续保留 | Ready |
| Tailscale 退出登录 | TUI 退出前二次确认;已退出设备不重复操作;不隐式修改子网路由或 forwarding | Ready |
| 未登录时取消启用 | `tailscale up` 确认框选择 No 后不退出脚本,直接返回主页面 | Ready |
| OAuth 凭证不持久化 | 检查仓库、状态文件和输出不包含 Client Secret、Access Token 或 Auth Key | Ready |
| OAuth 标签权限过滤 | Policy 中仅由 Provisioner 直接拥有的标签进入 TUI 列表 | Ready |
......@@ -27,5 +37,6 @@
| OAuth 注册失败恢复 | 取消或失败后删除临时 Key 文件并返回主页面 | Ready |
| OAuth Auth Key 描述 | 描述只包含 API 接受的 ASCII 字母数字和连字符,且不超过 50 字符 | Ready |
| OAuth 标签大小写 | Provisioner 与设备标签统一使用小写,Auth Key 标签和 `tailscale up` 请求必须一致 | Ready |
| OAuth 默认 QArm 标签 | `tag:qarm` 可用时自动包含它,用户可增加其他标签;不存在时保持普通多选 | Ready |
| 硬件 SN 名称前缀 | 有效 `hw.json.sn` 生成 `<sn>-` 默认值;缺失、损坏、非法或过长时回退 hostname | Ready |
| OAuth 批量真实注册 | 在至少两台新设备连续运行并核对名称、标签和连接状态 | Pending |
......@@ -6,22 +6,31 @@ set -euo pipefail
#
# 支持平台:使用 systemd 或兼容服务管理方式的 Linux。
# 权限要求:安装、修改 sysctl 和配置路由时必须以 root 运行。
# 管理范围:只管理 MANAGED_ROUTES 中声明的子网路由。
# 管理范围:只管理 TARGET_IPV4_ROUTES 对应的 4via6 路由和旧版路由。
# 恢复行为:禁用时保留其他 Tailscale advertised routes,并仅恢复本脚本
# 首次启用 forwarding 前记录的内核状态。
readonly INSTALL_URL="https://tailscale.com/install.sh"
readonly SCRIPT_VERSION="0.3.2"
readonly SCRIPT_VERSION="0.4.2"
readonly RC_CANCELLED=10
readonly SYSCTL_FILE="/etc/sysctl.d/99-tailscale-routes.conf"
readonly STATE_FILE="/var/lib/tailscale-route-manager.state"
readonly MANAGED_MARKER="# Managed by qarm tailscale-route-manager"
readonly OAUTH_HELPER_NAME="tailscale-oauth-helper.py"
readonly DEFAULT_HARDWARE_CONFIG_FILE="/opt/armaster/data/hw.json"
readonly DEFAULT_DEVICE_TAG="${TS_DEFAULT_DEVICE_TAG:-tag:qarm}"
readonly SITE_CONFIG_FILE="${TS_SITE_CONFIG_FILE:-/var/lib/tailscale-route-manager.site}"
readonly SITE_CONFIG_MARKER="# Managed by qarm tailscale-route-manager site config"
readonly DEFAULT_ROUTE_MODE="4via6"
TEMP_INSTALLER=""
TEMP_AUTH_KEY_FILE=""
readonly -a MANAGED_ROUTES=(
readonly -a TARGET_IPV4_ROUTES=(
"192.168.11.201/32"
"192.168.22.202/32"
)
readonly -a LEGACY_ROUTES=(
"192.168.11.0/24"
"192.168.22.0/24"
)
......@@ -61,7 +70,6 @@ message_box() {
if command_exists dialog; then
dialog --colors --title "$title" --msgbox "$message" 22 76 || true
clear || true
elif command_exists whiptail; then
whiptail --title "$title" --msgbox "$message" 22 76 || true
else
......@@ -74,6 +82,35 @@ message_box() {
fi
}
output_box() {
local title="$1"
local content="$2"
if command_exists dialog; then
dialog --colors --scrollbar --title "$title" --msgbox "$content" 24 84 || true
elif command_exists whiptail; then
whiptail --scrolltext --title "$title" --msgbox "$content" 24 84 || true
else
clear || true
printf '============================================================\n'
printf '%s\n' "$title"
printf '============================================================\n\n'
printf '%s\n\n' "$content"
pause_for_user
fi
}
show_tui_progress() {
local title="$1"
local message="$2"
if command_exists dialog; then
dialog --colors --title "$title" --infobox "$message" 7 68 || true
elif command_exists whiptail; then
whiptail --title "$title" --infobox "$message" 7 68 || true
fi
}
yes_no() {
local title="$1"
local message="$2"
......@@ -81,7 +118,6 @@ yes_no() {
if command_exists dialog; then
dialog --title "$title" --yesno "$message" 18 76
local result=$?
clear || true
return "$result"
elif command_exists whiptail; then
whiptail --title "$title" --yesno "$message" 18 76
......@@ -100,7 +136,6 @@ input_value() {
if command_exists dialog; then
dialog --stdout --title "$title" --inputbox "$message" 12 76 "$default_value"
local result=$?
clear || true
return "$result"
elif command_exists whiptail; then
whiptail --title "$title" --inputbox "$message" 12 76 "$default_value" 3>&1 1>&2 2>&3
......@@ -114,56 +149,97 @@ input_value() {
select_tags() {
local available_tags="$1"
local -a tags=()
local -a optional_tags=()
local default_available=0
local tag
while IFS= read -r tag; do
[[ -n "$tag" ]] && tags+=("$tag")
[[ -n "$tag" ]] || continue
tags+=("$tag")
if [[ "${tag,,}" == "${DEFAULT_DEVICE_TAG,,}" ]]; then
default_available=1
else
optional_tags+=("$tag")
fi
done <<<"$available_tags"
if [[ "${#tags[@]}" -eq 0 ]]; then
return 1
fi
if [[ "$default_available" -eq 0 ]]; then
optional_tags=("${tags[@]}")
elif [[ "${#optional_tags[@]}" -eq 0 ]]; then
printf '%s\n' "$DEFAULT_DEVICE_TAG"
return 0
fi
local prompt="使用空格选择标签,使用 Enter 确认:"
if [[ "$default_available" -eq 1 ]]; then
prompt="已默认选择 $DEFAULT_DEVICE_TAG。请按需选择其他标签:"
fi
if command_exists dialog; then
local -a options=()
for tag in "${tags[@]}"; do
for tag in "${optional_tags[@]}"; do
options+=("$tag" "" "off")
done
dialog --stdout --separate-output --title "选择设备标签" \
--checklist "使用空格选择标签,使用 Enter 确认:" 22 76 12 \
"${options[@]}"
local result=$?
clear || true
return "$result"
local selected_output=""
local result=0
selected_output="$(dialog --stdout --separate-output --title "选择设备标签" \
--checklist "$prompt" 22 76 12 "${options[@]}")" || result=$?
[[ "$result" -eq 0 ]] || return "$result"
[[ "$default_available" -eq 1 ]] && printf '%s\n' "$DEFAULT_DEVICE_TAG"
[[ -n "$selected_output" ]] && printf '%s\n' "$selected_output"
elif command_exists whiptail; then
local -a options=()
for tag in "${tags[@]}"; do
for tag in "${optional_tags[@]}"; do
options+=("$tag" "" "off")
done
whiptail --separate-output --title "选择设备标签" \
--checklist "使用空格选择标签,使用 Enter 确认:" 22 76 12 \
"${options[@]}" 3>&1 1>&2 2>&3
local selected_output=""
local result=0
selected_output="$(whiptail --separate-output --title "选择设备标签" \
--checklist "$prompt" 22 76 12 "${options[@]}" 3>&1 1>&2 2>&3)" || result=$?
[[ "$result" -eq 0 ]] || return "$result"
[[ "$default_available" -eq 1 ]] && printf '%s\n' "$DEFAULT_DEVICE_TAG"
[[ -n "$selected_output" ]] && printf '%s\n' "$selected_output"
else
if [[ "$default_available" -eq 1 ]]; then
printf '\n默认设备标签:%s\n\n可选附加标签:\n\n' "$DEFAULT_DEVICE_TAG" >&2
else
printf '\n可分配的设备标签:\n\n' >&2
fi
local index
for index in "${!tags[@]}"; do
printf '%d) %s\n' "$((index + 1))" "${tags[$index]}" >&2
for index in "${!optional_tags[@]}"; do
printf '%d) %s\n' "$((index + 1))" "${optional_tags[$index]}" >&2
done
if [[ "$default_available" -eq 1 ]]; then
printf '\n请输入附加标签序号,多个序号使用逗号分隔;直接回车仅使用默认标签,输入 0 取消。\n' >&2
else
printf '\n请输入序号,多个序号使用逗号分隔;直接回车取消。\n' >&2
fi
local selection
read -r -p "标签序号: " selection
[[ -n "$selection" ]] || return "$RC_CANCELLED"
if [[ -z "$selection" ]]; then
if [[ "$default_available" -eq 1 ]]; then
printf '%s\n' "$DEFAULT_DEVICE_TAG"
return 0
fi
return "$RC_CANCELLED"
fi
[[ "$selection" != "0" ]] || return "$RC_CANCELLED"
local -a selected=()
[[ "$default_available" -eq 1 ]] && selected+=("$DEFAULT_DEVICE_TAG")
local -a requested_indices=()
local raw_index
IFS=',' read -r -a requested_indices <<<"$selection"
for raw_index in "${requested_indices[@]}"; do
raw_index="${raw_index//[[:space:]]/}"
if [[ ! "$raw_index" =~ ^[0-9]+$ ]] \
|| (( raw_index < 1 || raw_index > ${#tags[@]} )); then
|| (( raw_index < 1 || raw_index > ${#optional_tags[@]} )); then
return 1
fi
tag="${tags[$((raw_index - 1))]}"
tag="${optional_tags[$((raw_index - 1))]}"
if [[ ! " ${selected[*]} " =~ " ${tag} " ]]; then
selected+=("$tag")
fi
......@@ -295,6 +371,12 @@ get_backend_state() {
| head -n 1
}
get_tailscale_network_name() {
tailscale status --json 2>/dev/null \
| sed -n 's/.*"HostName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n 1
}
is_tailscale_logged_in() {
[[ "$(get_backend_state || true)" == "Running" ]]
}
......@@ -416,6 +498,7 @@ oauth_register_device() {
local helper
helper="$(find_oauth_helper)"
local available_tags
show_tui_progress "OAuth 自动注册" "正在读取可分配的设备标签,请稍候……"
if ! available_tags="$(python3 "$helper" list-tags 2>&1)"; then
message_box "标签读取失败" "$available_tags"
return 1
......@@ -453,6 +536,7 @@ $(printf '%s\n' "$selected_tags" | sed 's/^/ /')
fi
local auth_key
show_tui_progress "OAuth 自动注册" "正在创建临时注册密钥,请稍候……"
if ! auth_key="$(python3 "$helper" create-auth-key \
--tags "$selected_csv" \
--description "qarm registration $device_name" \
......@@ -467,6 +551,7 @@ $(printf '%s\n' "$selected_tags" | sed 's/^/ /')
unset auth_key
local up_result=0
show_tui_progress "OAuth 自动注册" "正在将设备注册到 Tailscale,请稍候……"
tailscale up \
--auth-key="file:$TEMP_AUTH_KEY_FILE" \
--hostname="$device_name" \
......@@ -515,6 +600,111 @@ ensure_tailscale_ready() {
is_tailscale_logged_in
}
login_tailscale() {
if ! is_tailscale_installed; then
message_box "无法登录" "Tailscale 尚未安装。\n\n请先选择“安装/检查 Tailscale”。"
return 1
fi
if command_exists systemctl && ! systemctl start tailscaled; then
message_box "登录失败" "无法启动 tailscaled 服务。请检查 systemd 状态。"
return 1
fi
if is_tailscale_logged_in; then
message_box "Tailscale 登录" "当前设备已经登录,无需重复操作。\n\n网络名称:$(get_tailscale_network_name 2>/dev/null || echo 未知)"
return 0
fi
clear || true
local login_result=0
tailscale login || login_result=$?
if [[ "$login_result" -ne 0 ]] || ! is_tailscale_logged_in; then
message_box "登录失败" "Tailscale 未能完成登录。\n\n请检查终端输出、网络连接和登录授权。"
return 1
fi
message_box "登录完成" "设备已登录 Tailscale。\n\n网络名称:$(get_tailscale_network_name 2>/dev/null || echo 未知)"
}
logout_tailscale() {
if ! is_tailscale_installed; then
message_box "无法退出登录" "Tailscale 尚未安装。"
return 1
fi
if ! is_tailscale_logged_in; then
message_box "Tailscale 退出登录" "当前设备已经处于未登录状态。"
return 0
fi
if ! tailscale logout; then
message_box "退出登录失败" "Tailscale 未能完成退出登录。请检查上方输出。"
return 1
fi
if is_tailscale_logged_in; then
message_box "退出登录失败" "命令已执行,但设备仍显示为已连接状态。"
return 1
fi
message_box "退出登录完成" "设备已断开 Tailscale,当前登录凭据已经失效。\n\n本操作未修改子网路由设置或 IP 转发状态。"
}
confirm_logout_tailscale() {
if ! yes_no "确认退出 Tailscale" "退出登录会立即断开当前设备,并使当前登录凭据失效。\n\n本操作不会自动禁用子网路由或恢复 IP 转发。是否继续?"; then
return "$RC_CANCELLED"
fi
logout_tailscale
}
select_login_method() {
local choice
local prompt="请选择 Tailscale 登录方式。\n\nOAuth 自动注册会按新设备流程创建登录身份,旧设备记录可能仍需在管理后台清理。"
if command_exists dialog; then
choice="$(dialog --stdout --title "选择登录方式" \
--menu "$prompt" 18 78 4 \
"manual" "网页登录(需要打开链接)" \
"oauth" "OAuth 自动注册(无需点击链接)")" || return "$RC_CANCELLED"
elif command_exists whiptail; then
choice="$(whiptail --title "选择登录方式" \
--menu "$prompt" 18 78 4 \
"manual" "网页登录(需要打开链接)" \
"oauth" "OAuth 自动注册(无需点击链接)" \
3>&1 1>&2 2>&3)" || return "$RC_CANCELLED"
else
printf '\n请选择 Tailscale 登录方式:\n\n' >&2
printf '1) 网页登录(需要打开链接)\n' >&2
printf '2) OAuth 自动注册(无需点击链接)\n' >&2
printf '0) 取消并返回主页面\n\n' >&2
read -r -p "请选择 [0-2]: " choice
case "$choice" in
1) choice="manual" ;;
2) choice="oauth" ;;
0) return "$RC_CANCELLED" ;;
*) return 1 ;;
esac
fi
printf '%s\n' "$choice"
}
login_from_menu() {
if is_tailscale_installed && is_tailscale_logged_in; then
login_tailscale
return $?
fi
local method
method="$(select_login_method)" || return $?
case "$method" in
manual) login_tailscale ;;
oauth) oauth_register_device ;;
*) return 1 ;;
esac
}
ipv4_forward_enabled() {
[[ "$(sysctl -n net.ipv4.ip_forward 2>/dev/null || echo 0)" == "1" ]]
}
......@@ -622,6 +812,233 @@ restore_forwarding() {
rm -f -- "$STATE_FILE"
}
valid_site_id() {
local value="$1"
[[ "$value" =~ ^[1-9][0-9]*$ ]] && (( value <= 65535 ))
}
valid_route_mode() {
[[ "$1" == "4via6" ]] || [[ "$1" == "legacy" ]]
}
get_route_mode() {
if [[ ! -f "$SITE_CONFIG_FILE" ]]; then
printf '%s\n' "$DEFAULT_ROUTE_MODE"
return 0
fi
grep -Fqx "$SITE_CONFIG_MARKER" "$SITE_CONFIG_FILE" || return 1
local value
value="$(sed -n 's/^ROUTE_MODE=//p' "$SITE_CONFIG_FILE" | head -n 1)"
[[ -n "$value" ]] || value="$DEFAULT_ROUTE_MODE"
valid_route_mode "$value" || return 1
printf '%s\n' "$value"
}
get_site_id() {
[[ -f "$SITE_CONFIG_FILE" ]] || return 1
grep -Fqx "$SITE_CONFIG_MARKER" "$SITE_CONFIG_FILE" || return 1
local value
value="$(sed -n 's/^SITE_ID=\([0-9][0-9]*\)$/\1/p' "$SITE_CONFIG_FILE" | head -n 1)"
valid_site_id "$value" || return 1
printf '%s\n' "$value"
}
write_route_config() {
local site_id="$1"
local route_mode="$2"
[[ -z "$site_id" ]] || valid_site_id "$site_id" || return 1
valid_route_mode "$route_mode" || return 1
if [[ -f "$SITE_CONFIG_FILE" ]] && ! grep -Fqx "$SITE_CONFIG_MARKER" "$SITE_CONFIG_FILE"; then
message_box "配置冲突" "$SITE_CONFIG_FILE 已存在且不是本脚本创建的文件。\n\n为避免覆盖人工配置,本次操作已停止。"
return 1
fi
local temporary
temporary="$(mktemp /tmp/tailscale-site-config.XXXXXX)"
{
printf '%s\n' "$SITE_CONFIG_MARKER"
printf 'ROUTE_MODE=%s\n' "$route_mode"
[[ -z "$site_id" ]] || printf 'SITE_ID=%s\n' "$site_id"
} >"$temporary"
install -d -m 0755 "$(dirname "$SITE_CONFIG_FILE")"
if ! install -m 0644 "$temporary" "$SITE_CONFIG_FILE"; then
rm -f -- "$temporary"
return 1
fi
rm -f -- "$temporary"
}
save_site_id() {
local value="$1"
local route_mode
valid_site_id "$value" || return 1
route_mode="$(get_route_mode 2>/dev/null || printf '%s\n' "$DEFAULT_ROUTE_MODE")"
write_route_config "$value" "$route_mode"
}
save_route_mode() {
local route_mode="$1"
local site_id=""
valid_route_mode "$route_mode" || return 1
site_id="$(get_site_id 2>/dev/null || true)"
write_route_config "$site_id" "$route_mode"
}
get_4via6_routes_for_site() {
local site_id="$1"
valid_site_id "$site_id" || return 1
is_tailscale_installed || return 1
local target
local generated
for target in "${TARGET_IPV4_ROUTES[@]}"; do
generated="$(tailscale debug via "$site_id" "$target" 2>/dev/null | tail -n 1)" || return 1
[[ "$generated" =~ ^[0-9A-Fa-f:]+/[0-9]+$ ]] || return 1
printf '%s\n' "$generated"
done
}
get_managed_routes() {
local route_mode
route_mode="$(get_route_mode)" || return 1
if [[ "$route_mode" == "legacy" ]]; then
printf '%s\n' "${LEGACY_ROUTES[@]}"
return 0
fi
local site_id
site_id="$(get_site_id)" || return 1
get_4via6_routes_for_site "$site_id"
}
get_all_owned_routes() {
printf '%s\n' "${LEGACY_ROUTES[@]}"
local site_id
site_id="$(get_site_id 2>/dev/null || true)"
if [[ -n "$site_id" ]] && is_tailscale_installed; then
get_4via6_routes_for_site "$site_id" 2>/dev/null || true
fi
}
configure_site_id() {
local requested="${1:-}"
local current=""
current="$(get_site_id 2>/dev/null || true)"
if [[ -z "$requested" ]]; then
requested="$(input_value "配置 Site ID" "请输入本设备唯一的 4via6 Site ID。\n\n允许范围:1-65535\n同一 Tailnet 中不得与其他 QArm 重复。" "$current")" || return "$RC_CANCELLED"
fi
if ! valid_site_id "$requested"; then
message_box "Site ID 无效" "Site ID 必须是 1-65535 之间的整数。"
return 1
fi
if [[ -n "$current" ]] && [[ "$current" != "$requested" ]] && is_tailscale_installed; then
local advertised
local current_site_routes
if ! advertised="$(get_all_advertised_routes)"; then
message_box "无法修改 Site ID" "无法读取当前 advertised routes。为避免遗留旧路由,本次操作已停止。"
return 1
fi
if ! current_site_routes="$(get_4via6_routes_for_site "$current")"; then
message_box "无法修改 Site ID" "无法生成当前 Site ID 的 4via6 路由。为避免遗留旧路由,本次操作已停止。"
return 1
fi
local route
while IFS= read -r route; do
if printf '%s\n' "$advertised" | grep -Fqx "$route"; then
message_box "无法修改 Site ID" "当前 Site ID 的 4via6 路由仍在发布。\n\n请先禁用子网路由,再修改 Site ID。"
return 1
fi
done <<<"$current_site_routes"
fi
save_site_id "$requested" || return 1
message_box "Site ID 已保存" "本设备 Site ID:$requested\n\n启用子网路由时,将为配置的 IPv4 目标生成唯一 4via6 路由。"
}
select_route_mode() {
local choice
if command_exists dialog; then
choice="$(dialog --stdout --title "选择路由模式" \
--menu "两种模式互斥。切换模式前必须先禁用当前路由。" 16 76 4 \
"4via6" "仅 4via6(推荐)" \
"legacy" "仅旧 IPv4")" || return "$RC_CANCELLED"
elif command_exists whiptail; then
choice="$(whiptail --title "选择路由模式" \
--menu "两种模式互斥。切换模式前必须先禁用当前路由。" 16 76 4 \
"4via6" "仅 4via6(推荐)" \
"legacy" "仅旧 IPv4" \
3>&1 1>&2 2>&3)" || return "$RC_CANCELLED"
else
printf '\n请选择路由模式:\n\n' >&2
printf '1) 仅 4via6(推荐)\n' >&2
printf '2) 仅旧 IPv4\n' >&2
printf '0) 取消并返回主页面\n\n' >&2
read -r -p "请选择 [0-2]: " choice
case "$choice" in
1) choice="4via6" ;;
2) choice="legacy" ;;
0) return "$RC_CANCELLED" ;;
*) return 1 ;;
esac
fi
printf '%s\n' "$choice"
}
configure_route_mode() {
local requested="${1:-}"
local current
current="$(get_route_mode)" || {
message_box "路由模式无效" "$SITE_CONFIG_FILE 中的路由模式无效。"
return 1
}
if [[ -z "$requested" ]]; then
requested="$(select_route_mode)" || return $?
fi
if ! valid_route_mode "$requested"; then
message_box "路由模式无效" "只允许使用 4via6 或 legacy。"
return 1
fi
if [[ "$requested" == "$current" ]]; then
message_box "路由模式" "当前已经是:$([[ "$current" == "4via6" ]] && echo '仅 4via6(推荐)' || echo '仅旧 IPv4')"
return 0
fi
if is_tailscale_installed; then
local advertised
local current_mode_routes=""
if ! advertised="$(get_all_advertised_routes)"; then
message_box "无法切换路由模式" "无法读取当前 advertised routes。"
return 1
fi
if [[ "$current" == "legacy" ]] || get_site_id >/dev/null 2>&1; then
if ! current_mode_routes="$(get_managed_routes)"; then
message_box "无法切换路由模式" "无法确定当前模式管理的 routes。"
return 1
fi
fi
local route
while IFS= read -r route; do
[[ -z "$route" ]] && continue
if printf '%s\n' "$advertised" | grep -Fqx "$route"; then
message_box "无法切换路由模式" "当前模式的路由仍在发布。\n\n请先禁用子网路由,再切换模式。"
return 1
fi
done <<<"$current_mode_routes"
fi
save_route_mode "$requested" || return 1
message_box "路由模式已保存" "当前路由模式:$([[ "$requested" == "4via6" ]] && echo '仅 4via6(推荐)' || echo '仅旧 IPv4')"
}
get_all_advertised_routes() {
local prefs
prefs="$(tailscale debug prefs 2>/dev/null)" || return 1
......@@ -643,9 +1060,9 @@ get_all_advertised_routes() {
route_is_managed() {
local candidate="$1"
local managed
for managed in "${MANAGED_ROUTES[@]}"; do
while IFS= read -r managed; do
[[ "$candidate" == "$managed" ]] && return 0
done
done < <(get_all_owned_routes)
return 1
}
......@@ -655,17 +1072,24 @@ route_is_advertised() {
}
all_routes_enabled() {
local managed_routes
managed_routes="$(get_managed_routes)" || return 1
[[ -n "$managed_routes" ]] || return 1
local route
for route in "${MANAGED_ROUTES[@]}"; do
while IFS= read -r route; do
route_is_advertised "$route" || return 1
done
done <<<"$managed_routes"
}
any_route_enabled() {
local managed_routes
managed_routes="$(get_managed_routes)" || return 1
local route
for route in "${MANAGED_ROUTES[@]}"; do
while IFS= read -r route; do
route_is_advertised "$route" && return 0
done
done <<<"$managed_routes"
return 1
}
......@@ -679,13 +1103,41 @@ routes_without_managed() {
merge_with_managed_routes() {
local current_routes="$1"
local remaining_routes
local managed_routes
remaining_routes="$(printf '%s\n' "$current_routes" | routes_without_managed)"
managed_routes="$(get_managed_routes)" || return 1
{
printf '%s\n' "$current_routes"
printf '%s\n' "${MANAGED_ROUTES[@]}"
printf '%s\n' "$remaining_routes"
printf '%s\n' "$managed_routes"
} | sed '/^$/d' | sort -u | paste -sd, -
}
enable_routes() {
if ! is_tailscale_installed; then
install_tailscale || return 1
fi
local route_mode
if ! route_mode="$(get_route_mode)"; then
message_box "启用失败" "$SITE_CONFIG_FILE 中的路由模式无效。"
return 1
fi
if [[ "$route_mode" == "4via6" ]] && ! get_site_id >/dev/null; then
configure_site_id || return $?
fi
local managed_routes
if ! managed_routes="$(get_managed_routes)"; then
if [[ "$route_mode" == "4via6" ]]; then
message_box "启用失败" "无法生成 4via6 路由。\n\n请检查 Tailscale 版本、Site ID 和目标配置。"
else
message_box "启用失败" "无法读取旧 IPv4 路由配置。"
fi
return 1
fi
local ready_result=0
ensure_tailscale_ready || ready_result=$?
......@@ -704,18 +1156,45 @@ enable_routes() {
return 1
fi
local merged_routes
merged_routes="$(merge_with_managed_routes "$current_routes")" || return 1
enable_forwarding || return 1
tailscale set --advertise-routes="$(merge_with_managed_routes "$current_routes")"
if ! tailscale set --advertise-routes="$merged_routes"; then
message_box "启用失败" "Tailscale 未能更新 advertised routes。"
return 1
fi
if [[ "$route_mode" == "4via6" ]]; then
message_box "子网路由已启用" "路由模式:仅 4via6(推荐)
已发布以下 4via6 路由:
$(printf '%s\n' "$managed_routes" | sed 's/^/ /')
对应 IPv4 目标:
$(printf '%s\n' "${TARGET_IPV4_ROUTES[@]}" | sed 's/^/ /')
IPv4 Forwarding: $(sysctl -n net.ipv4.ip_forward)
IPv6 Forwarding: $(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null || echo N/A)
message_box "子网路由已启用" "已发布以下 Tailscale 子网路由:
旧版重复 IPv4 /24 路由已从本设备的发布列表移除。
${MANAGED_ROUTES[0]}
${MANAGED_ROUTES[1]}
首次启用时,还需在 Tailscale Admin Console 中批准这些 4via6 路由。"
else
message_box "子网路由已启用" "路由模式:仅旧 IPv4
已发布以下旧 IPv4 路由:
$(printf '%s\n' "$managed_routes" | sed 's/^/ /')
IPv4 Forwarding: $(sysctl -n net.ipv4.ip_forward)
IPv6 Forwarding: $(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null || echo N/A)
首次启用时,还需在 Tailscale Admin Console 中批准这些路由。"
本设备已知的 4via6 路由已从发布列表移除。
注意:多个 QArm 设备存在重复 IPv4 网段时,只能有一台设备发布这些旧 IPv4 路由。"
fi
}
disable_routes() {
......@@ -724,6 +1203,18 @@ disable_routes() {
return 0
fi
if [[ -f "$SITE_CONFIG_FILE" ]]; then
local route_mode
if ! route_mode="$(get_route_mode)"; then
message_box "禁用失败" "$SITE_CONFIG_FILE 中的路由模式无效。\n\n为避免删除错误路由,本次操作已停止。"
return 1
fi
if [[ "$route_mode" == "4via6" ]] && ! get_site_id >/dev/null 2>&1; then
message_box "禁用失败" "$SITE_CONFIG_FILE 缺少有效 Site ID。\n\n为避免遗留无法识别的 4via6 路由,本次操作已停止。"
return 1
fi
fi
local current_routes
if ! current_routes="$(get_all_advertised_routes)"; then
message_box "禁用失败" "无法读取当前 advertised routes。为避免删除其他路由,未执行任何修改。"
......@@ -737,13 +1228,16 @@ disable_routes() {
remaining_csv="$(printf '%s\n' "$remaining_routes" | paste -sd, -)"
fi
tailscale set --advertise-routes="$remaining_csv"
if ! tailscale set --advertise-routes="$remaining_csv"; then
message_box "禁用失败" "Tailscale 未能更新 advertised routes。"
return 1
fi
restore_forwarding || return 1
message_box "子网路由已禁用" "已停止发布本脚本管理的子网:
message_box "子网路由已禁用" "已停止发布本脚本管理的 4via6 路由和旧版 IPv4 路由。
${MANAGED_ROUTES[0]}
${MANAGED_ROUTES[1]}
IPv4 目标:
$(printf '%s\n' "${TARGET_IPV4_ROUTES[@]}" | sed 's/^/ /')
设备上其他 advertised routes 已保留。
......@@ -752,7 +1246,9 @@ IPv6 Forwarding: $(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null || echo N/
}
get_route_status() {
if all_routes_enabled; then
if ! get_managed_routes >/dev/null 2>&1; then
echo "未配置"
elif all_routes_enabled; then
echo "已启用"
elif any_route_enabled; then
echo "部分启用"
......@@ -823,12 +1319,32 @@ get_status_text() {
local installed
local daemon
local connected
local network_name="未连接"
local route_mode
local route_mode_label
local site_id="未配置"
local managed_routes_text="请先配置 Site ID"
local inactive_status="无"
local advertised="无"
local route_status
installed="$(format_status_value error "未安装" "$mode")"
daemon="$(format_status_value warn "未知" "$mode")"
connected="$(format_status_value error "未连接" "$mode")"
route_mode="$(get_route_mode 2>/dev/null || echo invalid)"
case "$route_mode" in
4via6) route_mode_label="仅 4via6(推荐)" ;;
legacy) route_mode_label="仅旧 IPv4" ;;
*) route_mode_label="配置无效" ;;
esac
site_id="$(get_site_id 2>/dev/null || true)"
if [[ -z "$site_id" ]]; then
if [[ "$route_mode" == "legacy" ]]; then
site_id="未配置(旧 IPv4 模式不需要)"
else
site_id="未配置"
fi
fi
if is_tailscale_installed; then
installed="$(format_status_value ok "已安装" "$mode")"
......@@ -841,11 +1357,42 @@ get_status_text() {
fi
if is_tailscale_logged_in; then
connected="$(format_status_value ok "已连接" "$mode")"
network_name="$(get_tailscale_network_name 2>/dev/null || true)"
[[ -z "$network_name" ]] && network_name="未知"
fi
advertised="$(get_all_advertised_routes 2>/dev/null || echo 未知)"
[[ -z "$advertised" ]] && advertised="无"
fi
if [[ "$route_mode" == "legacy" ]]; then
managed_routes_text="$(get_managed_routes 2>/dev/null || echo 读取失败)"
elif [[ "$route_mode" == "4via6" ]] && [[ "$site_id" != "未配置" ]] && is_tailscale_installed; then
managed_routes_text="$(get_managed_routes 2>/dev/null || echo 生成失败)"
elif [[ "$route_mode" == "invalid" ]]; then
managed_routes_text="路由模式配置无效"
elif [[ "$site_id" != "未配置" ]]; then
managed_routes_text="Tailscale 未安装,无法生成 4via6 路由"
fi
local inactive_route
local inactive_routes=""
if [[ "$route_mode" == "4via6" ]]; then
inactive_routes="$(printf '%s\n' "${LEGACY_ROUTES[@]}")"
elif [[ "$route_mode" == "legacy" ]]; then
local configured_site_id=""
configured_site_id="$(get_site_id 2>/dev/null || true)"
if [[ -n "$configured_site_id" ]] && is_tailscale_installed; then
inactive_routes="$(get_4via6_routes_for_site "$configured_site_id" 2>/dev/null || true)"
fi
fi
while IFS= read -r inactive_route; do
[[ -z "$inactive_route" ]] && continue
if printf '%s\n' "$advertised" | grep -Fqx "$inactive_route"; then
inactive_status="检测到(启用当前模式时移除)"
break
fi
done <<<"$inactive_routes"
route_status="$(get_route_status)"
case "$route_status" in
已启用) route_status="$(format_status_value ok "$route_status" "$mode")" ;;
......@@ -863,13 +1410,20 @@ Tailscale 状态
安装状态 : $installed
运行状态 : $daemon
连接状态 : $connected
网络名称 : $network_name
路由模式 : $route_mode_label
Site ID : $site_id
IPv4 维护目标
------------------------------------------------------------
$(printf '%s\n' "${TARGET_IPV4_ROUTES[@]}")
受管子网路由
当前受管路由
------------------------------------------------------------
${MANAGED_ROUTES[0]}
${MANAGED_ROUTES[1]}
$managed_routes_text
路由状态 : $route_status
非当前模式路由 : $inactive_status
IP 转发
------------------------------------------------------------
......@@ -885,6 +1439,10 @@ EOF
get_main_action() {
if ! is_tailscale_installed; then
echo "安装 Tailscale"
elif ! get_route_mode >/dev/null 2>&1; then
echo "选择路由模式"
elif [[ "$(get_route_mode)" == "4via6" ]] && ! get_site_id >/dev/null 2>&1; then
echo "配置 Site ID"
elif all_routes_enabled; then
echo "禁用子网路由"
else
......@@ -892,9 +1450,20 @@ get_main_action() {
fi
}
get_main_status_text() {
get_status_text "${1:-text}" | awk '
NR <= 11 { print; next }
/^路由状态[[:space:]]*:/ || /^非当前模式路由[[:space:]]*:/ { print }
'
}
run_main_action() {
if ! is_tailscale_installed; then
install_tailscale
elif ! get_route_mode >/dev/null 2>&1; then
configure_route_mode
elif [[ "$(get_route_mode)" == "4via6" ]] && ! get_site_id >/dev/null 2>&1; then
configure_site_id
elif all_routes_enabled; then
disable_routes
else
......@@ -911,11 +1480,39 @@ show_status() {
elif [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]] && [[ "${TERM:-}" != "dumb" ]]; then
mode="ansi"
fi
message_box "Tailscale 路由状态" "$(get_status_text "$mode")"
output_box "Tailscale 路由状态" "$(get_status_text "$mode")"
}
run_netcheck() {
if ! is_tailscale_installed; then
printf 'Tailscale 尚未安装,无法执行网络检测。\n' >&2
return 1
fi
tailscale netcheck
}
show_netcheck() {
local output
local result=0
output="$(run_netcheck 2>&1)" || result=$?
[[ -z "$output" ]] && output="网络检测没有返回内容。"
if [[ "$result" -ne 0 ]]; then
output="$output
网络检测失败(退出码:$result)。"
fi
output_box "Tailscale 网络检测" "$output"
}
run_tui_branch() {
local progress_message="$1"
shift
# TUI 分支中的取消或命令失败必须返回主页面,不能被全局 set -e 终止。
show_tui_progress "Tailscale 路由管理器" "$progress_message"
set +e
"$@"
set -e
......@@ -931,20 +1528,30 @@ run_menu() {
if command_exists dialog; then
if ! choice="$(dialog --colors --stdout --no-cancel --title "Tailscale 路由管理器" \
--menu "$(get_status_text dialog)\n\n请选择操作:" 31 78 7 \
--menu "$(get_main_status_text dialog)\n\n请选择操作:" 29 84 12 \
"toggle" "$action" \
"route-mode" "选择路由模式" \
"site-id" "配置 4via6 Site ID" \
"login" "登录 Tailscale" \
"logout" "退出登录" \
"register" "OAuth 注册新设备" \
"status" "刷新/查看状态" \
"netcheck" "网络检测" \
"install" "安装/检查 Tailscale" \
"exit" "退出")"; then
continue
fi
elif command_exists whiptail; then
if ! choice="$(whiptail --nocancel --title "Tailscale 路由管理器" \
--menu "$(get_status_text whiptail)\n\n请选择操作:" 31 78 7 \
--menu "$(get_main_status_text whiptail)\n\n请选择操作:" 29 84 12 \
"toggle" "$action" \
"route-mode" "选择路由模式" \
"site-id" "配置 4via6 Site ID" \
"login" "登录 Tailscale" \
"logout" "退出登录" \
"register" "OAuth 注册新设备" \
"status" "刷新/查看状态" \
"netcheck" "网络检测" \
"install" "安装/检查 Tailscale" \
"exit" "退出" 3>&1 1>&2 2>&3)"; then
continue
......@@ -953,31 +1560,51 @@ run_menu() {
clear
get_status_text
printf '\n============================================================\n'
printf '1) %s\n2) OAuth 注册新设备\n3) 刷新/查看状态\n4) 安装/检查 Tailscale\n5) 退出\n' "$action"
printf '1) %s\n2) 选择路由模式\n3) 配置 4via6 Site ID\n4) 登录 Tailscale\n5) 退出登录\n6) OAuth 注册新设备\n7) 刷新/查看状态\n8) 网络检测\n9) 安装/检查 Tailscale\n10) 退出\n' "$action"
printf '============================================================\n'
read -r -p "请选择 [1-5]: " choice
read -r -p "请选择 [1-10]: " choice
case "$choice" in
1) choice="toggle" ;;
2) choice="register" ;;
3) choice="status" ;;
4) choice="install" ;;
5) choice="exit" ;;
2) choice="route-mode" ;;
3) choice="site-id" ;;
4) choice="login" ;;
5) choice="logout" ;;
6) choice="register" ;;
7) choice="status" ;;
8) choice="netcheck" ;;
9) choice="install" ;;
10) choice="exit" ;;
*) continue ;;
esac
fi
case "$choice" in
toggle)
run_tui_branch run_main_action
run_tui_branch "正在处理子网路由,请稍候……" run_main_action
;;
route-mode)
run_tui_branch "正在打开路由模式设置……" configure_route_mode
;;
site-id)
run_tui_branch "正在打开 Site ID 设置……" configure_site_id
;;
login)
run_tui_branch "正在检查 Tailscale 登录状态……" login_from_menu
;;
logout)
run_tui_branch "正在检查 Tailscale 登录状态……" confirm_logout_tailscale
;;
register)
run_tui_branch oauth_register_device
run_tui_branch "正在准备 OAuth 自动注册……" oauth_register_device
;;
status)
run_tui_branch show_status
run_tui_branch "正在读取 Tailscale 状态……" show_status
;;
netcheck)
run_tui_branch "正在执行网络检测,请稍候……" show_netcheck
;;
install)
run_tui_branch install_tailscale
run_tui_branch "正在检查 Tailscale 安装状态……" install_tailscale
;;
exit) break ;;
*) continue ;;
......@@ -993,10 +1620,15 @@ show_help() {
命令:
menu 打开交互菜单(默认)
install 安装或检查 Tailscale
route-mode 配置路由模式(4via6 或 legacy)
site-id 配置本设备唯一的 4via6 Site ID(1-65535)
login 登录 Tailscale
logout 退出 Tailscale,并使当前登录凭据失效
register 通过 OAuth 选择设备名称和标签并自动注册
enable 登录并发布本脚本管理的子网路由
disable 停止发布本脚本管理的子网路由并恢复 forwarding
status 输出状态,不修改系统
netcheck 执行 Tailscale 网络检测并输出完整结果
help 显示帮助
EOF
}
......@@ -1008,14 +1640,27 @@ main() {
status)
get_status_text
;;
netcheck)
run_netcheck
;;
help|-h|--help)
show_help
;;
menu|install|register|enable|disable)
site-id)
require_root
configure_site_id "${2:-}"
;;
route-mode)
require_root
configure_route_mode "${2:-}"
;;
menu|install|login|logout|register|enable|disable)
require_root
case "$action" in
menu) run_menu ;;
install) install_tailscale ;;
login) login_tailscale ;;
logout) logout_tailscale ;;
register) oauth_register_device ;;
enable) enable_routes ;;
disable) disable_routes ;;
......
......@@ -3,6 +3,8 @@
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
test_tmp_dir="$(mktemp -d)"
export TS_SITE_CONFIG_FILE="$test_tmp_dir/site-config"
# shellcheck source=../../scripts/tailscale/tailscale-route-manager.sh
source "$repo_root/scripts/tailscale/tailscale-route-manager.sh"
......@@ -20,7 +22,7 @@ if valid_device_name "qarm_001"; then
fi
hw_file="$(mktemp)"
trap 'rm -f -- "$hw_file"' EXIT
trap 'cleanup; rm -f -- "$hw_file" "$TS_SITE_CONFIG_FILE"; rmdir "$test_tmp_dir" 2>/dev/null || true' EXIT
printf '%s\n' '{"sn":"CA2EUP-20261020-001"}' >"$hw_file"
QARM_HW_CONFIG_FILE="$hw_file"
export QARM_HW_CONFIG_FILE
......@@ -40,18 +42,284 @@ helper_path="$(find_oauth_helper)"
[[ "$helper_path" == "$repo_root/scripts/tailscale/tailscale-oauth-helper.py" ]] \
|| fail "OAuth helper was not found beside the manager"
TAILSCALE_SET_ARGUMENT=""
MOCK_ADVERTISED_ROUTES='"192.168.11.0/24","fd7a:115c:a1e0:b1a:0:65:c0a8:bc9/128","fd7a:115c:a1e0:b1a:0:65:c0a8:16ca/128","10.20.30.0/24"'
tailscale() {
if [[ "${1:-}" == "set" ]]; then
TAILSCALE_SET_ARGUMENT="${2:-}"
return 0
fi
if [[ "${1:-} ${2:-}" == "debug via" ]]; then
case "${3:-} ${4:-}" in
"101 192.168.11.201/32") printf '%s\n' 'fd7a:115c:a1e0:b1a:0:65:c0a8:bc9/128' ;;
"101 192.168.22.202/32") printf '%s\n' 'fd7a:115c:a1e0:b1a:0:65:c0a8:16ca/128' ;;
*) return 1 ;;
esac
return 0
fi
case "${1:-} ${2:-}" in
"status --json")
cat <<'JSON'
{
"BackendState": "Running",
"Self": {
"HostName": "CA2EUP-20261020-001-coffee"
},
"Peer": {
"peer-id": {
"HostName": "other-device"
}
}
}
JSON
;;
"netcheck ")
printf '%s\n' 'Report:' '* UDP: true'
;;
"debug prefs")
printf '{"AdvertiseRoutes":[%s]}\n' "$MOCK_ADVERTISED_ROUTES"
;;
*) return 1 ;;
esac
}
is_tailscale_installed() { return 0; }
valid_site_id 1 || fail "minimum site ID was rejected"
valid_site_id 65535 || fail "maximum site ID was rejected"
if valid_site_id 0 || valid_site_id 65536 || valid_site_id invalid; then
fail "invalid site ID was accepted"
fi
[[ "$(get_route_status)" == "未配置" ]] || fail "missing site ID was not reported"
[[ "$(get_route_mode)" == "4via6" ]] || fail "missing route config did not default to 4via6"
printf '%s\nROUTE_MODE=invalid\n' "$SITE_CONFIG_MARKER" >"$TS_SITE_CONFIG_FILE"
if get_route_mode >/dev/null 2>&1; then
fail "invalid route mode was accepted"
fi
printf '%s\nSITE_ID=101\n' "$SITE_CONFIG_MARKER" >"$TS_SITE_CONFIG_FILE"
[[ "$(get_route_mode)" == "4via6" ]] || fail "0.4.0 route config did not default to 4via6"
save_site_id 101 || fail "site ID was not saved"
[[ "$(get_site_id)" == "101" ]] || fail "saved site ID was not loaded"
[[ "$(get_route_mode)" == "4via6" ]] || fail "saving site ID did not preserve the route mode"
managed_routes="$(get_managed_routes)"
[[ "$managed_routes" == $'fd7a:115c:a1e0:b1a:0:65:c0a8:bc9/128\nfd7a:115c:a1e0:b1a:0:65:c0a8:16ca/128' ]] \
|| fail "4via6 routes were not generated from the configured site ID"
remaining_routes="$(printf '%s\n' \
'192.168.11.0/24' \
'fd7a:115c:a1e0:b1a:0:65:c0a8:bc9/128' \
'10.20.30.0/24' | routes_without_managed)"
[[ "$remaining_routes" == "10.20.30.0/24" ]] \
|| fail "managed and legacy routes were not removed safely"
merged_routes="$(merge_with_managed_routes $'192.168.22.0/24\n10.20.30.0/24')"
[[ "$merged_routes" == *"10.20.30.0/24"* ]] \
&& [[ "$merged_routes" == *"fd7a:115c:a1e0:b1a:0:65:c0a8:bc9/128"* ]] \
&& [[ "$merged_routes" != *"192.168.22.0/24"* ]] \
|| fail "route merge did not preserve unrelated routes or migrate legacy routes"
[[ "$(get_tailscale_network_name)" == "CA2EUP-20261020-001-coffee" ]] \
|| fail "Tailscale network name was not read from Self.HostName"
[[ "$(get_status_text text)" == *"网络名称 : CA2EUP-20261020-001-coffee"* ]] \
|| fail "status text did not show the Tailscale network name"
[[ "$(run_netcheck)" == $'Report:\n* UDP: true' ]] \
|| fail "tailscale netcheck output was not preserved"
# TUI branches must cover the terminal immediately after the main menu closes.
TUI_EVENT_FILE="$test_tmp_dir/tui-events"
dialog() {
if [[ " $* " == *" --infobox "* ]]; then
printf 'progress:%s\n' "$*" >>"$TUI_EVENT_FILE"
elif [[ " $* " == *" --msgbox "* ]]; then
printf 'message:%s\n' "$*" >>"$TUI_EVENT_FILE"
fi
return 0
}
command_exists() {
[[ "$1" == "dialog" ]]
}
tui_probe_action() {
printf 'action\n' >>"$TUI_EVENT_FILE"
}
CLEAR_CALLS=0
clear() { CLEAR_CALLS=$((CLEAR_CALLS + 1)); }
run_tui_branch "正在执行网络检测,请稍候……" tui_probe_action
[[ "$(sed -n '1p' "$TUI_EVENT_FILE")" == progress:*"正在执行网络检测,请稍候……"* ]] \
|| fail "TUI branch did not show a progress box before running the action"
[[ "$(sed -n '2p' "$TUI_EVENT_FILE")" == "action" ]] \
|| fail "TUI action did not run after the progress box"
message_box "测试" "完成"
[[ "$CLEAR_CALLS" -eq 0 ]] || fail "dialog message box exposed the terminal by clearing the screen"
: >"$TUI_EVENT_FILE"
whiptail() {
if [[ " $* " == *" --infobox "* ]]; then
printf 'progress:%s\n' "$*" >>"$TUI_EVENT_FILE"
fi
return 0
}
command_exists() {
[[ "$1" == "whiptail" ]]
}
run_tui_branch "正在读取 Tailscale 状态……" tui_probe_action
[[ "$(sed -n '1p' "$TUI_EVENT_FILE")" == progress:*"正在读取 Tailscale 状态……"* ]] \
|| fail "whiptail branch did not show a progress box before running the action"
[[ "$(sed -n '2p' "$TUI_EVENT_FILE")" == "action" ]] \
|| fail "whiptail action did not run after the progress box"
# Force the ordinary terminal adapter so this test does not depend on local TUI packages.
command_exists() {
return 1
}
mode="$(printf '1\n' | select_route_mode)"
[[ "$mode" == "4via6" ]] || fail "recommended 4via6 route mode was not selected"
mode="$(printf '2\n' | select_route_mode)"
[[ "$mode" == "legacy" ]] || fail "legacy route mode was not selected"
if printf '0\n' | select_route_mode >/dev/null; then
fail "route mode cancellation was reported as successful"
else
result=$?
[[ "$result" -eq "$RC_CANCELLED" ]] || fail "route mode cancellation returned an unexpected status"
fi
FORWARDING_CALLS=0
message_box() { return 0; }
enable_forwarding() { FORWARDING_CALLS=$((FORWARDING_CALLS + 1)); }
restore_forwarding() { return 0; }
if configure_route_mode legacy; then
fail "route mode changed while current 4via6 routes were still advertised"
fi
[[ "$(get_route_mode)" == "4via6" ]] || fail "rejected route mode switch changed the saved mode"
enable_routes || fail "mocked 4via6 route enable failed"
[[ "$FORWARDING_CALLS" -eq 1 ]] || fail "route enable did not request forwarding"
[[ "$TAILSCALE_SET_ARGUMENT" == *"10.20.30.0/24"* ]] \
&& [[ "$TAILSCALE_SET_ARGUMENT" == *"fd7a:115c:a1e0:b1a:0:65:c0a8:bc9/128"* ]] \
&& [[ "$TAILSCALE_SET_ARGUMENT" != *"192.168.11.0/24"* ]] \
|| fail "route enable did not migrate and preserve advertised routes"
TAILSCALE_SET_ARGUMENT=""
disable_routes || fail "mocked 4via6 route disable failed"
[[ "$TAILSCALE_SET_ARGUMENT" == "--advertise-routes=10.20.30.0/24" ]] \
|| fail "route disable did not preserve only the unrelated route"
MOCK_ADVERTISED_ROUTES='"10.20.30.0/24"'
configure_route_mode legacy || fail "route mode did not switch after managed routes were disabled"
[[ "$(get_route_mode)" == "legacy" ]] || fail "legacy route mode was not saved"
[[ "$(get_managed_routes)" == $'192.168.11.0/24\n192.168.22.0/24' ]] \
|| fail "legacy route mode did not manage the expected IPv4 routes"
TAILSCALE_SET_ARGUMENT=""
enable_routes || fail "mocked legacy route enable failed"
[[ "$FORWARDING_CALLS" -eq 2 ]] || fail "legacy route enable did not request forwarding"
[[ "$TAILSCALE_SET_ARGUMENT" == *"192.168.11.0/24"* ]] \
&& [[ "$TAILSCALE_SET_ARGUMENT" == *"192.168.22.0/24"* ]] \
&& [[ "$TAILSCALE_SET_ARGUMENT" == *"10.20.30.0/24"* ]] \
&& [[ "$TAILSCALE_SET_ARGUMENT" != *"fd7a:115c:a1e0:b1a:0:65"* ]] \
|| fail "legacy route enable did not replace 4via6 routes and preserve unrelated routes"
MOCK_ADVERTISED_ROUTES='"192.168.11.0/24","192.168.22.0/24","10.20.30.0/24"'
TAILSCALE_SET_ARGUMENT=""
disable_routes || fail "mocked legacy route disable failed"
[[ "$TAILSCALE_SET_ARGUMENT" == "--advertise-routes=10.20.30.0/24" ]] \
|| fail "legacy route disable did not preserve only the unrelated route"
write_route_config "" legacy || fail "legacy route mode without a site ID was not saved"
if get_site_id >/dev/null 2>&1; then
fail "site ID unexpectedly remained in a legacy-only config"
fi
MOCK_ADVERTISED_ROUTES='"10.20.30.0/24"'
TAILSCALE_SET_ARGUMENT=""
enable_routes || fail "legacy route enable incorrectly required a site ID"
[[ "$TAILSCALE_SET_ARGUMENT" == *"192.168.11.0/24"* ]] \
&& [[ "$TAILSCALE_SET_ARGUMENT" == *"192.168.22.0/24"* ]] \
|| fail "legacy route enable without a site ID did not publish both IPv4 routes"
[[ "$(get_status_text text)" == *"路由模式 : 仅旧 IPv4"* ]] \
|| fail "status text did not show the legacy route mode"
MOCK_ADVERTISED_ROUTES='"192.168.11.0/24","192.168.22.0/24","10.20.30.0/24"'
disable_routes || fail "legacy route disable without a site ID failed"
selected="$(printf '1,2\n' | select_tags $'tag:germany\ntag:qarm-router')"
[[ "$selected" == $'tag:germany\ntag:qarm-router' ]] \
|| fail "terminal checklist did not return selected tags"
selected="$(printf '\n' | select_tags $'tag:qarm\ntag:europe\ntag:sea')"
[[ "$selected" == "tag:qarm" ]] \
|| fail "default qarm tag was not selected"
selected="$(printf '1,2\n' | select_tags $'tag:qarm\ntag:europe\ntag:sea')"
[[ "$selected" == $'tag:qarm\ntag:europe\ntag:sea' ]] \
|| fail "optional tags were not added to the default qarm tag"
if printf '0\n' | select_tags $'tag:qarm\ntag:europe' >/dev/null; then
fail "terminal default-tag selection did not support cancellation"
else
result=$?
[[ "$result" -eq "$RC_CANCELLED" ]] || fail "terminal cancellation returned an unexpected status"
fi
default_value="$(printf '\n' | input_value "测试" "设备名称" "qarm-default")"
[[ "$default_value" == "qarm-default" ]] || fail "terminal input default was not preserved"
method="$(printf '1\n' | select_login_method)"
[[ "$method" == "manual" ]] || fail "manual login method was not selected"
method="$(printf '2\n' | select_login_method)"
[[ "$method" == "oauth" ]] || fail "OAuth login method was not selected"
if printf '0\n' | select_login_method >/dev/null; then
fail "login method cancellation was reported as successful"
else
result=$?
[[ "$result" -eq "$RC_CANCELLED" ]] || fail "login method cancellation returned an unexpected status"
fi
# Exercise login/logout state transitions without contacting Tailscale.
LOGIN_STATE=0
LOGIN_CALLS=0
LOGOUT_CALLS=0
is_tailscale_installed() { return 0; }
is_tailscale_logged_in() { [[ "$LOGIN_STATE" -eq 1 ]]; }
message_box() { return 0; }
tailscale() {
case "$1" in
login)
LOGIN_CALLS=$((LOGIN_CALLS + 1))
LOGIN_STATE=1
;;
logout)
LOGOUT_CALLS=$((LOGOUT_CALLS + 1))
LOGIN_STATE=0
;;
status)
printf '%s\n' '{"Self":{"HostName":"qarm-login-test"}}'
;;
*) return 1 ;;
esac
}
login_tailscale || fail "mocked Tailscale login failed"
[[ "$LOGIN_STATE" -eq 1 && "$LOGIN_CALLS" -eq 1 ]] \
|| fail "login did not transition to the connected state"
login_tailscale || fail "already-connected login should be successful"
[[ "$LOGIN_CALLS" -eq 1 ]] || fail "already-connected device logged in again"
yes_no() { return 1; }
if confirm_logout_tailscale; then
fail "cancelled logout was reported as successful"
else
result=$?
[[ "$result" -eq "$RC_CANCELLED" ]] || fail "cancelled logout returned an unexpected status"
fi
[[ "$LOGIN_STATE" -eq 1 && "$LOGOUT_CALLS" -eq 0 ]] \
|| fail "cancelled logout changed the login state"
logout_tailscale || fail "mocked Tailscale logout failed"
[[ "$LOGIN_STATE" -eq 0 && "$LOGOUT_CALLS" -eq 1 ]] \
|| fail "logout did not transition to the disconnected state"
logout_tailscale || fail "already-logged-out operation should be successful"
[[ "$LOGOUT_CALLS" -eq 1 ]] || fail "already-logged-out device logged out again"
# Exercise the registration orchestration without contacting Tailscale or changing the host.
export TS_OAUTH_CLIENT_ID="test-client"
export TS_OAUTH_CLIENT_SECRET="test-secret"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment