9dbd500399
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)
213 lines
7.9 KiB
Python
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
|