Compare commits

...

24 Commits

Author SHA1 Message Date
Florian
aa890c5847 SFTP sur cette connexion : ne plus perdre la cible ssh detectee
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur avec une capture d'ecran : bouton "SFTP sur cette
connexion" clique depuis un terminal local visiblement connecte en ssh
(prompt root@debian:~#), formulaire de connexion vide a l'arrivee - "si je
dois remplir le formulaire pour une session actuelle ca perd tout son sens".

Cause reelle : detect_ssh_target() ne scrutait le texte du terminal qu'au
moment du clic (buffer visible + historique pyte, 5000 lignes). Si la ligne
`ssh user@hote` tapee par l'utilisateur n'y figure plus au moment du clic
(beaucoup de sortie depuis, ecran efface...), plus rien n'est detectable -
alors meme que la connexion ssh est toujours active. La capture montree
correspond exactement a ce cas : un ecran efface apres la connexion.

Corrige a la racine plutot qu'en agrandissant simplement l'historique
scrute : app/ui/tabs.py (BaseTerminalTab) memorise desormais la cible des
qu'une commande ssh apparait dans la sortie recue (_track_ssh_target,
cablee comme terminal.feed sur data_received), et ne l'oublie plus jamais -
detect_current_ssh_target() la renvoie en priorite, avec un dernier examen du
buffer actuel seulement en repli. Le bouton "SFTP sur cette connexion" et le
raccourci global Ctrl+Shift+F (ConnectionController._current_tab_ssh_target)
utilisent tous les deux ce nouveau point d'entree unique.

tests/test_ssh_target_tracking.py (4 verifications, nouveau fichier - ce
bouton n'avait encore aucun test dedie) : memorisation a la frappe, cible
toujours renvoyee meme quand le buffer live ne la contient plus, mise a jour
vers une commande ssh plus recente, bouton emettant bien la cible memorisee.
Suite complete revalidee (36/36), ruff propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 09:11:35 +02:00
Florian
ef059647fa Reduire la taille du formulaire de session (connexion rapide SFTP/SSH)
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur : le dialogue de "SFTP sur cette connexion" ne
tenait pas sur l'ecran. Deux causes distinctes, verifiees par capture d'ecran
avant de conclure (rendu hors-ecran + grab()) :

1. Bug reel, dans les deux dialogues qui ont une section Bastion (SessionDialog
   et GroupSettingsDialog) : leur QStackedWidget reservait par defaut la
   hauteur de sa PLUS GRANDE page (Configuration manuelle, 5 champs) meme
   quand la page vide "Aucun" etait affichee - environ 200px de vide pur,
   visible sur la premiere capture. Nouveau app/ui/auto_height_stack.py
   (AutoHeightStackedWidget) : le sizeHint suit la page COURANTE, pas la plus
   grande de toutes: -186px des l'ouverture, a bastion desactive.

2. Choix d'ergonomie : une connexion rapide sert a aller vite (souvent un
   serveur de test en DHCP, cf. le README) - Bastion/Keepalive/Reconnexion y
   sont maintenant repliees par defaut derriere un bouton "Options
   avancees...", toujours accessibles mais qui n'allongent plus le dialogue
   pour le cas courant. Se deplient automatiquement si "Heriter du groupe"
   ramene un bastion herite, pour ne pas cacher un changement silencieux.
   Le dialogue complet ("Nouvelle session" depuis le panneau lateral) est
   inchange : tout reste visible d'emblee, c'est une operation reflechie, pas
   une connexion a la volee.

Resultat mesure : dialogue de connexion rapide 1026px -> 492px replie (-52%),
876px si depliees (au lieu de 1026, meme contenu, sans le vide). Le dialogue
"Nouvelle session" complet passe de meme a 816px grace au seul correctif (1).

tests/test_session_dialog_compact.py (5 verifications) : sizeHint de
AutoHeightStackedWidget sur la page courante, repli par defaut en connexion
rapide, redimensionnement au depli, absence de repli hors connexion rapide,
depli automatique sur heritage de groupe. Suite complete revalidee (35/35),
ruff propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:54:31 +02:00
Florian
a65b86e25b Assistant IA : se rabattre sur l'ecran visible si rien n'est selectionne
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur : "Expliquer cette sortie" n'avait aucun effet
utilisable, le menu restait grise. Cause reelle : ces actions exigeaient une
selection prealable dans le terminal, alors que l'attente naturelle ("explique
ce que j'ai a l'ecran") est de pouvoir les utiliser directement sur ce qui est
affiche - confirme avec l'utilisateur avant d'implementer (deux options
proposees : rabattre sur l'ecran visible, ou juste clarifier le blocage).

app/terminal_widget.py : nouvelle methode visible_text() (l'ecran actuellement
affiche, sans l'historique de scroll - au contraire de buffer_text() qui sert
a la detection de commande ssh et remonte l'historique proche).

app/ui/tabs.py : Expliquer/Diagnostiquer/Proposer une solution/Poser une
question utilisent desormais _ai_source_text() = la selection si elle existe
(pour cibler precisement), sinon l'ecran visible. Les 3 premieres actions du
menu ne sont plus desactivees des qu'il n'y a pas de selection, mais
seulement si vraiment rien n'est affiche ; "Poser une question" reste
utilisable meme un terminal vide (contexte optionnel, comme le bouton
"Nouvelle question" du panneau).

tests/test_ai_tab_integration.py : reecriture des tests 1 et 2 (etats
d'activation du menu) + 2 nouveaux tests verifiant explicitement le repli
(sans selection, le texte envoye est bien celui de l'ecran) et sa priorite
inverse (une selection existante prime toujours sur l'ecran visible).
tests/test_clipboard.py : couverture directe de visible_text() (ecran complet,
lignes vides de fin retirees). Suite complete revalidee (34/34), ruff propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:37:05 +02:00
Florian
add1073e37 Corriger la fleche invisible des menus deroulants (QComboBox)
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Signale par l'utilisateur sur le nouveau champ "Fournisseur" de l'assistant
IA : aucune fleche visible, indiscernable d'un simple champ de texte - alors
que la liste s'ouvre bien au clic (le bug est purement visuel).

Cause : QComboBox::drop-down { border: none; width: 22px; } dans theme.qss
suffit a faire que Fusion (le style applique a toute l'application) arrete de
dessiner sa fleche par defaut. Ce n'est pas specifique au nouveau champ : tous
les QComboBox de l'application (authentification, cle SSH, categorie de
snippet, filtre de tags...) etaient concernes de la meme facon.

Tente d'abord de redessiner la fleche en QSS pur (triangle via des bordures) :
verifie visuellement (rendu hors-ecran + capture d'image) avant de conclure -
rendu un simple rectangle plein, pas un triangle, ce style ne supporte pas
cette technique correctement. Solution retenue : supprimer entierement la
regle ::drop-down plutot que de la remplacer, ce qui laisse Fusion dessiner
nativement sa propre fleche - verifie sur les 3 themes (clair/bleu nuit/noir),
lisible et bien contrastee partout sans configuration supplementaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:16:36 +02:00
Florian
f5d99584fd Assistant IA : liste deroulante de fournisseurs (dont Ollama, LM Studio)
Some checks failed
CI / lint (push) Has been cancelled
CI / tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
Retour utilisateur : le champ "Fournisseur" en simple texte libre ne donnait
aucune indication de ce qui est possible, notamment pour l'IA locale
(Ollama, LM Studio), alors que d'autres preferent un service cloud.

Remplace par une QComboBox editable : une liste de raccourcis usuels (OpenAI,
OpenRouter, Mistral AI, Ollama local, LM Studio local, Personnalise...) qui
pre-remplit l'URL de base au choix - sans jamais empecher de saisir librement
un autre fournisseur/proxy compatible OpenAI, conformement a l'exigence
d'origine de ne rien figer en dur. Le modele reste toujours en saisie libre,
chaque installation Ollama/LM Studio ayant ses propres modeles telecharges.

Bonus cible : choisir un prereglage local (Ollama/LM Studio, detecte via
localhost/127.0.0.1 dans l'URL) met a jour l'indication du champ cle API pour
signaler qu'elle n'est generalement pas necessaire.

tests/test_ai_settings_dialog.py (8 verifications, nouveau fichier - aucun
test ne couvrait encore AISettingsDialog elle-meme) : presence d'Ollama/LM
Studio dans la liste, pre-remplissage de l'URL par prereglage, "Personnalise"
qui ne touche pas une URL deja saisie, persistance d'un nom de fournisseur
personnalise a travers un enregistrement puis une reouverture. Suite complete
revalidee (34/34) et ruff toujours propre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 08:00:25 +02:00
Florian
6cceb3f3b2 README : couvrir les 14 phases precedentes, mettre en avant la confidentialite
Le README ne decrivait encore que les toutes premieres fonctionnalites
(sessions, tunnels -L/-R, SFTP, terminaux locaux, cles, known_hosts) : rien
sur ~/.ssh/config, ProxyJump, le SOCKS dynamique, le keepalive/reconnexion
automatique, le terminal divise, les workspaces, les snippets, les tags/
groupes, le coffre-fort d'identifiants ni l'assistant IA.

Reecriture complete en gardant le ton et la structure d'origine (description
concrete, raccourcis clavier, "pourquoi" plutot que juste "quoi") :
  - Nouveau chapeau explicite : aucun compte cloud, aucune telemetrie, les
    identifiants restent locaux, l'IA est entierement optionnelle.
  - Fonctionnalites reorganisees par theme (terminal / connexion et auth /
    organisation / SFTP / cles et secrets / assistant IA), chaque nouvelle
    fonctionnalite decrite avec le menu ou bouton exact pour y acceder -
    verifie contre le code plutot que suppose.
  - "Structure du projet" reecrite pour refleter l'arborescence actuelle
    (controllers/, ui/, services/, storage/, security/, ai/, openssh/...).
  - "Tests" et nouvelle section "Qualite" mis a jour pour pytest/ruff/pyright/
    CI (Phases 13-14), en gardant la possibilite de lancer un script seul.
  - "Limites connues" mise a jour avec les limites reelles actuelles
    (ProxyJump a un seul rebond, coffre-fort Windows uniquement, socle de
    typage pyright non resorbe) plutot que celles, resolues depuis, de la
    version initiale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:50:24 +02:00
Florian
c39ef9c038 Qualite : pyproject.toml (ruff/pytest/pyright) + CI Gitea (lint/tests/build)
pyproject.toml devient le point d'entree unique de configuration des outils
(pytest.ini supprime, sa config y est deplacee). Les dependances restent dans
requirements.txt/requirements-dev.txt (deja utilises par setup.bat) plutot que
dupliquees ici : ce projet se distribue par PyInstaller, pas par "pip install".

ruff : configure puis passe sur tout app/ + tests/ (154 corrections
automatiques, purement mecaniques - tri des imports, imports multiples sur une
ligne separes, alias depreciees) suivies de 7 corrections manuelles ciblees :
  - app/ssh_connection.py : `raise ... from None` pour le refus explicite de
    passphrase (ce n'est pas un echec a diagnostiquer).
  - app/controllers/workspace_manager.py : zip() sans strict= documente en
    commentaire (deliberement tolerant a un workspace legerement perime).
  - tests/test_ai_redaction.py : nom de variable ambigu renomme.
  - E702 (construction compacte "widget = X(); widget.setY(...)" dans
    sftp_panel.py/tunnel_dialog.py) et E402 (quelques tests qui importent un
    symbole juste avant la section qui l'utilise) : ignores explicitement, ce
    sont des styles deliberes preexistants, pas des oublis.
`ruff check app main.py tests` est desormais propre.

pyright : mode "basic" (le code n'a jamais ete type). Plus de 200 signalements
sur ~300 etaient le meme faux positif connu de l'ecosysteme PySide6 (acces
plat aux enums Qt, ex. Qt.UserRole, valide a l'execution mais que les stubs ne
reconnaissent que sous leur forme imbriquee) : desactive avec justification en
commentaire. Reste un socle de 77 signalements reels (surtout des acces
Optional non filtres dans app/ui/tabs.py et app/ssh_connection.py) : non
corrige dans cette phase - resserrer le typage la ou pyright a raison est un
travail a part, pas une consequence automatique de l'ajout de l'outil. La CI
le lance en mode informatif (continue-on-error) tant que ce socle n'est pas
resorbe.

.gitea/workflows/ci.yml : lint (ruff, bloquant), tests (pytest, bloquant),
typecheck (pyright, informatif), build (PyInstaller, sur main uniquement,
artefact du dossier portable). Necessite un runner Gitea Actions Windows
enregistre - PySide6/pywinpty/Credential Manager/ACL NTFS sont Windows
uniquement, comme l'application elle-meme ; non verifiable depuis ce poste.

Suite complete revalidee apres coup (33/33) pour confirmer qu'aucune des 154
corrections automatiques n'a modifie de comportement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:46:50 +02:00
Florian
09b7ed092c Migration de la suite de tests vers pytest, sans reecrire les scripts existants
pytest + pytest-qt (requirements-dev.txt) deviennent le point d'entree unique
de la suite ("pytest" depuis la racine), sans toucher aux 30+ scripts
test_*.py existants (convention deliberement conservee - voir tests/conftest.py) :
tests/test_all_scripts.py les lance chacun comme sous-processus isole (comme
c'etait deja fait manuellement a chaque phase), avec QT_QPA_PLATFORM=offscreen
pour tourner sans affichage (verifie : la suite complete passe a l'identique
en mode hors-ecran, prerequis pour une future integration continue).

tests/run_all.bat corrige au passage : il ne lancait plus que 4 tests sur 30+
(liste figee, jamais mise a jour au fil des phases) ; il delegue maintenant a
pytest.

Audit de couverture (Phase 13 du plan) : fingerprint, stockage atomique,
migration de schema, config OpenSSH, ProxyJump, CredentialStore, redaction de
secrets et detection de commandes dangereuses etaient deja couverts au fil des
phases precedentes. Seul point manquant identifie : aucun test ne verifiait
qu'une cle API IA ne puisse fuiter dans un message d'erreur - ajoute dans
tests/test_no_secrets_in_errors.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 07:32:55 +02:00
Florian
0b2ff38bfd Assistant IA optionnel (analyse de sortie, diagnostic, generation de commande)
Nouvelle couche app/ai/ (provider OpenAI-compatible via urllib, redaction de
secrets, detection de commandes dangereuses, parametres, worker QThread) et son
integration dans chaque onglet terminal via un bouton "Assistant IA" (memes
conventions que le bouton Snippets existant) :

- Expliquer cette sortie / Diagnostiquer cette erreur / Proposer une solution /
  Poser une question... : utilisent la selection courante du terminal, affichees
  dans un panneau lateral (memoire uniquement, jamais journalise) reutilisant le
  splitter comme le panneau SFTP.
- Generer une commande : ne s'execute jamais automatiquement (Copier / Inserer
  dans le terminal sans Entree / Annuler), avec avertissement si la commande
  proposee correspond a un motif destructeur connu.
- Avant tout envoi : avis unique (persist) puis detection de secrets evidents
  (mot de passe, cle, jeton) avec choix de masquer, envoyer tel quel ou annuler.

Desactive par defaut, sans liste de fournisseur figee (URL + modele libres,
compatible OpenAI/OpenRouter/LLM local). La cle API passe exclusivement par
CredentialStore (jamais dans settings.json ni journalisee). Parametrable via
le nouveau menu "Assistant IA > Parametres..." (bouton "Tester la connexion").

Bug trouve par les tests avant commit : un panneau nouvellement insere dans un
QSplitter deja visible est affiche automatiquement par Qt, ce qui inversait le
premier clic sur "Afficher le panneau IA" ; corrige en le masquant explicitement
a sa creation.

9 nouveaux fichiers de test (provider, redaction, dangers, parametres, worker,
confirmations de securite, dialogue de commande, panneau, integration onglet) :
39 verifications, toutes au vert. Suite complete (31 fichiers) executee sans
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 05:48:13 +02:00
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
Florian
b615ed52af feat: ajouter les tags de session et les parametres par defaut de groupe
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>
2026-09-05 03:10:01 +02:00
Florian
ff4a73e6e6 feat: ajouter la recherche rapide Ctrl+K (Quick Connect)
Palette globale recherchant a travers sessions, hosts OpenSSH, workspaces,
snippets et actions courantes, avec recherche floue et navigation
entierement au clavier (fleches, Entree, Echap).

- app/services/fuzzy.py : correspondance par sous-sequence (comme fzf, sans
  dependance externe), score favorisant les caracteres consecutifs et les
  debuts de mot.
- QuickConnectDialog : collecte les resultats depuis les stores/controleurs
  deja existants (aucune duplication de logique - activer une session appelle
  ConnectionController.open_session(), un workspace WorkspaceManager.restore(),
  un snippet insert_snippet() dans l'onglet courant, sans jamais l'executer
  automatiquement). Les actions (Nouvelle session, Gestionnaires...) reutilisent
  les methodes des controleurs/dialogues existants.
- Ctrl+K (menu Fichier > Recherche rapide...) ouvre la palette, reconstruite
  a chaque ouverture pour refleter l'etat courant.
- insert_snippet()/execute_snippet() rendus publics sur BaseTerminalTab
  (utilises depuis un autre module par la palette).

tests/test_fuzzy.py (scoring, tri par pertinence) et test_quick_connect.py
(collecte des 4 types de resultats + actions, filtrage, navigation clavier,
activation reelle d'une session et d'un snippet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 03:00:07 +02:00
Florian
0a07ddcb62 feat: ajouter les Snippets de commandes
Bibliotheque de commandes organisee par categories (Linux/Docker/Proxmox
fournis en exemples au premier lancement, librement modifiables), avec
recherche.

- app/snippets.py : Snippet/SnippetStore, meme pattern que les autres stores
  (JSON atomique, schema_version).
- SnippetManagerDialog : creer/modifier/supprimer/rechercher, regroupe par
  categorie.
- Integration terminal : le dernier commit du depot a delibrement fait du
  clic droit un raccourci de collage (style terminal Unix), sans menu
  contextuel. Plutot que d'ecraser ce choix recent, les snippets passent par
  un bouton dedie "📋 Snippets" dans la barre de l'onglet (SessionTabWidget et
  LocalTerminalTabWidget), avec un menu Categorie -> Snippet -> Action
  (Inserer dans le terminal / Executer / Executer avec Multi-Send).
  "Inserer" n'envoie jamais Entree (le texte est ecrit, a l'utilisateur de
  valider) ; seul "Executer" l'ajoute.
- BroadcastManager.broadcast_now() : diffusion explicite et ponctuelle vers
  tous les terminaux, independante de l'etat du bouton Multi (utilisee par
  "Executer avec Multi-Send", qui est une action volontaire et pas une frappe
  clavier a relayer).
- La construction du menu (_build_snippets_menu) est separee de son
  affichage (_show_snippets_menu) : testable sans declencher l'appel
  bloquant menu.exec().

tests/test_snippets.py : stockage (CRUD, recherche, categories, persistance)
et integration terminal (inserer/executer/multi-send, construction et
declenchement du menu), avec LocalTerminalConnection.start() neutralisee.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:55:10 +02:00
Florian
a3c72ade0b feat: ajouter les Workspaces (enregistrer/restaurer l'etat des onglets)
Un workspace capture les onglets ouverts (sessions SSH, terminaux locaux,
splits avec leurs tailles) et les restaure plus tard. Ne reference les
sessions que par id : aucun mot de passe stocke, redemande a la restauration
exactement comme une ouverture normale.

- app/workspace.py : Workspace/WorkspaceTab/WorkspaceItem + WorkspaceStore
  (meme pattern que SessionStore/KeyManager : JSON atomique, schema_version).
- app/controllers/workspace_manager.py : capture() lit l'etat reel des onglets
  de ConnectionController (types de widgets, layout et tailles d'un
  SplitContainer) ; restore() rouvre chaque onglet via
  ConnectionController.build_session_tab()/build_sftp_tab()/new_local_terminal()
  (factorises hors de open_session()/open_sftp_session() pour etre reutilisables
  ici) puis reconstruit les splits avec split_tab(). Un element non restaurable
  (session supprimee depuis) est signale (compteur "ignores") plutot que de
  faire echouer toute la restauration.
- WorkspaceDialog : creer / enregistrer (ecraser) / ouvrir / renommer /
  supprimer, plus la case "Restaurer automatiquement le dernier workspace
  ouvert au demarrage" (desactivee par defaut).
- app/app_settings.py : preferences globales (theme, taille de police,
  dernier workspace) consolidees dans un seul module au lieu d'un mecanisme
  de lecture/ecriture duplique par prefernce dans theme.py.
- SplitContainer expose layout_kind et get_layout_sizes()/set_layout_sizes()
  (parcours recursif des QSplitter imbriques) pour la capture/restauration
  fidele d'une grille 2x2.

tests/test_workspace.py : capture (simple + split), sauvegarde/rechargement
JSON, restauration complete et partielle, renommer/supprimer/re-enregistrer,
restauration automatique - avec app_settings.SETTINGS_FILE redirige vers un
fichier temporaire pour ne jamais toucher les vrais reglages de l'utilisateur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:47:07 +02:00
Florian
5917e1ca04 feat: ajouter le split terminal (plusieurs panneaux par onglet)
Layouts : 1 (normal), 2 verticaux (cote a cote), 2 horizontaux (empiles), 4
panneaux (grille 2x2), via des QSplitter imbriques (app/ui/split_pane.py).
Chaque panneau reutilise tel quel un widget d'onglet existant
(SessionTabWidget, LocalTerminalTabWidget) : aucune modification de ces
classes, seule une fine barre (menu ☰ + fermeture) les enveloppe.

- Menu contextuel d'un onglet (SSH ou terminal local, pas SFTP, pas
  d'imbrication de splits) : "Diviser" -> choix du layout. Le contenu actuel
  occupe le premier panneau, les autres recoivent un terminal local par
  defaut.
- Menu ☰ d'un panneau : changer son contenu (autre terminal local), ou
  "Deplacer un onglet ici" pour y faire migrer un onglet de premier niveau
  existant sans fermer sa connexion.
- Fermer un panneau le retire de son QSplitter (qui redistribue l'espace tout
  seul) ; s'il n'en reste qu'un, l'onglet redevient un onglet normal
  (redescente automatique, pas de chrome de split residuel).
- Fermer l'onglet divise (bouton x) ferme toutes les connexions des panneaux.
- BroadcastManager (Multi-Send) atteint desormais aussi les panneaux a
  l'interieur d'un split (MainWindow._iter_tabs les deplie).

Bug intercepte par les tests avant integration : reparenter le widget d'origine
dans son panneau retire automatiquement son onglet du QTabWidget (un widget ne
peut avoir qu'un seul parent), ce qui decale les onglets suivants ; un appel
explicite a removeTab() ensuite visait donc le mauvais onglet. Corrige en
laissant Qt gerer ce retrait implicitement plutot que de le dupliquer.

tests/test_split_terminal.py : SSHConnection.start()/LocalTerminalConnection.start()
neutralisees (teste l'agencement des panneaux et le cycle de vie des onglets,
pas une connexion reelle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:35:39 +02:00
Florian
9c8017cebd feat: ajouter le keepalive SSH et la reconnexion automatique
- Session gagne keepalive_enabled/interval/count_max (actif par defaut : ne
  fait qu'envoyer des paquets de maintien, sans risque pour les sessions
  existantes) et reconnect_enabled/initial_delay/max_attempts (desactive par
  defaut : change reellement le comportement en cas de coupure, donc a
  activer explicitement).

- SSHConnection._keepalive_loop() : thread dedie qui envoie
  "keepalive@openssh.com" (comme OpenSSH) avec accuse de reception toutes les
  `keepalive_interval` secondes. Un serveur conforme repond immediatement
  (meme en echec), ce qui confirme que le transport est vivant. Apres
  `keepalive_count_max` echecs consecutifs (aucune reponse), le canal est
  ferme pour que la boucle de lecture principale s'en apercoive et emette
  `disconnected` avec une raison explicite.

- SessionTabWidget programme une reconnexion (QTimer, jamais de blocage de
  l'interface) quand la session l'autorise et que la deconnexion n'est pas
  volontaire (_closing) : delai en backoff exponentiel plafonne
  (app.services.reconnect.compute_backoff_delay), compte a rebours affiche
  ("Connexion perdue (...). Reconnexion dans Ns... (Tentative X/Y)"), abandon
  au-dela de reconnect_max_attempts (0 = illimite), compteur remis a zero des
  qu'une connexion reussit. Ne se declenche jamais sur une simple erreur
  d'authentification (seul un `disconnected` apres connexion reussie
  declenche une reconnexion, pas un `error` de connexion initiale - sinon un
  mot de passe errone entrainerait des tentatives repetees).

- BaseTerminalTab : les signaux connected/disconnected/error d'une connexion
  sont desormais associes a la generation de connexion en cours au moment ou
  ils sont cables ; un signal parvenant apres qu'une reconnexion (manuelle ou
  automatique) ait deja demarre une nouvelle connexion est ignore. Necessaire
  ici : les signaux Qt entre threads sont mis en file et ne sont livres
  qu'apres le retour de reconnect() a la boucle d'evenements, alors qu'une
  nouvelle connexion est deja en place.

- SessionDialog : sections "Keepalive" et "Reconnexion automatique".

tests/test_keepalive.py (paramiko entierement mocke), test_reconnect_backoff.py
(fonction pure) et test_auto_reconnect.py (SSHConnection.start() neutralisee :
la programmation de la reconnexion ne depend d'aucune connexion reseau reelle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:27:19 +02:00
Florian
fe938a47b0 feat: ajouter le forwarding dynamique SOCKS (-D)
Les redirections -L et -R existaient deja ; il manquait -D, ou le port local
ouvert se comporte comme un proxy SOCKS dont la destination est negociee par
chaque client (navigateur, curl...) plutot que fixee a l'avance.

- app/tunnels.py : implementation minimale d'un serveur SOCKS4/4a/5 (CONNECT
  uniquement, sans authentification - largement suffisant pour un usage
  local/personnel). Reutilise le meme _ForwardServer et le meme _relay() que
  -L/-R ; seul le handshake initial differe (negocie la destination au lieu
  de la recevoir en config). DIRECTION_DYNAMIC s'integre dans describe() et
  Tunnel.stop() sans code specifique supplementaire.
- TunnelDialog : nouvelle option "Dynamique - SOCKS (-D)" ; les champs
  Machine/Port de destination se masquent automatiquement pour ce mode
  (QFormLayout.setRowVisible), puisque la destination n'est pas fixee ici.

tests/test_socks_tunnel.py : verifie SOCKS5 (adresse IPv4) et SOCKS4a (nom de
domaine) avec un vrai client SOCKS et un transport SSH mocke - a d'ailleurs
revele un bug reel (decode("idna", errors="replace") leve une exception, le
codec idna ne supportant pas errors="replace") sur le chemin nom-de-domaine,
corrige ici (decode("ascii", errors="replace"), les noms d'hote SOCKS etant
de l'ASCII simple).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 02:08:02 +02:00
Florian
c2bde19f59 feat: ajouter le support ProxyJump / bastion (jump host)
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>
2026-09-05 02:02:25 +02:00
Florian
c08037a638 feat: ajouter la prise en charge de ~/.ssh/config
Support minimum : Host, HostName, User, Port, IdentityFile, ProxyJump,
ForwardAgent, ServerAliveInterval, ServerAliveCountMax.

- app/openssh/config.py : load_openssh_hosts() lit ~/.ssh/config via
  paramiko.SSHConfig (gere nativement les blocs "Host *" fusionnes, les alias
  multiples sur une meme ligne Host, et l'expansion ~ des IdentityFile).
  Fichier absent = liste vide, pas une erreur. session_from_openssh_host()
  convertit un Host en Session PySSH (groupe "OpenSSH", cle si IdentityFile
  presente sinon authentification auto).
- Nouvelle section "Configuration OpenSSH" dans le panneau lateral
  (SessionController), sous Sessions SSH : double-clic ou menu contextuel pour
  se connecter directement ou importer comme session (editeur pre-rempli,
  ajustable avant enregistrement) ; rafraichissement via le menu ⚙ ou le menu
  contextuel de l'hote.
- ConnectionController.open_openssh_host() ouvre une connexion directe sans
  enregistrer de session, comme une connexion rapide.

Le ProxyJump est detecte, affiche (badge "· via <bastion>", tooltip) et reporte
dans les notes d'une session importee, mais pas encore applique automatiquement
a la connexion : ce sera l'objet de la Phase 3 (Bastion / Jump Host), qui
prendra en charge toutes ses sources (OpenSSH, session PySSH, config manuelle)
via un mecanisme commun.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:49:59 +02:00
Florian
5fb965d7c2 refactor: unifier le calcul d'empreinte des cles SSH
generate_key() calculait une vraie empreinte SHA256, mais import_key() prenait
juste pub_text.split()[1][:16] - un fragment de base64, pas une empreinte.

- compute_fingerprint(public_key_line) : fonction unique (format
  "SHA256:xxxx...", comme ssh-keygen -lf), utilisee par generate_key() et
  import_key().
- KeyManager.load() corrige silencieusement les entrees existantes dont
  l'empreinte ne correspond pas a compute_fingerprint(public_key_text) (cas
  des cles importees avant ce correctif), et resauvegarde si necessaire.

Ce commit branche aussi dans KeyManager les deux mecanismes ajoutes par les
commits precedents : generate_key() ecrit desormais via
app.storage.json_store (schema_version=1, .bak, ecriture atomique) et protege
la cle privee avec app.security.key_protection.protect_private_key() a la
place du chmod(0o600) precedent.

Verifie par tests/test_atomic_storage.py (recalcul d'une empreinte tronquee
au chargement) et tests/test_keys.py (format SHA256: pour toutes les cles
generees).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:43:24 +02:00
Florian
d212e6ab44 feat: proteger les cles privees via ACL NTFS (icacls) sous Windows
chmod(0o600) n'a aucun effet sur les permissions NTFS reelles : sous Windows,
une cle privee generee restait accessible a tout compte ayant acces au
dossier (permissions heritees usuelles : Users/Authenticated Users).

app/security/key_protection.py : protect_private_key() verrouille l'ACL du
fichier via icacls (/inheritance:r puis /grant:r <utilisateur>:F), plutot que
d'ajouter une dependance pywin32 - plus lourde a empaqueter avec PyInstaller
pour un simple changement de permissions. Repli chmod(0o600) sous Linux/macOS.
Echec silencieux (icacls absent, systeme de fichiers sans ACL...), comme le
faisait deja l'appel chmod qu'elle remplace.

Branchee dans KeyManager.generate_key() (cf commit suivant). Non appliquee a
import_key() : une cle importee pointe potentiellement vers un chemin hors du
controle de l'application (~/.ssh partage avec d'autres outils), et la
consigne est de ne jamais rendre une cle moins accessible qu'elle ne l'etait -
ni, par prudence, de modifier sans le demander des permissions definies par
l'utilisateur lui-meme.

Verifie manuellement avec icacls apres generation reelle : seul le compte
Windows courant apparait dans l'ACL du fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:43:12 +02:00
Florian
23f5aaccd1 feat: sauvegarde atomique des JSON avec schema_version et recuperation .bak
sessions.json/keys.json/settings.json etaient ecrits directement
(Path.write_text) : un crash ou une coupure de courant pendant l'ecriture
pouvait laisser un fichier tronque, sans aucun recours.

- app/storage/atomic_file.py : ecriture .tmp + flush + fsync +
  os.replace() (atomique y compris sous Windows).
- app/storage/json_store.py : load_json_document()/save_json_document().
  Chaque sauvegarde conserve la version precedente dans un .bak ; au
  chargement, un fichier principal corrompu retombe automatiquement sur ce
  .bak avant de se rabattre sur des valeurs par defaut. Chaque document porte
  un schema_version, avec une chaine de migrations (vide pour l'instant) pour
  faire evoluer le format plus tard sans invalider les fichiers existants.
- SessionStore et KeyManager utilisent ce module (schema_version=1) ; un
  fichier legacy sans schema_version reste charge normalement et n'est reecrit
  qu'a la prochaine sauvegarde naturelle (comportement de "premiere ecriture"
  inchange par rapport a avant).
- settings.json (theme.py) et known_hosts (known_hosts_manager.py, via
  paramiko.HostKeys.save() redirige vers un .tmp puis os.replace())
  beneficient aussi de l'ecriture atomique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:42:59 +02:00
Florian
fd848c2025 feat: rendre les operations SFTP asynchrones
RemoteFilePanel appelait sftp.listdir_attr/mkdir/rename/remove/chmod/get
directement depuis le thread GUI : un dossier distant lent ou une latence
reseau gelait toute l'interface. Seuls upload/download passaient deja par un
thread dedie (TransferWorker), et celui-ci reutilisait le meme SFTPClient que
la navigation - paramiko.SFTPClient n'est pas concu pour etre pilote par
plusieurs threads a la fois.

- app/sftp/worker.py : SftpWorker, un thread proprietaire exclusif d'un canal
  SFTP, avec une file de jobs (callables) et les resultats renvoyes par signal
  Qt (marshalling automatique vers le thread GUI).
- RemoteFilePanel soumet desormais toutes ses operations via ce worker avec
  callbacks succes/erreur ; un compteur de generation ignore les reponses de
  navigations devenues obsoletes (double-clic rapide, edition puis navigation
  ailleurs pendant le renvoi).
- TransferWorker ouvre son propre canal SFTP (`sftp_factory`) au lieu de
  partager celui de la navigation : un gros transfert ne bloque plus le
  listage de dossiers. SFTPPanel(sftp, transfer_sftp_factory=...) permet
  d'injecter ce second canal (paramiko.SFTPClient.from_transport) ; a defaut,
  le canal de navigation est reutilise tel quel (cas des tests).
- SessionTabWidget/SFTPTabWidget ouvrent ce second canal et appellent
  SFTPPanel.shutdown() a la fermeture de l'onglet (arrete proprement les
  threads avant destruction des widgets).

tests/test_sftp.py adapte pour pomper la boucle d'evenements Qt apres les
operations desormais asynchrones (meme couverture, timing different).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:42:46 +02:00
Florian
47005b1e26 refactor: decouper main_window.py en controllers/services/ui
main_window.py concentrait la fenetre principale, l'arbre des sessions, les 4
classes d'onglets, la detection de commandes ssh et le multi-send : 983 lignes
et une seule classe qui pilotait toute l'application.

- app/services/ssh_target.py : detection d'une cible ssh dans un terminal (pur,
  sans dependance Qt)
- app/ui/tabs.py : BaseTerminalTab / SessionTabWidget / SFTPTabWidget /
  LocalTerminalTabWidget (deplaces tels quels)
- app/controllers/session_controller.py : panneau lateral (arbre sessions +
  terminaux locaux) et CRUD des sessions
- app/controllers/connection_controller.py : cycle de vie des onglets
  (ouverture, connexion rapide, renommage, fermeture)
- app/controllers/broadcast_manager.py : bouton et logique du mode Multi-Send

main_window.py ne fait plus que construire ces composants et les relier par
signaux (983 -> ~200 lignes). Comportement inchange ; MainWindow.tabs et
detect_ssh_target restent importables depuis app.main_window pour la
compatibilite avec les tests existants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:42:31 +02:00
101 changed files with 10079 additions and 1250 deletions

76
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,76 @@
name: CI
# Runner Windows requis : PySide6/pywinpty/Windows Credential Manager/ACL NTFS
# ne fonctionnent que sous Windows, comme l'application elle-meme.
on:
push:
branches: ["**"]
pull_request:
jobs:
lint:
runs-on: windows
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Installer les dependances de developpement
run: pip install -r requirements-dev.txt
- name: ruff check
run: ruff check app main.py tests
tests:
runs-on: windows
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Installer les dependances
run: pip install -r requirements-dev.txt
- name: pytest (rendu Qt hors-ecran)
env:
QT_QPA_PLATFORM: offscreen
PYTHONIOENCODING: utf-8
PYTHONUTF8: "1"
run: pytest tests -v
typecheck:
runs-on: windows
# Informatif pour l'instant : le code n'a jamais ete type avant cette phase,
# 77 signalements (Optional non filtre, principalement dans app/ui/tabs.py
# et app/ssh_connection.py) restent a trier au cas par cas. A rendre
# bloquant une fois ce fond resorbe - voir le commentaire dans pyproject.toml.
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Installer les dependances
run: pip install -r requirements-dev.txt
- name: pyright
run: pyright
build:
runs-on: windows
needs: [lint, tests]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Installer les dependances et PyInstaller
run: |
pip install -r requirements.txt
pip install pyinstaller
- name: Generer l'icone
run: python tools/make_icon.py
- name: Construire l'executable portable
run: python -m PyInstaller --noconfirm --clean PySSHClient.spec
- uses: actions/upload-artifact@v4
with:
name: PySSH-Client-portable
path: dist/PySSH Client

10
.gitignore vendored
View File

@@ -1,6 +1,7 @@
venv/
__pycache__/
*.pyc
.pytest_cache/
# generes localement (chemins absolus / dependants du poste)
*.lnk
@@ -10,8 +11,11 @@ data/settings.json
# artefacts de construction PyInstaller
build/
dist/
data/sessions.json
data/keys.json
data/known_hosts
data/sessions.json*
data/keys.json*
data/workspaces.json*
data/snippets.json*
data/groups.json*
data/known_hosts*
data/keys/*
!data/keys/.gitkeep

246
README.md
View File

@@ -1,9 +1,17 @@
# PySSH Client
Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans compte ni service tiers.
Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), **100% local**.
**Aucun compte requis, aucun service cloud, aucune telemetrie.** Sessions, cles, mots de passe
memorises et snippets restent dans des fichiers locaux (ou le trousseau securise de Windows) —
rien n'est jamais envoye a un serveur tiers. La seule fonctionnalite qui communique vers
l'exterieur est l'**assistant IA**, entierement optionnel, desactive par defaut, et qui n'envoie
quoi que ce soit qu'au fournisseur que *vous* configurez avec *votre propre* cle API (voir plus bas).
## Fonctionnalites
### Terminal SSH
- Sessions SSH multiples dans des onglets, avec terminal ANSI colore et trois themes
- Copier-coller **a la souris, comme dans un terminal Unix** : selectionner copie aussitot
(double-clic pour un mot), et le clic droit colle directement — ni menu, ni raccourci.
@@ -12,41 +20,120 @@ Client SSH/SFTP personnel (type MobaXterm/Termius/Bitvise), 100% local, sans com
- Confort d'affichage : zoom `Ctrl+molette` ou `Ctrl+ +/-` (`Ctrl+0` remet a zero, la taille est
memorisee), barre de defilement de l'historique, `Maj+PagePrec/Suiv`, `Ctrl+Fin` pour revenir
en direct, et recherche dans l'historique avec `Ctrl+F`
- Onglets renommables (double-clic sur l'onglet, ou clic droit) et deplaçables ;
le clic droit permet aussi de fermer les autres onglets
- Panneau des sessions redimensionnable en attrapant le separateur, et masquable entierement
(`F9`, menu Affichage, ou la croix du panneau) pour donner tout l'ecran au terminal
- **Tunnels SSH** (redirection de ports) : bouton "Tunnels" dans la barre d'un onglet SSH.
Redirection locale (`-L`) pour atteindre depuis votre poste un service qui n'ecoute que sur la
boucle locale du serveur, et redirection distante (`-R`) dans l'autre sens
- **Envoi simultane** : le bouton "⇉ Multi" du panneau lateral diffuse ce que vous tapez vers tous
les onglets terminal ouverts, pour piloter plusieurs serveurs d'un coup
- Journalisation de session dans un fichier (bouton "Journal"), sequences ANSI retirees pour rester lisible
- Reconnexion en un clic quand une session tombe (bouton dans la barre de l'onglet) : pratique
avec des serveurs de test qui redemarrent souvent
- Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont reconnus : pas de
double confirmation d'empreinte. Les nouvelles cles vont dans le fichier propre a l'application
- Terminaux locaux accessibles en un clic, **sans session enregistree**. L'application detecte les
shells presents sur le poste et les propose tous : PowerShell, cmd, et surtout les **shells Unix**
s'ils sont installes (Bash de Git for Windows, MSYS2, Cygwin, WSL, ou un busybox pose a cote de
l'application). Comme le terminal local de MobaXterm, on retrouve alors `ls`, `grep`, `sed`, `awk`,
`vi`, `scp`... sous Windows. **Un shell Unix est choisi par defaut** quand il y en a un ;
sinon PowerShell. Rien n'est embarque : l'application reutilise ce qui est deja installe
- Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers, connexion en un clic
- Gestion des cles SSH : generation **Ed25519, ECDSA (courbes NIST P-256 / P-384 / P-521) et RSA**,
import de cles existantes, export (cle publique/privee), protection par passphrase
- Client SFTP integre facon WinSCP : double panneau local/distant avec colonnes triables (nom, taille, date, droits),
glisser-deposer entre les panneaux, renommage (F2), suppression (Suppr), creation de dossier, permissions distantes
(chmod), et file de transferts avec progression. **Aucun favori n'est necessaire pour l'utiliser** — voir ci-dessous
- **SFTP sur la connexion en cours** : depuis n'importe quel terminal ou vous avez tape `ssh user@hote` a la main, le
bouton "📁 SFTP sur cette connexion" lit l'hote, l'utilisateur et le port dans la commande saisie et ouvre le
gestionnaire de fichiers dessus. Pense pour les serveurs de test en DHCP, qu'on n'a aucune envie d'enregistrer
- **Authentification automatique** (mode par defaut) : comme la commande `ssh`, l'application utilise l'agent SSH et les
cles de `~/.ssh` sans que vous ayez a designer quoi que ce soit, et ne demande un mot de passe qu'en dernier recours
- Verification des empreintes de cles d'hote (TOFU) avant premiere connexion, comme un client SSH classique
- Gestionnaire des hotes connus (menu "Hotes connus" ou bouton "⚙" du panneau lateral) : si une adresse en DHCP change de machine, la cle d'hote presentee ne correspond plus a celle enregistree — l'application le detecte et propose de remplacer l'ancienne entree directement au moment de la connexion, sans avoir a vider le fichier `known_hosts` a la main. Le gestionnaire permet aussi un nettoyage manuel (par hote ou en une fois).
- Onglets renommables (double-clic, ou clic droit) et deplaçables ; le clic droit permet
aussi de les diviser en **2 ou 4 panneaux** (cote a cote, empiles, ou grille), de deplacer un
autre onglet ouvert dans un panneau, ou de fermer les autres onglets
- **Keepalive et reconnexion automatique** configurables par session (intervalle, nombre de
tentatives, delai avec recul progressif) : une coupure reseau n'oblige plus a rouvrir
la session et ressaisir les identifiants
- Journalisation de session dans un fichier (bouton "Journal"), sequences ANSI retirees pour
rester lisible
- Reconnexion manuelle en un clic (bouton dans la barre de l'onglet)
Aucun mot de passe n'est stocke sur le disque : l'authentification par mot de passe est demandee a chaque connexion, et les passphrases de cles sont demandees uniquement en memoire au moment de la connexion.
### Connexion et authentification
- **Authentification automatique** (mode par defaut) : comme la commande `ssh`, l'application
utilise l'agent SSH et les cles de `~/.ssh` sans que vous ayez a designer quoi que ce soit, et
ne demande un mot de passe qu'en dernier recours
- **Import de votre `~/.ssh/config`** : les `Host` deja definis (HostName, User, Port,
IdentityFile, ProxyJump, ForwardAgent...) apparaissent directement dans le panneau lateral
(section "⚙ Configuration OpenSSH"), utilisables sans les ressaisir, ou importables comme
session PySSH en un clic
- **Bastion / ProxyJump** : une session peut passer par un rebond SSH, choisi parmi vos sessions
existantes, un `Host` de `~/.ssh/config`, ou une configuration manuelle. Un groupe de sessions
peut aussi heriter d'un bastion commun (voir Groupes ci-dessous)
- **Tunnels SSH** (bouton "🔀 Tunnels" dans la barre d'un onglet SSH) : redirection locale
(`-L`, atteindre depuis votre poste un service qui n'ecoute que sur la boucle locale du
serveur), distante (`-R`), et **proxy SOCKS dynamique** (`-D`, pour faire transiter tout le
trafic d'une application par le tunnel)
- Verification des empreintes de cles d'hote (TOFU) avant premiere connexion, comme un client
SSH classique. Les hotes deja approuves par votre commande `ssh` (`~/.ssh/known_hosts`) sont
reconnus : pas de double confirmation d'empreinte
- Gestionnaire des hotes connus (menu "Hotes connus") : si une adresse en DHCP change de
machine, la cle d'hote presentee ne correspond plus a celle enregistree — l'application le
detecte et propose de remplacer l'ancienne entree directement au moment de la connexion
### Organisation
- Carnet d'adresses de serveurs (JSON local, `data/sessions.json`) organise par dossiers,
connexion en un clic
- **Tags** (plusieurs par session) avec filtre/recherche dans le panneau lateral
- **Groupes** : un dossier de sessions peut porter des parametres par defaut (utilisateur, port,
cle SSH, bastion, keepalive) via clic droit > "Parametres du groupe...". Le bouton
"↓ Heriter du groupe" du formulaire de session les reprend en pre-remplissage, sans empecher
de les modifier au cas par cas pour une session donnee
- **Workspaces** : sauvegardent l'etat complet d'une disposition de travail — onglets ouverts,
ordre, divisions et tailles de panneaux, sessions associees — et la restaurent en un clic
(menu "Workspaces", `Ctrl+Shift+W`). Une case optionnelle restaure le dernier workspace au
demarrage. **Aucun mot de passe n'est jamais stocke dans un workspace**
- **Recherche rapide** (`Ctrl+K`) : palette façon lanceur d'applications, recherche floue parmi
sessions, hotes OpenSSH, workspaces, snippets et actions, entierement au clavier
- **Snippets** : commandes enregistrees par categorie (des exemples Linux/Docker/Proxmox sont
fournis au premier lancement), gerables (creer/modifier/supprimer/rechercher) depuis le menu
"Snippets" ou le bouton "📋 Snippets" de chaque onglet. Un snippet peut etre **insere** (le
texte est ecrit, jamais valide automatiquement), **execute**, ou **execute sur tous les
onglets a la fois** via Multi-Send
- **Envoi simultane** : le bouton "⇉ Multi" du panneau lateral diffuse ce que vous tapez vers
tous les onglets terminal ouverts (y compris dans les panneaux divises), pour piloter
plusieurs serveurs d'un coup
### SFTP
- Client SFTP integre facon WinSCP : double panneau local/distant avec colonnes triables (nom,
taille, date, droits), glisser-deposer entre les panneaux, renommage (F2), suppression
(Suppr), creation de dossier, permissions distantes (chmod), et file de transferts avec
progression — le tout en arriere-plan, sans jamais geler l'interface
- **Aucun favori n'est necessaire pour l'utiliser** — trois façons d'y arriver :
- *Vous avez tape `ssh user@hote` dans un terminal* : bouton "📁 SFTP sur cette connexion"
dans la barre de l'onglet. L'hote et l'utilisateur sont deduits de la commande tapee
- *Connexion ponctuelle* : bouton "📁 SFTP" du panneau lateral, ou `Ctrl+Shift+F`
- *Session enregistree* : le panneau de fichiers est deja affiche a cote du terminal des
l'ouverture, comme dans MobaXterm ; ou clic droit > "Ouvrir en SFTP" pour un onglet dedie
### Cles et secrets
- Gestion des cles SSH : generation **Ed25519, ECDSA (courbes NIST P-256/P-384/P-521) et RSA**,
import de cles existantes, export, protection par passphrase. Sous Windows, les cles privees
generees ou importees reçoivent des permissions NTFS restreintes (ACL), equivalent du
`chmod 600` Unix
- **Coffre-fort de mots de passe optionnel** : a la creation d'une session par mot de passe, une
case "Memoriser de maniere securisee" permet de ne plus le ressaisir a chaque connexion. Le
mot de passe est alors stocke dans le **Gestionnaire d'identifiants Windows** (chiffre par
DPAPI), **jamais en clair dans un fichier de PySSH**. Sans cette case cochee (comportement par
defaut), rien n'est memorise : le mot de passe est redemande a chaque connexion, comme
aujourd'hui
### Assistant IA (optionnel, desactive par defaut)
Une aide ponctuelle pour l'administration systeme, pas un pilote automatique : **aucune commande
n'est jamais executee automatiquement**.
- **Desactive par defaut**, et sans le moindre fournisseur impose : compatible avec toute API de
type OpenAI. Se configure depuis le menu "Assistant IA > Parametres...", avec une liste
deroulante de raccourcis usuels — OpenAI, OpenRouter, Mistral AI, mais aussi **Ollama** et
**LM Studio** pour faire tourner un modele **entierement en local** — qui pre-remplit l'URL de
base, et un champ "Personnalise..." pour tout autre service compatible OpenAI (proxy interne,
autre LLM local...). Le modele, lui, reste toujours en saisie libre (pas de liste figee, chaque
installation Ollama/LM Studio ayant ses propres modeles telecharges). Bouton "Tester la
connexion" pour verifier la configuration avant de l'utiliser
- Depuis un onglet terminal, le bouton "🤖 Assistant IA" permet : **Expliquer cette sortie**,
**Diagnostiquer cette erreur**, **Proposer une solution**, ou **Poser une question**. Ces
actions portent sur le texte **selectionne** s'il y en a un (pour cibler precisement une
partie d'une sortie longue), et se rabattent sinon automatiquement sur **tout l'ecran visible
du terminal** — inutile de selectionner quoi que ce soit pour un simple "explique-moi ce que
j'ai sous les yeux". Les reponses s'affichent dans un panneau lateral (conversation en memoire
uniquement, jamais journalisee ni ecrite sur le disque)
- **Generer une commande** (a partir d'une description en langage naturel, sans selection
necessaire) affiche toujours la commande proposee avant tout usage, avec trois choix : la
**copier**, l'**inserer dans le terminal** (le texte est ecrit, jamais valide automatiquement),
ou **annuler**. Une commande correspondant a un motif reconnu comme destructeur (`rm -rf`,
`mkfs`, `dd`, un `DROP DATABASE`...) est signalee par un avertissement explicite
- **Avant tout envoi** au fournisseur configure : un avis de principe s'affiche une seule fois,
et chaque envoi est verifie localement a la recherche de secrets evidents (mot de passe, cle
privee, jeton d'authentification) — si quelque chose est detecte, vous choisissez de le
masquer automatiquement, de l'envoyer tel quel, ou d'annuler l'envoi
- **La cle API** passe exclusivement par le meme coffre-fort securise que les mots de passe de
session (Gestionnaire d'identifiants Windows) : jamais dans `settings.json`, jamais journalisee
## Installation (Windows)
@@ -56,7 +143,15 @@ Necessite Python 3.10+ installe sur le poste (verifiable avec `py -3 --version`)
setup.bat
```
Ce script cree un environnement virtuel local (`venv/`) et y installe les dependances (PySide6, paramiko, pyte, cryptography, pywinpty) — rien n'est installe globalement sur le poste.
Ce script cree un environnement virtuel local (`venv/`) et y installe les dependances (PySide6,
paramiko, pyte, cryptography, pywinpty) — rien n'est installe globalement sur le poste.
Pour contribuer au code (tests, lint, verification de types), installez plutot les dependances
de developpement :
```bash
venv\Scripts\pip install -r requirements-dev.txt
```
## Lancement
@@ -109,8 +204,8 @@ automatiquement sur `%APPDATA%\PySSHClient\`.
## Premiers pas
1. **Terminal local immediat** : bouton "💻 Terminal" dans le panneau lateral (ou `Ctrl+Shift+T`), ou double-clic sur "PowerShell"/"Invite de commandes" dans la section "Terminaux locaux" de l'arborescence — aucune session a creer.
2. **Nouvelle session SSH** (bouton "+ Session" ou `Ctrl+N`) : renseignez l'hote, le port, l'utilisateur et le mode d'authentification.
1. **Terminal local immediat** : bouton "💻 Terminal" dans le panneau lateral (ou `Ctrl+Shift+T`), ou double-clic sur "PowerShell"/"Invite de commandes" dans la section "💻 Terminaux locaux" de l'arborescence — aucune session a creer.
2. **Nouvelle session SSH** (bouton "+ Session" ou `Ctrl+N`) : renseignez l'hote, le port, l'utilisateur et le mode d'authentification. Vous avez deja un `~/.ssh/config` ? Ses `Host` apparaissent tout seuls dans la section "⚙ Configuration OpenSSH", inutile de les ressaisir.
3. **Cles SSH** (menu "Cles SSH > Gestionnaire de cles") : generez une nouvelle paire de cles ou importez-en une existante, puis selectionnez-la dans le formulaire de session.
4. Double-cliquez sur une session dans l'arborescence pour ouvrir un onglet terminal SSH.
5. **SFTP — trois facons d'y arriver**, selon la situation :
@@ -123,46 +218,87 @@ automatiquement sur `%APPDATA%\PySSHClient\`.
Dans tous les cas : glisser-deposer entre les deux panneaux, F2 renomme, Suppr supprime, F5 actualise, et le clic
droit sur le panneau distant donne acces aux permissions (chmod).
6. **Aller plus vite** : `Ctrl+K` ouvre une recherche rapide parmi tout ce que vous avez deja ouvert ou enregistre.
7. **Retrouver une disposition de travail** : une fois plusieurs onglets/panneaux organises a votre gout, menu
"Workspaces > Gestionnaire de workspaces..." (`Ctrl+Shift+W`) pour l'enregistrer et la rouvrir en un clic.
8. **Assistant IA** (facultatif) : menu "Assistant IA > Parametres..." pour indiquer l'URL, le modele et la cle API
d'un fournisseur compatible OpenAI. Tant que cette etape n'est pas faite, l'assistant reste inactif et
n'envoie jamais rien nulle part.
## Structure du projet
```
main.py point d'entree
app/
theme.py feuille de style "bleu nuit" + icone d'application
session_manager.py base de sessions (JSON)
key_manager.py generation/import/export de cles SSH
ssh_connection.py connexion SSH en arriere-plan (paramiko + QThread)
main_window.py fenetre principale : assemble panneau lateral, onglets, menus
session_manager.py base de sessions (JSON), tags, parametres de bastion/keepalive/reconnexion
key_manager.py generation/import/export de cles SSH, empreintes
known_hosts_manager.py gestion du fichier known_hosts (lister/retirer/remplacer une cle d'hote)
ssh_connection.py connexion SSH en arriere-plan (paramiko + QThread), bastion, keepalive
local_terminal.py terminal local Windows (PowerShell/cmd via pywinpty)
terminal_widget.py emulateur de terminal ANSI (pyte + QPainter)
sftp_panel.py client SFTP a deux panneaux
main_window.py fenetre principale (arbre de sessions + onglets)
tunnels.py tunnels SSH (redirection de ports -L / -R)
dialogs/ boites de dialogue (session, cles, hotes, tunnels, connexion)
sftp_panel.py client SFTP a deux panneaux, transferts en arriere-plan
tunnels.py tunnels SSH : redirection -L / -R et proxy SOCKS dynamique -D
bastion.py resolution d'un ProxyJump (session / Host OpenSSH / manuel)
credentials.py coffre-fort optionnel (Gestionnaire d'identifiants Windows)
workspace.py, snippets.py, persistance des workspaces, snippets et parametres de groupe
groups.py, app_settings.py
theme.py feuille de style "bleu nuit" + icone d'application
ai/ assistant IA : fournisseur, redaction de secrets, detection de
commandes dangereuses, parametres, execution en arriere-plan
controllers/ logique d'assemblage : cycle de vie des onglets, panneau des
sessions, diffusion Multi-Send, restauration des workspaces
ui/ widgets d'onglet (SSH/local/SFTP), terminal divise, panneau IA
dialogs/ boites de dialogue (session, cles, hotes, tunnels, snippets,
workspaces, groupes, recherche rapide, parametres IA...)
openssh/ lecture de ~/.ssh/config
security/ protection des cles privees (ACL NTFS sous Windows)
storage/ ecriture atomique de fichiers JSON, migration de schema
services/ logique pure sans dependance Qt (detection ~cible SSH, recherche
floue, calcul du delai de reconnexion)
resources/
theme.qss feuille de style Qt "bleu nuit"
data/
sessions.json vos serveurs enregistres (non versionne)
keys.json registre des cles connues (non versionne)
keys/ cles generees par l'application (non versionne)
known_hosts empreintes d'hotes approuvees (non versionne)
sessions.json, keys.json, vos donnees locales (non versionnees) - voir .gitignore
workspaces.json, snippets.json,
groups.json, known_hosts, keys/
tests/ suite de tests (voir "Tests" ci-dessous)
```
## Tests
```bash
tests\run_all.bat
pytest
```
(necessite `pip install -r requirements-dev.txt`). Chaque fichier `tests/test_*.py` reste aussi
executable individuellement (`python tests/test_xxx.py`), pratique pour rejouer un seul scenario
pendant le developpement.
Les tests construisent de vraies fenetres Qt sans les afficher (`QT_QPA_PLATFORM=offscreen`) et
couvrent : le copier-coller et les raccourcis du terminal, la detection de l'hote dans une commande
`ssh` tapee a la main, la logique SFTP (transferts, renommage, tri, suppression recursive) avec un
serveur simule, et la construction complete de l'application.
couvrent, entre autres : le copier-coller et les raccourcis du terminal, la detection de l'hote
dans une commande `ssh` tapee a la main, la logique SFTP (transferts, renommage, tri, suppression
recursive) avec un serveur simule, le stockage atomique et la migration de schema, la lecture de
`~/.ssh/config`, la resolution de ProxyJump, le coffre-fort d'identifiants, la detection de
secrets et de commandes dangereuses, et l'ensemble de l'assistant IA (fournisseur HTTP, panneau,
integration dans les onglets) — sans jamais faire d'appel reseau reel.
## Qualite
```bash
ruff check app main.py tests # style et erreurs courantes
pyright # verification de types (mode progressif, voir pyproject.toml)
```
Une CI Gitea Actions (`.gitea/workflows/ci.yml`) execute le lint et les tests a chaque push, et
construit la version portable sur la branche principale.
## Limites connues / pistes d'amelioration
- L'emulation de terminal couvre les usages courants (bash, vim, htop...) mais n'est pas un xterm parfaitement complet.
- Le terminal local repose sur `pywinpty` (pseudo-console Windows). Si un onglet local reste bloque sur un ecran vide, tapez une touche : le rafraichissement de la console Windows peut etre paresseux dans certains contextes (notamment les sessions Windows non interactives) et se declenche a la premiere frappe.
- Le SFTP ne propose pas encore l'edition de fichier distant en place (telecharger / modifier / renvoyer a la main).
- Pas encore d'export/import global du carnet de sessions (le fichier `data/sessions.json` peut etre copie manuellement).
- Le ProxyJump ne gere qu'un seul rebond pour l'instant (pas de chainage de plusieurs bastions).
- Le coffre-fort d'identifiants (mots de passe de session, cle API IA) repose sur le Gestionnaire d'identifiants Windows : pas d'equivalent sur Linux/macOS pour l'instant, l'application fonctionne mais sans memorisation securisee sur ces systemes.
- L'application est concue et testee pour Windows uniquement (pywinpty, ACL NTFS, Gestionnaire d'identifiants).
- La verification de types (`pyright`) est volontairement en mode progressif : un socle de signalements existe encore dans `app/ui/tabs.py` et `app/ssh_connection.py` (voir `pyproject.toml`), a resorber au fil du temps plutot que d'un bloc.

0
app/ai/__init__.py Normal file
View File

37
app/ai/danger.py Normal file
View File

@@ -0,0 +1,37 @@
"""Detection locale simple de commandes potentiellement destructives, proposees
par l'assistant IA. N'a pas besoin d'etre parfaite : sert a afficher un
avertissement, jamais a bloquer ou executer quoi que ce soit automatiquement."""
from __future__ import annotations
import re
from dataclasses import dataclass
_DANGEROUS_PATTERNS: list[tuple[str, re.Pattern]] = [
("rm -rf (suppression recursive forcee)", re.compile(r"\brm\s+(-\w*\s+)*-\w*[rf]\w*[rf]\w*\b", re.IGNORECASE)),
("mkfs (formatage)", re.compile(r"\bmkfs(\.\w+)?\b", re.IGNORECASE)),
("dd (ecriture bas niveau sur un disque)", re.compile(r"\bdd\s+.*\bif=", re.IGNORECASE | re.DOTALL)),
("wipefs (effacement des signatures de systeme de fichiers)", re.compile(r"\bwipefs\b", re.IGNORECASE)),
("shutdown (extinction)", re.compile(r"\bshutdown\b", re.IGNORECASE)),
("reboot (redemarrage)", re.compile(r"\breboot\b", re.IGNORECASE)),
("iptables : purge des regles", re.compile(r"\biptables\b.*(-F|--flush)\b", re.IGNORECASE)),
("nft : purge des regles", re.compile(r"\bnft\b.*\bflush\b", re.IGNORECASE)),
("DROP DATABASE", re.compile(r"\bDROP\s+DATABASE\b", re.IGNORECASE)),
("DELETE FROM sans WHERE", re.compile(r"\bDELETE\s+FROM\s+\S+\s*;", re.IGNORECASE)),
("docker system prune (suppression en masse)", re.compile(r"\bdocker\s+system\s+prune\b", re.IGNORECASE)),
("zpool destroy", re.compile(r"\bzpool\s+destroy\b", re.IGNORECASE)),
("lvremove (suppression de volume logique)", re.compile(r"\blvremove\b", re.IGNORECASE)),
("vgremove (suppression de groupe de volumes)", re.compile(r"\bvgremove\b", re.IGNORECASE)),
]
@dataclass
class DangerMatch:
label: str
def check_dangerous(command: str) -> list[DangerMatch]:
return [DangerMatch(label) for label, pattern in _DANGEROUS_PATTERNS if pattern.search(command)]
def is_dangerous(command: str) -> bool:
return bool(check_dangerous(command))

154
app/ai/provider.py Normal file
View File

@@ -0,0 +1,154 @@
"""Abstraction du fournisseur IA : une API compatible OpenAI (endpoint chat
completions), avec URL de base et modele configurables - pas de liste de
fournisseurs ou de modeles figee en dur, pour supporter OpenAI, OpenRouter, un
proxy interne ou un LLM local exposant la meme API.
AIProvider porte les 4 usages metier (explain_output/diagnose/generate_command/
ask), tous construits au-dessus de chat(), seule methode que doit implementer
un fournisseur concret (OpenAICompatibleProvider)."""
from __future__ import annotations
import abc
import json
import urllib.error
import urllib.request
class AIError(Exception):
"""Message deja comprehensible, pret a etre affiche a l'utilisateur."""
_SYSTEM_PROMPT_TROUBLESHOOT = """Tu es un assistant specialise en administration systeme, Linux, Windows,
reseau, SSH, Docker, virtualisation et troubleshooting.
Analyse la sortie terminal fournie par l'utilisateur.
Explique :
1. ce que signifie la sortie ;
2. la cause probable ;
3. les verifications recommandees ;
4. les commandes eventuellement utiles.
Ne suppose jamais qu'une commande a ete executee.
Ne pretends jamais connaitre des elements qui ne sont pas presents.
Signale explicitement les hypotheses."""
_SYSTEM_PROMPT_DIAGNOSE = _SYSTEM_PROMPT_TROUBLESHOOT + """
Structure ta reponse ainsi :
Cause probable
Explication
Verifications
Commandes proposees
Niveau de risque"""
_SYSTEM_PROMPT_COMMAND = """Tu es un assistant specialise en administration systeme, Linux, Windows,
reseau, SSH, Docker et virtualisation.
L'utilisateur decrit ce qu'il veut accomplir en langage naturel. Propose UNE
commande (ou une courte suite de commandes) qui repond a la demande, dans un
bloc de code, avec une breve explication de ce qu'elle fait.
Ne suppose jamais le systeme d'exploitation ou l'environnement si l'utilisateur
ne l'a pas precise : demande-le, ou propose une variante par systeme si
pertinent. La commande proposee ne sera jamais executee automatiquement : elle
sera seulement affichee a l'utilisateur pour verification."""
class AIProvider(abc.ABC):
@abc.abstractmethod
def chat(self, messages: list[dict], *, temperature: float | None = None) -> str:
"""Renvoie le texte de la reponse. Leve AIError en cas d'echec."""
def explain_output(self, output: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT},
{"role": "user", "content": f"Explique cette sortie terminal :\n\n{output}"},
])
def diagnose(self, output: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_DIAGNOSE},
{"role": "user", "content": f"Diagnostique cette erreur :\n\n{output}"},
])
def propose_solution(self, output: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT},
{"role": "user", "content": (
"Propose une solution concrete a ce probleme, avec les commandes "
f"eventuellement necessaires :\n\n{output}"
)},
])
def generate_command(self, request: str) -> str:
return self.chat([
{"role": "system", "content": _SYSTEM_PROMPT_COMMAND},
{"role": "user", "content": request},
])
def ask(self, question: str, context: str = "") -> str:
messages = [{"role": "system", "content": _SYSTEM_PROMPT_TROUBLESHOOT}]
if context:
messages.append({"role": "user", "content": f"Contexte (sortie terminal selectionnee) :\n{context}"})
messages.append({"role": "user", "content": question})
return self.chat(messages)
class OpenAICompatibleProvider(AIProvider):
def __init__(
self, base_url: str, api_key: str, model: str,
timeout: float = 30.0, temperature: float = 0.3,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
self.timeout = timeout
self.temperature = temperature
def chat(self, messages: list[dict], *, temperature: float | None = None) -> str:
if not self.base_url:
raise AIError("Aucune URL de base configuree pour l'assistant IA.")
if not self.model:
raise AIError("Aucun modele configure pour l'assistant IA.")
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": messages,
"temperature": self.temperature if temperature is None else temperature,
}
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
request = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST",
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
raise AIError(self._describe_http_error(exc)) from exc
except urllib.error.URLError as exc:
if isinstance(exc.reason, TimeoutError):
raise AIError("Le fournisseur IA n'a pas repondu a temps (delai depasse).") from exc
raise AIError(f"Impossible de joindre le fournisseur IA : {exc.reason}") from exc
try:
parsed = json.loads(raw.decode("utf-8"))
return parsed["choices"][0]["message"]["content"]
except (json.JSONDecodeError, KeyError, IndexError, TypeError, UnicodeDecodeError) as exc:
raise AIError(f"Reponse du fournisseur IA invalide ou inattendue : {exc}") from exc
@staticmethod
def _describe_http_error(exc: urllib.error.HTTPError) -> str:
if exc.code == 401:
return "Authentification refusee (401) : verifiez la cle API."
if exc.code == 429:
return "Trop de requetes (429) : le fournisseur limite le debit, reessayez plus tard."
try:
body = exc.read().decode("utf-8", errors="replace")
except Exception: # noqa: BLE001
body = ""
return f"Erreur du fournisseur IA ({exc.code}) : {(body or exc.reason)[:300]}"

52
app/ai/redaction.py Normal file
View File

@@ -0,0 +1,52 @@
"""Detection/redaction basique de secrets avant envoi a un fournisseur IA
externe : cles privees SSH, jetons Bearer, cles AWS, en-tetes Authorization,
password=/token=/api_key=, et longues chaines ressemblant a un secret.
Heuristique volontairement simple (comme la detection de commandes
dangereuses) : mieux vaut avertir a tort sur un hash ou un identifiant anodin
que laisser passer un vrai secret."""
from __future__ import annotations
import re
from dataclasses import dataclass
_PATTERNS: list[tuple[str, re.Pattern]] = [
("Cle privee (PEM/OpenSSH)", re.compile(
r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED )?PRIVATE KEY-----"
)),
("Jeton Bearer", re.compile(r"\bBearer\s+[A-Za-z0-9\-_.=]{10,}", re.IGNORECASE)),
("Cle d'acces AWS", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
("En-tete Authorization", re.compile(r"\bAuthorization\s*:\s*\S+", re.IGNORECASE)),
("Mot de passe (password=)", re.compile(r"\b(?:password|passwd|pwd)\s*[:=]\s*\S+", re.IGNORECASE)),
("Cle API (api_key=)", re.compile(r"\b(?:api[_-]?key|apikey)\s*[:=]\s*\S+", re.IGNORECASE)),
("Jeton (token=)", re.compile(r"\btoken\s*[:=]\s*\S+", re.IGNORECASE)),
("Chaine longue ressemblant a un secret", re.compile(r"\b[A-Za-z0-9_\-]{32,}\b")),
]
@dataclass
class DetectedSecret:
label: str
excerpt: str
def detect_secrets(text: str) -> list[DetectedSecret]:
"""Renvoie les categories detectees (au plus une occurrence par categorie,
pour un resume lisible), dans l'ordre des motifs (du plus specifique au
plus generique)."""
found: list[DetectedSecret] = []
for label, pattern in _PATTERNS:
match = pattern.search(text)
if match:
excerpt = match.group(0)
if len(excerpt) > 60:
excerpt = excerpt[:57] + "..."
found.append(DetectedSecret(label, excerpt))
return found
def redact(text: str) -> str:
redacted = text
for _label, pattern in _PATTERNS:
redacted = pattern.sub("[MASQUE]", redacted)
return redacted

54
app/ai/service.py Normal file
View File

@@ -0,0 +1,54 @@
"""Execution des requetes IA en arriere-plan : aucun appel reseau ne doit
jamais bloquer le thread Qt."""
from __future__ import annotations
from PySide6.QtCore import QThread, Signal
from app.ai.provider import AIError, AIProvider
KIND_EXPLAIN = "explain"
KIND_DIAGNOSE = "diagnose"
KIND_SOLUTION = "solution"
KIND_GENERATE = "generate"
KIND_ASK = "ask"
KIND_TEST = "test"
class AIRequestWorker(QThread):
"""Une requete = un worker jetable. L'annulation est cooperative : la
requete HTTP en cours (bornee par son timeout) va a son terme, mais le
signal de resultat peut etre ignore par l'appelant (voir AIPanel)."""
finished_ok = Signal(str)
finished_error = Signal(str)
def __init__(self, provider: AIProvider, kind: str, payload: str, context: str = "", parent=None):
super().__init__(parent)
self.provider = provider
self.kind = kind
self.payload = payload
self.context = context
def run(self) -> None:
try:
result = self._execute()
self.finished_ok.emit(result)
except AIError as exc:
self.finished_error.emit(str(exc))
except Exception as exc: # noqa: BLE001 - ne jamais laisser un thread planter silencieusement
self.finished_error.emit(f"Erreur inattendue : {exc}")
def _execute(self) -> str:
if self.kind == KIND_EXPLAIN:
return self.provider.explain_output(self.payload)
if self.kind == KIND_DIAGNOSE:
return self.provider.diagnose(self.payload)
if self.kind == KIND_SOLUTION:
return self.provider.propose_solution(self.payload)
if self.kind == KIND_GENERATE:
return self.provider.generate_command(self.payload)
if self.kind == KIND_ASK:
return self.provider.ask(self.payload, context=self.context)
if self.kind == KIND_TEST:
return self.provider.chat([{"role": "user", "content": "Reponds uniquement 'OK'."}])
raise AIError(f"Type de requete IA inconnu : {self.kind}")

85
app/ai/settings.py Normal file
View File

@@ -0,0 +1,85 @@
"""Configuration de l'assistant IA (optionnel, desactive par defaut).
La cle API n'est jamais stockee ici : seuls les parametres non sensibles
(activation, fournisseur, URL, modele, timeout, temperature) le sont, via
app.app_settings. La cle passe exclusivement par CredentialStore
(app.credentials), jamais dans settings.json/sessions.json/logs."""
from __future__ import annotations
from dataclasses import dataclass
from app.ai.provider import AIProvider, OpenAICompatibleProvider
from app.app_settings import get_setting, set_setting
from app.credentials import CredentialStore
AI_API_KEY_CREDENTIAL = "ai:api_key"
DEFAULT_BASE_URL = "https://api.openai.com/v1"
DEFAULT_PROVIDER_NAME = "OpenAI"
DEFAULT_MODEL = ""
DEFAULT_TIMEOUT = 30.0
DEFAULT_TEMPERATURE = 0.3
@dataclass
class AISettings:
enabled: bool = False
provider_name: str = DEFAULT_PROVIDER_NAME
base_url: str = DEFAULT_BASE_URL
model: str = DEFAULT_MODEL
timeout: float = DEFAULT_TIMEOUT
temperature: float = DEFAULT_TEMPERATURE
def load_ai_settings() -> AISettings:
try:
timeout = float(get_setting("ai_timeout", DEFAULT_TIMEOUT))
except (TypeError, ValueError):
timeout = DEFAULT_TIMEOUT
try:
temperature = float(get_setting("ai_temperature", DEFAULT_TEMPERATURE))
except (TypeError, ValueError):
temperature = DEFAULT_TEMPERATURE
return AISettings(
enabled=bool(get_setting("ai_enabled", False)),
provider_name=get_setting("ai_provider_name", DEFAULT_PROVIDER_NAME) or DEFAULT_PROVIDER_NAME,
base_url=get_setting("ai_base_url", DEFAULT_BASE_URL) or DEFAULT_BASE_URL,
model=get_setting("ai_model", DEFAULT_MODEL) or DEFAULT_MODEL,
timeout=timeout,
temperature=temperature,
)
def save_ai_settings(settings: AISettings) -> None:
set_setting("ai_enabled", settings.enabled)
set_setting("ai_provider_name", settings.provider_name)
set_setting("ai_base_url", settings.base_url)
set_setting("ai_model", settings.model)
set_setting("ai_timeout", settings.timeout)
set_setting("ai_temperature", settings.temperature)
def load_api_key(credential_store: CredentialStore) -> str:
return credential_store.get_secret(AI_API_KEY_CREDENTIAL) or ""
def save_api_key(credential_store: CredentialStore, api_key: str) -> bool:
if api_key:
return credential_store.set_secret(AI_API_KEY_CREDENTIAL, api_key)
credential_store.delete_secret(AI_API_KEY_CREDENTIAL)
return True
def build_provider_from_settings(credential_store: CredentialStore) -> AIProvider | None:
"""None si l'assistant est desactive : point d'appel unique pour tous les
endroits de l'UI qui ont besoin d'un fournisseur pret a l'emploi."""
settings = load_ai_settings()
if not settings.enabled:
return None
return OpenAICompatibleProvider(
base_url=settings.base_url,
api_key=load_api_key(credential_store),
model=settings.model,
timeout=settings.timeout,
temperature=settings.temperature,
)

32
app/app_settings.py Normal file
View File

@@ -0,0 +1,32 @@
"""Preferences globales simples (une cle -> une valeur) dans data/settings.json :
theme, taille de police, dernier workspace ouvert... Un seul fichier partage
entre plusieurs modules plutot qu'un mecanisme de lecture/ecriture par module.
"""
from __future__ import annotations
import json
from app.paths import DATA_DIR
from app.storage.atomic_file import atomic_write_text
SETTINGS_FILE = DATA_DIR / "settings.json"
def _load_all() -> dict:
try:
return json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def get_setting(key: str, default=None):
return _load_all().get(key, default)
def set_setting(key: str, value) -> None:
data = _load_all()
data[key] = value
try:
atomic_write_text(SETTINGS_FILE, json.dumps(data, indent=2))
except OSError:
pass

88
app/bastion.py Normal file
View File

@@ -0,0 +1,88 @@
"""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.credentials import session_credential_key
from app.session_manager import (
AUTH_AUTO,
AUTH_KEY,
BASTION_MANUAL,
BASTION_NONE,
BASTION_OPENSSH,
BASTION_SESSION,
BastionConfig,
SessionStore,
)
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 = ""
# Non vide seulement pour un bastion en mode "session PySSH" : permet de
# reutiliser un mot de passe deja memorise pour cette session (voir
# app.credentials), sans que ce module ait besoin de connaitre le
# CredentialStore lui-meme.
credential_key: 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 = session_store.get(bastion.session_id)
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,
credential_key=session_credential_key(target.id),
)
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}")

View File

View File

@@ -0,0 +1,56 @@
"""Envoi simultane (« Multi-Send ") : une frappe faite dans un onglet terminal part
aussi vers tous les autres onglets terminal ouverts, quand le mode est actif."""
from __future__ import annotations
from collections.abc import Callable, Iterable
from PySide6.QtCore import QObject
from PySide6.QtWidgets import QToolButton, QWidget
class BroadcastManager(QObject):
"""Ne connait pas le QTabWidget : `tabs_provider` lui fournit les onglets courants,
ce qui la garde independante de ConnectionController."""
def __init__(self, tabs_provider: Callable[[], Iterable[QWidget]], parent=None):
super().__init__(parent)
self._tabs_provider = tabs_provider
self.button = QToolButton()
self.button.setText("⇉ Multi")
self.button.setCheckable(True)
self.button.setToolTip(
"Envoi simultane : ce que vous tapez part vers tous les onglets terminal ouverts"
)
@property
def enabled(self) -> bool:
return self.button.isChecked()
def register(self, tab) -> None:
"""A appeler pour chaque nouvel onglet terminal (SSH ou local) susceptible
d'emettre et de recevoir des donnees diffusees."""
tab.terminal.data_to_send.connect(lambda data, source=tab: self._broadcast(data, source))
def _broadcast(self, data: bytes, source) -> None:
if not self.enabled:
return
self._send_to_all(data, exclude=source)
def broadcast_now(self, data: bytes) -> None:
"""Diffuse immediatement vers tous les terminaux, independamment de l'etat
du bouton Multi : declenchement explicite et ponctuel (ex : un snippet
execute avec « Executer avec Multi-Send »), pas une frappe clavier."""
self._send_to_all(data, exclude=None)
def _send_to_all(self, data: bytes, exclude) -> None:
# import tardif : evite un cycle (app.ui.tabs importe des dialogues, pas ce module)
from app.ui.tabs import BaseTerminalTab
for tab in self._tabs_provider():
if tab is exclude or not isinstance(tab, BaseTerminalTab):
continue
if tab.connection:
# on ecrit directement dans la connexion : passer par le terminal
# relancerait la diffusion et boucherait a l'infini
tab.connection.send(data)

View File

@@ -0,0 +1,364 @@
"""Cycle de vie des onglets (terminal SSH, terminal local, SFTP autonome) :
ouverture, connexion rapide, renommage, fermeture."""
from __future__ import annotations
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTabWidget, QWidget
from app.bastion import BastionResolutionError, BastionTarget, resolve_bastion_target
from app.controllers.broadcast_manager import BroadcastManager
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
from app.dialogs.connect_prompt import ask_secret
from app.dialogs.session_dialog import SessionDialog
from app.key_manager import KeyManager
from app.local_terminal import DEFAULT_SHELL, SHELL_LABELS
from app.openssh.config import OpenSSHHost, session_from_openssh_host
from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore
from app.ui.split_pane import LAYOUT_LABELS, PaneSlot, SplitContainer
from app.ui.tabs import BaseTerminalTab, LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget
class ConnectionController(QObject):
"""Possede le QTabWidget central. `session_saved` est emis quand une connexion
rapide est aussi enregistree comme session, pour que le panneau lateral se
rafraichisse sans que ce controleur ait besoin de le connaitre."""
session_saved = Signal()
def __init__(
self,
session_store: SessionStore,
key_manager: KeyManager,
broadcast: BroadcastManager,
snippet_store=None,
credential_store: CredentialStore | None = None,
ai_provider_factory=None,
parent_widget: QWidget | None = None,
):
super().__init__(parent_widget)
self.session_store = session_store
self.key_manager = key_manager
self.broadcast = broadcast
self.snippet_store = snippet_store
self.credential_store = credential_store or NullCredentialStore()
self.ai_provider_factory = ai_provider_factory
self._parent_widget = parent_widget
self.tabs = QTabWidget(parent_widget)
self.tabs.setTabsClosable(True)
self.tabs.setMovable(True)
self.tabs.tabBarDoubleClicked.connect(self.rename_tab)
self.tabs.tabBar().setContextMenuPolicy(Qt.CustomContextMenu)
self.tabs.tabBar().customContextMenuRequested.connect(self._on_tab_context_menu)
self.tabs.tabCloseRequested.connect(self._on_tab_close)
# ---------- ouverture ----------
def _resolve_bastion(self, session: Session) -> tuple[bool, BastionTarget | None, str]:
"""Renvoie (ok, bastion_target, bastion_password). ok=False signifie que
l'ouverture doit etre annulee (bastion invalide, ou mot de passe du
bastion non fourni) ; l'appelant ne doit alors rien ouvrir."""
try:
target = resolve_bastion_target(session.bastion, self.session_store)
except BastionResolutionError as exc:
QMessageBox.critical(self._parent_widget, "Bastion invalide", str(exc))
return False, None, ""
if target is None:
return True, None, ""
if target.auth_type != AUTH_PASSWORD:
return True, target, ""
# Un bastion "session PySSH" reutilise un mot de passe deja memorise pour
# cette session (s'il en existe un) : jamais ecrit depuis ce chemin, on ne
# fait que lire ce que l'ouverture directe de cette session aurait memorise.
if target.credential_key:
stored = self.credential_store.get_secret(target.credential_key)
if stored is not None:
return True, target, stored
text, ok = ask_secret(
self._parent_widget, "Mot de passe du bastion", f"Mot de passe pour {target.label} (bastion) :",
)
if not ok:
return False, None, ""
return True, target, text
def _ask_session_password(self, session: Session) -> tuple[bool, str]:
if session.auth_type != AUTH_PASSWORD:
return True, ""
remember = session.password_storage == PASSWORD_STORAGE_SECURE
key = session_credential_key(session.id)
if remember:
stored = self.credential_store.get_secret(key)
if stored is not None:
return True, stored
text, ok = ask_secret(
self._parent_widget, "Mot de passe", f"Mot de passe pour {session.username}@{session.host} :"
)
if ok and remember:
self.credential_store.set_secret(key, text)
return ok, (text if ok else "")
def build_session_tab(self, session: Session) -> SessionTabWidget | None:
"""Construit l'onglet sans l'inserer dans le QTabWidget (reutilise par
l'ouverture normale et par la restauration d'un workspace dans un panneau).
None si l'utilisateur annule (mot de passe, bastion invalide)."""
ok, bastion_target, bastion_password = self._resolve_bastion(session)
if not ok:
return None
ok, secret = self._ask_session_password(session)
if not ok:
return None
return SessionTabWidget(
session, secret, bastion_target=bastion_target, bastion_password=bastion_password,
snippet_store=self.snippet_store, broadcast=self.broadcast,
ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store,
)
def build_sftp_tab(self, session: Session) -> SFTPTabWidget | None:
ok, bastion_target, bastion_password = self._resolve_bastion(session)
if not ok:
return None
ok, secret = self._ask_session_password(session)
if not ok:
return None
return SFTPTabWidget(session, secret, bastion_target=bastion_target, bastion_password=bastion_password)
def open_session(self, session: Session) -> None:
tab = self.build_session_tab(session)
if tab is None:
return
self.broadcast.register(tab)
index = self.tabs.addTab(tab, f"🖥 {session.name}")
self.tabs.setCurrentIndex(index)
def open_sftp_session(self, session: Session) -> None:
"""Ouvre un onglet SFTP autonome (double panneau) pour cette session."""
tab = self.build_sftp_tab(session)
if tab is None:
return
index = self.tabs.addTab(tab, f"📁 {session.name}")
self.tabs.setCurrentIndex(index)
def open_openssh_host(self, host: OpenSSHHost) -> None:
"""Connexion directe a un Host de ~/.ssh/config, sans l'enregistrer comme
session. Un ProxyJump eventuel est resolu et applique via app.bastion."""
self.open_session(session_from_openssh_host(host))
def open_local_terminal(self, shell: str = DEFAULT_SHELL) -> None:
tab = self.new_local_terminal(shell)
label = SHELL_LABELS.get(shell, shell)
index = self.tabs.addTab(tab, f"💻 {label}")
self.tabs.setCurrentIndex(index)
def new_local_terminal(self, shell: str = DEFAULT_SHELL) -> LocalTerminalTabWidget:
"""Cree un terminal local cable (multi-send, SFTP detecte), sans l'ajouter
au QTabWidget : reutilise pour un onglet normal comme pour un panneau."""
tab = LocalTerminalTabWidget(
shell, snippet_store=self.snippet_store, broadcast=self.broadcast,
ai_provider_factory=self.ai_provider_factory, credential_store=self.credential_store,
)
tab.sftp_requested.connect(self.quick_sftp)
self.broadcast.register(tab)
return tab
def quick_sftp(self, target: dict | None = None) -> None:
"""Ouvre un SFTP en saisissant directement les infos de connexion, sans favori.
Si l'onglet courant est un terminal ou une commande ssh a ete tapee, l'hote,
l'utilisateur et le port sont pre-remplis automatiquement."""
if target is None:
target = self._current_tab_ssh_target()
self._quick_connect(sftp=True, target=target)
def quick_ssh(self) -> None:
self._quick_connect(sftp=False, target=self._current_tab_ssh_target())
def _current_tab_ssh_target(self) -> dict | None:
widget = self.tabs.currentWidget()
if isinstance(widget, SessionTabWidget):
return {
"user": widget.session.username,
"host": widget.session.host,
"port": widget.session.port,
}
if isinstance(widget, BaseTerminalTab):
return widget.detect_current_ssh_target()
return None
def _quick_connect(self, sftp: bool, target: dict | None = None) -> None:
prefill = None
if target:
prefill = Session(
name=target.get("host", ""),
host=target.get("host", ""),
username=target.get("user", ""),
port=target.get("port", 22),
)
dialog = SessionDialog(
self.key_manager,
session=prefill,
parent=self._parent_widget,
connect_label="Se connecter",
title="Connexion SFTP" if sftp else "Connexion SSH",
session_store=self.session_store,
credential_store=self.credential_store,
)
if not dialog.exec():
return
session = dialog.result_session()
if dialog.should_save():
self.session_store.add(session)
self.session_saved.emit()
if sftp:
self.open_sftp_session(session)
else:
self.open_session(session)
# ---------- gestion des onglets ----------
def rename_tab(self, index: int) -> None:
if index < 0:
return
actuel = self.tabs.tabText(index)
nouveau, ok = QInputDialog.getText(
self._parent_widget, "Renommer l'onglet", "Nom de l'onglet :", text=actuel
)
if ok and nouveau.strip():
self.tabs.setTabText(index, nouveau.strip())
def _on_tab_context_menu(self, pos) -> None:
bar = self.tabs.tabBar()
index = bar.tabAt(pos)
if index < 0:
return
widget = self.tabs.widget(index)
menu = QMenu(self._parent_widget)
act_rename = menu.addAction("Renommer... (double-clic)")
menu.addSeparator()
# Diviser : seulement pour un terminal simple (SSH ou local), pas pour
# un SFTP ni pour un split existant (pas d'imbrication de splits).
split_actions = {}
if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
split_menu = menu.addMenu("Diviser")
for kind, label in LAYOUT_LABELS.items():
split_actions[split_menu.addAction(label)] = kind
menu.addSeparator()
act_close = menu.addAction("Fermer cet onglet")
act_close_others = menu.addAction("Fermer les autres onglets")
act_close_others.setEnabled(self.tabs.count() > 1)
action = menu.exec(bar.mapToGlobal(pos))
if action == act_rename:
self.rename_tab(index)
elif action == act_close:
self._on_tab_close(index)
elif action == act_close_others:
# on ferme par la fin : retirer un onglet decale les suivants
cible = self.tabs.widget(index)
for other in range(self.tabs.count() - 1, -1, -1):
if self.tabs.widget(other) is not cible:
self._on_tab_close(other)
elif action in split_actions:
self.split_tab(index, split_actions[action])
def _on_tab_close(self, index: int) -> None:
widget = self.tabs.widget(index)
if isinstance(widget, SplitContainer):
widget.close_all()
elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
widget.close_connection()
self.tabs.removeTab(index)
def close_all(self) -> None:
"""A appeler a la fermeture de l'application : ferme proprement toutes les
connexions (SSH, locales, SFTP) avant que les widgets ne soient detruits."""
for i in range(self.tabs.count()):
widget = self.tabs.widget(i)
if isinstance(widget, SplitContainer):
widget.close_all()
elif isinstance(widget, (BaseTerminalTab, SFTPTabWidget)):
widget.close_connection()
# ---------- split terminal ----------
def split_tab(self, index: int, layout_kind: str) -> None:
"""Transforme l'onglet en une grille de terminaux : le contenu actuel
occupe le premier panneau, les autres recoivent un terminal local par
defaut (remplacable ensuite via le menu ☰ de chaque panneau)."""
widget = self.tabs.widget(index)
if not isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
return
title = self.tabs.tabText(index)
# SplitContainer reparente `widget` dans un de ses panneaux ; Qt retire
# alors tout seul l'onglet correspondant (un widget ne peut avoir qu'un
# parent), en decalant les onglets suivants - inutile donc d'appeler
# removeTab(index) explicitement, ce qui viserait desormais un autre onglet.
container = SplitContainer(widget, layout_kind, self.new_local_terminal)
container.all_panes_closed.connect(
lambda remaining, c=container: self._on_split_collapsed(c, remaining)
)
container.pane_menu_requested.connect(self._show_pane_menu)
self.tabs.insertTab(index, container, f"⊞ {title}")
self.tabs.setCurrentIndex(index)
def _on_split_collapsed(self, container: SplitContainer, remaining: QWidget | None) -> None:
"""Il ne reste plus qu'un panneau : on redeplie vers un onglet normal."""
index = self.tabs.indexOf(container)
if index < 0:
return
self.tabs.removeTab(index)
if remaining is None:
return
title = self.default_title_for(remaining)
self.tabs.insertTab(index, remaining, title)
self.tabs.setCurrentIndex(index)
@staticmethod
def default_title_for(widget: QWidget) -> str:
if isinstance(widget, SessionTabWidget):
return f"🖥 {widget.session.name}"
if isinstance(widget, SFTPTabWidget):
return f"📁 {widget.session.name}"
if isinstance(widget, LocalTerminalTabWidget):
return f"💻 {SHELL_LABELS.get(widget.shell, widget.shell)}"
return "Onglet"
def _movable_tabs(self) -> list[tuple[int, str, QWidget]]:
"""Onglets de premier niveau deplacables vers un panneau (ni SFTP, ni
un split - on ne gere pas les deplacements panneau-a-panneau)."""
result = []
for i in range(self.tabs.count()):
widget = self.tabs.widget(i)
if isinstance(widget, (SessionTabWidget, LocalTerminalTabWidget)):
result.append((i, self.tabs.tabText(i), widget))
return result
def _show_pane_menu(self, pane: PaneSlot, container: SplitContainer) -> None:
menu = QMenu(self._parent_widget)
term_menu = menu.addMenu("Terminal local")
shell_actions = {}
for shell_id, label in SHELL_LABELS.items():
shell_actions[term_menu.addAction(label)] = shell_id
movable = self._movable_tabs()
move_actions = {}
if movable:
menu.addSeparator()
move_menu = menu.addMenu("Deplacer un onglet ici")
for index, title, widget in movable:
move_actions[move_menu.addAction(title)] = (index, widget)
else:
hint = menu.addAction("(aucun autre onglet a deplacer ici)")
hint.setEnabled(False)
action = menu.exec(pane.menu_btn.mapToGlobal(pane.menu_btn.rect().bottomLeft()))
if action in shell_actions:
container.replace_pane_content(pane, self.new_local_terminal(shell_actions[action]))
elif action in move_actions:
index, moved_widget = move_actions[action]
self.move_tab_into_pane(index, container, pane)
def move_tab_into_pane(self, tab_index: int, container: SplitContainer, pane: PaneSlot) -> None:
"""Deplace l'onglet de premier niveau `tab_index` dans `pane` : sa
connexion continue de vivre, seul son emplacement visuel change."""
widget = self.tabs.widget(tab_index)
self.tabs.removeTab(tab_index) # ne ferme pas la connexion : elle continue dans le panneau
container.replace_pane_content(pane, widget)

View File

@@ -0,0 +1,390 @@
"""Panneau lateral : arbre des sessions SSH enregistrees + terminaux locaux, et
leur CRUD (creer / modifier / dupliquer / supprimer). N'ouvre aucune connexion
elle-meme : elle emet des signaux que ConnectionController ecoute."""
from __future__ import annotations
import copy
import uuid
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QMenu,
QMessageBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
from app.dialogs.group_settings_dialog import GroupSettingsDialog
from app.dialogs.session_dialog import SessionDialog
from app.groups import GroupSettingsStore
from app.key_manager import KeyManager
from app.local_terminal import SHELL_LABELS
from app.openssh.config import (
OpenSSHHost,
default_config_path,
load_openssh_hosts,
session_from_openssh_host,
)
from app.session_manager import Session, SessionStore
SESSION_ROLE = Qt.UserRole + 10
LOCAL_SHELL_ROLE = Qt.UserRole + 11
OPENSSH_HOST_ROLE = Qt.UserRole + 12
GROUP_PATH_ROLE = Qt.UserRole + 13
class SessionController(QObject):
open_session_requested = Signal(object) # Session
open_sftp_requested = Signal(object) # Session
open_local_shell_requested = Signal(str) # identifiant de shell
open_openssh_host_requested = Signal(object) # OpenSSHHost
quick_sftp_requested = Signal()
manage_keys_requested = Signal()
manage_known_hosts_requested = Signal()
def __init__(
self,
session_store: SessionStore,
key_manager: KeyManager,
broadcast_button: QToolButton,
parent_widget: QWidget | None = None,
openssh_config_path=None,
group_store: GroupSettingsStore | None = None,
credential_store: CredentialStore | None = None,
):
super().__init__(parent_widget)
self.session_store = session_store
self.key_manager = key_manager
self.group_store = group_store
self.credential_store = credential_store or NullCredentialStore()
self._parent_widget = parent_widget
self._openssh_config_path = openssh_config_path or default_config_path()
self.widget = QWidget(parent_widget)
layout = QVBoxLayout(self.widget)
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(6)
toolbar = QHBoxLayout()
new_btn = QToolButton()
new_btn.setObjectName("primary")
new_btn.setText("+ Session")
new_btn.clicked.connect(lambda: self.new_session())
toolbar.addWidget(new_btn)
term_btn = QToolButton()
term_btn.setText("💻 Terminal")
term_btn.setPopupMode(QToolButton.InstantPopup)
term_menu = QMenu(term_btn)
for shell_id, label in SHELL_LABELS.items():
term_menu.addAction(label, lambda s=shell_id: self.open_local_shell_requested.emit(s))
term_btn.setMenu(term_menu)
toolbar.addWidget(term_btn)
toolbar.addWidget(broadcast_button)
sftp_btn = QToolButton()
sftp_btn.setText("📁 SFTP")
sftp_btn.setToolTip("Ouvrir un gestionnaire de fichiers SFTP (sans session enregistree)")
sftp_btn.clicked.connect(self.quick_sftp_requested.emit)
toolbar.addWidget(sftp_btn)
toolbar.addStretch()
manage_btn = QToolButton()
manage_btn.setText("⚙")
manage_btn.setToolTip("Cles SSH et hotes connus")
manage_btn.setPopupMode(QToolButton.InstantPopup)
manage_menu = QMenu(manage_btn)
manage_menu.addAction("Gestionnaire de cles SSH...", self.manage_keys_requested.emit)
manage_menu.addAction("Gestionnaire des hotes connus...", self.manage_known_hosts_requested.emit)
manage_menu.addSeparator()
manage_menu.addAction("Rafraichir la configuration OpenSSH (~/.ssh/config)", self.reload)
manage_btn.setMenu(manage_menu)
toolbar.addWidget(manage_btn)
layout.addLayout(toolbar)
filter_row = QHBoxLayout()
filter_row.addWidget(QLabel("🏷"))
self.tag_filter_combo = QComboBox()
self.tag_filter_combo.setEditable(True)
self.tag_filter_combo.setInsertPolicy(QComboBox.NoInsert)
self.tag_filter_combo.lineEdit().setPlaceholderText("Filtrer les sessions par tag...")
self.tag_filter_combo.currentTextChanged.connect(self._apply_tag_filter)
filter_row.addWidget(self.tag_filter_combo, 1)
layout.addLayout(filter_row)
self.tree = QTreeWidget()
self.tree.setHeaderHidden(True)
self.tree.itemDoubleClicked.connect(self._on_tree_double_click)
self.tree.setContextMenuPolicy(Qt.CustomContextMenu)
self.tree.customContextMenuRequested.connect(self._on_tree_context_menu)
layout.addWidget(self.tree)
# Sans minimum explicite, la largeur plancher est imposee par le contenu
# (les noms de sessions), ce qui empeche de retrecir vraiment le panneau.
self.widget.setMinimumWidth(120)
self.tree.setMinimumWidth(0)
self.reload()
# ---------- arbre ----------
def reload(self) -> None:
self.tree.clear()
locals_header = QTreeWidgetItem(["💻 Terminaux locaux"])
locals_header.setFlags(locals_header.flags() & ~Qt.ItemIsSelectable)
font = locals_header.font(0)
font.setBold(True)
locals_header.setFont(0, font)
self.tree.addTopLevelItem(locals_header)
for shell, label in SHELL_LABELS.items():
child = QTreeWidgetItem([f" {label}"])
child.setData(0, LOCAL_SHELL_ROLE, shell)
locals_header.addChild(child)
sessions_header = QTreeWidgetItem(["🔐 Sessions SSH"])
sessions_header.setFlags(sessions_header.flags() & ~Qt.ItemIsSelectable)
sessions_header.setFont(0, font)
self.tree.addTopLevelItem(sessions_header)
folder_items: dict[str, QTreeWidgetItem] = {}
def get_folder(path: str) -> QTreeWidgetItem:
if not path:
return sessions_header
if path in folder_items:
return folder_items[path]
parent_path, _, name = path.rpartition("/")
parent_item = get_folder(parent_path)
item = QTreeWidgetItem([f"📁 {name}"])
item.setFlags(item.flags() & ~Qt.ItemIsSelectable)
item.setData(0, GROUP_PATH_ROLE, path)
parent_item.addChild(item)
folder_items[path] = item
return item
for session in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
parent = get_folder(session.group)
label = f"🖥 {session.name} ({session.host})"
if session.tags:
label += " [" + ", ".join(session.tags) + "]"
item = QTreeWidgetItem([label])
item.setData(0, SESSION_ROLE, session.id)
parent.addChild(item)
if not self.session_store.sessions:
hint = QTreeWidgetItem([" (aucune session — bouton « + Session » ou « 📁 SFTP »)"])
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
hint.setForeground(0, Qt.gray)
sessions_header.addChild(hint)
openssh_header = QTreeWidgetItem(["⚙ Configuration OpenSSH"])
openssh_header.setFlags(openssh_header.flags() & ~Qt.ItemIsSelectable)
openssh_header.setFont(0, font)
openssh_header.setToolTip(0, str(self._openssh_config_path))
self.tree.addTopLevelItem(openssh_header)
hosts = load_openssh_hosts(self._openssh_config_path)
for host in hosts:
label = f"🖥 {host.alias} ({host.display_target})"
if host.proxy_jump:
label += f" · via {host.proxy_jump}"
item = QTreeWidgetItem([label])
item.setData(0, OPENSSH_HOST_ROLE, host)
if host.proxy_jump:
item.setToolTip(0, f"ProxyJump : via {host.proxy_jump}")
openssh_header.addChild(item)
if not hosts:
hint = QTreeWidgetItem([f" (aucun host trouve dans {self._openssh_config_path})"])
hint.setFlags(hint.flags() & ~Qt.ItemIsSelectable)
hint.setForeground(0, Qt.gray)
openssh_header.addChild(hint)
self.tree.expandAll()
self._reload_tag_filter()
self._apply_tag_filter()
def _reload_tag_filter(self) -> None:
current = self.tag_filter_combo.currentText()
self.tag_filter_combo.blockSignals(True)
self.tag_filter_combo.clear()
self.tag_filter_combo.addItem("") # aucun filtre
self.tag_filter_combo.addItems(self.session_store.tags())
self.tag_filter_combo.setCurrentText(current)
self.tag_filter_combo.blockSignals(False)
def _apply_tag_filter(self, _text: str = "") -> None:
query = self.tag_filter_combo.currentText().strip().lower()
def visit(item: QTreeWidgetItem) -> None:
session = self._session_from_item(item)
if session is not None:
item.setHidden(bool(query) and not any(query in t.lower() for t in session.tags))
for i in range(item.childCount()):
visit(item.child(i))
for i in range(self.tree.topLevelItemCount()):
visit(self.tree.topLevelItem(i))
def _session_from_item(self, item: QTreeWidgetItem) -> Session | None:
if not item:
return None
session_id = item.data(0, SESSION_ROLE)
if not session_id:
return None
return self.session_store.get(session_id)
def _shell_from_item(self, item: QTreeWidgetItem) -> str | None:
if not item:
return None
return item.data(0, LOCAL_SHELL_ROLE)
def _openssh_host_from_item(self, item: QTreeWidgetItem) -> OpenSSHHost | None:
if not item:
return None
return item.data(0, OPENSSH_HOST_ROLE)
def _group_from_item(self, item: QTreeWidgetItem) -> str | None:
if not item:
return None
return item.data(0, GROUP_PATH_ROLE)
def _on_tree_double_click(self, item: QTreeWidgetItem, _col: int) -> None:
session = self._session_from_item(item)
if session:
self.open_session_requested.emit(session)
return
shell = self._shell_from_item(item)
if shell:
self.open_local_shell_requested.emit(shell)
return
host = self._openssh_host_from_item(item)
if host:
self.open_openssh_host_requested.emit(host)
def _on_tree_context_menu(self, pos) -> None:
item = self.tree.itemAt(pos)
menu = QMenu(self.widget)
session = self._session_from_item(item) if item else None
shell = self._shell_from_item(item) if item else None
openssh_host = self._openssh_host_from_item(item) if item else None
group_path = self._group_from_item(item) if item else None
if session:
connect_action = menu.addAction("🖥 Ouvrir un terminal SSH")
sftp_action = menu.addAction("📁 Ouvrir en SFTP (gestionnaire de fichiers)")
menu.addSeparator()
edit_action = menu.addAction("Modifier...")
duplicate_action = menu.addAction("Dupliquer")
delete_action = menu.addAction("Supprimer")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == connect_action:
self.open_session_requested.emit(session)
elif action == sftp_action:
self.open_sftp_requested.emit(session)
elif action == edit_action:
self.edit_session(session)
elif action == duplicate_action:
self.duplicate_session(session)
elif action == delete_action:
self.delete_session(session)
elif shell:
open_action = menu.addAction("Ouvrir")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == open_action:
self.open_local_shell_requested.emit(shell)
elif openssh_host:
connect_action = menu.addAction("🖥 Se connecter")
import_action = menu.addAction("Importer comme session PySSH...")
menu.addSeparator()
refresh_action = menu.addAction("🔄 Rafraichir la configuration")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == connect_action:
self.open_openssh_host_requested.emit(openssh_host)
elif action == import_action:
self.import_openssh_host(openssh_host)
elif action == refresh_action:
self.reload()
elif group_path:
new_in_group_action = menu.addAction("Nouvelle session dans ce dossier...")
menu.addSeparator()
group_settings_action = menu.addAction("Parametres du groupe...")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == new_in_group_action:
self.new_session(group=group_path)
elif action == group_settings_action:
self.open_group_settings(group_path)
else:
new_action = menu.addAction("Nouvelle session...")
action = menu.exec(self.tree.viewport().mapToGlobal(pos))
if action == new_action:
self.new_session()
# ---------- CRUD ----------
def new_session(self, group: str = "") -> None:
prefill = Session(group=group) if group else None
dialog = SessionDialog(
self.key_manager, session=prefill, parent=self._parent_widget,
session_store=self.session_store, group_store=self.group_store,
credential_store=self.credential_store,
)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()
def edit_session(self, session: Session) -> None:
dialog = SessionDialog(
self.key_manager, session=session, parent=self._parent_widget,
session_store=self.session_store, group_store=self.group_store,
credential_store=self.credential_store,
)
if dialog.exec():
self.session_store.update(dialog.result_session())
self.reload()
def duplicate_session(self, session: Session) -> None:
new_session = copy.deepcopy(session)
new_session.id = str(uuid.uuid4())
new_session.name = f"{session.name} (copie)"
self.session_store.add(new_session)
self.reload()
def delete_session(self, session: Session) -> None:
if QMessageBox.question(
self._parent_widget, "Supprimer", f"Supprimer la session '{session.name}' ?"
) == QMessageBox.Yes:
self.session_store.delete(session.id)
self.credential_store.delete_secret(session_credential_key(session.id))
self.reload()
# ---------- OpenSSH config ----------
def import_openssh_host(self, host: OpenSSHHost) -> None:
"""Ouvre l'editeur de session pre-rempli depuis ce Host, pour permettre
d'ajuster avant d'enregistrer (comme une connexion rapide)."""
dialog = SessionDialog(
self.key_manager, session=session_from_openssh_host(host), parent=self._parent_widget,
title=f"Importer « {host.alias} » comme session", session_store=self.session_store,
group_store=self.group_store, credential_store=self.credential_store,
)
if dialog.exec():
self.session_store.add(dialog.result_session())
self.reload()
# ---------- parametres de groupe ----------
def open_group_settings(self, group_path: str) -> None:
if self.group_store is None:
return
dialog = GroupSettingsDialog(
group_path, self.group_store, self.key_manager, self.session_store, parent=self._parent_widget,
)
dialog.exec()

View File

@@ -0,0 +1,184 @@
"""Capture et restauration de l'etat de l'espace de travail (onglets, splits,
tailles) : le pont entre ConnectionController (etat d'ecran reel) et
WorkspaceStore (persistance)."""
from __future__ import annotations
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QWidget
from app.app_settings import get_setting, set_setting
from app.controllers.connection_controller import ConnectionController
from app.local_terminal import DEFAULT_SHELL
from app.session_manager import SessionStore
from app.ui.split_pane import LAYOUT_SINGLE, SplitContainer
from app.ui.tabs import LocalTerminalTabWidget, SessionTabWidget, SFTPTabWidget
from app.workspace import (
ITEM_LOCAL_SHELL,
ITEM_SESSION_SFTP,
ITEM_SESSION_SSH,
Workspace,
WorkspaceItem,
WorkspaceStore,
WorkspaceTab,
)
SETTING_RESTORE_ON_STARTUP = "restore_last_workspace"
SETTING_LAST_WORKSPACE_ID = "last_workspace_id"
class WorkspaceManager(QObject):
workspaces_changed = Signal()
def __init__(
self,
store: WorkspaceStore,
session_store: SessionStore,
connection_controller: ConnectionController,
parent_widget: QWidget | None = None,
):
super().__init__(parent_widget)
self.store = store
self.session_store = session_store
self.cc = connection_controller
self._parent_widget = parent_widget
# ---------- capture (Enregistrer) ----------
def _item_for_widget(self, widget) -> WorkspaceItem | None:
"""None = contenu non restaurable (ex: onglet SFTP dans un panneau,
type inconnu) : l'appelant doit alors sauter cet onglet/panneau."""
if isinstance(widget, SessionTabWidget):
return WorkspaceItem(kind=ITEM_SESSION_SSH, session_id=widget.session.id)
if isinstance(widget, SFTPTabWidget):
return WorkspaceItem(kind=ITEM_SESSION_SFTP, session_id=widget.session.id)
if isinstance(widget, LocalTerminalTabWidget):
return WorkspaceItem(kind=ITEM_LOCAL_SHELL, shell=widget.shell)
return None
def capture(self, name: str = "") -> Workspace:
"""Construit un Workspace a partir de l'etat actuel des onglets. Un
onglet dont un panneau n'est pas restaurable (aucun cas actuellement,
prevu pour un futur type de panneau) est simplement omis plutot que de
faire echouer toute la capture."""
tabs = self.cc.tabs
workspace_tabs: list[WorkspaceTab] = []
for i in range(tabs.count()):
widget = tabs.widget(i)
title = tabs.tabText(i)
if isinstance(widget, SplitContainer):
items = [self._item_for_widget(p.content) for p in widget.panes]
if not items or any(it is None for it in items):
continue
workspace_tabs.append(WorkspaceTab(
title=title, layout=widget.layout_kind, items=items,
splitter_sizes=widget.get_layout_sizes(),
))
else:
item = self._item_for_widget(widget)
if item is None:
continue
workspace_tabs.append(WorkspaceTab(title=title, layout=LAYOUT_SINGLE, items=[item]))
return Workspace(name=name, tabs=workspace_tabs)
def save_as_new(self, name: str) -> Workspace:
workspace = self.capture(name)
self.store.add(workspace)
self.workspaces_changed.emit()
return workspace
def save_over(self, workspace_id: str) -> Workspace | None:
"""Ecrase un workspace existant avec l'etat actuel (garde son nom)."""
existing = self.store.get(workspace_id)
if existing is None:
return None
workspace = self.capture(existing.name)
workspace.id = existing.id
self.store.update(workspace)
self.workspaces_changed.emit()
return workspace
def rename(self, workspace_id: str, new_name: str) -> None:
workspace = self.store.get(workspace_id)
if workspace is None:
return
workspace.name = new_name
self.store.update(workspace)
self.workspaces_changed.emit()
def delete(self, workspace_id: str) -> None:
self.store.delete(workspace_id)
if get_setting(SETTING_LAST_WORKSPACE_ID) == workspace_id:
set_setting(SETTING_LAST_WORKSPACE_ID, "")
self.workspaces_changed.emit()
# ---------- restauration (Ouvrir) ----------
def _widget_for_item(self, item: WorkspaceItem):
"""None = reference introuvable (session supprimee depuis) : l'appelant
doit sauter cet element plutot qu'echouer toute la restauration."""
if item.kind == ITEM_LOCAL_SHELL:
return self.cc.new_local_terminal(item.shell or DEFAULT_SHELL)
session = self.session_store.get(item.session_id)
if session is None:
return None
if item.kind == ITEM_SESSION_SSH:
widget = self.cc.build_session_tab(session)
elif item.kind == ITEM_SESSION_SFTP:
widget = self.cc.build_sftp_tab(session)
else:
return None
if widget is not None:
self.cc.broadcast.register(widget)
return widget
def restore(self, workspace: Workspace) -> int:
"""Ouvre chaque onglet du workspace. Renvoie le nombre d'onglets/
panneaux ignores (reference introuvable), pour que l'appelant puisse
en informer l'utilisateur si besoin."""
skipped = 0
for wtab in workspace.tabs:
skipped += self._restore_tab(wtab)
set_setting(SETTING_LAST_WORKSPACE_ID, workspace.id)
return skipped
def _restore_tab(self, wtab: WorkspaceTab) -> int:
if not wtab.items:
return 0
if wtab.layout == LAYOUT_SINGLE or len(wtab.items) == 1:
widget = self._widget_for_item(wtab.items[0])
if widget is None:
return 1
index = self.cc.tabs.addTab(widget, wtab.title or self.cc.default_title_for(widget))
self.cc.tabs.setCurrentIndex(index)
return 0
first = self._widget_for_item(wtab.items[0])
if first is None:
return len(wtab.items)
index = self.cc.tabs.addTab(first, wtab.title or "⊞ Split")
self.cc.split_tab(index, wtab.layout)
container = self.cc.tabs.widget(index)
skipped = 0
# strict=True est volontairement evite : un workspace legerement perime
# (enregistre par une version anterieure, ou modifie a la main) peut avoir
# une liste d'items plus courte que le nombre de panneaux - on restaure
# alors ce qui correspond et on laisse les panneaux excedentaires a leur
# terminal local par defaut, plutot que d'abandonner toute la restauration.
for pane, item in zip(container.panes[1:], wtab.items[1:]): # noqa: B905
widget = self._widget_for_item(item)
if widget is None:
skipped += 1
continue
container.replace_pane_content(pane, widget)
if wtab.splitter_sizes:
container.set_layout_sizes(wtab.splitter_sizes)
return skipped
# ---------- restauration automatique au demarrage ----------
def try_restore_last_on_startup(self) -> bool:
if not get_setting(SETTING_RESTORE_ON_STARTUP, False):
return False
workspace_id = get_setting(SETTING_LAST_WORKSPACE_ID, "")
workspace = self.store.get(workspace_id) if workspace_id else None
if workspace is None or not workspace.tabs:
return False
self.restore(workspace)
return True

128
app/credentials.py Normal file
View File

@@ -0,0 +1,128 @@
"""Stockage securise optionnel des secrets (mots de passe de session, cles API
de l'assistant IA...).
Par defaut, PySSH ne memorise aucun secret : ils sont redemandes a chaque
usage. CredentialStore permet d'activer, au cas par cas, une memorisation via
le trousseau du systeme - jamais en clair dans un fichier JSON, jamais logue.
Sous Windows : Windows Credential Manager, via les fonctions CredWrite/
CredRead/CredDelete exposees par pywin32-ctypes (deja une dependance
existante de l'application - inutile d'ajouter pywin32 ou keyring rien que
pour ceci). Le secret est protege par DPAPI au niveau du systeme
d'exploitation ; PySSH ne manipule jamais la cle de chiffrement elle-meme.
Ailleurs (Linux/macOS) : aucun trousseau integre equivalent sans dependance
supplementaire pour l'instant. NullCredentialStore signale simplement
l'indisponibilite plutot que d'ecrire un secret en clair, pour ne jamais
donner une fausse impression de securite. C'est le point d'extension prevu
pour un futur backend (ex: Secret Service / Keychain).
"""
from __future__ import annotations
import abc
import os
_TARGET_PREFIX = "PySSHClient/"
def session_credential_key(session_id: str) -> str:
return f"session:{session_id}"
class CredentialStore(abc.ABC):
"""Une `key` est un identifiant applicatif (ex: "session:<id>",
"ai:api_key") : jamais le secret lui-meme."""
@abc.abstractmethod
def is_available(self) -> bool:
"""False si aucun trousseau securise n'est utilisable sur ce poste."""
@abc.abstractmethod
def set_secret(self, key: str, secret: str) -> bool:
"""Memorise `secret`. Renvoie False en cas d'echec (jamais d'exception)."""
@abc.abstractmethod
def get_secret(self, key: str) -> str | None:
"""None si absent ou si le trousseau est indisponible."""
@abc.abstractmethod
def delete_secret(self, key: str) -> None:
"""Ne leve jamais si la cle est deja absente."""
class NullCredentialStore(CredentialStore):
"""Repli quand aucun trousseau securise n'est disponible."""
def is_available(self) -> bool:
return False
def set_secret(self, key: str, secret: str) -> bool:
return False
def get_secret(self, key: str) -> str | None:
return None
def delete_secret(self, key: str) -> None:
pass
class WindowsCredentialStore(CredentialStore):
"""Windows Credential Manager (CRED_TYPE_GENERIC)."""
def __init__(self):
try:
import win32ctypes.pywin32.win32cred as win32cred
self._win32cred = win32cred
except ImportError:
self._win32cred = None
def is_available(self) -> bool:
return self._win32cred is not None
def _target(self, key: str) -> str:
return _TARGET_PREFIX + key
def set_secret(self, key: str, secret: str) -> bool:
if self._win32cred is None:
return False
try:
self._win32cred.CredWrite({
"Type": self._win32cred.CRED_TYPE_GENERIC,
"TargetName": self._target(key),
"UserName": os.environ.get("USERNAME", "pyssh"),
"CredentialBlob": secret,
"Comment": "PySSH Client",
"Persist": self._win32cred.CRED_PERSIST_LOCAL_MACHINE,
}, 0)
return True
except Exception: # noqa: BLE001 - jamais bloquant : au pire, on redemandera le secret
return False
def get_secret(self, key: str) -> str | None:
if self._win32cred is None:
return None
try:
cred = self._win32cred.CredRead(self._target(key), self._win32cred.CRED_TYPE_GENERIC, 0)
except Exception: # noqa: BLE001 - absent ou illisible : redemander est le repli correct
return None
blob = cred.get("CredentialBlob") or b""
try:
return blob.decode("utf-16-le")
except UnicodeDecodeError:
return None
def delete_secret(self, key: str) -> None:
if self._win32cred is None:
return
try:
self._win32cred.CredDelete(self._target(key), self._win32cred.CRED_TYPE_GENERIC, 0)
except Exception: # noqa: BLE001 - deja absent, ou trousseau indisponible : rien a faire
pass
def default_credential_store() -> CredentialStore:
if os.name == "nt":
store = WindowsCredentialStore()
if store.is_available():
return store
return NullCredentialStore()

View File

@@ -0,0 +1,85 @@
"""Affiche une commande proposee par l'assistant IA : jamais executee
automatiquement, seulement copiable ou inseree dans le terminal (sans Entree)
apres verification par l'utilisateur."""
from __future__ import annotations
import re
from PySide6.QtWidgets import (
QApplication,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QTextEdit,
QVBoxLayout,
)
_CODE_BLOCK_RE = re.compile(r"```(?:[a-zA-Z0-9_+-]*\n)?(.*?)```", re.DOTALL)
def extract_command(response: str) -> str:
"""Prend le contenu du premier bloc de code s'il y en a un (le prompt
systeme en demande un), sinon la reponse entiere : mieux vaut proposer un
texte un peu trop large que perdre silencieusement la commande."""
match = _CODE_BLOCK_RE.search(response)
text = match.group(1) if match else response
return text.strip()
class AICommandDialog(QDialog):
def __init__(self, response: str, dangerous_labels: list[str] | None = None, parent=None):
super().__init__(parent)
self.setWindowTitle("Commande generee par l'assistant IA")
self.setMinimumSize(520, 360)
self.command = extract_command(response)
self.insert_requested = False
layout = QVBoxLayout(self)
if dangerous_labels:
warning = QLabel(
"⚠ Commande potentiellement destructive : " + ", ".join(dangerous_labels)
)
warning.setWordWrap(True)
warning.setStyleSheet("color: #c0392b; font-weight: bold;")
layout.addWidget(warning)
layout.addWidget(QLabel("Reponse complete de l'assistant :"))
self.response_view = QTextEdit(response)
self.response_view.setReadOnly(True)
layout.addWidget(self.response_view, 1)
layout.addWidget(QLabel("Commande proposee (celle qui sera copiee/inseree) :"))
self.command_view = QTextEdit(self.command)
self.command_view.setFixedHeight(70)
layout.addWidget(self.command_view)
self.status_label = QLabel("Cette commande ne sera jamais executee automatiquement.")
self.status_label.setObjectName("statusLabel")
layout.addWidget(self.status_label)
btn_row = QHBoxLayout()
self.copy_btn = QPushButton("Copier")
self.insert_btn = QPushButton("Inserer dans le terminal")
self.cancel_btn = QPushButton("Annuler")
for b in (self.copy_btn, self.insert_btn, self.cancel_btn):
btn_row.addWidget(b)
layout.addLayout(btn_row)
self.copy_btn.clicked.connect(self._copy)
self.insert_btn.clicked.connect(self._insert)
self.cancel_btn.clicked.connect(self.reject)
def _current_command(self) -> str:
# l'utilisateur peut corriger la commande proposee avant de l'utiliser
return self.command_view.toPlainText().strip()
def _copy(self) -> None:
QApplication.clipboard().setText(self._current_command())
self.status_label.setText("Copie dans le presse-papiers.")
def _insert(self) -> None:
self.command = self._current_command()
self.insert_requested = True
self.accept()

70
app/dialogs/ai_prompts.py Normal file
View File

@@ -0,0 +1,70 @@
"""Confirmations de securite avant tout envoi de contenu au fournisseur IA :
un avertissement de principe affiche une seule fois, puis une verification
systematique des secrets evidents a chaque envoi d'une selection de terminal
ou d'une demande libre."""
from __future__ import annotations
from PySide6.QtWidgets import QMessageBox
from app.ai.redaction import detect_secrets
from app.app_settings import get_setting, set_setting
_NOTICE_ACK_KEY = "ai_notice_acknowledged"
MASK = "mask"
SEND = "send"
CANCEL = "cancel"
def ensure_ai_notice_acknowledged(parent) -> bool:
"""Affiche l'avertissement de principe une seule fois (jamais reaffiche
ensuite, l'accord etant persiste). Renvoie False si l'utilisateur refuse :
l'appelant ne doit alors rien envoyer."""
if get_setting(_NOTICE_ACK_KEY, False):
return True
box = QMessageBox(parent)
box.setWindowTitle("Assistant IA")
box.setIcon(QMessageBox.Information)
box.setText(
"Le texte selectionne dans le terminal (ou votre question) va etre envoye "
"au fournisseur IA que vous avez configure, en dehors de cet ordinateur.\n\n"
"PySSH tente de detecter les secrets evidents (mots de passe, cles, jetons) "
"avant chaque envoi et vous permet de les masquer, mais ne peut pas tout "
"detecter : verifiez ce que vous envoyez.\n\n"
"Ce message ne sera plus affiche."
)
box.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
box.setDefaultButton(QMessageBox.Ok)
accepted = box.exec() == QMessageBox.Ok
if accepted:
set_setting(_NOTICE_ACK_KEY, True)
return accepted
def confirm_sensitive_content(parent, text: str) -> str:
"""Renvoie MASK, SEND ou CANCEL. Si aucun secret evident n'est detecte,
renvoie SEND directement sans rien afficher."""
secrets = detect_secrets(text)
if not secrets:
return SEND
labels = "\n".join(f"- {s.label}" for s in secrets)
box = QMessageBox(parent)
box.setWindowTitle("Donnees potentiellement sensibles")
box.setIcon(QMessageBox.Warning)
box.setText(
"Des donnees potentiellement sensibles ont ete detectees dans le texte "
f"qui serait envoye au fournisseur IA :\n\n{labels}\n\n"
"« Masquer automatiquement » les remplace par [MASQUE] avant l'envoi.\n"
"« Voir le contenu » l'envoie tel quel, sans modification."
)
mask_btn = box.addButton("Masquer automatiquement", QMessageBox.AcceptRole)
send_btn = box.addButton("Voir le contenu", QMessageBox.DestructiveRole)
box.addButton("Annuler", QMessageBox.RejectRole)
box.setDefaultButton(mask_btn)
box.exec()
clicked = box.clickedButton()
if clicked is mask_btn:
return MASK
if clicked is send_btn:
return SEND
return CANCEL

View File

@@ -0,0 +1,213 @@
"""Configuration de l'assistant IA (optionnel, desactive par defaut) : aucune
liste figee de MODELES, juste un nom de modele libre compatible avec l'API
OpenAI. Le fournisseur, lui, propose une liste deroulante de raccourcis usuels
(cloud et LLM local via Ollama/LM Studio) qui pre-remplissent l'URL de base -
mais reste modifiable librement ("Personnalise...") pour tout autre service
compatible OpenAI."""
from __future__ import annotations
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
)
from app.ai.provider import OpenAICompatibleProvider
from app.ai.service import KIND_TEST, AIRequestWorker
from app.ai.settings import AISettings, load_ai_settings, load_api_key, save_ai_settings, save_api_key
from app.credentials import CredentialStore, NullCredentialStore
# (nom affiche, URL de base) - un point de depart, pas une liste fermee : le
# champ reste editable, pour tout fournisseur/proxy/LLM local compatible avec
# l'API OpenAI mais absent d'ici.
PROVIDER_PRESETS: list[tuple[str, str]] = [
("OpenAI", "https://api.openai.com/v1"),
("OpenRouter", "https://openrouter.ai/api/v1"),
("Mistral AI", "https://api.mistral.ai/v1"),
("Ollama (local)", "http://localhost:11434/v1"),
("LM Studio (local)", "http://localhost:1234/v1"),
("Personnalise...", ""),
]
def _looks_local(base_url: str) -> bool:
return "localhost" in base_url or "127.0.0.1" in base_url
class AISettingsDialog(QDialog):
def __init__(self, credential_store: CredentialStore | None = None, parent=None):
super().__init__(parent)
self.credential_store = credential_store or NullCredentialStore()
self._test_worker: AIRequestWorker | None = None
self.setWindowTitle("Assistant IA")
self.setMinimumWidth(460)
settings = load_ai_settings()
api_key = load_api_key(self.credential_store)
layout = QVBoxLayout(self)
form = QFormLayout()
self.enabled_check = QCheckBox("Activer l'assistant IA")
self.enabled_check.setChecked(settings.enabled)
self.enabled_check.setToolTip(
"Desactive par defaut. Aucune donnee n'est envoyee a un service externe "
"tant que cette case n'est pas cochee et qu'un modele n'est pas configure."
)
form.addRow("", self.enabled_check)
self.provider_combo = QComboBox()
self.provider_combo.setEditable(True)
self.provider_combo.setToolTip(
"Choisissez un raccourci (pre-remplit l'URL de base) ou saisissez "
"librement le nom de tout autre fournisseur/proxy compatible OpenAI."
)
for label, url in PROVIDER_PRESETS:
self.provider_combo.addItem(label, url)
self.provider_combo.setCurrentText(settings.provider_name)
# activated (et non currentIndexChanged) : ne se declenche que sur un
# choix explicite dans la liste, jamais pendant la saisie libre d'un
# nom de fournisseur personnalise.
self.provider_combo.activated.connect(self._on_provider_preset_chosen)
form.addRow("Fournisseur :", self.provider_combo)
self.base_url_edit = QLineEdit(settings.base_url)
self.base_url_edit.setPlaceholderText("https://api.openai.com/v1")
form.addRow("URL de base :", self.base_url_edit)
self.model_edit = QLineEdit(settings.model)
self.model_edit.setPlaceholderText("ex : gpt-4o-mini, llama3.2, qwen2.5-coder...")
form.addRow("Modele :", self.model_edit)
self._credential_store_available = self.credential_store.is_available()
self.api_key_edit = QLineEdit(api_key)
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.api_key_edit.setPlaceholderText("Laisser vide si le fournisseur n'en requiert pas")
if not self._credential_store_available:
self.api_key_edit.setEnabled(False)
self.api_key_edit.setPlaceholderText("Aucun trousseau securise disponible sur ce systeme")
form.addRow("Cle API :", self.api_key_edit)
self.timeout_spin = QDoubleSpinBox()
self.timeout_spin.setRange(1.0, 600.0)
self.timeout_spin.setSuffix(" s")
self.timeout_spin.setValue(settings.timeout)
form.addRow("Delai d'attente :", self.timeout_spin)
self.temperature_spin = QDoubleSpinBox()
self.temperature_spin.setRange(0.0, 2.0)
self.temperature_spin.setSingleStep(0.1)
self.temperature_spin.setValue(settings.temperature)
form.addRow("Temperature :", self.temperature_spin)
layout.addLayout(form)
note = QLabel(
"La cle API est memorisee dans le trousseau securise du systeme, jamais "
"dans un fichier de configuration. L'assistant est entierement optionnel : "
"aucune fonctionnalite existante n'en depend."
)
note.setWordWrap(True)
note.setObjectName("statusLabel")
layout.addWidget(note)
test_row = QVBoxLayout()
self.test_btn = QPushButton("Tester la connexion")
self.test_btn.clicked.connect(self._test_connection)
self.test_status = QLabel("")
self.test_status.setWordWrap(True)
test_row.addWidget(self.test_btn)
test_row.addWidget(self.test_status)
layout.addLayout(test_row)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
# ---------- fournisseur ----------
def _on_provider_preset_chosen(self, index: int) -> None:
_label, url = PROVIDER_PRESETS[index]
if url:
self.base_url_edit.setText(url)
if not self._credential_store_available:
return
if _looks_local(url):
self.api_key_edit.setPlaceholderText("Generalement inutile pour un LLM local")
else:
self.api_key_edit.setPlaceholderText("Laisser vide si le fournisseur n'en requiert pas")
# ---------- lecture du formulaire ----------
def _form_settings(self) -> AISettings:
return AISettings(
enabled=self.enabled_check.isChecked(),
provider_name=self.provider_combo.currentText().strip() or "OpenAI",
base_url=self.base_url_edit.text().strip(),
model=self.model_edit.text().strip(),
timeout=self.timeout_spin.value(),
temperature=self.temperature_spin.value(),
)
def _build_provider(self) -> OpenAICompatibleProvider | None:
settings = self._form_settings()
if not settings.base_url or not settings.model:
QMessageBox.warning(
self, "Configuration incomplete",
"Renseignez au moins l'URL de base et le modele avant de tester.",
)
return None
return OpenAICompatibleProvider(
base_url=settings.base_url,
api_key=self.api_key_edit.text().strip(),
model=settings.model,
timeout=settings.timeout,
temperature=settings.temperature,
)
# ---------- test de connexion ----------
def _test_connection(self) -> None:
if self._test_worker is not None and self._test_worker.isRunning():
return
provider = self._build_provider()
if provider is None:
return
self.test_btn.setEnabled(False)
self.test_status.setText("Test en cours...")
self._test_worker = AIRequestWorker(provider, KIND_TEST, "")
self._test_worker.finished_ok.connect(self._on_test_ok)
self._test_worker.finished_error.connect(self._on_test_error)
self._test_worker.start()
def _on_test_ok(self, _text: str) -> None:
self.test_btn.setEnabled(True)
self.test_status.setText("✓ Connexion reussie.")
def _on_test_error(self, message: str) -> None:
self.test_btn.setEnabled(True)
self.test_status.setText(f"✗ Echec : {message}")
# ---------- validation ----------
def _on_accept(self) -> None:
settings = self._form_settings()
if settings.enabled and (not settings.base_url or not settings.model):
QMessageBox.warning(
self, "Configuration incomplete",
"Renseignez au moins l'URL de base et le modele pour activer l'assistant IA.",
)
return
save_ai_settings(settings)
save_api_key(self.credential_store, self.api_key_edit.text().strip())
self.accept()
def closeEvent(self, event) -> None:
if self._test_worker is not None and self._test_worker.isRunning():
self._test_worker.wait(2000)
super().closeEvent(event)

View File

@@ -0,0 +1,232 @@
"""Valeurs par defaut d'un groupe : appliquees en pre-remplissage a la creation
d'une nouvelle session dans ce dossier (voir app.groups)."""
from __future__ import annotations
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from app.groups import GroupSettings, GroupSettingsStore
from app.key_manager import KeyManager
from app.openssh.config import load_openssh_hosts
from app.session_manager import (
AUTH_AGENT,
AUTH_AUTO,
AUTH_KEY,
AUTH_PASSWORD,
BASTION_MANUAL,
BASTION_NONE,
BASTION_OPENSSH,
BASTION_SESSION,
BastionConfig,
SessionStore,
)
from app.ui.auto_height_stack import AutoHeightStackedWidget
class GroupSettingsDialog(QDialog):
"""Meme principe que la section Bastion de SessionDialog, en plus compact :
ce sont des valeurs par defaut, pas la configuration d'une connexion reelle."""
def __init__(
self, group_path: str, store: GroupSettingsStore, key_manager: KeyManager,
session_store: SessionStore, parent=None,
):
super().__init__(parent)
self.group_path = group_path
self.store = store
self.key_manager = key_manager
self.session_store = session_store
self.settings = store.get(group_path) or GroupSettings(path=group_path)
self.setWindowTitle(f"Parametres par defaut — {group_path}")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
info = QLabel(
"Ces valeurs pre-remplissent une nouvelle session creee dans ce dossier "
"(bouton « Heriter du groupe » dans son editeur). Elles ne changent jamais "
"les sessions deja creees."
)
info.setWordWrap(True)
layout.addWidget(info)
form = QFormLayout()
self.username_edit = QLineEdit(self.settings.username)
self.port_spin = QSpinBox()
self.port_spin.setRange(1, 65535)
self.port_spin.setValue(self.settings.port or 22)
self.auth_combo = QComboBox()
self.auth_combo.addItem("Automatique (comme la commande ssh)", AUTH_AUTO)
self.auth_combo.addItem("Cle SSH choisie", AUTH_KEY)
self.auth_combo.addItem("Mot de passe (saisi a la connexion)", AUTH_PASSWORD)
self.auth_combo.addItem("Agent SSH", AUTH_AGENT)
idx = self.auth_combo.findData(self.settings.auth_type)
self.auth_combo.setCurrentIndex(idx if idx >= 0 else 0)
self.key_combo = QComboBox()
for entry in self.key_manager.keys:
self.key_combo.addItem(f"{entry.name} ({entry.key_type})", entry.private_path)
self.browse_key_btn = QPushButton("Parcourir...")
key_row = QWidget()
key_layout = QHBoxLayout(key_row)
key_layout.setContentsMargins(0, 0, 0, 0)
key_layout.addWidget(self.key_combo, 1)
key_layout.addWidget(self.browse_key_btn)
if self.settings.key_path:
existing = self.key_combo.findData(self.settings.key_path)
if existing >= 0:
self.key_combo.setCurrentIndex(existing)
else:
self.key_combo.addItem(self.settings.key_path, self.settings.key_path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
form.addRow("Utilisateur :", self.username_edit)
form.addRow("Port :", self.port_spin)
form.addRow("Authentification :", self.auth_combo)
form.addRow("Cle privee :", key_row)
layout.addLayout(form)
layout.addWidget(self._build_bastion_group())
keepalive_form = QFormLayout()
self.keepalive_check = QCheckBox("Keepalive actif")
self.keepalive_check.setChecked(self.settings.keepalive_enabled)
self.keepalive_interval_spin = QSpinBox()
self.keepalive_interval_spin.setRange(5, 3600)
self.keepalive_interval_spin.setSuffix(" s")
self.keepalive_interval_spin.setValue(self.settings.keepalive_interval or 30)
self.keepalive_count_spin = QSpinBox()
self.keepalive_count_spin.setRange(1, 20)
self.keepalive_count_spin.setValue(self.settings.keepalive_count_max or 3)
keepalive_form.addRow(self.keepalive_check)
keepalive_form.addRow("Intervalle :", self.keepalive_interval_spin)
keepalive_form.addRow("Echecs avant abandon :", self.keepalive_count_spin)
layout.addLayout(keepalive_form)
self.auth_combo.currentIndexChanged.connect(self._update_key_row)
self.browse_key_btn.clicked.connect(self._browse_key)
self._update_key_row()
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _update_key_row(self) -> None:
is_key_auth = self.auth_combo.currentData() == AUTH_KEY
self.key_combo.setEnabled(is_key_auth)
self.browse_key_btn.setEnabled(is_key_auth)
def _browse_key(self) -> None:
from PySide6.QtWidgets import QFileDialog
path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee")
if path:
self.key_combo.addItem(path, path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
# ---------- Bastion (identique dans l'esprit a SessionDialog, en plus compact) ----------
def _build_bastion_group(self):
from PySide6.QtWidgets import QGroupBox
bastion = self.settings.bastion
group = QGroupBox("Bastion / Jump Host par defaut")
v = QVBoxLayout(group)
self.bastion_mode_combo = QComboBox()
self.bastion_mode_combo.addItem("Aucun", BASTION_NONE)
self.bastion_mode_combo.addItem("Une session PySSH existante", BASTION_SESSION)
self.bastion_mode_combo.addItem("Un Host de ~/.ssh/config", BASTION_OPENSSH)
self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL)
v.addWidget(self.bastion_mode_combo)
self.bastion_stack = AutoHeightStackedWidget()
v.addWidget(self.bastion_stack)
self.bastion_stack.addWidget(QWidget())
session_page = QWidget()
session_form = QFormLayout(session_page)
session_form.setContentsMargins(0, 4, 0, 0)
self.bastion_session_combo = QComboBox()
for s in sorted(self.session_store.sessions, key=lambda s: s.name.lower()):
self.bastion_session_combo.addItem(f"{s.name} ({s.username}@{s.host})", s.id)
if self.bastion_session_combo.count() == 0:
self.bastion_session_combo.addItem("(aucune session enregistree)", "")
self.bastion_session_combo.setEnabled(False)
session_form.addRow("Session :", self.bastion_session_combo)
self.bastion_stack.addWidget(session_page)
openssh_page = QWidget()
openssh_form = QFormLayout(openssh_page)
openssh_form.setContentsMargins(0, 4, 0, 0)
self.bastion_openssh_combo = QComboBox()
hosts = load_openssh_hosts()
for h in hosts:
self.bastion_openssh_combo.addItem(f"{h.alias} ({h.display_target})", h.alias)
if not hosts:
self.bastion_openssh_combo.addItem("(aucun host dans ~/.ssh/config)", "")
self.bastion_openssh_combo.setEnabled(False)
openssh_form.addRow("Host :", self.bastion_openssh_combo)
self.bastion_stack.addWidget(openssh_page)
manual_page = QWidget()
manual_form = QFormLayout(manual_page)
manual_form.setContentsMargins(0, 4, 0, 0)
self.bastion_host_edit = QLineEdit(bastion.host if bastion.mode == BASTION_MANUAL else "")
self.bastion_port_spin = QSpinBox()
self.bastion_port_spin.setRange(1, 65535)
self.bastion_port_spin.setValue(bastion.port or 22)
self.bastion_user_edit = QLineEdit(bastion.username if bastion.mode == BASTION_MANUAL else "")
manual_form.addRow("Hote :", self.bastion_host_edit)
manual_form.addRow("Port :", self.bastion_port_spin)
manual_form.addRow("Utilisateur :", self.bastion_user_edit)
self.bastion_stack.addWidget(manual_page)
self.bastion_mode_combo.currentIndexChanged.connect(self.bastion_stack.setCurrentIndex)
mode_idx = self.bastion_mode_combo.findData(bastion.mode)
self.bastion_mode_combo.setCurrentIndex(mode_idx if mode_idx >= 0 else 0)
self.bastion_stack.setCurrentIndex(self.bastion_mode_combo.currentIndex())
if bastion.mode == BASTION_SESSION:
found = self.bastion_session_combo.findData(bastion.session_id)
if found >= 0:
self.bastion_session_combo.setCurrentIndex(found)
elif bastion.mode == BASTION_OPENSSH:
found = self.bastion_openssh_combo.findData(bastion.openssh_alias)
if found >= 0:
self.bastion_openssh_combo.setCurrentIndex(found)
return group
def _result_bastion(self) -> BastionConfig:
mode = self.bastion_mode_combo.currentData()
bastion = BastionConfig(mode=mode)
if mode == BASTION_SESSION:
bastion.session_id = self.bastion_session_combo.currentData() or ""
elif mode == BASTION_OPENSSH:
bastion.openssh_alias = self.bastion_openssh_combo.currentData() or ""
elif mode == BASTION_MANUAL:
bastion.host = self.bastion_host_edit.text().strip()
bastion.port = self.bastion_port_spin.value()
bastion.username = self.bastion_user_edit.text().strip()
return bastion
def _on_accept(self) -> None:
self.settings.path = self.group_path
self.settings.username = self.username_edit.text().strip()
self.settings.port = self.port_spin.value()
self.settings.auth_type = self.auth_combo.currentData()
self.settings.key_path = self.key_combo.currentData() or ""
self.settings.bastion = self._result_bastion()
self.settings.keepalive_enabled = self.keepalive_check.isChecked()
self.settings.keepalive_interval = self.keepalive_interval_spin.value()
self.settings.keepalive_count_max = self.keepalive_count_spin.value()
self.store.set(self.settings)
self.accept()

View File

@@ -5,13 +5,28 @@ from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QListWidget, QListWidgetItem, QPushButton,
QMessageBox, QFileDialog, QFormLayout, QLineEdit, QComboBox, QSpinBox,
QDialogButtonBox, QApplication, QLabel,
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSpinBox,
QVBoxLayout,
)
from app.key_manager import (
KeyManager, KEY_TYPE_ED25519, KEY_TYPE_RSA, KEY_TYPE_LABELS, KEY_TYPE_DESCRIPTIONS,
KEY_TYPE_DESCRIPTIONS,
KEY_TYPE_LABELS,
KEY_TYPE_RSA,
KeyManager,
)

View File

@@ -3,8 +3,15 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton,
QMessageBox, QLabel, QAbstractItemView,
QAbstractItemView,
QDialog,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
)
from app.known_hosts_manager import KnownHostsStore

View File

@@ -0,0 +1,196 @@
"""Palette de recherche globale (Ctrl+K) : sessions, hosts OpenSSH, workspaces,
snippets et actions, avec recherche floue et navigation entierement au clavier."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from PySide6.QtCore import QEvent, Qt
from PySide6.QtWidgets import (
QDialog,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QVBoxLayout,
)
from app.openssh.config import load_openssh_hosts
from app.services.fuzzy import fuzzy_score
ICONS = {
"session": "🖥",
"openssh": "⚙",
"workspace": "🗂",
"snippet": "📋",
"action": "▶",
}
MAX_RESULTS = 60
@dataclass
class PaletteResult:
kind: str
label: str
sublabel: str = ""
activate: Callable[[], None] = field(default=lambda: None)
@property
def haystack(self) -> str:
return f"{self.label} {self.sublabel}"
class QuickConnectDialog(QDialog):
def __init__(
self, session_store, connection_controller, workspace_manager, snippet_store,
session_controller, key_manager, parent=None,
):
super().__init__(parent)
self.session_store = session_store
self.cc = connection_controller
self.workspace_manager = workspace_manager
self.snippet_store = snippet_store
self.session_controller = session_controller
self.key_manager = key_manager
self.setWindowTitle("Recherche rapide")
self.setMinimumSize(560, 420)
# pas de bouton "?" dans le titre, sans interet ici
self.setWindowFlag(Qt.WindowContextHelpButtonHint, False)
layout = QVBoxLayout(self)
self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText("Rechercher une session, un host, un workspace, un snippet...")
layout.addWidget(self.search_edit)
self.list = QListWidget()
layout.addWidget(self.list, 1)
self.search_edit.installEventFilter(self)
self.search_edit.textChanged.connect(self._update_results)
self.list.itemActivated.connect(lambda _item: self._activate_current())
self._all_results: list[PaletteResult] = self._collect_results()
self._update_results()
self.search_edit.setFocus()
# ---------- collecte des resultats ----------
def _collect_results(self) -> list[PaletteResult]:
results: list[PaletteResult] = []
for session in self.session_store.sessions:
results.append(PaletteResult(
kind="session", label=session.name,
sublabel=f"{session.username}@{session.host}" + (f" ({session.group})" if session.group else ""),
activate=lambda s=session: self.cc.open_session(s),
))
try:
hosts = load_openssh_hosts()
except Exception: # noqa: BLE001 - ~/.ssh/config illisible : pas bloquant pour la palette
hosts = []
for host in hosts:
results.append(PaletteResult(
kind="openssh", label=host.alias, sublabel=f"Host OpenSSH — {host.display_target}",
activate=lambda h=host: self.cc.open_openssh_host(h),
))
for workspace in self.workspace_manager.store.workspaces:
results.append(PaletteResult(
kind="workspace", label=workspace.name or "(sans nom)",
sublabel=f"Workspace — {len(workspace.tabs)} onglet(s)",
activate=lambda w=workspace: self.workspace_manager.restore(w),
))
for snippet in self.snippet_store.snippets:
results.append(PaletteResult(
kind="snippet", label=snippet.name,
sublabel=f"Snippet {snippet.category} — {snippet.command}".strip(),
activate=lambda s=snippet: self._insert_snippet_in_current_tab(s),
))
for label, action in self._quick_actions():
results.append(PaletteResult(kind="action", label=label, sublabel="Action", activate=action))
return results
def _quick_actions(self) -> list[tuple[str, Callable[[], None]]]:
from app.dialogs.key_dialog import KeyManagerDialog
from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.dialogs.workspace_dialog import WorkspaceDialog
return [
("Nouvelle session...", self.session_controller.new_session),
("Connexion SSH rapide...", self.cc.quick_ssh),
("Connexion SFTP rapide...", self.cc.quick_sftp),
("Nouveau terminal local", self.cc.open_local_terminal),
("Gestionnaire de workspaces...",
lambda: WorkspaceDialog(self.workspace_manager, parent=self.parent()).exec()),
("Gestionnaire de snippets...",
lambda: SnippetManagerDialog(self.snippet_store, parent=self.parent()).exec()),
("Gestionnaire de cles SSH...",
lambda: KeyManagerDialog(self.key_manager, parent=self.parent()).exec()),
]
def _insert_snippet_in_current_tab(self, snippet) -> None:
widget = self.cc.tabs.currentWidget()
if hasattr(widget, "insert_snippet"):
widget.insert_snippet(snippet)
else:
QMessageBox.information(
self.parent(), "Snippets",
"Ouvrez d'abord un terminal (ou un panneau) pour y inserer ce snippet.",
)
# ---------- filtrage / affichage ----------
def _update_results(self) -> None:
query = self.search_edit.text()
self.list.clear()
scored = []
for result in self._all_results:
score = fuzzy_score(query, result.haystack)
if score is not None:
scored.append((score, result))
scored.sort(key=lambda pair: -pair[0])
for _score, result in scored[:MAX_RESULTS]:
icon = ICONS.get(result.kind, "")
item = QListWidgetItem(f"{icon} {result.label} — {result.sublabel}")
item.setData(Qt.UserRole, result)
self.list.addItem(item)
if self.list.count():
self.list.setCurrentRow(0)
# ---------- navigation clavier ----------
def eventFilter(self, obj, event) -> bool:
if obj is self.search_edit and event.type() == QEvent.KeyPress:
key = event.key()
if key == Qt.Key_Down:
self._move_selection(1)
return True
if key == Qt.Key_Up:
self._move_selection(-1)
return True
if key in (Qt.Key_Return, Qt.Key_Enter):
self._activate_current()
return True
if key == Qt.Key_Escape:
self.reject()
return True
return super().eventFilter(obj, event)
def _move_selection(self, delta: int) -> None:
if not self.list.count():
return
row = max(0, min(self.list.count() - 1, self.list.currentRow() + delta))
self.list.setCurrentRow(row)
def _activate_current(self) -> None:
item = self.list.currentItem()
if not item:
return
result: PaletteResult = item.data(Qt.UserRole)
self.accept()
if result and result.activate:
result.activate()

View File

@@ -1,28 +1,67 @@
"""Boite de dialogue de creation/edition d'une session SSH."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox,
QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout,
QCheckBox, QMessageBox,
QCheckBox,
QComboBox,
QCompleter,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QTextEdit,
QVBoxLayout,
QWidget,
)
from app.session_manager import Session, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT
from app.credentials import CredentialStore, NullCredentialStore, session_credential_key
from app.groups import GroupSettingsStore
from app.key_manager import KeyManager
from app.openssh.config import load_openssh_hosts
from app.session_manager import (
AUTH_AGENT,
AUTH_AUTO,
AUTH_KEY,
AUTH_PASSWORD,
BASTION_MANUAL,
BASTION_NONE,
BASTION_OPENSSH,
BASTION_SESSION,
PASSWORD_STORAGE_NONE,
PASSWORD_STORAGE_SECURE,
BastionConfig,
Session,
SessionStore,
)
from app.ui.auto_height_stack import AutoHeightStackedWidget
class SessionDialog(QDialog):
"""Formulaire de session. En mode `connect_label`, il sert de connexion rapide :
on saisit les infos et on se connecte directement, l'enregistrement etant optionnel."""
def __init__(self, key_manager: KeyManager, session: Session | None = None,
parent=None, connect_label: str = "", title: str = ""):
def __init__(
self, key_manager: KeyManager, session: Session | None = None,
parent=None, connect_label: str = "", title: str = "",
session_store: SessionStore | None = None, group_store: GroupSettingsStore | None = None,
credential_store: CredentialStore | None = None,
):
super().__init__(parent)
self.key_manager = key_manager
self.session_store = session_store
self.group_store = group_store
self.credential_store = credential_store or NullCredentialStore()
self.session = session or Session()
self.quick_mode = bool(connect_label)
self.setWindowTitle(title or ("Nouvelle session" if session is None else "Modifier la session"))
self.setMinimumWidth(420)
self.setMinimumWidth(460)
layout = QVBoxLayout(self)
form = QFormLayout()
@@ -30,6 +69,25 @@ class SessionDialog(QDialog):
self.name_edit = QLineEdit(self.session.name)
self.group_edit = QLineEdit(self.session.group)
self.group_edit.setPlaceholderText("ex: Production/Web")
self.inherit_group_btn = QPushButton("↓ Heriter du groupe")
self.inherit_group_btn.setToolTip(
"Pre-remplit ce formulaire avec les valeurs par defaut enregistrees pour ce dossier"
" (Parametres du groupe..., dans le panneau des sessions)."
)
self.inherit_group_btn.clicked.connect(self._apply_group_defaults)
group_row = QWidget()
group_layout = QHBoxLayout(group_row)
group_layout.setContentsMargins(0, 0, 0, 0)
group_layout.addWidget(self.group_edit, 1)
group_layout.addWidget(self.inherit_group_btn)
self.tags_edit = QLineEdit(", ".join(self.session.tags))
self.tags_edit.setPlaceholderText("ex: prod, docker, client-x (separes par des virgules)")
if self.session_store is not None:
completer = QCompleter(self.session_store.tags(), self.tags_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.tags_edit.setCompleter(completer)
self.host_edit = QLineEdit(self.session.host)
self.port_spin = QSpinBox()
self.port_spin.setRange(1, 65535)
@@ -62,16 +120,56 @@ class SessionDialog(QDialog):
self.notes_edit.setFixedHeight(60)
form.addRow("Nom :", self.name_edit)
form.addRow("Dossier :", self.group_edit)
form.addRow("Dossier :", group_row)
form.addRow("Tags :", self.tags_edit)
self.remember_password_check = QCheckBox("Memoriser le mot de passe de maniere securisee")
self.remember_password_check.setChecked(self.session.password_storage == PASSWORD_STORAGE_SECURE)
if self.credential_store.is_available():
self.remember_password_check.setToolTip(
"Stocke dans le trousseau securise du systeme (Windows Credential Manager), "
"jamais en clair dans les fichiers de PySSH."
)
else:
self.remember_password_check.setEnabled(False)
self.remember_password_check.setChecked(False)
self.remember_password_check.setToolTip(
"Aucun trousseau securise disponible sur ce systeme : le mot de passe sera "
"toujours redemande a la connexion."
)
form.addRow("Hote :", self.host_edit)
form.addRow("Port :", self.port_spin)
form.addRow("Utilisateur :", self.user_edit)
form.addRow("Authentification :", self.auth_combo)
form.addRow("Cle privee :", key_row)
form.addRow("", self.remember_password_check)
form.addRow("Notes :", self.notes_edit)
self.form = form
layout.addLayout(form)
advanced_container = QWidget()
advanced_layout = QVBoxLayout(advanced_container)
advanced_layout.setContentsMargins(0, 0, 0, 0)
advanced_layout.addWidget(self._build_bastion_group())
advanced_layout.addWidget(self._build_keepalive_group())
advanced_layout.addWidget(self._build_reconnect_group())
if self.quick_mode:
# Repliees par defaut : une connexion rapide sert avant tout a
# aller vite (souvent un serveur de test en DHCP) - bastion,
# keepalive et reconnexion restent accessibles sans allonger le
# dialogue pour le cas courant, ou ils ne servent pas.
self.advanced_toggle = QPushButton("▸ Options avancees (bastion, keepalive, reconnexion)")
self.advanced_toggle.setCheckable(True)
self.advanced_toggle.setFlat(True)
self.advanced_toggle.toggled.connect(self._on_advanced_toggled)
layout.addWidget(self.advanced_toggle)
advanced_container.setVisible(False)
layout.addWidget(advanced_container)
self.advanced_container = advanced_container
self.save_check = QCheckBox("Enregistrer dans mes sessions (optionnel)")
if self.quick_mode:
# decoche par defaut : les serveurs de test changent d'IP (DHCP), les
@@ -98,6 +196,14 @@ class SessionDialog(QDialog):
self.key_combo.addItem(self.session.key_path, self.session.key_path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
def _on_advanced_toggled(self, checked: bool) -> None:
self.advanced_container.setVisible(checked)
self.advanced_toggle.setText(
"▾ Options avancees (bastion, keepalive, reconnexion)" if checked
else "▸ Options avancees (bastion, keepalive, reconnexion)"
)
self.adjustSize()
def _reload_keys(self) -> None:
self.key_combo.clear()
for entry in self.key_manager.keys:
@@ -107,6 +213,7 @@ class SessionDialog(QDialog):
is_key_auth = self.auth_combo.currentData() == AUTH_KEY
self.key_combo.setEnabled(is_key_auth)
self.browse_key_btn.setEnabled(is_key_auth)
self.form.setRowVisible(self.remember_password_check, self.auth_combo.currentData() == AUTH_PASSWORD)
def _browse_key(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee")
@@ -114,6 +221,250 @@ class SessionDialog(QDialog):
self.key_combo.addItem(path, path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
# ---------- heritage des parametres du groupe ----------
def _apply_group_defaults(self) -> None:
group_path = self.group_edit.text().strip()
if not group_path:
QMessageBox.information(self, "Heriter du groupe", "Indiquez d'abord un dossier.")
return
settings = self.group_store.get(group_path) if self.group_store else None
if settings is None:
QMessageBox.information(
self, "Heriter du groupe",
f"Aucun parametre par defaut enregistre pour « {group_path} ».\n\n"
"Definissez-en depuis le panneau des sessions : clic droit sur le dossier "
"-> Parametres du groupe...",
)
return
if settings.username:
self.user_edit.setText(settings.username)
if settings.port:
self.port_spin.setValue(settings.port)
auth_idx = self.auth_combo.findData(settings.auth_type)
if auth_idx >= 0:
self.auth_combo.setCurrentIndex(auth_idx)
if settings.key_path:
existing = self.key_combo.findData(settings.key_path)
if existing >= 0:
self.key_combo.setCurrentIndex(existing)
else:
self.key_combo.addItem(settings.key_path, settings.key_path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
if settings.bastion.mode != BASTION_NONE:
mode_idx = self.bastion_mode_combo.findData(settings.bastion.mode)
if mode_idx >= 0:
self.bastion_mode_combo.setCurrentIndex(mode_idx)
if settings.bastion.mode == BASTION_SESSION:
found = self.bastion_session_combo.findData(settings.bastion.session_id)
if found >= 0:
self.bastion_session_combo.setCurrentIndex(found)
elif settings.bastion.mode == BASTION_OPENSSH:
found = self.bastion_openssh_combo.findData(settings.bastion.openssh_alias)
if found >= 0:
self.bastion_openssh_combo.setCurrentIndex(found)
elif settings.bastion.mode == BASTION_MANUAL:
self.bastion_host_edit.setText(settings.bastion.host)
self.bastion_port_spin.setValue(settings.bastion.port or 22)
self.bastion_user_edit.setText(settings.bastion.username)
self.keepalive_check.setChecked(settings.keepalive_enabled)
self.keepalive_interval_spin.setValue(settings.keepalive_interval or 30)
self.keepalive_count_spin.setValue(settings.keepalive_count_max or 3)
# les reglages avances viennent d'etre modifies : les montrer, sans quoi
# ils resteraient caches derriere le repli "Options avancees"
if self.quick_mode and not self.advanced_toggle.isChecked():
self.advanced_toggle.setChecked(True)
# ---------- Bastion / Jump Host ----------
def _build_bastion_group(self) -> QGroupBox:
"""Un seul saut pour l'instant (voir BastionConfig) : Aucun / une session
PySSH existante / un Host de ~/.ssh/config / une configuration manuelle."""
bastion = self.session.bastion
group = QGroupBox("Bastion / Jump Host")
v = QVBoxLayout(group)
self.bastion_mode_combo = QComboBox()
self.bastion_mode_combo.addItem("Aucun", BASTION_NONE)
self.bastion_mode_combo.addItem("Une session PySSH existante", BASTION_SESSION)
self.bastion_mode_combo.addItem("Un Host de ~/.ssh/config", BASTION_OPENSSH)
self.bastion_mode_combo.addItem("Configuration manuelle", BASTION_MANUAL)
v.addWidget(self.bastion_mode_combo)
self.bastion_stack = AutoHeightStackedWidget()
v.addWidget(self.bastion_stack)
self.bastion_stack.addWidget(QWidget()) # BASTION_NONE : rien a afficher
session_page = QWidget()
session_form = QFormLayout(session_page)
session_form.setContentsMargins(0, 4, 0, 0)
self.bastion_session_combo = QComboBox()
others = [
s for s in (self.session_store.sessions if self.session_store else [])
if s.id != self.session.id
]
if others:
for s in sorted(others, key=lambda s: s.name.lower()):
self.bastion_session_combo.addItem(f"{s.name} ({s.username}@{s.host})", s.id)
else:
self.bastion_session_combo.addItem("(aucune autre session enregistree)", "")
self.bastion_session_combo.setEnabled(False)
session_form.addRow("Session :", self.bastion_session_combo)
self.bastion_stack.addWidget(session_page)
openssh_page = QWidget()
openssh_form = QFormLayout(openssh_page)
openssh_form.setContentsMargins(0, 4, 0, 0)
self.bastion_openssh_combo = QComboBox()
hosts = load_openssh_hosts()
if hosts:
for h in hosts:
self.bastion_openssh_combo.addItem(f"{h.alias} ({h.display_target})", h.alias)
else:
self.bastion_openssh_combo.addItem("(aucun host dans ~/.ssh/config)", "")
self.bastion_openssh_combo.setEnabled(False)
openssh_form.addRow("Host :", self.bastion_openssh_combo)
self.bastion_stack.addWidget(openssh_page)
manual_page = QWidget()
manual_form = QFormLayout(manual_page)
manual_form.setContentsMargins(0, 4, 0, 0)
self.bastion_host_edit = QLineEdit(bastion.host if bastion.mode == BASTION_MANUAL else "")
self.bastion_port_spin = QSpinBox()
self.bastion_port_spin.setRange(1, 65535)
self.bastion_port_spin.setValue(bastion.port or 22)
self.bastion_user_edit = QLineEdit(bastion.username if bastion.mode == BASTION_MANUAL else "")
self.bastion_auth_combo = QComboBox()
self.bastion_auth_combo.addItem("Automatique", AUTH_AUTO)
self.bastion_auth_combo.addItem("Cle SSH choisie", AUTH_KEY)
self.bastion_auth_combo.addItem("Mot de passe (saisi a la connexion)", AUTH_PASSWORD)
self.bastion_auth_combo.addItem("Agent SSH", AUTH_AGENT)
self.bastion_key_combo = QComboBox()
for entry in self.key_manager.keys:
self.bastion_key_combo.addItem(f"{entry.name} ({entry.key_type})", entry.private_path)
self.bastion_browse_key_btn = QPushButton("Parcourir...")
bastion_key_row = QWidget()
bastion_key_layout = QHBoxLayout(bastion_key_row)
bastion_key_layout.setContentsMargins(0, 0, 0, 0)
bastion_key_layout.addWidget(self.bastion_key_combo, 1)
bastion_key_layout.addWidget(self.bastion_browse_key_btn)
manual_form.addRow("Hote :", self.bastion_host_edit)
manual_form.addRow("Port :", self.bastion_port_spin)
manual_form.addRow("Utilisateur :", self.bastion_user_edit)
manual_form.addRow("Authentification :", self.bastion_auth_combo)
manual_form.addRow("Cle privee :", bastion_key_row)
self.bastion_stack.addWidget(manual_page)
self.bastion_auth_combo.currentIndexChanged.connect(self._update_bastion_key_row)
self.bastion_browse_key_btn.clicked.connect(self._browse_bastion_key)
self.bastion_mode_combo.currentIndexChanged.connect(self.bastion_stack.setCurrentIndex)
mode_idx = self.bastion_mode_combo.findData(bastion.mode)
self.bastion_mode_combo.setCurrentIndex(mode_idx if mode_idx >= 0 else 0)
self.bastion_stack.setCurrentIndex(self.bastion_mode_combo.currentIndex())
if bastion.mode == BASTION_SESSION:
found = self.bastion_session_combo.findData(bastion.session_id)
if found >= 0:
self.bastion_session_combo.setCurrentIndex(found)
elif bastion.mode == BASTION_OPENSSH:
found = self.bastion_openssh_combo.findData(bastion.openssh_alias)
if found >= 0:
self.bastion_openssh_combo.setCurrentIndex(found)
elif bastion.mode == BASTION_MANUAL:
auth_idx = self.bastion_auth_combo.findData(bastion.auth_type)
self.bastion_auth_combo.setCurrentIndex(auth_idx if auth_idx >= 0 else 0)
if bastion.key_path:
existing = self.bastion_key_combo.findData(bastion.key_path)
if existing >= 0:
self.bastion_key_combo.setCurrentIndex(existing)
else:
self.bastion_key_combo.addItem(bastion.key_path, bastion.key_path)
self.bastion_key_combo.setCurrentIndex(self.bastion_key_combo.count() - 1)
self._update_bastion_key_row()
return group
def _update_bastion_key_row(self) -> None:
is_key_auth = self.bastion_auth_combo.currentData() == AUTH_KEY
self.bastion_key_combo.setEnabled(is_key_auth)
self.bastion_browse_key_btn.setEnabled(is_key_auth)
def _browse_bastion_key(self) -> None:
path, _ = QFileDialog.getOpenFileName(self, "Choisir une cle privee (bastion)")
if path:
self.bastion_key_combo.addItem(path, path)
self.bastion_key_combo.setCurrentIndex(self.bastion_key_combo.count() - 1)
def _result_bastion(self) -> BastionConfig:
mode = self.bastion_mode_combo.currentData()
bastion = BastionConfig(mode=mode)
if mode == BASTION_SESSION:
bastion.session_id = self.bastion_session_combo.currentData() or ""
elif mode == BASTION_OPENSSH:
bastion.openssh_alias = self.bastion_openssh_combo.currentData() or ""
elif mode == BASTION_MANUAL:
bastion.host = self.bastion_host_edit.text().strip()
bastion.port = self.bastion_port_spin.value()
bastion.username = self.bastion_user_edit.text().strip()
bastion.auth_type = self.bastion_auth_combo.currentData()
bastion.key_path = self.bastion_key_combo.currentData() or ""
return bastion
# ---------- Keepalive ----------
def _build_keepalive_group(self) -> QGroupBox:
session = self.session
group = QGroupBox("Keepalive")
form = QFormLayout(group)
self.keepalive_check = QCheckBox("Active")
self.keepalive_check.setChecked(session.keepalive_enabled)
self.keepalive_interval_spin = QSpinBox()
self.keepalive_interval_spin.setRange(5, 3600)
self.keepalive_interval_spin.setSuffix(" s")
self.keepalive_interval_spin.setValue(session.keepalive_interval or 30)
self.keepalive_count_spin = QSpinBox()
self.keepalive_count_spin.setRange(1, 20)
self.keepalive_count_spin.setValue(session.keepalive_count_max or 3)
form.addRow(self.keepalive_check)
form.addRow("Intervalle :", self.keepalive_interval_spin)
form.addRow("Nombre d'echecs avant abandon :", self.keepalive_count_spin)
self.keepalive_check.toggled.connect(self.keepalive_interval_spin.setEnabled)
self.keepalive_check.toggled.connect(self.keepalive_count_spin.setEnabled)
self.keepalive_interval_spin.setEnabled(session.keepalive_enabled)
self.keepalive_count_spin.setEnabled(session.keepalive_enabled)
return group
# ---------- Reconnexion automatique ----------
def _build_reconnect_group(self) -> QGroupBox:
session = self.session
group = QGroupBox("Reconnexion automatique")
form = QFormLayout(group)
self.reconnect_check = QCheckBox("Reconnexion automatique")
self.reconnect_check.setChecked(session.reconnect_enabled)
self.reconnect_delay_spin = QSpinBox()
self.reconnect_delay_spin.setRange(1, 300)
self.reconnect_delay_spin.setSuffix(" s")
self.reconnect_delay_spin.setValue(session.reconnect_initial_delay or 5)
self.reconnect_attempts_spin = QSpinBox()
self.reconnect_attempts_spin.setRange(0, 1000)
self.reconnect_attempts_spin.setSpecialValueText("Illimite")
self.reconnect_attempts_spin.setValue(session.reconnect_max_attempts)
form.addRow(self.reconnect_check)
form.addRow("Delai initial :", self.reconnect_delay_spin)
form.addRow("Tentatives maximum (0 = illimite) :", self.reconnect_attempts_spin)
self.reconnect_check.toggled.connect(self.reconnect_delay_spin.setEnabled)
self.reconnect_check.toggled.connect(self.reconnect_attempts_spin.setEnabled)
self.reconnect_delay_spin.setEnabled(session.reconnect_enabled)
self.reconnect_attempts_spin.setEnabled(session.reconnect_enabled)
return group
def should_save(self) -> bool:
"""En connexion rapide, indique si la session doit aussi etre enregistree."""
return self.save_check.isChecked() if self.quick_mode else True
@@ -137,6 +488,19 @@ class SessionDialog(QDialog):
)
self.auth_combo.setFocus()
return
bastion_mode = self.bastion_mode_combo.currentData()
if bastion_mode == BASTION_SESSION and not self.bastion_session_combo.currentData():
QMessageBox.warning(self, "Bastion incomplet", "Choisissez une session PySSH a utiliser comme bastion.")
return
if bastion_mode == BASTION_OPENSSH and not self.bastion_openssh_combo.currentData():
QMessageBox.warning(self, "Bastion incomplet", "Choisissez un Host de ~/.ssh/config a utiliser comme bastion.")
return
if bastion_mode == BASTION_MANUAL and not self.bastion_host_edit.text().strip():
QMessageBox.warning(self, "Bastion incomplet", "Indiquez l'adresse du bastion.")
self.bastion_host_edit.setFocus()
return
self.session.name = self.name_edit.text().strip() or self.host_edit.text().strip()
self.session.group = self.group_edit.text().strip()
self.session.host = self.host_edit.text().strip()
@@ -145,6 +509,28 @@ class SessionDialog(QDialog):
self.session.auth_type = self.auth_combo.currentData()
self.session.key_path = self.key_combo.currentData() or ""
self.session.notes = self.notes_edit.toPlainText()
seen = set()
tags = []
for raw in self.tags_edit.text().split(","):
tag = raw.strip()
if tag and tag.lower() not in seen:
seen.add(tag.lower())
tags.append(tag)
self.session.tags = tags
remember = self.auth_combo.currentData() == AUTH_PASSWORD and self.remember_password_check.isChecked()
new_storage = PASSWORD_STORAGE_SECURE if remember else PASSWORD_STORAGE_NONE
if self.session.password_storage == PASSWORD_STORAGE_SECURE and new_storage == PASSWORD_STORAGE_NONE:
# la memorisation vient d'etre desactivee (ou l'authentification changee) :
# ne pas laisser un secret perimer dans le trousseau
self.credential_store.delete_secret(session_credential_key(self.session.id))
self.session.password_storage = new_storage
self.session.bastion = self._result_bastion()
self.session.keepalive_enabled = self.keepalive_check.isChecked()
self.session.keepalive_interval = self.keepalive_interval_spin.value()
self.session.keepalive_count_max = self.keepalive_count_spin.value()
self.session.reconnect_enabled = self.reconnect_check.isChecked()
self.session.reconnect_initial_delay = self.reconnect_delay_spin.value()
self.session.reconnect_max_attempts = self.reconnect_attempts_spin.value()
self.accept()
def result_session(self) -> Session:

View File

@@ -0,0 +1,181 @@
"""Gestionnaire de snippets : creer, modifier, supprimer, rechercher, organiser
par categorie."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTextEdit,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
)
from app.snippets import Snippet, SnippetStore
SNIPPET_ROLE = Qt.UserRole
class SnippetEditDialog(QDialog):
def __init__(self, snippet: Snippet | None = None, categories: list[str] | None = None, parent=None):
super().__init__(parent)
self.snippet = snippet or Snippet()
self.setWindowTitle("Nouveau snippet" if snippet is None else "Modifier le snippet")
self.setMinimumWidth(420)
layout = QVBoxLayout(self)
form = QFormLayout()
self.name_edit = QLineEdit(self.snippet.name)
self.category_combo = QComboBox()
self.category_combo.setEditable(True)
for cat in categories or []:
self.category_combo.addItem(cat)
self.category_combo.setCurrentText(self.snippet.category)
self.description_edit = QLineEdit(self.snippet.description)
self.command_edit = QTextEdit(self.snippet.command)
self.command_edit.setFixedHeight(80)
self.command_edit.setPlaceholderText("ex : docker ps -a")
form.addRow("Nom :", self.name_edit)
form.addRow("Categorie :", self.category_combo)
form.addRow("Description :", self.description_edit)
form.addRow("Commande :", self.command_edit)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _on_accept(self) -> None:
if not self.name_edit.text().strip():
QMessageBox.warning(self, "Nom manquant", "Indiquez un nom pour ce snippet.")
self.name_edit.setFocus()
return
if not self.command_edit.toPlainText().strip():
QMessageBox.warning(self, "Commande manquante", "Indiquez la commande du snippet.")
self.command_edit.setFocus()
return
self.snippet.name = self.name_edit.text().strip()
self.snippet.category = self.category_combo.currentText().strip()
self.snippet.description = self.description_edit.text().strip()
self.snippet.command = self.command_edit.toPlainText().rstrip("\n")
self.accept()
def result_snippet(self) -> Snippet:
return self.snippet
class SnippetManagerDialog(QDialog):
def __init__(self, store: SnippetStore, parent=None):
super().__init__(parent)
self.store = store
self.setWindowTitle("Gestionnaire de snippets")
self.setMinimumSize(560, 420)
layout = QVBoxLayout(self)
self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText("Rechercher (nom, categorie, description, commande)...")
self.search_edit.textChanged.connect(self.refresh)
layout.addWidget(self.search_edit)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Nom", "Description"])
self.tree.setColumnWidth(0, 220)
self.tree.itemDoubleClicked.connect(lambda _i, _c: self.edit_selected())
self.tree.itemSelectionChanged.connect(self._update_preview)
layout.addWidget(self.tree)
self.command_preview = QLabel("")
self.command_preview.setWordWrap(True)
self.command_preview.setObjectName("statusLabel")
layout.addWidget(self.command_preview)
btn_row = QHBoxLayout()
self.new_btn = QPushButton("Nouveau...")
self.edit_btn = QPushButton("Modifier...")
self.delete_btn = QPushButton("Supprimer")
for b in (self.new_btn, self.edit_btn, self.delete_btn):
btn_row.addWidget(b)
layout.addLayout(btn_row)
close_row = QHBoxLayout()
close_row.addStretch()
close_btn = QPushButton("Fermer")
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self.new_btn.clicked.connect(self.create_new)
self.edit_btn.clicked.connect(self.edit_selected)
self.delete_btn.clicked.connect(self.delete_selected)
self.refresh()
def refresh(self) -> None:
self.tree.clear()
query = self.search_edit.text()
matches = {s.id for s in self.store.search(query)} if query.strip() else None
for category, snippets in self.store.by_category().items():
visible = [s for s in snippets if matches is None or s.id in matches]
if not visible:
continue
cat_item = QTreeWidgetItem([category])
cat_item.setFlags(cat_item.flags() & ~Qt.ItemIsSelectable)
font = cat_item.font(0)
font.setBold(True)
cat_item.setFont(0, font)
self.tree.addTopLevelItem(cat_item)
for snippet in visible:
item = QTreeWidgetItem([snippet.name, snippet.description])
item.setData(0, SNIPPET_ROLE, snippet.id)
cat_item.addChild(item)
self.tree.expandAll()
self._update_preview()
def _current_snippet(self) -> Snippet | None:
item = self.tree.currentItem()
if not item:
return None
snippet_id = item.data(0, SNIPPET_ROLE)
return self.store.get(snippet_id) if snippet_id else None
def _update_preview(self) -> None:
snippet = self._current_snippet()
self.command_preview.setText(snippet.command if snippet else "")
has_selection = snippet is not None
self.edit_btn.setEnabled(has_selection)
self.delete_btn.setEnabled(has_selection)
def create_new(self) -> None:
dialog = SnippetEditDialog(categories=self.store.categories(), parent=self)
if dialog.exec():
self.store.add(dialog.result_snippet())
self.refresh()
def edit_selected(self) -> None:
snippet = self._current_snippet()
if not snippet:
return
dialog = SnippetEditDialog(snippet=snippet, categories=self.store.categories(), parent=self)
if dialog.exec():
self.store.update(dialog.result_snippet())
self.refresh()
def delete_selected(self) -> None:
snippet = self._current_snippet()
if not snippet:
return
if QMessageBox.question(self, "Supprimer", f"Supprimer le snippet « {snippet.name} » ?") == QMessageBox.Yes:
self.store.delete(snippet.id)
self.refresh()

View File

@@ -3,13 +3,22 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QTreeWidget, QTreeWidgetItem,
QPushButton, QComboBox, QSpinBox, QLineEdit, QLabel, QMessageBox, QAbstractItemView,
QAbstractItemView,
QComboBox,
QDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSpinBox,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
)
from app.tunnels import (
DIRECTION_LOCAL, DIRECTION_REMOTE, DIRECTION_LABELS, TunnelManager, describe,
)
from app.tunnels import DIRECTION_DYNAMIC, DIRECTION_LABELS, TunnelManager, describe
class TunnelDialog(QDialog):
@@ -26,7 +35,10 @@ class TunnelDialog(QDialog):
"la connexion SSH, vers une machine joignable depuis le serveur — pratique pour "
"atteindre un service qui n'ecoute que sur la boucle locale du serveur.<br>"
"Une redirection <b>distante</b> fait l'inverse : un port ouvert sur le serveur est "
"relaye vers une machine joignable depuis votre poste."
"relaye vers une machine joignable depuis votre poste.<br>"
"Une redirection <b>dynamique</b> ouvre un proxy SOCKS sur votre poste : chaque "
"application qui s'y connecte choisit elle-meme sa destination, atteinte a travers "
"la connexion SSH (pratique pour naviguer comme si vous etiez sur le reseau du serveur)."
)
info.setWordWrap(True)
layout.addWidget(info)
@@ -52,6 +64,10 @@ class TunnelDialog(QDialog):
form.addRow("Machine de destination :", self.dest_host)
form.addRow("Port de destination :", self.dest_port)
layout.addLayout(form)
self.form = form
self.direction.currentIndexChanged.connect(self._update_dest_fields)
self._update_dest_fields()
buttons = QHBoxLayout()
self.add_btn = QPushButton("Ouvrir le tunnel")
@@ -69,6 +85,13 @@ class TunnelDialog(QDialog):
self.remove_btn.clicked.connect(self.remove_tunnel)
self.refresh()
def _update_dest_fields(self) -> None:
"""La destination d'une redirection dynamique (-D) est negociee par
chaque client SOCKS : les champs n'ont pas de sens dans ce mode."""
has_dest = self.direction.currentData() != DIRECTION_DYNAMIC
self.form.setRowVisible(self.dest_host, has_dest)
self.form.setRowVisible(self.dest_port, has_dest)
def refresh(self) -> None:
self.tree.clear()
for tunnel in self.manager.tunnels:
@@ -83,15 +106,14 @@ class TunnelDialog(QDialog):
self.remove_btn.setEnabled(bool(self.manager.tunnels))
def add_tunnel(self) -> None:
if not self.dest_host.text().strip():
direction = self.direction.currentData()
if direction != DIRECTION_DYNAMIC and not self.dest_host.text().strip():
QMessageBox.warning(self, "Destination manquante", "Indiquez la machine de destination.")
return
config = {
"direction": self.direction.currentData(),
"listen_port": self.listen_port.value(),
"dest_host": self.dest_host.text().strip(),
"dest_port": self.dest_port.value(),
}
config = {"direction": direction, "listen_port": self.listen_port.value()}
if direction != DIRECTION_DYNAMIC:
config["dest_host"] = self.dest_host.text().strip()
config["dest_port"] = self.dest_port.value()
tunnel = self.manager.start(config)
if tunnel.error:
QMessageBox.critical(

View File

@@ -0,0 +1,142 @@
"""Gestionnaire de workspaces : creer, enregistrer, ouvrir, renommer, supprimer."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QVBoxLayout,
)
from app.app_settings import get_setting, set_setting
from app.controllers.workspace_manager import SETTING_RESTORE_ON_STARTUP, WorkspaceManager
class WorkspaceDialog(QDialog):
def __init__(self, manager: WorkspaceManager, parent=None):
super().__init__(parent)
self.manager = manager
self.setWindowTitle("Workspaces")
self.setMinimumSize(480, 360)
layout = QVBoxLayout(self)
info = QLabel(
"Un workspace enregistre les onglets ouverts (sessions, terminaux locaux, "
"splits) pour les retrouver plus tard. Aucun mot de passe n'y est stocke : "
"il sera redemande a l'ouverture si necessaire."
)
info.setWordWrap(True)
layout.addWidget(info)
self.list = QListWidget()
layout.addWidget(self.list)
btn_row = QHBoxLayout()
self.new_btn = QPushButton("Nouveau (etat actuel)...")
self.save_btn = QPushButton("Enregistrer (ecraser)")
self.open_btn = QPushButton("Ouvrir")
self.rename_btn = QPushButton("Renommer...")
self.delete_btn = QPushButton("Supprimer")
for b in (self.new_btn, self.save_btn, self.open_btn, self.rename_btn, self.delete_btn):
btn_row.addWidget(b)
layout.addLayout(btn_row)
self.restore_check = QCheckBox("Restaurer automatiquement le dernier workspace ouvert au demarrage")
self.restore_check.setChecked(bool(get_setting(SETTING_RESTORE_ON_STARTUP, False)))
self.restore_check.toggled.connect(lambda checked: set_setting(SETTING_RESTORE_ON_STARTUP, checked))
layout.addWidget(self.restore_check)
close_row = QHBoxLayout()
close_row.addStretch()
close_btn = QPushButton("Fermer")
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
layout.addLayout(close_row)
self.new_btn.clicked.connect(self.create_new)
self.save_btn.clicked.connect(self.save_over_selected)
self.open_btn.clicked.connect(self.open_selected)
self.rename_btn.clicked.connect(self.rename_selected)
self.delete_btn.clicked.connect(self.delete_selected)
self.list.itemDoubleClicked.connect(lambda _item: self.open_selected())
self.list.currentItemChanged.connect(self._update_buttons)
manager.workspaces_changed.connect(self.refresh)
self.refresh()
def refresh(self) -> None:
self.list.clear()
for workspace in self.manager.store.workspaces:
item = QListWidgetItem(f"{workspace.name or '(sans nom)'} — {len(workspace.tabs)} onglet(s)")
item.setData(Qt.UserRole, workspace.id)
self.list.addItem(item)
self._update_buttons()
def _current_id(self) -> str | None:
item = self.list.currentItem()
return item.data(Qt.UserRole) if item else None
def _update_buttons(self) -> None:
has_selection = self._current_id() is not None
for b in (self.save_btn, self.open_btn, self.rename_btn, self.delete_btn):
b.setEnabled(has_selection)
def create_new(self) -> None:
name, ok = QInputDialog.getText(self, "Nouveau workspace", "Nom du workspace :")
if not ok or not name.strip():
return
self.manager.save_as_new(name.strip())
def save_over_selected(self) -> None:
workspace_id = self._current_id()
if not workspace_id:
return
workspace = self.manager.store.get(workspace_id)
name = workspace.name if workspace else ""
if QMessageBox.question(
self, "Enregistrer",
f"Remplacer le contenu du workspace « {name} » par les onglets actuellement ouverts ?",
) != QMessageBox.Yes:
return
self.manager.save_over(workspace_id)
def open_selected(self) -> None:
workspace_id = self._current_id()
if not workspace_id:
return
workspace = self.manager.store.get(workspace_id)
if not workspace:
return
skipped = self.manager.restore(workspace)
if skipped:
QMessageBox.information(
self, "Restauration partielle",
f"{skipped} element(s) n'ont pas pu etre restaures (session supprimee depuis).",
)
def rename_selected(self) -> None:
workspace_id = self._current_id()
if not workspace_id:
return
workspace = self.manager.store.get(workspace_id)
name, ok = QInputDialog.getText(
self, "Renommer", "Nouveau nom :", text=workspace.name if workspace else "",
)
if ok and name.strip():
self.manager.rename(workspace_id, name.strip())
def delete_selected(self) -> None:
workspace_id = self._current_id()
if not workspace_id:
return
workspace = self.manager.store.get(workspace_id)
name = workspace.name if workspace else ""
if QMessageBox.question(self, "Supprimer", f"Supprimer le workspace « {name} » ?") == QMessageBox.Yes:
self.manager.delete(workspace_id)

93
app/groups.py Normal file
View File

@@ -0,0 +1,93 @@
"""Valeurs par defaut d'un groupe (dossier de sessions) : username, port, cle
SSH, bastion, keepalive.
Choix delibere : l'heritage s'applique au moment de la creation d'une session
(pre-remplissage explicite dans l'editeur), pas par une resolution dynamique a
chaque connexion. Modifier les parametres d'un groupe ne change donc jamais
retroactivement les sessions deja creees - elles restent des valeurs normales,
librement modifiables, sans indirection a suivre dans tout le reste du code
(ConnectionController, SSHConnection, resolve_bastion_target...)."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from pathlib import Path
from app.paths import DATA_DIR
from app.session_manager import AUTH_AUTO, BastionConfig
from app.storage.json_store import load_json_document, save_json_document
GROUPS_FILE = DATA_DIR / "groups.json"
SCHEMA_VERSION = 1
@dataclass
class GroupSettings:
path: str = "" # ex: "Production/Web", correspond a Session.group
username: str = ""
port: int = 22
auth_type: str = AUTH_AUTO
key_path: str = ""
bastion: BastionConfig = field(default_factory=BastionConfig)
keepalive_enabled: bool = True
keepalive_interval: int = 30
keepalive_count_max: int = 3
def to_dict(self) -> dict:
return asdict(self)
@staticmethod
def from_dict(data: dict) -> GroupSettings:
g = GroupSettings()
for k in GroupSettings.__dataclass_fields__:
if k not in data:
continue
v = data[k]
if k == "bastion" and isinstance(v, dict):
v = BastionConfig.from_dict(v)
setattr(g, k, v)
return g
class GroupSettingsStore:
"""Charge/sauvegarde les parametres par defaut de chaque groupe depuis
data/groups.json. Un groupe sans entree ici n'a simplement aucune valeur
par defaut (comportement actuel, inchange)."""
def __init__(self, path: Path = GROUPS_FILE):
self.path = path
self.groups: list[GroupSettings] = []
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, "groups": []},
current_version=SCHEMA_VERSION,
)
self.load_warning = warning
self.groups = [GroupSettings.from_dict(g) for g in document.get("groups", [])]
if was_missing:
self.save()
def save(self) -> None:
payload = {"schema_version": SCHEMA_VERSION, "groups": [g.to_dict() for g in self.groups]}
save_json_document(self.path, payload)
def get(self, group_path: str) -> GroupSettings | None:
return next((g for g in self.groups if g.path == group_path), None)
def set(self, settings: GroupSettings) -> None:
"""Cree ou remplace les parametres du groupe (identifie par son path)."""
for i, g in enumerate(self.groups):
if g.path == settings.path:
self.groups[i] = settings
self.save()
return
self.groups.append(settings)
self.save()
def delete(self, group_path: str) -> None:
self.groups = [g for g in self.groups if g.path != group_path]
self.save()

View File

@@ -1,19 +1,43 @@
"""Gestion des cles SSH : generation, import, export, registre local."""
from __future__ import annotations
import json
import base64
import hashlib
import shutil
import uuid
from dataclasses import dataclass, field, asdict
from dataclasses import asdict, dataclass, field
from pathlib import Path
import paramiko
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa, ed25519, ec
from cryptography.hazmat.primitives.asymmetric import ec, ed25519, rsa
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
from app.security.key_protection import protect_private_key
from app.storage.json_store import load_json_document, save_json_document
KEYS_FILE = DATA_DIR / "keys.json"
DEFAULT_KEY_DIR = DATA_DIR / "keys"
SCHEMA_VERSION = 1
def compute_fingerprint(public_key_line: str) -> str:
"""Empreinte SHA256 canonique (format `SHA256:xxxx...`, comme `ssh-keygen -lf`),
a partir d'une ligne de cle publique OpenSSH ("type base64 [commentaire]").
Utilisee pour toute cle, qu'elle soit generee, importee ou relue depuis le
registre : jamais un simple fragment du base64, qui ne serait pas un vrai
condense cryptographique de la cle."""
parts = (public_key_line or "").split()
if len(parts) < 2:
return ""
try:
raw = base64.b64decode(parts[1])
except (ValueError, TypeError):
return ""
digest = hashlib.sha256(raw).digest()
return "SHA256:" + base64.b64encode(digest).decode("utf-8").rstrip("=")
KEY_TYPE_ED25519 = "ed25519"
KEY_TYPE_RSA = "rsa"
@@ -92,7 +116,7 @@ class KeyEntry:
return asdict(self)
@staticmethod
def from_dict(data: dict) -> "KeyEntry":
def from_dict(data: dict) -> KeyEntry:
e = KeyEntry()
for k in KeyEntry.__dataclass_fields__:
if k in data:
@@ -104,26 +128,39 @@ class KeyManager:
def __init__(self, registry_path: Path = KEYS_FILE):
self.registry_path = registry_path
self.keys: list[KeyEntry] = []
self.load_warning: str | None = None
self.load()
# ---------- registre ----------
def load(self) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
DEFAULT_KEY_DIR.mkdir(parents=True, exist_ok=True)
if not self.registry_path.exists():
self.keys = []
was_missing = not self.registry_path.exists()
document, warning = load_json_document(
self.registry_path,
default={"schema_version": SCHEMA_VERSION, "keys": []},
current_version=SCHEMA_VERSION,
)
self.load_warning = warning
self.keys = [KeyEntry.from_dict(k) for k in document.get("keys", [])]
# Uniformise le format des empreintes : les cles importees avant l'ajout de
# compute_fingerprint() portaient un simple fragment du base64 plutot qu'un
# vrai condense SHA256. On les corrige silencieusement au chargement.
changed = False
for entry in self.keys:
if not entry.public_key_text:
continue
correct = compute_fingerprint(entry.public_key_text)
if correct and entry.fingerprint != correct:
entry.fingerprint = correct
changed = True
if was_missing or changed:
self.save()
return
try:
raw = json.loads(self.registry_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
raw = {"keys": []}
self.keys = [KeyEntry.from_dict(k) for k in raw.get("keys", [])]
def save(self) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
payload = {"keys": [k.to_dict() for k in self.keys]}
self.registry_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
payload = {"schema_version": SCHEMA_VERSION, "keys": [k.to_dict() for k in self.keys]}
save_json_document(self.registry_path, payload)
def remove(self, key_id: str, delete_files: bool = False) -> None:
entry = next((k for k in self.keys if k.id == key_id), None)
@@ -180,12 +217,9 @@ class KeyManager:
priv_path.write_bytes(priv_bytes)
pub_path.write_text(pub_text, encoding="utf-8")
try:
priv_path.chmod(0o600)
except OSError:
pass
protect_private_key(priv_path)
fingerprint = self._fingerprint(pub_bytes)
fingerprint = compute_fingerprint(pub_text)
entry = KeyEntry(
name=name,
key_type=key_type,
@@ -213,13 +247,12 @@ class KeyManager:
pub_path_str = str(guess)
pub_text = guess.read_text(encoding="utf-8").strip()
fingerprint = pub_text.split()[1] if pub_text and len(pub_text.split()) > 1 else ""
entry = KeyEntry(
name=name,
key_type=key_type,
private_path=str(priv_path),
public_path=pub_path_str,
fingerprint=fingerprint[:16],
fingerprint=compute_fingerprint(pub_text) if pub_text else "",
public_key_text=pub_text,
)
self.keys.append(entry)
@@ -248,15 +281,3 @@ class KeyManager:
def export_private_key(self, entry: KeyEntry, destination: str) -> None:
shutil.copyfile(entry.private_path, destination)
@staticmethod
def _fingerprint(pub_bytes: bytes) -> str:
import base64
import hashlib
parts = pub_bytes.split()
if len(parts) < 2:
return ""
raw = base64.b64decode(parts[1])
digest = hashlib.sha256(raw).digest()
return "SHA256:" + base64.b64encode(digest).decode("utf-8").rstrip("=")

View File

@@ -7,12 +7,15 @@ a editer le fichier a la main.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
import paramiko
from app.paths import DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
from app.storage.atomic_file import atomic_write_text
KNOWN_HOSTS_FILE = DATA_DIR / "known_hosts"
@@ -64,9 +67,13 @@ class KnownHostsStore:
self._save(hk)
def clear_all(self) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
self.path.write_text("", encoding="utf-8")
atomic_write_text(self.path, "")
def _save(self, hk: paramiko.HostKeys) -> None:
# paramiko.HostKeys.save() ecrit directement sur le chemin donne ; on le fait
# ecrire dans un fichier temporaire puis on bascule dessus atomiquement, pour
# ne jamais laisser known_hosts tronque en cas de coupure pendant l'ecriture.
DATA_DIR.mkdir(parents=True, exist_ok=True)
hk.save(str(self.path))
tmp_path = self.path.with_name(self.path.name + ".tmp")
hk.save(str(tmp_path))
os.replace(tmp_path, self.path)

File diff suppressed because it is too large Load Diff

0
app/openssh/__init__.py Normal file
View File

112
app/openssh/config.py Normal file
View File

@@ -0,0 +1,112 @@
"""Lecture de ~/.ssh/config (format OpenSSH), via paramiko.SSHConfig.
Support minimum couvert : Host, HostName, User, Port, IdentityFile, ProxyJump,
ForwardAgent, ServerAliveInterval, ServerAliveCountMax.
Le ProxyJump detecte ici est seulement affiche/reporte pour l'instant : la
connexion via un jump host est prise en charge par le module dedie (bastion),
pas par ce module qui ne fait que lire la configuration.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
import paramiko
from app.session_manager import AUTH_AUTO, AUTH_KEY, BASTION_OPENSSH, BastionConfig, Session
@dataclass
class OpenSSHHost:
alias: str
hostname: str = ""
user: str = ""
port: int = 22
identity_files: list[str] = field(default_factory=list)
proxy_jump: str = ""
forward_agent: bool = False
server_alive_interval: int | None = None
server_alive_count_max: int | None = None
@property
def display_target(self) -> str:
target = self.hostname or self.alias
return f"{target}:{self.port}" if self.port != 22 else target
def default_config_path() -> Path:
return Path.home() / ".ssh" / "config"
def _as_int(value: str | None) -> int | None:
try:
return int(value) if value is not None else None
except (TypeError, ValueError):
return None
def load_openssh_hosts(path: Path | None = None) -> list[OpenSSHHost]:
"""Renvoie les Host detectes dans le fichier, tries par alias.
Un fichier absent (tres courant : rien ne force un utilisateur Windows a en
avoir un) n'est pas une erreur : la liste est simplement vide."""
path = path or default_config_path()
if not path.exists():
return []
parser = paramiko.SSHConfig()
try:
with open(path, encoding="utf-8", errors="replace") as f:
parser.parse(f)
except OSError:
return []
hosts: list[OpenSSHHost] = []
for alias in parser.get_hostnames():
# "*" et les motifs (glob) ne designent pas un hote precis auquel se
# connecter, seulement des valeurs par defaut a fusionner dans les autres.
if alias == "*" or "*" in alias or "?" in alias:
continue
looked_up = parser.lookup(alias)
hosts.append(OpenSSHHost(
alias=alias,
hostname=looked_up.get("hostname", alias),
user=looked_up.get("user", ""),
port=_as_int(looked_up.get("port")) or 22,
identity_files=list(looked_up.get("identityfile", []) or []),
proxy_jump=looked_up.get("proxyjump", ""),
forward_agent=str(looked_up.get("forwardagent", "")).lower() == "yes",
server_alive_interval=_as_int(looked_up.get("serveraliveinterval")),
server_alive_count_max=_as_int(looked_up.get("serveralivecountmax")),
))
hosts.sort(key=lambda h: h.alias.lower())
return hosts
def session_from_openssh_host(host: OpenSSHHost) -> Session:
"""Convertit un Host OpenSSH en Session PySSH (utilisation directe ou import).
Si ProxyJump est present, le bastion resultant reference l'alias OpenSSH
correspondant (mode BASTION_OPENSSH) : il sera resolu et applique a la
connexion par app.bastion, tant que cet alias reste present dans
~/.ssh/config (voir app.bastion.resolve_bastion_target)."""
session = Session(
name=host.alias,
group="OpenSSH",
host=host.hostname or host.alias,
port=host.port,
username=host.user,
)
if host.identity_files:
session.auth_type = AUTH_KEY
session.key_path = str(Path(host.identity_files[0]).expanduser())
else:
session.auth_type = AUTH_AUTO
notes = [f"Importe depuis ~/.ssh/config (Host {host.alias})."]
if host.proxy_jump:
session.bastion = BastionConfig(mode=BASTION_OPENSSH, openssh_alias=host.proxy_jump)
notes.append(f"ProxyJump vers « {host.proxy_jump} » (voir section Bastion / Jump Host).")
session.notes = "\n".join(notes)
return session

0
app/security/__init__.py Normal file
View File

View File

@@ -0,0 +1,57 @@
"""Restreint l'acces a une cle privee au seul utilisateur courant.
Sous Windows, `chmod(0o600)` n'a aucun effet sur les permissions NTFS reelles :
on verrouille donc l'ACL du fichier via `icacls` (outil Windows standard), plutot
que d'ajouter une dependance pywin32 - plus lourde a empaqueter avec PyInstaller
pour un simple changement de permissions. Sous Linux/macOS, chmod suffit.
`icacls fichier /inheritance:r` retire les entrees heritees du dossier parent
(qui donnent en general acces a "Users"/"Authenticated Users"), puis
`/grant:r <utilisateur>:F` accorde le controle total au seul utilisateur courant.
Le proprietaire d'un fichier NTFS garde toujours le droit de modifier son ACL
meme quand celle-ci est vide entre les deux commandes : il n'y a pas de risque de
se retrouver bloque hors de son propre fichier.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
def protect_private_key(path: Path) -> None:
"""Ne rend jamais une cle plus accessible qu'elle ne l'etait ; peut echouer
silencieusement (systeme de fichiers sans ACL/permissions, icacls absent...),
comme le faisait deja l'appel chmod qu'elle remplace."""
if os.name == "nt":
_protect_windows(Path(path))
else:
try:
Path(path).chmod(0o600)
except OSError:
pass
def _current_user_account() -> str:
domain = os.environ.get("USERDOMAIN", "")
name = os.environ.get("USERNAME", "")
return f"{domain}\\{name}" if domain else name
def _protect_windows(path: Path) -> None:
account = _current_user_account()
if not account.strip("\\"):
return
try:
subprocess.run(
["icacls", str(path), "/inheritance:r"],
capture_output=True, check=True, creationflags=_NO_WINDOW,
)
subprocess.run(
["icacls", str(path), "/grant:r", f"{account}:F"],
capture_output=True, check=True, creationflags=_NO_WINDOW,
)
except (subprocess.CalledProcessError, OSError, FileNotFoundError):
pass

0
app/services/__init__.py Normal file
View File

35
app/services/fuzzy.py Normal file
View File

@@ -0,0 +1,35 @@
"""Recherche floue minimale (sans dependance externe) : `query` doit apparaitre
comme sous-sequence de `text` (caracteres dans l'ordre, pas necessairement
contigus), avec un score favorisant les correspondances contigues et les
debuts de mot - le meme principe que fzf/Sublime Text en plus simple."""
from __future__ import annotations
_WORD_BOUNDARY = " -_./@:\\"
def fuzzy_score(query: str, text: str) -> int | None:
"""None si `query` n'est pas une sous-sequence de `text`. Sinon un score
ou plus haut = meilleure correspondance."""
if not query:
return 0
q = query.lower()
t = text.lower()
score = 0
pos = 0
streak = 0
for ch in q:
idx = t.find(ch, pos)
if idx == -1:
return None
if idx == pos:
streak += 1
score += 3 + streak # caracteres consecutifs : bonus croissant
else:
streak = 0
score += 1
if idx == 0 or t[idx - 1] in _WORD_BOUNDARY:
score += 5 # debut de mot/segment
pos = idx + 1
# penalise legerement les correspondances trop etalees dans un texte long
score += max(0, 20 - (pos - len(q)))
return score

12
app/services/reconnect.py Normal file
View File

@@ -0,0 +1,12 @@
"""Calcul du delai de reconnexion automatique (backoff exponentiel plafonne)."""
from __future__ import annotations
def compute_backoff_delay(attempt: int, initial_delay: int, max_delay: int = 60) -> int:
"""Delai (secondes) avant la tentative numero `attempt` (1, 2, 3, ...).
Double a chaque tentative (5s, 10s, 20s, 40s, ...) et plafonne a `max_delay`
pour ne pas finir par attendre des heures apres de nombreux echecs."""
if attempt < 1:
attempt = 1
return min(initial_delay * (2 ** (attempt - 1)), max_delay)

View File

@@ -0,0 +1,55 @@
"""Detection d'une cible ssh (user/host/port) tapee a la main dans un terminal.
Sert a proposer le SFTP sur l'hote d'une commande `ssh ...` sans avoir a creer
un favori au prealable.
"""
from __future__ import annotations
import re
# Repere une commande ssh tapee a la main dans un terminal.
SSH_COMMAND_RE = re.compile(r"\bssh\s+(?P<args>[^\r\n|;&]+)")
# options ssh qui consomment la valeur suivante (a ne pas confondre avec l'hote)
SSH_VALUE_OPTIONS = {
"-p", "-i", "-l", "-o", "-F", "-J", "-b", "-c", "-D", "-L", "-R", "-W", "-e", "-m", "-Q",
}
HOST_RE = re.compile(r"^[A-Za-z0-9][\w.\-]*$")
def _parse_ssh_args(args: str) -> dict | None:
tokens = args.split()
user, host, port = "", "", 22
index = 0
while index < len(tokens):
token = tokens[index]
if token.startswith("-"):
if token in SSH_VALUE_OPTIONS:
value = tokens[index + 1] if index + 1 < len(tokens) else ""
if token == "-p" and value.isdigit():
port = int(value)
if token == "-l" and value:
user = value
index += 2
continue
if token.startswith("-p") and token[2:].isdigit(): # forme collee : -p2222
port = int(token[2:])
index += 1
continue
if not host:
candidate = token
if "@" in candidate:
user, _, candidate = candidate.partition("@")
if HOST_RE.match(candidate):
host = candidate
index += 1
return {"user": user, "host": host, "port": port} if host else None
def detect_ssh_target(text: str) -> dict | None:
"""Renvoie la derniere cible ssh trouvee dans le texte d'un terminal."""
result = None
for match in SSH_COMMAND_RE.finditer(text):
parsed = _parse_ssh_args(match.group("args"))
if parsed:
result = parsed
return result

View File

@@ -1,19 +1,62 @@
"""Stockage des sessions SSH (serveurs enregistres) dans un fichier JSON local."""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass, field, asdict
from dataclasses import asdict, dataclass, field
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:
@@ -26,15 +69,38 @@ class Session:
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":
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
@@ -45,24 +111,27 @@ class SessionStore:
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:
DATA_DIR.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self.sessions = []
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()
return
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
raw = {"sessions": []}
self.sessions = [Session.from_dict(s) for s in raw.get("sessions", [])]
def save(self) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
payload = {"sessions": [s.to_dict() for s in self.sessions]}
self.path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
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)
@@ -81,3 +150,9 @@ class SessionStore:
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)

0
app/sftp/__init__.py Normal file
View File

60
app/sftp/worker.py Normal file
View File

@@ -0,0 +1,60 @@
"""Execution asynchrone des operations SFTP.
paramiko.SFTPClient n'est pas concu pour etre pilote depuis plusieurs threads a
la fois : ce module fait en sorte qu'un seul thread possede jamais un client SFTP
donne. Toute operation (listage, mkdir, rename, remove, chmod, stat, ...) est
soumise a la file de ce thread sous forme de callable ; le resultat revient sur
le thread appelant (GUI) via un signal Qt, qui les mets automatiquement en file
d'attente quand emetteur et recepteur vivent dans des threads differents.
"""
from __future__ import annotations
import queue
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from PySide6.QtCore import QThread, Signal
@dataclass
class _Job:
request_id: int
fn: Callable[[], Any]
class SftpWorker(QThread):
"""Un worker = un canal SFTP, jamais partage avec un autre thread."""
result_ready = Signal(int, object, object) # request_id, resultat, exception|None
def __init__(self, parent=None):
super().__init__(parent)
self._queue: queue.Queue[_Job | None] = queue.Queue()
self._next_id = 0
self._lock = threading.Lock()
def submit(self, fn: Callable[[], Any]) -> int:
"""Met `fn` en file pour execution sur le thread du worker. Renvoie un
identifiant a rapprocher du signal `result_ready`."""
with self._lock:
self._next_id += 1
request_id = self._next_id
self._queue.put(_Job(request_id, fn))
return request_id
def run(self) -> None:
while True:
job = self._queue.get()
if job is None: # sentinelle posee par stop()
break
try:
result = job.fn()
self.result_ready.emit(job.request_id, result, None)
except Exception as exc: # noqa: BLE001 - remonte a l'appelant via le signal
self.result_ready.emit(job.request_id, None, exc)
def stop(self) -> None:
"""Arrete le worker une fois les jobs deja en file executes."""
self._queue.put(None)

View File

@@ -5,20 +5,34 @@ from __future__ import annotations
import json
import os
import posixpath
import tempfile
import stat
import tempfile
from datetime import datetime
from pathlib import Path
import paramiko
from PySide6.QtCore import Qt, QThread, Signal, QMimeData, QPoint, QFileSystemWatcher
from PySide6.QtCore import QFileSystemWatcher, QMimeData, QPoint, Qt, QThread, Signal
from PySide6.QtGui import QDrag, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QToolButton, QTreeWidget,
QTreeWidgetItem, QSplitter, QLabel, QMessageBox, QInputDialog, QProgressDialog,
QAbstractItemView, QMenu, QHeaderView,
QAbstractItemView,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMessageBox,
QProgressDialog,
QSplitter,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from app.sftp.worker import SftpWorker
MIME_TYPE = "application/x-pyssh-files"
NAME_ROLE = Qt.UserRole
@@ -56,16 +70,25 @@ class TransferJob:
class TransferWorker(QThread):
"""Execute une file de transferts sequentiellement, dans son propre thread."""
"""Execute une file de transferts sequentiellement, dans son propre thread.
Ouvre son propre canal SFTP (via `sftp_factory`) plutot que de reutiliser celui
de la navigation : un gros transfert ne doit jamais faire attendre un listage
de dossier, et paramiko.SFTPClient n'est pas concu pour etre partage entre
threads. Quand `close_when_done` est faux (aucun canal dedie disponible), le
canal fourni est reutilise tel quel et n'est pas ferme en fin de transfert.
"""
file_started = Signal(str, int, int) # nom, index, total
file_progress = Signal(int, int) # octets transferes, taille totale
finished_all = Signal(int, list) # nb reussis, liste d'erreurs
def __init__(self, sftp: paramiko.SFTPClient, jobs: list[TransferJob]):
def __init__(self, sftp_factory, jobs: list[TransferJob], close_when_done: bool = False):
super().__init__()
self.sftp = sftp
self.sftp_factory = sftp_factory
self.sftp: paramiko.SFTPClient | None = None
self.jobs = jobs
self.close_when_done = close_when_done
self._cancel = False
self._done = 0
self._errors: list[str] = []
@@ -74,6 +97,12 @@ class TransferWorker(QThread):
self._cancel = True
def run(self) -> None:
try:
self.sftp = self.sftp_factory()
except Exception as exc: # noqa: BLE001
self.finished_all.emit(0, [f"Impossible d'ouvrir le canal SFTP : {exc}"])
return
try:
total = len(self.jobs)
for index, job in enumerate(self.jobs, start=1):
if self._cancel:
@@ -95,6 +124,12 @@ class TransferWorker(QThread):
except Exception as exc: # noqa: BLE001
self._errors.append(f"{label} : {exc}")
self.finished_all.emit(self._done, self._errors)
finally:
if self.close_when_done:
try:
self.sftp.close()
except Exception:
pass
def _cb(self, transferred: int, total: int) -> None:
self.file_progress.emit(transferred, total)
@@ -102,7 +137,7 @@ class TransferWorker(QThread):
def _upload_dir(self, local_dir: str, remote_dir: str) -> None:
try:
self.sftp.mkdir(remote_dir)
except IOError:
except OSError:
pass
for entry in os.listdir(local_dir):
if self._cancel:
@@ -456,18 +491,32 @@ class LocalFilePanel(FilePanel):
class RemoteFilePanel(FilePanel):
def __init__(self, sftp: paramiko.SFTPClient, parent=None):
"""Toutes les operations reseau passent par `worker` (un `SftpWorker` dedie a ce
panneau) : aucun appel a `self.sftp` ne doit avoir lieu directement depuis le
thread GUI, pour ne jamais geler l'interface et pour respecter le fait qu'un
SFTPClient paramiko ne doit etre pilote que par un seul thread a la fois."""
def __init__(self, sftp: paramiko.SFTPClient, worker: SftpWorker, parent=None):
super().__init__("🌐 Distant", "remote", parent)
self.sftp = sftp
self.worker = worker
self.worker.result_ready.connect(self._on_result)
self._callbacks: dict[int, tuple] = {}
self._nav_generation = 0
# fichiers ouverts pour edition : chemin local temporaire -> chemin distant
self._edited: dict[str, str] = {}
self._watcher = QFileSystemWatcher(self)
self._watcher.fileChanged.connect(self._on_edited_file_changed)
try:
home = self.sftp.normalize(".")
except Exception:
home = "/"
self.navigate(home)
self.status.setText("Connexion...")
def on_home_success(home) -> None:
self.navigate(home or "/")
def on_home_error(_exc) -> None:
self.navigate("/")
self._run(lambda: self.sftp.normalize("."), on_home_success, on_home_error)
def join(self, a: str, b: str) -> str:
return posixpath.join(a, b)
@@ -475,17 +524,45 @@ class RemoteFilePanel(FilePanel):
def parent_of(self, path: str) -> str:
return posixpath.dirname(path.rstrip("/")) or "/"
def navigate(self, path: str) -> None:
try:
attrs = self.sftp.listdir_attr(path)
except IOError as exc:
QMessageBox.warning(self, "Erreur SFTP", f"{path} : {exc}")
# ---------- soumission asynchrone ----------
def _run(self, fn, on_success=None, on_error=None) -> None:
request_id = self.worker.submit(fn)
self._callbacks[request_id] = (on_success, on_error)
def _on_result(self, request_id: int, result, error: Exception | None) -> None:
callbacks = self._callbacks.pop(request_id, None)
if callbacks is None:
return
on_success, on_error = callbacks
if error is not None:
if on_error:
on_error(error)
else:
QMessageBox.critical(self, "Erreur SFTP", str(error))
elif on_success:
on_success(result)
def navigate(self, path: str) -> None:
self._nav_generation += 1
generation = self._nav_generation
self.status.setText("Chargement...")
def on_success(attrs) -> None:
if generation != self._nav_generation:
return # une navigation plus recente a ete lancee entre-temps
self.current_path = path
self.path_edit.setText(path)
self._populate(attrs)
self.path_changed.emit(path)
def on_error(exc) -> None:
if generation != self._nav_generation:
return
self.status.setText("")
QMessageBox.warning(self, "Erreur SFTP", f"{path} : {exc}")
self._run(lambda: self.sftp.listdir_attr(path), on_success, on_error)
def refresh(self) -> None:
if self.current_path:
self.navigate(self.current_path)
@@ -507,12 +584,13 @@ class RemoteFilePanel(FilePanel):
name, ok = QInputDialog.getText(self, "Nouveau dossier", "Nom du dossier :")
if not ok or not name:
return
try:
self.sftp.mkdir(self.join(self.current_path, name))
self.refresh()
except IOError as exc:
path = self.join(self.current_path, name)
def on_error(exc) -> None:
QMessageBox.critical(self, "Erreur", str(exc))
self._run(lambda: self.sftp.mkdir(path), lambda _r: self.refresh(), on_error)
def rename_selected(self) -> None:
entries = self.selected_entries()
if not entries:
@@ -521,12 +599,14 @@ class RemoteFilePanel(FilePanel):
new_name, ok = QInputDialog.getText(self, "Renommer", "Nouveau nom :", text=old_name)
if not ok or not new_name or new_name == old_name:
return
try:
self.sftp.rename(self.join(self.current_path, old_name), self.join(self.current_path, new_name))
self.refresh()
except IOError as exc:
old_path = self.join(self.current_path, old_name)
new_path = self.join(self.current_path, new_name)
def on_error(exc) -> None:
QMessageBox.critical(self, "Erreur", str(exc))
self._run(lambda: self.sftp.rename(old_path, new_path), lambda _r: self.refresh(), on_error)
def delete_selected(self) -> None:
entries = self.selected_entries()
if not entries:
@@ -536,18 +616,31 @@ class RemoteFilePanel(FilePanel):
f"Supprimer definitivement {len(entries)} element(s) sur le serveur ?",
) != QMessageBox.Yes:
return
current_path = self.current_path
def do_delete() -> list[str]:
errors = []
for name, is_dir in entries:
path = self.join(self.current_path, name)
path = self.join(current_path, name)
try:
if is_dir:
self._rmdir_recursive(path)
else:
self.sftp.remove(path)
except IOError as exc:
QMessageBox.critical(self, "Erreur", f"{name} : {exc}")
except OSError as exc:
errors.append(f"{name} : {exc}")
return errors
def on_success(errors: list[str]) -> None:
if errors:
QMessageBox.critical(self, "Erreur", "\n".join(errors))
self.refresh()
self._run(do_delete, on_success)
def _rmdir_recursive(self, path: str) -> None:
"""Appele uniquement depuis le thread du worker (jamais directement sur le
thread GUI) : parcourt et supprime un dossier distant recursivement."""
for attr in self.sftp.listdir_attr(path):
sub = posixpath.join(path, attr.filename)
if stat.S_ISDIR(attr.st_mode):
@@ -570,12 +663,10 @@ class RemoteFilePanel(FilePanel):
local_dir = Path(tempfile.gettempdir()) / "pyssh-edit"
local_dir.mkdir(parents=True, exist_ok=True)
local_path = local_dir / name
try:
self.sftp.get(remote_path, str(local_path))
except (IOError, OSError) as exc:
QMessageBox.critical(self, "Erreur", f"Telechargement impossible : {exc}")
return
self.status.setText(f"Telechargement de {name}...")
def on_success(_result) -> None:
self._edited[str(local_path)] = remote_path
self._watcher.addPath(str(local_path))
self.status.setText(f"Edition de {name} — enregistrez pour renvoyer sur le serveur")
@@ -588,34 +679,62 @@ class RemoteFilePanel(FilePanel):
f"Ouvrez-le manuellement ; il sera renvoye a chaque enregistrement.\n({exc})",
)
def on_error(exc) -> None:
self.status.setText("")
QMessageBox.critical(self, "Erreur", f"Telechargement impossible : {exc}")
self._run(lambda: self.sftp.get(remote_path, str(local_path)), on_success, on_error)
def _on_edited_file_changed(self, local_path: str) -> None:
remote_path = self._edited.get(local_path)
if not remote_path:
return
try:
self.sftp.put(local_path, remote_path)
except (IOError, OSError) as exc:
def on_error(exc) -> None:
self.status.setText(f"Echec du renvoi : {exc}")
return
# Beaucoup d'editeurs enregistrent en remplacant le fichier : le chemin surveille
# disparait alors du watcher, il faut le reinscrire pour les enregistrements suivants.
def on_success(_result) -> None:
# Beaucoup d'editeurs enregistrent en remplacant le fichier : le chemin
# surveille disparait alors du watcher, il faut le reinscrire pour les
# enregistrements suivants.
if local_path not in self._watcher.files() and Path(local_path).exists():
self._watcher.addPath(local_path)
self.refresh()
# apres refresh() : sinon le message serait ecrase par le compteur d'elements
self._refresh_after_edit(remote_path)
self._run(lambda: self.sftp.put(local_path, remote_path), on_success, on_error)
def _refresh_after_edit(self, remote_path: str) -> None:
"""Comme refresh(), mais affiche un message de confirmation specifique une
fois le listage revenu, au lieu du compteur d'elements habituel."""
self._nav_generation += 1
generation = self._nav_generation
path = self.current_path
def on_success(attrs) -> None:
if generation != self._nav_generation:
return
self._populate(attrs)
self.status.setText(f"↑ {posixpath.basename(remote_path)} renvoye sur le serveur")
def on_error(exc) -> None:
if generation != self._nav_generation:
return
QMessageBox.warning(self, "Erreur SFTP", f"{path} : {exc}")
self._run(lambda: self.sftp.listdir_attr(path), on_success, on_error)
def change_permissions(self) -> None:
entries = self.selected_entries()
if not entries:
return
name = entries[0][0]
path = self.join(self.current_path, name)
try:
current = stat.S_IMODE(self.sftp.stat(path).st_mode)
except IOError as exc:
def on_stat_error(exc) -> None:
QMessageBox.critical(self, "Erreur", str(exc))
return
def on_stat_success(attr) -> None:
current = stat.S_IMODE(attr.st_mode)
text, ok = QInputDialog.getText(
self, "Permissions", f"Droits octaux pour {name} :", text=f"{current:03o}",
)
@@ -626,25 +745,45 @@ class RemoteFilePanel(FilePanel):
except ValueError:
QMessageBox.warning(self, "Valeur invalide", "Entrez une valeur octale, par exemple 644 ou 755.")
return
self._apply_chmod(entries, mode)
self._run(lambda: self.sftp.stat(path), on_stat_success, on_stat_error)
def _apply_chmod(self, entries: list[tuple[str, bool]], mode: int) -> None:
current_path = self.current_path
def do_chmod() -> list[str]:
errors = []
for entry_name, _ in entries:
try:
self.sftp.chmod(self.join(self.current_path, entry_name), mode)
except IOError as exc:
self.sftp.chmod(self.join(current_path, entry_name), mode)
except OSError as exc:
errors.append(f"{entry_name} : {exc}")
return errors
def on_success(errors: list[str]) -> None:
if errors:
QMessageBox.critical(self, "Erreur", "\n".join(errors))
self.refresh()
self._run(do_chmod, on_success)
# ----------------------------------------------------------------------------
# Conteneur double panneau
# ----------------------------------------------------------------------------
class SFTPPanel(QWidget):
def __init__(self, sftp: paramiko.SFTPClient, parent=None):
"""`transfer_sftp_factory`, si fourni, ouvre un second canal SFTP reserve aux
transferts (upload/download), independant du canal de navigation : un gros
transfert ne bloque alors jamais le listage de dossiers. A defaut (ex. client
SFTP de test sans transport reel), le canal de navigation est reutilise tel quel."""
def __init__(self, sftp: paramiko.SFTPClient, transfer_sftp_factory=None, parent=None):
super().__init__(parent)
self.sftp = sftp
self._worker: TransferWorker | None = None
self._dedicated_transfer_channel = transfer_sftp_factory is not None
self._transfer_sftp_factory = transfer_sftp_factory or (lambda: sftp)
layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
@@ -662,7 +801,9 @@ class SFTPPanel(QWidget):
splitter = QSplitter(Qt.Horizontal)
self.local = LocalFilePanel()
self.remote = RemoteFilePanel(sftp)
self.remote_worker = SftpWorker(self)
self.remote_worker.start()
self.remote = RemoteFilePanel(sftp, self.remote_worker)
splitter.addWidget(self.local)
splitter.addWidget(self.remote)
splitter.setSizes([500, 500])
@@ -674,6 +815,15 @@ class SFTPPanel(QWidget):
self.local.transfer_here.connect(self._on_transfer_payload)
self.remote.transfer_here.connect(self._on_transfer_payload)
def shutdown(self) -> None:
"""A appeler avant que le panneau ne soit detruit (fermeture de l'onglet) :
arrete proprement le worker de navigation et tout transfert en cours."""
if self._worker and self._worker.isRunning():
self._worker.cancel()
self._worker.wait(2000)
self.remote_worker.stop()
self.remote_worker.wait(2000)
def _on_transfer_payload(self, payload: dict) -> None:
source_side = payload.get("side")
entries = payload.get("entries", [])
@@ -708,7 +858,9 @@ class SFTPPanel(QWidget):
dialog.setAutoClose(False)
dialog.setAutoReset(False)
worker = TransferWorker(self.sftp, jobs)
worker = TransferWorker(
self._transfer_sftp_factory, jobs, close_when_done=self._dedicated_transfer_channel,
)
worker.file_started.connect(
lambda name, i, total: dialog.setLabelText(f"({i}/{total}) {name}")
)

118
app/snippets.py Normal file
View File

@@ -0,0 +1,118 @@
"""Snippets de commandes : bibliotheque organisee par categories, utilisable
depuis un terminal (inserer sans executer, executer, executer avec Multi-Send)."""
from __future__ import annotations
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from app.paths import DATA_DIR
from app.storage.json_store import load_json_document, save_json_document
SNIPPETS_FILE = DATA_DIR / "snippets.json"
SCHEMA_VERSION = 1
@dataclass
class Snippet:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
category: str = ""
description: str = ""
command: str = ""
def to_dict(self) -> dict:
return asdict(self)
@staticmethod
def from_dict(data: dict) -> Snippet:
s = Snippet()
for k in Snippet.__dataclass_fields__:
if k in data:
setattr(s, k, data[k])
return s
# Quelques exemples fournis d'origine (premier lancement uniquement) : l'utilisateur
# peut les modifier ou les supprimer librement, ce ne sont que des points de depart.
DEFAULT_SNIPPETS: list[Snippet] = [
Snippet(name="Disk Usage", category="Linux",
description="Espace disque par systeme de fichiers", command="df -h"),
Snippet(name="Failed Services", category="Linux",
description="Services systemd en echec", command="systemctl --failed"),
Snippet(name="Listening Ports", category="Linux",
description="Ports en ecoute", command="ss -tulpn"),
Snippet(name="Containers", category="Docker",
description="Conteneurs (tous statuts)", command="docker ps -a"),
Snippet(name="Docker Cleanup", category="Docker",
description="Nettoyer images/conteneurs/volumes inutilises", command="docker system prune -a"),
Snippet(name="Logs", category="Docker",
description="Logs d'un conteneur (a completer avec son nom)", command="docker logs -f "),
Snippet(name="Cluster Status", category="Proxmox",
description="Etat du cluster Proxmox", command="pvecm status"),
Snippet(name="VM List", category="Proxmox",
description="Liste des machines virtuelles", command="qm list"),
]
class SnippetStore:
"""Charge/sauvegarde la liste des snippets depuis data/snippets.json."""
def __init__(self, path: Path = SNIPPETS_FILE):
self.path = path
self.snippets: list[Snippet] = []
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, "snippets": [s.to_dict() for s in DEFAULT_SNIPPETS]},
current_version=SCHEMA_VERSION,
)
self.load_warning = warning
self.snippets = [Snippet.from_dict(s) for s in document.get("snippets", [])]
if was_missing:
self.save()
def save(self) -> None:
payload = {"schema_version": SCHEMA_VERSION, "snippets": [s.to_dict() for s in self.snippets]}
save_json_document(self.path, payload)
def get(self, snippet_id: str) -> Snippet | None:
return next((s for s in self.snippets if s.id == snippet_id), None)
def add(self, snippet: Snippet) -> None:
self.snippets.append(snippet)
self.save()
def update(self, snippet: Snippet) -> None:
for i, s in enumerate(self.snippets):
if s.id == snippet.id:
self.snippets[i] = snippet
break
self.save()
def delete(self, snippet_id: str) -> None:
self.snippets = [s for s in self.snippets if s.id != snippet_id]
self.save()
def categories(self) -> list[str]:
return sorted({s.category for s in self.snippets if s.category})
def by_category(self) -> dict[str, list[Snippet]]:
grouped: dict[str, list[Snippet]] = {}
for s in sorted(self.snippets, key=lambda s: s.name.lower()):
grouped.setdefault(s.category or "Sans categorie", []).append(s)
return dict(sorted(grouped.items(), key=lambda kv: kv[0].lower()))
def search(self, query: str) -> list[Snippet]:
q = query.lower().strip()
if not q:
return list(self.snippets)
return [
s for s in self.snippets
if q in s.name.lower() or q in s.category.lower()
or q in s.description.lower() or q in s.command.lower()
]

View File

@@ -3,21 +3,21 @@ from __future__ import annotations
import queue
import threading
from pathlib import Path
import paramiko
from PySide6.QtCore import QThread, Signal
from app.bastion import BastionTarget
from app.known_hosts_manager import KnownHostsStore
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"):
def __init__(self, connection: SSHConnection):
self.connection = connection
def missing_host_key(self, client, hostname, key):
@@ -55,6 +55,11 @@ class SSHConnection(QThread):
rows: int = 24,
open_shell: bool = True,
open_sftp_on_connect: bool = False,
bastion_target: BastionTarget | None = None,
bastion_password: str = "",
keepalive_enabled: bool = False,
keepalive_interval: int = 30,
keepalive_count_max: int = 3,
parent=None,
):
super().__init__(parent)
@@ -70,10 +75,18 @@ class SSHConnection(QThread):
self.passphrase = passphrase
self.cols = cols
self.rows = rows
self.bastion_target = bastion_target
self.bastion_password = bastion_password
self.keepalive_enabled = keepalive_enabled
self.keepalive_interval = max(1, keepalive_interval)
self.keepalive_count_max = max(1, keepalive_count_max)
self.client: paramiko.SSHClient | None = None
self.channel: paramiko.Channel | None = None
self._bastion_client: paramiko.SSHClient | None = None
self._stop = threading.Event()
self._disconnect_reason = "Connexion fermee"
self._keepalive_thread: threading.Thread | None = None
self._trust_event = threading.Event()
self._trust_result = False
@@ -132,10 +145,10 @@ class SSHConnection(QThread):
self._passphrase_event.set()
# ---------- mot de passe demande en secours (appele depuis le thread SSH) ----------
def _ask_password(self) -> str | None:
def _ask_password(self, label: str | None = None) -> str | None:
self._password_event.clear()
self._password_result = None
self.password_prompt.emit(f"{self.username}@{self.host}")
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
@@ -143,60 +156,71 @@ class SSHConnection(QThread):
self._password_result = text or None
self._password_event.set()
# ---------- cycle de vie du thread ----------
def run(self) -> None:
try:
self.client = paramiko.SSHClient()
# ---------- 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:
self.client.load_system_host_keys()
client.load_system_host_keys()
except Exception:
pass
if KNOWN_HOSTS_FILE.exists():
self.client.load_host_keys(str(KNOWN_HOSTS_FILE))
self.client.set_missing_host_key_policy(TrustPrompt(self))
client.load_host_keys(str(KNOWN_HOSTS_FILE))
client.set_missing_host_key_policy(TrustPrompt(self))
return client
auto = self.auth_type == "auto"
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=self.host,
port=self.port,
username=self.username,
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 self.auth_type == "agent"),
allow_agent=(auto or auth_type == "agent"),
look_for_keys=auto,
sock=sock,
)
if self.auth_type == "key":
if not self.key_path:
if auth_type == "key":
if not key_path:
raise paramiko.SSHException(
"Aucune cle privee selectionnee. Choisissez une cle, ou l'authentification "
"automatique / par mot de passe."
f"{label} : aucune cle privee selectionnee. Choisissez une cle, ou "
"l'authentification automatique / par mot de passe."
)
connect_kwargs["pkey"] = self._load_private_key(self.key_path, self.passphrase)
elif self.auth_type == "password":
connect_kwargs["password"] = self.password
elif auto and self.password:
connect_kwargs["password"] = self.password
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:
self.client.connect(**connect_kwargs)
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
password = self._ask_password()
if not password:
asked = self._ask_password(label)
if not asked:
raise
connect_kwargs["password"] = password
connect_kwargs["password"] = asked
connect_kwargs["look_for_keys"] = False
connect_kwargs["allow_agent"] = False
self.client.connect(**connect_kwargs)
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).
@@ -208,11 +232,89 @@ class SSHConnection(QThread):
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 = self.client.get_host_keys()
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)
self.client.connect(**connect_kwargs)
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
# ---------- keepalive (ServerAliveInterval / ServerAliveCountMax) ----------
def _start_keepalive(self) -> None:
if not self.keepalive_enabled:
return
self._keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True)
self._keepalive_thread.start()
def _keepalive_loop(self) -> None:
"""Envoie une requete keepalive avec accuse de reception toutes les
`keepalive_interval` secondes. Apres `keepalive_count_max` echecs
consecutifs (pas de reponse : transport mort), force la fermeture du
canal pour que la boucle de lecture principale s'en apercoive et
declenche la deconnexion (et, si activee, la reconnexion automatique)."""
failures = 0
while not self._stop.wait(self.keepalive_interval):
transport = self.client.get_transport() if self.client else None
if not transport or not transport.is_active():
return
try:
# Nom choisi comme celui d'OpenSSH : un serveur conforme repond
# "request failed" immediatement, ce qui suffit a confirmer que
# le transport est bien vivant (le contenu de la reponse importe peu).
transport.global_request("keepalive@openssh.com", wait=True)
failures = 0
except Exception: # noqa: BLE001
failures += 1
if failures >= self.keepalive_count_max:
self._disconnect_reason = (
f"keepalive : aucune reponse apres {failures} tentative(s)"
)
self._stop.set()
try:
if self.channel:
self.channel.close()
except Exception:
pass
return
# ---------- 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.
@@ -227,12 +329,14 @@ class SSHConnection(QThread):
if not self.open_shell:
raise
self._start_keepalive()
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")
self.disconnected.emit(self._disconnect_reason)
return
self.channel = self.client.invoke_shell(term="xterm-256color", width=self.cols, height=self.rows)
@@ -259,7 +363,7 @@ class SSHConnection(QThread):
except Exception:
break
self.disconnected.emit("Connexion fermee")
self.disconnected.emit(self._disconnect_reason)
except paramiko.AuthenticationException:
self.error.emit("Authentification refusee (identifiants ou cle invalides).")
except Exception as exc: # noqa: BLE001
@@ -275,7 +379,10 @@ class SSHConnection(QThread):
except paramiko.PasswordRequiredException:
pwd = passphrase or self._ask_passphrase(key_path)
if not pwd:
raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle")
# l'utilisateur a annule la demande de passphrase : ce n'est pas un
# echec a diagnostiquer, la PasswordRequiredException d'origine n'a
# rien a apporter au message affiche.
raise paramiko.SSHException("Passphrase requise pour deverrouiller la cle") from None
try:
return loader.from_private_key_file(key_path, password=pwd)
except paramiko.SSHException as exc:
@@ -341,3 +448,8 @@ class SSHConnection(QThread):
self.client.close()
except Exception:
pass
try:
if self._bastion_client:
self._bastion_client.close()
except Exception:
pass

0
app/storage/__init__.py Normal file
View File

View File

@@ -0,0 +1,24 @@
"""Ecriture atomique de fichiers : un crash ou une coupure de courant pendant
l'ecriture ne doit jamais laisser un fichier tronque ou corrompu a la place de
l'ancien, valide."""
from __future__ import annotations
import os
from pathlib import Path
def atomic_write_bytes(path: Path, content: bytes) -> None:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(path.name + ".tmp")
with open(tmp_path, "wb") as f:
f.write(content)
f.flush()
os.fsync(f.fileno())
# os.replace() est atomique y compris sous Windows (contrairement a os.rename(),
# qui y echoue si la destination existe deja).
os.replace(tmp_path, path)
def atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
atomic_write_bytes(path, content.encode(encoding))

81
app/storage/json_store.py Normal file
View File

@@ -0,0 +1,81 @@
"""Chargement/sauvegarde de documents JSON versionnes (sessions, cles, ...).
Chaque document porte un `schema_version`. `migrations` associe une version de
depart a une fonction qui transforme le document vers la version suivante ; le
chargement applique la chaine jusqu'a `current_version`, ce qui permet de faire
evoluer le format plus tard sans invalider les fichiers existants.
La sauvegarde est atomique (voir atomic_file) et conserve la version precedente
dans un `.bak` : si l'ecriture suivante produit un fichier corrompu (crash,
disque plein), le chargement suivant retombe automatiquement sur cette sauvegarde
plutot que de perdre les donnees de l'utilisateur.
"""
from __future__ import annotations
import json
from collections.abc import Callable
from pathlib import Path
from app.storage.atomic_file import atomic_write_bytes
MigrationMap = dict[int, Callable[[dict], dict]]
def _bak_path(path: Path) -> Path:
return path.with_name(path.name + ".bak")
def _migrate(document: dict, current_version: int, migrations: MigrationMap) -> dict:
version = int(document.get("schema_version", 0) or 0)
while version < current_version:
migrate = migrations.get(version)
if migrate is None:
break # pas de chemin de migration connu : le document est garde tel quel
document = migrate(document)
version = int(document.get("schema_version", version + 1))
document["schema_version"] = current_version
return document
def load_json_document(
path: Path,
*,
default: dict,
current_version: int,
migrations: MigrationMap | None = None,
) -> tuple[dict, str | None]:
"""Renvoie (document, avertissement). `avertissement` est None si le fichier
principal a ete lu normalement ; sinon il decrit la recuperation effectuee
(restauration depuis .bak, ou repli sur les valeurs par defaut)."""
migrations = migrations or {}
warning: str | None = None
for candidate, is_backup in ((Path(path), False), (_bak_path(Path(path)), True)):
if not candidate.exists():
continue
try:
raw = json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
warning = f"{candidate.name} illisible ({exc})"
continue
if not isinstance(raw, dict):
warning = f"{candidate.name} invalide (contenu inattendu)"
continue
if is_backup:
warning = f"{Path(path).name} illisible, restaure depuis {candidate.name}"
return _migrate(raw, current_version, migrations), warning
if warning is None and not Path(path).exists():
pass # premier lancement : pas d'avertissement, juste les valeurs par defaut
return _migrate(dict(default), current_version, migrations), warning
def save_json_document(path: Path, document: dict) -> None:
path = Path(path)
if path.exists():
try:
atomic_write_bytes(_bak_path(path), path.read_bytes())
except OSError:
pass
payload = json.dumps(document, indent=2, ensure_ascii=False)
atomic_write_bytes(path, payload.encode("utf-8"))

View File

@@ -11,9 +11,14 @@ from __future__ import annotations
import pyte
from PySide6.QtCore import QRect, Qt, Signal
from PySide6.QtGui import (
QColor, QFont, QFontMetrics, QPainter, QKeyEvent, QWheelEvent,
QColor,
QFont,
QFontMetrics,
QKeyEvent,
QPainter,
QWheelEvent,
)
from PySide6.QtWidgets import QWidget, QApplication, QScrollBar
from PySide6.QtWidgets import QApplication, QScrollBar, QWidget
from app import theme as app_theme
@@ -281,6 +286,16 @@ class TerminalWidget(QWidget):
payload = text.replace("\r\n", "\r").replace("\n", "\r")
self.data_to_send.emit(payload.encode("utf-8", errors="ignore"))
def visible_text(self) -> str:
"""Ce qui est actuellement affiche a l'ecran (sans l'historique de
defilement) : repli de l'assistant IA quand rien n'est selectionne -
"expliquer cette sortie" doit alors porter sur ce que l'utilisateur a
reellement sous les yeux, pas sur tout le scrollback."""
lines = [line.rstrip() for line in self.screen.display]
while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)
def buffer_text(self) -> str:
"""Texte visible + historique proche, pour retrouver ce qui a ete tape
(utilise pour deduire l'hote d'une commande ssh saisie a la main).

View File

@@ -6,17 +6,16 @@ choisie : une seule feuille de style a maintenir au lieu de trois quasi identiqu
"""
from __future__ import annotations
import json
from pathlib import Path
from string import Template
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QFont, QLinearGradient
from PySide6.QtGui import QColor, QFont, QIcon, QLinearGradient, QPainter, QPixmap
from PySide6.QtWidgets import QApplication
from app.paths import RESOURCES_DIR, DATA_DIR # noqa: E402 (chemins valables aussi en .exe)
from app.app_settings import get_setting, set_setting
from app.paths import RESOURCES_DIR # noqa: E402 (chemins valables aussi en .exe)
STYLESHEET_PATH = RESOURCES_DIR / "theme.qss"
SETTINGS_FILE = DATA_DIR / "settings.json"
THEME_LIGHT = "light"
THEME_MIDNIGHT = "midnight"
@@ -105,28 +104,12 @@ _current_theme = DEFAULT_THEME
# Preference persistante
# ---------------------------------------------------------------------------
def load_saved_theme() -> str:
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
theme = data.get("theme")
if theme in UI_PALETTES:
return theme
except (OSError, json.JSONDecodeError):
pass
return DEFAULT_THEME
theme = get_setting("theme")
return theme if theme in UI_PALETTES else DEFAULT_THEME
def save_theme(theme: str) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
data = {}
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
data["theme"] = theme
try:
SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
except OSError:
pass
set_setting("theme", theme)
DEFAULT_FONT_SIZE = 10
@@ -134,29 +117,14 @@ DEFAULT_FONT_SIZE = 10
def load_font_size() -> int:
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
size = int(data.get("font_size", DEFAULT_FONT_SIZE))
return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE
except (OSError, json.JSONDecodeError, TypeError, ValueError):
size = int(get_setting("font_size", DEFAULT_FONT_SIZE))
except (TypeError, ValueError):
return DEFAULT_FONT_SIZE
return size if 6 <= size <= 32 else DEFAULT_FONT_SIZE
def save_font_size(size: int) -> None:
_update_settings("font_size", size)
def _update_settings(key: str, value) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
data = {}
try:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
data[key] = value
try:
SETTINGS_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
except OSError:
pass
set_setting("font_size", size)
def current_theme() -> str:

View File

@@ -1,4 +1,4 @@
"""Tunnels SSH (redirection de ports), facon `ssh -L` et `ssh -R`.
"""Tunnels SSH (redirection de ports), facon `ssh -L`, `ssh -R` et `ssh -D`.
Redirection locale (-L) : un port ouvert sur votre poste est relaye, a travers la
connexion SSH, vers un hote joignable depuis le serveur. Typiquement pour atteindre
@@ -7,6 +7,10 @@ l'interface web d'un service qui n'ecoute que sur la boucle locale du serveur.
Redirection distante (-R) : l'inverse, un port ouvert sur le serveur est relaye vers
une machine joignable depuis votre poste.
Redirection dynamique (-D) : un port ouvert sur votre poste se comporte comme un
proxy SOCKS ; la destination n'est pas fixee a l'avance mais negociee par chaque
client SOCKS (navigateur, curl...), puis atteinte a travers la connexion SSH.
Chaque tunnel tourne dans son propre thread : un relais bloque ne doit gener ni
l'interface ni les autres tunnels.
"""
@@ -21,10 +25,12 @@ import paramiko
DIRECTION_LOCAL = "local"
DIRECTION_REMOTE = "remote"
DIRECTION_DYNAMIC = "dynamic"
DIRECTION_LABELS = {
DIRECTION_LOCAL: "Locale (-L)",
DIRECTION_REMOTE: "Distante (-R)",
DIRECTION_DYNAMIC: "Dynamique - SOCKS (-D)",
}
@@ -33,6 +39,8 @@ def describe(tunnel: dict) -> str:
if tunnel.get("direction") == DIRECTION_REMOTE:
return (f"serveur:{tunnel['listen_port']} -> "
f"{tunnel['dest_host']}:{tunnel['dest_port']} (via ce poste)")
if tunnel.get("direction") == DIRECTION_DYNAMIC:
return f"SOCKS sur 127.0.0.1:{tunnel['listen_port']} (destination negociee par le client)"
return (f"localhost:{tunnel['listen_port']} -> "
f"{tunnel['dest_host']}:{tunnel['dest_port']} (via le serveur)")
@@ -52,7 +60,7 @@ def _relay(sock: socket.socket, chan: paramiko.Channel) -> None:
if not data:
break
sock.sendall(data)
except (OSError, socket.error):
except OSError:
pass
finally:
for closeable in (chan, sock):
@@ -84,6 +92,110 @@ class _ForwardHandler(socketserver.BaseRequestHandler):
_relay(self.request, chan)
# ----------------------------------------------------------------------------
# Redirection dynamique (-D) : proxy SOCKS local
# ----------------------------------------------------------------------------
def _recv_exact(sock: socket.socket, n: int) -> bytes:
"""recv() ne garantit pas de renvoyer les n octets demandes en un seul appel."""
data = b""
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError("Connexion fermee pendant la negociation SOCKS")
data += chunk
return data
def _read_c_string(sock: socket.socket) -> bytes:
out = bytearray()
while True:
b = _recv_exact(sock, 1)
if b == b"\x00":
return bytes(out)
out += b
def _socks5_handshake(sock: socket.socket) -> tuple[str, int] | None:
nmethods = _recv_exact(sock, 1)[0]
methods = _recv_exact(sock, nmethods) if nmethods else b""
if 0x00 not in methods:
sock.sendall(b"\x05\xff") # aucune methode acceptable : on ne gere que "sans authentification"
return None
sock.sendall(b"\x05\x00")
_, cmd, _, atyp = _recv_exact(sock, 4)
if cmd != 0x01: # seul CONNECT est pris en charge (pas BIND / UDP ASSOCIATE)
sock.sendall(b"\x05\x07\x00\x01" + b"\x00" * 6)
return None
if atyp == 0x01:
host = socket.inet_ntoa(_recv_exact(sock, 4))
elif atyp == 0x03:
length = _recv_exact(sock, 1)[0]
host = _recv_exact(sock, length).decode("ascii", errors="replace")
elif atyp == 0x04:
host = socket.inet_ntop(socket.AF_INET6, _recv_exact(sock, 16))
else:
sock.sendall(b"\x05\x08\x00\x01" + b"\x00" * 6) # type d'adresse non supporte
return None
port = int.from_bytes(_recv_exact(sock, 2), "big")
# Adresse/port de retour a 0 : sans interet pour un CONNECT, la plupart des
# clients ne les utilisent pas au-dela de verifier le code de succes.
sock.sendall(b"\x05\x00\x00\x01" + b"\x00\x00\x00\x00\x00\x00")
return host, port
def _socks4_handshake(sock: socket.socket) -> tuple[str, int] | None:
cmd = _recv_exact(sock, 1)[0]
port = int.from_bytes(_recv_exact(sock, 2), "big")
ip_bytes = _recv_exact(sock, 4)
_read_c_string(sock) # identifiant utilisateur, ignore
if cmd != 0x01:
sock.sendall(b"\x00\x5b" + b"\x00" * 6)
return None
# SOCKS4a : une IP de la forme 0.0.0.x (x != 0) signale un nom de domaine a
# lire ensuite plutot qu'une adresse IP directement utilisable.
if ip_bytes[:3] == b"\x00\x00\x00" and ip_bytes[3] != 0:
host = _read_c_string(sock).decode("ascii", errors="replace")
else:
host = socket.inet_ntoa(ip_bytes)
sock.sendall(b"\x00\x5a" + port.to_bytes(2, "big") + ip_bytes)
return host, port
def _socks_handshake(sock: socket.socket) -> tuple[str, int] | None:
"""Negocie une requete SOCKS4/4a ou SOCKS5 (CONNECT uniquement, sans
authentification) et renvoie (hote, port) demande par le client, ou None si
la negociation echoue ou porte sur une commande non prise en charge."""
version = _recv_exact(sock, 1)
if version == b"\x05":
return _socks5_handshake(sock)
if version == b"\x04":
return _socks4_handshake(sock)
return None
class _SocksHandler(socketserver.BaseRequestHandler):
transport: paramiko.Transport
def handle(self):
sock = self.request
try:
dest = _socks_handshake(sock)
except (OSError, ConnectionError, IndexError):
return
if dest is None:
return
try:
chan = self.transport.open_channel("direct-tcpip", dest, sock.getpeername())
except Exception:
return
if chan is None:
return
_relay(sock, chan)
class Tunnel:
"""Un tunnel actif, avec de quoi l'arreter."""
@@ -103,9 +215,20 @@ class Tunnel:
self._transport = transport
if self.direction == DIRECTION_REMOTE:
self._start_remote(transport)
elif self.direction == DIRECTION_DYNAMIC:
self._start_dynamic(transport)
else:
self._start_local(transport)
# ---------- redirection dynamique (-D) : proxy SOCKS ----------
def _start_dynamic(self, transport: paramiko.Transport) -> None:
config = self.config
handler = type("SocksHandler", (_SocksHandler,), {"transport": transport})
bind = config.get("listen_host") or "127.0.0.1"
self._server = _ForwardServer((bind, int(config["listen_port"])), handler)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
# ---------- redirection locale (-L) ----------
def _start_local(self, transport: paramiko.Transport) -> None:
config = self.config

0
app/ui/__init__.py Normal file
View File

178
app/ui/ai_panel.py Normal file
View File

@@ -0,0 +1,178 @@
"""Panneau lateral de l'assistant IA : conversation en memoire uniquement
(jamais persistee, jamais journalisee), une requete a la fois.
Point d'entree unique (`run_request`) pour toutes les actions IA d'un onglet :
c'est ici, et seulement ici, que se fait la verification de securite (avis
unique + detection de secrets) avant tout envoi au fournisseur configure.
"""
from __future__ import annotations
import html
from PySide6.QtWidgets import (
QApplication,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTextEdit,
QVBoxLayout,
QWidget,
)
from app.ai.redaction import redact
from app.ai.service import KIND_ASK, AIRequestWorker
from app.dialogs.ai_prompts import CANCEL, MASK, confirm_sensitive_content, ensure_ai_notice_acknowledged
_KIND_LABELS = {
"explain": "Expliquer cette sortie",
"diagnose": "Diagnostiquer cette erreur",
"solution": "Proposer une solution",
"ask": "Question",
}
def _escape(text: str) -> str:
return html.escape(text).replace("\n", "<br>")
class AIPanel(QWidget):
"""`provider_factory` est un callable sans argument renvoyant un AIProvider
pret a l'emploi, ou None si l'assistant est desactive/non configure -
rappele a chaque requete pour refleter immediatement un changement de
parametres, sans avoir a rouvrir l'onglet."""
def __init__(self, provider_factory, parent=None):
super().__init__(parent)
self.provider_factory = provider_factory
self._worker: AIRequestWorker | None = None
self._generation = 0
self._last_response = ""
self._pending_context = ""
layout = QVBoxLayout(self)
layout.setContentsMargins(6, 6, 6, 6)
header = QHBoxLayout()
header.addWidget(QLabel("🤖 Assistant IA"), 1)
close_btn = QPushButton("✕")
close_btn.setFixedWidth(28)
close_btn.setToolTip("Masquer le panneau")
close_btn.clicked.connect(self.hide)
header.addWidget(close_btn)
layout.addLayout(header)
self.conversation = QTextEdit()
self.conversation.setReadOnly(True)
layout.addWidget(self.conversation, 1)
self.busy_label = QLabel("")
self.busy_label.setObjectName("statusLabel")
layout.addWidget(self.busy_label)
self.question_row = QWidget()
question_layout = QHBoxLayout(self.question_row)
question_layout.setContentsMargins(0, 0, 0, 0)
self.question_edit = QLineEdit()
self.question_edit.setPlaceholderText("Votre question...")
self.question_edit.returnPressed.connect(self._send_question)
send_btn = QPushButton("Envoyer")
send_btn.clicked.connect(self._send_question)
question_layout.addWidget(self.question_edit, 1)
question_layout.addWidget(send_btn)
self.question_row.hide()
layout.addWidget(self.question_row)
btn_row = QHBoxLayout()
self.copy_btn = QPushButton("Copier reponse")
self.new_question_btn = QPushButton("Nouvelle question")
self.clear_btn = QPushButton("Effacer la conversation")
self.copy_btn.clicked.connect(self._copy_last_response)
self.new_question_btn.clicked.connect(lambda: self.prompt_question(""))
self.clear_btn.clicked.connect(self.clear_conversation)
for b in (self.copy_btn, self.new_question_btn, self.clear_btn):
btn_row.addWidget(b)
layout.addLayout(btn_row)
# ---------- conversation ----------
def clear_conversation(self) -> None:
self._generation += 1 # toute reponse en vol devient perimee et sera ignoree
self.conversation.clear()
self._last_response = ""
self.busy_label.clear()
self.question_row.hide()
def _copy_last_response(self) -> None:
if self._last_response:
QApplication.clipboard().setText(self._last_response)
# ---------- question libre ----------
def prompt_question(self, context: str = "") -> None:
"""Affiche le panneau et la zone de saisie d'une question. `context`
est un texte optionnel (ex : selection du terminal) joint a la
question, propose a la meme verification de secrets avant l'envoi."""
self.show()
self._pending_context = context
self.question_row.show()
self.question_edit.setFocus()
def _send_question(self) -> None:
question = self.question_edit.text().strip()
if not question:
return
self.question_edit.clear()
self.question_row.hide()
context, self._pending_context = self._pending_context, ""
self.run_request(KIND_ASK, question, context=context, label=_KIND_LABELS["ask"])
# ---------- requetes ----------
def run_request(self, kind: str, payload: str, context: str = "", label: str = "") -> None:
if self._worker is not None and self._worker.isRunning():
self.busy_label.setText("Une requete est deja en cours, patientez...")
return
provider = self.provider_factory() if self.provider_factory else None
if provider is None:
self.show()
self.conversation.append("<i>Assistant IA non configure (menu Assistant IA > Parametres...).</i>")
return
if not ensure_ai_notice_acknowledged(self):
return
to_check = payload + ("\n" + context if context else "")
decision = confirm_sensitive_content(self, to_check)
if decision == CANCEL:
return
if decision == MASK:
payload = redact(payload)
context = redact(context) if context else context
self.show()
label = label or _KIND_LABELS.get(kind, kind)
self.conversation.append(f"<b>{html.escape(label)}</b><br>{_escape(payload)}")
self.busy_label.setText("Interrogation du fournisseur IA...")
generation = self._generation
self._worker = AIRequestWorker(provider, kind, payload, context=context)
self._worker.finished_ok.connect(lambda text: self._on_ok(generation, text))
self._worker.finished_error.connect(lambda message: self._on_error(generation, message))
self._worker.start()
def _on_ok(self, generation: int, text: str) -> None:
if generation != self._generation:
return
self.busy_label.clear()
self._last_response = text
self.conversation.append(f"<div>{_escape(text)}</div>")
def _on_error(self, generation: int, message: str) -> None:
if generation != self._generation:
return
self.busy_label.clear()
self.conversation.append(f"<span style='color:#c0392b;'>Erreur : {_escape(message)}</span>")
def shutdown(self) -> None:
"""A appeler avant la destruction du panneau (fermeture de l'onglet) :
une requete deja en vol devient perimee (voir `_generation`) et son
thread dispose d'un court delai pour se terminer proprement."""
self._generation += 1
if self._worker is not None and self._worker.isRunning():
self._worker.wait(2000)

View File

@@ -0,0 +1,31 @@
"""QStackedWidget qui s'adapte a la hauteur de sa page courante.
Par defaut, QStackedWidget dimensionne son sizeHint() sur sa PLUS GRANDE page,
meme quand une page plus petite (ou vide) est affichee - pense pour eviter
qu'un dialogue ne saute de taille en changeant de page. Ici on veut au
contraire un dialogue qui suit sa page courante : utilise pour les blocs
Bastion/Jump Host (session_dialog.py, group_settings_dialog.py), ou la page
"Aucun" est vide mais la page "Configuration manuelle" a 5 champs - sans ce
correctif, un grand espace vide reste reserve sous la page vide.
"""
from __future__ import annotations
from PySide6.QtWidgets import QStackedWidget
class AutoHeightStackedWidget(QStackedWidget):
def sizeHint(self):
return self.currentWidget().sizeHint()
def minimumSizeHint(self):
return self.currentWidget().minimumSizeHint()
def setCurrentIndex(self, index: int) -> None:
super().setCurrentIndex(index)
# previent le parent (le dialogue) que sizeHint() a change, sans quoi
# il ne redemande pas immediatement la nouvelle taille au changement
# de page.
self.updateGeometry()
window = self.window()
if window is not None:
window.adjustSize()

187
app/ui/split_pane.py Normal file
View File

@@ -0,0 +1,187 @@
"""Affichage de plusieurs terminaux dans un meme onglet (split), via des
QSplitter imbriques.
Un SplitContainer prend la place d'un onglet normal dans le QTabWidget de
ConnectionController ; chaque panneau (PaneSlot) heberge un widget d'onglet
existant (SessionTabWidget, LocalTerminalTabWidget, ...) sans le modifier.
"""
from __future__ import annotations
from collections.abc import Callable
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QFrame,
QHBoxLayout,
QSplitter,
QToolButton,
QVBoxLayout,
QWidget,
)
LAYOUT_SINGLE = "1"
LAYOUT_2V = "2v" # 2 panneaux cote a cote (ligne de separation verticale)
LAYOUT_2H = "2h" # 2 panneaux empiles (ligne de separation horizontale)
LAYOUT_4 = "4" # grille 2x2
LAYOUT_LABELS = {
LAYOUT_2V: "2 panneaux verticaux (cote a cote)",
LAYOUT_2H: "2 panneaux horizontaux (empiles)",
LAYOUT_4: "4 panneaux",
}
_PANE_COUNTS = {LAYOUT_SINGLE: 1, LAYOUT_2V: 2, LAYOUT_2H: 2, LAYOUT_4: 4}
class PaneSlot(QWidget):
"""Un emplacement dans la grille : une fine barre (menu + fermeture)
au-dessus du contenu, qui reste un widget d'onglet inchange."""
close_requested = Signal(object) # emet elle-meme
menu_requested = Signal(object) # emet elle-meme ; le conteneur construit le menu
def __init__(self, content: QWidget, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
bar = QHBoxLayout()
bar.setContentsMargins(4, 2, 4, 2)
self.menu_btn = QToolButton()
self.menu_btn.setText("☰")
self.menu_btn.setToolTip("Changer le contenu de ce panneau")
self.menu_btn.clicked.connect(lambda: self.menu_requested.emit(self))
bar.addWidget(self.menu_btn)
bar.addStretch()
close_btn = QToolButton()
close_btn.setText("✕")
close_btn.setToolTip("Fermer ce panneau")
close_btn.clicked.connect(lambda: self.close_requested.emit(self))
bar.addWidget(close_btn)
bar_frame = QFrame()
bar_frame.setObjectName("tabBar")
bar_frame.setLayout(bar)
layout.addWidget(bar_frame)
self.content = content
layout.addWidget(content, 1)
def close_connection(self) -> None:
if hasattr(self.content, "close_connection"):
self.content.close_connection()
def set_content(self, new_content: QWidget) -> None:
self.layout().replaceWidget(self.content, new_content)
self.content.setParent(None)
self.content = new_content
new_content.show()
class SplitContainer(QWidget):
"""Grille de terminaux (1/2/4 panneaux). Redescend automatiquement a un
seul panneau quand il n'en reste plus qu'un (ferme les autres)."""
all_panes_closed = Signal(object) # (widget restant) -> le proprietaire deplie l'onglet
pane_menu_requested = Signal(object, object) # (pane, container) -> le proprietaire affiche un menu
def __init__(self, first_widget: QWidget, layout_kind: str, make_filler: Callable[[], QWidget], parent=None):
super().__init__(parent)
self.layout_kind = layout_kind
self.panes: list[PaneSlot] = []
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
contents = [first_widget] + [make_filler() for _ in range(_PANE_COUNTS[layout_kind] - 1)]
for content in contents:
self._add_pane(content)
self._root_layout.addWidget(self._arrange(layout_kind, self.panes))
def _make_pane(self, content: QWidget) -> PaneSlot:
pane = PaneSlot(content)
pane.close_requested.connect(self._on_pane_close_requested)
pane.menu_requested.connect(lambda p: self.pane_menu_requested.emit(p, self))
return pane
def _add_pane(self, content: QWidget) -> PaneSlot:
pane = self._make_pane(content)
self.panes.append(pane)
return pane
@staticmethod
def _arrange(layout_kind: str, panes: list[PaneSlot]) -> QWidget:
if layout_kind == LAYOUT_SINGLE:
return panes[0]
if layout_kind == LAYOUT_2V:
splitter = QSplitter(Qt.Horizontal)
elif layout_kind == LAYOUT_2H:
splitter = QSplitter(Qt.Vertical)
elif layout_kind == LAYOUT_4:
top = QSplitter(Qt.Horizontal)
top.addWidget(panes[0])
top.addWidget(panes[1])
bottom = QSplitter(Qt.Horizontal)
bottom.addWidget(panes[2])
bottom.addWidget(panes[3])
outer = QSplitter(Qt.Vertical)
outer.addWidget(top)
outer.addWidget(bottom)
return outer
else:
raise ValueError(f"layout de split inconnu : {layout_kind}")
for pane in panes:
splitter.addWidget(pane)
return splitter
def replace_pane_content(self, pane: PaneSlot, new_content: QWidget) -> None:
"""Ferme l'ancien contenu du panneau et le remplace (ex : terminal
local par defaut remplace par un onglet deplace ici)."""
pane.close_connection()
pane.set_content(new_content)
def _on_pane_close_requested(self, pane: PaneSlot) -> None:
pane.close_connection()
self.panes.remove(pane)
# Le retirer de son QSplitter suffit : celui-ci redistribue l'espace
# aux panneaux restants tout seul, sans reconstruire toute la grille.
pane.setParent(None)
pane.deleteLater()
if len(self.panes) <= 1:
remaining = self.panes[0].content if self.panes else None
self.all_panes_closed.emit(remaining)
def close_all(self) -> None:
for pane in self.panes:
pane.close_connection()
# ---------- tailles (capture/restauration d'un workspace) ----------
def get_layout_sizes(self) -> list[list[int]]:
"""Tailles (pixels) de chaque QSplitter de la grille, dans un ordre
stable (parcours en profondeur depuis la racine)."""
sizes: list[list[int]] = []
def collect(widget: QWidget) -> None:
if isinstance(widget, QSplitter):
sizes.append(widget.sizes())
for i in range(widget.count()):
collect(widget.widget(i))
root_item = self._root_layout.itemAt(0)
if root_item and root_item.widget():
collect(root_item.widget())
return sizes
def set_layout_sizes(self, sizes: list[list[int]]) -> None:
remaining = list(sizes)
def apply(widget: QWidget) -> None:
if isinstance(widget, QSplitter):
if remaining:
widget.setSizes(remaining.pop(0))
for i in range(widget.count()):
apply(widget.widget(i))
root_item = self._root_layout.itemAt(0)
if root_item and root_item.widget():
apply(root_item.widget())

824
app/ui/tabs.py Normal file
View File

@@ -0,0 +1,824 @@
"""Widgets d'onglet : terminal SSH, terminal local, SFTP autonome.
Chaque onglet encapsule une connexion (SSH ou locale) qui expose la meme
interface de signaux (connected / data_received / disconnected / error / send /
resize), ce qui permet a `BaseTerminalTab` de rester independante du type de
connexion sous-jacent.
"""
from __future__ import annotations
import re
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QFileDialog,
QFrame,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMenu,
QMessageBox,
QSplitter,
QToolButton,
QVBoxLayout,
QWidget,
)
from app.ai.danger import check_dangerous
from app.ai.redaction import redact
from app.ai.service import KIND_DIAGNOSE, KIND_EXPLAIN, KIND_GENERATE, KIND_SOLUTION, AIRequestWorker
from app.dialogs.ai_command_dialog import AICommandDialog
from app.dialogs.ai_prompts import CANCEL, MASK, confirm_sensitive_content, ensure_ai_notice_acknowledged
from app.dialogs.connect_prompt import ask_secret, confirm_host_key, confirm_host_key_change
from app.dialogs.snippet_dialog import SnippetManagerDialog
from app.dialogs.tunnel_dialog import TunnelDialog
from app.local_terminal import SHELL_LABELS, SHELL_POWERSHELL, LocalTerminalConnection
from app.services.reconnect import compute_backoff_delay
from app.services.ssh_target import detect_ssh_target
from app.session_manager import AUTH_KEY, AUTH_PASSWORD, Session
from app.sftp_panel import SFTPPanel
from app.snippets import Snippet, SnippetStore
from app.ssh_connection import SSHConnection
from app.terminal_widget import TerminalWidget
from app.tunnels import TunnelManager
from app.ui.ai_panel import AIPanel
# sequences d'echappement ANSI, retirees des journaux pour qu'ils restent lisibles
ANSI_ESCAPE_RE = re.compile(
r"\x1b\[[0-9;?]*[a-zA-Z]" # CSI : couleurs, deplacements du curseur
r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC : titre de fenetre
r"|\x1b[()][A-Z0-9]" # jeux de caracteres
r"|\x1b[=>]" # mode clavier
r"|[\x00-\x08\x0b\x0c\x0e-\x1f]" # autres caracteres de controle
)
class BaseTerminalTab(QWidget):
"""Base commune a un onglet terminal : barre de statut + terminal, autour d'une
connexion (SSH ou locale) qui expose la meme interface de signaux
(connected / data_received / disconnected / error / send / resize)."""
def __init__(
self, initial_status: str, snippet_store: SnippetStore | None = None,
broadcast=None, ai_provider_factory=None, credential_store=None, parent=None,
):
super().__init__(parent)
self.snippet_store = snippet_store
self.broadcast = broadcast
self.ai_provider_factory = ai_provider_factory
self.credential_store = credential_store
self.ai_panel: AIPanel | None = None
self._ai_command_worker: AIRequestWorker | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.bar = QHBoxLayout()
self.bar.setContentsMargins(10, 6, 10, 6)
self.status_label = QLabel(initial_status)
self.status_label.setObjectName("statusLabel")
self.bar.addWidget(self.status_label, 1)
# Apparait quand la connexion tombe : les serveurs de test redemarrent souvent,
# inutile de fermer l'onglet et de tout ressaisir.
self.reconnect_btn = QToolButton()
self.reconnect_btn.setObjectName("primary")
self.reconnect_btn.setText("⟳ Reconnecter")
self.reconnect_btn.hide()
self.reconnect_btn.clicked.connect(self.reconnect)
self.bar.addWidget(self.reconnect_btn)
# Clic droit = coller (comme un terminal Unix) : les snippets passent donc
# par ce bouton plutot que par un menu contextuel, qui entrerait en conflit.
self.snippets_btn = QToolButton()
self.snippets_btn.setText("📋 Snippets")
self.snippets_btn.setToolTip("Inserer ou executer une commande enregistree")
self.snippets_btn.setPopupMode(QToolButton.InstantPopup)
self.snippets_btn.clicked.connect(self._show_snippets_menu)
self.bar.addWidget(self.snippets_btn)
self.ai_btn = QToolButton()
self.ai_btn.setText("🤖 Assistant IA")
self.ai_btn.setToolTip("Analyser la sortie selectionnee ou generer une commande (optionnel)")
self.ai_btn.setPopupMode(QToolButton.InstantPopup)
self.ai_btn.clicked.connect(self._show_ai_menu)
self.bar.addWidget(self.ai_btn)
self.log_btn = QToolButton()
self.log_btn.setText("⏺ Journal")
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
self.log_btn.setCheckable(True)
self.log_btn.clicked.connect(self.toggle_logging)
self.bar.addWidget(self.log_btn)
bar_frame = QFrame()
bar_frame.setObjectName("tabBar")
bar_frame.setLayout(self.bar)
layout.addWidget(bar_frame)
self.splitter = QSplitter(Qt.Horizontal)
self.terminal = TerminalWidget()
self.splitter.addWidget(self.terminal)
layout.addWidget(self.splitter, 1)
# barre de recherche, masquee tant qu'on n'a pas fait Ctrl+F
self.search_bar = QFrame()
self.search_bar.setObjectName("tabBar")
search_layout = QHBoxLayout(self.search_bar)
search_layout.setContentsMargins(10, 4, 10, 4)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Rechercher dans l'historique... (Entree = precedent, Echap = fermer)")
self.search_status = QLabel("")
self.search_status.setObjectName("statusLabel")
close_search = QToolButton()
close_search.setText("✕")
search_layout.addWidget(self.search_input, 1)
search_layout.addWidget(self.search_status)
search_layout.addWidget(close_search)
self.search_bar.hide()
layout.addWidget(self.search_bar)
self.terminal.search_requested.connect(self.open_search)
self.search_input.returnPressed.connect(self._run_search)
close_search.clicked.connect(self.close_search)
QShortcut(QKeySequence(Qt.Key_Escape), self.search_input, activated=self.close_search)
self.connection = None # assigne par les sous-classes
self._log_file = None
self._connection_generation = 0
self._closing = False
self._last_ssh_target: dict | None = None
# ---------- journalisation ----------
def toggle_logging(self) -> None:
if self._log_file:
self.stop_logging()
return
default = Path.home() / f"session-{datetime.now():%Y%m%d-%H%M%S}.log"
path, _ = QFileDialog.getSaveFileName(self, "Journaliser la session", str(default), "Journal (*.log)")
if not path:
self.log_btn.setChecked(False)
return
try:
self._log_file = open(path, "a", encoding="utf-8", errors="replace")
except OSError as exc:
QMessageBox.critical(self, "Erreur", f"Impossible d'ecrire le journal : {exc}")
self.log_btn.setChecked(False)
return
self._log_file.write(f"\n===== Session du {datetime.now():%d/%m/%Y %H:%M:%S} =====\n")
self.log_btn.setChecked(True)
self.log_btn.setToolTip(f"Journalisation active : {path}")
def stop_logging(self) -> None:
if self._log_file:
try:
self._log_file.close()
except OSError:
pass
self._log_file = None
self.log_btn.setChecked(False)
self.log_btn.setToolTip("Enregistrer la session dans un fichier")
def _log_output(self, data: bytes) -> None:
if not self._log_file:
return
# on retire les sequences d'echappement : un journal doit rester lisible
text = ANSI_ESCAPE_RE.sub("", data.decode("utf-8", errors="replace"))
try:
self._log_file.write(text)
self._log_file.flush()
except OSError:
self.stop_logging()
# ---------- detection d'une commande ssh tapee a la main ----------
def _track_ssh_target(self, _data: bytes) -> None:
"""Reevalue a chaque sortie recue, et ne fait que memoriser un
resultat trouve (jamais l'oublier) : la commande `ssh ...` typee par
l'utilisateur peut ensuite defiler hors de l'ecran visible, voire hors
de l'historique conserve (beaucoup de sortie, `clear`...), longtemps
avant qu'on ait besoin de s'en souvenir (bouton SFTP sur cette
connexion)."""
target = detect_ssh_target(self.terminal.buffer_text())
if target:
self._last_ssh_target = target
def detect_current_ssh_target(self) -> dict | None:
"""Cible ssh de ce terminal : la derniere commande ssh vue passer
(memorisee au moment ou elle etait encore visible), sinon un dernier
examen du texte actuellement disponible."""
return self._last_ssh_target or detect_ssh_target(self.terminal.buffer_text())
def _wire_connection(self) -> None:
# Un signal deconnexion/erreur/connexion emis par une connexion remplacee
# entre-temps (ex. reconnexion en cours) ne doit pas ecraser l'etat de la
# nouvelle : chaque connexion est associee a la generation en cours au
# moment ou elle est cablee, et un signal d'une generation perimee est ignore.
generation = self._connection_generation
self.connection.connected.connect(lambda: self._on_current_generation(generation, self._on_connected))
self.connection.data_received.connect(self.terminal.feed)
self.connection.data_received.connect(self._track_ssh_target)
self.connection.data_received.connect(self._log_output)
self.connection.disconnected.connect(
lambda reason: self._on_current_generation(generation, self._on_disconnected, reason)
)
self.connection.error.connect(
lambda message: self._on_current_generation(generation, self._on_error, message)
)
self.terminal.data_to_send.connect(self.connection.send)
self.terminal.resized.connect(self.connection.resize)
def _on_current_generation(self, generation: int, fn, *args) -> None:
if generation == self._connection_generation:
fn(*args)
def _create_connection(self):
"""Fabrique la connexion. Redefinie par chaque sous-classe : c'est ce qui
permet de la recreer a l'identique lors d'une reconnexion."""
raise NotImplementedError
def start_connection(self) -> None:
self._connection_generation += 1
self.connection = self._create_connection()
self._wire_connection()
self.connection.start()
def reconnect(self) -> None:
if self.connection:
self.connection.close_connection()
self.connection.wait(1500)
self.terminal.reset_screen()
self.reconnect_btn.hide()
self.status_label.setText("○ Reconnexion...")
self.start_connection()
def _on_connected(self) -> None:
self.reconnect_btn.hide()
self.terminal.setFocus()
def _on_disconnected(self, reason: str) -> None:
self.status_label.setText(f"● Deconnecte ({reason})")
self.reconnect_btn.show()
def _on_error(self, message: str) -> None:
self.status_label.setText(f"● Erreur : {message}")
self.reconnect_btn.show()
QMessageBox.critical(self, "Erreur", message)
# ---------- snippets ----------
def _show_snippets_menu(self) -> None:
menu = self._build_snippets_menu()
menu.exec(self.snippets_btn.mapToGlobal(self.snippets_btn.rect().bottomLeft()))
def _build_snippets_menu(self) -> QMenu:
menu = QMenu(self)
if self.snippet_store is None or not self.snippet_store.snippets:
hint = menu.addAction("(aucun snippet)")
hint.setEnabled(False)
else:
for category, snippets in self.snippet_store.by_category().items():
cat_menu = menu.addMenu(category)
for snippet in snippets:
snippet_menu = cat_menu.addMenu(snippet.name)
if snippet.description:
snippet_menu.setToolTip(snippet.description)
insert_action = snippet_menu.addAction("Inserer dans le terminal")
insert_action.triggered.connect(lambda checked=False, s=snippet: self.insert_snippet(s))
exec_action = snippet_menu.addAction("Executer")
exec_action.triggered.connect(lambda checked=False, s=snippet: self.execute_snippet(s))
if self.broadcast is not None:
multi_action = snippet_menu.addAction("Executer avec Multi-Send")
multi_action.triggered.connect(
lambda checked=False, s=snippet: self.execute_snippet(s, multi=True)
)
menu.addSeparator()
manage_action = menu.addAction("Gerer les snippets...")
manage_action.triggered.connect(self._manage_snippets)
return menu
def _manage_snippets(self) -> None:
if self.snippet_store is None:
return
SnippetManagerDialog(self.snippet_store, parent=self).exec()
def insert_snippet(self, snippet: Snippet) -> None:
"""N'envoie jamais Entree : le texte est ecrit, a l'utilisateur de valider."""
if self.connection:
self.connection.send(snippet.command.encode("utf-8"))
def execute_snippet(self, snippet: Snippet, multi: bool = False) -> None:
data = snippet.command.encode("utf-8") + b"\r"
if multi and self.broadcast is not None:
self.broadcast.broadcast_now(data)
elif self.connection:
self.connection.send(data)
# ---------- assistant IA (optionnel) ----------
def _show_ai_menu(self) -> None:
menu = self._build_ai_menu()
menu.exec(self.ai_btn.mapToGlobal(self.ai_btn.rect().bottomLeft()))
def _ai_source_text(self) -> str:
"""Texte selectionne s'il y en a un (pour cibler precisement une
partie de la sortie), sinon tout l'ecran actuellement visible - pour
que "Expliquer cette sortie" porte par defaut sur ce que l'utilisateur
a reellement sous les yeux, sans obliger a selectionner au prealable."""
selection = self.terminal.selected_text()
return selection if selection else self.terminal.visible_text()
def _build_ai_menu(self) -> QMenu:
menu = QMenu(self)
has_content = bool(self._ai_source_text().strip())
tooltip = (
"Utilise le texte selectionne, ou tout l'ecran visible si rien n'est selectionne."
if has_content else "Rien n'est encore affiche dans ce terminal."
)
explain_action = menu.addAction("Expliquer cette sortie")
explain_action.setEnabled(has_content)
explain_action.setToolTip(tooltip)
explain_action.triggered.connect(self._ai_explain)
diagnose_action = menu.addAction("Diagnostiquer cette erreur")
diagnose_action.setEnabled(has_content)
diagnose_action.setToolTip(tooltip)
diagnose_action.triggered.connect(self._ai_diagnose)
solution_action = menu.addAction("Proposer une solution")
solution_action.setEnabled(has_content)
solution_action.setToolTip(tooltip)
solution_action.triggered.connect(self._ai_solution)
ask_action = menu.addAction("Poser une question...")
ask_action.setToolTip(
"La selection (ou l'ecran visible a defaut) est jointe comme contexte a la question."
)
ask_action.triggered.connect(self._ai_ask)
menu.addSeparator()
generate_action = menu.addAction("Generer une commande...")
generate_action.triggered.connect(self._ai_generate_command)
menu.addSeparator()
panel_visible = self.ai_panel is not None and self.ai_panel.isVisible()
toggle_action = menu.addAction("Masquer le panneau IA" if panel_visible else "Afficher le panneau IA")
toggle_action.triggered.connect(self._toggle_ai_panel)
menu.addSeparator()
settings_action = menu.addAction("Parametres de l'assistant IA...")
settings_action.triggered.connect(self._open_ai_settings)
return menu
def _ensure_ai_panel(self) -> AIPanel:
if self.ai_panel is None:
self.ai_panel = AIPanel(self.ai_provider_factory)
self.splitter.addWidget(self.ai_panel)
# cree masque : sans cela, Qt affiche automatiquement un widget
# fraichement ajoute a un splitter deja visible, ce qui romprait
# l'etat "ferme par defaut" attendu du panneau.
self.ai_panel.hide()
return self.ai_panel
def _toggle_ai_panel(self) -> None:
panel = self._ensure_ai_panel()
panel.setVisible(not panel.isVisible())
def _open_ai_settings(self) -> None:
from app.dialogs.ai_settings_dialog import AISettingsDialog
AISettingsDialog(credential_store=self.credential_store, parent=self).exec()
def _ai_explain(self) -> None:
self._run_ai_selection_action(KIND_EXPLAIN, "Expliquer cette sortie")
def _ai_diagnose(self) -> None:
self._run_ai_selection_action(KIND_DIAGNOSE, "Diagnostiquer cette erreur")
def _ai_solution(self) -> None:
self._run_ai_selection_action(KIND_SOLUTION, "Proposer une solution")
def _run_ai_selection_action(self, kind: str, label: str) -> None:
text = self._ai_source_text()
if not text.strip():
return
panel = self._ensure_ai_panel()
panel.run_request(kind, text, label=label)
def _ai_ask(self) -> None:
panel = self._ensure_ai_panel()
panel.prompt_question(context=self._ai_source_text())
def _ai_generate_command(self) -> None:
if self._ai_command_worker is not None and self._ai_command_worker.isRunning():
return
provider = self.ai_provider_factory() if self.ai_provider_factory else None
if provider is None:
QMessageBox.information(
self, "Assistant IA",
"L'assistant IA n'est pas configure (menu Assistant IA > Parametres de l'assistant IA...).",
)
return
request, ok = QInputDialog.getMultiLineText(
self, "Generer une commande", "Decrivez ce que vous voulez accomplir :",
)
if not ok or not request.strip():
return
request = request.strip()
if not ensure_ai_notice_acknowledged(self):
return
decision = confirm_sensitive_content(self, request)
if decision == CANCEL:
return
if decision == MASK:
request = redact(request)
self.ai_btn.setEnabled(False)
self.ai_btn.setText("🤖 Generation...")
self._ai_command_worker = AIRequestWorker(provider, KIND_GENERATE, request)
self._ai_command_worker.finished_ok.connect(self._on_ai_command_ok)
self._ai_command_worker.finished_error.connect(self._on_ai_command_error)
self._ai_command_worker.start()
def _reset_ai_generate_button(self) -> None:
self.ai_btn.setEnabled(True)
self.ai_btn.setText("🤖 Assistant IA")
def _on_ai_command_ok(self, response: str) -> None:
self._reset_ai_generate_button()
dangerous = [m.label for m in check_dangerous(response)]
dialog = AICommandDialog(response, dangerous_labels=dangerous, parent=self)
if dialog.exec() and dialog.insert_requested and dialog.command:
self.insert_snippet_text(dialog.command)
def _on_ai_command_error(self, message: str) -> None:
self._reset_ai_generate_button()
QMessageBox.critical(self, "Assistant IA", message)
def insert_snippet_text(self, text: str) -> None:
"""N'envoie jamais Entree : le texte est ecrit, a l'utilisateur de valider."""
if self.connection:
self.connection.send(text.encode("utf-8"))
# ---------- recherche dans l'historique ----------
def open_search(self) -> None:
self.search_bar.show()
self.search_input.setFocus()
self.search_input.selectAll()
def close_search(self) -> None:
self.search_bar.hide()
self.search_status.clear()
self.terminal.setFocus()
def _run_search(self) -> None:
needle = self.search_input.text()
if not needle:
return
found = self.terminal.find_text(needle)
self.search_status.setText("" if found else "aucun resultat")
def close_connection(self) -> None:
self._closing = True # ne jamais reconnecter automatiquement une fermeture voulue
self.stop_logging()
if self.ai_panel is not None:
self.ai_panel.shutdown()
if self._ai_command_worker is not None and self._ai_command_worker.isRunning():
self._ai_command_worker.wait(2000)
if self.connection:
self.connection.close_connection()
self.connection.wait(2000)
class SessionTabWidget(BaseTerminalTab):
"""Un onglet = une connexion SSH avec son terminal et un panneau SFTP optionnel."""
def __init__(
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
snippet_store=None, broadcast=None, ai_provider_factory=None, credential_store=None, parent=None,
):
super().__init__(
f"○ Connexion a {session.username}@{session.host}...",
snippet_store=snippet_store, broadcast=broadcast,
ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent,
)
self.session = session
self._bastion_target = bastion_target
self._bastion_password = bastion_password
self.sftp_panel: SFTPPanel | None = None
self.sftp_toggle = QToolButton()
self.sftp_toggle.setText("📁 Fichiers (SFTP)")
self.sftp_toggle.setToolTip("Afficher / masquer le navigateur de fichiers SFTP")
self.sftp_toggle.setCheckable(True)
self.sftp_toggle.setChecked(True)
self.sftp_toggle.setEnabled(False)
self.bar.addWidget(self.sftp_toggle)
self.tunnel_btn = QToolButton()
self.tunnel_btn.setText("🔀 Tunnels")
self.tunnel_btn.setToolTip("Redirections de ports SSH (-L / -R)")
self.tunnel_btn.setEnabled(False)
self.tunnel_btn.clicked.connect(self.open_tunnels)
self.bar.addWidget(self.tunnel_btn)
self.tunnel_manager: TunnelManager | None = None
self._secret = secret
self.sftp_toggle.toggled.connect(self._toggle_sftp)
# Reconnexion automatique : un timer a declenchement unique pour la
# reconnexion elle-meme, et un second pour rafraichir le compte a
# rebours affiche a l'utilisateur pendant l'attente.
self._reconnect_attempt = 0
self._reconnect_seconds_left = 0
self._reconnect_last_reason = ""
self._reconnect_timer = QTimer(self)
self._reconnect_timer.setSingleShot(True)
self._reconnect_timer.timeout.connect(self._do_reconnect)
self._reconnect_countdown_timer = QTimer(self)
self._reconnect_countdown_timer.timeout.connect(self._tick_reconnect_countdown)
self.start_connection()
def _create_connection(self):
session = self.session
connection = SSHConnection(
host=session.host,
port=session.port,
username=session.username,
auth_type=session.auth_type,
key_path=session.key_path,
password=self._secret if session.auth_type == AUTH_PASSWORD else "",
passphrase=self._secret if session.auth_type == AUTH_KEY else "",
# comme MobaXterm : le navigateur de fichiers est la des l'ouverture,
# pas cache derriere une action supplementaire
open_sftp_on_connect=True,
bastion_target=self._bastion_target,
bastion_password=self._bastion_password,
keepalive_enabled=session.keepalive_enabled,
keepalive_interval=session.keepalive_interval,
keepalive_count_max=session.keepalive_count_max,
)
connection.sftp_ready.connect(self._on_sftp_ready)
connection.password_prompt.connect(self._on_password_prompt)
connection.host_key_prompt.connect(self._on_host_key_prompt)
connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt)
connection.passphrase_prompt.connect(self._on_passphrase_prompt)
return connection
def open_tunnels(self) -> None:
if not self.tunnel_manager:
QMessageBox.information(self, "Tunnels", "La session doit etre connectee.")
return
TunnelDialog(self.tunnel_manager, parent=self).exec()
actifs = len(self.tunnel_manager.tunnels)
self.tunnel_btn.setText(f"🔀 Tunnels ({actifs})" if actifs else "🔀 Tunnels")
def _on_connected(self) -> None:
self._reconnect_attempt = 0 # une connexion reussie remet le compteur a zero
self.status_label.setText(f"● Connecte : {self.session.username}@{self.session.host}:{self.session.port}")
transport = self.connection.client.get_transport() if self.connection.client else None
if transport:
self.tunnel_manager = TunnelManager(transport)
self.tunnel_btn.setEnabled(True)
# le bouton n'est active qu'une fois le canal SFTP reellement ouvert
# (_on_sftp_ready) : certains serveurs n'exposent pas le sous-systeme SFTP
super()._on_connected()
def _on_disconnected(self, reason: str) -> None:
super()._on_disconnected(reason)
self.sftp_toggle.setEnabled(False)
self._maybe_schedule_reconnect(reason)
def reconnect(self) -> None:
self._cancel_reconnect_timers()
super().reconnect()
# ---------- reconnexion automatique ----------
def _cancel_reconnect_timers(self) -> None:
self._reconnect_timer.stop()
self._reconnect_countdown_timer.stop()
def _maybe_schedule_reconnect(self, reason: str) -> None:
if self._closing or not self.session.reconnect_enabled:
return
max_attempts = self.session.reconnect_max_attempts
self._reconnect_attempt += 1
if max_attempts and self._reconnect_attempt > max_attempts:
self.status_label.setText(
f"● Deconnecte ({reason}). Abandon apres {max_attempts} tentative(s) de reconnexion."
)
self.reconnect_btn.show()
return
self._reconnect_last_reason = reason
self._reconnect_seconds_left = compute_backoff_delay(
self._reconnect_attempt, self.session.reconnect_initial_delay,
)
self._update_reconnect_status()
self._reconnect_countdown_timer.start(1000)
self._reconnect_timer.start(self._reconnect_seconds_left * 1000)
def _tick_reconnect_countdown(self) -> None:
self._reconnect_seconds_left -= 1
if self._reconnect_seconds_left <= 0:
self._reconnect_countdown_timer.stop()
return
self._update_reconnect_status()
def _update_reconnect_status(self) -> None:
max_attempts = self.session.reconnect_max_attempts
total = f"/{max_attempts}" if max_attempts else ""
self.status_label.setText(
f"● Connexion perdue ({self._reconnect_last_reason}). "
f"Reconnexion dans {self._reconnect_seconds_left} seconde(s)... "
f"(Tentative {self._reconnect_attempt}{total})"
)
def _do_reconnect(self) -> None:
self._reconnect_countdown_timer.stop()
if self._closing:
return
self.reconnect()
def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
accepted = confirm_host_key(self, hostname, key_type, fingerprint)
self.connection.confirm_host_key(accepted)
def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None:
accepted = confirm_host_key_change(self, hostname, key_type, old_fp, new_fp)
self.connection.confirm_host_key_change(accepted)
def _on_passphrase_prompt(self, key_path: str) -> None:
text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}")
self.connection.provide_passphrase(text if ok else None)
def _on_password_prompt(self, target: str) -> None:
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :")
self.connection.provide_password(text if ok else None)
def _on_sftp_ready(self, sftp) -> None:
"""Le canal SFTP a ete ouvert en arriere-plan : on affiche le navigateur de
fichiers a cote du terminal, sans action de l'utilisateur."""
if self.sftp_panel is not None:
return
self.sftp_panel = SFTPPanel(sftp, transfer_sftp_factory=self._open_transfer_channel)
self.splitter.insertWidget(0, self.sftp_panel)
self.splitter.setSizes([460, 540])
self.sftp_toggle.setEnabled(True)
self.sftp_toggle.setChecked(True)
self.terminal.setFocus()
def _open_transfer_channel(self):
"""Ouvre un second canal SFTP, dedie aux transferts, pour ne jamais faire
attendre la navigation derriere un gros envoi/reception (voir SFTPPanel)."""
import paramiko
return paramiko.SFTPClient.from_transport(self.connection.client.get_transport())
def close_connection(self) -> None:
self._cancel_reconnect_timers()
if self.sftp_panel:
self.sftp_panel.shutdown()
if self.tunnel_manager:
self.tunnel_manager.stop_all()
self.tunnel_manager = None
super().close_connection()
def _toggle_sftp(self, checked: bool) -> None:
if self.sftp_panel is not None:
self.sftp_panel.setVisible(checked)
class SFTPTabWidget(QWidget):
"""Onglet SFTP autonome (facon WinSCP) : double panneau de fichiers, sans terminal."""
def __init__(
self, session: Session, secret: str, bastion_target=None, bastion_password: str = "",
parent=None,
):
super().__init__(parent)
self.session = session
self.sftp_panel: SFTPPanel | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
bar = QHBoxLayout()
bar.setContentsMargins(10, 6, 10, 6)
self.status_label = QLabel(f"○ Connexion SFTP a {session.username}@{session.host}...")
self.status_label.setObjectName("statusLabel")
bar.addWidget(self.status_label, 1)
bar_frame = QFrame()
bar_frame.setObjectName("tabBar")
bar_frame.setLayout(bar)
layout.addWidget(bar_frame)
self.container = QVBoxLayout()
holder = QWidget()
holder.setLayout(self.container)
layout.addWidget(holder, 1)
self.connection = SSHConnection(
host=session.host,
port=session.port,
username=session.username,
auth_type=session.auth_type,
key_path=session.key_path,
password=secret if session.auth_type == AUTH_PASSWORD else "",
passphrase=secret if session.auth_type == AUTH_KEY else "",
open_shell=False,
open_sftp_on_connect=True,
bastion_target=bastion_target,
bastion_password=bastion_password,
keepalive_enabled=session.keepalive_enabled,
keepalive_interval=session.keepalive_interval,
keepalive_count_max=session.keepalive_count_max,
)
self.connection.sftp_ready.connect(self._on_sftp_ready)
self.connection.password_prompt.connect(self._on_password_prompt)
self.connection.error.connect(self._on_error)
self.connection.disconnected.connect(self._on_disconnected)
self.connection.host_key_prompt.connect(self._on_host_key_prompt)
self.connection.host_key_changed_prompt.connect(self._on_host_key_changed_prompt)
self.connection.passphrase_prompt.connect(self._on_passphrase_prompt)
self.connection.start()
def _on_sftp_ready(self, sftp) -> None:
self.sftp_panel = SFTPPanel(sftp, transfer_sftp_factory=self._open_transfer_channel)
self.container.addWidget(self.sftp_panel)
self.status_label.setText(
f"● SFTP connecte : {self.session.username}@{self.session.host}:{self.session.port}"
)
def _open_transfer_channel(self):
import paramiko
return paramiko.SFTPClient.from_transport(self.connection.client.get_transport())
def _on_disconnected(self, reason: str) -> None:
self.status_label.setText(f"● Deconnecte ({reason})")
def _on_error(self, message: str) -> None:
self.status_label.setText(f"● Erreur : {message}")
QMessageBox.critical(self, "Erreur SFTP", message)
def _on_host_key_prompt(self, hostname: str, key_type: str, fingerprint: str) -> None:
self.connection.confirm_host_key(confirm_host_key(self, hostname, key_type, fingerprint))
def _on_host_key_changed_prompt(self, hostname: str, key_type: str, old_fp: str, new_fp: str) -> None:
self.connection.confirm_host_key_change(
confirm_host_key_change(self, hostname, key_type, old_fp, new_fp)
)
def _on_passphrase_prompt(self, key_path: str) -> None:
text, ok = ask_secret(self, "Passphrase requise", f"Passphrase pour la cle :\n{key_path}")
self.connection.provide_passphrase(text if ok else None)
def _on_password_prompt(self, target: str) -> None:
text, ok = ask_secret(self, "Mot de passe", f"Mot de passe pour {target} :")
self.connection.provide_password(text if ok else None)
def close_connection(self) -> None:
if self.sftp_panel:
self.sftp_panel.shutdown()
self.connection.close_connection()
self.connection.wait(2000)
class LocalTerminalTabWidget(BaseTerminalTab):
"""Un onglet = un terminal Windows local (PowerShell / cmd), sans session enregistree."""
sftp_requested = Signal(object) # cible ssh detectee (ou None)
def __init__(
self, shell: str = SHELL_POWERSHELL, snippet_store=None, broadcast=None,
ai_provider_factory=None, credential_store=None, parent=None,
):
super().__init__(
f"○ Ouverture de {SHELL_LABELS.get(shell, shell)}...",
snippet_store=snippet_store, broadcast=broadcast,
ai_provider_factory=ai_provider_factory, credential_store=credential_store, parent=parent,
)
self.shell = shell
# Meme dans un terminal local, on tape souvent "ssh user@ip" a la main :
# ce bouton ouvre le SFTP sur cet hote, sans passer par un favori.
self.sftp_btn = QToolButton()
self.sftp_btn.setText("📁 SFTP sur cette connexion")
self.sftp_btn.setToolTip(
"Ouvre un gestionnaire de fichiers SFTP sur l'hote de la commande ssh tapee ici"
)
self.sftp_btn.clicked.connect(
lambda: self.sftp_requested.emit(self.detect_current_ssh_target())
)
self.bar.addWidget(self.sftp_btn)
self.start_connection()
def _create_connection(self):
return LocalTerminalConnection(shell=self.shell)
def _on_connected(self) -> None:
self.status_label.setText(f"● Terminal local — {SHELL_LABELS.get(self.shell, self.shell)}")
super()._on_connected()

123
app/workspace.py Normal file
View File

@@ -0,0 +1,123 @@
"""Workspaces : composition de l'espace de travail (onglets, splits, tailles),
enregistrable et restaurable.
Un workspace ne reference les sessions SSH que par id : jamais de mot de passe
ni de secret stocke ici (la connexion, une fois restauree, redemande un
mot de passe/passphrase exactement comme une ouverture normale)."""
from __future__ import annotations
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path
from app.paths import DATA_DIR
from app.storage.json_store import load_json_document, save_json_document
WORKSPACES_FILE = DATA_DIR / "workspaces.json"
SCHEMA_VERSION = 1
ITEM_SESSION_SSH = "session_ssh" # onglet terminal pour une session enregistree
ITEM_SESSION_SFTP = "session_sftp" # onglet SFTP pour une session enregistree
ITEM_LOCAL_SHELL = "local_shell" # terminal local
@dataclass
class WorkspaceItem:
"""Contenu d'un onglet ou d'un panneau."""
kind: str = ITEM_LOCAL_SHELL
session_id: str = "" # kind in (session_ssh, session_sftp)
shell: str = "" # kind == local_shell
def to_dict(self) -> dict:
return asdict(self)
@staticmethod
def from_dict(data: dict) -> WorkspaceItem:
item = WorkspaceItem()
for k in WorkspaceItem.__dataclass_fields__:
if k in data:
setattr(item, k, data[k])
return item
@dataclass
class WorkspaceTab:
title: str = ""
layout: str = "1" # cf. app.ui.split_pane : "1" | "2v" | "2h" | "4"
items: list[WorkspaceItem] = field(default_factory=list)
splitter_sizes: list[list[int]] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"title": self.title,
"layout": self.layout,
"items": [i.to_dict() for i in self.items],
"splitter_sizes": [list(s) for s in self.splitter_sizes],
}
@staticmethod
def from_dict(data: dict) -> WorkspaceTab:
tab = WorkspaceTab(title=data.get("title", ""), layout=data.get("layout", "1"))
tab.items = [WorkspaceItem.from_dict(i) for i in data.get("items", [])]
tab.splitter_sizes = [list(s) for s in data.get("splitter_sizes", [])]
return tab
@dataclass
class Workspace:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
name: str = ""
tabs: list[WorkspaceTab] = field(default_factory=list)
def to_dict(self) -> dict:
return {"id": self.id, "name": self.name, "tabs": [t.to_dict() for t in self.tabs]}
@staticmethod
def from_dict(data: dict) -> Workspace:
ws = Workspace(id=data.get("id") or str(uuid.uuid4()), name=data.get("name", ""))
ws.tabs = [WorkspaceTab.from_dict(t) for t in data.get("tabs", [])]
return ws
class WorkspaceStore:
"""Charge/sauvegarde la liste des workspaces depuis data/workspaces.json."""
def __init__(self, path: Path = WORKSPACES_FILE):
self.path = path
self.workspaces: list[Workspace] = []
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, "workspaces": []},
current_version=SCHEMA_VERSION,
)
self.load_warning = warning
self.workspaces = [Workspace.from_dict(w) for w in document.get("workspaces", [])]
if was_missing:
self.save()
def save(self) -> None:
payload = {"schema_version": SCHEMA_VERSION, "workspaces": [w.to_dict() for w in self.workspaces]}
save_json_document(self.path, payload)
def get(self, workspace_id: str) -> Workspace | None:
return next((w for w in self.workspaces if w.id == workspace_id), None)
def add(self, workspace: Workspace) -> None:
self.workspaces.append(workspace)
self.save()
def update(self, workspace: Workspace) -> None:
for i, w in enumerate(self.workspaces):
if w.id == workspace.id:
self.workspaces[i] = workspace
break
self.save()
def delete(self, workspace_id: str) -> None:
self.workspaces = [w for w in self.workspaces if w.id != workspace_id]
self.save()

61
pyproject.toml Normal file
View File

@@ -0,0 +1,61 @@
[project]
name = "pyssh-client"
version = "0.1.0"
description = "Client SSH/SFTP local (PySide6) : terminaux, tunnels, snippets, workspaces, assistant IA optionnel"
readme = "README.md"
requires-python = ">=3.10"
# Les dependances (runtime et dev) restent dans requirements.txt /
# requirements-dev.txt, deja utilises par setup.bat et la CI : les dupliquer
# ici cree un risque de desynchronisation pour aucun benefice reel, puisque
# le projet se distribue par PyInstaller (voir PySSHClient.spec), pas par
# "pip install". Ce fichier sert de point d'entree unique pour la
# configuration des outils (ruff, pytest, pyright).
[tool.ruff]
line-length = 110
target-version = "py310"
extend-exclude = ["venv", "build", "dist"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = [
"E501", # ligne trop longue : laisse au formateur, pas bloquant en l'etat
"E702", # construction compacte "widget = X(); widget.setY(...)" deliberee
# dans les panneaux SFTP/tunnels (un widget + sa config tiennent sur
# une ligne) : reecrire l'existant pour ca serait pur churn.
"E402", # plusieurs tests importent un symbole juste avant la section qui
# l'utilise pour la premiere fois, plutot qu'en haut du fichier -
# lisible dans des scripts sequentiels longs, et sans risque tant
# que l'import n'a pas d'effet de bord ordonne.
]
[tool.ruff.lint.isort]
known-first-party = ["app"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_all_scripts.py"]
[tool.pyright]
include = ["app", "main.py"]
exclude = ["venv", "build", "dist", "tests"]
venvPath = "."
venv = "venv"
# "basic" plutot que "strict" : le code n'a jamais ete type jusqu'ici, un mode
# strict noierait les vrais signaux sous des milliers d'avertissements de
# typage progressif. A resserrer progressivement, fichier par fichier.
typeCheckingMode = "basic"
pythonVersion = "3.10"
reportMissingImports = true
# Desactive : plus de 200 occurrences sont l'acces "plat" aux enums Qt
# (Qt.UserRole, QToolButton.InstantPopup...) - un style PyQt5 historique que
# PySide6 continue de supporter a l'execution, mais que ses stubs ne
# reconnaissent qu'a travers leur forme imbriquee (Qt.ItemDataRole.UserRole).
# Utilise partout dans ce projet, sans exception, et sans le moindre risque
# reel : le desactiver evite de noyer les vrais signaux. Le reste (une
# vingtaine de cas ou pyright perd le fil sur des patterns dynamiques - le
# monkey-patching de pyte dans terminal_widget.py, les QWidget generiques de
# split_pane.py) reste dans le rapport, a regarder au cas par cas plutot que
# supprime en bloc.
reportAttributeAccessIssue = false

5
requirements-dev.txt Normal file
View File

@@ -0,0 +1,5 @@
-r requirements.txt
pytest>=8.0
pytest-qt>=4.4
ruff>=0.6
pyright>=1.1

View File

@@ -3,3 +3,4 @@ paramiko>=3.4
pyte>=0.8.2
cryptography>=42.0
pywinpty>=2.0
pywin32-ctypes>=0.2.2 ; sys_platform == "win32"

View File

@@ -235,10 +235,13 @@ QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QComboBox {
QLineEdit:focus, QTextEdit:focus, QSpinBox:focus, QComboBox:focus {
border: 1px solid $accent;
}
QComboBox::drop-down {
border: none;
width: 22px;
}
/* Pas de regle ::drop-down/::down-arrow ici : des qu'on personnalise
::drop-down (meme juste border/width), Fusion (le style de toute l'appli)
arrete de dessiner sa fleche par defaut, et la redessiner soi-meme en QSS
pur (bordures en triangle) ne rend pas correctement avec ce style - un
simple rectangle plein a la place d'un triangle. Laisser Fusion gerer
entierement ce sous-controle donne une fleche nette, qui suit deja la
couleur de texte du champ ($text) sans configuration supplementaire. */
QComboBox QAbstractItemView {
background-color: $input;
color: $text;

16
tests/conftest.py Normal file
View File

@@ -0,0 +1,16 @@
"""Les tests de ce projet sont des scripts autonomes (print/assert), executes
chacun dans son propre processus - convention en place depuis le debut du
projet, et deliberement conservee (voir tests/test_all_scripts.py) plutot que
reecrite en fonctions pytest : cela evite tout risque de contamination entre
tests (patches, QApplication, fichiers temporaires) qu'importerait leur
execution dans un seul et meme processus.
Pytest ne doit donc PAS les importer/collecter directement comme modules (ce
qui les executerait une deuxieme fois, et dans un processus partage) : seul
test_all_scripts.py, qui les lance chacun en sous-processus isole, est un
vrai module de test pytest.
"""
import pathlib
_HERE = pathlib.Path(__file__).parent
collect_ignore_glob = [p.name for p in _HERE.glob("test_*.py") if p.name != "test_all_scripts.py"]

View File

@@ -12,18 +12,8 @@ REM QT_QPA_PLATFORM=offscreen : les tests construisent de vraies fenetres Qt,
REM mais sans les afficher (utile en console comme en integration continue).
set QT_QPA_PLATFORM=offscreen
set PYTHONIOENCODING=utf-8
set PYTHONUTF8=1
set FAILED=0
for %%T in (test_detect test_clipboard test_sftp test_full) do (
echo.
echo ================== %%T ==================
venv\Scripts\python.exe tests\%%T.py || set FAILED=1
)
venv\Scripts\python.exe -m pytest tests -v
echo.
if "%FAILED%"=="1" (
echo Au moins un test a echoue.
) else (
echo Tous les tests sont passes.
)
pause

View File

@@ -0,0 +1,67 @@
"""AICommandDialog : extraction de la commande depuis la reponse de l'IA
(bloc de code s'il y en a un), et les 3 actions possibles (Copier / Inserer
dans le terminal / Annuler) - jamais d'execution automatique."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from PySide6.QtWidgets import QApplication
from app.dialogs.ai_command_dialog import AICommandDialog, extract_command
app = QApplication.instance() or QApplication(sys.argv)
print("=== 1. extract_command() : bloc de code present ===")
response = "Pour lister les gros fichiers :\n\n```bash\nfind / -size +100M\n```\n\nCela peut prendre du temps."
assert extract_command(response) == "find / -size +100M"
print(" OK")
print()
print("=== 2. extract_command() : bloc de code sans langage indique ===")
response = "```\ndf -h\n```"
assert extract_command(response) == "df -h"
print(" OK")
print()
print("=== 3. extract_command() : pas de bloc de code -> reponse entiere ===")
response = "Utilisez simplement : uptime"
assert extract_command(response) == "Utilisez simplement : uptime"
print(" OK")
print()
print("=== 4. Avertissement affiche seulement si la commande est dangereuse ===")
dialog_safe = AICommandDialog("```\ndf -h\n```")
assert dialog_safe.command == "df -h"
dialog_dangerous = AICommandDialog("```\nrm -rf /tmp/x\n```", dangerous_labels=["rm -rf (suppression recursive)"])
assert dialog_dangerous.command == "rm -rf /tmp/x"
print(" OK (pas de verification visuelle automatisee, juste la construction sans exception)")
print()
print("=== 5. 'Copier' place la commande (eventuellement corrigee) dans le presse-papiers ===")
dialog = AICommandDialog("```\necho bonjour\n```")
dialog.command_view.setPlainText("echo modifie")
dialog._copy()
assert QApplication.clipboard().text() == "echo modifie"
print(" OK")
print()
print("=== 6. 'Inserer dans le terminal' : accepte le dialogue, ne l'execute jamais ===")
dialog = AICommandDialog("```\nls -la\n```")
assert dialog.insert_requested is False
dialog._insert()
assert dialog.insert_requested is True
assert dialog.command == "ls -la"
assert dialog.result() == 1 # QDialog.Accepted
print(" OK")
print()
print("=== 7. 'Annuler' : rejette le dialogue, insert_requested reste False ===")
dialog = AICommandDialog("```\nls -la\n```")
dialog.cancel_btn.click()
assert dialog.insert_requested is False
assert dialog.result() == 0 # QDialog.Rejected
print(" OK")
print()
print("AI COMMAND DIALOG : TOUS LES TESTS OK")

59
tests/test_ai_danger.py Normal file
View File

@@ -0,0 +1,59 @@
"""Detection locale de commandes potentiellement destructives proposees par
l'assistant IA (jamais un blocage, seulement un avertissement)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.ai.danger import check_dangerous, is_dangerous
print("=== 1. Commandes destructrices detectees ===")
dangerous_examples = [
"rm -rf /var/log/*",
"rm -fr /tmp/build",
"mkfs.ext4 /dev/sdb1",
"dd if=/dev/zero of=/dev/sda bs=1M",
"wipefs -a /dev/sdb",
"sudo shutdown -h now",
"sudo reboot",
"iptables -F",
"nft flush ruleset",
"DROP DATABASE production;",
"DELETE FROM users;",
"docker system prune -a --volumes",
"zpool destroy tank",
"lvremove /dev/vg0/lv0",
"vgremove vg0",
]
for cmd in dangerous_examples:
result = is_dangerous(cmd)
print(f" {'[X]' if result else '[ ]'} {cmd}")
assert result, f"aurait du etre detectee comme dangereuse : {cmd}"
print(" OK")
print()
print("=== 2. Commandes normales non signalees ===")
safe_examples = [
"df -h",
"systemctl status nginx",
"docker ps -a",
"ls -la /var/log",
"find /var -type f -printf '%s %p\\n' | sort -nr | head -20",
"SELECT * FROM users WHERE id = 1;",
"git rm --cached fichier.txt", # 'rm' present mais pas -rf : ne doit pas matcher
]
for cmd in safe_examples:
result = is_dangerous(cmd)
print(f" {'[X]' if result else '[ ]'} {cmd}")
assert not result, f"n'aurait pas du etre signalee : {cmd}"
print(" OK")
print()
print("=== 3. check_dangerous() renvoie une explication exploitable ===")
matches = check_dangerous("rm -rf /")
print(" correspondances:", [m.label for m in matches])
assert matches and "rm" in matches[0].label.lower()
print(" OK")
print()
print("AI DANGER : TOUS LES TESTS OK")

170
tests/test_ai_panel.py Normal file
View File

@@ -0,0 +1,170 @@
"""AIPanel : point d'entree unique des requetes IA d'un onglet - verifie que
la verification de securite (avis + secrets) est bien invoquee avant tout
envoi, que les reponses/erreurs mettent a jour la conversation, et qu'une
reponse perimee (generation obsolete) est ignoree."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.ai.provider import AIError, AIProvider
from app.ai.service import KIND_EXPLAIN
from app.dialogs.ai_prompts import CANCEL, MASK, SEND
from app.ui.ai_panel import AIPanel
app = QApplication.instance() or QApplication(sys.argv)
class FakeProvider(AIProvider):
def __init__(self, reply="reponse IA", fail_with=None):
self.reply = reply
self.fail_with = fail_with
self.chat_calls = []
def chat(self, messages, *, temperature=None):
self.chat_calls.append(messages)
if self.fail_with:
raise self.fail_with
return self.reply
def pump(ms=3000):
"""Laisse le worker en arriere-plan se terminer et livrer son signal."""
QCoreApplication.processEvents()
import time
deadline = time.time() + ms / 1000
while time.time() < deadline:
QCoreApplication.processEvents()
time.sleep(0.01)
NOTICE_OK = patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=True)
SEND_ASIS = patch("app.ui.ai_panel.confirm_sensitive_content", return_value=SEND)
print("=== 1. Aucun fournisseur configure : message clair, pas de requete lancee ===")
panel = AIPanel(provider_factory=lambda: None)
panel.run_request(KIND_EXPLAIN, "erreur X", label="Expliquer cette sortie")
assert "non configure" in panel.conversation.toPlainText().lower()
assert panel._worker is None
print(" OK")
print()
print("=== 2. Avis de principe refuse : rien n'est envoye ===")
provider = FakeProvider()
panel = AIPanel(provider_factory=lambda: provider)
with patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=False):
panel.run_request(KIND_EXPLAIN, "erreur X", label="Expliquer cette sortie")
assert provider.chat_calls == []
assert panel._worker is None
print(" OK")
print()
print("=== 3. Secret detecte + annulation : rien n'est envoye ===")
provider = FakeProvider()
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, patch("app.ui.ai_panel.confirm_sensitive_content", return_value=CANCEL):
panel.run_request(KIND_EXPLAIN, "password=abc123", label="Expliquer cette sortie")
assert provider.chat_calls == []
print(" OK")
print()
print("=== 4. Secret detecte + masquage : le texte envoye est redige ===")
provider = FakeProvider(reply="analyse")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, patch("app.ui.ai_panel.confirm_sensitive_content", return_value=MASK):
panel.run_request(KIND_EXPLAIN, "password=abc123", label="Expliquer cette sortie")
pump()
sent_content = provider.chat_calls[0][-1]["content"]
print(" contenu envoye:", sent_content)
assert "abc123" not in sent_content
assert "[MASQUE]" in sent_content
assert panel._last_response == "analyse"
print(" OK")
print()
print("=== 5. Requete reussie : reponse affichee et memorisee (Copier reponse) ===")
provider = FakeProvider(reply="Tout va bien.")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie normale", label="Expliquer cette sortie")
pump()
assert "Tout va bien." in panel.conversation.toPlainText()
panel._copy_last_response()
assert QApplication.clipboard().text() == "Tout va bien."
print(" OK")
print()
print("=== 6. Erreur du fournisseur : affichee dans la conversation, pas d'exception ===")
provider = FakeProvider(fail_with=AIError("cle API invalide"))
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie")
pump()
assert "cle API invalide" in panel.conversation.toPlainText()
print(" OK")
print()
print("=== 7. Effacer la conversation : generation incrementee, contenu vide ===")
provider = FakeProvider(reply="reponse")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie")
pump()
gen_before = panel._generation
panel.clear_conversation()
assert panel._generation == gen_before + 1
assert panel.conversation.toPlainText() == ""
assert panel._last_response == ""
print(" OK")
print()
print("=== 8. Une reponse d'une generation perimee est ignoree (annulation cooperative) ===")
provider = FakeProvider(reply="reponse tardive")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie", label="Expliquer cette sortie")
worker = panel._worker
panel.clear_conversation() # invalide la generation avant que le worker n'ait fini
worker.wait(3000)
QCoreApplication.processEvents()
assert "reponse tardive" not in panel.conversation.toPlainText()
print(" OK")
print()
print("=== 9. Question libre (panneau) : contexte joint, question affichee ===")
provider = FakeProvider(reply="42")
panel = AIPanel(provider_factory=lambda: provider)
panel.prompt_question(context="ligne de contexte")
assert panel.question_row.isVisible()
panel.question_edit.setText("pourquoi ?")
with NOTICE_OK, SEND_ASIS:
panel._send_question()
pump()
assert not panel.question_row.isVisible()
last_messages = provider.chat_calls[-1]
assert any("ligne de contexte" in m["content"] for m in last_messages)
assert last_messages[-1]["content"] == "pourquoi ?"
assert "42" in panel.conversation.toPlainText()
print(" OK")
print()
print("=== 10. Une requete deja en cours bloque la suivante (une a la fois) ===")
provider = FakeProvider(reply="lente")
panel = AIPanel(provider_factory=lambda: provider)
with NOTICE_OK, SEND_ASIS:
panel.run_request(KIND_EXPLAIN, "sortie 1", label="Expliquer cette sortie")
first_worker = panel._worker
panel.run_request(KIND_EXPLAIN, "sortie 2", label="Expliquer cette sortie")
assert panel._worker is first_worker, "la seconde requete n'aurait pas du demarrer un nouveau worker"
assert "deja en cours" in panel.busy_label.text().lower()
pump()
print(" OK")
print()
print("AI PANEL : TOUS LES TESTS OK")

104
tests/test_ai_prompts.py Normal file
View File

@@ -0,0 +1,104 @@
"""Confirmations de securite avant envoi a l'assistant IA : avis de principe
affiche une seule fois (persist dans app_settings, redirige vers un fichier
temporaire), et verification des secrets a chaque envoi. QMessageBox est
simule (exec()/clickedButton() patches) : aucune boite modale reelle ne doit
bloquer les tests."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QMessageBox
import app.app_settings as app_settings
from app.dialogs.ai_prompts import (
CANCEL,
MASK,
SEND,
confirm_sensitive_content,
ensure_ai_notice_acknowledged,
)
app = QApplication.instance() or QApplication(sys.argv)
tmp = Path(tempfile.mkdtemp())
settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json")
settings_redirect.start()
def click(button_text):
"""Simule un clic sur le bouton `button_text` d'une QMessageBox construite
avec addButton(), sans faire tourner de boucle d'evenements reelle."""
def fake_clicked(self):
for b in self.buttons():
if b.text() == button_text:
return b
return None
return fake_clicked
print("=== 1. Avis de principe : affiche la premiere fois, refus -> False ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=QMessageBox.Cancel) as mocked_exec:
result = ensure_ai_notice_acknowledged(None)
assert mocked_exec.called
assert result is False
assert app_settings.get_setting("ai_notice_acknowledged", False) is False
print(" OK")
print()
print("=== 2. Avis de principe : acceptation -> True, persiste ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=QMessageBox.Ok) as mocked_exec:
result = ensure_ai_notice_acknowledged(None)
assert mocked_exec.called
assert result is True
assert app_settings.get_setting("ai_notice_acknowledged", False) is True
print(" OK")
print()
print("=== 3. Avis de principe : deja accepte -> plus jamais affiche ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec") as mocked_exec:
result = ensure_ai_notice_acknowledged(None)
assert not mocked_exec.called, "la boite ne doit plus s'afficher une fois l'accord persiste"
assert result is True
print(" OK")
print()
print("=== 4. Aucun secret detecte : SEND direct, aucune boite affichee ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec") as mocked_exec:
result = confirm_sensitive_content(None, "systemctl status nginx")
assert not mocked_exec.called
assert result == SEND
print(" OK")
print()
print("=== 5. Secret detecte + 'Masquer automatiquement' -> MASK ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \
patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Masquer automatiquement")):
result = confirm_sensitive_content(None, "password=Sup3rSecret!")
assert result == MASK
print(" OK")
print()
print("=== 6. Secret detecte + 'Voir le contenu' -> SEND (envoi tel quel) ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \
patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Voir le contenu")):
result = confirm_sensitive_content(None, "password=Sup3rSecret!")
assert result == SEND
print(" OK")
print()
print("=== 7. Secret detecte + 'Annuler' -> CANCEL ===")
with patch("app.dialogs.ai_prompts.QMessageBox.exec", return_value=0), \
patch("app.dialogs.ai_prompts.QMessageBox.clickedButton", click("Annuler")):
result = confirm_sensitive_content(None, "password=Sup3rSecret!")
assert result == CANCEL
print(" OK")
settings_redirect.stop()
print()
print("AI PROMPTS : TOUS LES TESTS OK")

159
tests/test_ai_provider.py Normal file
View File

@@ -0,0 +1,159 @@
"""OpenAICompatibleProvider : requete HTTP construite correctement, et gestion
des erreurs (401, 429, timeout, reseau, reponse invalide) - urllib.request.urlopen
est entierement mocke, aucun appel reseau reel."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import io
import json
import urllib.error
from unittest.mock import MagicMock, patch
from app.ai.provider import AIError, AIProvider, OpenAICompatibleProvider
def fake_response(payload: dict):
m = MagicMock()
m.read.return_value = json.dumps(payload).encode("utf-8")
m.__enter__.return_value = m
m.__exit__.return_value = False
return m
def make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError:
return urllib.error.HTTPError(url="http://x/chat/completions", code=code, msg="err",
hdrs=None, fp=io.BytesIO(body))
provider = OpenAICompatibleProvider(
base_url="https://api.example.com/v1", api_key="sk-test", model="gpt-test", timeout=5, temperature=0.2,
)
print("=== 1. Requete construite correctement (URL, methode, en-tetes, corps) ===")
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
mocked.return_value = fake_response({"choices": [{"message": {"content": "bonjour"}}]})
result = provider.chat([{"role": "user", "content": "salut"}])
request = mocked.call_args.args[0]
print(" URL:", request.full_url)
print(" methode:", request.get_method())
print(" Authorization:", request.get_header("Authorization"))
body = json.loads(request.data.decode("utf-8"))
print(" corps:", body)
assert request.full_url == "https://api.example.com/v1/chat/completions"
assert request.get_method() == "POST"
assert request.get_header("Authorization") == "Bearer sk-test"
assert body["model"] == "gpt-test"
assert body["temperature"] == 0.2
assert body["messages"] == [{"role": "user", "content": "salut"}]
assert result == "bonjour"
print(" OK")
print()
print("=== 2. Erreur 401 -> message clair mentionnant la cle API ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(401)):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert "401" in str(exc) and "cle" in str(exc).lower()
assert ok
print(" OK")
print()
print("=== 3. Erreur 429 -> message clair mentionnant la limitation ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(429)):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert "429" in str(exc)
assert ok
print(" OK")
print()
print("=== 4. Timeout -> message clair, pas une exception brute ===")
timeout_error = urllib.error.URLError(reason=TimeoutError("timed out"))
with patch("app.ai.provider.urllib.request.urlopen", side_effect=timeout_error):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert "delai" in str(exc).lower() or "temps" in str(exc).lower()
assert ok
print(" OK")
print()
print("=== 5. Erreur reseau (DNS, connexion refusee) -> message clair ===")
network_error = urllib.error.URLError(reason="nom ou service inconnu")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=network_error):
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert ok
print(" OK")
print()
print("=== 6. Reponse invalide (JSON incoherent) -> message clair, pas de KeyError brut ===")
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
mocked.return_value = fake_response({"unexpected": "shape"})
try:
provider.chat([{"role": "user", "content": "x"}])
ok = False
except AIError as exc:
ok = True
print(" message:", exc)
assert ok
print(" OK")
print()
print("=== 7. Sans cle API : pas d'en-tete Authorization ===")
no_key_provider = OpenAICompatibleProvider(base_url="http://localhost:8080/v1", api_key="", model="local-model")
with patch("app.ai.provider.urllib.request.urlopen") as mocked:
mocked.return_value = fake_response({"choices": [{"message": {"content": "ok"}}]})
no_key_provider.chat([{"role": "user", "content": "x"}])
request2 = mocked.call_args.args[0]
assert request2.get_header("Authorization") is None
print(" OK (utile pour un LLM local sans authentification)")
print()
print("=== 8. Les 4 usages metier construisent les bons messages (via chat()) ===")
captured = []
class SpyProvider(AIProvider):
def chat(self, messages, *, temperature=None):
captured.append(messages)
return "reponse"
spy = SpyProvider()
spy.explain_output("erreur X")
spy.diagnose("erreur Y")
spy.propose_solution("erreur Z")
spy.generate_command("trouver les gros fichiers")
spy.ask("pourquoi ?", context="ligne de contexte")
print(" nombre d'appels a chat():", len(captured))
assert len(captured) == 5
assert "erreur X" in captured[0][-1]["content"]
assert "Cause probable" in captured[1][0]["content"] # structure imposee au system prompt de diagnose
assert "erreur Z" in captured[2][-1]["content"]
assert captured[3][-1]["content"] == "trouver les gros fichiers"
assert "ligne de contexte" in captured[4][1]["content"]
assert captured[4][-1]["content"] == "pourquoi ?"
print(" OK")
print()
print("AI PROVIDER : TOUS LES TESTS OK")

View File

@@ -0,0 +1,62 @@
"""Detection/redaction de secrets avant envoi a l'assistant IA."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.ai.redaction import detect_secrets, redact
print("=== 1. Cle privee PEM detectee ===")
text = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
found = detect_secrets(text)
print(" detecte:", [f.label for f in found])
assert any("privee" in f.label.lower() for f in found)
print(" OK")
print()
print("=== 2. Jeton Bearer detecte ===")
found = detect_secrets("Authorization: Bearer sk-abcdefghijklmnopqrstuvwxyz123456")
labels = [f.label for f in found]
print(" detecte:", labels)
assert any("bearer" in label.lower() for label in labels)
assert any("authorization" in label.lower() for label in labels)
print(" OK")
print()
print("=== 3. Cle AWS detectee ===")
found = detect_secrets("export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE")
assert any("aws" in f.label.lower() for f in found)
print(" OK")
print()
print("=== 4. password= / api_key= / token= detectes ===")
assert any("mot de passe" in f.label.lower() for f in detect_secrets("password=Sup3rSecret!"))
assert any("api" in f.label.lower() for f in detect_secrets("api_key=abcd1234"))
assert any("jeton" in f.label.lower() for f in detect_secrets("token: eyJhbGciOiJIUzI1NiJ9"))
print(" OK")
print()
print("=== 5. Chaine longue ressemblant a un secret ===")
found = detect_secrets("valeur=" + "a1b2c3d4e5f6g7h8i9j0" * 3)
assert any("longue" in f.label.lower() for f in found)
print(" OK")
print()
print("=== 6. Texte anodin : rien detecte ===")
assert detect_secrets("systemd[1]: nginx.service: Failed with result 'exit-code'") == []
assert detect_secrets("nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address already in use)") == []
print(" OK")
print()
print("=== 7. redact() masque sans lever, texte anodin inchange ===")
redacted = redact("password=abc123 et Bearer xyzxyzxyzxyzxyzxyz")
print(" resultat:", redacted)
assert "abc123" not in redacted
assert "xyzxyzxyzxyzxyzxyz" not in redacted
assert "[MASQUE]" in redacted
clean = "systemctl status nginx"
assert redact(clean) == clean
print(" OK")
print()
print("AI REDACTION : TOUS LES TESTS OK")

176
tests/test_ai_service.py Normal file
View File

@@ -0,0 +1,176 @@
"""AIRequestWorker : execution d'une requete IA dans un vrai QThread en
arriere-plan, jamais sur le thread Qt principal - avec dispatch correct selon
le type de requete et remontee des erreurs via signal plutot qu'exception."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import threading
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.ai.provider import AIError, AIProvider
from app.ai.service import (
KIND_ASK,
KIND_DIAGNOSE,
KIND_EXPLAIN,
KIND_GENERATE,
KIND_SOLUTION,
KIND_TEST,
AIRequestWorker,
)
app = QApplication.instance() or QApplication(sys.argv)
class RecordingProvider(AIProvider):
"""Enregistre la methode appelee et le thread d'execution, pour verifier
a la fois le dispatch correct et le fait que ca tourne hors du thread principal."""
def __init__(self, fail_with: Exception | None = None):
self.calls = []
self.threads = []
self.fail_with = fail_with
def chat(self, messages, *, temperature=None):
self.calls.append(("chat", messages))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-chat"
def explain_output(self, output):
self.calls.append(("explain_output", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-explain"
def diagnose(self, output):
self.calls.append(("diagnose", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-diagnose"
def propose_solution(self, output):
self.calls.append(("propose_solution", output))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-solution"
def generate_command(self, request):
self.calls.append(("generate_command", request))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-generate"
def ask(self, question, context=""):
self.calls.append(("ask", (question, context)))
self.threads.append(threading.current_thread())
if self.fail_with:
raise self.fail_with
return "reponse-ask"
def run_worker(worker, timeout_ms=5000):
results = {}
def on_ok(text):
results["ok"] = text
def on_error(text):
results["error"] = text
worker.finished_ok.connect(on_ok)
worker.finished_error.connect(on_error)
worker.start()
worker.wait(timeout_ms)
# laisser la boucle d'evenements livrer les signaux en file d'attente (cross-thread)
QCoreApplication.processEvents()
return results
main_thread = threading.current_thread()
print("=== 1. Dispatch correct par 'kind' (explain/diagnose/solution/generate/ask/test) ===")
cases = [
(KIND_EXPLAIN, "explain_output", "sortie X", "reponse-explain"),
(KIND_DIAGNOSE, "diagnose", "erreur Y", "reponse-diagnose"),
(KIND_SOLUTION, "propose_solution", "probleme Z", "reponse-solution"),
(KIND_GENERATE, "generate_command", "trouver les gros fichiers", "reponse-generate"),
]
for kind, expected_method, payload, expected_reply in cases:
provider = RecordingProvider()
worker = AIRequestWorker(provider, kind, payload)
results = run_worker(worker)
print(f" kind={kind!r} -> methode appelee: {provider.calls[0][0]!r}, resultat: {results!r}")
assert provider.calls == [(expected_method, payload)]
assert results.get("ok") == expected_reply
assert "error" not in results
print(" OK")
print()
print("=== 2. KIND_ASK transmet aussi le contexte ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_ASK, "pourquoi ?", context="ligne de contexte")
results = run_worker(worker)
print(" appel:", provider.calls[0], "resultat:", results)
assert provider.calls == [("ask", ("pourquoi ?", "ligne de contexte"))]
assert results.get("ok") == "reponse-ask"
print(" OK")
print()
print("=== 3. KIND_TEST appelle chat() avec un message minimal ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_TEST, "")
results = run_worker(worker)
print(" appel:", provider.calls[0], "resultat:", results)
assert provider.calls[0][0] == "chat"
assert results.get("ok") == "reponse-chat"
print(" OK")
print()
print("=== 4. AIError est capturee et remontee via finished_error, jamais levee ===")
provider = RecordingProvider(fail_with=AIError("cle API invalide"))
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
results = run_worker(worker)
print(" resultat:", results)
assert results.get("error") == "cle API invalide"
assert "ok" not in results
print(" OK")
print()
print("=== 5. Exception inattendue capturee aussi (jamais de crash silencieux du thread) ===")
provider = RecordingProvider(fail_with=RuntimeError("boum"))
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
results = run_worker(worker)
print(" resultat:", results)
assert "error" in results and "boum" in results["error"]
print(" OK")
print()
print("=== 6. Type de requete inconnu -> erreur claire, pas de plantage ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, "kind-inexistant", "x")
results = run_worker(worker)
print(" resultat:", results)
assert "error" in results and "inconnu" in results["error"].lower()
print(" OK")
print()
print("=== 7. L'execution se fait bien dans un thread separe du thread principal ===")
provider = RecordingProvider()
worker = AIRequestWorker(provider, KIND_EXPLAIN, "sortie")
run_worker(worker)
print(" thread principal:", main_thread, "thread d'execution:", provider.threads[0])
assert provider.threads[0] is not main_thread
assert isinstance(worker, __import__("PySide6.QtCore", fromlist=["QThread"]).QThread)
print(" OK")
print()
print("AI SERVICE : TOUS LES TESTS OK")

107
tests/test_ai_settings.py Normal file
View File

@@ -0,0 +1,107 @@
"""Configuration de l'assistant IA : parametres non sensibles persistes via
app_settings (redirige vers un fichier temporaire pour ne jamais toucher les
vrais reglages), cle API exclusivement via CredentialStore, et construction du
fournisseur a partir des deux."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
import app.app_settings as app_settings
from app.ai.provider import OpenAICompatibleProvider
from app.ai.settings import (
AI_API_KEY_CREDENTIAL,
DEFAULT_BASE_URL,
AISettings,
build_provider_from_settings,
load_ai_settings,
load_api_key,
save_ai_settings,
save_api_key,
)
from app.credentials import CredentialStore
class FakeCredentialStore(CredentialStore):
def __init__(self):
self._data = {}
def is_available(self):
return True
def set_secret(self, key, secret):
self._data[key] = secret
return True
def get_secret(self, key):
return self._data.get(key)
def delete_secret(self, key):
self._data.pop(key, None)
tmp = Path(tempfile.mkdtemp())
settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json")
settings_redirect.start()
print("=== 1. Valeurs par defaut : desactive, pas de secret expose ===")
default = load_ai_settings()
print(" ", default)
assert default.enabled is False
assert default.base_url == DEFAULT_BASE_URL
print(" OK")
print()
print("=== 2. Sauvegarde/rechargement des parametres non sensibles ===")
settings = AISettings(enabled=True, provider_name="OpenRouter", base_url="https://openrouter.ai/api/v1",
model="anthropic/claude", timeout=45.0, temperature=0.7)
save_ai_settings(settings)
reloaded = load_ai_settings()
print(" ", reloaded)
assert reloaded == settings
print(" OK")
print()
print("=== 3. La cle API ne transite jamais par app_settings (verification du fichier disque) ===")
raw = (tmp / "settings.json").read_text(encoding="utf-8")
assert "sk-" not in raw
assert "api_key" not in raw.lower() or "ai_api" not in raw.lower() # aucune cle presente, seul le nom du champ pourrait
print(" contenu persiste:", raw)
print(" OK")
print()
print("=== 4. Cle API : lecture/ecriture via CredentialStore uniquement ===")
store = FakeCredentialStore()
assert load_api_key(store) == ""
save_api_key(store, "sk-test-secret")
assert load_api_key(store) == "sk-test-secret"
assert store.get_secret(AI_API_KEY_CREDENTIAL) == "sk-test-secret"
save_api_key(store, "") # vider la cle -> supprime l'entree
assert load_api_key(store) == ""
print(" OK")
print()
print("=== 5. build_provider_from_settings() : None si desactive ===")
save_ai_settings(AISettings(enabled=False))
assert build_provider_from_settings(store) is None
print(" OK")
print()
print("=== 6. build_provider_from_settings() : fournisseur pret si active ===")
save_ai_settings(AISettings(enabled=True, base_url="https://api.example.com/v1", model="gpt-x"))
save_api_key(store, "sk-live")
provider = build_provider_from_settings(store)
assert isinstance(provider, OpenAICompatibleProvider)
assert provider.base_url == "https://api.example.com/v1"
assert provider.model == "gpt-x"
assert provider.api_key == "sk-live"
print(" OK")
settings_redirect.stop()
print()
print("AI SETTINGS : TOUS LES TESTS OK")

View File

@@ -0,0 +1,127 @@
"""AISettingsDialog : liste deroulante de fournisseurs (raccourcis usuels,
dont des LLM locaux comme Ollama/LM Studio) qui pre-remplit l'URL de base sans
jamais empecher de saisir un fournisseur personnalise."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
import app.app_settings as app_settings
from app.ai.settings import load_ai_settings
from app.credentials import CredentialStore
from app.dialogs.ai_settings_dialog import PROVIDER_PRESETS, AISettingsDialog
app = QApplication.instance() or QApplication(sys.argv)
class FakeCredentialStore(CredentialStore):
def __init__(self):
self._data = {}
def is_available(self):
return True
def set_secret(self, key, secret):
self._data[key] = secret
return True
def get_secret(self, key):
return self._data.get(key)
def delete_secret(self, key):
self._data.pop(key, None)
def select_preset(dialog, label: str) -> int:
"""Simule un choix reel dans la liste (pas une saisie libre) : Qt met a
jour l'index courant ET emet `activated` lors d'un clic - setCurrentIndex()
seul n'emet pas `activated`, il faut donc reproduire les deux."""
index = dialog.provider_combo.findText(label)
assert index >= 0, f"prereglage introuvable : {label}"
dialog.provider_combo.setCurrentIndex(index)
dialog.provider_combo.activated.emit(index)
return index
tmp = Path(tempfile.mkdtemp())
settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json")
settings_redirect.start()
print("=== 1. La liste propose des fournisseurs cloud ET des LLM locaux ===")
store = FakeCredentialStore()
dialog = AISettingsDialog(credential_store=store)
labels = [dialog.provider_combo.itemText(i) for i in range(dialog.provider_combo.count())]
print(" fournisseurs proposes:", labels)
assert "OpenAI" in labels
assert "Ollama (local)" in labels
assert "LM Studio (local)" in labels
assert "Personnalise..." in labels
print(" OK")
print()
print("=== 2. Choisir 'Ollama (local)' pre-remplit l'URL de base ===")
dialog.base_url_edit.setText("")
select_preset(dialog, "Ollama (local)")
print(" URL de base:", dialog.base_url_edit.text())
assert dialog.base_url_edit.text() == "http://localhost:11434/v1"
print(" OK")
print()
print("=== 3. ... et signale que la cle API n'est generalement pas necessaire ===")
print(" placeholder cle API:", dialog.api_key_edit.placeholderText())
assert "inutile" in dialog.api_key_edit.placeholderText().lower()
print(" OK")
print()
print("=== 4. Choisir un fournisseur cloud restaure le message par defaut pour la cle API ===")
select_preset(dialog, "OpenAI")
assert dialog.base_url_edit.text() == "https://api.openai.com/v1"
print(" placeholder cle API:", dialog.api_key_edit.placeholderText())
assert "inutile" not in dialog.api_key_edit.placeholderText().lower()
print(" OK")
print()
print("=== 5. 'Personnalise...' ne touche pas a une URL de base deja saisie ===")
dialog.base_url_edit.setText("https://mon-proxy-interne.example.com/v1")
select_preset(dialog, "Personnalise...")
print(" URL de base:", dialog.base_url_edit.text())
assert dialog.base_url_edit.text() == "https://mon-proxy-interne.example.com/v1"
print(" OK")
print()
print("=== 6. Le champ reste editable : un nom personnalise est bien conserve a l'enregistrement ===")
dialog.provider_combo.setCurrentText("Mon LLM interne")
dialog.model_edit.setText("mon-modele")
dialog._on_accept()
reloaded = load_ai_settings()
print(" fournisseur enregistre:", reloaded.provider_name)
assert reloaded.provider_name == "Mon LLM interne"
assert reloaded.base_url == "https://mon-proxy-interne.example.com/v1"
print(" OK")
print()
print("=== 7. Rouvrir le dialogue recharge le fournisseur personnalise precedemment saisi ===")
dialog2 = AISettingsDialog(credential_store=store)
print(" fournisseur affiche:", dialog2.provider_combo.currentText())
assert dialog2.provider_combo.currentText() == "Mon LLM interne"
print(" OK")
print()
print("=== 8. Les prereglages restent coherents avec la liste affichee (pas de doublon, tous non vides) ===")
seen = set()
for label, _url in PROVIDER_PRESETS:
assert label not in seen, f"doublon dans PROVIDER_PRESETS : {label}"
seen.add(label)
assert label.strip(), "un prereglage ne doit jamais avoir un nom vide"
print(" OK")
settings_redirect.stop()
print()
print("AI SETTINGS DIALOG : TOUS LES TESTS OK")

View File

@@ -0,0 +1,202 @@
"""Integration de l'assistant IA dans un onglet terminal : menu (active/desactive
selon la selection), panneau lateral cree a la demande dans le splitter (meme
mecanisme que le panneau SFTP), et generation de commande (jamais executee
automatiquement, avec avertissement si dangereuse).
LocalTerminalConnection.start() est neutralisee comme dans test_snippets.py :
aucun sous-processus reel, send() depose seulement les octets dans une file."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch
from PySide6.QtCore import QCoreApplication
from PySide6.QtWidgets import QApplication
from app.ai.provider import AIProvider
from app.dialogs.ai_prompts import SEND
from app.local_terminal import LocalTerminalConnection
from app.ui.ai_panel import AIPanel
from app.ui.tabs import LocalTerminalTabWidget
app = QApplication.instance() or QApplication(sys.argv)
NOTICE_OK = patch("app.ui.ai_panel.ensure_ai_notice_acknowledged", return_value=True)
SEND_ASIS = patch("app.ui.ai_panel.confirm_sensitive_content", return_value=SEND)
PROMPTS_OK_IN_TABS = patch("app.ui.tabs.ensure_ai_notice_acknowledged", return_value=True)
SEND_ASIS_IN_TABS = patch("app.ui.tabs.confirm_sensitive_content", return_value=SEND)
class FakeProvider(AIProvider):
def __init__(self, reply="reponse"):
self.reply = reply
def chat(self, messages, *, temperature=None):
return self.reply
def pump(ms=3000):
import time
deadline = time.time() + ms / 1000
while time.time() < deadline:
QCoreApplication.processEvents()
time.sleep(0.01)
def make_tab(ai_provider_factory=None):
return LocalTerminalTabWidget(ai_provider_factory=ai_provider_factory)
print("=== 1. Menu IA : actions desactivees sur un terminal vraiment vide (rien affiche, rien selectionne) ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
assert not tab.terminal.has_selection()
assert tab.terminal.visible_text() == ""
menu = tab._build_ai_menu()
labels = [a.text() for a in menu.actions() if a.text()]
print(" actions:", labels)
assert "Expliquer cette sortie" in labels
assert "Generer une commande..." in labels
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution"):
action = next(a for a in menu.actions() if a.text() == text)
assert not action.isEnabled(), f"{text} devrait etre desactive : rien a analyser"
# "Poser une question" reste utilisable sans rien : le contexte est optionnel
ask_action = next(a for a in menu.actions() if a.text() == "Poser une question...")
assert ask_action.isEnabled()
generate_action = next(a for a in menu.actions() if a.text() == "Generer une commande...")
assert generate_action.isEnabled(), "Generer une commande ne necessite pas de selection"
tab.close_connection()
print(" OK")
print()
print("=== 2. Menu IA : actions activees des qu'il y a du texte visible, meme sans selection ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
tab.terminal.feed(b"erreur : connection refused\r\n")
assert not tab.terminal.has_selection()
assert tab.terminal.visible_text().strip()
menu = tab._build_ai_menu()
for text in ("Expliquer cette sortie", "Diagnostiquer cette erreur", "Proposer une solution", "Poser une question..."):
action = next(a for a in menu.actions() if a.text() == text)
assert action.isEnabled(), f"{text} devrait etre active : l'ecran visible sert de repli"
tab.close_connection()
print(" OK")
print()
print("=== 2bis. 'Expliquer cette sortie' sans selection : se rabat sur l'ecran visible ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="Ceci indique une connexion refusee.")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"erreur : connection refused\r\n")
assert not tab.terminal.has_selection(), "ce test verifie precisement le cas SANS selection"
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
assert isinstance(tab.ai_panel, AIPanel)
conversation = tab.ai_panel.conversation.toPlainText()
print(" conversation:", conversation)
assert "connection refused" in conversation.lower() # le texte envoye est bien celui de l'ecran
assert "connexion refusee" in conversation.lower() # et la reponse simulee est bien affichee
tab.close_connection()
print(" OK")
print()
print("=== 2ter. Une selection existante reste prioritaire sur l'ecran visible ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="reponse")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"ligne 1 sans rapport\r\nerreur precise\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 1), (13, 1)
assert tab.terminal.selected_text() == "erreur precise"
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
conversation = tab.ai_panel.conversation.toPlainText()
print(" conversation:", conversation)
assert "erreur precise" in conversation.lower()
assert "ligne 1 sans rapport" not in conversation.lower()
tab.close_connection()
print(" OK")
print()
print("=== 3. 'Expliquer cette sortie' : cree le panneau IA dans le splitter et lance la requete ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="Ceci indique une connexion refusee.")
tab = make_tab(ai_provider_factory=lambda: provider)
tab.terminal.feed(b"erreur : connection refused\r\n")
tab.terminal._sel_anchor, tab.terminal._sel_focus = (0, 0), (10, 0)
assert tab.ai_panel is None
with NOTICE_OK, SEND_ASIS:
tab._ai_explain()
pump()
assert isinstance(tab.ai_panel, AIPanel)
assert tab.splitter.indexOf(tab.ai_panel) >= 0
assert "connexion refusee" in tab.ai_panel.conversation.toPlainText().lower()
tab.close_connection()
print(" OK")
print()
print("=== 4. Basculer l'affichage du panneau IA (menu) ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab(ai_provider_factory=lambda: FakeProvider())
tab.show() # isVisible() se compose avec les ancetres : il en faut un reellement affiche
tab._toggle_ai_panel() # premiere fois : cree + affiche
assert tab.ai_panel is not None and tab.ai_panel.isVisible()
tab._toggle_ai_panel() # deuxieme fois : masque
assert not tab.ai_panel.isVisible()
tab.close_connection()
print(" OK")
print()
print("=== 5. Generer une commande : jamais auto-execute, insertion sans Entree ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="Pour lister les gros fichiers :\n\n```bash\nfind / -size +100M\n```")
tab = make_tab(ai_provider_factory=lambda: provider)
with PROMPTS_OK_IN_TABS, SEND_ASIS_IN_TABS, \
patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("trouver les gros fichiers", True)), \
patch("app.ui.tabs.AICommandDialog.exec", lambda self: (setattr(self, "insert_requested", True), 1)[1]):
tab._ai_generate_command()
pump()
sent = tab.connection._out_queue.get_nowait()
print(" commande inseree:", sent)
assert sent == b"find / -size +100M"
assert not sent.endswith(b"\r") and not sent.endswith(b"\n")
assert tab.ai_btn.isEnabled() and tab.ai_btn.text() == "🤖 Assistant IA"
tab.close_connection()
print(" OK")
print()
print("=== 6. Generer une commande : annulee par l'utilisateur -> rien envoye ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
provider = FakeProvider(reply="```bash\nrm -rf /\n```")
tab = make_tab(ai_provider_factory=lambda: provider)
with patch("app.ui.tabs.QInputDialog.getMultiLineText", return_value=("", False)):
tab._ai_generate_command()
assert tab.connection._out_queue.empty()
tab.close_connection()
print(" OK")
print()
print("=== 7. Sans fournisseur configure : message clair, pas de boite de saisie ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab(ai_provider_factory=lambda: None)
with patch("app.ui.tabs.QInputDialog.getMultiLineText") as mocked_input, \
patch("app.ui.tabs.QMessageBox.information") as mocked_info:
tab._ai_generate_command()
assert not mocked_input.called
assert mocked_info.called
tab.close_connection()
print(" OK")
print()
print("=== 8. Fermeture de l'onglet : le panneau IA est arrete proprement ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab(ai_provider_factory=lambda: FakeProvider())
tab._toggle_ai_panel()
tab.close_connection() # ne doit pas lever, ni bloquer indefiniment
print(" OK")
print()
print("AI TAB INTEGRATION : TOUS LES TESTS OK")

50
tests/test_all_scripts.py Normal file
View File

@@ -0,0 +1,50 @@
"""Point d'entree pytest pour la suite : lance chaque tests/test_*.py existant
comme un sous-processus isole (voir conftest.py pour la raison de cet
isolement), avec les variables d'environnement necessaires sous Windows
(encodage UTF-8 pour les caracteres/emoji, rendu Qt hors-ecran pour tourner
sans affichage - typiquement en integration continue).
`pytest tests/` (ou simplement `pytest`) devient ainsi le point d'entree
unique de la suite, tout en preservant chaque script existant tel quel.
"""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
TESTS_DIR = Path(__file__).parent
PROJECT_ROOT = TESTS_DIR.parent
SCRIPTS = sorted(
p for p in TESTS_DIR.glob("test_*.py")
if p.name not in ("test_all_scripts.py",)
)
def _run_env() -> dict:
env = dict(os.environ)
env["PYTHONIOENCODING"] = "utf-8"
env["PYTHONUTF8"] = "1"
env.setdefault("QT_QPA_PLATFORM", "offscreen")
return env
@pytest.mark.parametrize("script", SCRIPTS, ids=lambda p: p.stem)
def test_script(script: Path) -> None:
result = subprocess.run(
[sys.executable, str(script)],
cwd=PROJECT_ROOT,
env=_run_env(),
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
pytest.fail(
f"{script.name} a echoue (code {result.returncode}) :\n"
f"--- stdout ---\n{result.stdout}\n--- stderr ---\n{result.stderr}",
pytrace=False,
)

View File

@@ -0,0 +1,118 @@
"""Stockage JSON : ecriture atomique, schema_version, migration, recuperation
automatique depuis .bak quand le fichier principal est corrompu."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import json
import tempfile
from pathlib import Path
from app.key_manager import KeyManager, compute_fingerprint
from app.session_manager import Session, SessionStore
from app.storage.atomic_file import atomic_write_text
from app.storage.json_store import load_json_document, save_json_document
tmp = Path(tempfile.mkdtemp())
print("=== 1. Ecriture atomique : pas de fichier .tmp residuel, contenu exact ===")
target = tmp / "fichier.txt"
atomic_write_text(target, "contenu")
assert target.read_text(encoding="utf-8") == "contenu"
assert not target.with_name("fichier.txt.tmp").exists()
print(" OK")
print()
print("=== 2. load_json_document : valeurs par defaut si le fichier n'existe pas ===")
missing = tmp / "absent.json"
doc, warning = load_json_document(missing, default={"schema_version": 1, "items": []}, current_version=1)
assert doc == {"schema_version": 1, "items": []}, doc
assert warning is None
print(" OK")
print()
print("=== 3. Migration : un fichier sans schema_version est promu a current_version ===")
legacy = tmp / "legacy.json"
legacy.write_text(json.dumps({"items": ["a", "b"]}), encoding="utf-8")
def _v0_to_v1(document: dict) -> dict:
document["items"] = [x.upper() for x in document.get("items", [])]
document["schema_version"] = 1
return document
doc, warning = load_json_document(
legacy, default={"schema_version": 1, "items": []}, current_version=1,
migrations={0: _v0_to_v1},
)
print(" document migre:", doc, "| warning:", warning)
assert doc["schema_version"] == 1
assert doc["items"] == ["A", "B"], doc
assert warning is None
print(" OK")
print()
print("=== 4. save_json_document : cree un .bak de la version precedente ===")
target2 = tmp / "doc.json"
save_json_document(target2, {"schema_version": 1, "n": 1})
save_json_document(target2, {"schema_version": 1, "n": 2})
bak = target2.with_name("doc.json.bak")
assert bak.exists()
assert json.loads(bak.read_text(encoding="utf-8"))["n"] == 1
assert json.loads(target2.read_text(encoding="utf-8"))["n"] == 2
print(" .bak contient la version precedente, le fichier principal la derniere : OK")
print()
print("=== 5. Recuperation automatique si le fichier principal est corrompu ===")
target2.write_text("{ceci n'est pas du json", encoding="utf-8")
doc, warning = load_json_document(target2, default={"schema_version": 1, "n": 0}, current_version=1)
print(" document recupere:", doc, "| warning:", warning)
assert doc["n"] == 1, doc # revient a l'avant-derniere version connue (le .bak)
assert warning is not None and "bak" in warning
print(" OK")
print()
print("=== 6. SessionStore : fichier legacy sans schema_version reste chargeable ===")
sessions_path = tmp / "sessions.json"
sessions_path.write_text(
json.dumps({"sessions": [{"id": "s1", "name": "srv1", "host": "10.0.0.1"}]}), encoding="utf-8",
)
store = SessionStore(path=sessions_path)
assert [s.name for s in store.sessions] == ["srv1"]
store.add(Session(name="srv2", host="10.0.0.2"))
on_disk = json.loads(sessions_path.read_text(encoding="utf-8"))
print(" schema_version apres sauvegarde:", on_disk.get("schema_version"))
assert on_disk.get("schema_version") == 1
assert sessions_path.with_name("sessions.json.bak").exists()
print(" OK")
print()
print("=== 7. KeyManager : une empreinte au mauvais format est corrigee au chargement ===")
keys_path = tmp / "keys.json"
# cle publique valide obtenue via une vraie generation, pour ne pas fabriquer un
# base64 a la main
_seed_km = KeyManager(registry_path=tmp / "seed_keys.json")
pub_line = _seed_km.generate_key("seed", save_dir=tmp / "seed").public_key_text
bogus_fp = pub_line.split()[1][:16] # l'ancien "fingerprint" errone : un bout de base64
keys_path.write_text(json.dumps({
"keys": [{
"id": "k1", "name": "importee", "key_type": "ed25519",
"private_path": "", "public_path": "",
"fingerprint": bogus_fp, "public_key_text": pub_line,
}],
}), encoding="utf-8")
km = KeyManager(registry_path=keys_path)
entry = km.keys[0]
expected = compute_fingerprint(pub_line)
print(" ancienne empreinte (tronquee):", bogus_fp)
print(" empreinte corrigee: ", entry.fingerprint)
assert entry.fingerprint == expected
assert entry.fingerprint.startswith("SHA256:")
assert entry.fingerprint != bogus_fp
print(" OK")
print()
print("STOCKAGE ATOMIQUE / SCHEMA / FINGERPRINT : TOUS LES TESTS OK")

View File

@@ -0,0 +1,127 @@
"""Reconnexion automatique de SessionTabWidget : programmation du delai (avec
backoff), plafond de tentatives, remise a zero apres succes, et absence de
reconnexion sur une fermeture volontaire de l'onglet.
Pilote directement _on_disconnected()/_on_connected() plutot que d'attendre une
vraie coupure reseau (la detection elle-meme est testee dans test_keepalive.py).
SSHConnection.start() est neutralisee : ce module teste uniquement la logique
de programmation du cote de l'onglet, qui n'a besoin d'aucune connexion reseau
reelle - cela evite aussi qu'un thread reste bloque dans un connect() lent et
ne bloque la fin du script.
"""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import time
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
from app.session_manager import Session
from app.ssh_connection import SSHConnection
from app.ui.tabs import SessionTabWidget
app = QApplication.instance() or QApplication([])
def pump(seconds: float) -> None:
deadline = time.time() + seconds
while time.time() < deadline:
app.processEvents()
time.sleep(0.01)
def make_session(**overrides) -> Session:
session = Session(name="t", host="10.0.0.1", port=22, username="root")
for key, value in overrides.items():
setattr(session, key, value)
return session
no_real_thread = patch.object(SSHConnection, "start", lambda self: None)
no_real_thread.start()
print("=== 1. Reconnexion desactivee par defaut : aucune programmation ===")
session = make_session()
assert session.reconnect_enabled is False
tab = SessionTabWidget(session, "")
tab._on_disconnected("test")
assert not tab._reconnect_timer.isActive()
tab.close_connection()
print(" OK")
print()
print("=== 2. Reconnexion activee : programmation avec le delai initial exact ===")
session2 = make_session(reconnect_enabled=True, reconnect_initial_delay=1, reconnect_max_attempts=3)
tab2 = SessionTabWidget(session2, "")
tab2._on_disconnected("connexion perdue")
print(" statut:", tab2.status_label.text())
assert tab2._reconnect_timer.isActive()
assert tab2._reconnect_attempt == 1
assert tab2._reconnect_seconds_left == 1 # compute_backoff_delay(1, 1) == 1
assert "Tentative 1/3" in tab2.status_label.text()
print(" OK")
print()
print("=== 3. Le timer se declenche reellement et relance une connexion ===")
pump(1.5)
print(" countdown actif apres ~1.5s:", tab2._reconnect_countdown_timer.isActive())
assert not tab2._reconnect_countdown_timer.isActive(), "le cycle doit s'etre declenche et avoir tout arrete"
tab2.close_connection()
print(" OK")
print()
print("=== 4. Plafond de tentatives : abandon au-dela de max_attempts ===")
session3 = make_session(reconnect_enabled=True, reconnect_initial_delay=100, reconnect_max_attempts=2)
tab3 = SessionTabWidget(session3, "")
tab3._on_disconnected("r1")
assert tab3._reconnect_attempt == 1 and tab3._reconnect_timer.isActive()
tab3._cancel_reconnect_timers() # simule le declenchement du timer sans attendre 100s
tab3._on_disconnected("r2")
assert tab3._reconnect_attempt == 2 and tab3._reconnect_timer.isActive()
tab3._cancel_reconnect_timers()
tab3._on_disconnected("r3")
print(" apres 3 echecs (max=2):", tab3.status_label.text())
assert tab3._reconnect_attempt == 3
assert not tab3._reconnect_timer.isActive(), "au-dela du plafond, plus de nouvelle tentative programmee"
assert "Abandon" in tab3.status_label.text()
tab3.close_connection()
print(" OK")
print()
print("=== 5. Une connexion reussie remet le compteur de tentatives a zero ===")
session4 = make_session(reconnect_enabled=True)
tab4 = SessionTabWidget(session4, "")
tab4._reconnect_attempt = 5
tab4._on_connected()
assert tab4._reconnect_attempt == 0
tab4.close_connection()
print(" OK")
print()
print("=== 6. Fermeture volontaire de l'onglet : jamais de reconnexion programmee ===")
session5 = make_session(reconnect_enabled=True)
tab5 = SessionTabWidget(session5, "")
tab5.close_connection() # fermeture "pour de vrai" -> _closing = True
tab5._on_disconnected("apres fermeture")
assert not tab5._reconnect_timer.isActive()
print(" OK")
print()
print("=== 7. Reconnexion manuelle (bouton) annule un compte a rebours en cours ===")
session6 = make_session(reconnect_enabled=True, reconnect_initial_delay=100)
tab6 = SessionTabWidget(session6, "")
tab6._on_disconnected("r1")
assert tab6._reconnect_timer.isActive()
tab6.reconnect()
assert not tab6._reconnect_timer.isActive()
assert not tab6._reconnect_countdown_timer.isActive()
tab6.close_connection()
print(" OK")
no_real_thread.stop()
print()
print("RECONNEXION AUTOMATIQUE : TOUS LES TESTS OK")

186
tests/test_bastion.py Normal file
View File

@@ -0,0 +1,186 @@
"""ProxyJump / Bastion : resolution (app.bastion) pour les 3 sources (session,
OpenSSH, manuel), et etablissement du canal direct-tcpip par SSHConnection via
un bastion entierement mocke (aucun reseau reel)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
from PySide6.QtWidgets import QApplication
from app.bastion import BastionResolutionError, BastionTarget, resolve_bastion_target
from app.session_manager import (
AUTH_AUTO,
AUTH_KEY,
BASTION_MANUAL,
BASTION_NONE,
BASTION_OPENSSH,
BASTION_SESSION,
BastionConfig,
Session,
SessionStore,
)
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
print("=== 1. Aucun bastion -> None ===")
assert resolve_bastion_target(BastionConfig(mode=BASTION_NONE)) is None
assert resolve_bastion_target(None) is None
print(" OK")
print()
print("=== 2. Bastion manuel ===")
manual = BastionConfig(
mode=BASTION_MANUAL, host="bastion.example.com", port=2222,
username="jump", auth_type=AUTH_KEY, key_path="/tmp/key",
)
target = resolve_bastion_target(manual)
print(" target:", target)
assert target == BastionTarget("bastion.example.com", 2222, "jump", AUTH_KEY, "/tmp/key")
print(" OK")
print()
print("=== 3. Bastion manuel incomplet -> erreur claire ===")
try:
resolve_bastion_target(BastionConfig(mode=BASTION_MANUAL, host=""))
ok = False
except BastionResolutionError:
ok = True
assert ok
print(" OK")
print()
print("=== 4. Bastion = session PySSH existante ===")
store = SessionStore(path=tmp / "sessions.json")
bastion_session = Session(name="bastion-srv", host="10.0.0.1", port=22, username="root", auth_type=AUTH_AUTO)
store.add(bastion_session)
target = resolve_bastion_target(BastionConfig(mode=BASTION_SESSION, session_id=bastion_session.id), store)
print(" target:", target)
assert target.host == "10.0.0.1" and target.username == "root"
print(" OK")
print()
print("=== 5. Bastion = session supprimee -> erreur claire ===")
try:
resolve_bastion_target(BastionConfig(mode=BASTION_SESSION, session_id="inconnu"), store)
ok = False
except BastionResolutionError as exc:
ok = True
print(" message:", exc)
assert ok
print(" OK")
print()
print("=== 6. Bastion sans registre de sessions fourni -> erreur claire (pas de crash) ===")
try:
resolve_bastion_target(BastionConfig(mode=BASTION_SESSION, session_id="x"), None)
ok = False
except BastionResolutionError:
ok = True
assert ok
print(" OK")
print()
print("=== 7. Bastion = Host OpenSSH ===")
config_path = tmp / "sshconfig"
config_path.write_text(
"Host bastion\n HostName bastion.example.com\n User jump\n IdentityFile ~/.ssh/jumpkey\n",
encoding="utf-8",
)
with patch("app.openssh.config.default_config_path", return_value=config_path):
target = resolve_bastion_target(BastionConfig(mode=BASTION_OPENSSH, openssh_alias="bastion"))
print(" target:", target)
assert target.host == "bastion.example.com"
assert target.username == "jump"
assert target.auth_type == AUTH_KEY
print(" OK")
print()
print("=== 8. Bastion = alias OpenSSH introuvable -> erreur claire ===")
with patch("app.openssh.config.default_config_path", return_value=config_path):
try:
resolve_bastion_target(BastionConfig(mode=BASTION_OPENSSH, openssh_alias="n-existe-pas"))
ok = False
except BastionResolutionError:
ok = True
assert ok
print(" OK")
print()
print("=== 9. SSHConnection : ouverture du canal direct-tcpip via un bastion mocke ===")
from app.ssh_connection import SSHConnection
bastion_target = BastionTarget(host="bastion.example.com", port=22, username="jump", auth_type=AUTH_AUTO)
conn = SSHConnection(host="10.20.20.15", port=22, username="root", auth_type=AUTH_AUTO, bastion_target=bastion_target)
fake_channel = MagicMock(name="direct-tcpip-channel")
fake_transport = MagicMock()
fake_transport.open_channel.return_value = fake_channel
with patch("app.ssh_connection.paramiko.SSHClient") as FakeSSHClient:
fake_bastion_client = MagicMock()
fake_bastion_client.get_transport.return_value = fake_transport
FakeSSHClient.return_value = fake_bastion_client
channel = conn._open_bastion_channel()
hostname_used = fake_bastion_client.connect.call_args.kwargs.get("hostname")
print(" connect() vers le bastion, hostname =", hostname_used)
assert hostname_used == "bastion.example.com"
assert fake_transport.open_channel.call_args.args[0] == "direct-tcpip"
assert fake_transport.open_channel.call_args.args[1] == ("10.20.20.15", 22)
assert channel is fake_channel
assert conn._bastion_client is fake_bastion_client
print(" OK")
print()
print("=== 10. Cible connectee avec sock=<canal du bastion> (pas de socket TCP direct) ===")
conn2 = SSHConnection(host="10.20.20.15", port=22, username="root", auth_type=AUTH_AUTO, bastion_target=bastion_target)
with patch("app.ssh_connection.paramiko.SSHClient") as FakeSSHClient:
fake_bastion_client2 = MagicMock()
fake_target_client2 = MagicMock()
fake_transport2 = MagicMock()
fake_transport2.open_channel.return_value = fake_channel
fake_bastion_client2.get_transport.return_value = fake_transport2
# comme dans run() : le 1er SSHClient() cree est celui de la cible (self.client,
# assigne avant l'appel a _open_bastion_channel), le 2e celui du bastion (cree a
# l'interieur de _open_bastion_channel)
FakeSSHClient.side_effect = [fake_target_client2, fake_bastion_client2]
conn2.client = conn2._new_client()
sock = conn2._open_bastion_channel()
conn2._connect_host(
conn2.client, conn2.host, conn2.port, conn2.username, conn2.auth_type,
conn2.key_path, conn2.password, conn2.passphrase, sock=sock,
)
print(" cible connect() sock=canal du bastion :", fake_target_client2.connect.call_args.kwargs.get("sock") is fake_channel)
assert fake_target_client2.connect.call_args.kwargs.get("sock") is fake_channel
print(" OK")
print()
print("=== 11. Echec de connexion au bastion -> message clair, jamais de fuite d'exception brute ===")
conn3 = SSHConnection(host="10.20.20.15", port=22, username="root", auth_type=AUTH_AUTO, bastion_target=bastion_target)
with patch("app.ssh_connection.paramiko.SSHClient") as FakeSSHClient:
fake_bastion_client3 = MagicMock()
fake_bastion_client3.connect.side_effect = OSError("Connexion refusee")
FakeSSHClient.return_value = fake_bastion_client3
try:
conn3._open_bastion_channel()
ok = False
except Exception as exc: # noqa: BLE001
ok = True
message = str(exc)
print(" message:", message)
assert "Bastion" in message
assert ok
print(" OK")
print()
print("BASTION / PROXYJUMP : TOUS LES TESTS OK")

View File

@@ -1,9 +1,11 @@
"""Copier/coller du terminal : selection, presse-papiers, raccourcis."""
import sys, pathlib
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import QEvent, QPoint, Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtCore import QEvent, Qt, QPoint
from PySide6.QtWidgets import QApplication
from app.terminal_widget import TerminalWidget
@@ -34,6 +36,15 @@ print(" lignes:", lignes)
assert lignes[0].startswith("root@srv"), lignes
assert lignes[1] == "serveur-test-01", lignes
print()
print("=== 3bis. visible_text() : tout l'ecran, sans les lignes vides de fin ===")
visible = t.visible_text()
print(" ecran visible:", repr(visible))
lignes_visibles = visible.split("\n")
assert lignes_visibles[0].startswith("root@srv"), lignes_visibles
assert lignes_visibles[1] == "serveur-test-01", lignes_visibles
assert lignes_visibles[-1].startswith("root@srv"), "le prompt courant doit etre la derniere ligne non vide"
print()
print("=== 4. Collage (le presse-papiers part vers le shell) ===")
envoye = []
@@ -90,8 +101,8 @@ print(" OK")
print()
print("=== 9. Souris : selection = copie, clic droit = collage (comme sous Unix) ===")
from PySide6.QtGui import QMouseEvent, QContextMenuEvent
from PySide6.QtCore import QPointF
from PySide6.QtGui import QContextMenuEvent, QMouseEvent
def clic(bouton, col, row, type_evt=QEvent.MouseButtonPress):

166
tests/test_credentials.py Normal file
View File

@@ -0,0 +1,166 @@
"""Credential Vault : abstraction CredentialStore, backend Windows Credential
Manager reel (nettoye apres coup), et integration avec les flux de mot de
passe (ConnectionController, bastion, suppression de session).
SSHConnection.start() est neutralisee pour les tests d'integration qui
construisent un onglet reel (aucune connexion reseau necessaire)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import json
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QMessageBox, QWidget
from app.bastion import BastionTarget
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController
from app.controllers.session_controller import SessionController
from app.credentials import (
CredentialStore,
NullCredentialStore,
WindowsCredentialStore,
default_credential_store,
session_credential_key,
)
from app.session_manager import AUTH_PASSWORD, PASSWORD_STORAGE_SECURE, Session, SessionStore
from app.ssh_connection import SSHConnection
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None)
no_ssh_thread.start()
class FakeCredentialStore(CredentialStore):
"""Trousseau en memoire : deterministe, sans dependre du vrai Windows
Credential Manager pour les tests qui ne visent pas specifiquement a le
verifier lui-meme."""
def __init__(self):
self._data: dict[str, str] = {}
def is_available(self) -> bool:
return True
def set_secret(self, key: str, secret: str) -> bool:
self._data[key] = secret
return True
def get_secret(self, key: str):
return self._data.get(key)
def delete_secret(self, key: str) -> None:
self._data.pop(key, None)
print("=== 1. NullCredentialStore : jamais disponible, jamais memorise ===")
null_store = NullCredentialStore()
assert not null_store.is_available()
assert null_store.set_secret("x", "y") is False
assert null_store.get_secret("x") is None
null_store.delete_secret("x") # ne doit pas lever
print(" OK")
print()
print("=== 2. WindowsCredentialStore : aller-retour reel via Windows Credential Manager ===")
win_store = WindowsCredentialStore()
if win_store.is_available():
test_key = "test-vault-phase11"
win_store.delete_secret(test_key) # nettoyage prealable si un run precedent a echoue
assert win_store.get_secret(test_key) is None
ok = win_store.set_secret(test_key, "s3cret-valeur-de-test")
assert ok
assert win_store.get_secret(test_key) == "s3cret-valeur-de-test"
win_store.delete_secret(test_key)
assert win_store.get_secret(test_key) is None
print(" ecriture/lecture/suppression reelles : OK")
else:
print(" Windows Credential Manager indisponible sur ce poste : test saute")
print(" OK")
print()
print("=== 3. default_credential_store() ne leve jamais, meme si indisponible ===")
store = default_credential_store()
assert isinstance(store, CredentialStore)
print(" OK")
print()
print("=== 4. Session.to_dict() ne contient jamais le secret, seulement le mode ===")
session = Session(name="prod", host="10.0.0.1", username="root", auth_type=AUTH_PASSWORD,
password_storage=PASSWORD_STORAGE_SECURE)
serialized = json.dumps(session.to_dict())
print(" cle presente:", "password_storage" in serialized, "| secret absent: 's3cret' not in json")
assert "password_storage" in serialized
assert "s3cret" not in serialized
assert "password" not in session.to_dict() or True # aucun champ "password" n'existe sur Session
assert not hasattr(session, "password")
print(" OK")
print()
print("=== 5. ConnectionController : memorise puis reutilise le mot de passe ===")
session_store = SessionStore(path=tmp / "sessions.json")
key_manager_broadcast_widget = QWidget()
broadcast = BroadcastManager(lambda: [key_manager_broadcast_widget])
from app.key_manager import KeyManager
key_manager = KeyManager(registry_path=tmp / "keys.json")
fake_store = FakeCredentialStore()
cc = ConnectionController(session_store, key_manager, broadcast, credential_store=fake_store,
parent_widget=key_manager_broadcast_widget)
remembered = Session(name="r", host="10.0.0.2", username="root", auth_type=AUTH_PASSWORD,
password_storage=PASSWORD_STORAGE_SECURE)
session_store.add(remembered)
with patch("app.controllers.connection_controller.ask_secret", return_value=("motdepasse123", True)) as asked:
ok, secret = cc._ask_session_password(remembered)
assert ok and secret == "motdepasse123"
assert asked.called, "premiere fois : doit demander"
print(" premiere connexion -> mot de passe demande puis memorise")
assert fake_store.get_secret(session_credential_key(remembered.id)) == "motdepasse123"
with patch("app.controllers.connection_controller.ask_secret") as asked2:
ok2, secret2 = cc._ask_session_password(remembered)
assert ok2 and secret2 == "motdepasse123"
assert not asked2.called, "deuxieme fois : ne doit pas redemander"
print(" deuxieme connexion -> mot de passe reutilise sans redemander : OK")
print()
print("=== 6. Suppression de session : le secret memorise est aussi supprime ===")
sc = SessionController(session_store, key_manager, broadcast.button,
parent_widget=key_manager_broadcast_widget, credential_store=fake_store,
openssh_config_path=tmp / "no-ssh-config")
with patch("app.controllers.session_controller.QMessageBox.question", return_value=QMessageBox.Yes):
sc.delete_session(remembered)
assert fake_store.get_secret(session_credential_key(remembered.id)) is None
print(" OK")
print()
print("=== 7. Bastion en mode session reutilise le mot de passe deja memorise ===")
bastion_session = Session(name="bastion", host="10.0.0.3", username="jump", auth_type=AUTH_PASSWORD,
password_storage=PASSWORD_STORAGE_SECURE)
session_store.add(bastion_session)
fake_store.set_secret(session_credential_key(bastion_session.id), "bastion-secret")
target = BastionTarget(
host=bastion_session.host, port=22, username=bastion_session.username, auth_type=AUTH_PASSWORD,
credential_key=session_credential_key(bastion_session.id),
)
with patch("app.controllers.connection_controller.ask_secret") as asked3:
session2 = Session(name="cible", host="10.0.0.4", username="root")
with patch("app.controllers.connection_controller.resolve_bastion_target", return_value=target):
ok3, resolved_target, password3 = cc._resolve_bastion(session2)
assert ok3 and password3 == "bastion-secret"
assert not asked3.called, "le mot de passe du bastion doit etre reutilise, pas redemande"
print(" OK")
no_ssh_thread.stop()
print()
print("CREDENTIAL VAULT : TOUS LES TESTS OK")

View File

@@ -1,4 +1,6 @@
import sys, pathlib
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.main_window import detect_ssh_target

View File

@@ -1,6 +1,9 @@
import sys, pathlib
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from PySide6.QtWidgets import QApplication
from app.main_window import MainWindow
from app.theme import apply_theme, build_app_icon

46
tests/test_fuzzy.py Normal file
View File

@@ -0,0 +1,46 @@
"""Recherche floue (app.services.fuzzy) : sous-sequence, score, tri."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.services.fuzzy import fuzzy_score
print("=== 1. Sous-sequence simple ===")
assert fuzzy_score("pve", "Proxmox Site A — root@192.168.10.10") is None # 'pve' n'est pas une sous-sequence
assert fuzzy_score("psa", "Proxmox Site A") is not None
assert fuzzy_score("xyz", "Proxmox") is None
print(" OK")
print()
print("=== 2. Insensible a la casse ===")
assert fuzzy_score("PROXMOX", "proxmox-prod") is not None
assert fuzzy_score("proxmox", "PROXMOX-PROD") is not None
print(" OK")
print()
print("=== 3. Chaine vide -> tout correspond, score neutre ===")
assert fuzzy_score("", "quoi que ce soit") == 0
print(" OK")
print()
print("=== 4. Une correspondance contigue en debut de mot est mieux classee ===")
better = fuzzy_score("prox", "proxmox-prod") # debut de chaine, contigu
worse = fuzzy_score("prox", "reverse-proxy-x") # eparpille
print(f" score contigu={better}, score eparpille={worse}")
assert better > worse
print(" OK")
print()
print("=== 5. Tri d'une liste de candidats par pertinence ===")
candidates = ["reverse-proxy-eu", "proxmox-prod", "docker-proxy", "proxmox-lab"]
scored = sorted(
((fuzzy_score("prox", c), c) for c in candidates),
key=lambda pair: -pair[0],
)
print(" ordre:", [c for _, c in scored])
assert scored[0][1] in ("proxmox-prod", "proxmox-lab")
print(" OK")
print()
print("FUZZY SEARCH : TOUS LES TESTS OK")

114
tests/test_keepalive.py Normal file
View File

@@ -0,0 +1,114 @@
"""Keepalive de SSHConnection : verifie l'envoi periodique et l'abandon apres
`keepalive_count_max` echecs consecutifs, avec un transport paramiko mocke
(aucune connexion reseau reelle)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import threading
import time
from unittest.mock import MagicMock
from PySide6.QtWidgets import QApplication
from app.ssh_connection import SSHConnection
app = QApplication.instance() or QApplication([])
def make_connection(interval=0.05, count_max=3):
conn = SSHConnection(
host="10.0.0.1", port=22, username="root", auth_type="auto",
keepalive_enabled=True, keepalive_interval=1, keepalive_count_max=count_max,
)
# keepalive_interval est borne a >= 1 dans __init__ (secondes reelles) : pour un
# test rapide, on contourne ensuite l'attribut directement (le code ne relit
# l'intervalle qu'a chaque iteration, une valeur fractionnaire est acceptee par
# threading.Event.wait()).
conn.keepalive_interval = interval
return conn
print("=== 1. Requetes reussies : le compteur d'echecs ne progresse jamais ===")
conn = make_connection()
transport = MagicMock()
transport.is_active.return_value = True
conn.client = MagicMock()
conn.client.get_transport.return_value = transport
thread = threading.Thread(target=conn._keepalive_loop, daemon=True)
thread.start()
time.sleep(0.35)
conn._stop.set()
thread.join(timeout=2)
calls = transport.global_request.call_count
print(f" requetes envoyees en ~0.35s (intervalle 0.05s): {calls}")
assert calls >= 3, calls
assert conn._disconnect_reason == "Connexion fermee" # jamais modifie : aucun echec
print(" OK")
print()
print("=== 2. N echecs consecutifs -> abandon, canal ferme, raison mise a jour ===")
conn2 = make_connection(count_max=3)
transport2 = MagicMock()
transport2.is_active.return_value = True
transport2.global_request.side_effect = OSError("reseau injoignable")
conn2.client = MagicMock()
conn2.client.get_transport.return_value = transport2
fake_channel = MagicMock()
conn2.channel = fake_channel
thread2 = threading.Thread(target=conn2._keepalive_loop, daemon=True)
thread2.start()
thread2.join(timeout=3)
print(f" requetes tentees avant abandon: {transport2.global_request.call_count}")
assert transport2.global_request.call_count == 3, transport2.global_request.call_count
assert conn2._stop.is_set(), "la connexion doit etre marquee pour arret"
assert fake_channel.close.called, "le canal doit etre ferme pour reveiller la boucle de lecture"
print(" raison de deconnexion:", conn2._disconnect_reason)
assert "keepalive" in conn2._disconnect_reason.lower()
print(" OK")
print()
print("=== 3. Une reponse reussie apres des echecs repart de zero ===")
conn3 = make_connection(count_max=3)
transport3 = MagicMock()
transport3.is_active.return_value = True
_calls = {"n": 0}
def _flaky_then_ok(*_a, **_k):
_calls["n"] += 1
if _calls["n"] <= 2:
raise OSError("timeout")
return None
transport3.global_request.side_effect = _flaky_then_ok
conn3.client = MagicMock()
conn3.client.get_transport.return_value = transport3
thread3 = threading.Thread(target=conn3._keepalive_loop, daemon=True)
thread3.start()
time.sleep(0.35)
conn3._stop.set()
thread3.join(timeout=2)
print(f" requetes envoyees: {transport3.global_request.call_count} (jamais 3 echecs consecutifs)")
# la connexion ne doit pas avoir ete abandonnee malgre 2 echecs, puisqu'un succes
# est intervenu avant le 3e (le compteur d'echecs consecutifs est remis a zero) :
assert conn3._disconnect_reason == "Connexion fermee"
print(" OK")
print()
print("=== 4. Keepalive desactive : aucun thread demarre ===")
conn4 = SSHConnection(host="10.0.0.1", port=22, username="root", auth_type="auto", keepalive_enabled=False)
conn4._start_keepalive()
assert conn4._keepalive_thread is None
print(" OK")
print()
print("KEEPALIVE : TOUS LES TESTS OK")

View File

@@ -0,0 +1,91 @@
"""Protection des cles privees : verifie la sequence d'appels icacls sous Windows
(sans dependre des ACL reelles du systeme de fichiers, pour rester fiable quelle
que soit la machine qui execute le test) et le bon aiguillage vers le repli chmod
ailleurs que sous Windows."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import os
import subprocess
import tempfile
from pathlib import Path
from unittest.mock import patch
from app.security.key_protection import _protect_windows, protect_private_key
tmp = Path(tempfile.mkdtemp())
key_file = tmp / "id_test"
key_file.write_text("contenu-cle-privee", encoding="utf-8")
print("=== 1. Sous Windows : /inheritance:r puis /grant:r pour l'utilisateur courant ===")
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return subprocess.CompletedProcess(cmd, 0)
with patch.dict(os.environ, {"USERDOMAIN": "MACHINE", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
_protect_windows(key_file)
print(" commandes executees:")
for c in calls:
print(" ", c)
assert len(calls) == 2, calls
assert calls[0] == ["icacls", str(key_file), "/inheritance:r"], calls[0]
assert calls[1] == ["icacls", str(key_file), "/grant:r", "MACHINE\\florian:F"], calls[1]
print(" OK")
print()
print("=== 2. Sans domaine (poste hors domaine) : compte = USERNAME seul ===")
calls.clear()
with patch.dict(os.environ, {"USERDOMAIN": "", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
_protect_windows(key_file)
assert calls[1][-1] == "florian:F", calls[1]
print(" OK")
print()
print("=== 3. Un echec icacls ne remonte jamais (best effort, comme l'ancien chmod) ===")
with patch("subprocess.run", side_effect=OSError("icacls introuvable")):
_protect_windows(key_file) # ne doit lever aucune exception
print(" aucune exception propagee : OK")
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "icacls")):
_protect_windows(key_file)
print(" code de sortie non nul : aucune exception propagee non plus : OK")
print()
print("=== 4. protect_private_key() aiguille vers icacls sous Windows ===")
with patch("os.name", "nt"), patch("app.security.key_protection._protect_windows") as windows_impl:
protect_private_key(key_file)
assert windows_impl.called
print(" os.name == 'nt' -> _protect_windows() appele : OK")
print()
print("=== 5. protect_private_key() retombe sur chmod(0o600) hors Windows ===")
with patch("os.name", "posix"), patch("pathlib.Path.chmod") as chmod_mock:
protect_private_key(key_file)
chmod_mock.assert_called_once_with(0o600)
print(" os.name != 'nt' -> chmod(0o600) appele : OK")
print()
print("=== 6. Ne rend jamais la cle MOINS protegee : jamais d'octroi a un groupe large ===")
all_granted_users = set()
with patch.dict(os.environ, {"USERDOMAIN": "MACHINE", "USERNAME": "florian"}):
with patch("subprocess.run", side_effect=fake_run):
calls.clear()
_protect_windows(key_file)
for cmd in calls:
if "/grant:r" in cmd:
all_granted_users.add(cmd[-1].split(":")[0])
print(" comptes ayant reçu un acces:", all_granted_users)
assert all_granted_users == {"MACHINE\\florian"}, all_granted_users
print(" OK")
print()
print("KEY_PROTECTION_WINDOWS_ACL : TOUS LES TESTS OK")

View File

@@ -1,6 +1,8 @@
"""Generation de cles SSH : tous les types proposes doivent produire une cle
relisible par paramiko, donc utilisable pour se connecter."""
import sys, pathlib
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
@@ -9,7 +11,11 @@ from pathlib import Path
import paramiko
from app.key_manager import (
KeyManager, KEY_TYPE_LABELS, KEY_TYPE_RSA, KEY_TYPE_ED25519, ECDSA_CURVES,
ECDSA_CURVES,
KEY_TYPE_ED25519,
KEY_TYPE_LABELS,
KEY_TYPE_RSA,
KeyManager,
)
tmp = Path(tempfile.mkdtemp())

View File

@@ -0,0 +1,91 @@
"""Aucun secret (mot de passe de session, cle API IA) ne doit jamais apparaitre
dans un message d'erreur, une exception ou sa representation textuelle - ce
sont les seuls endroits, en dehors des fichiers de stockage deja couverts par
test_credentials.py/test_ai_settings.py, ou un secret pourrait fuiter sans
etre ecrit intentionnellement (une boite de dialogue d'erreur, une trace
affichee a l'utilisateur ou capturee dans un rapport de bug)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import io
import urllib.error
from unittest.mock import MagicMock, patch
from app.ai.provider import AIError, OpenAICompatibleProvider
SECRET_API_KEY = "sk-tres-secret-ne-jamais-fuiter-1234567890"
def make_http_error(code: int, body: bytes = b"") -> urllib.error.HTTPError:
return urllib.error.HTTPError(url="http://x/chat/completions", code=code, msg="err",
hdrs=None, fp=io.BytesIO(body))
provider = OpenAICompatibleProvider(
base_url="https://api.example.com/v1", api_key=SECRET_API_KEY, model="gpt-test",
)
print("=== 1. Erreur 401 : le message ne contient jamais la cle API ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(401, b"unauthorized")):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 2. Erreur 500 avec corps de reponse : la cle API n'y apparait jamais ===")
# le corps de la reponse est celui du FOURNISSEUR (pas la requete) : il ne peut
# pas contenir la cle, mais on verifie que rien ne l'y ajoute par erreur.
with patch("app.ai.provider.urllib.request.urlopen", side_effect=make_http_error(500, b"internal error")):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 3. Erreur reseau : le message ne contient jamais la cle API ===")
with patch("app.ai.provider.urllib.request.urlopen", side_effect=urllib.error.URLError("connexion refusee")):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 4. Reponse invalide (JSON incoherent) : le message ne contient jamais la cle API ===")
bad_response = MagicMock()
bad_response.read.return_value = b"{\"unexpected\": \"shape\"}"
bad_response.__enter__.return_value = bad_response
bad_response.__exit__.return_value = False
with patch("app.ai.provider.urllib.request.urlopen", return_value=bad_response):
try:
provider.chat([{"role": "user", "content": "x"}])
raise AssertionError("aurait du lever AIError")
except AIError as exc:
message = str(exc)
print(" message:", message)
assert SECRET_API_KEY not in message
print(" OK")
print()
print("=== 5. La cle API n'apparait pas non plus dans repr(provider) ===")
print(" repr:", repr(provider))
assert SECRET_API_KEY not in repr(provider)
assert SECRET_API_KEY not in str(provider)
print(" OK")
print()
print("NO SECRETS IN ERRORS : TOUS LES TESTS OK")

View File

@@ -0,0 +1,108 @@
"""Lecture de ~/.ssh/config : hosts detectes, champs minimum, conversion en Session."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from app.openssh.config import load_openssh_hosts, session_from_openssh_host
from app.session_manager import AUTH_AUTO, AUTH_KEY
tmp = Path(tempfile.mkdtemp())
config_path = tmp / "config"
config_path.write_text(
"""
Host *
ServerAliveInterval 60
ServerAliveCountMax 3
Host bastion
HostName bastion.example.com
User jump-user
Port 2222
IdentityFile ~/.ssh/bastion_key
Host proxmox-prod
HostName 10.20.20.15
User root
IdentityFile ~/.ssh/homelab
ProxyJump bastion
ForwardAgent yes
Host sans-cle
HostName 10.0.0.9
User admin
""",
encoding="utf-8",
)
print("=== 1. Fichier absent -> liste vide, pas d'exception ===")
absent = load_openssh_hosts(tmp / "n-existe-pas")
assert absent == []
print(" OK")
print()
print("=== 2. Hosts detectes, '*' exclu ===")
hosts = load_openssh_hosts(config_path)
aliases = [h.alias for h in hosts]
print(" alias detectes:", aliases)
assert aliases == sorted(aliases), "tries par alias"
assert "*" not in aliases
assert set(aliases) == {"bastion", "proxmox-prod", "sans-cle"}
print(" OK")
print()
print("=== 3. Champs minimum correctement lus ===")
by_alias = {h.alias: h for h in hosts}
prod = by_alias["proxmox-prod"]
print(" proxmox-prod:", prod)
assert prod.hostname == "10.20.20.15"
assert prod.user == "root"
assert prod.port == 22 # non precise -> defaut
assert prod.proxy_jump == "bastion"
assert prod.forward_agent is True
assert prod.identity_files and prod.identity_files[0].endswith("homelab")
# heritees du bloc "Host *"
assert prod.server_alive_interval == 60
assert prod.server_alive_count_max == 3
print(" OK")
bastion = by_alias["bastion"]
assert bastion.port == 2222
assert bastion.forward_agent is False # non precise pour cet hote
print(" bastion : port et ForwardAgent par defaut corrects : OK")
sans_cle = by_alias["sans-cle"]
assert sans_cle.identity_files == []
print(" sans-cle : aucune IdentityFile -> liste vide : OK")
print()
print("=== 4. Conversion en Session PySSH ===")
session_prod = session_from_openssh_host(prod)
print(" session:", session_prod)
assert session_prod.name == "proxmox-prod"
assert session_prod.host == "10.20.20.15"
assert session_prod.username == "root"
assert session_prod.group == "OpenSSH"
assert session_prod.auth_type == AUTH_KEY
assert session_prod.key_path.endswith("homelab")
assert "ProxyJump" in session_prod.notes and "bastion" in session_prod.notes
print(" OK")
session_sans_cle = session_from_openssh_host(sans_cle)
assert session_sans_cle.auth_type == AUTH_AUTO
assert "ProxyJump" not in session_sans_cle.notes
print(" sans-cle -> AUTH_AUTO, pas de mention ProxyJump : OK")
print()
print("=== 5. Host sans HostName : l'alias fait office d'hote (comme OpenSSH) ===")
config2 = tmp / "config2"
config2.write_text("Host monserveur\n User root\n", encoding="utf-8")
hosts2 = load_openssh_hosts(config2)
assert hosts2[0].hostname == "monserveur"
print(" OK")
print()
print("OPENSSH CONFIG : TOUS LES TESTS OK")

146
tests/test_quick_connect.py Normal file
View File

@@ -0,0 +1,146 @@
"""Palette Quick Connect (Ctrl+K) : collecte des resultats (sessions, hosts
OpenSSH, workspaces, snippets, actions), filtrage flou, navigation clavier et
activation.
SSHConnection.start()/LocalTerminalConnection.start() sont neutralisees, et
~/.ssh/config est redirige vers un fichier absent (pas de dependance au poste
qui execute le test)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QApplication, QWidget
import app.app_settings as app_settings
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController
from app.controllers.session_controller import SessionController
from app.controllers.workspace_manager import WorkspaceManager
from app.dialogs.quick_connect_dialog import QuickConnectDialog
from app.key_manager import KeyManager
from app.local_terminal import LocalTerminalConnection
from app.session_manager import Session, SessionStore
from app.snippets import Snippet, SnippetStore
from app.ssh_connection import SSHConnection
from app.workspace import WorkspaceStore
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
absent_ssh_config = tmp / "no-such-ssh-config"
no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None)
no_local_thread = patch.object(LocalTerminalConnection, "start", lambda self: None)
settings_redirect = patch.object(app_settings, "SETTINGS_FILE", tmp / "settings.json")
no_ssh_thread.start()
no_local_thread.start()
settings_redirect.start()
def make_palette():
session_store = SessionStore(path=tmp / "sessions.json")
key_manager = KeyManager(registry_path=tmp / "keys.json")
workspace_store = WorkspaceStore(path=tmp / "workspaces.json")
snippet_store = SnippetStore(path=tmp / "snippets.json")
snippet_store.snippets = [Snippet(name="Disk Usage", category="Linux", command="df -h")]
host_widget = QWidget()
broadcast = BroadcastManager(lambda: [host_widget])
cc = ConnectionController(session_store, key_manager, broadcast, snippet_store=snippet_store, parent_widget=host_widget)
sc = SessionController(session_store, key_manager, broadcast.button, parent_widget=host_widget,
openssh_config_path=absent_ssh_config)
wm = WorkspaceManager(workspace_store, session_store, cc, parent_widget=host_widget)
dialog = QuickConnectDialog(session_store, cc, wm, snippet_store, sc, key_manager, parent=host_widget)
return session_store, cc, wm, snippet_store, dialog
print("=== 1. Collecte : sessions, workspaces, snippets et actions presents ===")
session_store, cc, wm, snippet_store, dialog = make_palette()
session = Session(name="prod-web", host="10.0.0.5", username="root")
session_store.add(session)
cc.open_local_terminal()
workspace = wm.save_as_new("Homelab")
dialog._all_results = dialog._collect_results() # recollecte apres l'ajout de la session/workspace
kinds = {r.kind for r in dialog._all_results}
print(" types de resultats collectes:", kinds)
assert "session" in kinds
assert "workspace" in kinds
assert "snippet" in kinds
assert "action" in kinds
assert any(r.label == "prod-web" for r in dialog._all_results)
assert any(r.label == "Homelab" for r in dialog._all_results)
assert any(r.label == "Disk Usage" for r in dialog._all_results)
print(" OK")
print()
print("=== 2. Filtrage : la recherche restreint et classe les resultats affiches ===")
dialog.search_edit.setText("prod")
print(f" {dialog.list.count()} resultat(s) pour 'prod'")
assert dialog.list.count() >= 1
top_label = dialog.list.item(0).data(Qt.UserRole).label
print(" meilleur resultat:", top_label)
assert "prod" in top_label.lower()
print(" OK")
print()
print("=== 3. Chaine vide -> tous les resultats affiches ===")
dialog.search_edit.setText("")
assert dialog.list.count() == len(dialog._all_results)
print(" OK")
print()
print("=== 4. Navigation clavier : bas/haut deplacent la selection ===")
dialog.search_edit.setText("")
dialog.list.setCurrentRow(0)
down = QKeyEvent(QEvent.KeyPress, Qt.Key_Down, Qt.NoModifier)
dialog.eventFilter(dialog.search_edit, down)
assert dialog.list.currentRow() == 1
up = QKeyEvent(QEvent.KeyPress, Qt.Key_Up, Qt.NoModifier)
dialog.eventFilter(dialog.search_edit, up)
assert dialog.list.currentRow() == 0
print(" OK")
print()
print("=== 5. Activation d'une session ouvre bien un onglet SSH ===")
session_store2, cc2, wm2, snippet_store2, dialog2 = make_palette()
session2 = Session(name="cible", host="10.0.0.9", username="root")
session_store2.add(session2)
dialog2._all_results = dialog2._collect_results()
dialog2.search_edit.setText("cible")
assert dialog2.list.count() == 1
before = cc2.tabs.count()
dialog2._activate_current()
print(f" onglets avant/apres activation: {before}/{cc2.tabs.count()}")
assert cc2.tabs.count() == before + 1
from app.ui.tabs import SessionTabWidget
assert isinstance(cc2.tabs.widget(cc2.tabs.count() - 1), SessionTabWidget)
print(" OK")
print()
print("=== 6. Activation d'un snippet insere dans l'onglet courant ===")
session_store3, cc3, wm3, snippet_store3, dialog3 = make_palette()
cc3.open_local_terminal()
dialog3._all_results = dialog3._collect_results()
dialog3.search_edit.setText("Disk Usage")
assert dialog3.list.count() == 1
dialog3._activate_current()
current_tab = cc3.tabs.currentWidget()
sent = current_tab.connection._out_queue.get_nowait()
print(" envoye dans l'onglet courant:", sent)
assert sent == b"df -h"
print(" OK")
no_ssh_thread.stop()
no_local_thread.stop()
settings_redirect.stop()
print()
print("QUICK CONNECT : TOUS LES TESTS OK")

View File

@@ -0,0 +1,34 @@
"""Calcul du delai de reconnexion automatique (backoff exponentiel plafonne)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from app.services.reconnect import compute_backoff_delay
print("=== 1. Progression exponentielle ===")
delays = [compute_backoff_delay(n, initial_delay=5) for n in range(1, 6)]
print(" delais:", delays)
assert delays == [5, 10, 20, 40, 60], delays # le 5e (80s) est plafonne a 60s
print(" OK")
print()
print("=== 2. Plafond respecte au-dela ===")
assert compute_backoff_delay(10, initial_delay=5, max_delay=60) == 60
assert compute_backoff_delay(20, initial_delay=5, max_delay=120) == 120
print(" OK")
print()
print("=== 3. Tentative < 1 traitee comme la premiere ===")
assert compute_backoff_delay(0, initial_delay=5) == 5
assert compute_backoff_delay(-3, initial_delay=5) == 5
print(" OK")
print()
print("=== 4. Delai initial personnalise ===")
assert compute_backoff_delay(1, initial_delay=10) == 10
assert compute_backoff_delay(2, initial_delay=10) == 20
print(" OK")
print()
print("BACKOFF DE RECONNEXION : TOUS LES TESTS OK")

View File

@@ -0,0 +1,134 @@
"""Editeur de session : section Bastion / Jump Host (construction, pre-remplissage
a l'edition, et resultat renvoye pour chacun des 4 modes)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
from app.dialogs.session_dialog import SessionDialog
from app.key_manager import KeyManager
from app.session_manager import (
AUTH_KEY,
BASTION_MANUAL,
BASTION_NONE,
BASTION_OPENSSH,
BASTION_SESSION,
BastionConfig,
Session,
SessionStore,
)
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
key_manager = KeyManager(registry_path=tmp / "keys.json")
store = SessionStore(path=tmp / "sessions.json")
bastion_session = Session(name="bastion-srv", host="10.0.0.1", username="root")
store.add(bastion_session)
target_session = Session(name="cible", host="10.20.20.15", username="root")
store.add(target_session)
config_path = tmp / "sshconfig"
config_path.write_text("Host bastion-openssh\n HostName 1.2.3.4\n User jump\n", encoding="utf-8")
print("=== 1. Par defaut : mode Aucun ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
assert dlg.bastion_mode_combo.currentData() == BASTION_NONE
assert dlg._result_bastion().mode == BASTION_NONE
dlg.close()
print(" OK")
print()
print("=== 2. La session en cours d'edition n'apparait pas dans sa propre liste de bastions ===")
dlg = SessionDialog(key_manager, session=target_session, session_store=store)
ids_proposes = [dlg.bastion_session_combo.itemData(i) for i in range(dlg.bastion_session_combo.count())]
print(" sessions proposees comme bastion:", ids_proposes)
assert target_session.id not in ids_proposes
assert bastion_session.id in ids_proposes
dlg.close()
print(" OK")
print()
print("=== 3. Mode Session : selection puis resultat ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_SESSION)
dlg.bastion_mode_combo.setCurrentIndex(idx)
idx_session = dlg.bastion_session_combo.findData(bastion_session.id)
dlg.bastion_session_combo.setCurrentIndex(idx_session)
result = dlg._result_bastion()
print(" resultat:", result)
assert result.mode == BASTION_SESSION
assert result.session_id == bastion_session.id
dlg.close()
print(" OK")
print()
print("=== 4. Mode OpenSSH : liste chargee depuis ~/.ssh/config, puis resultat ===")
with patch("app.openssh.config.default_config_path", return_value=config_path):
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_OPENSSH)
dlg.bastion_mode_combo.setCurrentIndex(idx)
idx_host = dlg.bastion_openssh_combo.findData("bastion-openssh")
assert idx_host >= 0, "l'alias detecte doit apparaitre dans la liste"
dlg.bastion_openssh_combo.setCurrentIndex(idx_host)
result = dlg._result_bastion()
print(" resultat:", result)
assert result.mode == BASTION_OPENSSH
assert result.openssh_alias == "bastion-openssh"
dlg.close()
print(" OK")
print()
print("=== 5. Mode manuel : champs -> resultat ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_MANUAL)
dlg.bastion_mode_combo.setCurrentIndex(idx)
dlg.bastion_host_edit.setText("bastion.example.com")
dlg.bastion_port_spin.setValue(2222)
dlg.bastion_user_edit.setText("jump")
result = dlg._result_bastion()
print(" resultat:", result)
assert result.mode == BASTION_MANUAL
assert result.host == "bastion.example.com"
assert result.port == 2222
assert result.username == "jump"
dlg.close()
print(" OK")
print()
print("=== 6. Edition d'une session avec bastion deja configure : pre-remplissage correct ===")
pre_filled = Session(
name="avec-bastion", host="10.20.20.15", username="root",
bastion=BastionConfig(mode=BASTION_MANUAL, host="old-bastion", port=2200, username="olduser"),
)
dlg = SessionDialog(key_manager, session=pre_filled, session_store=store)
print(" mode pre-selectionne:", dlg.bastion_mode_combo.currentData())
print(" page affichee (stack index):", dlg.bastion_stack.currentIndex())
assert dlg.bastion_mode_combo.currentData() == BASTION_MANUAL
assert dlg.bastion_stack.currentIndex() == dlg.bastion_mode_combo.currentIndex()
assert dlg.bastion_host_edit.text() == "old-bastion"
assert dlg.bastion_port_spin.value() == 2200
assert dlg.bastion_user_edit.text() == "olduser"
dlg.close()
print(" OK")
print()
print("=== 7. Cle bastion visible seulement en authentification par cle ===")
dlg = SessionDialog(key_manager, session=Session(), session_store=store)
idx = dlg.bastion_mode_combo.findData(BASTION_MANUAL)
dlg.bastion_mode_combo.setCurrentIndex(idx)
assert not dlg.bastion_key_combo.isEnabled()
idx_key = dlg.bastion_auth_combo.findData(AUTH_KEY)
dlg.bastion_auth_combo.setCurrentIndex(idx_key)
assert dlg.bastion_key_combo.isEnabled()
dlg.close()
print(" OK")
print()
print("SESSION DIALOG - BASTION : TOUS LES TESTS OK")

View File

@@ -0,0 +1,125 @@
"""Compacite du formulaire de session : signale par l'utilisateur ("trop grand,
ne rentre pas dans l'ecran") sur le dialogue de connexion SFTP rapide.
Deux causes distinctes corrigees ensemble :
1. AutoHeightStackedWidget : le QStackedWidget de la section Bastion reservait
par defaut la hauteur de sa PLUS GRANDE page (Configuration manuelle, 5
champs) meme quand la page vide ("Aucun") etait affichee - un rectangle
vide d'environ 200px, repere en comparant une capture d'ecran avant/apres.
2. En connexion rapide (quick_mode), Bastion/Keepalive/Reconnexion sont
repliees par defaut derriere un bouton "Options avancees..." : peu
pertinentes pour une connexion ponctuelle (souvent un serveur de test)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from PySide6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
from app.dialogs.session_dialog import SessionDialog
from app.groups import GroupSettings, GroupSettingsStore
from app.key_manager import KeyManager
from app.session_manager import AUTH_AUTO, BASTION_MANUAL, BastionConfig, Session, SessionStore
from app.ui.auto_height_stack import AutoHeightStackedWidget
app = QApplication.instance() or QApplication(sys.argv)
tmp = Path(tempfile.mkdtemp())
key_manager = KeyManager(registry_path=tmp / "keys.json")
store = SessionStore(path=tmp / "sessions.json")
def make_page(height: int) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
label = QLabel("x")
label.setFixedHeight(height)
layout.addWidget(label)
return page
print("=== 1. AutoHeightStackedWidget : sizeHint suit la page courante, pas la plus grande ===")
stack = AutoHeightStackedWidget()
small_page = make_page(10)
tall_page = make_page(300)
stack.addWidget(small_page)
stack.addWidget(tall_page)
stack.setCurrentIndex(0)
height_on_small = stack.sizeHint().height()
stack.setCurrentIndex(1)
height_on_tall = stack.sizeHint().height()
print(f" hauteur sur petite page: {height_on_small}, sur grande page: {height_on_tall}")
assert height_on_small < height_on_tall, "la page courante doit determiner la hauteur, pas la plus grande des deux"
assert height_on_tall >= 300
print(" OK")
print()
print("=== 2. Connexion rapide : les options avancees sont repliees par defaut ===")
dlg = SessionDialog(
key_manager, session=Session(), parent=None, connect_label="Se connecter",
title="Connexion SFTP", session_store=store,
)
assert hasattr(dlg, "advanced_toggle"), "le bouton de repli doit exister en mode connexion rapide"
assert not dlg.advanced_toggle.isChecked()
assert not dlg.advanced_container.isVisible()
collapsed_height = dlg.sizeHint().height()
print(" hauteur repliee:", collapsed_height)
dlg.close()
print(" OK")
print()
print("=== 3. Deplier affiche les reglages avances et agrandit le dialogue ===")
dlg = SessionDialog(
key_manager, session=Session(), parent=None, connect_label="Se connecter",
title="Connexion SFTP", session_store=store,
)
dlg.show() # isVisible() se compose avec les ancetres : il en faut un reellement affiche
dlg.adjustSize()
collapsed = dlg.height()
dlg.advanced_toggle.setChecked(True)
app.processEvents()
expanded = dlg.height()
print(f" hauteur repliee: {collapsed}, depliee: {expanded}")
assert dlg.advanced_container.isVisible()
assert "▾" in dlg.advanced_toggle.text()
assert expanded > collapsed
dlg.close()
print(" OK")
print()
print("=== 4. Nouvelle session complete (pas de connexion rapide) : tout est visible, pas de repli ===")
dlg = SessionDialog(key_manager, session=Session(), parent=None, session_store=store)
dlg.show()
assert not hasattr(dlg, "advanced_toggle"), "pas de bouton de repli hors connexion rapide"
assert dlg.advanced_container.isVisible()
dlg.close()
print(" OK")
print()
print("=== 5. Heriter du groupe en connexion rapide deplie automatiquement si un bastion est herite ===")
group_store = GroupSettingsStore(path=tmp / "groups.json")
group_store.set(GroupSettings(
path="Prod", username="deploy", auth_type=AUTH_AUTO,
bastion=BastionConfig(mode=BASTION_MANUAL, host="bastion.example.com", username="jump"),
))
dlg = SessionDialog(
key_manager, session=Session(), parent=None, connect_label="Se connecter",
title="Connexion SFTP", session_store=store, group_store=group_store,
)
dlg.show()
assert not dlg.advanced_toggle.isChecked()
assert not dlg.advanced_container.isVisible()
dlg.group_edit.setText("Prod")
dlg._apply_group_defaults()
print(" bouton avance coche apres heritage:", dlg.advanced_toggle.isChecked())
assert dlg.advanced_toggle.isChecked(), "l'heritage d'un bastion doit reveler les options avancees"
assert dlg.advanced_container.isVisible()
assert dlg.bastion_host_edit.text() == "bastion.example.com"
dlg.close()
print(" OK")
print()
print("SESSION DIALOG - COMPACITE : TOUS LES TESTS OK")

View File

@@ -1,8 +1,9 @@
"""Test de la logique SFTP avec un client SFTP simule (adosse a un dossier temporaire)."""
import sys, pathlib
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import os
import posixpath
import shutil
import stat as statmod
import tempfile
@@ -10,9 +11,8 @@ import time
from pathlib import Path
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from app.sftp_panel import SFTPPanel, TransferJob, NAME_ROLE, ISDIR_ROLE, PARENT_ROLE
from app.sftp_panel import NAME_ROLE, PARENT_ROLE, SFTPPanel
class FakeAttr:
@@ -70,6 +70,19 @@ class FakeSFTP:
app = QApplication([])
def wait_idle(panel_side, timeout=5.0):
"""Pompe la boucle d'evenements Qt jusqu'a ce que toutes les operations SFTP
soumises par ce panneau (navigate/refresh/etc, desormais asynchrones) soient
revenues."""
deadline = time.time() + timeout
app.processEvents()
while panel_side._callbacks and time.time() < deadline:
app.processEvents()
time.sleep(0.01)
app.processEvents()
remote_root = Path(tempfile.mkdtemp())
local_root = Path(tempfile.mkdtemp())
@@ -85,6 +98,7 @@ local_root = Path(tempfile.mkdtemp())
panel = SFTPPanel(FakeSFTP(remote_root))
panel.local.navigate(str(local_root))
panel.resize(1000, 600)
wait_idle(panel.remote) # laisse le temps a la navigation initiale (asynchrone) d'arriver
print("=== 1. Listing distant avec colonnes ===")
tree = panel.remote.tree
@@ -135,11 +149,13 @@ assert got.exists()
print()
print("=== 5. Renommage distant ===")
panel.remote.navigate("/")
wait_idle(panel.remote)
item = next(panel.remote.tree.topLevelItem(i) for i in range(panel.remote.tree.topLevelItemCount())
if panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE) == "config.yml")
item.setSelected(True)
panel.remote.sftp.rename("/config.yml", "/config_renomme.yml")
panel.remote.refresh()
wait_idle(panel.remote)
names = [panel.remote.tree.topLevelItem(i).data(0, NAME_ROLE)
for i in range(panel.remote.tree.topLevelItemCount())]
print(" entrees apres renommage:", [n for n in names if n])
@@ -155,6 +171,7 @@ print()
print("=== 7. Suppression recursive distante ===")
panel.remote._rmdir_recursive("/dossier_local")
panel.remote.refresh()
wait_idle(panel.remote)
print(" dossier supprime du serveur:", not (remote_root / "dossier_local").exists())
assert not (remote_root / "dossier_local").exists()
@@ -165,5 +182,7 @@ has_parent = any(panel.remote.tree.topLevelItem(i).data(0, PARENT_ROLE)
print(" ligne '..' presente:", has_parent)
assert has_parent
panel.shutdown()
print()
print("TOUS LES TESTS SFTP OK")

150
tests/test_snippets.py Normal file
View File

@@ -0,0 +1,150 @@
"""Snippets : stockage (CRUD, recherche, categories) et integration terminal
(inserer sans executer, executer, executer avec Multi-Send).
LocalTerminalConnection.start() est neutralisee : send() ne fait que deposer
les octets dans une file interne (aucun sous-processus reel), ce qui suffit a
verifier ce qui a ete envoye sans avoir besoin d'un vrai terminal."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
from app.controllers.broadcast_manager import BroadcastManager
from app.local_terminal import LocalTerminalConnection
from app.snippets import DEFAULT_SNIPPETS, Snippet, SnippetStore
from app.ui.tabs import LocalTerminalTabWidget
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
print("=== 1. Premier lancement : les exemples fournis sont charges ===")
store = SnippetStore(path=tmp / "snippets.json")
print(f" {len(store.snippets)} snippet(s) au premier lancement")
assert len(store.snippets) == len(DEFAULT_SNIPPETS)
assert any(s.name == "Disk Usage" and s.category == "Linux" for s in store.snippets)
print(" OK")
print()
print("=== 2. CRUD ===")
store2 = SnippetStore(path=tmp / "snippets2.json")
initial_count = len(store2.snippets)
snippet = Snippet(name="Mon snippet", category="Perso", description="test", command="echo bonjour")
store2.add(snippet)
assert len(store2.snippets) == initial_count + 1
snippet.command = "echo modifie"
store2.update(snippet)
assert store2.get(snippet.id).command == "echo modifie"
store2.delete(snippet.id)
assert store2.get(snippet.id) is None
assert len(store2.snippets) == initial_count
print(" OK")
print()
print("=== 3. Categories et regroupement ===")
store3 = SnippetStore(path=tmp / "snippets3.json")
store3.snippets = [
Snippet(name="b", category="Docker", command="x"),
Snippet(name="a", category="Docker", command="y"),
Snippet(name="z", category="Linux", command="z"),
]
grouped = store3.by_category()
print(" categories:", list(grouped.keys()))
assert list(grouped.keys()) == ["Docker", "Linux"]
assert [s.name for s in grouped["Docker"]] == ["a", "b"] # tries par nom
print(" OK")
print()
print("=== 4. Recherche ===")
results = store3.search("docker")
assert {s.category for s in results} == {"Docker"}
results = store3.search("")
assert len(results) == len(store3.snippets)
print(" OK")
print()
print("=== 5. Persistance (relecture depuis le disque) ===")
path4 = tmp / "snippets4.json"
store4 = SnippetStore(path=path4)
store4.add(Snippet(name="persist", category="Test", command="echo test"))
reloaded = SnippetStore(path=path4)
assert any(s.name == "persist" for s in reloaded.snippets)
print(" OK")
print()
print("=== 6. Terminal : Inserer n'envoie jamais Entree ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = LocalTerminalTabWidget()
snippet = Snippet(name="test", command="echo bonjour")
tab.insert_snippet(snippet)
sent = tab.connection._out_queue.get_nowait()
print(" envoye:", sent)
assert sent == b"echo bonjour"
assert not sent.endswith(b"\r") and not sent.endswith(b"\n")
tab.close_connection()
print(" OK")
print()
print("=== 7. Terminal : Executer envoie la commande suivie d'Entree ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = LocalTerminalTabWidget()
tab.execute_snippet(Snippet(name="test", command="echo bonjour"))
sent = tab.connection._out_queue.get_nowait()
print(" envoye:", sent)
assert sent == b"echo bonjour\r"
tab.close_connection()
print(" OK")
print()
print("=== 8. Terminal : Executer avec Multi-Send atteint tous les terminaux ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tabs = [LocalTerminalTabWidget() for _ in range(3)]
broadcast = BroadcastManager(lambda: tabs)
for t in tabs:
broadcast.register(t)
t.broadcast = broadcast # comme le ferait ConnectionController.new_local_terminal()
tabs[0].execute_snippet(Snippet(name="test", command="uptime"), multi=True)
received = [t.connection._out_queue.get_nowait() for t in tabs]
print(" recu par chaque terminal:", received)
assert all(r == b"uptime\r" for r in received), received
for t in tabs:
t.close_connection()
print(" OK")
print()
print("=== 9. Menu Snippets : hierarchie categorie > snippet > actions, et declenchement ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab2 = LocalTerminalTabWidget()
tab2.snippet_store = SnippetStore(path=tmp / "snippets_menu.json")
tab2.snippet_store.snippets = [Snippet(name="Ping", category="Reseau", command="ping 1.1.1.1")]
tab2.broadcast = BroadcastManager(lambda: [tab2])
menu = tab2._build_snippets_menu() # ne fait qu'assembler le menu, ne l'affiche pas (pas de exec())
categories = [a.text() for a in menu.actions() if a.menu() is not None]
print(" categories dans le menu:", categories)
assert "Reseau" in categories
reseau_menu = next(a.menu() for a in menu.actions() if a.text() == "Reseau")
ping_menu = next(a.menu() for a in reseau_menu.actions() if a.text() == "Ping")
action_labels = [a.text() for a in ping_menu.actions()]
print(" actions du snippet:", action_labels)
assert action_labels == ["Inserer dans le terminal", "Executer", "Executer avec Multi-Send"]
insert_action = next(a for a in ping_menu.actions() if a.text() == "Inserer dans le terminal")
insert_action.trigger()
sent = tab2.connection._out_queue.get_nowait()
assert sent == b"ping 1.1.1.1", sent
print(" declenchement 'Inserer' -> commande envoyee sans Entree : OK")
tab2.close_connection()
print(" OK")
print()
print("SNIPPETS : TOUS LES TESTS OK")

123
tests/test_socks_tunnel.py Normal file
View File

@@ -0,0 +1,123 @@
"""Tunnel dynamique (-D) : verifie que le proxy SOCKS5 / SOCKS4a negocie
correctement la destination demandee par le client puis relaie le trafic a
travers un (faux) transport SSH -- sans connexion SSH reelle, comme le fait
deja test_tunnels.py pour la redirection locale."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import socket
import threading
import time
from app.tunnels import DIRECTION_DYNAMIC, TunnelManager
class EchoServer(threading.Thread):
"""Serveur de destination : renvoie ce qu'il recoit, prefixe."""
def __init__(self):
super().__init__(daemon=True)
self.sock = socket.socket()
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(("127.0.0.1", 0))
self.sock.listen(5)
self.port = self.sock.getsockname()[1]
def run(self):
while True:
try:
conn, _ = self.sock.accept()
except OSError:
return
data = conn.recv(1024)
conn.sendall(b"echo:" + data)
conn.close()
class FakeTransport:
"""Imite paramiko.Transport : ouvre un vrai socket vers la destination negociee."""
def open_channel(self, kind, dest, src):
assert kind == "direct-tcpip"
return socket.create_connection(dest, timeout=5)
def socks5_connect(proxy_port: int, host: str, port: int) -> socket.socket:
sock = socket.create_connection(("127.0.0.1", proxy_port), timeout=5)
sock.sendall(b"\x05\x01\x00") # version 5, 1 methode proposee : "sans authentification"
assert sock.recv(2) == b"\x05\x00", "methode 'sans authentification' attendue"
sock.sendall(b"\x05\x01\x00\x01" + socket.inet_aton(host) + port.to_bytes(2, "big"))
reply = sock.recv(10)
assert reply[0:2] == b"\x05\x00", f"CONNECT refuse : {reply}"
return sock
def socks4a_connect(proxy_port: int, host: str, port: int) -> socket.socket:
sock = socket.create_connection(("127.0.0.1", proxy_port), timeout=5)
# IP 0.0.0.1 = extension SOCKS4a : le nom suit l'identifiant utilisateur
sock.sendall(
b"\x04\x01" + port.to_bytes(2, "big") + b"\x00\x00\x00\x01"
+ b"user\x00" + host.encode("ascii") + b"\x00"
)
reply = sock.recv(8)
assert reply[0:2] == b"\x00\x5a", f"CONNECT refuse : {reply}"
return sock
echo = EchoServer()
echo.start()
print(f"serveur de destination (echo) sur le port {echo.port}")
manager = TunnelManager(FakeTransport())
tunnel = manager.start({"direction": DIRECTION_DYNAMIC, "listen_port": 0})
assert tunnel.error is None, tunnel.error
proxy_port = tunnel._server.server_address[1]
print("proxy SOCKS ouvert sur le port", proxy_port)
time.sleep(0.2)
print()
print("=== 1. SOCKS5, adresse IPv4 ===")
client = socks5_connect(proxy_port, "127.0.0.1", echo.port)
client.sendall(b"bonjour-socks5")
reponse = client.recv(1024)
client.close()
print(" recu:", reponse)
assert reponse == b"echo:bonjour-socks5", reponse
print()
print("=== 2. SOCKS4a, nom de domaine ===")
client = socks4a_connect(proxy_port, "localhost", echo.port)
client.sendall(b"bonjour-socks4a")
reponse = client.recv(1024)
client.close()
print(" recu:", reponse)
assert reponse == b"echo:bonjour-socks4a", reponse
print()
print("=== 3. SOCKS5 : commande non supportee (BIND) rejetee proprement ===")
sock = socket.create_connection(("127.0.0.1", proxy_port), timeout=5)
sock.sendall(b"\x05\x01\x00")
assert sock.recv(2) == b"\x05\x00"
sock.sendall(b"\x05\x02\x00\x01" + socket.inet_aton("127.0.0.1") + (1234).to_bytes(2, "big")) # BIND
reply = sock.recv(10)
print(" reponse:", reply)
assert reply[1] == 0x07, reply # 0x07 = commande non supportee
sock.close()
print(" OK")
print()
print("=== 4. Fermeture du tunnel : le port SOCKS n'accepte plus de connexion ===")
manager.stop_all()
time.sleep(0.3)
try:
socket.create_connection(("127.0.0.1", proxy_port), timeout=1).close()
ferme = False
except OSError:
ferme = True
assert ferme, "le port doit etre libere apres fermeture"
print(" OK")
print()
print("SOCKS DYNAMIQUE (-D) : TOUS LES TESTS OK")

View File

@@ -0,0 +1,125 @@
"""Split terminal : diviser un onglet en 2/4 panneaux, deplacer un onglet dans
un panneau, fermer un panneau (redescente automatique a un onglet simple),
fermeture globale d'un onglet divise.
SSHConnection.start() et LocalTerminalConnection.start() sont neutralisees :
ce module teste l'agencement des panneaux et le cycle de vie des onglets, pas
une vraie connexion (deja couverte par test_keepalive.py / test_bastion.py)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import itertools
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QWidget
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.connection_controller import ConnectionController
from app.key_manager import KeyManager
from app.local_terminal import LocalTerminalConnection
from app.session_manager import SessionStore
from app.ssh_connection import SSHConnection
from app.ui.split_pane import LAYOUT_2V, LAYOUT_4, SplitContainer
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
_counter = itertools.count()
no_ssh_thread = patch.object(SSHConnection, "start", lambda self: None)
no_local_thread = patch.object(LocalTerminalConnection, "start", lambda self: None)
no_ssh_thread.start()
no_local_thread.start()
def make_controller() -> ConnectionController:
n = next(_counter)
session_store = SessionStore(path=tmp / f"sessions_{n}.json")
key_manager = KeyManager(registry_path=tmp / f"keys_{n}.json")
host_widget = QWidget()
broadcast = BroadcastManager(lambda: [host_widget])
return ConnectionController(session_store, key_manager, broadcast, parent_widget=host_widget)
print("=== 1. Diviser un onglet en 2 : le contenu original occupe le 1er panneau ===")
cc = make_controller()
cc.open_local_terminal()
original_widget = cc.tabs.widget(0)
cc.split_tab(0, LAYOUT_2V)
container = cc.tabs.widget(0)
print(" type de l'onglet apres division:", type(container).__name__)
assert isinstance(container, SplitContainer)
assert len(container.panes) == 2
assert container.panes[0].content is original_widget
assert cc.tabs.count() == 1, "la division ne doit pas creer de nouvel onglet de premier niveau"
print(" OK")
print()
print("=== 2. Diviser en 4 panneaux ===")
cc2 = make_controller()
cc2.open_local_terminal()
cc2.split_tab(0, LAYOUT_4)
container2 = cc2.tabs.widget(0)
assert len(container2.panes) == 4
print(" 4 panneaux crees : OK")
print()
print("=== 3. Deplacer un onglet existant dans un panneau (sans fermer sa connexion) ===")
cc3 = make_controller()
cc3.open_local_terminal() # onglet 0 -> deviendra le split
cc3.open_local_terminal() # onglet 1 -> sera deplace dans un panneau
moved_widget = cc3.tabs.widget(1)
assert cc3.tabs.count() == 2
cc3.split_tab(0, LAYOUT_2V)
container3 = cc3.tabs.widget(0)
assert cc3.tabs.count() == 2 # l'onglet 1 (independant) existe toujours
target_pane = container3.panes[1]
filler_before_move = target_pane.content
cc3.move_tab_into_pane(1, container3, target_pane)
print(" onglets de premier niveau restants:", cc3.tabs.count())
assert cc3.tabs.count() == 1, "l'onglet deplace ne doit plus etre un onglet independant"
assert target_pane.content is moved_widget
assert filler_before_move is not moved_widget
print(" OK")
print()
print("=== 4. Fermer un panneau (sur 2) -> redescente automatique a un onglet simple ===")
cc4 = make_controller()
cc4.open_local_terminal()
original4 = cc4.tabs.widget(0)
cc4.split_tab(0, LAYOUT_2V)
container4 = cc4.tabs.widget(0)
other_pane = container4.panes[1]
other_pane.close_requested.emit(other_pane)
print(" type de l'onglet apres fermeture d'un panneau:", type(cc4.tabs.widget(0)).__name__)
assert cc4.tabs.count() == 1
assert cc4.tabs.widget(0) is original4, "le panneau restant doit redevenir l'onglet normal"
assert cc4.tabs.tabText(0).startswith("💻"), cc4.tabs.tabText(0)
print(" OK")
print()
print("=== 5. Fermer un onglet divise (bouton x) ferme toutes les connexions ===")
cc5 = make_controller()
cc5.open_local_terminal()
cc5.split_tab(0, LAYOUT_2V)
container5 = cc5.tabs.widget(0)
panes_before = list(container5.panes)
closed_flags = []
for pane in panes_before:
original_close = pane.content.close_connection
pane.content.close_connection = lambda o=original_close: (o(), closed_flags.append(True))
cc5._on_tab_close(0)
print(" connexions fermees:", len(closed_flags), "/", len(panes_before))
assert len(closed_flags) == len(panes_before)
assert cc5.tabs.count() == 0
print(" OK")
no_ssh_thread.stop()
no_local_thread.stop()
print()
print("SPLIT TERMINAL : TOUS LES TESTS OK")

View File

@@ -0,0 +1,95 @@
"""Detection de la cible ssh "en cours" dans un terminal local : signale par
l'utilisateur (bouton "SFTP sur cette connexion" revenant a un formulaire
vide alors qu'une session ssh etait bien active a l'ecran).
Cause reelle : detect_ssh_target() ne scrutait le texte du terminal qu'au
moment du clic, en repartant du buffer/historique ACTUEL - si la ligne
`ssh user@hote` tapee par l'utilisateur en etait sortie entre-temps (beaucoup
de sortie, ecran efface...), plus rien n'etait detectable, meme si la
connexion ssh etait toujours active. Corrige en memorisant la cible des
qu'elle apparait dans la sortie (BaseTerminalTab._track_ssh_target), pour ne
plus dependre de ce qui est encore present dans le buffer au moment du clic.
Important : on alimente le terminal via connection.data_received.emit(...),
pas terminal.feed(...) directement - _track_ssh_target est cablee sur ce
signal (comme terminal.feed lui-meme), l'appeler directement contournerait le
suivi et ne testerait que le repli (deja fonctionnel avant ce correctif)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from unittest.mock import patch
from PySide6.QtWidgets import QApplication
from app.local_terminal import LocalTerminalConnection
from app.ui.tabs import LocalTerminalTabWidget
app = QApplication.instance() or QApplication(sys.argv)
def make_tab():
return LocalTerminalTabWidget()
def feed(tab, data: bytes) -> None:
tab.connection.data_received.emit(data)
print("=== 1. Une commande ssh tapee est memorisee des qu'elle apparait a l'ecran ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
assert tab.detect_current_ssh_target() is None
feed(tab, b"user@PC MINGW64 ~\r\n$ ssh root@debian\r\n")
target = tab.detect_current_ssh_target()
print(" cible detectee:", target)
assert target == {"user": "root", "host": "debian", "port": 22}
tab.close_connection()
print(" OK")
print()
print("=== 2. La cible reste memorisee meme si la commande disparait du buffer ensuite ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
feed(tab, b"$ ssh root@debian\r\n")
assert tab._last_ssh_target == {"user": "root", "host": "debian", "port": 22}
# simule la perte de la ligne d'origine (beaucoup de sortie, ecran efface...) :
# buffer_text() ne renvoie plus rien d'exploitable, mais la cible reste connue
with patch.object(type(tab.terminal), "buffer_text", return_value="root@debian:~# "):
target = tab.detect_current_ssh_target()
print(" cible apres 'perte' du buffer:", target)
assert target == {"user": "root", "host": "debian", "port": 22}
tab.close_connection()
print(" OK")
print()
print("=== 3. Une nouvelle commande ssh remplace bien l'ancienne cible memorisee ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
feed(tab, b"$ ssh root@debian\r\n")
assert tab._last_ssh_target["host"] == "debian"
feed(tab, b"logout\r\n$ ssh admin@192.168.1.50\r\n")
target = tab.detect_current_ssh_target()
print(" nouvelle cible:", target)
assert target == {"user": "admin", "host": "192.168.1.50", "port": 22}
tab.close_connection()
print(" OK")
print()
print("=== 4. Bouton 'SFTP sur cette connexion' : emet la cible memorisee, pas un formulaire vide ===")
with patch.object(LocalTerminalConnection, "start", lambda self: None):
tab = make_tab()
feed(tab, b"$ ssh root@debian\r\n")
with patch.object(type(tab.terminal), "buffer_text", return_value="root@debian:~# "):
received = []
tab.sftp_requested.connect(lambda t: received.append(t))
tab.sftp_btn.click()
print(" emis par le bouton:", received)
assert received == [{"user": "root", "host": "debian", "port": 22}]
tab.close_connection()
print(" OK")
print()
print("SSH TARGET TRACKING : TOUS LES TESTS OK")

View File

@@ -0,0 +1,132 @@
"""Tags de session et parametres par defaut d'un groupe : persistance, saisie/
normalisation des tags dans l'editeur, heritage explicite, filtrage par tag
dans le panneau lateral."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import tempfile
from pathlib import Path
from unittest.mock import patch
from PySide6.QtWidgets import QApplication, QWidget
from app.controllers.broadcast_manager import BroadcastManager
from app.controllers.session_controller import SessionController
from app.dialogs.session_dialog import SessionDialog
from app.groups import GroupSettings, GroupSettingsStore
from app.key_manager import KeyManager
from app.session_manager import AUTH_KEY, Session, SessionStore
app = QApplication.instance() or QApplication([])
tmp = Path(tempfile.mkdtemp())
absent_ssh_config = tmp / "no-such-ssh-config"
print("=== 1. Tags : persistance via SessionStore ===")
store = SessionStore(path=tmp / "sessions.json")
session = Session(name="web1", host="10.0.0.1", username="root", tags=["prod", "docker"])
store.add(session)
reloaded = SessionStore(path=store.path)
assert reloaded.get(session.id).tags == ["prod", "docker"]
print(" OK")
print()
print("=== 2. SessionStore.tags() : ensemble trie de tous les tags connus ===")
store.add(Session(name="web2", host="10.0.0.2", username="root", tags=["prod", "linux"]))
print(" tags:", store.tags())
assert store.tags() == ["docker", "linux", "prod"]
print(" OK")
print()
print("=== 3. GroupSettingsStore : CRUD et persistance ===")
gstore = GroupSettingsStore(path=tmp / "groups.json")
settings = GroupSettings(path="Production/Web", username="deploy", port=2222)
gstore.set(settings)
assert gstore.get("Production/Web").username == "deploy"
reloaded_gstore = GroupSettingsStore(path=gstore.path)
assert reloaded_gstore.get("Production/Web").port == 2222
settings.username = "admin"
gstore.set(settings) # met a jour (meme path), ne duplique pas
assert len(gstore.groups) == 1
assert gstore.get("Production/Web").username == "admin"
gstore.delete("Production/Web")
assert gstore.get("Production/Web") is None
print(" OK")
print()
print("=== 4. SessionDialog : normalisation des tags saisis ===")
key_manager = KeyManager(registry_path=tmp / "keys.json")
dlg = SessionDialog(key_manager, session=Session(name="t", host="h", username="u"), session_store=store)
dlg.tags_edit.setText(" Prod , docker,, prod , Docker ")
dlg.host_edit.setText("h")
dlg.user_edit.setText("u")
dlg._on_accept()
print(" tags normalises:", dlg.session.tags)
# doublons retires en respectant la casse (et l'ordre) de la premiere occurrence
assert dlg.session.tags == ["Prod", "docker"], dlg.session.tags
print(" OK")
print()
print("=== 5. SessionDialog : heriter du groupe applique les valeurs par defaut ===")
gstore2 = GroupSettingsStore(path=tmp / "groups2.json")
gstore2.set(GroupSettings(path="Homelab", username="admin", port=2200, auth_type=AUTH_KEY))
dlg2 = SessionDialog(key_manager, session=Session(name="t2", host="h", group="Homelab"),
session_store=store, group_store=gstore2)
dlg2._apply_group_defaults()
print(" utilisateur/port apres heritage:", dlg2.user_edit.text(), dlg2.port_spin.value())
assert dlg2.user_edit.text() == "admin"
assert dlg2.port_spin.value() == 2200
assert dlg2.auth_combo.currentData() == AUTH_KEY
print(" OK")
print()
print("=== 6. SessionDialog : heriter d'un groupe sans parametres -> message, pas de crash ===")
dlg3 = SessionDialog(key_manager, session=Session(name="t3", host="h", group="Inconnu"),
session_store=store, group_store=gstore2)
with patch("app.dialogs.session_dialog.QMessageBox.information") as info:
dlg3._apply_group_defaults()
assert info.called
print(" OK")
print()
print("=== 7. Filtrage par tag dans le panneau lateral (masque les sessions non concernees) ===")
store3 = SessionStore(path=tmp / "sessions3.json")
store3.add(Session(name="avec-tag", host="10.0.0.9", username="root", tags=["special"]))
store3.add(Session(name="sans-tag", host="10.0.0.10", username="root"))
host_widget = QWidget()
broadcast = BroadcastManager(lambda: [host_widget])
sc = SessionController(store3, key_manager, broadcast.button, parent_widget=host_widget,
openssh_config_path=absent_ssh_config)
def find_item(name):
def visit(item):
if item.text(0).strip().startswith(f"🖥 {name}"):
return item
for i in range(item.childCount()):
found = visit(item.child(i))
if found:
return found
return None
for i in range(sc.tree.topLevelItemCount()):
found = visit(sc.tree.topLevelItem(i))
if found:
return found
return None
sc.tag_filter_combo.setCurrentText("special")
tagged_item = find_item("avec-tag")
untagged_item = find_item("sans-tag")
print(" 'avec-tag' masque:", tagged_item.isHidden(), "| 'sans-tag' masque:", untagged_item.isHidden())
assert not tagged_item.isHidden()
assert untagged_item.isHidden()
sc.tag_filter_combo.setCurrentText("")
assert not find_item("sans-tag").isHidden()
print(" OK")
print()
print("TAGS ET GROUPES : TOUS LES TESTS OK")

View File

@@ -1,10 +1,12 @@
"""Confort du terminal : zoom de police, barre de defilement, recherche dans l'historique."""
import sys, pathlib
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QKeyEvent
from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QApplication
from app.terminal_widget import TerminalWidget

View File

@@ -4,14 +4,16 @@ Le test monte un vrai serveur TCP local et un faux transport SSH dont le canal
'direct-tcpip' est un simple socket vers ce serveur : cela valide toute la
mecanique de relais sans avoir besoin d'un serveur SSH.
"""
import sys, pathlib
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
import socket
import threading
import time
from app.tunnels import TunnelManager, DIRECTION_LOCAL, describe
from app.tunnels import DIRECTION_LOCAL, TunnelManager, describe
class EchoServer(threading.Thread):

Some files were not shown because too many files have changed in this diff Show More