diff --git a/CHANGELOG.md b/CHANGELOG.md index 44fea1d..7e2a31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [0.7.0] — 2026-09-01 + +### Ajouté +- **Instance unique.** Un second lancement (icône du menu, raccourci double-cliqué, autostart concurrent d'un lancement manuel) réveille désormais l'instance déjà active au lieu d'ouvrir une deuxième fenêtre sur le même tunnel. Implémenté via un socket local nommé (`app/core/single_instance.py`), portable Linux/Windows. +- **Lancement automatique et reconnexion automatique activés par défaut** pour une nouvelle installation (reconnexion à 15 s). Une configuration existante n'est jamais modifiée sans action explicite. +- **Page « À propos » refaite** sur le modèle QuiOulé/TuxMarket : cinq onglets (Fonctions, Garanties, Technique, Journal, Auteur), en-tête avec pastilles d'état vivantes (MFA, configuration sudoers). Contenu déplacé dans `app/app_info.py`, séparé de la présentation ; version lue dynamiquement depuis `app.__version__`. + +### Corrigé +- **Dernier handshake et compteurs RX/TX qui ne s'affichaient plus.** `wg show` exige les droits root même en lecture seule ; l'appel se faisait sans élévation et échouait silencieusement dès que l'application ne tournait plus en root (le cas normal une fois `make setup-sudoers` en place). Nouvelle fonction `run_privileged_readonly` : élévation silencieuse via `sudo -n` uniquement, jamais de dialogue sur un sondage exécuté toutes les 3 secondes. +- **Diagnostic des droits sudo trompeur.** `make check-privileges` testait chaque commande avec `--version`/`-V`, des appels qui ne correspondent à aucune règle NOPASSWD restreinte par motif (ex. `ip link delete dev *`) : le diagnostic affichait « dialogue à chaque appel » même quand tout était correctement configuré. Les sondes reproduisent maintenant un appel réel couvert par les règles installées. +- **`make setup-sudoers` restait bloqué indéfiniment** hors session graphique (SSH, tmux, terminal sans agent polkit) : `pkexec` attendait une réponse d'un agent d'authentification absent. Remplacé par `sudo`, qui invite directement dans le terminal. La cible est aussi rendue idempotente : une relance ne redemande le mot de passe que si les règles ont changé. +- **README obsolète** : retrait des mentions d'un Kill Switch iptables absent du code actuel (probablement retiré lors d'un refactor sans mise à jour de la doc) ; section sur l'élévation de privilèges corrigée pour refléter l'ordre réel (`sudo -n` → askpass → `pkexec`, et non l'inverse). + ## [0.6.0] — 2026-09-01 ### Ajouté diff --git a/Makefile b/Makefile index 0e65872..c1fa783 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.6.0 +VERSION := 0.7.0 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip @@ -193,12 +193,20 @@ print() if not rows else print(chr(10) + ' ⚠️ → lancez make setup-su @echo "" # ── Droits sudo wg-quick sans dialogue de mot de passe ─────────────────────── +# `pkexec` (utilisé jusqu'ici) exige un agent d'authentification polkit +# graphique : absent d'un terminal simple (SSH, tmux, session sans agent), il +# reste bloqué en attente d'une réponse qui n'arrive jamais. `sudo` n'a pas ce +# problème : lancé depuis un terminal, il y invite directement le mot de +# passe, avec ou sans polkit. La cible est idempotente : elle recalcule les +# règles à chaque lancement et n'invite pour un mot de passe que si le +# contenu déjà installé diffère (donc jamais lors d'une relance sans +# changement). setup-sudoers: @echo "" @echo " 🔧 Configuration sudoers (wg-quick + nettoyage réseau, sans mot de passe)…" @$(PYTHON) -c "\ import os, subprocess, tempfile, shutil; \ -user = os.environ.get('USER', os.path.basename(os.path.expanduser('~'))); \ +user = os.environ.get('SUDO_USER') or os.environ.get('USER') or os.path.basename(os.path.expanduser('~')); \ w = lambda n: shutil.which(n) or ''; \ cmds = [c for c in ( \ w('wg-quick'), \ @@ -209,14 +217,19 @@ cmds = [c for c in ( \ w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \ ) if c]; \ rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \ -fd, tmp = tempfile.mkstemp(prefix='wgsecure_sudoers_', dir=os.path.expanduser('~')); \ +dest = '/etc/sudoers.d/wgsecure'; \ +current = subprocess.run(['sudo', '-n', 'cat', dest], capture_output=True, text=True); \ +already_ok = current.returncode == 0 and current.stdout == rules; \ +print(' ✅ Déjà configuré pour ' + user + ' — rien à faire.') if already_ok else None; \ +import sys; sys.exit(0) if already_ok else None; \ +fd, tmp = tempfile.mkstemp(prefix='wgsecure_sudoers_'); \ os.write(fd, rules.encode()); os.close(fd); os.chmod(tmp, 0o600); \ chk = subprocess.run(['visudo', '-cqf', tmp], capture_output=True); \ ok = chk.returncode == 0; \ -r = subprocess.run(['pkexec', 'bash', '-c', 'install -m 440 -o root -g root ' + tmp + ' /etc/sudoers.d/wgsecure']) if ok else None; \ +r = subprocess.run(['sudo', 'install', '-m', '440', '-o', 'root', '-g', 'root', tmp, dest]) if ok else None; \ os.unlink(tmp); \ print(' ❌ Règles sudoers invalides, rien installé : ' + chk.stderr.decode().strip()) if not ok else \ -print(' ✅ /etc/sudoers.d/wgsecure configuré — plus de dialogue admin.' if r.returncode == 0 else ' ❌ Échec.')" +print(' ✅ ' + dest + ' configuré pour ' + user + ' — plus de dialogue admin.' if r.returncode == 0 else ' ❌ Échec.')" @echo "" # ── Réinitialisation du mot de passe administrateur ────────────────────────── diff --git a/README.md b/README.md index b004e8b..fa3e9fa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -🚀 v0.6.0 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard +🚀 v0.7.0 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard **WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP). @@ -20,11 +20,12 @@ | 👤 | **Multi-profils** | Sauvegarde / bascule entre profils, import/export `.conf` | | 🛡️ | **Configuration WireGuard** | Serveur, port UDP, DNS, IP client, keepalive | | 🔐 | **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 | +| 🔄 | **Auto-reconnexion** | Surveillance et reconnexion automatique si le tunnel tombe — activée par défaut (15 s) | +| 📡 | **Test de connexion** | Handshake WireGuard réel, sans monter le tunnel, avec détection du filtrage sur port source | +| 🚀 | **Démarrage automatique** | Lancement au démarrage du système — activé par défaut | +| 1️⃣ | **Instance unique** | Un second lancement réveille l'instance déjà active plutôt que d'en ouvrir une seconde | | 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) | --- @@ -110,30 +111,47 @@ python main.py --no-tray ## 🐧 Élévation de privilèges Linux -`wg-quick` nécessite les droits root pour monter/démonter le tunnel. -WGSecure tente dans l'ordre : +`wg-quick`, la lecture de l'état du tunnel (`wg show`) et le nettoyage réseau +(interface orpheline, DNS) nécessitent les droits root. WGSecure tente dans +l'ordre : -1. **pkexec** (polkit) — dialogue graphique GNOME/KDE, aucune configuration requise +1. **sudo -n** — silencieux, fonctionne si une règle NOPASSWD est en place + (voir ci-dessous) 2. **sudo -A** avec programme askpass graphique (`ssh-askpass`, etc.) -3. **sudo -n** — fonctionne si une règle NOPASSWD est en place +3. **pkexec** (polkit) — dialogue graphique GNOME/KDE ; nécessite un agent + d'authentification actif, absent d'un simple terminal (SSH, tmux) -### Supprimer la demande de mot de passe (optionnel) +Le sondage périodique de l'état du tunnel (toutes les 3 s) ne tente que +l'étape 1 : sans règle NOPASSWD, aucun dialogue n'apparaît, le handshake et +les compteurs RX/TX restent simplement vides. -Pour éviter toute invite à chaque connexion, créer une règle sudoers dédiée : +### Supprimer la demande de mot de passe (recommandé) + +```bash +make setup-sudoers +``` + +Invite le mot de passe une fois dans le terminal (via `sudo`, plus fiable que +`pkexec` hors session graphique), puis installe une règle NOPASSWD limitée à +`wg-quick`, `wg`, et au nettoyage d'une interface/DNS orpheline — jamais un +accès root généralisé. Idempotent : une relance ne redemande le mot de passe +que si le contenu a changé. `make check-privileges` affiche l'état courant. + +Pour le faire à la main : ```bash sudo visudo -f /etc/sudoers.d/wgsecure ``` -Ajouter la ligne suivante (remplacer `` par votre login) : - ``` ALL=(ALL) NOPASSWD: /usr/bin/wg-quick + ALL=(ALL) NOPASSWD: /usr/bin/wg + ALL=(ALL) NOPASSWD: /usr/sbin/ip link delete dev * + ALL=(ALL) NOPASSWD: /usr/sbin/resolvconf -d * + ALL=(ALL) NOPASSWD: /usr/bin/resolvectl revert * + ALL=(ALL) NOPASSWD: /usr/bin/install -D -m 600 -o root -g root * /etc/wireguard/* ``` -> ⚠️ 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 @@ -151,9 +169,12 @@ WGSecure/ ├── Makefile ├── app/ │ ├── __init__.py # Version +│ ├── app_info.py # ℹ️ Contenu de la page « À propos » │ ├── core/ │ │ ├── config.py # 💾 Configuration JSON persistante + bcrypt -│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut, kill switch, DNS leak +│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut, DNS leak +│ │ ├── wg_handshake.py # 🤝 Sonde de handshake réel, sans monter le tunnel +│ │ ├── single_instance.py # 1️⃣ Garde une seule instance active │ │ ├── history.py # 🕘 Historique des sessions (sessions.json) │ │ ├── log.py # 📋 Journal d'événements (events.json) │ │ └── mfa.py # 🔐 TOTP / QR Code @@ -179,7 +200,6 @@ WGSecure/ | 🗝️ 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. @@ -188,4 +208,4 @@ WGSecure/ ## 👤 Auteur Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) -Version : ![v0.6.0](https://img.shields.io/badge/v0.6.0-septembre%202026-2980b9?style=flat-square) — Septembre 2026 +Version : ![v0.7.0](https://img.shields.io/badge/v0.7.0-septembre%202026-2980b9?style=flat-square) — Septembre 2026 diff --git a/app/__init__.py b/app/__init__.py index 463115e..ecea1fa 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.6.0" +__version__ = "0.7.0" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/app_info.py b/app/app_info.py new file mode 100644 index 0000000..4c4b587 --- /dev/null +++ b/app/app_info.py @@ -0,0 +1,128 @@ +"""Contenu de la page « À propos ». + +Source de vérité unique pour les textes de la page : l'onglet Qt +(app/ui/admin_window.py, méthode `_tab_about`) ne fait que les mettre en +forme. La version, elle, vient de `app.__version__` (app/__init__.py), le +seul endroit où elle est bumpée à chaque release — jamais recopiée en dur ici. +""" + +import os + +from app import __version__ + +_CHANGELOG_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "CHANGELOG.md" +) + +APP_NAME = "WGSecure" +TAGLINE = "Client WireGuard avec surcouche MFA TOTP, pour Linux et Windows" +VERSION = __version__ +LICENSE = "Usage personnel" +REPO_URL = "https://git.tuxtech.fr/tuxgyver/WGSecure" +AUTHOR_NAME = "Johnny" +AUTHOR_ROLE = "Auteur" + +FEATURES = [ + ( + "Connexion en un clic", + "Icône dans la barre système, montage/démontage du tunnel WireGuard " + "sans quitter le systray.", + ), + ( + "Suivi en direct", + "Statut RX/TX, dernier handshake et latence affichés en continu ; " + "graphique de bande passante sur les 30 dernières secondes.", + ), + ( + "Journal et historique", + "Horodatage de chaque connexion, déconnexion et erreur ; historique " + "des sessions passées (durée, volume transféré, serveur).", + ), + ( + "Multi-profils", + "Plusieurs configurations WireGuard sauvegardées et basculables, " + "avec import/export de fichiers .conf standards.", + ), + ( + "Génération de clés", + "Paire de clés Curve25519 générée nativement en Python, sans " + "dépendre du binaire `wg` pour cette étape.", + ), + ( + "Mode Admin protégé", + "Configuration complète (serveur, DNS, IP, keepalive, MTU) derrière " + "un mot de passe et, en option, une double authentification TOTP.", + ), + ( + "Diagnostic réseau", + "Test de handshake réel sans monter le tunnel, détection du " + "filtrage sur port source, et test de fuite DNS.", + ), + ( + "Auto-reconnexion", + "Surveillance du tunnel et reconnexion automatique en cas de " + "coupure, à l'intervalle choisi.", + ), +] + +GUARANTEES_INTRO = "Ce que l'application garantit, quel que soit le profil configuré :" +GUARANTEES = [ + "Le mot de passe du mode Admin n'est jamais stocké en clair : il est " + "haché avec bcrypt (12 tours) avant d'être écrit dans la configuration.", + "La clé privée WireGuard est toujours écrite avec des permissions " + "restrictives (600) — aussi bien dans la configuration locale que dans " + "le fichier installé sous /etc/wireguard.", + "Le test de handshake et le sondage de statut (dernier handshake, " + "RX/TX) n'ouvrent jamais de tunnel et ne modifient aucun fichier : ce " + "sont des lectures ou un simple datagramme UDP jetable.", + "Les commandes système nécessitant les privilèges root sont exécutées " + "une par une, jamais l'application entière : `make setup-sudoers` " + "autorise sans mot de passe uniquement wg-quick, wg, et la suppression " + "d'interface/DNS orpheline — jamais un accès root généralisé.", + "Le sondage périodique de l'état du tunnel (toutes les 3 secondes) " + "n'ouvre jamais de dialogue de mot de passe ou d'UAC : il échoue " + "silencieusement si les privilèges nécessaires ne sont pas déjà " + "accordés.", + "Le test de fuite DNS vérifie que les requêtes sortent bien par le " + "resolver attendu une fois le tunnel monté, plutôt que de supposer la " + "configuration correcte.", +] + +TECH_STACK = [ + ("Interface", "PyQt6"), + ("Cryptographie", "cryptography (X25519, ChaCha20-Poly1305, bcrypt)"), + ("MFA", "pyotp (TOTP) + qrcode/Pillow pour le QR code"), + ("Tunnel", "wg-quick / wg (Linux), WireGuard for Windows"), + ("Élévation de privilèges", "sudo NOPASSWD ciblé, repli askpass puis pkexec"), + ("Empaquetage", "PyInstaller (Linux/Windows) + Inno Setup (installeur Windows)"), +] + + +def parse_changelog() -> list[dict]: + """Lit CHANGELOG.md : titres `## [x.y.z] — date`, sous-titres `### ...` + et puces `- ...` (avec leurs lignes de continuation) uniquement — le + format effectivement utilisé par ce fichier, sans dépendance Markdown. + """ + entries: list[dict] = [] + version = None + section = None + try: + with open(_CHANGELOG_PATH, encoding="utf-8") as f: + for raw in f: + line = raw.rstrip("\n") + if line.startswith("## "): + version = {"title": line[3:].strip(), "sections": []} + entries.append(version) + section = None + elif line.startswith("### ") and version is not None: + section = {"name": line[4:].strip(), "items": []} + version["sections"].append(section) + elif line.startswith("- ") and section is not None: + section["items"].append(line[2:].strip()) + elif (line.strip() and section is not None and section["items"] + and not line.startswith(("#", "---"))): + # Ligne de continuation d'une puce étalée sur plusieurs lignes. + section["items"][-1] += " " + line.strip() + except FileNotFoundError: + pass + return entries diff --git a/app/core/config.py b/app/core/config.py index acc3254..1886611 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -36,7 +36,7 @@ _WG_DEFAULT: dict[str, Any] = { } _DEFAULT: dict[str, Any] = { - "version": "0.6.0", + "version": "0.7.0", "admin_password_hash": "", "admin_salt": "", "mfa_enabled": False, @@ -46,10 +46,10 @@ _DEFAULT: dict[str, Any] = { "wg": deepcopy(_WG_DEFAULT), "ui": { "minimize_to_tray": True, - "autostart": False, + "autostart": True, "auto_connect_on_startup": False, - "auto_reconnect": False, - "reconnect_interval": 30, + "auto_reconnect": True, + "reconnect_interval": 15, }, } @@ -69,7 +69,11 @@ class Config: except Exception: self._data = deepcopy(_DEFAULT) else: + # Première exécution : le défaut « autostart activé » n'a d'effet + # que si on l'applique réellement (fichier .desktop / clé de + # registre), sinon la case reste cochée sans rien avoir installé. self._data = deepcopy(_DEFAULT) + self._apply_autostart(self._data["ui"]["autostart"]) def save(self): os.makedirs(os.path.dirname(self._path), exist_ok=True) diff --git a/app/core/single_instance.py b/app/core/single_instance.py new file mode 100644 index 0000000..5f5bbf1 --- /dev/null +++ b/app/core/single_instance.py @@ -0,0 +1,63 @@ +"""Garantit une seule instance de WGSecure en cours d'exécution. + +Avec le lancement automatique au démarrage désormais activé par défaut +([[wg-autostart-reconnect]]), un second lancement manuel (icône du menu, +raccourci double-cliqué) peut facilement se superposer à l'instance déjà +présente en systray — deux fenêtres sur le même tunnel, deux timers de +reconnexion concurrents. Le second lancement doit simplement réveiller le +premier et s'arrêter. + +Implémenté via un socket local nommé (QLocalServer/QLocalSocket) : le +mécanisme Qt standard pour ça, portable Linux/Windows sans dépendance +supplémentaire. Le premier lancement écoute ; tout lancement suivant s'y +connecte, signale sa présence, puis quitte. +""" + +from PyQt6.QtNetwork import QLocalServer, QLocalSocket + +_SERVER_NAME = "wgsecure-single-instance" + + +class SingleInstanceGuard: + def __init__(self): + self._server: QLocalServer | None = None + self._on_activate = None + + def try_acquire(self) -> bool: + """True si cette instance est la première et doit continuer. + + False si une autre tourne déjà : celle-ci lui a signalé sa présence + et doit se terminer sans ouvrir la moindre fenêtre. + """ + probe = QLocalSocket() + probe.connectToServer(_SERVER_NAME) + if probe.waitForConnected(200): + probe.write(b"activate") + probe.waitForBytesWritten(200) + probe.disconnectFromServer() + return False + + # Un crash précédent peut laisser le socket enregistré sans qu'aucun + # serveur ne l'écoute : listen() échouerait indéfiniment sinon. + QLocalServer.removeServer(_SERVER_NAME) + self._server = QLocalServer() + self._server.newConnection.connect(self._handle_connection) + self._server.listen(_SERVER_NAME) + return True + + def on_activate_requested(self, callback) -> None: + """`callback` (sans argument) est appelé quand un second lancement + tente de démarrer — à connecter au réaffichage de la fenêtre.""" + self._on_activate = callback + + def _handle_connection(self) -> None: + sock = self._server.nextPendingConnection() + if sock is None: + return + sock.readyRead.connect(lambda: self._consume(sock)) + + def _consume(self, sock: QLocalSocket) -> None: + sock.readAll() + if self._on_activate: + self._on_activate() + sock.disconnectFromServer() diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 53215de..7fb40c4 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -14,6 +14,7 @@ from app.utils.platform_utils import ( is_windows, run_command, run_privileged, + run_privileged_readonly, wg_available, wg_exe, wg_quick_available, @@ -150,12 +151,14 @@ def write_client_config(cfg: Config) -> tuple[bool, str]: def _wg_show(name: str) -> str: """Sortie de `wg show `, ou "" si les privilèges manquent. - Attention : sans CAP_NET_ADMIN, `wg show` écrit « Unable to access - interface: Operation not permitted » sur stderr **et sort avec le code 0**. - Un simple test `code == 0` conclut donc « déconnecté » pour tout - utilisateur non root — c'était le cas de l'application entière. + `wg show` exige CAP_NET_ADMIN (root) pour lire l'état d'une interface, + même en lecture seule — un utilisateur ordinaire reçoit « Unable to + access interface: Operation not permitted ». D'où l'appel via + `run_privileged_readonly` : sans lui, le handshake et les compteurs de + trafic restent vides dès que l'application ne tourne plus en root (le cas + normal une fois `make setup-sudoers` en place pour wg-quick). """ - code, out, err = run_command([wg_exe(), "show", name]) + code, out, err = run_privileged_readonly([wg_exe(), "show", name]) if code != 0 or not out: return "" if "not permitted" in err.lower() or "unable to access" in err.lower(): @@ -402,7 +405,7 @@ def last_handshake_age(name: str) -> int | None: Retourne -1 si aucun handshake n'a jamais eu lieu, None si l'information est inaccessible (privilèges insuffisants, `wg` absent). """ - code, out, _ = run_command([wg_exe(), "show", name, "latest-handshakes"], 10) + code, out, _ = run_privileged_readonly([wg_exe(), "show", name, "latest-handshakes"], 10) if code != 0 or not out.strip(): return None newest = 0 diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index e395a9a..e46f537 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -1,3 +1,5 @@ +import re + from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, @@ -861,69 +863,250 @@ class AdminWindow(QDialog): # Onglet À propos # ------------------------------------------------------------------ # def _tab_about(self) -> QWidget: - w = QWidget() - w.setStyleSheet(theme.TAB_CSS) - w.setAutoFillBackground(True) - layout = QVBoxLayout(w) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.setSpacing(12) - layout.setContentsMargins(30, 24, 30, 24) + """Page « À propos », en cinq sous-onglets (modèle QuiOulé/TuxMarket). - from app.ui.icons import icon_app - logo_lbl = QLabel() - logo_lbl.setPixmap(icon_app().pixmap(86, 86)) - logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(logo_lbl) + Le contenu (fonctions, garanties, pile technique, auteur) vit dans + `app/app_info.py`, séparé de sa présentation ici — le rendre + modifiable sans toucher au Qt. Le journal est lu depuis + CHANGELOG.md à l'affichage, jamais recopié. + """ + from app import app_info + w, lay = self._dark_page("ℹ️ À propos", theme.BANNER["about"]) + lay.setContentsMargins(0, 0, 0, 0) + lay.setSpacing(0) - name_lbl = QLabel("WGSecure") - name_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - f = QFont(); f.setPointSize(22); f.setBold(True) - name_lbl.setFont(f) - name_lbl.setStyleSheet("color: white;") - layout.addWidget(name_lbl) + lay.addWidget(self._about_header(app_info)) - version_lbl = QLabel("Version 0.6.0") - version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;") - layout.addWidget(version_lbl) - - sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) - sep.setStyleSheet("color: rgba(255,255,255,0.15);") - layout.addWidget(sep) - - desc_lbl = QLabel( - "Interface graphique WireGuard avec surcouche MFA TOTP.\n" - "Compatible Linux et Windows." - ) - desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - desc_lbl.setWordWrap(True) - desc_lbl.setStyleSheet("color: white; font-size: 13px;") - layout.addWidget(desc_lbl) - - sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine) - sep2.setStyleSheet("color: rgba(255,255,255,0.15);") - layout.addWidget(sep2) - - author_lbl = QLabel("Développé par") - author_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - author_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 12px;") - layout.addWidget(author_lbl) - - brand_lbl = QLabel("JT-Tools by Johnny") - brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - fb = QFont(); fb.setPointSize(15); fb.setBold(True) - brand_lbl.setFont(fb) - brand_lbl.setStyleSheet("color: #5dade2;") - layout.addWidget(brand_lbl) - - date_lbl = QLabel("Septembre 2026") - date_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - date_lbl.setStyleSheet("color: rgba(255,255,255,0.55); font-size: 12px;") - layout.addWidget(date_lbl) - - layout.addStretch() + sub = QTabWidget() + sub.setStyleSheet(theme.TABBAR_CSS) + sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions") + sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties") + sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique") + sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal") + sub.addTab(self._subtab_about_author(app_info), "👤 Auteur") + lay.addWidget(sub) return w + # ── En-tête identité : icône, nom, accroche, pastilles d'état ──────── + def _about_header(self, info) -> QWidget: + from app.ui.icons import icon_app + box = QWidget() + box.setStyleSheet(f"background: {theme.BG_RAISED};") + v = QVBoxLayout(box) + v.setContentsMargins(20, 16, 20, 14) + v.setSpacing(6) + v.setAlignment(Qt.AlignmentFlag.AlignCenter) + + logo = QLabel() + logo.setPixmap(icon_app().pixmap(72, 72)) + logo.setAlignment(Qt.AlignmentFlag.AlignCenter) + v.addWidget(logo) + + name = QLabel(info.APP_NAME) + name.setAlignment(Qt.AlignmentFlag.AlignCenter) + nf = QFont(); nf.setPointSize(18); nf.setBold(True) + name.setFont(nf) + name.setStyleSheet(f"color: {theme.TEXT};") + v.addWidget(name) + + tagline = QLabel(info.TAGLINE) + tagline.setAlignment(Qt.AlignmentFlag.AlignCenter) + tagline.setWordWrap(True) + tagline.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;") + v.addWidget(tagline) + + pills = QWidget() + pills.setStyleSheet("background: transparent;") + ph = QHBoxLayout(pills) + ph.setContentsMargins(0, 4, 0, 0) + ph.setSpacing(8) + ph.setAlignment(Qt.AlignmentFlag.AlignCenter) + for text, color in self._about_pills(info): + pill = QLabel(text) + pill.setStyleSheet( + f"background: {color}; color: white; border-radius: 9px;" + " padding: 3px 10px; font-size: 11px; font-weight: bold;" + ) + ph.addWidget(pill) + v.addWidget(pills) + return box + + def _about_pills(self, info) -> list[tuple[str, str]]: + """Pastilles de version/licence, plus l'état réel de MFA et des + droits sudo — lu en direct, jamais une valeur supposée.""" + from app.utils.platform_utils import ( + has_root_privileges, is_windows, privilege_report, + ) + pills = [ + (f"v{info.VERSION}", theme.ACCENT), + (info.LICENSE, "#5b6b7a"), + ] + if self._cfg.mfa_enabled: + pills.append(("🔐 MFA activée", theme.OK_SOLID)) + else: + pills.append(("🔐 MFA désactivée", "#5b6b7a")) + + if not is_windows(): + if has_root_privileges(): + pills.append(("⚠️ Exécuté en root", theme.WARN_SOLID)) + else: + rows = privilege_report() + if rows and all(ok for _, ok, _ in rows): + pills.append(("✅ sudoers configuré", theme.OK_SOLID)) + elif rows: + pills.append(("⚠️ dialogue à chaque appel", theme.WARN_SOLID)) + return pills + + # ── Sous-onglet : fonctions ─────────────────────────────────────────── + def _subtab_about_features(self, info) -> QWidget: + page, v = self._test_page() + area = QScrollArea() + area.setWidgetResizable(True) + area.setStyleSheet(theme.scrollarea_style()) + inner = QWidget() + inner.setStyleSheet(f"background: {theme.BG};") + iv = QVBoxLayout(inner) + iv.setContentsMargins(0, 0, 8, 0) + iv.setSpacing(10) + for title, desc in info.FEATURES: + iv.addWidget(self._feature_row(title, desc)) + iv.addStretch() + area.setWidget(inner) + v.addWidget(area) + return page + + def _feature_row(self, title: str, desc: str) -> QWidget: + row = QWidget() + row.setStyleSheet("background: transparent;") + rv = QVBoxLayout(row) + rv.setContentsMargins(0, 0, 0, 0) + rv.setSpacing(2) + t = QLabel(title) + tf = QFont(); tf.setBold(True) + t.setFont(tf) + t.setStyleSheet(f"color: {theme.TEXT};") + rv.addWidget(t) + d = QLabel(desc) + d.setWordWrap(True) + d.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;") + rv.addWidget(d) + return row + + # ── Sous-onglet : garanties ─────────────────────────────────────────── + def _subtab_about_guarantees(self, info) -> QWidget: + page, v = self._test_page() + v.addWidget(self._hint(info.GUARANTEES_INTRO)) + area = QScrollArea() + area.setWidgetResizable(True) + area.setStyleSheet(theme.scrollarea_style()) + inner = QWidget() + inner.setStyleSheet(f"background: {theme.BG};") + iv = QVBoxLayout(inner) + iv.setContentsMargins(0, 0, 8, 0) + iv.setSpacing(8) + for item in info.GUARANTEES: + lbl = QLabel("• " + item) + lbl.setWordWrap(True) + lbl.setStyleSheet(f"color: {theme.TEXT}; font-size: 12px;") + iv.addWidget(lbl) + iv.addStretch() + area.setWidget(inner) + v.addWidget(area) + return page + + # ── Sous-onglet : pile technique ────────────────────────────────────── + def _subtab_about_tech(self, info) -> QWidget: + page, v = self._test_page() + grp = QGroupBox("Pile technique") + g = QFormLayout(grp) + for layer, tool in info.TECH_STACK: + layer_lbl = QLabel(layer) + lf = QFont(); lf.setBold(True) + layer_lbl.setFont(lf) + tool_lbl = QLabel(tool) + tool_lbl.setWordWrap(True) + tool_lbl.setStyleSheet(f"color: {theme.TEXT_MUTED};") + g.addRow(layer_lbl, tool_lbl) + v.addWidget(grp) + v.addStretch() + return page + + # ── Sous-onglet : journal (lu depuis CHANGELOG.md) ──────────────────── + def _subtab_about_changelog(self, info) -> QWidget: + page, v = self._test_page() + view = QTextEdit() + view.setReadOnly(True) + view.setStyleSheet(theme.result_view_style("idle", font_size=12)) + parts = [] + for entry in info.parse_changelog(): + parts.append( + f"

