feat: SSH 改纯密码认证(paramiko,禁用密钥/agent 不弹授权框,密码放 .env)+ 部署配置全迁 .env(config.py 不再内置 STF/SSH/Tailscale 值,.env.example 补全 11 项)
This commit is contained in:
+33
-4
@@ -1,12 +1,41 @@
|
||||
# 环境变量示例:复制为 .env 并按需修改(.env 不进 git)
|
||||
# 生产环境建议用环境变量/密钥管理注入,不要把 token 写进 git。
|
||||
# 生产环境建议用环境变量/密钥管理注入,不要把真实 token 写进 git。
|
||||
# 全部可配置项见 config.py(_env() 读取,未配置时用代码内默认值);
|
||||
# 标"必须"的项务必设置,其余可留空用默认。
|
||||
|
||||
# STF 服务地址
|
||||
# ==================== STF 平台 ====================
|
||||
# STF 服务地址(默认 http://192.168.20.220:7100)
|
||||
STF_URL=http://192.168.20.220:7100
|
||||
|
||||
# STF API Token(安全敏感,生产必须设置)
|
||||
# STF API Token(必须:STF 个人设置 → API Keys 生成)
|
||||
STF_TOKEN=请填写你的STF_token
|
||||
|
||||
# Web 会话密钥(安全敏感,生产必须设置为随机长字符串)
|
||||
# 启动时自动释放残留 STF 占用(单实例无人值守开 true;多实例共用账户勿开,会误放另一实例的任务)
|
||||
# AUTO_RELEASE_STALE_OCCUPY=true
|
||||
|
||||
# ==================== Web 会话 ====================
|
||||
# Web 会话密钥(必须:生产设置为随机长字符串,重启不失效)
|
||||
# 生成:python -c "import secrets; print(secrets.token_hex(32))"
|
||||
WEB_SECRET_KEY=请填写随机密钥
|
||||
|
||||
# ==================== SSH 到部署机(维护页重启 STF / 工具页 STF 设备管理) ====================
|
||||
# SSH 目标(默认 [email protected])
|
||||
# [email protected]
|
||||
|
||||
# SSH 认证密码(推荐):配置后走 paramiko **纯密码**登录(不碰本地密钥/SSH agent,
|
||||
# 不会弹授权框,跨平台无需 sshpass);留空则退回系统 ssh 免密密钥(BatchMode=yes)。
|
||||
STF_SSH_PASSWORD=请填写SSH密码
|
||||
|
||||
# 220 上 STF / adb 的 Docker 容器名(默认 stf / adb,一般不用改)
|
||||
# STF_DOCKER_CONTAINER=stf
|
||||
# STF_ADB_CONTAINER=adb
|
||||
|
||||
# STF 设备池脚本路径(工具页"STF 设备管理"增删设备时读写其 DEVICES 列表,默认值见 config.py)
|
||||
# STF_SCRIPT_PATH=/mnt/data/openstf/connect_devices.sh
|
||||
|
||||
# ==================== Tailscale 管理(工具页) ====================
|
||||
# API key(必须:Tailscale 后台 → Settings → API Access Tokens 生成)
|
||||
TAILSCALE_API_KEY=请填写Tailscale_API_key
|
||||
|
||||
# tailnet 名/ID(默认按邮箱前缀,如 [email protected])
|
||||
# [email protected]
|
||||
|
||||
@@ -30,7 +30,8 @@ def _env(key, default):
|
||||
|
||||
|
||||
# ================== STF 配置 ==================
|
||||
STF_URL = _env("STF_URL", "http://192.168.20.220:7100")
|
||||
# 部署相关配置统一从 .env 读取(模板见 .env.example),config.py 不再内置具体值
|
||||
STF_URL = _env("STF_URL", "")
|
||||
STF_TOKEN = _env("STF_TOKEN", "") # 写入 .env,不要提交到 git
|
||||
|
||||
# ================== adb 路径 ==================
|
||||
@@ -65,21 +66,24 @@ APK_DIR = os.path.join(DATA_DIR, "apks")
|
||||
# 仅单实例部署时设为 True:多实例共用同一 STF 账户时不要开,会误释放另一实例的运行中任务。
|
||||
AUTO_RELEASE_STALE_OCCUPY = _env("AUTO_RELEASE_STALE_OCCUPY", "false").lower() in ("1", "true", "yes")
|
||||
|
||||
# ================== 维护(STF 容器一键重启 + STF 设备管理) ==================
|
||||
# 通过 SSH 到部署机执行 docker 命令/改脚本。
|
||||
# 前置条件:本机可免密 SSH 到目标机(stf@220 需已授权本机公钥)。
|
||||
STF_SSH_TARGET = _env("STF_SSH_TARGET", "[email protected]")
|
||||
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "stf")
|
||||
# STF 设备管理:"工具 → STF 设备管理"维护设备池。
|
||||
# 220 上 adb 跑在独立容器(connect_devices.sh 里是 docker exec adb adb ...);
|
||||
# 设备列表由脚本的 DEVICES 数组维护(cron 每 5 分钟补连)。
|
||||
STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "adb")
|
||||
STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "/mnt/data/openstf/connect_devices.sh")
|
||||
# ================== SSH 到部署机(维护页重启 STF + 工具页 STF 设备管理) ==================
|
||||
# 通过 SSH 到部署机执行 docker 命令/改脚本。配置从 .env 读取:
|
||||
# STF_SSH_TARGET SSH 目标(user@host)
|
||||
# STF_SSH_PASSWORD SSH 密码(配置后 paramiko 纯密码登录,跨平台;
|
||||
# 留空则退回系统 ssh 免密密钥 BatchMode=yes)
|
||||
# STF_DOCKER_CONTAINER STF Docker 容器名
|
||||
# STF_ADB_CONTAINER 220 上 adb Docker 容器名(STF 设备管理执行 adb 用)
|
||||
# STF_SCRIPT_PATH STF 设备池脚本路径(增删设备读写其 DEVICES 列表)
|
||||
STF_SSH_TARGET = _env("STF_SSH_TARGET", "")
|
||||
STF_SSH_PASSWORD = _env("STF_SSH_PASSWORD", "")
|
||||
STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "")
|
||||
STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "")
|
||||
STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "")
|
||||
|
||||
# ================== 维护(Tailscale 管理) ==================
|
||||
# 维护页"Tailscale 管理"调用官方 API v2 管理 tailnet 设备。
|
||||
# TAILSCALE_API_KEY:Tailscale 后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret)
|
||||
# TAILSCALE_TAILNET:tailnet 名称/ID,个人账号一般是登录邮箱前缀
|
||||
# ================== Tailscale 管理(工具页) ==================
|
||||
# 调用官方 API v2 管理 tailnet 设备。配置从 .env 读取:
|
||||
# TAILSCALE_API_KEY:后台 → 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]")
|
||||
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""SSH 远程命令执行统一封装(STF 重启 / STF 设备管理共用)。
|
||||
|
||||
认证方式二选一:
|
||||
1. `.env` 配置 STF_SSH_PASSWORD → 密码认证(paramiko 实现,跨平台,
|
||||
无需 sshpass/ssh 等外部工具,Windows/Linux/macOS 通吃)
|
||||
2. 未配置 → 退回系统 `ssh` 命令 + BatchMode=yes(免密密钥,与旧行为一致)
|
||||
|
||||
用法:
|
||||
code, out, err = ssh_client.run("docker ps") # 返回 (退出码, stdout, stderr)
|
||||
失败抛 SSHError(连接失败/超时),由调用方转成友好错误返回。
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import paramiko
|
||||
|
||||
from config import STF_SSH_TARGET, STF_SSH_PASSWORD
|
||||
from core.logger import get_logger
|
||||
|
||||
_log = get_logger("core.ssh")
|
||||
|
||||
|
||||
class SSHError(Exception):
|
||||
"""SSH 执行错误(连接失败/超时等)。"""
|
||||
|
||||
|
||||
def _parse_target(target):
|
||||
"""解析 STF_SSH_TARGET(user@host[:port] 或 host)→ (user, host, port)。"""
|
||||
target = (target or "").strip()
|
||||
user, host, port = "root", target, 22
|
||||
m = re.match(r"^([^@]+)@(.+)$", target)
|
||||
if m:
|
||||
user, host = m.group(1), m.group(2)
|
||||
if ":" in host:
|
||||
host, p = host.rsplit(":", 1)
|
||||
try:
|
||||
port = int(p)
|
||||
except ValueError:
|
||||
pass
|
||||
return user, host, port
|
||||
|
||||
|
||||
def uses_password():
|
||||
"""当前是否走密码认证。"""
|
||||
return bool(STF_SSH_PASSWORD)
|
||||
|
||||
|
||||
def run(cmd, timeout=25):
|
||||
"""在部署机上执行一条命令。返回 (code, stdout, stderr)。"""
|
||||
if uses_password():
|
||||
return _run_paramiko(cmd, timeout)
|
||||
return _run_system_ssh(cmd, timeout)
|
||||
|
||||
|
||||
def _run_system_ssh(cmd, timeout):
|
||||
"""免密密钥路径:系统 ssh + BatchMode=yes(不弹密码提示)。"""
|
||||
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET]
|
||||
try:
|
||||
r = subprocess.run(ssh + [cmd], capture_output=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise SSHError(f"SSH 执行超时: {cmd[:80]}")
|
||||
except FileNotFoundError:
|
||||
raise SSHError("本机未安装 ssh,无法远程执行")
|
||||
except Exception as e:
|
||||
raise SSHError(f"SSH 连接失败: {e}")
|
||||
return r.returncode, (r.stdout or b"").decode("utf-8", errors="replace"), \
|
||||
(r.stderr or b"").decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _run_paramiko(cmd, timeout):
|
||||
"""密码路径:paramiko SSHClient,**纯密码认证**。
|
||||
|
||||
look_for_keys=False + allow_agent=False:不尝试本地密钥、不询问 SSH agent
|
||||
(如 Bitwarden),否则 agent 会弹授权框/用密钥绕过密码。
|
||||
"""
|
||||
user, host, port = _parse_target(STF_SSH_TARGET)
|
||||
client = paramiko.SSHClient()
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
try:
|
||||
client.connect(host, port=port, username=user, password=STF_SSH_PASSWORD,
|
||||
timeout=8, banner_timeout=8, auth_timeout=8,
|
||||
look_for_keys=False, allow_agent=False)
|
||||
try:
|
||||
stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
code = stdout.channel.recv_exit_status()
|
||||
return code, out, err
|
||||
finally:
|
||||
client.close()
|
||||
except paramiko.AuthenticationException:
|
||||
raise SSHError(f"SSH 密码认证失败(请检查 .env 的 STF_SSH_PASSWORD)")
|
||||
except paramiko.SSHException as e:
|
||||
raise SSHError(f"SSH 连接失败: {e}")
|
||||
except Exception as e:
|
||||
raise SSHError(f"SSH 连接失败: {e}")
|
||||
+6
-14
@@ -13,32 +13,24 @@
|
||||
"""
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
from config import STF_SSH_TARGET, STF_ADB_CONTAINER, STF_SCRIPT_PATH
|
||||
from config import STF_ADB_CONTAINER, STF_SCRIPT_PATH
|
||||
from core.logger import get_logger
|
||||
from core.ssh_client import run as _ssh_run, SSHError
|
||||
|
||||
_log = get_logger("core.stfdev")
|
||||
|
||||
_SSH_BASE = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET]
|
||||
|
||||
|
||||
class StfDevError(Exception):
|
||||
"""STF 设备管理错误(SSH/脚本/命令失败)。"""
|
||||
|
||||
|
||||
def _ssh(cmd, timeout=25):
|
||||
"""在 220 上执行一条 shell 命令。返回 (code, stdout, stderr)。"""
|
||||
"""在 220 上执行一条 shell 命令(认证见 core.ssh_client)。返回 (code, stdout, stderr)。"""
|
||||
try:
|
||||
r = subprocess.run(_SSH_BASE + [cmd], capture_output=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise StfDevError(f"SSH 执行超时: {cmd[:80]}")
|
||||
except Exception as e:
|
||||
raise StfDevError(f"SSH 连接失败: {e}")
|
||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
err = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||
return r.returncode, out, err
|
||||
return _ssh_run(cmd, timeout=timeout)
|
||||
except SSHError as e:
|
||||
raise StfDevError(str(e))
|
||||
|
||||
|
||||
def _adb(args, remote_timeout=15):
|
||||
|
||||
+5
-2
@@ -105,7 +105,8 @@ STF_URL = "http://你的STF地址:端口"
|
||||
| `WEB_HOST` | `0.0.0.0` | 仅本机访问改为 `127.0.0.1` |
|
||||
| `WEB_PORT` | `18050` | 端口冲突时修改 |
|
||||
| `ADB_PATH` | 自动识别 | 用自定义 adb 时修改 |
|
||||
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"与工具页"STF 设备管理"的 SSH 目标(需免密登录) |
|
||||
| `STF_SSH_TARGET` | `[email protected]` | 维护页"一键重启 STF 容器"与工具页"STF 设备管理"的 SSH 目标 |
|
||||
| `STF_SSH_PASSWORD` | (空) | SSH 密码认证(.env 配置后走 paramiko 密码登录,跨平台);留空则退回系统 ssh 免密密钥(BatchMode) |
|
||||
| `STF_DOCKER_CONTAINER` | `stf` | 220 上 STF Docker 容器名(`docker ps` 查看) |
|
||||
| `STF_ADB_CONTAINER` | `adb` | 220 上 adb Docker 容器名(STF 设备管理执行 `docker exec adb adb ...`) |
|
||||
| `STF_SCRIPT_PATH` | `/mnt/data/openstf/connect_devices.sh` | STF 设备池脚本路径(工具页增删设备会读写其 DEVICES 列表) |
|
||||
@@ -115,7 +116,9 @@ STF_URL = "http://你的STF地址:端口"
|
||||
> **工具页 Tailscale 管理前置**:`.env` 写入 `TAILSCALE_API_KEY` 后重启服务;
|
||||
> 未配置时管理分区显示明确提示,不影响其他功能。
|
||||
|
||||
> **维护页 STF 重启 / 工具页 STF 设备管理前置**:本机需能免密 SSH 到部署机(把本机公钥加入目标机 `authorized_keys`),
|
||||
> **维护页 STF 重启 / 工具页 STF 设备管理前置**:需能 SSH 到部署机——两种方式任选:
|
||||
> 1. `.env` 配置 `STF_SSH_PASSWORD` 走密码认证(paramiko,无需额外工具,推荐)
|
||||
> 2. 免密密钥:本机公钥加入目标机 `authorized_keys`
|
||||
> 且 `STF_DOCKER_CONTAINER`/`STF_ADB_CONTAINER` 与真实容器名一致;未配置好时按钮会返回明确错误。
|
||||
|
||||
### 2.4 启动服务
|
||||
|
||||
@@ -18,6 +18,10 @@ APScheduler>=3.10,<4.0
|
||||
# HTTP 客户端(STF API 调用)
|
||||
requests>=2.28
|
||||
|
||||
# SSH 远程执行(STF 重启 / STF 设备管理;.env 配 STF_SSH_PASSWORD 时走密码认证,
|
||||
# 跨平台无需 sshpass;未配置则退回系统 ssh 免密密钥)
|
||||
paramiko>=3.0
|
||||
|
||||
# Android UI 自动化(uiautomator2)
|
||||
uiautomator2>=3.0
|
||||
|
||||
|
||||
+11
-15
@@ -38,10 +38,11 @@ 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_connect, _ADB_LOCK, _adb)
|
||||
from core import uiauto_helper, tailscale_client, stf_device_mgmt
|
||||
from core import uiauto_helper, tailscale_client, stf_device_mgmt, ssh_client
|
||||
from core.tailscale_client import TailscaleError
|
||||
from core.stf_device_mgmt import StfDevError
|
||||
from config import ADB_PATH, STF_SSH_TARGET, STF_DOCKER_CONTAINER
|
||||
from core.ssh_client import SSHError
|
||||
from config import ADB_PATH, STF_DOCKER_CONTAINER
|
||||
from tasks import list_task_types, get_task_class
|
||||
|
||||
_log = get_logger("web")
|
||||
@@ -1111,22 +1112,17 @@ def api_stf_restart():
|
||||
if running:
|
||||
return jsonify({"ok": False,
|
||||
"error": f"有 {len(running)} 台设备正在运行任务,请先停止再维护"}), 409
|
||||
ssh_cmd = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=no", STF_SSH_TARGET,
|
||||
f"docker restart {STF_DOCKER_CONTAINER} && "
|
||||
f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'"]
|
||||
try:
|
||||
r = subprocess.run(ssh_cmd, capture_output=True, timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"ok": False, "error": "SSH 执行超时(60s)"}), 408
|
||||
except FileNotFoundError:
|
||||
return jsonify({"ok": False, "error": "本机未安装 ssh,无法远程操作"}), 500
|
||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
err = (r.stderr or b"").decode("utf-8", errors="replace")
|
||||
if r.returncode != 0:
|
||||
code, out, err = ssh_client.run(
|
||||
f"docker restart {STF_DOCKER_CONTAINER} && "
|
||||
f"docker ps --filter name={STF_DOCKER_CONTAINER} --format '{{{{.Names}}}}: {{{{.Status}}}}'",
|
||||
timeout=60)
|
||||
except SSHError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 502
|
||||
if code != 0:
|
||||
_log.error(f"STF 容器重启失败: {err or out}")
|
||||
return jsonify({"ok": False,
|
||||
"error": f"SSH 执行失败:{err or out}(请确认本机可免密 SSH 到 {STF_SSH_TARGET},且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
|
||||
"error": f"SSH 执行失败:{err or out}(请确认 SSH 配置(STF_SSH_TARGET / STF_SSH_PASSWORD)且容器名 {STF_DOCKER_CONTAINER} 正确)"}), 502
|
||||
# 轮询 STF API 确认恢复(最长 30s)
|
||||
alive = False
|
||||
for _ in range(30):
|
||||
|
||||
Reference in New Issue
Block a user