fix(ui): plus d'abort à l'arrêt, et temps restant de l'accès (v0.8.7)
prepare_quit() attend les threads en cours avant toute destruction, mais sa liste en oubliait deux : le sondage d'état ajouté en 0.8.6 — ma régression — et le thread de validation de la fenêtre de saisie, parenté à cette fenêtre depuis la v0.8.2 et jamais attendu. Fermer la fenêtre de code pendant qu'une requête est en vol suffisait à avorter le process, ce qui arrive dès que le serveur tarde. Reproduit avec un serveur qui accepte sans jamais répondre, puis vérifié corrigé sur les deux chemins. Le sondage est ramené à 4 s : l'arrêt l'attend désormais, et dix secondes y seraient une interface figée. Son échec ne coûte rien, contrairement à une validation ratée qui gâche un code déjà saisi. Ajoute le temps restant sur la ligne Accès distant, et un repli pour les serveurs sans GET /api/session : sur un 404, le client observe si le trafic atteint le réseau distant plutôt que de réclamer un code déjà validé. Un 403 reste une réponse qui fait autorité. 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.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
|
## [0.8.6] — 2026-09-07
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
## ──────────────────────────────────────────────
|
## ──────────────────────────────────────────────
|
||||||
|
|
||||||
APP := wgsecure
|
APP := wgsecure
|
||||||
VERSION := 0.8.6
|
VERSION := 0.8.7
|
||||||
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.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**.
|
**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.6"
|
__version__ = "0.8.7"
|
||||||
APP_NAME = "WGSecure"
|
APP_NAME = "WGSecure"
|
||||||
APP_SHORT = "WGS"
|
APP_SHORT = "WGS"
|
||||||
|
|||||||
+47
-1
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -30,6 +31,15 @@ from typing import NamedTuple
|
|||||||
from app.core.config import Config
|
from app.core.config import Config
|
||||||
|
|
||||||
_TIMEOUT = 10
|
_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):
|
class AuthResult(NamedTuple):
|
||||||
@@ -86,6 +96,33 @@ def api_base_url(cfg: Config) -> str:
|
|||||||
return f"http://{gateway}:8000"
|
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:
|
def session_status(cfg: Config) -> AuthResult:
|
||||||
"""Demande au serveur si l'accès de ce poste est déjà ouvert.
|
"""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")
|
request = urllib.request.Request(f"{base}/api/session", method="GET")
|
||||||
try:
|
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"))
|
body = json.loads(response.read().decode("utf-8"))
|
||||||
until = _parse_until(body.get("access_until"))
|
until = _parse_until(body.get("access_until"))
|
||||||
if until is None:
|
if until is None:
|
||||||
@@ -121,6 +158,15 @@ def session_status(cfg: Config) -> AuthResult:
|
|||||||
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
||||||
except (ValueError, OSError):
|
except (ValueError, OSError):
|
||||||
detail = ""
|
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})")
|
return AuthResult(False, detail or f"Accès fermé (code {exc.code})")
|
||||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
except (urllib.error.URLError, OSError, ValueError) as exc:
|
||||||
# Serveur ancien (route absente), injoignable, ou réponse illisible :
|
# Serveur ancien (route absente), injoignable, ou réponse illisible :
|
||||||
|
|||||||
@@ -234,6 +234,22 @@ class AccessCodeDialog(QDialog):
|
|||||||
self._btn_ok.setText("Vérification…" if busy else "Valider")
|
self._btn_ok.setText("Vérification…" if busy else "Valider")
|
||||||
self._code_input.setEnabled(not busy)
|
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:
|
def message(self) -> str:
|
||||||
return self._message
|
return self._message
|
||||||
|
|
||||||
|
|||||||
+37
-2
@@ -52,6 +52,9 @@ class MainWindow(QMainWindow):
|
|||||||
# déclenché sans invite est déjà parti.
|
# déclenché sans invite est déjà parti.
|
||||||
self._probe_worker: ValueWorker | None = None
|
self._probe_worker: ValueWorker | None = None
|
||||||
self._probe_prompt = False
|
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._reconnect_failures = 0
|
||||||
self._quitting = False
|
self._quitting = False
|
||||||
self._warned_foreign: set[str] = set()
|
self._warned_foreign: set[str] = set()
|
||||||
@@ -621,6 +624,24 @@ class MainWindow(QMainWindow):
|
|||||||
return True
|
return True
|
||||||
return datetime.now(timezone.utc) < self._access_until
|
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:
|
def _access_state(self) -> str:
|
||||||
"""État de l'autorisation réseau accordée par le serveur.
|
"""É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_valid():
|
||||||
if self._access_until is None:
|
if self._access_until is None:
|
||||||
return "Ouvert ✓"
|
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:
|
if self._access_open:
|
||||||
# L'autorisation a couru puis expiré : le dire, plutôt que de
|
# L'autorisation a couru puis expiré : le dire, plutôt que de
|
||||||
# laisser croire qu'aucun code n'a jamais été saisi.
|
# laisser croire qu'aucun code n'a jamais été saisi.
|
||||||
@@ -902,6 +929,7 @@ class MainWindow(QMainWindow):
|
|||||||
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."""
|
||||||
dlg = AccessCodeDialog(self._cfg, self)
|
dlg = AccessCodeDialog(self._cfg, self)
|
||||||
|
self._access_dialog = dlg
|
||||||
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
|
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
|
||||||
self._access_open = accepted
|
self._access_open = accepted
|
||||||
self._access_until = dlg.access_until() if accepted else None
|
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 :
|
# 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.
|
# 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:
|
try:
|
||||||
if worker is not None and worker.isRunning():
|
if worker is not None and worker.isRunning():
|
||||||
worker.wait(15000)
|
worker.wait(15000)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass # objet Qt déjà détruit
|
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._worker = None
|
||||||
self._ping_worker = None
|
self._ping_worker = None
|
||||||
|
self._probe_worker = None
|
||||||
|
|
||||||
if self._session_id is not None:
|
if self._session_id is not None:
|
||||||
hist.end_session(self._session_id, self._prev_rx or 0,
|
hist.end_session(self._session_id, self._prev_rx or 0,
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
; ──────────────────────────────────────────────
|
; ──────────────────────────────────────────────
|
||||||
|
|
||||||
#define MyAppName "WGSecure"
|
#define MyAppName "WGSecure"
|
||||||
#define MyAppVersion "0.8.6"
|
#define MyAppVersion "0.8.7"
|
||||||
#define MyAppPublisher "WGSecure"
|
#define MyAppPublisher "WGSecure"
|
||||||
#define MyAppExeName "wgsecure.exe"
|
#define MyAppExeName "wgsecure.exe"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user