diff --git a/app/core/vpn_session.py b/app/core/vpn_session.py index c277174..0eab8cf 100644 --- a/app/core/vpn_session.py +++ b/app/core/vpn_session.py @@ -68,12 +68,19 @@ class AuthResult(NamedTuple): 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. + + `enrolled` ne vaut faux que lorsque le serveur affirme ne connaître aucun + pair à cette adresse — le poste n'est pas enrôlé et aucun code ne + l'ouvrira. Renseigné par `session_status` seul : sur une demande + d'ouverture, le même code HTTP signifie « identifiant ou code invalide », + ce qui est tout autre chose. """ ok: bool message: str access_until: datetime | None = None authoritative: bool = True + enrolled: bool = True def _parse_until(raw: object) -> datetime | None: @@ -255,6 +262,14 @@ def session_status(cfg: Config) -> AuthResult: return AuthResult(False, detail or "État de l'accès inconnu " "(serveur sans /api/session)", None, False) + if exc.code == 401: + # Le serveur ne connaît aucun pair à cette adresse : ce poste + # n'est pas enrôlé, et aucun code n'y changera rien. Distinct du + # 403 « accès fermé », où une saisie a tout son sens. Les deux + # rendaient 403 avant la v0.3.2 du serveur, et le client réclamait + # donc un code à des postes qui n'en avaient aucun à donner. + return AuthResult(False, detail or "Ce poste n'est pas enrôlé sur " + "le serveur.", None, True, 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})") @@ -264,7 +279,7 @@ def session_status(cfg: Config) -> AuthResult: # 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: +def authenticate(cfg: Config, code: str) -> AuthResult: """Valide un code TOTP auprès du serveur et ouvre l'accès réseau. Retourne un `AuthResult`. Un échec n'est jamais silencieux : sans @@ -272,23 +287,25 @@ def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult: l'utilisateur doit comprendre pourquoi plutôt que de constater un réseau inexplicablement muet. - `username` permet de fournir l'identifiant à la volée, quand il n'est pas - encore en configuration : la fenêtre de saisie le demande alors avec le - code, plutôt que de renvoyer l'utilisateur vers le panneau Administrateur - au moment précis où il cherche à ouvrir son accès. Il est enregistré - une fois le serveur l'ayant accepté. + Aucun identifiant n'est transmis : le serveur reconnaît l'appelant à l'IP + source de son tunnel, comme il le fait déjà pour `session_status`. Il + l'exigeait, et le poste devait donc le connaître — la fenêtre de saisie + le réclamait à l'utilisateur, qui pouvait le taper de travers, et un poste + n'ayant jamais réussi à s'authentifier le redemandait indéfiniment. Le + serveur, lui, l'a toujours su. + + Cette identification est plus sûre, pas seulement plus simple : sur + WireGuard l'adresse source est garantie par le routage par clé, alors + qu'un identifiant en clair permettait d'ouvrir l'accès d'un compte depuis + n'importe quelle adresse. """ base = api_base_url(cfg) if not base: return AuthResult(False, "Adresse du serveur d'authentification " "inconnue. Renseignez-la dans le panneau " - "Administrateur.") + "Administrateur.", None, False) - username = (username or cfg.wg.get("vpn_username") or "").strip() - if not username: - return AuthResult(False, "Indiquez votre identifiant VPN.") - - payload = json.dumps({"username": username, "code": code}).encode("utf-8") + payload = json.dumps({"code": code}).encode("utf-8") request = urllib.request.Request( f"{base}/api/session", data=payload, headers={"Content-Type": "application/json"}, method="POST", @@ -314,7 +331,11 @@ def authenticate(cfg: Config, code: str, username: str = "") -> AuthResult: return AuthResult(False, detail or f"Authentification refusée (code {exc.code})") except urllib.error.URLError as exc: + # `authoritative` faux : le serveur n'a rien dit. Il n'a donc contesté + # ni le code ni l'identifiant, et l'appelant peut retenir ce dernier + # plutôt que de le redemander à chaque essai (cf. AccessCodeDialog). return AuthResult(False, f"Serveur d'authentification injoignable " - f"({base}) : {exc.reason}") + f"({base}) : {exc.reason}", None, False) except (OSError, ValueError) as exc: - return AuthResult(False, f"Échec de l'authentification : {exc}") + return AuthResult(False, f"Échec de l'authentification : {exc}", + None, False) diff --git a/app/core/wireguard.py b/app/core/wireguard.py index dac06c8..58ef1a9 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -615,12 +615,34 @@ def _networks(value: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network] return nets +# Réseau supposé autour de l'adresse du client quand rien d'autre ne permet +# de le déterminer : les conventions les plus répandues pour un tunnel. +_TUNNEL_FALLBACK_PREFIX = {4: 24, 6: 64} + + 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 - réseau : on se replie alors sur le premier réseau des « IPs autorisées », - qui contient le pair distant. + réseau : on se replie alors sur les « IPs autorisées ». + + Trois pièges, tous rencontrés : + + - Une route par défaut (`0.0.0.0/0`, `::/0`) est une IP autorisée + parfaitement banale — c'est le tunnel intégral — mais ne désigne aucun + réseau de tunnel. La retenir donnait pour passerelle son premier hôte, + `0.0.0.1`, injoignable par construction. + - Les IPs autorisées listent aussi les réseaux *distants*, joints à + travers le tunnel. Le serveur n'y est pas : il est dans le réseau du + tunnel, celui qui contient l'adresse du client. D'où le filtre sur + l'appartenance. + - Plusieurs réseaux peuvent contenir le client. Le plus spécifique est le + bon : un /16 englobant décrirait un plan d'adressage, pas ce tunnel-ci. + + Faute de tout candidat, on suppose le réseau usuel autour de l'adresse du + client — /24 en IPv4, /64 en IPv6. C'est ce que faisait l'ancienne + déduction, et elle avait raison sur ce point : mieux vaut une convention + répandue qu'aucune adresse du tout. """ try: iface = ipaddress.ip_interface(client_address.split(",")[0].strip()) @@ -630,9 +652,15 @@ def tunnel_gateway(client_address: str, allowed_ips: str = "") -> str: candidates = [] if iface.network.prefixlen < iface.network.max_prefixlen - 1: candidates.append(iface.network) - candidates += [n for n in _networks(allowed_ips) - if n.prefixlen < n.max_prefixlen - 1 - and n.version == iface.ip.version] + usable = [n for n in _networks(allowed_ips) + if n.version == iface.ip.version + and 0 < n.prefixlen < n.max_prefixlen - 1 + and iface.ip in n] + candidates += sorted(usable, key=lambda n: n.prefixlen, reverse=True) + if not candidates: + candidates.append(ipaddress.ip_network( + f"{iface.ip}/{_TUNNEL_FALLBACK_PREFIX[iface.ip.version]}", + strict=False)) for net in candidates: for host in net.hosts(): diff --git a/app/ui/access_code_dialog.py b/app/ui/access_code_dialog.py index 11351b5..715a617 100644 --- a/app/ui/access_code_dialog.py +++ b/app/ui/access_code_dialog.py @@ -45,7 +45,6 @@ class AccessCodeDialog(QDialog): self._worker: ValueWorker | None = None self._message = "" self._access_until = None - self._username = "" self.setWindowTitle("WGSecure — Code d'accès") self.setFixedWidth(360) self.setModal(True) @@ -88,19 +87,11 @@ class AccessCodeDialog(QDialog): sep.setStyleSheet(f"color: {theme.BORDER_SOFT};") layout.addWidget(sep) - # L'identifiant n'est demandé que s'il manque encore. Le renvoyer au - # panneau Administrateur à cet instant précis — tunnel monté, accès - # fermé, code en main — était un cul-de-sac : il est saisi ici, puis - # enregistré dès que le serveur l'a reconnu. - self._user_input: QLineEdit | None = None - if not (self._cfg.wg.get("vpn_username") or "").strip(): - self._user_input = QLineEdit() - self._user_input.setPlaceholderText("Identifiant VPN (ex. TestB)") - self._user_input.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._user_input.setStyleSheet(self._input_css(theme.BORDER_SOFT)) - self._user_input.returnPressed.connect(self._submit) - layout.addWidget(self._user_input) - + # Aucun identifiant n'est demandé : le serveur reconnaît le poste à + # l'IP source de son tunnel. Ce champ existait parce que le serveur + # exigeait l'identifiant, et il n'apportait que des ennuis — une + # faute de frappe faisait refuser un code valide, et un poste n'ayant + # jamais réussi à s'authentifier se le voyait redemander sans fin. self._code_input = QLineEdit() self._code_input.setPlaceholderText("000 000") self._code_input.setMaxLength(7) @@ -188,18 +179,10 @@ class AccessCodeDialog(QDialog): self._fail("Entrez exactement 6 chiffres.") return - self._username = "" - if self._user_input is not None: - self._username = self._user_input.text().strip() - if not self._username: - self._error_label.setText("Indiquez votre identifiant VPN.") - self._user_input.setStyleSheet(self._input_css(theme.FAIL_TEXT)) - return - self._error_label.setText("") self._set_busy(True) self._worker = ValueWorker(vpn_session.authenticate, self._cfg, raw, - self._username, parent=self) + parent=self) self._worker.done.connect(self._on_reply) self._worker.finished.connect(self._worker.deleteLater) self._worker.start() @@ -210,15 +193,10 @@ class AccessCodeDialog(QDialog): # `ValueWorker` absorbe une exception imprévue en rendant None : # dire alors que l'échec vient d'ici, et non d'un refus serveur. result = vpn_session.AuthResult( - False, "Échec inattendu de l'authentification.") + False, "Échec inattendu de l'authentification.", None, False) self._message = result.message self._access_until = result.access_until if result.ok: - # Enregistré seulement maintenant : un identifiant que le serveur - # a refusé n'a rien à faire dans la configuration. - if self._username: - self._cfg.set("wg", "vpn_username", self._username) - self._cfg.save() self.accept() return self._set_busy(False) @@ -233,8 +211,6 @@ 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 diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index fa2d4bb..356e217 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -396,8 +396,9 @@ class AdminWindow(QDialog): self._vpn_username.setPlaceholderText("TestB") self._vpn_username.setToolTip( "Nom du compte VPN tel qu'il figure sur le serveur.\n" - "Il sert à valider votre code à 6 chiffres après la connexion.\n" - "Laissé vide, aucun code n'est demandé (serveur en mode permissif)." + "Purement indicatif : le serveur reconnaît ce poste à l'adresse\n" + "de son tunnel, et l'identifiant ne lui est plus transmis.\n" + "Le laisser vide ne change rien au fonctionnement." ) self._row(form2, "Identifiant VPN :", self._vpn_username) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 5cc500e..36fb552 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -76,6 +76,11 @@ class MainWindow(QMainWindow): self._access_probed_at: datetime | None = None self._access_denials = 0 self._access_message = "" + # Le serveur connaît-il un pair à notre adresse de tunnel ? `None` + # tant qu'il n'a pas répondu. Ce constat remplace l'ancien « un + # identifiant VPN est-il configuré ? », qui prétendait répondre à la + # question depuis le poste alors que seul le serveur la tranche. + self._access_enrolled: bool | None = None # 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. @@ -715,9 +720,16 @@ class MainWindow(QMainWindow): # L'autorisation a couru puis expiré : le dire, plutôt que de # laisser croire qu'aucun code n'a jamais été saisi. return "Autorisation expirée (code requis)" - if (self._cfg.wg.get("vpn_username") or "").strip(): - return "Quarantaine (code requis)" - return "Non vérifié" + if self._access_enrolled is False: + # Le serveur affirme ne connaître aucun pair à cette adresse : + # aucun code n'ouvrira quoi que ce soit ici. + return "Non géré par le serveur" + if self._access_enrolled is None: + # Rien demandé, ou serveur muet : le client n'en sait rien et ne + # prétend donc rien. Il l'affirmait autrefois d'après sa seule + # configuration locale, et se trompait. + return "Non vérifié" + return "Quarantaine (code requis)" def _refresh_status(self): wg = self._cfg.wg @@ -768,6 +780,7 @@ class MainWindow(QMainWindow): self._access_denials = 0 self._access_message = "" self._probe_retried = False + self._access_enrolled = None self._info_labels["access"].setText(self._access_state()) # Le bouton n'apparaît que lorsqu'il y a réellement un code à saisir ; @@ -780,12 +793,11 @@ class MainWindow(QMainWindow): self._btn_access.setVisible(needed) self._sync_window_height() if needed: - # Un code est attendu de façon certaine quand l'identifiant VPN - # est connu ; sinon le bouton reste disponible mais discret, pour - # ne pas réclamer une action à une installation qui n'a pas de - # serveur d'authentification. - self._style_access_button( - bool((self._cfg.wg.get("vpn_username") or "").strip())) + # Un code est attendu de façon certaine quand le serveur a + # reconnu ce pair ; sinon le bouton reste disponible mais + # discret, pour ne pas réclamer une action à une installation + # sans serveur d'authentification. + self._style_access_button(self._access_enrolled is True) if connected: if self._connected_since is None: @@ -932,11 +944,14 @@ class MainWindow(QMainWindow): # # Une reconnexion automatique ne fait jamais surgir de fenêtre modale # — personne n'est devant l'écran, l'application resterait figée ; le - # bouton prend le relais. Sans identifiant VPN configuré non plus : - # une installation sans serveur d'authentification n'a pas à voir - # cette fenêtre à chaque connexion. - known = bool((self._cfg.wg.get("vpn_username") or "").strip()) - self._probe_access(prompt_if_closed=(not silent) and known) + # bouton prend le relais. + # + # Le cas « pas de serveur d'authentification » n'est plus tranché ici : + # il l'était d'après la présence d'un identifiant VPN en configuration, + # ce que le poste ne pouvait pas savoir. C'est `_on_probe_reply` qui + # renonce à l'invite quand le serveur répond ne connaître aucun pair à + # cette adresse — avec sa réponse en main, plutôt qu'avant de l'avoir. + self._probe_access(prompt_if_closed=not silent) def _probe_access(self, prompt_if_closed: bool): """Interroge le serveur sur l'état de l'accès, sans bloquer l'écran. @@ -975,6 +990,10 @@ class MainWindow(QMainWindow): self._probe_prompt = False was_open = self._access_open self._access_probed_at = datetime.now(timezone.utc) + # Seule une réponse du serveur renseigne l'enrôlement ; son silence + # laisse le constat précédent en place plutôt que de le contredire. + if result is not None and result.authoritative: + self._access_enrolled = result.enrolled if result is not None and result.ok: self._access_open = True @@ -1032,6 +1051,15 @@ class MainWindow(QMainWindow): return self._probe_retried = False + if prompt and self._access_enrolled is False: + # Le serveur ne connaît aucun pair à cette adresse : lui réclamer + # un code serait absurde, aucun ne l'ouvrira. Le bouton reste + # atteignable — discret — car le poste peut être enrôlé plus tard + # sans que rien ici ne l'apprenne. + self._add_log("Ce poste n'est pas enrôlé sur le serveur " + "d'authentification", "warning") + self._refresh_status() + return if prompt: self._open_access_dialog() return