Files
pyssh-client/app/session_manager.py
Florian 9216f636bd feat: ajouter le Credential Vault (memorisation securisee optionnelle)
Par defaut, inchange : les mots de passe sont toujours redemandes a chaque
connexion. Nouvelle case a cocher optionnelle dans l'editeur de session,
visible seulement en authentification par mot de passe : "Memoriser le mot
de passe de maniere securisee".

- app/credentials.py : abstraction CredentialStore (set_secret/get_secret/
  delete_secret/is_available), pour supporter plus tard d'autres systemes que
  Windows. WindowsCredentialStore utilise Windows Credential Manager
  (CredWrite/CredRead/CredDelete) via pywin32-ctypes - deja present dans
  l'environnement (dependance de PyInstaller), mais pas encore declare comme
  dependance d'execution : ajoute a requirements.txt (marque sys_platform ==
  "win32"), sinon la fonctionnalite aurait silencieusement ete indisponible
  sur une installation propre. NullCredentialStore en repli si indisponible :
  jamais d'ecriture en clair a la place.
- Session.password_storage ("none" par defaut | "secure") : seulement le mode
  est stocke dans sessions.json, jamais le secret. Le mot de passe est ecrit/
  lu dans le trousseau via une cle applicative (session:<id>), et supprime
  automatiquement si l'utilisateur desactive la memorisation ou supprime la
  session.
- Un bastion en mode "session PySSH" reutilise un mot de passe deja memorise
  pour cette session (lecture seule depuis ce chemin : l'ecriture ne se fait
  que via l'ouverture directe de la session concernee).

tests/test_credentials.py : NullCredentialStore, aller-retour reel avec
Windows Credential Manager (nettoye et verifie apres coup, aucune entree
residuelle), integration ConnectionController (memorise puis reutilise sans
redemander), suppression en cascade, reutilisation par un bastion, et
verification que le secret n'apparait jamais dans la serialisation JSON de
la session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:21:32 +02:00

159 lines
5.7 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"
# Memorisation du mot de passe : jamais dans sessions.json (voir app.credentials),
# uniquement dans le trousseau securise du systeme, et seulement si demande.
PASSWORD_STORAGE_NONE = "none"
PASSWORD_STORAGE_SECURE = "secure"
# 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)
# Keepalive : sans risque pour une session existante (n'envoie que des
# paquets de maintien de connexion), active par defaut.
keepalive_enabled: bool = True
keepalive_interval: int = 30
keepalive_count_max: int = 3
# Reconnexion automatique : change reellement le comportement en cas de
# coupure, donc desactivee par defaut pour ne pas surprendre les sessions
# existantes ; a activer explicitement.
reconnect_enabled: bool = False
reconnect_initial_delay: int = 5
reconnect_max_attempts: int = 10 # 0 = illimite
tags: list[str] = field(default_factory=list) # ex: ["prod", "docker"]
# "none" (defaut) : jamais memorise, redemande a chaque connexion.
# "secure" : memorise dans le trousseau securise du systeme (voir app.credentials) -
# jamais dans ce fichier JSON.
password_storage: str = PASSWORD_STORAGE_NONE
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 get(self, session_id: str) -> Session | None:
return next((s for s in self.sessions if s.id == session_id), None)
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})
def tags(self) -> list[str]:
all_tags: set[str] = set()
for s in self.sessions:
all_tags.update(s.tags)
return sorted(all_tags, key=str.lower)