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:
@@ -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é
|
||||
|
||||
@@ -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 ║"
|
||||
|
||||
@@ -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 :  — Septembre 2026
|
||||
Version :  — Septembre 2026
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
__version__ = "0.7.11"
|
||||
__version__ = "0.7.12"
|
||||
APP_NAME = "WGSecure"
|
||||
APP_SHORT = "WGS"
|
||||
|
||||
+31
-8
@@ -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")
|
||||
|
||||
+49
-2
@@ -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():
|
||||
|
||||
@@ -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à 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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
pywin32>=306
|
||||
+27
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user