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:
Florian
2026-09-05 02:27:19 +02:00
parent fe938a47b0
commit 9c8017cebd
8 changed files with 499 additions and 6 deletions

View File

@@ -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