diff --git a/CHANGELOG.md b/CHANGELOG.md index ce5cbec..cd8ed5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [0.8.4] — 2026-09-06 + +### Corrigé +- **Aucun moyen de saisir le code tant que le champ *Identifiant VPN* n'était pas renseigné** — ni bouton, ni entrée de systray, sur les deux plateformes. Ce champ, introduit en v0.8.0 et vide par défaut, conditionnait l'affichage des deux commandes : un poste que le serveur tenait bel et bien en quarantaine n'affichait donc rien pour en sortir, précisément dans la situation où il en avait besoin. La saisie ne dépend plus que du tunnel : monté et accès non ouvert, elle est atteignable. L'identifiant est demandé dans la fenêtre elle-même quand il manque, et enregistré une fois que le serveur l'a reconnu — jamais avant. +- **L'état « Non géré par le serveur » était une affirmation sans vérification.** Le client la tirait de sa seule configuration locale, alors qu'il n'en sait rien : le serveur peut parfaitement tenir ce pair en quarantaine pendant que le poste ne mentionne aucun serveur. La ligne indique désormais « Non vérifié », qui est la vérité tant qu'aucun code n'a été validé. + +### Modifié +- L'invite spontanée après connexion reste, elle, conditionnée à un identifiant connu : une installation sans serveur d'authentification n'a aucune raison de voir surgir cette fenêtre à chaque connexion. Le bouton reflète la nuance — mis en avant quand un code est réellement attendu, discret sinon. + +--- + ## [0.8.3] — 2026-09-06 ### Corrigé diff --git a/Makefile b/Makefile index 2c7dbf6..7be167f 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.8.3 +VERSION := 0.8.4 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip diff --git a/README.md b/README.md index 74e432b..60cf591 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -🚀 v0.8.3 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard +🚀 v0.8.4 · 🐍 · 🪟🐧 · 🔐 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**. diff --git a/app/__init__.py b/app/__init__.py index 6d67560..ab57e5a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.8.3" +__version__ = "0.8.4" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/core/vpn_session.py b/app/core/vpn_session.py index f4621cf..f2def74 100644 --- a/app/core/vpn_session.py +++ b/app/core/vpn_session.py @@ -52,23 +52,28 @@ def api_base_url(cfg: Config) -> str: return f"http://{gateway}:8000" -def authenticate(cfg: Config, code: str) -> tuple[bool, str]: +def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]: """Valide un code TOTP auprès du serveur et ouvre l'accès réseau. Retourne (succès, message). Un échec n'est jamais silencieux : sans cette validation le tunnel reste monté mais ne mène nulle part, et l'utilisateur doit comprendre pourquoi plutôt que de constater un réseau inexplicablement muet. + + `username` permet de fournir l'identifiant à la volée, quand il n'est pas + encore en configuration : la fenêtre de saisie le demande alors avec le + code, plutôt que de renvoyer l'utilisateur vers le panneau Administrateur + au moment précis où il cherche à ouvrir son accès. Il est enregistré + une fois le serveur l'ayant accepté. """ base = api_base_url(cfg) if not base: return False, ("Adresse du serveur d'authentification inconnue. " "Renseignez-la dans le panneau Administrateur.") - username = (cfg.wg.get("vpn_username") or "").strip() + username = (username or cfg.wg.get("vpn_username") or "").strip() if not username: - return False, ("Aucun identifiant VPN configuré. Renseignez-le dans " - "le panneau Administrateur.") + return False, "Indiquez votre identifiant VPN." payload = json.dumps({"username": username, "code": code}).encode("utf-8") request = urllib.request.Request( diff --git a/app/ui/access_code_dialog.py b/app/ui/access_code_dialog.py index ffcbf97..0958f51 100644 --- a/app/ui/access_code_dialog.py +++ b/app/ui/access_code_dialog.py @@ -41,6 +41,7 @@ class AccessCodeDialog(QDialog): self._cfg = cfg self._worker: TaskWorker | None = None self._message = "" + self._username = "" self.setWindowTitle("WGSecure — Code d'accès") self.setFixedWidth(360) self.setModal(True) @@ -83,6 +84,19 @@ class AccessCodeDialog(QDialog): sep.setStyleSheet(f"color: {theme.BORDER_SOFT};") layout.addWidget(sep) + # L'identifiant n'est demandé que s'il manque encore. Le renvoyer au + # panneau Administrateur à cet instant précis — tunnel monté, accès + # fermé, code en main — était un cul-de-sac : il est saisi ici, puis + # enregistré dès que le serveur l'a reconnu. + self._user_input: QLineEdit | None = None + if not (self._cfg.wg.get("vpn_username") or "").strip(): + self._user_input = QLineEdit() + self._user_input.setPlaceholderText("Identifiant VPN (ex. TestB)") + self._user_input.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._user_input.setStyleSheet(self._input_css(theme.BORDER_SOFT)) + self._user_input.returnPressed.connect(self._submit) + layout.addWidget(self._user_input) + self._code_input = QLineEdit() self._code_input.setPlaceholderText("000 000") self._code_input.setMaxLength(7) @@ -170,10 +184,18 @@ class AccessCodeDialog(QDialog): self._fail("Entrez exactement 6 chiffres.") return + self._username = "" + if self._user_input is not None: + self._username = self._user_input.text().strip() + if not self._username: + self._error_label.setText("Indiquez votre identifiant VPN.") + self._user_input.setStyleSheet(self._input_css(theme.FAIL_TEXT)) + return + self._error_label.setText("") self._set_busy(True) self._worker = TaskWorker(vpn_session.authenticate, self._cfg, raw, - tag="auth", parent=self) + self._username, tag="auth", parent=self) self._worker.done.connect(self._on_reply) self._worker.finished.connect(self._worker.deleteLater) self._worker.start() @@ -182,6 +204,11 @@ class AccessCodeDialog(QDialog): self._worker = None self._message = message if ok: + # Enregistré seulement maintenant : un identifiant que le serveur + # a refusé n'a rien à faire dans la configuration. + if self._username: + self._cfg.set("wg", "vpn_username", self._username) + self._cfg.save() self.accept() return self._set_busy(False) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 78fdb1e..366f05b 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -247,14 +247,10 @@ class MainWindow(QMainWindow): # rien ajouter aux installations sans authentification serveur. self._btn_access = QPushButton("🔐 Saisir le code d'accès") self._btn_access.setFixedHeight(32) - self._btn_access.setStyleSheet( - f"QPushButton {{ background: {theme.WARN_SOLID}; color: {theme.TEXT};" - " font-size: 12px; font-weight: bold; border-radius: 6px; border: none; }" - "QPushButton:hover { background: #d68910; }" - ) self._btn_access.clicked.connect(self.request_access_code) self._btn_access.setVisible(False) s.addWidget(self._btn_access) + self._style_access_button(False) # Repli/dépli du détail (transfert, config, bande passante, journal) : # rattaché à l'action qu'il détaille, pas à l'en-tête. @@ -370,6 +366,18 @@ class MainWindow(QMainWindow): ) self._sync_window_height() + 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)) + weight = "bold" if expected else "normal" + self._btn_access.setStyleSheet( + f"QPushButton {{ background: {bg}; color: {theme.TEXT};" + f" font-size: 12px; font-weight: {weight}; border-radius: 6px;" + " border: none; }" + f"QPushButton:hover {{ background: {hover}; }}" + ) + def _sync_window_height(self): """Réajuste la hauteur fixe de la fenêtre à son contenu visible. @@ -591,12 +599,19 @@ class MainWindow(QMainWindow): tant que le serveur n'a pas levé la quarantaine. Sans cette ligne, un utilisateur ayant refusé la saisie du code verrait « Connecté » et un réseau inexplicablement muet. + + Sans identifiant VPN, l'état est « non vérifié » et non « non géré » : + le client n'en sait rien. Il l'affirmait, et se trompait — le serveur + peut parfaitement tenir ce pair en quarantaine pendant que la + configuration locale, elle, ne mentionne aucun serveur. """ - if not (self._cfg.wg.get("vpn_username") or "").strip(): - return "Non géré par le serveur" if not self._is_connected: return "—" - return "Ouvert ✓" if self._access_open else "Quarantaine (code requis)" + if self._access_open: + return "Ouvert ✓" + if (self._cfg.wg.get("vpn_username") or "").strip(): + return "Quarantaine (code requis)" + return "Non vérifié" def _refresh_status(self): wg = self._cfg.wg @@ -638,6 +653,13 @@ class MainWindow(QMainWindow): if needed == self._btn_access.isHidden(): self._btn_access.setVisible(needed) self._sync_window_height() + if needed: + # Un code est attendu de façon certaine quand l'identifiant VPN + # est connu ; sinon le bouton reste disponible mais discret, pour + # ne pas réclamer une action à une installation qui n'a pas de + # serveur d'authentification. + self._style_access_button( + bool((self._cfg.wg.get("vpn_username") or "").strip())) if connected: if self._connected_since is None: @@ -734,12 +756,17 @@ class MainWindow(QMainWindow): def access_code_needed(self) -> bool: """Vrai quand une saisie de code aurait un sens ici et maintenant. - Trois conditions : un identifiant VPN configuré (sans quoi le serveur - ne gère pas ce poste), un tunnel monté (l'API n'est joignable que par - l'intérieur) et aucune autorisation en cours. + Deux conditions seulement : un tunnel monté — l'API du serveur n'est + joignable que par l'intérieur — et aucune autorisation en cours. + + L'identifiant VPN n'en fait volontairement pas partie. Il en faisait + partie, et c'était un piège : un poste bel et bien tenu en quarantaine + par le serveur, mais dont ce champ n'avait jamais été renseigné, + n'affichait aucun moyen de saisir son code — précisément dans la + situation où il en avait besoin. La fenêtre de saisie demande + l'identifiant quand il manque. """ - return (bool((self._cfg.wg.get("vpn_username") or "").strip()) - and self._is_connected and not self._access_open) + return self._is_connected and not self._access_open def request_access_code(self): """Ouvre la saisie du code, à la demande de l'utilisateur. @@ -755,21 +782,17 @@ class MainWindow(QMainWindow): "Le serveur d'authentification n'est joignable que depuis " "l'intérieur du tunnel.\nConnectez-vous d'abord.") return - if not (self._cfg.wg.get("vpn_username") or "").strip(): - QMessageBox.information( - self, "Aucun identifiant VPN", - "Renseignez l'identifiant VPN connu du serveur dans le " - "panneau Administrateur.") - return - self._authenticate_with_server(silent=False) + self._open_access_dialog() def _authenticate_with_server(self, silent: bool): - """Demande le code à 6 chiffres et le fait valider par le serveur. + """Invite spontanée à saisir le code, juste après une connexion. - Sans identifiant VPN configuré, l'étape est ignorée : le serveur - peut très bien tourner en mode permissif, où un compte non enrôlé - conserve son accès. Imposer la saisie dans ce cas bloquerait des - installations qui fonctionnent parfaitement. + Sans identifiant VPN configuré, l'invite est tue : le serveur peut + très bien tourner en mode permissif, où un compte non enrôlé conserve + son accès, et une installation sans serveur d'authentification n'a + aucune raison de voir surgir cette fenêtre à chaque connexion. Cela + ne vaut que pour l'invite : la saisie reste atteignable à la demande, + par le bouton et par le systray, quelle que soit la configuration. """ if not (self._cfg.wg.get("vpn_username") or "").strip(): return @@ -780,7 +803,10 @@ class MainWindow(QMainWindow): self._add_log("Code d'accès requis — bouton « Saisir le code " "d'accès »", "warning") return + self._open_access_dialog() + def _open_access_dialog(self): + """Ouvre la fenêtre de saisie et journalise la réponse du serveur.""" dlg = AccessCodeDialog(self._cfg, self) accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted self._access_open = accepted diff --git a/wgsecure.iss b/wgsecure.iss index 82dc664..2cd3cd8 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.8.3" +#define MyAppVersion "0.8.4" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"