feat: SSH 改纯密码认证(paramiko,禁用密钥/agent 不弹授权框,密码放 .env)+ 部署配置全迁 .env(config.py 不再内置 STF/SSH/Tailscale 值,.env.example 补全 11 项)

This commit is contained in:
2026-08-11 14:12:52 +08:00
parent 136613bc12
commit 2e491b19e2
7 changed files with 176 additions and 51 deletions
+33 -4
View File
@@ -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]
+20 -16
View File
@@ -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", "")
+97
View File
@@ -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
View File
@@ -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
View File
@@ -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 启动服务
+4
View File
@@ -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
View File
@@ -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):