Files
pyssh-client/app/ssh_connection.py
Florian c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:46:50 +02:00

456 lines
20 KiB
Python

"""Connexion SSH en arriere-plan (thread Qt) : shell interactif + verification des cles d'hote."""
from __future__ import annotations
import queue
import threading
import paramiko
from PySide6.QtCore import QThread, Signal
from app.bastion import BastionTarget
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,
bastion_target: BastionTarget | None = None,
bastion_password: str = "",
keepalive_enabled: bool = False,
keepalive_interval: int = 30,
keepalive_count_max: int = 3,
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.bastion_target = bastion_target
self.bastion_password = bastion_password
self.keepalive_enabled = keepalive_enabled
self.keepalive_interval = max(1, keepalive_interval)
self.keepalive_count_max = max(1, keepalive_count_max)
self.client: paramiko.SSHClient | None = None
self.channel: paramiko.Channel | None = None
self._bastion_client: paramiko.SSHClient | None = None
self._stop = threading.Event()
self._disconnect_reason = "Connexion fermee"
self._keepalive_thread: threading.Thread | None = None
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, label: str | None = None) -> str | None:
self._password_event.clear()
self._password_result = None
self.password_prompt.emit(label or 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()
# ---------- connexion (cible ou bastion, meme logique pour les deux) ----------
def _new_client(self) -> paramiko.SSHClient:
"""Client SSH pret a etre connecte : cles d'hote systeme + notre known_hosts,
et confirmation TOFU via cette meme SSHConnection pour une cle inconnue/modifiee."""
client = paramiko.SSHClient()
DATA_DIR.mkdir(parents=True, exist_ok=True)
# Les hotes deja approuves par la commande ssh (~/.ssh/known_hosts) sont
# reconnus : inutile de reconfirmer une empreinte que votre ssh connait deja.
# paramiko ne reecrit jamais ce fichier ; les nouvelles cles vont dans le notre.
try:
client.load_system_host_keys()
except Exception:
pass
if KNOWN_HOSTS_FILE.exists():
client.load_host_keys(str(KNOWN_HOSTS_FILE))
client.set_missing_host_key_policy(TrustPrompt(self))
return client
def _connect_host(
self, client: paramiko.SSHClient, host: str, port: int, username: str, auth_type: str,
key_path: str, password: str, passphrase: str, sock=None, label: str | None = None,
) -> None:
"""Connecte `client` a host:port, en gerant l'authentification, la demande
interactive de mot de passe de secours (mode auto) et une cle d'hote modifiee.
Utilise a l'identique pour la cible et pour un eventuel bastion."""
label = label or f"{username}@{host}"
auto = auth_type == "auto"
connect_kwargs = dict(
hostname=host,
port=port,
username=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 auth_type == "agent"),
look_for_keys=auto,
sock=sock,
)
if auth_type == "key":
if not key_path:
raise paramiko.SSHException(
f"{label} : aucune cle privee selectionnee. Choisissez une cle, ou "
"l'authentification automatique / par mot de passe."
)
connect_kwargs["pkey"] = self._load_private_key(key_path, passphrase)
elif auth_type == "password":
connect_kwargs["password"] = password
elif auto and password:
connect_kwargs["password"] = password
try:
try:
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
asked = self._ask_password(label)
if not asked:
raise
connect_kwargs["password"] = asked
connect_kwargs["look_for_keys"] = False
connect_kwargs["allow_agent"] = False
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 = 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)
client.connect(**connect_kwargs)
def _open_bastion_channel(self) -> paramiko.Channel:
"""Connexion au bastion puis canal direct-tcpip vers la cible reelle, a
passer en `sock=` de la connexion finale (mecanisme ProxyJump standard)."""
target = self.bastion_target
bastion_client = self._new_client()
label = f"{target.label} (bastion)"
try:
self._connect_host(
bastion_client, target.host, target.port, target.username, target.auth_type,
target.key_path, self.bastion_password, "", label=label,
)
except Exception as exc: # noqa: BLE001
try:
bastion_client.close()
except Exception:
pass
raise paramiko.SSHException(f"Bastion {target.label} : {exc}") from exc
self._bastion_client = bastion_client
try:
return bastion_client.get_transport().open_channel(
"direct-tcpip", (self.host, self.port), ("127.0.0.1", 0),
)
except Exception as exc: # noqa: BLE001
raise paramiko.SSHException(
f"Bastion {target.label} : impossible d'ouvrir un canal vers "
f"{self.host}:{self.port} ({exc})"
) from exc
# ---------- keepalive (ServerAliveInterval / ServerAliveCountMax) ----------
def _start_keepalive(self) -> None:
if not self.keepalive_enabled:
return
self._keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True)
self._keepalive_thread.start()
def _keepalive_loop(self) -> None:
"""Envoie une requete keepalive avec accuse de reception toutes les
`keepalive_interval` secondes. Apres `keepalive_count_max` echecs
consecutifs (pas de reponse : transport mort), force la fermeture du
canal pour que la boucle de lecture principale s'en apercoive et
declenche la deconnexion (et, si activee, la reconnexion automatique)."""
failures = 0
while not self._stop.wait(self.keepalive_interval):
transport = self.client.get_transport() if self.client else None
if not transport or not transport.is_active():
return
try:
# Nom choisi comme celui d'OpenSSH : un serveur conforme repond
# "request failed" immediatement, ce qui suffit a confirmer que
# le transport est bien vivant (le contenu de la reponse importe peu).
transport.global_request("keepalive@openssh.com", wait=True)
failures = 0
except Exception: # noqa: BLE001
failures += 1
if failures >= self.keepalive_count_max:
self._disconnect_reason = (
f"keepalive : aucune reponse apres {failures} tentative(s)"
)
self._stop.set()
try:
if self.channel:
self.channel.close()
except Exception:
pass
return
# ---------- cycle de vie du thread ----------
def run(self) -> None:
try:
self.client = self._new_client()
sock = self._open_bastion_channel() if self.bastion_target is not None else None
self._connect_host(
self.client, self.host, self.port, self.username, self.auth_type,
self.key_path, self.password, self.passphrase, sock=sock,
)
# 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
self._start_keepalive()
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(self._disconnect_reason)
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(self._disconnect_reason)
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:
# l'utilisateur a annule la demande de passphrase : ce n'est pas un
# echec a diagnostiquer, la PasswordRequiredException d'origine n'a
# rien a apporter au message affiche.
raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle") from None
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
try:
if self._bastion_client:
self._bastion_client.close()
except Exception:
pass