Client SSH/SFTP personnel pour Windows

Reunit en un seul outil les fonctions utiles de MobaXterm, Termius et Bitvise,
sans compte ni service tiers, avec toutes les donnees en local.

- Terminaux multi-onglets : sessions SSH et terminaux Windows locaux
  (PowerShell / cmd) via la pseudo-console native ConPTY
- Gestionnaire de fichiers SFTP facon WinSCP : double panneau, colonnes
  triables, glisser-deposer, renommage, permissions, file de transferts.
  Ouvrable sur une connexion en cours sans creer de favori
- Gestion des cles SSH : generation Ed25519/RSA, import, export
- Gestion du fichier known_hosts, avec remplacement guide d'une cle d'hote
  modifiee (cas frequent des serveurs en DHCP)
- Trois themes (clair, bleu nuit, noir), commutables a chaud
- Authentification automatique reprenant le comportement de la commande ssh
  (agent SSH et cles ~/.ssh), mot de passe demande seulement en secours

Aucun mot de passe n'est stocke sur le disque.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
snoopy
2026-09-04 06:50:10 +02:00
commit b977842186
28 changed files with 3946 additions and 0 deletions

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

View File

@@ -0,0 +1,42 @@
"""Dialogues utilises au moment de la connexion : saisie de secret et confirmation de cle d'hote."""
from __future__ import annotations
from PySide6.QtWidgets import QInputDialog, QLineEdit, QMessageBox, QWidget
def ask_secret(parent: QWidget, title: str, label: str) -> tuple[str, bool]:
text, ok = QInputDialog.getText(parent, title, label, QLineEdit.Password)
return text, ok
def confirm_host_key(parent: QWidget, hostname: str, key_type: str, fingerprint: str) -> bool:
msg = (
f"L'empreinte de la cle du serveur '{hostname}' n'est pas connue.\n\n"
f"Type : {key_type}\n"
f"Empreinte : {fingerprint}\n\n"
"Verifiez cette empreinte aupres de l'administrateur du serveur si vous n'etes pas certain.\n"
"Faire confiance a cette cle et se connecter ?"
)
answer = QMessageBox.question(
parent, "Nouvelle cle d'hote detectee", msg,
QMessageBox.Yes | QMessageBox.No, QMessageBox.No,
)
return answer == QMessageBox.Yes
def confirm_host_key_change(parent: QWidget, hostname: str, key_type: str, old_fingerprint: str, new_fingerprint: str) -> bool:
msg = (
f"La cle presentee par le serveur '{hostname}' ne correspond plus a celle enregistree.\n\n"
f"Ancienne empreinte : {old_fingerprint}\n"
f"Nouvelle empreinte : {new_fingerprint}\n\n"
"C'est generalement normal si ce serveur a une adresse IP attribuee par DHCP et "
"qu'elle a ete reutilisee par une autre machine, ou si le serveur a ete reinstalle. "
"Mais cela peut aussi indiquer une interception de la connexion (attaque de type "
"'man-in-the-middle') : verifiez la nouvelle empreinte si vous avez un doute.\n\n"
"Remplacer l'ancienne cle enregistree et se connecter ?"
)
answer = QMessageBox.warning(
parent, "Cle d'hote modifiee", msg,
QMessageBox.Yes | QMessageBox.No, QMessageBox.No,
)
return answer == QMessageBox.Yes

198
app/dialogs/key_dialog.py Normal file
View File

