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
+41
View File
@@ -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)