Compare commits
24 Commits
f641357073
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa890c5847 | ||
|
|
ef059647fa | ||
|
|
a65b86e25b | ||
|
|
add1073e37 | ||
|
|
f5d99584fd | ||
|
|
6cceb3f3b2 | ||
|
|
c39ef9c038 | ||
|
|
09b7ed092c | ||
|
|
0b2ff38bfd | ||
|
|
9216f636bd | ||
|
|
b615ed52af | ||
|
|
ff4a73e6e6 | ||
|
|
0a07ddcb62 | ||
|
|
a3c72ade0b | ||
|
|
5917e1ca04 | ||
|
|
9c8017cebd | ||
|
|
fe938a47b0 | ||
|
|
c2bde19f59 | ||
|
|
c08037a638 | ||
|
|
5fb965d7c2 | ||
|
|
d212e6ab44 | ||
|
|
23f5aaccd1 | ||
|
|
fd848c2025 | ||
|
|
47005b1e26 |
76
.gitea/workflows/ci.yml
Normal file
76
.gitea/workflows/ci.yml
Normal 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
10
.gitignore
vendored
@@ -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
246
README.md
@@ -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
0
app/ai/__init__.py
Normal file
37
app/ai/danger.py
Normal file
37
app/ai/danger.py
Normal 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
154
app/ai/provider.py
Normal 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
52
app/ai/redaction.py
Normal 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
54
app/ai/service.py
Normal 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
85
app/ai/settings.py
Normal 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
32
app/app_settings.py
Normal 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
88
app/bastion.py
Normal 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}")
|
||||
0
app/controllers/__init__.py
Normal file
0
app/controllers/__init__.py
Normal file
56
app/controllers/broadcast_manager.py
Normal file
56
app/controllers/broadcast_manager.py
Normal 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)
|
||||
364
app/controllers/connection_controller.py
Normal file
364
app/controllers/connection_controller.py
Normal 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)
|
||||
390
app/controllers/session_controller.py
Normal file
390
app/controllers/session_controller.py
Normal 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()
|
||||
184
app/controllers/workspace_manager.py
Normal file
184
app/controllers/workspace_manager.py
Normal 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
128
app/credentials.py
Normal 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()
|
||||
85
app/dialogs/ai_command_dialog.py
Normal file
85
app/dialogs/ai_command_dialog.py
Normal 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
70
app/dialogs/ai_prompts.py
Normal 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
|
||||
213
app/dialogs/ai_settings_dialog.py
Normal file
213
app/dialogs/ai_settings_dialog.py
Normal 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)
|
||||
232
app/dialogs/group_settings_dialog.py
Normal file
232
app/dialogs/group_settings_dialog.py
Normal 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()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
196
app/dialogs/quick_connect_dialog.py
Normal file
196
app/dialogs/quick_connect_dialog.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
181
app/dialogs/snippet_dialog.py
Normal file
181
app/dialogs/snippet_dialog.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
142
app/dialogs/workspace_dialog.py
Normal file
142
app/dialogs/workspace_dialog.py
Normal 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
93
app/groups.py
Normal 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()
|
||||
@@ -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("=")
|
||||
|
||||
@@ -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
0
app/openssh/__init__.py
Normal file
112
app/openssh/config.py
Normal file
112
app/openssh/config.py
Normal 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
0
app/security/__init__.py
Normal file
57
app/security/key_protection.py
Normal file
57
app/security/key_protection.py
Normal 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
0
app/services/__init__.py
Normal file
35
app/services/fuzzy.py
Normal file
35
app/services/fuzzy.py
Normal 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
12
app/services/reconnect.py
Normal 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)
|
||||
55
app/services/ssh_target.py
Normal file
55
app/services/ssh_target.py
Normal 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
|
||||
@@ -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
0
app/sftp/__init__.py
Normal file
60
app/sftp/worker.py
Normal file
60
app/sftp/worker.py
Normal 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)
|
||||
@@ -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
118
app/snippets.py
Normal 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()
|
||||
]
|
||||
@@ -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
0
app/storage/__init__.py
Normal file
24
app/storage/atomic_file.py
Normal file
24
app/storage/atomic_file.py
Normal 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
81
app/storage/json_store.py
Normal 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"))
|
||||
@@ -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).
|
||||
|
||||
54
app/theme.py
54
app/theme.py
@@ -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:
|
||||
|
||||
127
app/tunnels.py
127
app/tunnels.py
@@ -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
0
app/ui/__init__.py
Normal file
178
app/ui/ai_panel.py
Normal file
178
app/ui/ai_panel.py
Normal 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)
|
||||
31
app/ui/auto_height_stack.py
Normal file
31
app/ui/auto_height_stack.py
Normal 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
187
app/ui/split_pane.py
Normal 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
824
app/ui/tabs.py
Normal 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
123
app/workspace.py
Normal 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
61
pyproject.toml
Normal 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
5
requirements-dev.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
pytest-qt>=4.4
|
||||
ruff>=0.6
|
||||
pyright>=1.1
|
||||
@@ -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"
|
||||
|
||||
@@ -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
16
tests/conftest.py
Normal 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"]
|
||||
@@ -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
|
||||
|
||||
67
tests/test_ai_command_dialog.py
Normal file
67
tests/test_ai_command_dialog.py
Normal 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
59
tests/test_ai_danger.py
Normal 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
170
tests/test_ai_panel.py
Normal 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
104
tests/test_ai_prompts.py
Normal 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
159
tests/test_ai_provider.py
Normal 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")
|
||||
62
tests/test_ai_redaction.py
Normal file
62
tests/test_ai_redaction.py
Normal 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
176
tests/test_ai_service.py
Normal 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
107
tests/test_ai_settings.py
Normal 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")
|
||||
127
tests/test_ai_settings_dialog.py
Normal file
127
tests/test_ai_settings_dialog.py
Normal 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")
|
||||
202
tests/test_ai_tab_integration.py
Normal file
202
tests/test_ai_tab_integration.py
Normal 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
50
tests/test_all_scripts.py
Normal 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,
|
||||
)
|
||||
118
tests/test_atomic_storage.py
Normal file
118
tests/test_atomic_storage.py
Normal 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")
|
||||
127
tests/test_auto_reconnect.py
Normal file
127
tests/test_auto_reconnect.py
Normal 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
186
tests/test_bastion.py
Normal 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")
|
||||
@@ -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
166
tests/test_credentials.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
46
tests/test_fuzzy.py
Normal 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
114
tests/test_keepalive.py
Normal 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")
|
||||
91
tests/test_key_protection.py
Normal file
91
tests/test_key_protection.py
Normal 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")
|
||||
@@ -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())
|
||||
|
||||
91
tests/test_no_secrets_in_errors.py
Normal file
91
tests/test_no_secrets_in_errors.py
Normal 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")
|
||||
108
tests/test_openssh_config.py
Normal file
108
tests/test_openssh_config.py
Normal 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
146
tests/test_quick_connect.py
Normal 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")
|
||||
34
tests/test_reconnect_backoff.py
Normal file
34
tests/test_reconnect_backoff.py
Normal 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")
|
||||
134
tests/test_session_dialog_bastion.py
Normal file
134
tests/test_session_dialog_bastion.py
Normal 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")
|
||||
125
tests/test_session_dialog_compact.py
Normal file
125
tests/test_session_dialog_compact.py
Normal 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")
|
||||
@@ -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
150
tests/test_snippets.py
Normal 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
123
tests/test_socks_tunnel.py
Normal 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")
|
||||
125
tests/test_split_terminal.py
Normal file
125
tests/test_split_terminal.py
Normal 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")
|
||||
95
tests/test_ssh_target_tracking.py
Normal file
95
tests/test_ssh_target_tracking.py
Normal 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")
|
||||
132
tests/test_tags_and_groups.py
Normal file
132
tests/test_tags_and_groups.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user