feat(update): mise à jour servie par le serveur, depuis l'intérieur du tunnel

Les binaires ne peuvent pas venir du dépôt Gitea : il est privé, et un jeton
embarqué dans une application distribuée est un jeton donné à tous ses
utilisateurs. Ils viennent donc du serveur WGSecure, où le contrôle d'accès
existe déjà — l'API n'est joignable que par le tunnel, et on y parvient parce
que sa clé WireGuard est déclarée. Aucun secret à embarquer.

Le serveur expose GET /api/version, qui rend la version publiée et l'empreinte
de chaque artefact, et /api/version/download/{clé} qui les sert. Le manifeste
est déposé par `make publish-updates`, avec les condensats calculés une fois à
la publication : les recalculer à chaque appel bloquerait l'API sur 125 Mo.
`ANNOUNCE=` permet d'annoncer les artefacts sous un autre numéro, pour exercer
la chaîne sans compiler une seconde version.

Rien ne s'installe sans un clic. La vérification est automatique — au montage
du tunnel puis toutes les demi-heures, car ne la faire qu'au montage laissait
un serveur momentanément injoignable annuler toute proposition pour la session
— mais le téléchargement attend le bouton. Cette application monte un VPN, et
une version défaillante qui se propagerait seule couperait l'accès d'un parc
entier sans que personne ne l'ait demandé.

L'artefact est vérifié contre son empreinte SHA-256 avant d'être mis en place,
et le fichier temporaire est détruit à la moindre anomalie. Le canal est déjà
authentifié par WireGuard ; le condensat couvre ce qu'il ne couvre pas — un
téléchargement tronqué, un disque plein, un artefact mal publié.

Sous Linux, le binaire est remplacé par renommage, sur le même système de
fichiers que sa destination. Un déplacement depuis /tmp se rabattait sur une
copie, donc sur une écriture dans l'exécutable en cours, que le noyau refuse
(ETXTBSY). Un renommage ne touche qu'une entrée de répertoire : l'ancien inode
reste vivant pour le processus, qui continue jusqu'à sa fermeture — et
l'application propose désormais de redémarrer plutôt que de le laisser deviner.

Sous Windows, l'installation passe par le service, qui tourne en LocalSystem et
écrit dans Program Files sans invite d'élévation. La commande `apply_update` ne
prend aucun paramètre : le service lit le manifeste, télécharge et vérifie
lui-même. Lui passer un fichier déjà téléchargé aurait donné à 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.

L'installeur orchestre le remplacement, ce qu'il ne faisait pas : les deux
exécutables à remplacer tournent au moment de la mise à jour, et Windows
verrouille l'image d'un processus vivant. Il ferme donc l'application par le
gestionnaire de redémarrage, arrête les tunnels et le service avant la copie —
en attendant la libération réelle du fichier, `sc stop` rendant la main avant
la fin de l'arrêt — et garde `restartreplace` en secours. Le service, lui,
lance l'installeur détaché et ne l'attend pas : il lui demande de se remplacer
lui-même.

Durcissement du service au passage. Le premier appelant devient propriétaire et
son SID est retenu ; ensuite seuls ce compte et les administrateurs sont
servis. Le pipe étant ouvert à tout utilisateur interactif, n'importe quel
compte du poste pouvait jusqu'ici couper le tunnel d'un autre ou poser une
règle NRPT valable pour toute la machine. Le contenu d'un `.conf` est également
vérifié — seul son chemin l'était — et les directives exécutables y sont
refusées.

