Files
2026-05-09 16:02:40 +08:00

1348 lines
51 KiB
Python

# coding: utf-8
# -------------------------------------------------------------------
# aaPanel
# -------------------------------------------------------------------
# Copyright (c) 2014-2099 aaPanel(www.aapanel.com) All rights reserved.
# -------------------------------------------------------------------
# Author: aapanel
# -------------------------------------------------------------------
# SSH Manager - SSH 连接管理和智能下载
import asyncio
import base64
import hashlib
import json
import os
import re
import signal
import time
import uuid
from dataclasses import dataclass, asdict
from typing import Tuple, List, Optional, Dict
from functools import wraps
import public
try:
import asyncssh
except ImportError:
os.system("btpip install asyncssh")
try:
import asyncssh # noqa
except Exception:
raise Exception(
"asyncssh packet not installed, please try again, or run cmd [btpip install asyncssh]"
)
from datetime import datetime
from .parser import parse_combined_ssl
from .tools import humanize_to_bytes
from .logger import MigrateLogger
# ==================== 常量定义 ====================
DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
LOCK_FILE = "/tmp/aa_ssh_migrate.pid"
# SSH 连接常量
DEFAULT_PORT = 22
DEFAULT_USER = "root"
DEFAULT_TIMEOUT = 10
# 下载常量
BASE_CHUNK_READ = 2 * 1024 * 1024 # 2MB 读取块
PROGRESS_INTERVAL = 512 * 1024 # 512KB 进度更新间隔
# 重试常量
MAX_RETRIES = 3
# 并发下载常量
SMALL_FILE_THRESHOLD = 50 * 1024 * 1024 # 50MB
MEDIUM_FILE_THRESHOLD = 500 * 1024 * 1024 # 500MB
LARGE_FILE_THRESHOLD = 5 * 1024 * 1024 * 1024 # 5GB
DEFAULT_CONCURRENCY_SMALL = 3
DEFAULT_CONCURRENCY_MEDIUM = 6
DEFAULT_CONCURRENCY_LARGE = 12
DEFAULT_CONCURRENCY_XLARGE = 16
MIN_CHUNK_SIZE = 10 * 1024 * 1024 # 10MB 最小切块
MAX_CHUNK_SIZE = 200 * 1024 * 1024 # 200MB 最大切块
# ==================== 装饰器 ====================
def retry_act(max_retries: int = MAX_RETRIES, delay: int = 3):
"""重试装饰器. 失败前尝试恢复连接."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_result = None
for attempt in range(max_retries):
try:
result = func(*args, **kwargs)
last_result = result
is_success = result[0] if isinstance(result, tuple) else result
if is_success:
return result
MigrateLogger(clear_log=False).error(f"retry: {result[1]}")
except Exception as e:
if attempt == max_retries - 1:
raise e
# 重试前确保连接
try:
ssh_mgr = args[0]
if hasattr(ssh_mgr, 'connect'):
ssh_mgr.connect()
except Exception:
pass
if attempt < max_retries - 1:
time.sleep(delay)
return last_result
return wrapper
return decorator
# ==================== 工具函数 ====================
def _mask_email(email: str) -> str:
"""邮箱隐私处理."""
if not email or "@" not in email:
return email
name, domain = email.rsplit("@", 1)
if len(name) <= 2:
masked = "***"
else:
masked = f"{name[0]}***{name[-1]}"
return f"{masked}@{domain}"
def format_bytes(bytes_num: float) -> str:
"""格式化字节数."""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if bytes_num < 1024.0:
return f"{bytes_num:.1f} {unit}"
bytes_num /= 1024.0
return f"{bytes_num:.1f} PB"
# ==================== 数据类 ====================
@dataclass
class DownloadChunk:
"""单个下载块的信息"""
index: int # 块索引
start_offset: int # 起始偏移量
end_offset: int # 结束偏移量(不包含)
size: int # 块大小
downloaded: int = 0 # 已下载大小
status: str = "pending" # pending, downloading, completed, failed
retry_count: int = 0 # 重试次数
temp_file: str = "" # 临时文件路径
@dataclass
class DownloadState:
"""下载状态(用于断点续传)"""
file_size: int
total_downloaded: int
chunks: List[DownloadChunk]
sha256: str = ""
class DownloadProgressTracker:
"""下载进度跟踪器"""
def __init__(self, chunks: List[DownloadChunk], file_size: int, logger=None):
self.chunks = chunks
self.file_size = file_size
self.logger = logger
self.last_progress_time = 0
self.last_percent = -1
self.start_time = time.time()
def get_total_downloaded(self) -> int:
"""获取总下载量"""
return sum(chunk.downloaded for chunk in self.chunks)
def update_progress(self):
"""更新并显示进度"""
if not self.logger:
return
total_downloaded = self.get_total_downloaded()
current_time = time.time()
percent = int(total_downloaded * 100 / self.file_size)
# 每 5% 或每 1 秒写一次日志
if (percent - self.last_percent >= 5 and percent % 5 == 0) or \
(current_time - self.last_progress_time) >= 1 or \
total_downloaded == self.file_size:
transferred_str = format_bytes(total_downloaded)
total_str = format_bytes(self.file_size)
# 计算下载速度
elapsed = current_time - self.start_time
if elapsed > 0:
speed = total_downloaded / elapsed
speed_str = format_bytes(speed) + "/s"
else:
speed_str = "N/A"
self.logger.info(f"Downloading: {percent}% ({transferred_str}/{total_str}) [{speed_str}]")
self.last_progress_time = current_time
self.last_percent = percent
# ============= 并行下载辅助 End ================
class SSHManager:
"""SSH 管理器. 提供异步 SSH 连接和命令执行功能."""
def __init__(self, host: str, auth: Dict[str, str], port: int = DEFAULT_PORT,
user: str = DEFAULT_USER, timeout: int = DEFAULT_TIMEOUT,
lock_file: str = None) -> None:
"""初始化 SSH 管理器.
Args:
host: 服务器地址
auth: 认证信息 (password 或 key_file)
port: SSH 端口
user: SSH 用户名
timeout: 连接超时时间
lock_file: 锁文件路径
"""
self.host = host
self.port = port
self.user = user
self.auth = auth
self.timeout = timeout
self._loop = None
self._conn = None
self._lock_file = lock_file if lock_file else LOCK_FILE
def __enter__(self) -> "SSHManager":
self.__only_one()
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.__release()
self.close()
def __only_one(self) -> None:
"""确保只有一个实例运行."""
if os.path.exists(self._lock_file):
try:
with open(self._lock_file, 'r') as f:
old_pid = int(f.read().strip())
if old_pid != os.getpid():
os.kill(old_pid, 0)
os.kill(old_pid, signal.SIGKILL)
time.sleep(0.5)
except (ValueError, ProcessLookupError, OSError):
pass
try:
with open(self._lock_file, 'w') as f:
f.write(str(os.getpid()))
except Exception:
pass
def __release(self) -> None:
"""释放锁文件."""
if os.path.exists(self._lock_file):
try:
with open(self._lock_file, 'r') as f:
current_content = f.read().strip()
if current_content == str(os.getpid()):
os.remove(self._lock_file)
except:
pass
def _run(self, coro):
"""异步协程"""
if self._loop is None or self._loop.is_closed():
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
return self._loop.run_until_complete(coro)
# ==================== 下载辅助方法 ====================
def _get_concurrency(self, file_size: int) -> int:
"""根据文件大小确定并发数.
Args:
file_size: 文件大小(字节)
Returns:
并发连接数
"""
if file_size < SMALL_FILE_THRESHOLD:
return DEFAULT_CONCURRENCY_SMALL
elif file_size < MEDIUM_FILE_THRESHOLD:
return DEFAULT_CONCURRENCY_MEDIUM
elif file_size < LARGE_FILE_THRESHOLD:
return DEFAULT_CONCURRENCY_LARGE
else:
return DEFAULT_CONCURRENCY_XLARGE
def _calculate_download_timeout(self, file_size: int) -> int:
"""根据基准计算最大超时时间"""
base_speed = 100 * 1024 # 100KB/s
estimated_seconds = file_size / base_speed
# 额外30%,最少5min
return max(300, int(estimated_seconds * 1.3))
def _calculate_chunk_size(self, file_size: int, concurrency: int) -> int:
"""计算切块大小.
Args:
file_size: 文件大小(字节)
concurrency: 并发连接数
Returns:
切块大小(字节)
"""
raw_chunk = file_size // concurrency
chunk = max(MIN_CHUNK_SIZE, min(raw_chunk, MAX_CHUNK_SIZE))
# 对齐到 1MB
return (chunk // (1024 * 1024)) * (1024 * 1024)
def _save_download_state(self, state: DownloadState, state_file: str) -> bool:
"""
保存下载状态到文件
Args:
state: 下载状态对象
state_file: 状态文件路径
Returns:
bool: 是否保存成功
"""
try:
state_dict = {
"file_size": state.file_size,
"total_downloaded": state.total_downloaded,
"chunks": [asdict(chunk) for chunk in state.chunks],
"sha256": state.sha256
}
with open(state_file, 'w') as f:
json.dump(state_dict, f)
return True
except Exception:
return False
def _load_download_state(self, state_file: str) -> Optional[DownloadState]:
"""
从文件加载下载状态
Args:
state_file: 状态文件路径
Returns:
DownloadState | None: 下载状态对象,如果文件不存在或加载失败返回 None
"""
if not os.path.exists(state_file):
return None
try:
with open(state_file, 'r') as f:
data = json.load(f)
chunks = []
for chunk_data in data.get("chunks", []):
chunk = DownloadChunk(**chunk_data)
chunks.append(chunk)
return DownloadState(
file_size=data["file_size"],
total_downloaded=data["total_downloaded"],
chunks=chunks,
sha256=data.get("sha256", "")
)
except Exception:
return None
async def _download_chunk(
self,
chunk: DownloadChunk,
remote_tar: str,
local_tar: str,
sftp,
semaphore: asyncio.Semaphore,
progress_tracker=None
) -> Optional[DownloadChunk]:
"""
下载单个块
Args:
chunk: 下载块信息
remote_tar: 远程文件路径
local_tar: 本地文件路径(用于构造临时文件名)
sftp: SFTP 客户端
semaphore: 并发控制信号量
progress_tracker: 进度跟踪器
Returns:
DownloadChunk: 更新后的块信息
"""
async with semaphore:
max_retries = 3
for attempt in range(max_retries):
try:
# 临时文件
temp_file = f"{local_tar}.chunk_{chunk.index}"
chunk.temp_file = temp_file
# 检查断点续传
downloaded = 0
if os.path.exists(temp_file):
downloaded = os.path.getsize(temp_file)
# 打开远程文件,定位到起始位置
async with await sftp.open(remote_tar, 'rb') as remote_file:
await remote_file.seek(chunk.start_offset + downloaded)
# 写入(断点续传追加)
with open(temp_file, 'ab' if downloaded > 0 else 'wb') as local_f:
remaining = chunk.size - downloaded
last_update_size = 0
last_update_time = time.time()
while remaining > 0:
read_size = min(BASE_CHUNK_READ, remaining)
data = await remote_file.read(read_size)
if not data:
break
local_f.write(data)
downloaded += len(data)
remaining -= len(data)
# 更新块进度
chunk.downloaded = downloaded
# 定期更新总体进度
current_time = time.time()
if progress_tracker and (
downloaded - last_update_size >= PROGRESS_INTERVAL or
current_time - last_update_time >= 1
):
progress_tracker.update_progress()
last_update_size = downloaded
last_update_time = current_time
# 完成后更新一次进度
if progress_tracker:
progress_tracker.last_progress_time = 0
progress_tracker.update_progress()
chunk.status = "completed"
return chunk
except Exception:
chunk.retry_count += 1
if attempt < max_retries - 1:
# 重试前指数延迟
await asyncio.sleep(min(2 ** attempt, 4))
continue
else:
chunk.status = "failed"
raise
async def _merge_chunks(self, chunks: List[DownloadChunk], local_tar: str, file_size: int) -> Tuple[bool, str]:
"""
按顺序合并所有块到最终文件,并计算 SHA256
Args:
chunks: 所有块信息
local_tar: 最终文件路径
file_size: 文件总大小
Returns:
Tuple[bool, str]: (是否成功, SHA256哈希值或错误信息)
"""
try:
# 验证所有块
for chunk in chunks:
if chunk.status != "completed":
raise Exception(f"Chunk {chunk.index} not completed: {chunk.status}")
# 按顺序合并并计算 SHA256
sha256_hash = hashlib.sha256()
with open(local_tar, 'wb') as final_file:
for chunk in chunks:
temp_file = chunk.temp_file
if not os.path.exists(temp_file):
raise Exception(f"Temp file for chunk {chunk.index} not found")
with open(temp_file, 'rb') as temp_f:
while True:
data = temp_f.read(1024 * 1024) # 1MB 缓冲
if not data:
break
final_file.write(data)
sha256_hash.update(data)
# 验证文件大小
actual_size = os.path.getsize(local_tar)
if actual_size != file_size:
raise Exception(f"Size mismatch: expected {file_size}, got {actual_size}")
# 清理临时文件
for chunk in chunks:
if os.path.exists(chunk.temp_file):
os.remove(chunk.temp_file)
# 清理状态文件
state_file = f"{local_tar}.download_state"
if os.path.exists(state_file):
os.remove(state_file)
return True, sha256_hash.hexdigest()
except Exception as e:
raise Exception(f"Merge failed: {e}")
async def _stream_download(self, remote_tar: str, local_tar: str, file_size: int, logger) -> Tuple[bool, str]:
"""单连接下载的异步实现"""
sha256_hash = hashlib.sha256()
# 断点续传检查
offset = 0
resume_mode = False
if os.path.exists(local_tar):
offset = os.path.getsize(local_tar)
if offset > 0:
resume_mode = True
logger.info(f"Resuming download from {format_bytes(offset)}")
with open(local_tar, 'rb') as f:
while True:
chunk = f.read(65536)
if not chunk:
break
sha256_hash.update(chunk)
last_progress_time = 0
last_percent = -1
start_time = time.time() # 记录开始时间
# 计算超时时间
timeout = self._calculate_download_timeout(file_size)
try:
async with asyncio.timeout(timeout):
async with self._conn.start_sftp_client() as sftp:
open_mode = 'ab' if resume_mode else 'wb'
if not resume_mode:
logger.info(f"Download started: {format_bytes(file_size)}")
async with await sftp.open(remote_tar, 'rb') as remote_file:
if resume_mode:
await remote_file.seek(offset)
with open(local_tar, open_mode) as f:
while offset < file_size:
chunk = await remote_file.read(BASE_CHUNK_READ)
if not chunk:
break
f.write(chunk)
sha256_hash.update(chunk)
offset += len(chunk)
current_time = time.time()
percent = int(offset * 100 / file_size)
if (percent - last_percent >= 5 and percent % 5 == 0) or \
(current_time - last_progress_time) >= 1 or \
offset == file_size:
transferred_str = format_bytes(offset)
total_str = format_bytes(file_size)
# 计算下载速度
elapsed = current_time - start_time
if elapsed > 0:
speed = offset / elapsed
speed_str = format_bytes(speed) + "/s"
else:
speed_str = "N/A"
logger.info(f"Downloading: {percent}% ({transferred_str}/{total_str}) [{speed_str}]")
last_progress_time = current_time
last_percent = percent
except asyncio.TimeoutError:
try:
await self._conn.run(f"rm -f {remote_tar}")
except Exception:
pass
return False, f"Download timeout after {timeout}s"
except Exception as e:
try:
await self._conn.run(f"rm -f {remote_tar}")
except Exception:
pass
return False, f"SFTP download failed: {str(e)}"
try:
await self._conn.run(f"rm -f {remote_tar}")
except Exception:
pass
return True, sha256_hash.hexdigest()
async def _parallel_download(self, remote_tar: str, local_tar: str, file_size: int, logger, concurrency: int = None) -> Tuple[bool, str]:
"""并行下载的异步实现"""
# 计算超时时间
timeout = self._calculate_download_timeout(file_size)
# 步骤1: 加载断点续传状态
state_file = f"{local_tar}.download_state"
state = self._load_download_state(state_file)
if state and state.file_size == file_size:
chunks = state.chunks
actual_concurrency = len(chunks)
logger.info(f"Resuming download with {actual_concurrency} chunks")
else:
# 使用指定的并发数或自动计算
if concurrency is None:
actual_concurrency = self._get_concurrency(file_size)
else:
actual_concurrency = concurrency
chunk_size = self._calculate_chunk_size(file_size, actual_concurrency)
chunks = []
offset = 0
index = 0
while offset < file_size:
end = min(offset + chunk_size, file_size)
chunks.append(DownloadChunk(
index=index,
start_offset=offset,
end_offset=end,
size=end - offset
))
offset = end
index += 1
initial_state = DownloadState(
file_size=file_size,
total_downloaded=0,
chunks=chunks,
sha256=""
)
self._save_download_state(initial_state, state_file)
# 进度跟踪器
progress_tracker = DownloadProgressTracker(chunks, file_size, logger)
logger.info(
f"Download started: {format_bytes(file_size)} with {actual_concurrency} connections"
)
try:
async with asyncio.timeout(timeout):
# 步骤2: 并发下载
async with self._conn.start_sftp_client() as sftp:
semaphore = asyncio.Semaphore(actual_concurrency)
async def download_with_context(chunk):
return await self._download_chunk(
chunk, remote_tar, local_tar,
sftp, semaphore, progress_tracker
)
tasks = [download_with_context(chunk) for chunk in chunks]
results = await asyncio.gather(*tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception):
chunks[i].status = "failed"
raise result
chunks[i] = result
except asyncio.TimeoutError:
return False, f"Download timeout after {timeout}s"
except Exception as e:
return False, f"Parallel download failed: {str(e)}"
# 进度更新
progress_tracker.update_progress()
# 步骤3: 合并块并计算 SHA256
success, sha256_value = await self._merge_chunks(chunks, local_tar, file_size)
if not success:
return False, sha256_value # 这里是错误信息
# 步骤4: 清理远程文件
try:
await self._conn.run(f"rm -f {remote_tar}")
except Exception:
pass
return True, sha256_value
async def _calculate_remote_sha256(self, remote_path: str) -> Optional[str]:
"""
计算远端文件的 SHA256 哈希值
Args:
remote_path: 远端文件路径
Returns:
Optional[str]: SHA256 哈希值,失败返回 None
"""
try:
# 计算哈希
result = await self._conn.run(f"sha256sum '{remote_path}'", check=False)
if result.exit_status == 0:
# sha256sum 输出: <hash> <filename>
sha256 = result.stdout.strip().split()[0]
return sha256
return None
except Exception:
return None
# ======================= public ============================
def connect(self):
if self._conn:
return
async def _async_connect():
connect_kwargs = {
"host": self.host,
"port": self.port,
"username": self.user,
"login_timeout": self.timeout,
"known_hosts": None,
"keepalive_interval": 30, # 30秒一次心跳
"keepalive_count_max": 3, # 3次心跳无响应断开
# "encryption_algs": ['aes128-gcm@openssh.com', 'chacha20-poly1305@openssh.com'], # 快速加密算法
}
if "key_file" in self.auth and os.path.exists(self.auth["key_file"]):
connect_kwargs["client_keys"] = [self.auth["key_file"]] # noqa
else:
connect_kwargs["password"] = self.auth.get("password")
return await asyncssh.connect(**connect_kwargs)
self._conn = self._run(_async_connect())
def execute(self, command: str, timeout: int = 15) -> tuple[int, str, str]:
"""单条命令"""
async def _async_execute():
result = await asyncio.wait_for(
self._conn.run(command, check=False),
timeout=timeout
)
return result.exit_status, result.stdout.strip(), result.stderr.strip()
try:
return self._run(_async_execute())
except asyncio.TimeoutError:
return -1, "", f"Command timeout after {timeout}s"
except Exception as e:
return -1, "", f"Command failed: {type(e).__name__}: {str(e)}"
def execute_many(self, commands: list[str], timeout: int = 60) -> list[dict]:
"""批量执行cmds, 远端并发"""
if not commands: return []
async def _internal_batch():
boundary = f"BND_{uuid.uuid4().hex}"
tmp_dir = f"/tmp/ssh_batch_{uuid.uuid4().hex}"
# Shell并发 (远端提速)
run_parts = []
collect_parts = []
for i, cmd in enumerate(commands):
octal_cmd = "".join(f"\\{b:03o}" for b in cmd.encode('utf-8'))
run_parts.append(
f"( ( eval \"$(printf '%b' '{octal_cmd}')\" ) > {tmp_dir}/o_{i} 2> {tmp_dir}/e_{i}; echo $? > {tmp_dir}/r_{i} ) & "
)
collect_parts.append(
f"printf '\\n{boundary}_S_{i}\\n'; cat {tmp_dir}/o_{i} 2>/dev/null; "
f"RET=$(cat {tmp_dir}/r_{i} 2>/dev/null || echo -1); "
f"printf '\\n{boundary}_E_{i}_%d\\n' \"$RET\"; "
f"printf '\\n{boundary}_S_{i}\\n' >&2; cat {tmp_dir}/e_{i} 2>/dev/null >&2; "
f"printf '\\n{boundary}_E_{i}\\n' >&2;"
)
full_cmd = f"mkdir -p {tmp_dir}; {''.join(run_parts)} wait; {''.join(collect_parts)} rm -rf {tmp_dir};"
result = await asyncio.wait_for(self._conn.run(full_cmd), timeout=timeout)
out, err = result.stdout, result.stderr
# 解析
results = []
for i, cmd_str in enumerate(commands):
res = {
"index": i, "cmd": cmd_str, "code": -1, "stdout": "", "stderr": ""
}
out_match = re.search(rf"{boundary}_S_{i}\s*(.*?)\s*{boundary}_E_{i}_(-?\d+)", out, re.DOTALL)
err_match = re.search(rf"{boundary}_S_{i}\s*(.*?)\s*{boundary}_E_{i}", err, re.DOTALL)
if out_match:
res["stdout"], res["code"] = out_match.group(1).strip(), int(out_match.group(2))
if err_match:
res["stderr"] = err_match.group(1).strip()
results.append(res)
return results
return self._run(_internal_batch())
def asnyc_execute(self, commands: list[str], timeout: int = 60, max_concurrent: int = 5) -> list[dict]:
"""批量执行cmds, 异步"""
if not commands:
return []
async def _run_command(index: int, cmd: str, semaphore: asyncio.Semaphore):
async with semaphore:
try:
result = await asyncio.wait_for(
self._conn.run(cmd, check=False),
timeout=timeout
)
return {
"index": index,
"cmd": cmd,
"code": result.exit_status,
"stdout": result.stdout.strip(),
"stderr": result.stderr.strip()
}
except Exception as e:
return {
"index": index,
"cmd": cmd,
"code": -1,
"stdout": "",
"stderr": f"Error: {str(e)}"
}
async def _internal_batch():
semaphore = asyncio.Semaphore(max_concurrent)
tasks = [
_run_command(i, cmd, semaphore)
for i, cmd in enumerate(commands)
]
return await asyncio.gather(*tasks)
return self._run(_internal_batch())
def smart_download(self, remote_dir: str, local_tar: str, logger, max_retries=5) -> Tuple[bool, str]:
"""
智能下载策略,根据文件大小自动选择最优下载方式
将打包 remote_dir, 到本地 local_tar
下载后会验证远端和本地文件的 SHA256 哈希值
Args:
remote_dir: 远程备份目录
local_tar: 本地 tar 文件路径
logger: 日志记录器
max_retries: 整体重试次数
Returns:
Tuple[bool, str]: (成功状态, SHA256哈希值或错误信息)
"""
remote_tar = f"{remote_dir}.tar"
async def _async_smart_download():
# 远程打包
tar_cmd = f"cd {os.path.dirname(remote_dir)} && tar -cf {remote_tar} {os.path.basename(remote_dir)}"
try:
result = await asyncio.wait_for(self._conn.run(tar_cmd, check=False), timeout=300)
if result.exit_status != 0:
return False, f"Remote tar failed: {result.stderr.strip()}"
except asyncio.TimeoutError:
return False, "Remote tar timeout"
except Exception as e:
return False, f"Remote tar failed: {str(e)}"
# 获取文件大小
try:
file_size = int((await self._conn.run(f"stat -c %s {remote_tar}")).stdout.strip())
except Exception as e:
return False, f"Failed to stat remote tar: {str(e)}"
# 获取远端哈希
remote_sha256 = await self._calculate_remote_sha256(remote_tar)
if not remote_sha256:
return False, "Failed to calculate remote file SHA256"
logger.info(f"Remote SHA256: {remote_sha256[:16]}...")
# 选择下载方式
if file_size < 50 * 1024 * 1024:
# 小文件单连接
logger.info(f"Using single-connection download")
success, local_sha256 = await self._stream_download(remote_tar, local_tar, file_size, logger)
else:
# 大文件多连接
concurrency = self._get_concurrency(file_size)
logger.info(f"Using parallel download ({concurrency} connections)")
success, local_sha256 = await self._parallel_download(remote_tar, local_tar, file_size, logger, concurrency)
if not success:
return False, local_sha256 # 这里 local_sha256 是错误信息
# 验证哈希值
logger.info(f"Local SHA256: {local_sha256[:16]}...")
if remote_sha256 != local_sha256:
return False, f"SHA256 verification failed: remote={remote_sha256[:16]}..., local={local_sha256[:16]}..."
logger.info("SHA256 verification passed!")
return True, local_sha256
# 重试逻辑
for attempt in range(max_retries):
os.makedirs(os.path.dirname(local_tar), exist_ok=True)
try:
success, result = self._run(_async_smart_download())
if success:
return True, result
# 失败处理
if attempt >= max_retries - 1:
return False, result
# 重试
delay = min(2 ** attempt, 10)
logger.info(f"Download failed (attempt {attempt + 1}/{max_retries}): {result}")
logger.info(f"Retrying in {delay} seconds...")
time.sleep(delay)
# 重连
try:
self.connect()
except Exception:
pass
except Exception as e:
if attempt >= max_retries - 1:
return False, f"Download failed after {max_retries} attempts: {str(e)}"
delay = min(2 ** attempt, 10)
logger.info(f"Download error (attempt {attempt + 1}/{max_retries}): {e}")
logger.info(f"Retrying in {delay} seconds...")
time.sleep(delay)
try:
self.connect()
except Exception:
pass
return False, "Max retries exceeded"
def close(self):
"""关闭连接"""
if self._conn:
try:
self._conn.abort()
except Exception:
pass
finally:
self._conn = None
if self._loop:
try:
if self._loop.is_running():
self._loop.stop()
self._loop.close()
except Exception:
pass
finally:
self._loop = None
class CpanelSSHManager(SSHManager):
def __init__(self, host: str, auth: dict, *args, **kwargs):
super().__init__(host, auth, *args, **kwargs)
# ======================== public =============================
@staticmethod
def sort_wp_list(result: dict) -> dict:
if not result:
return {}
# 对每个用户的 wp 数组进行排序
for user in result:
wp_list = result[user]
if not wp_list:
continue
# 按 domain 分组
domain_groups = {}
for wp in wp_list:
domain = wp.get('domain', '')
if domain not in domain_groups:
domain_groups[domain] = []
domain_groups[domain].append(wp)
# 每个domain组内排序
for domain in domain_groups:
domain_groups[domain].sort(key=lambda x: (
0 if not x.get('sub_path') else 1, # sub_path 为空的排第一
len(x.get('sub_path', '')) if x.get('sub_path') else 0, # 长度排序
x.get('sub_path', '') # 字母排序
))
# domain字母顺序排序各组
sorted_domains = sorted(domain_groups.keys())
# 重新组合
sorted_wp_list = []
for domain in sorted_domains:
sorted_wp_list.extend(domain_groups[domain])
result[user] = sorted_wp_list
return result
def get_remote_disk_free(self, path: str = "/") -> int:
"""
获取远端磁盘可用剩余空间B单位
Args:
path: 检查的路径,默认为根目录
Returns:
int: 可用空间字节数
"""
cmd = f"df -B1 {path} 2>/dev/null | tail -1 | awk '{{print $4}}'"
exit_status, out, err = self.execute(cmd)
if exit_status == 0 and out.strip().isdigit():
return int(out.strip())
# 降级使用statvfs方式
cmd_stat = f"stat -f '%f * %S' {path} 2>/dev/null | bc"
exit_status, out, _ = self.execute(cmd_stat)
if exit_status == 0 and out.strip().isdigit():
return int(out.strip())
return 0
def get_cp_user_info(self) -> list:
"""获取用户综合信息"""
res = "whmapi1 listaccts --output=json"
exit_status, out, err = self.execute(res, timeout=10)
if exit_status != 0:
raise Exception(f"Failed to list cPanel accounts: {err}")
try:
data = json.loads(out)
accounts = data.get("data", {}).get("acct", [])
except json.JSONDecodeError:
raise Exception("Failed to parse cPanel listaccts response")
result = []
for acct in accounts:
setup_date = ""
raw_date = acct.get("startdate", "")
if raw_date:
try:
dt = datetime.strptime(raw_date, "%y %b %d %H:%M")
setup_date = dt.strftime(DATE_FORMAT)
except:
setup_date = raw_date
mail = acct.get("email", "")
if mail and "@" in mail:
name, domain = mail.rsplit("@", 1)
if len(name) <= 2:
masked = "***"
else:
masked = f"{name[0]}***{name[-1]}"
mail = f"{masked}@{domain}"
user_info = {
"domain": acct.get("domain", "").lower(),
"ip": acct.get("ip", ""),
"user": acct.get("user", ""),
"mail": mail or "",
"setup_date": setup_date,
"partition": acct.get("partition", ""),
"owner": acct.get("owner", ""),
"plan": acct.get("plan", ""),
"theme": acct.get("theme", ""),
"disk_block_limit": acct.get("disklimit", "0"), # 不做处理暂时
"disk_use": humanize_to_bytes(acct.get("diskused", "0")),
}
result.append(user_info)
return result
def get_cp_user_wp(self, configs: list[dict]) -> dict:
"""获取用户的wp网站,返回 dict[username -> list]"""
if not configs: return {}
finally_result = {cfg.get("user"): [] for cfg in configs}
user_tasks = []
valid_users = []
for cfg in configs:
user = cfg.get("user")
home = cfg.get("partition", "home")
if not user: continue
# 限制find深度
cmd = (
f"user='{user}'; "
f"search_dir='/{home}/{user}/public_html'; "
f"[ -d \"$search_dir\" ] || exit 0; "
f"find \"$search_dir\" -maxdepth 5 -name 'wp-config.php' -type f 2>/dev/null | while read cfg_path; do "
f" dir=$(dirname \"$cfg_path\"); "
# 提取数据库信息
f" db_name=$(sed -n \"s/.*DB_NAME.*'\\([^']*\\)'.*/\\1/p\" \"$cfg_path\"); "
f" db_user=$(sed -n \"s/.*DB_USER.*'\\([^']*\\)'.*/\\1/p\" \"$cfg_path\"); "
f" db_pass=$(sed -n \"s/.*DB_PASSWORD.*'\\([^']*\\)'.*/\\1/p\" \"$cfg_path\"); "
f" db_pre=$(sed -n \"s/.*table_prefix.*'\\([^']*\\)'.*/\\1/p\" \"$cfg_path\"); "
f" [ -z \"$db_pre\" ] && db_pre='wp_'; "
# 获取域名
f" domain=$(mysql -u\"$db_user\" -p\"$db_pass\" \"$db_name\" -N -s -e "
f" \"SELECT option_value FROM ${{db_pre}}options WHERE option_name='siteurl' LIMIT 1\" 2>/dev/null); "
# 兜底 domain: 如果 mysql 失败, 用文件夹名
f" [ -z \"$domain\" ] && domain=$(basename \"$dir\"); "
f" domain=$(echo \"$domain\" | sed -e 's|^https://||' -e 's|^http://||' -e 's|^/||' -e 's|/$||'); "
# 获取大小
f" size=$(du -sb \"$dir\" 2>/dev/null | awk '{{print $1}}'); "
f" [ -z \"$size\" ] && size=0; "
# 获取证书内容 (包含子站点)
f" ssl_cert_b64=''; "
# 提取主域名 (去掉子站点路径) 并转小写
f" ssl_domain=\"${{domain%%/*}}\"; "
f" ssl_domain=$(echo \"$ssl_domain\" | tr '[:upper:]' '[:lower:]'); "
f" ssl_config=\"/var/cpanel/userdata/$user/${{ssl_domain}}_SSL\"; "
f" if [ -f \"$ssl_config\" ] || [ -f \"${{ssl_config}}.cache\" ]; then "
f" cert_path=\"\"; "
# 方法1: 从web配置中读取证书路径
f" conf_file=\"$ssl_config\"; "
f" [ ! -f \"$conf_file\" ] && conf_file=\"${{ssl_config}}.cache\"; "
f" if [ -f \"$conf_file\" ]; then "
f" cert_path=$(grep -E '^SSLCertificateFile[[:space:]]+' \"$conf_file\" 2>/dev/null | awk '{{print $2}}' | head -1); "
f" [ -z \"$cert_path\" ] && cert_path=$(grep -Ei '^sslcertificatefile=' \"$conf_file\" 2>/dev/null | cut -d= -f2); "
f" fi; "
# 方法2: 直接使用标准证书路径
f" if [ -z \"$cert_path\" ] || [ ! -f \"$cert_path\" ]; then "
f" if [ -f \"/var/cpanel/ssl/apache_tls/${{ssl_domain}}/combined\" ]; then "
f" cert_path=\"/var/cpanel/ssl/apache_tls/${{ssl_domain}}/combined\"; "
f" fi; "
f" fi; "
# 方法3: 通配符证书
f" if [ -z \"$cert_path\" ] || [ ! -f \"$cert_path\" ]; then "
f" main_domain=\"${{ssl_domain#*.}}\"; "
f" if [ \"$main_domain\" != \"$ssl_domain\" ]; then "
f" for wc in /var/cpanel/ssl/apache_tls/*.\"$main_domain\"/combined; do "
f" if [ -f \"$wc\" ]; then "
f" cert_path=\"$wc\"; "
f" break; "
f" fi; "
f" done; "
f" fi; "
f" fi; "
f" if [ -n \"$cert_path\" ] && [ -f \"$cert_path\" ]; then "
f" ssl_cert_b64=$(base64 -w0 \"$cert_path\" 2>/dev/null); "
f" fi; "
f" fi; "
# 输出结果格式: SITE_DATA|路径|域名|字节|表前缀|db_name|db_user|db_pass|ssl_cert_b64
f" echo \"SITE_DATA|$dir|$domain|$size|$db_pre|$db_name|$db_user|$db_pass|$ssl_cert_b64\"; "
f"done"
)
user_tasks.append(cmd)
valid_users.append(user)
if not user_tasks: return finally_result
batch_res = self.asnyc_execute(user_tasks, timeout=120, max_concurrent=5)
for i, res in enumerate(batch_res):
user = valid_users[i]
if res["code"] != 0 or not res["stdout"]: continue
lines = res["stdout"].strip().split('\n')
for line in lines:
if not line.startswith("SITE_DATA|"): continue
try:
parts = line.split('|')
if len(parts) < 8: continue
path = parts[1]
domain = parts[2]
if domain and isinstance(domain, str):
domain = domain.strip().strip('/')
domain = re.sub(r'^https?://', '', domain)
domain_part = domain.split("/")
domain = domain_part[0].lower() if domain_part else domain.lower()
sub_path = "/".join(domain_part[1:]) if len(domain_part) > 1 else ""
sub_path = sub_path.lstrip('/').strip()
disk_usage = int(parts[3]) if parts[3].isdigit() else 0
db_prefix = parts[4] or "wp_"
db_name = parts[5]
db_user = parts[6]
db_pass = parts[7]
ssl_cert_b64 = parts[8].strip() if len(parts) > 8 else ""
finally_result[user].append({
"name": domain if not sub_path else f"{domain}/{sub_path}",
"type": "wp",
"domain": domain,
"sub_path": sub_path,
"site_path": path,
"disk_usage": disk_usage,
"db_prefix": db_prefix,
"db_name": db_name,
"db_user": db_user,
"db_pass": db_pass,
"ssl_cert": ssl_cert_b64 if ssl_cert_b64 else None,
})
except Exception as e:
public.print_log(f"Cpaenl Ssh manager Error parsing WP data for user {user}: {str(e)}")
continue
finally_result = self.sort_wp_list(finally_result)
return finally_result
def get_cp_user_php(self, configs: list[dict]) -> list:
"""获取用户的php网站"""
...
def get_cp_user_ssl(self, configs: list[dict]) -> dict:
"""获取用户的ssl证书,返回 dict[username -> list]"""
if not configs:
return {c.get("user"): [] for c in configs}
user_tasks = []
valid_users = []
for cfg in configs:
user = cfg.get("user")
if not user: continue
# grep 属于当前用户的域名
# /etc/userdomains "domain: user"
script = (
f"user='{user}'; "
f"root_path='/var/cpanel/ssl/apache_tls'; "
f"grep \": $user$\" /etc/userdomains 2>/dev/null | cut -d: -f1 | while read dom; do "
f" dom=$(echo $dom | tr -d '[:space:]'); "
f" [ -z \"$dom\" ] && continue; "
f" cert_path=\"$root_path/$dom/combined\"; "
f" if [ -f \"$cert_path\" ]; then "
f" content=$(base64 -w0 \"$cert_path\" 2>/dev/null); "
f" if [ ! -z \"$content\" ]; then "
f" echo \"SSL_FULL::::$dom::::$cert_path::::$content\"; "
f" fi; "
f" fi; "
f"done"
)
user_tasks.append(script)
valid_users.append(user)
if not user_tasks:
return {c.get("user"): [] for c in configs}
batch_res = self.asnyc_execute(user_tasks, timeout=60)
ssl_by_user = {u: [] for u in valid_users}
for i, res in enumerate(batch_res):
user = valid_users[i]
if res["code"] != 0 or not res["stdout"]:
continue
lines = res["stdout"].strip().split('\n')
for line in lines:
if "SSL_FULL::::" not in line:
continue
parts = line.split('::::')
if len(parts) < 4:
continue
domain = parts[1]
path = parts[2]
b64_content = parts[3]
try:
cert_content = base64.b64decode(b64_content).decode('utf-8', errors='ignore').strip()
ssl_info = {
"name": domain.lower(),
"domain": domain.lower(),
"cert_path": path,
"disk_usage": 0,
"ssl_cert_b64": b64_content,
**parse_combined_ssl(cert_content)
}
ssl_info['type'] = ssl_info.get('issuer_O')
ssl_by_user[user].append(ssl_info)
except Exception:
continue
return ssl_by_user
def get_cp_user_database(self, configs: list[dict]):
"""获取数据库"""
...
@retry_act()
def simple_pack(self, site_path: str, target_tar_path: str) -> Tuple[bool, str]:
"""打包"""
cmd = f"cd '{site_path}' && tar -czf '{target_tar_path}' ."
exit_status, out, err = self.execute(cmd)
if exit_status != 0:
return False, err
return True, "success"
@retry_act()
def pack_wp_site(
self, site_path: str, target_tar_path: str, sql_path: str = None, meta_json: dict = None
) -> Tuple[bool, str]:
"""针对cPanel包wp站点文件"""
safe_site_path = site_path.replace("'", "'\\''")
safe_tar_path = target_tar_path.replace("'", "'\\''")
safe_sql_path = sql_path.replace("'", "'\\''") if sql_path else ""
meta_str = json.dumps(meta_json) if meta_json else "{}"
temp_meta_name = f"{uuid.uuid4().hex}_meta.json"
temp_meta_path = f"/tmp/{temp_meta_name}"
cmd = f"""
cd '{safe_site_path}' || exit 1
cat << 'EOF_META' > '{temp_meta_path}'
{meta_str}
EOF_META
exclude_args=()
while IFS= read -r nested_wp_conf; do
if [ -n "$nested_wp_conf" ]; then
nested_dir=$(dirname "$nested_wp_conf")
# 相对路径下的第一级目录
first_level=$(echo "$nested_dir" | cut -d'/' -f2)
# 保护主站目录
if [[ "$first_level" == "wp-admin" || "$first_level" == "wp-content" || "$first_level" == "wp-includes" ]]; then
exclude_args+=( "--exclude=$nested_dir" )
else
# 连锅端
exclude_args+=( "--exclude=./$first_level" )
fi
fi
done < <(find . -mindepth 2 -maxdepth 6 -type f -name "wp-config.php" 2>/dev/null)
# 排除目标自身
tar_name=$(basename '{safe_tar_path}')
exclude_args+=( "--exclude=./$tar_name" )
exclude_args+=( "--exclude=./error_log" "--exclude=./.trash" )
if [ -n "{safe_sql_path}" ]; then
sql_dir="$(dirname '{safe_sql_path}')"
sql_name="$(basename '{safe_sql_path}')"
tar -czf '{safe_tar_path}' --transform 's,^\\./,files/,' --transform="s,^$sql_name,sqldump.sql," --transform="s,^{temp_meta_name}$,meta.json," "${{exclude_args[@]}}" . -C "$sql_dir" "$sql_name" -C /tmp "{temp_meta_name}"
rm -f '{safe_sql_path}'
else
tar -czf '{safe_tar_path}' --transform 's,^\\./,files/,' --transform="s,^{temp_meta_name}$,meta.json," "${{exclude_args[@]}}" . -C /tmp "{temp_meta_name}"
fi
rm -f '{temp_meta_path}'
"""
# 打包 300 秒超时
exit_status, out, err = self.execute(cmd, timeout=300)
if exit_status != 0:
return False, err
return True, out
@retry_act()
def dump_db(self, db_name, db_user, db_pass, target_sql_path) -> Tuple[bool, str]:
"""导出数据库"""
sql_dir = os.path.dirname(target_sql_path)
cmd = f"mkdir -p '{sql_dir}' && mysqldump -u'{db_user}' -p'{db_pass}' {db_name} > {target_sql_path}"
exit_status, out, err = self.execute(cmd)
return exit_status == 0, out if out else err