feat: ajouter le support ProxyJump / bastion (jump host)
Une session peut desormais passer par un jump host avant d'atteindre sa cible, avec 3 sources possibles (limite a un seul saut pour l'instant) : une session PySSH existante, un Host de ~/.ssh/config, ou une configuration manuelle (hote/port/utilisateur/authentification/cle). - session_manager.py : BastionConfig (mode + parametres), champ Session.bastion. Jamais de mot de passe stocke : un bastion en mode "mot de passe" le redemande a la connexion, comme le fait deja Session.auth_type. - app/bastion.py : resolve_bastion_target() convertit un BastionConfig en parametres de connexion concrets quelle que soit sa source, et leve une erreur claire si la reference est cassee (session supprimee, alias OpenSSH introuvable, config manuelle incomplete) plutot que d'echouer plus tard avec un message obscur. - ssh_connection.py : la logique de connexion (auth, cle d'hote modifiee, mot de passe de secours en mode auto) est factorisee dans _connect_host(), reutilisee a l'identique pour le bastion et pour la cible. _open_bastion_channel() connecte d'abord le bastion puis ouvre un canal direct-tcpip vers la cible, passe en sock= a la connexion finale (mecanisme ProxyJump standard de paramiko). Le bastion a son propre SSHClient, ferme proprement a la fin de la connexion. - SessionDialog : section "Bastion / Jump Host" (choix de la source + champs associes, caches/affiches via un QStackedWidget). - ConnectionController resout le bastion et demande son mot de passe (si nécessaire) avant d'ouvrir l'onglet ; erreur affichee immediatement si la reference est invalide plutot que de lancer une connexion vouee a l'echec. - app/openssh/config.py : un Host avec ProxyJump produit maintenant une Session dont le bastion reference cet alias (mode BASTION_OPENSSH) : le scenario proxmox-prod / bastion de la Phase 2 fonctionne de bout en bout. tests/test_bastion.py (resolution des 3 sources + erreurs, et ouverture du canal via un bastion entierement mocke, sans reseau reel) et tests/test_session_dialog_bastion.py (UI : construction, pre-remplissage a l'edition, resultat par mode). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,25 +4,33 @@ from __future__ import annotations
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox,
|
||||
QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout,
|
||||
QCheckBox, QMessageBox,
|
||||
QCheckBox, QMessageBox, QGroupBox, QStackedWidget,
|
||||
)
|
||||
|
||||
from app.session_manager import Session, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT
|
||||
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 = ""):
|
||||
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(420)
|
||||
self.setMinimumWidth(460)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
form = QFormLayout()
|
||||
@@ -71,6 +79,7 @@ class SessionDialog(QDialog):
|
||||
form.addRow("Notes :", self.notes_edit)
|
||||
|
||||
layout.addLayout(form)
|
||||
layout.addWidget(self._build_bastion_group())
|
||||
|
||||
self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)")
|
||||
if self.quick_mode:
|
||||
@@ -114,6 +123,143 @@ class SessionDialog(QDialog):
|
||||
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
|
||||
|
||||
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
|
||||
@@ -137,6 +283,19 @@ class SessionDialog(QDialog):
|
||||
)
|
||||
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()
|
||||
@@ -145,6 +304,7 @@ class SessionDialog(QDialog):
|
||||
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.accept()
|
||||
|
||||
def result_session(self) -> Session:
|
||||
|
||||
Reference in New Issue
Block a user