From a7ac6d13757e569aa91e0d9e02218cca3d073d86 Mon Sep 17 00:00:00 2001 From: Johnny Date: Sun, 6 Sep 2026 12:40:34 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows):=20tunnel=20install=C3=A9=20une?= =?UTF-8?q?=20fois,=20puis=20d=C3=A9marr=C3=A9=20=E2=80=94=20et=20code=20v?= =?UTF-8?q?alid=C3=A9=20par=20le=20serveur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux changements liés, qui achèvent le déplacement de la sécurité vers le serveur. 1. Le tunnel n'est plus recréé à chaque connexion. Créer ou supprimer un service Windows n'est jamais délégable à un utilisateur standard, le démarrer peut l'être : c'est cette réinstallation systématique qui imposait une authentification administrateur à chaque connexion ET à chaque déconnexion. Désormais connect() démarre le service s'il existe déjà, disconnect() se contente de l'arrêter, et le tunnel reste installé entre les deux. Trois voies en cascade pour ce démarrage/arrêt : sc.exe sans élévation, puis le service wgsecure-helper, puis l'élévation en dernier recours. Le helper gagne donc deux commandes (start/stop_tunnel_service). Cette cascade tient quelle que soit la configuration du poste — le test en session standard a montré que l'ACL posée sur le service WireGuard ne donne aucun droit sur le service du tunnel, qui est un objet distinct. 2. Après le montage, l'application demande le code à 6 chiffres et le fait valider PAR LE SERVEUR (app/core/vpn_session.py). Jusqu'ici le code était vérifié localement, contre un secret que l'application détenait : elle validait donc un code qu'elle pouvait produire, et ne verrouillait que sa propre interface. L'ordre est imposé par le réseau — l'API n'est joignable que depuis le tunnel, qui sert de réseau de quarantaine tant que le code n'est pas passé. L'URL de l'API est déduite de l'adresse du client (10.6.0.6 → 10.6.0.1) faute de valeur explicite : c'est l'adresse du serveur lui-même, la seule jamais filtrée depuis la quarantaine. Sans identifiant VPN configuré, l'étape est ignorée : le serveur peut tourner en mode permissif, où un compte non enrôlé garde son accès, et imposer la saisie bloquerait des installations qui fonctionnent. Chemins Linux inchangés (branches is_windows()). Co-Authored-By: Claude Opus 5 --- app/core/config.py | 5 ++ app/core/vpn_session.py | 96 +++++++++++++++++++++++++++++++++++ app/core/wireguard.py | 88 ++++++++++++++++++++++++++++---- app/service/helper_service.py | 25 ++++++++- app/ui/admin_window.py | 23 +++++++++ app/ui/main_window.py | 38 ++++++++++++++ 6 files changed, 264 insertions(+), 11 deletions(-) create mode 100644 app/core/vpn_session.py diff --git a/app/core/config.py b/app/core/config.py index 848a537..16c90c3 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -36,6 +36,11 @@ _WG_DEFAULT: dict[str, Any] = { # Split-DNS : serveur DNS dédié pour les noms du réseau distant, # avec liste de domaines à résoudre via ce serveur (ex: ["local", "internal"]). # Laisser vide pour utiliser le DNS par défaut pour tout le trafic. + # Authentification auprès du serveur après montage du tunnel : identifiant + # du compte VPN tel que connu du serveur, et URL de son API. Laissée vide, + # celle-ci est déduite de l'adresse du client (cf. vpn_session.py). + "vpn_username": "", + "api_url": "", "split_dns_server": "", "split_dns_domains": [], } diff --git a/app/core/vpn_session.py b/app/core/vpn_session.py new file mode 100644 index 0000000..f4621cf --- /dev/null +++ b/app/core/vpn_session.py @@ -0,0 +1,96 @@ +"""Authentification auprès du serveur, après montage du tunnel. + +Le tunnel qui monte ne donne accès à rien : le serveur place le pair en +quarantaine et ne laisse passer son trafic vers le réseau distant qu'une +fois un code à 6 chiffres validé. C'est ce que fait ce module. + +Le code est vérifié **par le serveur**, contre un secret que cette +application ne détient pas — contrairement au MFA local d'origine, qui +validait un code qu'elle pouvait elle-même produire, et ne verrouillait +donc que sa propre interface. + +L'ordre est imposé par le réseau : l'API du serveur n'est joignable que +depuis l'intérieur du tunnel. On monte donc d'abord, on s'authentifie +ensuite — le tunnel jouant le rôle d'un réseau de quarantaine tant que le +code n'est pas passé. + +`urllib` plutôt qu'une bibliothèque HTTP tierce : un seul appel POST, aucune +raison d'alourdir les dépendances de l'application. +""" + +from __future__ import annotations + +import ipaddress +import json +import urllib.error +import urllib.request + +from app.core.config import Config + +_TIMEOUT = 10 + + +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. + """ + explicit = (cfg.wg.get("api_url") or "").strip() + if explicit: + return explicit.rstrip("/") + + client_address = (cfg.wg.get("client_address") or "").split("/")[0].strip() + try: + host = ipaddress.ip_address(client_address) + except ValueError: + return "" + gateway = ipaddress.ip_address(int(host) - (int(host) & 0xFF) + 1) + return f"http://{gateway}:8000" + + +def authenticate(cfg: Config, code: str) -> tuple[bool, str]: + """Valide un code TOTP auprès du serveur et ouvre l'accès réseau. + + Retourne (succès, message). Un échec n'est jamais silencieux : sans + cette validation le tunnel reste monté mais ne mène nulle part, et + l'utilisateur doit comprendre pourquoi plutôt que de constater un + réseau inexplicablement muet. + """ + base = api_base_url(cfg) + if not base: + return False, ("Adresse du serveur d'authentification inconnue. " + "Renseignez-la dans le panneau Administrateur.") + + username = (cfg.wg.get("vpn_username") or "").strip() + if not username: + return False, ("Aucun identifiant VPN configuré. Renseignez-le dans " + "le panneau Administrateur.") + + payload = json.dumps({"username": username, "code": code}).encode("utf-8") + request = urllib.request.Request( + f"{base}/api/session", data=payload, + headers={"Content-Type": "application/json"}, method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=_TIMEOUT) as response: + body = json.loads(response.read().decode("utf-8")) + until = (body.get("access_until") or "").replace("T", " ")[:16] + return True, f"Accès réseau ouvert jusqu'à {until}" if until else "Accès réseau ouvert" + except urllib.error.HTTPError as exc: + # 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 = "" + return False, detail or f"Authentification refusée (code {exc.code})" + except urllib.error.URLError as exc: + return False, (f"Serveur d'authentification injoignable ({base}) : " + f"{exc.reason}") + except (OSError, ValueError) as exc: + return False, f"Échec de l'authentification : {exc}" diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 9e15233..eecc210 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -252,6 +252,59 @@ def _try_helper_tunnel_cmd(cmd: list[str]) -> tuple[int, str, str] | None: return (0 if resp.get("ok") else 1), "", str(resp.get("message") or "") +def _tunnel_service_name(name: str) -> str: + """Nom du service Windows créé par wireguard.exe pour ce tunnel.""" + return f"WireGuardTunnel${name}" + + +def tunnel_service_installed(name: str) -> bool: + """True si le service du tunnel existe, qu'il tourne ou non. + + Distinct de `is_connected()`, qui n'est vrai que s'il tourne : c'est + cette différence qui permet d'installer le tunnel une seule fois puis de + se contenter de le démarrer et l'arrêter — au lieu de le recréer et le + détruire à chaque connexion, ce qui imposait une élévation à chaque fois. + """ + if not is_windows(): + return False + code, out, _ = run_command( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", + f"if (Get-Service -Name '{_tunnel_service_name(name)}' " + f"-ErrorAction SilentlyContinue) {{ 'OUI' }}"], + timeout=10, + ) + return code == 0 and "OUI" in out + + +def _control_tunnel_service(name: str, action: str, timeout: int = 45) -> tuple[bool, str]: + """Démarre ou arrête le service d'un tunnel déjà installé. + + Trois voies, de la moins intrusive à la plus lourde : + + 1. `sc.exe` sans élévation — fonctionne si les droits de démarrage ont + été accordés sur *ce service* (le bouton « Configurer les + permissions » les pose sur le service WireGuard, qui est un autre + objet : accorder l'un ne donne rien sur l'autre) ; + 2. le service privilégié wgsecure-helper, s'il est installé ; + 3. l'élévation UAC, en dernier recours. + """ + service = _tunnel_service_name(name) + verb = "start" if action == "start" else "stop" + + code, out, err = run_command(["sc.exe", verb, service], timeout=timeout) + if code == 0: + return True, "" + + resp = call_helper({"cmd": f"{verb}_tunnel_service", "name": name}) + if resp is not None: + return bool(resp.get("ok")), str(resp.get("message") or "") + + code, out, err = run_privileged(["sc.exe", verb, service], timeout=timeout) + if code == 0: + return True, "" + return False, err or out or f"Échec de « sc {verb} {service} » (code {code})" + + def _run_wireguard_service_cmd(cmd: list[str], timeout: int) -> tuple[int, str, str]: """Lance `wireguard.exe /install…` ou `/uninstall…tunnelservice`. @@ -365,8 +418,21 @@ def connect(cfg: Config) -> tuple[bool, str]: # service WireGuardTunnel$. if is_connected(cfg): return True, "Tunnel WireGuard déjà actif" - if dns_util.interface_exists(name): - force_cleanup(cfg) + + # Tunnel déjà installé : il suffit de le démarrer. C'est le cas + # courant, et le seul qui ne demande aucune élévation — à condition + # que les droits de démarrage aient été accordés sur ce service. + # Réinstaller à chaque connexion, comme auparavant, imposait une + # authentification administrateur à chaque fois : créer ou supprimer + # un service n'est jamais délégable, le démarrer peut l'être. + if tunnel_service_installed(name): + ok, err = _control_tunnel_service(name, "start", timeout=45) + if ok and _wait_connected(cfg, timeout=15): + return True, _post_connect_message(cfg) + if not ok: + return False, f"Impossible de démarrer le tunnel : {err}" + return False, "Le tunnel a démarré mais n'est pas monté" + cmd = [wireguard_exe(), "/installtunnelservice", result] try: code, _, err = _run_wireguard_service_cmd(cmd, timeout=90) @@ -417,13 +483,17 @@ def disconnect(cfg: Config) -> tuple[bool, str]: name = cfg.wg.get("interface_name", "wgs0") if is_windows(): - # force_cleanup() appelle wireguard.exe /uninstalltunnelservice si le service existe, - # puis nettoie les règles DNS résiduelles (NRPT). - force_cleanup(cfg) - if not dns_util.interface_exists(name): - return True, "Déconnecté" - # Si le service existe toujours, c'est qu'il n'a pas pu être supprimé. - return False, "Erreur lors de la déconnexion : le service n'a pas pu être arrêté" + # Arrêter, pas désinstaller : le service du tunnel reste en place + # pour la prochaine connexion. Le supprimer à chaque déconnexion + # obligeait à le recréer ensuite, et donc à redemander une + # authentification administrateur — alors que l'arrêter peut être + # délégué. Le service restitue routes et DNS en s'arrêtant. + ok, err = _control_tunnel_service(name, "stop", timeout=45) + dns_util.cleanup_split_dns(cfg) + dns_util.cleanup(name) + if ok or not is_connected(cfg): + return True, "Tunnel WireGuard désactivé" + return False, f"Erreur lors de la déconnexion : {err}" err = "" if is_connected(cfg): diff --git a/app/service/helper_service.py b/app/service/helper_service.py index 3afa5a3..8137714 100644 --- a/app/service/helper_service.py +++ b/app/service/helper_service.py @@ -1,6 +1,6 @@ """Service Windows privilégié pour WGSecure. -Installe/désinstalle le tunnel WireGuard, pose/retire les règles NRPT +Installe, démarre, arrête et désinstalle le tunnel WireGuard, pose/retire les règles NRPT (split-DNS) et lit l'état du tunnel (`wg show`) sur demande de l'app — non privilégiée — pour éviter une invite d'authentification à chaque connexion et déconnexion. Tourne en LocalSystem. @@ -11,7 +11,7 @@ le pipe nommé (`app/utils/helper_ipc.py`, ctypes pur, sans dépendance à pywin32). Invariant de sécurité central : le pipe reste accessible à tout process -d'un utilisateur local, pas seulement WGSecure — donc CINQ commandes fixes +d'un utilisateur local, pas seulement WGSecure — donc SEPT commandes fixes seulement, jamais de script/commande arbitraire. Chaque commande valide strictement ses paramètres avant d'exécuter quoi que ce soit. """ @@ -183,6 +183,25 @@ def _handle_uninstall_tunnel(req: dict, handle) -> dict: return {"ok": code == 0, "message": msg} +def _handle_start_tunnel_service(req: dict, handle) -> dict: + """Démarre le service d'un tunnel déjà installé. + + Complète install/uninstall : dans le fonctionnement courant le tunnel + est installé une fois, puis seulement démarré et arrêté. Le nom est + validé, et le préfixe « WireGuardTunnel$ » est ajouté ici — l'appelant + ne choisit donc jamais quel service piloter. + """ + name = _validate_interface_name(req.get("name")) + code, msg = _run(["sc.exe", "start", f"WireGuardTunnel${name}"], timeout=45) + return {"ok": code == 0, "message": msg} + + +def _handle_stop_tunnel_service(req: dict, handle) -> dict: + name = _validate_interface_name(req.get("name")) + code, msg = _run(["sc.exe", "stop", f"WireGuardTunnel${name}"], timeout=45) + return {"ok": code == 0, "message": msg} + + def _handle_set_split_dns(req: dict, handle) -> dict: server = _validate_server(req.get("server")) domains = _validate_domains(req.get("domains")) @@ -217,6 +236,8 @@ def _handle_get_status(req: dict, handle) -> dict: _HANDLERS = { "install_tunnel": _handle_install_tunnel, "uninstall_tunnel": _handle_uninstall_tunnel, + "start_tunnel_service": _handle_start_tunnel_service, + "stop_tunnel_service": _handle_stop_tunnel_service, "set_split_dns": _handle_set_split_dns, "clear_split_dns": _handle_clear_split_dns, "get_status": _handle_get_status, diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index d79ef9b..be5698e 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -392,6 +392,25 @@ class AdminWindow(QDialog): self._row(form2, "DNS :", self._dns) # Split-DNS : serveur dédié pour les noms du réseau distant + # Authentification auprès du serveur, après montage du tunnel. + self._vpn_username = QLineEdit() + 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)." + ) + self._row(form2, "Identifiant VPN :", self._vpn_username) + + self._api_url = QLineEdit() + self._api_url.setPlaceholderText("déduit automatiquement") + self._api_url.setToolTip( + "URL du serveur WGSecure, joignable depuis le tunnel.\n" + "Laissée vide, elle est déduite de l'adresse du client\n" + "(10.6.0.6 → http://10.6.0.1:8000)." + ) + self._row(form2, "Serveur d'authentification :", self._api_url) + self._split_dns_server = QLineEdit() self._split_dns_server.setPlaceholderText("192.168.1.210") self._split_dns_server.setToolTip( @@ -1295,6 +1314,8 @@ class AdminWindow(QDialog): # Split-DNS : réseau distant par défaut de ce déploiement (H3), tant # qu'aucune valeur explicite n'a encore été saisie ou importée. + self._vpn_username.setText(wg.get("vpn_username") or "") + self._api_url.setText(wg.get("api_url") or "") self._split_dns_server.setText(wg.get("split_dns_server") or "192.168.1.210") domains = wg.get("split_dns_domains") or ["h3adm.lan"] self._split_dns_domains.setText(", ".join(domains) if isinstance(domains, list) else domains) @@ -1323,6 +1344,8 @@ class AdminWindow(QDialog): self._cfg.set("wg", "client_public_key", self._pub_key.text().strip()) # Split-DNS + self._cfg.set("wg", "vpn_username", self._vpn_username.text().strip()) + self._cfg.set("wg", "api_url", self._api_url.text().strip()) self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip()) domains_text = self._split_dns_domains.text().strip() domains_list = [d.strip() for d in domains_text.split(",") if d.strip()] if domains_text else [] diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 572272c..ac1feb3 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -9,6 +9,7 @@ from PyQt6.QtCore import Qt, QTimer, QDateTime from PyQt6.QtGui import QFont, QCloseEvent, QColor from app.core.config import Config +from app.core import vpn_session from app.core import wireguard as wg_core from app.core import dns as dns_util from app.core import log as conn_log @@ -692,6 +693,38 @@ class MainWindow(QMainWindow): self._worker.finished.connect(self._worker.deleteLater) self._worker.start() + def _authenticate_with_server(self, silent: bool): + """Demande le code à 6 chiffres et le fait valider par le serveur. + + Sans identifiant VPN configuré, l'étape est ignorée : le serveur + peut très bien tourner en mode permissif, où un compte non enrôlé + conserve son accès. Imposer la saisie dans ce cas bloquerait des + installations qui fonctionnent parfaitement. + """ + if not (self._cfg.wg.get("vpn_username") or "").strip(): + return + if silent: + # Reconnexion automatique : ouvrir une fenêtre modale sans + # personne devant l'écran laisserait l'application figée. + self._add_log("Code d'accès requis — reconnectez-vous manuellement", + "warning") + return + + code, accepted = QInputDialog.getText( + self, "Code d'accès", + "Le tunnel est monté, mais l'accès aux ressources distantes\n" + "reste fermé tant que votre code n'est pas validé.\n\n" + "Code à 6 chiffres :", + ) + if not accepted or not code.strip(): + self._add_log("Authentification annulée — accès distant fermé", "warning") + return + + ok, message = vpn_session.authenticate(self._cfg, code.strip()) + self._add_log(message, "success" if ok else "error") + if not ok: + QMessageBox.warning(self, "Accès refusé", message) + def _begin_task(self, label: str): self._connecting = True self._btn_connect.setEnabled(False) @@ -717,6 +750,11 @@ class MainWindow(QMainWindow): base, _, extra = msg.partition("\n") if extra: self._add_log(extra, "warning") + # Le tunnel est monté mais ne mène encore nulle part : le + # serveur garde le pair en quarantaine tant qu'un code n'a + # pas été validé. C'est seulement maintenant que l'API est + # joignable — elle ne l'est que depuis l'intérieur du tunnel. + self._authenticate_with_server(silent) QTimer.singleShot(5000, self._update_ping) else: self._reconnect_failures += 1