fix(auth): fiabilise la sortie de quarantaine

Audit du parcours complet — montage du tunnel, sondage de l'état, saisie
du code, ouverture de l'accès. Neuf défauts, dont plusieurs laissaient le
poste en quarantaine sans moyen visible d'en sortir : exactement ce que le
bouton « Saisir le code d'accès » existe pour éviter depuis la v0.8.2.

Adresse de l'API. api_base_url retranchait le dernier octet de l'adresse
du client, ce qui suppose un /24 et ignore les IPs autorisées. Or une
adresse client en /32 est la forme la plus courante et ne décrit aucun
réseau : un client en 10.6.1.7/32 sur un 10.6.0.0/16 était expédié vers
10.6.1.1, qui n'existe pas. La déduction passe désormais par
wireguard.tunnel_gateway, qui servait déjà au diagnostic et gère /32,
IPv6 et le repli sur les IPs autorisées. Une adresse IPv6 est encadrée de
crochets, sans quoi son « : » se confondait avec celui du port.

Révocation invisible. Le client ne sondait qu'au retour du tunnel : une
révocation avant terme — redémarrage du serveur, purge de sa table de
sessions, décision d'un administrateur — n'était annoncée par rien et
l'application affichait « Ouvert ✓ » devant un réseau muet, bouton
masqué, jusqu'à la chute du tunnel. Sans échéance annoncée, indéfiniment.
L'accès est reconfirmé chaque minute tant qu'il est cru ouvert.

Authentification perdue. « Plus tard » restait actif pendant la
vérification, Échap et la croix aussi. Fermer là rendait la main sur un
refus alors que le serveur pouvait encore accepter : sa réponse arrivait
après la sortie d'exec() et n'était lue par personne. L'accès était
ouvert côté serveur, l'application affichait la quarantaine, et le code
saisi était consommé pour rien. La fenêtre ne se ferme plus tant qu'une
requête est en vol.

Fenêtre survivante. Ni accept() ni reject() ne passent par closeEvent :
le minuteur de 500 ms continuait à battre après la fermeture, et la
fenêtre — parentée à la principale — survivait à sa disparition.
dispose() l'arrête et programme la destruction ; _access_dialog est remis
à None, son thread n'étant plus attendu à l'arrêt de l'application.

Repli 404 trompeur. La sonde d'accessibilité tenait pour preuve toute
réponse du serveur DNS du split-DNS, sans vérifier qu'il était joint par
le tunnel. Une adresse RFC1918 routée par le réseau local du poste
répondait donc hors tunnel, et le repli censé éviter le blocage le
provoquait : accès déclaré ouvert sans échéance, bouton masqué. La route
est vérifiée d'abord, sans émettre de paquet — connect() sur une socket
UDP suffit à connaître l'adresse source retenue par le noyau.

Proxy. urlopen emploie l'ouvreur par défaut, dont le ProxyHandler lit
http_proxy dans l'environnement : sur un poste d'entreprise la requête
vers l'adresse privée du tunnel partait au proxy, dont la réponse — 404
souvent — était prise pour celle du serveur, déclenchant le repli
ci-dessus. Panne intermittente parfaite : selon l'environnement de
lancement, ça passait ou non.

Silence pris pour un refus. Un ok faux couvrait « le serveur refuse » et
« le serveur n'a rien dit », traités pareil : un hoquet au montage du
tunnel faisait surgir la fenêtre et brûler un code alors que l'accès
était peut-être déjà ouvert. AuthResult porte désormais authoritative.
Un refus du serveur referme du premier coup, il en est seul juge ; un
silence déclenche un second sondage avant de déranger l'utilisateur, et
n'entame l'accès qu'après trois minutes sans réponse.

Annulation destructrice. _open_access_dialog écrivait l'état d'accès
quoi qu'il arrive. Un sondage abouti pendant que la fenêtre était ouverte
— les minuteries tournent dans la boucle imbriquée d'exec() — pouvait
établir que l'accès l'était, que « Plus tard » remettait aussitôt à
fermé. Renoncer à saisir un code ne referme rien côté serveur ; plus rien
ici non plus.

