Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
Q
qarm_setup_scripts
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
jaysurplus
qarm_setup_scripts
Commits
85b2d526
Commit
85b2d526
authored
Oct 25, 2026
by
jaysurplus@126.com
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
docs(iteration): 归档项目迭代 V0.4.2 - 路由模式与 TUI 完善
parent
4fa3be35
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
1110 additions
and
84 deletions
+1110
-84
docs/tailscale/README.md
+94
-14
meta/ARCHITECTURE.md
+9
-3
meta/FEATURES.md
+1
-1
meta/ITERATIONS.md
+11
-0
meta/PROJECT_MANIFEST.json
+9
-4
meta/REGRESSION.md
+12
-1
scripts/tailscale/tailscale-route-manager.sh
+705
-60
tests/tailscale/test-route-manager.sh
+269
-1
No files found.
docs/tailscale/README.md
View file @
85b2d526
# Tailscale 路由管理器
# 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.11.201/32`
(维护端口
`5800`
)
-
`192.168.22.0/24`
-
`192.168.22.202/32`
(维护端口
`5800`
)
4via6 模式下,每台 QArm 必须配置唯一的
`Site ID`
。端口不属于 subnet route;应在 Tailnet Policy 中只允许维护人员访问生成的 4via6 地址的 TCP
`5800`
。仅旧 IPv4 模式用于兼容历史部署,不需要 Site ID,但重复网段场景只能由一台 QArm 发布。
## 功能
## 功能
-
检测、下载并安装 Tailscale。
-
检测、下载并安装 Tailscale。
-
检测
`tailscaled`
和登录状态,必要时运行
`tailscale up`
。
-
检测
`tailscaled`
和登录状态,必要时运行
`tailscale up`
。
-
提供独立的 Tailscale 登录和退出登录操作;登录时可选择网页认证或 OAuth 自动注册,退出登录前需要确认。
-
使用 OAuth Client 在线读取可分配标签,在 TUI 中输入设备名称并多选标签,自动注册新设备。
-
使用 OAuth Client 在线读取可分配标签,在 TUI 中输入设备名称并多选标签,自动注册新设备。
-
注册名称默认从
`/opt/armaster/data/hw.json`
读取
`sn`
并生成
`<sn>-`
前缀,文件不可用时回退到系统 hostname。
-
注册名称默认从
`/opt/armaster/data/hw.json`
读取
`sn`
并生成
`<sn>-`
前缀,文件不可用时回退到系统 hostname。
-
每台设备动态创建 10 分钟有效、单次使用、非临时且预批准的 Auth Key。
-
每台设备动态创建 10 分钟有效、单次使用、非临时且预批准的 Auth Key。
-
启用 IPv4 forwarding,并在系统支持时启用 IPv6 forwarding。
-
启用 IPv4 forwarding,并在系统支持时启用 IPv6 forwarding。
-
使用
`tailscale set --advertise-routes=...`
发布两个固定网段。
-
支持“仅 4via6(推荐)”和“仅旧 IPv4”两种互斥模式;切换前必须先禁用当前路由。
-
识别路由的“已启用”“部分启用”和“已禁用”状态。
-
4via6 模式保存本设备唯一的
`Site ID`
,使用
`tailscale debug via`
为两个 IPv4
`/32`
生成路由。
-
禁用时只移除本脚本管理的两个网段,保留设备上其他 advertised routes。
-
旧 IPv4 模式直接发布
`192.168.11.0/24`
、
`192.168.22.0/24`
。
-
使用
`tailscale set --advertise-routes=...`
发布当前模式的受管路由,并移除另一模式的受管路由。
-
识别路由的“未配置”“已启用”“部分启用”和“已禁用”状态。
-
禁用时只移除本脚本管理的 4via6 和旧版 IPv4 路由,保留设备上其他 advertised routes。
-
仅在 forwarding 由本脚本修改时恢复原始状态。
-
仅在 forwarding 由本脚本修改时恢复原始状态。
-
优先使用支持逐段着色的
`dialog`
,其次使用
`whiptail`
,否则使用普通终端菜单。
-
优先使用支持逐段着色的
`dialog`
,其次使用
`whiptail`
,否则使用普通终端菜单。
-
对安装、运行、连接和 Route 状态使用统一的状态颜色。
-
对安装、运行、连接和 Route 状态使用统一的状态颜色。
-
状态页显示当前设备在 Tailscale 网络中的名称。
-
主菜单提供独立的“网络检测”,完整显示
`tailscale netcheck`
的结果并返回主页面。
## TUI 导航规则
## TUI 导航规则
-
主页面的“退出”是 TUI 正常结束的唯一入口。
-
主页面的“退出”是 TUI 正常结束的唯一入口。
-
安装、登录、启用、禁用和状态查看等分支完成后统一返回主页面。
-
安装、
Site ID 配置、登录、退出
登录、启用、禁用和状态查看等分支完成后统一返回主页面。
-
在分支流程中选择“否”、取消、关闭提示框或操作失败时,也会返回主页面。
-
在分支流程中选择“否”、取消、关闭提示框或操作失败时,也会返回主页面。
-
在
`whiptail`
或
`dialog`
主菜单按 Esc 不会退出脚本,而是重新显示主页面。
-
在
`whiptail`
或
`dialog`
主菜单按 Esc 不会退出脚本,而是重新显示主页面。
-
在“是否现在运行
`tailscale up`
”中选择 No,会取消本次启用并直接返回主页面。
-
在“是否现在运行
`tailscale up`
”中选择 No,会取消本次启用并直接返回主页面。
-
`dialog`
/
`whiptail`
下选择菜单操作后会立即显示中文处理中界面,网络检测、状态读取和 OAuth 请求期间不会短暂露出底层终端。
-
网页登录和首次安装器执行需要展示认证 URL 或安装日志,因此这两类明确的交互仍会进入终端;结束后返回主页面。
-
`install`
、
`enable`
、
`disable`
等命令行直接调用模式不进入 TUI 循环,仍通过退出码向调用方报告结果。
-
`install`
、
`enable`
、
`disable`
等命令行直接调用模式不进入 TUI 循环,仍通过退出码向调用方报告结果。
-
状态页显示脚本版本,可用于确认目标设备是否仍在运行旧副本。
-
状态页显示脚本版本,可用于确认目标设备是否仍在运行旧副本。
...
@@ -49,6 +59,7 @@ sudo apt-get install -y dialog
...
@@ -49,6 +59,7 @@ sudo apt-get install -y dialog
## 支持环境
## 支持环境
-
Linux 目标设备。
-
Linux 目标设备。
-
Tailscale
`1.102.3`
或更新版本;这是 4via6 subnet router 当前的最低安全版本。
-
Bash、Python 3、
`sysctl`
、
`sed`
、
`awk`
、
`grep`
、
`sort`
和
`paste`
。
-
Bash、Python 3、
`sysctl`
、
`sed`
、
`awk`
、
`grep`
、
`sort`
和
`paste`
。
-
安装阶段需要 HTTPS 网络访问和
`curl`
。
-
安装阶段需要 HTTPS 网络访问和
`curl`
。
-
自动补装
`curl`
时支持
`apt-get`
、
`dnf`
、
`yum`
或
`zypper`
。
-
自动补装
`curl`
时支持
`apt-get`
、
`dnf`
、
`yum`
或
`zypper`
。
...
@@ -78,10 +89,15 @@ sudo tailscale-route-manager
...
@@ -78,10 +89,15 @@ sudo tailscale-route-manager
```
bash
```
bash
sudo
tailscale-route-manager install
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
\
sudo
--preserve-env
=
TS_OAUTH_CLIENT_ID,TS_OAUTH_CLIENT_SECRET
\
tailscale-route-manager register
tailscale-route-manager register
sudo
tailscale-route-manager
enable
sudo
tailscale-route-manager
enable
tailscale-route-manager status
tailscale-route-manager status
tailscale-route-manager netcheck
sudo
tailscale-route-manager disable
sudo
tailscale-route-manager disable
```
```
...
@@ -123,6 +139,8 @@ OAuth 自动注册适用于批量部署新的、尚未连接到 Tailscale 的 QA
...
@@ -123,6 +139,8 @@ OAuth 自动注册适用于批量部署新的、尚未连接到 Tailscale 的 QA
默认
Provisioner
标签是
`tag
:
qarm-provisioner`。如
Tailnet
使用其他名称,可额外设置
`TS_OAUTH_OWNER_TAG`,并把它加入
`sudo
--preserve-env`。TUI
会读取完整
`tagOwners`,但只展示该
Provisioner
直接拥有、OAuth
Client
实际可以分配的标签。
默认
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`
必须完全一致,避免混合大小写被控制端拒绝。
Provisioner
和设备标签建议全部使用小写,例如
`tag
:
qarm`。Auth
Key
标签与
`tailscale
up
--advertise-tags`
必须完全一致,避免混合大小写被控制端拒绝。
设备名称输入框会优先读取
`/opt/armaster/data/hw.json`
的
`sn`,例如默认显示
`CA
2
EUP
-20261020-001
-`,用户在末尾补充名称即可。脚本只读该文件;文件缺失、JSON
无效、SN
不符合
DNS
标签字符规则或没有后缀空间时,默认值回退到系统
hostname。
设备名称输入框会优先读取
`/opt/armaster/data/hw.json`
的
`sn`,例如默认显示
`CA
2
EUP
-20261020-001
-`,用户在末尾补充名称即可。脚本只读该文件;文件缺失、JSON
无效、SN
不符合
DNS
标签字符规则或没有后缀空间时,默认值回退到系统
hostname。
...
@@ -131,6 +149,16 @@ Provisioner 和设备标签建议全部使用小写,例如 `tag:qarm`。Auth K
...
@@ -131,6 +149,16 @@ Provisioner 和设备标签建议全部使用小写,例如 `tag:qarm`。Auth K
OAuth
注册仅用于新设备。已连接设备不会被脚本自动重新认证,避免意外改变设备身份、标签或现有配置。未提供
OAuth
环境变量时,启用路由仍保留原有浏览器登录流程。
OAuth
注册仅用于新设备。已连接设备不会被脚本自动重新认证,避免意外改变设备身份、标签或现有配置。未提供
OAuth
环境变量时,启用路由仍保留原有浏览器登录流程。
##
登录与退出登录
主菜单中的“登录
Tailscale”会提供两种方式:选择“网页登录”时使用
`tailscale
login`
打开标准登录流程;选择“OAuth
自动注册”时复用设备名称和标签选择流程,无需点击登录链接。设备已经连接时不会重复登录。主菜单仍保留“OAuth
注册新设备”快捷入口。
logout
后可以使用任一种方式重新认证。OAuth
方式按新设备流程创建登录身份,旧设备记录可能仍保留在
Tailscale
管理后台,需要管理员按实际情况清理。
“退出登录”使用
`tailscale
logout`,会立即断开设备并使当前登录凭据失效,下次登录需要重新认证。
登录和退出登录只管理
Tailscale
身份状态。退出登录不会自动移除
advertised
routes,也不会恢复
IPv
4
/IPv
6
forwarding;如需清理本脚本管理的子网路由和转发状态,应先选择“禁用子网路由”。
##
安装安全边界
##
安装安全边界
脚本不会使用
`curl
|
sh`。它会:
脚本不会使用
`curl
|
sh`。它会:
...
@@ -142,17 +170,56 @@ OAuth 注册仅用于新设备。已连接设备不会被脚本自动重新认
...
@@ -142,17 +170,56 @@ OAuth 注册仅用于新设备。已连接设备不会被脚本自动重新认
SHA
-256
仅用于本次文件识别;由于官方页面未在此流程中提供固定校验值,它不能替代
TLS、来源审阅或软件包签名验证。需要严格供应链验证时,应改用与目标发行版匹配的
Tailscale
官方软件仓库安装步骤。
SHA
-256
仅用于本次文件识别;由于官方页面未在此流程中提供固定校验值,它不能替代
TLS、来源审阅或软件包签名验证。需要严格供应链验证时,应改用与目标发行版匹配的
Tailscale
官方软件仓库安装步骤。
##
首次启用
##
Site
ID
与
4
via
6
同一
Tailnet
中,每台
QArm
的
Site
ID
必须唯一,允许范围为
`
1-65535
`。脚本不会根据
SN
猜测编号,也不会连接中心服务检查冲突;管理员需要在批量部署清单中分配编号。
执行
`enable`
后,脚本会发布:
可以在
TUI
中选择“配置
4
via
6
Site
ID”,也可以非交互执行:
```
bash
sudo tailscale-route-manager site-id 101
```
配置保存在:
```
text
```
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
...
@@ -164,6 +231,15 @@ sudo tailscale set --accept-routes=true
Windows、macOS、iOS 和 Android 通常会自动接受已批准的 subnet routes。
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 默认值。
Tailscale subnet router 默认使用 SNAT,因此多数场景不需要在局域网路由器上增加到 `100.64.0.0/10` 的返回路由。本脚本不修改 SNAT 默认值。
## 系统变更与恢复
## 系统变更与恢复
...
@@ -172,10 +248,11 @@ Tailscale subnet router 默认使用 SNAT,因此多数场景不需要在局域
...
@@ -172,10 +248,11 @@ Tailscale subnet router 默认使用 SNAT,因此多数场景不需要在局域
- `/etc/sysctl.d/99-tailscale-routes.conf`
- `/etc/sysctl.d/99-tailscale-routes.conf`
- `/var/lib/tailscale-route-manager.state`
- `/var/lib/tailscale-route-manager.state`
- `/var/lib/tailscale-route-manager.site`
状态文件记录脚本首次修改前的 IPv4/IPv6 forwarding 状态。执行 `disable` 时:
状态文件记录脚本首次修改前的 IPv4/IPv6 forwarding 状态。执行 `disable` 时:
1. 从当前 advertised routes 中移除本
脚本管理的两个网段
。
1. 从当前 advertised routes 中移除本
设备已知 Site ID 对应的 4via6 routes,以及旧版管理的两个 IPv4 `/24`
。
2. 保留其他 advertised routes。
2. 保留其他 advertised routes。
3. 删除带有本脚本标记的 sysctl 文件。
3. 删除带有本脚本标记的 sysctl 文件。
4. 恢复记录的 forwarding 状态并删除状态文件。
4. 恢复记录的 forwarding 状态并删除状态文件。
...
@@ -206,11 +283,14 @@ shellcheck scripts/tailscale/tailscale-route-manager.sh
...
@@ -206,11 +283,14 @@ shellcheck scripts/tailscale/tailscale-route-manager.sh
# 目标设备只读检查
# 目标设备只读检查
scripts/tailscale/tailscale-route-manager.sh status
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
sudo tailscale-route-manager enable
tailscale status
tailscale status
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
```
```
最后从另一台已加入同一 tailnet 的设备
访问两个网段中的目标地址
,确认端到端路由可用。
最后从另一台已加入同一 tailnet 的设备
分别访问两个
`via-<site-id>`
MagicDNS 名称的
`5800`
端口
,确认端到端路由可用。
meta/ARCHITECTURE.md
View file @
85b2d526
...
@@ -25,7 +25,8 @@
...
@@ -25,7 +25,8 @@
-
下载并审阅 Tailscale 官方安装器后执行安装。
-
下载并审阅 Tailscale 官方安装器后执行安装。
-
检查
`tailscaled`
、登录状态和当前 advertised routes。
-
检查
`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,在禁用时恢复脚本执行前的状态。
-
在启用子网路由时配置 IP forwarding,在禁用时恢复脚本执行前的状态。
-
通过
`whiptail`
、
`dialog`
或普通终端提供中文单出口 TUI。
-
通过
`whiptail`
、
`dialog`
或普通终端提供中文单出口 TUI。
-
从
`/opt/armaster/data/hw.json`
只读获取
`sn`
作为设备名默认前缀,收集用户补充名称和标签选择,并调用 helper 完成 OAuth 自动注册;配置缺失或无效时回退到系统 hostname。
-
从
`/opt/armaster/data/hw.json`
只读获取
`sn`
作为设备名默认前缀,收集用户补充名称和标签选择,并调用 helper 完成 OAuth 自动注册;配置缺失或无效时回退到系统 hostname。
...
@@ -36,6 +37,7 @@
...
@@ -36,6 +37,7 @@
-
`/etc/sysctl.d/99-tailscale-routes.conf`
:仅在脚本需要开启 forwarding 时创建,包含管理标记。
-
`/etc/sysctl.d/99-tailscale-routes.conf`
:仅在脚本需要开启 forwarding 时创建,包含管理标记。
-
`/var/lib/tailscale-route-manager.state`
:记录 IPv4/IPv6 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`
的短期文件中存在,不属于持久状态。
-
OAuth Secret、Access Token 和 Auth Key:只在进程内或权限为
`0600`
的短期文件中存在,不属于持久状态。
主要执行流:
主要执行流:
...
@@ -44,13 +46,17 @@
...
@@ -44,13 +46,17 @@
主页面
主页面
-> 安装/检查 Tailscale
-> 安装/检查 Tailscale
-> OAuth 注册(设备名称 + 在线标签多选)或浏览器登录
-> OAuth 注册(设备名称 + 在线标签多选)或浏览器登录
-> 选择互斥路由模式
-> 4via6:配置唯一 Site ID 并生成两个 IPv4 /32 对应的 routes
-> 旧 IPv4:使用两个历史 IPv4 /24 routes
-> 读取并合并 advertised routes
-> 读取并合并 advertised routes
-> 移除非当前模式的受管 routes
-> 记录并开启 forwarding
-> 记录并开启 forwarding
-> 发布
受管子网
-> 发布
当前模式的受管 routes
-> 返回主页面
-> 返回主页面
禁用受管路由
禁用受管路由
-> 从当前路由集合移除受管
子网
-> 从当前路由集合移除受管
4via6 和旧版 IPv4 routes
-> 保留其他 advertised routes
-> 保留其他 advertised routes
-> 恢复 forwarding 原状态
-> 恢复 forwarding 原状态
-> 返回主页面
-> 返回主页面
...
...
meta/FEATURES.md
View file @
85b2d526
...
@@ -4,5 +4,5 @@
...
@@ -4,5 +4,5 @@
| --- | --- | --- |
| --- | --- | --- |
| Agent 治理骨架 | Done | AGENTS、workflow、rule、skill 与多 Agent 适配层已初始化 |
| Agent 治理骨架 | Done | AGENTS、workflow、rule、skill 与多 Agent 适配层已初始化 |
| 脚本安全约束 | Done | 已定义敏感信息、幂等、权限、危险操作与验证边界 |
| 脚本安全约束 | 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 | 等待后续按真实目标平台和配置需求添加 |
| 其他 QArm 业务配置脚本 | Pending | 等待后续按真实目标平台和配置需求添加 |
meta/ITERATIONS.md
View file @
85b2d526
# 迭代记录
# 迭代记录
## [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)
## [V0.3.2] OAuth 批量注册与设备命名 (OAuth Provisioning and Device Naming)
-
**功能**
: 新增 OAuth Client 自动注册链路,在线读取 Provisioner 可直接分配的标签,在中文 TUI 中输入设备名称并多选标签;动态创建短期一次性 Auth Key,安全清理临时凭证,并使用
`/opt/armaster/data/hw.json`
的
`sn`
作为设备名称默认前缀。
-
**功能**
: 新增 OAuth Client 自动注册链路,在线读取 Provisioner 可直接分配的标签,在中文 TUI 中输入设备名称并多选标签;动态创建短期一次性 Auth Key,安全清理临时凭证,并使用
`/opt/armaster/data/hw.json`
的
`sn`
作为设备名称默认前缀。
...
...
meta/PROJECT_MANIFEST.json
View file @
85b2d526
{
{
"project_name"
:
"qarm_setup_scripts"
,
"project_name"
:
"qarm_setup_scripts"
,
"version"
:
"0.
3
.2"
,
"version"
:
"0.
4
.2"
,
"iteration_count"
:
4
,
"iteration_count"
:
5
,
"last_update"
:
"2026-10-2
3
"
,
"last_update"
:
"2026-10-2
5
"
,
"stack_summary"
:
[
"stack_summary"
:
[
"PowerShell"
,
"PowerShell"
,
"Shell"
,
"Shell"
,
...
@@ -40,6 +40,11 @@
...
@@ -40,6 +40,11 @@
"tailscale_whiptail_width_safe"
:
true
,
"tailscale_whiptail_width_safe"
:
true
,
"tailscale_dialog_color_ready"
:
true
,
"tailscale_dialog_color_ready"
:
true
,
"tailscale_oauth_registration_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
}
}
}
}
meta/REGRESSION.md
View file @
85b2d526
...
@@ -14,12 +14,22 @@
...
@@ -14,12 +14,22 @@
| 敏感信息检查 | 检查凭证、私钥、token、生产地址和未脱敏日志 | Ready |
| 敏感信息检查 | 检查凭证、私钥、token、生产地址和未脱敏日志 | Ready |
| Tailscale 脚本 Bash 语法 |
`bash -n scripts/tailscale/tailscale-route-manager.sh`
| Ready |
| Tailscale 脚本 Bash 语法 |
`bash -n scripts/tailscale/tailscale-route-manager.sh`
| Ready |
| Tailscale 只读命令 |
`tailscale-route-manager.sh help`
与
`status`
不修改系统 | 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 |
| Forwarding 恢复 | IPv4/IPv6 原始状态均能独立记录和恢复 | Pending |
| 目标设备端到端路由 | 从 tailnet 客户端访问两个 QArm 局域网 | Pending |
| 目标设备端到端路由 | 从 tailnet 客户端访问两个 QArm 局域网 | Pending |
| Tailscale 状态颜色 |
`dialog`
与交互终端显示彩色圆点和中文状态,
`whiptail`
仅显示中文状态,重定向或
`NO_COLOR`
时为纯文本 | Ready |
| Tailscale 状态颜色 |
`dialog`
与交互终端显示彩色圆点和中文状态,
`whiptail`
仅显示中文状态,重定向或
`NO_COLOR`
时为纯文本 | Ready |
| Tailscale 网络名称 | 已连接时状态页显示
`Self.HostName`
;未连接时显示“未连接”,字段缺失时显示“未知” | Ready |
| Tailscale 网络检测 | 主菜单和
`netcheck`
命令显示完整检测输出;失败信息可见且 TUI 返回主页面 | Ready |
| Tailscale TUI 操作过渡 |
`dialog`
/
`whiptail`
菜单分支在执行前显示处理中界面;窗口间不主动清屏暴露底层终端 | Ready |
| Whiptail 边框宽度 | 状态文本不得包含双列 emoji;右侧边框不因状态行发生错位 | Ready |
| Whiptail 边框宽度 | 状态文本不得包含双列 emoji;右侧边框不因状态行发生错位 | Ready |
| Tailscale TUI 单出口 | 分支完成、取消、Esc 或失败后返回主页面;仅主页面“退出”结束 TUI | Ready |
| Tailscale TUI 单出口 | 分支完成、取消、Esc 或失败后返回主页面;仅主页面“退出”结束 TUI | Ready |
| Tailscale 登录 | 未登录设备可执行
`tailscale login`
;已登录设备不重复操作;完成后返回主页面 | Ready |
| Tailscale 登录方式 | TUI 可选择网页登录或 OAuth 自动注册;取消选择返回主页面;OAuth 快捷入口继续保留 | Ready |
| Tailscale 退出登录 | TUI 退出前二次确认;已退出设备不重复操作;不隐式修改子网路由或 forwarding | Ready |
| 未登录时取消启用 |
`tailscale up`
确认框选择 No 后不退出脚本,直接返回主页面 | Ready |
| 未登录时取消启用 |
`tailscale up`
确认框选择 No 后不退出脚本,直接返回主页面 | Ready |
| OAuth 凭证不持久化 | 检查仓库、状态文件和输出不包含 Client Secret、Access Token 或 Auth Key | Ready |
| OAuth 凭证不持久化 | 检查仓库、状态文件和输出不包含 Client Secret、Access Token 或 Auth Key | Ready |
| OAuth 标签权限过滤 | Policy 中仅由 Provisioner 直接拥有的标签进入 TUI 列表 | Ready |
| OAuth 标签权限过滤 | Policy 中仅由 Provisioner 直接拥有的标签进入 TUI 列表 | Ready |
...
@@ -27,5 +37,6 @@
...
@@ -27,5 +37,6 @@
| OAuth 注册失败恢复 | 取消或失败后删除临时 Key 文件并返回主页面 | Ready |
| OAuth 注册失败恢复 | 取消或失败后删除临时 Key 文件并返回主页面 | Ready |
| OAuth Auth Key 描述 | 描述只包含 API 接受的 ASCII 字母数字和连字符,且不超过 50 字符 | Ready |
| OAuth Auth Key 描述 | 描述只包含 API 接受的 ASCII 字母数字和连字符,且不超过 50 字符 | Ready |
| OAuth 标签大小写 | Provisioner 与设备标签统一使用小写,Auth Key 标签和
`tailscale up`
请求必须一致 | Ready |
| OAuth 标签大小写 | Provisioner 与设备标签统一使用小写,Auth Key 标签和
`tailscale up`
请求必须一致 | Ready |
| OAuth 默认 QArm 标签 |
`tag:qarm`
可用时自动包含它,用户可增加其他标签;不存在时保持普通多选 | Ready |
| 硬件 SN 名称前缀 | 有效
`hw.json.sn`
生成
`<sn>-`
默认值;缺失、损坏、非法或过长时回退 hostname | Ready |
| 硬件 SN 名称前缀 | 有效
`hw.json.sn`
生成
`<sn>-`
默认值;缺失、损坏、非法或过长时回退 hostname | Ready |
| OAuth 批量真实注册 | 在至少两台新设备连续运行并核对名称、标签和连接状态 | Pending |
| OAuth 批量真实注册 | 在至少两台新设备连续运行并核对名称、标签和连接状态 | Pending |
scripts/tailscale/tailscale-route-manager.sh
View file @
85b2d526
...
@@ -6,22 +6,31 @@ set -euo pipefail
...
@@ -6,22 +6,31 @@ set -euo pipefail
#
#
# 支持平台:使用 systemd 或兼容服务管理方式的 Linux。
# 支持平台:使用 systemd 或兼容服务管理方式的 Linux。
# 权限要求:安装、修改 sysctl 和配置路由时必须以 root 运行。
# 权限要求:安装、修改 sysctl 和配置路由时必须以 root 运行。
# 管理范围:只管理
MANAGED_ROUTES 中声明的子网
路由。
# 管理范围:只管理
TARGET_IPV4_ROUTES 对应的 4via6 路由和旧版
路由。
# 恢复行为:禁用时保留其他 Tailscale advertised routes,并仅恢复本脚本
# 恢复行为:禁用时保留其他 Tailscale advertised routes,并仅恢复本脚本
# 首次启用 forwarding 前记录的内核状态。
# 首次启用 forwarding 前记录的内核状态。
readonly
INSTALL_URL
=
"https://tailscale.com/install.sh"
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
RC_CANCELLED
=
10
readonly
SYSCTL_FILE
=
"/etc/sysctl.d/99-tailscale-routes.conf"
readonly
SYSCTL_FILE
=
"/etc/sysctl.d/99-tailscale-routes.conf"
readonly
STATE_FILE
=
"/var/lib/tailscale-route-manager.state"
readonly
STATE_FILE
=
"/var/lib/tailscale-route-manager.state"
readonly
MANAGED_MARKER
=
"# Managed by qarm tailscale-route-manager"
readonly
MANAGED_MARKER
=
"# Managed by qarm tailscale-route-manager"
readonly
OAUTH_HELPER_NAME
=
"tailscale-oauth-helper.py"
readonly
OAUTH_HELPER_NAME
=
"tailscale-oauth-helper.py"
readonly
DEFAULT_HARDWARE_CONFIG_FILE
=
"/opt/armaster/data/hw.json"
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_INSTALLER
=
""
TEMP_AUTH_KEY_FILE
=
""
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.11.0/24"
"192.168.22.0/24"
"192.168.22.0/24"
)
)
...
@@ -61,7 +70,6 @@ message_box() {
...
@@ -61,7 +70,6 @@ message_box() {
if
command_exists dialog
;
then
if
command_exists dialog
;
then
dialog
--colors
--title
"
$title
"
--msgbox
"
$message
"
22 76
||
true
dialog
--colors
--title
"
$title
"
--msgbox
"
$message
"
22 76
||
true
clear
||
true
elif
command_exists whiptail
;
then
elif
command_exists whiptail
;
then
whiptail
--title
"
$title
"
--msgbox
"
$message
"
22 76
||
true
whiptail
--title
"
$title
"
--msgbox
"
$message
"
22 76
||
true
else
else
...
@@ -74,6 +82,35 @@ message_box() {
...
@@ -74,6 +82,35 @@ message_box() {
fi
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
()
{
yes_no
()
{
local
title
=
"
$1
"
local
title
=
"
$1
"
local
message
=
"
$2
"
local
message
=
"
$2
"
...
@@ -81,7 +118,6 @@ yes_no() {
...
@@ -81,7 +118,6 @@ yes_no() {
if
command_exists dialog
;
then
if
command_exists dialog
;
then
dialog
--title
"
$title
"
--yesno
"
$message
"
18 76
dialog
--title
"
$title
"
--yesno
"
$message
"
18 76
local
result
=
$?
local
result
=
$?
clear
||
true
return
"
$result
"
return
"
$result
"
elif
command_exists whiptail
;
then
elif
command_exists whiptail
;
then
whiptail
--title
"
$title
"
--yesno
"
$message
"
18 76
whiptail
--title
"
$title
"
--yesno
"
$message
"
18 76
...
@@ -100,7 +136,6 @@ input_value() {
...
@@ -100,7 +136,6 @@ input_value() {
if
command_exists dialog
;
then
if
command_exists dialog
;
then
dialog
--stdout
--title
"
$title
"
--inputbox
"
$message
"
12 76
"
$default_value
"
dialog
--stdout
--title
"
$title
"
--inputbox
"
$message
"
12 76
"
$default_value
"
local
result
=
$?
local
result
=
$?
clear
||
true
return
"
$result
"
return
"
$result
"
elif
command_exists whiptail
;
then
elif
command_exists whiptail
;
then
whiptail
--title
"
$title
"
--inputbox
"
$message
"
12 76
"
$default_value
"
3>&1 1>&2 2>&3
whiptail
--title
"
$title
"
--inputbox
"
$message
"
12 76
"
$default_value
"
3>&1 1>&2 2>&3
...
@@ -114,56 +149,97 @@ input_value() {
...
@@ -114,56 +149,97 @@ input_value() {
select_tags
()
{
select_tags
()
{
local
available_tags
=
"
$1
"
local
available_tags
=
"
$1
"
local
-a
tags
=()
local
-a
tags
=()
local
-a
optional_tags
=()
local
default_available
=
0
local
tag
local
tag
while
IFS
=
read
-r
tag
;
do
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
"
done
<<<
"
$available_tags
"
if
[[
"
${#
tags
[@]
}
"
-eq
0
]]
;
then
if
[[
"
${#
tags
[@]
}
"
-eq
0
]]
;
then
return
1
return
1
fi
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
if
command_exists dialog
;
then
local
-a
options
=()
local
-a
options
=()
for
tag
in
"
${
tags
[@]
}
"
;
do
for
tag
in
"
${
optional_
tags
[@]
}
"
;
do
options+
=(
"
$tag
"
""
"off"
)
options+
=(
"
$tag
"
""
"off"
)
done
done
dialog
--stdout
--separate-output
--title
"选择设备标签"
\
local
selected_output
=
""
--checklist
"使用空格选择标签,使用 Enter 确认:"
22 76 12
\
local
result
=
0
"
${
options
[@]
}
"
selected_output
=
"
$(
dialog
--stdout
--separate-output
--title
"选择设备标签"
\
local
result
=
$?
--checklist
"
$prompt
"
22 76 12
"
${
options
[@]
}
"
)
"
||
result
=
$?
clear
||
true
[[
"
$result
"
-eq
0
]]
||
return
"
$result
"
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
elif
command_exists whiptail
;
then
local
-a
options
=()
local
-a
options
=()
for
tag
in
"
${
tags
[@]
}
"
;
do
for
tag
in
"
${
optional_
tags
[@]
}
"
;
do
options+
=(
"
$tag
"
""
"off"
)
options+
=(
"
$tag
"
""
"off"
)
done
done
whiptail
--separate-output
--title
"选择设备标签"
\
local
selected_output
=
""
--checklist
"使用空格选择标签,使用 Enter 确认:"
22 76 12
\
local
result
=
0
"
${
options
[@]
}
"
3>&1 1>&2 2>&3
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
else
printf
'\n可分配的设备标签:\n\n'
>
&2
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
local
index
for
index
in
"
${
!tags[@]
}
"
;
do
for
index
in
"
${
!
optional_
tags[@]
}
"
;
do
printf
'%d) %s\n'
"
$((
index
+
1
))
"
"
${
tags
[
$index
]
}
"
>
&2
printf
'%d) %s\n'
"
$((
index
+
1
))
"
"
${
optional_
tags
[
$index
]
}
"
>
&2
done
done
printf
'\n请输入序号,多个序号使用逗号分隔;直接回车取消。\n'
>
&2
if
[[
"
$default_available
"
-eq
1
]]
;
then
printf
'\n请输入附加标签序号,多个序号使用逗号分隔;直接回车仅使用默认标签,输入 0 取消。\n'
>
&2
else
printf
'\n请输入序号,多个序号使用逗号分隔;直接回车取消。\n'
>
&2
fi
local
selection
local
selection
read
-r
-p
"标签序号: "
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
=()
local
-a
selected
=()
[[
"
$default_available
"
-eq
1
]]
&&
selected+
=(
"
$DEFAULT_DEVICE_TAG
"
)
local
-a
requested_indices
=()
local
-a
requested_indices
=()
local
raw_index
local
raw_index
IFS
=
','
read
-r
-a
requested_indices
<<<
"
$selection
"
IFS
=
','
read
-r
-a
requested_indices
<<<
"
$selection
"
for
raw_index
in
"
${
requested_indices
[@]
}
"
;
do
for
raw_index
in
"
${
requested_indices
[@]
}
"
;
do
raw_index
=
"
${
raw_index
//[[
:space:]]/
}
"
raw_index
=
"
${
raw_index
//[[
:space:]]/
}
"
if
[[
!
"
$raw_index
"
=
~ ^[0-9]+
$
]]
\
if
[[
!
"
$raw_index
"
=
~ ^[0-9]+
$
]]
\
||
((
raw_index < 1
||
raw_index
>
${#
tags
[@]
}
))
;
then
||
((
raw_index < 1
||
raw_index
>
${#
optional_
tags
[@]
}
))
;
then
return
1
return
1
fi
fi
tag
=
"
${
tags
[
$((
raw_index
-
1
))
]
}
"
tag
=
"
${
optional_
tags
[
$((
raw_index
-
1
))
]
}
"
if
[[
!
"
${
selected
[*]
}
"
=
~
"
${
tag
}
"
]]
;
then
if
[[
!
"
${
selected
[*]
}
"
=
~
"
${
tag
}
"
]]
;
then
selected+
=(
"
$tag
"
)
selected+
=(
"
$tag
"
)
fi
fi
...
@@ -295,6 +371,12 @@ get_backend_state() {
...
@@ -295,6 +371,12 @@ get_backend_state() {
| head
-n
1
| 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
()
{
is_tailscale_logged_in
()
{
[[
"
$(
get_backend_state
||
true
)
"
==
"Running"
]]
[[
"
$(
get_backend_state
||
true
)
"
==
"Running"
]]
}
}
...
@@ -416,6 +498,7 @@ oauth_register_device() {
...
@@ -416,6 +498,7 @@ oauth_register_device() {
local
helper
local
helper
helper
=
"
$(
find_oauth_helper
)
"
helper
=
"
$(
find_oauth_helper
)
"
local
available_tags
local
available_tags
show_tui_progress
"OAuth 自动注册"
"正在读取可分配的设备标签,请稍候……"
if
!
available_tags
=
"
$(
python3
"
$helper
"
list-tags 2>&1
)
"
;
then
if
!
available_tags
=
"
$(
python3
"
$helper
"
list-tags 2>&1
)
"
;
then
message_box
"标签读取失败"
"
$available_tags
"
message_box
"标签读取失败"
"
$available_tags
"
return
1
return
1
...
@@ -453,6 +536,7 @@ $(printf '%s\n' "$selected_tags" | sed 's/^/ /')
...
@@ -453,6 +536,7 @@ $(printf '%s\n' "$selected_tags" | sed 's/^/ /')
fi
fi
local
auth_key
local
auth_key
show_tui_progress
"OAuth 自动注册"
"正在创建临时注册密钥,请稍候……"
if
!
auth_key
=
"
$(
python3
"
$helper
"
create-auth-key
\
if
!
auth_key
=
"
$(
python3
"
$helper
"
create-auth-key
\
--tags
"
$selected_csv
"
\
--tags
"
$selected_csv
"
\
--description
"qarm registration
$device_name
"
\
--description
"qarm registration
$device_name
"
\
...
@@ -467,6 +551,7 @@ $(printf '%s\n' "$selected_tags" | sed 's/^/ /')
...
@@ -467,6 +551,7 @@ $(printf '%s\n' "$selected_tags" | sed 's/^/ /')
unset
auth_key
unset
auth_key
local
up_result
=
0
local
up_result
=
0
show_tui_progress
"OAuth 自动注册"
"正在将设备注册到 Tailscale,请稍候……"
tailscale up
\
tailscale up
\
--auth-key
=
"file:
$TEMP_AUTH_KEY_FILE
"
\
--auth-key
=
"file:
$TEMP_AUTH_KEY_FILE
"
\
--hostname
=
"
$device_name
"
\
--hostname
=
"
$device_name
"
\
...
@@ -515,6 +600,111 @@ ensure_tailscale_ready() {
...
@@ -515,6 +600,111 @@ ensure_tailscale_ready() {
is_tailscale_logged_in
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\n
OAuth 自动注册会按新设备流程创建登录身份,旧设备记录可能仍需在管理后台清理。"
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
()
{
ipv4_forward_enabled
()
{
[[
"
$(
sysctl
-n
net.ipv4.ip_forward 2>/dev/null
||
echo
0
)
"
==
"1"
]]
[[
"
$(
sysctl
-n
net.ipv4.ip_forward 2>/dev/null
||
echo
0
)
"
==
"1"
]]
}
}
...
@@ -622,6 +812,233 @@ restore_forwarding() {
...
@@ -622,6 +812,233 @@ restore_forwarding() {
rm
-f
--
"
$STATE_FILE
"
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
()
{
get_all_advertised_routes
()
{
local
prefs
local
prefs
prefs
=
"
$(
tailscale debug prefs 2>/dev/null
)
"
||
return
1
prefs
=
"
$(
tailscale debug prefs 2>/dev/null
)
"
||
return
1
...
@@ -643,9 +1060,9 @@ get_all_advertised_routes() {
...
@@ -643,9 +1060,9 @@ get_all_advertised_routes() {
route_is_managed
()
{
route_is_managed
()
{
local
candidate
=
"
$1
"
local
candidate
=
"
$1
"
local
managed
local
managed
for
managed
in
"
${
MANAGED_ROUTES
[@]
}
"
;
do
while
IFS
=
read
-r
managed
;
do
[[
"
$candidate
"
==
"
$managed
"
]]
&&
return
0
[[
"
$candidate
"
==
"
$managed
"
]]
&&
return
0
done
done
< <
(
get_all_owned_routes
)
return
1
return
1
}
}
...
@@ -655,17 +1072,24 @@ route_is_advertised() {
...
@@ -655,17 +1072,24 @@ route_is_advertised() {
}
}
all_routes_enabled
()
{
all_routes_enabled
()
{
local
managed_routes
managed_routes
=
"
$(
get_managed_routes
)
"
||
return
1
[[
-n
"
$managed_routes
"
]]
||
return
1
local
route
local
route
for
route
in
"
${
MANAGED_ROUTES
[@]
}
"
;
do
while
IFS
=
read
-r
route
;
do
route_is_advertised
"
$route
"
||
return
1
route_is_advertised
"
$route
"
||
return
1
done
done
<<<
"
$managed_routes
"
}
}
any_route_enabled
()
{
any_route_enabled
()
{
local
managed_routes
managed_routes
=
"
$(
get_managed_routes
)
"
||
return
1
local
route
local
route
for
route
in
"
${
MANAGED_ROUTES
[@]
}
"
;
do
while
IFS
=
read
-r
route
;
do
route_is_advertised
"
$route
"
&&
return
0
route_is_advertised
"
$route
"
&&
return
0
done
done
<<<
"
$managed_routes
"
return
1
return
1
}
}
...
@@ -679,13 +1103,41 @@ routes_without_managed() {
...
@@ -679,13 +1103,41 @@ routes_without_managed() {
merge_with_managed_routes
()
{
merge_with_managed_routes
()
{
local
current_routes
=
"
$1
"
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'
"
$
remaining
_routes
"
printf
'%s\n'
"
$
{
MANAGED_ROUTES
[@]
}
"
printf
'%s\n'
"
$
managed_routes
"
}
| sed
'/^$/d'
| sort
-u
| paste
-sd
, -
}
| sed
'/^$/d'
| sort
-u
| paste
-sd
, -
}
}
enable_routes
()
{
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
local
ready_result
=
0
ensure_tailscale_ready
||
ready_result
=
$?
ensure_tailscale_ready
||
ready_result
=
$?
...
@@ -704,18 +1156,45 @@ enable_routes() {
...
@@ -704,18 +1156,45 @@ enable_routes() {
return
1
return
1
fi
fi
local
merged_routes
merged_routes
=
"
$(
merge_with_managed_routes
"
$current_routes
"
)
"
||
return
1
enable_forwarding
||
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
message_box
"子网路由已启用"
"已发布以下 Tailscale 子网路由:
if
[[
"
$route_mode
"
==
"4via6"
]]
;
then
message_box
"子网路由已启用"
"路由模式:仅 4via6(推荐)
${
MANAGED_ROUTES
[0]
}
已发布以下 4via6 路由:
${
MANAGED_ROUTES
[1]
}
$(
printf
'%s\n'
"
$managed_routes
"
| sed
's/^/ /'
)
对应 IPv4 目标:
$(
printf
'%s\n'
"
${
TARGET_IPV4_ROUTES
[@]
}
"
| sed
's/^/ /'
)
IPv4 Forwarding:
$(
sysctl
-n
net.ipv4.ip_forward
)
IPv4 Forwarding:
$(
sysctl
-n
net.ipv4.ip_forward
)
IPv6 Forwarding:
$(
sysctl
-n
net.ipv6.conf.all.forwarding 2>/dev/null
||
echo
N/A
)
IPv6 Forwarding:
$(
sysctl
-n
net.ipv6.conf.all.forwarding 2>/dev/null
||
echo
N/A
)
首次启用时,还需在 Tailscale Admin Console 中批准这些路由。"
旧版重复 IPv4 /24 路由已从本设备的发布列表移除。
首次启用时,还需在 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
)
本设备已知的 4via6 路由已从发布列表移除。
注意:多个 QArm 设备存在重复 IPv4 网段时,只能有一台设备发布这些旧 IPv4 路由。"
fi
}
}
disable_routes
()
{
disable_routes
()
{
...
@@ -724,6 +1203,18 @@ disable_routes() {
...
@@ -724,6 +1203,18 @@ disable_routes() {
return
0
return
0
fi
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
local
current_routes
if
!
current_routes
=
"
$(
get_all_advertised_routes
)
"
;
then
if
!
current_routes
=
"
$(
get_all_advertised_routes
)
"
;
then
message_box
"禁用失败"
"无法读取当前 advertised routes。为避免删除其他路由,未执行任何修改。"
message_box
"禁用失败"
"无法读取当前 advertised routes。为避免删除其他路由,未执行任何修改。"
...
@@ -737,13 +1228,16 @@ disable_routes() {
...
@@ -737,13 +1228,16 @@ disable_routes() {
remaining_csv
=
"
$(
printf
'%s\n'
"
$remaining_routes
"
| paste
-sd
, -
)
"
remaining_csv
=
"
$(
printf
'%s\n'
"
$remaining_routes
"
| paste
-sd
, -
)
"
fi
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
restore_forwarding
||
return
1
message_box
"子网路由已禁用"
"已停止发布本脚本管理的
子网:
message_box
"子网路由已禁用"
"已停止发布本脚本管理的
4via6 路由和旧版 IPv4 路由。
${
MANAGED_ROUTES
[0]
}
IPv4 目标:
${
MANAGED_ROUTES
[1]
}
$(
printf
'%s\n'
"
${
TARGET_IPV4_ROUTES
[@]
}
"
| sed
's/^/ /'
)
设备上其他 advertised routes 已保留。
设备上其他 advertised routes 已保留。
...
@@ -752,7 +1246,9 @@ IPv6 Forwarding: $(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null || echo N/
...
@@ -752,7 +1246,9 @@ IPv6 Forwarding: $(sysctl -n net.ipv6.conf.all.forwarding 2>/dev/null || echo N/
}
}
get_route_status
()
{
get_route_status
()
{
if
all_routes_enabled
;
then
if
!
get_managed_routes
>
/dev/null 2>&1
;
then
echo
"未配置"
elif
all_routes_enabled
;
then
echo
"已启用"
echo
"已启用"
elif
any_route_enabled
;
then
elif
any_route_enabled
;
then
echo
"部分启用"
echo
"部分启用"
...
@@ -823,12 +1319,32 @@ get_status_text() {
...
@@ -823,12 +1319,32 @@ get_status_text() {
local
installed
local
installed
local
daemon
local
daemon
local
connected
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
advertised
=
"无"
local
route_status
local
route_status
installed
=
"
$(
format_status_value error
"未安装"
"
$mode
"
)
"
installed
=
"
$(
format_status_value error
"未安装"
"
$mode
"
)
"
daemon
=
"
$(
format_status_value warn
"未知"
"
$mode
"
)
"
daemon
=
"
$(
format_status_value warn
"未知"
"
$mode
"
)
"
connected
=
"
$(
format_status_value error
"未连接"
"
$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
if
is_tailscale_installed
;
then
installed
=
"
$(
format_status_value ok
"已安装"
"
$mode
"
)
"
installed
=
"
$(
format_status_value ok
"已安装"
"
$mode
"
)
"
...
@@ -841,11 +1357,42 @@ get_status_text() {
...
@@ -841,11 +1357,42 @@ get_status_text() {
fi
fi
if
is_tailscale_logged_in
;
then
if
is_tailscale_logged_in
;
then
connected
=
"
$(
format_status_value ok
"已连接"
"
$mode
"
)
"
connected
=
"
$(
format_status_value ok
"已连接"
"
$mode
"
)
"
network_name
=
"
$(
get_tailscale_network_name 2>/dev/null
||
true
)
"
[[
-z
"
$network_name
"
]]
&&
network_name
=
"未知"
fi
fi
advertised
=
"
$(
get_all_advertised_routes 2>/dev/null
||
echo
未知
)
"
advertised
=
"
$(
get_all_advertised_routes 2>/dev/null
||
echo
未知
)
"
[[
-z
"
$advertised
"
]]
&&
advertised
=
"无"
[[
-z
"
$advertised
"
]]
&&
advertised
=
"无"
fi
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
)
"
route_status
=
"
$(
get_route_status
)
"
case
"
$route_status
"
in
case
"
$route_status
"
in
已启用
)
route_status
=
"
$(
format_status_value ok
"
$route_status
"
"
$mode
"
)
"
;;
已启用
)
route_status
=
"
$(
format_status_value ok
"
$route_status
"
"
$mode
"
)
"
;;
...
@@ -863,13 +1410,20 @@ Tailscale 状态
...
@@ -863,13 +1410,20 @@ Tailscale 状态
安装状态 :
$installed
安装状态 :
$installed
运行状态 :
$daemon
运行状态 :
$daemon
连接状态 :
$connected
连接状态 :
$connected
网络名称 :
$network_name
路由模式 :
$route_mode_label
Site ID :
$site_id
IPv4 维护目标
------------------------------------------------------------
$(
printf
'%s\n'
"
${
TARGET_IPV4_ROUTES
[@]
}
"
)
受管子网
路由
当前受管
路由
------------------------------------------------------------
------------------------------------------------------------
${
MANAGED_ROUTES
[0]
}
$managed_routes_text
${
MANAGED_ROUTES
[1]
}
路由状态 :
$route_status
路由状态 :
$route_status
非当前模式路由 :
$inactive_status
IP 转发
IP 转发
------------------------------------------------------------
------------------------------------------------------------
...
@@ -885,6 +1439,10 @@ EOF
...
@@ -885,6 +1439,10 @@ EOF
get_main_action
()
{
get_main_action
()
{
if
!
is_tailscale_installed
;
then
if
!
is_tailscale_installed
;
then
echo
"安装 Tailscale"
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
elif
all_routes_enabled
;
then
echo
"禁用子网路由"
echo
"禁用子网路由"
else
else
...
@@ -892,9 +1450,20 @@ get_main_action() {
...
@@ -892,9 +1450,20 @@ get_main_action() {
fi
fi
}
}
get_main_status_text
()
{
get_status_text
"
${
1
:-
text
}
"
| awk
'
NR <= 11 { print; next }
/^路由状态[[:space:]]*:/ || /^非当前模式路由[[:space:]]*:/ { print }
'
}
run_main_action
()
{
run_main_action
()
{
if
!
is_tailscale_installed
;
then
if
!
is_tailscale_installed
;
then
install_tailscale
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
elif
all_routes_enabled
;
then
disable_routes
disable_routes
else
else
...
@@ -911,11 +1480,39 @@ show_status() {
...
@@ -911,11 +1480,39 @@ show_status() {
elif
[[
-t
1
]]
&&
[[
-z
"
${
NO_COLOR
:-}
"
]]
&&
[[
"
${
TERM
:-}
"
!=
"dumb"
]]
;
then
elif
[[
-t
1
]]
&&
[[
-z
"
${
NO_COLOR
:-}
"
]]
&&
[[
"
${
TERM
:-}
"
!=
"dumb"
]]
;
then
mode
=
"ansi"
mode
=
"ansi"
fi
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
()
{
run_tui_branch
()
{
local
progress_message
=
"
$1
"
shift
# TUI 分支中的取消或命令失败必须返回主页面,不能被全局 set -e 终止。
# TUI 分支中的取消或命令失败必须返回主页面,不能被全局 set -e 终止。
show_tui_progress
"Tailscale 路由管理器"
"
$progress_message
"
set
+e
set
+e
"
$@
"
"
$@
"
set
-e
set
-e
...
@@ -931,20 +1528,30 @@ run_menu() {
...
@@ -931,20 +1528,30 @@ run_menu() {
if
command_exists dialog
;
then
if
command_exists dialog
;
then
if
!
choice
=
"
$(
dialog
--colors
--stdout
--no-cancel
--title
"Tailscale 路由管理器"
\
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
"
\
"toggle"
"
$action
"
\
"route-mode"
"选择路由模式"
\
"site-id"
"配置 4via6 Site ID"
\
"login"
"登录 Tailscale"
\
"logout"
"退出登录"
\
"register"
"OAuth 注册新设备"
\
"register"
"OAuth 注册新设备"
\
"status"
"刷新/查看状态"
\
"status"
"刷新/查看状态"
\
"netcheck"
"网络检测"
\
"install"
"安装/检查 Tailscale"
\
"install"
"安装/检查 Tailscale"
\
"exit"
"退出"
)
"
;
then
"exit"
"退出"
)
"
;
then
continue
continue
fi
fi
elif
command_exists whiptail
;
then
elif
command_exists whiptail
;
then
if
!
choice
=
"
$(
whiptail
--nocancel
--title
"Tailscale 路由管理器"
\
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
"
\
"toggle"
"
$action
"
\
"route-mode"
"选择路由模式"
\
"site-id"
"配置 4via6 Site ID"
\
"login"
"登录 Tailscale"
\
"logout"
"退出登录"
\
"register"
"OAuth 注册新设备"
\
"register"
"OAuth 注册新设备"
\
"status"
"刷新/查看状态"
\
"status"
"刷新/查看状态"
\
"netcheck"
"网络检测"
\
"install"
"安装/检查 Tailscale"
\
"install"
"安装/检查 Tailscale"
\
"exit"
"退出"
3>&1 1>&2 2>&3
)
"
;
then
"exit"
"退出"
3>&1 1>&2 2>&3
)
"
;
then
continue
continue
...
@@ -953,31 +1560,51 @@ run_menu() {
...
@@ -953,31 +1560,51 @@ run_menu() {
clear
clear
get_status_text
get_status_text
printf
'\n============================================================\n'
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'
printf
'============================================================\n'
read
-r
-p
"请选择 [1-
5
]: "
choice
read
-r
-p
"请选择 [1-
10
]: "
choice
case
"
$choice
"
in
case
"
$choice
"
in
1
)
choice
=
"toggle"
;;
1
)
choice
=
"toggle"
;;
2
)
choice
=
"register"
;;
2
)
choice
=
"route-mode"
;;
3
)
choice
=
"status"
;;
3
)
choice
=
"site-id"
;;
4
)
choice
=
"install"
;;
4
)
choice
=
"login"
;;
5
)
choice
=
"exit"
;;
5
)
choice
=
"logout"
;;
6
)
choice
=
"register"
;;
7
)
choice
=
"status"
;;
8
)
choice
=
"netcheck"
;;
9
)
choice
=
"install"
;;
10
)
choice
=
"exit"
;;
*
)
continue
;;
*
)
continue
;;
esac
esac
fi
fi
case
"
$choice
"
in
case
"
$choice
"
in
toggle
)
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
)
register
)
run_tui_branch oauth_register_device
run_tui_branch
"正在准备 OAuth 自动注册……"
oauth_register_device
;;
;;
status
)
status
)
run_tui_branch show_status
run_tui_branch
"正在读取 Tailscale 状态……"
show_status
;;
netcheck
)
run_tui_branch
"正在执行网络检测,请稍候……"
show_netcheck
;;
;;
install
)
install
)
run_tui_branch install_tailscale
run_tui_branch
"正在检查 Tailscale 安装状态……"
install_tailscale
;;
;;
exit
)
break
;;
exit
)
break
;;
*
)
continue
;;
*
)
continue
;;
...
@@ -993,10 +1620,15 @@ show_help() {
...
@@ -993,10 +1620,15 @@ show_help() {
命令:
命令:
menu 打开交互菜单(默认)
menu 打开交互菜单(默认)
install 安装或检查 Tailscale
install 安装或检查 Tailscale
route-mode 配置路由模式(4via6 或 legacy)
site-id 配置本设备唯一的 4via6 Site ID(1-65535)
login 登录 Tailscale
logout 退出 Tailscale,并使当前登录凭据失效
register 通过 OAuth 选择设备名称和标签并自动注册
register 通过 OAuth 选择设备名称和标签并自动注册
enable 登录并发布本脚本管理的子网路由
enable 登录并发布本脚本管理的子网路由
disable 停止发布本脚本管理的子网路由并恢复 forwarding
disable 停止发布本脚本管理的子网路由并恢复 forwarding
status 输出状态,不修改系统
status 输出状态,不修改系统
netcheck 执行 Tailscale 网络检测并输出完整结果
help 显示帮助
help 显示帮助
EOF
EOF
}
}
...
@@ -1008,14 +1640,27 @@ main() {
...
@@ -1008,14 +1640,27 @@ main() {
status
)
status
)
get_status_text
get_status_text
;;
;;
netcheck
)
run_netcheck
;;
help
|
-h
|
--help
)
help
|
-h
|
--help
)
show_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
require_root
case
"
$action
"
in
case
"
$action
"
in
menu
)
run_menu
;;
menu
)
run_menu
;;
install
)
install_tailscale
;;
install
)
install_tailscale
;;
login
)
login_tailscale
;;
logout
)
logout_tailscale
;;
register
)
oauth_register_device
;;
register
)
oauth_register_device
;;
enable
)
enable_routes
;;
enable
)
enable_routes
;;
disable
)
disable_routes
;;
disable
)
disable_routes
;;
...
...
tests/tailscale/test-route-manager.sh
View file @
85b2d526
...
@@ -3,6 +3,8 @@
...
@@ -3,6 +3,8 @@
set
-euo
pipefail
set
-euo
pipefail
repo_root
=
"
$(
cd
--
"
$(
dirname
--
"
${
BASH_SOURCE
[0]
}
"
)
/../.."
&&
pwd
)
"
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
# shellcheck source=../../scripts/tailscale/tailscale-route-manager.sh
source
"
$repo_root
/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
...
@@ -20,7 +22,7 @@ if valid_device_name "qarm_001"; then
fi
fi
hw_file
=
"
$(
mktemp
)
"
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
"
printf
'%s\n'
'{"sn":"CA2EUP-20261020-001"}'
>
"
$hw_file
"
QARM_HW_CONFIG_FILE
=
"
$hw_file
"
QARM_HW_CONFIG_FILE
=
"
$hw_file
"
export
QARM_HW_CONFIG_FILE
export
QARM_HW_CONFIG_FILE
...
@@ -40,18 +42,284 @@ helper_path="$(find_oauth_helper)"
...
@@ -40,18 +42,284 @@ helper_path="$(find_oauth_helper)"
[[
"
$helper_path
"
==
"
$repo_root
/scripts/tailscale/tailscale-oauth-helper.py"
]]
\
[[
"
$helper_path
"
==
"
$repo_root
/scripts/tailscale/tailscale-oauth-helper.py"
]]
\
||
fail
"OAuth helper was not found beside the manager"
||
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
\n
fd7a: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
\n
10.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.
# Force the ordinary terminal adapter so this test does not depend on local TUI packages.
command_exists
()
{
command_exists
()
{
return
1
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
\n
192.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
\n
tag:qarm-router'
)
"
selected
=
"
$(
printf
'1,2\n'
| select_tags
$'tag:germany
\n
tag:qarm-router'
)
"
[[
"
$selected
"
==
$'tag:germany
\n
tag:qarm-router'
]]
\
[[
"
$selected
"
==
$'tag:germany
\n
tag:qarm-router'
]]
\
||
fail
"terminal checklist did not return selected tags"
||
fail
"terminal checklist did not return selected tags"
selected
=
"
$(
printf
'\n'
| select_tags
$'tag:qarm
\n
tag:europe
\n
tag:sea'
)
"
[[
"
$selected
"
==
"tag:qarm"
]]
\
||
fail
"default qarm tag was not selected"
selected
=
"
$(
printf
'1,2\n'
| select_tags
$'tag:qarm
\n
tag:europe
\n
tag:sea'
)
"
[[
"
$selected
"
==
$'tag:qarm
\n
tag:europe
\n
tag:sea'
]]
\
||
fail
"optional tags were not added to the default qarm tag"
if
printf
'0\n'
| select_tags
$'tag:qarm
\n
tag: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
=
"
$(
printf
'\n'
| input_value
"测试"
"设备名称"
"qarm-default"
)
"
[[
"
$default_value
"
==
"qarm-default"
]]
||
fail
"terminal input default was not preserved"
[[
"
$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.
# Exercise the registration orchestration without contacting Tailscale or changing the host.
export
TS_OAUTH_CLIENT_ID
=
"test-client"
export
TS_OAUTH_CLIENT_ID
=
"test-client"
export
TS_OAUTH_CLIENT_SECRET
=
"test-secret"
export
TS_OAUTH_CLIENT_SECRET
=
"test-secret"
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment