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

@@ -0,0 +1,126 @@
"""Editeur de session : section Bastion / Jump Host (construction, pre-remplissage
a l'edition, et resultat renvoye pour chacun des 4 modes)."""
import sys, pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
from app.session_manager import (
SessionStore, Session, BastionConfig,
BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL, AUTH_KEY,
)
from app.key_manager import KeyManager
from app.dialogs.session_dialog import SessionDialog
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
key_manager = KeyManager(registry_path=tmp / "keys.json")
store = SessionStore(path=tmp / "sessions.json")
bastion_session = Session(name="bastion-srv", host="10.0.0.1", username="root")
store.add(bastion_session)
target_session = Session(name="cible", host="10.20.20.15", username="root")
store.add(target_session)
config_path = tmp / "sshconfig"
config_path.write_text("Host bastion-openssh\n HostName 1.2.3.4\n User jump\n", encoding="utf-8")
print("=== 1. Par defaut : mode Aucun ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
assert dlg.bastion_mode_combo.currentData() == BASTION_NONE
assert dlg._result_bastion().mode == BASTION_NONE
dlg.close()
print(" OK")
print()
print("=== 2. La session en cours d'edition n'apparait pas dans sa propre liste de bastions ===")
dlg = SessionDialog(key_manager, session=target_session, session_store=store)
ids_proposes = [dlg.bastion_session_combo.itemData(i) for i in range(dlg.bastion_session_combo.count())]
print(" sessions proposees comme bastion:", ids_proposes)
assert target_session.id not in ids_proposes
assert bastion_session.id in ids_proposes
dlg.close()
print(" OK")
print()
print("=== 3. Mode Session : selection puis resultat ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_SESSION)
dlg.bastion_mode_combo.setCurrentIndex(idx)
idx_session = dlg.bastion_session_combo.findData(bastion_session.id)
dlg.bastion_session_combo.setCurrentIndex(idx_session)
result = dlg._result_bastion()
print(" resultat:", result)
assert result.mode == BASTION_SESSION
assert result.session_id == bastion_session.id
dlg.close()
print(" OK")
print()
print("=== 4. Mode OpenSSH : liste chargee depuis ~/.ssh/config, puis resultat ===")
with patch("app.openssh.config.default_config_path", return_value=config_path):
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_OPENSSH)
dlg.bastion_mode_combo.setCurrentIndex(idx)
idx_host = dlg.bastion_openssh_combo.findData("bastion-openssh")
assert idx_host >= 0, "l'alias detecte doit apparaitre dans la liste"
dlg.bastion_openssh_combo.setCurrentIndex(idx_host)
result = dlg._result_bastion()
print(" resultat:", result)
assert result.mode == BASTION_OPENSSH
assert result.openssh_alias == "bastion-openssh"
dlg.close()
print(" OK")
print()
print("=== 5. Mode manuel : champs -> resultat ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_MANUAL)
dlg.bastion_mode_combo.setCurrentIndex(idx)
dlg.bastion_host_edit.setText("bastion.example.com")
dlg.bastion_port_spin.setValue(2222)
dlg.bastion_user_edit.setText("jump")
result = dlg._result_bastion()
print(" resultat:", result)
assert result.mode == BASTION_MANUAL
assert result.host == "bastion.example.com"
assert result.port == 2222
assert result.username == "jump"
dlg.close()
print(" OK")
print()
print("=== 6. Edition d'une session avec bastion deja configure : pre-remplissage correct ===")
pre_filled = Session(
name="avec-bastion", host="10.20.20.15", username="root",
bastion=BastionConfig(mode=BASTION_MANUAL, host="old-bastion", port=2200, username="olduser"),
)
dlg = SessionDialog(key_manager, session=pre_filled, session_store=store)
print(" mode pre-selectionne:", dlg.bastion_mode_combo.currentData())
print(" page affichee (stack index):", dlg.bastion_stack.currentIndex())
assert dlg.bastion_mode_combo.currentData() == BASTION_MANUAL
assert dlg.bastion_stack.currentIndex() == dlg.bastion_mode_combo.currentIndex()
assert dlg.bastion_host_edit.text() == "old-bastion"
assert dlg.bastion_port_spin.value() == 2200
assert dlg.bastion_user_edit.text() == "olduser"
dlg.close()
print(" OK")
print()
print("=== 7. Cle bastion visible seulement en authentification par cle ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_MANUAL)
dlg.bastion_mode_combo.setCurrentIndex(idx)
assert not dlg.bastion_key_combo.isEnabled()
idx_key = dlg.bastion_auth_combo.findData(AUTH_KEY)
dlg.bastion_auth_combo.setCurrentIndex(idx_key)
assert dlg.bastion_key_combo.isEnabled()
dlg.close()
print(" OK")
print()
print("SESSION DIALOG - BASTION : TOUS LES TESTS OK")