Files
WGSecure/app/service/helper_service.py
T
tuxgyverandClaude Opus 5 a7ac6d1375 feat(windows): tunnel installé une fois, puis démarré — et code validé par le serveur
Deux changements liés, qui achèvent le déplacement de la sécurité vers
le serveur.

1. Le tunnel n'est plus recréé à chaque connexion. Créer ou supprimer un
   service Windows n'est jamais délégable à un utilisateur standard, le
   démarrer peut l'être : c'est cette réinstallation systématique qui
   imposait une authentification administrateur à chaque connexion ET à
   chaque déconnexion. Désormais connect() démarre le service s'il
   existe déjà, disconnect() se contente de l'arrêter, et le tunnel
   reste installé entre les deux.

   Trois voies en cascade pour ce démarrage/arrêt : sc.exe sans
   élévation, puis le service wgsecure-helper, puis l'élévation en
   dernier recours. Le helper gagne donc deux commandes
   (start/stop_tunnel_service). Cette cascade tient quelle que soit la
   configuration du poste — le test en session standard a montré que
   l'ACL posée sur le service WireGuard ne donne aucun droit sur le
   service du tunnel, qui est un objet distinct.

2. Après le montage, l'application demande le code à 6 chiffres et le
   fait valider PAR LE SERVEUR (app/core/vpn_session.py). Jusqu'ici le
   code était vérifié localement, contre un secret que l'application
   détenait : elle validait donc un code qu'elle pouvait produire, et ne
   verrouillait que sa propre interface. L'ordre est imposé par le
   réseau — l'API n'est joignable que depuis le tunnel, qui sert de
   réseau de quarantaine tant que le code n'est pas passé.

   L'URL de l'API est déduite de l'adresse du client (10.6.0.6 →
   10.6.0.1) faute de valeur explicite : c'est l'adresse du serveur
   lui-même, la seule jamais filtrée depuis la quarantaine.

Sans identifiant VPN configuré, l'étape est ignorée : le serveur peut
tourner en mode permissif, où un compte non enrôlé garde son accès, et
imposer la saisie bloquerait des installations qui fonctionnent.

Chemins Linux inchangés (branches is_windows()).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:40:34 +02:00

413 lines
17 KiB
Python

