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>
360 lines
16 KiB
Python
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)
|