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>
125 lines
4.3 KiB
Python
125 lines
4.3 KiB
Python
"""Stockage des sessions SSH (serveurs enregistres) dans un fichier JSON local."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from dataclasses import dataclass, field, asdict
|
|
from pathlib import Path
|
|
|
|
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
|
|
from app.storage.json_store import load_json_document, save_json_document
|
|
|
|
SESSIONS_FILE = DATA_DIR / "sessions.json"
|
|
SCHEMA_VERSION = 1
|
|
|
|
AUTH_AUTO = "auto" # comme la commande ssh : agent + cles ~/.ssh, mot de passe en secours
|
|
AUTH_KEY = "key" # cle privee explicitement choisie
|
|
AUTH_PASSWORD = "password"
|
|
AUTH_AGENT = "agent"
|
|
|
|
# Sources possibles d'un jump host (ProxyJump / bastion) - voir BastionConfig.
|
|
BASTION_NONE = "none" # connexion directe, sans bastion
|
|
BASTION_SESSION = "session" # reutilise une session PySSH existante (par id)
|
|
BASTION_OPENSSH = "openssh" # reutilise un Host de ~/.ssh/config (par alias)
|
|
BASTION_MANUAL = "manual" # parametres saisis directement
|
|
|
|
|
|
@dataclass
|
|
class BastionConfig:
|
|
"""Jump host d'une session. Un seul saut pour l'instant (mode != "chain"),
|
|
mais rien n'empeche d'ajouter plus tard une liste de sauts a la place d'un
|
|
bastion unique : le mecanisme de resolution (app.bastion) traite deja ceci
|
|
comme "le saut a franchir avant la cible", independamment du nombre total.
|
|
|
|
Jamais de mot de passe stocke ici : comme pour Session.auth_type, un bastion
|
|
en mode mot de passe le redemande a la connexion."""
|
|
mode: str = BASTION_NONE
|
|
session_id: str = "" # mode == BASTION_SESSION
|
|
openssh_alias: str = "" # mode == BASTION_OPENSSH
|
|
host: str = "" # mode == BASTION_MANUAL
|
|
port: int = 22
|
|
username: str = ""
|
|
auth_type: str = AUTH_AUTO
|
|
key_path: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
@staticmethod
|
|
def from_dict(data: dict) -> "BastionConfig":
|
|
b = BastionConfig()
|
|
for k in BastionConfig.__dataclass_fields__:
|
|
if k in data:
|
|
setattr(b, k, data[k])
|
|
return b
|
|
|
|
|
|
@dataclass
|
|
class Session:
|
|
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
name: str = ""
|
|
group: str = "" # ex: "Prod/Web" -- sert a construire l'arborescence de dossiers
|
|
host: str = ""
|
|
port: int = 22
|
|
username: str = ""
|
|
auth_type: str = AUTH_AUTO # auto | key | password | agent
|
|
key_path: str = ""
|
|
notes: str = ""
|
|
bastion: BastionConfig = field(default_factory=BastionConfig)
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
@staticmethod
|
|
def from_dict(data: dict) -> "Session":
|
|
known = {f: data.get(f) for f in Session.__dataclass_fields__ if f in data}
|
|
s = Session()
|
|
for k, v in known.items():
|
|
if k == "bastion" and isinstance(v, dict):
|
|
v = BastionConfig.from_dict(v)
|
|
setattr(s, k, v)
|
|
return s
|
|
|
|
|
|
class SessionStore:
|
|
"""Charge/sauvegarde la liste des sessions depuis data/sessions.json."""
|
|
|
|
def __init__(self, path: Path = SESSIONS_FILE):
|
|
self.path = path
|
|
self.sessions: list[Session] = []
|
|
self.load_warning: str | None = None
|
|
self.load()
|
|
|
|
def load(self) -> None:
|
|
was_missing = not self.path.exists()
|
|
document, warning = load_json_document(
|
|
self.path,
|
|
default={"schema_version": SCHEMA_VERSION, "sessions": []},
|
|
current_version=SCHEMA_VERSION,
|
|
)
|
|
self.load_warning = warning
|
|
self.sessions = [Session.from_dict(s) for s in document.get("sessions", [])]
|
|
if was_missing:
|
|
self.save()
|
|
|
|
def save(self) -> None:
|
|
payload = {"schema_version": SCHEMA_VERSION, "sessions": [s.to_dict() for s in self.sessions]}
|
|
save_json_document(self.path, payload)
|
|
|
|
def add(self, session: Session) -> None:
|
|
self.sessions.append(session)
|
|
self.save()
|
|
|
|
def update(self, session: Session) -> None:
|
|
for i, s in enumerate(self.sessions):
|
|
if s.id == session.id:
|
|
self.sessions[i] = session
|
|
break
|
|
self.save()
|
|
|
|
def delete(self, session_id: str) -> None:
|
|
self.sessions = [s for s in self.sessions if s.id != session_id]
|
|
self.save()
|
|
|
|
def groups(self) -> list[str]:
|
|
return sorted({s.group for s in self.sessions if s.group})
|