From b3f0a2eecc9929efe740f727b2ee6f0dc7d99f31 Mon Sep 17 00:00:00 2001 From: Johnny Date: Tue, 2 Jun 2026 07:34:09 +0200 Subject: [PATCH] Update --- Makefile | 74 +++++++++++++++++++++++------ README.md | 93 ++++++++++++++++++++++++++++++------- app/core/config.py | 23 +++++---- app/ui/admin_window.py | 29 ++++++++++-- app/ui/history_dialog.py | 2 +- app/ui/main_window.py | 4 +- app/ui/mfa_dialog.py | 39 ++++++++++------ app/utils/platform_utils.py | 65 +++++++++++++++++++++++--- requirements.txt | 1 + 9 files changed, 261 insertions(+), 69 deletions(-) diff --git a/Makefile b/Makefile index c491908..8536142 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ PI_OPTS := \ --hidden-import cryptography.hazmat.primitives.asymmetric.x25519 \ --collect-submodules PyQt6 -.PHONY: all linux windows release install run run-admin icon clean help +.PHONY: all linux windows release install install-gnome uninstall-gnome run run-admin icon reset-password clean help # ── Cible par défaut ──────────────────────────────────────────────────────── all: linux @@ -85,12 +85,57 @@ release: clean linux @mv $(DIST)/$(APP) $(DIST)/$(APP)-$(VERSION)-linux-x86_64 @echo " 📦 Release → $(DIST)/$(APP)-$(VERSION)-linux-x86_64" +# ── Installation GNOME (utilisateur local, sans sudo) ──────────────────────── +INSTALL_BIN := $(HOME)/.local/bin +INSTALL_ICON := $(HOME)/.local/share/icons/hicolor/256x256/apps +INSTALL_APPS := $(HOME)/.local/share/applications + +install-gnome: linux + @echo "" + @echo " 🐧 Installation de WGSecure pour GNOME…" + @mkdir -p $(INSTALL_BIN) $(INSTALL_ICON) $(INSTALL_APPS) + @cp $(DIST)/$(APP) $(INSTALL_BIN)/$(APP) + @chmod +x $(INSTALL_BIN)/$(APP) + @echo " ✅ Binaire → $(INSTALL_BIN)/$(APP)" + @cp $(ICON) $(INSTALL_ICON)/$(APP).png + @echo " ✅ Icône → $(INSTALL_ICON)/$(APP).png" + @printf '[Desktop Entry]\nType=Application\nName=WGSecure\nComment=WireGuard GUI avec MFA TOTP\nExec=%s\nIcon=%s\nCategories=Network;Security;\nStartupWMClass=wgsecure\nTerminal=false\n' \ + "$(INSTALL_BIN)/$(APP)" "$(APP)" \ + > $(INSTALL_APPS)/$(APP).desktop + @echo " ✅ Lanceur → $(INSTALL_APPS)/$(APP).desktop" + @gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true + @update-desktop-database $(INSTALL_APPS) 2>/dev/null || true + @echo "" + @echo " 🎉 Installation terminée. WGSecure est disponible dans le menu GNOME." + @echo "" + +uninstall-gnome: + @echo " 🗑️ Désinstallation de WGSecure…" + @rm -f $(INSTALL_BIN)/$(APP) + @rm -f $(INSTALL_ICON)/$(APP).png + @rm -f $(INSTALL_APPS)/$(APP).desktop + @gtk-update-icon-cache -f -t $(HOME)/.local/share/icons/hicolor 2>/dev/null || true + @update-desktop-database $(INSTALL_APPS) 2>/dev/null || true + @echo " ✅ WGSecure désinstallé" + # ── Dépendances Python ──────────────────────────────────────────────────────── install: $(PYTHON) -m pip install -r requirements.txt $(PYTHON) -m pip install pyinstaller @echo " ✅ Dépendances installées" +# ── Réinitialisation du mot de passe administrateur ────────────────────────── +reset-password: + @echo "" + @echo " ⚠️ Réinitialisation du mot de passe administrateur WGSecure…" + @$(PYTHON) -c "\ +import sys; sys.path.insert(0,'.');\ +from app.core.config import Config;\ +cfg = Config();\ +cfg.set_admin_password('');\ +print(' ✅ Mot de passe supprimé — accès admin sans restriction au prochain démarrage.')" + @echo "" + # ── Lancer l'application ───────────────────────────────────────────────────── run: DISPLAY=:0 $(PYTHON) $(SRC) @@ -109,16 +154,19 @@ clean: # ── Aide ───────────────────────────────────────────────────────────────────── help: @echo "" - @echo " ╔════════════════════════════════════════════╗" - @echo " ║ 🛡️ WGSecure v$(VERSION) — Makefile ║" - @echo " ╠════════════════════════════════════════════╣" - @echo " ║ make install Installe les dépendances ║" - @echo " ║ make linux Binaire Linux (onefile) ║" - @echo " ║ make windows Binaire Windows (Wine) ║" - @echo " ║ make release Linux + nommage release ║" - @echo " ║ make setup-wine Python Windows dans Wine ║" - @echo " ║ make run Lance l'application ║" - @echo " ║ make run-admin Lance en mode admin ║" - @echo " ║ make clean Supprime les artefacts ║" - @echo " ╚════════════════════════════════════════════╝" + @echo " ╔══════════════════════════════════════════════╗" + @echo " ║ 🛡️ WGSecure v$(VERSION) — Makefile ║" + @echo " ╠══════════════════════════════════════════════╣" + @echo " ║ make install Installe les dépendances ║" + @echo " ║ make linux Binaire Linux (onefile) ║" + @echo " ║ make windows Binaire Windows (Wine) ║" + @echo " ║ make release Linux + nommage release ║" + @echo " ║ make setup-wine Python Windows dans Wine ║" + @echo " ║ make install-gnome Installe dans GNOME ║" + @echo " ║ make uninstall-gnome Désinstalle de GNOME ║" + @echo " ║ make reset-password Supprime le MDP admin ║" + @echo " ║ make run Lance l'application ║" + @echo " ║ make run-admin Lance en mode admin ║" + @echo " ║ make clean Supprime les artefacts ║" + @echo " ╚══════════════════════════════════════════════╝" @echo "" diff --git a/README.md b/README.md index f78005b..f295a32 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,18 @@ |---|---|---| | 🖥️ | **Interface systray** | Icône bouclier dans la barre de notification, connexion en un clic | | 👤 | **Mode User** (défaut) | Connexion / déconnexion WireGuard, statut RX/TX en temps réel | +| 📊 | **Graphique bande passante** | Courbes RX/TX en direct sur les 30 dernières secondes | +| 📋 | **Journal d'événements** | Horodatage de chaque connexion, déconnexion, erreur | +| 🕘 | **Historique des sessions** | Durée, données transférées, serveur par session | | ⚙️ | **Mode Admin** | Configuration complète, protégé par mot de passe | | 🔑 | **Génération de clés** | Paire Curve25519 native (sans dépendance au binaire `wg`) | +| 👤 | **Multi-profils** | Sauvegarde / bascule entre profils, import/export `.conf` | | 🛡️ | **Configuration WireGuard** | Serveur, port UDP, DNS, IP client, keepalive | -| 🔐 | **MFA TOTP** | Compatible Google Authenticator / Aegis + QR Code | +| 🔐 | **MFA TOTP** | Compatible Bitwarden + QR Code | +| 🔒 | **Kill Switch** | Bloque tout trafic hors tunnel via iptables (Linux) | +| 🔍 | **Test DNS leak** | Vérifie que le DNS passe bien par le tunnel | +| 🏓 | **Ping en temps réel** | Badge de latence dans l'interface principale | +| 🔄 | **Auto-reconnexion** | Surveillance et reconnexion automatique si le tunnel tombe | | 📡 | **Test de connexion** | Vérification UDP du serveur avant établissement du tunnel | | 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) | @@ -60,11 +68,15 @@ pip install -r requirements.txt ### Mode User (par défaut) ```bash python main.py +# ou via Makefile : +make run ``` ### ⚙️ Mode Administrateur (configuration) ```bash python main.py --admin +# ou via Makefile : +make run-admin ``` ### Sans systray @@ -74,34 +86,90 @@ python main.py --no-tray --- +## 🔧 Makefile + +| Commande | Description | +|---|---| +| `make install` | Installe les dépendances Python | +| `make linux` | Compile un binaire Linux autonome (PyInstaller) | +| `make windows` | Compile un binaire Windows via Wine | +| `make release` | Binaire Linux renommé avec la version | +| `make install-gnome` | Installe dans le menu GNOME (`~/.local/`) | +| `make uninstall-gnome` | Désinstalle du menu GNOME | +| `make reset-password` | Supprime le mot de passe administrateur | +| `make run` | Lance l'application | +| `make run-admin` | Lance en mode administrateur | +| `make clean` | Supprime les artefacts de build | + +--- + ## 🗺️ Workflow recommandé 1. 🚀 Lancer `python main.py --admin` 2. 🛡️ Onglet **WireGuard** : renseigner l'adresse, le port et la clé publique du serveur 3. 🔑 Onglet **Clés** : générer une paire de clés → copier la clé publique sur le serveur -4. 🔐 Onglet **MFA** : activer le MFA, générer un secret, scanner le QR Code +4. 🔐 Onglet **MFA** : activer le MFA, générer un secret, scanner le QR Code avec Bitwarden 5. 🔒 Onglet **Sécurité** : définir un mot de passe pour l'accès admin 6. ✅ **Enregistrer** → quitter → lancer `python main.py` en mode user --- +## 🐧 Élévation de privilèges Linux + +`wg-quick` nécessite les droits root pour monter/démonter le tunnel. +WGSecure tente dans l'ordre : + +1. **pkexec** (polkit) — dialogue graphique GNOME/KDE, aucune configuration requise +2. **sudo -A** avec programme askpass graphique (`ssh-askpass`, etc.) +3. **sudo -n** — fonctionne si une règle NOPASSWD est en place + +### Supprimer la demande de mot de passe (optionnel) + +Pour éviter toute invite à chaque connexion, créer une règle sudoers dédiée : + +```bash +sudo visudo -f /etc/sudoers.d/wgsecure +``` + +Ajouter la ligne suivante (remplacer `` par votre login) : + +``` + ALL=(ALL) NOPASSWD: /usr/bin/wg-quick +``` + +> ⚠️ Cette règle accorde l'exécution de `wg-quick` sans mot de passe uniquement, +> et non un accès root général. + +### Réinitialiser le mot de passe admin oublié + +```bash +make reset-password +``` + +--- + ## 🏗️ Architecture ``` WGSecure/ ├── main.py # 🚀 Point d'entrée ├── requirements.txt +├── Makefile ├── app/ │ ├── __init__.py # Version │ ├── core/ -│ │ ├── config.py # 💾 Configuration JSON persistante -│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut +│ │ ├── config.py # 💾 Configuration JSON persistante + bcrypt +│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut, kill switch, DNS leak +│ │ ├── history.py # 🕘 Historique des sessions (sessions.json) +│ │ ├── log.py # 📋 Journal d'événements (events.json) │ │ └── mfa.py # 🔐 TOTP / QR Code │ ├── ui/ │ │ ├── icons.py # 🎨 Icônes bouclier générées par QPainter │ │ ├── main_window.py # 👤 Fenêtre principale (mode user) -│ │ ├── admin_window.py # ⚙️ Panneau admin (6 onglets, thème sombre) -│ │ ├── mfa_dialog.py # 🔐 Dialog de vérification MFA +│ │ ├── admin_window.py # ⚙️ Panneau admin (onglets, thème sombre) +│ │ ├── bw_graph.py # 📊 Widget graphique bande passante +│ │ ├── history_dialog.py # 🕘 Dialogue historique des sessions +│ │ ├── mfa_dialog.py # 🔐 Dialogue de vérification MFA │ │ └── systray.py # 🖥️ Icône systray + menu contextuel │ └── utils/ │ └── platform_utils.py # 🔧 Détection OS, élévation de privilèges @@ -113,25 +181,16 @@ WGSecure/ | Élément | Protection | |---|---| -| 🔑 Mot de passe admin | Hash SHA-256 + salt aléatoire 16 octets | +| 🔑 Mot de passe admin | **bcrypt** (rounds=12) + sel intégré — migration transparente depuis SHA-256 | | 🗝️ Clé privée WireGuard | `~/.wgsecure/config.json` (Linux) / `%APPDATA%\WGSecure\` (Windows) | | 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local | | 📄 Fichier `.conf` WireGuard | Permissions `0600` (lecture propriétaire uniquement) | +| 🔒 Kill Switch | Chaîne iptables dédiée `WGS_KILLSWITCH` — isolée du pare-feu système | > ⚠️ **Recommandation** : chiffrez votre disque pour protéger le fichier de configuration. --- -## 🖼️ Interface - -| Fenêtre principale | Panneau Admin | -|---|---| -| Statut connexion en temps réel | 6 onglets thème sombre | -| Bouton connexion / déconnexion | WireGuard · Clés · MFA · Test · Sécurité · À propos | -| Icône systray bouclier colorée | Bandeau coloré par onglet | - ---- - ## 👤 Auteur Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) diff --git a/app/core/config.py b/app/core/config.py index f66a6b1..978741d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -2,6 +2,7 @@ import json import os import hashlib import secrets +import bcrypt from copy import deepcopy from typing import Any from app.utils.platform_utils import get_config_dir @@ -28,7 +29,7 @@ _DEFAULT: dict[str, Any] = { "admin_salt": "", "mfa_enabled": False, "mfa_secret": "", - "active_profile": "Défaut", + "active_profile": "H3", "profiles": {}, "wg": deepcopy(_WG_DEFAULT), "ui": { @@ -94,19 +95,21 @@ class Config: self._data["admin_salt"] = "" self._data["admin_password_hash"] = "" else: - salt = secrets.token_hex(16) - hashed = hashlib.sha256((salt + password).encode()).hexdigest() - self._data["admin_salt"] = salt - self._data["admin_password_hash"] = hashed + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)) + self._data["admin_salt"] = "" + self._data["admin_password_hash"] = hashed.decode("utf-8") self.save() def check_admin_password(self, password: str) -> bool: - salt = self._data.get("admin_salt", "") stored = self._data.get("admin_password_hash", "") if not stored: return True - candidate = hashlib.sha256((salt + password).encode()).hexdigest() - return secrets.compare_digest(candidate, stored) + # Migration transparente : anciens hashes SHA-256 (64 hex chars, pas de $2b$) + if not stored.startswith("$2"): + salt = self._data.get("admin_salt", "") + candidate = hashlib.sha256((salt + password).encode()).hexdigest() + return secrets.compare_digest(candidate, stored) + return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8")) def has_admin_password(self) -> bool: return bool(self._data.get("admin_password_hash", "")) @@ -144,7 +147,7 @@ class Config: @property def active_profile(self) -> str: - return self._data.get("active_profile", "Défaut") + return self._data.get("active_profile", "H3") def list_profiles(self) -> list[str]: return list(self._data.get("profiles", {}).keys()) @@ -171,7 +174,7 @@ class Config: return False del profiles[name] if self._data.get("active_profile") == name: - self._data["active_profile"] = "Défaut" + self._data["active_profile"] = "H3" self.save() return True diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 279aa5f..59e7e01 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -75,7 +75,7 @@ class AdminWindow(QDialog): | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint ) - self.setFixedSize(700, 560) + self.setFixedSize(700, 600) self._build_ui() self._load_values() @@ -340,11 +340,11 @@ class AdminWindow(QDialog): self._row(form, "", btn_gen_secret) lay.addWidget(grp) - grp2 = QGroupBox("QR Code — Scanner avec Google Authenticator / Aegis") + grp2 = QGroupBox("QR Code — Scanner avec Bitwarden") v2 = QVBoxLayout(grp2) self._qr_label = QLabel("(générez un secret pour afficher le QR Code)") self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._qr_label.setFixedHeight(180) + self._qr_label.setFixedHeight(230) v2.addWidget(self._qr_label) self._mfa_uri_label = QLabel("") self._mfa_uri_label.setWordWrap(True) @@ -736,6 +736,8 @@ class AdminWindow(QDialog): # Actions # ------------------------------------------------------------------ # def _import_conf(self): + from PyQt6.QtWidgets import QInputDialog + import os as _os path, _ = QFileDialog.getOpenFileName( self, "Importer un fichier WireGuard", "", "WireGuard Config (*.conf);;Tous les fichiers (*)" @@ -746,6 +748,19 @@ class AdminWindow(QDialog): if not values: QMessageBox.warning(self, "Erreur", "Impossible de lire ce fichier .conf") return + + # Demander le nom du profil (défaut = nom du fichier sans extension) + default_name = _os.path.splitext(_os.path.basename(path))[0] + name, ok = QInputDialog.getText( + self, "Nom du profil", + "Nom du nouveau profil :", + text=default_name, + ) + if not ok or not name.strip(): + return + name = name.strip() + + # Appliquer les valeurs dans les champs mapping = { "server_endpoint": self._srv_endpoint, "server_public_key": self._srv_pubkey, @@ -762,10 +777,14 @@ class AdminWindow(QDialog): self._srv_port.setValue(int(values["server_port"])) if "keepalive" in values: self._keepalive.setValue(int(values["keepalive"])) + + # Sauvegarder en tant que nouveau profil self._save_values() + self._cfg.save_profile(name) + self._refresh_profile_list() QMessageBox.information( self, "Import réussi", - f"Configuration importée depuis :\n{path}" + f"Profil « {name} » créé depuis :\n{path}" ) def _export_conf(self): @@ -810,7 +829,7 @@ class AdminWindow(QDialog): return pixmap = mfa_core.generate_qr_pixmap(secret) self._qr_label.setPixmap( - pixmap.scaled(160, 160, Qt.AspectRatioMode.KeepAspectRatio, + pixmap.scaled(210, 210, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation) ) self._mfa_uri_label.setText(mfa_core.get_provisioning_uri(secret)) diff --git a/app/ui/history_dialog.py b/app/ui/history_dialog.py index f681240..4a73901 100644 --- a/app/ui/history_dialog.py +++ b/app/ui/history_dialog.py @@ -2,7 +2,7 @@ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QHeaderView, - QMessageBox, + QMessageBox, QWidget, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QColor, QFont diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 028d2c8..154e41c 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -326,14 +326,14 @@ class MainWindow(QMainWindow): def _reload_profiles(self): self._profile_combo.blockSignals(True) self._profile_combo.clear() - self._profile_combo.addItem("⭐ Défaut (actif)") + self._profile_combo.addItem(f"⭐ {self._cfg.active_profile} (actif)") for name in self._cfg.list_profiles(): marker = "✓ " if name == self._cfg.active_profile else " " self._profile_combo.addItem(f"{marker}{name}") self._profile_combo.blockSignals(False) def _on_profile_changed(self, text: str): - if "Défaut" in text: + if "(actif)" in text: return name = text.lstrip("✓ ").strip() if not name or name == self._cfg.active_profile: diff --git a/app/ui/mfa_dialog.py b/app/ui/mfa_dialog.py index e850df1..6bc16e5 100644 --- a/app/ui/mfa_dialog.py +++ b/app/ui/mfa_dialog.py @@ -6,6 +6,9 @@ from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QFont from app.core import mfa as mfa_core +_DARK = "#1c2833" +_DARK2 = "#17202a" + class MFADialog(QDialog): def __init__(self, secret: str, parent=None): @@ -15,6 +18,10 @@ class MFADialog(QDialog): self.setWindowTitle("WGSecure — Authentification MFA") self.setFixedWidth(340) self.setModal(True) + self.setStyleSheet(f""" + QDialog {{ background: {_DARK2}; }} + QLabel {{ color: white; background: transparent; }} + """) self._build_ui() self._timer = QTimer(self) self._timer.timeout.connect(self._tick) @@ -37,12 +44,12 @@ class MFADialog(QDialog): sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.") sub.setWordWrap(True) sub.setAlignment(Qt.AlignmentFlag.AlignCenter) - sub.setStyleSheet("color: #555;") + sub.setStyleSheet("color: rgba(255,255,255,0.6);") layout.addWidget(sub) sep = QFrame() sep.setFrameShape(QFrame.Shape.HLine) - sep.setStyleSheet("color: #ddd;") + sep.setStyleSheet("color: rgba(255,255,255,0.15);") layout.addWidget(sep) self._code_input = QLineEdit() @@ -53,14 +60,15 @@ class MFADialog(QDialog): f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4) self._code_input.setFont(f2) self._code_input.setStyleSheet( - "padding: 8px; border: 2px solid #3498db; border-radius: 6px;" + "QLineEdit { padding: 8px; border: 2px solid #2471a3;" + f" border-radius: 6px; background: {_DARK}; color: white; }}" ) self._code_input.returnPressed.connect(self._verify) layout.addWidget(self._code_input) self._error_label = QLabel("") self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._error_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + self._error_label.setStyleSheet("color: #f1948a; font-weight: bold;") layout.addWidget(self._error_label) self._progress = QProgressBar() @@ -68,30 +76,31 @@ class MFADialog(QDialog): self._progress.setTextVisible(False) self._progress.setFixedHeight(6) self._progress.setStyleSheet( - "QProgressBar { border-radius: 3px; background: #ecf0f1; }" - "QProgressBar::chunk { background: #3498db; border-radius: 3px; }" + f"QProgressBar {{ border-radius: 3px; background: {_DARK}; }}" + "QProgressBar::chunk { background: #5dade2; border-radius: 3px; }" ) layout.addWidget(self._progress) self._timer_label = QLabel("") self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._timer_label.setStyleSheet("color: #999; font-size: 11px;") + self._timer_label.setStyleSheet("color: #5d6d7e; font-size: 11px;") layout.addWidget(self._timer_label) btn_row = QHBoxLayout() btn_cancel = QPushButton("Annuler") btn_cancel.setStyleSheet( - "QPushButton { padding: 8px 16px; border-radius: 5px; " - "background: #ecf0f1; } QPushButton:hover { background: #bdc3c7; }" + "QPushButton { padding: 8px 16px; border-radius: 5px; border: none;" + f" background: #2e4057; color: white; }}" + "QPushButton:hover { background: #3d5166; }" ) btn_cancel.clicked.connect(self.reject) self._btn_ok = QPushButton("Vérifier") self._btn_ok.setDefault(True) self._btn_ok.setStyleSheet( - "QPushButton { padding: 8px 20px; border-radius: 5px; " - "background: #3498db; color: white; font-weight: bold; }" - "QPushButton:hover { background: #2980b9; }" + "QPushButton { padding: 8px 20px; border-radius: 5px; border: none;" + " background: #2471a3; color: white; font-weight: bold; }" + "QPushButton:hover { background: #1a5276; }" ) self._btn_ok.clicked.connect(self._verify) btn_row.addWidget(btn_cancel) @@ -109,7 +118,8 @@ class MFADialog(QDialog): if len(raw) != 6 or not raw.isdigit(): self._error_label.setText("Entrez exactement 6 chiffres.") self._code_input.setStyleSheet( - "padding: 8px; border: 2px solid #e74c3c; border-radius: 6px;" + f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;" + f" border-radius: 6px; background: {_DARK}; color: white; }}" ) return if mfa_core.verify_code(self._secret, raw): @@ -119,7 +129,8 @@ class MFADialog(QDialog): self._error_label.setText("Code incorrect. Réessayez.") self._code_input.clear() self._code_input.setStyleSheet( - "padding: 8px; border: 2px solid #e74c3c; border-radius: 6px;" + f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;" + f" border-radius: 6px; background: {_DARK}; color: white; }}" ) def is_verified(self) -> bool: diff --git a/app/utils/platform_utils.py b/app/utils/platform_utils.py index 76b575e..cdc5da5 100644 --- a/app/utils/platform_utils.py +++ b/app/utils/platform_utils.py @@ -56,17 +56,68 @@ def run_command(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]: return -1, "", str(e) -def run_privileged(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]: +def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: if is_windows(): return run_command(cmd, timeout) if has_root_privileges(): return run_command(cmd, timeout) - # Tente pkexec puis sudo - for elevator in ("pkexec", "sudo"): - code, out, err = run_command([elevator] + cmd, timeout) - if code != -1 or "introuvable" not in err: - return code, out, err - return -1, "", "Élévation de privilèges impossible" + + # 1. pkexec — dialogue graphique polkit (GNOME/KDE), timeout long pour la saisie + code, out, err = run_command(["pkexec"] + cmd, timeout) + if code != -1 or "introuvable" not in err: + return code, out, err + + # 2. sudo avec programme askpass graphique (pas de TTY dans une app Qt) + askpass = _find_askpass() + if askpass: + env = os.environ.copy() + env["SUDO_ASKPASS"] = askpass + try: + result = subprocess.run( + ["sudo", "-A"] + cmd, + capture_output=True, text=True, + timeout=timeout, env=env, + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, "", "Timeout élévation sudo" + except Exception as e: + return -1, "", str(e) + + # 3. sudo classique (fonctionne si NOPASSWD configuré dans sudoers) + code, out, err = run_command(["sudo", "-n"] + cmd, 10) + if code != -1: + return code, out, err + + return ( + -1, "", + "Élévation de privilèges impossible.\n" + "Solutions :\n" + " • Installer pkexec (polkit) pour le dialogue graphique\n" + " • Ou ajouter dans /etc/sudoers :\n" + f" {os.environ.get('USER','')} ALL=(ALL) NOPASSWD: /usr/bin/wg-quick" + ) + + +def _find_askpass() -> str: + """Cherche un programme askpass graphique disponible.""" + candidates = [ + os.environ.get("SUDO_ASKPASS", ""), + "/usr/lib/openssh/gnome-ssh-askpass", + "/usr/bin/ssh-askpass", + "/usr/bin/x11-ssh-askpass", + "/usr/libexec/ssh-askpass", + ] + for p in candidates: + if p and os.path.isfile(p) and os.access(p, os.X_OK): + return p + # Cherche aussi via shutil + import shutil + for name in ("ssh-askpass", "x11-ssh-askpass", "gnome-ssh-askpass"): + found = shutil.which(name) + if found: + return found + return "" def wg_available() -> bool: diff --git a/requirements.txt b/requirements.txt index 534f61e..a4abf1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ cryptography>=41.0.0 pyotp>=2.9.0 qrcode>=7.4.2 Pillow>=10.0.0 +bcrypt>=4.0.0