"""Service Windows privilégié pour WGSecure.
Installe, démarre, arrête et 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 SEPT 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
import sys
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 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, handle) -> str:
"""Valide le .conf que l'appelant demande d'installer.
Le chemin ne peut pas être comparé à un dossier fixe : l'app écrit sa
config dans le `%APPDATA%` de *l'utilisateur* (`get_client_config_path`
dans app/core/wireguard.py), or ce service tourne en LocalSystem — son
propre `%APPDATA%` est celui de SYSTEM, pas celui de l'appelant, et il
n'a aucun moyen fiable de recalculer celui-ci.
Le contrôle réel est donc l'usurpation d'identité du client du pipe :
le fichier doit être ouvrable *par l'appelant lui-même*. Un utilisateur
ne peut ainsi jamais faire lire à SYSTEM un fichier auquel il n'a pas
déjà accès. S'y ajoutent des contrôles structurels bon marché : chemin
absolu, sans remontée de répertoire, nommé exactement `<name>.conf`
dans un dossier `WGSecure`.
"""
if not isinstance(config_path, str) or not config_path:
raise ValidationError("Chemin de configuration invalide")
path = os.path.abspath(config_path)
if ".." in path.replace("/", "\\").split("\\"):
raise ValidationError(f"Chemin de configuration suspect : {config_path!r}")
if os.path.basename(path).lower() != f"{name}.conf".lower():
raise ValidationError(
f"Le fichier doit s'appeler {name}.conf, reçu : {os.path.basename(path)!r}")
if os.path.basename(os.path.dirname(path)).lower() != "wgsecure":
raise ValidationError(
"Le fichier doit se trouver dans un dossier WGSecure, reçu : "
f"{os.path.dirname(path)!r}")
# Ouverture sous l'identité de l'appelant : échoue si le fichier ne lui
# appartient pas / ne lui est pas lisible. Fail closed — si
# l'usurpation elle-même échoue, on refuse.
try:
win32security.ImpersonateNamedPipeClient(handle)
except pywintypes.error as e:
raise ValidationError(f"Identité de l'appelant non vérifiable : {e}") from None
try:
with open(path, "rb"):
pass
except OSError as e:
raise ValidationError(
f"Fichier illisible pour l'appelant : {config_path!r} ({e})") from None
finally:
win32security.RevertToSelf()
return 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, handle) -> dict:
name = _validate_interface_name(req.get("name"))
config_path = _validate_config_path(req.get("config_path"), name, handle)
code, msg = _run([wireguard_exe(), "/installtunnelservice", config_path], timeout=90)
return {"ok": code == 0, "message": msg}
def _handle_uninstall_tunnel(req: dict, handle) -> 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_start_tunnel_service(req: dict, handle) -> dict:
"""Démarre le service d'un tunnel déjà installé.
Complète install/uninstall : dans le fonctionnement courant le tunnel
est installé une fois, puis seulement démarré et arrêté. Le nom est
validé, et le préfixe « WireGuardTunnel$ » est ajouté ici — l'appelant
ne choisit donc jamais quel service piloter.
"""
name = _validate_interface_name(req.get("name"))
code, msg = _run(["sc.exe", "start", f"WireGuardTunnel${name}"], timeout=45)
return {"ok": code == 0, "message": msg}
def _handle_stop_tunnel_service(req: dict, handle) -> dict:
name = _validate_interface_name(req.get("name"))
code, msg = _run(["sc.exe", "stop", f"WireGuardTunnel${name}"], timeout=45)
return {"ok": code == 0, "message": msg}
def _handle_set_split_dns(req: dict, handle) -> 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, handle) -> 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, handle) -> 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,
"start_tunnel_service": _handle_start_tunnel_service,
"stop_tunnel_service": _handle_stop_tunnel_service,
"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, handle)
except ValidationError as e:
resp = {"ok": False, "message": str(e)}
logger.info("%s -> ok=%s", cmd, resp.get("ok"))
except Exception 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 Exception:
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_, ""),
)
# La création du journal fichier est elle-même faillible (dossier
# non inscriptible…) : sans ce garde-fou, un échec ici laisserait
# zéro trace nulle part. Le journal d'événements Windows sert donc
# de canal de dernier recours (Observateur d'événements →
# Applications, source « WGSecureHelper »).
try:
logger = _get_logger()
except Exception as e:
servicemanager.LogErrorMsg(
f"WGSecureHelper : journal fichier impossible ({e})")
raise
logger.info("Service démarré")
try:
self._serve_forever(logger)
except Exception:
# Filet de sécurité final : si malgré tout ce qui suit une
# exception s'échappe encore, elle laisse au moins une trace
# exploitable ici plutôt qu'un service qui disparaît en
# silence sans rien dans le log.
logger.exception("Arrêt inattendu de la boucle principale")
servicemanager.LogErrorMsg(
"WGSecureHelper : arrêt inattendu, cf. helper.log")
finally:
logger.info("Service arrêté")
def _serve_forever(self, logger: logging.Logger) -> None:
# Chaque étape est individuellement protégée et ne peut donc jamais
# faire sortir la boucle sur une exception non prévue : un service
# censé tourner indéfiniment sans surveillance ne doit jamais
# s'arrêter à cause d'un incident sur UNE requête. Une version
# antérieure ne capturait que pywintypes.error (pas Exception en
# général) et ne protégeait pas CloseHandle individuellement dans
# le `finally` : une erreur non prévue là pouvait remonter et
# arrêter tout le service — expliquant un premier échange réussi
# (juste après le démarrage du service) puis silence total ensuite.
ERROR_PIPE_CONNECTED = 535
while self._running:
handle = None
try:
handle = _create_pipe_instance()
try:
win32pipe.ConnectNamedPipe(handle, None)
except pywintypes.error as e:
# Le client s'est connecté entre CreateNamedPipe et
# ConnectNamedPipe : cas normal (pas une erreur), pas
# rare vu la fréquence des appels — sans ce cas
# particulier, la requête était silencieusement ratée
# à chaque fois qu'il se produisait.
if e.winerror != ERROR_PIPE_CONNECTED:
raise
if not self._running:
break
_handle_connection(handle, logger)
except Exception:
logger.exception("Erreur sur une connexion cliente")
finally:
if handle:
try:
win32pipe.DisconnectNamedPipe(handle)
except Exception:
pass
try:
win32file.CloseHandle(handle)
except Exception:
pass
if __name__ == "__main__":
if len(sys.argv) == 1:
# Lancé par le gestionnaire de services (SCM), qui n'ajoute aucune
# commande à la ligne d'appel : il faut entrer explicitement dans le
# dispatcher. `HandleCommandLine()` seul, dans ce cas, se contente
# d'afficher son aide et de sortir — le service démarrait donc puis
# s'arrêtait aussitôt, sans jamais exécuter SvcDoRun (d'où l'absence
# totale de fichier journal et un pipe qui n'existait jamais, l'app
# retombant alors systématiquement sur l'élévation UAC).
servicemanager.Initialize()
servicemanager.PrepareToHostSingle(WGSecureHelperService)
servicemanager.StartServiceCtrlDispatcher()
else:
# Invocation manuelle : install / remove / start / stop / debug…
win32serviceutil.HandleCommandLine(WGSecureHelperService)