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")