feat: SSHClient v1.0.0 - PyQt5 + paramiko 跨平台 SSH 客户端
功能: - 多主机管理 (增删改查, 密码/私钥双认证, 导入导出) - 远程终端 (命令执行 + 常用命令快捷栏 + 超时控制) - SFTP 文件浏览 (上传/下载带进度, 新建/删除/重命名) - 实时监控 (CPU/内存/磁盘/网络, 1-10秒可调刷新) - AI Agent (OpenAI 兼容 API, 5 工具自动调用: 命令/指标/列文件/读文件/上传) 技术栈: PyQt5 + paramiko + psutil + requests + PyInstaller 打包: build_windows.bat / build.sh 一键产出 ~57MB 单文件 exe 测试: core 6/6 + UI 3/3 + E2E 6/6 全部通过
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
本地 AI Agent 模块
|
||||
通过 OpenAI 兼容的 HTTP API 跟大模型对话,并把工具调用能力
|
||||
(执行 SSH 命令、查询系统状态、文件操作)暴露给模型。
|
||||
无需本地 GPU,配置 API Key + endpoint 即可使用。
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Callable
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
# AI Agent 可调用的工具定义(OpenAI function calling 格式)
|
||||
TOOL_DEFINITIONS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "exec_ssh_command",
|
||||
"description": "在当前 SSH 连接的远程主机上执行一条 shell 命令,返回标准输出、错误和退出码。用于排查问题、查看文件、启停服务、修改配置等所有需要 shell 的场景。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {"type": "string", "description": "要执行的完整 shell 命令字符串"},
|
||||
"timeout": {"type": "integer", "description": "超时秒数,默认 30,最大 300", "default": 30},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_system_metrics",
|
||||
"description": "获取远程主机的实时系统指标:CPU 使用率/核心数、内存、磁盘使用率、各网卡收发字节、负载。",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_remote_files",
|
||||
"description": "列出远程主机上指定目录的文件和子目录。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "要列出的远程绝对路径", "default": "/"},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_remote_file",
|
||||
"description": "读取远程主机上一个文本文件的内容(最大 8000 字节;超过会截断)。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "description": "远程文件的绝对路径"},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "upload_local_file",
|
||||
"description": "把本地文件上传到远程主机的指定路径。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"local_path": {"type": "string", "description": "本地文件绝对路径"},
|
||||
"remote_path": {"type": "string", "description": "远程目标绝对路径"},
|
||||
},
|
||||
"required": ["local_path", "remote_path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class AIAgent:
|
||||
"""OpenAI 兼容协议的对话 + 工具调用 Agent"""
|
||||
|
||||
def __init__(self, api_key: str = "", base_url: str = "https://api.openai.com/v1",
|
||||
model: str = "gpt-4o-mini", system_prompt: str = ""):
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
self.system_prompt = system_prompt or self._default_system_prompt()
|
||||
self.history: List[Dict[str, Any]] = []
|
||||
self.max_steps = 8 # 单轮最大工具调用次数,避免死循环
|
||||
|
||||
@staticmethod
|
||||
def _default_system_prompt() -> str:
|
||||
return (
|
||||
"你是一个专业的服务器运维 AI Agent,可以通过工具调用操作当前 SSH 会话里的远程主机。"
|
||||
"优先使用工具获取真实数据再回答,不要凭空猜测。"
|
||||
"对破坏性操作(rm -rf、kill -9、systemctl stop、重启等)务必先确认。"
|
||||
"回复简洁,用中文,给出可执行的结论和命令。"
|
||||
)
|
||||
|
||||
def update_config(self, api_key: str = "", base_url: str = "", model: str = "",
|
||||
system_prompt: str = ""):
|
||||
"""热更新配置"""
|
||||
if api_key:
|
||||
self.api_key = api_key
|
||||
if base_url:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if model:
|
||||
self.model = model
|
||||
if system_prompt:
|
||||
self.system_prompt = system_prompt
|
||||
|
||||
def clear_history(self):
|
||||
self.history = []
|
||||
|
||||
def chat(self, user_message: str,
|
||||
tool_executor: Callable[[str, dict], str],
|
||||
on_step: Optional[Callable[[str, str], None]] = None) -> str:
|
||||
"""
|
||||
发起一轮对话。tool_executor(tool_name, arguments) -> str(工具执行的纯文本结果)。
|
||||
on_step(role, content) 在每个思考/工具步骤触发,用于把过程实时渲染到 UI。
|
||||
返回最终助手回复文本。
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise RuntimeError("未配置 API Key,请先在「AI 设置」中填写")
|
||||
self.history.append({"role": "user", "content": user_message})
|
||||
|
||||
for step in range(self.max_steps):
|
||||
try:
|
||||
resp = self._call_llm()
|
||||
except Exception as e:
|
||||
msg = f"[AI 调用失败] {e}"
|
||||
if on_step:
|
||||
on_step("error", msg)
|
||||
return msg
|
||||
|
||||
msg = resp.choices[0].message
|
||||
tool_calls = getattr(msg, "tool_calls", None) or []
|
||||
content = (msg.content or "").strip()
|
||||
|
||||
if not tool_calls:
|
||||
# 没有工具调用 -> 终态
|
||||
self.history.append({"role": "assistant", "content": content})
|
||||
if on_step and content:
|
||||
on_step("assistant", content)
|
||||
return content
|
||||
|
||||
# 工具调用阶段
|
||||
self.history.append({
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
} for tc in tool_calls
|
||||
],
|
||||
})
|
||||
if on_step and content:
|
||||
on_step("assistant_thinking", content)
|
||||
|
||||
for tc in tool_calls:
|
||||
fn_name = tc.function.name
|
||||
try:
|
||||
args = json.loads(tc.function.arguments) if tc.function.arguments else {}
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
if on_step:
|
||||
on_step("tool_call", f"调用工具: {fn_name}({json.dumps(args, ensure_ascii=False)})")
|
||||
result = tool_executor(fn_name, args)
|
||||
# 截断过长的结果,避免撑爆上下文
|
||||
result_str = result if len(result) < 6000 else result[:6000] + "\n...(已截断)"
|
||||
self.history.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": fn_name,
|
||||
"content": result_str,
|
||||
})
|
||||
if on_step:
|
||||
on_step("tool_result", f"[{fn_name}] -> {result_str[:400]}")
|
||||
|
||||
return "已达最大工具调用步数,可能未完成任务。可继续提问或追加要求。"
|
||||
|
||||
def _call_llm(self):
|
||||
"""发起一次 LLM 调用"""
|
||||
if not self.api_key:
|
||||
raise RuntimeError("未配置 API Key")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": [{"role": "system", "content": self.system_prompt}] + self.history,
|
||||
"tools": TOOL_DEFINITIONS,
|
||||
"tool_choice": "auto",
|
||||
"temperature": 0.2,
|
||||
}
|
||||
r = requests.post(
|
||||
f"{self.base_url}/chat/completions",
|
||||
headers=headers, json=payload, timeout=60,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}")
|
||||
# 用一个轻量对象包装,调用方用 .choices[0].message.tool_calls / .content
|
||||
return _Resp(r.json())
|
||||
|
||||
|
||||
class _Msg:
|
||||
def __init__(self, d):
|
||||
self.content = d.get("content") or ""
|
||||
self.tool_calls = None
|
||||
tcs = d.get("tool_calls")
|
||||
if tcs:
|
||||
self.tool_calls = []
|
||||
for tc in tcs:
|
||||
fn = tc.get("function", {})
|
||||
self.tool_calls.append(_TC(tc.get("id", ""), fn.get("name", ""), fn.get("arguments", "")))
|
||||
|
||||
|
||||
class _TC:
|
||||
def __init__(self, _id, name, arguments):
|
||||
self.id = _id
|
||||
self.function = _FN(name, arguments)
|
||||
|
||||
|
||||
class _FN:
|
||||
def __init__(self, name, arguments):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
|
||||
class _Choice:
|
||||
def __init__(self, d):
|
||||
self.message = _Msg(d.get("message", {}))
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, j):
|
||||
self.choices = [_Choice(c) for c in j.get("choices", [])]
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
连接管理器:保存多个 SSH 会话的配置和活跃连接。
|
||||
配置持久化到 ~/.sshclient/hosts.json。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from .ssh_client import SSHConnection
|
||||
|
||||
|
||||
CONFIG_DIR = Path.home() / ".sshclient"
|
||||
CONFIG_FILE = CONFIG_DIR / "hosts.json"
|
||||
AI_CONFIG_FILE = CONFIG_DIR / "ai.json"
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""多主机连接管理 + 配置持久化"""
|
||||
|
||||
def __init__(self):
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self.hosts: List[dict] = [] # 主机配置
|
||||
self.connections: Dict[str, SSHConnection] = {} # host_id -> SSHConnection
|
||||
self._load_hosts()
|
||||
|
||||
def _load_hosts(self):
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
self.hosts = json.load(f)
|
||||
except Exception:
|
||||
self.hosts = []
|
||||
if not self.hosts:
|
||||
# 给一个示例条目,让 UI 不为空
|
||||
self.hosts = [{
|
||||
"id": "demo", "name": "示例主机", "host": "127.0.0.1",
|
||||
"port": 22, "username": "root", "password": "", "key_path": "",
|
||||
}]
|
||||
|
||||
def save_hosts(self):
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(self.hosts, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def list_hosts(self) -> List[dict]:
|
||||
return list(self.hosts)
|
||||
|
||||
def get_host(self, host_id: str) -> Optional[dict]:
|
||||
for h in self.hosts:
|
||||
if h.get("id") == host_id:
|
||||
return dict(h)
|
||||
return None
|
||||
|
||||
def add_host(self, host_info: dict) -> str:
|
||||
"""新增主机;返回 id"""
|
||||
with self._lock:
|
||||
new_id = host_info.get("id") or f"host-{int(__import__('time').time()*1000)}"
|
||||
host_info["id"] = new_id
|
||||
self.hosts.append(host_info)
|
||||
self.save_hosts()
|
||||
return new_id
|
||||
|
||||
def update_host(self, host_id: str, host_info: dict):
|
||||
with self._lock:
|
||||
for i, h in enumerate(self.hosts):
|
||||
if h.get("id") == host_id:
|
||||
host_info["id"] = host_id
|
||||
self.hosts[i] = host_info
|
||||
self.save_hosts()
|
||||
# 断开旧连接
|
||||
if host_id in self.connections:
|
||||
self.connections[host_id].disconnect()
|
||||
del self.connections[host_id]
|
||||
return
|
||||
|
||||
def remove_host(self, host_id: str):
|
||||
with self._lock:
|
||||
self.hosts = [h for h in self.hosts if h.get("id") != host_id]
|
||||
if host_id in self.connections:
|
||||
self.connections[host_id].disconnect()
|
||||
del self.connections[host_id]
|
||||
self.save_hosts()
|
||||
|
||||
def connect(self, host_id: str) -> tuple:
|
||||
"""连接指定主机;返回 (conn, 成功, 消息)"""
|
||||
info = self.get_host(host_id)
|
||||
if not info:
|
||||
return None, False, "主机不存在"
|
||||
with self._lock:
|
||||
conn = self.connections.get(host_id)
|
||||
if conn and conn.connected:
|
||||
return conn, True, "已连接"
|
||||
conn = SSHConnection(
|
||||
host=info["host"], port=info.get("port", 22),
|
||||
username=info.get("username", ""),
|
||||
password=info.get("password", ""),
|
||||
key_path=info.get("key_path", ""),
|
||||
)
|
||||
ok, msg = conn.connect()
|
||||
if ok:
|
||||
self.connections[host_id] = conn
|
||||
return conn, ok, msg
|
||||
|
||||
def disconnect(self, host_id: str):
|
||||
with self._lock:
|
||||
if host_id in self.connections:
|
||||
self.connections[host_id].disconnect()
|
||||
del self.connections[host_id]
|
||||
|
||||
def get_connection(self, host_id: str) -> Optional[SSHConnection]:
|
||||
return self.connections.get(host_id)
|
||||
|
||||
def close_all(self):
|
||||
with self._lock:
|
||||
for c in self.connections.values():
|
||||
c.disconnect()
|
||||
self.connections.clear()
|
||||
|
||||
|
||||
def load_ai_config() -> dict:
|
||||
if AI_CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(AI_CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"api_key": "",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model": "gpt-4o-mini",
|
||||
"system_prompt": "",
|
||||
}
|
||||
|
||||
|
||||
def save_ai_config(cfg: dict):
|
||||
with open(AI_CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
远程主机系统监控模块
|
||||
通过 SSH 一次性采集 CPU/内存/磁盘/网络/负载指标。
|
||||
Linux 用 /proc 和常用命令;macOS/BSD 走兼容路径。
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .ssh_client import SSHConnection
|
||||
|
||||
|
||||
class SystemMonitor:
|
||||
"""远程主机的资源监控器(数据全部从 SSH 通道采集,不依赖 agent)"""
|
||||
|
||||
# 一次性获取所有指标的脚本(Linux)
|
||||
_LINUX_METRICS_SCRIPT = r"""
|
||||
echo "===CPU==="
|
||||
# 第一次采样 1 秒间隔,用来计算差值
|
||||
read cpu_user cpu_nice cpu_system cpu_idle cpu_iowait cpu_irq cpu_softirq cpu_steal < /proc/stat
|
||||
sleep 1
|
||||
read cpu_user2 cpu_nice2 cpu_system2 cpu_idle2 cpu_iowait2 cpu_irq2 cpu_softirq2 cpu_steal2 < /proc/stat
|
||||
total1=$((cpu_user+cpu_nice+cpu_system+cpu_idle+cpu_iowait+cpu_irq+cpu_softirq+cpu_steal))
|
||||
total2=$((cpu_user2+cpu_nice2+cpu_system2+cpu_idle2+cpu_iowait2+cpu_irq2+cpu_softirq2+cpu_steal2))
|
||||
idle1=$cpu_idle; idle2=$cpu_idle2
|
||||
dt=$((total2-total1)); di=$((idle2-idle1))
|
||||
if [ $dt -gt 0 ]; then usage=$(( (1000*(dt-di)/dt+5)/10 )); else usage=0; fi
|
||||
echo "CPU_USAGE=$usage"
|
||||
echo "CPU_CORES=$(nproc 2>/dev/null || echo 1)"
|
||||
echo "LOAD=$(cat /proc/loadavg | awk '{print $1,$2,$3}')"
|
||||
echo "UPTIME=$(awk '{printf "%.0f",$1}' /proc/uptime)"
|
||||
echo "===MEM==="
|
||||
mem_total=$(awk '/MemTotal/{print $2}' /proc/meminfo)
|
||||
mem_avail=$(awk '/MemAvailable/{print $2}' /proc/meminfo)
|
||||
swap_total=$(awk '/SwapTotal/{print $2}' /proc/meminfo)
|
||||
swap_free=$(awk '/SwapFree/{print $2}' /proc/meminfo)
|
||||
if [ -z "$mem_avail" ]; then mem_avail=$((mem_total - $(awk '/^(Buffers|Cached|SReclaimable):/{s+=$2} END{print s}' /proc/meminfo))); fi
|
||||
used=$((mem_total - mem_avail))
|
||||
echo "MEM_TOTAL=$mem_total"
|
||||
echo "MEM_USED=$used"
|
||||
echo "MEM_AVAIL=$mem_avail"
|
||||
echo "SWAP_TOTAL=$swap_total"
|
||||
echo "SWAP_USED=$((swap_total-swap_free))"
|
||||
echo "===DISK==="
|
||||
df -PB1 -x tmpfs -x devtmpfs 2>/dev/null | awk 'NR>1 {printf "DISK|%s|%d|%d|%s\n",$NF,$2,$3,$5}'
|
||||
echo "===NET==="
|
||||
for iface in $(ls /sys/class/net/ 2>/dev/null | grep -v lo); do
|
||||
rx=$(cat /sys/class/net/$iface/statistics/rx_bytes 2>/dev/null || echo 0)
|
||||
tx=$(cat /sys/class/net/$iface/statistics/tx_bytes 2>/dev/null || echo 0)
|
||||
echo "NET|$iface|$rx|$tx"
|
||||
done
|
||||
echo "===HOST==="
|
||||
echo "HOSTNAME=$(hostname)"
|
||||
echo "KERNEL=$(uname -r)"
|
||||
echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _parse_kv(text: str, key: str, default: str = "0") -> str:
|
||||
"""从 KEY=VALUE 行中取值"""
|
||||
m = re.search(rf"^{re.escape(key)}=(.+)$", text, re.MULTILINE)
|
||||
return m.group(1).strip() if m else default
|
||||
|
||||
@classmethod
|
||||
def collect(cls, conn: SSHConnection) -> dict:
|
||||
"""采集一次指标;返回 dict"""
|
||||
empty = {
|
||||
"cpu": 0.0, "cores": 1, "load1": 0, "load5": 0, "load15": 0,
|
||||
"uptime": 0, "hostname": "", "kernel": "", "os": "",
|
||||
"mem_total": 0, "mem_used": 0, "mem_percent": 0.0,
|
||||
"swap_total": 0, "swap_used": 0,
|
||||
"disks": [], "net": [],
|
||||
"ts": time.time(),
|
||||
}
|
||||
if not conn or not conn.connected:
|
||||
return empty
|
||||
code, out, err = conn.exec_command(cls._LINUX_METRICS_SCRIPT, timeout=10)
|
||||
if code != 0 or not out:
|
||||
empty["error"] = err or "采集失败"
|
||||
return empty
|
||||
|
||||
result = dict(empty)
|
||||
result["hostname"] = cls._parse_kv(out, "HOSTNAME")
|
||||
result["kernel"] = cls._parse_kv(out, "KERNEL")
|
||||
result["os"] = cls._parse_kv(out, "OS")
|
||||
try:
|
||||
result["cpu"] = float(cls._parse_kv(out, "CPU_USAGE"))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
result["cores"] = int(cls._parse_kv(out, "CPU_CORES", "1"))
|
||||
except ValueError:
|
||||
pass
|
||||
load = cls._parse_kv(out, "LOAD", "0 0 0").split()
|
||||
try:
|
||||
result["load1"] = float(load[0])
|
||||
result["load5"] = float(load[1]) if len(load) > 1 else 0
|
||||
result["load15"] = float(load[2]) if len(load) > 2 else 0
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
try:
|
||||
result["uptime"] = int(cls._parse_kv(out, "UPTIME"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
mt = int(cls._parse_kv(out, "MEM_TOTAL"))
|
||||
mu = int(cls._parse_kv(out, "MEM_USED"))
|
||||
result["mem_total"] = mt
|
||||
result["mem_used"] = mu
|
||||
result["mem_percent"] = (mu / mt * 100) if mt > 0 else 0.0
|
||||
result["swap_total"] = int(cls._parse_kv(out, "SWAP_TOTAL"))
|
||||
result["swap_used"] = int(cls._parse_kv(out, "SWAP_USED"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
result["disks"] = []
|
||||
for line in out.splitlines():
|
||||
if line.startswith("DISK|"):
|
||||
_, mount, total, used, percent = line.split("|", 4)
|
||||
try:
|
||||
result["disks"].append({
|
||||
"mount": mount, "total": int(total),
|
||||
"used": int(used), "percent": int(percent.rstrip("%")),
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
result["net"] = []
|
||||
for line in out.splitlines():
|
||||
if line.startswith("NET|"):
|
||||
_, name, rx, tx = line.split("|", 3)
|
||||
try:
|
||||
result["net"].append({
|
||||
"iface": name, "rx": int(rx), "tx": int(tx),
|
||||
})
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def format_bytes(n: int) -> str:
|
||||
"""人类可读字节数"""
|
||||
n = float(n)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB", "PB"):
|
||||
if n < 1024:
|
||||
return f"{n:.1f}{unit}"
|
||||
n /= 1024
|
||||
return f"{n:.1f}EB"
|
||||
|
||||
@staticmethod
|
||||
def format_uptime(seconds: int) -> str:
|
||||
seconds = int(seconds)
|
||||
d, rem = divmod(seconds, 86400)
|
||||
h, rem = divmod(rem, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
if d:
|
||||
return f"{d}天{h}小时"
|
||||
if h:
|
||||
return f"{h}小时{m}分"
|
||||
if m:
|
||||
return f"{m}分{s}秒"
|
||||
return f"{s}秒"
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
SSH 客户端核心模块
|
||||
封装 paramiko,处理连接、命令执行、SFTP 文件传输。
|
||||
所有 SSH 操作都通过此模块,与 UI 解耦。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, List
|
||||
|
||||
import paramiko
|
||||
from paramiko import SSHClient, AutoAddPolicy, RSAKey, Ed25519Key
|
||||
from paramiko.ssh_exception import AuthenticationException, SSHException
|
||||
|
||||
|
||||
class SSHConnection:
|
||||
"""单台主机的 SSH 连接管理"""
|
||||
|
||||
def __init__(self, host: str, port: int = 22, username: str = "",
|
||||
password: str = "", key_path: str = "", timeout: int = 10):
|
||||
self.host = host
|
||||
self.port = int(port) if port else 22
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.key_path = key_path
|
||||
self.timeout = timeout
|
||||
self.client: Optional[SSHClient] = None
|
||||
self.sftp: Optional[paramiko.SFTPClient] = None
|
||||
self.connected = False
|
||||
self.last_error = ""
|
||||
|
||||
def connect(self) -> Tuple[bool, str]:
|
||||
"""建立连接;返回 (成功, 消息)"""
|
||||
try:
|
||||
self.client = SSHClient()
|
||||
self.client.set_missing_host_key_policy(AutoAddPolicy())
|
||||
connect_kwargs = {
|
||||
"hostname": self.host,
|
||||
"port": self.port,
|
||||
"username": self.username,
|
||||
"timeout": self.timeout,
|
||||
"allow_agent": False,
|
||||
"look_for_keys": False,
|
||||
}
|
||||
if self.key_path and os.path.isfile(self.key_path):
|
||||
pkey = self._load_key(self.key_path, self.password)
|
||||
connect_kwargs["pkey"] = pkey
|
||||
if self.password:
|
||||
connect_kwargs["password"] = self.password
|
||||
else:
|
||||
connect_kwargs["password"] = self.password
|
||||
|
||||
self.client.connect(**connect_kwargs)
|
||||
self.sftp = self.client.open_sftp()
|
||||
self.connected = True
|
||||
return True, f"已连接到 {self.username}@{self.host}:{self.port}"
|
||||
except AuthenticationException as e:
|
||||
self.last_error = f"认证失败: {e}"
|
||||
except SSHException as e:
|
||||
self.last_error = f"SSH 错误: {e}"
|
||||
except Exception as e:
|
||||
self.last_error = f"连接失败: {e}"
|
||||
self.connected = False
|
||||
return False, self.last_error
|
||||
|
||||
def _load_key(self, path: str, passphrase: str = ""):
|
||||
"""自动识别 RSA / Ed25519 私钥格式"""
|
||||
for loader in (Ed25519Key, RSAKey):
|
||||
try:
|
||||
return loader.from_private_key_file(path, password=passphrase or None)
|
||||
except paramiko.ssh_exception.PasswordRequiredException:
|
||||
raise
|
||||
except paramiko.ssh_exception.SSHException:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
raise SSHException(f"无法加载私钥: {path}")
|
||||
|
||||
def disconnect(self):
|
||||
"""关闭 SFTP 和 SSH 连接"""
|
||||
for handle in (self.sftp, self.client):
|
||||
try:
|
||||
if handle:
|
||||
handle.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.sftp = None
|
||||
self.client = None
|
||||
self.connected = False
|
||||
|
||||
def exec_command(self, command: str, timeout: int = 30) -> Tuple[int, str, str]:
|
||||
"""执行远程命令;返回 (退出码, stdout, stderr)"""
|
||||
if not self.connected or not self.client:
|
||||
return -1, "", "未连接"
|
||||
try:
|
||||
stdin, stdout, stderr = self.client.exec_command(command, 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
|
||||
except Exception as e:
|
||||
return -1, "", f"执行错误: {e}"
|
||||
|
||||
def list_dir(self, remote_path: str) -> List[dict]:
|
||||
"""列出远程目录;返回 [{name, size, mtime, mode, is_dir}, ...]"""
|
||||
if not self.sftp:
|
||||
return []
|
||||
try:
|
||||
entries = []
|
||||
for attr in self.sftp.listdir_attr(remote_path):
|
||||
entries.append({
|
||||
"name": attr.filename,
|
||||
"size": attr.st_size or 0,
|
||||
"mtime": attr.st_mtime or 0,
|
||||
"mode": attr.st_mode or 0,
|
||||
"is_dir": attr.st_mode is not None and (attr.st_mode & 0o170000) == 0o040000,
|
||||
})
|
||||
# 目录优先,再按名字排序
|
||||
entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
|
||||
return entries
|
||||
except Exception as e:
|
||||
self.last_error = f"列目录失败: {e}"
|
||||
return []
|
||||
|
||||
def upload(self, local_path: str, remote_path: str, progress_cb=None) -> Tuple[bool, str]:
|
||||
"""上传本地文件到远程;progress_cb(done, total) 回调"""
|
||||
if not self.sftp:
|
||||
return False, "SFTP 未就绪"
|
||||
try:
|
||||
total = os.path.getsize(local_path)
|
||||
done = [0]
|
||||
|
||||
def _cb(transferred, _total):
|
||||
done[0] = transferred
|
||||
if progress_cb:
|
||||
progress_cb(transferred, _total or total)
|
||||
|
||||
self.sftp.put(local_path, remote_path, callback=_cb)
|
||||
return True, f"已上传 {os.path.basename(local_path)} ({total} bytes)"
|
||||
except Exception as e:
|
||||
return False, f"上传失败: {e}"
|
||||
|
||||
def download(self, remote_path: str, local_path: str, progress_cb=None) -> Tuple[bool, str]:
|
||||
"""下载远程文件到本地"""
|
||||
if not self.sftp:
|
||||
return False, "SFTP 未就绪"
|
||||
try:
|
||||
total = self.sftp.stat(remote_path).st_size
|
||||
done = [0]
|
||||
|
||||
def _cb(transferred, _total):
|
||||
done[0] = transferred
|
||||
if progress_cb:
|
||||
progress_cb(transferred, _total or total)
|
||||
|
||||
self.sftp.get(remote_path, local_path, callback=_cb)
|
||||
return True, f"已下载到 {local_path}"
|
||||
except Exception as e:
|
||||
return False, f"下载失败: {e}"
|
||||
|
||||
def mkdir(self, remote_path: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
self.sftp.mkdir(remote_path)
|
||||
return True, f"已创建 {remote_path}"
|
||||
except Exception as e:
|
||||
return False, f"创建失败: {e}"
|
||||
|
||||
def remove(self, remote_path: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
try:
|
||||
self.sftp.remove(remote_path)
|
||||
except IOError:
|
||||
self.sftp.rmdir(remote_path)
|
||||
return True, f"已删除 {remote_path}"
|
||||
except Exception as e:
|
||||
return False, f"删除失败: {e}"
|
||||
|
||||
def rename(self, old_path: str, new_path: str) -> Tuple[bool, str]:
|
||||
try:
|
||||
self.sftp.rename(old_path, new_path)
|
||||
return True, "已重命名"
|
||||
except Exception as e:
|
||||
return False, f"重命名失败: {e}"
|
||||
|
||||
def stat(self, remote_path: str):
|
||||
try:
|
||||
return self.sftp.stat(remote_path)
|
||||
except Exception as e:
|
||||
self.last_error = f"stat 失败: {e}"
|
||||
return None
|
||||
Reference in New Issue
Block a user