feat: test de handshake réel sans monter le tunnel

Le diagnostic s'arrêtait sur « le port UDP ne rejette rien ». C'est le maximum
qu'une sonde aveugle puisse dire : un serveur WireGuard ignore silencieusement
tout paquet non authentifié, si bien qu'un port non redirigé, une clé inconnue
du serveur et un serveur en parfaite santé produisent exactement la même
absence de réponse. Le tunnel n'étant pas monté, les étapes suivantes étaient
sautées et le rapport concluait « chaîne validée » sur une configuration qui
n'avait aucune chance de négocier.

WGSecure construit désormais un vrai message d'initiation (Noise_IKpsk2_25519_
ChaChaPoly_BLAKE2s) et interprète la réponse. Le serveur ne répond que si notre
clé publique statique lui est connue et si sa propre clé est celle que nous
croyons : une réponse prouve la configuration, et son déchiffrement dit quelle
clé pré-partagée est en jeu. Aucun tunnel n'est monté, aucun privilège demandé,
aucun état conservé.

Quand le serveur reste muet, une seconde tentative part depuis le port du
serveur. S'il répond alors, c'est qu'un équipement en chemin n'accepte le
trafic que si le port source égale le port de destination — une règle courante
sur FortiGate, dont l'objet Service porte un udp-portrange 51820:51820. Un
client WireGuard émettant depuis un port aléatoire ne peut jamais la
satisfaire, et rien nulle part ne le disait.

Corrige aussi le verdict rendu quand le tunnel est inactif : « chaîne validée »
laissait croire que tout avait été vérifié, alors que les étapes sautées sont
précisément celles qui échouent. Le rapport les nomme désormais.

La fenêtre d'administration revient à 720x600 et les encarts posés au-dessus
des sous-onglets disparaissent, où ils répétaient ce que les sous-onglets
annoncent déjà.
This commit is contained in:
tuxgyver
2026-09-01 15:09:28 +02:00
parent c4a9c59fec
commit 1f555eafc7
3 changed files with 344 additions and 29 deletions
+23 -29
View File
@@ -24,10 +24,10 @@ class AdminWindow(QDialog):
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowCloseButtonHint
)
# 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)
# 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, 600)
self._build_ui()
self._load_values()
# L'aperçu s'ouvrait vide : il fallait cliquer « Rafraîchir » pour
@@ -191,12 +191,7 @@ class AdminWindow(QDialog):
tunnel, les clés, puis les fichiers.
"""
w, lay = self._dark_page("🛡️ WireGuard", theme.BANNER["wireguard"])
lay.setContentsMargins(10, 10, 10, 0)
lay.addWidget(self._hint(
"Tout ce qui définit le tunnel : les paramètres du pair, vos clés, "
"et les profils enregistrés. Les modifications ne prennent effet "
"qu'après « Enregistrer et fermer »."
))
lay.setContentsMargins(0, 0, 0, 0)
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
@@ -453,7 +448,7 @@ class AdminWindow(QDialog):
self._config_preview = QTextEdit()
self._config_preview.setReadOnly(True)
self._config_preview.setFont(QFont("Courier", 9))
self._config_preview.setMinimumHeight(170)
self._config_preview.setMinimumHeight(140)
self._config_preview.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
@@ -512,7 +507,7 @@ class AdminWindow(QDialog):
v2 = QVBoxLayout(grp2)
self._qr_label = QLabel("(générez un secret pour afficher le QR Code)")
self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._qr_label.setFixedHeight(230)
self._qr_label.setFixedHeight(190)
v2.addWidget(self._qr_label)
self._mfa_uri_label = QLabel("")
self._mfa_uri_label.setWordWrap(True)
@@ -537,12 +532,7 @@ class AdminWindow(QDialog):
dispose maintenant de toute la hauteur.
"""
w, lay = self._dark_page("📡 Tests & diagnostic", theme.BANNER["test"])
lay.setContentsMargins(10, 10, 10, 0)
lay.addWidget(self._hint(
"Trois vérifications indépendantes : où la chaîne de connexion se "
"rompt, si le DNS fuit hors du tunnel, et si les droits "
"nécessaires au montage sont réunis."
))
lay.setContentsMargins(0, 0, 0, 0)
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
@@ -590,7 +580,7 @@ class AdminWindow(QDialog):
# entier quelle que soit la taille de la fenêtre.
self._test_result = QTextEdit()
self._test_result.setReadOnly(True)
self._test_result.setMinimumHeight(300)
self._test_result.setMinimumHeight(200)
self._test_result.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
@@ -626,7 +616,7 @@ class AdminWindow(QDialog):
self._dns_result = QTextEdit()
self._dns_result.setReadOnly(True)
self._dns_result.setMinimumHeight(220)
self._dns_result.setMinimumHeight(150)
self._dns_result.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
@@ -684,7 +674,7 @@ class AdminWindow(QDialog):
self._priv_result = QTextEdit()
self._priv_result.setReadOnly(True)
self._priv_result.setMinimumHeight(220)
self._priv_result.setMinimumHeight(150)
self._priv_result.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
@@ -819,11 +809,7 @@ class AdminWindow(QDialog):
qui en comptait sept.
"""
w, lay = self._dark_page("🔒 Sécurité", theme.BANNER["security"])
lay.setContentsMargins(10, 10, 10, 0)
lay.addWidget(self._hint(
"Qui a le droit de monter le tunnel, et qui a le droit "
"d'administrer WGSecure."
))
lay.setContentsMargins(0, 0, 0, 0)
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
@@ -896,7 +882,7 @@ class AdminWindow(QDialog):
name_lbl.setStyleSheet("color: white;")
layout.addWidget(name_lbl)
version_lbl = QLabel("Version 0.5.0")
version_lbl = QLabel("Version 0.6.0")
version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;")
layout.addWidget(version_lbl)
@@ -1143,9 +1129,17 @@ class AdminWindow(QDialog):
verdict = (f'<div style="margin-bottom:9px; font-weight:bold;">'
f'⛔ Blocage : {escape(failed["label"])}</div>')
elif any(s["state"] == "skip" for s in steps):
# « Chaîne validée » auparavant : le mot laissait croire que tout
# avait été vérifié, alors que les étapes sautées sont justement
# celles qui échouent quand un tunnel refuse de négocier.
skipped = ", ".join(s["label"] for s in steps
if s["state"] == "skip")
verdict = ('<div style="margin-bottom:9px; font-weight:bold;">'
'➡️ Chaîne validée jusqu\'au tunnel — reste à le monter'
'</div>')
'➡️ Rien de bloquant détecté, mais le tunnel est '
'inactif</div>'
'<div style="margin-bottom:9px;">Non vérifié faute de '
f'tunnel monté : {escape(skipped)}. Connectez-vous, '
'puis relancez ce test.</div>')
else:
verdict = ('<div style="margin-bottom:9px; font-weight:bold;">'
'✅ Chaîne de connexion complète</div>')