feat(ui): panneau allégé, et le diagnostic nomme le chevauchement de plages
L'assistant « configuration serveur + client » disparaît. Il datait d'avant la v0.8.0 et produisait un serveur WireGuard nu — sans API, sans quarantaine, sans TOTP — incompatible avec le produit actuel, où le serveur enrôle le compte et fournit un .conf. Son bouton « Appliquer le volet client » écrasait clés client et clé publique du serveur : sur un poste déjà enrôlé, il détruisait le lien avec le serveur, qui déclare l'ancienne clé publique. L'identifiant VPN disparaît aussi, devenu sans emploi depuis que le serveur reconnaît le poste à l'adresse de son tunnel. Un champ qui ne fait rien est pire qu'un champ absent : il laisse croire qu'il compte. Les configurations existantes sont purgées au chargement, profils compris. La pastille « À propos » qui en déduisait « Code d'accès serveur » ou « Aucun code d'accès » n'affirme plus rien qu'elle ne sache : l'accès passe par le serveur dans tous les cas. Le sous-onglet Tunnel passe d'une liste de quinze champs à trois groupes : l'essentiel, le split-DNS nommé comme tel — trois champs y portaient « DNS » sans que rien ne dise lesquels allaient ensemble — et un repli « Avancé ». Celui-ci réunit ce qui vient du serveur (adresse, port, clé publique) et ce qui est correct par défaut (nom d'interface, URL de l'API, keepalive, MTU). Le verrouillage après import d'un .conf devient persistant. Il existait déjà, mais `_load_values` le levait sans condition : rouvrir le panneau ou changer de profil rendait éditables, en silence, les champs décrivant un pair déjà déclaré côté serveur. L'état est désormais enregistré et suit la configuration chargée. Le diagnostic gagne une étape « Serveur DNS du réseau distant », qui couvre un angle mort : le tunnel monte, le trafic passe, la résolution est au vert, et pas un nom interne ne se résout. Deux causes, l'une et l'autre muettes — le serveur DNS distant n'est couvert par aucune IP autorisée, ou il l'est mais son adresse tombe aussi dans une plage que porte une carte du poste, auquel cas la route locale l'emporte. Le second cas ne se déduit pas de la configuration : l'étape interroge la route réellement retenue par le noyau, sans émettre de paquet, et propose la route d'hôte qui reprend la main. Le sous-onglet « Fuite DNS » devient « Résolution des noms » : il en faisait trois choses sous un titre qui n'en annonçait qu'une. Son verdict « Statut inconnu » recouvrait trois causes appelant trois gestes différents ; chacune est maintenant nommée, avec l'action qui suit. Enfin, la fenêtre. Sa hauteur augmente de 15 %, et son plancher passe de 520 à 703 : la page de test réclame 569 px et se chevauchait en dessous. Le rapport de diagnostic passe en tableau à deux colonnes — il occupait 224 px quelle que soit la largeur de la fenêtre, soit jusqu'à 79 % de blanc à droite, et tient maintenant sur 38 % de hauteur en moins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dd899fadc6
commit
b3cf706bb6
+255
-227
@@ -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 = ['<table width="100%" cellspacing="0" cellpadding="0">']
|
||||
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'<div style="margin-bottom:5px;">'
|
||||
f'<span style="color:{color}; font-weight:{weight};">'
|
||||
f'{bullet} {escape(st["label"])}</span>'
|
||||
f'<br><span style="color:{theme.TEXT_MUTED}; font-size:11px;">'
|
||||
f' {detail}</span></div>'
|
||||
'<tr>'
|
||||
f'<td width="42%" style="padding-bottom:5px; color:{color};'
|
||||
f' font-weight:{weight};">{bullet} {escape(st["label"])}</td>'
|
||||
f'<td style="padding-bottom:5px; padding-left:10px;'
|
||||
f' color:{theme.TEXT_MUTED}; font-size:11px;">'
|
||||
f'{escape(st["detail"])}</td>'
|
||||
'</tr>'
|
||||
)
|
||||
rows.append('</table>')
|
||||
|
||||
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 <SERVER_IP> 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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user