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))