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
+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;