提交 b0b61d2e authored 作者: 陈泽健's avatar 陈泽健

fix(service-monitor): SSH 连接测试错误分类与依赖检查

- executor.py: 新增 SSHConnectionError/DependencyError 异常类,classify_ssh_error() 返回 6 种错误码
- target_service.py: test_connection() 返回 detail + error_code 字段
- routes.py: API 返回完整错误结果
- targets.html: 显示详细错误信息,鼠标悬停查看详情
- upload_to_server.py: 新增 _check_and_install_deps() 自动检查 paramiko/cryptography
- upload_to_server.py: 新增 _sftp_put_unix_lines() 自动转换 .sh/.template 换行符
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 b9bd58fd
......@@ -64,11 +64,62 @@ RECURSIVE_DIRS_TO_UPLOAD = [
# 递归上传时排除的目录名与文件后缀
_EXCLUDE_DIRS = {'__pycache__', 'tests', 'data', '.pytest_cache'}
_EXCLUDE_SUFFIXES = ('.pyc', '.pyo')
# 需要自动转换 CRLF→LF 的后缀(bash 脚本在 Linux 服务器执行,不能有 \r)
_UNIX_LINE_END_SUFFIXES = ('.sh', '.template')
# 服务监测模块需要的 Python 依赖(SSH 远程执行)
_REQUIRED_PACKAGES = ['paramiko', 'cryptography']
def _check_and_install_deps(ssh):
"""检查并安装服务器端 Python 依赖。"""
print("\n Checking Python dependencies...")
for pkg in _REQUIRED_PACKAGES:
stdin, stdout, stderr = ssh.exec_command(
f"python3 -c 'import {pkg}' 2>&1"
)
result = stdout.read().decode()
if 'ModuleNotFoundError' in result or 'No module named' in result:
print(f" [INSTALL] {pkg} 未安装,正在安装...")
stdin, stdout, stderr = ssh.exec_command(
f"pip3 install --break-system-packages {pkg} 2>&1"
)
install_result = stdout.read().decode()
if 'Successfully installed' in install_result or f'Requirement already satisfied: {pkg}' in install_result:
print(f" [OK] {pkg} 安装成功")
else:
print(f" [WARN] {pkg} 安装可能失败,请检查日志")
else:
# 获取版本
stdin, stdout, stderr = ssh.exec_command(
f"python3 -c 'import {pkg}; print({pkg}.__version__)' 2>&1"
)
version = stdout.read().decode().strip()
print(f" [OK] {pkg} 已安装 ({version})")
def _sftp_put_unix_lines(sftp, local_path, remote_path):
"""上传文件,若后缀匹配则自动转换 CRLF→LF。
Windows 开发环境 .sh/.template 文件含 \\r\\n,
Linux 服务器 bash 无法执行含 \\r 的脚本,需转为 \\n。
"""
if local_path.endswith(_UNIX_LINE_END_SUFFIXES):
with open(local_path, 'rb') as f:
content = f.read()
converted = content.replace(b'\r\n', b'\n')
with sftp.open(remote_path, 'wb') as f:
f.write(converted)
else:
sftp.put(local_path, remote_path)
def _upload_dir_recursive(ssh, sftp, local_dir, remote_dir, remote_rel):
"""递归上传目录(service_monitor 子包用)。
排除 __pycache__ / tests / data / .pyc 等运行期与测试产物。
.sh / .template 文件自动转换 CRLF→LF。
"""
# 远程目录可能含中文/多层,先确保存在
ssh.exec_command(f'mkdir -p "{remote_dir}"')[1].channel.recv_exit_status()
......@@ -82,7 +133,7 @@ def _upload_dir_recursive(ssh, sftp, local_dir, remote_dir, remote_rel):
if fname.endswith(_EXCLUDE_SUFFIXES):
continue
try:
sftp.put(lpath, rpath)
_sftp_put_unix_lines(sftp, lpath, rpath)
print(" [OK] " + remote_rel + "/" + os.path.relpath(lpath, LOCAL_BASE).replace("\\", "/"))
except Exception as e:
print(" [FAIL] " + fname + ": " + str(e))
......@@ -104,14 +155,17 @@ def upload_files():
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
print("\n[1/4] Connecting...")
print("\n[1/5] Connecting...")
ssh.connect(HOST, username=USER, password=PASSWORD)
print("[OK] Connected")
print("\n[2/4] Backing up...")
print("\n[2/5] Checking dependencies...")
_check_and_install_deps(ssh)
print("\n[3/5] Backing up...")
today = datetime.now().strftime('%Y%m%d')
print("\n[3/4] Uploading files...")
print("\n[4/5] Uploading files...")
sftp = ssh.open_sftp()
# 确保远程 web 目录存在
......@@ -156,10 +210,13 @@ def upload_files():
print(" Uploading dir: " + local_rel + "/")
for fname in os.listdir(local_dir):
lpath = os.path.join(local_dir, fname)
rpath = remote_dir + "/" + fname
if os.path.isfile(lpath) and not fname.endswith('.pyc'):
rpath = remote_dir + "/" + fname
sftp.put(lpath, rpath)
_sftp_put_unix_lines(sftp, lpath, rpath)
print(" [OK] " + remote_rel + "/" + fname)
elif os.path.isdir(lpath):
# 子目录也递归上传(如 templates/service_monitor/)
_upload_dir_recursive(ssh, sftp, lpath, rpath, remote_rel + "/" + fname)
# 递归上传子包目录(service_monitor 等含多层子目录)
for local_rel, remote_rel in RECURSIVE_DIRS_TO_UPLOAD:
......@@ -174,7 +231,7 @@ def upload_files():
sftp.close()
print("\n[OK] Files uploaded")
print("\n[4/4] Restarting service...")
print("\n[5/5] Restarting service...")
restart_cmd = (
'cd /opt/troubleshoot/web && '
'pkill -f "python.*server.py" || true && '
......@@ -188,7 +245,7 @@ def upload_files():
print("\nWaiting for service...")
time.sleep(3)
print("\n[5/5] Verifying...")
print("\n[6/6] Verifying...")
stdin, stdout, stderr = ssh.exec_command('curl -s http://localhost:8088/api/health')
result = stdout.read().decode()
......
......@@ -167,11 +167,20 @@ def api_delete_target(target_id):
@bp.route('/api/service-monitor/targets/test', methods=['POST'])
def api_test_connection():
"""测试 SSH 连接(管理员)。
Returns: {
"success": bool,
"message": str,
"detail": str | None,
"error_code": str | None
}
"""
guard = _require_admin_json()
if guard:
return guard
result = target_service.test_connection(request.get_json(force=True) or {})
return jsonify({"success": result["success"], "message": result["message"]})
return jsonify(result)
# ============================================================
......
......@@ -17,7 +17,7 @@ from typing import Optional
from ..utils.paths import TARGETS_FILE, ensure_dirs
from ..utils.crypto import encrypt_password, decrypt_password, is_encrypted
from ..utils.executor import LocalExecutor, SSHExecutor
from ..utils.executor import LocalExecutor, SSHExecutor, SSHConnectionError, DependencyError
logger = logging.getLogger("service_monitor.target_service")
......@@ -258,7 +258,12 @@ def delete_target(target_id: str) -> bool:
def test_connection(data: dict) -> dict:
"""测试远程目标 SSH 连通性(不落库)。
Returns: {"success": bool, "message": str}
Returns: {
"success": bool,
"message": str,
"detail": str | None, # 详细错误信息
"error_code": str | None # 错误码
}
"""
host = (data.get("host") or "").strip()
port = data.get("port", 22)
......@@ -266,27 +271,54 @@ def test_connection(data: dict) -> dict:
password = data.get("password") or ""
if not _HOST_RE.match(host):
return {"success": False, "message": "主机地址非法"}
return {"success": False, "message": "主机地址非法", "detail": None, "error_code": "INVALID_HOST"}
if not _USER_RE.match(username):
return {"success": False, "message": "用户名非法"}
return {"success": False, "message": "用户名非法", "detail": None, "error_code": "INVALID_USER"}
if not password:
return {"success": False, "message": "密码不能为空"}
return {"success": False, "message": "密码不能为空", "detail": None, "error_code": "PASSWORD_REQUIRED"}
try:
port = int(port)
except (TypeError, ValueError):
return {"success": False, "message": "端口必须为数字"}
return {"success": False, "message": "端口必须为数字", "detail": None, "error_code": "INVALID_PORT"}
try:
exe = SSHExecutor(run_id="test", host=host, port=port, username=username, password=password)
except DependencyError as e:
return {
"success": False,
"message": str(e),
"detail": str(e),
"error_code": "DEPENDENCY_MISSING",
}
exe = SSHExecutor(run_id="test", host=host, port=port, username=username, password=password)
try:
ok = exe.test_connection()
return {
"success": ok,
"message": "连接成功" if ok else "SSH 连接失败",
"detail": None,
"error_code": None,
}
except SSHConnectionError as e:
return {
"success": False,
"message": str(e),
"detail": e.detail,
"error_code": e.error_code,
}
except Exception as e:
logger.exception("SSH 连接测试异常: %s", e)
return {
"success": False,
"message": f"SSH 连接失败:{e}",
"detail": str(e),
"error_code": "UNKNOWN",
}
except ConnectionError as e:
return {"success": False, "message": str(e)}
finally:
exe.cleanup()
try:
exe.cleanup()
except Exception:
pass
# ============================================================
......
......@@ -17,6 +17,7 @@ from __future__ import annotations
import abc
import logging
import os
import socket
import subprocess
import tempfile
import time
......@@ -35,6 +36,113 @@ logger = logging.getLogger("service_monitor.executor")
# 目标机上的工作目录前缀(后端根据 run_id 创建子目录)
_REMOTE_BASE = "/tmp/check_modules"
# ============================================================
# paramiko 可用性检查
# ============================================================
_HAS_PARAMIKO = True
_paramiko = None
try:
import paramiko as _paramiko_mod
_paramiko = _paramiko_mod
except ImportError:
_HAS_PARAMIKO = False
class DependencyError(Exception):
"""Python 依赖缺失异常。"""
pass
class SSHConnectionError(Exception):
"""SSH 连接异常(含分类错误码)。
Attributes:
error_code: 错误码(DEPENDENCY_MISSING / AUTH_FAILED / SSH_ERROR /
TIMEOUT / CONNECTION_REFUSED / DNS_ERROR / UNKNOWN)
detail: 原始异常信息
"""
def __init__(self, message: str, error_code: str = "UNKNOWN", detail: str = ""):
super().__init__(message)
self.error_code = error_code
self.detail = detail
def classify_ssh_error(exc: Exception) -> SSHConnectionError:
"""将 SSH 连接异常分类为 SSHConnectionError。
根据异常类型返回用户友好的错误消息和错误码。
"""
if isinstance(exc, DependencyError):
return SSHConnectionError(
message=str(exc),
error_code="DEPENDENCY_MISSING",
detail=str(exc),
)
if _HAS_PARAMIKO and isinstance(exc, _paramiko.AuthenticationException):
return SSHConnectionError(
message="认证失败:用户名或密码错误",
error_code="AUTH_FAILED",
detail=str(exc),
)
if _HAS_PARAMIKO and isinstance(exc, _paramiko.SSHException):
return SSHConnectionError(
message=f"SSH 协议错误:{exc}",
error_code="SSH_ERROR",
detail=str(exc),
)
if isinstance(exc, (socket.timeout, TimeoutError)):
return SSHConnectionError(
message="连接超时:目标不可达或端口未开放",
error_code="TIMEOUT",
detail=str(exc),
)
# OSError 子类(如 socket.error)检查消息内容
if isinstance(exc, OSError):
exc_str = str(exc).lower()
if "timed out" in exc_str or "timeout" in exc_str:
return SSHConnectionError(
message="连接超时:目标不可达或端口未开放",
error_code="TIMEOUT",
detail=str(exc),
)
if "unable to connect" in exc_str or "connection refused" in exc_str:
return SSHConnectionError(
message="连接被拒绝:目标端口未开放或 SSH 服务未运行",
error_code="CONNECTION_REFUSED",
detail=str(exc),
)
if "network is unreachable" in exc_str or "no route to host" in exc_str:
return SSHConnectionError(
message="网络不可达:目标主机不存在或网络配置错误",
error_code="NETWORK_UNREACHABLE",
detail=str(exc),
)
if isinstance(exc, ConnectionRefusedError):
return SSHConnectionError(
message="连接被拒绝:目标端口未开放或 SSH 服务未运行",
error_code="CONNECTION_REFUSED",
detail=str(exc),
)
if isinstance(exc, socket.gaierror):
return SSHConnectionError(
message=f"主机名解析失败:{exc}",
error_code="DNS_ERROR",
detail=str(exc),
)
# 兜底
return SSHConnectionError(
message=f"SSH 连接失败:{exc}",
error_code="UNKNOWN",
detail=str(exc),
)
class BaseExecutor(abc.ABC):
"""执行器抽象基类。"""
......@@ -129,13 +237,26 @@ class BaseExecutor(abc.ABC):
self._workdir = None
def test_connection(self) -> bool:
"""测试连接。"""
"""测试连接。
Returns:
True: 连接成功
False: 连接失败(但不抛异常的情况)
Raises:
SSHConnectionError: SSH 连接异常(含分类错误码)
DependencyError: 依赖缺失
"""
try:
result = self._exec("echo OK", timeout=10)
return "OK" in result
except (DependencyError, SSHConnectionError):
# 这些异常已经分类好了,直接向上抛
raise
except Exception as e:
logger.warning("连接测试失败: %s", e)
return False
# 分类并抛出
raise classify_ssh_error(e)
# ============================================================
......@@ -231,6 +352,10 @@ class SSHExecutor(BaseExecutor):
def __init__(self, run_id: str, host: str, port: int,
username: str, password: str, connect_timeout: int = 15):
super().__init__(run_id)
if not _HAS_PARAMIKO:
raise DependencyError(
"paramiko 未安装,请在服务器执行 pip install paramiko"
)
self.host = host
self.port = port
self.username = username
......@@ -246,9 +371,8 @@ class SSHExecutor(BaseExecutor):
def _get_ssh(self):
"""懒加载 SSH 连接。"""
if self._ssh_client is None:
import paramiko
self._ssh_client = paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self._ssh_client = _paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(_paramiko.AutoAddPolicy())
try:
self._ssh_client.connect(
hostname=self.host, port=self.port,
......@@ -258,9 +382,12 @@ class SSHExecutor(BaseExecutor):
)
logger.info("SSH 连接成功: %s@%s:%d", self.username, self.host, self.port)
except Exception as e:
self._ssh_client.close()
try:
self._ssh_client.close()
except Exception:
pass
self._ssh_client = None
raise ConnectionError(f"SSH 连接失败: {e}") from e
raise classify_ssh_error(e) from e
return self._ssh_client
def _get_sftp(self):
......
......@@ -196,7 +196,15 @@
});
const d = await r.json();
msg.style.color = d.success ? 'var(--green)' : 'var(--red)';
msg.textContent = (d.success ? '✓ ' : '✗ ') + (d.message || '');
if (d.success) {
msg.textContent = '✓ ' + (d.message || '连接成功');
msg.title = '';
} else {
// 显示主消息,详细错误作为悬停提示
let text = '✗ ' + (d.message || '连接失败');
msg.textContent = text;
msg.title = d.detail ? ('详情: ' + d.detail + (d.error_code ? ' [' + d.error_code + ']' : '')) : '';
}
} catch(e) { msg.style.color='var(--red)'; msg.textContent = '✗ 请求失败'; }
}
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论