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."""