fix(ui): ne plus réclamer un code déjà validé après une reconnexion (v0.8.6)

Le client tenait pour acquis que l'autorisation ne survivait pas à la chute
du tunnel — un commentaire l'affirmait noir sur blanc. 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. Se reconnecter
ne révoque rien ; seul le client oubliait, et redemandait un code que le
serveur avait déjà accepté.

Il pose maintenant la question au lieu de la supposer, via GET /api/session
(serveur >= 0.3.0), dès que le tunnel monte : démarrage sur un tunnel déjà
actif, reconnexion automatique, ou avant toute invite de saisie. Le serveur
identifie l'appelant à l'IP source, aucun identifiant ne circule.

Serveur injoignable, réponse illisible ou serveur antérieur à la 0.3.0 ne
valent pas « accès ouvert » : sans information, le client ne conclut rien et
laisse le bouton disponible — le sens sûr, et le comportement de la 0.8.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 18:27:32 +02:00
co-authored by Claude Opus 5
parent 4cf3e26dff
commit 306e512aab
7 changed files with 127 additions and 14 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.8.5"
__version__ = "0.8.6"
APP_NAME = "WGSecure"
APP_SHORT = "WGS"
+42
View File
@@ -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.
+69 -10
View File
@@ -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."""