diff --git a/CHANGELOG.md b/CHANGELOG.md index f889837..6ce8f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Corrigé diff --git a/Makefile b/Makefile index 8d128d3..338a85d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.8.5 +VERSION := 0.8.6 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip diff --git a/README.md b/README.md index 206883d..31ce253 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ 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**. diff --git a/app/__init__.py b/app/__init__.py index bbfcc78..2d4b1a3 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.8.5" +__version__ = "0.8.6" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/core/vpn_session.py b/app/core/vpn_session.py index a45bffb..1b95cac 100644 --- a/app/core/vpn_session.py +++ b/app/core/vpn_session.py @@ -86,6 +86,48 @@ def api_base_url(cfg: Config) -> str: 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: """Valide un code TOTP auprès du serveur et ouvre l'accès réseau. diff --git a/app/ui/main_window.py b/app/ui/main_window.py index e267ca3..a0215f8 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -18,6 +18,7 @@ from app.core import dns as dns_util 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.ui.worker import TaskWorker, ValueWorker from app.ui.access_code_dialog import AccessCodeDialog 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 # un serveur qui n'en annonce pas — et jamais « expire maintenant ». 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._quitting = False self._warned_foreign: set[str] = set() @@ -663,12 +670,23 @@ class MainWindow(QMainWindow): except Exception: connected = False info = {} + was_connected = self._is_connected self._is_connected = connected if connected: 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: - # Tunnel tombé : l'autorisation obtenue auprès du serveur ne vaut - # que pour l'adresse du pair, elle ne survit pas à la déconnexion. + # Tunnel tombé : le client oublie ce qu'il croyait savoir. Le + # 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_until = None 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, 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 - 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 " - "d'accès »", "warning") + 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 - 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): """Ouvre la fenêtre de saisie et journalise la réponse du serveur.""" diff --git a/wgsecure.iss b/wgsecure.iss index 4791e3e..181a0b5 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.8.5" +#define MyAppVersion "0.8.6" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"