diff --git a/app/core/vpn_session.py b/app/core/vpn_session.py index 3f709e3..c277174 100644 --- a/app/core/vpn_session.py +++ b/app/core/vpn_session.py @@ -29,6 +29,7 @@ from datetime import datetime, timezone from typing import NamedTuple from app.core.config import Config +from app.core.wireguard import tunnel_gateway _TIMEOUT = 10 # 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é. _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): """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` 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. + + `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 message: str access_until: datetime | None = None + authoritative: bool = True 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. À 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 - en 10.6.0.6). C'est l'adresse la plus fiable depuis la quarantaine — elle - désigne le serveur lui-même, dont l'accès n'est jamais filtré, alors - qu'une adresse du réseau distant le serait. + serveur occupe la première adresse du réseau du tunnel. C'est l'adresse la + plus fiable depuis la quarantaine — elle désigne le serveur lui-même, dont + l'accès n'est jamais filtré, alors qu'une adresse du réseau distant le + 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() 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("/") - client_address = (cfg.wg.get("client_address") or "").split("/")[0].strip() - try: - host = ipaddress.ip_address(client_address) - except ValueError: + gateway = tunnel_gateway(cfg.wg.get("client_address") or "", + cfg.wg.get("allowed_ips") or "") + if not gateway: return "" - gateway = ipaddress.ip_address(int(host) - (int(host) & 0xFF) + 1) - return f"http://{gateway}:8000" + # Une adresse IPv6 doit être encadrée de crochets dans une URL, sans quoi + # 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: @@ -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 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 comparaison, on n'observe rien et on ne conclut rien. """ target = (cfg.wg.get("split_dns_server") or "").strip() - if not target: + if not target or not _routed_through_tunnel(cfg, target): return False try: 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é, l'API n'étant joignable que par l'intérieur. - Un `ok` faux ne distingue pas « accès fermé » de « serveur injoignable » : - l'appelant traite les deux pareil — il redemande un code, ce qui est le - comportement sûr dans le doute. + Un `ok` faux vaut « accès non ouvert » ; c'est `authoritative` qui dit si + le serveur l'a affirmé ou si l'on n'en sait rien. L'affichage reste + 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) 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") 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")) until = _parse_until(body.get("access_until")) 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 " "réseau ; serveur trop ancien pour " "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})") except (urllib.error.URLError, OSError, ValueError) as exc: - # Serveur ancien (route absente), injoignable, ou réponse illisible : - # aucune information, donc aucune conclusion — on ne prétend pas que - # l'accès est ouvert. - return AuthResult(False, f"État de l'accès inconnu : {exc}") + # Serveur injoignable ou réponse illisible : aucune information, donc + # aucune conclusion — on ne prétend pas que l'accès est ouvert, mais + # on ne prétend pas non plus qu'il est fermé. + return AuthResult(False, f"État de l'accès inconnu : {exc}", None, False) def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult: """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", ) 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")) # 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. diff --git a/app/core/wireguard.py b/app/core/wireguard.py index eecc210..dac06c8 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -615,7 +615,7 @@ def _networks(value: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network] 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). Une adresse client en /32 — la forme la plus courante — ne décrit aucun @@ -1000,8 +1000,8 @@ def _diag_tunnel(cfg: Config) -> list[dict]: else: steps.append(_step(_OK, "Trafic chiffré", f"↓ {rx} ↑ {tx}")) - gateway = _tunnel_gateway(wg.get("client_address", ""), - wg.get("allowed_ips", "")) + gateway = tunnel_gateway(wg.get("client_address", ""), + wg.get("allowed_ips", "")) if not gateway: steps.append(_step(_SKIP, "Pair joignable dans le tunnel", "Adresse du tunnel non exploitable")) diff --git a/app/ui/access_code_dialog.py b/app/ui/access_code_dialog.py index 1024854..11351b5 100644 --- a/app/ui/access_code_dialog.py +++ b/app/ui/access_code_dialog.py @@ -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) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index faeaae5..5cc500e 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -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()