2 Commits
Author SHA1 Message Date
tuxgyverandClaude Opus 5 0bb8036df8 chore(release): v0.12.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:14:00 +02:00
tuxgyverandClaude Opus 5 e781acbf4a fix(update): sous Windows, l'application est relancée et la copie attend vraiment
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>
2026-09-08 18:14:00 +02:00
6 changed files with 190 additions and 11 deletions
+9
View File
@@ -6,6 +6,15 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
--- ---
## [0.12.1] — 2026-09-08
### Corrigé
- **Sous Windows, l'application n'était jamais relancée après une mise à jour.** Trois choses y concouraient : le service lance l'installeur avec `/VERYSILENT`, l'entrée `[Run]` qui rouvre l'application porte `skipifsilent` et s'en trouve écartée, et `RestartApplications=no` laisse le Restart Manager fermer sans rouvrir. Le service annonçait pourtant que l'application redémarrerait. Lui rendre l'entrée `[Run]` n'aurait rien réglé : l'installeur étant lancé par le service, il tourne 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 donc 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 est celui de la session console : l'application repart avec les droits de l'utilisateur.
- **Le marqueur de relance est posé par l'installeur, non par le service.** Le service ne saurait qu'avoir lancé un installeur, quand celui-ci sait que l'installation a abouti. Il porte de surcroît déjà la 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. Il n'est posé qu'en installation silencieuse : une installation lancée à la main rouvre déjà l'application par son entrée `[Run]`.
- **L'installeur copiait par-dessus un binaire encore verrouillé.** L'attente censée laisser mourir le service demandait à `RenameFile` de renommer le fichier sur lui-même et tenait l'échec pour un verrou. Elle ne mesurait rien : Windows autorise le renommage de l'image d'un processus vivant — c'est ce qui rend `restartreplace` possible — et `MoveFile` refuse de toute façon une destination existante. La réponse était donc la même quel que soit l'état réel du service. La copie échouait, `restartreplace` reportait le remplacement au redémarrage suivant, et l'installeur s'achevait en annonçant une réussite que rien n'avait accomplie. L'attente porte maintenant 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.
---
## [0.12.0] — 2026-09-08 ## [0.12.0] — 2026-09-08
### Ajouté ### Ajouté
+1 -1
View File
@@ -4,7 +4,7 @@
## ────────────────────────────────────────────── ## ──────────────────────────────────────────────
APP := wgsecure APP := wgsecure
VERSION := 0.12.0 VERSION := 0.12.1
VENV := .venv VENV := .venv
PYTHON := $(VENV)/bin/python3 PYTHON := $(VENV)/bin/python3
PIP := $(VENV)/bin/pip PIP := $(VENV)/bin/pip
+2 -2
View File
@@ -1,6 +1,6 @@
# 🛡️ WGSecure (WGS) # 🛡️ WGSecure (WGS)
🚀 v0.12.0 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard 🚀 v0.12.1 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**. **WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**.
@@ -210,4 +210,4 @@ WGSecure/
## 👤 Auteur ## 👤 Auteur
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
Version : ![v0.12.0](https://img.shields.io/badge/v0.12.0-septembre%202026-2980b9?style=flat-square) — Septembre 2026 Version : ![v0.12.1](https://img.shields.io/badge/v0.12.1-septembre%202026-2980b9?style=flat-square) — Septembre 2026
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.12.0" __version__ = "0.12.1"
APP_NAME = "WGSecure" APP_NAME = "WGSecure"
APP_SHORT = "WGS" APP_SHORT = "WGS"
+106
View File
@@ -26,6 +26,7 @@ import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
@@ -38,6 +39,9 @@ import win32event
import win32file import win32file
import win32pipe import win32pipe
import win32security import win32security
import win32process
import win32profile
import win32ts
import win32service import win32service
import win32serviceutil import win32serviceutil
@@ -66,6 +70,21 @@ _LOG_FILE = os.path.join(_LOG_DIR, "helper.log")
# poser une règle DNS valable pour toute la machine. # poser une règle DNS valable pour toute la machine.
_OWNER_FILE = os.path.join(_LOG_DIR, "owner.sid") _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 # Directives refusées dans un `.conf` : elles font exécuter des commandes par
# le service, donc en LocalSystem. `wireguard.exe` les ignore sous Windows, # 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 # mais le fichier n'a aucune raison d'en porter — les refuser coûte une ligne
@@ -526,6 +545,85 @@ def _handle_connection(handle, logger: logging.Logger) -> None:
pass 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): class WGSecureHelperService(win32serviceutil.ServiceFramework):
_svc_name_ = "WGSecureHelper" _svc_name_ = "WGSecureHelper"
_svc_display_name_ = "WGSecure Helper" _svc_display_name_ = "WGSecure Helper"
@@ -575,6 +673,14 @@ class WGSecureHelperService(win32serviceutil.ServiceFramework):
raise raise
logger.info("Service démarré") 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: try:
self._serve_forever(logger) self._serve_forever(logger)
except Exception: except Exception:
+71 -7
View File
@@ -5,7 +5,7 @@
; ────────────────────────────────────────────── ; ──────────────────────────────────────────────
#define MyAppName "WGSecure" #define MyAppName "WGSecure"
#define MyAppVersion "0.12.0" #define MyAppVersion "0.12.1"
#define MyAppPublisher "WGSecure" #define MyAppPublisher "WGSecure"
#define MyAppExeName "wgsecure.exe" #define MyAppExeName "wgsecure.exe"
@@ -35,6 +35,13 @@ PrivilegesRequired=admin
; remplacement au prochain démarrage si un verrou subsiste malgré tout. ; remplacement au prochain démarrage si un verrou subsiste malgré tout.
CloseApplications=yes CloseApplications=yes
CloseApplicationsFilter=*.exe CloseApplicationsFilter=*.exe
; Le Restart Manager ferme l'application, il ne la rouvre pas. C'est
; volontaire : lors d'une mise à jour automatique, cet installeur est lancé
; par le service, donc en LocalSystem depuis la session 0 — ce que le Restart
; Manager y relancerait n'aurait ni bureau visible ni les droits de
; l'utilisateur. La relance est confiée au service, qui repart une fois les
; binaires en place et sait ouvrir un processus dans la session console
; (cf. `_relaunch_after_update` dans app/service/helper_service.py).
RestartApplications=no RestartApplications=no
[Languages] [Languages]
@@ -71,6 +78,11 @@ Filename: "{tmp}\VC_redist.x64.exe"; Parameters: "/install /quiet /norestart"; S
; (PrivilegesRequired=admin ci-dessus), donc pas d'UAC supplémentaire non plus. ; (PrivilegesRequired=admin ci-dessus), donc pas d'UAC supplémentaire non plus.
; `waituntilterminated` : l'app ne doit pas démarrer avant que wg.exe existe. ; `waituntilterminated` : l'app ne doit pas démarrer avant que wg.exe existe.
Filename: "{tmp}\wireguard-installer.exe"; StatusMsg: "Installation de WireGuard…"; Check: WireGuardNeedsInstall; Flags: waituntilterminated Filename: "{tmp}\wireguard-installer.exe"; StatusMsg: "Installation de WireGuard…"; Check: WireGuardNeedsInstall; Flags: waituntilterminated
; `skipifsilent` est délibéré : cette entrée ne vaut que pour une installation
; lancée à la main, où elle démarre l'application sous le compte qui installe.
; La mise à jour automatique, elle, passe par `/VERYSILENT` sous LocalSystem —
; l'application y serait ouverte dans la session 0, invisible et en SYSTEM.
; C'est le service qui la relance dans la bonne session (cf. RestartApplications).
Filename: "{app}\{#MyAppExeName}"; Description: "Lancer {#MyAppName}"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#MyAppExeName}"; Description: "Lancer {#MyAppName}"; Flags: nowait postinstall skipifsilent
[Code] [Code]
@@ -131,6 +143,31 @@ begin
ewWaitUntilTerminated, ResultCode); ewWaitUntilTerminated, ResultCode);
end; end;
// Le processus du service est-il encore vivant ?
//
// L'attente précédente demandait à RenameFile() de renommer le binaire sur
// lui-même, en tenant l'échec pour un verrou. Elle ne mesurait rien : Windows
// laisse renommer l'image d'un processus vivant — c'est même ce qui rend
// possible le `restartreplace` — et MoveFile() refuse de toute façon une
// destination existante, verrou ou pas. La boucle rendait donc toujours la
// même réponse, quel que soit l'état réel du service, et la copie partait sur
// un fichier encore tenu : elle échouait, `restartreplace` reportait le
// remplacement au redémarrage suivant, et l'installeur s'achevait en
// annonçant une réussite que rien n'avait accomplie.
//
// tasklist interroge la liste des processus, et le nom d'image n'est pas
// traduit — contrairement aux libellés d'état de sc.exe, qui le sont sur un
// Windows français.
function HelperStillRunning: Boolean;
var
ResultCode: Integer;
begin
Result := Exec(ExpandConstant('{cmd}'),
'/C tasklist /FI "IMAGENAME eq wgsecure-helper.exe" | find /I "wgsecure-helper.exe"',
'', SW_HIDE, ewWaitUntilTerminated, ResultCode)
and (ResultCode = 0);
end;
// Arrêt de tout ce qui tient les fichiers, avant la copie. Le service se // 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 // 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. // avant que le processus n'ait fini de mourir — d'où l'attente qui suit.
@@ -156,24 +193,51 @@ begin
Exec(ExpandConstant('{sys}\sc.exe'), 'stop WGSecureHelper', '', SW_HIDE, Exec(ExpandConstant('{sys}\sc.exe'), 'stop WGSecureHelper', '', SW_HIDE,
ewWaitUntilTerminated, ResultCode); ewWaitUntilTerminated, ResultCode);
// `sc stop` demande l'arrêt sans l'attendre : copier tout de suite // `sc stop` demande l'arrêt sans l'attendre : copier tout de suite
// retomberait sur un binaire encore verrouillé. // retomberait sur un binaire encore verrouillé. On attend donc la
for I := 1 to 20 do // disparition du *processus*, seule chose qui tienne le verrou — le service
// peut être annoncé arrêté alors que son image l'est encore.
for I := 1 to 60 do
begin begin
if not FileExists(ExpandConstant('{app}\wgsecure-helper.exe')) then if not HelperStillRunning then
Break;
if RenameFile(ExpandConstant('{app}\wgsecure-helper.exe'),
ExpandConstant('{app}\wgsecure-helper.exe')) then
Break; Break;
Sleep(500); Sleep(500);
end; end;
end; end;
// Signale au service qu'il devra rouvrir l'interface. Uniquement en mode
// silencieux : une installation lancée à la main rouvre l'application par son
// entrée [Run], sous le compte qui installe, et poser le marqueur ferait
// alors deux ouvertures pour une seule installation.
//
// Écrit ici plutôt que par le service qui a lancé cet installeur : le service
// ne sait que l'avoir lancé, cet installeur sait que l'installation a abouti.
// Il porte de surcroît déjà la correction, donc la première mise à jour en
// profite — un marqueur posé par l'ancien service n'aurait rien produit.
//
// Avant `InstallHelperService`, qui démarre le service : c'est ce démarrage
// qui le relit.
procedure MarkRelaunchPending;
var
Dir: String;
begin
if not WizardSilent then
exit;
Dir := ExpandConstant('{commonappdata}\WGSecure');
if not ForceDirectories(Dir) then
exit; // pas de marqueur, pas de relance
SaveStringToFile(Dir + '\relaunch.pending',
'{#MyAppVersion}', False);
end;
procedure CurStepChanged(CurStep: TSetupStep); procedure CurStepChanged(CurStep: TSetupStep);
begin begin
if CurStep = ssInstall then if CurStep = ssInstall then
StopForUpgrade; StopForUpgrade;
if CurStep = ssPostInstall then if CurStep = ssPostInstall then
begin
MarkRelaunchPending;
InstallHelperService; InstallHelperService;
end;
end; end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);