diff --git a/app/core/vpn_session.py b/app/core/vpn_session.py index 0eab8cf..f24fb8f 100644 --- a/app/core/vpn_session.py +++ b/app/core/vpn_session.py @@ -101,6 +101,23 @@ def _parse_until(raw: object) -> datetime | None: return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc) +def _error_detail(exc: urllib.error.HTTPError) -> str: + """Le motif du refus tel que le serveur l'énonce, ou "" s'il n'en dit rien. + + FastAPI répond aux erreurs de validation (422) par un `detail` qui est une + *liste* d'erreurs, pas une phrase. Relayée telle quelle, elle atterrissait + dans un champ `message` que l'interface passe ensuite à Qt : le refus + n'était alors pas affiché, il levait une exception au moment de le lire. + Seule une chaîne est un message ; tout autre corps ne dit rien + d'affichable, et le message générique de l'appelant vaut mieux. + """ + try: + detail = json.loads(exc.read().decode("utf-8")).get("detail", "") + except (ValueError, OSError, AttributeError): + return "" # corps illisible, ou JSON sans objet + return detail if isinstance(detail, str) else "" + + def api_base_url(cfg: Config) -> str: """URL de l'API du serveur, telle qu'atteignable depuis le tunnel. @@ -179,7 +196,7 @@ def _routed_through_tunnel(cfg: Config, target: str) -> bool: return False -def _remote_network_reachable(cfg: Config) -> bool: +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 @@ -243,11 +260,8 @@ def session_status(cfg: Config) -> AuthResult: return AuthResult(True, "Accès réseau déjà ouvert jusqu'à " f"{until.astimezone():%d/%m/%Y %H:%M}", until) except urllib.error.HTTPError as exc: - try: - detail = json.loads(exc.read().decode("utf-8")).get("detail", "") - except (ValueError, OSError): - detail = "" - if exc.code == 404 and _remote_network_reachable(cfg): + detail = _error_detail(exc) + 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 @@ -324,10 +338,16 @@ def authenticate(cfg: Config, code: str) -> AuthResult: # Le serveur explique lui-même le refus (code invalide, compte # verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un # message générique qui obligerait à consulter les journaux. - try: - detail = json.loads(exc.read().decode("utf-8")).get("detail", "") - except (ValueError, OSError): - detail = "" + detail = _error_detail(exc) + if exc.code == 422: + # Serveur antérieur à la v0.3.2 : il exige encore un identifiant + # dans le corps, que ce poste ne transmet plus. Son `detail` est + # la liste d'erreurs de validation de FastAPI — `_error_detail` + # l'écarte, et il n'en resterait qu'un « code 422 » énigmatique. + return AuthResult(False, "Ce serveur attend un identifiant VPN " + "que ce poste ne transmet plus. Mettez " + "le serveur à jour (v0.3.2 ou " + "supérieur).") return AuthResult(False, detail or f"Authentification refusée (code {exc.code})") except urllib.error.URLError as exc: diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 43f9914..a2a03d0 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -88,6 +88,10 @@ class MainWindow(QMainWindow): # déclenché sans invite est déjà parti. self._probe_worker: ValueWorker | None = None self._probe_prompt = False + # Vérification, après un code accepté, que le trafic atteint bien le + # réseau distant. Distincte du sondage : celui-ci demande au serveur + # ce qu'il a enregistré, celle-là observe ce qui passe réellement. + self._reach_worker: ValueWorker | None = None # 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 @@ -1369,6 +1373,7 @@ class MainWindow(QMainWindow): self._access_denials = 0 self._probe_retried = False self._add_log(message, "success") + self._announce_access_granted() 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 @@ -1383,6 +1388,53 @@ class MainWindow(QMainWindow): "accès distant fermé", "warning") self._refresh_status() + def _announce_access_granted(self): + """Confirme la validation, en disant jusqu'à quand l'accès tient. + + Le message du serveur partait jusqu'ici dans le seul journal, pendant + que la fenêtre se refermait : rien ne distinguait à l'écran un code + accepté d'un code ignoré, et la durée obtenue restait invisible alors + que c'est précisément ce que l'utilisateur vient de demander. + """ + if self._access_until is not None: + fin = self._access_until.astimezone() + texte = (f"Code accepté.\n\nAccès au réseau distant ouvert " + f"jusqu'au {fin:%d/%m/%Y à %H:%M} " + f"(soit {self._remaining_text()}).") + else: + texte = ("Code accepté.\n\nAccès au réseau distant ouvert. Le " + "serveur n'a pas annoncé d'échéance.") + QMessageBox.information(self, "Accès ouvert", texte) + # Le serveur peut avoir enregistré l'autorisation sans que le trafic + # passe pour autant — règle de pare-feu non appliquée, par exemple. + # Le constater vaut mieux que laisser l'utilisateur découvrir seul un + # réseau resté muet après un code pourtant accepté. + self._verify_access_effective() + + def _verify_access_effective(self): + """Vérifie en fond que le trafic atteint réellement le réseau distant.""" + if self._reach_worker is not None: + return + self._reach_worker = ValueWorker(vpn_session.remote_network_reachable, + self._cfg, parent=self) + self._reach_worker.done.connect(self._on_reach_reply) + self._reach_worker.finished.connect(self._reach_worker.deleteLater) + self._reach_worker.start() + + def _on_reach_reply(self, joignable: bool): + self._reach_worker = None + cible = (self._cfg.wg.get("split_dns_server") or "").strip() + if not cible: + return # rien à sonder, donc rien à conclure + if joignable: + self._add_log(f"Trafic vers le réseau distant confirmé ({cible})", + "success") + return + self._add_log( + f"Code accepté, mais {cible} reste injoignable : le serveur a " + "enregistré l'autorisation sans que le trafic passe. Prévenez " + "l'administrateur.", "warning") + def _begin_task(self, label: str): self._connecting = True self._btn_connect.setEnabled(False) @@ -1505,7 +1557,7 @@ class MainWindow(QMainWindow): # Laisser les threads en cours se terminer avant de démonter le tunnel : # une connexion et une déconnexion simultanées laisseraient un état mixte. for worker in (self._worker, self._ping_worker, self._probe_worker, - self._update_worker): + self._reach_worker, self._update_worker): try: if worker is not None and worker.isRunning(): worker.wait(15000) @@ -1520,6 +1572,7 @@ class MainWindow(QMainWindow): self._worker = None self._ping_worker = None self._probe_worker = None + self._reach_worker = None self._update_worker = None if self._session_id is not None: