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:
@@ -3,7 +3,7 @@ ouverture, connexion rapide, renommage, fermeture."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal, QObject
|
||||
from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QInputDialog
|
||||
from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog
|
||||
|
||||
from app.session_manager import SessionStore, Session, AUTH_PASSWORD
|
||||
from app.key_manager import KeyManager
|
||||
@@ -12,6 +12,7 @@ from app.dialogs.session_dialog import SessionDialog
|
||||
from app.dialogs.connect_prompt import ask_secret
|
||||
from app.services.ssh_target import detect_ssh_target
|
||||
from app.openssh.config import OpenSSHHost, session_from_openssh_host
|
||||
from app.bastion import BastionTarget, resolve_bastion_target, BastionResolutionError
|
||||
from app.controllers.broadcast_manager import BroadcastManager
|
||||
from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget
|
||||
|
||||
@@ -45,7 +46,30 @@ class ConnectionController(QObject):
|
||||
self.tabs.tabCloseRequested.connect(self._on_tab_close)
|
||||
|
||||
# ---------- ouverture ----------
|
||||
def _resolve_bastion(self, session: Session) -> tuple[bool, BastionTarget | None, str]:
|
||||
"""Renvoie (ok, bastion_target, bastion_password). ok=False signifie que
|
||||
l'ouverture doit etre annulee (bastion invalide, ou mot de passe du
|
||||
bastion non fourni) ; l'appelant ne doit alors rien ouvrir."""
|
||||
try:
|
||||
target = resolve_bastion_target(session.bastion, self.session_store)
|
||||
except BastionResolutionError as exc:
|
||||
QMessageBox.critical(self._parent_widget, "Bastion invalide", str(exc))
|
||||
return False, None, ""
|
||||
if target is None:
|
||||
return True, None, ""
|
||||
if target.auth_type != AUTH_PASSWORD:
|
||||
return True, target, ""
|
||||
text, ok = ask_secret(
|
||||
self._parent_widget, "Mot de passe du bastion", f"Mot de passe pour {target.label} (bastion) :",
|
||||
)
|
||||
if not ok:
|
||||
return False, None, ""
|
||||
return True, target, text
|
||||
|
||||
def open_session(self, session: Session) -> None:
|
||||
ok, bastion_target, bastion_password = self._resolve_bastion(session)
|
||||
if not ok:
|
||||
return
|
||||
secret = ""
|
||||
if session.auth_type == AUTH_PASSWORD:
|
||||
text, ok = ask_secret(
|
||||
@@ -55,13 +79,16 @@ class ConnectionController(QObject):
|
||||
return
|
||||
secret = text
|
||||
|
||||
tab = SessionTabWidget(session, secret)
|
||||
tab = SessionTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password)
|
||||
self.broadcast.register(tab)
|
||||
index = self.tabs.addTab(tab, f"🖥 {session.name}")
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
def open_sftp_session(self, session: Session) -> None:
|
||||
"""Ouvre un onglet SFTP autonome (double panneau) pour cette session."""
|
||||
ok, bastion_target, bastion_password = self._resolve_bastion(session)
|
||||
if not ok:
|
||||
return
|
||||
secret = ""
|
||||
if session.auth_type == AUTH_PASSWORD:
|
||||
text, ok = ask_secret(
|
||||
@@ -71,13 +98,13 @@ class ConnectionController(QObject):
|
||||
return
|
||||
secret = text
|
||||
|
||||
tab = SFTPTabWidget(session, secret)
|
||||
tab = SFTPTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password)
|
||||
index = self.tabs.addTab(tab, f"📁 {session.name}")
|
||||
self.tabs.setCurrentIndex(index)
|
||||
|
||||
def open_openssh_host(self, host: OpenSSHHost) -> None:
|
||||
"""Connexion directe a un Host de ~/.ssh/config, sans l'enregistrer comme
|
||||
session. Le ProxyJump eventuel n'est pas encore applique (Phase 3)."""
|
||||
session. Un ProxyJump eventuel est resolu et applique via app.bastion."""
|
||||
self.open_session(session_from_openssh_host(host))
|
||||
|
||||
def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None:
|
||||
@@ -127,6 +154,7 @@ class ConnectionController(QObject):
|
||||
parent=self._parent_widget,
|
||||
connect_label="Se connecter",
|
||||
title="Connexion SFTP" if sftp else "Connexion SSH",
|
||||
session_store=self.session_store,
|
||||
)
|
||||
if not dialog.exec():
|
||||
return
|
||||
|
||||
@@ -168,11 +168,7 @@ class SessionController(QObject):
|
||||
item = QTreeWidgetItem([label])
|
||||
item.setData(0, OPENSSH_HOST_ROLE, host)
|
||||
if host.proxy_jump:
|
||||
item.setToolTip(
|
||||
0,
|
||||
f"ProxyJump : {host.proxy_jump} (pas encore applique automatiquement, "
|
||||
"connexion directe pour l'instant)",
|
||||
)
|
||||
item.setToolTip(0, f"ProxyJump : via {host.proxy_jump}")
|
||||
openssh_header.addChild(item)
|
||||
|
||||
if not hosts:
|
||||
@@ -264,13 +260,15 @@ class SessionController(QObject):
|
||||
|
||||
# ---------- CRUD ----------
|
||||
def new_session(self) -> None:
|
||||
dialog = SessionDialog(self.key_manager, parent=self._parent_widget)
|
||||
dialog = SessionDialog(self.key_manager, parent=self._parent_widget, session_store=self.session_store)
|
||||
if dialog.exec():
|
||||
self.session_store.add(dialog.result_session())
|
||||
self.reload()
|
||||
|
||||
def edit_session(self, session: Session) -> None:
|
||||
dialog = SessionDialog(self.key_manager, session=session, parent=self._parent_widget)
|
||||
dialog = SessionDialog(
|
||||
self.key_manager, session=session, parent=self._parent_widget, session_store=self.session_store,
|
||||
)
|
||||
if dialog.exec():
|
||||
self.session_store.update(dialog.result_session())
|
||||
self.reload()
|
||||
@@ -295,7 +293,7 @@ class SessionController(QObject):
|
||||
d'ajuster avant d'enregistrer (comme une connexion rapide)."""
|
||||
dialog = SessionDialog(
|
||||
self.key_manager, session=session_from_openssh_host(host), parent=self._parent_widget,
|
||||
title=f"Importer « {host.alias} » comme session",
|
||||
title=f"Importer « {host.alias} » comme session", session_store=self.session_store,
|
||||
)
|
||||
if dialog.exec():
|
||||
self.session_store.add(dialog.result_session())
|
||||
|
||||
Reference in New Issue
Block a user