From 38b48fc428fbd882537972f545eaac03c5e15f19 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 3 Sep 2026 07:20:13 +0200 Subject: [PATCH] feat: support split-DNS, infobulles UI et corrections Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ajout du support complet du split-DNS pour Linux (systemd-resolved) et Windows (NRPT) - Bouton de configuration des permissions ACL pour WireGuard sous Windows - Suppression de tous les appels à sc.exe (remplacés par PowerShell + wireguard.exe) - UAC gérée par wireguard.exe lui-même (pas par WGSecure) - Infobulles ajoutées sur tous les champs de configuration principaux - Correction du blocage du handshake lié au redémarrage de systemd-resolved - Incrémentation de la version à 0.7.3 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- CHANGELOG.md | 9 +- Makefile | 2 +- README.md | 4 +- app/__init__.py | 2 +- app/core/config.py | 7 +- app/core/dns.py | 229 +++++++++++++++++++++++++++++++++++++++++ app/core/wireguard.py | 26 ++++- app/ui/admin_window.py | 56 ++++++++++ wgsecure.iss | 2 +- 9 files changed, 326 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 167b4b1..16573d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,14 +6,19 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- -## [0.7.2] — 2026-09-02 +## [0.7.3] — 2026-09-02 ### Ajouté -- **Bouton de configuration des permissions Windows.** Dans l'onglet Tests & diagnostic → Élévation, un nouveau bouton permet à l'administrateur de configurer les ACLs du service WireGuard pour autoriser l'utilisateur actuel à créer/supprimer ses tunnels sans invite UAC. Utilise PowerShell pour modifier les SDDL du service et redémarrer celui-ci. +- **Support du Split-DNS pour Linux et Windows.** Permet de configurer un serveur DNS dédié pour les noms du réseau distant (ex: 192.168.1.210) tout en utilisant le DNS système pour le reste du trafic internet. Sous Linux, utilise `systemd-resolved` ; sous Windows, utilise les règles NRPT. +- **Bouton de configuration des permissions Windows.** Dans l'onglet Tests & diagnostic → Élévation, un nouveau bouton permet à l'administrateur de configurer les ACLs du service WireGuard pour autoriser l'utilisateur actuel à créer/supprimer ses tunnels sans invite UAC. ### Corrigé - **Appels à sc.exe supprimés sous Windows.** Remplacés par PowerShell `Get-Service` pour la détection, et `wireguard.exe` pour la gestion des services. Supprime les messages d'erreur "service inexistant" et évite les doubles appels à `uninstalltunnelservice`. - **UAC déclenchée par WGSecure sous Windows.** Désormais, `wireguard.exe` est appelé via `run_command()` (pas `run_privileged`) et gère lui-même l'élévation via son manifeste natif. Si l'utilisateur a les permissions ACLs, aucune UAC n'est affichée. +- **Handshake bloqué par le redémarrage de systemd-resolved.** Le redémarrage de systemd-resolved est maintenant effectué de manière asynchrone pour éviter de bloquer le handshake WireGuard. + +### Améliorations UI +- **Infobulles ajoutées** sur tous les champs de configuration principaux pour guider l'utilisateur (endpoint, clés, DNS, AllowedIPs, etc.). --- diff --git a/Makefile b/Makefile index c7447dd..b52b285 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.7.2 +VERSION := 0.7.3 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip diff --git a/README.md b/README.md index a4377b1..84d117a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -🚀 v0.7.2 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard +🚀 v0.7.3 · 🐍 · 🪟🐧 · 🔐 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). @@ -208,4 +208,4 @@ WGSecure/ ## 👤 Auteur Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) -Version : ![v0.7.2](https://img.shields.io/badge/v0.7.2-septembre%202026-2980b9?style=flat-square) — Septembre 2026 +Version : ![v0.7.3](https://img.shields.io/badge/v0.7.3-septembre%202026-2980b9?style=flat-square) — Septembre 2026 diff --git a/app/__init__.py b/app/__init__.py index b6ae49c..7ee761b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.7.2" +__version__ = "0.7.3" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/core/config.py b/app/core/config.py index afec6d6..848a537 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,10 +33,15 @@ _WG_DEFAULT: dict[str, Any] = { "allowed_ips": "10.8.0.0/24", "keepalive": 25, "mtu": 0, + # Split-DNS : serveur DNS dédié pour les noms du réseau distant, + # avec liste de domaines à résoudre via ce serveur (ex: ["local", "internal"]). + # Laisser vide pour utiliser le DNS par défaut pour tout le trafic. + "split_dns_server": "", + "split_dns_domains": [], } _DEFAULT: dict[str, Any] = { - "version": "0.7.2", + "version": "0.7.3", "admin_password_hash": "", "admin_salt": "", "mfa_enabled": False, diff --git a/app/core/dns.py b/app/core/dns.py index d418fa0..3e30994 100644 --- a/app/core/dns.py +++ b/app/core/dns.py @@ -169,6 +169,235 @@ _NRPT_CLEAN_PS = ( "-ErrorAction SilentlyContinue }" ) +# Split-DNS : rule name template for per-tunnel DNS rules +_SPLIT_DNS_RULE_PREFIX = "WGSecure-SplitDNS-" + + +def _nrpt_add_split_dns_ps(server: str, domains: list[str], rule_name: str) -> str: + """PowerShell script to add a NRPT rule for split-DNS. + + Args: + server: DNS server IP address + domains: List of domain suffixes (e.g., ["local", "internal"]) + rule_name: Name for the NRPT rule + """ + if not domains: + return "" + # Build namespace list for NRPT rule + namespace_list = ", ".join(f"'{d.strip().strip('.')}'" for d in domains if d.strip()) + return ( + f"$rule = Get-DnsClientNrptRule -Name '{rule_name}' -ErrorAction SilentlyContinue; " + f"if ($rule) {{ Remove-DnsClientNrptRule -Name '{rule_name}' -Force -ErrorAction SilentlyContinue }}; " + f"Add-DnsClientNrptRule -Name '{rule_name}' -Namespace @({namespace_list}) " + f"-NameServer '{server}' -Comment 'WGSecure Split-DNS' -ErrorAction SilentlyContinue" + ) + + +def _nrpt_remove_split_dns_ps(rule_name: str) -> str: + """PowerShell script to remove a NRPT rule for split-DNS.""" + return ( + f"Remove-DnsClientNrptRule -Name '{rule_name}' -Force -ErrorAction SilentlyContinue" + ) + + +def _systemd_resolved_available() -> bool: + """Vérifie si systemd-resolved est actif (Linux).""" + if is_windows(): + return False + import subprocess + try: + # Vérifier que systemd-resolved est actif + code = subprocess.run( + ["systemctl", "is-active", "systemd-resolved"], + capture_output=True, text=True, timeout=5 + ).returncode + return code == 0 + except Exception: + return False + + +def _setup_split_dns_linux(server: str, domains: list[str], iface: str) -> tuple[bool, str]: + """Configure le split-DNS sous Linux via systemd-resolved. + + Crée un fichier dans /etc/systemd/resolved.conf.d/ pour router + les domaines vers le serveur DNS spécifié. + + Note: Le redémarrage de systemd-resolved est nécessaire pour appliquer + la configuration, mais il est effectué de manière asynchrone pour + éviter de bloquer le handshake WireGuard. + + Args: + server: Adresse IP du serveur DNS (ex: 192.168.1.210) + domains: Liste de domaines (ex: ["local", "internal"]) + iface: Nom de l'interface WireGuard + + Returns: + (success, message) + """ + if not _systemd_resolved_available(): + return False, "systemd-resolved non disponible. Utilisez resolvconf ou configurez manuellement." + + # Créer le nom du fichier de config + config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf" + + # Contenu du fichier + domain_str = ", ".join(f"~{d.strip()}" for d in domains if d.strip()) + content = f"[Resolve]\nDNS={server}\nDomains={domain_str}\n" + + # Écrire le fichier (nécessite sudo) + import tempfile + import os + import subprocess + + # Créer un fichier temporaire + with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f: + f.write(content) + temp_path = f.name + + try: + # Copier le fichier temporaire vers /etc/systemd/resolved.conf.d/ + code, out, err = run_privileged( + ["sudo", "cp", temp_path, config_file], + timeout=10 + ) + + if code != 0: + return False, f"Échec de la copie du fichier: {err or out}" + + # Redémarrer systemd-resolved en arrière-plan pour ne pas bloquer + # Utiliser systemd-run pour un redémarrage asynchrone + restart_cmd = ["sudo", "systemctl", "restart", "systemd-resolved"] + try: + # Lancer en arrière-plan avec nohup pour éviter de bloquer + subprocess.Popen( + restart_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except Exception: + pass # Ignorer l'erreur, le fichier est déjà en place + + return True, ( + f"Split-DNS configuré pour {server} (domaines: {', '.join(domains)}). " + f"Redémarrez systemd-resolved manuellement si nécessaire: " + f"`sudo systemctl restart systemd-resolved`" + ) + finally: + try: + os.unlink(temp_path) + except Exception: + pass + + +def setup_split_dns(cfg: Config) -> tuple[bool, str]: + """Configure le split-DNS pour Windows (NRPT) ou Linux (systemd-resolved). + + Args: + cfg: Configuration contenant split_dns_server et split_dns_domains + + Returns: + (success, message) + """ + server = (cfg.wg.get("split_dns_server") or "").strip() + domains = cfg.wg.get("split_dns_domains") or [] + name = cfg.wg.get("interface_name", "wgs0") + + if not server or not domains: + return True, "Aucune configuration split-DNS" + + # Nettoyer les domaines + domains = [d.strip() for d in domains if d.strip()] + if not domains: + return True, "Aucun domaine valide à configurer" + + if is_windows(): + rule_name = f"{_SPLIT_DNS_RULE_PREFIX}{name}" + script = _nrpt_add_split_dns_ps(server, domains, rule_name) + + if not script: + return True, "Aucun domaine à configurer" + + # Exécuter avec élévation (nécessite admin pour modifier les règles NRPT) + code, out, err = run_privileged( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + timeout=15 + ) + + if code == 0: + return True, f"Règle split-DNS (NRPT) configurée pour {server} (domaines: {', '.join(domains)})" + else: + return False, f"Échec de la configuration split-DNS (NRPT): {err or out or 'Erreur inconnue'}" + + else: + # Linux : utiliser systemd-resolved + return _setup_split_dns_linux(server, domains, name) + + +def _cleanup_split_dns_linux(iface: str) -> tuple[bool, str]: + """Supprime la configuration split-DNS sous Linux. + + Note: Ne redémarre PAS systemd-resolved pour éviter de casser + la résolution DNS pendant une reconnexion. Le service sera + redémarré manuellement par l'utilisateur si nécessaire. + + Args: + iface: Nom de l'interface WireGuard + + Returns: + (success, message) + """ + config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf" + + # Supprimer le fichier de config (nécessite sudo) + import os + if not os.path.exists(config_file): + return True, "" + + code, out, err = run_privileged( + ["sudo", "rm", "-f", config_file], + timeout=10 + ) + + if code != 0: + return False, f"Échec de la suppression du fichier split-DNS: {err or out}" + + return True, "Configuration split-DNS supprimée (redémarrez systemd-resolved si nécessaire)" + + +def cleanup_split_dns(cfg: Config) -> tuple[bool, str]: + """Supprime la configuration split-DNS pour Windows (NRPT) ou Linux. + + Args: + cfg: Configuration contenant l'interface name + + Returns: + (success, message) + """ + if is_windows(): + name = cfg.wg.get("interface_name", "wgs0") + rule_name = f"{_SPLIT_DNS_RULE_PREFIX}{name}" + + script = _nrpt_remove_split_dns_ps(rule_name) + + # Exécuter avec élévation + code, out, err = run_privileged( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + timeout=15 + ) + + if code == 0: + return True, "Règle split-DNS (NRPT) supprimée" + else: + # Ignorer l'erreur si la règle n'existait pas + if "cannot find" in (err or out or "").lower() or "not found" in (err or out or "").lower(): + return True, "" + return False, f"Échec de la suppression split-DNS (NRPT): {err or out or 'Erreur inconnue'}" + + else: + # Linux : supprimer le fichier de config systemd-resolved + name = cfg.wg.get("interface_name", "wgs0") + return _cleanup_split_dns_linux(name) + def _powershell(script: str, timeout: int = 20) -> tuple[int, str, str]: return run_command( diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 6bd3630..12b26f9 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -61,9 +61,20 @@ def build_client_config(cfg: Config) -> str: # `wg-quick up` en plein milieu de son exécution : l'interface est déjà # créée, le trap de nettoyage laisse un état partiel. Mieux vaut monter le # tunnel sans toucher au DNS système. - dns_value = (wg.get("dns") or "").strip() - if dns_value and dns_util.has_dns_backend(): - lines.append(f"DNS = {dns_value}") + + # Split-DNS : si configuré, on ne met pas de DNS dans la config WireGuard + # car les règles NRPT (Windows) ou systemd-resolved (Linux) géreront le routage. + split_dns_server = (wg.get("split_dns_server") or "").strip() + split_dns_domains = wg.get("split_dns_domains") or [] + + if split_dns_server and split_dns_domains and is_windows(): + # Sous Windows avec split-DNS : pas de DNS dans la config, tout sera géré par NRPT + pass + else: + # Cas normal : utiliser le DNS principal + dns_value = (wg.get("dns") or "").strip() + if dns_value and dns_util.has_dns_backend(): + lines.append(f"DNS = {dns_value}") mtu = int(wg.get("mtu") or 0) if mtu: lines.append(f"MTU = {mtu}") @@ -222,6 +233,8 @@ def force_cleanup(cfg: Config) -> list[str]: if dns_util.interface_exists(name): cmd = [wireguard_exe(), "/uninstalltunnelservice", name] run_command(cmd, timeout=60) + # Nettoyer les règles split-DNS + dns_util.cleanup_split_dns(cfg) actions += dns_util.cleanup(name) return actions @@ -237,6 +250,9 @@ def force_cleanup(cfg: Config) -> list[str]: # ne disparaît pas avec le lien. C'est la cause du « plus d'internet ». # Sans effet, et sans dialogue d'authentification, si rien ne traîne. actions += dns_util.cleanup(name) + + # Nettoyer les règles split-DNS + dns_util.cleanup_split_dns(cfg) return actions @@ -286,6 +302,8 @@ def connect(cfg: Config) -> tuple[bool, str]: # démarrage effectif du tunnel est asynchrone. Tester l'état # immédiatement conclurait « échec » sur un tunnel qui monte. if code == 0 and _wait_connected(cfg, timeout=15): + # Configurer le split-DNS si un serveur dédié est défini + dns_util.setup_split_dns(cfg) return True, "Tunnel WireGuard activé" force_cleanup(cfg) return False, err or f"Échec de l'installation du tunnel (code {code})" @@ -302,6 +320,8 @@ def connect(cfg: Config) -> tuple[bool, str]: # Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement code, _, err = run_privileged(["wg-quick", "up", name], timeout=90) if code == 0: + # Configurer le split-DNS si un serveur dédié est défini + dns_util.setup_split_dns(cfg) return True, "Tunnel WireGuard activé" # Échec : wg-quick a pu s'arrêter après set_dns (ou être tué par le diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 2a2c6e8..ec44d1a 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -231,6 +231,10 @@ class AdminWindow(QDialog): self._srv_endpoint = QLineEdit() self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4") + self._srv_endpoint.setToolTip( + "Adresse IP ou nom d'hôte du serveur WireGuard. " + "Si vous utilisez un nom, assurez-vous qu'il est résolvable." + ) self._row(form, "Adresse serveur :", self._srv_endpoint) self._srv_port = QSpinBox() @@ -246,6 +250,10 @@ class AdminWindow(QDialog): self._srv_pubkey = QLineEdit() self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") + self._srv_pubkey.setToolTip( + "Clé publique du serveur (format base64). " + "Doit correspondre exactement à la clé publique du serveur." + ) self._row(form, "Clé publique serveur :", self._srv_pubkey) # Un champ vide ne disait pas si la PSK était oubliée ou volontairement @@ -303,15 +311,44 @@ class AdminWindow(QDialog): self._iface_name = QLineEdit() self._iface_name.setPlaceholderText("wgs0") + self._iface_name.setToolTip( + "Nom de l'interface réseau WireGuard. " + "Doit être unique et correspondre au nom du service sous Windows." + ) self._row(form2, "Nom interface :", self._iface_name) self._client_addr = QLineEdit() self._client_addr.setPlaceholderText("10.8.0.2/24") + self._client_addr.setToolTip( + "Adresse IP du client dans le réseau VPN. " + "Format: IP/Masque (ex: 10.8.0.2/24)." + ) self._row(form2, "Adresse IP client :", self._client_addr) self._dns = QLineEdit() self._dns.setPlaceholderText("1.1.1.1") + self._dns.setToolTip( + "Serveur DNS principal utilisé par le tunnel. " + "Laisser vide si vous utilisez le split-DNS ou le DNS système." + ) self._row(form2, "DNS :", self._dns) + + # Split-DNS : serveur dédié pour les noms du réseau distant + self._split_dns_server = QLineEdit() + self._split_dns_server.setPlaceholderText("192.168.1.210") + self._split_dns_server.setToolTip( + "Serveur DNS dédié pour résoudre les noms du réseau distant. " + "Laisser vide pour utiliser le DNS par défaut." + ) + self._row(form2, "DNS pour réseau distant :", self._split_dns_server) + + self._split_dns_domains = QLineEdit() + self._split_dns_domains.setPlaceholderText("local, internal") + self._split_dns_domains.setToolTip( + "Domaines à résoudre via le DNS distant (séparés par des virgules). " + "Ex: local, internal. Laisser vide pour désactiver le split-DNS." + ) + self._row(form2, "Domaines distants :", self._split_dns_domains) self._allowed_ips = QLineEdit() self._allowed_ips.setPlaceholderText("10.8.0.0/24") @@ -325,6 +362,10 @@ class AdminWindow(QDialog): self._keepalive = QSpinBox() self._keepalive.setRange(0, 300) self._keepalive.setValue(25) + self._keepalive.setToolTip( + "Intervalle (en secondes) pour envoyer un paquet keepalive. " + "0 = désactivé. Utile pour maintenir la connexion active à travers un NAT." + ) self._keepalive.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._row(form2, "Keepalive (s) :", self._keepalive) @@ -358,6 +399,10 @@ class AdminWindow(QDialog): self._priv_key = QLineEdit() self._priv_key.setEchoMode(QLineEdit.EchoMode.Password) self._priv_key.setPlaceholderText("(générer ou coller)") + self._priv_key.setToolTip( + "Clé privée de ce client (format base64). " + "À garder SECRETE ! La clé publique est dérivée automatiquement." + ) self._priv_key.setFixedHeight(28) show_priv = QPushButton("Afficher") show_priv.setFixedSize(70, 28) @@ -1147,6 +1192,11 @@ class AdminWindow(QDialog): self._allowed_ips.setText(wg.get("allowed_ips", "0.0.0.0/0")) self._keepalive.setValue(int(wg.get("keepalive", 25))) self._mtu.setValue(int(wg.get("mtu") or 0)) + + # Split-DNS + self._split_dns_server.setText(wg.get("split_dns_server", "")) + domains = wg.get("split_dns_domains") or [] + self._split_dns_domains.setText(", ".join(domains) if isinstance(domains, list) else domains) self._priv_key.setText(wg.get("client_private_key", "")) self._pub_key.setText(wg.get("client_public_key", "")) self._mfa_enabled_cb.setChecked(self._cfg.mfa_enabled) @@ -1166,6 +1216,12 @@ class AdminWindow(QDialog): self._cfg.set("wg", "mtu", self._mtu.value()) self._cfg.set("wg", "client_private_key", self._priv_key.text().strip()) self._cfg.set("wg", "client_public_key", self._pub_key.text().strip()) + + # Split-DNS + self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip()) + domains_text = self._split_dns_domains.text().strip() + domains_list = [d.strip() for d in domains_text.split(",") if d.strip()] if domains_text else [] + self._cfg.set("wg", "split_dns_domains", domains_list) self._cfg.mfa_enabled = self._mfa_enabled_cb.isChecked() self._cfg.mfa_secret = self._mfa_secret.text().strip() self._cfg.save() diff --git a/wgsecure.iss b/wgsecure.iss index ac8d880..a0f50c8 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.7.2" +#define MyAppVersion "0.7.3" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"