api_url. Le schéma est vérifié plutôt que repris tel quel : identifiant
et code partent dans cette URL. Reste ouvert, et hors de ce dépôt : l'API
par défaut est en clair et le serveur n'est pas authentifié.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tuxgyver
2026-09-08 09:27:36 +02:00
co-authored by Claude Opus 5
parent 205258cbf1
commit 556f22e7c9
4 changed files with 305 additions and 34 deletions
+111 -21
View File
@@ -29,6 +29,7 @@ from datetime import datetime, timezone
from typing import NamedTuple from typing import NamedTuple
from app.core.config import Config from app.core.config import Config
from app.core.wireguard import tunnel_gateway
_TIMEOUT = 10 _TIMEOUT = 10
# Le sondage d'état est borné plus court que la validation d'un code : il part # Le sondage d'état est borné plus court que la validation d'un code : il part
@@ -41,6 +42,16 @@ _PROBE_TIMEOUT = 4
# le tunnel, deux secondes suffisent largement — au-delà, c'est filtré. # le tunnel, deux secondes suffisent largement — au-delà, c'est filtré.
_REACH_TIMEOUT = 2 _REACH_TIMEOUT = 2
# Ouvreur sans proxy. `urllib.request.urlopen` emploie l'ouvreur par défaut,
# dont le `ProxyHandler` lit `http_proxy` / `HTTP_PROXY` dans l'environnement :
# sur un poste d'entreprise, la requête vers l'adresse privée du tunnel partait
# au proxy au lieu du serveur. Sa réponse — souvent un 404 — était prise pour
# celle du serveur, et un 404 déclenche justement le repli d'observation
# réseau. Symptôme parfait de panne intermittente : selon l'environnement de
# lancement, ça passait ou non. Le serveur d'authentification n'est de toute
# façon joignable que par l'intérieur du tunnel, jamais par un proxy.
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
class AuthResult(NamedTuple): class AuthResult(NamedTuple):
"""Réponse du serveur à une demande d'ouverture d'accès. """Réponse du serveur à une demande d'ouverture d'accès.
@@ -49,11 +60,20 @@ class AuthResult(NamedTuple):
la seule autorité, le client la relit sans jamais la calculer. `None` 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 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. terme connu, et le client s'abstient d'en inventer un.
`authoritative` dit si le serveur a réellement répondu. Un `ok` faux
couvrait deux situations très différentes — « le serveur refuse » et « le
serveur n'a rien dit » — que l'appelant traitait pareil : un hoquet réseau
au montage du tunnel faisait donc surgir la fenêtre de saisie et brûler un
code, alors que l'accès était peut-être déjà ouvert. Faux ici veut dire
« on ne sait pas » : serveur injoignable, réponse illisible, ou route
`/api/session` absente d'un serveur antérieur à la v0.3.0.
""" """
ok: bool ok: bool
message: str message: str
access_until: datetime | None = None access_until: datetime | None = None
authoritative: bool = True
def _parse_until(raw: object) -> datetime | None: def _parse_until(raw: object) -> datetime | None:
@@ -78,22 +98,78 @@ def api_base_url(cfg: Config) -> str:
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel. """URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
À défaut d'URL explicite, elle est déduite de l'adresse du client : le À défaut d'URL explicite, elle est déduite de l'adresse du client : le
serveur occupe la première adresse de ce réseau (10.6.0.1 pour un client serveur occupe la première adresse du réseau du tunnel. C'est l'adresse la
en 10.6.0.6). C'est l'adresse la plus fiable depuis la quarantaine — elle plus fiable depuis la quarantaine — elle désigne le serveur lui-même, dont
désigne le serveur lui-même, dont l'accès n'est jamais filtré, alors l'accès n'est jamais filtré, alors qu'une adresse du réseau distant le
qu'une adresse du réseau distant le serait. serait.
La déduction est déléguée à `wireguard.tunnel_gateway`, qui sert déjà au
diagnostic. Elle se faisait ici à part, en retranchant le dernier octet :
cela supposait un /24 et ignorait les IPs autorisées, alors qu'une adresse
client en /32 — la forme la plus courante — ne décrit aucun réseau. Un
client en 10.6.1.7/32 sur un réseau 10.6.0.0/16 se voyait ainsi expédié
vers 10.6.1.1, qui n'existe pas : sondage et validation échouaient tous
deux, sans autre issue que de renseigner `api_url` à la main.
""" """
explicit = (cfg.wg.get("api_url") or "").strip() explicit = (cfg.wg.get("api_url") or "").strip()
if explicit: if explicit:
# Le schéma est vérifié plutôt que repris tel quel : identifiant et
# code partent dans cette URL, et une valeur saisie de travers — un
# hôte nu sans schéma, un `file:` — enverrait la requête ailleurs que
# prévu, ou la ferait échouer sans dire pourquoi. `https` est accepté
# pour un serveur qui l'expose ; `http` reste le cas courant, la
# confidentialité venant alors du tunnel lui-même.
if not explicit.lower().startswith(("http://", "https://")):
return ""
return explicit.rstrip("/") return explicit.rstrip("/")
client_address = (cfg.wg.get("client_address") or "").split("/")[0].strip() gateway = tunnel_gateway(cfg.wg.get("client_address") or "",
try: cfg.wg.get("allowed_ips") or "")
host = ipaddress.ip_address(client_address) if not gateway:
except ValueError:
return "" return ""
gateway = ipaddress.ip_address(int(host) - (int(host) & 0xFF) + 1) # Une adresse IPv6 doit être encadrée de crochets dans une URL, sans quoi
return f"http://{gateway}:8000" # son propre « : » se confond avec celui du port.
host = f"[{gateway}]" if ":" in gateway else gateway
return f"http://{host}:8000"
def _routed_through_tunnel(cfg: Config, target: str) -> bool:
"""Le trafic vers `target` sort-il bien par le tunnel ?
Rien ne garantit que le serveur DNS déclaré ne soit joignable *que* par le
tunnel : une adresse RFC1918 d'entreprise peut très bien être routée par le
réseau local du poste — télétravail depuis un site jumeau, VPN d'un autre
fournisseur déjà monté. La sonde d'accessibilité réussissait alors hors
tunnel et déclarait la quarantaine levée alors qu'elle tenait toujours :
l'accès s'affichait « Ouvert ✓ », sans échéance donc sans fin, et le bouton
de saisie restait masqué. Le repli censé éviter le blocage le provoquait.
La question se tranche sans émettre le moindre paquet : `connect()` sur une
socket UDP ne fait que fixer la destination, et le noyau y attache déjà
l'adresse source qu'il emploierait — celle du tunnel si et seulement si
c'est par là que passe la route.
"""
client_ip = ((cfg.wg.get("client_address") or "")
.split(",")[0].split("/")[0].strip())
if not client_ip:
return False
try:
family, socktype, proto, _, sockaddr = socket.getaddrinfo(
target, 53, type=socket.SOCK_DGRAM)[0]
probe = socket.socket(family, socktype, proto)
try:
probe.connect(sockaddr)
source = probe.getsockname()[0]
finally:
probe.close()
except (OSError, IndexError):
return False
# Comparaison numérique : le noyau peut rendre une forme différente de
# celle écrite en configuration (zéros abrégés d'une adresse IPv6).
try:
return ipaddress.ip_address(source) == ipaddress.ip_address(client_ip)
except ValueError:
return False
def _remote_network_reachable(cfg: Config) -> bool: def _remote_network_reachable(cfg: Config) -> bool:
@@ -109,11 +185,15 @@ def _remote_network_reachable(cfg: Config) -> bool:
refus de connexion en est une : le paquet a atteint l'hôte, qui a répondu 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é ». par un RST. Seul un silence signifie « filtré ».
Encore faut-il que la cible soit atteinte *par le tunnel* — sans quoi on
observe le réseau local et on en conclut n'importe quoi (cf.
`_routed_through_tunnel`).
Renvoie False dès qu'aucune cible n'est configurée : sans point de Renvoie False dès qu'aucune cible n'est configurée : sans point de
comparaison, on n'observe rien et on ne conclut rien. comparaison, on n'observe rien et on ne conclut rien.
""" """
target = (cfg.wg.get("split_dns_server") or "").strip() target = (cfg.wg.get("split_dns_server") or "").strip()
if not target: if not target or not _routed_through_tunnel(cfg, target):
return False return False
try: try:
socket.create_connection((target, 53), timeout=_REACH_TIMEOUT).close() socket.create_connection((target, 53), timeout=_REACH_TIMEOUT).close()
@@ -136,17 +216,19 @@ def session_status(cfg: Config) -> AuthResult:
celui qui parle. Rien à demander avant que le tunnel ne soit monté, celui qui parle. Rien à demander avant que le tunnel ne soit monté,
l'API n'étant joignable que par l'intérieur. l'API n'étant joignable que par l'intérieur.
Un `ok` faux ne distingue pas « accès fermé » de « serveur injoignable » : Un `ok` faux vaut « accès non ouvert » ; c'est `authoritative` qui dit si
l'appelant traite les deux pareil — il redemande un code, ce qui est le le serveur l'a affirmé ou si l'on n'en sait rien. L'affichage reste
comportement sûr dans le doute. prudent dans les deux cas — le bouton de saisie demeure atteignable — mais
l'appelant ne fait plus surgir la fenêtre sur un simple silence du réseau.
""" """
base = api_base_url(cfg) base = api_base_url(cfg)
if not base: if not base:
return AuthResult(False, "Adresse du serveur d'authentification inconnue.") return AuthResult(False, "Adresse du serveur d'authentification "
"inconnue.", None, False)
request = urllib.request.Request(f"{base}/api/session", method="GET") request = urllib.request.Request(f"{base}/api/session", method="GET")
try: try:
with urllib.request.urlopen(request, timeout=_PROBE_TIMEOUT) as response: with _OPENER.open(request, timeout=_PROBE_TIMEOUT) as response:
body = json.loads(response.read().decode("utf-8")) body = json.loads(response.read().decode("utf-8"))
until = _parse_until(body.get("access_until")) until = _parse_until(body.get("access_until"))
if until is None: if until is None:
@@ -167,12 +249,20 @@ def session_status(cfg: Config) -> AuthResult:
return AuthResult(True, "Accès réseau ouvert (constaté sur le " return AuthResult(True, "Accès réseau ouvert (constaté sur le "
"réseau ; serveur trop ancien pour " "réseau ; serveur trop ancien pour "
"l'annoncer)") "l'annoncer)")
if exc.code == 404:
# Route absente et rien d'observable sur le réseau : c'est un
# serveur trop ancien pour répondre à la question, pas un refus.
return AuthResult(False, detail or "État de l'accès inconnu "
"(serveur sans /api/session)",
None, False)
# Toute autre réponse HTTP vient du serveur lui-même : elle fait
# autorité, un 403 comme le reste.
return AuthResult(False, detail or f"Accès fermé (code {exc.code})") return AuthResult(False, detail or f"Accès fermé (code {exc.code})")
except (urllib.error.URLError, OSError, ValueError) as exc: except (urllib.error.URLError, OSError, ValueError) as exc:
# Serveur ancien (route absente), injoignable, ou réponse illisible : # Serveur injoignable ou réponse illisible : aucune information, donc
# aucune information, donc aucune conclusion — on ne prétend pas que # aucune conclusion — on ne prétend pas que l'accès est ouvert, mais
# l'accès est ouvert. # on ne prétend pas non plus qu'il est fermé.
return AuthResult(False, f"État de l'accès inconnu : {exc}") return AuthResult(False, f"État de l'accès inconnu : {exc}", None, False)
def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult: def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau. """Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
@@ -204,7 +294,7 @@ def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult:
headers={"Content-Type": "application/json"}, method="POST", headers={"Content-Type": "application/json"}, method="POST",
) )
try: try:
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: with _OPENER.open(request, timeout=_TIMEOUT) as response:
body = json.loads(response.read().decode("utf-8")) body = json.loads(response.read().decode("utf-8"))
# Affichée dans le fuseau du poste : l'échéance arrive en UTC, et la # 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. # présenter telle quelle annonçait une heure fausse à l'utilisateur.
+2 -2
View File
@@ -615,7 +615,7 @@ def _networks(value: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]
return nets return nets
def _tunnel_gateway(client_address: str, allowed_ips: str = "") -> str: def tunnel_gateway(client_address: str, allowed_ips: str = "") -> str:
"""Première IP utilisable du réseau du tunnel (typiquement le serveur). """Première IP utilisable du réseau du tunnel (typiquement le serveur).
Une adresse client en /32 — la forme la plus courante — ne décrit aucun Une adresse client en /32 — la forme la plus courante — ne décrit aucun
@@ -1000,7 +1000,7 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
else: else:
steps.append(_step(_OK, "Trafic chiffré", f"{rx}{tx}")) steps.append(_step(_OK, "Trafic chiffré", f"{rx}{tx}"))
gateway = _tunnel_gateway(wg.get("client_address", ""), gateway = tunnel_gateway(wg.get("client_address", ""),
wg.get("allowed_ips", "")) wg.get("allowed_ips", ""))
if not gateway: if not gateway:
steps.append(_step(_SKIP, "Pair joignable dans le tunnel", steps.append(_step(_SKIP, "Pair joignable dans le tunnel",
+41
View File
@@ -233,6 +233,41 @@ class AccessCodeDialog(QDialog):
self._btn_ok.setEnabled(not busy) self._btn_ok.setEnabled(not busy)
self._btn_ok.setText("Vérification…" if busy else "Valider") self._btn_ok.setText("Vérification…" if busy else "Valider")
self._code_input.setEnabled(not busy) self._code_input.setEnabled(not busy)
if self._user_input is not None:
self._user_input.setEnabled(not busy)
# « Plus tard » est neutralisé le temps de la requête : la fermeture
# rendait la main à l'appelant sur un refus, alors que le serveur
# pouvait encore répondre par une acceptation. Celle-ci arrivait après
# la sortie de `exec()` et n'était donc lue par personne — l'accès
# était ouvert côté serveur, l'application affichait la quarantaine, et
# le code saisi était consommé pour rien.
self._btn_cancel.setEnabled(not busy)
def reject(self):
"""Refuse d'abandonner tant qu'une validation est en vol.
Couvre « Plus tard », la touche Échap et la croix de la fenêtre — les
trois passent par ici. L'attente est bornée par le délai de la requête
(10 s), et le bouton affiche « Vérification… » pendant ce temps.
"""
if self._worker is not None:
return
super().reject()
def dispose(self) -> None:
"""Arrête le minuteur et programme la destruction de la fenêtre.
Ni `accept()` ni `reject()` ne passent par `closeEvent` — vérifié sur
Qt 6 : le minuteur de 500 ms continuait donc à battre après la
fermeture, et la fenêtre, parentée à la fenêtre principale, survivait à
sa propre disparition. Une de plus à chaque passage en quarantaine, son
minuteur avec.
Appelé par l'appelant une fois `exec()` sorti et les réponses lues :
`message()` et `access_until()` ne survivent pas à la destruction.
"""
self._timer.stop()
self.deleteLater()
def wait_for_worker(self, msecs: int) -> None: def wait_for_worker(self, msecs: int) -> None:
"""Attend la fin de la requête en cours, s'il y en a une. """Attend la fin de la requête en cours, s'il y en a une.
@@ -258,5 +293,11 @@ class AccessCodeDialog(QDialog):
return self._access_until return self._access_until
def closeEvent(self, event): def closeEvent(self, event):
if self._worker is not None:
# `QDialog.closeEvent` délègue à `reject()`, qui refuse pendant la
# requête — mais l'événement resterait accepté et la fenêtre
# disparaîtrait quand même, réponse du serveur perdue.
event.ignore()
return
self._timer.stop() self._timer.stop()
super().closeEvent(event) super().closeEvent(event)
+148 -8
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QApplication, QApplication,
@@ -27,6 +27,23 @@ from app.ui.history_dialog import HistoryDialog
from app.ui import icons from app.ui import icons
from app.ui import theme from app.ui import theme
# Intervalle entre deux re-vérifications de l'accès auprès du serveur, tant
# qu'il est cru ouvert. Assez court pour qu'une révocation ne laisse pas
# l'utilisateur devant un réseau muet plus d'une minute, assez long pour ne
# pas transformer la scrutation de statut (3 s) en martèlement de l'API.
_ACCESS_RECHECK = timedelta(seconds=60)
# Nombre de sondages sans réponse d'affilée avant de renoncer à croire l'accès
# encore ouvert. Un refus venant du serveur, lui, fait autorité et referme du
# premier coup ; ceci ne vaut que pour le doute — serveur injoignable, réponse
# illisible — où refermer aussitôt ferait clignoter l'état entre « Ouvert » et
# « Quarantaine » au moindre hoquet réseau. Trois sondages, soit trois minutes
# de silence.
_ACCESS_UNKNOWN_BEFORE_CLOSE = 3
# Délai avant de resonder quand le serveur n'a rien dit alors qu'une saisie
# devait être proposée. Le tunnel vient de monter : ses routes peuvent n'être
# pas encore posées, et la fenêtre surgirait pour un code déjà valide.
_PROBE_RETRY_MS = 3000
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self, config: Config, parent=None): def __init__(self, config: Config, parent=None):
@@ -52,6 +69,17 @@ class MainWindow(QMainWindow):
# déclenché sans invite est déjà parti. # déclenché sans invite est déjà parti.
self._probe_worker: ValueWorker | None = None self._probe_worker: ValueWorker | None = None
self._probe_prompt = False self._probe_prompt = False
# Date du dernier sondage abouti, et nombre de sondages consécutifs
# ayant trouvé fermé un accès qu'on croyait ouvert (cf.
# `_access_stale` et `_on_probe_reply`). `_access_message` évite de
# répéter la même ligne de journal à chaque re-vérification.
self._access_probed_at: datetime | None = None
self._access_denials = 0
self._access_message = ""
# Un seul nouvel essai quand le serveur reste muet alors qu'une saisie
# devait être proposée : au-delà, le bouton prend le relais plutôt que
# de faire tourner un sondage en boucle.
self._probe_retried = False
# Conservée après fermeture : la fenêtre reste parentée à celle-ci, et # Conservée après fermeture : la fenêtre reste parentée à celle-ci, et
# son thread doit être attendu avant destruction (cf. prepare_quit). # son thread doit être attendu avant destruction (cf. prepare_quit).
self._access_dialog: AccessCodeDialog | None = None self._access_dialog: AccessCodeDialog | None = None
@@ -624,6 +652,22 @@ class MainWindow(QMainWindow):
return True return True
return datetime.now(timezone.utc) < self._access_until return datetime.now(timezone.utc) < self._access_until
def _access_stale(self) -> bool:
"""L'état de l'accès mérite-t-il d'être redemandé au serveur ?
L'échéance annoncée n'est pas la seule façon dont un accès se referme :
le serveur peut révoquer avant terme — redémarrage, purge de sa table
de sessions, décision d'un administrateur — sans que rien n'en
parvienne au poste. Le client ne sondait qu'au retour du tunnel : il
affichait donc « Ouvert ✓ » et masquait le bouton devant un réseau
déjà muet, jusqu'à la chute du tunnel. Sans échéance annoncée, cet
état pouvait durer indéfiniment.
"""
if self._access_probed_at is None:
return True
return (datetime.now(timezone.utc)
- self._access_probed_at) >= _ACCESS_RECHECK
def _remaining_text(self) -> str: def _remaining_text(self) -> str:
"""Temps restant avant l'échéance, tel qu'on le lit d'un coup d'œil. """Temps restant avant l'échéance, tel qu'on le lit d'un coup d'œil.
@@ -707,6 +751,10 @@ class MainWindow(QMainWindow):
# hors de l'application. Demander au serveur avant de conclure # hors de l'application. Demander au serveur avant de conclure
# quoi que ce soit : lui seul sait si l'accès est encore ouvert. # quoi que ce soit : lui seul sait si l'accès est encore ouvert.
self._probe_access(prompt_if_closed=False) self._probe_access(prompt_if_closed=False)
elif self._access_open and self._access_stale():
# Accès cru ouvert : le reconfirmer périodiquement, une
# révocation côté serveur n'étant annoncée par rien.
self._probe_access(prompt_if_closed=False)
else: else:
# Tunnel tombé : le client oublie ce qu'il croyait savoir. Le # Tunnel tombé : le client oublie ce qu'il croyait savoir. Le
# serveur, lui, n'oublie rien — l'autorisation porte sur l'adresse # serveur, lui, n'oublie rien — l'autorisation porte sur l'adresse
@@ -716,6 +764,10 @@ class MainWindow(QMainWindow):
# confusion qui faisait réclamer un code déjà validé. # confusion qui faisait réclamer un code déjà validé.
self._access_open = False self._access_open = False
self._access_until = None self._access_until = None
self._access_probed_at = None
self._access_denials = 0
self._access_message = ""
self._probe_retried = False
self._info_labels["access"].setText(self._access_state()) self._info_labels["access"].setText(self._access_state())
# Le bouton n'apparaît que lorsqu'il y a réellement un code à saisir ; # Le bouton n'apparaît que lorsqu'il y a réellement un code à saisir ;
@@ -904,21 +956,82 @@ class MainWindow(QMainWindow):
self._probe_worker.finished.connect(self._probe_worker.deleteLater) self._probe_worker.finished.connect(self._probe_worker.deleteLater)
self._probe_worker.start() self._probe_worker.start()
def _retry_probe(self):
"""Second sondage, après un silence du serveur (cf. `_on_probe_reply`).
Le différé peut arriver dans un monde qui a changé : tunnel retombé,
application en train de se fermer. Dans les deux cas il n'y a plus rien
à demander, et surtout plus personne à qui montrer une fenêtre.
"""
if self._quitting or not self._is_connected:
self._probe_prompt = False
return
self._probe_access(prompt_if_closed=True)
def _on_probe_reply(self, result): def _on_probe_reply(self, result):
"""Adopte l'état renvoyé par le serveur, ou réclame un code.""" """Adopte l'état renvoyé par le serveur, ou réclame un code."""
self._probe_worker = None self._probe_worker = None
prompt = self._probe_prompt prompt = self._probe_prompt
self._probe_prompt = False self._probe_prompt = False
was_open = self._access_open
self._access_probed_at = datetime.now(timezone.utc)
if result is not None and result.ok: if result is not None and result.ok:
self._access_open = True self._access_open = True
self._access_until = result.access_until self._access_until = result.access_until
self._access_denials = 0
self._probe_retried = False
# Journalisé au changement seulement : la re-vérification repasse
# ici chaque minute, et répéter la même ligne noierait le journal.
if not was_open or result.message != self._access_message:
self._add_log(result.message, "success") self._add_log(result.message, "success")
self._access_message = result.message
self._refresh_status() self._refresh_status()
return return
# Accès fermé, serveur injoignable ou route absente : dans tous les # Le serveur a-t-il seulement parlé ? Un worker en échec rend None, et
# cas on ne prétend rien et un code reste à saisir. # `authoritative` distingue le refus du silence.
spoke = result is not None and result.authoritative
if was_open:
# Re-vérification d'un accès qu'on croyait ouvert.
if spoke:
# Le serveur affirme que l'accès ne l'est plus : inutile
# d'attendre confirmation, il est seul juge.
self._access_open = False
self._access_until = None
self._access_message = ""
self._access_denials = 0
self._add_log(result.message or "Accès distant refermé par le "
"serveur — code à ressaisir", "warning")
else:
# Silence : n'en rien conclure tout de suite, sinon l'état
# clignoterait au moindre hoquet. Après plusieurs minutes sans
# réponse, mieux vaut rendre le bouton que d'afficher un
# « Ouvert ✓ » que plus rien n'étaye.
self._access_denials += 1
if self._access_denials >= _ACCESS_UNKNOWN_BEFORE_CLOSE:
self._access_open = False
self._access_until = None
self._access_message = ""
self._add_log("Serveur d'authentification sans réponse — "
"accès distant à reconfirmer", "warning")
self._refresh_status()
return
# Accès jamais ouvert de cette session. Une invite était-elle attendue ?
if prompt and not spoke and not self._probe_retried:
# Le serveur n'a rien dit : faire surgir la fenêtre maintenant
# ferait saisir un code peut-être inutile — l'accès est peut-être
# déjà ouvert, et le tunnel vient de monter. Un second essai avant
# de déranger l'utilisateur.
self._probe_retried = True
self._probe_prompt = True
QTimer.singleShot(_PROBE_RETRY_MS, self._retry_probe)
self._refresh_status()
return
self._probe_retried = False
if prompt: if prompt:
self._open_access_dialog() self._open_access_dialog()
return return
@@ -927,18 +1040,45 @@ class MainWindow(QMainWindow):
self._refresh_status() self._refresh_status()
def _open_access_dialog(self): def _open_access_dialog(self):
"""Ouvre la fenêtre de saisie et journalise la réponse du serveur.""" """Ouvre la fenêtre de saisie et journalise la réponse du serveur.
La fenêtre est lue puis détruite dans la foulée : parentée à celle-ci,
elle survivait autrement à chaque saisie — minuteur compris — et
`_access_dialog` écrasait la précédente, dont le thread n'était alors
plus attendu à l'arrêt de l'application.
"""
dlg = AccessCodeDialog(self._cfg, self) dlg = AccessCodeDialog(self._cfg, self)
self._access_dialog = dlg self._access_dialog = dlg
try:
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
self._access_open = accepted # Lus avant destruction : ils ne survivent pas à `dispose()`.
self._access_until = dlg.access_until() if accepted else None message = dlg.message()
until = dlg.access_until()
finally:
self._access_dialog = None
dlg.dispose()
if accepted: if accepted:
self._add_log(dlg.message(), "success") self._access_open = True
self._access_until = until
self._access_message = message
# Le serveur vient de répondre : cela vaut sondage, et remet à
# zéro le décompte des silences qui finit par refermer l'accès.
self._access_probed_at = datetime.now(timezone.utc)
self._access_denials = 0
self._probe_retried = False
self._add_log(message, "success")
else: else:
# Abandon : l'état d'accès n'est pas touché. Il l'était, et à tort
# — un sondage abouti pendant que la fenêtre était ouverte (les
# minuteries tournent dans la boucle imbriquée d'`exec()`) pouvait
# établir que l'accès était ouvert, que « Plus tard » remettait
# aussitôt à fermé. Renoncer à saisir un code ne referme rien côté
# serveur ; ça ne doit rien refermer ici non plus.
#
# La fenêtre affiche déjà le refus du serveur et laisse réessayer : # La fenêtre affiche déjà le refus du serveur et laisse réessayer :
# y superposer une boîte d'alerte n'apprendrait rien de plus. # y superposer une boîte d'alerte n'apprendrait rien de plus.
self._add_log(dlg.message() or "Authentification abandonnée — " self._add_log(message or "Authentification abandonnée — "
"accès distant fermé", "warning") "accès distant fermé", "warning")
self._refresh_status() self._refresh_status()