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>
463 lines
19 KiB
Python
463 lines
19 KiB
Python
"""Widgets d'onglet : terminal SSH, terminal local, SFTP autonome.
|
|
|
|
Chaque onglet encapsule une connexion (SSH ou locale) qui expose la meme
|
|
interface de signaux (connected / data_received / disconnected / error / send /
|
|
resize), ce qui permet a `BaseTerminalTab` de rester independante du type de
|
|
connexion sous-jacent.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtGui import QKeySequence, QShortcut
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QLabel, QMessageBox, QSplitter,
|
|
QFrame, QLineEdit, QFileDialog,
|
|
)
|
|
|
|
from app.session_manager import Session, AUTH_KEY, AUTH_PASSWORD
|
|
from app.ssh_connection import SSHConnection
|
|
from app.local_terminal import LocalTerminalConnection, SHELL_POWERSHELL, SHELL_LABELS
|
|
from app.terminal_widget import TerminalWidget
|
|
from app.sftp_panel import SFTPPanel
|
|
from app.dialogs.tunnel_dialog import TunnelDialog
|
|
from app.tunnels import TunnelManager
|
|
from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change
|
|
from app.services.ssh_target import detect_ssh_target
|
|
|
|
# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
|
|
ANSI_ESCAPE_RE = re.compile(
|
|
r"\x1b\[[0-9;?]*[a-zA-Z]" # CSI : couleurs, deplacements du curseur
|
|
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC : titre de fenetre
|
|
r"|\x1b[()][A-Z0-9]" # jeux de caracteres
|
|
r"|\x1b[=>]" # mode clavier
|
|
r"|[\x00-\x08\x0b\x0c\x0e-\x1f]" # autres caracteres de controle
|
|
)
|
|
|
|
|
|
class BaseTerminalTab(QWidget):
|
|
"""Base commune a un onglet terminal : barre de statut + terminal, autour d'une
|
|
connexion (SSH ou locale) qui expose la meme interface de signaux
|
|
(connected / data_received / disconnected / error / send / resize)."""
|
|
|
|
def __init__(self, initial_status: str, parent=None):
|
|
super().__init__(parent)
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
self.bar = QHBoxLayout()
|
|
self.bar.setContentsMargins(10, 6, 10, 6)
|
|
self.status_label = QLabel(initial_status)
|
|
self.status_label.setObjectName("statusLabel")
|
|
self.bar.addWidget(self.status_label, 1)
|
|
|
|
# Apparait quand la connexion tombe : les serveurs de test redemarrent souvent,
|
|
# inutile de fermer l'onglet et de tout ressaisir.
|
|
self.reconnect_btn = QToolButton()
|
|
self.reconnect_btn.setObjectName("primary")
|
|
self.reconnect_btn.setText("⟳ Reconnecter")
|
|
self.reconnect_btn.hide()
|
|
self.reconnect_btn.clicked.connect(self.reconnect)
|
|
self.bar.addWidget(self.reconnect_btn)
|
|
|
|
self.log_btn = QToolButton()
|
|
self.log_btn.setText("⏺ Journal")
|
|
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
|
|
self.log_btn.setCheckable(True)
|
|
self.log_btn.clicked.connect(self.toggle_logging)
|
|
self.bar.addWidget(self.log_btn)
|
|
bar_frame = QFrame()
|
|
bar_frame.setObjectName("tabBar")
|
|
bar_frame.setLayout(self.bar)
|
|
layout.addWidget(bar_frame)
|
|
|
|
self.splitter = QSplitter(Qt.Horizontal)
|
|
self.terminal = TerminalWidget()
|
|
self.splitter.addWidget(self.terminal)
|
|
layout.addWidget(self.splitter, 1)
|
|
|
|
# barre de recherche, masquee tant qu'on n'a pas fait Ctrl+F
|
|
self.search_bar = QFrame()
|
|
self.search_bar.setObjectName("tabBar")
|
|
search_layout = QHBoxLayout(self.search_bar)
|
|
search_layout.setContentsMargins(10, 4, 10, 4)
|
|
self.search_input = QLineEdit()
|
|
self.search_input.setPlaceholderText("Rechercher dans l'historique... (Entree = precedent, Echap = fermer)")
|
|
self.search_status = QLabel("")
|
|
self.search_status.setObjectName("statusLabel")
|
|
close_search = QToolButton()
|
|
close_search.setText("✕")
|
|
search_layout.addWidget(self.search_input, 1)
|
|
search_layout.addWidget(self.search_status)
|
|
search_layout.addWidget(close_search)
|
|
self.search_bar.hide()
|
|
layout.addWidget(self.search_bar)
|
|
|
|
self.terminal.search_requested.connect(self.open_search)
|
|
self.search_input.returnPressed.connect(self._run_search)
|
|
close_search.clicked.connect(self.close_search)
|
|
QShortcut(QKeySequence(Qt.Key_Escape), self.search_input, activated=self.close_search)
|
|
|
|
self.connection = None # assigne par les sous-classes
|
|
self._log_file = None
|
|
|
|
# ---------- journalisation ----------
|
|
def toggle_logging(self) -> None:
|
|
if self._log_file:
|
|
self.stop_logging()
|
|
return
|
|
default = Path.home() / f"session-{datetime.now():%Y%m%d-%H%M%S}.log"
|
|
path, _ = QFileDialog.getSaveFileName(self, "Journaliser la session", str(default), "Journal (*.log)")
|
|
if not path:
|
|
self.log_btn.setChecked(False)
|
|
return
|
|
try:
|
|
self._log_file = open(path, "a", encoding="utf-8", errors="replace")
|
|
except OSError as exc:
|
|
QMessageBox.critical(self, "Erreur", f"Impossible d'ecrire le journal : {exc}")
|
|
self.log_btn.setChecked(False)
|
|
return
|
|
self._log_file.write(f"\n===== Session du {datetime.now():%d/%m/%Y %H:%M:%S} =====\n")
|
|
self.log_btn.setChecked(True)
|
|
self.log_btn.setToolTip(f"Journalisation active : {path}")
|
|
|
|
def stop_logging(self) -> None:
|
|
if self._log_file:
|
|
try:
|
|
self._log_file.close()
|
|
except OSError:
|
|
pass
|
|
self._log_file = None
|
|
self.log_btn.setChecked(False)
|
|
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
|
|
|
|
def _log_output(self, data: bytes) -> None:
|
|
if not self._log_file:
|
|
return
|
|
# on retire les sequences d'echappement : un journal doit rester lisible
|
|
text = ANSI_ESCAPE_RE.sub("", data.decode("utf-8", errors="replace"))
|
|
try:
|
|
self._log_file.write(text)
|
|
self._log_file.flush()
|
|
except OSError:
|
|
self.stop_logging()
|
|
|
|
def _wire_connection(self) -> None:
|
|
self.connection.connected.connect(self._on_connected)
|
|
self.connection.data_received.connect(self.terminal.feed)
|
|
self.connection.data_received.connect(self._log_output)
|
|
self.connection.disconnected.connect(self._on_disconnected)
|
|
self.connection.error.connect(self._on_error)
|
|
self.terminal.data_to_send.connect(self.connection.send)
|
|
self.terminal.resized.connect(self.connection.resize)
|
|
|
|
def _create_connection(self):
|
|
"""Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui
|
|
permet de la recreer a l'identique lors d'une reconnexion."""
|
|
raise NotImplementedError
|
|
|
|
def start_connection(self) -> None:
|
|
self.connection = self._create_connection()
|
|
self._wire_connection()
|
|
self.connection.start()
|
|
|
|
def reconnect(self) -> None:
|
|
if self.connection:
|
|
self.connection.close_connection()
|
|
self.connection.wait(1500)
|
|
self.terminal.reset_screen()
|
|
self.reconnect_btn.hide()
|
|
self.status_label.setText("○ Reconnexion...")
|
|
self.start_connection()
|
|
|
|
def _on_connected(self) -> None:
|
|
self.reconnect_btn.hide()
|
|
self.terminal.setFocus()
|
|
|
|
def _on_disconnected(self, reason: str) -> None:
|
|
self.status_label.setText(f"● Deconnecte ({reason})")
|
|
self.reconnect_btn.show()
|
|
|
|
def _on_error(self, message: str) -> None:
|
|
self.status_label.setText(f"● Erreur : {message}")
|
|
self.reconnect_btn.show()
|
|
QMessageBox.critical(self, "Erreur", message)
|
|
|
|
# ---------- recherche dans l'historique ----------
|
|
def open_search(self) -> None:
|
|
self.search_bar.show()
|
|
self.search_input.setFocus()
|
|
self.search_input.selectAll()
|
|
|
|
def close_search(self) -> None:
|
|
self.search_bar.hide()
|
|
self.search_status.clear()
|
|
self.terminal.setFocus()
|
|
|
|
def _run_search(self) -> None:
|
|
needle = self.search_input.text()
|
|
if not needle:
|
|
return
|
|
found = self.terminal.find_text(needle)
|
|
self.search_status.setText("" if found else "aucun resultat")
|
|
|
|
def close_connection(self) -> None:
|
|
self.stop_logging()
|
|
if self.connection:
|
|
self.connection.close_connection()
|
|
self.connection.wait(2000)
|
|
|
|
|
|
class SessionTabWidget(BaseTerminalTab):
|
|
"""Un onglet = une connexion SSH avec son terminal et un panneau SFTP optionnel."""
|
|
|
|
def __init__(
|
|
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
|
|
parent=None,
|
|
):
|
|
super().__init__(f"○ Connexion a {session.username}@{session.host}...", parent)
|
|
self.session = session
|
|
self._bastion_target = bastion_target
|
|
self._bastion_password = bastion_password
|
|
self.sftp_panel: SFTPPanel | None = None
|
|
|
|
self.sftp_toggle = QToolButton()
|
|
self.sftp_toggle.setText("📁 Fichiers (SFTP)")
|
|
self.sftp_toggle.setToolTip("Afficher / masquer le navigateur de fichiers SFTP")
|
|
self.sftp_toggle.setCheckable(True)
|
|
self.sftp_toggle.setChecked(True)
|
|
self.sftp_toggle.setEnabled(False)
|
|
self.bar.addWidget(self.sftp_toggle)
|
|
|
|
self.tunnel_btn = QToolButton()
|
|
self.tunnel_btn.setText("🔀 Tunnels")
|
|
self.tunnel_btn.setToolTip("Redirections de ports SSH (-L / -R)")
|
|
self.tunnel_btn.setEnabled(False)
|
|
self.tunnel_btn.clicked.connect(self.open_tunnels)
|
|
self.bar.addWidget(self.tunnel_btn)
|
|
|
|
self.tunnel_manager: TunnelManager | None = None
|
|
|
|
self._secret = secret
|
|
self.sftp_toggle.toggled.connect(self._toggle_sftp)
|
|
self.start_connection()
|
|
|
|
def _create_connection(self):
|
|
session = self.session
|
|
connection = SSHConnection(
|
|
host=session.host,
|
|
port=session.port,
|
|
username=session.username,
|
|
auth_type=session.auth_type,
|
|
key_path=session.key_path,
|
|
password=self._secret if session.auth_type == AUTH_PASSWORD else "",
|
|
passphrase=self._secret if session.auth_type == AUTH_KEY else "",
|
|
# comme MobaXterm : le navigateur de fichiers est la des l'ouverture,
|
|
# pas cache derriere une action supplementaire
|
|
open_sftp_on_connect=True,
|
|
bastion_target=self._bastion_target,
|
|
bastion_password=self._bastion_password,
|
|
)
|
|
connection.sftp_ready.connect(self._on_sftp_ready)
|
|
connection.password_prompt.connect(self._on_password_prompt)
|
|
connection.host_key_prompt.connect(self._on_host_key_prompt)
|
|
connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt)
|
|
connection.passphrase_prompt.connect(self._on_passphrase_prompt)
|
|
return connection
|
|
|
|
def open_tunnels(self) -> None:
|
|
if not self.tunnel_manager:
|
|
QMessageBox.information(self, "Tunnels", "La session doit etre connectee.")
|
|
return
|
|
TunnelDialog(self.tunnel_manager, parent=self).exec()
|
|
actifs = len(self.tunnel_manager.tunnels)
|
|
self.tunnel_btn.setText(f"🔀 Tunnels ({actifs})" if actifs else "🔀 Tunnels")
|
|
|
|
def _on_connected(self) -> None:
|
|
self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}")
|
|
transport = self.connection.client.get_transport() if self.connection.client else None
|
|
if transport:
|
|
self.tunnel_manager = TunnelManager(transport)
|
|
self.tunnel_btn.setEnabled(True)
|
|
# le bouton n'est active qu'une fois le canal SFTP reellement ouvert
|
|
# (_on_sftp_ready) : certains serveurs n'exposent pas le sous-systeme SFTP
|
|
super()._on_connected()
|
|
|
|
def _on_disconnected(self, reason: str) -> None:
|
|
super()._on_disconnected(reason)
|
|
self.sftp_toggle.setEnabled(False)
|
|
|
|
def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
|
|
accepted = confirm_host_key(self, hostname, key_type, fingerprint)
|
|
self.connection.confirm_host_key(accepted)
|
|
|
|
def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None:
|
|
accepted = confirm_host_key_change(self, hostname, key_type, old_fp, new_fp)
|
|
self.connection.confirm_host_key_change(accepted)
|
|
|
|
def _on_passphrase_prompt(self, key_path: str) -> None:
|
|
text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}")
|
|
self.connection.provide_passphrase(text if ok else None)
|
|
|
|
def _on_password_prompt(self, target: str) -> None:
|
|
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :")
|
|
self.connection.provide_password(text if ok else None)
|
|
|
|
def _on_sftp_ready(self, sftp) -> None:
|
|
"""Le canal SFTP a ete ouvert en arriere-plan : on affiche le navigateur de
|
|
fichiers a cote du terminal, sans action de l'utilisateur."""
|
|
if self.sftp_panel is not None:
|
|
return
|
|
self.sftp_panel = SFTPPanel(sftp, transfer_sftp_factory=self._open_transfer_channel)
|
|
self.splitter.insertWidget(0, self.sftp_panel)
|
|
self.splitter.setSizes([460, 540])
|
|
self.sftp_toggle.setEnabled(True)
|
|
self.sftp_toggle.setChecked(True)
|
|
self.terminal.setFocus()
|
|
|
|
def _open_transfer_channel(self):
|
|
"""Ouvre un second canal SFTP, dedie aux transferts, pour ne jamais faire
|
|
attendre la navigation derriere un gros envoi/reception (voir SFTPPanel)."""
|
|
import paramiko
|
|
return paramiko.SFTPClient.from_transport(self.connection.client.get_transport())
|
|
|
|
def close_connection(self) -> None:
|
|
if self.sftp_panel:
|
|
self.sftp_panel.shutdown()
|
|
if self.tunnel_manager:
|
|
self.tunnel_manager.stop_all()
|
|
self.tunnel_manager = None
|
|
super().close_connection()
|
|
|
|
def _toggle_sftp(self, checked: bool) -> None:
|
|
if self.sftp_panel is not None:
|
|
self.sftp_panel.setVisible(checked)
|
|
|
|
|
|
class SFTPTabWidget(QWidget):
|
|
"""Onglet SFTP autonome (facon WinSCP) : double panneau de fichiers, sans terminal."""
|
|
|
|
def __init__(
|
|
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
|
|
parent=None,
|
|
):
|
|
super().__init__(parent)
|
|
self.session = session
|
|
self.sftp_panel: SFTPPanel | None = None
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.setSpacing(0)
|
|
|
|
bar = QHBoxLayout()
|
|
bar.setContentsMargins(10, 6, 10, 6)
|
|
self.status_label = QLabel(f"○ Connexion SFTP a {session.username}@{session.host}...")
|
|
self.status_label.setObjectName("statusLabel")
|
|
bar.addWidget(self.status_label, 1)
|
|
bar_frame = QFrame()
|
|
bar_frame.setObjectName("tabBar")
|
|
bar_frame.setLayout(bar)
|
|
layout.addWidget(bar_frame)
|
|
|
|
self.container = QVBoxLayout()
|
|
holder = QWidget()
|
|
holder.setLayout(self.container)
|
|
layout.addWidget(holder, 1)
|
|
|
|
self.connection = SSHConnection(
|
|
host=session.host,
|
|
port=session.port,
|
|
username=session.username,
|
|
auth_type=session.auth_type,
|
|
key_path=session.key_path,
|
|
password=secret if session.auth_type == AUTH_PASSWORD else "",
|
|
passphrase=secret if session.auth_type == AUTH_KEY else "",
|
|
open_shell=False,
|
|
open_sftp_on_connect=True,
|
|
bastion_target=bastion_target,
|
|
bastion_password=bastion_password,
|
|
)
|
|
self.connection.sftp_ready.connect(self._on_sftp_ready)
|
|
self.connection.password_prompt.connect(self._on_password_prompt)
|
|
self.connection.error.connect(self._on_error)
|
|
self.connection.disconnected.connect(self._on_disconnected)
|
|
self.connection.host_key_prompt.connect(self._on_host_key_prompt)
|
|
self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt)
|
|
self.connection.passphrase_prompt.connect(self._on_passphrase_prompt)
|
|
self.connection.start()
|
|
|
|
def _on_sftp_ready(self, sftp) -> None:
|
|
self.sftp_panel = SFTPPanel(sftp, transfer_sftp_factory=self._open_transfer_channel)
|
|
self.container.addWidget(self.sftp_panel)
|
|
self.status_label.setText(
|
|
f"● SFTP connecte : {self.session.username}@{self.session.host}:{self.session.port}"
|
|
)
|
|
|
|
def _open_transfer_channel(self):
|
|
import paramiko
|
|
return paramiko.SFTPClient.from_transport(self.connection.client.get_transport())
|
|
|
|
def _on_disconnected(self, reason: str) -> None:
|
|
self.status_label.setText(f"● Deconnecte ({reason})")
|
|
|
|
def _on_error(self, message: str) -> None:
|
|
self.status_label.setText(f"● Erreur : {message}")
|
|
QMessageBox.critical(self, "Erreur SFTP", message)
|
|
|
|
def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
|
|
self.connection.confirm_host_key(confirm_host_key(self, hostname, key_type, fingerprint))
|
|
|
|
def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None:
|
|
self.connection.confirm_host_key_change(
|
|
confirm_host_key_change(self, hostname, key_type, old_fp, new_fp)
|
|
)
|
|
|
|
def _on_passphrase_prompt(self, key_path: str) -> None:
|
|
text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}")
|
|
self.connection.provide_passphrase(text if ok else None)
|
|
|
|
def _on_password_prompt(self, target: str) -> None:
|
|
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :")
|
|
self.connection.provide_password(text if ok else None)
|
|
|
|
def close_connection(self) -> None:
|
|
if self.sftp_panel:
|
|
self.sftp_panel.shutdown()
|
|
self.connection.close_connection()
|
|
self.connection.wait(2000)
|
|
|
|
|
|
class LocalTerminalTabWidget(BaseTerminalTab):
|
|
"""Un onglet = un terminal Windows local (PowerShell / cmd), sans session enregistree."""
|
|
|
|
sftp_requested = Signal(object) # cible ssh detectee (ou None)
|
|
|
|
def __init__(self, shell: str = SHELL_POWERSHELL, parent=None):
|
|
super().__init__(f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...", parent)
|
|
self.shell = shell
|
|
|
|
# Meme dans un terminal local, on tape souvent "ssh user@ip" a la main :
|
|
# ce bouton ouvre le SFTP sur cet hote, sans passer par un favori.
|
|
self.sftp_btn = QToolButton()
|
|
self.sftp_btn.setText("📁 SFTP sur cette connexion")
|
|
self.sftp_btn.setToolTip(
|
|
"Ouvre un gestionnaire de fichiers SFTP sur l'hote de la commande ssh tapee ici"
|
|
)
|
|
self.sftp_btn.clicked.connect(
|
|
lambda: self.sftp_requested.emit(detect_ssh_target(self.terminal.buffer_text()))
|
|
)
|
|
self.bar.addWidget(self.sftp_btn)
|
|
|
|
self.start_connection()
|
|
|
|
def _create_connection(self):
|
|
return LocalTerminalConnection(shell=self.shell)
|
|
|
|
def _on_connected(self) -> None:
|
|
self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}")
|
|
super()._on_connected()
|