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>
75 lines
3.0 KiB
Python
75 lines
3.0 KiB
Python
"""Resolution d'un BastionConfig (session/openssh/manuel) en parametres de
|
|
connexion concrets, independamment de la source choisie dans l'editeur.
|
|
|
|
La connexion elle-meme (ouverture du canal direct-tcpip a travers le bastion)
|
|
est geree par SSHConnection (app.ssh_connection), qui consomme un BastionTarget
|
|
sans se soucier de sa provenance.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from app.session_manager import (
|
|
SessionStore, BastionConfig,
|
|
BASTION_NONE, BASTION_SESSION, BASTION_OPENSSH, BASTION_MANUAL,
|
|
AUTH_AUTO, AUTH_KEY,
|
|
)
|
|
|
|
|
|
class BastionResolutionError(Exception):
|
|
"""Le bastion configure ne peut pas etre resolu (reference cassee, config
|
|
invalide...). Empeche de lancer une connexion vouee a l'echec."""
|
|
|
|
|
|
@dataclass
|
|
class BastionTarget:
|
|
"""Parametres de connexion resolus pour le saut a franchir avant la cible."""
|
|
host: str
|
|
port: int
|
|
username: str
|
|
auth_type: str
|
|
key_path: str = ""
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return f"{self.username}@{self.host}" if self.username else self.host
|
|
|
|
|
|
def resolve_bastion_target(
|
|
bastion: BastionConfig | None, session_store: SessionStore | None = None,
|
|
) -> BastionTarget | None:
|
|
"""Renvoie None si aucun bastion n'est configure. Leve BastionResolutionError
|
|
si le mode choisi ne peut pas etre resolu en ce moment (session supprimee,
|
|
alias OpenSSH introuvable, config manuelle incomplete...)."""
|
|
if bastion is None or bastion.mode in (BASTION_NONE, ""):
|
|
return None
|
|
|
|
if bastion.mode == BASTION_MANUAL:
|
|
if not bastion.host.strip():
|
|
raise BastionResolutionError("Bastion manuel : aucune adresse renseignee.")
|
|
return BastionTarget(bastion.host.strip(), bastion.port, bastion.username, bastion.auth_type, bastion.key_path)
|
|
|
|
if bastion.mode == BASTION_SESSION:
|
|
if session_store is None:
|
|
raise BastionResolutionError("Bastion vers une session PySSH : aucun registre de sessions disponible.")
|
|
target = next((s for s in session_store.sessions if s.id == bastion.session_id), None)
|
|
if target is None:
|
|
raise BastionResolutionError(
|
|
"La session utilisee comme bastion est introuvable (supprimee ou renommee)."
|
|
)
|
|
return BastionTarget(target.host, target.port, target.username, target.auth_type, target.key_path)
|
|
|
|
if bastion.mode == BASTION_OPENSSH:
|
|
from app.openssh.config import load_openssh_hosts
|
|
|
|
host = next((h for h in load_openssh_hosts() if h.alias == bastion.openssh_alias), None)
|
|
if host is None:
|
|
raise BastionResolutionError(
|
|
f"Host OpenSSH « {bastion.openssh_alias} » introuvable dans ~/.ssh/config."
|
|
)
|
|
key_path = host.identity_files[0] if host.identity_files else ""
|
|
auth_type = AUTH_KEY if key_path else AUTH_AUTO
|
|
return BastionTarget(host.hostname or host.alias, host.port, host.user, auth_type, key_path)
|
|
|
|
raise BastionResolutionError(f"Mode de bastion inconnu : {bastion.mode}")
|