feat: ajouter le keepalive SSH et la reconnexion automatique
- Session gagne keepalive_enabled/interval/count_max (actif par defaut : ne fait qu'envoyer des paquets de maintien, sans risque pour les sessions existantes) et reconnect_enabled/initial_delay/max_attempts (desactive par defaut : change reellement le comportement en cas de coupure, donc a activer explicitement). - SSHConnection._keepalive_loop() : thread dedie qui envoie "keepalive@openssh.com" (comme OpenSSH) avec accuse de reception toutes les `keepalive_interval` secondes. Un serveur conforme repond immediatement (meme en echec), ce qui confirme que le transport est vivant. Apres `keepalive_count_max` echecs consecutifs (aucune reponse), le canal est ferme pour que la boucle de lecture principale s'en apercoive et emette `disconnected` avec une raison explicite. - SessionTabWidget programme une reconnexion (QTimer, jamais de blocage de l'interface) quand la session l'autorise et que la deconnexion n'est pas volontaire (_closing) : delai en backoff exponentiel plafonne (app.services.reconnect.compute_backoff_delay), compte a rebours affiche ("Connexion perdue (...). Reconnexion dans Ns... (Tentative X/Y)"), abandon au-dela de reconnect_max_attempts (0 = illimite), compteur remis a zero des qu'une connexion reussit. Ne se declenche jamais sur une simple erreur d'authentification (seul un `disconnected` apres connexion reussie declenche une reconnexion, pas un `error` de connexion initiale - sinon un mot de passe errone entrainerait des tentatives repetees). - BaseTerminalTab : les signaux connected/disconnected/error d'une connexion sont desormais associes a la generation de connexion en cours au moment ou ils sont cables ; un signal parvenant apres qu'une reconnexion (manuelle ou automatique) ait deja demarre une nouvelle connexion est ignore. Necessaire ici : les signaux Qt entre threads sont mis en file et ne sont livres qu'apres le retour de reconnect() a la boucle d'evenements, alors qu'une nouvelle connexion est deja en place. - SessionDialog : sections "Keepalive" et "Reconnexion automatique". tests/test_keepalive.py (paramiko entierement mocke), test_reconnect_backoff.py (fonction pure) et test_auto_reconnect.py (SSHConnection.start() neutralisee : la programmation de la reconnexion ne depend d'aucune connexion reseau reelle). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,9 @@ class SSHConnection(QThread):
|
||||
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)
|
||||
@@ -74,11 +77,16 @@ class SSHConnection(QThread):
|
||||
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
|
||||
@@ -259,6 +267,44 @@ class SSHConnection(QThread):
|
||||
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:
|
||||
@@ -283,12 +329,14 @@ class SSHConnection(QThread):
|
||||
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("Connexion fermee")
|
||||
self.disconnected.emit(self._disconnect_reason)
|
||||
return
|
||||
|
||||
self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows)
|
||||
@@ -315,7 +363,7 @@ class SSHConnection(QThread):
|
||||
except Exception:
|
||||
break
|
||||
|
||||
self.disconnected.emit("Connexion fermee")
|
||||
self.disconnected.emit(self._disconnect_reason)
|
||||
except paramiko.AuthenticationException:
|
||||
self.error.emit("Authentification refusee (identifiants ou cle invalides).")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
||||
Reference in New Issue
Block a user