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

@@ -11,7 +11,7 @@ import re
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtCore import Qt, Signal, QTimer
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter,
@@ -27,6 +27,7 @@ from app.dialogs.tunnel_dialog import TunnelDialog
from app.tunnels import TunnelManager
from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change
from app.services.ssh_target import detect_ssh_target
from app.services.reconnect import compute_backoff_delay
# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
ANSI_ESCAPE_RE = re.compile(
@@ -104,6 +105,8 @@ class BaseTerminalTab(QWidget):
self.connection = None # assigne par les sous-classes
self._log_file = None
self._connection_generation = 0
self._closing = False
# ---------- journalisation ----------
def toggle_logging(self) -> None:
@@ -147,20 +150,34 @@ class BaseTerminalTab(QWidget):
self.stop_logging()
def _wire_connection(self) -> None:
self.connection.connected.connect(self._on_connected)
# Un signal deconnexion/erreur/connexion emis par une connexion remplacee
# entre-temps (ex. reconnexion en cours) ne doit pas ecraser l'etat de la
# nouvelle : chaque connexion est associee a la generation en cours au
# moment ou elle est cablee, et un signal d'une generation perimee est ignore.
generation = self._connection_generation
self.connection.connected.connect(lambda: self._on_current_generation(generation, self._on_connected))
self.connection.data_received.connect(self.terminal.feed)
self.connection.data_received.connect(self._log_output)
self.connection.disconnected.connect(self._on_disconnected)
self.connection.error.connect(self._on_error)
self.connection.disconnected.connect(
lambda reason: self._on_current_generation(generation, self._on_disconnected, reason)
)
self.connection.error.connect(
lambda message: self._on_current_generation(generation, self._on_error, message)
)
self.terminal.data_to_send.connect(self.connection.send)
self.terminal.resized.connect(self.connection.resize)
def _on_current_generation(self, generation: int, fn, *args) -> None:
if generation == self._connection_generation:
fn(*args)
def _create_connection(self):
"""Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui
permet de la recreer a l'identique lors d'une reconnexion."""
raise NotImplementedError
def start_connection(self) -> None:
self._connection_generation += 1
self.connection = self._create_connection()
self._wire_connection()
self.connection.start()
@@ -206,6 +223,7 @@ class BaseTerminalTab(QWidget):
self.search_status.setText("" if found else "aucun resultat")
def close_connection(self) -> None:
self._closing = True # ne jamais reconnecter automatiquement une fermeture voulue
self.stop_logging()
if self.connection:
self.connection.close_connection()
@@ -244,6 +262,19 @@ class SessionTabWidget(BaseTerminalTab):
self._secret = secret
self.sftp_toggle.toggled.connect(self._toggle_sftp)
# Reconnexion automatique : un timer a declenchement unique pour la
# reconnexion elle-meme, et un second pour rafraichir le compte a
# rebours affiche a l'utilisateur pendant l'attente.
self._reconnect_attempt = 0
self._reconnect_seconds_left = 0
self._reconnect_last_reason = ""
self._reconnect_timer = QTimer(self)
self._reconnect_timer.setSingleShot(True)
self._reconnect_timer.timeout.connect(self._do_reconnect)
self._reconnect_countdown_timer = QTimer(self)
self._reconnect_countdown_timer.timeout.connect(self._tick_reconnect_countdown)
self.start_connection()
def _create_connection(self):
@@ -261,6 +292,9 @@ class SessionTabWidget(BaseTerminalTab):
open_sftp_on_connect=True,
bastion_target=self._bastion_target,
bastion_password=self._bastion_password,
keepalive_enabled=session.keepalive_enabled,
keepalive_interval=session.keepalive_interval,
keepalive_count_max=session.keepalive_count_max,
)
connection.sftp_ready.connect(self._on_sftp_ready)
connection.password_prompt.connect(self._on_password_prompt)
@@ -278,6 +312,7 @@ class SessionTabWidget(BaseTerminalTab):
self.tunnel_btn.setText(f"🔀 Tunnels ({actifs})" if actifs else "🔀 Tunnels")
def _on_connected(self) -> None:
self._reconnect_attempt = 0 # une connexion reussie remet le compteur a zero
self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}")
transport = self.connection.client.get_transport() if self.connection.client else None
if transport:
@@ -290,6 +325,57 @@ class SessionTabWidget(BaseTerminalTab):
def _on_disconnected(self, reason: str) -> None:
super()._on_disconnected(reason)
self.sftp_toggle.setEnabled(False)
self._maybe_schedule_reconnect(reason)
def reconnect(self) -> None:
self._cancel_reconnect_timers()
super().reconnect()
# ---------- reconnexion automatique ----------
def _cancel_reconnect_timers(self) -> None:
self._reconnect_timer.stop()
self._reconnect_countdown_timer.stop()
def _maybe_schedule_reconnect(self, reason: str) -> None:
if self._closing or not self.session.reconnect_enabled:
return
max_attempts = self.session.reconnect_max_attempts
self._reconnect_attempt += 1
if max_attempts and self._reconnect_attempt > max_attempts:
self.status_label.setText(
f"● Deconnecte ({reason}). Abandon apres {max_attempts} tentative(s) de reconnexion."
)
self.reconnect_btn.show()
return
self._reconnect_last_reason = reason
self._reconnect_seconds_left = compute_backoff_delay(
self._reconnect_attempt, self.session.reconnect_initial_delay,
)
self._update_reconnect_status()
self._reconnect_countdown_timer.start(1000)
self._reconnect_timer.start(self._reconnect_seconds_left * 1000)
def _tick_reconnect_countdown(self) -> None:
self._reconnect_seconds_left -= 1
if self._reconnect_seconds_left <= 0:
self._reconnect_countdown_timer.stop()
return
self._update_reconnect_status()
def _update_reconnect_status(self) -> None:
max_attempts = self.session.reconnect_max_attempts
total = f"/{max_attempts}" if max_attempts else ""
self.status_label.setText(
f"● Connexion perdue ({self._reconnect_last_reason}). "
f"Reconnexion dans {self._reconnect_seconds_left} seconde(s)... "
f"(Tentative {self._reconnect_attempt}{total})"
)
def _do_reconnect(self) -> None:
self._reconnect_countdown_timer.stop()
if self._closing:
return
self.reconnect()
def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
accepted = confirm_host_key(self, hostname, key_type, fingerprint)
@@ -326,6 +412,7 @@ class SessionTabWidget(BaseTerminalTab):
return paramiko.SFTPClient.from_transport(self.connection.client.get_transport())
def close_connection(self) -> None:
self._cancel_reconnect_timers()
if self.sftp_panel:
self.sftp_panel.shutdown()
if self.tunnel_manager:
@@ -380,6 +467,9 @@ class SFTPTabWidget(QWidget):
open_sftp_on_connect=True,
bastion_target=bastion_target,
bastion_password=bastion_password,
keepalive_enabled=session.keepalive_enabled,
keepalive_interval=session.keepalive_interval,
keepalive_count_max=session.keepalive_count_max,
)
self.connection.sftp_ready.connect(self._on_sftp_ready)
self.connection.password_prompt.connect(self._on_password_prompt)