From c08037a6380088f56a340ddb9c2826b1c40b00b5 Mon Sep 17 00:00:00 2001 From: Florian Date: Sat, 5 Sep 2026 01:49:59 +0200 Subject: [PATCH] feat: ajouter la prise en charge de ~/.ssh/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support minimum : Host, HostName, User, Port, IdentityFile, ProxyJump, ForwardAgent, ServerAliveInterval, ServerAliveCountMax. - app/openssh/config.py : load_openssh_hosts() lit ~/.ssh/config via paramiko.SSHConfig (gere nativement les blocs "Host *" fusionnes, les alias multiples sur une meme ligne Host, et l'expansion ~ des IdentityFile). Fichier absent = liste vide, pas une erreur. session_from_openssh_host() convertit un Host en Session PySSH (groupe "OpenSSH", cle si IdentityFile presente sinon authentification auto). - Nouvelle section "Configuration OpenSSH" dans le panneau lateral (SessionController), sous Sessions SSH : double-clic ou menu contextuel pour se connecter directement ou importer comme session (editeur pre-rempli, ajustable avant enregistrement) ; rafraichissement via le menu ⚙ ou le menu contextuel de l'hote. - ConnectionController.open_openssh_host() ouvre une connexion directe sans enregistrer de session, comme une connexion rapide. Le ProxyJump est detecte, affiche (badge "· via ", tooltip) et reporte dans les notes d'une session importee, mais pas encore applique automatiquement a la connexion : ce sera l'objet de la Phase 3 (Bastion / Jump Host), qui prendra en charge toutes ses sources (OpenSSH, session PySSH, config manuelle) via un mecanisme commun. Co-Authored-By: Claude Sonnet 5 --- app/controllers/connection_controller.py | 6 ++ app/controllers/session_controller.py | 76 ++++++++++++++- app/main_window.py | 1 + app/openssh/__init__.py | 0 app/openssh/config.py | 113 +++++++++++++++++++++++ tests/test_openssh_config.py | 106 +++++++++++++++++++++ 6 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 app/openssh/__init__.py create mode 100644 app/openssh/config.py create mode 100644 tests/test_openssh_config.py diff --git a/app/controllers/connection_controller.py b/app/controllers/connection_controller.py index 7e8f528..cfc9b19 100644 --- a/app/controllers/connection_controller.py +++ b/app/controllers/connection_controller.py @@ -11,6 +11,7 @@ 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.controllers.broadcast_manager import BroadcastManager from app.ui.tabs import BaseTerminalTab, SessionTabWidget, SFTPTabWidget, LocalTerminalTabWidget @@ -74,6 +75,11 @@ class ConnectionController(QObject): 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. Le ProxyJump eventuel n'est pas encore applique (Phase 3).""" + self.open_session(session_from_openssh_host(host)) + def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None: tab = LocalTerminalTabWidget(shell) tab.sftp_requested.connect(self.quick_sftp) diff --git a/app/controllers/session_controller.py b/app/controllers/session_controller.py index 7736530..91659d3 100644 --- a/app/controllers/session_controller.py +++ b/app/controllers/session_controller.py @@ -16,15 +16,20 @@ 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_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() @@ -35,11 +40,13 @@ class SessionController(QObject): 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) @@ -79,6 +86,8 @@ class SessionController(QObject): 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) @@ -145,6 +154,33 @@ class SessionController(QObject): 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 : {host.proxy_jump} (pas encore applique automatiquement, " + "connexion directe pour l'instant)", + ) + 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: @@ -160,6 +196,11 @@ class SessionController(QObject): 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: @@ -168,12 +209,17 @@ class SessionController(QObject): 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") @@ -198,6 +244,18 @@ class SessionController(QObject): 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)) @@ -230,3 +288,15 @@ class SessionController(QObject): ) == 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", + ) + if dialog.exec(): + self.session_store.add(dialog.result_session()) + self.reload() diff --git a/app/main_window.py b/app/main_window.py index 6c36aa1..f71e93e 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -68,6 +68,7 @@ class MainWindow(QMainWindow): sc.open_session_requested.connect(cc.open_session) sc.open_sftp_requested.connect(cc.open_sftp_session) sc.open_local_shell_requested.connect(cc.open_local_terminal) + sc.open_openssh_host_requested.connect(cc.open_openssh_host) sc.quick_sftp_requested.connect(cc.quick_sftp) sc.manage_keys_requested.connect(self.open_key_manager) sc.manage_known_hosts_requested.connect(self.open_known_hosts_manager) diff --git a/app/openssh/__init__.py b/app/openssh/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/openssh/config.py b/app/openssh/config.py new file mode 100644 index 0000000..275351b --- /dev/null +++ b/app/openssh/config.py @@ -0,0 +1,113 @@ +"""Lecture de ~/.ssh/config (format OpenSSH), via paramiko.SSHConfig. + +Support minimum couvert : Host, HostName, User, Port, IdentityFile, ProxyJump, +ForwardAgent, ServerAliveInterval, ServerAliveCountMax. + +Le ProxyJump detecte ici est seulement affiche/reporte pour l'instant : la +connexion via un jump host est prise en charge par le module dedie (bastion), +pas par ce module qui ne fait que lire la configuration. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +import paramiko + +from app.session_manager import Session, AUTH_AUTO, AUTH_KEY + + +@dataclass +class OpenSSHHost: + alias: str + hostname: str = "" + user: str = "" + port: int = 22 + identity_files: list[str] = field(default_factory=list) + proxy_jump: str = "" + forward_agent: bool = False + server_alive_interval: int | None = None + server_alive_count_max: int | None = None + + @property + def display_target(self) -> str: + target = self.hostname or self.alias + return f"{target}:{self.port}" if self.port != 22 else target + + +def default_config_path() -> Path: + return Path.home() / ".ssh" / "config" + + +def _as_int(value: str | None) -> int | None: + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None + + +def load_openssh_hosts(path: Path | None = None) -> list[OpenSSHHost]: + """Renvoie les Host detectes dans le fichier, tries par alias. + + Un fichier absent (tres courant : rien ne force un utilisateur Windows a en + avoir un) n'est pas une erreur : la liste est simplement vide.""" + path = path or default_config_path() + if not path.exists(): + return [] + + parser = paramiko.SSHConfig() + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + parser.parse(f) + except OSError: + return [] + + hosts: list[OpenSSHHost] = [] + for alias in parser.get_hostnames(): + # "*" et les motifs (glob) ne designent pas un hote precis auquel se + # connecter, seulement des valeurs par defaut a fusionner dans les autres. + if alias == "*" or "*" in alias or "?" in alias: + continue + looked_up = parser.lookup(alias) + hosts.append(OpenSSHHost( + alias=alias, + hostname=looked_up.get("hostname", alias), + user=looked_up.get("user", ""), + port=_as_int(looked_up.get("port")) or 22, + identity_files=list(looked_up.get("identityfile", []) or []), + proxy_jump=looked_up.get("proxyjump", ""), + forward_agent=str(looked_up.get("forwardagent", "")).lower() == "yes", + server_alive_interval=_as_int(looked_up.get("serveraliveinterval")), + server_alive_count_max=_as_int(looked_up.get("serveralivecountmax")), + )) + hosts.sort(key=lambda h: h.alias.lower()) + return hosts + + +def session_from_openssh_host(host: OpenSSHHost) -> Session: + """Convertit un Host OpenSSH en Session PySSH (utilisation directe ou import). + + Le ProxyJump n'est pas encore applique automatiquement (necessite le support + Bastion/Jump Host) : on le signale dans les notes plutot que de le perdre + silencieusement.""" + session = Session( + name=host.alias, + group="OpenSSH", + host=host.hostname or host.alias, + port=host.port, + username=host.user, + ) + if host.identity_files: + session.auth_type = AUTH_KEY + session.key_path = str(Path(host.identity_files[0]).expanduser()) + else: + session.auth_type = AUTH_AUTO + + notes = [f"Importe depuis ~/.ssh/config (Host {host.alias})."] + if host.proxy_jump: + notes.append( + f"ProxyJump detecte vers « {host.proxy_jump} » — pas encore applique " + "automatiquement (connexion directe pour l'instant)." + ) + session.notes = "\n".join(notes) + return session diff --git a/tests/test_openssh_config.py b/tests/test_openssh_config.py new file mode 100644 index 0000000..c9ce106 --- /dev/null +++ b/tests/test_openssh_config.py @@ -0,0 +1,106 @@ +"""Lecture de ~/.ssh/config : hosts detectes, champs minimum, conversion en Session.""" +import sys, pathlib +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import tempfile +from pathlib import Path + +from app.openssh.config import load_openssh_hosts, session_from_openssh_host +from app.session_manager import AUTH_AUTO, AUTH_KEY + +tmp = Path(tempfile.mkdtemp()) +config_path = tmp / "config" +config_path.write_text( + """ +Host * + ServerAliveInterval 60 + ServerAliveCountMax 3 + +Host bastion + HostName bastion.example.com + User jump-user + Port 2222 + IdentityFile ~/.ssh/bastion_key + +Host proxmox-prod + HostName 10.20.20.15 + User root + IdentityFile ~/.ssh/homelab + ProxyJump bastion + ForwardAgent yes + +Host sans-cle + HostName 10.0.0.9 + User admin +""", + encoding="utf-8", +) + +print("=== 1. Fichier absent -> liste vide, pas d'exception ===") +absent = load_openssh_hosts(tmp / "n-existe-pas") +assert absent == [] +print(" OK") + +print() +print("=== 2. Hosts detectes, '*' exclu ===") +hosts = load_openssh_hosts(config_path) +aliases = [h.alias for h in hosts] +print(" alias detectes:", aliases) +assert aliases == sorted(aliases), "tries par alias" +assert "*" not in aliases +assert set(aliases) == {"bastion", "proxmox-prod", "sans-cle"} +print(" OK") + +print() +print("=== 3. Champs minimum correctement lus ===") +by_alias = {h.alias: h for h in hosts} +prod = by_alias["proxmox-prod"] +print(" proxmox-prod:", prod) +assert prod.hostname == "10.20.20.15" +assert prod.user == "root" +assert prod.port == 22 # non precise -> defaut +assert prod.proxy_jump == "bastion" +assert prod.forward_agent is True +assert prod.identity_files and prod.identity_files[0].endswith("homelab") +# heritees du bloc "Host *" +assert prod.server_alive_interval == 60 +assert prod.server_alive_count_max == 3 +print(" OK") + +bastion = by_alias["bastion"] +assert bastion.port == 2222 +assert bastion.forward_agent is False # non precise pour cet hote +print(" bastion : port et ForwardAgent par defaut corrects : OK") + +sans_cle = by_alias["sans-cle"] +assert sans_cle.identity_files == [] +print(" sans-cle : aucune IdentityFile -> liste vide : OK") + +print() +print("=== 4. Conversion en Session PySSH ===") +session_prod = session_from_openssh_host(prod) +print(" session:", session_prod) +assert session_prod.name == "proxmox-prod" +assert session_prod.host == "10.20.20.15" +assert session_prod.username == "root" +assert session_prod.group == "OpenSSH" +assert session_prod.auth_type == AUTH_KEY +assert session_prod.key_path.endswith("homelab") +assert "ProxyJump" in session_prod.notes and "bastion" in session_prod.notes +print(" OK") + +session_sans_cle = session_from_openssh_host(sans_cle) +assert session_sans_cle.auth_type == AUTH_AUTO +assert "ProxyJump" not in session_sans_cle.notes +print(" sans-cle -> AUTH_AUTO, pas de mention ProxyJump : OK") + +print() +print("=== 5. Host sans HostName : l'alias fait office d'hote (comme OpenSSH) ===") +config2 = tmp / "config2" +config2.write_text("Host monserveur\n User root\n", encoding="utf-8") +hosts2 = load_openssh_hosts(config2) +assert hosts2[0].hostname == "monserveur" +print(" OK") + +print() +print("OPENSSH CONFIG : TOUS LES TESTS OK")