This commit is contained in:
2026-06-02 07:34:09 +02:00
parent d927afbb87
commit b3f0a2eecc
9 changed files with 261 additions and 69 deletions
+61 -13
View File
@@ -25,7 +25,7 @@ PI_OPTS := \
--hidden-import cryptography.hazmat.primitives.asymmetric.x25519 \ --hidden-import cryptography.hazmat.primitives.asymmetric.x25519 \
--collect-submodules PyQt6 --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 ──────────────────────────────────────────────────────── # ── Cible par défaut ────────────────────────────────────────────────────────
all: linux all: linux
@@ -85,12 +85,57 @@ release: clean linux
@mv $(DIST)/$(APP) $(DIST)/$(APP)-$(VERSION)-linux-x86_64 @mv $(DIST)/$(APP) $(DIST)/$(APP)-$(VERSION)-linux-x86_64
@echo " 📦 Release → $(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 ──────────────────────────────────────────────────────── # ── Dépendances Python ────────────────────────────────────────────────────────
install: install:
$(PYTHON) -m pip install -r requirements.txt $(PYTHON) -m pip install -r requirements.txt
$(PYTHON) -m pip install pyinstaller $(PYTHON) -m pip install pyinstaller
@echo " ✅ Dépendances installées" @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 ───────────────────────────────────────────────────── # ── Lancer l'application ─────────────────────────────────────────────────────
run: run:
DISPLAY=:0 $(PYTHON) $(SRC) DISPLAY=:0 $(PYTHON) $(SRC)
@@ -109,16 +154,19 @@ clean:
# ── Aide ───────────────────────────────────────────────────────────────────── # ── Aide ─────────────────────────────────────────────────────────────────────
help: help:
@echo "" @echo ""
@echo " ╔════════════════════════════════════════════╗" @echo " "
@echo " ║ 🛡️ WGSecure v$(VERSION) — Makefile " @echo " 🛡 WGSecure v$(VERSION) Makefile "
@echo " ╠════════════════════════════════════════════╣" @echo " "
@echo " ║ make install Installe les dépendances ║" @echo " make install Installe les dépendances "
@echo " ║ make linux Binaire Linux (onefile) ║" @echo " make linux Binaire Linux (onefile) "
@echo " ║ make windows Binaire Windows (Wine) ║" @echo " make windows Binaire Windows (Wine) "
@echo " ║ make release Linux + nommage release ║" @echo " make release Linux + nommage release "
@echo " ║ make setup-wine Python Windows dans Wine ║" @echo " make setup-wine Python Windows dans Wine "
@echo " ║ make run Lance l'application ║" @echo " make install-gnome Installe dans GNOME "
@echo " ║ make run-admin Lance en mode admin ║" @echo " make uninstall-gnome Désinstalle de GNOME "
@echo " ║ make clean Supprime les artefacts ║" @echo " make reset-password Supprime le MDP admin "
@echo " ╚════════════════════════════════════════════╝" @echo " make run Lance l'application "
@echo " make run-admin Lance en mode admin "
@echo " make clean Supprime les artefacts "
@echo " "
@echo "" @echo ""
+76 -17
View File
@@ -18,10 +18,18 @@
|---|---|---| |---|---|---|
| 🖥️ | **Interface systray** | Icône bouclier dans la barre de notification, connexion en un clic | | 🖥️ | **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 | | 👤 | **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 | | ⚙️ | **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`) | | 🔑 | **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 | | 🛡️ | **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 | | 📡 | **Test de connexion** | Vérification UDP du serveur avant établissement du tunnel |
| 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) | | 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) |
@@ -60,11 +68,15 @@ pip install -r requirements.txt
### Mode User (par défaut) ### Mode User (par défaut)
```bash ```bash
python main.py python main.py
# ou via Makefile :
make run
``` ```
### ⚙️ Mode Administrateur (configuration) ### ⚙️ Mode Administrateur (configuration)
```bash ```bash
python main.py --admin python main.py --admin
# ou via Makefile :
make run-admin
``` ```
### Sans systray ### 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é ## 🗺️ Workflow recommandé
1. 🚀 Lancer `python main.py --admin` 1. 🚀 Lancer `python main.py --admin`
2. 🛡️ Onglet **WireGuard** : renseigner l'adresse, le port et la clé publique du serveur 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 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 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 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 `<utilisateur>` par votre login) :
```
<utilisateur> 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 ## 🏗️ Architecture
``` ```
WGSecure/ WGSecure/
├── main.py # 🚀 Point d'entrée ├── main.py # 🚀 Point d'entrée
├── requirements.txt ├── requirements.txt
├── Makefile
├── app/ ├── app/
│ ├── __init__.py # Version │ ├── __init__.py # Version
│ ├── core/ │ ├── core/
│ │ ├── config.py # 💾 Configuration JSON persistante │ │ ├── config.py # 💾 Configuration JSON persistante + bcrypt
│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut │ │ ├── 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 │ │ └── mfa.py # 🔐 TOTP / QR Code
│ ├── ui/ │ ├── ui/
│ │ ├── icons.py # 🎨 Icônes bouclier générées par QPainter │ │ ├── icons.py # 🎨 Icônes bouclier générées par QPainter
│ │ ├── main_window.py # 👤 Fenêtre principale (mode user) │ │ ├── main_window.py # 👤 Fenêtre principale (mode user)
│ │ ├── admin_window.py # ⚙️ Panneau admin (6 onglets, thème sombre) │ │ ├── admin_window.py # ⚙️ Panneau admin (onglets, thème sombre)
│ │ ├── mfa_dialog.py # 🔐 Dialog de vérification MFA │ │ ├── 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 │ │ └── systray.py # 🖥️ Icône systray + menu contextuel
│ └── utils/ │ └── utils/
│ └── platform_utils.py # 🔧 Détection OS, élévation de privilèges │ └── platform_utils.py # 🔧 Détection OS, élévation de privilèges
@@ -113,25 +181,16 @@ WGSecure/
| Élément | Protection | | É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) | | 🗝️ Clé privée WireGuard | `~/.wgsecure/config.json` (Linux) / `%APPDATA%\WGSecure\` (Windows) |
| 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local | | 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local |
| 📄 Fichier `.conf` WireGuard | Permissions `0600` (lecture propriétaire uniquement) | | 📄 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. > ⚠️ **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 ## 👤 Auteur
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
+13 -10
View File
@@ -2,6 +2,7 @@ import json
import os import os
import hashlib import hashlib
import secrets import secrets
import bcrypt
from copy import deepcopy from copy import deepcopy
from typing import Any from typing import Any
from app.utils.platform_utils import get_config_dir from app.utils.platform_utils import get_config_dir
@@ -28,7 +29,7 @@ _DEFAULT: dict[str, Any] = {
"admin_salt": "", "admin_salt": "",
"mfa_enabled": False, "mfa_enabled": False,
"mfa_secret": "", "mfa_secret": "",
"active_profile": "Défaut", "active_profile": "H3",
"profiles": {}, "profiles": {},
"wg": deepcopy(_WG_DEFAULT), "wg": deepcopy(_WG_DEFAULT),
"ui": { "ui": {
@@ -94,19 +95,21 @@ class Config:
self._data["admin_salt"] = "" self._data["admin_salt"] = ""
self._data["admin_password_hash"] = "" self._data["admin_password_hash"] = ""
else: else:
salt = secrets.token_hex(16) hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12))
hashed = hashlib.sha256((salt + password).encode()).hexdigest() self._data["admin_salt"] = ""
self._data["admin_salt"] = salt self._data["admin_password_hash"] = hashed.decode("utf-8")
self._data["admin_password_hash"] = hashed
self.save() self.save()
def check_admin_password(self, password: str) -> bool: def check_admin_password(self, password: str) -> bool:
salt = self._data.get("admin_salt", "")
stored = self._data.get("admin_password_hash", "") stored = self._data.get("admin_password_hash", "")
if not stored: if not stored:
return True return True
candidate = hashlib.sha256((salt + password).encode()).hexdigest() # Migration transparente : anciens hashes SHA-256 (64 hex chars, pas de $2b$)
return secrets.compare_digest(candidate, stored) 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: def has_admin_password(self) -> bool:
return bool(self._data.get("admin_password_hash", "")) return bool(self._data.get("admin_password_hash", ""))
@@ -144,7 +147,7 @@ class Config:
@property @property
def active_profile(self) -> str: 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]: def list_profiles(self) -> list[str]:
return list(self._data.get("profiles", {}).keys()) return list(self._data.get("profiles", {}).keys())
@@ -171,7 +174,7 @@ class Config:
return False return False
del profiles[name] del profiles[name]
if self._data.get("active_profile") == name: if self._data.get("active_profile") == name:
self._data["active_profile"] = "Défaut" self._data["active_profile"] = "H3"
self.save() self.save()
return True return True
+24 -5
View File
@@ -75,7 +75,7 @@ class AdminWindow(QDialog):
| Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowCloseButtonHint | Qt.WindowType.WindowCloseButtonHint
) )
self.setFixedSize(700, 560) self.setFixedSize(700, 600)
self._build_ui() self._build_ui()
self._load_values() self._load_values()
@@ -340,11 +340,11 @@ class AdminWindow(QDialog):
self._row(form, "", btn_gen_secret) self._row(form, "", btn_gen_secret)
lay.addWidget(grp) lay.addWidget(grp)
grp2 = QGroupBox("QR Code — Scanner avec Google Authenticator / Aegis") grp2 = QGroupBox("QR Code — Scanner avec Bitwarden")
v2 = QVBoxLayout(grp2) v2 = QVBoxLayout(grp2)
self._qr_label = QLabel("(générez un secret pour afficher le QR Code)") self._qr_label = QLabel("(générez un secret pour afficher le QR Code)")
self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._qr_label.setFixedHeight(180) self._qr_label.setFixedHeight(230)
v2.addWidget(self._qr_label) v2.addWidget(self._qr_label)
self._mfa_uri_label = QLabel("") self._mfa_uri_label = QLabel("")
self._mfa_uri_label.setWordWrap(True) self._mfa_uri_label.setWordWrap(True)
@@ -736,6 +736,8 @@ class AdminWindow(QDialog):
# Actions # Actions
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _import_conf(self): def _import_conf(self):
from PyQt6.QtWidgets import QInputDialog
import os as _os
path, _ = QFileDialog.getOpenFileName( path, _ = QFileDialog.getOpenFileName(
self, "Importer un fichier WireGuard", self, "Importer un fichier WireGuard",
"", "WireGuard Config (*.conf);;Tous les fichiers (*)" "", "WireGuard Config (*.conf);;Tous les fichiers (*)"
@@ -746,6 +748,19 @@ class AdminWindow(QDialog):
if not values: if not values:
QMessageBox.warning(self, "Erreur", "Impossible de lire ce fichier .conf") QMessageBox.warning(self, "Erreur", "Impossible de lire ce fichier .conf")
return 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 = { mapping = {
"server_endpoint": self._srv_endpoint, "server_endpoint": self._srv_endpoint,
"server_public_key": self._srv_pubkey, "server_public_key": self._srv_pubkey,
@@ -762,10 +777,14 @@ class AdminWindow(QDialog):
self._srv_port.setValue(int(values["server_port"])) self._srv_port.setValue(int(values["server_port"]))
if "keepalive" in values: if "keepalive" in values:
self._keepalive.setValue(int(values["keepalive"])) self._keepalive.setValue(int(values["keepalive"]))
# Sauvegarder en tant que nouveau profil
self._save_values() self._save_values()
self._cfg.save_profile(name)
self._refresh_profile_list()
QMessageBox.information( QMessageBox.information(
self, "Import réussi", self, "Import réussi",
f"Configuration importée depuis :\n{path}" f"Profil « {name} » créé depuis :\n{path}"
) )
def _export_conf(self): def _export_conf(self):
@@ -810,7 +829,7 @@ class AdminWindow(QDialog):
return return
pixmap = mfa_core.generate_qr_pixmap(secret) pixmap = mfa_core.generate_qr_pixmap(secret)
self._qr_label.setPixmap( self._qr_label.setPixmap(
pixmap.scaled(160, 160, Qt.AspectRatioMode.KeepAspectRatio, pixmap.scaled(210, 210, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation) Qt.TransformationMode.SmoothTransformation)
) )
self._mfa_uri_label.setText(mfa_core.get_provisioning_uri(secret)) self._mfa_uri_label.setText(mfa_core.get_provisioning_uri(secret))
+1 -1
View File
@@ -2,7 +2,7 @@
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, QDialog, QVBoxLayout, QHBoxLayout, QTableWidget,
QTableWidgetItem, QPushButton, QLabel, QHeaderView, QTableWidgetItem, QPushButton, QLabel, QHeaderView,
QMessageBox, QMessageBox, QWidget,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QFont from PyQt6.QtGui import QColor, QFont
+2 -2
View File
@@ -326,14 +326,14 @@ class MainWindow(QMainWindow):
def _reload_profiles(self): def _reload_profiles(self):
self._profile_combo.blockSignals(True) self._profile_combo.blockSignals(True)
self._profile_combo.clear() 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(): for name in self._cfg.list_profiles():
marker = "" if name == self._cfg.active_profile else " " marker = "" if name == self._cfg.active_profile else " "
self._profile_combo.addItem(f"{marker}{name}") self._profile_combo.addItem(f"{marker}{name}")
self._profile_combo.blockSignals(False) self._profile_combo.blockSignals(False)
def _on_profile_changed(self, text: str): def _on_profile_changed(self, text: str):
if "Défaut" in text: if "(actif)" in text:
return return
name = text.lstrip("").strip() name = text.lstrip("").strip()
if not name or name == self._cfg.active_profile: if not name or name == self._cfg.active_profile:
+25 -14
View File
@@ -6,6 +6,9 @@ from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
from app.core import mfa as mfa_core from app.core import mfa as mfa_core
_DARK = "#1c2833"
_DARK2 = "#17202a"
class MFADialog(QDialog): class MFADialog(QDialog):
def __init__(self, secret: str, parent=None): def __init__(self, secret: str, parent=None):
@@ -15,6 +18,10 @@ class MFADialog(QDialog):
self.setWindowTitle("WGSecure — Authentification MFA") self.setWindowTitle("WGSecure — Authentification MFA")
self.setFixedWidth(340) self.setFixedWidth(340)
self.setModal(True) self.setModal(True)
self.setStyleSheet(f"""
QDialog {{ background: {_DARK2}; }}
QLabel {{ color: white; background: transparent; }}
""")
self._build_ui() self._build_ui()
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.timeout.connect(self._tick) 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 = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.")
sub.setWordWrap(True) sub.setWordWrap(True)
sub.setAlignment(Qt.AlignmentFlag.AlignCenter) sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
sub.setStyleSheet("color: #555;") sub.setStyleSheet("color: rgba(255,255,255,0.6);")
layout.addWidget(sub) layout.addWidget(sub)
sep = QFrame() sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine) sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: #ddd;") sep.setStyleSheet("color: rgba(255,255,255,0.15);")
layout.addWidget(sep) layout.addWidget(sep)
self._code_input = QLineEdit() self._code_input = QLineEdit()
@@ -53,14 +60,15 @@ class MFADialog(QDialog):
f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4) f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4)
self._code_input.setFont(f2) self._code_input.setFont(f2)
self._code_input.setStyleSheet( 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) self._code_input.returnPressed.connect(self._verify)
layout.addWidget(self._code_input) layout.addWidget(self._code_input)
self._error_label = QLabel("") self._error_label = QLabel("")
self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 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) layout.addWidget(self._error_label)
self._progress = QProgressBar() self._progress = QProgressBar()
@@ -68,30 +76,31 @@ class MFADialog(QDialog):
self._progress.setTextVisible(False) self._progress.setTextVisible(False)
self._progress.setFixedHeight(6) self._progress.setFixedHeight(6)
self._progress.setStyleSheet( self._progress.setStyleSheet(
"QProgressBar { border-radius: 3px; background: #ecf0f1; }" f"QProgressBar {{ border-radius: 3px; background: {_DARK}; }}"
"QProgressBar::chunk { background: #3498db; border-radius: 3px; }" "QProgressBar::chunk { background: #5dade2; border-radius: 3px; }"
) )
layout.addWidget(self._progress) layout.addWidget(self._progress)
self._timer_label = QLabel("") self._timer_label = QLabel("")
self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 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) layout.addWidget(self._timer_label)
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_cancel = QPushButton("Annuler") btn_cancel = QPushButton("Annuler")
btn_cancel.setStyleSheet( btn_cancel.setStyleSheet(
"QPushButton { padding: 8px 16px; border-radius: 5px; " "QPushButton { padding: 8px 16px; border-radius: 5px; border: none;"
"background: #ecf0f1; } QPushButton:hover { background: #bdc3c7; }" f" background: #2e4057; color: white; }}"
"QPushButton:hover { background: #3d5166; }"
) )
btn_cancel.clicked.connect(self.reject) btn_cancel.clicked.connect(self.reject)
self._btn_ok = QPushButton("Vérifier") self._btn_ok = QPushButton("Vérifier")
self._btn_ok.setDefault(True) self._btn_ok.setDefault(True)
self._btn_ok.setStyleSheet( self._btn_ok.setStyleSheet(
"QPushButton { padding: 8px 20px; border-radius: 5px; " "QPushButton { padding: 8px 20px; border-radius: 5px; border: none;"
"background: #3498db; color: white; font-weight: bold; }" " background: #2471a3; color: white; font-weight: bold; }"
"QPushButton:hover { background: #2980b9; }" "QPushButton:hover { background: #1a5276; }"
) )
self._btn_ok.clicked.connect(self._verify) self._btn_ok.clicked.connect(self._verify)
btn_row.addWidget(btn_cancel) btn_row.addWidget(btn_cancel)
@@ -109,7 +118,8 @@ class MFADialog(QDialog):
if len(raw) != 6 or not raw.isdigit(): if len(raw) != 6 or not raw.isdigit():
self._error_label.setText("Entrez exactement 6 chiffres.") self._error_label.setText("Entrez exactement 6 chiffres.")
self._code_input.setStyleSheet( 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 return
if mfa_core.verify_code(self._secret, raw): if mfa_core.verify_code(self._secret, raw):
@@ -119,7 +129,8 @@ class MFADialog(QDialog):
self._error_label.setText("Code incorrect. Réessayez.") self._error_label.setText("Code incorrect. Réessayez.")
self._code_input.clear() self._code_input.clear()
self._code_input.setStyleSheet( 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: def is_verified(self) -> bool:
+58 -7
View File
@@ -56,17 +56,68 @@ def run_command(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
return -1, "", str(e) 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(): if is_windows():
return run_command(cmd, timeout) return run_command(cmd, timeout)
if has_root_privileges(): if has_root_privileges():
return run_command(cmd, timeout) return run_command(cmd, timeout)
# Tente pkexec puis sudo
for elevator in ("pkexec", "sudo"): # 1. pkexec — dialogue graphique polkit (GNOME/KDE), timeout long pour la saisie
code, out, err = run_command([elevator] + cmd, timeout) code, out, err = run_command(["pkexec"] + cmd, timeout)
if code != -1 or "introuvable" not in err: if code != -1 or "introuvable" not in err:
return code, out, err return code, out, err
return -1, "", "Élévation de privilèges impossible"
# 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','<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: def wg_available() -> bool:
+1
View File
@@ -3,3 +3,4 @@ cryptography>=41.0.0
pyotp>=2.9.0 pyotp>=2.9.0
qrcode>=7.4.2 qrcode>=7.4.2
Pillow>=10.0.0 Pillow>=10.0.0
bcrypt>=4.0.0