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>
456 lines
20 KiB
Python
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
|