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>
405 lines
17 KiB
Python
405 lines
17 KiB
Python
"""Connexion SSH en arriere-plan (thread Qt) : shell interactif + verification des cles d'hote."""
|
|
from __future__ import annotations
|
|
|
|
import queue
|
|
import threading
|
|
|
|
import paramiko
|
|
from PySide6.QtCore import QThread, Signal
|
|
|
|
from app.known_hosts_manager import KnownHostsStore
|
|
from app.bastion import BastionTarget
|
|
|
|
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
|
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
|
|
|
|
|
|
class TrustPrompt(paramiko.MissingHostKeyPolicy):
|
|
"""Politique qui demande confirmation (via signal Qt) avant de faire confiance a une nouvelle cle d'hote."""
|
|
|
|
def __init__(self, connection: "SSHConnection"):
|
|
self.connection = connection
|
|
|
|
def missing_host_key(self, client, hostname, key):
|
|
accepted = self.connection._ask_host_key_trust(hostname, key)
|
|
if not accepted:
|
|
raise paramiko.SSHException(f"Cle d'hote refusee pour {hostname}")
|
|
client.get_host_keys().add(hostname, key.get_name(), key)
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
client.save_host_keys(str(KNOWN_HOSTS_FILE))
|
|
|
|
|
|
class SSHConnection(QThread):
|
|
"""Gere une connexion SSH + un canal shell interactif dans un thread dedie."""
|
|
|
|
connected = Signal()
|
|
data_received = Signal(bytes)
|
|
disconnected = Signal(str)
|
|
error = Signal(str)
|
|
host_key_prompt = Signal(str, str, str) # hostname, key_type, fingerprint -> attend confirm_host_key()
|
|
host_key_changed_prompt = Signal(str, str, str, str) # hostname, key_type, old_fp, new_fp -> attend confirm_host_key_change()
|
|
passphrase_prompt = Signal(str) # key_path -> attend provide_passphrase()
|
|
password_prompt = Signal(str) # "user@host" -> attend provide_password()
|
|
sftp_ready = Signal(object) # SFTPClient ouvert dans le thread, pret a l'emploi
|
|
|
|
def __init__(
|
|
self,
|
|
host: str,
|
|
port: int,
|
|
username: str,
|
|
auth_type: str,
|
|
key_path: str = "",
|
|
password: str = "",
|
|
passphrase: str = "",
|
|
cols: int = 80,
|
|
rows: int = 24,
|
|
open_shell: bool = True,
|
|
open_sftp_on_connect: bool = False,
|
|
bastion_target: BastionTarget | None = None,
|
|
bastion_password: str = "",
|
|
parent=None,
|
|
):
|
|
super().__init__(parent)
|
|
self.open_shell = open_shell
|
|
self.open_sftp_on_connect = open_sftp_on_connect
|
|
self.sftp: paramiko.SFTPClient | None = None
|
|
self.host = host
|
|
self.port = port
|
|
self.username = username
|
|
self.auth_type = auth_type
|
|
self.key_path = key_path
|
|
self.password = password
|
|
self.passphrase = passphrase
|
|
self.cols = cols
|
|
self.rows = rows
|
|
self.bastion_target = bastion_target
|
|
self.bastion_password = bastion_password
|
|
|
|
self.client: paramiko.SSHClient | None = None
|
|
self.channel: paramiko.Channel | None = None
|
|
self._bastion_client: paramiko.SSHClient | None = None
|
|
self._stop = threading.Event()
|
|
|
|
self._trust_event = threading.Event()
|
|
self._trust_result = False
|
|
|
|
self._passphrase_event = threading.Event()
|
|
self._passphrase_result: str | None = None
|
|
|
|
self._host_key_change_event = threading.Event()
|
|
self._host_key_change_result = False
|
|
|
|
self._password_event = threading.Event()
|
|
self._password_result: str | None = None
|
|
self.known_hosts = KnownHostsStore(KNOWN_HOSTS_FILE)
|
|
|
|
# File d'envoi + thread dedie : send() est appele directement depuis le thread GUI
|
|
# (une frappe clavier). channel.send() peut bloquer (reseau congestionne, fenetre TCP
|
|
# pleine) ; sans decouplage, ce blocage gele toute l'interface le temps de l'ecriture.
|
|
self._out_queue: queue.Queue[bytes] = queue.Queue()
|
|
self._writer_thread: threading.Thread | None = None
|
|
|
|
# ---------- confiance cle d'hote (appele depuis le thread SSH) ----------
|
|
def _ask_host_key_trust(self, hostname: str, key: paramiko.PKey) -> bool:
|
|
self._trust_event.clear()
|
|
fingerprint = key.get_fingerprint().hex(":")
|
|
self.host_key_prompt.emit(hostname, key.get_name(), fingerprint)
|
|
self._trust_event.wait() # debloque via confirm_host_key() depuis le thread GUI
|
|
return self._trust_result
|
|
|
|
def confirm_host_key(self, accepted: bool) -> None:
|
|
self._trust_result = accepted
|
|
self._trust_event.set()
|
|
|
|
# ---------- cle d'hote modifiee, ex. IP DHCP reattribuee (appele depuis le thread SSH) ----------
|
|
def _ask_host_key_change(self, hostname: str, got_key: paramiko.PKey, expected_key: paramiko.PKey) -> bool:
|
|
self._host_key_change_event.clear()
|
|
old_fp = expected_key.get_fingerprint().hex(":")
|
|
new_fp = got_key.get_fingerprint().hex(":")
|
|
self.host_key_changed_prompt.emit(hostname, got_key.get_name(), old_fp, new_fp)
|
|
self._host_key_change_event.wait() # debloque via confirm_host_key_change() depuis le thread GUI
|
|
return self._host_key_change_result
|
|
|
|
def confirm_host_key_change(self, accepted: bool) -> None:
|
|
self._host_key_change_result = accepted
|
|
self._host_key_change_event.set()
|
|
|
|
# ---------- passphrase de cle privee (appele depuis le thread SSH) ----------
|
|
def _ask_passphrase(self, key_path: str) -> str | None:
|
|
self._passphrase_event.clear()
|
|
self._passphrase_result = None
|
|
self.passphrase_prompt.emit(key_path)
|
|
self._passphrase_event.wait() # debloque via provide_passphrase() depuis le thread GUI
|
|
return self._passphrase_result
|
|
|
|
def provide_passphrase(self, text: str | None) -> None:
|
|
self._passphrase_result = text or None
|
|
self._passphrase_event.set()
|
|
|
|
# ---------- mot de passe demande en secours (appele depuis le thread SSH) ----------
|
|
def _ask_password(self, label: str | None = None) -> str | None:
|
|
self._password_event.clear()
|
|
self._password_result = None
|
|
self.password_prompt.emit(label or f"{self.username}@{self.host}")
|
|
self._password_event.wait() # debloque via provide_password() depuis le thread GUI
|
|
return self._password_result
|
|
|
|
def provide_password(self, text: str | None) -> None:
|
|
self._password_result = text or None
|
|
self._password_event.set()
|
|
|
|
# ---------- connexion (cible ou bastion, meme logique pour les deux) ----------
|
|
def _new_client(self) -> paramiko.SSHClient:
|
|
"""Client SSH pret a etre connecte : cles d'hote systeme + notre known_hosts,
|
|
et confirmation TOFU via cette meme SSHConnection pour une cle inconnue/modifiee."""
|
|
client = paramiko.SSHClient()
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
# Les hotes deja approuves par la commande ssh (~/.ssh/known_hosts) sont
|
|
# reconnus : inutile de reconfirmer une empreinte que votre ssh connait deja.
|
|
# paramiko ne reecrit jamais ce fichier ; les nouvelles cles vont dans le notre.
|
|
try:
|
|
client.load_system_host_keys()
|
|
except Exception:
|
|
pass
|
|
if KNOWN_HOSTS_FILE.exists():
|
|
client.load_host_keys(str(KNOWN_HOSTS_FILE))
|
|
client.set_missing_host_key_policy(TrustPrompt(self))
|
|
return client
|
|
|
|
def _connect_host(
|
|
self, client: paramiko.SSHClient, host: str, port: int, username: str, auth_type: str,
|
|
key_path: str, password: str, passphrase: str, sock=None, label: str | None = None,
|
|
) -> None:
|
|
"""Connecte `client` a host:port, en gerant l'authentification, la demande
|
|
interactive de mot de passe de secours (mode auto) et une cle d'hote modifiee.
|
|
Utilise a l'identique pour la cible et pour un eventuel bastion."""
|
|
label = label or f"{username}@{host}"
|
|
auto = auth_type == "auto"
|
|
connect_kwargs = dict(
|
|
hostname=host,
|
|
port=port,
|
|
username=username,
|
|
timeout=15,
|
|
# en mode auto on se comporte comme la commande ssh : agent SSH
|
|
# et cles par defaut de ~/.ssh, sans rien avoir a designer
|
|
allow_agent=(auto or auth_type == "agent"),
|
|
look_for_keys=auto,
|
|
sock=sock,
|
|
)
|
|
if auth_type == "key":
|
|
if not key_path:
|
|
raise paramiko.SSHException(
|
|
f"{label} : aucune cle privee selectionnee. Choisissez une cle, ou "
|
|
"l'authentification automatique / par mot de passe."
|
|
)
|
|
connect_kwargs["pkey"] = self._load_private_key(key_path, passphrase)
|
|
elif auth_type == "password":
|
|
connect_kwargs["password"] = password
|
|
elif auto and password:
|
|
connect_kwargs["password"] = password
|
|
|
|
try:
|
|
try:
|
|
client.connect(**connect_kwargs)
|
|
except paramiko.AuthenticationException:
|
|
# Mode auto : ni l'agent ni les cles ~/.ssh n'ont convenu,
|
|
# on demande alors un mot de passe plutot que d'echouer.
|
|
if not auto or connect_kwargs.get("password"):
|
|
raise
|
|
asked = self._ask_password(label)
|
|
if not asked:
|
|
raise
|
|
connect_kwargs["password"] = asked
|
|
connect_kwargs["look_for_keys"] = False
|
|
connect_kwargs["allow_agent"] = False
|
|
client.connect(**connect_kwargs)
|
|
except paramiko.BadHostKeyException as exc:
|
|
# La cle presentee par le serveur ne correspond plus a celle enregistree
|
|
# (frequent avec des IP DHCP reattribuees a une autre machine).
|
|
accepted = self._ask_host_key_change(exc.hostname, exc.got_key, exc.expected_key)
|
|
if not accepted:
|
|
raise paramiko.SSHException(
|
|
f"Cle d'hote modifiee refusee pour {exc.hostname} (connexion annulee)."
|
|
) from exc
|
|
self.known_hosts.replace_host_key(exc.hostname, exc.got_key)
|
|
# Corrige aussi la copie en memoire utilisee par ce client pour la reconnexion
|
|
# (load_host_keys() fusionnerait avec l'ancienne entree encore en memoire).
|
|
live_keys = client.get_host_keys()
|
|
if exc.hostname in live_keys:
|
|
del live_keys[exc.hostname]
|
|
live_keys.add(exc.hostname, exc.got_key.get_name(), exc.got_key)
|
|
client.connect(**connect_kwargs)
|
|
|
|
def _open_bastion_channel(self) -> paramiko.Channel:
|
|
"""Connexion au bastion puis canal direct-tcpip vers la cible reelle, a
|
|
passer en `sock=` de la connexion finale (mecanisme ProxyJump standard)."""
|
|
target = self.bastion_target
|
|
bastion_client = self._new_client()
|
|
label = f"{target.label} (bastion)"
|
|
try:
|
|
self._connect_host(
|
|
bastion_client, target.host, target.port, target.username, target.auth_type,
|
|
target.key_path, self.bastion_password, "", label=label,
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
try:
|
|
bastion_client.close()
|
|
except Exception:
|
|
pass
|
|
raise paramiko.SSHException(f"Bastion {target.label} : {exc}") from exc
|
|
|
|
self._bastion_client = bastion_client
|
|
try:
|
|
return bastion_client.get_transport().open_channel(
|
|
"direct-tcpip", (self.host, self.port), ("127.0.0.1", 0),
|
|
)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise paramiko.SSHException(
|
|
f"Bastion {target.label} : impossible d'ouvrir un canal vers "
|
|
f"{self.host}:{self.port} ({exc})"
|
|
) from exc
|
|
|
|
# ---------- cycle de vie du thread ----------
|
|
def run(self) -> None:
|
|
try:
|
|
self.client = self._new_client()
|
|
|
|
sock = self._open_bastion_channel() if self.bastion_target is not None else None
|
|
self._connect_host(
|
|
self.client, self.host, self.port, self.username, self.auth_type,
|
|
self.key_path, self.password, self.passphrase, sock=sock,
|
|
)
|
|
|
|
# Ouvert ici (dans le thread) et non depuis l'interface : open_sftp() fait un
|
|
# aller-retour reseau qui figerait la fenetre s'il etait appele au clic.
|
|
if self.open_sftp_on_connect:
|
|
try:
|
|
self.sftp = self.client.open_sftp()
|
|
self.sftp_ready.emit(self.sftp)
|
|
except Exception:
|
|
# Sans shell, le SFTP est la raison d'etre de la connexion : c'est fatal.
|
|
# Avec un shell, le terminal doit rester utilisable meme si le serveur
|
|
# n'expose pas le sous-systeme SFTP.
|
|
if not self.open_shell:
|
|
raise
|
|
|
|
if not self.open_shell:
|
|
# Connexion dediee au SFTP : on garde la session vivante sans shell.
|
|
self.connected.emit()
|
|
while not self._stop.is_set():
|
|
self._stop.wait(0.2)
|
|
self.disconnected.emit("Connexion fermee")
|
|
return
|
|
|
|
self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows)
|
|
# La taille reelle du widget arrive souvent apres la creation de la connexion :
|
|
# on reapplique la derniere taille demandee, sinon le shell distant reste en
|
|
# 80x24 alors que l'affichage est plus grand (retours a la ligne incoherents).
|
|
try:
|
|
self.channel.resize_pty(width=self.cols, height=self.rows)
|
|
except Exception:
|
|
pass
|
|
self.channel.settimeout(0.5)
|
|
self._writer_thread = threading.Thread(target=self._writer_loop, daemon=True)
|
|
self._writer_thread.start()
|
|
self.connected.emit()
|
|
|
|
while not self._stop.is_set():
|
|
try:
|
|
data = self.channel.recv(4096)
|
|
if not data:
|
|
break
|
|
self.data_received.emit(data)
|
|
except TimeoutError:
|
|
continue
|
|
except Exception:
|
|
break
|
|
|
|
self.disconnected.emit("Connexion fermee")
|
|
except paramiko.AuthenticationException:
|
|
self.error.emit("Authentification refusee (identifiants ou cle invalides).")
|
|
except Exception as exc: # noqa: BLE001
|
|
self.error.emit(str(exc))
|
|
finally:
|
|
self._cleanup()
|
|
|
|
def _load_private_key(self, key_path: str, passphrase: str) -> paramiko.PKey:
|
|
last_exc = None
|
|
for loader in (paramiko.Ed25519Key, paramiko.ECDSAKey, paramiko.RSAKey):
|
|
try:
|
|
return loader.from_private_key_file(key_path, password=passphrase or None)
|
|
except paramiko.PasswordRequiredException:
|
|
pwd = passphrase or self._ask_passphrase(key_path)
|
|
if not pwd:
|
|
raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle")
|
|
try:
|
|
return loader.from_private_key_file(key_path, password=pwd)
|
|
except paramiko.SSHException as exc:
|
|
last_exc = exc
|
|
continue
|
|
except paramiko.SSHException as exc:
|
|
last_exc = exc
|
|
continue
|
|
raise last_exc or paramiko.SSHException("Format de cle non reconnu")
|
|
|
|
def send(self, data: bytes) -> None:
|
|
"""Appele depuis le thread GUI (frappe clavier) : ne doit jamais bloquer."""
|
|
self._out_queue.put(data)
|
|
|
|
def _writer_loop(self) -> None:
|
|
"""Tourne dans son propre thread : effectue les ecritures reseau potentiellement bloquantes.
|
|
|
|
Une erreur d'ecriture ponctuelle ne doit pas tuer le thread : sinon, toute frappe
|
|
suivante serait silencieusement perdue pour le reste de la session (le canal
|
|
restant lui-meme ouvert). On n'arrete la boucle que si le canal est ferme."""
|
|
while not self._stop.is_set():
|
|
try:
|
|
data = self._out_queue.get(timeout=0.2)
|
|
except queue.Empty:
|
|
continue
|
|
if not (self.channel and not self.channel.closed):
|
|
continue
|
|
try:
|
|
self.channel.send(data)
|
|
except OSError:
|
|
if not self.channel or self.channel.closed:
|
|
break
|
|
|
|
def resize(self, cols: int, rows: int) -> None:
|
|
self.cols, self.rows = cols, rows
|
|
if self.channel and not self.channel.closed:
|
|
try:
|
|
self.channel.resize_pty(width=cols, height=rows)
|
|
except Exception:
|
|
pass
|
|
|
|
def open_sftp(self) -> paramiko.SFTPClient | None:
|
|
if self.client:
|
|
return self.client.open_sftp()
|
|
return None
|
|
|
|
def close_connection(self) -> None:
|
|
self._stop.set()
|
|
self._trust_event.set() # au cas ou on serait bloque sur une confirmation
|
|
self._passphrase_event.set()
|
|
self._host_key_change_event.set()
|
|
self._password_event.set()
|
|
self._cleanup()
|
|
|
|
def _cleanup(self) -> None:
|
|
try:
|
|
if self.channel:
|
|
self.channel.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if self.client:
|
|
self.client.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if self._bastion_client:
|
|
self._bastion_client.close()
|
|
except Exception:
|
|
pass
|