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:
Hermes
2026-07-28 21:01:31 +08:00
commit a57dbc0252
22 changed files with 3294 additions and 0 deletions
View File
+250
View File
@@ -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
View File
@@ -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
View File
@@ -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}"
+191
View File
@@ -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