"""Boite de dialogue de creation/edition d'une session SSH.""" from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QCheckBox, QComboBox, QCompleter, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLineEdit, QMessageBox, QPushButton, QSpinBox, QTextEdit, QVBoxLayout, QWidget, ) from app.credentials import CredentialStore, NullCredentialStore, session_credential_key from app.groups import GroupSettingsStore from app.key_manager import KeyManager from app.openssh.config import load_openssh_hosts from app.session_manager import ( AUTH_AGENT, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, BASTION_MANUAL, BASTION_NONE, BASTION_OPENSSH, BASTION_SESSION, PASSWORD_STORAGE_NONE, PASSWORD_STORAGE_SECURE, BastionConfig, Session, SessionStore, ) from app.ui.auto_height_stack import AutoHeightStackedWidget 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, group_store: GroupSettingsStore | None = None, credential_store: CredentialStore | None = None, ): super().__init__(parent) self.key_manager = key_manager self.session_store = session_store self.group_store = group_store self.credential_store = credential_store or NullCredentialStore() 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.inherit_group_btn = QPushButton("↓ Heriter du groupe") self.inherit_group_btn.setToolTip( "Pre-remplit ce formulaire avec les valeurs par defaut enregistrees pour ce dossier" " (Parametres du groupe..., dans le panneau des sessions)." ) self.inherit_group_btn.clicked.connect(self._apply_group_defaults) group_row = QWidget() group_layout = QHBoxLayout(group_row) group_layout.setContentsMargins(0, 0, 0, 0) group_layout.addWidget(self.group_edit, 1) group_layout.addWidget(self.inherit_group_btn) self.tags_edit = QLineEdit(", ".join(self.session.tags)) self.tags_edit.setPlaceholderText("ex: prod, docker, client-x (separes par des virgules)") if self.session_store is not None: completer = QCompleter(self.session_store.tags(), self.tags_edit) completer.setCaseSensitivity(Qt.CaseInsensitive) self.tags_edit.setCompleter(completer) 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 :", group_row) form.addRow("Tags :", self.tags_edit) self.remember_password_check = QCheckBox("Memoriser le mot de passe de maniere securisee") self.remember_password_check.setChecked(self.session.password_storage == PASSWORD_STORAGE_SECURE) if self.credential_store.is_available(): self.remember_password_check.setToolTip( "Stocke dans le trousseau securise du systeme (Windows Credential Manager), " "jamais en clair dans les fichiers de PySSH." ) else: self.remember_password_check.setEnabled(False) self.remember_password_check.setChecked(False) self.remember_password_check.setToolTip( "Aucun trousseau securise disponible sur ce systeme : le mot de passe sera " "toujours redemande a la connexion." ) 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("", self.remember_password_check) form.addRow("Notes :", self.notes_edit) self.form = form layout.addLayout(form) advanced_container = QWidget() advanced_layout = QVBoxLayout(advanced_container) advanced_layout.setContentsMargins(0, 0, 0, 0) advanced_layout.addWidget(self._build_bastion_group()) advanced_layout.addWidget(self._build_keepalive_group()) advanced_layout.addWidget(self._build_reconnect_group()) if self.quick_mode: # Repliees par defaut : une connexion rapide sert avant tout a # aller vite (souvent un serveur de test en DHCP) - bastion, # keepalive et reconnexion restent accessibles sans allonger le # dialogue pour le cas courant, ou ils ne servent pas. self.advanced_toggle = QPushButton("▸ Options avancees (bastion, keepalive, reconnexion)") self.advanced_toggle.setCheckable(True) self.advanced_toggle.setFlat(True) self.advanced_toggle.toggled.connect(self._on_advanced_toggled) layout.addWidget(self.advanced_toggle) advanced_container.setVisible(False) layout.addWidget(advanced_container) self.advanced_container = advanced_container 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 _on_advanced_toggled(self, checked: bool) -> None: self.advanced_container.setVisible(checked) self.advanced_toggle.setText( "▾ Options avancees (bastion, keepalive, reconnexion)" if checked else "▸ Options avancees (bastion, keepalive, reconnexion)" ) self.adjustSize() 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) self.form.setRowVisible(self.remember_password_check, self.auth_combo.currentData() == AUTH_PASSWORD) 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) # ---------- heritage des parametres du groupe ---------- def _apply_group_defaults(self) -> None: group_path = self.group_edit.text().strip() if not group_path: QMessageBox.information(self, "Heriter du groupe", "Indiquez d'abord un dossier.") return settings = self.group_store.get(group_path) if self.group_store else None if settings is None: QMessageBox.information( self, "Heriter du groupe", f"Aucun parametre par defaut enregistre pour « {group_path} ».\n\n" "Definissez-en depuis le panneau des sessions : clic droit sur le dossier " "-> Parametres du groupe...", ) return if settings.username: self.user_edit.setText(settings.username) if settings.port: self.port_spin.setValue(settings.port) auth_idx = self.auth_combo.findData(settings.auth_type) if auth_idx >= 0: self.auth_combo.setCurrentIndex(auth_idx) if settings.key_path: existing = self.key_combo.findData(settings.key_path) if existing >= 0: self.key_combo.setCurrentIndex(existing) else: self.key_combo.addItem(settings.key_path, settings.key_path) self.key_combo.setCurrentIndex(self.key_combo.count() - 1) if settings.bastion.mode != BASTION_NONE: mode_idx = self.bastion_mode_combo.findData(settings.bastion.mode) if mode_idx >= 0: self.bastion_mode_combo.setCurrentIndex(mode_idx) if settings.bastion.mode == BASTION_SESSION: found = self.bastion_session_combo.findData(settings.bastion.session_id) if found >= 0: self.bastion_session_combo.setCurrentIndex(found) elif settings.bastion.mode == BASTION_OPENSSH: found = self.bastion_openssh_combo.findData(settings.bastion.openssh_alias) if found >= 0: self.bastion_openssh_combo.setCurrentIndex(found) elif settings.bastion.mode == BASTION_MANUAL: self.bastion_host_edit.setText(settings.bastion.host) self.bastion_port_spin.setValue(settings.bastion.port or 22) self.bastion_user_edit.setText(settings.bastion.username) self.keepalive_check.setChecked(settings.keepalive_enabled) self.keepalive_interval_spin.setValue(settings.keepalive_interval or 30) self.keepalive_count_spin.setValue(settings.keepalive_count_max or 3) # les reglages avances viennent d'etre modifies : les montrer, sans quoi # ils resteraient caches derriere le repli "Options avancees" if self.quick_mode and not self.advanced_toggle.isChecked(): self.advanced_toggle.setChecked(True) # ---------- 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 = AutoHeightStackedWidget() 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() seen = set() tags = [] for raw in self.tags_edit.text().split(","): tag = raw.strip() if tag and tag.lower() not in seen: seen.add(tag.lower()) tags.append(tag) self.session.tags = tags remember = self.auth_combo.currentData() == AUTH_PASSWORD and self.remember_password_check.isChecked() new_storage = PASSWORD_STORAGE_SECURE if remember else PASSWORD_STORAGE_NONE if self.session.password_storage == PASSWORD_STORAGE_SECURE and new_storage == PASSWORD_STORAGE_NONE: # la memorisation vient d'etre desactivee (ou l'authentification changee) : # ne pas laisser un secret perimer dans le trousseau self.credential_store.delete_secret(session_credential_key(self.session.id)) self.session.password_storage = new_storage 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