diff --git a/app/service/helper_service.py b/app/service/helper_service.py index 10762e0..1a5841b 100644 --- a/app/service/helper_service.py +++ b/app/service/helper_service.py @@ -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: diff --git a/wgsecure.iss b/wgsecure.iss index 72b3c89..a789980 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -35,6 +35,13 @@ PrivilegesRequired=admin ; remplacement au prochain démarrage si un verrou subsiste malgré tout. CloseApplications=yes 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 [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. ; `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 +; `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 [Code] @@ -131,6 +143,31 @@ begin ewWaitUntilTerminated, ResultCode); 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 // 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. @@ -156,24 +193,51 @@ begin 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 + // retomberait sur un binaire encore verrouillé. On attend donc la + // 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 - if not FileExists(ExpandConstant('{app}\wgsecure-helper.exe')) then - Break; - if RenameFile(ExpandConstant('{app}\wgsecure-helper.exe'), - ExpandConstant('{app}\wgsecure-helper.exe')) then + if not HelperStillRunning then Break; Sleep(500); 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); begin if CurStep = ssInstall then StopForUpgrade; if CurStep = ssPostInstall then + begin + MarkRelaunchPending; InstallHelperService; + end; end; procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);