Files
pyssh-client/app/controllers/session_controller.py
Florian 9216f636bd feat: ajouter le Credential Vault (memorisation securisee optionnelle)
Par defaut, inchange : les mots de passe sont toujours redemandes a chaque
connexion. Nouvelle case a cocher optionnelle dans l'editeur de session,
visible seulement en authentification par mot de passe : "Memoriser le mot
de passe de maniere securisee".

- app/credentials.py : abstraction CredentialStore (set_secret/get_secret/
  delete_secret/is_available), pour supporter plus tard d'autres systemes que
  Windows. WindowsCredentialStore utilise Windows Credential Manager
  (CredWrite/CredRead/CredDelete) via pywin32-ctypes - deja present dans
  l'environnement (dependance de PyInstaller), mais pas encore declare comme
  dependance d'execution : ajoute a requirements.txt (marque sys_platform ==
  "win32"), sinon la fonctionnalite aurait silencieusement ete indisponible
  sur une installation propre. NullCredentialStore en repli si indisponible :
  jamais d'ecriture en clair a la place.
- Session.password_storage ("none" par defaut | "secure") : seulement le mode
  est stocke dans sessions.json, jamais le secret. Le mot de passe est ecrit/
  lu dans le trousseau via une cle applicative (session:<id>), et supprime
  automatiquement si l'utilisateur desactive la memorisation ou supprime la
  session.
- Un bastion en mode "session PySSH" reutilise un mot de passe deja memorise
  pour cette session (lecture seule depuis ce chemin : l'ecriture ne se fait
  que via l'ouverture directe de la session concernee).

tests/test_credentials.py : NullCredentialStore, aller-retour reel avec
Windows Credential Manager (nettoye et verifie apres coup, aucune entree
residuelle), integration ConnectionController (memorise puis reutilise sans
redemander), suppression en cascade, reutilisation par un bastion, et
verification que le secret n'apparait jamais dans la serialisation JSON de
la session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:21:32 +02:00

380 lines
16 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, QComboBox, QLabel,
)
from app.session_manager import SessionStore, Session
from app.key_manager import KeyManager
from app.groups import GroupSettingsStore
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
from app.local_terminal import SHELL_LABELS
from app.dialogs.session_dialog import SessionDialog
from app.dialogs.group_settings_dialog import GroupSettingsDialog
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
GROUP_PATH_ROLE = Qt.UserRole + 13
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,
group_store: GroupSettingsStore | None = None,
credential_store: CredentialStore | None = None,
):
super().__init__(parent_widget)
self.session_store = session_store
self.key_manager = key_manager
self.group_store = group_store
self.credential_store = credential_store or NullCredentialStore()
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(lambda: 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)
filter_row = QHBoxLayout()
filter_row.addWidget(QLabel("🏷"))
self.tag_filter_combo = QComboBox()
self.tag_filter_combo.setEditable(True)
self.tag_filter_combo.setInsertPolicy(QComboBox.NoInsert)
self.tag_filter_combo.lineEdit().setPlaceholderText("Filtrer les sessions par tag...")
self.tag_filter_combo.currentTextChanged.connect(self._apply_tag_filter)
filter_row.addWidget(self.tag_filter_combo, 1)
layout.addLayout(filter_row)
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)
item.setData(0, GROUP_PATH_ROLE, path)
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)
label = f"🖥 {session.name} ({session.host})"
if session.tags:
label += " [" + ", ".join(session.tags) + "]"
item = QTreeWidgetItem([label])
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()
self._reload_tag_filter()
self._apply_tag_filter()
def _reload_tag_filter(self) -> None:
current = self.tag_filter_combo.currentText()
self.tag_filter_combo.blockSignals(True)
self.tag_filter_combo.clear()
self.tag_filter_combo.addItem("") # aucun filtre
self.tag_filter_combo.addItems(self.session_store.tags())
self.tag_filter_combo.setCurrentText(current)
self.tag_filter_combo.blockSignals(False)
def _apply_tag_filter(self, _text: str = "") -> None:
query = self.tag_filter_combo.currentText().strip().lower()
def visit(item: QTreeWidgetItem) -> None:
session = self._session_from_item(item)
if session is not None:
item.setHidden(bool(query) and not any(query in t.lower() for t in session.tags))
for i in range(item.childCount()):
visit(item.child(i))
for i in range(self.tree.topLevelItemCount()):
visit(self.tree.topLevelItem(i))
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 self.session_store.get(session_id)
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 _group_from_item(self, item: QTreeWidgetItem) -> str | None:
if not item:
return None
return item.data(0, GROUP_PATH_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
group_path = self._group_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()
elif group_path:
new_in_group_action = menu.addAction("Nouvelle session dans ce dossier...")
menu.addSeparator()
group_settings_action = menu.addAction("Parametres du groupe...")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == new_in_group_action:
self.new_session(group=group_path)
elif action == group_settings_action:
self.open_group_settings(group_path)
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, group: str = "") -> None:
prefill = Session(group=group) if group else None
dialog = SessionDialog(
self.key_manager, session=prefill, parent=self._parent_widget,
session_store=self.session_store, group_store=self.group_store,
credential_store=self.credential_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, group_store=self.group_store,
credential_store=self.credential_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.credential_store.delete_secret(session_credential_key(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,
group_store=self.group_store, credential_store=self.credential_store,
)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()
# ---------- parametres de groupe ----------
def open_group_settings(self, group_path: str) -> None:
if self.group_store is None:
return
dialog = GroupSettingsDialog(
group_path, self.group_store, self.key_manager, self.session_store, parent=self._parent_widget,
)
dialog.exec()