Files
sshclient/core/ssh_client.py
T
Hermes 9dbd500399 feat: real interactive terminal (Xshell/PuTTY style)
User feedback: 'your terminal can only send commands, not type
freely. Useless.'

Replaced the old 'single command + output' terminal with a true
interactive shell panel:

NEW: ui/terminal_panel.py
- Uses paramiko invoke_shell() to open a long-lived shell session
- Every keystroke is forwarded to the remote shell (not a textbox)
- Remote output (incl. ANSI color codes) is parsed and rendered
  inline in a QPlainTextEdit (16-color SGR, bold, underline, invert)
- Cursor kept on the current input line; user can't edit past output
- Local command history with up/down navigation (stores last 200)
- Standard terminal shortcuts: Ctrl+C (SIGINT), Ctrl+D (EOF),
  Ctrl+L (clear), Ctrl+A/E (line start/end), Ctrl+U/K/W
- Terminal resize forwarded to remote PTY (vim/top work correctly)
- Tab key forwarded to remote for completion

CHANGED: core/ssh_client.py
- Added open_shell(term_type, cols, rows) -> Channel
- Added resize_shell(chan, cols, rows)

CHANGED: ui/main_window.py
- Replaced old _build_terminal_tab (now removed) with TerminalPanel
- On host connect -> auto-open shell, switch to Terminal tab
- On host disconnect / switch -> close shell cleanly

NEW: test_terminal.py (5 tests, all pass)
- Real SSH connection, opens shell
- Sends 'ls /tmp' via simulated keypresses, verifies 162-line output
- Sends ANSI color command, verifies colored text appears
- Tests up-arrow history recall
- Verifies prompt returns after command completes

All existing tests still pass (core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5)
2026-07-28 21:36:49 +08:00

213 lines
7.9 KiB
Python

"""
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 open_shell(self, term_type: str = "xterm", cols: int = 80, rows: int = 24):
"""打开一个交互式 shell 会话(用于真正的终端模拟器)"""
if not self.connected or not self.client:
return None
try:
chan = self.client.invoke_shell(term=term_type, width=cols, height=rows)
chan.settimeout(0.0) # 非阻塞
return chan
except Exception as e:
self.last_error = f"打开 shell 失败: {e}"
return None
def resize_shell(self, chan, cols: int, rows: int):
"""终端大小变化时通知远端(vim/top 这类全屏程序需要)"""
if not chan:
return
try:
chan.resize_pty(width=cols, height=rows)
except Exception:
pass
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