@@ -0,0 +1,198 @@
"""Gestionnaire de cles SSH : generation, import, export."""
from __future__ import annotations
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,
)
from app.key_manager import KeyManager, KEY_TYPE_ED25519, KEY_TYPE_RSA
class GenerateKeyDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Generer une nouvelle cle SSH")
layout = QVBoxLayout(self)
form = QFormLayout()
self.name_edit = QLineEdit()
self.type_combo = QComboBox()
self.type_combo.addItem("Ed25519 (recommande)", KEY_TYPE_ED25519)
self.type_combo.addItem("RSA", KEY_TYPE_RSA)
self.bits_spin = QSpinBox()
self.bits_spin.setRange(2048, 8192)
self.bits_spin.setSingleStep(1024)
self.bits_spin.setValue(4096)
self.passphrase_edit = QLineEdit()
self.passphrase_edit.setEchoMode(QLineEdit.Password)
self.comment_edit = QLineEdit()
form.addRow("Nom :", self.name_edit)
form.addRow("Type :", self.type_combo)
form.addRow("Taille (RSA) :", self.bits_spin)
form.addRow("Passphrase (optionnel) :", self.passphrase_edit)
form.addRow("Commentaire :", self.comment_edit)
layout.addLayout(form)
self.type_combo.currentIndexChanged.connect(
lambda: self.bits_spin.setEnabled(self.type_combo.currentData() == KEY_TYPE_RSA)
)
self.bits_spin.setEnabled(False)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def values(self) -> dict:
return dict(
name=self.name_edit.text().strip() or "cle_ssh",
key_type=self.type_combo.currentData(),
rsa_bits=self.bits_spin.value(),
passphrase=self.passphrase_edit.text(),
comment=self.comment_edit.text().strip(),
)
class KeyManagerDialog(QDialog):
def __init__(self, key_manager: KeyManager, parent=None):
super().__init__(parent)
self.key_manager = key_manager
self.setWindowTitle("Gestionnaire de cles SSH")
self.setMinimumSize(560, 360)
layout = QVBoxLayout(self)
self.list = QListWidget()
layout.addWidget(self.list)
self.info_label = QLabel("")
self.info_label.setWordWrap(True)
layout.addWidget(self.info_label)
btn_row = QHBoxLayout()
self.generate_btn = QPushButton("Generer...")
self.import_btn = QPushButton("Importer...")
self.export_pub_btn = QPushButton("Exporter cle publique...")
self.export_priv_btn = QPushButton("Exporter cle privee...")
self.copy_pub_btn = QPushButton("Copier cle publique")
self.remove_btn = QPushButton("Supprimer")
for b in (self.generate_btn, self.import_btn, self.export_pub_btn,
self.export_priv_btn, self.copy_pub_btn, self.remove_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.generate_btn.clicked.connect(self.generate_key)
self.import_btn.clicked.connect(self.import_key)
self.export_pub_btn.clicked.connect(self.export_public)
self.export_priv_btn.clicked.connect(self.export_private)
self.copy_pub_btn.clicked.connect(self.copy_public)
self.remove_btn.clicked.connect(self.remove_key)
self.list.currentItemChanged.connect(self._update_info)
self.refresh()
def refresh(self) -> None:
self.list.clear()
for entry in self.key_manager.keys:
item = QListWidgetItem(f"{entry.name} [{entry.key_type}]")
item.setData(Qt.UserRole, entry.id)
self.list.addItem(item)
self.info_label.setText("")
def _current_entry(self):
item = self.list.currentItem()
if not item:
return None
key_id = item.data(Qt.UserRole)
return next((k for k in self.key_manager.keys if k.id == key_id), None)
def _update_info(self) -> None:
entry = self._current_entry()
if not entry:
self.info_label.setText("")
return
self.info_label.setText(
f"Chemin : {entry.private_path}\nEmpreinte : {entry.fingerprint}\n\n{entry.public_key_text}"
)
def generate_key(self) -> None:
dlg = GenerateKeyDialog(self)
if dlg.exec() != QDialog.Accepted:
return
vals = dlg.values()
try:
self.key_manager.generate_key(**vals)
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Erreur", str(exc))
return
self.refresh()
def import_key(self) -> None:
priv_path, _ = QFileDialog.getOpenFileName(self, "Selectionner la cle privee a importer")
if not priv_path:
return
name = Path(priv_path).stem
try:
self.key_manager.import_key(name=name, private_key_path=priv_path)
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "Erreur", str(exc))
return
self.refresh()
def export_public(self) -> None:
entry = self._current_entry()
if not entry:
return
dest, _ = QFileDialog.getSaveFileName(self, "Exporter la cle publique", f"{entry.name}.pub")
if dest:
self.key_manager.export_public_key(entry, dest)
QMessageBox.information(self, "Export", "Cle publique exportee.")
def export_private(self) -> None:
entry = self._current_entry()
if not entry:
return
if QMessageBox.warning(
self, "Attention",
"La cle privee est sensible : quiconque y a acces peut se connecter a vos serveurs. Continuer ?",
QMessageBox.Yes | QMessageBox.No,
) != QMessageBox.Yes:
return
dest, _ = QFileDialog.getSaveFileName(self, "Exporter la cle privee", entry.name)
if dest:
self.key_manager.export_private_key(entry, dest)
QMessageBox.information(self, "Export", "Cle privee exportee.")
def copy_public(self) -> None:
entry = self._current_entry()
if not entry:
return
QApplication.clipboard().setText(entry.public_key_text)
QMessageBox.information(self, "Copie", "Cle publique copiee dans le presse-papiers.")
def remove_key(self) -> None:
entry = self._current_entry()
if not entry:
return
delete_files = QMessageBox.question(
self, "Supprimer",
"Retirer aussi les fichiers de cle du disque (irreversible) ?\n"
"Non = seulement retirer de la liste.",
QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
)
if delete_files == QMessageBox.Cancel:
return
self.key_manager.remove(entry.id, delete_files=(delete_files == QMessageBox.Yes))
self.refresh()

