From 2e491b19e26da3eb3e9a5054fa9b427996784a3a Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Tue, 11 Aug 2026 14:12:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20SSH=20=E6=94=B9=E7=BA=AF=E5=AF=86?= =?UTF-8?q?=E7=A0=81=E8=AE=A4=E8=AF=81=EF=BC=88paramiko=EF=BC=8C=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E5=AF=86=E9=92=A5/agent=20=E4=B8=8D=E5=BC=B9=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E6=A1=86=EF=BC=8C=E5=AF=86=E7=A0=81=E6=94=BE=20.env?= =?UTF-8?q?=EF=BC=89+=20=E9=83=A8=E7=BD=B2=E9=85=8D=E7=BD=AE=E5=85=A8?= =?UTF-8?q?=E8=BF=81=20.env=EF=BC=88config.py=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=86=85=E7=BD=AE=20STF/SSH/Tailscale=20=E5=80=BC=EF=BC=8C.env?= =?UTF-8?q?.example=20=E8=A1=A5=E5=85=A8=2011=20=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 37 ++++++++++++++-- config.py | 36 ++++++++------- core/ssh_client.py | 97 +++++++++++++++++++++++++++++++++++++++++ core/stf_device_mgmt.py | 20 +++------ doc/DEPLOY.md | 7 ++- requirements.txt | 4 ++ web_server.py | 26 +++++------ 7 files changed, 176 insertions(+), 51 deletions(-) create mode 100644 core/ssh_client.py diff --git a/.env.example b/.env.example index d758ac3..cda825d 100644 --- a/.env.example +++ b/.env.example @@ -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 目标(默认 stf@192.168.20.220) +# STF_SSH_TARGET=stf@192.168.20.220 + +# 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(默认按邮箱前缀,如 1422726308@gmail.com) +# TAILSCALE_TAILNET=1422726308@gmail.com diff --git a/config.py b/config.py index 27bdceb..d3a132b 100644 --- a/config.py +++ b/config.py @@ -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", "stf@192.168.20.220") -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", "skn1422726308@gmail.com") +TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "") diff --git a/core/ssh_client.py b/core/ssh_client.py new file mode 100644 index 0000000..935400f --- /dev/null +++ b/core/ssh_client.py @@ -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}") diff --git a/core/stf_device_mgmt.py b/core/stf_device_mgmt.py index 7ebe134..90defd6 100644 --- a/core/stf_device_mgmt.py +++ b/core/stf_device_mgmt.py @@ -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): diff --git a/doc/DEPLOY.md b/doc/DEPLOY.md index 386ed54..e4d599d 100644 --- a/doc/DEPLOY.md +++ b/doc/DEPLOY.md @@ -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` | `stf@192.168.20.220` | 维护页"一键重启 STF 容器"与工具页"STF 设备管理"的 SSH 目标(需免密登录) | +| `STF_SSH_TARGET` | `stf@192.168.20.220` | 维护页"一键重启 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 启动服务 diff --git a/requirements.txt b/requirements.txt index 35c8ef1..0b81c8f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/web_server.py b/web_server.py index dbf452e..9b90839 100644 --- a/web_server.py +++ b/web_server.py @@ -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):