From b3cf706bb6363f3f737060b43c594a08baafa32b Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Tue, 8 Sep 2026 12:18:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20panneau=20all=C3=A9g=C3=A9,=20et=20?= =?UTF-8?q?le=20diagnostic=20nomme=20le=20chevauchement=20de=20plages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'assistant « configuration serveur + client » disparaît. Il datait d'avant la v0.8.0 et produisait un serveur WireGuard nu — sans API, sans quarantaine, sans TOTP — incompatible avec le produit actuel, où le serveur enrôle le compte et fournit un .conf. Son bouton « Appliquer le volet client » écrasait clés client et clé publique du serveur : sur un poste déjà enrôlé, il détruisait le lien avec le serveur, qui déclare l'ancienne clé publique. L'identifiant VPN disparaît aussi, devenu sans emploi depuis que le serveur reconnaît le poste à l'adresse de son tunnel. Un champ qui ne fait rien est pire qu'un champ absent : il laisse croire qu'il compte. Les configurations existantes sont purgées au chargement, profils compris. La pastille « À propos » qui en déduisait « Code d'accès serveur » ou « Aucun code d'accès » n'affirme plus rien qu'elle ne sache : l'accès passe par le serveur dans tous les cas. Le sous-onglet Tunnel passe d'une liste de quinze champs à trois groupes : l'essentiel, le split-DNS nommé comme tel — trois champs y portaient « DNS » sans que rien ne dise lesquels allaient ensemble — et un repli « Avancé ». Celui-ci réunit ce qui vient du serveur (adresse, port, clé publique) et ce qui est correct par défaut (nom d'interface, URL de l'API, keepalive, MTU). Le verrouillage après import d'un .conf devient persistant. Il existait déjà, mais `_load_values` le levait sans condition : rouvrir le panneau ou changer de profil rendait éditables, en silence, les champs décrivant un pair déjà déclaré côté serveur. L'état est désormais enregistré et suit la configuration chargée. Le diagnostic gagne une étape « Serveur DNS du réseau distant », qui couvre un angle mort : le tunnel monte, le trafic passe, la résolution est au vert, et pas un nom interne ne se résout. Deux causes, l'une et l'autre muettes — le serveur DNS distant n'est couvert par aucune IP autorisée, ou il l'est mais son adresse tombe aussi dans une plage que porte une carte du poste, auquel cas la route locale l'emporte. Le second cas ne se déduit pas de la configuration : l'étape interroge la route réellement retenue par le noyau, sans émettre de paquet, et propose la route d'hôte qui reprend la main. Le sous-onglet « Fuite DNS » devient « Résolution des noms » : il en faisait trois choses sous un titre qui n'en annonçait qu'une. Son verdict « Statut inconnu » recouvrait trois causes appelant trois gestes différents ; chacune est maintenant nommée, avec l'action qui suit. Enfin, la fenêtre. Sa hauteur augmente de 15 %, et son plancher passe de 520 à 703 : la page de test réclame 569 px et se chevauchait en dessous. Le rapport de diagnostic passe en tableau à deux colonnes — il occupait 224 px quelle que soit la largeur de la fenêtre, soit jusqu'à 79 % de blanc à droite, et tient maintenant sur 38 % de hauteur en moins. Co-Authored-By: Claude Opus 5 --- app/core/config.py | 34 ++- app/core/wireguard.py | 153 +++++++------ app/ui/admin_window.py | 482 ++++++++++++++++++++++------------------- 3 files changed, 370 insertions(+), 299 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 2f22b55..2a35308 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -36,11 +36,17 @@ _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": "", + # Authentification auprès du serveur après montage du tunnel : URL de son + # API. Laissée vide, elle est déduite de l'adresse du client et des IPs + # autorisées (cf. vpn_session.py). Aucun identifiant n'est stocké ni + # transmis : le serveur reconnaît le poste à l'IP source de son tunnel. "api_url": "", + # Champs décrivant le pair tel que le serveur le connaît (adresse, port, + # clé publique, IPs autorisées…) verrouillés parce qu'ils viennent d'un + # `.conf` importé. Persisté : le verrou ne tenait que le temps d'une + # session du panneau, et rouvrir celui-ci rendait ces champs éditables + # sans que rien ne le signale. + "config_locked": False, "split_dns_server": "", "split_dns_domains": [], } @@ -85,7 +91,9 @@ class Config: self._apply_autostart(self._data["ui"]["autostart"]) def _drop_local_mfa(self) -> None: - """Efface le secret TOTP local hérité des versions ≤ 0.8.0. + """Efface les réglages d'authentification devenus caducs. + + Le secret TOTP local hérité des versions ≤ 0.8.0. Ce secret ne sert plus : le code à 6 chiffres est désormais validé par le serveur, contre un secret que le poste client ne détient pas. @@ -94,10 +102,22 @@ class Config: au chargement plutôt qu'un simple abandon des clés. """ stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data] - if not stale: - return for key in stale: del self._data[key] + # `vpn_username` a disparu en v0.9.2 : le serveur reconnaît le poste à + # l'IP source de son tunnel, l'identifiant ne lui est plus transmis. + # Le retirer plutôt que l'ignorer, sinon `config.json` conserverait + # indéfiniment un réglage que plus rien ne lit et que l'utilisateur + # croirait encore actif. + if "vpn_username" in self._data.get("wg", {}): + del self._data["wg"]["vpn_username"] + stale.append("wg.vpn_username") + for profile in self._data.get("profiles", {}).values(): + if isinstance(profile, dict) and "vpn_username" in profile: + del profile["vpn_username"] + stale.append("profil") + if not stale: + return self.save() def save(self): diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 58ef1a9..f235e2b 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -1044,6 +1044,8 @@ def _diag_tunnel(cfg: Config) -> list[dict]: steps.append(_step(_OK, "Pair joignable dans le tunnel", f"{gateway} répond en {ms} ms")) + steps += _diag_split_dns(cfg) + if not dns_util.resolution_works(): steps.append(_step(_FAIL, "Résolution DNS dans le tunnel", "Plus aucun nom ne se résout — utilisez " @@ -1071,6 +1073,72 @@ def _diag_tunnel(cfg: Config) -> list[dict]: return steps +def _diag_split_dns(cfg: Config) -> list[dict]: + """Le serveur DNS du réseau distant est-il seulement joignable ? + + Point aveugle du diagnostic jusqu'ici : le tunnel monte, le trafic passe, + « Résolution DNS » est au vert — et pas un seul nom interne ne se résout. + L'échec ne se manifestait que par un « All attempts to contact name + servers or networks failed » de resolvectl, qui ne désigne rien. + + Deux causes, toutes deux invisibles : + + - Le serveur DNS distant n'est couvert par aucune « IP autorisée ». Rien + ne le route dans le tunnel, il est donc cherché sur le réseau local. + - Il est couvert, mais son adresse tombe aussi dans une plage que porte + une carte du poste. C'est le cas d'un réseau distant en 192.168.1.0/24 + vu depuis un poste chez lui, où la même plage est la plus répandue de + toutes. La route locale l'emporte, et le split-DNS ne part jamais. + + La vérification se fait sur la route réellement retenue par le noyau, + tunnel monté, plutôt que sur la seule configuration : c'est la seule + façon de trancher le second cas. + """ + wg = cfg.wg + server = (wg.get("split_dns_server") or "").strip() + domains = wg.get("split_dns_domains") or [] + if not server or not domains: + return [] # split-DNS non configuré : rien à dire + + label = "Serveur DNS du réseau distant" + try: + target = ipaddress.ip_address(server) + except ValueError: + return [_step(_FAIL, label, f"« {server} » n'est pas une adresse IP")] + + allowed = [n for n in _networks(wg.get("allowed_ips", "")) + if n.version == target.version] + couvert = [str(n) for n in allowed if target in n] + client_ip = (wg.get("client_address") or "").split(",")[0].split("/")[0].strip() + source = _local_route_source(server, 53) + + if not couvert: + return [_step( + _FAIL, label, + f"{server} n'est couvert par aucune « IP autorisée » : rien ne le " + f"route dans le tunnel, il est donc cherché sur le réseau local. " + f"Ajoutez {server}/32 aux IPs autorisées.")] + + if source and client_ip and source != client_ip: + return [_step( + _FAIL, label, + f"{server} est bien couvert par {', '.join(couvert)}, mais les " + f"paquets partent de {source} — votre réseau local — et non de " + f"{client_ip}, l'adresse du tunnel. Cette adresse appartient aux " + f"deux réseaux à la fois, et la route locale l'emporte. " + f"Remplacez {', '.join(couvert)} par {server}/32 : une route " + f"d'hôte est plus spécifique et reprend la main, sans router tout " + f"le voisinage dans le tunnel.")] + + if not source: + return [_step(_WARN, label, + f"Route vers {server} indéterminable depuis ce poste")] + + return [_step(_OK, label, + f"{server} joint par le tunnel (couvert par " + f"{', '.join(couvert)}) pour {', '.join(domains)}")] + + def diagnose_connection(cfg: Config, timeout: int = 5) -> list[dict]: """Vérifie toute la chaîne de connexion et désigne le point de rupture. @@ -1286,9 +1354,18 @@ def ping_or_handshake(cfg: Config, timeout: int = 2) -> tuple[int | None, str]: # ── Test DNS leak ───────────────────────────────────────────────────────── def dns_leak_test(cfg: Config) -> dict: - """ - Teste si le DNS fuit en dehors du tunnel. - Retourne {"status": ok|leak|unknown|down, "resolvers": [...], "expected": str} + """Les requêtes de noms empruntent-elles le tunnel ? + + Compare le résolveur réellement actif sur le système au DNS déclaré pour + le tunnel. Une divergence signifie que les noms demandés partent au + résolveur du réseau local — qui apprend donc ce que ce poste consulte, + et peut répondre autre chose que le réseau distant. + + `status` vaut `ok`, `leak`, `down` (plus aucune résolution) ou `unknown`. + `reason` précise ce dernier cas, qui en recouvre trois : sans elle, + l'écran affichait « Statut inconnu » sans dire quoi faire, alors que les + trois causes appellent des gestes différents — monter le tunnel, + renseigner un DNS, ou constater que le système n'en déclare aucun. """ expected_dns = (cfg.wg.get("dns", "") or "").split(",")[0].strip() @@ -1308,15 +1385,17 @@ def dns_leak_test(cfg: Config) -> dict: if lines: probe_ip = lines[-1] + reason = "" if not dns_util.resolution_works(): status = "down" - elif not resolvers: - status = "unknown" elif not tunnel_up: - # Tunnel inactif : parler de fuite n'a pas de sens. - status = "unknown" + # Tunnel inactif : parler de fuite n'a pas de sens, tout le trafic + # passe légitimement par le résolveur local. + status, reason = "unknown", "tunnel_down" + elif not resolvers: + status, reason = "unknown", "no_resolver" elif not expected_dns: - status = "unknown" + status, reason = "unknown", "no_expected" elif any(expected_dns == r or expected_dns in r for r in resolvers): status = "ok" else: @@ -1324,6 +1403,7 @@ def dns_leak_test(cfg: Config) -> dict: return { "status": status, + "reason": reason, "resolvers": resolvers, "expected": expected_dns, "probe_ip": probe_ip, @@ -1333,63 +1413,6 @@ def dns_leak_test(cfg: Config) -> dict: # ── Génération config serveur ───────────────────────────────────────────── -def generate_server_config( - server_port: int = 51820, - server_address: str = "10.8.0.1/24", - client_address: str = "10.8.0.2/24", - client_allowed_ips: str = "10.8.0.2/32", - use_psk: bool = True, -) -> dict: - """ - Génère une paire complète (server_conf, client_conf) avec de nouvelles clés. - Retourne un dict avec server_priv/pub, client_priv/pub, server_conf, client_conf. - - `use_psk=False` produit les deux configurations sans clé pré-partagée : - l'assistant en imposait une des deux côtés, sans moyen de s'en passer. - """ - srv_priv, srv_pub = generate_keypair() - cli_priv, cli_pub = generate_keypair() - psk = generate_preshared_key() if use_psk else "" - psk_line = [f"PresharedKey = {psk}"] if use_psk else [] - - server_conf = "\n".join([ - "[Interface]", - f"PrivateKey = {srv_priv}", - f"Address = {server_address}", - f"ListenPort = {server_port}", - "PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE", - "PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE", - "", - "# === Peer client ===", - "[Peer]", - f"PublicKey = {cli_pub}", - *psk_line, - f"AllowedIPs = {client_allowed_ips}", - ]) - - client_conf = "\n".join([ - "[Interface]", - f"PrivateKey = {cli_priv}", - f"Address = {client_address}", - "DNS = 1.1.1.1", - "", - "[Peer]", - f"PublicKey = {srv_pub}", - *psk_line, - "AllowedIPs = 10.8.0.0/24", - "Endpoint = :" + str(server_port), - "PersistentKeepalive = 25", - ]) - - return { - "server_priv": srv_priv, "server_pub": srv_pub, - "client_priv": cli_priv, "client_pub": cli_pub, - "psk": psk, - "server_conf": server_conf, - "client_conf": client_conf, - } - - def _fmt_bytes(n: int) -> str: for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)): if n >= div: diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 356e217..244a1a8 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -39,8 +39,20 @@ class AdminWindow(QDialog): ) # Le découpage en sous-onglets a supprimé l'empilement qui imposait une # grande fenêtre : chaque page n'affiche plus qu'une section à la fois. - self.setMinimumSize(640, 520) - self.resize(720, 640) + # + # Hauteur relevée de 15 % depuis : le sous-onglet Tunnel compte + # désormais trois groupes titrés au lieu d'un, et les encarts + # explicatifs des pages de test tiennent sur plusieurs lignes. À + # l'ancienne hauteur, les libellés se chevauchaient faute de place. + # + # Le plancher suit, et ce n'est pas cosmétique : il valait 520, alors + # que la page la plus exigeante — « Test connexion », dont le rapport + # réclame 200 px incompressibles — en demande 569 une fois retirés les + # 134 px de bandeau, marges et barre d'onglets. Réduire la fenêtre y + # écrasait les libellés les uns sur les autres. 703 est la hauteur + # mesurée à laquelle plus aucune page non défilable ne déborde. + self.setMinimumSize(640, 703) + self.resize(720, 736) self._build_ui() self._load_values() @@ -142,6 +154,27 @@ class AdminWindow(QDialog): widget.setFixedHeight(field_h) form.addRow(lbl, widget) + def _collapsible_group(self, title: str) -> tuple[QGroupBox, QFormLayout]: + """Groupe replié par défaut, dépliable d'un clic sur son titre. + + `QGroupBox.setCheckable` seul ne replie rien : il grise le contenu en + le laissant occuper la place, ce qui allonge la page sans rien gagner. + D'où le conteneur intermédiaire, masqué et rendu visible à la volée — + la fenêtre se remesure alors sur le contenu réellement affiché. + """ + box = QGroupBox(title) + box.setCheckable(True) + box.setChecked(False) + outer = QVBoxLayout(box) + outer.setContentsMargins(0, 0, 0, 0) + body = QWidget() + body.setVisible(False) + form = QFormLayout(body) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + outer.addWidget(body) + box.toggled.connect(body.setVisible) + return box, form + @staticmethod def _hint(text: str) -> QLabel: """Encart expliquant à quoi sert l'onglet où il figure. @@ -284,38 +317,14 @@ class AdminWindow(QDialog): sep.setStyleSheet(f"background: {theme.BORDER_SOFT}; border: none; max-height: 1px;") lay.addWidget(sep) - # ── Serveur ────────────────────────────────────────────────────── - grp = QGroupBox("Serveur WireGuard") + # ── Clé pré-partagée ───────────────────────────────────────────── + # Adresse, port et clé publique du serveur ont rejoint « Avancé » : + # ils décrivent le pair tel que le serveur le déclare, viennent du + # `.conf` importé et ne se modifient pas à la main. + grp = QGroupBox("Clé pré-partagée (optionnelle)") form = QFormLayout(grp) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) - self._srv_endpoint = QLineEdit() - self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4") - self._srv_endpoint.setToolTip( - "Adresse IP ou nom d'hôte du serveur WireGuard. " - "Si vous utilisez un nom, assurez-vous qu'il est résolvable." - ) - self._row(form, "Adresse serveur :", self._srv_endpoint) - - self._srv_port = QSpinBox() - self._srv_port.setRange(1, 65535) - self._srv_port.setValue(51820) - self._srv_port.setToolTip( - "Port UDP sur lequel le serveur écoute. Un port erroné ne produit\n" - "aucune erreur visible : WireGuard ignore silencieusement les\n" - "paquets, le tunnel monte et le handshake n'arrive jamais." - ) - self._srv_port.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self._row(form, "Port UDP :", self._srv_port) - - self._srv_pubkey = QLineEdit() - self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") - self._srv_pubkey.setToolTip( - "Clé publique du serveur (format base64). " - "Doit correspondre exactement à la clé publique du serveur." - ) - self._row(form, "Clé publique serveur :", self._srv_pubkey) - # Un champ vide ne disait pas si la PSK était oubliée ou volontairement # absente : le diagnostic avertissait donc sur tout serveur qui n'en # utilise pas, c'est-à-dire la configuration WireGuard par défaut. @@ -362,71 +371,23 @@ class AdminWindow(QDialog): lay.addWidget(grp) # ── Interface client ───────────────────────────────────────────── + # Trois groupes, du plus courant au plus rare : ce qu'un .conf importé + # renseigne, le split-DNS, puis le reste — replié. La page alignait + # quinze champs d'un bloc, où l'adresse du client et le MTU avaient le + # même poids visuel alors que l'un se règle toujours et l'autre jamais. grp2 = QGroupBox("Interface client") form2 = QFormLayout(grp2) form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) - self._iface_name = QLineEdit() - self._iface_name.setPlaceholderText("wgs0") - self._iface_name.setToolTip( - "Nom de l'interface réseau WireGuard. " - "Doit être unique et correspondre au nom du service sous Windows." - ) - self._row(form2, "Nom interface :", self._iface_name) - self._client_addr = QLineEdit() self._client_addr.setPlaceholderText("10.8.0.2/24") self._client_addr.setToolTip( - "Adresse IP du client dans le réseau VPN. " - "Format: IP/Masque (ex: 10.8.0.2/24)." + "Adresse IP de ce poste dans le réseau du tunnel, telle que le\n" + "serveur l'a attribuée. Format : IP/Masque (ex. 10.6.0.6/32).\n" + "C'est à cette adresse que le serveur reconnaît ce poste." ) self._row(form2, "Adresse IP client :", self._client_addr) - self._dns = QLineEdit() - self._dns.setPlaceholderText("1.1.1.1") - self._dns.setToolTip( - "Serveur DNS principal utilisé par le tunnel. " - "Laisser vide si vous utilisez le split-DNS ou le DNS système." - ) - 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" - "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) - - 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( - "Serveur DNS dédié pour résoudre les noms du réseau distant. " - "Laisser vide pour utiliser le DNS par défaut." - ) - self._row(form2, "DNS pour réseau distant :", self._split_dns_server) - - self._split_dns_domains = QLineEdit() - self._split_dns_domains.setPlaceholderText("local, internal") - self._split_dns_domains.setToolTip( - "Domaines à résoudre via le DNS distant (séparés par des virgules). " - "Ex: local, internal. Laisser vide pour désactiver le split-DNS." - ) - self._row(form2, "Domaines distants :", self._split_dns_domains) - self._allowed_ips = QLineEdit() self._allowed_ips.setPlaceholderText("10.8.0.0/24") self._allowed_ips.setToolTip( @@ -436,15 +397,110 @@ class AdminWindow(QDialog): ) self._row(form2, "IPs autorisées :", self._allowed_ips) + self._dns = QLineEdit() + self._dns.setPlaceholderText("1.1.1.1") + self._dns.setToolTip( + "Résolveur employé par le tunnel pour tous les noms.\n" + "Laisser vide pour conserver le DNS du système." + ) + self._row(form2, "DNS :", self._dns) + lay.addWidget(grp2) + + # ── Split-DNS ──────────────────────────────────────────────────── + # Regroupés et nommés : trois champs portaient « DNS » dans une seule + # liste, sans que rien ne dise lesquels allaient ensemble. + grp3 = QGroupBox("Split-DNS — noms du réseau distant") + form3 = QFormLayout(grp3) + form3.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + note3 = QLabel( + "Résout les noms internes par le serveur DNS du réseau distant, " + "et tout le reste par le DNS ci-dessus. Les deux champs vont " + "ensemble : l'un sans l'autre ne fait rien." + ) + note3.setWordWrap(True) + note3.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;") + form3.addRow(note3) + + self._split_dns_server = QLineEdit() + self._split_dns_server.setPlaceholderText("192.168.1.210") + self._split_dns_server.setToolTip( + "Serveur DNS du réseau distant, joint par le tunnel.\n" + "Il doit être couvert par les IPs autorisées." + ) + self._row(form3, "Serveur DNS distant :", self._split_dns_server) + + self._split_dns_domains = QLineEdit() + self._split_dns_domains.setPlaceholderText("local, internal") + self._split_dns_domains.setToolTip( + "Domaines confiés au serveur ci-dessus, séparés par des virgules.\n" + "Vide = split-DNS inactif, tout passe par le DNS général." + ) + self._row(form3, "Domaines concernés :", self._split_dns_domains) + lay.addWidget(grp3) + + # ── Avancé ─────────────────────────────────────────────────────── + grp4, form4 = self._collapsible_group( + "Avancé — issus du serveur ou corrects par défaut ; à ne pas modifier") + + # Adresse, port et clé publique du serveur : ils viennent du `.conf` + # fourni par l'administrateur et décrivent un pair déjà déclaré côté + # serveur. Les modifier à la main ne produit aucune erreur visible — + # juste un handshake qui n'aboutit jamais — d'où leur place ici, + # repliée, et leur verrouillage après import. + self._srv_endpoint = QLineEdit() + self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4") + self._srv_endpoint.setToolTip( + "Adresse IP ou nom d'hôte du serveur WireGuard.\n" + "Si vous utilisez un nom, assurez-vous qu'il est résolvable." + ) + self._row(form4, "Adresse serveur :", self._srv_endpoint) + + self._srv_port = QSpinBox() + self._srv_port.setRange(1, 65535) + self._srv_port.setValue(51820) + self._srv_port.setToolTip( + "Port UDP sur lequel le serveur écoute. Un port erroné ne produit\n" + "aucune erreur visible : WireGuard ignore silencieusement les\n" + "paquets, le tunnel monte et le handshake n'arrive jamais." + ) + self._srv_port.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._row(form4, "Port UDP :", self._srv_port) + + self._srv_pubkey = QLineEdit() + self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") + self._srv_pubkey.setToolTip( + "Clé publique du serveur, en base64. Doit correspondre exactement\n" + "à celle que le serveur présente, sans quoi rien ne négocie." + ) + self._row(form4, "Clé publique serveur :", self._srv_pubkey) + + self._iface_name = QLineEdit() + self._iface_name.setPlaceholderText("wgs0") + self._iface_name.setToolTip( + "Nom de l'interface réseau WireGuard. Doit être unique et\n" + "correspondre au nom du service sous Windows." + ) + self._row(form4, "Nom interface :", self._iface_name) + + 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 et des\n" + "IPs autorisées (10.6.0.6/32 + 10.6.0.0/24 → http://10.6.0.1:8000).\n" + "À ne renseigner que si le serveur écoute ailleurs." + ) + self._row(form4, "Serveur d'authentification :", self._api_url) + self._keepalive = QSpinBox() self._keepalive.setRange(0, 300) self._keepalive.setValue(25) self._keepalive.setToolTip( - "Intervalle (en secondes) pour envoyer un paquet keepalive. " - "0 = désactivé. Utile pour maintenir la connexion active à travers un NAT." + "Intervalle en secondes entre deux paquets keepalive. 0 = désactivé.\n" + "Maintient la connexion à travers un NAT." ) self._keepalive.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self._row(form2, "Keepalive (s) :", self._keepalive) + self._row(form4, "Keepalive (s) :", self._keepalive) self._mtu = QSpinBox() self._mtu.setRange(0, 9000) @@ -454,8 +510,8 @@ class AdminWindow(QDialog): "monte mais que les gros paquets ne passent pas." ) self._mtu.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self._row(form2, "MTU :", self._mtu) - lay.addWidget(grp2) + self._row(form4, "MTU :", self._mtu) + lay.addWidget(grp4) lay.addStretch() # Champs verrouillés après import d'un .conf. Le split-DNS et le nom @@ -482,6 +538,12 @@ class AdminWindow(QDialog): jamais). """ self._fields_locked = locked + # Enregistré, pas seulement appliqué : sans cela le verrou tombait à + # la fermeture du panneau, et les champs décrivant le pair + # redevenaient éditables au rechargement sans que rien ne l'annonce. + if self._cfg.get("wg", "config_locked") != locked: + self._cfg.set("wg", "config_locked", locked) + self._cfg.save() for wdg in self._locked_fields: wdg.setEnabled(not locked) if locked: @@ -550,20 +612,6 @@ class AdminWindow(QDialog): self._row(form, "", self._btn_gen_keys) lay.addWidget(grp) - grp2 = QGroupBox("🖥️ Assistant : configuration serveur + client") - v2 = QVBoxLayout(grp2) - note = QLabel( - "Génère une paire complète prête à déployer : nouvelles clés des " - "deux côtés,\nclé pré-partagée si l'option est cochée dans " - "l'onglet Tunnel." - ) - note.setWordWrap(True) - note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;") - v2.addWidget(note) - btn_gen_srv = QPushButton("⚡ Générer config serveur + client") - btn_gen_srv.clicked.connect(self._generate_server_config) - v2.addWidget(btn_gen_srv) - lay.addWidget(grp2) lay.addStretch() return page @@ -588,7 +636,7 @@ class AdminWindow(QDialog): sub = QTabWidget() sub.setStyleSheet(theme.TABBAR_CSS) sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion") - sub.addTab(self._subtab_dns(), "🔍 Fuite DNS") + sub.addTab(self._subtab_dns(), "🌐 Résolution des noms") lay.addWidget(sub) return w @@ -613,11 +661,15 @@ class AdminWindow(QDialog): grp = QGroupBox("Diagnostic de la chaîne de connexion") g = QVBoxLayout(grp) - g.addWidget(QLabel( - "Vérifie toute la chaîne : configuration, clés, route, port UDP,\n" - "montage du tunnel, handshake, trafic et DNS — et désigne l'étape\n" + lbl = QLabel( + "Vérifie toute la chaîne : configuration, clés, route, port UDP, " + "montage du tunnel, handshake, trafic et DNS — et désigne l'étape " "qui bloque." - )) + ) + # Sans repli automatique, les sauts de ligne écrits à la main figeaient + # la largeur du texte : la fenêtre s'élargissait, le libellé non. + lbl.setWordWrap(True) + g.addWidget(lbl) self._btn_diag_test = QPushButton("🚀 Lancer le test de connexion") self._btn_diag_test.setStyleSheet(theme.primary_button_style()) self._btn_diag_test.clicked.connect(self._run_test) @@ -642,21 +694,28 @@ class AdminWindow(QDialog): # ── Sous-onglet : fuite DNS ────────────────────────────────────────── def _subtab_dns(self) -> QWidget: page, v = self._test_page() + # Volontairement court : chaque groupe ci-dessous porte déjà sa propre + # explication, et l'encart les répétait — il occupait 93 px et faisait + # déborder la page, poussant les libellés les uns sur les autres. v.addWidget(self._hint( - "Vérifie que les requêtes de noms empruntent bien le tunnel plutôt " - "que le résolveur du réseau local. La réparation retire une " - "configuration DNS laissée par un tunnel arrêté brutalement, qui " - "empêche toute résolution." + "Le tunnel peut être monté et le trafic passer pendant qu'aucun " + "nom ne se résout : c'est ici que cela se voit, et se répare." )) - grp = QGroupBox("🔍 Test de fuite DNS") + grp = QGroupBox("🔍 Par où sortent les requêtes de noms ?") g = QVBoxLayout(grp) - g.addWidget(QLabel("Vérifie que le DNS passe bien par le tunnel VPN.")) - self._btn_dns_test = QPushButton("🔍 Analyser le DNS") + lbl = QLabel( + "Compare le résolveur réellement actif au DNS déclaré pour le " + "tunnel. Une divergence expose ce que ce poste consulte au réseau " + "local, et peut lui faire obtenir de mauvaises réponses." + ) + lbl.setWordWrap(True) + g.addWidget(lbl) + self._btn_dns_test = QPushButton("🔍 Analyser la résolution des noms") self._btn_dns_test.setStyleSheet(theme.primary_button_style()) self._btn_dns_test.clicked.connect(self._run_dns_test) g.addWidget(self._btn_dns_test) - self._btn_dns_fix = QPushButton("🛠️ Réparer le DNS (tunnel arrêté)") + self._btn_dns_fix = QPushButton("🛠️ Réparer — plus aucun nom ne se résout") self._btn_dns_fix.setToolTip( "Retire la configuration DNS laissée par un tunnel arrêté " "brutalement, qui empêche toute résolution de noms." @@ -683,12 +742,17 @@ class AdminWindow(QDialog): # (un hôte particulier, une forme courte via le suffixe de recherche…). domains = self._cfg.wg.get("split_dns_domains") or [] example = f"hôte.{domains[0]}" if domains else "hôte.domaine.exemple" - grp2 = QGroupBox("🌐 Résoudre un nom précis") + grp2 = QGroupBox("🌐 Résoudre un nom précis (vérifie le split-DNS)") g2 = QVBoxLayout(grp2) - g2.addWidget(QLabel( - f"Ex. : {example}, ou juste le nom court s'il est dans le " + lbl2 = QLabel( + "L'analyse ci-dessus ne dit rien du split-DNS : un nom interne " + "peut recevoir une réponse publique inattendue alors que la " + "résolution « fonctionne ».\n" + f"Ex. : {example}, ou le nom court s'il est couvert par le " "suffixe de recherche du réseau distant." - )) + ) + lbl2.setWordWrap(True) + g2.addWidget(lbl2) resolve_row = QHBoxLayout() resolve_row.setSpacing(6) self._resolve_input = QLineEdit() @@ -924,7 +988,7 @@ class AdminWindow(QDialog): )) note = QLabel( - "Définissez un mot de passe pour protéger l'accès au panneau Administrateur.\n" + "Définissez un mot de passe pour protéger l'accès au panneau Administrateur. " "Laissez vide pour désactiver la protection." ) note.setWordWrap(True) @@ -1040,10 +1104,12 @@ class AdminWindow(QDialog): (f"v{info.VERSION}", theme.ACCENT), (info.LICENSE, "#5b6b7a"), ] - if (self._cfg.wg.get("vpn_username") or "").strip(): - pills.append(("🔐 Code d'accès serveur", theme.OK_SOLID)) - else: - pills.append(("🔐 Aucun code d'accès", "#5b6b7a")) + # Pastille fixe : l'ouverture de l'accès passe par le serveur dans + # tous les cas. Elle se fondait sur la présence d'un identifiant VPN + # en configuration locale, qui n'a jamais rien dit de ce que le + # serveur exige — un poste sans identifiant pouvait être tenu en + # quarantaine, et la pastille annonçait « Aucun code d'accès ». + pills.append(("🔐 Accès validé par le serveur", theme.OK_SOLID)) if not is_windows(): if has_root_privileges(): @@ -1318,7 +1384,6 @@ 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"] @@ -1326,9 +1391,12 @@ class AdminWindow(QDialog): self._priv_key.setText(wg.get("client_private_key", "")) self._pub_key.setText(wg.get("client_public_key", "")) - # Un chargement de profil repart d'une configuration de confiance - # (gérée par WGSecure) : seul un import .conf brut verrouille. - self._set_fields_locked(False) + # Le verrou suit la configuration chargée, profil compris : un profil + # né d'un `.conf` importé décrit un pair déjà déclaré côté serveur et + # reste donc verrouillé. Il était remis à faux ici sans condition, si + # bien qu'il ne survivait ni à un changement de profil ni à une simple + # réouverture du panneau. + self._set_fields_locked(bool(wg.get("config_locked"))) def _save_values(self): self._cfg.set("wg", "server_endpoint", self._srv_endpoint.text().strip()) @@ -1346,7 +1414,6 @@ 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() @@ -1553,18 +1620,26 @@ class AdminWindow(QDialog): def _format_diagnostic(self, steps: list[dict]) -> str: """Met en forme les étapes ; la première en échec porte le verdict.""" from html import escape - rows = [] + # Deux colonnes plutôt que deux lignes par étape : l'étape et son + # détail se lisent d'un seul balayage, et la largeur de la fenêtre est + # employée au lieu d'être laissée blanche à droite. Empilés, dix + # étapes occupaient vingt lignes pour un texte qui en remplissait la + # moitié. Le tableau se replie tout seul dans une fenêtre étroite, + # chaque cellule renvoyant à la ligne. + rows = [''] for st in steps: bullet, color = self._STEP_STYLE.get(st["state"], ("•", theme.TEXT)) weight = "bold" if st["state"] == "fail" else "normal" - detail = escape(st["detail"]) rows.append( - f'
' - f'' - f'{bullet} {escape(st["label"])}' - f'
' - f'    {detail}
' + '' + f'' + f'' + '' ) + rows.append('
{bullet} {escape(st["label"])}' + f'{escape(st["detail"])}
') failed = next((s for s in steps if s["state"] == "fail"), None) if failed: @@ -1623,74 +1698,6 @@ class AdminWindow(QDialog): v.addWidget(note) dlg.exec() - def _generate_server_config(self): - """Génère et affiche une paire complète server + client.""" - from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTabWidget, QTextEdit, QPushButton - result = wg_core.generate_server_config( - server_port=self._srv_port.value() if hasattr(self, '_srv_port') else 51820, - use_psk=self._use_psk.isChecked(), - ) - dlg = QDialog(self) - dlg.setWindowTitle("Config générée — Server + Client") - dlg.setMinimumSize(600, 420) - dlg.setStyleSheet(f"background: {theme.BG}; color: {theme.TEXT};") - v = QVBoxLayout(dlg) - tabs = QTabWidget() - tabs.setStyleSheet(theme.TABBAR_CSS) - for title, content in [("🖥️ Serveur", result["server_conf"]), - ("💻 Client", result["client_conf"])]: - te = QTextEdit() - te.setReadOnly(True) - te.setFont(QFont("Courier", 9)) - te.setPlainText(content) - te.setStyleSheet( - f"background: {theme.BG_SUNKEN}; color: {theme.TEXT_CODE};" - " border: none; font-size: 11px;" - ) - tabs.addTab(te, title) - v.addWidget(tabs) - - note = QLabel( - f"🔑 Clé pub. serveur : {result['server_pub'][:32]}…\n" - f"🔑 Clé pub. client : {result['client_pub'][:32]}…\n" - "⚠️ Remplacez dans la config client par l'IP publique du serveur." - ) - note.setWordWrap(True) - note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 10px; padding: 6px;") - v.addWidget(note) - - # Sans ce bouton, la PSK générée n'existait que dans ce texte : elle - # n'était reportée nulle part, et le tunnel monté avec les clés - # affichées ne négociait jamais avec le serveur. - btn_apply = QPushButton("📥 Appliquer le volet client à cette configuration") - btn_apply.setStyleSheet(theme.primary_button_style()) - - def _apply(): - self._priv_key.setText(result["client_priv"]) - self._pub_key.setText(result["client_pub"]) - self._srv_pubkey.setText(result["server_pub"]) - self._use_psk.setChecked(bool(result["psk"])) - self._psk.setText(result["psk"]) - self._save_values() - dlg.accept() - QMessageBox.information( - self, "Config client appliquée", - "Clés client, clé publique du serveur" - + (" et clé pré-partagée" if result["psk"] else "") - + " reportées dans la configuration.\n\n" - "⚠ Déployez le volet serveur sur la machine distante et " - "renseignez son adresse publique." - ) - - btn_apply.clicked.connect(_apply) - v.addWidget(btn_apply) - - btn_close = QPushButton("Fermer") - btn_close.setStyleSheet(theme.secondary_button_style()) - btn_close.clicked.connect(dlg.accept) - v.addWidget(btn_close) - dlg.exec() - def _run_dns_test(self): if self._dns_worker is not None: return @@ -1705,30 +1712,51 @@ class AdminWindow(QDialog): def _on_dns_test_done(self, r: dict | None): self._dns_worker = None self._btn_dns_test.setEnabled(True) - r = r or {"status": "unknown", "resolvers": [], "expected": ""} - lines = [] + r = r or {"status": "unknown", "reason": "", "resolvers": [], "expected": ""} status = r["status"] - if status == "ok": - lines.append("✅ Aucune fuite DNS détectée") - state = "ok" - elif status == "leak": - lines.append("⚠️ Fuite DNS potentielle !") - state = "fail" - elif status == "down": - lines.append("🚫 Résolution DNS hors service — utilisez « Réparer le DNS »") - state = "fail" - elif not r.get("tunnel_up", False): - lines.append("ℹ️ Tunnel inactif : la comparaison n'a pas de sens") - state = "skip" - else: - lines.append("❓ Statut inconnu") - state = "skip" + # Chaque verdict dit ce qu'il constate *et* ce qu'il reste à faire. + # « Statut inconnu » seul renvoyait l'utilisateur à lui-même, alors + # que les trois causes possibles appellent trois gestes distincts. + verdicts = { + "ok": ("✅ Aucune fuite : les noms sont résolus par le DNS du tunnel.", "ok"), + "leak": ( + "⚠️ Fuite DNS : les noms partent au résolveur du réseau local,\n" + " pas à celui du tunnel. Ce résolveur voit donc ce que ce poste\n" + " consulte, et peut répondre autre chose que le réseau distant.\n" + " → Vérifiez le champ DNS de l'onglet Tunnel, et que le système\n" + " dispose de resolvconf ou systemd-resolved.", "fail"), + "down": ( + "🚫 Plus aucun nom ne se résout sur ce poste.\n" + " Souvent le reliquat d'un tunnel arrêté brutalement.\n" + " → Bouton « Réparer le DNS » ci-dessous, tunnel arrêté.", "fail"), + } + reasons = { + "tunnel_down": ( + "ℹ️ Tunnel arrêté : rien à comparer, tout passe légitimement par\n" + " le résolveur local. → Connectez-vous, puis relancez ce test.", "skip"), + "no_expected": ( + "ℹ️ Aucun DNS n'est déclaré pour le tunnel : il n'y a donc pas de\n" + " valeur attendue à comparer. → Renseignez le champ DNS de\n" + " l'onglet Tunnel si les noms doivent passer par le tunnel.", "skip"), + "no_resolver": ( + "❓ Le système ne déclare aucun résolveur exploitable.\n" + " → Vérifiez resolvconf ou systemd-resolved.", "skip"), + } + head, state = verdicts.get(status) or reasons.get( + r.get("reason", ""), ("❓ Statut indéterminé.", "skip")) + lines = [head, ""] if r["resolvers"]: - lines.append("Serveurs DNS actifs : " + " • ".join(r["resolvers"])) + lines.append("Résolveurs actifs sur le système : " + " • ".join(r["resolvers"])) if r["expected"]: - lines.append(f"DNS configuré (VPN) : {r['expected']}") + lines.append(f"DNS attendu (celui du tunnel) : {r['expected']}") if r.get("probe_ip"): - lines.append(f"IP retournée par whoami.akamai.net : {r['probe_ip']}") + # Sans cette phrase, l'adresse s'affichait sans que rien ne dise + # ce qu'elle vaut ni d'où elle sort. + lines.append( + f"Résolveur récursif vu depuis Internet : {r['probe_ip']}\n" + " (adresse que whoami.akamai.net renvoie : c'est le résolveur qui\n" + " a réellement posé la question sur Internet, pas forcément celui\n" + " que ce poste interroge en premier.)") self._dns_result.setPlainText("\n".join(lines)) self._dns_result.setStyleSheet(theme.result_view_style(state))