View File

@@ -0,0 +1,95 @@
"""Gestionnaire des hotes SSH connus (fichier known_hosts local)."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem, QPushButton,
QMessageBox, QLabel, QAbstractItemView,
)
from app.known_hosts_manager import KnownHostsStore
class KnownHostsDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.store = KnownHostsStore()
self.setWindowTitle("Gestionnaire des hotes connus")
self.setMinimumSize(560, 380)
layout = QVBoxLayout(self)
info = QLabel(
"Cles des serveurs approuvees lors d'une premiere connexion. Si un serveur change "
"d'adresse (ex. reattribution DHCP), l'application vous proposera automatiquement de "
"remplacer son entree au moment de la connexion. Vous pouvez aussi nettoyer manuellement ici."
)
info.setWordWrap(True)
layout.addWidget(info)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Hote", "Type de cle", "Empreinte"])
self.tree.setRootIsDecorated(False)
self.tree.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.tree.setColumnWidth(0, 200)
self.tree.setColumnWidth(1, 110)
layout.addWidget(self.tree)
self.count_label = QLabel("")
self.count_label.setStyleSheet("color: #93a4c2;")
layout.addWidget(self.count_label)
btn_row = QHBoxLayout()
self.refresh_btn = QPushButton("Actualiser")
self.remove_btn = QPushButton("Supprimer la selection")
self.clear_btn = QPushButton("Tout effacer")
btn_row.addWidget(self.refresh_btn)
btn_row.addWidget(self.remove_btn)
btn_row.addWidget(self.clear_btn)
btn_row.addStretch()
close_btn = QPushButton("Fermer")
close_btn.clicked.connect(self.accept)
btn_row.addWidget(close_btn)
layout.addLayout(btn_row)
self.refresh_btn.clicked.connect(self.refresh)
self.remove_btn.clicked.connect(self.remove_selected)
self.clear_btn.clicked.connect(self.clear_all)
self.refresh()
def refresh(self) -> None:
self.tree.clear()
entries = self.store.list_entries()
for entry in entries:
item = QTreeWidgetItem([entry.hostname, entry.key_type, entry.fingerprint])
item.setData(0, Qt.UserRole, entry.hostname)
self.tree.addTopLevelItem(item)
self.count_label.setText(f"{len(entries)} hote(s) enregistre(s).")
self.remove_btn.setEnabled(bool(entries))
self.clear_btn.setEnabled(bool(entries))
def remove_selected(self) -> None:
items = self.tree.selectedItems()
if not items:
return
hostnames = {item.data(0, Qt.UserRole) for item in items}
if QMessageBox.question(
self, "Supprimer",
f"Retirer {len(hostnames)} hote(s) du fichier known_hosts ?\n"
"Une confirmation d'empreinte sera redemandee a la prochaine connexion.",
) != QMessageBox.Yes:
return
for hostname in hostnames:
self.store.remove_host(hostname)
self.refresh()
def clear_all(self) -> None:
if QMessageBox.question(
self, "Tout effacer",
"Vider entierement le fichier known_hosts ?\n"
"Toutes les connexions redemanderont une confirmation d'empreinte.",
) != QMessageBox.Yes:
return
self.store.clear_all()
self.refresh()

View File

@@ -0,0 +1,151 @@
"""Boite de dialogue de creation/edition d'une session SSH."""
from __future__ import annotations
from PySide6.QtWidgets import (
QDialog, QFormLayout, QLineEdit, QSpinBox, QComboBox, QDialogButtonBox,
QHBoxLayout, QPushButton, QTextEdit, QFileDialog, QWidget, QVBoxLayout,
QCheckBox, QMessageBox,
)
from app.session_manager import Session, AUTH_AUTO, AUTH_KEY, AUTH_PASSWORD, AUTH_AGENT
from app.key_manager import KeyManager
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 = ""):
super().__init__(parent)
self.key_manager = key_manager
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)
layout = QVBoxLayout(self)
form = QFormLayout()
self.name_edit = QLineEdit(self.session.name)
self.group_edit = QLineEdit(self.session.group)
self.group_edit.setPlaceholderText("ex: Production/Web")
self.host_edit = QLineEdit(self.session.host)
self.port_spin = QSpinBox()
self.port_spin.setRange(1, 65535)
self.port_spin.setValue(self.session.port or 22)
self.user_edit = QLineEdit(self.session.username)
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)
self.auth_combo.setToolTip(
"Automatique : utilise l'agent SSH et vos cles ~/.ssh comme le fait la commande ssh,\n"
"et demande un mot de passe seulement si aucune cle ne convient."
)
idx = self.auth_combo.findData(self.session.auth_type)
self.auth_combo.setCurrentIndex(idx if idx >= 0 else 0)
self.key_combo = QComboBox()
self._reload_keys()
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)
self.notes_edit = QTextEdit(self.session.notes)
self.notes_edit.setFixedHeight(60)
form.addRow("Nom :", self.name_edit)
form.addRow("Dossier :", self.group_edit)
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("Notes :", self.notes_edit)
layout.addLayout(form)
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
# enregistrer systematiquement encombre la liste pour rien
self.save_check.setChecked(False)
layout.addWidget(self.save_check)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
if self.quick_mode:
buttons.button(QDialogButtonBox.Ok).setText(connect_label)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self.auth_combo.currentIndexChanged.connect(self._update_key_row)
self.browse_key_btn.clicked.connect(self._browse_key)
self._update_key_row()
if self.session.key_path:
existing = self.key_combo.findData(self.session.key_path)
if existing >= 0:
self.key_combo.setCurrentIndex(existing)
else:
self.key_combo.addItem(self.session.key_path, self.session.key_path)
self.key_combo.setCurrentIndex(self.key_combo.count() - 1)
def _reload_keys(self) -> None:
self.key_combo.clear()
for entry in self.key_manager.keys:
self.key_combo.addItem(f"{entry.name} ({entry.key_type})", entry.private_path)
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:
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)
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
def _on_accept(self) -> None:
if not self.host_edit.text().strip():
QMessageBox.warning(self, "Hote manquant", "Indiquez l'adresse du serveur (nom d'hote ou IP).")
self.host_edit.setFocus()
return
if not self.user_edit.text().strip():
QMessageBox.warning(self, "Utilisateur manquant", "Indiquez le nom d'utilisateur.")
self.user_edit.setFocus()
return
if self.auth_combo.currentData() == AUTH_KEY and not self.key_combo.currentData():
QMessageBox.warning(
self, "Cle manquante",
"Aucune cle privee n'est selectionnee.\n\n"
"Choisissez une cle (bouton « Parcourir... »), ou passez l'authentification "
"sur « Automatique » — qui utilisera l'agent SSH et vos cles ~/.ssh comme le "
"fait la commande ssh.",
)
self.auth_combo.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()
self.session.port = self.port_spin.value()
self.session.username = self.user_edit.text().strip()
self.session.auth_type = self.auth_combo.currentData()
self.session.key_path = self.key_combo.currentData() or ""
self.session.notes = self.notes_edit.toPlainText()
self.accept()
def result_session(self) -> Session:
return self.session