Rien ne rouvrait l'application après une mise à jour. Trois choses y concouraient : le service lance l'installeur avec `/VERYSILENT`, l'entrée `[Run]` qui la rouvre porte `skipifsilent` et s'en trouve écartée, et `RestartApplications=no` laisse le Restart Manager fermer sans rouvrir. Le service annonçait pourtant qu'elle redémarrerait. Lui rendre l'entrée `[Run]` n'aurait rien réglé : l'installeur est lancé par le service, donc en LocalSystem dans la session 0, où l'application se serait ouverte sans bureau visible et avec les privilèges du système. La relance revient au service, seul composant capable d'ouvrir un processus dans la session d'un autre, et que l'installeur redémarre une fois les binaires en place. Le jeton vient de la session console : l'application repart avec les droits de l'utilisateur, pas ceux du service. Le marqueur qui la commande est posé par l'installeur et non par le service : celui-ci ne saurait qu'avoir lancé un installeur, quand l'installeur sait que l'installation a abouti. Il porte de surcroît déjà cette correction, si bien que la première mise à jour en bénéficie — un marqueur écrit par l'ancien service ne serait apparu qu'à la deuxième. Posé en installation silencieuse seulement, l'entrée `[Run]` couvrant déjà l'installation manuelle. L'attente qui devait laisser mourir le service avant la copie ne mesurait par ailleurs rien : elle demandait à `RenameFile` de renommer le binaire sur lui-même et tenait l'échec pour un verrou, alors que Windows autorise le renommage de l'image d'un processus vivant — c'est ce qui rend `restartreplace` possible — et que `MoveFile` refuse de toute façon une destination existante. La copie partait donc sur un fichier encore tenu, échouait, `restartreplace` reportait le remplacement au redémarrage suivant, et l'installeur s'achevait en annonçant une réussite. L'attente porte désormais sur la disparition du processus, relevée par `tasklist` — dont le nom d'image, contrairement aux libellés d'état de `sc.exe`, n'est pas traduit sur un Windows français. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
754 lines
32 KiB
Python
754 lines
32 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 hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
import pywintypes
|
|
import servicemanager
|
|
import win32api
|
|
import win32con
|
|
import win32event
|
|
import win32file
|
|
import win32pipe
|
|
import win32security
|
|
import win32process
|
|
import win32profile
|
|
import win32ts
|
|
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")
|
|
# SID du compte autorisé à piloter le tunnel, retenu au premier usage. Le pipe
|
|
# est ouvert à tout utilisateur interactif : sans cet ancrage, n'importe quel
|
|
# compte du poste pouvait arrêter le tunnel d'un autre, installer le sien, ou
|
|
# poser une règle DNS valable pour toute la machine.
|
|
_OWNER_FILE = os.path.join(_LOG_DIR, "owner.sid")
|
|
|
|
# Posé par l'installeur au terme d'une installation silencieuse, relu par ce
|
|
# service qu'il redémarre juste après. L'installeur en est l'auteur et non le
|
|
# service : lui seul sait qu'une installation s'est réellement achevée, là où
|
|
# le service ne saurait que l'avoir lancée. Et comme il porte déjà la version
|
|
# corrigée, la toute première mise à jour en bénéficie — un marqueur écrit par
|
|
# le service ne serait apparu qu'à la deuxième.
|
|
#
|
|
# Sa date de modification fait foi pour la péremption : elle est posée par
|
|
# l'écriture elle-même, sans que l'auteur ait à l'inscrire.
|
|
_PENDING_RELAUNCH = os.path.join(_LOG_DIR, "relaunch.pending")
|
|
# Au-delà, le marqueur est tenu pour un reliquat : un service qui redémarre
|
|
# des jours plus tard, pour une tout autre raison, n'a pas à ouvrir une
|
|
# fenêtre sur le bureau de l'utilisateur.
|
|
_RELAUNCH_WINDOW = 30 * 60
|
|
|
|
# Directives refusées dans un `.conf` : elles font exécuter des commandes par
|
|
# le service, donc en LocalSystem. `wireguard.exe` les ignore sous Windows,
|
|
# mais le fichier n'a aucune raison d'en porter — les refuser coûte une ligne
|
|
# et ferme la porte si un jour il les honore.
|
|
_CONF_FORBIDDEN = ("postup", "predown", "postdown", "preup")
|
|
# Un `.conf` WireGuard tient en quelques centaines d'octets ; au-delà, ce
|
|
# n'est pas une configuration.
|
|
_CONF_MAX_BYTES = 64 * 1024
|
|
|
|
|
|
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 _caller_sid(handle) -> str:
|
|
"""SID de l'appelant du pipe, sous forme textuelle.
|
|
|
|
Lève plutôt que de rendre une valeur par défaut : une identité qu'on ne
|
|
peut pas établir ne doit jamais valoir autorisation.
|
|
"""
|
|
try:
|
|
win32security.ImpersonateNamedPipeClient(handle)
|
|
except pywintypes.error as e:
|
|
raise ValidationError(f"Identité de l'appelant non vérifiable : {e}") from None
|
|
try:
|
|
token = win32security.OpenThreadToken(
|
|
win32api.GetCurrentThread(), win32con.TOKEN_QUERY, True)
|
|
sid, _ = win32security.GetTokenInformation(token, win32security.TokenUser)
|
|
return win32security.ConvertSidToStringSid(sid)
|
|
except pywintypes.error as e:
|
|
raise ValidationError(f"Identité de l'appelant illisible : {e}") from None
|
|
finally:
|
|
win32security.RevertToSelf()
|
|
|
|
|
|
def _caller_is_admin(handle) -> bool:
|
|
"""L'appelant est-il administrateur local ? Faux si indéterminable."""
|
|
try:
|
|
win32security.ImpersonateNamedPipeClient(handle)
|
|
except pywintypes.error:
|
|
return False
|
|
try:
|
|
admins = win32security.CreateWellKnownSid(win32security.WinBuiltinAdministratorsSid)
|
|
token = win32security.OpenThreadToken(
|
|
win32api.GetCurrentThread(), win32con.TOKEN_QUERY, True)
|
|
for sid, attrs in win32security.GetTokenInformation(token, win32security.TokenGroups):
|
|
if sid == admins and attrs & win32security.SE_GROUP_ENABLED:
|
|
return True
|
|
return False
|
|
except pywintypes.error:
|
|
return False
|
|
finally:
|
|
win32security.RevertToSelf()
|
|
|
|
|
|
def _authorize(handle, logger: logging.Logger) -> str:
|
|
"""Autorise une commande qui modifie l'état de la machine.
|
|
|
|
Le premier appelant devient le propriétaire, et son SID est retenu : sur
|
|
un poste où une seule personne configure le VPN — le cas courant — cela se
|
|
fait sans rien demander. Ensuite, seuls ce compte et les administrateurs
|
|
sont servis.
|
|
|
|
Ce n'est pas une barrière contre le propriétaire lui-même, qui reste libre
|
|
d'installer le tunnel qu'il veut : c'en est une contre *les autres comptes
|
|
du poste*, qui pouvaient jusqu'ici couper son tunnel ou détourner la
|
|
résolution de noms de toute la machine sans être administrateurs.
|
|
"""
|
|
sid = _caller_sid(handle)
|
|
try:
|
|
owner = open(_OWNER_FILE, encoding="utf-8").read().strip()
|
|
except OSError:
|
|
owner = ""
|
|
|
|
if not owner:
|
|
try:
|
|
os.makedirs(_LOG_DIR, exist_ok=True)
|
|
with open(_OWNER_FILE, "w", encoding="utf-8") as f:
|
|
f.write(sid)
|
|
logger.info("Propriétaire du helper enregistré : %s", sid)
|
|
except OSError as e:
|
|
raise ValidationError(f"Propriétaire non enregistrable : {e}") from None
|
|
return sid
|
|
|
|
if sid != owner and not _caller_is_admin(handle):
|
|
logger.warning("Commande refusée : %s n'est ni le propriétaire (%s) "
|
|
"ni administrateur", sid, owner)
|
|
raise ValidationError(
|
|
"Ce compte n'est pas autorisé à piloter le tunnel WGSecure.")
|
|
return sid
|
|
|
|
|
|
def _validate_conf_content(path: str) -> None:
|
|
"""Refuse un `.conf` qui porterait des directives exécutables.
|
|
|
|
Le chemin était vérifié, jamais le contenu : un utilisateur pouvait faire
|
|
installer par LocalSystem un tunnel dont il choisissait l'endpoint, les
|
|
clés et les routes. Le contrôle reste modeste — il ne dit pas *quel*
|
|
serveur est légitime, information que ce service n'a pas — mais il barre
|
|
l'exécution de commandes, qui est le pas de trop.
|
|
"""
|
|
try:
|
|
size = os.path.getsize(path)
|
|
except OSError as e:
|
|
raise ValidationError(f"Configuration illisible : {e}") from None
|
|
if size > _CONF_MAX_BYTES:
|
|
raise ValidationError(f"Configuration anormalement volumineuse ({size} octets)")
|
|
try:
|
|
with open(path, encoding="utf-8", errors="replace") as f:
|
|
lignes = f.read().splitlines()
|
|
except OSError as e:
|
|
raise ValidationError(f"Configuration illisible : {e}") from None
|
|
|
|
for ligne in lignes:
|
|
cle = ligne.split("=", 1)[0].strip().lower()
|
|
if cle in _CONF_FORBIDDEN:
|
|
raise ValidationError(
|
|
f"Directive « {cle} » interdite dans une configuration "
|
|
f"installée par le service : elle ferait exécuter des "
|
|
f"commandes avec les privilèges du système.")
|
|
if not any(l.strip().lower() == "[interface]" for l in lignes):
|
|
raise ValidationError("Configuration sans section [Interface]")
|
|
|
|
|
|
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, logger) -> dict:
|
|
_authorize(handle, logger)
|
|
name = _validate_interface_name(req.get("name"))
|
|
config_path = _validate_config_path(req.get("config_path"), name, handle)
|
|
_validate_conf_content(config_path)
|
|
code, msg = _run([wireguard_exe(), "/installtunnelservice", config_path], timeout=90)
|
|
return {"ok": code == 0, "message": msg}
|
|
|
|
|
|
def _handle_uninstall_tunnel(req: dict, handle, logger) -> dict:
|
|
_authorize(handle, logger)
|
|
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, logger) -> 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.
|
|
"""
|
|
_authorize(handle, logger)
|
|
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, logger) -> dict:
|
|
_authorize(handle, logger)
|
|
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, logger) -> dict:
|
|
_authorize(handle, logger)
|
|
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, logger) -> dict:
|
|
_authorize(handle, logger)
|
|
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, logger) -> 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}
|
|
|
|
|
|
def _handle_apply_update(req: dict, handle, logger) -> dict:
|
|
"""Installe la mise à jour publiée par le serveur. Sans aucun paramètre.
|
|
|
|
L'appelant ne fournit ni chemin, ni binaire, ni empreinte : il déclenche,
|
|
rien de plus. C'est la seule forme acceptable ici. Une commande qui
|
|
installerait un fichier désigné par l'appelant donnerait à tout compte du
|
|
poste — le pipe est ouvert aux utilisateurs interactifs — le moyen de
|
|
faire exécuter ce qu'il veut avec les privilèges du système. Le service
|
|
lit donc lui-même le manifeste, télécharge lui-même, et vérifie lui-même.
|
|
|
|
La confiance restante va au serveur WGSecure, joignable par le seul
|
|
tunnel : un serveur compromis diffuserait un binaire exécuté en
|
|
LocalSystem. C'est le propre de toute mise à jour automatique ; seule une
|
|
signature vérifiée contre une clé publique embarquée ici la lèverait.
|
|
"""
|
|
_authorize(handle, logger)
|
|
base = (req.get("api_base") or "").strip()
|
|
# L'URL vient de l'appelant, mais elle est bornée : uniquement http(s),
|
|
# et seule l'adresse du serveur dans le tunnel a un manifeste à servir.
|
|
# Ce qui compte est que ni le fichier ni son empreinte n'en dépendent —
|
|
# les deux viennent de la même source, lue par le service.
|
|
if not base.startswith(("http://", "https://")):
|
|
raise ValidationError("Adresse du serveur de mise à jour invalide")
|
|
|
|
try:
|
|
with urllib.request.urlopen(f"{base}/api/version", timeout=10) as r:
|
|
manifest = json.loads(r.read().decode("utf-8"))
|
|
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
return {"ok": False, "message": f"Manifeste illisible : {e}"}
|
|
|
|
entry = next((a for a in manifest.get("artifacts", [])
|
|
if a.get("key") == "windows-setup"), None)
|
|
if not entry or not entry.get("sha256"):
|
|
return {"ok": False, "message": "Aucun installeur Windows publié."}
|
|
|
|
digest = hashlib.sha256()
|
|
fd, tmp = tempfile.mkstemp(prefix="wgsecure-update-", suffix=".exe",
|
|
dir=_LOG_DIR)
|
|
try:
|
|
url = f"{base}/api/version/download/windows-setup"
|
|
with urllib.request.urlopen(url, timeout=600) as r, os.fdopen(fd, "wb") as out:
|
|
while chunk := r.read(262144):
|
|
out.write(chunk)
|
|
digest.update(chunk)
|
|
if digest.hexdigest() != entry["sha256"].lower():
|
|
os.unlink(tmp)
|
|
logger.warning("Mise à jour refusée : empreinte non conforme")
|
|
return {"ok": False,
|
|
"message": "Empreinte non conforme — rien n'a été installé."}
|
|
except (urllib.error.URLError, OSError, ValueError) as e:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
return {"ok": False, "message": f"Téléchargement impossible : {e}"}
|
|
|
|
# L'installeur est lancé *détaché*, et surtout pas attendu : son travail
|
|
# consiste à arrêter ce service et à remplacer son binaire. L'attendre
|
|
# reviendrait à demander à un processus de survivre à sa propre mise à
|
|
# mort — `subprocess.run` ne rendrait jamais la main, et Windows refuse de
|
|
# toute façon d'écraser l'image d'un processus vivant.
|
|
#
|
|
# DETACHED_PROCESS le sort de la console du service, CREATE_NEW_PROCESS_GROUP
|
|
# de son groupe, et CREATE_BREAKAWAY_FROM_JOB de son job éventuel : sans
|
|
# cela, l'arrêt du service emporterait l'installeur avec lui.
|
|
#
|
|
# Le fichier est déposé sous %PROGRAMDATA%\WGSecure, écrit par le seul
|
|
# LocalSystem : un compte local ne peut pas le remplacer entre la
|
|
# vérification et l'exécution.
|
|
DETACHED_PROCESS = 0x00000008
|
|
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
|
CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
|
flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB
|
|
try:
|
|
subprocess.Popen(
|
|
[tmp, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/NOCANCEL"],
|
|
creationflags=flags, close_fds=True,
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL)
|
|
except OSError as e:
|
|
try:
|
|
os.unlink(tmp)
|
|
except OSError:
|
|
pass
|
|
return {"ok": False, "message": f"Installeur non lançable : {e}"}
|
|
|
|
# Volontairement pas de `unlink` : le fichier est encore en cours
|
|
# d'exécution, et ce service va être arrêté avant d'avoir pu le nettoyer.
|
|
# L'installeur suivant écrasera ce reliquat, nommé de façon unique.
|
|
logger.info("Installeur %s lancé, arrêt du service imminent",
|
|
manifest.get("version"))
|
|
return {"ok": True,
|
|
"message": f"Version {manifest.get('version')} en cours "
|
|
f"d'installation. WGSecure va se fermer, le tunnel "
|
|
f"sera coupé, puis l'application redémarrera à jour."}
|
|
|
|
|
|
_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,
|
|
"apply_update": _handle_apply_update,
|
|
}
|
|
|
|
|
|
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, logger)
|
|
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
|
|
|
|
|
|
def _relaunch_after_update(logger: logging.Logger) -> None:
|
|
"""Relance l'interface dans la session de l'utilisateur, après une mise à jour.
|
|
|
|
Rien ne la relançait. L'installeur est lancé par ce service, donc en
|
|
LocalSystem et dans la session 0 ; son entrée `[Run]` porte `skipifsilent`
|
|
et le `/VERYSILENT` de l'appel l'écarte. La lui rendre ne réglerait rien :
|
|
elle démarrerait l'interface là où elle tourne, session 0, sans bureau
|
|
visible et avec les privilèges du système — une fenêtre que personne ne
|
|
voit, tenant le VPN en SYSTEM.
|
|
|
|
Le geste revient donc ici. Ce service est le seul composant à pouvoir
|
|
ouvrir un processus dans la session d'un autre, et l'installeur le
|
|
redémarre une fois les binaires en place, après avoir posé le marqueur que
|
|
voici : le service qui le lit est déjà la nouvelle version.
|
|
|
|
Le jeton vient de la session console : l'interface repart avec les droits
|
|
de l'utilisateur, pas ceux du service.
|
|
"""
|
|
try:
|
|
age = time.time() - os.path.getmtime(_PENDING_RELAUNCH)
|
|
with open(_PENDING_RELAUNCH, encoding="utf-8") as f:
|
|
version = f.read().strip()
|
|
except OSError:
|
|
return # cas ordinaire : pas de mise à jour
|
|
finally:
|
|
# Effacé quoi qu'il arrive, y compris si la relance échoue : mieux
|
|
# vaut une interface à rouvrir à la main qu'un marqueur qui la
|
|
# rouvrirait à chaque démarrage du service.
|
|
try:
|
|
os.unlink(_PENDING_RELAUNCH)
|
|
except OSError:
|
|
pass
|
|
|
|
if age > _RELAUNCH_WINDOW:
|
|
logger.info("Marqueur de relance périmé (%d s), ignoré", age)
|
|
return
|
|
|
|
exe = os.path.join(os.path.dirname(sys.executable), "wgsecure.exe")
|
|
if not os.path.isfile(exe):
|
|
logger.warning("Relance impossible : %s introuvable", exe)
|
|
return
|
|
|
|
session = win32ts.WTSGetActiveConsoleSessionId()
|
|
if session == 0xFFFFFFFF:
|
|
# Mise à jour appliquée alors que personne n'est connecté : il n'y a
|
|
# pas de bureau où ouvrir la fenêtre, et l'utilisateur retrouvera
|
|
# l'application à jour à sa prochaine ouverture de session.
|
|
logger.info("Aucune session console : relance sans objet")
|
|
return
|
|
|
|
token = env = None
|
|
try:
|
|
token = win32ts.WTSQueryUserToken(session)
|
|
env = win32profile.CreateEnvironmentBlock(token, False)
|
|
startup = win32process.STARTUPINFO()
|
|
startup.lpDesktop = r"winsta0\default"
|
|
handles = win32process.CreateProcessAsUser(
|
|
token, exe, None, None, None, False,
|
|
win32con.CREATE_UNICODE_ENVIRONMENT, env,
|
|
os.path.dirname(exe), startup)
|
|
for h in handles[:2]: # processus et thread, inutiles ici
|
|
win32api.CloseHandle(h)
|
|
logger.info("Interface relancée en %s dans la session %d",
|
|
version or "version inconnue", session)
|
|
except pywintypes.error as e:
|
|
logger.warning("Relance de l'interface impossible : %s", e)
|
|
finally:
|
|
if env is not None:
|
|
try:
|
|
win32profile.DestroyEnvironmentBlock(env)
|
|
except pywintypes.error:
|
|
pass
|
|
if token is not None:
|
|
try:
|
|
win32api.CloseHandle(token)
|
|
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_, ""),
|
|
)
|
|
# 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é")
|
|
# Avant de servir : ce démarrage est peut-être celui que l'installeur
|
|
# vient de provoquer. Protégé à part — une relance qui échoue ne doit
|
|
# pas empêcher le service de faire son travail.
|
|
try:
|
|
_relaunch_after_update(logger)
|
|
except Exception:
|
|
logger.exception("Relance post-mise à jour en échec")
|
|
|
|
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)
|