chore(release): v0.7.12

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:15:08 +02:00
co-authored by Claude Sonnet 5
parent 9dca066239
commit 7beb6deb7a
11 changed files with 546 additions and 16 deletions
+8
View File
@@ -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é
+33 -2
View File
@@ -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 ║"
+2 -2
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.7.11"
__version__ = "0.7.12"
APP_NAME = "WGSecure"
APP_SHORT = "WGS"
+23
View File
@@ -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,6 +283,19 @@ 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:
@@ -332,6 +346,15 @@ def cleanup_split_dns(cfg: Config) -> tuple[bool, str]:
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
+48 -1
View File
@@ -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' 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).
"""
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 or not out.strip():
if code != 0:
out = ""
if not out.strip():
return None
newest = 0
for line in out.splitlines():
View File
+299
View File
@@ -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 <name>.conf — jamais un chemin arbitraire. Le
service ne lit ni n'écrit ce fichier ; il vérifie seulement qu'il
pointe 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)
+95
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
pywin32>=306
+27 -1
View File
@@ -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;