fix: handshake bloqué par la passerelle dans les IPs autorisées

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.
This commit is contained in:
tuxgyver
2026-09-01 13:35:56 +02:00
parent 9d785c00cc
commit f6c3622e1d
2 changed files with 116 additions and 28 deletions
+52 -1
View File
@@ -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)} : "
+63 -26
View File
@@ -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,6 +122,24 @@ class AdminWindow(QDialog):
body_layout = QVBoxLayout(body)
body_layout.setContentsMargins(14, 14, 14, 14)
body_layout.setSpacing(10)
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(
'<span style="color:rgba(255,255,255,0.6);">Diagnostic en cours…</span>'
)
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."""