10 Commits
Author SHA1 Message Date
tuxgyverandClaude Opus 5 cc3330fa4c chore(release): v0.13.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:01:12 +02:00
tuxgyverandClaude Opus 5 142eff5916 feat(ui): un thème clair, et plus une couleur hors du module qui les tient
Le module de thème publie deux palettes derrière les mêmes noms : `apply()`
rebranche `BG`, `TEXT`, `TAB_CSS` sur celle qui est choisie au lancement, et
les cent soixante-dix appels répartis dans l'interface continuent de lire
`theme.BG` sans rien savoir du thème actif. Aucun widget n'a eu à changer —
c'est ce que permettait le passage systématique par `theme.X` à l'usage.

Le choix est lu une seule fois, au démarrage. Les feuilles de style sont
posées dans les constructeurs des fenêtres ; les repeindre à chaud
supposerait de reconstruire celle qui porte l'état du tunnel, ses fils
d'exécution et ses minuteries. Le message de confirmation annonce donc la
prise d'effet au prochain lancement plutôt que de laisser croire le contraire.
Le thème sombre reste le défaut, et rend exactement les mêmes feuilles de
style qu'avant.

Un thème clair n'est pas une inversion, et trois familles de valeurs vivaient
en dur là où elles étaient jusqu'ici invisibles. Dans la feuille de style
commune, les variantes d'état des champs et des boutons étaient des voiles
blancs translucides — c'est-à-dire rien du tout sur un fond clair. Le texte
posé sur un aplat saturé — bouton plein, onglet actif, bandeau, sélection —
portait `TEXT`, qui devient presque noir en thème clair et disparaîtrait sur
l'accent : un jeton distinct le dit, blanc dans les deux thèmes. Enfin le
graphe de bande passante peignait son fond en dur, soit un panneau sombre au
milieu d'une page claire ; ses sept teintes sont devenues des jetons, comme
quatre couleurs de survol de la fenêtre principale.

Deux endroits figeaient par ailleurs le thème au chargement de leur module et
auraient gardé les teintes sombres quel que soit le réglage : la feuille de
style de la fenêtre d'historique, bâtie au niveau du module, et la table
état → couleur du diagnostic, évaluée comme attribut de classe. La première
devient une fonction, la seconde ne retient que la puce et demande la couleur
au thème au moment de l'affichage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:01:12 +02:00
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
tuxgyverandClaude Opus 5 99e1db5880 chore(release): v0.12.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:13:35 +02:00
tuxgyverandClaude Opus 5 9e72fa4155 feat(ui): une saisie réussie se voit, et le trafic est vérifié derrière
Un code accepté ne se manifestait que par une ligne de journal, écrite
pendant que la fenêtre de saisie se refermait : rien à l'écran ne distinguait
une validation d'un abandon, et l'échéance obtenue — ce que l'utilisateur
venait précisément de demander — restait invisible. Une confirmation
l'annonce désormais, avec sa date de fin et le temps restant.

Le serveur peut par ailleurs enregistrer l'autorisation sans que le trafic
passe pour autant : la règle de pare-feu peut ne pas s'appliquer, et le
réseau reste alors muet malgré un code accepté. Le client le constate
maintenant en fond, en observant ce qui passe là où le sondage se contente de
demander au serveur ce qu'il a enregistré. `remote_network_reachable` devient
publique pour cela — l'interface l'appelle, et un nom privé traversant la
frontière du module aurait menti sur sa portée.

Corrige au passage un refus qui, au lieu de s'afficher, interrompait le
traitement de la réponse. Contre un serveur antérieur à la v0.3.2, qui exige
encore l'identifiant que ce poste ne transmet plus, `POST /api/session` répond
422 ; le `detail` d'une erreur de validation FastAPI est une liste, non une
phrase, et elle atterrissait telle quelle dans un champ que l'interface passe
ensuite à Qt. Les deux gestionnaires `HTTPError` extrayaient ce champ par les
mêmes quatre lignes : elles deviennent `_error_detail`, qui ne retient qu'une
chaîne et rattrape en plus le corps JSON sans objet, sur lequel `.get` levait
une `AttributeError` non capturée. Le 422 dit désormais quoi faire — mettre le
serveur à jour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:13:35 +02:00
tuxgyver 2f2da257c3 chore(release): v0.11.0 2026-09-08 14:37:11 +02:00
tuxgyverandClaude Opus 5 e520e30716 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>
2026-09-08 14:33:52 +02:00
tuxgyver 414144560e chore(release): v0.10.0 2026-09-08 12:20:19 +02:00
tuxgyverandClaude Opus 5 b3cf706bb6 feat(ui): panneau allégé, et le diagnostic nomme le chevauchement de plages
L'assistant « configuration serveur + client » disparaît. Il datait d'avant la
v0.8.0 et produisait un serveur WireGuard nu — sans API, sans quarantaine, sans
TOTP — incompatible avec le produit actuel, où le serveur enrôle le compte et
fournit un .conf. Son bouton « Appliquer le volet client » écrasait clés client
et clé publique du serveur : sur un poste déjà enrôlé, il détruisait le lien
avec le serveur, qui déclare l'ancienne clé publique.

L'identifiant VPN disparaît aussi, devenu sans emploi depuis que le serveur
reconnaît le poste à l'adresse de son tunnel. Un champ qui ne fait rien est
pire qu'un champ absent : il laisse croire qu'il compte. Les configurations
existantes sont purgées au chargement, profils compris. La pastille « À propos »
qui en déduisait « Code d'accès serveur » ou « Aucun code d'accès » n'affirme
plus rien qu'elle ne sache : l'accès passe par le serveur dans tous les cas.

Le sous-onglet Tunnel passe d'une liste de quinze champs à trois groupes :
l'essentiel, le split-DNS nommé comme tel — trois champs y portaient « DNS »
sans que rien ne dise lesquels allaient ensemble — et un repli « Avancé ».
Celui-ci réunit ce qui vient du serveur (adresse, port, clé publique) et ce qui
est correct par défaut (nom d'interface, URL de l'API, keepalive, MTU).

Le verrouillage après import d'un .conf devient persistant. Il existait déjà,
mais `_load_values` le levait sans condition : rouvrir le panneau ou changer de
profil rendait éditables, en silence, les champs décrivant un pair déjà déclaré
côté serveur. L'état est désormais enregistré et suit la configuration chargée.

Le diagnostic gagne une étape « Serveur DNS du réseau distant », qui couvre un
angle mort : le tunnel monte, le trafic passe, la résolution est au vert, et
pas un nom interne ne se résout. Deux causes, l'une et l'autre muettes — le
serveur DNS distant n'est couvert par aucune IP autorisée, ou il l'est mais son
adresse tombe aussi dans une plage que porte une carte du poste, auquel cas la
route locale l'emporte. Le second cas ne se déduit pas de la configuration :
l'étape interroge la route réellement retenue par le noyau, sans émettre de
paquet, et propose la route d'hôte qui reprend la main.

Le sous-onglet « Fuite DNS » devient « Résolution des noms » : il en faisait
trois choses sous un titre qui n'en annonçait qu'une. Son verdict « Statut
inconnu » recouvrait trois causes appelant trois gestes différents ; chacune
est maintenant nommée, avec l'action qui suit.

