From f6c3622e1d62001c352313ed61b066bc32de5a3c Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Tue, 1 Sep 2026 13:35:56 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20handshake=20bloqu=C3=A9=20par=20la=20pas?= =?UTF-8?q?serelle=20dans=20les=20IPs=20autoris=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quand la passerelle qui mène au serveur appartient à une plage des « IPs autorisées », wg-quick pose sa route vers le tunnel avec une métrique de 0. Elle bat la route de lien de la carte réseau : la passerelle n'est plus joignable que par le tunnel, alors que le tunnel a besoin d'elle pour sortir. Le tunnel montait, l'interface existait, et plus aucun paquet de handshake ne quittait la machine. Le diagnostic détecte désormais ce cas via _next_hop() (ip route get sous Linux, Find-NetRoute sous Windows) et le classe en échec, au lieu du simple avertissement sur l'imprimante et le NAS qui n'en disait pas la gravité. Corrige aussi le rapport de test de connexion : un QLabel que la mise en page comprimait à quelques lignes tronquées, remplacé par une zone de texte défilante ; la fenêtre d'administration, figée à 700x600, devient redimensionnable. --- app/core/wireguard.py | 53 +++++++++++++++++++++++- app/ui/admin_window.py | 91 +++++++++++++++++++++++++++++------------- 2 files changed, 116 insertions(+), 28 deletions(-) diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 174293f..eff8f3c 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -458,6 +458,36 @@ def _local_route_source(ip: str, port: int) -> str: return "" +def _next_hop(ip: str) -> str: + """Passerelle réellement empruntée pour joindre `ip`, "" si lien direct. + + Une plage « IPs autorisées » qui contient cette passerelle est fatale : + `wg-quick` pose sa route avec une métrique de 0, elle bat la route de lien + de l'interface physique, la passerelle devient joignable uniquement par le + tunnel — et les paquets de handshake, qui doivent sortir *par* elle, ne + quittent plus jamais la machine. + """ + if is_windows(): + code, out, _ = run_command( + ["powershell", "-NoProfile", "-Command", + f"(Find-NetRoute -RemoteIPAddress {ip}).NextHop"], 10) + if code != 0: + return "" + for line in out.splitlines(): + hop = line.strip() + if hop and hop not in ("0.0.0.0", "::"): + return hop + return "" + + code, out, _ = run_command(["ip", "route", "get", ip], 10) + if code != 0: + return "" + parts = out.split() + if "via" in parts: + return parts[parts.index("via") + 1] + return "" + + def _diag_config(cfg: Config) -> list[dict]: wg = cfg.wg steps: list[dict] = [] @@ -569,7 +599,28 @@ def _diag_reachability(cfg: Config, timeout: int) -> tuple[list[dict], str]: except ValueError: local_addr = None clash = [str(n) for n in allowed if local_addr and local_addr in n] - if clash: + + # Cas mortel du lot : la passerelle par défaut tombe dans une plage + # autorisée. `wg-quick` route la plage vers le tunnel avec une + # métrique de 0, ce qui bat la route de lien de la carte physique ; la + # passerelle n'est plus joignable que *par* le tunnel, alors que le + # tunnel a besoin d'elle pour sortir. Le tunnel monte, l'interface + # existe, et plus un seul paquet de handshake ne quitte la machine. + hop = _next_hop(server_ip) + try: + hop_addr = ipaddress.ip_address(hop) if hop else None + except ValueError: + hop_addr = None + hop_clash = [str(n) for n in allowed if hop_addr and hop_addr in n] + if hop_clash: + steps.append(_step( + _FAIL, "Cohérence des « IPs autorisées »", + f"La passerelle qui mène au serveur ({hop}) est incluse dans " + f"{', '.join(hop_clash)} : dès que le tunnel monte, cette " + f"plage est routée vers lui, la passerelle devient injoignable " + f"et le handshake n'arrive jamais. Retirez " + f"{', '.join(hop_clash)} des IPs autorisées.")) + elif clash: steps.append(_step( _WARN, "Cohérence des « IPs autorisées »", f"Votre réseau local ({src}) est couvert par {', '.join(clash)} : " diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 6453502..74903ff 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -66,6 +66,16 @@ _TAB_CSS = """ """ +_RESULT_CSS = ( + "QTextEdit { border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;" + " padding: 8px; font-size: 12px; }" + "QScrollBar:vertical { background: transparent; width: 8px; border: none; }" + "QScrollBar::handle:vertical { background: #2e4057; border-radius: 4px; }" + "QScrollBar::add-line:vertical," + "QScrollBar::sub-line:vertical { height: 0; }" +) + + class AdminWindow(QDialog): def __init__(self, config: Config, parent=None): super().__init__(parent) @@ -76,15 +86,24 @@ class AdminWindow(QDialog): | Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowCloseButtonHint ) - self.setFixedSize(700, 600) + # Taille figée auparavant : le rapport de diagnostic, long d'une + # dizaine d'étapes détaillées, était comprimé en une bande illisible. + self.setMinimumSize(720, 620) + self.resize(880, 740) self._build_ui() self._load_values() # ------------------------------------------------------------------ # # Helper : page sombre avec bandeau coloré # ------------------------------------------------------------------ # - def _dark_page(self, banner_text: str, banner_color: str): - """Retourne (page_widget, body_layout) : fond sombre + bandeau.""" + def _dark_page(self, banner_text: str, banner_color: str, + scroll: bool = False): + """Retourne (page_widget, body_layout) : fond sombre + bandeau. + + `scroll=True` place le corps dans une zone défilante : indispensable + pour les pages dont le contenu grandit à l'usage (rapport de + diagnostic), sinon les widgets se compriment jusqu'à l'illisible. + """ w = QWidget() w.setStyleSheet(_TAB_CSS) outer = QVBoxLayout(w) @@ -103,7 +122,25 @@ class AdminWindow(QDialog): body_layout = QVBoxLayout(body) body_layout.setContentsMargins(14, 14, 14, 14) body_layout.setSpacing(10) - outer.addWidget(body) + + if scroll: + from PyQt6.QtWidgets import QScrollArea + body.setStyleSheet("background: #1c2833;") + area = QScrollArea() + area.setWidgetResizable(True) + area.setStyleSheet( + "QScrollArea { border: none; background: #1c2833; }" + "QScrollBar:vertical { background: #1c2833; width: 8px;" + " border: none; }" + "QScrollBar::handle:vertical { background: #2e4057;" + " border-radius: 4px; }" + "QScrollBar::add-line:vertical," + "QScrollBar::sub-line:vertical { height: 0; }" + ) + area.setWidget(body) + outer.addWidget(area) + else: + outer.addWidget(body) return w, body_layout # ------------------------------------------------------------------ # @@ -456,7 +493,8 @@ class AdminWindow(QDialog): # Onglet Test connexion # ------------------------------------------------------------------ # def _tab_test(self) -> QWidget: - w, lay = self._dark_page("📡 Test de connectivité réseau", "#1a4a7a") + w, lay = self._dark_page("📡 Test de connectivité réseau", "#1a4a7a", + scroll=True) # Test UDP serveur grp1 = QGroupBox("Diagnostic de la chaîne de connexion") @@ -474,20 +512,22 @@ class AdminWindow(QDialog): ) btn_test.clicked.connect(self._run_test) g1.addWidget(btn_test) - self._test_result = QLabel("") - self._test_result.setWordWrap(True) - self._test_result.setTextFormat(Qt.TextFormat.RichText) - self._test_result.setAlignment( - Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop + # QLabel auparavant : le rapport fait une dizaine d'étapes avec un + # détail par étape, et un QLabel se laisse écraser par le layout — + # le texte était rogné à quelques lignes illisibles. Une vue en + # lecture seule porte sa propre barre de défilement : le rapport reste + # entier quelle que soit la taille de la fenêtre. + self._test_result = QTextEdit() + self._test_result.setReadOnly(True) + self._test_result.setMinimumHeight(320) + self._test_result.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) - self._test_result.setTextInteractionFlags( - Qt.TextInteractionFlag.TextSelectableByMouse - ) - self._test_result.setStyleSheet( - "color: white; padding: 8px; border-radius: 6px; font-size: 12px;" - ) - g1.addWidget(self._test_result) - lay.addWidget(grp1) + self._test_result.setStyleSheet(_RESULT_CSS + "color: white;") + g1.addWidget(self._test_result, 1) + # Facteur d'étirement : l'espace vertical libre revient au rapport de + # diagnostic plutôt qu'au ressort de fin de page. + lay.addWidget(grp1, 1) # Test DNS leak grp2 = QGroupBox("🔍 Test de fuite DNS") @@ -528,7 +568,6 @@ class AdminWindow(QDialog): g3.addWidget(self._priv_result) lay.addWidget(grp3) - lay.addStretch() return w def _refresh_profile_list(self): @@ -705,7 +744,7 @@ class AdminWindow(QDialog): name_lbl.setStyleSheet("color: white;") layout.addWidget(name_lbl) - version_lbl = QLabel("Version 0.4.5") + version_lbl = QLabel("Version 0.4.6") version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;") layout.addWidget(version_lbl) @@ -905,14 +944,14 @@ class AdminWindow(QDialog): def _run_test(self): from PyQt6.QtWidgets import QApplication self._save_values() - self._test_result.setText("Diagnostic en cours…") - self._test_result.setStyleSheet( - "color: rgba(255,255,255,0.6); padding: 12px; border-radius: 6px; font-size: 13px;" + self._test_result.setHtml( + 'Diagnostic en cours…' ) + self._test_result.setStyleSheet(_RESULT_CSS + "color: rgba(255,255,255,0.6);") QApplication.processEvents() steps = wg_core.diagnose_connection(self._cfg) - self._test_result.setText(self._format_diagnostic(steps)) + self._test_result.setHtml(self._format_diagnostic(steps)) states = [s["state"] for s in steps] if "fail" in states: @@ -921,9 +960,7 @@ class AdminWindow(QDialog): css = "background: rgba(241,196,15,0.15); color: #f7dc6f;" else: css = "background: rgba(39,174,96,0.20); color: #a9dfbf;" - self._test_result.setStyleSheet( - "padding: 12px; border-radius: 6px; font-size: 12px; " + css - ) + self._test_result.setStyleSheet(_RESULT_CSS + "QTextEdit { " + css + " }") def _format_diagnostic(self, steps: list[dict]) -> str: """Met en forme les étapes ; la première en échec porte le verdict."""