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:
Florian
2026-09-05 02:02:25 +02:00
parent c08037a638
commit c2bde19f59
10 changed files with 770 additions and 95 deletions

View File

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