diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce8f7d..2b2bc02 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.7] — 2026-09-07 + +### Corrigé +- **L'application avortait à l'arrêt (`QThread: Destroyed while thread is still running`, code 134).** `prepare_quit()` attend la fin des threads en cours avant de laisser détruire quoi que ce soit, mais sa liste en oubliait deux. Le premier est le sondage d'état ajouté en 0.8.6 — une régression introduite par cette version. Le second est le thread de validation de la fenêtre de saisie, parenté à cette fenêtre depuis la v0.8.2 et jamais attendu : il suffisait de fermer la fenêtre de code pendant qu'une requête était en vol, ce qui arrive dès que le serveur tarde à répondre. Les deux sont désormais attendus, le second via `AccessCodeDialog.wait_for_worker()`. Reproduit puis vérifié avec un serveur qui accepte les connexions sans jamais répondre. +- Le sondage d'état est borné à **4 secondes** au lieu des 10 de la validation d'un code. Sans cela, le correctif ci-dessus transformait le plantage en interface figée le temps du délai d'expiration : l'arrêt attend ce thread. Un sondage qui échoue ne coûte rien — le bouton reste disponible — là où une validation ratée gâche un code déjà saisi. + +### Ajouté +- **Temps restant sur la ligne *Accès distant*** du panneau Détails : « Ouvert ✓ jusqu'à 08/09 à 09:56 (reste 7 h 12 min) ». La date n'est rappelée que si l'échéance tombe un autre jour — la durée est réglable jusqu'à 168 h côté serveur. Le décompte est arrondi à la minute et non tronqué, sans quoi une fenêtre de 8 h fraîchement ouverte s'affichait « 7 h 59 min ». Rafraîchi au rythme du sondage de statut existant, sans minuteur supplémentaire. +- **Repli pour les serveurs antérieurs à la v0.3.0**, qui n'exposent pas `GET /api/session`. Face à un 404 — et à un 404 seulement — le client observe si le trafic atteint réellement le réseau distant, en sondant le serveur DNS du split-DNS déjà configuré. La quarantaine jette le trafic traversant : toute réponse de cet hôte prouve qu'elle est levée, un refus de connexion compris (le paquet a atteint la machine). L'accès est alors affiché ouvert, mais sans échéance : la sonde constate, elle ne peut pas dater. Un 403 reste une réponse qui fait autorité et n'est jamais contredite par la sonde ; sans `split_dns_server` configuré, aucun repli. + +--- + ## [0.8.6] — 2026-09-07 ### Corrigé diff --git a/Makefile b/Makefile index 338a85d..33f8bf2 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ ## ────────────────────────────────────────────── APP := wgsecure -VERSION := 0.8.6 +VERSION := 0.8.7 VENV := .venv PYTHON := $(VENV)/bin/python3 PIP := $(VENV)/bin/pip diff --git a/README.md b/README.md index 31ce253..d3ebc45 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -🚀 v0.8.6 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard +🚀 v0.8.7 · 🐍 · 🪟🐧 · 🔐 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 2d4b1a3..4e16936 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.8.6" +__version__ = "0.8.7" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/core/vpn_session.py b/app/core/vpn_session.py index 1b95cac..3f709e3 100644 --- a/app/core/vpn_session.py +++ b/app/core/vpn_session.py @@ -22,6 +22,7 @@ from __future__ import annotations import ipaddress import json +import socket import urllib.error import urllib.request from datetime import datetime, timezone @@ -30,6 +31,15 @@ from typing import NamedTuple from app.core.config import Config _TIMEOUT = 10 +# Le sondage d'état est borné plus court que la validation d'un code : il part +# tout seul au montage du tunnel, et l'arrêt de l'application l'attend avant de +# se terminer — dix secondes d'attente y seraient une interface figée. Son +# échec ne coûte rien (le bouton reste disponible), là où une validation ratée +# gâche un code déjà saisi. +_PROBE_TIMEOUT = 4 +# Sonde d'accessibilité du réseau distant : un aller-retour sur le LAN passé +# le tunnel, deux secondes suffisent largement — au-delà, c'est filtré. +_REACH_TIMEOUT = 2 class AuthResult(NamedTuple): @@ -86,6 +96,33 @@ def api_base_url(cfg: Config) -> str: return f"http://{gateway}:8000" +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 + `GET /api/session` : sans cette route le client ne peut pas *demander* + son état, il ne lui reste qu'à l'observer. + + La cible est le serveur DNS du split-DNS, qui vit sur le réseau distant : + la quarantaine jette le trafic *traversant* (chaîne FORWARD), donc une + réponse quelconque de sa part prouve que la quarantaine est levée. Un + refus de connexion en est une : le paquet a atteint l'hôte, qui a répondu + par un RST. Seul un silence signifie « filtré ». + + Renvoie False dès qu'aucune cible n'est configurée : sans point de + comparaison, on n'observe rien et on ne conclut rien. + """ + target = (cfg.wg.get("split_dns_server") or "").strip() + if not target: + return False + try: + socket.create_connection((target, 53), timeout=_REACH_TIMEOUT).close() + return True + except ConnectionRefusedError: + return True # l'hôte a répondu : le trafic passe + except OSError: + return False # silence, ou réseau injoignable + def session_status(cfg: Config) -> AuthResult: """Demande au serveur si l'accès de ce poste est déjà ouvert. @@ -109,7 +146,7 @@ def session_status(cfg: Config) -> AuthResult: request = urllib.request.Request(f"{base}/api/session", method="GET") try: - with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: + with urllib.request.urlopen(request, timeout=_PROBE_TIMEOUT) as response: body = json.loads(response.read().decode("utf-8")) until = _parse_until(body.get("access_until")) if until is None: @@ -121,6 +158,15 @@ def session_status(cfg: Config) -> AuthResult: detail = json.loads(exc.read().decode("utf-8")).get("detail", "") except (ValueError, OSError): detail = "" + 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 + # n'a pas de quoi la dire. Mieux vaut un décompte absent qu'un + # bouton qui réclame un code déjà validé. + return AuthResult(True, "Accès réseau ouvert (constaté sur le " + "réseau ; serveur trop ancien pour " + "l'annoncer)") 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 : diff --git a/app/ui/access_code_dialog.py b/app/ui/access_code_dialog.py index 89cf607..1024854 100644 --- a/app/ui/access_code_dialog.py +++ b/app/ui/access_code_dialog.py @@ -234,6 +234,22 @@ class AccessCodeDialog(QDialog): self._btn_ok.setText("Vérification…" if busy else "Valider") self._code_input.setEnabled(not busy) + def wait_for_worker(self, msecs: int) -> None: + """Attend la fin de la requête en cours, s'il y en a une. + + Le worker est parenté à cette fenêtre : la détruire pendant qu'il + tourne encore avorte le process (« QThread: Destroyed while thread is + still running »). La fenêtre survit à sa propre fermeture — elle est + parentée à la fenêtre principale — donc le moment critique est l'arrêt + de l'application, qui appelle ceci avant que quoi que ce soit ne soit + détruit. + """ + try: + if self._worker is not None and self._worker.isRunning(): + self._worker.wait(msecs) + except RuntimeError: + pass # objet Qt déjà détruit + def message(self) -> str: return self._message diff --git a/app/ui/main_window.py b/app/ui/main_window.py index a0215f8..faeaae5 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -52,6 +52,9 @@ class MainWindow(QMainWindow): # déclenché sans invite est déjà parti. self._probe_worker: ValueWorker | None = None self._probe_prompt = False + # 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 self._reconnect_failures = 0 self._quitting = False self._warned_foreign: set[str] = set() @@ -621,6 +624,24 @@ class MainWindow(QMainWindow): return True return datetime.now(timezone.utc) < self._access_until + def _remaining_text(self) -> str: + """Temps restant avant l'échéance, tel qu'on le lit d'un coup d'œil. + + Rafraîchi au rythme du sondage de statut (3 s) : la minute affichée + est donc juste, ce qui suffit pour une fenêtre qui se compte en + heures. Aucun minuteur supplémentaire pour l'entretenir. + """ + seconds = int((self._access_until + - datetime.now(timezone.utc)).total_seconds()) + # Arrondi à la minute, et non tronqué : une fenêtre de 8 h qui vient + # de s'ouvrir doit se lire « 8 h 00 min », pas « 7 h 59 min ». + minutes = (seconds + 30) // 60 + if minutes >= 60: + return f"{minutes // 60} h {minutes % 60:02d} min" + if minutes >= 1: + return f"{minutes} min" + return f"{max(seconds, 0)} s" + def _access_state(self) -> str: """État de l'autorisation réseau accordée par le serveur. @@ -639,7 +660,13 @@ class MainWindow(QMainWindow): if self._access_valid(): if self._access_until is None: return "Ouvert ✓" - return f"Ouvert ✓ jusqu'à {self._access_until.astimezone():%H:%M}" + fin = self._access_until.astimezone() + # La date n'est rappelée que si l'échéance n'est pas aujourd'hui : + # la durée est réglable jusqu'à 168 h côté serveur, « jusqu'à + # 02:30 » serait alors ambigu. + quand = (f"{fin:%H:%M}" if fin.date() == datetime.now().date() + else f"{fin:%d/%m à %H:%M}") + return f"Ouvert ✓ jusqu'à {quand} (reste {self._remaining_text()})" if self._access_open: # L'autorisation a couru puis expiré : le dire, plutôt que de # laisser croire qu'aucun code n'a jamais été saisi. @@ -902,6 +929,7 @@ class MainWindow(QMainWindow): def _open_access_dialog(self): """Ouvre la fenêtre de saisie et journalise la réponse du serveur.""" dlg = AccessCodeDialog(self._cfg, self) + self._access_dialog = dlg accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted self._access_open = accepted self._access_until = dlg.access_until() if accepted else None @@ -1035,14 +1063,21 @@ 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): + for worker in (self._worker, self._ping_worker, self._probe_worker): try: if worker is not None and worker.isRunning(): worker.wait(15000) except RuntimeError: pass # objet Qt déjà détruit + if self._access_dialog is not None: + # Une validation peut être partie puis la fenêtre fermée aussitôt : + # son thread survit à la fermeture et ne doit pas être détruit avec + # la fenêtre principale. + self._access_dialog.wait_for_worker(15000) + self._access_dialog = None self._worker = None self._ping_worker = None + self._probe_worker = None if self._session_id is not None: hist.end_session(self._session_id, self._prev_rx or 0, diff --git a/wgsecure.iss b/wgsecure.iss index 181a0b5..c0d6bdc 100644 --- a/wgsecure.iss +++ b/wgsecure.iss @@ -5,7 +5,7 @@ ; ────────────────────────────────────────────── #define MyAppName "WGSecure" -#define MyAppVersion "0.8.6" +#define MyAppVersion "0.8.7" #define MyAppPublisher "WGSecure" #define MyAppExeName "wgsecure.exe"