Tags : une session peut porter plusieurs tags libres (Session.tags), saisis comme une liste separee par des virgules dans l'editeur (normalises : espaces retires, doublons insensibles a la casse elimines), avec autocompletion sur les tags deja utilises. Affiches dans l'arbre des sessions, et filtrables via un champ dedie au-dessus de l'arbre (masque les sessions ne correspondant pas, sans toucher aux dossiers/terminaux locaux/hosts OpenSSH). Parametres de groupe : choix architectural deliberement plus simple qu'une resolution dynamique a la connexion. app/groups.py (GroupSettingsStore, meme pattern que les autres stores) associe un dossier (Session.group) a des valeurs par defaut (utilisateur, port, authentification, cle, bastion, keepalive). Ces valeurs ne s'appliquent qu'au moment de la creation d'une session, via un bouton explicite "↓ Heriter du groupe" dans SessionDialog - jamais de resolution automatique/retroactive. Ce choix evite d'introduire des valeurs sentinelles "herite vs. explicite" sur chaque champ (port=22 est a la fois une valeur par defaut ET une vraie valeur possible, par exemple) dans tout le reste du code (ConnectionController, SSHConnection...), pour une fonctionnalite que la priorisation du projet place parmi les secondaires. Une session reste ensuite un ensemble de valeurs normales, librement modifiables - "surcharger une valeur heritee" est donc trivialement satisfait. Clic droit sur un dossier dans le panneau lateral : "Nouvelle session dans ce dossier..." (pre-remplit le champ Dossier) et "Parametres du groupe..." (GroupSettingsDialog). tests/test_tags_and_groups.py : persistance des tags, CRUD GroupSettingsStore, normalisation de la saisie des tags, heritage applique/absent, filtrage dans l'arbre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
149 lines
5.2 KiB
Python
149 lines
5.2 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)
|
|
|
|
# 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"]
|
|
|
|
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)
|