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
+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."""