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:
co-authored by
Claude Opus 5
parent
205258cbf1
commit
556f22e7c9
@@ -233,6 +233,41 @@ class AccessCodeDialog(QDialog):
|
||||
self._btn_ok.setEnabled(not busy)
|
||||
self._btn_ok.setText("Vérification…" if busy else "Valider")
|
||||
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:
|
||||
"""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
|
||||
|
||||
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()
|
||||
super().closeEvent(event)
|
||||
|
||||
+150
-10
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -27,6 +27,23 @@ from app.ui.history_dialog import HistoryDialog
|
||||
from app.ui import icons
|
||||
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):
|
||||
def __init__(self, config: Config, parent=None):
|
||||
@@ -52,6 +69,17 @@ class MainWindow(QMainWindow):
|
||||
# déclenché sans invite est déjà parti.
|
||||
self._probe_worker: ValueWorker | None = None
|
||||
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
|
||||
# son thread doit être attendu avant destruction (cf. prepare_quit).
|
||||
self._access_dialog: AccessCodeDialog | None = None
|
||||
@@ -624,6 +652,22 @@ class MainWindow(QMainWindow):
|
||||
return True
|
||||
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:
|
||||
"""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
|
||||
# quoi que ce soit : lui seul sait si l'accès est encore ouvert.
|
||||
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:
|
||||
# Tunnel tombé : le client oublie ce qu'il croyait savoir. Le
|
||||
# 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é.
|
||||
self._access_open = False
|
||||
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())
|
||||
|
||||
# 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.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):
|
||||
"""Adopte l'état renvoyé par le serveur, ou réclame un code."""
|
||||
self._probe_worker = None
|
||||
prompt = self._probe_prompt
|
||||
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:
|
||||
self._access_open = True
|
||||
self._access_until = result.access_until
|
||||
self._add_log(result.message, "success")
|
||||
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._access_message = result.message
|
||||
self._refresh_status()
|
||||
return
|
||||
|
||||
# Accès fermé, serveur injoignable ou route absente : dans tous les
|
||||
# cas on ne prétend rien et un code reste à saisir.
|
||||
# Le serveur a-t-il seulement parlé ? Un worker en échec rend None, et
|
||||
# `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:
|
||||
self._open_access_dialog()
|
||||
return
|
||||
@@ -927,18 +1040,45 @@ class MainWindow(QMainWindow):
|
||||
self._refresh_status()
|
||||
|
||||
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)
|
||||
self._access_dialog = dlg
|
||||
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
|
||||
self._access_open = accepted
|
||||
self._access_until = dlg.access_until() if accepted else None
|
||||
try:
|
||||
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
|
||||
# Lus avant destruction : ils ne survivent pas à `dispose()`.
|
||||
message = dlg.message()
|
||||
until = dlg.access_until()
|
||||
finally:
|
||||
self._access_dialog = None
|
||||
dlg.dispose()
|
||||
|
||||
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:
|
||||
# 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 :
|
||||
# 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")
|
||||
self._refresh_status()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user