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>
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""Panneau lateral : arbre des sessions SSH enregistrees + terminaux locaux, et
|
|
leur CRUD (creer / modifier / dupliquer / supprimer). N'ouvre aucune connexion
|
|
elle-meme : elle emet des signaux que ConnectionController ecoute."""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import uuid
|
|
|
|
from PySide6.QtCore import Qt, Signal, QObject
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QTreeWidget, QTreeWidgetItem,
|
|
QMenu, QMessageBox,
|
|
)
|
|
|
|
from app.session_manager import SessionStore, Session
|
|
from app.key_manager import KeyManager
|
|
from app.local_terminal import SHELL_LABELS
|
|
from app.dialogs.session_dialog import SessionDialog
|
|
from app.openssh.config import (
|
|
OpenSSHHost, default_config_path, load_openssh_hosts, session_from_openssh_host,
|
|
)
|
|
|
|
SESSION_ROLE = Qt.UserRole + 10
|
|
LOCAL_SHELL_ROLE = Qt.UserRole + 11
|
|
OPENSSH_HOST_ROLE = Qt.UserRole + 12
|
|
|
|
|
|
class SessionController(QObject):
|
|
open_session_requested = Signal(object) # Session
|
|
open_sftp_requested = Signal(object) # Session
|
|
open_local_shell_requested = Signal(str) # identifiant de shell
|
|
open_openssh_host_requested = Signal(object) # OpenSSHHost
|
|
quick_sftp_requested = Signal()
|
|
manage_keys_requested = Signal()
|
|
manage_known_hosts_requested = Signal()
|
|
|
|
def __init__(
|
|
self,
|
|
session_store: SessionStore,
|
|
key_manager: KeyManager,
|
|
broadcast_button: QToolButton,
|
|
parent_widget: QWidget | None = None,
|
|
openssh_config_path=None,
|
|
):
|
|
super().__init__(parent_widget)
|
|
self.session_store = session_store
|
|
self.key_manager = key_manager
|
|
self._parent_widget = parent_widget
|
|
self._openssh_config_path = openssh_config_path or default_config_path()
|
|
|
|
self.widget = QWidget(parent_widget)
|
|
layout = QVBoxLayout(self.widget)
|
|
layout.setContentsMargins(6, 6, 6, 6)
|
|
layout.setSpacing(6)
|
|
|
|
toolbar = QHBoxLayout()
|
|
new_btn = QToolButton()
|
|
new_btn.setObjectName("primary")
|
|
new_btn.setText("+ Session")
|
|
new_btn.clicked.connect(self.new_session)
|
|
toolbar.addWidget(new_btn)
|
|
|
|
term_btn = QToolButton()
|
|
term_btn.setText("💻 Terminal")
|
|
term_btn.setPopupMode(QToolButton.InstantPopup)
|
|
term_menu = QMenu(term_btn)
|
|
for shell_id, label in SHELL_LABELS.items():
|
|
term_menu.addAction(label, lambda s=shell_id: self.open_local_shell_requested.emit(s))
|
|
term_btn.setMenu(term_menu)
|
|
toolbar.addWidget(term_btn)
|
|
|
|
toolbar.addWidget(broadcast_button)
|
|
|
|
sftp_btn = QToolButton()
|
|
sftp_btn.setText("📁 SFTP")
|
|
sftp_btn.setToolTip("Ouvrir un gestionnaire de fichiers SFTP (sans session enregistree)")
|
|
sftp_btn.clicked.connect(self.quick_sftp_requested.emit)
|
|
toolbar.addWidget(sftp_btn)
|
|
|
|
toolbar.addStretch()
|
|
|
|
manage_btn = QToolButton()
|
|
manage_btn.setText("⚙")
|
|
manage_btn.setToolTip("Cles SSH et hotes connus")
|
|
manage_btn.setPopupMode(QToolButton.InstantPopup)
|
|
manage_menu = QMenu(manage_btn)
|
|
manage_menu.addAction("Gestionnaire de cles SSH...", self.manage_keys_requested.emit)
|
|
manage_menu.addAction("Gestionnaire des hotes connus...", self.manage_known_hosts_requested.emit)
|
|
manage_menu.addSeparator()
|
|
manage_menu.addAction("Rafraichir la configuration OpenSSH (~/.ssh/config)", self.reload)
|
|
manage_btn.setMenu(manage_menu)
|
|
toolbar.addWidget(manage_btn)
|
|
|
|
layout.addLayout(toolbar)
|
|
|
|
self.tree = QTreeWidget()
|
|
self.tree.setHeaderHidden(True)
|
|
self.tree.itemDoubleClicked.connect(self._on_tree_double_click)
|
|
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
|
|
self.tree.customContextMenuRequested.connect(self._on_tree_context_menu)
|
|
layout.addWidget(self.tree)
|
|
|
|
# Sans minimum explicite, la largeur plancher est imposee par le contenu
|
|
# (les noms de sessions), ce qui empeche de retrecir vraiment le panneau.
|
|
self.widget.setMinimumWidth(120)
|
|
self.tree.setMinimumWidth(0)
|
|
|
|
self.reload()
|
|
|
|
# ---------- arbre ----------
|
|
def reload(self) -> None:
|
|
self.tree.clear()
|
|
|
|
locals_header = QTreeWidgetItem(["💻 Terminaux locaux"])
|
|
locals_header.setFlags(locals_header.flags() & ~Qt.ItemIsSelectable)
|
|
font = locals_header.font(0)
|
|
font.setBold(True)
|
|
locals_header.setFont(0, font)
|
|
self.tree.addTopLevelItem(locals_header)
|
|
for shell, label in SHELL_LABELS.items():
|
|
child = QTreeWidgetItem([f" {label}"])
|
|
child.setData(0, LOCAL_SHELL_ROLE, shell)
|
|
locals_header.addChild(child)
|
|
|
|
sessions_header = QTreeWidgetItem(["🔐 Sessions SSH"])
|
|
sessions_header.setFlags(sessions_header.flags() & ~Qt.ItemIsSelectable)
|
|
sessions_header.setFont(0, font)
|
|
self.tree.addTopLevelItem(sessions_header)
|
|
|
|
folder_items: dict[str, QTreeWidgetItem] = {}
|
|
|
|
def get_folder(path: str) -> QTreeWidgetItem:
|
|
if not path:
|
|
return sessions_header
|
|
if path in folder_items:
|
|
return folder_items[path]
|
|
parent_path, _, name = path.rpartition("/")
|
|
parent_item = get_folder(parent_path)
|
|
item = QTreeWidgetItem([f"📁 {name}"])
|
|
item.setFlags(item.flags() & ~Qt.ItemIsSelectable)
|
|
parent_item.addChild(item)
|
|
folder_items[path] = item
|
|
return item
|
|
|
|
for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
|
|
parent = get_folder(session.group)
|
|
item = QTreeWidgetItem([f"🖥 {session.name} ({session.host})"])
|
|
item.setData(0, SESSION_ROLE, session.id)
|
|
parent.addChild(item)
|
|
|
|
if not self.session_store.sessions:
|
|
hint = QTreeWidgetItem([" (aucune session — bouton « + Session » ou « 📁 SFTP »)"])
|
|
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
|
|
hint.setForeground(0, Qt.gray)
|
|
sessions_header.addChild(hint)
|
|
|
|
openssh_header = QTreeWidgetItem(["⚙ Configuration OpenSSH"])
|
|
openssh_header.setFlags(openssh_header.flags() & ~Qt.ItemIsSelectable)
|
|
openssh_header.setFont(0, font)
|
|
openssh_header.setToolTip(0, str(self._openssh_config_path))
|
|
self.tree.addTopLevelItem(openssh_header)
|
|
|
|
hosts = load_openssh_hosts(self._openssh_config_path)
|
|
for host in hosts:
|
|
label = f"🖥 {host.alias} ({host.display_target})"
|
|
if host.proxy_jump:
|
|
label += f" · via {host.proxy_jump}"
|
|
item = QTreeWidgetItem([label])
|
|
item.setData(0, OPENSSH_HOST_ROLE, host)
|
|
if host.proxy_jump:
|
|
item.setToolTip(0, f"ProxyJump : via {host.proxy_jump}")
|
|
openssh_header.addChild(item)
|
|
|
|
if not hosts:
|
|
hint = QTreeWidgetItem([f" (aucun host trouve dans {self._openssh_config_path})"])
|
|
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
|
|
hint.setForeground(0, Qt.gray)
|
|
openssh_header.addChild(hint)
|
|
|
|
self.tree.expandAll()
|
|
|
|
def _session_from_item(self, item: QTreeWidgetItem) -> Session | None:
|
|
if not item:
|
|
return None
|
|
session_id = item.data(0, SESSION_ROLE)
|
|
if not session_id:
|
|
return None
|
|
return next((s for s in self.session_store.sessions if s.id == session_id), None)
|
|
|
|
def _shell_from_item(self, item: QTreeWidgetItem) -> str | None:
|
|
if not item:
|
|
return None
|
|
return item.data(0, LOCAL_SHELL_ROLE)
|
|
|
|
def _openssh_host_from_item(self, item: QTreeWidgetItem) -> OpenSSHHost | None:
|
|
if not item:
|
|
return None
|
|
return item.data(0, OPENSSH_HOST_ROLE)
|
|
|
|
def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None:
|
|
session = self._session_from_item(item)
|
|
if session:
|
|
self.open_session_requested.emit(session)
|
|
return
|
|
shell = self._shell_from_item(item)
|
|
if shell:
|
|
self.open_local_shell_requested.emit(shell)
|
|
return
|
|
host = self._openssh_host_from_item(item)
|
|
if host:
|
|
self.open_openssh_host_requested.emit(host)
|
|
|
|
def _on_tree_context_menu(self, pos) -> None:
|
|
item = self.tree.itemAt(pos)
|
|
menu = QMenu(self.widget)
|
|
session = self._session_from_item(item) if item else None
|
|
shell = self._shell_from_item(item) if item else None
|
|
openssh_host = self._openssh_host_from_item(item) if item else None
|
|
|
|
if session:
|
|
connect_action = menu.addAction("🖥 Ouvrir un terminal SSH")
|
|
sftp_action = menu.addAction("📁 Ouvrir en SFTP (gestionnaire de fichiers)")
|
|
menu.addSeparator()
|
|
edit_action = menu.addAction("Modifier...")
|
|
duplicate_action = menu.addAction("Dupliquer")
|
|
delete_action = menu.addAction("Supprimer")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == connect_action:
|
|
self.open_session_requested.emit(session)
|
|
elif action == sftp_action:
|
|
self.open_sftp_requested.emit(session)
|
|
elif action == edit_action:
|
|
self.edit_session(session)
|
|
elif action == duplicate_action:
|
|
self.duplicate_session(session)
|
|
elif action == delete_action:
|
|
self.delete_session(session)
|
|
elif shell:
|
|
open_action = menu.addAction("Ouvrir")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == open_action:
|
|
self.open_local_shell_requested.emit(shell)
|
|
elif openssh_host:
|
|
connect_action = menu.addAction("🖥 Se connecter")
|
|
import_action = menu.addAction("Importer comme session PySSH...")
|
|
menu.addSeparator()
|
|
refresh_action = menu.addAction("🔄 Rafraichir la configuration")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == connect_action:
|
|
self.open_openssh_host_requested.emit(openssh_host)
|
|
elif action == import_action:
|
|
self.import_openssh_host(openssh_host)
|
|
elif action == refresh_action:
|
|
self.reload()
|
|
else:
|
|
new_action = menu.addAction("Nouvelle session...")
|
|
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
|
|
if action == new_action:
|
|
self.new_session()
|
|
|
|
# ---------- CRUD ----------
|
|
def new_session(self) -> None:
|
|
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, session_store=self.session_store,
|
|
)
|
|
if dialog.exec():
|
|
self.session_store.update(dialog.result_session())
|
|
self.reload()
|
|
|
|
def duplicate_session(self, session: Session) -> None:
|
|
new_session = copy.deepcopy(session)
|
|
new_session.id = str(uuid.uuid4())
|
|
new_session.name = f"{session.name} (copie)"
|
|
self.session_store.add(new_session)
|
|
self.reload()
|
|
|
|
def delete_session(self, session: Session) -> None:
|
|
if QMessageBox.question(
|
|
self._parent_widget, "Supprimer", f"Supprimer la session '{session.name}' ?"
|
|
) == QMessageBox.Yes:
|
|
self.session_store.delete(session.id)
|
|
self.reload()
|
|
|
|
# ---------- OpenSSH config ----------
|
|
def import_openssh_host(self, host: OpenSSHHost) -> None:
|
|
"""Ouvre l'editeur de session pre-rempli depuis ce Host, pour permettre
|
|
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", session_store=self.session_store,
|
|
)
|
|
if dialog.exec():
|
|
self.session_store.add(dialog.result_session())
|
|
self.reload()
|