Enfin, la fenêtre. Sa hauteur augmente de 15 %, et son plancher passe de 520 à
703 : la page de test réclame 569 px et se chevauchait en dessous. Le rapport
de diagnostic passe en tableau à deux colonnes — il occupait 224 px quelle que
soit la largeur de la fenêtre, soit jusqu'à 79 % de blanc à droite, et tient
maintenant sur 38 % de hauteur en moins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:18:08 +02:00
16 changed files with 1988 additions and 513 deletions
+83
View File
@@ -6,6 +6,89 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
---
## [0.13.0] — 2026-09-08
### Ajouté
- **Un thème clair, au choix dans Paramètres → Apparence.** Le thème sombre reste celui par défaut : rien ne change sur les postes déjà installés tant que le réglage n'est pas touché. Le choix est lu au lancement — les feuilles de style sont posées dans les constructeurs des fenêtres, et les repeindre à chaud supposerait de reconstruire celle qui porte l'état du tunnel, ses fils d'exécution et ses minuteries. Le message de confirmation l'annonce plutôt que de laisser croire à un réglage sans effet.
- Le module de thème publie désormais deux palettes derrière les mêmes noms. Les cent soixante-dix appels répartis dans l'interface lisent `theme.BG` sans rien savoir du thème actif : ajouter une palette n'a demandé de toucher aucun widget.
### Modifié
- **Les couleurs qui vivaient encore hors du module de thème l'ont rejoint.** Le graphe de bande passante peignait son fond en dur — un panneau sombre au milieu d'une page claire — et sept de ses teintes sont maintenant des jetons. Quatre couleurs de survol de la fenêtre principale, recopiées à la main, désignent les jetons de même valeur.
- **Le texte posé sur un aplat saturé ne suit plus la couleur de texte ordinaire.** Bouton plein, onglet actif, bandeau, ligne sélectionnée : ils portaient `TEXT`, blanc en thème sombre et presque noir en thème clair, où il aurait disparu sur le fond d'accent. Un jeton distinct le dit, blanc dans les deux thèmes.
- Trois variantes d'état des champs et des boutons — lecture seule, désactivé, flèches de compteur — étaient écrites en dur dans la feuille de style commune sous forme de voiles blancs translucides, c'est-à-dire invisibles sur un fond clair. Elles deviennent des jetons de palette.
### Corrigé
- **Deux endroits figeaient le thème au chargement de leur module** et auraient gardé les teintes sombres quel que soit le réglage : la feuille de style de la fenêtre d'historique, construite au niveau du module, et la table état → couleur du diagnostic, évaluée comme attribut de classe. La première devient une fonction, la seconde ne retient que la puce et demande la couleur au thème à l'affichage.
- **Le bandeau de titre du panneau d'administration s'affichait blanc sur gris pâle en thème clair.** Il prend le fond en creux de la fenêtre, sur lequel le blanc des bandeaux d'onglets — qui sont, eux, des aplats saturés — ne se voyait pas.
---
## [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
### Ajouté
- **Une saisie de code réussie se voit enfin à l'écran.** Le message du serveur partait dans le seul journal, pendant que la fenêtre de saisie se refermait : rien ne distinguait un code accepté d'un code ignoré, et la durée obtenue — précisément ce que l'utilisateur venait de demander — restait invisible. Une confirmation l'annonce désormais avec son échéance et le temps restant.
- **Le client vérifie ensuite que le trafic atteint réellement le réseau distant.** Le serveur peut enregistrer l'autorisation sans que la règle de pare-feu s'applique ; le réseau reste alors muet malgré un code accepté, et l'utilisateur ne pouvait que le découvrir seul, plus tard. La vérification observe ce qui passe, là où le sondage se contente de demander au serveur ce qu'il a enregistré. Son verdict rejoint le journal, succès comme échec, en nommant la cible et en renvoyant à l'administrateur.
### Corrigé
- **Un refus du serveur pouvait faire lever une exception au lieu de s'afficher.** Contre un serveur antérieur à la v0.3.2, qui exige encore un identifiant que ce poste ne transmet plus, `POST /api/session` répond 422 — et le `detail` d'une erreur de validation FastAPI est une *liste*, non une phrase. Elle atterrissait telle quelle dans le champ `message` de la réponse, que l'interface passe ensuite à Qt : le refus n'était pas montré, il interrompait le traitement de la réponse. Seule une chaîne est désormais retenue comme message, et ce cas précis explique ce qu'il faut faire — mettre le serveur à jour. Un corps JSON qui n'est pas un objet, qui provoquait la même interruption un cran plus tôt, est écarté de même.
---
## [0.11.0] — 2026-09-08
### Ajouté
- **Mise à jour de l'application, servie par le serveur depuis l'intérieur du tunnel.** Les binaires ne peuvent pas venir du dépôt Gitea, qui est privé : 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 y est déclarée. Aucun secret n'est embarqué dans le client.
- **Un bandeau propose la version disponible ; rien ne s'installe sans un clic.** La vérification est automatique — au montage du tunnel, puis toutes les demi-heures —, le téléchargement attend le bouton. Cette application monte un VPN : une version défaillante qui se propagerait seule couperait l'accès d'un parc entier sans que personne ne l'ait demandé. La croix rouge reporte la proposition pour la session ; elle revient au démarrage suivant.
- **Barre de progression** pendant le téléchargement, alimentée depuis le thread qui le mène. Sur une centaine de mégaoctets, l'application paraissait figée et rien ne distinguait une attente d'un échec.
- **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 que le chiffrement ne couvre pas — un téléchargement tronqué, un disque plein, un artefact mal publié.
- **Sous Windows, l'installation passe par le service**, qui tourne en LocalSystem et écrit dans Program Files sans invite d'élévation. La commande 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 canal de communication est ouvert aux utilisateurs interactifs — le moyen de faire exécuter ce qu'il veut avec les privilèges du système.
- Nouvelle cible `make publish-updates`, qui dépose artefacts et manifeste sur le serveur. `ANNOUNCE=` annonce les binaires sous un autre numéro, pour exercer la chaîne sans compiler une seconde version.
### Sécurité
- **Le service n'obéit plus qu'à un seul compte.** Le premier appelant devient propriétaire et son identifiant est retenu ; ensuite, seuls ce compte et les administrateurs sont servis. Le canal é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 de résolution DNS valable pour la machine entière, sans être administrateur.
- **Le contenu d'un fichier `.conf` installé par le service est vérifié**, et non plus son seul chemin : les directives exécutables y sont refusées, la taille est bornée, la structure contrôlée. Elles auraient fait exécuter des commandes avec les privilèges du système.
### Corrigé
- **Le remplacement du binaire échouait sous Linux** (« Fichier texte occupé »). Le téléchargement atterrissait dans `/tmp`, souvent un autre système de fichiers que la destination : la mise en place se faisait alors par copie, donc par écriture dans l'exécutable en cours, ce que le noyau refuse. Le fichier est désormais téléchargé à côté de sa destination et mis en place par renommage, qui ne touche qu'une entrée de répertoire et laisse le programme tourner jusqu'à sa fermeture. L'application propose ensuite de redémarrer, plutôt que de laisser deviner.
- **La mise à jour ne s'installait pas sous Windows.** Les deux exécutables à remplacer tournent au moment de la mise à jour, et Windows verrouille l'image d'un processus vivant : l'installeur, lancé de surcroît par le service dont il devait remplacer le binaire, abandonnait en silence. Il ferme maintenant 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, l'ordre d'arrêt rendant la main avant la fin de celui-ci — et garde en dernier recours le remplacement au redémarrage. Le service, lui, lance l'installeur détaché sans l'attendre.
- **La proposition de mise à jour ne revenait jamais** si le serveur n'était pas joignable à l'instant précis où le tunnel montait — routes non encore posées, quarantaine en cours. La question se repose désormais toutes les demi-heures.
- **Le texte du bandeau était illisible** et deux boutons se disputaient l'attention. Un seul bouton vert, et une croix rouge pour reporter.
- **Compiler Linux puis Windows produisait des binaires corrompus.** Les deux cibles partageaient le répertoire de travail de PyInstaller, que l'option `--clean` vide au démarrage : lancées à la suite, chacune effaçait les fichiers intermédiaires de l'autre. Un installeur Windows gonflé de 44 Mo et un binaire Linux tronqué, dont l'archive ne se décompressait plus, ont été produits ainsi. Chaque cible a maintenant son propre répertoire.
---
## [0.10.0] — 2026-09-08
### Supprimé
- **L'assistant « configuration serveur + client ».** Il datait d'avant la v0.8.0 et produisait un serveur WireGuard nu — sans API d'authentification, sans quarantaine, sans code à 6 chiffres — c'est-à-dire un modèle incompatible avec le fonctionnement actuel, où le serveur enrôle le compte et fournit le fichier `.conf`. Son bouton « Appliquer le volet client » écrasait de surcroît les clés du client et la clé publique du serveur : sur un poste déjà enrôlé, il rompait le lien avec le serveur, qui déclare l'ancienne clé publique, sans que rien ne l'annonce.
- **Le champ Identifiant VPN**, sans emploi depuis que le serveur reconnaît le poste à l'adresse de son tunnel. Un champ qui ne fait rien est pire qu'un champ absent : il laisse croire qu'il compte. Les configurations existantes en sont purgées au chargement, profils compris.
### Modifié
- **Le sous-onglet Tunnel passe d'une liste de quinze champs à trois groupes** : l'essentiel — adresse du client, IPs autorisées, DNS —, le split-DNS enfin nommé comme tel, et un repli « Avancé ». Trois champs portaient « DNS » dans la même liste sans que rien ne dise lesquels allaient ensemble.
- **Ce qui vient du serveur est replié et verrouillé** : adresse, port et clé publique du serveur rejoignent « Avancé », aux côtés du nom d'interface, de l'URL de l'API, du keepalive et du MTU. Ils décrivent un pair déjà déclaré côté serveur ; les modifier à la main ne produit aucune erreur visible, seulement un handshake qui n'aboutit jamais.
- **Le sous-onglet « Fuite DNS » devient « Résolution des noms »** : il faisait trois choses sous un titre qui n'en annonçait qu'une. Son verdict « Statut inconnu » recouvrait trois causes appelant trois gestes différents — tunnel arrêté, aucun DNS déclaré, aucun résolveur système — chacune est désormais nommée avec l'action qui suit.
- La pastille de la page « À propos » n'affirme plus rien qu'elle ne sache : elle déduisait « Code d'accès serveur » ou « Aucun code d'accès » de la seule configuration locale, alors qu'un poste sans identifiant pouvait parfaitement être tenu en quarantaine.
### Ajouté
- **Nouvelle étape de diagnostic « Serveur DNS du réseau distant »**, qui comble un angle mort : le tunnel monte, le trafic passe, la résolution est au vert, et pas un nom interne ne se résout. Deux causes, l'une et l'autre muettes jusqu'ici. Ou bien le serveur DNS distant n'est couvert par aucune « IP autorisée » et rien ne le route dans le tunnel ; ou bien il l'est, mais son adresse tombe aussi dans une plage que porte une carte du poste — cas d'un réseau distant en 192.168.1.0/24 vu depuis un poste chez lui — et la route locale l'emporte. Ce second cas ne se déduit pas de la configuration : l'étape interroge la route réellement retenue par le noyau, sans émettre le moindre paquet, et propose la route d'hôte qui reprend la main.
### Corrigé
- **Le verrouillage des champs issus d'un `.conf` importé ne survivait pas à la fermeture du panneau.** Il existait depuis la v0.8.3, mais le chargement des valeurs le levait sans condition : rouvrir le panneau Administrateur, ou simplement changer de profil, rendait de nouveau éditables — en silence — l'adresse, le port et la clé publique du serveur. L'état est désormais enregistré et suit la configuration chargée, profils compris.
- **Le rapport de diagnostic n'occupait qu'une fraction de la largeur disponible.** Chaque étape s'écrivait sur deux lignes, libellé puis détail indenté : le texte tenait sur 224 px quelle que soit la taille de la fenêtre, laissant jusqu'à 79 % de blanc à droite. Il est passé en tableau à deux colonnes, occupe toute la largeur et tient sur 38 % de hauteur en moins.
- **La fenêtre du panneau Administrateur était trop courte et les libellés s'y chevauchaient.** Sa hauteur augmente de 15 %, et surtout son plancher passe de 520 à 703 px : la page de test réclame 569 px de contenu incompressible, et toute réduction en deçà écrasait les champs les uns sur les autres.
- Deux encarts explicatifs portaient des retours à la ligne écrits à la main, qui figeaient leur largeur : la fenêtre s'élargissait, le texte non.
---
## [0.9.1] — 2026-09-08
### Modifié
+56 -2
View File
@@ -4,7 +4,7 @@
## ──────────────────────────────────────────────
APP := wgsecure
VERSION := 0.9.1
VERSION := 0.13.0
VENV := .venv
PYTHON := $(VENV)/bin/python3
PIP := $(VENV)/bin/pip
@@ -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)"
+2 -2
View File
@@ -1,6 +1,6 @@
# 🛡️ WGSecure (WGS)
🚀 v0.9.1 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
🚀 v0.13.0 · 🐍 · 🪟🐧 · 🔐 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**.
@@ -210,4 +210,4 @@ WGSecure/
## 👤 Auteur
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
Version : ![v0.9.1](https://img.shields.io/badge/v0.9.1-septembre%202026-2980b9?style=flat-square) — Septembre 2026
Version : ![v0.13.0](https://img.shields.io/badge/v0.13.0-septembre%202026-2980b9?style=flat-square) — Septembre 2026
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.9.1"
__version__ = "0.13.0"
APP_NAME = "WGSecure"
APP_SHORT = "WGS"
+31 -7
View File
@@ -36,11 +36,17 @@ _WG_DEFAULT: dict[str, Any] = {
# Split-DNS : serveur DNS dédié pour les noms du réseau distant,
# avec liste de domaines à résoudre via ce serveur (ex: ["local", "internal"]).
# Laisser vide pour utiliser le DNS par défaut pour tout le trafic.
# Authentification auprès du serveur après montage du tunnel : identifiant
# du compte VPN tel que connu du serveur, et URL de son API. Laissée vide,
# celle-ci est déduite de l'adresse du client (cf. vpn_session.py).
"vpn_username": "",
# Authentification auprès du serveur après montage du tunnel : URL de son
# API. Laissée vide, elle est déduite de l'adresse du client et des IPs
# autorisées (cf. vpn_session.py). Aucun identifiant n'est stocké ni
# transmis : le serveur reconnaît le poste à l'IP source de son tunnel.
"api_url": "",
# Champs décrivant le pair tel que le serveur le connaît (adresse, port,
# clé publique, IPs autorisées…) verrouillés parce qu'ils viennent d'un
# `.conf` importé. Persisté : le verrou ne tenait que le temps d'une
# session du panneau, et rouvrir celui-ci rendait ces champs éditables
# sans que rien ne le signale.
"config_locked": False,
"split_dns_server": "",
"split_dns_domains": [],
}
@@ -58,6 +64,10 @@ _DEFAULT: dict[str, Any] = {
"auto_connect_on_startup": False,
"auto_reconnect": True,
"reconnect_interval": 15,
# « dark » ou « light ». Lu une seule fois, au lancement : les feuilles
# de style sont posées dans les constructeurs des fenêtres (cf.
# app/ui/theme.py). Une valeur inconnue retombe sur « dark ».
"theme": "dark",
},
}
@@ -85,7 +95,9 @@ class Config:
self._apply_autostart(self._data["ui"]["autostart"])
def _drop_local_mfa(self) -> None:
"""Efface le secret TOTP local hérité des versions ≤ 0.8.0.
"""Efface les réglages d'authentification devenus caducs.
Le secret TOTP local hérité des versions 0.8.0.
Ce secret ne sert plus : le code à 6 chiffres est désormais validé
par le serveur, contre un secret que le poste client ne détient pas.
@@ -94,10 +106,22 @@ class Config:
au chargement plutôt qu'un simple abandon des clés.
"""
stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data]
if not stale:
return
for key in stale:
del self._data[key]
# `vpn_username` a disparu en v0.9.2 : le serveur reconnaît le poste à
# l'IP source de son tunnel, l'identifiant ne lui est plus transmis.
# Le retirer plutôt que l'ignorer, sinon `config.json` conserverait
# indéfiniment un réglage que plus rien ne lit et que l'utilisateur
# croirait encore actif.
if "vpn_username" in self._data.get("wg", {}):
del self._data["wg"]["vpn_username"]
stale.append("wg.vpn_username")
for profile in self._data.get("profiles", {}).values():
if isinstance(profile, dict) and "vpn_username" in profile:
del profile["vpn_username"]
stale.append("profil")
if not stale:
return
self.save()
def save(self):
+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, 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 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 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.")
+30 -10
View File
@@ -101,6 +101,23 @@ def _parse_until(raw: object) -> datetime | None:
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def _error_detail(exc: urllib.error.HTTPError) -> str:
"""Le motif du refus tel que le serveur l'énonce, ou "" s'il n'en dit rien.
FastAPI répond aux erreurs de validation (422) par un `detail` qui est une
*liste* d'erreurs, pas une phrase. Relayée telle quelle, elle atterrissait
dans un champ `message` que l'interface passe ensuite à Qt : le refus
n'était alors pas affiché, il levait une exception au moment de le lire.
Seule une chaîne est un message ; tout autre corps ne dit rien
d'affichable, et le message générique de l'appelant vaut mieux.
"""
try:
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
except (ValueError, OSError, AttributeError):
return "" # corps illisible, ou JSON sans objet
return detail if isinstance(detail, str) else ""
def api_base_url(cfg: Config) -> str:
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
@@ -179,7 +196,7 @@ def _routed_through_tunnel(cfg: Config, target: str) -> bool:
return False
def _remote_network_reachable(cfg: Config) -> bool:
def remote_network_reachable(cfg: Config) -> bool:
"""Le trafic passe-t-il réellement vers le réseau distant ?
Repli pour les serveurs antérieurs à la v0.3.0, qui n'exposent pas
@@ -243,11 +260,8 @@ def session_status(cfg: Config) -> AuthResult:
return AuthResult(True, "Accès réseau déjà ouvert jusqu'à "
f"{until.astimezone():%d/%m/%Y %H:%M}", until)
except urllib.error.HTTPError as exc:
try:
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
except (ValueError, OSError):
detail = ""
if exc.code == 404 and _remote_network_reachable(cfg):
detail = _error_detail(exc)
if exc.code == 404 and remote_network_reachable(cfg):
# Serveur antérieur à la v0.3.0 : la route n'existe pas, mais le
# trafic atteint le réseau distant — l'accès est donc bien ouvert.
# Aucune échéance en revanche : seul le serveur la connaît, et il
@@ -324,10 +338,16 @@ def authenticate(cfg: Config, code: str) -> AuthResult:
# Le serveur explique lui-même le refus (code invalide, compte
# verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un
# message générique qui obligerait à consulter les journaux.
try:
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
except (ValueError, OSError):
detail = ""
detail = _error_detail(exc)
if exc.code == 422:
# Serveur antérieur à la v0.3.2 : il exige encore un identifiant
# dans le corps, que ce poste ne transmet plus. Son `detail` est
# la liste d'erreurs de validation de FastAPI — `_error_detail`
# l'écarte, et il n'en resterait qu'un « code 422 » énigmatique.
return AuthResult(False, "Ce serveur attend un identifiant VPN "
"que ce poste ne transmet plus. Mettez "
"le serveur à jour (v0.3.2 ou "
"supérieur).")
return AuthResult(False, detail
or f"Authentification refusée (code {exc.code})")
except urllib.error.URLError as exc:
+88 -65
View File
@@ -1044,6 +1044,8 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
steps.append(_step(_OK, "Pair joignable dans le tunnel",
f"{gateway} répond en {ms} ms"))
steps += _diag_split_dns(cfg)
if not dns_util.resolution_works():
steps.append(_step(_FAIL, "Résolution DNS dans le tunnel",
"Plus aucun nom ne se résout — utilisez "
@@ -1071,6 +1073,72 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
return steps
def _diag_split_dns(cfg: Config) -> list[dict]:
"""Le serveur DNS du réseau distant est-il seulement joignable ?
Point aveugle du diagnostic jusqu'ici : le tunnel monte, le trafic passe,
« Résolution DNS » est au vert et pas un seul nom interne ne se résout.
L'échec ne se manifestait que par un « All attempts to contact name
servers or networks failed » de resolvectl, qui ne désigne rien.
Deux causes, toutes deux invisibles :
- Le serveur DNS distant n'est couvert par aucune « IP autorisée ». Rien
ne le route dans le tunnel, il est donc cherché sur le réseau local.
- Il est couvert, mais son adresse tombe aussi dans une plage que porte
une carte du poste. C'est le cas d'un réseau distant en 192.168.1.0/24
vu depuis un poste chez lui, la même plage est la plus répandue de
toutes. La route locale l'emporte, et le split-DNS ne part jamais.
La vérification se fait sur la route réellement retenue par le noyau,
tunnel monté, plutôt que sur la seule configuration : c'est la seule
façon de trancher le second cas.
"""
wg = cfg.wg
server = (wg.get("split_dns_server") or "").strip()
domains = wg.get("split_dns_domains") or []
if not server or not domains:
return [] # split-DNS non configuré : rien à dire
label = "Serveur DNS du réseau distant"
try:
target = ipaddress.ip_address(server)
except ValueError:
return [_step(_FAIL, label, f"« {server} » n'est pas une adresse IP")]
allowed = [n for n in _networks(wg.get("allowed_ips", ""))
if n.version == target.version]
couvert = [str(n) for n in allowed if target in n]
client_ip = (wg.get("client_address") or "").split(",")[0].split("/")[0].strip()
source = _local_route_source(server, 53)
if not couvert:
return [_step(
_FAIL, label,
f"{server} n'est couvert par aucune « IP autorisée » : rien ne le "
f"route dans le tunnel, il est donc cherché sur le réseau local. "
f"Ajoutez {server}/32 aux IPs autorisées.")]
if source and client_ip and source != client_ip:
return [_step(
_FAIL, label,
f"{server} est bien couvert par {', '.join(couvert)}, mais les "
f"paquets partent de {source} — votre réseau local — et non de "
f"{client_ip}, l'adresse du tunnel. Cette adresse appartient aux "
f"deux réseaux à la fois, et la route locale l'emporte. "
f"Remplacez {', '.join(couvert)} par {server}/32 : une route "
f"d'hôte est plus spécifique et reprend la main, sans router tout "
f"le voisinage dans le tunnel.")]
if not source:
return [_step(_WARN, label,
f"Route vers {server} indéterminable depuis ce poste")]
return [_step(_OK, label,
f"{server} joint par le tunnel (couvert par "
f"{', '.join(couvert)}) pour {', '.join(domains)}")]
def diagnose_connection(cfg: Config, timeout: int = 5) -> list[dict]:
"""Vérifie toute la chaîne de connexion et désigne le point de rupture.
@@ -1286,9 +1354,18 @@ def ping_or_handshake(cfg: Config, timeout: int = 2) -> tuple[int | None, str]:
# ── Test DNS leak ─────────────────────────────────────────────────────────
def dns_leak_test(cfg: Config) -> dict:
"""
Teste si le DNS fuit en dehors du tunnel.
Retourne {"status": ok|leak|unknown|down, "resolvers": [...], "expected": str}
"""Les requêtes de noms empruntent-elles le tunnel ?
Compare le résolveur réellement actif sur le système au DNS déclaré pour
le tunnel. Une divergence signifie que les noms demandés partent au
résolveur du réseau local qui apprend donc ce que ce poste consulte,
et peut répondre autre chose que le réseau distant.
`status` vaut `ok`, `leak`, `down` (plus aucune résolution) ou `unknown`.
`reason` précise ce dernier cas, qui en recouvre trois : sans elle,
l'écran affichait « Statut inconnu » sans dire quoi faire, alors que les
trois causes appellent des gestes différents monter le tunnel,
renseigner un DNS, ou constater que le système n'en déclare aucun.
"""
expected_dns = (cfg.wg.get("dns", "") or "").split(",")[0].strip()
@@ -1308,15 +1385,17 @@ def dns_leak_test(cfg: Config) -> dict:
if lines:
probe_ip = lines[-1]
reason = ""
if not dns_util.resolution_works():
status = "down"
elif not resolvers:
status = "unknown"
elif not tunnel_up:
# Tunnel inactif : parler de fuite n'a pas de sens.
status = "unknown"
# Tunnel inactif : parler de fuite n'a pas de sens, tout le trafic
# passe légitimement par le résolveur local.
status, reason = "unknown", "tunnel_down"
elif not resolvers:
status, reason = "unknown", "no_resolver"
elif not expected_dns:
status = "unknown"
status, reason = "unknown", "no_expected"
elif any(expected_dns == r or expected_dns in r for r in resolvers):
status = "ok"
else:
@@ -1324,6 +1403,7 @@ def dns_leak_test(cfg: Config) -> dict:
return {
"status": status,
"reason": reason,
"resolvers": resolvers,
"expected": expected_dns,
"probe_ip": probe_ip,
@@ -1333,63 +1413,6 @@ def dns_leak_test(cfg: Config) -> dict:
# ── Génération config serveur ─────────────────────────────────────────────
def generate_server_config(
server_port: int = 51820,
server_address: str = "10.8.0.1/24",
client_address: str = "10.8.0.2/24",
client_allowed_ips: str = "10.8.0.2/32",
use_psk: bool = True,
) -> dict:
"""
Génère une paire complète (server_conf, client_conf) avec de nouvelles clés.
Retourne un dict avec server_priv/pub, client_priv/pub, server_conf, client_conf.
`use_psk=False` produit les deux configurations sans clé pré-partagée :
l'assistant en imposait une des deux côtés, sans moyen de s'en passer.
"""
srv_priv, srv_pub = generate_keypair()
cli_priv, cli_pub = generate_keypair()
psk = generate_preshared_key() if use_psk else ""
psk_line = [f"PresharedKey = {psk}"] if use_psk else []
server_conf = "\n".join([
"[Interface]",
f"PrivateKey = {srv_priv}",
f"Address = {server_address}",
f"ListenPort = {server_port}",
"PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE",
"PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE",
"",
"# === Peer client ===",
"[Peer]",
f"PublicKey = {cli_pub}",
*psk_line,
f"AllowedIPs = {client_allowed_ips}",
])
client_conf = "\n".join([
"[Interface]",
f"PrivateKey = {cli_priv}",
f"Address = {client_address}",
"DNS = 1.1.1.1",
"",
"[Peer]",
f"PublicKey = {srv_pub}",
*psk_line,
"AllowedIPs = 10.8.0.0/24",
"Endpoint = <SERVER_IP>:" + str(server_port),
"PersistentKeepalive = 25",
])
return {
"server_priv": srv_priv, "server_pub": srv_pub,
"client_priv": cli_priv, "client_pub": cli_pub,
"psk": psk,
"server_conf": server_conf,
"client_conf": client_conf,
}
def _fmt_bytes(n: int) -> str:
for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
if n >= div:
+349 -8
View File
@@ -18,20 +18,30 @@ 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 time
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
import win32security
import win32process
import win32profile
import win32ts
import win32service
import win32serviceutil
@@ -54,6 +64,35 @@ _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")
# 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
# 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 +113,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 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 +319,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 +343,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 +366,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 +389,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 +494,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 +532,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"))
@@ -291,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"
@@ -340,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:
+293 -237
View File
@@ -39,8 +39,20 @@ class AdminWindow(QDialog):
)
# Le découpage en sous-onglets a supprimé l'empilement qui imposait une
# grande fenêtre : chaque page n'affiche plus qu'une section à la fois.
self.setMinimumSize(640, 520)
self.resize(720, 640)
#
# Hauteur relevée de 15 % depuis : le sous-onglet Tunnel compte
# désormais trois groupes titrés au lieu d'un, et les encarts
# explicatifs des pages de test tiennent sur plusieurs lignes. À
# l'ancienne hauteur, les libellés se chevauchaient faute de place.
#
# Le plancher suit, et ce n'est pas cosmétique : il valait 520, alors
# que la page la plus exigeante — « Test connexion », dont le rapport
# réclame 200 px incompressibles — en demande 569 une fois retirés les
# 134 px de bandeau, marges et barre d'onglets. Réduire la fenêtre y
# écrasait les libellés les uns sur les autres. 703 est la hauteur
# mesurée à laquelle plus aucune page non défilable ne déborde.
self.setMinimumSize(640, 703)
self.resize(720, 736)
self._build_ui()
self._load_values()
@@ -90,7 +102,7 @@ class AdminWindow(QDialog):
banner = QLabel(" WGSecure — Configuration Administrateur")
banner.setFixedHeight(40)
banner.setStyleSheet(theme.banner_style(theme.BG_SUNKEN))
banner.setStyleSheet(theme.banner_style(theme.BG_SUNKEN, theme.TEXT))
layout.addWidget(banner)
# Onglets de premier niveau : config qui conditionne la connexion
@@ -142,6 +154,27 @@ class AdminWindow(QDialog):
widget.setFixedHeight(field_h)
form.addRow(lbl, widget)
def _collapsible_group(self, title: str) -> tuple[QGroupBox, QFormLayout]:
"""Groupe replié par défaut, dépliable d'un clic sur son titre.
`QGroupBox.setCheckable` seul ne replie rien : il grise le contenu en
le laissant occuper la place, ce qui allonge la page sans rien gagner.
D'où le conteneur intermédiaire, masqué et rendu visible à la volée —
la fenêtre se remesure alors sur le contenu réellement affiché.
"""
box = QGroupBox(title)
box.setCheckable(True)
box.setChecked(False)
outer = QVBoxLayout(box)
outer.setContentsMargins(0, 0, 0, 0)
body = QWidget()
body.setVisible(False)
form = QFormLayout(body)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
outer.addWidget(body)
box.toggled.connect(body.setVisible)
return box, form
@staticmethod
def _hint(text: str) -> QLabel:
"""Encart expliquant à quoi sert l'onglet où il figure.
@@ -284,38 +317,14 @@ class AdminWindow(QDialog):
sep.setStyleSheet(f"background: {theme.BORDER_SOFT}; border: none; max-height: 1px;")
lay.addWidget(sep)
# ── Serveur ──────────────────────────────────────────────────────
grp = QGroupBox("Serveur WireGuard")
# ── Clé pré-partagée ─────────────────────────────────────────────
# Adresse, port et clé publique du serveur ont rejoint « Avancé » :
# ils décrivent le pair tel que le serveur le déclare, viennent du
# `.conf` importé et ne se modifient pas à la main.
grp = QGroupBox("Clé pré-partagée (optionnelle)")
form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._srv_endpoint = QLineEdit()
self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4")
self._srv_endpoint.setToolTip(
"Adresse IP ou nom d'hôte du serveur WireGuard. "
"Si vous utilisez un nom, assurez-vous qu'il est résolvable."
)
self._row(form, "Adresse serveur :", self._srv_endpoint)
self._srv_port = QSpinBox()
self._srv_port.setRange(1, 65535)
self._srv_port.setValue(51820)
self._srv_port.setToolTip(
"Port UDP sur lequel le serveur écoute. Un port erroné ne produit\n"
"aucune erreur visible : WireGuard ignore silencieusement les\n"
"paquets, le tunnel monte et le handshake n'arrive jamais."
)
self._srv_port.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form, "Port UDP :", self._srv_port)
self._srv_pubkey = QLineEdit()
self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)")
self._srv_pubkey.setToolTip(
"Clé publique du serveur (format base64). "
"Doit correspondre exactement à la clé publique du serveur."
)
self._row(form, "Clé publique serveur :", self._srv_pubkey)
# Un champ vide ne disait pas si la PSK était oubliée ou volontairement
# absente : le diagnostic avertissait donc sur tout serveur qui n'en
# utilise pas, c'est-à-dire la configuration WireGuard par défaut.
@@ -362,71 +371,23 @@ class AdminWindow(QDialog):
lay.addWidget(grp)
# ── Interface client ─────────────────────────────────────────────
# Trois groupes, du plus courant au plus rare : ce qu'un .conf importé
# renseigne, le split-DNS, puis le reste — replié. La page alignait
# quinze champs d'un bloc, où l'adresse du client et le MTU avaient le
# même poids visuel alors que l'un se règle toujours et l'autre jamais.
grp2 = QGroupBox("Interface client")
form2 = QFormLayout(grp2)
form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._iface_name = QLineEdit()
self._iface_name.setPlaceholderText("wgs0")
self._iface_name.setToolTip(
"Nom de l'interface réseau WireGuard. "
"Doit être unique et correspondre au nom du service sous Windows."
)
self._row(form2, "Nom interface :", self._iface_name)
self._client_addr = QLineEdit()
self._client_addr.setPlaceholderText("10.8.0.2/24")
self._client_addr.setToolTip(
"Adresse IP du client dans le réseau VPN. "
"Format: IP/Masque (ex: 10.8.0.2/24)."
"Adresse IP de ce poste dans le réseau du tunnel, telle que le\n"
"serveur l'a attribuée. Format : IP/Masque (ex. 10.6.0.6/32).\n"
"C'est à cette adresse que le serveur reconnaît ce poste."
)
self._row(form2, "Adresse IP client :", self._client_addr)
self._dns = QLineEdit()
self._dns.setPlaceholderText("1.1.1.1")
self._dns.setToolTip(
"Serveur DNS principal utilisé par le tunnel. "
"Laisser vide si vous utilisez le split-DNS ou le DNS système."
)
self._row(form2, "DNS :", self._dns)
# Split-DNS : serveur dédié pour les noms du réseau distant
# Authentification auprès du serveur, après montage du tunnel.
self._vpn_username = QLineEdit()
self._vpn_username.setPlaceholderText("TestB")
self._vpn_username.setToolTip(
"Nom du compte VPN tel qu'il figure sur le serveur.\n"
"Purement indicatif : le serveur reconnaît ce poste à l'adresse\n"
"de son tunnel, et l'identifiant ne lui est plus transmis.\n"
"Le laisser vide ne change rien au fonctionnement."
)
self._row(form2, "Identifiant VPN :", self._vpn_username)
self._api_url = QLineEdit()
self._api_url.setPlaceholderText("déduit automatiquement")
self._api_url.setToolTip(
"URL du serveur WGSecure, joignable depuis le tunnel.\n"
"Laissée vide, elle est déduite de l'adresse du client\n"
"(10.6.0.6 → http://10.6.0.1:8000)."
)
self._row(form2, "Serveur d'authentification :", self._api_url)
self._split_dns_server = QLineEdit()
self._split_dns_server.setPlaceholderText("192.168.1.210")
self._split_dns_server.setToolTip(
"Serveur DNS dédié pour résoudre les noms du réseau distant. "
"Laisser vide pour utiliser le DNS par défaut."
)
self._row(form2, "DNS pour réseau distant :", self._split_dns_server)
self._split_dns_domains = QLineEdit()
self._split_dns_domains.setPlaceholderText("local, internal")
self._split_dns_domains.setToolTip(
"Domaines à résoudre via le DNS distant (séparés par des virgules). "
"Ex: local, internal. Laisser vide pour désactiver le split-DNS."
)
self._row(form2, "Domaines distants :", self._split_dns_domains)
self._allowed_ips = QLineEdit()
self._allowed_ips.setPlaceholderText("10.8.0.0/24")
self._allowed_ips.setToolTip(
@@ -436,15 +397,110 @@ class AdminWindow(QDialog):
)
self._row(form2, "IPs autorisées :", self._allowed_ips)
self._dns = QLineEdit()
self._dns.setPlaceholderText("1.1.1.1")
self._dns.setToolTip(
"Résolveur employé par le tunnel pour tous les noms.\n"
"Laisser vide pour conserver le DNS du système."
)
self._row(form2, "DNS :", self._dns)
lay.addWidget(grp2)
# ── Split-DNS ────────────────────────────────────────────────────
# Regroupés et nommés : trois champs portaient « DNS » dans une seule
# liste, sans que rien ne dise lesquels allaient ensemble.
grp3 = QGroupBox("Split-DNS — noms du réseau distant")
form3 = QFormLayout(grp3)
form3.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
note3 = QLabel(
"Résout les noms internes par le serveur DNS du réseau distant, "
"et tout le reste par le DNS ci-dessus. Les deux champs vont "
"ensemble : l'un sans l'autre ne fait rien."
)
note3.setWordWrap(True)
note3.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;")
form3.addRow(note3)
self._split_dns_server = QLineEdit()
self._split_dns_server.setPlaceholderText("192.168.1.210")
self._split_dns_server.setToolTip(
"Serveur DNS du réseau distant, joint par le tunnel.\n"
"Il doit être couvert par les IPs autorisées."
)
self._row(form3, "Serveur DNS distant :", self._split_dns_server)
self._split_dns_domains = QLineEdit()
self._split_dns_domains.setPlaceholderText("local, internal")
self._split_dns_domains.setToolTip(
"Domaines confiés au serveur ci-dessus, séparés par des virgules.\n"
"Vide = split-DNS inactif, tout passe par le DNS général."
)
self._row(form3, "Domaines concernés :", self._split_dns_domains)
lay.addWidget(grp3)
# ── Avancé ───────────────────────────────────────────────────────
grp4, form4 = self._collapsible_group(
"Avancé — issus du serveur ou corrects par défaut ; à ne pas modifier")
# Adresse, port et clé publique du serveur : ils viennent du `.conf`
# fourni par l'administrateur et décrivent un pair déjà déclaré côté
# serveur. Les modifier à la main ne produit aucune erreur visible —
# juste un handshake qui n'aboutit jamais — d'où leur place ici,
# repliée, et leur verrouillage après import.
self._srv_endpoint = QLineEdit()
self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4")
self._srv_endpoint.setToolTip(
"Adresse IP ou nom d'hôte du serveur WireGuard.\n"
"Si vous utilisez un nom, assurez-vous qu'il est résolvable."
)
self._row(form4, "Adresse serveur :", self._srv_endpoint)
self._srv_port = QSpinBox()
self._srv_port.setRange(1, 65535)
self._srv_port.setValue(51820)
self._srv_port.setToolTip(
"Port UDP sur lequel le serveur écoute. Un port erroné ne produit\n"
"aucune erreur visible : WireGuard ignore silencieusement les\n"
"paquets, le tunnel monte et le handshake n'arrive jamais."
)
self._srv_port.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form4, "Port UDP :", self._srv_port)
self._srv_pubkey = QLineEdit()
self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)")
self._srv_pubkey.setToolTip(
"Clé publique du serveur, en base64. Doit correspondre exactement\n"
"à celle que le serveur présente, sans quoi rien ne négocie."
)
self._row(form4, "Clé publique serveur :", self._srv_pubkey)
self._iface_name = QLineEdit()
self._iface_name.setPlaceholderText("wgs0")
self._iface_name.setToolTip(
"Nom de l'interface réseau WireGuard. Doit être unique et\n"
"correspondre au nom du service sous Windows."
)
self._row(form4, "Nom interface :", self._iface_name)
self._api_url = QLineEdit()
self._api_url.setPlaceholderText("déduit automatiquement")
self._api_url.setToolTip(
"URL du serveur WGSecure, joignable depuis le tunnel.\n"
"Laissée vide, elle est déduite de l'adresse du client et des\n"
"IPs autorisées (10.6.0.6/32 + 10.6.0.0/24 → http://10.6.0.1:8000).\n"
"À ne renseigner que si le serveur écoute ailleurs."
)
self._row(form4, "Serveur d'authentification :", self._api_url)
self._keepalive = QSpinBox()
self._keepalive.setRange(0, 300)
self._keepalive.setValue(25)
self._keepalive.setToolTip(
"Intervalle (en secondes) pour envoyer un paquet keepalive. "
"0 = désactivé. Utile pour maintenir la connexion active à travers un NAT."
"Intervalle en secondes entre deux paquets keepalive. 0 = désactivé.\n"
"Maintient la connexion à travers un NAT."
)
self._keepalive.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form2, "Keepalive (s) :", self._keepalive)
self._row(form4, "Keepalive (s) :", self._keepalive)
self._mtu = QSpinBox()
self._mtu.setRange(0, 9000)
@@ -454,8 +510,8 @@ class AdminWindow(QDialog):
"monte mais que les gros paquets ne passent pas."
)
self._mtu.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form2, "MTU :", self._mtu)
lay.addWidget(grp2)
self._row(form4, "MTU :", self._mtu)
lay.addWidget(grp4)
lay.addStretch()
# Champs verrouillés après import d'un .conf. Le split-DNS et le nom
@@ -482,6 +538,12 @@ class AdminWindow(QDialog):
jamais).
"""
self._fields_locked = locked
# Enregistré, pas seulement appliqué : sans cela le verrou tombait à
# la fermeture du panneau, et les champs décrivant le pair
# redevenaient éditables au rechargement sans que rien ne l'annonce.
if self._cfg.get("wg", "config_locked") != locked:
self._cfg.set("wg", "config_locked", locked)
self._cfg.save()
for wdg in self._locked_fields:
wdg.setEnabled(not locked)
if locked:
@@ -550,20 +612,6 @@ class AdminWindow(QDialog):
self._row(form, "", self._btn_gen_keys)
lay.addWidget(grp)
grp2 = QGroupBox("🖥️ Assistant : configuration serveur + client")
v2 = QVBoxLayout(grp2)
note = QLabel(
"Génère une paire complète prête à déployer : nouvelles clés des "
"deux côtés,\nclé pré-partagée si l'option est cochée dans "
"l'onglet Tunnel."
)
note.setWordWrap(True)
note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;")
v2.addWidget(note)
btn_gen_srv = QPushButton("⚡ Générer config serveur + client")
btn_gen_srv.clicked.connect(self._generate_server_config)
v2.addWidget(btn_gen_srv)
lay.addWidget(grp2)
lay.addStretch()
return page
@@ -588,7 +636,7 @@ class AdminWindow(QDialog):
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion")
sub.addTab(self._subtab_dns(), "🔍 Fuite DNS")
sub.addTab(self._subtab_dns(), "🌐 Résolution des noms")
lay.addWidget(sub)
return w
@@ -613,11 +661,15 @@ class AdminWindow(QDialog):
grp = QGroupBox("Diagnostic de la chaîne de connexion")
g = QVBoxLayout(grp)
g.addWidget(QLabel(
"Vérifie toute la chaîne : configuration, clés, route, port UDP,\n"
"montage du tunnel, handshake, trafic et DNS — et désigne l'étape\n"
lbl = QLabel(
"Vérifie toute la chaîne : configuration, clés, route, port UDP, "
"montage du tunnel, handshake, trafic et DNS — et désigne l'étape "
"qui bloque."
))
)
# Sans repli automatique, les sauts de ligne écrits à la main figeaient
# la largeur du texte : la fenêtre s'élargissait, le libellé non.
lbl.setWordWrap(True)
g.addWidget(lbl)
self._btn_diag_test = QPushButton("🚀 Lancer le test de connexion")
self._btn_diag_test.setStyleSheet(theme.primary_button_style())
self._btn_diag_test.clicked.connect(self._run_test)
@@ -642,21 +694,28 @@ class AdminWindow(QDialog):
# ── Sous-onglet : fuite DNS ──────────────────────────────────────────
def _subtab_dns(self) -> QWidget:
page, v = self._test_page()
# Volontairement court : chaque groupe ci-dessous porte déjà sa propre
# explication, et l'encart les répétait — il occupait 93 px et faisait
# déborder la page, poussant les libellés les uns sur les autres.
v.addWidget(self._hint(
"Vérifie que les requêtes de noms empruntent bien le tunnel plutôt "
"que le résolveur du réseau local. La réparation retire une "
"configuration DNS laissée par un tunnel arrêté brutalement, qui "
"empêche toute résolution."
"Le tunnel peut être monté et le trafic passer pendant qu'aucun "
"nom ne se résout : c'est ici que cela se voit, et se répare."
))
grp = QGroupBox("🔍 Test de fuite DNS")
grp = QGroupBox("🔍 Par où sortent les requêtes de noms ?")
g = QVBoxLayout(grp)
g.addWidget(QLabel("Vérifie que le DNS passe bien par le tunnel VPN."))
self._btn_dns_test = QPushButton("🔍 Analyser le DNS")
lbl = QLabel(
"Compare le résolveur réellement actif au DNS déclaré pour le "
"tunnel. Une divergence expose ce que ce poste consulte au réseau "
"local, et peut lui faire obtenir de mauvaises réponses."
)
lbl.setWordWrap(True)
g.addWidget(lbl)
self._btn_dns_test = QPushButton("🔍 Analyser la résolution des noms")
self._btn_dns_test.setStyleSheet(theme.primary_button_style())
self._btn_dns_test.clicked.connect(self._run_dns_test)
g.addWidget(self._btn_dns_test)
self._btn_dns_fix = QPushButton("🛠️ Réparer le DNS (tunnel arrêté)")
self._btn_dns_fix = QPushButton("🛠️ Réparer — plus aucun nom ne se résout")
self._btn_dns_fix.setToolTip(
"Retire la configuration DNS laissée par un tunnel arrêté "
"brutalement, qui empêche toute résolution de noms."
@@ -683,12 +742,17 @@ class AdminWindow(QDialog):
# (un hôte particulier, une forme courte via le suffixe de recherche…).
domains = self._cfg.wg.get("split_dns_domains") or []
example = f"hôte.{domains[0]}" if domains else "hôte.domaine.exemple"
grp2 = QGroupBox("🌐 Résoudre un nom précis")
grp2 = QGroupBox("🌐 Résoudre un nom précis (vérifie le split-DNS)")
g2 = QVBoxLayout(grp2)
g2.addWidget(QLabel(
f"Ex. : {example}, ou juste le nom court s'il est dans le "
lbl2 = QLabel(
"L'analyse ci-dessus ne dit rien du split-DNS : un nom interne "
"peut recevoir une réponse publique inattendue alors que la "
"résolution « fonctionne ».\n"
f"Ex. : {example}, ou le nom court s'il est couvert par le "
"suffixe de recherche du réseau distant."
))
)
lbl2.setWordWrap(True)
g2.addWidget(lbl2)
resolve_row = QHBoxLayout()
resolve_row.setSpacing(6)
self._resolve_input = QLineEdit()
@@ -877,6 +941,18 @@ class AdminWindow(QDialog):
lay.addWidget(grp2)
grp3 = QGroupBox("Apparence")
form3 = QFormLayout(grp3)
self._cmb_theme = QComboBox()
for cle, libelle in theme.PALETTES.items():
self._cmb_theme.addItem(libelle, cle)
actuel = self._cmb_theme.findData(theme.CURRENT)
self._cmb_theme.setCurrentIndex(actuel if actuel >= 0 else 0)
self._row(form3, "Thème :", self._cmb_theme)
lay.addWidget(grp3)
btn_apply = QPushButton("✅ Appliquer les paramètres")
btn_apply.clicked.connect(self._apply_settings)
lay.addWidget(btn_apply)
@@ -888,12 +964,26 @@ class AdminWindow(QDialog):
self._cfg.set("ui", "auto_reconnect", self._chk_autorecon.isChecked())
self._cfg.set("ui", "reconnect_interval", self._spin_interval.value())
self._cfg.set("ui", "auto_connect_on_startup", self._chk_auto_connect.isChecked())
# Le thème n'est lu qu'au lancement : les feuilles de style sont
# posées dans les constructeurs des fenêtres, et les repeindre
# supposerait de reconstruire celle qui porte le tunnel. Le dire plutôt
# que de laisser croire à un réglage sans effet.
choisi = self._cmb_theme.currentData()
theme_change = choisi != (self._cfg.get("ui", "theme") or "dark")
self._cfg.set("ui", "theme", choisi)
autostart = self._chk_autostart.isChecked()
if autostart != bool(self._cfg.get("ui", "autostart")):
self._cfg.set_autostart(autostart)
else:
self._cfg.save()
QMessageBox.information(self, "Paramètres", "Paramètres enregistrés.")
message = "Paramètres enregistrés."
if theme_change:
message += ("\n\nLe thème « "
f"{theme.PALETTES.get(choisi, choisi)} » s'appliquera "
"au prochain démarrage de WGSecure.")
QMessageBox.information(self, "Paramètres", message)
# ------------------------------------------------------------------ #
# Onglet Sécurité
@@ -924,7 +1014,7 @@ class AdminWindow(QDialog):
))
note = QLabel(
"Définissez un mot de passe pour protéger l'accès au panneau Administrateur.\n"
"Définissez un mot de passe pour protéger l'accès au panneau Administrateur. "
"Laissez vide pour désactiver la protection."
)
note.setWordWrap(True)
@@ -1040,10 +1130,12 @@ class AdminWindow(QDialog):
(f"v{info.VERSION}", theme.ACCENT),
(info.LICENSE, "#5b6b7a"),
]
if (self._cfg.wg.get("vpn_username") or "").strip():
pills.append(("🔐 Code d'accès serveur", theme.OK_SOLID))
else:
pills.append(("🔐 Aucun code d'accès", "#5b6b7a"))
# Pastille fixe : l'ouverture de l'accès passe par le serveur dans
# tous les cas. Elle se fondait sur la présence d'un identifiant VPN
# en configuration locale, qui n'a jamais rien dit de ce que le
# serveur exige — un poste sans identifiant pouvait être tenu en
# quarantaine, et la pastille annonçait « Aucun code d'accès ».
pills.append(("🔐 Accès validé par le serveur", theme.OK_SOLID))
if not is_windows():
if has_root_privileges():
@@ -1318,7 +1410,6 @@ class AdminWindow(QDialog):
# Split-DNS : réseau distant par défaut de ce déploiement (H3), tant
# qu'aucune valeur explicite n'a encore été saisie ou importée.
self._vpn_username.setText(wg.get("vpn_username") or "")
self._api_url.setText(wg.get("api_url") or "")
self._split_dns_server.setText(wg.get("split_dns_server") or "192.168.1.210")
domains = wg.get("split_dns_domains") or ["h3adm.lan"]
@@ -1326,9 +1417,12 @@ class AdminWindow(QDialog):
self._priv_key.setText(wg.get("client_private_key", ""))
self._pub_key.setText(wg.get("client_public_key", ""))
# Un chargement de profil repart d'une configuration de confiance
# (gérée par WGSecure) : seul un import .conf brut verrouille.
self._set_fields_locked(False)
# Le verrou suit la configuration chargée, profil compris : un profil
# né d'un `.conf` importé décrit un pair déjà déclaré côté serveur et
# reste donc verrouillé. Il était remis à faux ici sans condition, si
# bien qu'il ne survivait ni à un changement de profil ni à une simple
# réouverture du panneau.
self._set_fields_locked(bool(wg.get("config_locked")))
def _save_values(self):
self._cfg.set("wg", "server_endpoint", self._srv_endpoint.text().strip())
@@ -1346,7 +1440,6 @@ class AdminWindow(QDialog):
self._cfg.set("wg", "client_public_key", self._pub_key.text().strip())
# Split-DNS
self._cfg.set("wg", "vpn_username", self._vpn_username.text().strip())
self._cfg.set("wg", "api_url", self._api_url.text().strip())
self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip())
domains_text = self._split_dns_domains.text().strip()
@@ -1512,13 +1605,11 @@ class AdminWindow(QDialog):
v.addWidget(btn_close)
dlg.exec()
#: Rendu d'une étape de diagnostic : puce, couleur du libellé.
_STEP_STYLE = {
"ok": ("", theme.OK_TEXT),
"warn": ("⚠️", theme.WARN_TEXT),
"fail": ("", theme.FAIL_TEXT),
"skip": ("⏭️", theme.SKIP_TEXT),
}
#: Puce d'une étape de diagnostic. La couleur ne figure pas ici : évaluée
#: à l'import comme le reste d'un attribut de classe, elle aurait retenu
#: les teintes du thème d'alors. Elle est demandée au thème à l'affichage
#: (cf. `theme.state_text`).
_STEP_BULLET = {"ok": "", "warn": "⚠️", "fail": "", "skip": "⏭️"}
def _run_test(self):
if self._diag_worker is not None:
@@ -1553,18 +1644,30 @@ class AdminWindow(QDialog):
def _format_diagnostic(self, steps: list[dict]) -> str:
"""Met en forme les étapes ; la première en échec porte le verdict."""
from html import escape
rows = []
# Deux colonnes plutôt que deux lignes par étape : l'étape et son
# détail se lisent d'un seul balayage, et la largeur de la fenêtre est
# employée au lieu d'être laissée blanche à droite. Empilés, dix
# étapes occupaient vingt lignes pour un texte qui en remplissait la
# moitié. Le tableau se replie tout seul dans une fenêtre étroite,
# chaque cellule renvoyant à la ligne.
rows = ['<table width="100%" cellspacing="0" cellpadding="0">']
for st in steps:
bullet, color = self._STEP_STYLE.get(st["state"], ("", theme.TEXT))
if st["state"] in self._STEP_BULLET:
bullet = self._STEP_BULLET[st["state"]]
color = theme.state_text(st["state"])
else:
bullet, color = "", theme.TEXT
weight = "bold" if st["state"] == "fail" else "normal"
detail = escape(st["detail"])
rows.append(
f'<div style="margin-bottom:5px;">'
f'<span style="color:{color}; font-weight:{weight};">'
f'{bullet}&nbsp;{escape(st["label"])}</span>'
f'<br><span style="color:{theme.TEXT_MUTED}; font-size:11px;">'
f'&nbsp;&nbsp;&nbsp;&nbsp;{detail}</span></div>'
'<tr>'
f'<td width="42%" style="padding-bottom:5px; color:{color};'
f' font-weight:{weight};">{bullet}&nbsp;{escape(st["label"])}</td>'
f'<td style="padding-bottom:5px; padding-left:10px;'
f' color:{theme.TEXT_MUTED}; font-size:11px;">'
f'{escape(st["detail"])}</td>'
'</tr>'
)
rows.append('</table>')
failed = next((s for s in steps if s["state"] == "fail"), None)
if failed:
@@ -1623,74 +1726,6 @@ class AdminWindow(QDialog):
v.addWidget(note)
dlg.exec()
def _generate_server_config(self):
"""Génère et affiche une paire complète server + client."""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTabWidget, QTextEdit, QPushButton
result = wg_core.generate_server_config(
server_port=self._srv_port.value() if hasattr(self, '_srv_port') else 51820,
use_psk=self._use_psk.isChecked(),
)
dlg = QDialog(self)
dlg.setWindowTitle("Config générée — Server + Client")
dlg.setMinimumSize(600, 420)
dlg.setStyleSheet(f"background: {theme.BG}; color: {theme.TEXT};")
v = QVBoxLayout(dlg)
tabs = QTabWidget()
tabs.setStyleSheet(theme.TABBAR_CSS)
for title, content in [("🖥️ Serveur", result["server_conf"]),
("💻 Client", result["client_conf"])]:
te = QTextEdit()
te.setReadOnly(True)
te.setFont(QFont("Courier", 9))
te.setPlainText(content)
te.setStyleSheet(
f"background: {theme.BG_SUNKEN}; color: {theme.TEXT_CODE};"
" border: none; font-size: 11px;"
)
tabs.addTab(te, title)
v.addWidget(tabs)
note = QLabel(
f"🔑 Clé pub. serveur : {result['server_pub'][:32]}\n"
f"🔑 Clé pub. client : {result['client_pub'][:32]}\n"
"⚠️ Remplacez <SERVER_IP> dans la config client par l'IP publique du serveur."
)
note.setWordWrap(True)
note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 10px; padding: 6px;")
v.addWidget(note)
# Sans ce bouton, la PSK générée n'existait que dans ce texte : elle
# n'était reportée nulle part, et le tunnel monté avec les clés
# affichées ne négociait jamais avec le serveur.
btn_apply = QPushButton("📥 Appliquer le volet client à cette configuration")
btn_apply.setStyleSheet(theme.primary_button_style())
def _apply():
self._priv_key.setText(result["client_priv"])
self._pub_key.setText(result["client_pub"])
self._srv_pubkey.setText(result["server_pub"])
self._use_psk.setChecked(bool(result["psk"]))
self._psk.setText(result["psk"])
self._save_values()
dlg.accept()
QMessageBox.information(
self, "Config client appliquée",
"Clés client, clé publique du serveur"
+ (" et clé pré-partagée" if result["psk"] else "")
+ " reportées dans la configuration.\n\n"
"⚠ Déployez le volet serveur sur la machine distante et "
"renseignez son adresse publique."
)
btn_apply.clicked.connect(_apply)
v.addWidget(btn_apply)
btn_close = QPushButton("Fermer")
btn_close.setStyleSheet(theme.secondary_button_style())
btn_close.clicked.connect(dlg.accept)
v.addWidget(btn_close)
dlg.exec()
def _run_dns_test(self):
if self._dns_worker is not None:
return
@@ -1705,30 +1740,51 @@ class AdminWindow(QDialog):
def _on_dns_test_done(self, r: dict | None):
self._dns_worker = None
self._btn_dns_test.setEnabled(True)
r = r or {"status": "unknown", "resolvers": [], "expected": ""}
lines = []
r = r or {"status": "unknown", "reason": "", "resolvers": [], "expected": ""}
status = r["status"]
if status == "ok":
lines.append("✅ Aucune fuite DNS détectée")
state = "ok"
elif status == "leak":
lines.append("⚠️ Fuite DNS potentielle !")
state = "fail"
elif status == "down":
lines.append("🚫 Résolution DNS hors service — utilisez « Réparer le DNS »")
state = "fail"
elif not r.get("tunnel_up", False):
lines.append("️ Tunnel inactif : la comparaison n'a pas de sens")
state = "skip"
else:
lines.append("❓ Statut inconnu")
state = "skip"
# Chaque verdict dit ce qu'il constate *et* ce qu'il reste à faire.
# « Statut inconnu » seul renvoyait l'utilisateur à lui-même, alors
# que les trois causes possibles appellent trois gestes distincts.
verdicts = {
"ok": ("✅ Aucune fuite : les noms sont résolus par le DNS du tunnel.", "ok"),
"leak": (
"⚠️ Fuite DNS : les noms partent au résolveur du réseau local,\n"
" pas à celui du tunnel. Ce résolveur voit donc ce que ce poste\n"
" consulte, et peut répondre autre chose que le réseau distant.\n"
" → Vérifiez le champ DNS de l'onglet Tunnel, et que le système\n"
" dispose de resolvconf ou systemd-resolved.", "fail"),
"down": (
"🚫 Plus aucun nom ne se résout sur ce poste.\n"
" Souvent le reliquat d'un tunnel arrêté brutalement.\n"
" → Bouton « Réparer le DNS » ci-dessous, tunnel arrêté.", "fail"),
}
reasons = {
"tunnel_down": (
"️ Tunnel arrêté : rien à comparer, tout passe légitimement par\n"
" le résolveur local. → Connectez-vous, puis relancez ce test.", "skip"),
"no_expected": (
"️ Aucun DNS n'est déclaré pour le tunnel : il n'y a donc pas de\n"
" valeur attendue à comparer. → Renseignez le champ DNS de\n"
" l'onglet Tunnel si les noms doivent passer par le tunnel.", "skip"),
"no_resolver": (
"❓ Le système ne déclare aucun résolveur exploitable.\n"
" → Vérifiez resolvconf ou systemd-resolved.", "skip"),
}
head, state = verdicts.get(status) or reasons.get(
r.get("reason", ""), ("❓ Statut indéterminé.", "skip"))
lines = [head, ""]
if r["resolvers"]:
lines.append("Serveurs DNS actifs : " + "".join(r["resolvers"]))
lines.append("Résolveurs actifs sur le système : " + "".join(r["resolvers"]))
if r["expected"]:
lines.append(f"DNS configuré (VPN) : {r['expected']}")
lines.append(f"DNS attendu (celui du tunnel) : {r['expected']}")
if r.get("probe_ip"):
lines.append(f"IP retournée par whoami.akamai.net : {r['probe_ip']}")
# Sans cette phrase, l'adresse s'affichait sans que rien ne dise
# ce qu'elle vaut ni d'où elle sort.
lines.append(
f"Résolveur récursif vu depuis Internet : {r['probe_ip']}\n"
" (adresse que whoami.akamai.net renvoie : c'est le résolveur qui\n"
" a réellement posé la question sur Internet, pas forcément celui\n"
" que ce poste interroge en premier.)")
self._dns_result.setPlainText("\n".join(lines))
self._dns_result.setStyleSheet(theme.result_view_style(state))
+9 -7
View File
@@ -5,6 +5,8 @@ from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QFont, QPainterPath
from PyQt6.QtCore import Qt, QRect, QPointF
from app.ui import theme
def _fmt(bps: float) -> str:
if bps >= 1_048_576:
@@ -43,7 +45,7 @@ class BandwidthGraph(QWidget):
pad_l, pad_r, pad_t, pad_b = 42, 8, 6, 20
# Fond
p.fillRect(0, 0, w, h, QColor("#17202a"))
p.fillRect(0, 0, w, h, QColor(theme.GRAPH_BG))
plot_w = w - pad_l - pad_r
plot_h = h - pad_t - pad_b
@@ -71,18 +73,18 @@ class BandwidthGraph(QWidget):
line.lineTo(QPointF(x, y))
p.drawPath(line)
_path(self._rx, "#5dade2", "#1a3a52") # RX bleu
_path(self._tx, "#58d68d", "#1a3d2b") # TX vert
_path(self._rx, theme.GRAPH_RX, theme.GRAPH_RX_FILL) # RX
_path(self._tx, theme.GRAPH_TX, theme.GRAPH_TX_FILL) # TX
# Axes
p.setPen(QPen(QColor("#2e4057")))
p.setPen(QPen(QColor(theme.GRAPH_AXIS)))
p.drawLine(pad_l, pad_t, pad_l, pad_t + plot_h)
p.drawLine(pad_l, pad_t + plot_h, w - pad_r, pad_t + plot_h)
# Labels Y
font = QFont("Arial", 8)
p.setFont(font)
p.setPen(QPen(QColor("#5d6d7e")))
p.setPen(QPen(QColor(theme.GRAPH_LABEL)))
for frac, label in [(0.0, _fmt(max_val)), (0.5, _fmt(max_val / 2)), (1.0, "0")]:
y = int(pad_t + frac * plot_h)
p.drawText(QRect(0, y - 8, pad_l - 2, 16),
@@ -90,9 +92,9 @@ class BandwidthGraph(QWidget):
label)
# Légende
p.setPen(QPen(QColor("#5dade2")))
p.setPen(QPen(QColor(theme.GRAPH_RX)))
p.drawText(pad_l + 4, pad_t + 12, f"{_fmt(self._rx[-1])}")
p.setPen(QPen(QColor("#58d68d")))
p.setPen(QPen(QColor(theme.GRAPH_TX)))
p.drawText(pad_l + 90, pad_t + 12, f"{_fmt(self._tx[-1])}")
p.end()
+13 -5
View File
@@ -10,7 +10,13 @@ from PyQt6.QtGui import QColor, QFont
from app.core import history as hist
from app.ui import theme
_CSS = f"""
def _css() -> str:
"""Feuille de style de la fenêtre d'historique.
Fonction et non constante : bâtie à l'import, elle retenait les couleurs
du thème chargé à ce moment- et ignorait celui appliqué au démarrage.
"""
return f"""
QDialog {{ background: {theme.BG_SUNKEN}; }}
QLabel {{ color: {theme.TEXT}; background: transparent; }}
QTableWidget {{
@@ -21,12 +27,14 @@ _CSS = f"""
background: {theme.BG_RAISED}; color: {theme.TEXT}; padding: 5px;
border: none; font-weight: bold; font-size: 11px;
}}
QTableWidget::item:selected {{ background: {theme.ACCENT}; }}
QTableWidget::item:selected {{ background: {theme.ACCENT};
color: {theme.ON_SOLID}; }}
QPushButton {{
background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 5px;
padding: 6px 14px; border: none;
}}
QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}
QPushButton:hover {{ background: {theme.ACCENT_HOVER};
color: {theme.ON_SOLID}; }}
"""
@@ -35,7 +43,7 @@ class HistoryDialog(QDialog):
super().__init__(parent)
self.setWindowTitle("WGSecure — Historique des sessions")
self.setMinimumSize(680, 420)
self.setStyleSheet(_CSS)
self.setStyleSheet(_css())
self._build_ui()
self._load()
@@ -64,7 +72,7 @@ class HistoryDialog(QDialog):
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self._table.setAlternatingRowColors(True)
self._table.setStyleSheet(
_CSS +
_css() +
f"QTableWidget {{ alternate-background-color: {theme.BG_RAISED}; }}"
)
layout.addWidget(self._table)
+343 -13
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
@@ -69,6 +88,10 @@ class MainWindow(QMainWindow):
# déclenché sans invite est déjà parti.
self._probe_worker: ValueWorker | None = None
self._probe_prompt = False
# Vérification, après un code accepté, que le trafic atteint bien le
# réseau distant. Distincte du sondage : celui-ci demande au serveur
# ce qu'il a enregistré, celle-là observe ce qui passe réellement.
self._reach_worker: ValueWorker | None = None
# Date du dernier sondage abouti, et nombre de sondages consécutifs
# ayant trouvé fermé un accès qu'on croyait ouvert (cf.
# `_access_stale` et `_on_probe_reply`). `_access_message` évite de
@@ -88,6 +111,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 +187,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()
@@ -281,9 +553,9 @@ class MainWindow(QMainWindow):
self._btn_connect = QPushButton("Se connecter")
self._btn_connect.setFixedHeight(44)
self._btn_connect.setStyleSheet(
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #27ae60; }"
f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}"
f"QPushButton:disabled {{ background: {theme.BG_RAISED}; color: {theme.TEXT_FAINT}; }}"
)
self._btn_connect.clicked.connect(self._on_connect)
@@ -418,14 +690,16 @@ class MainWindow(QMainWindow):
def _style_access_button(self, expected: bool):
"""Met le bouton en avant seulement quand un code est réellement dû."""
bg, hover = ((theme.WARN_SOLID, "#d68910") if expected
else (theme.BG_RAISED, theme.ACCENT_HOVER))
bg, hover, fg = ((theme.WARN_SOLID, theme.WARN_HOVER, theme.ON_SOLID)
if expected
else (theme.BG_RAISED, theme.ACCENT_HOVER, theme.TEXT))
weight = "bold" if expected else "normal"
self._btn_access.setStyleSheet(
f"QPushButton {{ background: {bg}; color: {theme.TEXT};"
f"QPushButton {{ background: {bg}; color: {fg};"
f" font-size: 12px; font-weight: {weight}; border-radius: 6px;"
" border: none; }"
f"QPushButton:hover {{ background: {hover}; }}"
f"QPushButton:hover {{ background: {hover};"
f" color: {theme.ON_SOLID}; }}"
)
def _sync_window_height(self):
@@ -763,6 +1037,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.
@@ -808,9 +1087,9 @@ class MainWindow(QMainWindow):
)
self._btn_connect.setText("Se déconnecter")
self._btn_connect.setStyleSheet(
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; font-size: 14px;"
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #c0392b; }"
f"QPushButton:hover {{ background: {theme.FAIL_SOLID}; }}"
)
self._info_labels["rx_tx"].setText(
f"{info.get('rx_bytes','')}{info.get('tx_bytes','')}"
@@ -832,9 +1111,9 @@ class MainWindow(QMainWindow):
)
self._btn_connect.setText("Se connecter")
self._btn_connect.setStyleSheet(
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.ON_SOLID}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #27ae60; }"
f"QPushButton:hover {{ background: {theme.OK_HOVER}; }}"
)
self._info_labels["rx_tx"].setText("")
self._info_labels["handshake"].setText("")
@@ -1096,6 +1375,7 @@ class MainWindow(QMainWindow):
self._access_denials = 0
self._probe_retried = False
self._add_log(message, "success")
self._announce_access_granted()
else:
# Abandon : l'état d'accès n'est pas touché. Il l'était, et à tort
# — un sondage abouti pendant que la fenêtre était ouverte (les
@@ -1110,6 +1390,53 @@ class MainWindow(QMainWindow):
"accès distant fermé", "warning")
self._refresh_status()
def _announce_access_granted(self):
"""Confirme la validation, en disant jusqu'à quand l'accès tient.
Le message du serveur partait jusqu'ici dans le seul journal, pendant
que la fenêtre se refermait : rien ne distinguait à l'écran un code
accepté d'un code ignoré, et la durée obtenue restait invisible alors
que c'est précisément ce que l'utilisateur vient de demander.
"""
if self._access_until is not None:
fin = self._access_until.astimezone()
texte = (f"Code accepté.\n\nAccès au réseau distant ouvert "
f"jusqu'au {fin:%d/%m/%Y à %H:%M} "
f"(soit {self._remaining_text()}).")
else:
texte = ("Code accepté.\n\nAccès au réseau distant ouvert. Le "
"serveur n'a pas annoncé d'échéance.")
QMessageBox.information(self, "Accès ouvert", texte)
# Le serveur peut avoir enregistré l'autorisation sans que le trafic
# passe pour autant — règle de pare-feu non appliquée, par exemple.
# Le constater vaut mieux que laisser l'utilisateur découvrir seul un
# réseau resté muet après un code pourtant accepté.
self._verify_access_effective()
def _verify_access_effective(self):
"""Vérifie en fond que le trafic atteint réellement le réseau distant."""
if self._reach_worker is not None:
return
self._reach_worker = ValueWorker(vpn_session.remote_network_reachable,
self._cfg, parent=self)
self._reach_worker.done.connect(self._on_reach_reply)
self._reach_worker.finished.connect(self._reach_worker.deleteLater)
self._reach_worker.start()
def _on_reach_reply(self, joignable: bool):
self._reach_worker = None
cible = (self._cfg.wg.get("split_dns_server") or "").strip()
if not cible:
return # rien à sonder, donc rien à conclure
if joignable:
self._add_log(f"Trafic vers le réseau distant confirmé ({cible})",
"success")
return
self._add_log(
f"Code accepté, mais {cible} reste injoignable : le serveur a "
"enregistré l'autorisation sans que le trafic passe. Prévenez "
"l'administrateur.", "warning")
def _begin_task(self, label: str):
self._connecting = True
self._btn_connect.setEnabled(False)
@@ -1231,7 +1558,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._reach_worker, self._update_worker):
try:
if worker is not None and worker.isRunning():
worker.wait(15000)
@@ -1246,6 +1574,8 @@ class MainWindow(QMainWindow):
self._worker = None
self._ping_worker = None
self._probe_worker = None
self._reach_worker = None
self._update_worker = None
if self._session_id is not None:
hist.end_session(self._session_id, self._prev_rx or 0,
+317 -149
View File
@@ -5,78 +5,309 @@ distinctes dans le seul panneau d'administration, cinq bandeaux d'onglets sans
rapport de teinte entre eux, et des blocs de résultat dont le fond ne
correspondait ni à celui de la page ni à celui de leurs voisins. Tout passe
désormais par ce module une couleur ne se choisit plus au cas par cas.
Deux palettes cohabitent. Les noms exportés `BG`, `TEXT`, `TAB_CSS` ne
changent pas : `apply()` les rebranche sur la palette choisie au démarrage,
et les cent soixante-dix appels répartis dans l'interface continuent de lire
`theme.BG` sans rien savoir du thème actif. C'est ce qui permet d'ajouter une
palette sans toucher à un seul widget.
Le choix est lu une fois, au lancement (cf. `main.py`) : les feuilles de style
sont posées dans les constructeurs des fenêtres, et une bascule à chaud
demanderait de les reconstruire celle qui porte l'état du tunnel comprise.
"""
# ── Fonds ────────────────────────────────────────────────────────────────────
BG = "#1c2833" # fond de l'application et de toutes les pages
BG_SUNKEN = "#17202a" # zones en creux : configurations, blocs de code
BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires
BORDER = "rgba(255,255,255,0.18)"
BORDER_SOFT = "rgba(255,255,255,0.10)"
INPUT_BG = "rgba(255,255,255,0.10)" # fond des champs de saisie et listes
# ── Palettes ────────────────────────────────────────────────────────────────
# Chaque palette porte les mêmes clés ; ce sont elles que `apply()` publie
# comme variables de module. Tout ce qui dépend du thème vit ici, y compris
# ce qui était auparavant écrit au fil des feuilles de style ci-dessous —
# une valeur en dur dans `TAB_CSS` aurait été un fond blanc translucide posé
# sur un thème clair, c'est-à-dire rien du tout.
# ── Textes ───────────────────────────────────────────────────────────────────
TEXT = "white"
TEXT_MUTED = "rgba(255,255,255,0.70)"
TEXT_FAINT = "rgba(255,255,255,0.50)"
TEXT_CODE = "#a8d8ea" # texte à chasse fixe (aperçus de config, rapports)
_DARK = {
# Fonds
"BG": "#1c2833", # fond de l'application et de toutes les pages
"BG_SUNKEN": "#17202a", # zones en creux : configurations, blocs de code
"BG_RAISED": "#253545", # posé sur le fond : listes, en-têtes secondaires
"BORDER": "rgba(255,255,255,0.18)",
"BORDER_SOFT": "rgba(255,255,255,0.10)",
"INPUT_BG": "rgba(255,255,255,0.10)", # champs de saisie et listes
# Variantes d'état des champs et boutons, jusqu'ici en dur dans TAB_CSS.
"INPUT_BG_READONLY": "rgba(255,255,255,0.06)",
"INPUT_BG_DISABLED": "rgba(255,255,255,0.04)",
"SPIN_BUTTON_BG": "rgba(255,255,255,0.15)",
"BUTTON_DISABLED_BG": "rgba(255,255,255,0.08)",
# ── Accent ───────────────────────────────────────────────────────────────────
ACCENT = "#2471a3"
ACCENT_HOVER = "#1a5276"
ACCENT_ACTIVE = "#154360"
ACCENT_LIGHT = "#5dade2"
# Textes
"TEXT": "white",
"TEXT_MUTED": "rgba(255,255,255,0.70)",
"TEXT_FAINT": "rgba(255,255,255,0.50)",
"TEXT_CODE": "#a8d8ea", # chasse fixe (aperçus de config, rapports)
# Texte posé sur un aplat saturé — bouton plein, onglet actif, bandeau.
# Distinct de TEXT : sur un thème clair, TEXT est presque noir et
# deviendrait illisible sur un fond d'accent.
"ON_SOLID": "white",
"BANNER_TEXT": "white",
# ── États ────────────────────────────────────────────────────────────────────
# Chaque état porte un trio : teinte du texte, fond translucide, bouton plein.
OK_TEXT, OK_BG, OK_SOLID = "#a9dfbf", "rgba(39,174,96,0.18)", "#1e8449"
WARN_TEXT, WARN_BG, WARN_SOLID = "#f7dc6f", "rgba(241,196,15,0.15)", "#b7950b"
FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b"
SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)"
# Accent
"ACCENT": "#2471a3",
"ACCENT_HOVER": "#1a5276",
"ACCENT_ACTIVE": "#154360",
"ACCENT_LIGHT": "#5dade2",
# Fond de l'encart d'introduction : l'accent très dilué.
"HINT_BG": "rgba(36,113,163,0.16)",
# Teintes de survol des boutons pleins ok/fail — reprises telles quelles à
# plusieurs endroits (panneau admin, fenêtre principale) : un seul nom leur
# évite de dériver l'une de l'autre au fil des modifications.
OK_HOVER = "#27ae60"
FAIL_HOVER = "#e74c3c"
# États — chacun porte un trio : texte, fond translucide, bouton plein.
"OK_TEXT": "#a9dfbf", "OK_BG": "rgba(39,174,96,0.18)", "OK_SOLID": "#1e8449",
"WARN_TEXT": "#f7dc6f", "WARN_BG": "rgba(241,196,15,0.15)", "WARN_SOLID": "#b7950b",
"FAIL_TEXT": "#f1948a", "FAIL_BG": "rgba(231,76,60,0.18)", "FAIL_SOLID": "#c0392b",
"SKIP_TEXT": "rgba(255,255,255,0.45)",
"SKIP_BG": "rgba(255,255,255,0.05)",
# Survols des boutons pleins — nommés pour ne pas dériver l'un de l'autre.
"OK_HOVER": "#27ae60",
"WARN_HOVER": "#d68910",
"FAIL_HOVER": "#e74c3c",
_STATE = {
"ok": (OK_TEXT, OK_BG),
"warn": (WARN_TEXT, WARN_BG),
"fail": (FAIL_TEXT, FAIL_BG),
"skip": (SKIP_TEXT, SKIP_BG),
"idle": (TEXT_MUTED, "transparent"),
}
# Badge de ping (fenêtre principale). Teintes propres, volontairement plus
# tranchées que les aplats d'état : le badge est petit et lu d'un coup.
"PING_GOOD_BG": "#1e8449", "PING_GOOD_FG": "white",
"PING_MEDIUM_BG": "#d4ac0d", "PING_MEDIUM_FG": "black",
"PING_BAD_BG": "#922b21", "PING_BAD_FG": "white",
# ── Badge de ping (fenêtre principale) ──────────────────────────────────────
PING_CSS = {
"good": "background:#1e8449; color:white;",
"medium": "background:#d4ac0d; color:black;",
"bad": "background:#922b21; color:white;",
"offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};",
}
# Graphe de bande passante : fond, puis trait et remplissage par série.
"GRAPH_BG": "#17202a",
"GRAPH_RX": "#5dade2", "GRAPH_RX_FILL": "#1a3a52",
"GRAPH_TX": "#58d68d", "GRAPH_TX_FILL": "#1a3d2b",
"GRAPH_AXIS": "#2e4057", "GRAPH_LABEL": "#5d6d7e",
# ── Entrées de journal (fenêtre principale) ─────────────────────────────────
LOG_COLORS = {
"success": OK_TEXT,
"error": FAIL_TEXT,
"warning": WARN_TEXT,
"info": ACCENT_LIGHT,
}
# ── Bandeaux d'onglets ───────────────────────────────────────────────────────
# Six teintes calées sur la même luminosité et la même saturation : les
# onglets restent distinguables sans que l'un paraisse plus clair ou plus
# saturé que les autres, ce qui était le cas des valeurs choisies à la main.
BANNER = {
# Bandeaux d'onglets. Six teintes calées sur la même luminosité et la même
# saturation : les onglets restent distinguables sans que l'un paraisse
# plus clair ou plus saturé que les autres.
"BANNER": {
"wireguard": "#1a5276",
"keys": "#1a4a6e",
"test": "#1a4a7a",
"settings": "#155e52",
"security": "#7a3b1a",
"about": "#243b53",
},
}
# Le thème clair n'est pas l'inverse du sombre : inverser une luminance donne
# des aplats criards et des textes d'état illisibles. Les fonds descendent
# vers un gris très pâle plutôt que vers le blanc pur — un blanc plein fait
# vibrer les bordures et fatigue sur une fenêtre qui reste ouverte la journée.
# Les teintes d'état, elles, sont assombries : `#a9dfbf` est un vert lisible
# sur ardoise et invisible sur papier.
#
# L'accent, les bandeaux et les aplats pleins ne bougent pas : ils portent
# déjà un texte blanc dans les deux thèmes, et les garder identiques donne à
# l'application la même identité d'un thème à l'autre.
_LIGHT = {
"BG": "#f4f6f8",
"BG_SUNKEN": "#e9edf1",
"BG_RAISED": "#ffffff",
"BORDER": "rgba(28,40,51,0.22)",
"BORDER_SOFT": "rgba(28,40,51,0.12)",
"INPUT_BG": "#ffffff",
"INPUT_BG_READONLY": "rgba(28,40,51,0.05)",
"INPUT_BG_DISABLED": "rgba(28,40,51,0.03)",
"SPIN_BUTTON_BG": "rgba(28,40,51,0.10)",
"BUTTON_DISABLED_BG": "rgba(28,40,51,0.08)",
"TEXT": "#1c2833", # l'ardoise du thème sombre, passée au texte
"TEXT_MUTED": "rgba(28,40,51,0.72)",
"TEXT_FAINT": "rgba(28,40,51,0.50)",
"TEXT_CODE": "#1a5276",
"ON_SOLID": "white",
"BANNER_TEXT": "white",
"ACCENT": "#2471a3",
"ACCENT_HOVER": "#1a5276",
"ACCENT_ACTIVE": "#154360",
# Plus soutenu que dans le thème sombre : `#5dade2` sert aussi de couleur
# de texte au journal, et se perd sur un fond pâle.
"ACCENT_LIGHT": "#2e86c1",
"HINT_BG": "rgba(36,113,163,0.10)",
"OK_TEXT": "#1a6b3c", "OK_BG": "rgba(39,174,96,0.14)", "OK_SOLID": "#1e8449",
"WARN_TEXT": "#8a6d0b", "WARN_BG": "rgba(241,196,15,0.22)", "WARN_SOLID": "#b7950b",
"FAIL_TEXT": "#a5281b", "FAIL_BG": "rgba(231,76,60,0.12)", "FAIL_SOLID": "#c0392b",
"SKIP_TEXT": "rgba(28,40,51,0.45)",
"SKIP_BG": "rgba(28,40,51,0.05)",
"OK_HOVER": "#27ae60",
"WARN_HOVER": "#d68910",
"FAIL_HOVER": "#e74c3c",
"PING_GOOD_BG": "#1e8449", "PING_GOOD_FG": "white",
"PING_MEDIUM_BG": "#b7950b", "PING_MEDIUM_FG": "white",
"PING_BAD_BG": "#922b21", "PING_BAD_FG": "white",
# Le graphe garde son fond en creux, pâle comme le reste ; les deux séries
# sont assombries pour rester lisibles dessus, et leur remplissage devient
# un voile de la même teinte au lieu d'un aplat sombre.
"GRAPH_BG": "#e9edf1",
"GRAPH_RX": "#2e86c1", "GRAPH_RX_FILL": "#cfe3f2",
"GRAPH_TX": "#1e8449", "GRAPH_TX_FILL": "#d3efdd",
"GRAPH_AXIS": "#c3ccd6", "GRAPH_LABEL": "#5d6d7e",
"BANNER": {
"wireguard": "#1a5276",
"keys": "#1a4a6e",
"test": "#1a4a7a",
"settings": "#155e52",
"security": "#7a3b1a",
"about": "#243b53",
},
}
#: Palettes proposées, dans l'ordre où l'interface les présente.
PALETTES = {"dark": "Sombre", "light": "Clair"}
_PALETTES = {"dark": _DARK, "light": _LIGHT}
#: Palette active. Renseignée par `apply()`, lue par le panneau de réglages.
CURRENT = "dark"
def apply(name: str) -> str:
"""Rend `name` active et recalcule tout ce qui en dépend.
Rend le nom réellement appliqué : un réglage inconnu configuration
écrite à la main, retour à une version antérieure retombe sur le thème
sombre plutôt que de faire échouer le démarrage de l'interface.
À appeler avant de construire la moindre fenêtre : les feuilles de style
sont posées dans les constructeurs et ne sont pas relues ensuite.
"""
global CURRENT
CURRENT = name if name in _PALETTES else "dark"
globals().update(_PALETTES[CURRENT])
_derive()
return CURRENT
def _derive() -> None:
"""Recompose les valeurs dérivées de la palette qui vient d'être publiée.
Les fonctions du module lisent les jetons à l'appel et suivent donc
d'elles-mêmes ; seules les constantes calculées une fois à l'import
feuilles de style complètes et tables d'état — doivent être refaites.
"""
globals().update(
_STATE={
"ok": (OK_TEXT, OK_BG),
"warn": (WARN_TEXT, WARN_BG),
"fail": (FAIL_TEXT, FAIL_BG),
"skip": (SKIP_TEXT, SKIP_BG),
"idle": (TEXT_MUTED, "transparent"),
},
PING_CSS={
"good": f"background:{PING_GOOD_BG}; color:{PING_GOOD_FG};",
"medium": f"background:{PING_MEDIUM_BG}; color:{PING_MEDIUM_FG};",
"bad": f"background:{PING_BAD_BG}; color:{PING_BAD_FG};",
"offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};",
},
LOG_COLORS={
"success": OK_TEXT,
"error": FAIL_TEXT,
"warning": WARN_TEXT,
"info": ACCENT_LIGHT,
},
TAB_CSS=f"""
QWidget {{ background-color: {BG}; color: {TEXT}; }}
QLabel {{ color: {TEXT}; background: transparent; }}
QCheckBox {{ color: {TEXT}; background: transparent; }}
QGroupBox {{
color: {TEXT};
background: transparent;
border: 1px solid {BORDER};
border-radius: 5px;
margin-top: 10px;
font-weight: bold;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 4px;
color: {TEXT};
}}
QLineEdit {{
background: {INPUT_BG};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 4px 6px;
}}
QLineEdit:read-only {{ background: {INPUT_BG_READONLY}; }}
QLineEdit:disabled {{ background: {INPUT_BG_DISABLED};
color: {TEXT_FAINT}; }}
QSpinBox {{
background: {INPUT_BG};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 3px 6px;
}}
QSpinBox::up-button, QSpinBox::down-button {{
background: {SPIN_BUTTON_BG};
}}
QComboBox {{
background: {INPUT_BG};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 3px 8px;
}}
QComboBox::drop-down {{ border: none; }}
QComboBox QAbstractItemView {{
background: {BG_RAISED};
color: {TEXT};
selection-background-color: {ACCENT};
selection-color: {ON_SOLID};
}}
QTextEdit {{
background: {BG_SUNKEN};
color: {TEXT_CODE};
border: 1px solid {BORDER};
border-radius: 4px;
}}
QPushButton {{
background: {ACCENT};
color: {ON_SOLID};
border-radius: 5px;
padding: 7px 12px;
border: none;
}}
QPushButton:hover {{ background: {ACCENT_HOVER}; }}
QPushButton:checked {{ background: {ACCENT_ACTIVE}; }}
QPushButton:disabled {{ background: {BUTTON_DISABLED_BG};
color: {TEXT_FAINT}; }}
""",
TABBAR_CSS=f"""
QTabWidget::pane {{ border: none; background: {BG}; }}
QTabBar::tab {{
background: {BG_RAISED};
color: {TEXT_MUTED};
padding: 6px 14px;
border: none;
}}
QTabBar::tab:selected {{ background: {ACCENT}; color: {ON_SOLID}; }}
QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER};
color: {ON_SOLID}; }}
""",
)
def state_text(state: str) -> str:
"""Couleur de texte d'un état, pour un rendu qui se passe de fond.
Le panneau de diagnostic tenait sa propre table état couleur, bâtie à
l'import de son module : elle gardait donc les teintes du thème chargé à
ce moment-, quel que soit celui appliqué ensuite. La question revient
ici, la réponse suit la palette active.
"""
return _STATE.get(state, _STATE["idle"])[0]
def result_style(state: str, font_size: int = 11) -> str:
"""Feuille de style d'un bloc de résultat, selon son état.
@@ -116,109 +347,40 @@ def scrollarea_style() -> str:
)
def banner_style(color: str) -> str:
return (f"background: {color}; color: {TEXT}; font-size: 13px;"
" font-weight: bold; padding-left: 8px;")
def banner_style(color: str, fg: str = "") -> str:
"""Bandeau de titre posé en tête d'une fenêtre ou d'une page.
`fg` vaut le blanc des bandeaux d'onglets, qui sont des aplats saturés.
Le bandeau de titre du panneau d'administration, lui, prend le fond en
creux de la fenêtre : en thème clair, c'est un gris très pâle sur lequel
du blanc ne se voit pas. Il passe donc la couleur de texte ordinaire.
"""
return (f"background: {color}; color: {fg or BANNER_TEXT};"
" font-size: 13px; font-weight: bold; padding-left: 8px;")
#: CSS sombre commun à toutes les pages.
TAB_CSS = f"""
QWidget {{ background-color: {BG}; color: {TEXT}; }}
QLabel {{ color: {TEXT}; background: transparent; }}
QCheckBox {{ color: {TEXT}; background: transparent; }}
QGroupBox {{
color: {TEXT};
background: transparent;
border: 1px solid {BORDER};
border-radius: 5px;
margin-top: 10px;
font-weight: bold;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 4px;
color: {TEXT};
}}
QLineEdit {{
background: {INPUT_BG};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 4px 6px;
}}
QLineEdit:read-only {{ background: rgba(255,255,255,0.06); }}
QLineEdit:disabled {{ background: rgba(255,255,255,0.04);
color: {TEXT_FAINT}; }}
QSpinBox {{
background: {INPUT_BG};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 3px 6px;
}}
QSpinBox::up-button, QSpinBox::down-button {{
background: rgba(255,255,255,0.15);
}}
QComboBox {{
background: {INPUT_BG};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 3px 8px;
}}
QComboBox::drop-down {{ border: none; }}
QComboBox QAbstractItemView {{
background: {BG_RAISED};
color: {TEXT};
selection-background-color: {ACCENT};
}}
QTextEdit {{
background: {BG_SUNKEN};
color: {TEXT_CODE};
border: 1px solid {BORDER};
border-radius: 4px;
}}
QPushButton {{
background: {ACCENT};
color: {TEXT};
border-radius: 5px;
padding: 7px 12px;
border: none;
}}
QPushButton:hover {{ background: {ACCENT_HOVER}; }}
QPushButton:checked {{ background: {ACCENT_ACTIVE}; }}
QPushButton:disabled {{ background: rgba(255,255,255,0.08);
color: {TEXT_FAINT}; }}
"""
def primary_button_style(solid: str = "", hover: str = "") -> str:
"""Bouton d'action principale d'une page (lancer un test, par exemple).
#: Barre d'onglets, utilisée par le panneau principal et les sous-onglets.
TABBAR_CSS = f"""
QTabWidget::pane {{ border: none; background: {BG}; }}
QTabBar::tab {{
background: {BG_RAISED};
color: {TEXT_MUTED};
padding: 6px 14px;
border: none;
}}
QTabBar::tab:selected {{ background: {ACCENT}; color: {TEXT}; }}
QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER};
color: {TEXT}; }}
"""
def primary_button_style(solid: str = OK_SOLID, hover: str = OK_HOVER) -> str:
"""Bouton d'action principale d'une page (lancer un test, par exemple)."""
return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};"
Les valeurs par défaut sont résolues à l'appel, et non à la définition :
prises dans la signature, elles auraient figé les teintes de la palette
active au moment de l'import — soit toujours celles du thème sombre.
"""
solid = solid or OK_SOLID
hover = hover or OK_HOVER
return (f"QPushButton {{ padding: 9px; background: {solid};"
f" color: {ON_SOLID};"
f" border-radius: 5px; font-weight: bold; border: none; }}"
f"QPushButton:hover {{ background: {hover}; }}")
def secondary_button_style() -> str:
"""Bouton d'action secondaire d'une page (à côté d'une action principale)."""
return (f"QPushButton {{ padding: 9px; background: {BG_RAISED}; color: {TEXT};"
return (f"QPushButton {{ padding: 9px; background: {BG_RAISED};"
f" color: {TEXT};"
f" border-radius: 5px; border: none; }}"
f"QPushButton:hover {{ background: {ACCENT_HOVER}; }}")
f"QPushButton:hover {{ background: {ACCENT_HOVER};"
f" color: {ON_SOLID}; }}")
def hint_style() -> str:
@@ -227,7 +389,13 @@ def hint_style() -> str:
Assez contrasté pour se détacher du fond de page, assez sobre pour ne pas
concurrencer le contenu : un liseré d'accent à gauche, pas de cadre plein.
"""
return (f"background: rgba(36,113,163,0.16);"
return (f"background: {HINT_BG};"
f" border-left: 3px solid {ACCENT_LIGHT};"
f" border-radius: 4px; padding: 9px 11px;"
f" color: {TEXT_MUTED}; font-size: 11px;")
# Palette par défaut publiée dès l'import : le module reste utilisable sans
# initialisation explicite, et `main.py` n'a qu'à rappeler `apply()` avec le
# réglage lu en configuration.
apply("dark")
+8
View File
@@ -15,6 +15,7 @@ from app.core import log as conn_log
from app.core import shutdown as shutdown_guard
from app.core.single_instance import SingleInstanceGuard
from app.ui.main_window import MainWindow
from app.ui import theme
from app.ui.systray import SystemTray
from app.ui import icons
from app.utils.platform_utils import install_desktop_entry
@@ -112,6 +113,13 @@ def main():
config = Config()
# Avant la moindre fenêtre : les feuilles de style sont posées dans les
# constructeurs, et une palette changée après coup n'atteindrait plus
# rien. `apply` retombe seule sur le thème sombre si le réglage est
# inconnu — une configuration écrite à la main ne doit pas empêcher
# l'application de s'ouvrir.
theme.apply(config.get("ui", "theme") or "dark")
# Reste d'une session précédente mal terminée : DNS détourné vers un
# résolveur mort. À faire avant d'ouvrir la fenêtre pour rendre l'accès
# au réseau tout de suite.
+118 -3
View File
@@ -5,7 +5,7 @@
; ──────────────────────────────────────────────
#define MyAppName "WGSecure"
#define MyAppVersion "0.9.1"
#define MyAppVersion "0.13.0"
#define MyAppPublisher "WGSecure"
#define MyAppExeName "wgsecure.exe"
@@ -24,19 +24,38 @@ 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
; 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]
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
@@ -59,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]
@@ -119,10 +143,101 @@ 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.
// 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é. 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 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);