fix(ui): le bouton de saisie du code reparaît à l'expiration de l'accès (v0.8.5)
L'autorisation obtenue auprès du serveur a un terme, mais le client n'en tenait aucun compte : `access_until`, pourtant renvoyé à chaque validation, ne servait qu'à composer une ligne de journal. L'accès était marqué ouvert jusqu'à la chute du tunnel, si bien qu'une fois le délai écoulé l'interface affichait « Ouvert ✓ » et masquait le bouton comme l'entrée de systray — laissant l'utilisateur devant un réseau muet, sans issue visible, dans la situation même que ce bouton existe pour couvrir depuis la v0.8.2. L'échéance remonte désormais jusqu'à la fenêtre principale, qui la relit sans jamais la calculer : sa durée est un réglage serveur, que le poste n'a pas à deviner. Un serveur qui n'en annonce pas laisse l'accès ouvert jusqu'à la déconnexion, plutôt que de se voir imposer un terme inventé ici. Corrige au passage l'heure de fin, annoncée en UTC mais présentée comme locale — deux heures d'avance en été. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,20 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
|
||||
|
||||
---
|
||||
|
||||
## [0.8.5] — 2026-09-07
|
||||
|
||||
### Corrigé
|
||||
- **Le bouton « Saisir le code d'accès » restait masqué une fois l'autorisation expirée.** L'application marquait l'accès comme ouvert à la validation du code et ne revenait sur ce constat qu'à la chute du tunnel : rien ne consommait l'échéance que le serveur renvoie pourtant dans sa réponse, elle ne servait qu'à composer une phrase du journal. Passé le délai — un réglage serveur, invisible du poste — le pair retournait en quarantaine sans que l'interface en sache rien : elle affichait toujours « Ouvert ✓ », masquait le bouton et l'entrée de systray, et laissait l'utilisateur devant un réseau muet sans moyen visible de ressaisir un code. C'est exactement la situation que ce bouton existe pour éviter depuis la v0.8.2, et le seul cas qui lui échappait encore. Le client relit désormais l'échéance annoncée par le serveur et réévalue l'affichage à chaque rafraîchissement de statut, soit dans les trois secondes suivant l'expiration.
|
||||
- **L'heure de fin d'accès était annoncée en UTC mais présentée comme locale** : « ouvert jusqu'à 20:30 » pour une autorisation courant en réalité jusqu'à 22:30 heure de Paris, deux heures d'avance en été. L'horodatage est maintenant converti dans le fuseau du poste.
|
||||
|
||||
### Modifié
|
||||
- Nouvel état **« Autorisation expirée (code requis) »** sur la ligne *Accès distant*, distinct de « Quarantaine (code requis) » : l'utilisateur voit que son code a bien été accepté et que le délai a couru, plutôt que de croire que sa saisie n'a jamais abouti.
|
||||
- Quand l'accès est ouvert, la ligne indique l'heure de fin (« Ouvert ✓ jusqu'à 22:30 »), l'échéance n'étant plus une information jetée après affichage.
|
||||
- `vpn_session.authenticate()` retourne un `AuthResult(ok, message, access_until)` au lieu d'un couple `(bool, str)`, et la fenêtre de saisie passe de `TaskWorker` à `ValueWorker` : le premier n'achemine que `(bool, str, str)`, une signature partagée par tous les autres appels de l'application et qu'il aurait fallu élargir pour tout le monde.
|
||||
- Un serveur qui n'annonce aucune échéance laisse l'autorisation valable jusqu'à la déconnexion : le client ne lui en invente pas une, ce qui fermerait un accès encore ouvert. La durée reste décidée par le serveur, seule autorité en la matière.
|
||||
|
||||
---
|
||||
|
||||
## [0.8.4] — 2026-09-06
|
||||
|
||||
### Corrigé
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
## ──────────────────────────────────────────────
|
||||
|
||||
APP := wgsecure
|
||||
VERSION := 0.8.4
|
||||
VERSION := 0.8.5
|
||||
VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python3
|
||||
PIP := $(VENV)/bin/pip
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🛡️ WGSecure (WGS)
|
||||
|
||||
🚀 v0.8.4 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
|
||||
🚀 v0.8.5 · 🐍 · 🪟🐧 · 🔐 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**.
|
||||
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
__version__ = "0.8.4"
|
||||
__version__ = "0.8.5"
|
||||
APP_NAME = "WGSecure"
|
||||
APP_SHORT = "WGS"
|
||||
|
||||
+52
-11
@@ -24,12 +24,46 @@ import ipaddress
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from typing import NamedTuple
|
||||
|
||||
from app.core.config import Config
|
||||
|
||||
_TIMEOUT = 10
|
||||
|
||||
|
||||
class AuthResult(NamedTuple):
|
||||
"""Réponse du serveur à une demande d'ouverture d'accès.
|
||||
|
||||
`access_until` porte l'échéance telle que le serveur l'annonce — il en est
|
||||
la seule autorité, le client la relit sans jamais la calculer. `None`
|
||||
quand aucune échéance n'est annoncée : l'accès est alors ouvert sans
|
||||
terme connu, et le client s'abstient d'en inventer un.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
message: str
|
||||
access_until: datetime | None = None
|
||||
|
||||
|
||||
def _parse_until(raw: object) -> datetime | None:
|
||||
"""Lit l'échéance renvoyée par le serveur, en UTC.
|
||||
|
||||
Une valeur absente ou illisible ne vaut pas refus : l'accès vient d'être
|
||||
ouvert. Elle rend seulement l'échéance inconnue, cas déjà prévu.
|
||||
"""
|
||||
if not isinstance(raw, str) or not raw:
|
||||
return None
|
||||
try:
|
||||
moment = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
# Le serveur date en UTC ; sans fuseau explicite on l'y suppose, plutôt
|
||||
# que de prendre l'horodatage pour une heure locale et de décaler
|
||||
# l'échéance de l'offset du poste.
|
||||
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def api_base_url(cfg: Config) -> str:
|
||||
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
|
||||
|
||||
@@ -52,10 +86,10 @@ def api_base_url(cfg: Config) -> str:
|
||||
return f"http://{gateway}:8000"
|
||||
|
||||
|
||||
def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]:
|
||||
def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
|
||||
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
|
||||
|
||||
Retourne (succès, message). Un échec n'est jamais silencieux : sans
|
||||
Retourne un `AuthResult`. Un échec n'est jamais silencieux : sans
|
||||
cette validation le tunnel reste monté mais ne mène nulle part, et
|
||||
l'utilisateur doit comprendre pourquoi plutôt que de constater un
|
||||
réseau inexplicablement muet.
|
||||
@@ -68,12 +102,13 @@ def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]
|
||||
"""
|
||||
base = api_base_url(cfg)
|
||||
if not base:
|
||||
return False, ("Adresse du serveur d'authentification inconnue. "
|
||||
"Renseignez-la dans le panneau Administrateur.")
|
||||
return AuthResult(False, "Adresse du serveur d'authentification "
|
||||
"inconnue. Renseignez-la dans le panneau "
|
||||
"Administrateur.")
|
||||
|
||||
username = (username or cfg.wg.get("vpn_username") or "").strip()
|
||||
if not username:
|
||||
return False, "Indiquez votre identifiant VPN."
|
||||
return AuthResult(False, "Indiquez votre identifiant VPN.")
|
||||
|
||||
payload = json.dumps({"username": username, "code": code}).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
@@ -83,8 +118,13 @@ def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
until = (body.get("access_until") or "").replace("T", " ")[:16]
|
||||
return True, f"Accès réseau ouvert jusqu'à {until}" if until else "Accès réseau ouvert"
|
||||
# Affichée dans le fuseau du poste : l'échéance arrive en UTC, et la
|
||||
# présenter telle quelle annonçait une heure fausse à l'utilisateur.
|
||||
until = _parse_until(body.get("access_until"))
|
||||
if until is None:
|
||||
return AuthResult(True, "Accès réseau ouvert")
|
||||
return AuthResult(True, "Accès réseau ouvert jusqu'à "
|
||||
f"{until.astimezone():%d/%m/%Y %H:%M}", until)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Le serveur explique lui-même le refus (code invalide, compte
|
||||
# verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un
|
||||
@@ -93,9 +133,10 @@ def authenticate(cfg: Config, code: str, username: str = "") -> tuple[bool, str]
|
||||
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
||||
except (ValueError, OSError):
|
||||
detail = ""
|
||||
return False, detail or f"Authentification refusée (code {exc.code})"
|
||||
return AuthResult(False, detail
|
||||
or f"Authentification refusée (code {exc.code})")
|
||||
except urllib.error.URLError as exc:
|
||||
return False, (f"Serveur d'authentification injoignable ({base}) : "
|
||||
f"{exc.reason}")
|
||||
return AuthResult(False, f"Serveur d'authentification injoignable "
|
||||
f"({base}) : {exc.reason}")
|
||||
except (OSError, ValueError) as exc:
|
||||
return False, f"Échec de l'authentification : {exc}"
|
||||
return AuthResult(False, f"Échec de l'authentification : {exc}")
|
||||
|
||||
@@ -5,7 +5,7 @@ secret stocké sur le poste : elle ne verrouillait alors que sa propre
|
||||
interface. Ici le code part au serveur, seul détenteur du secret et seul
|
||||
point où l'accès est réellement ouvert ou refusé.
|
||||
|
||||
La requête part dans un `TaskWorker` : elle peut attendre jusqu'à dix
|
||||
La requête part dans un `ValueWorker` : elle peut attendre jusqu'à dix
|
||||
secondes si le serveur ne répond pas, et une fenêtre modale figée pendant
|
||||
ce temps donnerait l'impression d'une application plantée.
|
||||
"""
|
||||
@@ -24,7 +24,7 @@ from PyQt6.QtGui import QFont
|
||||
from app.core import vpn_session
|
||||
from app.core.config import Config
|
||||
from app.ui import theme
|
||||
from app.ui.worker import TaskWorker
|
||||
from app.ui.worker import ValueWorker
|
||||
|
||||
_TOTP_PERIOD = 30
|
||||
|
||||
@@ -34,13 +34,17 @@ class AccessCodeDialog(QDialog):
|
||||
|
||||
`message()` porte la réponse du serveur — succès comme refus — pour que
|
||||
l'appelant la journalise telle quelle plutôt qu'un message générique.
|
||||
`access_until()` porte l'échéance de l'autorisation obtenue : sans elle,
|
||||
l'appelant croirait l'accès ouvert indéfiniment et masquerait le moyen
|
||||
d'en redemander un une fois le délai écoulé.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: Config, parent=None):
|
||||
super().__init__(parent)
|
||||
self._cfg = cfg
|
||||
self._worker: TaskWorker | None = None
|
||||
self._worker: ValueWorker | None = None
|
||||
self._message = ""
|
||||
self._access_until = None
|
||||
self._username = ""
|
||||
self.setWindowTitle("WGSecure — Code d'accès")
|
||||
self.setFixedWidth(360)
|
||||
@@ -194,16 +198,22 @@ class AccessCodeDialog(QDialog):
|
||||
|
||||
self._error_label.setText("")
|
||||
self._set_busy(True)
|
||||
self._worker = TaskWorker(vpn_session.authenticate, self._cfg, raw,
|
||||
self._username, tag="auth", parent=self)
|
||||
self._worker = ValueWorker(vpn_session.authenticate, self._cfg, raw,
|
||||
self._username, parent=self)
|
||||
self._worker.done.connect(self._on_reply)
|
||||
self._worker.finished.connect(self._worker.deleteLater)
|
||||
self._worker.start()
|
||||
|
||||
def _on_reply(self, ok: bool, message: str, _tag: str):
|
||||
def _on_reply(self, result):
|
||||
self._worker = None
|
||||
self._message = message
|
||||
if ok:
|
||||
if result is None:
|
||||
# `ValueWorker` absorbe une exception imprévue en rendant None :
|
||||
# dire alors que l'échec vient d'ici, et non d'un refus serveur.
|
||||
result = vpn_session.AuthResult(
|
||||
False, "Échec inattendu de l'authentification.")
|
||||
self._message = result.message
|
||||
self._access_until = result.access_until
|
||||
if result.ok:
|
||||
# Enregistré seulement maintenant : un identifiant que le serveur
|
||||
# a refusé n'a rien à faire dans la configuration.
|
||||
if self._username:
|
||||
@@ -212,7 +222,7 @@ class AccessCodeDialog(QDialog):
|
||||
self.accept()
|
||||
return
|
||||
self._set_busy(False)
|
||||
self._fail(message)
|
||||
self._fail(result.message)
|
||||
self._code_input.clear()
|
||||
|
||||
def _fail(self, message: str):
|
||||
@@ -227,6 +237,10 @@ class AccessCodeDialog(QDialog):
|
||||
def message(self) -> str:
|
||||
return self._message
|
||||
|
||||
def access_until(self):
|
||||
"""Échéance annoncée par le serveur, ou None s'il n'en annonce pas."""
|
||||
return self._access_until
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._timer.stop()
|
||||
super().closeEvent(event)
|
||||
|
||||
+38
-2
@@ -1,4 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
@@ -38,6 +41,10 @@ class MainWindow(QMainWindow):
|
||||
self._ping_worker: ValueWorker | None = None
|
||||
self._is_connected = False
|
||||
self._access_open = False
|
||||
# Échéance annoncée par le serveur pour l'autorisation en cours. None
|
||||
# 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
|
||||
self._reconnect_failures = 0
|
||||
self._quitting = False
|
||||
self._warned_foreign: set[str] = set()
|
||||
@@ -592,6 +599,21 @@ class MainWindow(QMainWindow):
|
||||
# ------------------------------------------------------------------ #
|
||||
# Statut WireGuard
|
||||
# ------------------------------------------------------------------ #
|
||||
def _access_valid(self) -> bool:
|
||||
"""Vrai tant que l'autorisation obtenue court encore.
|
||||
|
||||
L'échéance vient du serveur et de lui seul : le client la relit, il
|
||||
ne la calcule pas — sa durée est un réglage serveur, modifiable sans
|
||||
que le poste en sache rien. Un serveur qui n'annonce aucune échéance
|
||||
laisse l'autorisation valable jusqu'à la chute du tunnel, faute de
|
||||
terme connu ; en inventer un fermerait un accès encore ouvert.
|
||||
"""
|
||||
if not self._access_open:
|
||||
return False
|
||||
if self._access_until is None:
|
||||
return True
|
||||
return datetime.now(timezone.utc) < self._access_until
|
||||
|
||||
def _access_state(self) -> str:
|
||||
"""État de l'autorisation réseau accordée par le serveur.
|
||||
|
||||
@@ -607,8 +629,14 @@ class MainWindow(QMainWindow):
|
||||
"""
|
||||
if not self._is_connected:
|
||||
return "—"
|
||||
if self._access_open:
|
||||
if self._access_valid():
|
||||
if self._access_until is None:
|
||||
return "Ouvert ✓"
|
||||
return f"Ouvert ✓ jusqu'à {self._access_until.astimezone():%H:%M}"
|
||||
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.
|
||||
return "Autorisation expirée (code requis)"
|
||||
if (self._cfg.wg.get("vpn_username") or "").strip():
|
||||
return "Quarantaine (code requis)"
|
||||
return "Non vérifié"
|
||||
@@ -642,6 +670,7 @@ class MainWindow(QMainWindow):
|
||||
# Tunnel tombé : l'autorisation obtenue auprès du serveur ne vaut
|
||||
# que pour l'adresse du pair, elle ne survit pas à la déconnexion.
|
||||
self._access_open = False
|
||||
self._access_until = None
|
||||
self._info_labels["access"].setText(self._access_state())
|
||||
|
||||
# Le bouton n'apparaît que lorsqu'il y a réellement un code à saisir ;
|
||||
@@ -759,6 +788,12 @@ class MainWindow(QMainWindow):
|
||||
Deux conditions seulement : un tunnel monté — l'API du serveur n'est
|
||||
joignable que par l'intérieur — et aucune autorisation en cours.
|
||||
|
||||
« En cours » et non « obtenue » : une autorisation a un terme, fixé
|
||||
par le serveur. Passé celui-ci le pair retourne en quarantaine sans
|
||||
que rien ne bouge côté poste, et le bouton doit reparaître — sans
|
||||
quoi l'utilisateur retrouve un réseau muet et un bouton masqué,
|
||||
exactement la situation que ce bouton existe pour éviter.
|
||||
|
||||
L'identifiant VPN n'en fait volontairement pas partie. Il en faisait
|
||||
partie, et c'était un piège : un poste bel et bien tenu en quarantaine
|
||||
par le serveur, mais dont ce champ n'avait jamais été renseigné,
|
||||
@@ -766,7 +801,7 @@ class MainWindow(QMainWindow):
|
||||
situation où il en avait besoin. La fenêtre de saisie demande
|
||||
l'identifiant quand il manque.
|
||||
"""
|
||||
return self._is_connected and not self._access_open
|
||||
return self._is_connected and not self._access_valid()
|
||||
|
||||
def request_access_code(self):
|
||||
"""Ouvre la saisie du code, à la demande de l'utilisateur.
|
||||
@@ -810,6 +845,7 @@ class MainWindow(QMainWindow):
|
||||
dlg = AccessCodeDialog(self._cfg, self)
|
||||
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
|
||||
self._access_open = accepted
|
||||
self._access_until = dlg.access_until() if accepted else None
|
||||
if accepted:
|
||||
self._add_log(dlg.message(), "success")
|
||||
else:
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
; ──────────────────────────────────────────────
|
||||
|
||||
#define MyAppName "WGSecure"
|
||||
#define MyAppVersion "0.8.4"
|
||||
#define MyAppVersion "0.8.5"
|
||||
#define MyAppPublisher "WGSecure"
|
||||
#define MyAppExeName "wgsecure.exe"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user