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='加载中...'; + const r=await apiGet('/api/tailscale/devices'); + if(!r)return; + if(!r.ok){ + tb.innerHTML=''+esc(r.error||'获取失败')+''; + if(st)st.textContent=''; + const s=await apiGet('/api/tailscale/status'); + if(s&&s.ok&&!s.configured&&hint)hint.textContent='⚠ 未配置:'+esc(s.hint||'')+'(写入 .env 后重启服务生效)'; + return; + } + if(hint)hint.textContent=''; + const devs=r.devices||[]; + if(!devs.length){tb.innerHTML='tailnet 暂无设备';if(st)st.textContent='';return;} + tb.innerHTML=devs.map(d=>{ + const ip=(d.addresses||[])[0]||'-'; + return ''+ + ''+esc(d.hostname||'-')+''+ + ''+esc(d.name||'-')+''+ + ''+esc(ip)+''+ + ''+esc(d.os||'-')+''+ + ''+((d.online===true)?'在线':(d.online===false?'离线':'最近在线'))+''+ + ''+(d.authorized?'已授权':'未授权')+''+ + ''+(d.key_expiry_disabled?'永不过期':'会过期')+''+ + ''+ + ' '+ + ' '+ + ' '+ + ' '+ + ''+ + ''; + }).join(''); + if(st)st.textContent='共 '+devs.length+' 台设备'; +} +function renameTailscaleDevice(id, current){ + const name=prompt('修改显示名(只改名称,不影响主机名):', current||''); + if(name===null)return; + apiPost('/api/tailscale/devices/'+id,{name:name}).then(r=>{ + if(!r)return; + if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();} + else showToast(r.error,'error'); + }); +} +function setTsIp(id, current){ + const ip=prompt('设置新的 Tailscale IPv4 地址(如 100.100.10.16):\n\n⚠ 改 IP 会断开设备当前 tailscale 连接,且平台设备池(STF serial)随之变化,改完记得同步 STF 设备池/分组/任务目标。', (current||'').split(':')[0]||''); + if(ip===null)return; + const v=ip.trim(); + if(!v){showToast('IP 不能为空','error');return;} + apiPost('/api/tailscale/devices/'+id+'/ip',{ipv4:v}).then(r=>{ + if(!r)return; + if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();} + else showToast(r.error,'error'); + }); +} +function toggleTsAuth(id, authorized){ + apiPost('/api/tailscale/devices/'+id,{authorized:authorized}).then(r=>{ + if(!r)return; + if(r.ok){showToast(authorized?'已授权':'已取消授权','success');loadTailscaleDevices();} + else showToast(r.error,'error'); + }); +} +function toggleTsKeyExpiry(id, disabled){ + apiPost('/api/tailscale/devices/'+id,{key_expiry_disabled:disabled}).then(r=>{ + if(!r)return; + if(r.ok){showToast(disabled?'已设置密钥永不过期(设备不再被踢下线)':'已恢复密钥定期过期','success');loadTailscaleDevices();} + else showToast(r.error,'error'); + }); +} +function deleteTailscaleDevice(id){ + if(!confirm('确定从 tailnet 移除该设备?\n设备下次上线需重新授权。'))return; + apiDelete('/api/tailscale/devices/'+id).then(r=>{ + if(!r)return; + if(r.ok){showToast(r.msg,'success');loadTailscaleDevices();} + else showToast(r.error,'error'); + }); +} +function openTailscaleAuthKey(){ + const desc=prompt('auth key 用途说明(可空,仅支持英文/数字):','auto_control'); + if(desc===null)return; + const reusable=confirm('允许复用(同一 key 给多台设备用)?\n确定=可复用,取消=一次性'); + const secs=prompt('有效期(秒,默认 3600=1 小时):','3600'); + if(secs===null)return; + const st=document.getElementById('ts-status'); + if(st)st.textContent='正在生成 auth key...'; + apiPost('/api/tailscale/authkey',{description:desc||'',reusable:reusable,expiry_seconds:parseInt(secs)||3600}).then(r=>{ + if(!r)return; + if(st)st.textContent=''; + if(r.ok){ + const ov=document.getElementById('test-overlay'); + document.getElementById('test-modal-title').textContent='Tailscale Auth Key(只显示一次)'; + document.getElementById('test-modal-body').innerHTML= + '
新设备执行 tailscale up --authkey=... 接入(默认已预授权)
'+ + ''+ + ''+ + ' '; + ov.classList.add('show'); + showToast('Auth Key 已生成','success'); + }else{ + showToast(r.error,'error'); + } + }); +} +function copyTsKey(){ + const box=document.getElementById('ts-key-box'); + if(!box)return; + box.select(); + try{document.execCommand('copy');}catch(e){} + const s=document.getElementById('ts-key-status'); + if(s)s.textContent='已复制'; +} + // ================== Tab: 应用 ================== let _apksCache=[]; let _installTimer=null; diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 50cde20..df2392b 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -517,6 +517,21 @@ select.form-control{cursor:pointer}
(执行命令后输出显示在这里)
+ +
Tailscale 管理
+
+
+ + + +
+ + + + + +
主机名显示名IP系统在线授权密钥过期操作
点击"刷新设备"加载
+
设备 IP 由 tailnet 自动分配,不可修改(只读展示);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。
diff --git a/web_server.py b/web_server.py index fbb51de..bec2788 100644 --- a/web_server.py +++ b/web_server.py @@ -20,6 +20,7 @@ import atexit import secrets import functools import shlex +import re import subprocess from datetime import datetime @@ -35,16 +36,19 @@ from core.logger import get_logger, _LOG_DIR, _MODULE_FILES from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction from core.apk_manager import ApkManager from core.adb_helper import screenshot, list_installed_apps, adb_connect_light, _ADB_LOCK, _adb -from core import uiauto_helper +from core import uiauto_helper, tailscale_client +from core.tailscale_client import TailscaleError from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER from tasks import list_task_types, get_task_class _log = get_logger("web") app = Flask(__name__) -# 生产必须通过环境变量 WEB_SECRET_KEY 设置强密钥;缺省用开发密钥(不安全) -app.config["SECRET_KEY"] = os.environ.get( - "WEB_SECRET_KEY", "dev-secret-key-change-in-production") +# 会话密钥:优先 .env 的 WEB_SECRET_KEY;未配置则随机生成(重启后登录态失效,生产务必配置固定值) +_web_secret = os.environ.get("WEB_SECRET_KEY") or secrets.token_hex(32) +if not os.environ.get("WEB_SECRET_KEY"): + _log.warning("未配置 WEB_SECRET_KEY,已随机生成会话密钥(重启后登录态失效)") +app.config["SECRET_KEY"] = _web_secret app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///" + os.path.join( os.path.dirname(os.path.abspath(__file__)), "data", "users.db") app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False @@ -976,6 +980,102 @@ def api_stf_restart(): "detail": out.strip(), "api_alive": alive}) +# ================== API:Tailscale 管理(仅管理员) ================== +# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。 +# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。 + + +@app.route("/api/tailscale/status") +@admin_required +def api_tailscale_status(): + """Tailscale 管理配置状态(API key / tailnet 是否已配置)。""" + c = tailscale_client.tailscale + return jsonify({"ok": True, "configured": c.is_configured(), + "hint": c.config_hint() if not c.is_configured() else ""}) + + +@app.route("/api/tailscale/devices") +@admin_required +def api_tailscale_devices(): + """列出 tailnet 全部设备。""" + try: + return jsonify({"ok": True, "devices": tailscale_client.tailscale.list_devices()}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/tailscale/devices/", methods=["POST"]) +@admin_required +def api_tailscale_device_update(device_id): + """更新设备:按传入字段分发到专属端点(改名 /name、授权 /authorized、密钥 /key)。""" + data = request.json or {} + c = tailscale_client.tailscale + try: + if "name" in data: + c.set_device_name(device_id, data.get("name")) + if "authorized" in data: + c.set_device_authorized(device_id, data.get("authorized")) + if "key_expiry_disabled" in data: + c.set_device_key_expiry(device_id, data.get("key_expiry_disabled")) + _log.info(f"Tailscale 设备更新 {device_id}: {data}") + return jsonify({"ok": True, "msg": "设备已更新"}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/tailscale/devices//ip", methods=["POST"]) +@admin_required +def api_tailscale_device_ip(device_id): + """设置设备 IPv4 地址(未公开端点,实测可用)。 + + ⚠ 改 IP 会断开设备当前 tailscale 会话,且平台设备池 serial 随之变化, + 改完需同步更新 STF 设备池(分组/任务目标里的旧 IP 会失效)。 + """ + data = request.json or {} + ipv4 = (data.get("ipv4") or "").strip() + if not ipv4: + return jsonify({"ok": False, "error": "缺少 ipv4"}), 400 + try: + tailscale_client.tailscale.set_device_ip(device_id, ipv4) + _log.info(f"Tailscale 设备 {device_id} IP 已设置为 {ipv4}") + return jsonify({"ok": True, "msg": f"设备 IP 已设置为 {ipv4}"}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/tailscale/devices/", methods=["DELETE"]) +@admin_required +def api_tailscale_device_delete(device_id): + """从 tailnet 移除设备。""" + try: + tailscale_client.tailscale.delete_device(device_id) + _log.info(f"Tailscale 设备已移除: {device_id}") + return jsonify({"ok": True, "msg": "设备已从 tailnet 移除"}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + +@app.route("/api/tailscale/authkey", methods=["POST"]) +@admin_required +def api_tailscale_authkey(): + """生成设备接入 auth key(key 只显示一次)。""" + data = request.json or {} + try: + # Tailscale 要求 description 仅 ASCII(中文会 400) + desc = re.sub(r"[^\x20-\x7e]", "", (data.get("description") or "").strip()) + key = tailscale_client.tailscale.create_auth_key( + description=desc or "auto_control", + reusable=bool(data.get("reusable", False)), + ephemeral=bool(data.get("ephemeral", False)), + preauthorized=data.get("preauthorized", True), + expiry_seconds=int(data.get("expiry_seconds", 3600))) + _log.info(f"Tailscale auth key 已生成: {key['id']}") + return jsonify({"ok": True, "key": key["key"], "id": key["id"], + "expires": key["expires"]}) + except TailscaleError as e: + return jsonify({"ok": False, "error": str(e)}), 502 + + # ================== uiautodev 自动启动 ================== _uiauto_proc = None # PID 文件:web_server 异常退出(kill -9/崩溃)时 uiautodev 成孤儿残留,