Corrige enfin un défaut de compilation : Linux et Windows partageaient le
répertoire de travail de PyInstaller, que `--clean` vide au démarrage. Lancées
à la suite, les deux cibles effaçaient mutuellement leurs fichiers
intermédiaires, produisant un .exe gonflé de 44 Mo et un binaire Linux tronqué
dont l'archive ne se décompressait plus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tuxgyver
2026-09-08 14:33:52 +02:00
co-authored by Claude Opus 5
parent 414144560e
commit e520e30716
5 changed files with 872 additions and 14 deletions
+55 -1
View File
@@ -14,6 +14,12 @@ BUILD := build
ICON := /tmp/wgsecure_icon_build.png
# Options PyInstaller communes aux deux plateformes
# `--clean` vide le répertoire de travail au démarrage. Linux et Windows
# portant le même `--name`, ils partageaient `build/wgsecure/` : lancés à la
# suite, chacun effaçait les fichiers intermédiaires de l'autre en plein
# travail. Résultat observé le 8 septembre 2026 — un .exe Windows gonflé de
# 44 Mo et un binaire Linux tronqué dont l'archive ne se décompressait plus
# (« decompression resulted in return code -3 »). D'où un workpath par cible.
PI_OPTS := \
--noconfirm \
--clean \
@@ -26,7 +32,7 @@ PI_OPTS := \
--hidden-import cryptography.hazmat.primitives.asymmetric.x25519 \
--collect-submodules PyQt6
.PHONY: all linux windows helper windows-release check-windows-release 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 windows-release check-windows-release installer-deps release publish publish-updates 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
@@ -66,6 +72,7 @@ linux: install icon
@echo " 🐧 Compilation Linux (PyInstaller onefile)"
@echo ""
$(PYTHON) -m PyInstaller $(PI_OPTS) \
--workpath build/linux \
--onefile \
--icon=$(ICON) \
--add-data "CHANGELOG.md:." \
@@ -92,6 +99,7 @@ windows: icon
|| (echo " ❌ qwindows.dll introuvable dans $(WINE_QT_PLUGINS)/platforms"; \
echo " → Installez PyQt6 dans Wine avec : make setup-wine"; exit 1)
@wine $(WINE_PYTHON) -m PyInstaller $(PI_OPTS) \
--workpath build/windows \
--onefile \
--windowed \
--icon=$(ICON) \
@@ -129,6 +137,7 @@ helper:
@wine $(WINE_PYTHON) -m PyInstaller \
--noconfirm \
--clean \
--workpath build/helper \
--name wgsecure-helper \
--onefile \
--hidden-import win32timezone \
@@ -248,6 +257,51 @@ publish: release
done; \
bash "$(GITEA_RELEASE_SH)" --version $(VERSION) $$ASSETS
# ── Publication des artefacts vers le serveur de mise à jour ─────────────────
# Le client va chercher ses mises à jour sur le serveur WGSecure, pas sur
# Gitea : le dépôt est privé, et un jeton embarqué dans une application
# distribuée est un jeton donné à tous ses utilisateurs. Cette cible dépose
# les binaires et le manifeste que la route /api/version publie.
#
# Le condensat est calculé ici, une fois, et non par le serveur à chaque
# appel : un SHA-256 sur 125 Mo à chaque interrogation bloquerait l'API.
UPDATE_HOST := root@192.168.1.206
UPDATE_DIR := /opt/WGSecure-Serveur/artifacts
# ANNOUNCE permet d'annoncer les artefacts sous un autre numéro que celui
# qu'ils portent : `make publish-updates ANNOUNCE=9.9.9` fait croire au parc
# qu'une version existe, sans avoir à en compiler une seconde. Sert à exercer
# la chaîne de mise à jour — détection, téléchargement, empreinte, bascule —
# avec les binaires déjà construits.
ANNOUNCE ?= $(VERSION)
publish-updates:
@test -f "$(DIST)/$(APP)-$(VERSION)-linux-x86_64" \
|| (echo " ❌ Artefact Linux absent — lancez d'abord : make release"; exit 1)
@echo ""
@echo " 📤 Publication des artefacts $(VERSION) vers $(UPDATE_HOST)"
@FILES="$(DIST)/$(APP)-$(VERSION)-linux-x86_64"; \
[ -f "$(DIST)/$(APP)-$(VERSION)-setup.exe" ] \
&& FILES="$$FILES $(DIST)/$(APP)-$(VERSION)-setup.exe" \
|| echo " ⚠️ Installeur Windows absent : seuls les postes Linux seront servis"; \
scp -q $$FILES $(UPDATE_HOST):$(UPDATE_DIR)/
@$(PYTHON) -c "import json, hashlib, os, sys; \
v='$(VERSION)'; a='$(ANNOUNCE)'; d='$(DIST)'; app='$(APP)'; \
spec=[('linux-x86_64', f'{app}-{v}-linux-x86_64'), ('windows-setup', f'{app}-{v}-setup.exe')]; \
arts=[{'key': k, 'filename': n, 'size': os.path.getsize(f'{d}/{n}'), \
'sha256': hashlib.sha256(open(f'{d}/{n}','rb').read()).hexdigest()} \
for k, n in spec if os.path.isfile(f'{d}/{n}')]; \
notes=open('CHANGELOG.md', encoding='utf-8').read().split('## [')[1]; \
json.dump({'version': a, 'notes': '## [' + notes.split('\n---')[0].strip(), \
'artifacts': arts}, open('/tmp/wgs-manifest.json','w', encoding='utf-8'), \
ensure_ascii=False, indent=2); \
print(f' 🔑 {len(arts)} artefact(s) de {v}, annonces comme {a}')"
@scp -q /tmp/wgs-manifest.json $(UPDATE_HOST):$(UPDATE_DIR)/manifest.json
@ssh $(UPDATE_HOST) "chown -R wgsecure:wgsecure $(UPDATE_DIR)"
@rm -f /tmp/wgs-manifest.json
@echo " ✅ Mise à jour $(ANNOUNCE) proposée aux clients"
@echo ""
# ── Version courante ──────────────────────────────────────────────────────────
version:
@echo "$(VERSION)"
+243
View File
@@ -0,0 +1,243 @@
"""Mise à jour du client, servie par le serveur depuis l'intérieur du tunnel.
Les binaires ne peuvent pas venir du dépôt Gitea : il est privé, et un jeton
embarqué dans une application distribuée est un jeton donné à tous ses
utilisateurs. Ils viennent donc du serveur WGSecure, où l'accès est déjà
tranché — l'API n'est joignable que par le tunnel, et on y parvient parce que
sa clé WireGuard est déclarée. Aucun secret à embarquer ici.
Rien ne s'installe sans un clic : la vérification est automatique, la suite ne
l'est pas. Cette application monte un VPN, et une version défaillante qui se
propagerait seule couperait l'accès de tout un parc sans que personne ne l'ait
demandé.
L'archive téléchargée est vérifiée contre le condensat SHA-256 annoncé avant
d'être installée. Le canal est déjà authentifié par WireGuard ; le condensat
couvre ce que le chiffrement ne couvre pas — un fichier tronqué, un disque
plein, un artefact mal publié.
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
import tempfile
import urllib.error
import urllib.request
from typing import Callable, NamedTuple
from app.core.config import Config
from app.core.vpn_session import _OPENER, api_base_url
from app.utils.helper_ipc import call_helper
from app.utils.platform_utils import is_windows
# Le sondage part au montage du tunnel, dans un thread que l'arrêt de
# l'application attend : bref, donc, comme celui de l'état d'accès.
_CHECK_TIMEOUT = 5
# Le téléchargement, lui, porte sur 125 Mo : la borne couvre une liaison lente,
# pas un serveur muet — l'absence de premier octet est tranchée par le sondage.
_DOWNLOAD_TIMEOUT = 600
_CHUNK = 256 * 1024
class Update(NamedTuple):
"""Une version disponible et l'artefact qui convient à ce poste."""
version: str
notes: str
key: str
filename: str
size: int
sha256: str
def _parse_version(raw: str) -> tuple[int, ...]:
"""« 0.10.0 » → (0, 10, 0), pour comparer autrement qu'alphabétiquement.
Comparées comme des chaînes, « 0.9.1 » passait pour plus récent que
« 0.10.0 » : le neuvième caractère décidait de tout.
"""
parts = []
for chunk in (raw or "").strip().lstrip("vV").split("."):
digits = "".join(c for c in chunk if c.isdigit())
parts.append(int(digits) if digits else 0)
return tuple(parts) or (0,)
def artifact_key() -> str:
"""Artefact attendu par ce poste.
Sous Windows, c'est l'installeur et non l'exécutable nu : le `.exe` en
cours ne peut pas être remplacé pendant qu'il tourne, alors que
l'installeur sait arrêter, remplacer et relancer.
"""
return "windows-setup" if is_windows() else "linux-x86_64"
def check(cfg: Config, current: str) -> Update | None:
"""Une version plus récente que `current` est-elle publiée ?
`None` couvre tous les cas où il n'y a rien à proposer : serveur muet,
serveur qui ne distribue pas de mise à jour, version identique ou plus
ancienne, artefact absent pour cette plateforme. Aucun n'est une panne, et
aucun ne doit se voir — une vérification qui échoue ne doit jamais
interrompre quelqu'un qui voulait juste ouvrir son VPN.
"""
base = api_base_url(cfg)
if not base:
return None
request = urllib.request.Request(f"{base}/api/version", method="GET")
try:
with _OPENER.open(request, timeout=_CHECK_TIMEOUT) as response:
body = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, OSError, ValueError):
return None
latest = (body.get("version") or "").strip()
if not latest or _parse_version(latest) <= _parse_version(current):
return None
key = artifact_key()
entry = next((a for a in body.get("artifacts", [])
if a.get("key") == key), None)
if not entry or not entry.get("sha256"):
return None
return Update(version=latest, notes=body.get("notes", ""), key=key,
filename=entry.get("filename", key),
size=int(entry.get("size") or 0),
sha256=entry["sha256"].lower())
def download(cfg: Config, update: Update,
progress: Callable[[int, int], None] | None = None,
dest_dir: str = "") -> str:
"""Récupère l'artefact et rend son chemin, ou lève.
Écrit dans un fichier temporaire vérifié avant d'être présenté : un
téléchargement interrompu ne doit jamais laisser derrière lui quelque
chose qui ressemble à une mise à jour valide.
`dest_dir` place ce temporaire là où le fichier finira. Il allait dans
`/tmp`, souvent un autre système de fichiers que `~/.local/bin` : la mise
en place se faisait alors par copie — donc par écriture sur l'exécutable
en cours — et le noyau la refusait (« Fichier texte occupé »). Sur le même
système de fichiers, c'est un simple renommage, que rien n'interdit.
"""
base = api_base_url(cfg)
if not base:
raise RuntimeError("Adresse du serveur de mise à jour inconnue.")
request = urllib.request.Request(
f"{base}/api/version/download/{update.key}", method="GET")
digest = hashlib.sha256()
received = 0
fd, tmp = tempfile.mkstemp(prefix=".wgsecure-update-",
suffix=os.path.splitext(update.filename)[1],
dir=dest_dir or None)
try:
with _OPENER.open(request, timeout=_DOWNLOAD_TIMEOUT) as response, \
os.fdopen(fd, "wb") as out:
while True:
chunk = response.read(_CHUNK)
if not chunk:
break
out.write(chunk)
digest.update(chunk)
received += len(chunk)
if progress:
progress(received, update.size)
except BaseException:
os.unlink(tmp)
raise
if digest.hexdigest() != update.sha256:
os.unlink(tmp)
raise RuntimeError(
"Le fichier téléchargé ne correspond pas à l'empreinte annoncée "
"par le serveur : il est incomplet ou altéré. Rien n'a été "
"installé.")
if update.size and received != update.size:
os.unlink(tmp)
raise RuntimeError(f"Taille inattendue : {received} octets reçus pour "
f"{update.size} annoncés.")
return tmp
def current_binary() -> str:
"""Chemin de l'exécutable à remplacer, "" hors binaire figé.
Lancée depuis les sources (`python main.py`), l'application n'a pas de
binaire à remplacer : la mise à jour ne s'y applique pas, et le proposer
remplacerait l'interpréteur Python du système.
"""
return sys.executable if getattr(sys, "frozen", False) else ""
def apply_update(archive: str) -> str:
"""Installe l'artefact téléchargé. Rend le message à montrer, ou lève.
Deux systèmes, deux gestes. Sous Windows, l'installeur est lancé et
l'application doit quitter : un `.exe` en cours d'exécution est verrouillé
par le système, et se remplacer soi-même y est impossible. Sous Linux,
l'ancien binaire est simplement délié puis le nouveau mis à sa place —
le noyau garde le fichier ouvert vivant jusqu'à la fermeture du programme,
qui continue donc de tourner normalement jusqu'à son redémarrage.
"""
if is_windows():
raise RuntimeError("Sous Windows, la mise à jour passe par "
"`apply_via_helper`, pas par ce chemin.")
target = current_binary()
if not target:
raise RuntimeError(
"Mise à jour impossible : l'application tourne depuis les sources "
"et non depuis un binaire installé.")
try:
os.chmod(archive, 0o755)
# `os.replace` et non `shutil.move` : le premier est un renommage pur,
# qui échoue plutôt que de se rabattre sur une copie. C'est ce repli
# qui cassait la mise à jour — copier revient à écrire dans le fichier
# exécuté, ce que Linux refuse (ETXTBSY). Le renommage, lui, ne touche
# qu'une entrée de répertoire : l'ancien inode reste vivant pour le
# processus en cours, qui continue de tourner jusqu'à sa fermeture.
os.replace(archive, target)
except OSError as exc:
try:
os.unlink(archive)
except OSError:
pass
raise RuntimeError(
f"Remplacement de {target} impossible : {exc}") from exc
return ("Mise à jour installée. Elle prendra effet au prochain démarrage "
"de WGSecure.")
def apply_via_helper(cfg: Config) -> str:
"""Fait installer la mise à jour par le service, sans élévation.
Le service tourne en LocalSystem : il écrit dans Program Files sans qu'une
invite UAC n'apparaisse. Rien ne lui est transmis d'autre que l'adresse de
l'API — ni fichier, ni chemin, ni empreinte. Il lit le manifeste,
télécharge et vérifie lui-même. Lui passer un binaire déjà téléchargé
serait plus rapide et ouvrirait à tout compte du poste le moyen de faire
exécuter n'importe quoi avec les privilèges du système : le pipe est
ouvert aux utilisateurs interactifs.
Le service peut être absent — l'installeur le dépose sans l'enregistrer.
L'appelant reçoit alors de quoi le dire, plutôt qu'un échec opaque.
"""
base = api_base_url(cfg)
if not base:
raise RuntimeError("Adresse du serveur de mise à jour inconnue.")
reponse = call_helper({"cmd": "apply_update", "api_base": base},
timeout_ms=900_000)
if reponse is None:
raise RuntimeError(
"Le service WGSecure Helper ne répond pas. Sans lui, la mise à "
"jour demande les droits administrateur : lancez l'installeur "
"téléchargé depuis le serveur.")
if not reponse.get("ok"):
raise RuntimeError(str(reponse.get("message") or "Mise à jour refusée."))
return str(reponse.get("message") or "Mise à jour installée.")
+243 -8
View File
@@ -18,16 +18,22 @@ 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 urllib.error
import urllib.request
from logging.handlers import RotatingFileHandler
import pywintypes
import servicemanager
import win32api
import win32con
import win32event
import win32file
import win32pipe
@@ -54,6 +60,20 @@ _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")
# 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:
@@ -74,6 +94,116 @@ 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}")
@@ -170,20 +300,23 @@ def _run(cmd: list[str], timeout: int = 30) -> tuple[int, str]:
return -1, str(e)
def _handle_install_tunnel(req: dict, handle) -> dict:
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) -> dict:
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) -> dict:
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
@@ -191,18 +324,21 @@ def _handle_start_tunnel_service(req: dict, handle) -> dict:
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) -> dict:
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) -> dict:
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"))
@@ -211,14 +347,15 @@ def _handle_set_split_dns(req: dict, handle) -> dict:
return {"ok": code == 0, "message": msg}
def _handle_clear_split_dns(req: dict, handle) -> dict:
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) -> dict:
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`
@@ -233,6 +370,103 @@ def _handle_get_status(req: dict, handle) -> dict:
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,
@@ -241,6 +475,7 @@ _HANDLERS = {
"set_split_dns": _handle_set_split_dns,
"clear_split_dns": _handle_clear_split_dns,
"get_status": _handle_get_status,
"apply_update": _handle_apply_update,
}
@@ -278,7 +513,7 @@ def _handle_connection(handle, logger: logging.Logger) -> None:
resp = {"ok": False, "message": f"Commande inconnue : {cmd!r}"}
else:
try:
resp = handler(req, handle)
resp = handler(req, handle, logger)
except ValidationError as e:
resp = {"ok": False, "message": str(e)}
logger.info("%s -> ok=%s", cmd, resp.get("ok"))
+278 -3
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import os
import subprocess
from datetime import datetime, timedelta, timezone
from PyQt6.QtWidgets import (
@@ -7,9 +10,9 @@ from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QFrame, QMessageBox, QInputDialog,
QLineEdit, QListWidget, QListWidgetItem,
QComboBox,
QComboBox, QProgressBar,
)
from PyQt6.QtCore import Qt, QTimer, QDateTime, QEvent
from PyQt6.QtCore import Qt, QTimer, QDateTime, QEvent, pyqtSignal
from PyQt6.QtGui import QFont, QCloseEvent, QColor
from app.core.config import Config
@@ -19,6 +22,9 @@ from app.core import log as conn_log
from app.core import history as hist
from app.core import shutdown as shutdown_guard
from app.core import vpn_session
from app.core import updater
from app.utils.platform_utils import is_windows
from app import __version__ as app_version
from app.ui.worker import TaskWorker, ValueWorker
from app.ui.access_code_dialog import AccessCodeDialog
from app.ui.admin_window import AdminWindow
@@ -43,9 +49,22 @@ _ACCESS_UNKNOWN_BEFORE_CLOSE = 3
# devait être proposée. Le tunnel vient de monter : ses routes peuvent n'être
# pas encore posées, et la fenêtre surgirait pour un code déjà valide.
_PROBE_RETRY_MS = 3000
# Intervalle entre deux interrogations du serveur de mise à jour. La question
# ne se posait qu'à l'instant précis où le tunnel montait : un serveur pas
# encore joignable à cette seconde-là — routes non posées, quarantaine en
# cours — et plus rien n'était proposé de toute la session, sans que rien ne
# le dise. Un intervalle unique plutôt que deux rythmes : `check` rend `None`
# aussi bien quand le serveur se tait que lorsqu'il n'a rien de neuf, et les
# distinguer demanderait de changer sa signature pour un gain de cadence nul.
_UPDATE_RECHECK = timedelta(minutes=30)
class MainWindow(QMainWindow):
# Émis depuis le thread de téléchargement : passer par un signal est la
# seule façon sûre de toucher un widget depuis un autre thread que celui
# de l'interface.
update_progress = pyqtSignal(int, int)
def __init__(self, config: Config, parent=None):
super().__init__(parent)
self._cfg = config
@@ -88,6 +107,16 @@ class MainWindow(QMainWindow):
# Conservée après fermeture : la fenêtre reste parentée à celle-ci, et
# son thread doit être attendu avant destruction (cf. prepare_quit).
self._access_dialog: AccessCodeDialog | None = None
# Mise à jour : proposée, jamais imposée. `_update_dismissed` ne dure
# que la session — une version écartée d'un clic distrait se
# represente au prochain démarrage plutôt que d'être perdue.
self._update_worker: ValueWorker | None = None
self._update = None
self._update_dismissed = False
# Date du dernier échange abouti avec le serveur de mise à jour, et
# non de la dernière tentative : un échec doit être réessayé vite, une
# réponse obtenue peut attendre.
self._update_checked_at: datetime | None = None
self._reconnect_failures = 0
self._quitting = False
self._warned_foreign: set[str] = set()
@@ -154,12 +183,251 @@ class MainWindow(QMainWindow):
root.setSpacing(0)
root.addWidget(self._build_header())
root.addWidget(self._build_update_banner())
root.addWidget(self._build_status_action())
root.addWidget(self._build_details())
# ── Pied de page ─────────────────────────────────────────────────
root.addWidget(self._build_footer())
def _build_update_banner(self) -> QWidget:
"""Bandeau proposant une mise à jour, masqué le reste du temps.
Placé sous l'en-tête et non dans le panneau de détails : une version
disponible se rate si elle attend qu'on déplie quelque chose. Il
n'apparaît qu'une fois le serveur consulté, ne se réaffiche pas dans
la session si l'utilisateur l'écarte, et n'installe rien sans clic.
"""
self._update_banner = QWidget()
self._update_banner.setStyleSheet(
f"background: {theme.WARN_BG}; border-radius: 4px;")
# Non parenté au bandeau : il reçoit plus bas un layout vertical qui
# empile cette ligne et la barre de progression.
row = QHBoxLayout()
row.setContentsMargins(10, 6, 10, 6)
row.setSpacing(6)
self._update_label = QLabel("")
self._update_label.setWordWrap(True)
# Blanc plutôt que le jaune du bandeau : sur ce fond, le texte
# d'avertissement passait pour du décor et se lisait mal.
self._update_label.setStyleSheet(
f"color: {theme.TEXT}; font-size: 11px; background: transparent;")
row.addWidget(self._update_label, 1)
self._btn_update = QPushButton("MAJ")
self._btn_update.setFixedHeight(24)
self._btn_update.setStyleSheet(
f"QPushButton {{ background: {theme.OK_SOLID}; color: white;"
" font-weight: bold; border: none; border-radius: 4px;"
" padding: 0 14px; }"
f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}"
f"QPushButton:disabled {{ background: {theme.BG_RAISED};"
f" color: {theme.TEXT_FAINT}; }}")
self._btn_update.clicked.connect(self._install_update)
row.addWidget(self._btn_update)
# Report : une croix, pas un mot. Elle dit « pas maintenant » sans
# entrer en concurrence de lecture avec l'action principale.
btn_later = QPushButton("")
btn_later.setFixedSize(24, 24)
btn_later.setToolTip("Reporter — la proposition reviendra au prochain "
"démarrage")
btn_later.setStyleSheet(
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: white;"
" font-weight: bold; border: none; border-radius: 4px; }"
f"QPushButton:hover {{ background: {theme.FAIL_HOVER}; }}")
btn_later.clicked.connect(self._dismiss_update)
row.addWidget(btn_later)
# Le téléchargement porte sur une centaine de mégaoctets : sans rien
# à l'écran, l'application paraissait figée et l'utilisateur ne
# pouvait pas distinguer une attente d'un échec.
self._update_progress = QProgressBar()
self._update_progress.setFixedHeight(6)
self._update_progress.setTextVisible(False)
self._update_progress.setStyleSheet(
f"QProgressBar {{ border: none; border-radius: 3px;"
f" background: {theme.BG}; }}"
f"QProgressBar::chunk {{ background: {theme.OK_SOLID};"
" border-radius: 3px; }")
self._update_progress.setVisible(False)
outer = QVBoxLayout()
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(4)
inner = QWidget()
inner.setLayout(row)
inner.setStyleSheet("background: transparent;")
outer.addWidget(inner)
outer.addWidget(self._update_progress)
self._update_banner.setLayout(outer)
self.update_progress.connect(self._on_download_progress)
self._update_banner.setVisible(False)
return self._update_banner
def _on_download_progress(self, recu: int, total: int):
"""Avancement du téléchargement, reçu du thread qui le mène."""
if total > 0:
self._update_progress.setRange(0, total)
self._update_progress.setValue(recu)
self._btn_update.setText(f"{100 * recu // total} %")
else:
# Total inconnu : barre en va-et-vient plutôt qu'une jauge figée
# à zéro, qui se lit comme une panne.
self._update_progress.setRange(0, 0)
def _update_due(self) -> bool:
"""Faut-il (re)demander au serveur s'il propose une version ?
Deux rythmes : rapide tant que le serveur n'a rien répondu, lent une
fois qu'il a parlé. Une mise à jour déjà proposée, ou écartée pour la
session, ne se redemande pas.
"""
if self._update is not None or self._update_dismissed:
return False
if self._update_checked_at is None:
return True
return (datetime.now(timezone.utc)
- self._update_checked_at) >= _UPDATE_RECHECK
def _check_update(self):
"""Interroge le serveur, sans bloquer ni déranger si rien n'est trouvé."""
if self._update_worker is not None or self._update_dismissed:
return
self._update_worker = ValueWorker(updater.check, self._cfg,
app_version, parent=self)
self._update_worker.done.connect(self._on_update_checked)
self._update_worker.finished.connect(self._update_worker.deleteLater)
self._update_worker.start()
def _on_update_checked(self, found):
self._update_worker = None
self._update = found
self._update_checked_at = datetime.now(timezone.utc)
if found is None or self._update_dismissed:
return
taille = f"{found.size / 1048576:.0f} Mo" if found.size else ""
self._update_label.setText(
f"⬆️ Version {found.version} disponible"
+ (f" ({taille})" if taille else ""))
self._update_banner.setVisible(True)
self._sync_window_height()
self._add_log(f"Version {found.version} disponible sur le serveur",
"info")
def _dismiss_update(self):
"""Écarte la proposition pour cette session, sans la refuser à jamais.
Rien n'est écrit : au prochain démarrage la question se repose, et une
version ignorée par mégarde n'est pas perdue pour toujours.
"""
self._update_dismissed = True
self._update_banner.setVisible(False)
self._sync_window_height()
def _install_update(self):
"""Télécharge puis installe, après confirmation explicite."""
if self._update is None or self._update_worker is not None:
return
reponse = QMessageBox.question(
self, "Mettre à jour WGSecure",
f"Installer la version {self._update.version} ?\n\n"
f"Le fichier sera téléchargé depuis le serveur, puis vérifié "
f"contre l'empreinte qu'il annonce avant d'être installé.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No)
if reponse != QMessageBox.StandardButton.Yes:
return
self._btn_update.setEnabled(False)
self._btn_update.setText("")
self._update_progress.setRange(0, 0) # va-et-vient tant que rien
self._update_progress.setVisible(True) # n'est encore reçu
self._sync_window_height()
self._update_worker = ValueWorker(self._download_and_apply,
self._update, parent=self)
self._update_worker.done.connect(self._on_update_installed)
self._update_worker.finished.connect(self._update_worker.deleteLater)
self._update_worker.start()
def _download_and_apply(self, info):
"""Exécuté hors du thread graphique : 125 Mo, puis remplacement.
Rend le message à montrer, ou le relaie préfixé — `ValueWorker` avale
les exceptions en rendant `None`, ce qui ne dirait pas ce qui a échoué.
"""
try:
if is_windows():
# Le service fait tout : il télécharge et vérifie lui-même,
# pour qu'aucun compte du poste ne puisse lui faire installer
# un fichier de son choix avec les privilèges du système.
return updater.apply_via_helper(self._cfg)
cible = updater.current_binary()
archive = updater.download(
self._cfg, info,
progress=lambda recu, total: self.update_progress.emit(recu, total),
dest_dir=os.path.dirname(cible) if cible else "")
return updater.apply_update(archive)
except Exception as exc: # noqa: BLE001 — remonté à l'UI
return f"ERREUR: {exc}"
def _on_update_installed(self, message):
self._update_worker = None
self._btn_update.setEnabled(True)
self._btn_update.setText("MAJ")
self._update_progress.setVisible(False)
self._sync_window_height()
texte = message or "Échec inattendu de la mise à jour."
if texte.startswith("ERREUR: "):
texte = texte[8:]
self._add_log(f"Mise à jour : {texte}", "error")
QMessageBox.warning(self, "Mise à jour impossible", texte)
return
self._add_log(texte, "success")
self._update_banner.setVisible(False)
self._sync_window_height()
QMessageBox.information(self, "Mise à jour", texte)
# Sous Windows, l'installeur tourne déjà et attend que les fichiers se
# libèrent : rester ouvert le ferait patienter jusqu'à son délai, puis
# échouer. On rend donc la main tout de suite — `prepare_quit` démonte
# le tunnel et restaure le DNS avant de fermer, ce qui laisse aussi le
# service du tunnel arrêtable par l'installeur.
if is_windows():
self._add_log("Fermeture pour laisser l'installeur remplacer les "
"fichiers", "warning")
self.prepare_quit()
QApplication.quit()
return
# Sous Linux, le binaire est déjà remplacé et l'application continue de
# tourner sur l'ancien, que le noyau garde en vie. Rien n'oblige à
# fermer — mais le dire sans rien proposer laissait l'utilisateur
# devant une version périmée sans savoir quoi faire.
cible = updater.current_binary()
if not cible:
return
if QMessageBox.question(
self, "Redémarrer WGSecure",
"La nouvelle version est en place. Redémarrer maintenant ?\n\n"
"Le tunnel sera coupé le temps du redémarrage.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes
) != QMessageBox.StandardButton.Yes:
return
try:
# Lancé détaché : sans cela le nouveau processus mourrait avec
# celui-ci, qui s'apprête justement à disparaître.
subprocess.Popen([cible], start_new_session=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
except OSError as exc:
QMessageBox.warning(self, "Redémarrage impossible", str(exc))
return
self.prepare_quit()
QApplication.quit()
# ── Section repliable : tout ce qui n'est pas statut/action ──────────
def _build_details(self) -> QWidget:
self._details = QWidget()
@@ -763,6 +1031,11 @@ class MainWindow(QMainWindow):
# hors de l'application. Demander au serveur avant de conclure
# quoi que ce soit : lui seul sait si l'accès est encore ouvert.
self._probe_access(prompt_if_closed=False)
# Le serveur de mise à jour n'est joignable que par le
# tunnel : la question ne se pose qu'une fois celui-ci monté.
QTimer.singleShot(2000, self._check_update)
elif self._update_due():
self._check_update()
elif self._access_open and self._access_stale():
# Accès cru ouvert : le reconfirmer périodiquement, une
# révocation côté serveur n'étant annoncée par rien.
@@ -1231,7 +1504,8 @@ class MainWindow(QMainWindow):
# Laisser les threads en cours se terminer avant de démonter le tunnel :
# une connexion et une déconnexion simultanées laisseraient un état mixte.
for worker in (self._worker, self._ping_worker, self._probe_worker):
for worker in (self._worker, self._ping_worker, self._probe_worker,
self._update_worker):
try:
if worker is not None and worker.isRunning():
worker.wait(15000)
@@ -1246,6 +1520,7 @@ class MainWindow(QMainWindow):
self._worker = None
self._ping_worker = None
self._probe_worker = None
self._update_worker = None
if self._session_id is not None:
hist.end_session(self._session_id, self._prev_rx or 0,
+53 -2
View File
@@ -24,19 +24,31 @@ SolidCompression=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=admin
; Mise à jour d'une installation existante : les deux exécutables à remplacer
; sont en cours d'exécution — wgsecure.exe côté utilisateur, wgsecure-helper.exe
; en service. Windows verrouille l'image d'un processus vivant : sans ce qui
; suit, la copie échoue et l'installeur repart en ayant l'air d'avoir réussi.
;
; Le Restart Manager ferme l'application qui détient les fichiers ; le service,
; lui, est arrêté explicitement avant la copie (cf. StopForUpgrade). Les
; fichiers reçoivent en secours le drapeau `restartreplace`, qui programme le
; remplacement au prochain démarrage si un verrou subsiste malgré tout.
CloseApplications=yes
CloseApplicationsFilter=*.exe
RestartApplications=no
[Languages]
Name: "french"; MessagesFile: "compiler:Languages\French.isl"
[Files]
Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion restartreplace
; 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
Source: "dist\wgsecure-helper.exe"; DestDir: "{app}"; Flags: ignoreversion restartreplace 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
@@ -119,8 +131,47 @@ begin
ewWaitUntilTerminated, ResultCode);
end;
// Arrêt de tout ce qui tient les fichiers, avant la copie. Le service se
// remplace lui-même : il doit donc être à l'arrêt, et `sc stop` rend la main
// avant que le processus n'ait fini de mourir — d'où l'attente qui suit.
// Les tunnels sont arrêtés au passage : leur service tourne en SYSTEM et
// n'est pas remplacé ici, mais laisser le VPN monté pendant que son pilote
// disparaît de l'écran n'aurait aucun sens pour l'utilisateur.
procedure StopForUpgrade;
var
ResultCode, I: Integer;
Names: TArrayOfString;
J: Integer;
begin
// Tunnels WireGuard posés par WGSecure : le nom d'interface est réglable,
// on interroge donc le registre des services plutôt que de deviner « wgs0 ».
if RegGetSubkeyNames(HKEY_LOCAL_MACHINE, 'SYSTEM\CurrentControlSet\Services', Names) then
begin
for J := 0 to GetArrayLength(Names) - 1 do
if Pos('WireGuardTunnel$', Names[J]) = 1 then
Exec(ExpandConstant('{sys}\sc.exe'), 'stop ' + AddQuotes(Names[J]),
'', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
Exec(ExpandConstant('{sys}\sc.exe'), 'stop WGSecureHelper', '', SW_HIDE,
ewWaitUntilTerminated, ResultCode);
// `sc stop` demande l'arrêt sans l'attendre : copier tout de suite
// retomberait sur un binaire encore verrouillé.
for I := 1 to 20 do
begin
if not FileExists(ExpandConstant('{app}\wgsecure-helper.exe')) then
Break;
if RenameFile(ExpandConstant('{app}\wgsecure-helper.exe'),
ExpandConstant('{app}\wgsecure-helper.exe')) then
Break;
Sleep(500);
end;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
StopForUpgrade;
if CurStep = ssPostInstall then
InstallHelperService;
end;