feat: 维护页 Tailscale 管理(设备列表/改名/授权/密钥不过期/设置IP/auth key,独立模块 core/tailscale_client.py)+ 密钥迁移 .env(STF_TOKEN/WEB_SECRET_KEY/TAILSCALE_API_KEY 不再入库)+ 修复设备更新端点(/device/{id} 405,改用 /name /authorized /key 专属端点)+ auth key description 仅 ASCII
This commit is contained in:
@@ -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) | 仅管理员 |
|
||||
|
||||
### 用户与权限
|
||||
|
||||
|
||||
@@ -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", "[email protected]")
|
||||
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", "[email protected]")
|
||||
|
||||
@@ -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()
|
||||
+72
@@ -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"}
|
||||
```
|
||||
|
||||
+16
-2
@@ -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` | `[email protected]` | 维护页"一键重启 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` 与真实容器名一致;未配置好时按钮会返回明确错误。
|
||||
|
||||
+117
-1
@@ -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='<tr><td colspan="8" class="empty">加载中...</td></tr>';
|
||||
const r=await apiGet('/api/tailscale/devices');
|
||||
if(!r)return;
|
||||
if(!r.ok){
|
||||
tb.innerHTML='<tr><td colspan="8" class="empty">'+esc(r.error||'获取失败')+'</td></tr>';
|
||||
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='<tr><td colspan="8" class="empty">tailnet 暂无设备</td></tr>';if(st)st.textContent='';return;}
|
||||
tb.innerHTML=devs.map(d=>{
|
||||
const ip=(d.addresses||[])[0]||'-';
|
||||
return '<tr>'+
|
||||
'<td><code>'+esc(d.hostname||'-')+'</code></td>'+
|
||||
'<td>'+esc(d.name||'-')+'</td>'+
|
||||
'<td style="font-family:monospace">'+esc(ip)+'</td>'+
|
||||
'<td>'+esc(d.os||'-')+'</td>'+
|
||||
'<td>'+((d.online===true)?'<span class="label label-success">在线</span>':(d.online===false?'<span class="label label-default">离线</span>':'<span class="label label-warning" title="12 小时内见过但当前未上报">最近在线</span>'))+'</td>'+
|
||||
'<td>'+(d.authorized?'<span class="label label-success">已授权</span>':'<span class="label label-danger">未授权</span>')+'</td>'+
|
||||
'<td>'+(d.key_expiry_disabled?'<span class="label label-warning">永不过期</span>':'<span class="label label-default">会过期</span>')+'</td>'+
|
||||
'<td style="white-space:nowrap">'+
|
||||
'<button class="btn btn-xs" onclick="renameTailscaleDevice(\''+esc(d.id)+'\',\''+esc(d.name||'')+'\')">改名</button> '+
|
||||
'<button class="btn btn-xs" onclick="setTsIp(\''+esc(d.id)+'\',\''+esc((d.addresses||[])[0]||'')+'\')" title="修改设备的 Tailscale IPv4(会断开当前连接,平台 serial 随之变化)">设置IP</button> '+
|
||||
'<button class="btn btn-xs" onclick="toggleTsAuth(\''+esc(d.id)+'\','+(!d.authorized)+')">'+(d.authorized?'取消授权':'授权')+'</button> '+
|
||||
'<button class="btn btn-xs" onclick="toggleTsKeyExpiry(\''+esc(d.id)+'\','+(!d.key_expiry_disabled)+')">'+(d.key_expiry_disabled?'恢复过期':'密钥不过期')+'</button> '+
|
||||
'<button class="btn btn-danger btn-xs" onclick="deleteTailscaleDevice(\''+esc(d.id)+'\')">移除</button>'+
|
||||
'</td></tr>';
|
||||
}).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=
|
||||
'<div style="margin-bottom:10px">新设备执行 <code>tailscale up --authkey=...</code> 接入(默认已预授权)</div>'+
|
||||
'<textarea id="ts-key-box" class="form-control" rows="4" style="font-family:monospace" readonly>'+esc(r.key||'')+'</textarea>'+
|
||||
'<button class="btn btn-primary" style="margin-top:10px" onclick="copyTsKey()">复制</button>'+
|
||||
' <span id="ts-key-status" class="text-muted"></span>';
|
||||
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;
|
||||
|
||||
@@ -517,6 +517,21 @@ select.form-control{cursor:pointer}
|
||||
<button class="btn btn-xs" onclick="checkStfAgent()" title="查看 jp.co.cyberagent.stf 是否已安装">检查 agent</button>
|
||||
</div>
|
||||
<pre id="adb-output" class="adb-console">(执行命令后输出显示在这里)</pre>
|
||||
|
||||
<div class="section-title" style="margin-top:16px">Tailscale 管理</div>
|
||||
<div class="help" id="ts-hint"></div>
|
||||
<div class="toolbar" style="margin:8px 0">
|
||||
<button class="btn btn-primary" onclick="loadTailscaleDevices()">刷新设备</button>
|
||||
<button class="btn" onclick="openTailscaleAuthKey()">生成接入 Auth Key</button>
|
||||
<span id="ts-status" class="text-muted" style="margin-left:10px"></span>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr>
|
||||
<th>主机名</th><th>显示名</th><th>IP</th><th>系统</th><th>在线</th><th>授权</th><th>密钥过期</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody id="tb-tailscale"><tr><td colspan="8" class="empty">点击"刷新设备"加载</td></tr></tbody>
|
||||
</table>
|
||||
<div class="help">设备 IP 由 tailnet 自动分配,不可修改(只读展示);"密钥不过期"= 关闭设备密钥验证,设备不会被定期踢下线;auth key 生成后只显示一次,请立即复制。</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.content -->
|
||||
|
||||
+104
-4
@@ -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/<device_id>", 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/<device_id>/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/<device_id>", 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 成孤儿残留,
|
||||
|
||||
Reference in New Issue
Block a user