- 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>
373 lines
18 KiB
Python
373 lines
18 KiB
Python
"""Boite de dialogue de creation/edition d'une session SSH."""
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtWidgets import (
|
|
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox,
|
|
QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout,
|
|
QCheckBox, QMessageBox, QGroupBox, QStackedWidget,
|
|
)
|
|
|
|
from app.session_manager import (
|
|
Session, SessionStore, BastionConfig, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT,
|
|
BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL,
|
|
)
|
|
from app.key_manager import KeyManager
|
|
from app.openssh.config import load_openssh_hosts
|
|
|
|
|
|
class SessionDialog(QDialog):
|
|
"""Formulaire de session. En mode `connect_label`, il sert de connexion rapide :
|
|
on saisit les infos et on se connecte directement, l'enregistrement etant optionnel."""
|
|
|
|
def __init__(
|
|
self, key_manager: KeyManager, session: Session | None = None,
|
|
parent=None, connect_label: str = "", title: str = "",
|
|
session_store: SessionStore | None = None,
|
|
):
|
|
super().__init__(parent)
|
|
self.key_manager = key_manager
|
|
self.session_store = session_store
|
|
self.session = session or Session()
|
|
self.quick_mode = bool(connect_label)
|
|
self.setWindowTitle(title or ("Nouvelle session" if session is None else "Modifier la session"))
|
|
self.setMinimumWidth(460)
|
|
|
|
layout = QVBoxLayout(self)
|
|
form = QFormLayout()
|
|
|
|
self.name_edit = QLineEdit(self.session.name)
|
|
self.group_edit = QLineEdit(self.session.group)
|
|
self.group_edit.setPlaceholderText("ex: Production/Web")
|
|
self.host_edit = QLineEdit(self.session.host)
|
|
self.port_spin = QSpinBox()
|
|
self.port_spin.setRange(1, 65535)
|
|
self.port_spin.setValue(self.session.port or 22)
|
|
self.user_edit = QLineEdit(self.session.username)
|
|
|
|
self.auth_combo = QComboBox()
|
|
self.auth_combo.addItem("Automatique (comme la commande ssh)", AUTH_AUTO)
|
|
self.auth_combo.addItem("Cle SSH choisie", AUTH_KEY)
|
|
self.auth_combo.addItem("Mot de passe (saisi a la connexion)", AUTH_PASSWORD)
|
|
self.auth_combo.addItem("Agent SSH", AUTH_AGENT)
|
|
self.auth_combo.setToolTip(
|
|
"Automatique : utilise l'agent SSH et vos cles ~/.ssh comme le fait la commande ssh,\n"
|
|
"et demande un mot de passe seulement si aucune cle ne convient."
|
|
)
|
|
idx = self.auth_combo.findData(self.session.auth_type)
|
|
self.auth_combo.setCurrentIndex(idx if idx >= 0 else 0)
|
|
|
|
self.key_combo = QComboBox()
|
|
self._reload_keys()
|
|
self.browse_key_btn = QPushButton("Parcourir...")
|
|
|
|
key_row = QWidget()
|
|
key_layout = QHBoxLayout(key_row)
|
|
key_layout.setContentsMargins(0, 0, 0, 0)
|
|
key_layout.addWidget(self.key_combo, 1)
|
|
key_layout.addWidget(self.browse_key_btn)
|
|
|
|
self.notes_edit = QTextEdit(self.session.notes)
|
|
self.notes_edit.setFixedHeight(60)
|
|
|
|
form.addRow("Nom :", self.name_edit)
|
|
form.addRow("Dossier :", self.group_edit)
|
|
form.addRow("Hote :", self.host_edit)
|
|
form.addRow("Port :", self.port_spin)
|
|
form.addRow("Utilisateur :", self.user_edit)
|
|
form.addRow("Authentification :", self.auth_combo)
|
|
form.addRow("Cle privee :", key_row)
|
|
form.addRow("Notes :", self.notes_edit)
|
|
|
|
layout.addLayout(form)
|
|
layout.addWidget(self._build_bastion_group())
|
|
layout.addWidget(self._build_keepalive_group())
|
|
layout.addWidget(self._build_reconnect_group())
|
|
|
|
self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)")
|
|
if self.quick_mode:
|
|
# decoche par defaut : les serveurs de test changent d'IP (DHCP), les
|
|
# enregistrer systematiquement encombre la liste pour rien
|
|
self.save_check.setChecked(False)
|
|
layout.addWidget(self.save_check)
|
|
|
|
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
if self.quick_mode:
|
|
buttons.button(QDialogButtonBox.Ok).setText(connect_label)
|
|
buttons.accepted.connect(self._on_accept)
|
|
buttons.rejected.connect(self.reject)
|
|
layout.addWidget(buttons)
|
|
|
|
self.auth_combo.currentIndexChanged.connect(self._update_key_row)
|
|
self.browse_key_btn.clicked.connect(self._browse_key)
|
|
self._update_key_row()
|
|
|
|
if self.session.key_path:
|
|
existing = self.key_combo.findData(self.session.key_path)
|
|
if existing >= 0:
|
|
self.key_combo.setCurrentIndex(existing)
|
|
else:
|
|
self.key_combo.addItem(self.session.key_path, self.session.key_path)
|
|
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
|
|
|
|
def _reload_keys(self) -> None:
|
|
self.key_combo.clear()
|
|
for entry in self.key_manager.keys:
|
|
self.key_combo.addItem(f"{entry.name} ({entry.key_type})", entry.private_path)
|
|
|
|
def _update_key_row(self) -> None:
|
|
is_key_auth = self.auth_combo.currentData() == AUTH_KEY
|
|
self.key_combo.setEnabled(is_key_auth)
|
|
self.browse_key_btn.setEnabled(is_key_auth)
|
|
|
|
def _browse_key(self) -> None:
|
|
path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee")
|
|
if path:
|
|
self.key_combo.addItem(path, path)
|
|
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
|
|
|
|
# ---------- Bastion / Jump Host ----------
|
|
def _build_bastion_group(self) -> QGroupBox:
|
|
"""Un seul saut pour l'instant (voir BastionConfig) : Aucun / une session
|
|
PySSH existante / un Host de ~/.ssh/config / une configuration manuelle."""
|
|
bastion = self.session.bastion
|
|
group = QGroupBox("Bastion / Jump Host")
|
|
v = QVBoxLayout(group)
|
|
|
|
self.bastion_mode_combo = QComboBox()
|
|
self.bastion_mode_combo.addItem("Aucun", BASTION_NONE)
|
|
self.bastion_mode_combo.addItem("Une session PySSH existante", BASTION_SESSION)
|
|
self.bastion_mode_combo.addItem("Un Host de ~/.ssh/config", BASTION_OPENSSH)
|
|
self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL)
|
|
v.addWidget(self.bastion_mode_combo)
|
|
|
|
self.bastion_stack = QStackedWidget()
|
|
v.addWidget(self.bastion_stack)
|
|
|
|
self.bastion_stack.addWidget(QWidget()) # BASTION_NONE : rien a afficher
|
|
|
|
session_page = QWidget()
|
|
session_form = QFormLayout(session_page)
|
|
session_form.setContentsMargins(0, 4, 0, 0)
|
|
self.bastion_session_combo = QComboBox()
|
|
others = [
|
|
s for s in (self.session_store.sessions if self.session_store else [])
|
|
if s.id != self.session.id
|
|
]
|
|
if others:
|
|
for s in sorted(others, key=lambda s: s.name.lower()):
|
|
self.bastion_session_combo.addItem(f"{s.name} ({s.username}@{s.host})", s.id)
|
|
else:
|
|
self.bastion_session_combo.addItem("(aucune autre session enregistree)", "")
|
|
self.bastion_session_combo.setEnabled(False)
|
|
session_form.addRow("Session :", self.bastion_session_combo)
|
|
self.bastion_stack.addWidget(session_page)
|
|
|
|
openssh_page = QWidget()
|
|
openssh_form = QFormLayout(openssh_page)
|
|
openssh_form.setContentsMargins(0, 4, 0, 0)
|
|
self.bastion_openssh_combo = QComboBox()
|
|
hosts = load_openssh_hosts()
|
|
if hosts:
|
|
for h in hosts:
|
|
self.bastion_openssh_combo.addItem(f"{h.alias} ({h.display_target})", h.alias)
|
|
else:
|
|
self.bastion_openssh_combo.addItem("(aucun host dans ~/.ssh/config)", "")
|
|
self.bastion_openssh_combo.setEnabled(False)
|
|
openssh_form.addRow("Host :", self.bastion_openssh_combo)
|
|
self.bastion_stack.addWidget(openssh_page)
|
|
|
|
manual_page = QWidget()
|
|
manual_form = QFormLayout(manual_page)
|
|
manual_form.setContentsMargins(0, 4, 0, 0)
|
|
self.bastion_host_edit = QLineEdit(bastion.host if bastion.mode == BASTION_MANUAL else "")
|
|
self.bastion_port_spin = QSpinBox()
|
|
self.bastion_port_spin.setRange(1, 65535)
|
|
self.bastion_port_spin.setValue(bastion.port or 22)
|
|
self.bastion_user_edit = QLineEdit(bastion.username if bastion.mode == BASTION_MANUAL else "")
|
|
self.bastion_auth_combo = QComboBox()
|
|
self.bastion_auth_combo.addItem("Automatique", AUTH_AUTO)
|
|
self.bastion_auth_combo.addItem("Cle SSH choisie", AUTH_KEY)
|
|
self.bastion_auth_combo.addItem("Mot de passe (saisi a la connexion)", AUTH_PASSWORD)
|
|
self.bastion_auth_combo.addItem("Agent SSH", AUTH_AGENT)
|
|
self.bastion_key_combo = QComboBox()
|
|
for entry in self.key_manager.keys:
|
|
self.bastion_key_combo.addItem(f"{entry.name} ({entry.key_type})", entry.private_path)
|
|
self.bastion_browse_key_btn = QPushButton("Parcourir...")
|
|
bastion_key_row = QWidget()
|
|
bastion_key_layout = QHBoxLayout(bastion_key_row)
|
|
bastion_key_layout.setContentsMargins(0, 0, 0, 0)
|
|
bastion_key_layout.addWidget(self.bastion_key_combo, 1)
|
|
bastion_key_layout.addWidget(self.bastion_browse_key_btn)
|
|
|
|
manual_form.addRow("Hote :", self.bastion_host_edit)
|
|
manual_form.addRow("Port :", self.bastion_port_spin)
|
|
manual_form.addRow("Utilisateur :", self.bastion_user_edit)
|
|
manual_form.addRow("Authentification :", self.bastion_auth_combo)
|
|
manual_form.addRow("Cle privee :", bastion_key_row)
|
|
self.bastion_stack.addWidget(manual_page)
|
|
|
|
self.bastion_auth_combo.currentIndexChanged.connect(self._update_bastion_key_row)
|
|
self.bastion_browse_key_btn.clicked.connect(self._browse_bastion_key)
|
|
self.bastion_mode_combo.currentIndexChanged.connect(self.bastion_stack.setCurrentIndex)
|
|
|
|
mode_idx = self.bastion_mode_combo.findData(bastion.mode)
|
|
self.bastion_mode_combo.setCurrentIndex(mode_idx if mode_idx >= 0 else 0)
|
|
self.bastion_stack.setCurrentIndex(self.bastion_mode_combo.currentIndex())
|
|
|
|
if bastion.mode == BASTION_SESSION:
|
|
found = self.bastion_session_combo.findData(bastion.session_id)
|
|
if found >= 0:
|
|
self.bastion_session_combo.setCurrentIndex(found)
|
|
elif bastion.mode == BASTION_OPENSSH:
|
|
found = self.bastion_openssh_combo.findData(bastion.openssh_alias)
|
|
if found >= 0:
|
|
self.bastion_openssh_combo.setCurrentIndex(found)
|
|
elif bastion.mode == BASTION_MANUAL:
|
|
auth_idx = self.bastion_auth_combo.findData(bastion.auth_type)
|
|
self.bastion_auth_combo.setCurrentIndex(auth_idx if auth_idx >= 0 else 0)
|
|
if bastion.key_path:
|
|
existing = self.bastion_key_combo.findData(bastion.key_path)
|
|
if existing >= 0:
|
|
self.bastion_key_combo.setCurrentIndex(existing)
|
|
else:
|
|
self.bastion_key_combo.addItem(bastion.key_path, bastion.key_path)
|
|
self.bastion_key_combo.setCurrentIndex(self.bastion_key_combo.count() - 1)
|
|
|
|
self._update_bastion_key_row()
|
|
return group
|
|
|
|
def _update_bastion_key_row(self) -> None:
|
|
is_key_auth = self.bastion_auth_combo.currentData() == AUTH_KEY
|
|
self.bastion_key_combo.setEnabled(is_key_auth)
|
|
self.bastion_browse_key_btn.setEnabled(is_key_auth)
|
|
|
|
def _browse_bastion_key(self) -> None:
|
|
path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee (bastion)")
|
|
if path:
|
|
self.bastion_key_combo.addItem(path, path)
|
|
self.bastion_key_combo.setCurrentIndex(self.bastion_key_combo.count() - 1)
|
|
|
|
def _result_bastion(self) -> BastionConfig:
|
|
mode = self.bastion_mode_combo.currentData()
|
|
bastion = BastionConfig(mode=mode)
|
|
if mode == BASTION_SESSION:
|
|
bastion.session_id = self.bastion_session_combo.currentData() or ""
|
|
elif mode == BASTION_OPENSSH:
|
|
bastion.openssh_alias = self.bastion_openssh_combo.currentData() or ""
|
|
elif mode == BASTION_MANUAL:
|
|
bastion.host = self.bastion_host_edit.text().strip()
|
|
bastion.port = self.bastion_port_spin.value()
|
|
bastion.username = self.bastion_user_edit.text().strip()
|
|
bastion.auth_type = self.bastion_auth_combo.currentData()
|
|
bastion.key_path = self.bastion_key_combo.currentData() or ""
|
|
return bastion
|
|
|
|
# ---------- Keepalive ----------
|
|
def _build_keepalive_group(self) -> QGroupBox:
|
|
session = self.session
|
|
group = QGroupBox("Keepalive")
|
|
form = QFormLayout(group)
|
|
|
|
self.keepalive_check = QCheckBox("Active")
|
|
self.keepalive_check.setChecked(session.keepalive_enabled)
|
|
self.keepalive_interval_spin = QSpinBox()
|
|
self.keepalive_interval_spin.setRange(5, 3600)
|
|
self.keepalive_interval_spin.setSuffix(" s")
|
|
self.keepalive_interval_spin.setValue(session.keepalive_interval or 30)
|
|
self.keepalive_count_spin = QSpinBox()
|
|
self.keepalive_count_spin.setRange(1, 20)
|
|
self.keepalive_count_spin.setValue(session.keepalive_count_max or 3)
|
|
|
|
form.addRow(self.keepalive_check)
|
|
form.addRow("Intervalle :", self.keepalive_interval_spin)
|
|
form.addRow("Nombre d'echecs avant abandon :", self.keepalive_count_spin)
|
|
|
|
self.keepalive_check.toggled.connect(self.keepalive_interval_spin.setEnabled)
|
|
self.keepalive_check.toggled.connect(self.keepalive_count_spin.setEnabled)
|
|
self.keepalive_interval_spin.setEnabled(session.keepalive_enabled)
|
|
self.keepalive_count_spin.setEnabled(session.keepalive_enabled)
|
|
return group
|
|
|
|
# ---------- Reconnexion automatique ----------
|
|
def _build_reconnect_group(self) -> QGroupBox:
|
|
session = self.session
|
|
group = QGroupBox("Reconnexion automatique")
|
|
form = QFormLayout(group)
|
|
|
|
self.reconnect_check = QCheckBox("Reconnexion automatique")
|
|
self.reconnect_check.setChecked(session.reconnect_enabled)
|
|
self.reconnect_delay_spin = QSpinBox()
|
|
self.reconnect_delay_spin.setRange(1, 300)
|
|
self.reconnect_delay_spin.setSuffix(" s")
|
|
self.reconnect_delay_spin.setValue(session.reconnect_initial_delay or 5)
|
|
self.reconnect_attempts_spin = QSpinBox()
|
|
self.reconnect_attempts_spin.setRange(0, 1000)
|
|
self.reconnect_attempts_spin.setSpecialValueText("Illimite")
|
|
self.reconnect_attempts_spin.setValue(session.reconnect_max_attempts)
|
|
|
|
form.addRow(self.reconnect_check)
|
|
form.addRow("Delai initial :", self.reconnect_delay_spin)
|
|
form.addRow("Tentatives maximum (0 = illimite) :", self.reconnect_attempts_spin)
|
|
|
|
self.reconnect_check.toggled.connect(self.reconnect_delay_spin.setEnabled)
|
|
self.reconnect_check.toggled.connect(self.reconnect_attempts_spin.setEnabled)
|
|
self.reconnect_delay_spin.setEnabled(session.reconnect_enabled)
|
|
self.reconnect_attempts_spin.setEnabled(session.reconnect_enabled)
|
|
return group
|
|
|
|
def should_save(self) -> bool:
|
|
"""En connexion rapide, indique si la session doit aussi etre enregistree."""
|
|
return self.save_check.isChecked() if self.quick_mode else True
|
|
|
|
def _on_accept(self) -> None:
|
|
if not self.host_edit.text().strip():
|
|
QMessageBox.warning(self, "Hote manquant", "Indiquez l'adresse du serveur (nom d'hote ou IP).")
|
|
self.host_edit.setFocus()
|
|
return
|
|
if not self.user_edit.text().strip():
|
|
QMessageBox.warning(self, "Utilisateur manquant", "Indiquez le nom d'utilisateur.")
|
|
self.user_edit.setFocus()
|
|
return
|
|
if self.auth_combo.currentData() == AUTH_KEY and not self.key_combo.currentData():
|
|
QMessageBox.warning(
|
|
self, "Cle manquante",
|
|
"Aucune cle privee n'est selectionnee.\n\n"
|
|
"Choisissez une cle (bouton « Parcourir... »), ou passez l'authentification "
|
|
"sur « Automatique » — qui utilisera l'agent SSH et vos cles ~/.ssh comme le "
|
|
"fait la commande ssh.",
|
|
)
|
|
self.auth_combo.setFocus()
|
|
return
|
|
|
|
bastion_mode = self.bastion_mode_combo.currentData()
|
|
if bastion_mode == BASTION_SESSION and not self.bastion_session_combo.currentData():
|
|
QMessageBox.warning(self, "Bastion incomplet", "Choisissez une session PySSH a utiliser comme bastion.")
|
|
return
|
|
if bastion_mode == BASTION_OPENSSH and not self.bastion_openssh_combo.currentData():
|
|
QMessageBox.warning(self, "Bastion incomplet", "Choisissez un Host de ~/.ssh/config a utiliser comme bastion.")
|
|
return
|
|
if bastion_mode == BASTION_MANUAL and not self.bastion_host_edit.text().strip():
|
|
QMessageBox.warning(self, "Bastion incomplet", "Indiquez l'adresse du bastion.")
|
|
self.bastion_host_edit.setFocus()
|
|
return
|
|
|
|
self.session.name = self.name_edit.text().strip() or self.host_edit.text().strip()
|
|
self.session.group = self.group_edit.text().strip()
|
|
self.session.host = self.host_edit.text().strip()
|
|
self.session.port = self.port_spin.value()
|
|
self.session.username = self.user_edit.text().strip()
|
|
self.session.auth_type = self.auth_combo.currentData()
|
|
self.session.key_path = self.key_combo.currentData() or ""
|
|
self.session.notes = self.notes_edit.toPlainText()
|
|
self.session.bastion = self._result_bastion()
|
|
self.session.keepalive_enabled = self.keepalive_check.isChecked()
|
|
self.session.keepalive_interval = self.keepalive_interval_spin.value()
|
|
self.session.keepalive_count_max = self.keepalive_count_spin.value()
|
|
self.session.reconnect_enabled = self.reconnect_check.isChecked()
|
|
self.session.reconnect_initial_delay = self.reconnect_delay_spin.value()
|
|
self.session.reconnect_max_attempts = self.reconnect_attempts_spin.value()
|
|
self.accept()
|
|
|
|
def result_session(self) -> Session:
|
|
return self.session
|