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:
178
tests/test_bastion.py
Normal file
178
tests/test_bastion.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""ProxyJump / Bastion : resolution (app.bastion) pour les 3 sources (session,
|
||||
OpenSSH, manuel), et etablissement du canal direct-tcpip par SSHConnection via
|
||||
un bastion entierement mocke (aucun reseau reel)."""
|
||||
import sys, pathlib
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from app.session_manager import (
|
||||
SessionStore, Session, BastionConfig,
|
||||
AUTH_AUTO, AUTH_KEY,
|
||||
BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL,
|
||||
)
|
||||
from app.bastion import resolve_bastion_target, BastionResolutionError, BastionTarget
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
|
||||
tmp = Path(tempfile.mkdtemp())
|
||||
|
||||
print("=== 1. Aucun bastion -> None ===")
|
||||
assert resolve_bastion_target(BastionConfig(mode=BASTION_NONE)) is None
|
||||
assert resolve_bastion_target(None) is None
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 2. Bastion manuel ===")
|
||||
manual = BastionConfig(
|
||||
mode=BASTION_MANUAL, host="bastion.example.com", port=2222,
|
||||
username="jump", auth_type=AUTH_KEY, key_path="/tmp/key",
|
||||
)
|
||||
target = resolve_bastion_target(manual)
|
||||
print(" target:", target)
|
||||
assert target == BastionTarget("bastion.example.com", 2222, "jump", AUTH_KEY, "/tmp/key")
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 3. Bastion manuel incomplet -> erreur claire ===")
|
||||
try:
|
||||
resolve_bastion_target(BastionConfig(mode=BASTION_MANUAL, host=""))
|
||||
ok = False
|
||||
except BastionResolutionError:
|
||||
ok = True
|
||||
assert ok
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 4. Bastion = session PySSH existante ===")
|
||||
store = SessionStore(path=tmp / "sessions.json")
|
||||
bastion_session = Session(name="bastion-srv", host="10.0.0.1", port=22, username="root", auth_type=AUTH_AUTO)
|
||||
store.add(bastion_session)
|
||||
target = resolve_bastion_target(BastionConfig(mode=BASTION_SESSION, session_id=bastion_session.id), store)
|
||||
print(" target:", target)
|
||||
assert target.host == "10.0.0.1" and target.username == "root"
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 5. Bastion = session supprimee -> erreur claire ===")
|
||||
try:
|
||||
resolve_bastion_target(BastionConfig(mode=BASTION_SESSION, session_id="inconnu"), store)
|
||||
ok = False
|
||||
except BastionResolutionError as exc:
|
||||
ok = True
|
||||
print(" message:", exc)
|
||||
assert ok
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 6. Bastion sans registre de sessions fourni -> erreur claire (pas de crash) ===")
|
||||
try:
|
||||
resolve_bastion_target(BastionConfig(mode=BASTION_SESSION, session_id="x"), None)
|
||||
ok = False
|
||||
except BastionResolutionError:
|
||||
ok = True
|
||||
assert ok
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 7. Bastion = Host OpenSSH ===")
|
||||
config_path = tmp / "sshconfig"
|
||||
config_path.write_text(
|
||||
"Host bastion\n HostName bastion.example.com\n User jump\n IdentityFile ~/.ssh/jumpkey\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch("app.openssh.config.default_config_path", return_value=config_path):
|
||||
target = resolve_bastion_target(BastionConfig(mode=BASTION_OPENSSH, openssh_alias="bastion"))
|
||||
print(" target:", target)
|
||||
assert target.host == "bastion.example.com"
|
||||
assert target.username == "jump"
|
||||
assert target.auth_type == AUTH_KEY
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 8. Bastion = alias OpenSSH introuvable -> erreur claire ===")
|
||||
with patch("app.openssh.config.default_config_path", return_value=config_path):
|
||||
try:
|
||||
resolve_bastion_target(BastionConfig(mode=BASTION_OPENSSH, openssh_alias="n-existe-pas"))
|
||||
ok = False
|
||||
except BastionResolutionError:
|
||||
ok = True
|
||||
assert ok
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 9. SSHConnection : ouverture du canal direct-tcpip via un bastion mocke ===")
|
||||
from app.ssh_connection import SSHConnection
|
||||
|
||||
bastion_target = BastionTarget(host="bastion.example.com", port=22, username="jump", auth_type=AUTH_AUTO)
|
||||
conn = SSHConnection(host="10.20.20.15", port=22, username="root", auth_type=AUTH_AUTO, bastion_target=bastion_target)
|
||||
|
||||
fake_channel = MagicMock(name="direct-tcpip-channel")
|
||||
fake_transport = MagicMock()
|
||||
fake_transport.open_channel.return_value = fake_channel
|
||||
|
||||
with patch("app.ssh_connection.paramiko.SSHClient") as FakeSSHClient:
|
||||
fake_bastion_client = MagicMock()
|
||||
fake_bastion_client.get_transport.return_value = fake_transport
|
||||
FakeSSHClient.return_value = fake_bastion_client
|
||||
|
||||
channel = conn._open_bastion_channel()
|
||||
|
||||
hostname_used = fake_bastion_client.connect.call_args.kwargs.get("hostname")
|
||||
print(" connect() vers le bastion, hostname =", hostname_used)
|
||||
assert hostname_used == "bastion.example.com"
|
||||
assert fake_transport.open_channel.call_args.args[0] == "direct-tcpip"
|
||||
assert fake_transport.open_channel.call_args.args[1] == ("10.20.20.15", 22)
|
||||
assert channel is fake_channel
|
||||
assert conn._bastion_client is fake_bastion_client
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 10. Cible connectee avec sock=<canal du bastion> (pas de socket TCP direct) ===")
|
||||
conn2 = SSHConnection(host="10.20.20.15", port=22, username="root", auth_type=AUTH_AUTO, bastion_target=bastion_target)
|
||||
with patch("app.ssh_connection.paramiko.SSHClient") as FakeSSHClient:
|
||||
fake_bastion_client2 = MagicMock()
|
||||
fake_target_client2 = MagicMock()
|
||||
fake_transport2 = MagicMock()
|
||||
fake_transport2.open_channel.return_value = fake_channel
|
||||
fake_bastion_client2.get_transport.return_value = fake_transport2
|
||||
# comme dans run() : le 1er SSHClient() cree est celui de la cible (self.client,
|
||||
# assigne avant l'appel a _open_bastion_channel), le 2e celui du bastion (cree a
|
||||
# l'interieur de _open_bastion_channel)
|
||||
FakeSSHClient.side_effect = [fake_target_client2, fake_bastion_client2]
|
||||
|
||||
conn2.client = conn2._new_client()
|
||||
sock = conn2._open_bastion_channel()
|
||||
conn2._connect_host(
|
||||
conn2.client, conn2.host, conn2.port, conn2.username, conn2.auth_type,
|
||||
conn2.key_path, conn2.password, conn2.passphrase, sock=sock,
|
||||
)
|
||||
|
||||
print(" cible connect() sock=canal du bastion :", fake_target_client2.connect.call_args.kwargs.get("sock") is fake_channel)
|
||||
assert fake_target_client2.connect.call_args.kwargs.get("sock") is fake_channel
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("=== 11. Echec de connexion au bastion -> message clair, jamais de fuite d'exception brute ===")
|
||||
conn3 = SSHConnection(host="10.20.20.15", port=22, username="root", auth_type=AUTH_AUTO, bastion_target=bastion_target)
|
||||
with patch("app.ssh_connection.paramiko.SSHClient") as FakeSSHClient:
|
||||
fake_bastion_client3 = MagicMock()
|
||||
fake_bastion_client3.connect.side_effect = OSError("Connexion refusee")
|
||||
FakeSSHClient.return_value = fake_bastion_client3
|
||||
try:
|
||||
conn3._open_bastion_channel()
|
||||
ok = False
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ok = True
|
||||
message = str(exc)
|
||||
print(" message:", message)
|
||||
assert "Bastion" in message
|
||||
assert ok
|
||||
print(" OK")
|
||||
|
||||
print()
|
||||
print("BASTION / PROXYJUMP : TOUS LES TESTS OK")
|
||||
126
tests/test_session_dialog_bastion.py
Normal file
126
tests/test_session_dialog_bastion.py
Normal 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")
|
||||
Reference in New Issue
Block a user