Client SSH/SFTP personnel pour Windows
Reunit en un seul outil les fonctions utiles de MobaXterm, Termius et Bitvise, sans compte ni service tiers, avec toutes les donnees en local. - Terminaux multi-onglets : sessions SSH et terminaux Windows locaux (PowerShell / cmd) via la pseudo-console native ConPTY - Gestionnaire de fichiers SFTP facon WinSCP : double panneau, colonnes triables, glisser-deposer, renommage, permissions, file de transferts. Ouvrable sur une connexion en cours sans creer de favori - Gestion des cles SSH : generation Ed25519/RSA, import, export - Gestion du fichier known_hosts, avec remplacement guide d'une cle d'hote modifiee (cas frequent des serveurs en DHCP) - Trois themes (clair, bleu nuit, noir), commutables a chaud - Authentification automatique reprenant le comportement de la commande ssh (agent SSH et cles ~/.ssh), mot de passe demande seulement en secours Aucun mot de passe n'est stocke sur le disque. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
336
app/ssh_connection.py
Normal file
336
app/ssh_connection.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Connexion SSH en arriere-plan (thread Qt) : shell interactif + verification des cles d'hote."""
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from app.known_hosts_manager import KnownHostsStore
|
||||
|
||||
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
||||
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
|
||||
|
||||
|
||||
class TrustPrompt(paramiko.MissingHostKeyPolicy):
|
||||
"""Politique qui demande confirmation (via signal Qt) avant de faire confiance a une nouvelle cle d'hote."""
|
||||
|
||||
def __init__(self, connection: "SSHConnection"):
|
||||
self.connection = connection
|
||||
|
||||
def missing_host_key(self, client, hostname, key):
|
||||
accepted = self.connection._ask_host_key_trust(hostname, key)
|
||||
if not accepted:
|
||||
raise paramiko.SSHException(f"Cle d'hote refusee pour {hostname}")
|
||||
client.get_host_keys().add(hostname, key.get_name(), key)
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
client.save_host_keys(str(KNOWN_HOSTS_FILE))
|
||||
|
||||
|
||||
class SSHConnection(QThread):
|
||||
"""Gere une connexion SSH + un canal shell interactif dans un thread dedie."""
|
||||
|
||||
connected = Signal()
|
||||
data_received = Signal(bytes)
|
||||
disconnected = Signal(str)
|
||||
error = Signal(str)
|
||||
host_key_prompt = Signal(str, str, str) # hostname, key_type, fingerprint -> attend confirm_host_key()
|
||||
host_key_changed_prompt = Signal(str, str, str, str) # hostname, key_type, old_fp, new_fp -> attend confirm_host_key_change()
|
||||
passphrase_prompt = Signal(str) # key_path -> attend provide_passphrase()
|
||||
password_prompt = Signal(str) # "user@host" -> attend provide_password()
|
||||
sftp_ready = Signal(object) # SFTPClient ouvert dans le thread, pret a l'emploi
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
username: str,
|
||||
auth_type: str,
|
||||
key_path: str = "",
|
||||
password: str = "",
|
||||
passphrase: str = "",
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
open_shell: bool = True,
|
||||
open_sftp_on_connect: bool = False,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.open_shell = open_shell
|
||||
self.open_sftp_on_connect = open_sftp_on_connect
|
||||
self.sftp: paramiko.SFTPClient | None = None
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.auth_type = auth_type
|
||||
self.key_path = key_path
|
||||
self.password = password
|
||||
self.passphrase = passphrase
|
||||
self.cols = cols
|
||||
self.rows = rows
|
||||
|
||||
self.client: paramiko.SSHClient | None = None
|
||||
self.channel: paramiko.Channel | None = None
|
||||
self._stop = threading.Event()
|
||||
|
||||
self._trust_event = threading.Event()
|
||||
self._trust_result = False
|
||||
|
||||
self._passphrase_event = threading.Event()
|
||||
self._passphrase_result: str | None = None
|
||||
|
||||
self._host_key_change_event = threading.Event()
|
||||
self._host_key_change_result = False
|
||||
|
||||
self._password_event = threading.Event()
|
||||
self._password_result: str | None = None
|
||||
self.known_hosts = KnownHostsStore(KNOWN_HOSTS_FILE)
|
||||
|
||||
# File d'envoi + thread dedie : send() est appele directement depuis le thread GUI
|
||||
# (une frappe clavier). channel.send() peut bloquer (reseau congestionne, fenetre TCP
|
||||
# pleine) ; sans decouplage, ce blocage gele toute l'interface le temps de l'ecriture.
|
||||
self._out_queue: queue.Queue[bytes] = queue.Queue()
|
||||
self._writer_thread: threading.Thread | None = None
|
||||
|
||||
# ---------- confiance cle d'hote (appele depuis le thread SSH) ----------
|
||||
def _ask_host_key_trust(self, hostname: str, key: paramiko.PKey) -> bool:
|
||||
self._trust_event.clear()
|
||||
fingerprint = key.get_fingerprint().hex(":")
|
||||
self.host_key_prompt.emit(hostname, key.get_name(), fingerprint)
|
||||
self._trust_event.wait() # debloque via confirm_host_key() depuis le thread GUI
|
||||
return self._trust_result
|
||||
|
||||
def confirm_host_key(self, accepted: bool) -> None:
|
||||
self._trust_result = accepted
|
||||
self._trust_event.set()
|
||||
|
||||
# ---------- cle d'hote modifiee, ex. IP DHCP reattribuee (appele depuis le thread SSH) ----------
|
||||
def _ask_host_key_change(self, hostname: str, got_key: paramiko.PKey, expected_key: paramiko.PKey) -> bool:
|
||||
self._host_key_change_event.clear()
|
||||
old_fp = expected_key.get_fingerprint().hex(":")
|
||||
new_fp = got_key.get_fingerprint().hex(":")
|
||||
self.host_key_changed_prompt.emit(hostname, got_key.get_name(), old_fp, new_fp)
|
||||
self._host_key_change_event.wait() # debloque via confirm_host_key_change() depuis le thread GUI
|
||||
return self._host_key_change_result
|
||||
|
||||
def confirm_host_key_change(self, accepted: bool) -> None:
|
||||
self._host_key_change_result = accepted
|
||||
self._host_key_change_event.set()
|
||||
|
||||
# ---------- passphrase de cle privee (appele depuis le thread SSH) ----------
|
||||
def _ask_passphrase(self, key_path: str) -> str | None:
|
||||
self._passphrase_event.clear()
|
||||
self._passphrase_result = None
|
||||
self.passphrase_prompt.emit(key_path)
|
||||
self._passphrase_event.wait() # debloque via provide_passphrase() depuis le thread GUI
|
||||
return self._passphrase_result
|
||||
|
||||
def provide_passphrase(self, text: str | None) -> None:
|
||||
self._passphrase_result = text or None
|
||||
self._passphrase_event.set()
|
||||
|
||||
# ---------- mot de passe demande en secours (appele depuis le thread SSH) ----------
|
||||
def _ask_password(self) -> str | None:
|
||||
self._password_event.clear()
|
||||
self._password_result = None
|
||||
self.password_prompt.emit(f"{self.username}@{self.host}")
|
||||
self._password_event.wait() # debloque via provide_password() depuis le thread GUI
|
||||
return self._password_result
|
||||
|
||||
def provide_password(self, text: str | None) -> None:
|
||||
self._password_result = text or None
|
||||
self._password_event.set()
|
||||
|
||||
# ---------- cycle de vie du thread ----------
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.client = paramiko.SSHClient()
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
if KNOWN_HOSTS_FILE.exists():
|
||||
self.client.load_host_keys(str(KNOWN_HOSTS_FILE))
|
||||
self.client.set_missing_host_key_policy(TrustPrompt(self))
|
||||
|
||||
auto = self.auth_type == "auto"
|
||||
connect_kwargs = dict(
|
||||
hostname=self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
timeout=15,
|
||||
# en mode auto on se comporte comme la commande ssh : agent SSH
|
||||
# et cles par defaut de ~/.ssh, sans rien avoir a designer
|
||||
allow_agent=(auto or self.auth_type == "agent"),
|
||||
look_for_keys=auto,
|
||||
)
|
||||
if self.auth_type == "key":
|
||||
if not self.key_path:
|
||||
raise paramiko.SSHException(
|
||||
"Aucune cle privee selectionnee. Choisissez une cle, ou l'authentification "
|
||||
"automatique / par mot de passe."
|
||||
)
|
||||
connect_kwargs["pkey"] = self._load_private_key(self.key_path, self.passphrase)
|
||||
elif self.auth_type == "password":
|
||||
connect_kwargs["password"] = self.password
|
||||
elif auto and self.password:
|
||||
connect_kwargs["password"] = self.password
|
||||
|
||||
try:
|
||||
try:
|
||||
self.client.connect(**connect_kwargs)
|
||||
except paramiko.AuthenticationException:
|
||||
# Mode auto : ni l'agent ni les cles ~/.ssh n'ont convenu,
|
||||
# on demande alors un mot de passe plutot que d'echouer.
|
||||
if not auto or connect_kwargs.get("password"):
|
||||
raise
|
||||
password = self._ask_password()
|
||||
if not password:
|
||||
raise
|
||||
connect_kwargs["password"] = password
|
||||
connect_kwargs["look_for_keys"] = False
|
||||
connect_kwargs["allow_agent"] = False
|
||||
self.client.connect(**connect_kwargs)
|
||||
except paramiko.BadHostKeyException as exc:
|
||||
# La cle presentee par le serveur ne correspond plus a celle enregistree
|
||||
# (frequent avec des IP DHCP reattribuees a une autre machine).
|
||||
accepted = self._ask_host_key_change(exc.hostname, exc.got_key, exc.expected_key)
|
||||
if not accepted:
|
||||
raise paramiko.SSHException(
|
||||
f"Cle d'hote modifiee refusee pour {exc.hostname} (connexion annulee)."
|
||||
) from exc
|
||||
self.known_hosts.replace_host_key(exc.hostname, exc.got_key)
|
||||
# Corrige aussi la copie en memoire utilisee par ce client pour la reconnexion
|
||||
# (load_host_keys() fusionnerait avec l'ancienne entree encore en memoire).
|
||||
live_keys = self.client.get_host_keys()
|
||||
if exc.hostname in live_keys:
|
||||
del live_keys[exc.hostname]
|
||||
live_keys.add(exc.hostname, exc.got_key.get_name(), exc.got_key)
|
||||
self.client.connect(**connect_kwargs)
|
||||
|
||||
# Ouvert ici (dans le thread) et non depuis l'interface : open_sftp() fait un
|
||||
# aller-retour reseau qui figerait la fenetre s'il etait appele au clic.
|
||||
if self.open_sftp_on_connect:
|
||||
try:
|
||||
self.sftp = self.client.open_sftp()
|
||||
self.sftp_ready.emit(self.sftp)
|
||||
except Exception:
|
||||
# Sans shell, le SFTP est la raison d'etre de la connexion : c'est fatal.
|
||||
# Avec un shell, le terminal doit rester utilisable meme si le serveur
|
||||
# n'expose pas le sous-systeme SFTP.
|
||||
if not self.open_shell:
|
||||
raise
|
||||
|
||||
if not self.open_shell:
|
||||
# Connexion dediee au SFTP : on garde la session vivante sans shell.
|
||||
self.connected.emit()
|
||||
while not self._stop.is_set():
|
||||
self._stop.wait(0.2)
|
||||
self.disconnected.emit("Connexion fermee")
|
||||
return
|
||||
|
||||
self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows)
|
||||
# La taille reelle du widget arrive souvent apres la creation de la connexion :
|
||||
# on reapplique la derniere taille demandee, sinon le shell distant reste en
|
||||
# 80x24 alors que l'affichage est plus grand (retours a la ligne incoherents).
|
||||
try:
|
||||
self.channel.resize_pty(width=self.cols, height=self.rows)
|
||||
except Exception:
|
||||
pass
|
||||
self.channel.settimeout(0.5)
|
||||
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
|
||||
self._writer_thread.start()
|
||||
self.connected.emit()
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
data = self.channel.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
self.data_received.emit(data)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception:
|
||||
break
|
||||
|
||||
self.disconnected.emit("Connexion fermee")
|
||||
except paramiko.AuthenticationException:
|
||||
self.error.emit("Authentification refusee (identifiants ou cle invalides).")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.error.emit(str(exc))
|
||||
finally:
|
||||
self._cleanup()
|
||||
|
||||
def _load_private_key(self, key_path: str, passphrase: str) -> paramiko.PKey:
|
||||
last_exc = None
|
||||
for loader in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
|
||||
try:
|
||||
return loader.from_private_key_file(key_path, password=passphrase or None)
|
||||
except paramiko.PasswordRequiredException:
|
||||
pwd = passphrase or self._ask_passphrase(key_path)
|
||||
if not pwd:
|
||||
raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle")
|
||||
try:
|
||||
return loader.from_private_key_file(key_path, password=pwd)
|
||||
except paramiko.SSHException as exc:
|
||||
last_exc = exc
|
||||
continue
|
||||
except paramiko.SSHException as exc:
|
||||
last_exc = exc
|
||||
continue
|
||||
raise last_exc or paramiko.SSHException("Format de cle non reconnu")
|
||||
|
||||
def send(self, data: bytes) -> None:
|
||||
"""Appele depuis le thread GUI (frappe clavier) : ne doit jamais bloquer."""
|
||||
self._out_queue.put(data)
|
||||
|
||||
def _writer_loop(self) -> None:
|
||||
"""Tourne dans son propre thread : effectue les ecritures reseau potentiellement bloquantes.
|
||||
|
||||
Une erreur d'ecriture ponctuelle ne doit pas tuer le thread : sinon, toute frappe
|
||||
suivante serait silencieusement perdue pour le reste de la session (le canal
|
||||
restant lui-meme ouvert). On n'arrete la boucle que si le canal est ferme."""
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
data = self._out_queue.get(timeout=0.2)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if not (self.channel and not self.channel.closed):
|
||||
continue
|
||||
try:
|
||||
self.channel.send(data)
|
||||
except OSError:
|
||||
if not self.channel or self.channel.closed:
|
||||
break
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
self.cols, self.rows = cols, rows
|
||||
if self.channel and not self.channel.closed:
|
||||
try:
|
||||
self.channel.resize_pty(width=cols, height=rows)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def open_sftp(self) -> paramiko.SFTPClient | None:
|
||||
if self.client:
|
||||
return self.client.open_sftp()
|
||||
return None
|
||||
|
||||
def close_connection(self) -> None:
|
||||
self._stop.set()
|
||||
self._trust_event.set() # au cas ou on serait bloque sur une confirmation
|
||||
self._passphrase_event.set()
|
||||
self._host_key_change_event.set()
|
||||
self._password_event.set()
|
||||
self._cleanup()
|
||||
|
||||
def _cleanup(self) -> None:
|
||||
try:
|
||||
if self.channel:
|
||||
self.channel.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if self.client:
|
||||
self.client.close()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user