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:
2026-09-07 17:38:03 +02:00
co-authored by Claude Opus 5
parent 9083525634
commit 4cf3e26dff
8 changed files with 131 additions and 26 deletions
+23 -9
View File
@@ -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
View File
@@ -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_valid():
if self._access_until is None:
return "Ouvert ✓"
return f"Ouvert ✓ jusqu'à {self._access_until.astimezone():%H:%M}"
if self._access_open:
return "Ouvert ✓"
# 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: