From 7beb6deb7a9db745382c0b3a2af320e244a1a9f6 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 4 Sep 2026 18:15:08 +0200 Subject: [PATCH] chore(release): v0.7.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute wgsecure-helper, un service Windows privilégié (LocalSystem) qui installe/désinstalle le tunnel et pose/retire les règles NRPT sans invite d'authentification répétée. L'ACL du service WireGuard (v0.7.6) ne délègue que démarrer/arrêter un tunnel déjà installé, jamais le créer/supprimer, ce que WGSecure fait pourtant à chaque connexion/déconnexion — d'où les prompts qui persistaient malgré v0.7.6-v0.7.11. Corrige aussi le handshake, qui échouait pour la même raison (wg show sans élévation complète). Purement additif : l'app retombe sur son chemin élevé existant si le service est absent (composant optionnel dans l'installeur). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 8 + Makefile | 35 +++- README.md | 4 +- app/__init__.py | 2 +- app/core/dns.py | 39 ++++- app/core/wireguard.py | 51 +++++- app/service/__init__.py | 0 app/service/helper_service.py | 299 ++++++++++++++++++++++++++++++++++ app/utils/helper_ipc.py | 95 +++++++++++ requirements-helper.txt | 1 + wgsecure.iss | 28 +++- 11 files changed, 546 insertions(+), 16 deletions(-) create mode 100644 app/service/__init__.py create mode 100644 app/service/helper_service.py create mode 100644 app/utils/helper_ipc.py create mode 100644 requirements-helper.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f8e713..423a026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [0.7.12] — 2026-09-04 + +### Ajouté +- **Service Windows privilégié `wgsecure-helper`.** Élimine les authentifications admin restantes à la connexion/déconnexion pour un compte non-admin. Tourne en LocalSystem, exécutable séparé jamais chargé par l'app principale ; expose cinq commandes fixes (`install_tunnel`, `uninstall_tunnel`, `set_split_dns`, `clear_split_dns`, `get_status`) via un pipe nommé sécurisé (SDDL explicite), chacune validant strictement ses paramètres — jamais de script/commande arbitraire accepté. L'app principale tente ce service en premier (client `ctypes` pur, sans nouvelle dépendance) et retombe sur le chemin élevé existant s'il est absent : purement additif, installation optionnelle. Corrige au passage le handshake qui ne se rafraîchissait jamais (`wg show` échouait systématiquement sans élévation complète, même avec les délégations de la v0.7.6). +- L'ACL du service WireGuard (bouton « Configurer les permissions ») ne délègue que démarrer/arrêter un tunnel déjà installé, jamais le créer/supprimer — WGSecure réinstalle pourtant le tunnel à chaque connexion/déconnexion. C'est ce qui rendait les invites d'authentification inévitables malgré la v0.7.6 à v0.7.11 ; le nouveau service couvre cette étape. + +--- + ## [0.7.11] — 2026-09-04 ### Corrigé diff --git a/Makefile b/Makefile index d559398..9ad10e9 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.7.11 +VERSION := 0.7.12 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip @@ -27,7 +27,7 @@ PI_OPTS := \ --hidden-import cryptography.hazmat.primitives.asymmetric.x25519 \ --collect-submodules PyQt6 -.PHONY: all linux windows installer-deps release publish version changelog install install-gnome uninstall-gnome run run-admin icon reset-password setup-sudoers check-privileges clean clean-all help venv +.PHONY: all linux windows helper installer-deps release publish version changelog install install-gnome uninstall-gnome run run-admin icon reset-password setup-sudoers check-privileges clean clean-all help venv # ── Cible par défaut ──────────────────────────────────────────────────────── all: linux @@ -108,6 +108,36 @@ windows: icon @echo " ✅ Binaire Windows → $(DIST)/$(APP).exe" @echo "" +# ── Service Windows privilégié (wgsecure-helper.exe) ───────────────────────── +# Exécutable séparé de wgsecure.exe : installe/désinstalle le tunnel et pose/ +# retire les règles NRPT (split-DNS) en LocalSystem, pour éviter une invite +# d'authentification à chaque connexion/déconnexion (cf. app/service/). Utilise +# pywin32, installé uniquement dans ce venv Wine — jamais dans .venv (Linux) ni +# dans les dépendances de wgsecure.exe. Cible explicite : ni `windows` ni +# `release` ne la déclenchent implicitement, ce composant restant optionnel. +# --hidden-import win32timezone : dépendance dynamique de pywin32 que +# l'analyse statique de PyInstaller ne détecte pas seule (service qui +# démarre mais plante à l'exécution sans ce forçage — cas connu). +helper: + @echo "" + @echo " 🛡️ Compilation du service (Wine + PyInstaller)…" + @echo "" + @which wine > /dev/null 2>&1 \ + || (echo " ❌ Wine non installé → sudo apt install wine"; exit 1) + @test -f "$(WINE_PYTHON)" \ + || (echo " ❌ Python Windows introuvable dans Wine → make setup-wine"; exit 1) + @wine $(WINE_PYTHON) -m pip install -r requirements-helper.txt --quiet + @wine $(WINE_PYTHON) -m PyInstaller \ + --noconfirm \ + --clean \ + --name wgsecure-helper \ + --onefile \ + --hidden-import win32timezone \ + app/service/helper_service.py + @echo "" + @echo " ✅ Service → $(DIST)/wgsecure-helper.exe" + @echo "" + # ── Prérequis de l'installeur Windows (wgsecure.iss) ───────────────────────── # WireGuard n'est pas empaqueté avec WGSecure (c'est wg.exe et le pilote # tunnel qui manquent, pas une simple DLL) : wgsecure.iss l'installe donc en @@ -314,6 +344,7 @@ help: @echo " ║ make install Installe les dépendances ║" @echo " ║ make linux Binaire Linux (onefile) ║" @echo " ║ make windows Binaire Windows (Wine) ║" + @echo " ║ make helper Service Windows (Wine) ║" @echo " ║ make installer-deps Prérequis wgsecure.iss ║" @echo " ║ make release Linux + nommage release ║" @echo " ║ make publish Tague + publie sur Gitea ║" diff --git a/README.md b/README.md index 1458614..eacfbba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -🚀 v0.7.11 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard +🚀 v0.7.12 · 🐍 · 🪟🐧 · 🔐 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). @@ -210,4 +210,4 @@ WGSecure/ ## 👤 Auteur Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) -Version : ![v0.7.11](https://img.shields.io/badge/v0.7.11-septembre%202026-2980b9?style=flat-square) — Septembre 2026 +Version : ![v0.7.12](https://img.shields.io/badge/v0.7.12-septembre%202026-2980b9?style=flat-square) — Septembre 2026 diff --git a/app/__init__.py b/app/__init__.py index 0c6b6da..c7dc049 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.7.11" +__version__ = "0.7.12" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/core/dns.py b/app/core/dns.py index a8bbf7d..31681f1 100644 --- a/app/core/dns.py +++ b/app/core/dns.py @@ -24,6 +24,7 @@ import os import shutil import socket +from app.utils.helper_ipc import call_helper from app.utils.platform_utils import is_windows, run_command, run_privileged # Domaine utilisé pour vérifier que la résolution DNS fonctionne encore. @@ -282,22 +283,35 @@ def setup_split_dns(cfg: Config) -> tuple[bool, str]: if is_windows(): rule_name = f"{_SPLIT_DNS_RULE_PREFIX}{name}" + + # Délègue à wgsecure-helper (service SYSTEM) s'il est installé : la + # règle NRPT est posée sans invite d'authentification. `None` = + # service absent/indisponible, on retombe sur le chemin élevé. + resp = call_helper({ + "cmd": "set_split_dns", "server": server, + "domains": domains, "rule_name": rule_name, + }) + if resp is not None: + if resp.get("ok"): + return True, f"Règle split-DNS (NRPT) configurée pour {server} (domaines: {', '.join(domains)})" + return False, f"Échec de la configuration split-DNS (NRPT): {resp.get('message') or 'Erreur inconnue'}" + 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) @@ -331,15 +345,24 @@ def cleanup_split_dns(cfg: Config) -> tuple[bool, str]: if is_windows(): name = cfg.wg.get("interface_name", "wgs0") rule_name = f"{_SPLIT_DNS_RULE_PREFIX}{name}" - + + resp = call_helper({"cmd": "clear_split_dns", "rule_name": rule_name}) + if resp is not None: + if resp.get("ok"): + return True, "Règle split-DNS (NRPT) supprimée" + msg = resp.get("message") or "" + if "cannot find" in msg.lower() or "not found" in msg.lower(): + return True, "" + return False, f"Échec de la suppression split-DNS (NRPT): {msg or 'Erreur inconnue'}" + 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: @@ -347,7 +370,7 @@ def cleanup_split_dns(cfg: Config) -> tuple[bool, str]: 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") diff --git a/app/core/wireguard.py b/app/core/wireguard.py index fa18fcc..ab40bf6 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -7,6 +7,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from app.core import dns as dns_util from app.core import wg_handshake from app.core.config import Config +from app.utils.helper_ipc import call_helper from app.utils.platform_utils import ( get_config_dir, has_root_privileges, @@ -168,7 +169,14 @@ def _wg_show(name: str) -> str: `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). + + Sous Windows, `wg.exe show` échoue même avec les délégations existantes + (ACL du service, LimitedOperatorUI) — délègue donc d'abord à + wgsecure-helper (service SYSTEM), lecture seule, si disponible. """ + resp = call_helper({"cmd": "get_status", "name": name}) + if resp is not None: + return resp.get("message") or "" if resp.get("ok") else "" code, out, err = run_privileged_readonly([wg_exe(), "show", name]) if code != 0 or not out: return "" @@ -213,6 +221,36 @@ def interface_exists(cfg: Config) -> bool: return dns_util.interface_exists(cfg.wg.get("interface_name", "wgs0")) +def _try_helper_tunnel_cmd(cmd: list[str]) -> tuple[int, str, str] | None: + """Délègue install/uninstalltunnelservice à wgsecure-helper (service + SYSTEM) s'il est installé — aucune invite d'authentification, y compris + sans ACL accordée. `None` si le service est absent/indisponible : + l'appelant bascule alors sur le chemin existant (ACL puis élévation). + + L'ACL du service WireGuard (bouton « Configurer les permissions ») ne + délègue que démarrer/arrêter un tunnel déjà installé, jamais le créer/ + supprimer — `/installtunnelservice` et `/uninstalltunnelservice` (qui + font justement l'un ou l'autre à chaque connexion/déconnexion, cf. + `connect()`/`force_cleanup()`) continuaient donc à réclamer une + élévation même une fois l'ACL en place. D'où ce service séparé. + """ + if len(cmd) < 3: + return None + verb = cmd[1] + if verb == "/installtunnelservice": + config_path = cmd[2] + name = os.path.splitext(os.path.basename(config_path))[0] + request = {"cmd": "install_tunnel", "config_path": config_path, "name": name} + elif verb == "/uninstalltunnelservice": + request = {"cmd": "uninstall_tunnel", "name": cmd[2]} + else: + return None + resp = call_helper(request) + if resp is None: + return None + return (0 if resp.get("ok") else 1), "", str(resp.get("message") or "") + + def _run_wireguard_service_cmd(cmd: list[str], timeout: int) -> tuple[int, str, str]: """Lance `wireguard.exe /install…` ou `/uninstall…tunnelservice`. @@ -228,6 +266,9 @@ def _run_wireguard_service_cmd(cmd: list[str], timeout: int) -> tuple[int, str, `run_privileged`, seul à passer par ShellExecuteEx "runas" pour obtenir une vraie invite UAC. """ + resp = _try_helper_tunnel_cmd(cmd) + if resp is not None: + return resp code, out, err = run_command(cmd, timeout) if code == 0: return code, out, err @@ -451,8 +492,14 @@ 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_privileged_readonly([wg_exe(), "show", name, "latest-handshakes"], 10) - if code != 0 or not out.strip(): + resp = call_helper({"cmd": "get_status", "name": name, "latest_handshakes": True}) + if resp is not None: + out = resp.get("message") or "" if resp.get("ok") else "" + else: + code, out, _ = run_privileged_readonly([wg_exe(), "show", name, "latest-handshakes"], 10) + if code != 0: + out = "" + if not out.strip(): return None newest = 0 for line in out.splitlines(): diff --git a/app/service/__init__.py b/app/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/service/helper_service.py b/app/service/helper_service.py new file mode 100644 index 0000000..c726edb --- /dev/null +++ b/app/service/helper_service.py @@ -0,0 +1,299 @@ +"""Service Windows privilégié pour WGSecure. + +Installe/désinstalle le tunnel WireGuard, pose/retire les règles NRPT +(split-DNS) et lit l'état du tunnel (`wg show`) sur demande de l'app — non +privilégiée — pour éviter une invite d'authentification à chaque connexion +et déconnexion. Tourne en LocalSystem. + +Exécutable séparé (`wgsecure-helper.exe`, build PyInstaller dédié) : jamais +importé par l'app principale (`wgsecure.exe`), qui lui parle uniquement via +le pipe nommé (`app/utils/helper_ipc.py`, ctypes pur, sans dépendance à +pywin32). + +Invariant de sécurité central : le pipe reste accessible à tout process +d'un utilisateur local, pas seulement WGSecure — donc CINQ commandes fixes +seulement, jamais de script/commande arbitraire. Chaque commande valide +strictement ses paramètres avant d'exécuter quoi que ce soit. +""" +from __future__ import annotations + +import ipaddress +import json +import logging +import os +import re +import subprocess +from logging.handlers import RotatingFileHandler + +import pywintypes +import servicemanager +import win32event +import win32file +import win32pipe +import win32security +import win32service +import win32serviceutil + +from app.core.dns import _nrpt_add_split_dns_ps, _nrpt_remove_split_dns_ps +from app.utils.platform_utils import get_wg_config_dir, wg_exe, wireguard_exe + +_PIPE_NAME = r"\\.\pipe\WGSecureHelper" +# SYSTEM + Administrateurs : contrôle total. Utilisateurs interactifs +# locaux (IU) : lecture/écriture seulement (se connecter, envoyer une +# requête, lire la réponse) — jamais anonyme, jamais réseau (un pipe nommé +# n'est de toute façon jamais accessible à distance sans partage explicite, +# absent ici). +_PIPE_SDDL = "D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)" + +_INTERFACE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,15}$") +_RULE_NAME_RE = re.compile(r"^WGSecure-SplitDNS-[A-Za-z0-9_-]+$") +_HOSTNAME_LABEL = r"[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?" +_HOSTNAME_RE = re.compile(rf"^{_HOSTNAME_LABEL}(\.{_HOSTNAME_LABEL})*$") +_MAX_DOMAINS = 20 + +_LOG_DIR = os.path.join(os.environ.get("PROGRAMDATA", r"C:\ProgramData"), "WGSecure") +_LOG_FILE = os.path.join(_LOG_DIR, "helper.log") + + +def _get_logger() -> logging.Logger: + os.makedirs(_LOG_DIR, exist_ok=True) + logger = logging.getLogger("wgsecure-helper") + if not logger.handlers: + logger.setLevel(logging.INFO) + handler = RotatingFileHandler(_LOG_FILE, maxBytes=1_000_000, + backupCount=3, encoding="utf-8") + handler.setFormatter(logging.Formatter( + "%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")) + logger.addHandler(handler) + logger.propagate = False + return logger + + +class ValidationError(Exception): + pass + + +def _validate_interface_name(name) -> str: + if not isinstance(name, str) or not _INTERFACE_NAME_RE.match(name): + raise ValidationError(f"Nom d'interface invalide : {name!r}") + return name + + +def _validate_rule_name(name) -> str: + if not isinstance(name, str) or not _RULE_NAME_RE.match(name): + raise ValidationError(f"Nom de règle NRPT invalide : {name!r}") + return name + + +def _validate_domains(domains) -> list[str]: + if not isinstance(domains, list) or not domains or len(domains) > _MAX_DOMAINS: + raise ValidationError("Liste de domaines invalide") + out = [] + for d in domains: + if not isinstance(d, str) or not _HOSTNAME_RE.match(d): + raise ValidationError(f"Domaine invalide : {d!r}") + out.append(d) + return out + + +def _validate_server(server) -> str: + if not isinstance(server, str): + raise ValidationError("Serveur invalide") + try: + ipaddress.ip_address(server) + except ValueError: + raise ValidationError(f"Adresse IP invalide : {server!r}") from None + return server + + +def _validate_config_path(config_path, name: str) -> str: + """Le fichier doit résider exactement dans le dossier de configuration + WireGuard et s'appeler .conf — jamais un chemin arbitraire. Le + service ne lit ni n'écrit ce fichier ; il vérifie seulement qu'il + pointe là où l'app (non privilégiée) est censée l'avoir déjà écrit + avant de demander à wireguard.exe de l'installer. + """ + if not isinstance(config_path, str): + raise ValidationError("Chemin de configuration invalide") + expected = os.path.join(get_wg_config_dir(), f"{name}.conf") + if os.path.normcase(os.path.abspath(config_path)) != os.path.normcase(os.path.abspath(expected)): + raise ValidationError(f"Chemin de configuration hors du dossier attendu : {config_path!r}") + return config_path + + +def _run(cmd: list[str], timeout: int = 30) -> tuple[int, str]: + """Exécution directe : le service tourne déjà en SYSTEM, aucune + élévation à demander (contrairement à platform_utils.run_privileged, + conçu pour un process non privilégié).""" + try: + result = subprocess.run(cmd, capture_output=True, timeout=timeout, + encoding="utf-8", errors="replace") + return result.returncode, (result.stderr.strip() or result.stdout.strip()) + except subprocess.TimeoutExpired: + return -1, "Timeout" + except OSError as e: + return -1, str(e) + + +def _handle_install_tunnel(req: dict) -> dict: + name = _validate_interface_name(req.get("name")) + config_path = _validate_config_path(req.get("config_path"), name) + code, msg = _run([wireguard_exe(), "/installtunnelservice", config_path], timeout=90) + return {"ok": code == 0, "message": msg} + + +def _handle_uninstall_tunnel(req: dict) -> dict: + name = _validate_interface_name(req.get("name")) + code, msg = _run([wireguard_exe(), "/uninstalltunnelservice", name], timeout=60) + return {"ok": code == 0, "message": msg} + + +def _handle_set_split_dns(req: dict) -> dict: + server = _validate_server(req.get("server")) + domains = _validate_domains(req.get("domains")) + rule_name = _validate_rule_name(req.get("rule_name")) + script = _nrpt_add_split_dns_ps(server, domains, rule_name) + code, msg = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], timeout=20) + return {"ok": code == 0, "message": msg} + + +def _handle_clear_split_dns(req: dict) -> dict: + rule_name = _validate_rule_name(req.get("rule_name")) + script = _nrpt_remove_split_dns_ps(rule_name) + code, msg = _run(["powershell", "-NoProfile", "-NonInteractive", "-Command", script], timeout=20) + return {"ok": code == 0, "message": msg} + + +def _handle_get_status(req: dict) -> dict: + """Lecture seule : "message" porte la sortie de `wg show`, pas une + erreur — "ok" reflète juste si la commande a pu s'exécuter. + `latest_handshakes` : ajoute le sous-argument `latest-handshakes` + (utilisé par `last_handshake_age` pour un horodatage exploitable, + plutôt que le texte humain « il y a X secondes » de `wg show` simple). + """ + name = _validate_interface_name(req.get("name")) + cmd = [wg_exe(), "show", name] + if req.get("latest_handshakes"): + cmd.append("latest-handshakes") + code, msg = _run(cmd, timeout=10) + return {"ok": code == 0, "message": msg} + + +_HANDLERS = { + "install_tunnel": _handle_install_tunnel, + "uninstall_tunnel": _handle_uninstall_tunnel, + "set_split_dns": _handle_set_split_dns, + "clear_split_dns": _handle_clear_split_dns, + "get_status": _handle_get_status, +} + + +def _create_pipe_instance(): + sd = win32security.ConvertStringSecurityDescriptorToSecurityDescriptor( + _PIPE_SDDL, win32security.SDDL_REVISION_1) + sa = win32security.SECURITY_ATTRIBUTES() + sa.SECURITY_DESCRIPTOR = sd + return win32pipe.CreateNamedPipe( + _PIPE_NAME, + win32pipe.PIPE_ACCESS_DUPLEX, + win32pipe.PIPE_TYPE_BYTE | win32pipe.PIPE_READMODE_BYTE | win32pipe.PIPE_WAIT, + win32pipe.PIPE_UNLIMITED_INSTANCES, + 65536, 65536, 0, sa, + ) + + +def _handle_connection(handle, logger: logging.Logger) -> None: + resp: dict + try: + # Un seul message par connexion (le client ferme après avoir lu la + # réponse) : bornée à 64 Ko pour ne jamais bloquer sur un client + # qui n'enverrait jamais de fin de ligne. + data = b"" + for _ in range(16): + _, chunk = win32file.ReadFile(handle, 4096) + data += chunk + if b"\n" in data or not chunk: + break + line = data.split(b"\n", 1)[0] + req = json.loads(line.decode("utf-8")) + cmd = req.get("cmd") if isinstance(req, dict) else None + handler = _HANDLERS.get(cmd) + if handler is None: + resp = {"ok": False, "message": f"Commande inconnue : {cmd!r}"} + else: + try: + resp = handler(req) + except ValidationError as e: + resp = {"ok": False, "message": str(e)} + logger.info("%s -> ok=%s", cmd, resp.get("ok")) + except (pywintypes.error, ValueError, OSError) as e: + logger.exception("Requête invalide ou erreur pipe") + resp = {"ok": False, "message": f"Erreur interne : {e}"} + try: + win32file.WriteFile(handle, (json.dumps(resp) + "\n").encode("utf-8")) + except pywintypes.error: + pass + + +class WGSecureHelperService(win32serviceutil.ServiceFramework): + _svc_name_ = "WGSecureHelper" + _svc_display_name_ = "WGSecure Helper" + _svc_description_ = ( + "Applique pour WGSecure, sans invite d'authentification répétée, " + "l'installation du tunnel WireGuard et les règles DNS (split-DNS) " + "qui exigent normalement des droits administrateur." + ) + + def __init__(self, args): + win32serviceutil.ServiceFramework.__init__(self, args) + self._stop_event = win32event.CreateEvent(None, 0, 0, None) + self._running = True + + def SvcStop(self): + self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING) + self._running = False + # Débloque un ConnectNamedPipe() en attente en se connectant à son + # propre pipe — plus simple et plus robuste qu'un I/O recouvrant + # pour un service à trafic très faible (quelques appels par + # connexion/déconnexion de tunnel). + try: + h = win32file.CreateFile( + _PIPE_NAME, win32file.GENERIC_READ, 0, None, + win32file.OPEN_EXISTING, 0, None) + win32file.CloseHandle(h) + except pywintypes.error: + pass + win32event.SetEvent(self._stop_event) + + def SvcDoRun(self): + servicemanager.LogMsg( + servicemanager.EVENTLOG_INFORMATION_TYPE, + servicemanager.PYS_SERVICE_STARTED, + (self._svc_name_, ""), + ) + logger = _get_logger() + logger.info("Service démarré") + while self._running: + try: + handle = _create_pipe_instance() + except pywintypes.error: + logger.exception("Création du pipe impossible") + break + try: + win32pipe.ConnectNamedPipe(handle, None) + if not self._running: + break + _handle_connection(handle, logger) + except pywintypes.error: + logger.exception("Erreur sur une connexion cliente") + finally: + try: + win32pipe.DisconnectNamedPipe(handle) + except pywintypes.error: + pass + win32file.CloseHandle(handle) + logger.info("Service arrêté") + + +if __name__ == "__main__": + win32serviceutil.HandleCommandLine(WGSecureHelperService) diff --git a/app/utils/helper_ipc.py b/app/utils/helper_ipc.py new file mode 100644 index 0000000..357e50d --- /dev/null +++ b/app/utils/helper_ipc.py @@ -0,0 +1,95 @@ +"""Client IPC pour wgsecure-helper, le service Windows privilégié. + +Utilise directement l'API Win32 via `ctypes` (même famille d'appels que +`_run_elevated_windows` dans `platform_utils.py`) plutôt que `pywin32`, +pour ne pas ajouter de dépendance à l'app principale — seul l'exécutable +séparé du service a besoin de `pywin32` (enregistrement SCM via +`win32serviceutil`), jamais `wgsecure.exe`. + +Le service tourne en LocalSystem et applique, sans jamais demander +d'élévation UAC, les opérations que `platform_utils.run_privileged()` +demande sinon à chaque appel : installer/désinstaller le tunnel, poser/ +retirer une règle NRPT (split-DNS), lire l'état du tunnel (`wg show`). +""" +from __future__ import annotations + +import json +import sys + +_PIPE_NAME = r"\\.\pipe\WGSecureHelper" + + +def call_helper(request: dict, timeout_ms: int = 1500) -> dict | None: + """Envoie `request` (JSON) au service et attend sa réponse JSON. + + Retourne `None` si le service est absent, indisponible, ou si l'échange + échoue pour n'importe quelle raison (pipe introuvable, occupé au-delà du + timeout, réponse malformée…) — l'appelant doit alors basculer sur son + chemin existant (élévation UAC). Ne lève jamais d'exception : cette + fonction est un pur best-effort, jamais un point de défaillance. + """ + if sys.platform != "win32": + return None + + import ctypes + from ctypes import wintypes + + GENERIC_READ = 0x80000000 + GENERIC_WRITE = 0x40000000 + OPEN_EXISTING = 3 + FILE_ATTRIBUTE_NORMAL = 0x80 + INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value + + kernel32 = ctypes.windll.kernel32 + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, + wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE, + ] + + # Un pipe dont toutes les instances sont déjà occupées fait échouer + # CreateFile immédiatement (ERROR_PIPE_BUSY) : WaitNamedPipe patiente + # qu'une instance se libère, dans la limite du timeout. Sans effet (et + # sans erreur bloquante) si le pipe n'existe pas du tout — CreateFile + # échouera juste ensuite avec ERROR_FILE_NOT_FOUND. + try: + kernel32.WaitNamedPipeW(_PIPE_NAME, timeout_ms) + except OSError: + pass + + handle = kernel32.CreateFileW( + _PIPE_NAME, GENERIC_READ | GENERIC_WRITE, 0, None, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, None, + ) + if not handle or handle == INVALID_HANDLE_VALUE: + return None + + try: + payload = (json.dumps(request) + "\n").encode("utf-8") + written = wintypes.DWORD(0) + ok = kernel32.WriteFile(handle, payload, len(payload), + ctypes.byref(written), None) + if not ok: + return None + + # Pipe en mode octet (pas mode message) des deux côtés : on lit + # jusqu'à trouver la fin de ligne qui termine la réponse JSON. + chunks: list[bytes] = [] + buf = ctypes.create_string_buffer(4096) + read = wintypes.DWORD(0) + while b"\n" not in b"".join(chunks): + ok = kernel32.ReadFile(handle, buf, len(buf), ctypes.byref(read), None) + if not ok or read.value == 0: + break + chunks.append(buf.raw[:read.value]) + + data = b"".join(chunks) + if not data: + return None + line = data.split(b"\n", 1)[0] + resp = json.loads(line.decode("utf-8")) + return resp if isinstance(resp, dict) else None + except (OSError, ValueError): + return None + finally: + kernel32.CloseHandle(handle) diff --git a/requirements-helper.txt b/requirements-helper.txt new file mode 100644 index 0000000..c5fde82 --- /dev/null +++ b/requirements-helper.txt @@ -0,0 +1 @@ +pywin32>=306 diff --git a/wgsecure.iss b/wgsecure.iss index 0ec5039..7b420a5 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.7.11" +#define MyAppVersion "0.7.12" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe" @@ -30,6 +30,13 @@ Name: "french"; MessagesFile: "compiler:Languages\French.isl" [Files] Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +; Service privilégié optionnel (cf. app/service/, `make helper`) : installe/ +; désinstalle le tunnel et pose/retire les règles NRPT (split-DNS) en +; LocalSystem, pour éviter une invite d'authentification à chaque connexion/ +; déconnexion. skipifsourcedoesntexist : le build peut être absent (composant +; optionnel, pas encore généré sur toutes les machines de build) — dans ce +; cas l'app retombe simplement sur son chemin élevé existant. +Source: "dist\wgsecure-helper.exe"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist ; VC++ Redistributable x64 — téléchargé automatiquement par `make installer-deps` : ; https://aka.ms/vs/17/release/vc_redist.x64.exe → installer\VC_redist.x64.exe Source: "installer\VC_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall @@ -52,8 +59,20 @@ Filename: "{tmp}\VC_redist.x64.exe"; Parameters: "/install /quiet /norestart"; S ; (PrivilegesRequired=admin ci-dessus), donc pas d'UAC supplémentaire non plus. ; `waituntilterminated` : l'app ne doit pas démarrer avant que wg.exe existe. Filename: "{tmp}\wireguard-installer.exe"; StatusMsg: "Installation de WireGuard…"; Check: WireGuardNeedsInstall; Flags: waituntilterminated +; Enregistre et démarre le service privilégié s'il a été fourni (cf. [Files] +; ci-dessus). "binPath= " avec l'espace après "=" est une exigence de la +; syntaxe sc.exe, pas d'Inno Setup — l'oublier fait échouer la commande. +Filename: "{sys}\sc.exe"; Parameters: "create WGSecureHelper binPath= ""{app}\wgsecure-helper.exe"" start= auto DisplayName= ""WGSecure Helper"""; StatusMsg: "Installation du service WGSecure Helper…"; Check: HelperServiceAvailable; Flags: runhidden +Filename: "{sys}\sc.exe"; Parameters: "start WGSecureHelper"; StatusMsg: "Démarrage du service WGSecure Helper…"; Check: HelperServiceAvailable; Flags: runhidden Filename: "{app}\{#MyAppExeName}"; Description: "Lancer {#MyAppName}"; Flags: nowait postinstall skipifsilent +[UninstallRun] +; Arrête puis supprime le service avant que ses fichiers ne disparaissent — +; sc.exe ignore silencieusement une commande sur un service déjà absent +; (ex. installeur généré sans le composant service), donc pas de Check ici. +Filename: "{sys}\sc.exe"; Parameters: "stop WGSecureHelper"; Flags: runhidden; RunOnceId: "StopWGSecureHelper" +Filename: "{sys}\sc.exe"; Parameters: "delete WGSecureHelper"; Flags: runhidden; RunOnceId: "DeleteWGSecureHelper" + [Code] function VCRedistNeedsInstall: Boolean; var @@ -70,3 +89,10 @@ begin // ArchitecturesInstallIn64BitMode=x64compatible ci-dessus. Result := not FileExists(ExpandConstant('{pf}\WireGuard\wireguard.exe')); end; + +function HelperServiceAvailable: Boolean; +begin + // wgsecure-helper.exe est optionnel (skipifsourcedoesntexist dans + // [Files]) : n'enregistrer le service que si le binaire a bien été copié. + Result := FileExists(ExpandConstant('{app}\wgsecure-helper.exe')); +end;