Files
pyssh-client/app/controllers/connection_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

360 lines
16 KiB
Python

"""Cycle de vie des onglets (terminal SSH, terminal local, SFTP autonome) :
ouverture, connexion rapide, renommage, fermeture."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal, QObject
from PySide6.QtWidgets import QTabWidget, QWidget, QMenu, QMessageBox, QInputDialog
from app.session_manager import SessionStore, Session, AUTH_PASSWORD, PASSWORD_STORAGE_SECURE
from app.key_manager import KeyManager
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
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.credentials import CredentialStore, NullCredentialStore, session_credential_key
from app.controllers.broadcast_manager import BroadcastManager
from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget
from app.ui.split_pane import SplitContainer, PaneSlot, LAYOUT_LABELS
class ConnectionController(QObject):
"""Possede le QTabWidget central. `session_saved` est emis quand une connexion
rapide est aussi enregistree comme session, pour que le panneau lateral se
rafraichisse sans que ce controleur ait besoin de le connaitre."""
session_saved = Signal()
def __init__(
self,
session_store: SessionStore,
key_manager: KeyManager,
broadcast: BroadcastManager,
snippet_store=None,
credential_store: CredentialStore | None = None,
parent_widget: QWidget | None = None,
):
super().__init__(parent_widget)
self.session_store = session_store
self.key_manager = key_manager
self.broadcast = broadcast
self.snippet_store = snippet_store
self.credential_store = credential_store or NullCredentialStore()
self._parent_widget = parent_widget
self.tabs = QTabWidget(parent_widget)
self.tabs.setTabsClosable(True)
self.tabs.setMovable(True)
self.tabs.tabBarDoubleClicked.connect(self.rename_tab)
self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
self.tabs.tabBar().customContextMenuRequested.connect(self._on_tab_context_menu)
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, ""
# Un bastion "session PySSH" reutilise un mot de passe deja memorise pour
# cette session (s'il en existe un) : jamais ecrit depuis ce chemin, on ne
# fait que lire ce que l'ouverture directe de cette session aurait memorise.
if target.credential_key:
stored = self.credential_store.get_secret(target.credential_key)
if stored is not None:
return True, target, stored
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 _ask_session_password(self, session: Session) -> tuple[bool, str]:
if session.auth_type != AUTH_PASSWORD:
return True, ""
remember = session.password_storage == PASSWORD_STORAGE_SECURE
key = session_credential_key(session.id)
if remember:
stored = self.credential_store.get_secret(key)
if stored is not None:
return True, stored
text, ok = ask_secret(
self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :"
)
if ok and remember:
self.credential_store.set_secret(key, text)
return ok, (text if ok else "")
def build_session_tab(self, session: Session) -> SessionTabWidget | None:
"""Construit l'onglet sans l'inserer dans le QTabWidget (reutilise par
l'ouverture normale et par la restauration d'un workspace dans un panneau).
None si l'utilisateur annule (mot de passe, bastion invalide)."""
ok, bastion_target, bastion_password = self._resolve_bastion(session)
if not ok:
return None
ok, secret = self._ask_session_password(session)
if not ok:
return None
return SessionTabWidget(
session, secret, bastion_target=bastion_target, bastion_password=bastion_password,
snippet_store=self.snippet_store, broadcast=self.broadcast,
)
def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None:
ok, bastion_target, bastion_password = self._resolve_bastion(session)
if not ok:
return None
ok, secret = self._ask_session_password(session)
if not ok:
return None
return SFTPTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password)
def open_session(self, session: Session) -> None:
tab = self.build_session_tab(session)
if tab is None:
return
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."""
tab = self.build_sftp_tab(session)
if tab is None:
return
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. 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:
tab = self.new_local_terminal(shell)
label = SHELL_LABELS.get(shell, shell)
index = self.tabs.addTab(tab, f"💻 {label}")
self.tabs.setCurrentIndex(index)
def new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget:
"""Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter
au QTabWidget : reutilise pour un onglet normal comme pour un panneau."""
tab = LocalTerminalTabWidget(shell, snippet_store=self.snippet_store, broadcast=self.broadcast)
tab.sftp_requested.connect(self.quick_sftp)
self.broadcast.register(tab)
return tab
def quick_sftp(self, target: dict | None = None) -> None:
"""Ouvre un SFTP en saisissant directement les infos de connexion, sans favori.
Si l'onglet courant est un terminal ou une commande ssh a ete tapee, l'hote,
l'utilisateur et le port sont pre-remplis automatiquement."""
if target is None:
target = self._current_tab_ssh_target()
self._quick_connect(sftp=True, target=target)
def quick_ssh(self) -> None:
self._quick_connect(sftp=False, target=self._current_tab_ssh_target())
def _current_tab_ssh_target(self) -> dict | None:
widget = self.tabs.currentWidget()
if isinstance(widget, SessionTabWidget):
return {
"user": widget.session.username,
"host": widget.session.host,
"port": widget.session.port,
}
if isinstance(widget, BaseTerminalTab):
return detect_ssh_target(widget.terminal.buffer_text())
return None
def _quick_connect(self, sftp: bool, target: dict | None = None) -> None:
prefill = None
if target:
prefill = Session(
name=target.get("host", ""),
host=target.get("host", ""),
username=target.get("user", ""),
port=target.get("port", 22),
)
dialog = SessionDialog(
self.key_manager,
session=prefill,
parent=self._parent_widget,
connect_label="Se connecter",
title="Connexion SFTP" if sftp else "Connexion SSH",
session_store=self.session_store,
credential_store=self.credential_store,
)
if not dialog.exec():
return
session = dialog.result_session()
if dialog.should_save():
self.session_store.add(session)
self.session_saved.emit()
if sftp:
self.open_sftp_session(session)
else:
self.open_session(session)
# ---------- gestion des onglets ----------
def rename_tab(self, index: int) -> None:
if index < 0:
return
actuel = self.tabs.tabText(index)
nouveau, ok = QInputDialog.getText(
self._parent_widget, "Renommer l'onglet", "Nom de l'onglet :", text=actuel
)
if ok and nouveau.strip():
self.tabs.setTabText(index, nouveau.strip())
def _on_tab_context_menu(self, pos) -> None:
bar = self.tabs.tabBar()
index = bar.tabAt(pos)
if index < 0:
return
widget = self.tabs.widget(index)
menu = QMenu(self._parent_widget)
act_rename = menu.addAction("Renommer... (double-clic)")
menu.addSeparator()
# Diviser : seulement pour un terminal simple (SSH ou local), pas pour
# un SFTP ni pour un split existant (pas d'imbrication de splits).
split_actions = {}
if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
split_menu = menu.addMenu("Diviser")
for kind, label in LAYOUT_LABELS.items():
split_actions[split_menu.addAction(label)] = kind
menu.addSeparator()
act_close = menu.addAction("Fermer cet onglet")
act_close_others = menu.addAction("Fermer les autres onglets")
act_close_others.setEnabled(self.tabs.count() > 1)
action = menu.exec(bar.mapToGlobal(pos))
if action == act_rename:
self.rename_tab(index)
elif action == act_close:
self._on_tab_close(index)
elif action == act_close_others:
# on ferme par la fin : retirer un onglet decale les suivants
cible = self.tabs.widget(index)
for other in range(self.tabs.count() - 1, -1, -1):
if self.tabs.widget(other) is not cible:
self._on_tab_close(other)
elif action in split_actions:
self.split_tab(index, split_actions[action])
def _on_tab_close(self, index: int) -> None:
widget = self.tabs.widget(index)
if isinstance(widget, SplitContainer):
widget.close_all()
elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
widget.close_connection()
self.tabs.removeTab(index)
def close_all(self) -> None:
"""A appeler a la fermeture de l'application : ferme proprement toutes les
connexions (SSH, locales, SFTP) avant que les widgets ne soient detruits."""
for i in range(self.tabs.count()):
widget = self.tabs.widget(i)
if isinstance(widget, SplitContainer):
widget.close_all()
elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
widget.close_connection()
# ---------- split terminal ----------
def split_tab(self, index: int, layout_kind: str) -> None:
"""Transforme l'onglet en une grille de terminaux : le contenu actuel
occupe le premier panneau, les autres recoivent un terminal local par
defaut (remplacable ensuite via le menu ☰ de chaque panneau)."""
widget = self.tabs.widget(index)
if not isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
return
title = self.tabs.tabText(index)
# SplitContainer reparente `widget` dans un de ses panneaux ; Qt retire
# alors tout seul l'onglet correspondant (un widget ne peut avoir qu'un
# parent), en decalant les onglets suivants - inutile donc d'appeler
# removeTab(index) explicitement, ce qui viserait desormais un autre onglet.
container = SplitContainer(widget, layout_kind, self.new_local_terminal)
container.all_panes_closed.connect(
lambda remaining, c=container: self._on_split_collapsed(c, remaining)
)
container.pane_menu_requested.connect(self._show_pane_menu)
self.tabs.insertTab(index, container, f"⊞ {title}")
self.tabs.setCurrentIndex(index)
def _on_split_collapsed(self, container: SplitContainer, remaining: QWidget | None) -> None:
"""Il ne reste plus qu'un panneau : on redeplie vers un onglet normal."""
index = self.tabs.indexOf(container)
if index < 0:
return
self.tabs.removeTab(index)
if remaining is None:
return
title = self.default_title_for(remaining)
self.tabs.insertTab(index, remaining, title)
self.tabs.setCurrentIndex(index)
@staticmethod
def default_title_for(widget: QWidget) -> str:
if isinstance(widget, SessionTabWidget):
return f"🖥 {widget.session.name}"
if isinstance(widget, SFTPTabWidget):
return f"📁 {widget.session.name}"
if isinstance(widget, LocalTerminalTabWidget):
return f"💻 {SHELL_LABELS.get(widget.shell, widget.shell)}"
return "Onglet"
def _movable_tabs(self) -> list[tuple[int, str, QWidget]]:
"""Onglets de premier niveau deplacables vers un panneau (ni SFTP, ni
un split - on ne gere pas les deplacements panneau-a-panneau)."""
result = []
for i in range(self.tabs.count()):
widget = self.tabs.widget(i)
if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
result.append((i, self.tabs.tabText(i), widget))
return result
def _show_pane_menu(self, pane: PaneSlot, container: SplitContainer) -> None:
menu = QMenu(self._parent_widget)
term_menu = menu.addMenu("Terminal local")
shell_actions = {}
for shell_id, label in SHELL_LABELS.items():
shell_actions[term_menu.addAction(label)] = shell_id
movable = self._movable_tabs()
move_actions = {}
if movable:
menu.addSeparator()
move_menu = menu.addMenu("Deplacer un onglet ici")
for index, title, widget in movable:
move_actions[move_menu.addAction(title)] = (index, widget)
else:
hint = menu.addAction("(aucun autre onglet a deplacer ici)")
hint.setEnabled(False)
action = menu.exec(pane.menu_btn.mapToGlobal(pane.menu_btn.rect().bottomLeft()))
if action in shell_actions:
container.replace_pane_content(pane, self.new_local_terminal(shell_actions[action]))
elif action in move_actions:
index, moved_widget = move_actions[action]
self.move_tab_into_pane(index, container, pane)
def move_tab_into_pane(self, tab_index: int, container: SplitContainer, pane: PaneSlot) -> None:
"""Deplace l'onglet de premier niveau `tab_index` dans `pane` : sa
connexion continue de vivre, seul son emplacement visuel change."""
widget = self.tabs.widget(tab_index)
self.tabs.removeTab(tab_index) # ne ferme pas la connexion : elle continue dans le panneau
container.replace_pane_content(pane, widget)