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:
@@ -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é
|
||||||
|
|||||||
@@ -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,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
@@ -1,3 +1,3 @@
|
|||||||
__version__ = "0.8.5"
|
__version__ = "0.8.6"
|
||||||
APP_NAME = "WGSecure"
|
APP_NAME = "WGSecure"
|
||||||
APP_SHORT = "WGS"
|
APP_SHORT = "WGS"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+69
-10
@@ -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
|
return
|
||||||
if silent:
|
self._probe_worker = ValueWorker(vpn_session.session_status, self._cfg,
|
||||||
# Reconnexion automatique : ouvrir une fenêtre modale sans
|
parent=self)
|
||||||
# personne devant l'écran laisserait l'application figée. Le
|
self._probe_worker.done.connect(self._on_probe_reply)
|
||||||
# bouton « Saisir le code d'accès » prend le relais.
|
self._probe_worker.finished.connect(self._probe_worker.deleteLater)
|
||||||
self._add_log("Code d'accès requis — bouton « Saisir le code "
|
self._probe_worker.start()
|
||||||
"d'accès »", "warning")
|
|
||||||
|
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
|
return
|
||||||
self._open_access_dialog()
|
|
||||||
|
# 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
|
||||||
|
self._add_log("Code d'accès requis — bouton « Saisir le code "
|
||||||
|
"d'accès »", "warning")
|
||||||
|
self._refresh_status()
|
||||||
|
|
||||||
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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user