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:
2026-09-07 22:33:41 +02:00
co-authored by Claude Opus 5
parent 306e512aab
commit a056ad260a
8 changed files with 116 additions and 7 deletions
+47 -1
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import ipaddress
import json
import socket
import urllib.error
import urllib.request
from datetime import datetime, timezone
@@ -30,6 +31,15 @@ from typing import NamedTuple
from app.core.config import Config
_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):
@@ -86,6 +96,33 @@ def api_base_url(cfg: Config) -> str:
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:
"""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")
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"))
until = _parse_until(body.get("access_until"))
if until is None:
@@ -121,6 +158,15 @@ def session_status(cfg: Config) -> AuthResult:
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
except (ValueError, OSError):
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})")
except (urllib.error.URLError, OSError, ValueError) as exc:
# Serveur ancien (route absente), injoignable, ou réponse illisible :