{entry['title']}

" + ) + for section in entry["sections"]: + parts.append(f"{section['name']}
    ") + for item in section["items"]: + parts.append( + "
  • " + re.sub(r"\*\*(.+?)\*\*", r"\1", item) + "
  • " + ) + parts.append("
") + view.setHtml("".join(parts) or "Aucun changelog trouvé.") + v.addWidget(view) + return page + + # ── Sous-onglet : auteur ─────────────────────────────────────────────── + def _subtab_about_author(self, info) -> QWidget: + page, v = self._test_page() + v.setAlignment(Qt.AlignmentFlag.AlignCenter) + + card = QWidget() + card.setStyleSheet(f"background: {theme.BG_RAISED}; border-radius: 8px;") + cv = QVBoxLayout(card) + cv.setContentsMargins(24, 20, 24, 20) + cv.setSpacing(6) + cv.setAlignment(Qt.AlignmentFlag.AlignCenter) + + initial = QLabel(info.AUTHOR_NAME[:1].upper()) + initial.setFixedSize(56, 56) + initial.setAlignment(Qt.AlignmentFlag.AlignCenter) + initial.setStyleSheet( + f"background: {theme.ACCENT}; color: white; border-radius: 28px;" + " font-size: 22px; font-weight: bold;" + ) + cv.addWidget(initial, alignment=Qt.AlignmentFlag.AlignCenter) + + name = QLabel(info.AUTHOR_NAME) + nf = QFont(); nf.setPointSize(14); nf.setBold(True) + name.setFont(nf) + name.setAlignment(Qt.AlignmentFlag.AlignCenter) + cv.addWidget(name) + + role = QLabel(info.AUTHOR_ROLE) + role.setAlignment(Qt.AlignmentFlag.AlignCenter) + role.setStyleSheet(f"color: {theme.TEXT_MUTED};") + cv.addWidget(role) + + repo = QLineEdit(info.REPO_URL) + repo.setReadOnly(True) + repo.setAlignment(Qt.AlignmentFlag.AlignCenter) + repo.setFixedWidth(320) + cv.addWidget(repo, alignment=Qt.AlignmentFlag.AlignCenter) + + disclosure = QLabel( + "Application développée avec l'assistance de l'IA, sous la " + f"direction et la supervision de {info.AUTHOR_NAME}." + ) + disclosure.setWordWrap(True) + disclosure.setAlignment(Qt.AlignmentFlag.AlignCenter) + disclosure.setFixedWidth(320) + disclosure.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;") + cv.addWidget(disclosure) + + v.addStretch() + v.addWidget(card, alignment=Qt.AlignmentFlag.AlignCenter) + v.addStretch() + return page + # ------------------------------------------------------------------ # # Chargement / sauvegarde # ------------------------------------------------------------------ # diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 2cc0648..2467128 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -480,7 +480,7 @@ class MainWindow(QMainWindow): # Auto-reconnect timer if self._cfg.get("ui", "auto_reconnect"): - interval = int(self._cfg.get("ui", "reconnect_interval") or 30) * 1000 + interval = int(self._cfg.get("ui", "reconnect_interval") or 15) * 1000 if not self._reconnect_timer.isActive(): self._reconnect_timer.start(interval) else: diff --git a/app/utils/platform_utils.py b/app/utils/platform_utils.py index a519683..ebb1164 100644 --- a/app/utils/platform_utils.py +++ b/app/utils/platform_utils.py @@ -280,6 +280,23 @@ def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: ) +def run_privileged_readonly(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]: + """Comme `run_privileged`, mais pour un sondage périodique en lecture seule. + + N'ouvre jamais de dialogue (askpass/pkexec/UAC) : un sondage lancé toutes + les quelques secondes (état du tunnel, handshake) rendrait l'application + inutilisable si chaque appel pouvait faire apparaître une invite. Ne + fonctionne donc que si le process est déjà root/administrateur, ou si la + commande est couverte par une règle sudo NOPASSWD (`make setup-sudoers`). + Échoue silencieusement sinon, comme un manque de privilèges ordinaire. + """ + if has_root_privileges(): + return run_command(cmd, timeout) + if is_windows(): + return -1, "", "Élévation requise" + return run_command(["sudo", "-n"] + cmd, timeout, env=_c_locale_env()) + + def _find_askpass() -> str: """Cherche un programme askpass graphique disponible.""" candidates = [ @@ -302,13 +319,39 @@ def _find_askpass() -> str: # Commandes que WGSecure lance avec des privilèges, et ce qu'elles servent. -_PRIVILEGED_COMMANDS = ( - (["wg-quick", "--help"], "monter / démonter le tunnel"), - (["install", "--version"], "écrire la config dans /etc/wireguard"), - (["ip", "-V"], "supprimer une interface orpheline"), - (["resolvconf", "--version"], "retirer l'entrée DNS du tunnel"), - (["resolvectl", "--version"], "purger le DNS systemd-resolved"), -) +# +# `make setup-sudoers` installe des règles NOPASSWD restreintes à des motifs +# d'arguments précis (ex. `ip link delete dev *`), jamais à la commande nue. +# Les sondes doivent donc reproduire un appel réel qui matche ce motif : +# tester avec `--version`/`-V` interroge un appel que sudoers n'autorise +# jamais, et affiche « dialogue à chaque appel » même quand tout est +# correctement configuré. +_PRIVILEGE_PROBE_IFACE = "wgsecure-privcheck0" + + +def _install_probe_cmd() -> list[str]: + """Reproduit le motif `install -D -m 600 -o root -g root * /etc/wireguard/*`. + + Écrit un fichier vide et sans conséquence (`.wgsecure-privcheck`, jamais + lu par wg-quick) : c'est le seul moyen fiable de tester ce motif, `sudo + -n -l` ne le pouvant pas (cf. `_sudo_refused_auth`). + """ + dest = os.path.join(get_wg_config_dir(), ".wgsecure-privcheck") + return ["install", "-D", "-m", "600", "-o", "root", "-g", "root", + "/dev/null", dest] + + +def _privileged_commands() -> tuple[tuple[list[str], str], ...]: + return ( + (["wg-quick", "--help"], "monter / démonter le tunnel"), + (_install_probe_cmd(), "écrire la config dans /etc/wireguard"), + (["ip", "link", "delete", "dev", _PRIVILEGE_PROBE_IFACE], + "supprimer une interface orpheline"), + (["resolvconf", "-d", _PRIVILEGE_PROBE_IFACE], + "retirer l'entrée DNS du tunnel"), + (["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE], + "purger le DNS systemd-resolved"), + ) def privilege_report() -> list[tuple[str, bool, str]]: @@ -322,7 +365,7 @@ def privilege_report() -> list[tuple[str, bool, str]]: report: list[tuple[str, bool, str]] = [] if is_windows() or has_root_privileges(): return report - for probe, role in _PRIVILEGED_COMMANDS: + for probe, role in _privileged_commands(): if not _sh.which(probe[0]): continue code, _, err = run_command(["sudo", "-n"] + probe, 8, env=_c_locale_env()) diff --git a/main.py b/main.py index d0eab69..ac11d3c 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 """ WGSecure (WGS) — Interface graphique WireGuard avec MFA -Version 0.6.0 """ import sys import argparse @@ -9,10 +8,12 @@ import argparse from PyQt6.QtWidgets import QApplication, QMessageBox, QInputDialog, QLineEdit, QSystemTrayIcon from PyQt6.QtCore import Qt +from app import __version__ from app.core.config import Config from app.core import dns as dns_util from app.core import log as conn_log from app.core import shutdown as shutdown_guard +from app.core.single_instance import SingleInstanceGuard from app.ui.main_window import MainWindow from app.ui.systray import SystemTray from app.ui import icons @@ -92,8 +93,14 @@ def main(): ) app = QApplication(sys.argv) app.setApplicationName("WGSecure") - app.setApplicationVersion("0.6.0") + app.setApplicationVersion(__version__) app.setOrganizationName("WGS") + + # Une seule instance à la fois : un second lancement réveille la + # première plutôt que d'ouvrir une deuxième fenêtre sur le même tunnel. + guard = SingleInstanceGuard() + if not guard.try_acquire(): + sys.exit(0) # Icône globale multi-tailles — barre des tâches + alt-tab app_icon = icons.icon_app() app.setWindowIcon(app_icon) @@ -122,10 +129,12 @@ def main(): win = AdminWindow(config) win.setWindowIcon(icons.icon_admin()) win.show() + guard.on_activate_requested(lambda: _raise_window(win)) sys.exit(app.exec()) # Mode User (défaut) window = MainWindow(config) + guard.on_activate_requested(lambda: _raise_window(window)) # Systray if not args.no_tray and QSystemTrayIcon_available(): @@ -148,6 +157,13 @@ def main(): sys.exit(app.exec()) +def _raise_window(window) -> None: + """Ramène une fenêtre existante au premier plan (second lancement).""" + window.showNormal() + window.raise_() + window.activateWindow() + + def QSystemTrayIcon_available() -> bool: from PyQt6.QtWidgets import QSystemTrayIcon return QSystemTrayIcon.isSystemTrayAvailable() diff --git a/wgsecure.iss b/wgsecure.iss index 93ebde0..2ebd34f 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -4,7 +4,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.6.0" +#define MyAppVersion "0.7.0" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"