Commit 4fa3be35 by jaysurplus@126.com

docs(iteration): 归档项目迭代 V0.3.2 - OAuth 批量注册与设备命名

新增 OAuth Client 自动注册、标签选择、短期 Auth Key 安全清理和硬件 SN 名称前缀。
补充隔离测试、用户文档、规格、回归清单与已完成计划归档,并保留真实成功入网和多设备验收风险。
parent 8e487009
...@@ -15,3 +15,5 @@ Thumbs.db ...@@ -15,3 +15,5 @@ Thumbs.db
*.log *.log
*.tmp *.tmp
*.swp *.swp
__pycache__/
*.pyc
...@@ -23,7 +23,7 @@ QArm 必要配置与环境初始化脚本仓库。 ...@@ -23,7 +23,7 @@ QArm 必要配置与环境初始化脚本仓库。
| 脚本 | 用途 | 文档 | | 脚本 | 用途 | 文档 |
| --- | --- | --- | | --- | --- | --- |
| `scripts/tailscale/tailscale-route-manager.sh` | 在目标 Linux 设备安装 Tailscale,并管理 `192.168.11.0/24`、`192.168.22.0/24` 子网路由 | `docs/tailscale/README.md` | | `scripts/tailscale/tailscale-route-manager.sh`、`tailscale-oauth-helper.py` | 在目标 Linux 设备安装和注册 Tailscale,选择设备标签,并管理 `192.168.11.0/24`、`192.168.22.0/24` 子网路由 | `docs/tailscale/README.md` |
## Agent 初始化 ## Agent 初始化
......
...@@ -9,6 +9,9 @@ ...@@ -9,6 +9,9 @@
- 检测、下载并安装 Tailscale。 - 检测、下载并安装 Tailscale。
- 检测 `tailscaled` 和登录状态,必要时运行 `tailscale up`。 - 检测 `tailscaled` 和登录状态,必要时运行 `tailscale up`。
- 使用 OAuth Client 在线读取可分配标签,在 TUI 中输入设备名称并多选标签,自动注册新设备。
- 注册名称默认从 `/opt/armaster/data/hw.json` 读取 `sn` 并生成 `<sn>-` 前缀,文件不可用时回退到系统 hostname。
- 每台设备动态创建 10 分钟有效、单次使用、非临时且预批准的 Auth Key。
- 启用 IPv4 forwarding,并在系统支持时启用 IPv6 forwarding。 - 启用 IPv4 forwarding,并在系统支持时启用 IPv6 forwarding。
- 使用 `tailscale set --advertise-routes=...` 发布两个固定网段。 - 使用 `tailscale set --advertise-routes=...` 发布两个固定网段。
- 识别路由的“已启用”“部分启用”和“已禁用”状态。 - 识别路由的“已启用”“部分启用”和“已禁用”状态。
...@@ -46,16 +49,20 @@ sudo apt-get install -y dialog ...@@ -46,16 +49,20 @@ sudo apt-get install -y dialog
## 支持环境 ## 支持环境
- Linux 目标设备。 - Linux 目标设备。
- Bash、`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`。
- 安装及所有修改操作需要 root 权限。 - 安装及所有修改操作需要 root 权限。
## 安装到目标设备 ## 安装到目标设备
先把脚本复制到设备,再执行: 先把管理器和 OAuth helper 复制到设备,再执行:
```bash ```bash
sudo install -d -m 0755 /usr/local/lib/qarm-setup-scripts
sudo install -m 0755 \
scripts/tailscale/tailscale-oauth-helper.py \
/usr/local/lib/qarm-setup-scripts/tailscale-oauth-helper.py
sudo install -m 0755 \ sudo install -m 0755 \
scripts/tailscale/tailscale-route-manager.sh \ scripts/tailscale/tailscale-route-manager.sh \
/usr/local/sbin/tailscale-route-manager /usr/local/sbin/tailscale-route-manager
...@@ -71,11 +78,59 @@ sudo tailscale-route-manager ...@@ -71,11 +78,59 @@ sudo tailscale-route-manager
```bash ```bash
sudo tailscale-route-manager install sudo tailscale-route-manager install
sudo --preserve-env=TS_OAUTH_CLIENT_ID,TS_OAUTH_CLIENT_SECRET \
tailscale-route-manager register
sudo tailscale-route-manager enable sudo tailscale-route-manager enable
tailscale-route-manager status tailscale-route-manager status
sudo tailscale-route-manager disable sudo tailscale-route-manager disable
``` ```
## OAuth 自动注册
OAuth 自动注册适用于批量部署新的、尚未连接到 Tailscale 的 QArm 设备。管理器不会修改 Tailnet Policy;管理员必须提前完成以下配置:
1. 在 Tailnet Policy 中定义 Provisioner 标签和设备标签的直接所有权:
```json
{
"tagOwners": {
"tag:qarm-provisioner": [],
"tag:qarm-router": ["tag:qarm-provisioner"],
"tag:germany": ["tag:qarm-provisioner"]
}
}
```
2. 创建 OAuth Client,并按最小权限授予:
- `Policy File`: Read
- `Devices / Core`: Read
- `Devices / Posture Attributes`: Read
- `Keys / Auth Keys`: Read、Write
- OAuth 标签:`tag:qarm-provisioner`
3. 通过环境变量临时注入凭证:
```bash
read -r -p 'OAuth Client ID: ' TS_OAUTH_CLIENT_ID
read -r -s -p 'OAuth Client Secret: ' TS_OAUTH_CLIENT_SECRET
printf '\n'
export TS_OAUTH_CLIENT_ID TS_OAUTH_CLIENT_SECRET
sudo --preserve-env=TS_OAUTH_CLIENT_ID,TS_OAUTH_CLIENT_SECRET \
tailscale-route-manager
```
默认 Provisioner 标签是 `tag:qarm-provisioner`。如 Tailnet 使用其他名称,可额外设置 `TS_OAUTH_OWNER_TAG`,并把它加入 `sudo --preserve-env`。TUI 会读取完整 `tagOwners`,但只展示该 Provisioner 直接拥有、OAuth Client 实际可以分配的标签。
Provisioner 和设备标签建议全部使用小写,例如 `tag:qarm`。Auth Key 标签与 `tailscale up --advertise-tags` 必须完全一致,避免混合大小写被控制端拒绝。
设备名称输入框会优先读取 `/opt/armaster/data/hw.json` 的 `sn`,例如默认显示 `CA2EUP-20261020-001-`,用户在末尾补充名称即可。脚本只读该文件;文件缺失、JSON 无效、SN 不符合 DNS 标签字符规则或没有后缀空间时,默认值回退到系统 hostname。
注册流程不会把 OAuth Secret、Access Token 或 Auth Key 写入仓库和持久状态。短期 Auth Key 会先写入权限为 `0600` 的临时文件,供 `tailscale up` 读取,并在命令结束或脚本收到退出信号时删除。Auth Key 已创建但注册失败时,未使用的 Key 最多在 10 分钟后过期。
OAuth 注册仅用于新设备。已连接设备不会被脚本自动重新认证,避免意外改变设备身份、标签或现有配置。未提供 OAuth 环境变量时,启用路由仍保留原有浏览器登录流程。
## 安装安全边界 ## 安装安全边界
脚本不会使用 `curl | sh`。它会: 脚本不会使用 `curl | sh`。它会:
...@@ -141,6 +196,11 @@ Tailscale subnet router 默认使用 SNAT,因此多数场景不需要在局域 ...@@ -141,6 +196,11 @@ Tailscale subnet router 默认使用 SNAT,因此多数场景不需要在局域
# 语法检查 # 语法检查
bash -n scripts/tailscale/tailscale-route-manager.sh bash -n scripts/tailscale/tailscale-route-manager.sh
# OAuth helper 语法和单元测试
python3 -m py_compile scripts/tailscale/tailscale-oauth-helper.py
python3 -m unittest discover -s tests/tailscale -p 'test_*.py'
bash tests/tailscale/test-route-manager.sh
# 静态检查(安装 shellcheck 后) # 静态检查(安装 shellcheck 后)
shellcheck scripts/tailscale/tailscale-route-manager.sh shellcheck scripts/tailscale/tailscale-route-manager.sh
......
...@@ -21,25 +21,29 @@ ...@@ -21,25 +21,29 @@
## Tailscale 子网路由管理模块 ## Tailscale 子网路由管理模块
`scripts/tailscale/tailscale-route-manager.sh` 是可独立部署到 QArm Linux 设备的单文件管理入口,职责包括: `scripts/tailscale/tailscale-route-manager.sh` 是 QArm Linux 设备上的管理入口。OAuth/API 复杂度收敛在同目录的 `tailscale-oauth-helper.py`,两者作为一个部署单元安装。入口职责包括:
- 下载并审阅 Tailscale 官方安装器后执行安装。 - 下载并审阅 Tailscale 官方安装器后执行安装。
- 检查 `tailscaled`、登录状态和当前 advertised routes。 - 检查 `tailscaled`、登录状态和当前 advertised routes。
- 发布或移除 `192.168.11.0/24`、`192.168.22.0/24`,同时保留设备上其他 advertised routes。 - 发布或移除 `192.168.11.0/24`、`192.168.22.0/24`,同时保留设备上其他 advertised routes。
- 在启用子网路由时配置 IP forwarding,在禁用时恢复脚本执行前的状态。 - 在启用子网路由时配置 IP forwarding,在禁用时恢复脚本执行前的状态。
- 通过 `whiptail`、`dialog` 或普通终端提供中文单出口 TUI。 - 通过 `whiptail`、`dialog` 或普通终端提供中文单出口 TUI。
- 从 `/opt/armaster/data/hw.json` 只读获取 `sn` 作为设备名默认前缀,收集用户补充名称和标签选择,并调用 helper 完成 OAuth 自动注册;配置缺失或无效时回退到系统 hostname。
`tailscale-oauth-helper.py` 的窄接口只提供“列出可分配标签”和“创建一次性 Auth Key”。它负责 OAuth 令牌交换、Tailscale API JSON 契约和错误脱敏;不负责 TUI、系统服务、路由或 forwarding。
关键持久状态: 关键持久状态:
- `/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 的原始状态,不保存凭证。
- OAuth Secret、Access Token 和 Auth Key:只在进程内或权限为 `0600` 的短期文件中存在,不属于持久状态。
主要执行流: 主要执行流:
```text ```text
主页面 主页面
-> 安装/检查 Tailscale -> 安装/检查 Tailscale
-> 登录确认 -> OAuth 注册(设备名称 + 在线标签多选)或浏览器登录
-> 读取并合并 advertised routes -> 读取并合并 advertised routes
-> 记录并开启 forwarding -> 记录并开启 forwarding
-> 发布受管子网 -> 发布受管子网
......
...@@ -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.2.1) | 安装 Tailscale,并管理两个 QArm 局域网的 subnet routes、forwarding 与中文彩色 TUI;已修复 `whiptail` 边框错位 | | Tailscale Route Manager | Done (V0.3.2) | 支持 OAuth 批量注册、在线标签多选、短期密钥安全清理及基于 `hw.json` SN 的设备命名前缀;成功入网和多设备批量验收待现场复验 |
| 其他 QArm 业务配置脚本 | Pending | 等待后续按真实目标平台和配置需求添加 | | 其他 QArm 业务配置脚本 | Pending | 等待后续按真实目标平台和配置需求添加 |
# 迭代记录 # 迭代记录
## [V0.3.2] OAuth 批量注册与设备命名 (OAuth Provisioning and Device Naming)
- **功能**: 新增 OAuth Client 自动注册链路,在线读取 Provisioner 可直接分配的标签,在中文 TUI 中输入设备名称并多选标签;动态创建短期一次性 Auth Key,安全清理临时凭证,并使用 `/opt/armaster/data/hw.json` 的 `sn` 作为设备名称默认前缀。
- **改动文件**:
- `scripts/tailscale/tailscale-route-manager.sh`: 增加 OAuth 注册入口、设备名与标签输入、临时 Auth Key 清理、SN 前缀读取和失败返回主页面。
- `scripts/tailscale/tailscale-oauth-helper.py`: 封装 OAuth 令牌交换、Policy 标签过滤、Auth Key 创建、描述字段清理和敏感信息脱敏。
- `tests/tailscale/`: 覆盖标签过滤、Auth Key 安全属性、描述合法化、SN 前缀以及注册成功与失败清理流程。
- `docs/tailscale/README.md`: 增加 OAuth Client 权限、`tagOwners`、凭证注入、SN 命名和人工复验说明。
- `meta/specs/tailscale-oauth-device-registration.md`: 记录 OAuth 注册安全边界和验收契约。
- `meta/plans/archive/tailscale-oauth-device-registration_build_plan.md`: 归档已完成的实现计划及剩余真实设备复验风险。
- **影响**: 新 QArm 设备可在不打开浏览器的情况下加入 Tailnet,并以硬件 SN 形成可追踪名称。OAuth Secret、Access Token 和 Auth Key 不进入持久状态。真实 OAuth 流程已推进到 Auth Key 创建和控制端注册请求,但成功入网及至少两台设备的批量验收仍待现场复验。
## [V0.2.1] 修复 TUI 状态渲染 (Fix TUI Status Rendering) ## [V0.2.1] 修复 TUI 状态渲染 (Fix TUI Status Rendering)
- **功能**: 修复 `whiptail/newt` 因彩色 emoji 双列宽度导致的右侧边框错位,同时保留完全中文的状态文字和可用环境中的红、黄、绿状态颜色。 - **功能**: 修复 `whiptail/newt` 因彩色 emoji 双列宽度导致的右侧边框错位,同时保留完全中文的状态文字和可用环境中的红、黄、绿状态颜色。
......
{ {
"project_name": "qarm_setup_scripts", "project_name": "qarm_setup_scripts",
"version": "0.2.1", "version": "0.3.2",
"iteration_count": 3, "iteration_count": 4,
"last_update": "2026-09-21", "last_update": "2026-10-23",
"stack_summary": [ "stack_summary": [
"PowerShell", "PowerShell",
"Shell", "Shell",
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
"scripts/sync-agent-adapters.ps1", "scripts/sync-agent-adapters.ps1",
"scripts/sync-agent-adapters.sh", "scripts/sync-agent-adapters.sh",
"scripts/tailscale/tailscale-route-manager.sh", "scripts/tailscale/tailscale-route-manager.sh",
"scripts/tailscale/tailscale-oauth-helper.py",
"docs/tailscale/README.md", "docs/tailscale/README.md",
"meta/REGRESSION.md", "meta/REGRESSION.md",
"meta/RELEASE_CHECKLIST.md" "meta/RELEASE_CHECKLIST.md"
...@@ -37,6 +38,8 @@ ...@@ -37,6 +38,8 @@
"tailscale_tui_single_exit_ready": true, "tailscale_tui_single_exit_ready": true,
"tailscale_cancel_flow_ready": true, "tailscale_cancel_flow_ready": true,
"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_hardware_sn_prefix_ready": true
} }
} }
...@@ -21,3 +21,11 @@ ...@@ -21,3 +21,11 @@
| Whiptail 边框宽度 | 状态文本不得包含双列 emoji;右侧边框不因状态行发生错位 | Ready | | Whiptail 边框宽度 | 状态文本不得包含双列 emoji;右侧边框不因状态行发生错位 | Ready |
| Tailscale TUI 单出口 | 分支完成、取消、Esc 或失败后返回主页面;仅主页面“退出”结束 TUI | Ready | | Tailscale TUI 单出口 | 分支完成、取消、Esc 或失败后返回主页面;仅主页面“退出”结束 TUI | Ready |
| 未登录时取消启用 | `tailscale up` 确认框选择 No 后不退出脚本,直接返回主页面 | Ready | | 未登录时取消启用 | `tailscale up` 确认框选择 No 后不退出脚本,直接返回主页面 | Ready |
| OAuth 凭证不持久化 | 检查仓库、状态文件和输出不包含 Client Secret、Access Token 或 Auth Key | Ready |
| OAuth 标签权限过滤 | Policy 中仅由 Provisioner 直接拥有的标签进入 TUI 列表 | Ready |
| OAuth Auth Key 安全属性 | 新 Key 必须为单次、非临时、预批准且有效期 600 秒 | Ready |
| OAuth 注册失败恢复 | 取消或失败后删除临时 Key 文件并返回主页面 | Ready |
| OAuth Auth Key 描述 | 描述只包含 API 接受的 ASCII 字母数字和连字符,且不超过 50 字符 | Ready |
| OAuth 标签大小写 | Provisioner 与设备标签统一使用小写,Auth Key 标签和 `tailscale up` 请求必须一致 | Ready |
| 硬件 SN 名称前缀 | 有效 `hw.json.sn` 生成 `<sn>-` 默认值;缺失、损坏、非法或过长时回退 hostname | Ready |
| OAuth 批量真实注册 | 在至少两台新设备连续运行并核对名称、标签和连接状态 | Pending |
---
最终状态:Done
完成日期:2026-10-23
最终验证摘要:8 个 Python 单元测试、Bash 注册流程测试、语法检查和静态差异检查通过;`.118` 已只读确认可从 `hw.json` 生成 SN 前缀,真实成功入网和多设备批量验收仍待现场复验。
相关 spec:`meta/specs/tailscale-oauth-device-registration.md`
相关 commit / PR:本次迭代归档提交,未关联 PR。
---
# Plan: Tailscale OAuth 设备注册
## 1. 来源 Spec
- Spec 文档:`meta/specs/tailscale-oauth-device-registration.md`
- 当前状态:用户已要求继续实现;规格基于前序确认的 OAuth 批量注册方案。
- 本次计划覆盖范围:OAuth helper、TUI 注册流程、自动化测试和使用文档。
## 2. 执行阶段
### Phase 1: OAuth 注册垂直切片
- 目标:完成从标签读取、TUI 选择到新设备注册的完整本地实现。
- 修改范围:`scripts/tailscale/`、`tests/tailscale/`、`docs/tailscale/README.md`、相关 `meta/` 文档。
- 前置条件:运行时由用户提供正确作用域的 OAuth Client 凭证;Tailnet 已配置 Provisioner 标签所有权。
- 验证方式:Python 单元测试、Python/Bash 语法检查、帮助输出检查、敏感信息扫描和 diff 检查。
- 完成标准:本地自动化验证通过,文档给出部署、权限、恢复和真实设备复验步骤。
- 是否可以独立提交:是;但未经用户授权不得提交。
## 3. 执行顺序
1. 建立 OAuth helper 及其隔离测试。
2. 集成管理器输入、多选、注册和安全清理流程。
3. 更新文档、架构、功能状态和回归清单。
4. 执行本地验证并回写进度。
## 4. 并行与串行判断
- 可并行:文档草拟与 helper 纯函数测试可并行思考,但本次不启用多 Agent。
- 必须串行:helper 接口确定后才能集成 TUI;实现完成后才能验证。
- 阻塞点:真实 OAuth 凭证、Tailnet Policy 和目标 Linux 设备仅用于最终人工验证。
## 5. 风险与暂停条件
- API 契约与官方当前文档不符时暂停实现。
- 发现必须将 Secret 写入仓库或持久状态时暂停并重新设计。
- 实际需求扩大到修改 Tailnet Policy、自动批准路由或中央服务时返回 spec。
- 真实设备验证不会在本地自动执行。
## 6. 当前进度表
| Phase | 状态 | 完成日期 | 下一步 |
| --- | --- | --- | --- |
| Phase 1: OAuth 注册垂直切片 | Done | 2026-10-23 | 计划已归档;后续执行真实设备与批量复验 |
进度详情:
- Phase 1 实际修改范围:新增 Python OAuth helper 与 Python/Bash 测试;管理器新增设备名输入、在线标签多选、一次性 Auth Key 注册、临时文件清理、`register` 命令和 `hw.json.sn` 名称前缀;同步更新安装、架构、功能和回归文档。
- Phase 1 验证结果:8 个 Python 单元测试通过;Bash 成功/失败注册编排测试通过;Python/Bash 语法、`help`、只读 `status`、`git diff --check` 和凭证模式扫描通过;`.118` 已只读确认生成 `CA2EUP-20261020-001-` 前缀。
- Phase 1 未验证风险:本机没有 `shellcheck`;真实 OAuth 已验证 Policy 读取与 Auth Key 创建,但尚未确认新设备成功入网,也未完成至少两台设备的批量验证;远端仍是 `0.3.1`,未部署本轮 `0.3.2` SN 前缀改动。
## 7. 重启或上下文恢复后的接续步骤
1. 读取 `AGENTS.md`、来源 spec 和本计划。
2. 检查工作区差异,保护用户已有修改。
3. 继续第一个非 `Done` phase。
4. 完成本地验证后更新本计划,不自动提交或推送。
## 8. 下一步建议
- 在全新 QArm 设备使用真实 OAuth Client 完成成功入网、标签、SN 名称及至少两台设备的批量人工复验。
# Tailscale OAuth 设备注册 Spec
## 1. 背景
现有 `tailscale-route-manager.sh` 可以安装 Tailscale、通过浏览器登录并管理 QArm 子网路由,但批量部署新设备时仍需逐台交互登录,也无法从 Tailnet 中选择设备标签。
## 2. 问题陈述
QArm 批量部署需要在目标设备上输入设备名称、读取 Tailnet 已定义标签、选择允许的标签,并在不打开浏览器的情况下完成注册。长期凭证不得进入仓库、状态文件或日志。
## 3. 目标
- 使用 OAuth Client 凭证读取 Tailnet Policy。
- 从 `tagOwners` 读取标签,并仅向 TUI 暴露 Provisioner 标签可直接分配的标签。
- 支持在 TUI 中输入设备名称并多选标签。
- 默认从 `/opt/armaster/data/hw.json` 读取 `sn`,以 `<sn>-` 作为可编辑的设备名称前缀。
- 为每台设备动态创建单次使用、非临时、预批准的短期 Auth Key。
- 使用 Auth Key、设备名称和所选标签完成注册。
- 所有 TUI 分支结束、取消或失败后返回主页面。
## 4. 非目标
- 不创建或修改 Tailnet Policy、`tagOwners` 或 `autoApprovers`。
- 不在目标设备长期保存 OAuth Secret、Access Token 或 Auth Key。
- 不替代 Tailscale Admin Console 的 OAuth Client 创建流程。
- 不自动批准子网路由;路由自动批准由 Tailnet `autoApprovers` 管理。
- 不实现中央注册服务。
## 5. 使用场景
运维人员在一台新的 QArm Linux 设备上以 root 运行管理器,输入设备名称,在在线获取的可分配标签中进行多选,然后自动加入 Tailnet。完成后可以继续启用 QArm 子网路由。
## 6. 范围
- 新增独立 Python OAuth/API helper,封装令牌交换、Policy 读取和 Auth Key 创建。
- 修改 Bash 管理器,增加输入框、标签多选和注册入口。
- 更新用户文档、架构说明、功能状态和回归清单。
- 新增不访问真实 Tailscale API 的 helper 自动化测试。
## 7. 约束
- 目标平台为带 Bash 和 Python 3 的 Linux。
- `hw.json` 仅用于只读获取 SN;缺失、损坏或 SN 无效时回退到系统 hostname,不修改硬件配置。
- OAuth Client ID/Secret 仅通过 `TS_OAUTH_CLIENT_ID`、`TS_OAUTH_CLIENT_SECRET` 注入。
- Provisioner 标签默认是 `tag:qarm-provisioner`,允许通过 `TS_OAUTH_OWNER_TAG` 覆盖。
- API Base URL 固定为 Tailscale 官方 API,避免运行时环境变量把 OAuth Secret 导向其他地址。
- helper 和管理器必须作为一套文件部署。
- 不输出或记录任何 OAuth Secret、Access Token 或 Auth Key。
## 8. 接口契约
### 8.1 环境变量
- `TS_OAUTH_CLIENT_ID`:必填,OAuth Client ID。
- `TS_OAUTH_CLIENT_SECRET`:必填,OAuth Client Secret。
- `TS_OAUTH_OWNER_TAG`:可选,默认 `tag:qarm-provisioner`。
- `TS_TAILNET`:可选,默认 `-`。
### 8.2 Helper CLI
```text
tailscale-oauth-helper.py list-tags --owner-tag <tag>
tailscale-oauth-helper.py create-auth-key --tags <csv> --description <text>
```
- `list-tags` 每行输出一个可分配标签,不输出密钥。
- `create-auth-key` 只在 stdout 输出新生成的 Auth Key。
- 失败时向 stderr 输出脱敏错误并返回非零退出码。
### 8.3 管理器 CLI
新增:
```text
sudo --preserve-env=TS_OAUTH_CLIENT_ID,TS_OAUTH_CLIENT_SECRET tailscale-route-manager register
```
## 9. 状态流 / 数据流
```text
运行时 OAuth 凭证
-> 换取一小时 Access Token(仅内存)
-> GET Tailnet Policy
-> 读取 tagOwners 并筛选 Provisioner 可分配标签
-> TUI 输入设备名和多选标签
-> 再次换取 Access Token
-> 创建 10 分钟有效、单次、非临时、预批准 Auth Key
-> 临时 0600 文件传给 tailscale up
-> 删除临时文件并清空 shell 变量
-> 验证 BackendState=Running
-> 返回主页面
```
## 10. 边界条件
- OAuth 环境变量缺失时停止自动注册,不影响原浏览器登录路径。
- Policy 中没有 `tagOwners` 或 Provisioner 没有直接拥有任何标签时停止。
- 用户未选择标签、取消输入或取消确认时不创建 Auth Key。
- 已登录设备重新注册前必须明确确认身份将发生变化。
- 设备名使用保守 DNS 标签格式校验。
## 11. 失败场景
- OAuth 换取失败、权限不足、Policy 读取失败或返回无效 JSON:显示脱敏错误并返回主页面。
- 选中标签不被 OAuth Client 授权:Auth Key 创建失败,不执行 `tailscale up`。
- Auth Key 已创建但 `tailscale up` 失败:立即删除临时 Key 文件并报告失败;已创建的一次性 Key 最多在 10 分钟后过期。
- helper 缺失或 Python 3 不可用:提示安装要求,不退回不安全解析。
## 12. 验收用例
1. helper 能从 Policy JSON 中找出 Provisioner 直接拥有的标签并排序去重。
2. helper 创建的 Auth Key 请求为 `reusable=false`、`ephemeral=false`、`preauthorized=true`,有效期 600 秒。
3. TUI 支持 `dialog`、`whiptail` 和普通终端的设备名输入与标签多选。
4. 取消任一注册步骤均返回主页面,且不会创建 Auth Key 或退出整个 TUI。
5. 注册成功后设备使用输入的名称和所选标签连接。
6. stdout/stderr、仓库文件和状态文件中不包含测试 Secret 或真实凭证。
7. 原安装、浏览器登录、路由启停、转发恢复和单出口行为不回归。
8. 有效 `hw.json.sn` 生成 `<sn>-` 默认前缀;异常配置安全回退到 hostname。
## 13. 验证方式
- `python -m unittest discover -s tests/tailscale -p 'test_*.py'`
- `python -m py_compile scripts/tailscale/tailscale-oauth-helper.py`
- `bash -n scripts/tailscale/tailscale-route-manager.sh`
- 可用时执行 `shellcheck`。
- 使用隔离 HTTP mock 验证请求方法、路径、请求体和脱敏错误。
- 在真实设备上人工注入 OAuth 凭证,完成一次注册并检查名称、标签和路由。
## 14. 开放问题
无阻塞问题。真实 Tailnet 中必须预先配置 `tag:qarm-provisioner` 对目标标签的直接所有权,否则目标标签不会出现在 TUI 中。
#!/usr/bin/env python3
"""Tailscale OAuth helper for QArm device registration.
The helper keeps OAuth/API details outside the interactive Bash manager. Secrets
are accepted only through environment variables and are never intentionally
written to stdout or included in error messages.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple
API_BASE_URL = "https://api.tailscale.com"
DEFAULT_TAILNET = "-"
DEFAULT_OWNER_TAG = "tag:qarm-provisioner"
DEFAULT_AUTH_KEY_EXPIRY_SECONDS = 600
DEFAULT_AUTH_KEY_DESCRIPTION = "qarm-registration"
class HelperError(RuntimeError):
"""Expected, user-facing helper failure."""
def require_environment(environ: Mapping[str, str]) -> Tuple[str, str]:
client_id = environ.get("TS_OAUTH_CLIENT_ID", "").strip()
client_secret = environ.get("TS_OAUTH_CLIENT_SECRET", "").strip()
if not client_id or not client_secret:
raise HelperError(
"缺少 TS_OAUTH_CLIENT_ID 或 TS_OAUTH_CLIENT_SECRET 环境变量。"
)
return client_id, client_secret
def redact(text: str, secrets: Iterable[str]) -> str:
result = text
for secret in secrets:
if secret:
result = result.replace(secret, "[已隐藏]")
return result
def request_json(
url: str,
*,
method: str = "GET",
headers: Optional[Mapping[str, str]] = None,
body: Optional[bytes] = None,
secrets: Iterable[str] = (),
) -> Dict[str, Any]:
request = urllib.request.Request(
url,
data=body,
headers=dict(headers or {}),
method=method,
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace").strip()
detail = redact(detail, secrets)
suffix = f":{detail}" if detail else ""
raise HelperError(f"Tailscale API 返回 HTTP {exc.code}{suffix}") from None
except urllib.error.URLError as exc:
reason = redact(str(exc.reason), secrets)
raise HelperError(f"无法连接 Tailscale API:{reason}") from None
try:
value = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise HelperError("Tailscale API 返回了无法解析的 JSON。") from None
if not isinstance(value, dict):
raise HelperError("Tailscale API 返回的 JSON 顶层不是对象。")
return value
def fetch_access_token(
api_base_url: str, client_id: str, client_secret: str
) -> str:
body = urllib.parse.urlencode(
{"client_id": client_id, "client_secret": client_secret}
).encode("ascii")
response = request_json(
f"{api_base_url}/api/v2/oauth/token",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
body=body,
secrets=(client_id, client_secret),
)
token = response.get("access_token")
if not isinstance(token, str) or not token:
raise HelperError("OAuth 响应中缺少 access_token。")
return token
def fetch_policy(api_base_url: str, tailnet: str, access_token: str) -> Dict[str, Any]:
encoded_tailnet = urllib.parse.quote(tailnet, safe="")
return request_json(
f"{api_base_url}/api/v2/tailnet/{encoded_tailnet}/acl",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
secrets=(access_token,),
)
def list_assignable_tags(policy: Mapping[str, Any], owner_tag: str) -> List[str]:
tag_owners = policy.get("tagOwners", {})
if not isinstance(tag_owners, dict):
raise HelperError("Tailnet Policy 的 tagOwners 格式无效。")
result = []
for tag, owners in tag_owners.items():
if not isinstance(tag, str) or not tag.startswith("tag:"):
continue
if tag == owner_tag or not isinstance(owners, list):
continue
if owner_tag in owners:
result.append(tag)
return sorted(set(result))
def normalize_tags(csv_value: str) -> List[str]:
tags: List[str] = []
for raw_tag in csv_value.split(","):
tag = raw_tag.strip().lower()
if not tag:
continue
if not tag.startswith("tag:") or len(tag) <= len("tag:"):
raise HelperError(f"标签格式无效:{tag}")
if tag not in tags:
tags.append(tag)
if not tags:
raise HelperError("至少需要选择一个标签。")
return tags
def normalize_description(value: str) -> str:
"""Return a Tailscale-compatible auth key description."""
description = re.sub(r"[^A-Za-z0-9 -]+", "-", value)
description = re.sub(r"[ -]+", "-", description).strip("-")
description = description[:50].rstrip("-")
return description or DEFAULT_AUTH_KEY_DESCRIPTION
def build_auth_key_payload(
tags: List[str], description: str, expiry_seconds: int
) -> Dict[str, Any]:
return {
"capabilities": {
"devices": {
"create": {
"reusable": False,
"ephemeral": False,
"preauthorized": True,
"tags": tags,
}
}
},
"expirySeconds": expiry_seconds,
"description": normalize_description(description),
}
def create_auth_key(
api_base_url: str,
tailnet: str,
access_token: str,
tags: List[str],
description: str,
expiry_seconds: int,
) -> str:
payload = build_auth_key_payload(tags, description, expiry_seconds)
encoded_tailnet = urllib.parse.quote(tailnet, safe="")
response = request_json(
f"{api_base_url}/api/v2/tailnet/{encoded_tailnet}/keys",
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json",
},
body=json.dumps(payload, separators=(",", ":")).encode("utf-8"),
secrets=(access_token,),
)
auth_key = response.get("key")
if not isinstance(auth_key, str) or not auth_key:
raise HelperError("Auth Key 响应中缺少 key。")
return auth_key
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="QArm Tailscale OAuth 注册辅助工具")
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser("list-tags", help="列出 Provisioner 可分配的标签")
list_parser.add_argument(
"--owner-tag",
default=os.environ.get("TS_OAUTH_OWNER_TAG", DEFAULT_OWNER_TAG),
help="拥有目标标签的 Provisioner 标签",
)
key_parser = subparsers.add_parser("create-auth-key", help="创建一次性设备 Auth Key")
key_parser.add_argument("--tags", required=True, help="逗号分隔的设备标签")
key_parser.add_argument("--description", required=True, help="Auth Key 描述")
key_parser.add_argument(
"--expiry-seconds",
type=int,
default=DEFAULT_AUTH_KEY_EXPIRY_SECONDS,
help="Auth Key 有效秒数,默认 600",
)
return parser
def run(argv: Optional[List[str]] = None, environ: Optional[Mapping[str, str]] = None) -> int:
args = build_parser().parse_args(argv)
environment = os.environ if environ is None else environ
client_id, client_secret = require_environment(environment)
api_base_url = API_BASE_URL
tailnet = environment.get("TS_TAILNET", DEFAULT_TAILNET).strip() or DEFAULT_TAILNET
access_token = fetch_access_token(api_base_url, client_id, client_secret)
if args.command == "list-tags":
policy = fetch_policy(api_base_url, tailnet, access_token)
tags = list_assignable_tags(policy, args.owner_tag)
for tag in tags:
print(tag)
return 0
if args.expiry_seconds < 60 or args.expiry_seconds > 3600:
raise HelperError("Auth Key 有效期必须在 60 到 3600 秒之间。")
tags = normalize_tags(args.tags)
auth_key = create_auth_key(
api_base_url,
tailnet,
access_token,
tags,
args.description,
args.expiry_seconds,
)
print(auth_key)
return 0
def main() -> int:
try:
return run()
except HelperError as exc:
secrets = (
os.environ.get("TS_OAUTH_CLIENT_ID", ""),
os.environ.get("TS_OAUTH_CLIENT_SECRET", ""),
)
print(f"错误:{redact(str(exc), secrets)}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=../../scripts/tailscale/tailscale-route-manager.sh
source "$repo_root/scripts/tailscale/tailscale-route-manager.sh"
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
valid_device_name "qarm-001" || fail "valid device name rejected"
if valid_device_name "-qarm"; then
fail "invalid leading hyphen accepted"
fi
if valid_device_name "qarm_001"; then
fail "invalid underscore accepted"
fi
hw_file="$(mktemp)"
trap 'rm -f -- "$hw_file"' EXIT
printf '%s\n' '{"sn":"CA2EUP-20261020-001"}' >"$hw_file"
QARM_HW_CONFIG_FILE="$hw_file"
export QARM_HW_CONFIG_FILE
[[ "$(hardware_sn_prefix)" == "CA2EUP-20261020-001-" ]] \
|| fail "hardware serial prefix was not loaded"
printf '%s\n' '{"sn":"invalid_sn"}' >"$hw_file"
if hardware_sn_prefix >/dev/null 2>&1; then
fail "invalid hardware serial was accepted"
fi
printf '%s\n' '{invalid json' >"$hw_file"
if hardware_sn_prefix >/dev/null 2>&1; then
fail "invalid hardware JSON was accepted"
fi
printf '%s\n' '{"sn":"CA2EUP-20261020-001"}' >"$hw_file"
helper_path="$(find_oauth_helper)"
[[ "$helper_path" == "$repo_root/scripts/tailscale/tailscale-oauth-helper.py" ]] \
|| fail "OAuth helper was not found beside the manager"
# Force the ordinary terminal adapter so this test does not depend on local TUI packages.
command_exists() {
return 1
}
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"
default_value="$(printf '\n' | input_value "测试" "设备名称" "qarm-default")"
[[ "$default_value" == "qarm-default" ]] || fail "terminal input default was not preserved"
# 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"
REGISTERED=0
CAPTURED_AUTH_FILE=""
is_tailscale_installed() { return 0; }
is_tailscale_logged_in() { [[ "$REGISTERED" -eq 1 ]]; }
oauth_runtime_ready() { return 0; }
input_value() { printf '%s\n' "qarm-001"; }
find_oauth_helper() { printf '%s\n' "/mock/oauth-helper.py"; }
select_tags() { printf '%s\n' "tag:germany" "tag:qarm-router"; }
yes_no() { return 0; }
message_box() { return 0; }
python3() {
if [[ "$2" == "list-tags" ]]; then
printf '%s\n' "tag:germany" "tag:qarm-router"
elif [[ "$2" == "create-auth-key" ]]; then
printf '%s\n' "tskey-auth-test-only"
else
return 1
fi
}
tailscale() {
[[ "$1" == "up" ]] || return 1
shift
local argument
local auth_file=""
local found_hostname=0
local found_tags=0
for argument in "$@"; do
case "$argument" in
--auth-key=file:*) auth_file="${argument#--auth-key=file:}" ;;
--hostname=qarm-001) found_hostname=1 ;;
--advertise-tags=tag:germany,tag:qarm-router) found_tags=1 ;;
esac
done
[[ -n "$auth_file" ]] || return 1
[[ "$(cat "$auth_file")" == "tskey-auth-test-only" ]] || return 1
[[ "$(stat -c '%a' "$auth_file")" == "600" ]] || return 1
[[ "$found_hostname" -eq 1 ]] || return 1
[[ "$found_tags" -eq 1 ]] || return 1
CAPTURED_AUTH_FILE="$auth_file"
REGISTERED=1
}
oauth_register_device || fail "mocked OAuth registration failed"
[[ "$REGISTERED" -eq 1 ]] || fail "registration success was not observed"
[[ -n "$CAPTURED_AUTH_FILE" ]] || fail "temporary auth key file was not passed"
[[ ! -e "$CAPTURED_AUTH_FILE" ]] || fail "temporary auth key file was not removed"
REGISTERED=0
CAPTURED_AUTH_FILE=""
tailscale() {
[[ "$1" == "up" ]] || return 1
shift
local argument
for argument in "$@"; do
case "$argument" in
--auth-key=file:*) CAPTURED_AUTH_FILE="${argument#--auth-key=file:}" ;;
esac
done
return 1
}
if oauth_register_device; then
fail "failed tailscale up was reported as success"
fi
[[ -n "$CAPTURED_AUTH_FILE" ]] || fail "failure path did not create auth key file"
[[ ! -e "$CAPTURED_AUTH_FILE" ]] || fail "failure path did not remove auth key file"
printf 'route manager function tests: OK\n'
import importlib.util
import io
import json
import pathlib
import unittest
from contextlib import redirect_stdout
from unittest import mock
HELPER_PATH = (
pathlib.Path(__file__).resolve().parents[2]
/ "scripts"
/ "tailscale"
/ "tailscale-oauth-helper.py"
)
SPEC = importlib.util.spec_from_file_location("tailscale_oauth_helper", HELPER_PATH)
HELPER = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(HELPER)
class FakeResponse:
def __init__(self, value):
self.body = json.dumps(value).encode("utf-8")
def __enter__(self):
return self
def __exit__(self, exc_type, exc, traceback):
return False
def read(self):
return self.body
class OAuthHelperTests(unittest.TestCase):
def test_lists_only_tags_directly_owned_by_provisioner(self):
policy = {
"tagOwners": {
"tag:qarm-provisioner": [],
"tag:qarm-router": ["tag:qarm-provisioner"],
"tag:germany": ["tag:qarm-provisioner", "group:ops"],
"tag:unrelated": ["group:ops"],
}
}
self.assertEqual(
["tag:germany", "tag:qarm-router"],
HELPER.list_assignable_tags(policy, "tag:qarm-provisioner"),
)
def test_auth_key_payload_is_single_use_non_ephemeral_and_preauthorized(self):
payload = HELPER.build_auth_key_payload(
["tag:qarm-router"], "qarm registration: qarm-001", 600
)
create = payload["capabilities"]["devices"]["create"]
self.assertFalse(create["reusable"])
self.assertFalse(create["ephemeral"])
self.assertTrue(create["preauthorized"])
self.assertEqual(["tag:qarm-router"], create["tags"])
self.assertEqual(600, payload["expirySeconds"])
self.assertEqual("qarm-registration-qarm-001", payload["description"])
def test_auth_key_description_is_ascii_safe_and_bounded(self):
description = HELPER.normalize_description(
"qarm registration: 设备-001/" + ("x" * 80)
)
self.assertEqual("qarm-registration-001-xxxxxxxxxxxxxxxxxxxxxxxxxxxx", description)
self.assertLessEqual(len(description), 50)
self.assertRegex(description, r"^[A-Za-z0-9-]+$")
def test_list_tags_command_prints_only_tags(self):
environment = {
"TS_OAUTH_CLIENT_ID": "client-id",
"TS_OAUTH_CLIENT_SECRET": "client-secret",
}
output = io.StringIO()
with mock.patch.object(HELPER, "fetch_access_token", return_value="access-token"), mock.patch.object(
HELPER,
"fetch_policy",
return_value={
"tagOwners": {
"tag:qarm-router": ["tag:qarm-provisioner"],
"tag:other": ["group:ops"],
}
},
), redirect_stdout(output):
result = HELPER.run(["list-tags"], environment)
self.assertEqual(0, result)
self.assertEqual("tag:qarm-router\n", output.getvalue())
self.assertNotIn("client-secret", output.getvalue())
def test_create_key_command_prints_only_generated_key(self):
environment = {
"TS_OAUTH_CLIENT_ID": "client-id",
"TS_OAUTH_CLIENT_SECRET": "client-secret",
}
output = io.StringIO()
with mock.patch.object(HELPER, "fetch_access_token", return_value="access-token"), mock.patch.object(
HELPER, "create_auth_key", return_value="tskey-auth-test"
) as create_key, redirect_stdout(output):
result = HELPER.run(
[
"create-auth-key",
"--tags",
"tag:qarm-router,tag:germany",
"--description",
"qarm registration: qarm-001",
],
environment,
)
self.assertEqual(0, result)
self.assertEqual("tskey-auth-test\n", output.getvalue())
args = create_key.call_args.args
self.assertEqual(["tag:qarm-router", "tag:germany"], args[3])
self.assertEqual(600, args[5])
def test_secret_is_redacted_from_http_error(self):
error = HELPER.redact(
json.dumps({"message": "bad client-secret"}), ["client-secret"]
)
self.assertNotIn("client-secret", error)
self.assertIn("[已隐藏]", error)
def test_fetch_policy_requests_json_with_bearer_token(self):
with mock.patch.object(
HELPER.urllib.request,
"urlopen",
return_value=FakeResponse({"tagOwners": {}}),
) as urlopen:
result = HELPER.fetch_policy(
"https://api.tailscale.com", "-", "access-token"
)
self.assertEqual({"tagOwners": {}}, result)
request = urlopen.call_args.args[0]
self.assertEqual(
"https://api.tailscale.com/api/v2/tailnet/-/acl", request.full_url
)
self.assertEqual("Bearer access-token", request.get_header("Authorization"))
self.assertEqual("application/json", request.get_header("Accept"))
def test_create_auth_key_posts_expected_security_properties(self):
with mock.patch.object(
HELPER.urllib.request,
"urlopen",
return_value=FakeResponse({"key": "tskey-auth-test"}),
) as urlopen:
key = HELPER.create_auth_key(
"https://api.tailscale.com",
"-",
"access-token",
["tag:qarm-router"],
"qarm registration: qarm-001",
600,
)
self.assertEqual("tskey-auth-test", key)
request = urlopen.call_args.args[0]
self.assertEqual("POST", request.method)
self.assertEqual(
"https://api.tailscale.com/api/v2/tailnet/-/keys", request.full_url
)
payload = json.loads(request.data.decode("utf-8"))
create = payload["capabilities"]["devices"]["create"]
self.assertEqual(
{
"reusable": False,
"ephemeral": False,
"preauthorized": True,
"tags": ["tag:qarm-router"],
},
create,
)
self.assertEqual(600, payload["expirySeconds"])
if __name__ == "__main__":
unittest.main()
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