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>
This commit is contained in:
@@ -26,6 +26,7 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from logging.handlers import RotatingFileHandler
|
||||
@@ -38,6 +39,9 @@ import win32event
|
||||
import win32file
|
||||
import win32pipe
|
||||
import win32security
|
||||
import win32process
|
||||
import win32profile
|
||||
import win32ts
|
||||
import win32service
|
||||
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.
|
||||
_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
|
||||
@@ -526,6 +545,85 @@ def _handle_connection(handle, logger: logging.Logger) -> None:
|
||||
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"
|
||||
@@ -575,6 +673,14 @@ class WGSecureHelperService(win32serviceutil.ServiceFramework):
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user