fix(ui): ne plus réclamer un code déjà validé après une reconnexion (v0.8.6)

Le client tenait pour acquis que l'autorisation ne survivait pas à la chute
du tunnel — un commentaire l'affirmait noir sur blanc. C'est l'inverse : le
serveur autorise l'adresse de tunnel du pair jusqu'à l'échéance enregistrée,
et cette adresse ne change pas d'une reconnexion à l'autre. Se reconnecter
ne révoque rien ; seul le client oubliait, et redemandait un code que le
serveur avait déjà accepté.

Il pose maintenant la question au lieu de la supposer, via GET /api/session
(serveur >= 0.3.0), dès que le tunnel monte : démarrage sur un tunnel déjà
actif, reconnexion automatique, ou avant toute invite de saisie. Le serveur
identifie l'appelant à l'IP source, aucun identifiant ne circule.

Serveur injoignable, réponse illisible ou serveur antérieur à la 0.3.0 ne
valent pas « accès ouvert » : sans information, le client ne conclut rien et
laisse le bouton disponible — le sens sûr, et le comportement de la 0.8.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 18:27:32 +02:00
co-authored by Claude Opus 5
parent 4cf3e26dff
commit 306e512aab
7 changed files with 127 additions and 14 deletions
+12
View File
@@ -6,6 +6,18 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
--- ---
## [0.8.6] — 2026-09-07
### Corrigé
- **Le bouton « Saisir le code d'accès » réclamait un code déjà validé après une reconnexion.** L'application tenait pour acquis que l'autorisation obtenue ne survivait pas à la chute du tunnel — un commentaire du code l'affirmait même explicitement. C'est l'inverse : le serveur autorise l'adresse de tunnel du pair jusqu'à l'échéance enregistrée, et cette adresse ne change pas d'une reconnexion à l'autre. Une déconnexion/reconnexion ne révoque donc rien, mais le client oubliait tout et repartait de zéro. Il interroge désormais le serveur — nouvelle route `GET /api/session`, disponible à partir de la v0.3.0 du serveur — dès que le tunnel monte : au démarrage de l'application sur un tunnel déjà actif, après une reconnexion automatique, et avant toute invite de saisie. Quand l'accès est encore ouvert, aucune fenêtre ne s'ouvre et l'échéance réelle est reprise telle quelle.
### Modifié
- Le sondage n'envoie aucun identifiant : le serveur reconnaît l'appelant à l'IP source de son tunnel, et ne renseigne donc jamais sur un autre compte que celui qui parle.
- **Un serveur injoignable, une réponse illisible ou une version de serveur antérieure à la 0.3.0 ne valent pas « accès ouvert »** : dans ces trois cas le client ne conclut rien et laisse le bouton disponible. Avec un serveur ancien, le comportement est exactement celui de la 0.8.5 — la route manquante est traitée comme une absence d'information, pas comme un refus.
- Le commentaire de `main_window.py` qui affirmait le contraire de la réalité (« elle ne vaut que pour l'adresse du pair, elle ne survit pas à la déconnexion ») est corrigé : elle survit précisément *parce qu'*elle vaut pour l'adresse du pair.
---
## [0.8.5] — 2026-09-07 ## [0.8.5] — 2026-09-07
### Corrigé ### Corrigé
+1 -1
View File
@@ -4,7 +4,7 @@
## ────────────────────────────────────────────── ## ──────────────────────────────────────────────
APP := wgsecure APP := wgsecure
VERSION := 0.8.5 VERSION := 0.8.6
VENV := .venv VENV := .venv
PYTHON := $(VENV)/bin/python3 PYTHON := $(VENV)/bin/python3
PIP := $(VENV)/bin/pip PIP := $(VENV)/bin/pip
+1 -1
View File
@@ -1,6 +1,6 @@
# 🛡️ WGSecure (WGS) # 🛡️ WGSecure (WGS)
🚀 v0.8.5 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard 🚀 v0.8.6 · 🐍 · 🪟🐧 · 🔐 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**. **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**.
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.8.5" __version__ = "0.8.6"
APP_NAME = "WGSecure" APP_NAME = "WGSecure"
APP_SHORT = "WGS" APP_SHORT = "WGS"
+42
View File
@@ -86,6 +86,48 @@ def api_base_url(cfg: Config) -> str:
return f"http://{gateway}:8000" return f"http://{gateway}:8000"
def session_status(cfg: Config) -> AuthResult:
"""Demande au serveur si l'accès de ce poste est déjà ouvert.
Un tunnel qui tombe et remonte ne révoque rien côté serveur :
l'autorisation porte sur l'adresse du pair, et cette adresse ne change
pas. Le client, lui, oubliait tout à chaque reconnexion et réclamait un
code déjà validé c'est ce que cette question évite.
Aucun identifiant n'est envoyé : le serveur reconnaît l'appelant à l'IP
source du tunnel, et ne renseigne donc jamais sur un autre compte que
celui qui parle. Rien à demander avant que le tunnel ne soit monté,
l'API n'étant joignable que par l'intérieur.
Un `ok` faux ne distingue pas « accès fermé » de « serveur injoignable » :
l'appelant traite les deux pareil — il redemande un code, ce qui est le
comportement sûr dans le doute.
"""
base = api_base_url(cfg)
if not base:
return AuthResult(False, "Adresse du serveur d'authentification inconnue.")
request = urllib.request.Request(f"{base}/api/session", method="GET")
try:
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
body = json.loads(response.read().decode("utf-8"))
until = _parse_until(body.get("access_until"))
if until is None:
return AuthResult(True, "Accès réseau déjà ouvert")
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 = ""
return AuthResult(False, detail or f"Accès fermé (code {exc.code})")
except (urllib.error.URLError, OSError, ValueError) as exc:
# Serveur ancien (route absente), injoignable, ou réponse illisible :
# aucune information, donc aucune conclusion — on ne prétend pas que
# l'accès est ouvert.
return AuthResult(False, f"État de l'accès inconnu : {exc}")
def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult: def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau. """Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
+68 -9
View File
@@ -18,6 +18,7 @@ from app.core import dns as dns_util
from app.core import log as conn_log from app.core import log as conn_log
from app.core import history as hist from app.core import history as hist
from app.core import shutdown as shutdown_guard from app.core import shutdown as shutdown_guard
from app.core import vpn_session
from app.ui.worker import TaskWorker, ValueWorker from app.ui.worker import TaskWorker, ValueWorker
from app.ui.access_code_dialog import AccessCodeDialog from app.ui.access_code_dialog import AccessCodeDialog
from app.ui.admin_window import AdminWindow from app.ui.admin_window import AdminWindow
@@ -45,6 +46,12 @@ class MainWindow(QMainWindow):
# signifie « aucune échéance connue » — soit aucun accès ouvert, soit # signifie « aucune échéance connue » — soit aucun accès ouvert, soit
# un serveur qui n'en annonce pas — et jamais « expire maintenant ». # un serveur qui n'en annonce pas — et jamais « expire maintenant ».
self._access_until: datetime | None = None self._access_until: datetime | None = None
# Sondage de l'état de l'accès auprès du serveur. `_probe_prompt`
# retient qu'une saisie devra être proposée si le sondage en cours
# revient « fermé » : la demande peut arriver alors qu'un sondage
# déclenché sans invite est déjà parti.
self._probe_worker: ValueWorker | None = None
self._probe_prompt = False
self._reconnect_failures = 0 self._reconnect_failures = 0
self._quitting = False self._quitting = False
self._warned_foreign: set[str] = set() self._warned_foreign: set[str] = set()
@@ -663,12 +670,23 @@ class MainWindow(QMainWindow):
except Exception: except Exception:
connected = False connected = False
info = {} info = {}
was_connected = self._is_connected
self._is_connected = connected self._is_connected = connected
if connected: if connected:
self._reconnect_failures = 0 self._reconnect_failures = 0
if not was_connected:
# Tunnel monté à l'instant — démarrage de l'application sur un
# tunnel déjà actif, reconnexion automatique, ou montage fait
# 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)
else: else:
# Tunnel tombé : l'autorisation obtenue auprès du serveur ne vaut # Tunnel tombé : le client oublie ce qu'il croyait savoir. Le
# que pour l'adresse du pair, elle ne survit pas à la déconnexion. # serveur, lui, n'oublie rien — l'autorisation porte sur l'adresse
# du pair, qui ne change pas d'une reconnexion à l'autre, et elle
# court jusqu'à son échéance. D'où le sondage au retour du tunnel
# plutôt qu'une remise à zéro tenue pour définitive : c'est cette
# confusion qui faisait réclamer un code déjà validé.
self._access_open = False self._access_open = False
self._access_until = None self._access_until = None
self._info_labels["access"].setText(self._access_state()) self._info_labels["access"].setText(self._access_state())
@@ -829,16 +847,57 @@ class MainWindow(QMainWindow):
ne vaut que pour l'invite : la saisie reste atteignable à la demande, ne vaut que pour l'invite : la saisie reste atteignable à la demande,
par le bouton et par le systray, quelle que soit la configuration. par le bouton et par le systray, quelle que soit la configuration.
""" """
if not (self._cfg.wg.get("vpn_username") or "").strip(): # L'invite ne part qu'après la réponse du serveur : le tunnel qui
# remonte ne révoque pas l'autorisation en cours, et réclamer un code
# sans avoir demandé revenait à en redemander un déjà validé.
#
# Une reconnexion automatique ne fait jamais surgir de fenêtre modale
# — personne n'est devant l'écran, l'application resterait figée ; le
# bouton prend le relais. Sans identifiant VPN configuré non plus :
# une installation sans serveur d'authentification n'a pas à voir
# cette fenêtre à chaque connexion.
known = bool((self._cfg.wg.get("vpn_username") or "").strip())
self._probe_access(prompt_if_closed=(not silent) and known)
def _probe_access(self, prompt_if_closed: bool):
"""Interroge le serveur sur l'état de l'accès, sans bloquer l'écran.
Un seul sondage à la fois : les deux déclencheurs front montant du
tunnel et fin d'une connexion — se produisent souvent coup sur coup.
L'intention d'inviter à saisir un code survit à cette fusion, sans
quoi une connexion manuelle pourrait n'ouvrir aucune fenêtre parce
qu'un sondage silencieux serait parti une fraction de seconde plus tôt.
"""
self._probe_prompt = self._probe_prompt or prompt_if_closed
if self._probe_worker is not None:
return
self._probe_worker = ValueWorker(vpn_session.session_status, self._cfg,
parent=self)
self._probe_worker.done.connect(self._on_probe_reply)
self._probe_worker.finished.connect(self._probe_worker.deleteLater)
self._probe_worker.start()
def _on_probe_reply(self, result):
"""Adopte l'état renvoyé par le serveur, ou réclame un code."""
self._probe_worker = None
prompt = self._probe_prompt
self._probe_prompt = False
if result is not None and result.ok:
self._access_open = True
self._access_until = result.access_until
self._add_log(result.message, "success")
self._refresh_status()
return
# Accès fermé, serveur injoignable ou route absente : dans tous les
# cas on ne prétend rien et un code reste à saisir.
if prompt:
self._open_access_dialog()
return return
if silent:
# Reconnexion automatique : ouvrir une fenêtre modale sans
# personne devant l'écran laisserait l'application figée. Le
# bouton « Saisir le code d'accès » prend le relais.
self._add_log("Code d'accès requis — bouton « Saisir le code " self._add_log("Code d'accès requis — bouton « Saisir le code "
"d'accès »", "warning") "d'accès »", "warning")
return self._refresh_status()
self._open_access_dialog()
def _open_access_dialog(self): def _open_access_dialog(self):
"""Ouvre la fenêtre de saisie et journalise la réponse du serveur.""" """Ouvre la fenêtre de saisie et journalise la réponse du serveur."""
+1 -1
View File
@@ -5,7 +5,7 @@
; ────────────────────────────────────────────── ; ──────────────────────────────────────────────
#define MyAppName "WGSecure" #define MyAppName "WGSecure"
#define MyAppVersion "0.8.5" #define MyAppVersion "0.8.6"
#define MyAppPublisher "WGSecure" #define MyAppPublisher "WGSecure"
#define MyAppExeName "wgsecure.exe" #define MyAppExeName "wgsecure.exe"