From de66c6194b691c7d14ca8d3607fd92d2491fa157 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Tue, 11 Aug 2026 09:06:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=B4=E6=8A=A4=E9=A1=B5=20Tailscale?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=EF=BC=88=E8=AE=BE=E5=A4=87=E5=88=97?= =?UTF-8?q?=E8=A1=A8/=E6=94=B9=E5=90=8D/=E6=8E=88=E6=9D=83/=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E4=B8=8D=E8=BF=87=E6=9C=9F/=E8=AE=BE=E7=BD=AEIP/auth?= =?UTF-8?q?=20key=EF=BC=8C=E7=8B=AC=E7=AB=8B=E6=A8=A1=E5=9D=97=20core/tail?= =?UTF-8?q?scale=5Fclient.py=EF=BC=89+=20=E5=AF=86=E9=92=A5=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=20.env=EF=BC=88STF=5FTOKEN/WEB=5FSECRET=5FKEY/TAILSCA?= =?UTF-8?q?LE=5FAPI=5FKEY=20=E4=B8=8D=E5=86=8D=E5=85=A5=E5=BA=93=EF=BC=89+?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=E8=AE=BE=E5=A4=87=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=EF=BC=88/device/{id}=20405=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E7=94=A8=20/name=20/authorized=20/key=20=E4=B8=93=E5=B1=9E?= =?UTF-8?q?=E7=AB=AF=E7=82=B9=EF=BC=89+=20auth=20key=20description=20?= =?UTF-8?q?=E4=BB=85=20ASCII?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- config.py | 10 +- core/tailscale_client.py | 213 +++++++++++++++++++++++++++++++++++ doc/API.md | 72 ++++++++++++ doc/DEPLOY.md | 18 ++- static/admin/monitor.js | 118 ++++++++++++++++++- templates/admin/monitor.html | 15 +++ web_server.py | 108 +++++++++++++++++- 8 files changed, 547 insertions(+), 9 deletions(-) create mode 100644 core/tailscale_client.py diff --git a/README.md b/README.md index 3620e7c..1d54a7f 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ self.set_progress(done=5, total=80, unit="视频", | 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 | | 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 | | 用户 | 用户管理:创建/删除/修改密码/分配权限 | 仅管理员 | -| 维护 | STF 容器一键重启、adb 远程终端(设备快捷选择/自动 `-s`、卸载 STF agent、重连设备) | 仅管理员 | +| 维护 | STF 容器一键重启、adb 远程终端(设备快捷选择/自动 `-s`、卸载 STF agent、重连设备)、Tailscale 管理(设备列表/改名/授权/密钥不过期/生成 auth key) | 仅管理员 | ### 用户与权限 diff --git a/config.py b/config.py index fb246d0..12d853a 100644 --- a/config.py +++ b/config.py @@ -31,7 +31,7 @@ def _env(key, default): # ================== STF 配置 ================== STF_URL = _env("STF_URL", "http://192.168.20.220:7100") -STF_TOKEN = _env("STF_TOKEN", "9024544b571647d3a6b09dab5dbfcb28bfd950f5b659400394e44e7217b7051b") +STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git # ================== adb 路径 ================== # adb 二进制及依赖统一放在 bin/adb/ 下 @@ -70,3 +70,11 @@ AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() i # 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。 STF_SSH_TARGET = _env("STF_SSH_TARGET", "stf@192.168.20.220") STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf") + +# ================== 维护(Tailscale 管理) ================== +# 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。 +# TAILSCALE_API_KEY:Tailscale 后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret) +# TAILSCALE_TAILNET:tailnet 名称/ID,个人账号一般是登录邮箱前缀 +# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。 +TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git +TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "skn1422726308@gmail.com") diff --git a/core/tailscale_client.py b/core/tailscale_client.py new file mode 100644 index 0000000..32c27c1 --- /dev/null +++ b/core/tailscale_client.py @@ -0,0 +1,213 @@ +"""Tailscale API v2 客户端封装(维护页"Tailscale 管理"用)。 + +能力: + - 列出 tailnet 全部设备(名称/主机名/IP/系统/在线/授权/密钥过期状态) + - 更新设备:显示名、主机名、授权开关、密钥不过期(keyExpiryDisabled) + - 生成设备接入 auth key(可配置 reusable/ephemeral/preauthorized/有效期) + - 删除设备 + +配置(config.py,支持 .env 注入,**不要提交密钥到 git**): + TAILSCALE_API_KEY — 管理后台 → Settings → API Access Tokens 生成的 key(或 OAuth client 的 client_id:secret) + TAILSCALE_TAILNET — tailnet 名称或 ID(个人账号一般是登录邮箱前缀,如 1422726308) + +注意:设备 IP 由 tailnet 自动分配,API 无法修改;列表里的 addresses 只读展示。 + +参考:https://tailscale.com/api +""" +import requests + +from config import TAILSCALE_API_KEY, TAILSCALE_TAILNET +from core.logger import get_logger + +_log = get_logger("core.tailscale") + +_API_BASE = "https://api.tailscale.com/api/v2" +_TIMEOUT = (3, 15) + + +class TailscaleError(Exception): + """Tailscale API 错误(含 HTTP 状态码)。""" + + def __init__(self, message, code=""): + super().__init__(message) + self.code = code + + +class TailscaleClient: + """Tailscale API v2 客户端。 + + 认证:Basic Auth,API key 作为用户名、空密码; + 也可传 OAuth client 的 "client_id:client_secret" 作为 key。 + """ + + def __init__(self, api_key=None, tailnet=None): + self.api_key = api_key or TAILSCALE_API_KEY + self.tailnet = tailnet or TAILSCALE_TAILNET + + # ================== 配置状态 ================== + def is_configured(self): + """是否已配置 API key 与 tailnet。""" + return bool(self.api_key and self.tailnet) + + def config_hint(self): + """未配置时的提示文案。""" + missing = [] + if not self.api_key: + missing.append("TAILSCALE_API_KEY(Tailscale 后台 → Settings → API Access Tokens)") + if not self.tailnet: + missing.append("TAILSCALE_TAILNET(tailnet 名,个人账号一般是邮箱前缀)") + return ";".join(missing) + + # ================== 请求基座 ================== + def _headers(self): + return {"Authorization": f"Basic {self._basic()}"} + + def _basic(self): + import base64 + return base64.b64encode(f"{self.api_key}:".encode()).decode() + + def _get(self, path): + if not self.is_configured(): + raise TailscaleError(f"未配置:{self.config_hint()}", "config") + try: + r = requests.get(f"{_API_BASE}{path}", headers=self._headers(), timeout=_TIMEOUT) + except requests.exceptions.ConnectionError as e: + raise TailscaleError(f"Tailscale API 不可达: {e}", "network") from e + except requests.exceptions.Timeout as e: + raise TailscaleError("Tailscale API 请求超时", "network") from e + return self._handle(r) + + def _post(self, path, body): + if not self.is_configured(): + raise TailscaleError(f"未配置:{self.config_hint()}", "config") + try: + r = requests.post(f"{_API_BASE}{path}", json=body, + headers=self._headers(), timeout=_TIMEOUT) + except requests.exceptions.ConnectionError as e: + raise TailscaleError(f"Tailscale API 不可达: {e}", "network") from e + except requests.exceptions.Timeout as e: + raise TailscaleError("Tailscale API 请求超时", "network") from e + return self._handle(r) + + def _handle(self, r): + try: + data = r.json() + except Exception: + data = {} + if r.status_code == 401: + raise TailscaleError("API key 无效或已过期(401)", "auth") + if r.status_code == 404: + raise TailscaleError("tailnet 不存在或无权访问(404),请检查 TAILSCALE_TAILNET", "notfound") + if r.status_code >= 400: + msg = data.get("message") or str(data)[:200] + _log.error(f"Tailscale API 错误 {r.status_code}: {msg}") + raise TailscaleError(f"Tailscale API 错误 {r.status_code}: {msg}", "api") + return data + + # ================== 设备 ================== + def list_devices(self): + """列出 tailnet 全部设备。 + + 返回 [{id, name, hostname, os, addresses[], authorized, + key_expiry_disabled, online, last_seen, ...}](字段已规整)。 + """ + data = self._get(f"/tailnet/{self.tailnet}/devices") + devices = [] + for d in data.get("devices", []): + online = d.get("online") + devices.append({ + "id": d.get("id", ""), + "name": d.get("name", ""), + "hostname": d.get("hostname", ""), + "os": d.get("os", ""), + "addresses": d.get("addresses", []), + "authorized": bool(d.get("authorized")), + "key_expiry_disabled": bool(d.get("keyExpiryDisabled")), + # online 三态:True=在线 / False=离线 / None=最近 12 小时内见过但当前未上报(API 返回 null)。 + # 注意不能 bool(null)——那会把"最近在线"误显示成"离线"。 + "online": online if online is not None else None, + "last_seen": d.get("lastSeen", ""), + "tags": d.get("tags", []), + }) + return devices + + def set_device_name(self, device_id, name): + """修改设备显示名(POST /device/{id}/name)。 + + 注意:POST /device/{id} 是 405,改名/授权/密钥各有专属端点。 + """ + if not name or not str(name).strip(): + raise TailscaleError("名称不能为空") + self._post(f"/device/{device_id}/name", {"name": str(name).strip()}) + return True + + def set_device_authorized(self, device_id, authorized): + """授权/取消授权(POST /device/{id}/authorized)。""" + self._post(f"/device/{device_id}/authorized", {"authorized": bool(authorized)}) + return True + + def set_device_key_expiry(self, device_id, disabled): + """关闭/恢复设备密钥过期(POST /device/{id}/key)。 + + disabled=True 即"密钥不过期"(设备不会被定期踢下线); + 恢复过期后按原定过期时间执行,若已过期需重新授权。 + """ + self._post(f"/device/{device_id}/key", {"keyExpiryDisabled": bool(disabled)}) + return True + + def set_device_ip(self, device_id, ipv4): + """为设备设置新的 Tailscale IPv4 地址。 + + 端点 POST /device/{device_id}/ip 实测存在(官方文档未收录,属未公开接口)。 + 注意: + - 修改 IP 会断开该设备当前的 tailscale 会话,几秒后以新 IP 重连 + - 平台设备池(STF serial)用的就是 tailnet IP,改完需同步更新 STF 设备池 + - IPv6 无公开 API 可改 + """ + if not ipv4 or not str(ipv4).strip(): + raise TailscaleError("IPv4 地址不能为空") + self._post(f"/device/{device_id}/ip", {"ipv4": str(ipv4).strip()}) + return True + + def delete_device(self, device_id): + """从 tailnet 移除设备(下次设备上线需重新授权)。""" + if not self.is_configured(): + raise TailscaleError(f"未配置:{self.config_hint()}", "config") + try: + r = requests.delete(f"{_API_BASE}/device/{device_id}", + headers=self._headers(), timeout=_TIMEOUT) + except requests.exceptions.ConnectionError as e: + raise TailscaleError(f"Tailscale API 不可达: {e}", "network") from e + return self._handle(r) + + # ================== Auth key ================== + def create_auth_key(self, description="auto_control", reusable=False, + ephemeral=False, preauthorized=True, expiry_seconds=3600): + """生成设备接入 auth key。 + + 返回 {id, key, expires}。key 只显示这一次,请立即复制保存。 + preauthorized=True:新设备接入自动授权(免去后台手动点授权)。 + """ + body = { + "description": description, + "expirySeconds": max(60, int(expiry_seconds)), + "capabilities": { + "devices": { + "create": { + "reusable": bool(reusable), + "ephemeral": bool(ephemeral), + "preauthorized": bool(preauthorized), + } + } + }, + } + data = self._post(f"/tailnet/{self.tailnet}/keys", body) + return { + "id": data.get("id", ""), + "key": data.get("key", ""), + "expires": data.get("expires", ""), + } + + +# 模块级单例(与 stf_client 的用法一致) +tailscale = TailscaleClient() diff --git a/doc/API.md b/doc/API.md index 990a20e..5f5cb27 100644 --- a/doc/API.md +++ b/doc/API.md @@ -667,3 +667,75 @@ adb 远程终端:用平台 adb 二进制执行任意 adb 命令(20s 超时 ``` `result`:`命中` / `未找到` / `已执行`(无选择器命中语义的步骤)。 无"设备控制"权限返回 403。 + +--- + +## 15. Tailscale 管理(仅管理员) + +维护页"Tailscale 管理"分区,调用 Tailscale 官方 API v2。所有接口仅管理员可用。 +前置:`.env` 配置 `TAILSCALE_API_KEY`(Settings → API Access Tokens)与 +`TAILSCALE_TAILNET`(tailnet 名,个人账号一般为邮箱前缀);未配置返回 502 并附提示。 +设备 IP 由 tailnet 分配,API 不可修改,列表只读展示。 + +### GET /api/tailscale/status + +配置状态检查。 + +**响应**: +```json +{"ok": true, "configured": false, "hint": "TAILSCALE_API_KEY(...);TAILSCALE_TAILNET(...)"} +``` + +### GET /api/tailscale/devices + +列出 tailnet 全部设备。 + +**响应**: +```json +{"ok": true, "devices": [ + {"id": "d1", "name": "dev-a", "hostname": "dev-a", "os": "linux", + "addresses": ["100.100.10.11"], "authorized": true, + "key_expiry_disabled": false, "online": true, "last_seen": "...", "tags": []} +]} +``` + +### POST /api/tailscale/devices/:device_id + +更新设备(按传入字段分发到 Tailscale 专属端点,`POST /device/{id}` 本身是 405): +`name` → `/name` 显示名;`authorized` → `/authorized` 授权开关; +`key_expiry_disabled` → `/key`(true=密钥永不过期,即关闭设备密钥验证,恢复后按原定过期时间执行)。 + +**请求**(JSON): +```json +{"key_expiry_disabled": true} +``` + +### POST /api/tailscale/devices/:device_id/ip + +设置设备的 Tailscale IPv4 地址(未公开端点,实测可用)。 + +**请求**(JSON): +```json +{"ipv4": "100.100.10.16"} +``` + +⚠ 改 IP 会断开设备当前 tailscale 会话;平台设备池 serial 随之变化,需同步更新 STF 设备池/分组/任务目标。 + +### DELETE /api/tailscale/devices/:device_id + +从 tailnet 移除设备(下次上线需重新授权)。 + +### POST /api/tailscale/authkey + +生成设备接入 auth key(key 只返回一次)。 + +**请求**(JSON): +```json +{"description": "新设备接入", "reusable": false, "ephemeral": false, + "preauthorized": true, "expiry_seconds": 3600} +``` + +**响应**: +```json +{"ok": true, "key": "tskey-auth-...", "id": "k1", "expires": "2026-08-11T01:00:00Z"} +``` diff --git a/doc/DEPLOY.md b/doc/DEPLOY.md index 8a0df5f..bf32420 100644 --- a/doc/DEPLOY.md +++ b/doc/DEPLOY.md @@ -79,11 +79,20 @@ pip install -r requirements.txt ### 2.3 修改配置 -编辑 `config.py`,**必须修改**以下两项: +**密钥类配置统一放项目根目录 `.env`**(已被 `.gitignore` 排除,不会提交到 git; +`config.py` 不再内置任何密钥): + +```ini +# .env 示例 +STF_TOKEN=你的STF_API_Token +WEB_SECRET_KEY=随机字符串(会话密钥,python -c "import secrets;print(secrets.token_hex(32))" 生成) +TAILSCALE_API_KEY=你的Tailscale_API_key +``` + +`config.py` 只改非密钥项,如 `STF_URL`: ```python STF_URL = "http://你的STF地址:端口" -STF_TOKEN = "你的STF_API_Token" ``` 其他配置按需调整: @@ -95,6 +104,11 @@ STF_TOKEN = "你的STF_API_Token" | `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 | | `STF_SSH_TARGET` | `stf@192.168.20.220` | 维护页"一键重启 STF 容器"的 SSH 目标(需免密登录) | | `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) | +| `TAILSCALE_TAILNET` | 按邮箱前缀 | tailnet 名称/ID(个人账号一般为登录邮箱前缀) | + +> **未配置 `WEB_SECRET_KEY`**:启动时随机生成(每次重启登录态失效,生产务必配置固定值)。 +> **维护页 Tailscale 前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务; +> 未配置时管理分区显示明确提示,不影响其他功能。 > **维护页 STF 重启前置**:本机需能免密 SSH 到部署机(把本机公钥加入目标机 `authorized_keys`), > 且 `STF_DOCKER_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。 diff --git a/static/admin/monitor.js b/static/admin/monitor.js index 2b8398a..abb489d 100644 --- a/static/admin/monitor.js +++ b/static/admin/monitor.js @@ -289,7 +289,7 @@ function showTab(name){ if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='tasks'){loadTasks();loadCustomActions();} if(name==='apps')loadApks(); - if(name==='maintenance')loadAdbDevices(); + if(name==='maintenance'){loadAdbDevices();loadTailscaleDevices();} if(name==='groups')loadGroups(); if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='users')loadUsers(); @@ -2267,6 +2267,122 @@ function closeTestModal(){ if(ov)ov.classList.remove('show'); } +// ================== Tab: 维护 - Tailscale 管理 ================== +async function loadTailscaleDevices(){ + const tb=document.getElementById('tb-tailscale'); + const st=document.getElementById('ts-status'); + const hint=document.getElementById('ts-hint'); + if(!tb)return; + tb.innerHTML='
'+esc(d.hostname||'-')+'tailscale up --authkey=... 接入(默认已预授权)(执行命令后输出显示在这里)+ +
| 主机名 | 显示名 | IP | 系统 | 在线 | 授权 | 密钥过期 | 操作 | +
|---|---|---|---|---|---|---|---|
| 点击"刷新设备"加载 | |||||||