2 Commits
Author SHA1 Message Date
tuxgyver 414144560e chore(release): v0.10.0 2026-09-08 12:20:19 +02:00
tuxgyverandClaude Opus 5 b3cf706bb6 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>
2026-09-08 12:18:08 +02:00
8 changed files with 398 additions and 304 deletions
+23
View File
@@ -6,6 +6,29 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
--- ---
## [0.10.0] — 2026-09-08
### Supprimé
- **L'assistant « configuration serveur + client ».** Il datait d'avant la v0.8.0 et produisait un serveur WireGuard nu — sans API d'authentification, sans quarantaine, sans code à 6 chiffres — c'est-à-dire un modèle incompatible avec le fonctionnement actuel, où le serveur enrôle le compte et fournit le fichier `.conf`. Son bouton « Appliquer le volet client » écrasait de surcroît les clés du client et la clé publique du serveur : sur un poste déjà enrôlé, il rompait le lien avec le serveur, qui déclare l'ancienne clé publique, sans que rien ne l'annonce.
- **Le champ Identifiant VPN**, 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 en sont purgées au chargement, profils compris.
### Modifié
- **Le sous-onglet Tunnel passe d'une liste de quinze champs à trois groupes** : l'essentiel — adresse du client, IPs autorisées, DNS —, le split-DNS enfin nommé comme tel, et un repli « Avancé ». Trois champs portaient « DNS » dans la même liste sans que rien ne dise lesquels allaient ensemble.
- **Ce qui vient du serveur est replié et verrouillé** : adresse, port et clé publique du serveur rejoignent « Avancé », aux côtés du nom d'interface, de l'URL de l'API, du keepalive et du MTU. Ils décrivent un pair déjà déclaré côté serveur ; les modifier à la main ne produit aucune erreur visible, seulement un handshake qui n'aboutit jamais.
- **Le sous-onglet « Fuite DNS » devient « Résolution des noms »** : il 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 — tunnel arrêté, aucun DNS déclaré, aucun résolveur système — chacune est désormais nommée avec l'action qui suit.
- La pastille de la page « À propos » n'affirme plus rien qu'elle ne sache : elle déduisait « Code d'accès serveur » ou « Aucun code d'accès » de la seule configuration locale, alors qu'un poste sans identifiant pouvait parfaitement être tenu en quarantaine.
### Ajouté
- **Nouvelle étape de diagnostic « Serveur DNS du réseau distant »**, qui comble 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 jusqu'ici. Ou bien le serveur DNS distant n'est couvert par aucune « IP autorisée » et rien ne le route dans le tunnel ; ou bien il l'est, mais son adresse tombe aussi dans une plage que porte une carte du poste — cas d'un réseau distant en 192.168.1.0/24 vu depuis un poste chez lui — et la route locale l'emporte. Ce second cas ne se déduit pas de la configuration : l'étape interroge la route réellement retenue par le noyau, sans émettre le moindre paquet, et propose la route d'hôte qui reprend la main.
### Corrigé
- **Le verrouillage des champs issus d'un `.conf` importé ne survivait pas à la fermeture du panneau.** Il existait depuis la v0.8.3, mais le chargement des valeurs le levait sans condition : rouvrir le panneau Administrateur, ou simplement changer de profil, rendait de nouveau éditables — en silence — l'adresse, le port et la clé publique du serveur. L'état est désormais enregistré et suit la configuration chargée, profils compris.
- **Le rapport de diagnostic n'occupait qu'une fraction de la largeur disponible.** Chaque étape s'écrivait sur deux lignes, libellé puis détail indenté : le texte tenait sur 224 px quelle que soit la taille de la fenêtre, laissant jusqu'à 79 % de blanc à droite. Il est passé en tableau à deux colonnes, occupe toute la largeur et tient sur 38 % de hauteur en moins.
- **La fenêtre du panneau Administrateur était trop courte et les libellés s'y chevauchaient.** Sa hauteur augmente de 15 %, et surtout son plancher passe de 520 à 703 px : la page de test réclame 569 px de contenu incompressible, et toute réduction en deçà écrasait les champs les uns sur les autres.
- Deux encarts explicatifs portaient des retours à la ligne écrits à la main, qui figeaient leur largeur : la fenêtre s'élargissait, le texte non.
---
## [0.9.1] — 2026-09-08 ## [0.9.1] — 2026-09-08
### Modifié ### Modifié
+1 -1
View File
@@ -4,7 +4,7 @@
## ────────────────────────────────────────────── ## ──────────────────────────────────────────────
APP := wgsecure APP := wgsecure
VERSION := 0.9.1 VERSION := 0.10.0
VENV := .venv VENV := .venv
PYTHON := $(VENV)/bin/python3 PYTHON := $(VENV)/bin/python3
PIP := $(VENV)/bin/pip PIP := $(VENV)/bin/pip
+2 -2
View File
@@ -1,6 +1,6 @@
# 🛡️ WGSecure (WGS) # 🛡️ WGSecure (WGS)
🚀 v0.9.1 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard 🚀 v0.10.0 · 🐍 · 🪟🐧 · 🔐 Accès validé par le serveur · 🛡️ WireGuard
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**. **WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard dont l'accès aux ressources distantes est ouvert par un code à 6 chiffres validé **par le serveur**.
@@ -210,4 +210,4 @@ WGSecure/
## 👤 Auteur ## 👤 Auteur
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
Version : ![v0.9.1](https://img.shields.io/badge/v0.9.1-septembre%202026-2980b9?style=flat-square) — Septembre 2026 Version : ![v0.10.0](https://img.shields.io/badge/v0.10.0-septembre%202026-2980b9?style=flat-square) — Septembre 2026
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.9.1" __version__ = "0.10.0"
APP_NAME = "WGSecure" APP_NAME = "WGSecure"
APP_SHORT = "WGS" APP_SHORT = "WGS"
+27 -7
View File
@@ -36,11 +36,17 @@ _WG_DEFAULT: dict[str, Any] = {
# Split-DNS : serveur DNS dédié pour les noms du réseau distant, # Split-DNS : serveur DNS dédié pour les noms du réseau distant,
# avec liste de domaines à résoudre via ce serveur (ex: ["local", "internal"]). # avec liste de domaines à résoudre via ce serveur (ex: ["local", "internal"]).
# Laisser vide pour utiliser le DNS par défaut pour tout le trafic. # Laisser vide pour utiliser le DNS par défaut pour tout le trafic.
# Authentification auprès du serveur après montage du tunnel : identifiant # Authentification auprès du serveur après montage du tunnel : URL de son
# du compte VPN tel que connu du serveur, et URL de son API. Laissée vide, # API. Laissée vide, elle est déduite de l'adresse du client et des IPs
# celle-ci est déduite de l'adresse du client (cf. vpn_session.py). # autorisées (cf. vpn_session.py). Aucun identifiant n'est stocké ni
"vpn_username": "", # transmis : le serveur reconnaît le poste à l'IP source de son tunnel.
"api_url": "", "api_url": "",
# Champs décrivant le pair tel que le serveur le connaît (adresse, port,
# clé publique, IPs autorisées…) verrouillés parce qu'ils viennent d'un
# `.conf` importé. Persisté : le verrou ne tenait que le temps d'une
# session du panneau, et rouvrir celui-ci rendait ces champs éditables
# sans que rien ne le signale.
"config_locked": False,
"split_dns_server": "", "split_dns_server": "",
"split_dns_domains": [], "split_dns_domains": [],
} }
@@ -85,7 +91,9 @@ class Config:
self._apply_autostart(self._data["ui"]["autostart"]) self._apply_autostart(self._data["ui"]["autostart"])
def _drop_local_mfa(self) -> None: def _drop_local_mfa(self) -> None:
"""Efface le secret TOTP local hérité des versions ≤ 0.8.0. """Efface les réglages d'authentification devenus caducs.
Le secret TOTP local hérité des versions 0.8.0.
Ce secret ne sert plus : le code à 6 chiffres est désormais validé Ce secret ne sert plus : le code à 6 chiffres est désormais validé
par le serveur, contre un secret que le poste client ne détient pas. par le serveur, contre un secret que le poste client ne détient pas.
@@ -94,10 +102,22 @@ class Config:
au chargement plutôt qu'un simple abandon des clés. au chargement plutôt qu'un simple abandon des clés.
""" """
stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data] stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data]
if not stale:
return
for key in stale: for key in stale:
del self._data[key] del self._data[key]
# `vpn_username` a disparu en v0.9.2 : le serveur reconnaît le poste à
# l'IP source de son tunnel, l'identifiant ne lui est plus transmis.
# Le retirer plutôt que l'ignorer, sinon `config.json` conserverait
# indéfiniment un réglage que plus rien ne lit et que l'utilisateur
# croirait encore actif.
if "vpn_username" in self._data.get("wg", {}):
del self._data["wg"]["vpn_username"]
stale.append("wg.vpn_username")
for profile in self._data.get("profiles", {}).values():
if isinstance(profile, dict) and "vpn_username" in profile:
del profile["vpn_username"]
stale.append("profil")
if not stale:
return
self.save() self.save()
def save(self): def save(self):
+88 -65
View File
@@ -1044,6 +1044,8 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
steps.append(_step(_OK, "Pair joignable dans le tunnel", steps.append(_step(_OK, "Pair joignable dans le tunnel",
f"{gateway} répond en {ms} ms")) f"{gateway} répond en {ms} ms"))
steps += _diag_split_dns(cfg)
if not dns_util.resolution_works(): if not dns_util.resolution_works():
steps.append(_step(_FAIL, "Résolution DNS dans le tunnel", steps.append(_step(_FAIL, "Résolution DNS dans le tunnel",
"Plus aucun nom ne se résout — utilisez " "Plus aucun nom ne se résout — utilisez "
@@ -1071,6 +1073,72 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
return steps return steps
def _diag_split_dns(cfg: Config) -> list[dict]:
"""Le serveur DNS du réseau distant est-il seulement joignable ?
Point aveugle du diagnostic jusqu'ici : le tunnel monte, le trafic passe,
« Résolution DNS » est au vert et pas un seul nom interne ne se résout.
L'échec ne se manifestait que par un « All attempts to contact name
servers or networks failed » de resolvectl, qui ne désigne rien.
Deux causes, toutes deux invisibles :
- Le serveur DNS distant n'est couvert par aucune « IP autorisée ». Rien
ne le route dans le tunnel, il est donc cherché sur le réseau local.
- Il est couvert, mais son adresse tombe aussi dans une plage que porte
une carte du poste. C'est le cas d'un réseau distant en 192.168.1.0/24
vu depuis un poste chez lui, la même plage est la plus répandue de
toutes. La route locale l'emporte, et le split-DNS ne part jamais.
La vérification se fait sur la route réellement retenue par le noyau,
tunnel monté, plutôt que sur la seule configuration : c'est la seule
façon de trancher le second cas.
"""
wg = cfg.wg
server = (wg.get("split_dns_server") or "").strip()
domains = wg.get("split_dns_domains") or []
if not server or not domains:
return [] # split-DNS non configuré : rien à dire
label = "Serveur DNS du réseau distant"
try:
target = ipaddress.ip_address(server)
except ValueError:
return [_step(_FAIL, label, f"« {server} » n'est pas une adresse IP")]
allowed = [n for n in _networks(wg.get("allowed_ips", ""))
if n.version == target.version]
couvert = [str(n) for n in allowed if target in n]
client_ip = (wg.get("client_address") or "").split(",")[0].split("/")[0].strip()
source = _local_route_source(server, 53)
if not couvert:
return [_step(
_FAIL, label,
f"{server} n'est couvert par aucune « IP autorisée » : rien ne le "
f"route dans le tunnel, il est donc cherché sur le réseau local. "
f"Ajoutez {server}/32 aux IPs autorisées.")]
if source and client_ip and source != client_ip:
return [_step(
_FAIL, label,
f"{server} est bien couvert par {', '.join(couvert)}, mais les "
f"paquets partent de {source} — votre réseau local — et non de "
f"{client_ip}, l'adresse du tunnel. Cette adresse appartient aux "
f"deux réseaux à la fois, et la route locale l'emporte. "
f"Remplacez {', '.join(couvert)} par {server}/32 : une route "
f"d'hôte est plus spécifique et reprend la main, sans router tout "
f"le voisinage dans le tunnel.")]
if not source:
return [_step(_WARN, label,
f"Route vers {server} indéterminable depuis ce poste")]
return [_step(_OK, label,
f"{server} joint par le tunnel (couvert par "
f"{', '.join(couvert)}) pour {', '.join(domains)}")]
def diagnose_connection(cfg: Config, timeout: int = 5) -> list[dict]: def diagnose_connection(cfg: Config, timeout: int = 5) -> list[dict]:
"""Vérifie toute la chaîne de connexion et désigne le point de rupture. """Vérifie toute la chaîne de connexion et désigne le point de rupture.
@@ -1286,9 +1354,18 @@ def ping_or_handshake(cfg: Config, timeout: int = 2) -> tuple[int | None, str]:
# ── Test DNS leak ───────────────────────────────────────────────────────── # ── Test DNS leak ─────────────────────────────────────────────────────────
def dns_leak_test(cfg: Config) -> dict: def dns_leak_test(cfg: Config) -> dict:
""" """Les requêtes de noms empruntent-elles le tunnel ?
Teste si le DNS fuit en dehors du tunnel.
Retourne {"status": ok|leak|unknown|down, "resolvers": [...], "expected": str} Compare le résolveur réellement actif sur le système au DNS déclaré pour
le tunnel. Une divergence signifie que les noms demandés partent au
résolveur du réseau local qui apprend donc ce que ce poste consulte,
et peut répondre autre chose que le réseau distant.
`status` vaut `ok`, `leak`, `down` (plus aucune résolution) ou `unknown`.
`reason` précise ce dernier cas, qui en recouvre trois : sans elle,
l'écran affichait « Statut inconnu » sans dire quoi faire, alors que les
trois causes appellent des gestes différents monter le tunnel,
renseigner un DNS, ou constater que le système n'en déclare aucun.
""" """
expected_dns = (cfg.wg.get("dns", "") or "").split(",")[0].strip() expected_dns = (cfg.wg.get("dns", "") or "").split(",")[0].strip()
@@ -1308,15 +1385,17 @@ def dns_leak_test(cfg: Config) -> dict:
if lines: if lines:
probe_ip = lines[-1] probe_ip = lines[-1]
reason = ""
if not dns_util.resolution_works(): if not dns_util.resolution_works():
status = "down" status = "down"
elif not resolvers:
status = "unknown"
elif not tunnel_up: elif not tunnel_up:
# Tunnel inactif : parler de fuite n'a pas de sens. # Tunnel inactif : parler de fuite n'a pas de sens, tout le trafic
status = "unknown" # passe légitimement par le résolveur local.
status, reason = "unknown", "tunnel_down"
elif not resolvers:
status, reason = "unknown", "no_resolver"
elif not expected_dns: elif not expected_dns:
status = "unknown" status, reason = "unknown", "no_expected"
elif any(expected_dns == r or expected_dns in r for r in resolvers): elif any(expected_dns == r or expected_dns in r for r in resolvers):
status = "ok" status = "ok"
else: else:
@@ -1324,6 +1403,7 @@ def dns_leak_test(cfg: Config) -> dict:
return { return {
"status": status, "status": status,
"reason": reason,
"resolvers": resolvers, "resolvers": resolvers,
"expected": expected_dns, "expected": expected_dns,
"probe_ip": probe_ip, "probe_ip": probe_ip,
@@ -1333,63 +1413,6 @@ def dns_leak_test(cfg: Config) -> dict:
# ── Génération config serveur ───────────────────────────────────────────── # ── Génération config serveur ─────────────────────────────────────────────
def generate_server_config(
server_port: int = 51820,
server_address: str = "10.8.0.1/24",
client_address: str = "10.8.0.2/24",
client_allowed_ips: str = "10.8.0.2/32",
use_psk: bool = True,
) -> dict:
"""
Génère une paire complète (server_conf, client_conf) avec de nouvelles clés.
Retourne un dict avec server_priv/pub, client_priv/pub, server_conf, client_conf.
`use_psk=False` produit les deux configurations sans clé pré-partagée :
l'assistant en imposait une des deux côtés, sans moyen de s'en passer.
"""
srv_priv, srv_pub = generate_keypair()
cli_priv, cli_pub = generate_keypair()
psk = generate_preshared_key() if use_psk else ""
psk_line = [f"PresharedKey = {psk}"] if use_psk else []
server_conf = "\n".join([
"[Interface]",
f"PrivateKey = {srv_priv}",
f"Address = {server_address}",
f"ListenPort = {server_port}",
"PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE",
"PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE",
"",
"# === Peer client ===",
"[Peer]",
f"PublicKey = {cli_pub}",
*psk_line,
f"AllowedIPs = {client_allowed_ips}",
])
client_conf = "\n".join([
"[Interface]",
f"PrivateKey = {cli_priv}",
f"Address = {client_address}",
"DNS = 1.1.1.1",
"",
"[Peer]",
f"PublicKey = {srv_pub}",
*psk_line,
"AllowedIPs = 10.8.0.0/24",
"Endpoint = <SERVER_IP>:" + str(server_port),
"PersistentKeepalive = 25",
])
return {
"server_priv": srv_priv, "server_pub": srv_pub,
"client_priv": cli_priv, "client_pub": cli_pub,
"psk": psk,
"server_conf": server_conf,
"client_conf": client_conf,
}
def _fmt_bytes(n: int) -> str: def _fmt_bytes(n: int) -> str:
for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)): for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
if n >= div: if n >= div:
+255 -227
View File
@@ -39,8 +39,20 @@ class AdminWindow(QDialog):
) )
# Le découpage en sous-onglets a supprimé l'empilement qui imposait une # 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. # 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._build_ui()
self._load_values() self._load_values()
@@ -142,6 +154,27 @@ class AdminWindow(QDialog):
widget.setFixedHeight(field_h) widget.setFixedHeight(field_h)
form.addRow(lbl, widget) 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 @staticmethod
def _hint(text: str) -> QLabel: def _hint(text: str) -> QLabel:
"""Encart expliquant à quoi sert l'onglet où il figure. """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;") sep.setStyleSheet(f"background: {theme.BORDER_SOFT}; border: none; max-height: 1px;")
lay.addWidget(sep) lay.addWidget(sep)
# ── Serveur ────────────────────────────────────────────────────── # ── Clé pré-partagée ─────────────────────────────────────────────
grp = QGroupBox("Serveur WireGuard") # 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 = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) 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 # 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 # absente : le diagnostic avertissait donc sur tout serveur qui n'en
# utilise pas, c'est-à-dire la configuration WireGuard par défaut. # utilise pas, c'est-à-dire la configuration WireGuard par défaut.
@@ -362,71 +371,23 @@ class AdminWindow(QDialog):
lay.addWidget(grp) lay.addWidget(grp)
# ── Interface client ───────────────────────────────────────────── # ── 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") grp2 = QGroupBox("Interface client")
form2 = QFormLayout(grp2) form2 = QFormLayout(grp2)
form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) 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 = QLineEdit()
self._client_addr.setPlaceholderText("10.8.0.2/24") self._client_addr.setPlaceholderText("10.8.0.2/24")
self._client_addr.setToolTip( self._client_addr.setToolTip(
"Adresse IP du client dans le réseau VPN. " "Adresse IP de ce poste dans le réseau du tunnel, telle que le\n"
"Format: IP/Masque (ex: 10.8.0.2/24)." "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._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 = QLineEdit()
self._allowed_ips.setPlaceholderText("10.8.0.0/24") self._allowed_ips.setPlaceholderText("10.8.0.0/24")
self._allowed_ips.setToolTip( self._allowed_ips.setToolTip(
@@ -436,15 +397,110 @@ class AdminWindow(QDialog):
) )
self._row(form2, "IPs autorisées :", self._allowed_ips) 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 = QSpinBox()
self._keepalive.setRange(0, 300) self._keepalive.setRange(0, 300)
self._keepalive.setValue(25) self._keepalive.setValue(25)
self._keepalive.setToolTip( self._keepalive.setToolTip(
"Intervalle (en secondes) pour envoyer un paquet keepalive. " "Intervalle en secondes entre deux paquets keepalive. 0 = désactivé.\n"
"0 = désactivé. Utile pour maintenir la connexion active à travers un NAT." "Maintient la connexion à travers un NAT."
) )
self._keepalive.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 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 = QSpinBox()
self._mtu.setRange(0, 9000) self._mtu.setRange(0, 9000)
@@ -454,8 +510,8 @@ class AdminWindow(QDialog):
"monte mais que les gros paquets ne passent pas." "monte mais que les gros paquets ne passent pas."
) )
self._mtu.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._mtu.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form2, "MTU :", self._mtu) self._row(form4, "MTU :", self._mtu)
lay.addWidget(grp2) lay.addWidget(grp4)
lay.addStretch() lay.addStretch()
# Champs verrouillés après import d'un .conf. Le split-DNS et le nom # Champs verrouillés après import d'un .conf. Le split-DNS et le nom
@@ -482,6 +538,12 @@ class AdminWindow(QDialog):
jamais). jamais).
""" """
self._fields_locked = locked 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: for wdg in self._locked_fields:
wdg.setEnabled(not locked) wdg.setEnabled(not locked)
if locked: if locked:
@@ -550,20 +612,6 @@ class AdminWindow(QDialog):
self._row(form, "", self._btn_gen_keys) self._row(form, "", self._btn_gen_keys)
lay.addWidget(grp) 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() lay.addStretch()
return page return page
@@ -588,7 +636,7 @@ class AdminWindow(QDialog):
sub = QTabWidget() sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS) sub.setStyleSheet(theme.TABBAR_CSS)
sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion") 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) lay.addWidget(sub)
return w return w
@@ -613,11 +661,15 @@ class AdminWindow(QDialog):
grp = QGroupBox("Diagnostic de la chaîne de connexion") grp = QGroupBox("Diagnostic de la chaîne de connexion")
g = QVBoxLayout(grp) g = QVBoxLayout(grp)
g.addWidget(QLabel( lbl = QLabel(
"Vérifie toute la chaîne : configuration, clés, route, port UDP,\n" "Vérifie toute la chaîne : configuration, clés, route, port UDP, "
"montage du tunnel, handshake, trafic et DNS — et désigne l'étape\n" "montage du tunnel, handshake, trafic et DNS — et désigne l'étape "
"qui bloque." "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 = QPushButton("🚀 Lancer le test de connexion")
self._btn_diag_test.setStyleSheet(theme.primary_button_style()) self._btn_diag_test.setStyleSheet(theme.primary_button_style())
self._btn_diag_test.clicked.connect(self._run_test) self._btn_diag_test.clicked.connect(self._run_test)
@@ -642,21 +694,28 @@ class AdminWindow(QDialog):
# ── Sous-onglet : fuite DNS ────────────────────────────────────────── # ── Sous-onglet : fuite DNS ──────────────────────────────────────────
def _subtab_dns(self) -> QWidget: def _subtab_dns(self) -> QWidget:
page, v = self._test_page() 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.addWidget(self._hint(
"Vérifie que les requêtes de noms empruntent bien le tunnel plutôt " "Le tunnel peut être monté et le trafic passer pendant qu'aucun "
"que le résolveur du réseau local. La réparation retire une " "nom ne se résout : c'est ici que cela se voit, et se répare."
"configuration DNS laissée par un tunnel arrêté brutalement, qui "
"empêche toute résolution."
)) ))
grp = QGroupBox("🔍 Test de fuite DNS") grp = QGroupBox("🔍 Par où sortent les requêtes de noms ?")
g = QVBoxLayout(grp) g = QVBoxLayout(grp)
g.addWidget(QLabel("Vérifie que le DNS passe bien par le tunnel VPN.")) lbl = QLabel(
self._btn_dns_test = QPushButton("🔍 Analyser le DNS") "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.setStyleSheet(theme.primary_button_style())
self._btn_dns_test.clicked.connect(self._run_dns_test) self._btn_dns_test.clicked.connect(self._run_dns_test)
g.addWidget(self._btn_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( self._btn_dns_fix.setToolTip(
"Retire la configuration DNS laissée par un tunnel arrêté " "Retire la configuration DNS laissée par un tunnel arrêté "
"brutalement, qui empêche toute résolution de noms." "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…). # (un hôte particulier, une forme courte via le suffixe de recherche…).
domains = self._cfg.wg.get("split_dns_domains") or [] domains = self._cfg.wg.get("split_dns_domains") or []
example = f"hôte.{domains[0]}" if domains else "hôte.domaine.exemple" 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 = QVBoxLayout(grp2)
g2.addWidget(QLabel( lbl2 = QLabel(
f"Ex. : {example}, ou juste le nom court s'il est dans le " "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." "suffixe de recherche du réseau distant."
)) )
lbl2.setWordWrap(True)
g2.addWidget(lbl2)
resolve_row = QHBoxLayout() resolve_row = QHBoxLayout()
resolve_row.setSpacing(6) resolve_row.setSpacing(6)
self._resolve_input = QLineEdit() self._resolve_input = QLineEdit()
@@ -924,7 +988,7 @@ class AdminWindow(QDialog):
)) ))
note = QLabel( 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." "Laissez vide pour désactiver la protection."
) )
note.setWordWrap(True) note.setWordWrap(True)
@@ -1040,10 +1104,12 @@ class AdminWindow(QDialog):
(f"v{info.VERSION}", theme.ACCENT), (f"v{info.VERSION}", theme.ACCENT),
(info.LICENSE, "#5b6b7a"), (info.LICENSE, "#5b6b7a"),
] ]
if (self._cfg.wg.get("vpn_username") or "").strip(): # Pastille fixe : l'ouverture de l'accès passe par le serveur dans
pills.append(("🔐 Code d'accès serveur", theme.OK_SOLID)) # tous les cas. Elle se fondait sur la présence d'un identifiant VPN
else: # en configuration locale, qui n'a jamais rien dit de ce que le
pills.append(("🔐 Aucun code d'accès", "#5b6b7a")) # 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 not is_windows():
if has_root_privileges(): 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 # 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. # 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._api_url.setText(wg.get("api_url") or "")
self._split_dns_server.setText(wg.get("split_dns_server") or "192.168.1.210") self._split_dns_server.setText(wg.get("split_dns_server") or "192.168.1.210")
domains = wg.get("split_dns_domains") or ["h3adm.lan"] 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._priv_key.setText(wg.get("client_private_key", ""))
self._pub_key.setText(wg.get("client_public_key", "")) self._pub_key.setText(wg.get("client_public_key", ""))
# Un chargement de profil repart d'une configuration de confiance # Le verrou suit la configuration chargée, profil compris : un profil
# (gérée par WGSecure) : seul un import .conf brut verrouille. # né d'un `.conf` importé décrit un pair déjà déclaré côté serveur et
self._set_fields_locked(False) # 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): def _save_values(self):
self._cfg.set("wg", "server_endpoint", self._srv_endpoint.text().strip()) 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()) self._cfg.set("wg", "client_public_key", self._pub_key.text().strip())
# Split-DNS # 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", "api_url", self._api_url.text().strip())
self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip()) self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip())
domains_text = self._split_dns_domains.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: def _format_diagnostic(self, steps: list[dict]) -> str:
"""Met en forme les étapes ; la première en échec porte le verdict.""" """Met en forme les étapes ; la première en échec porte le verdict."""
from html import escape 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: for st in steps:
bullet, color = self._STEP_STYLE.get(st["state"], ("", theme.TEXT)) bullet, color = self._STEP_STYLE.get(st["state"], ("", theme.TEXT))
weight = "bold" if st["state"] == "fail" else "normal" weight = "bold" if st["state"] == "fail" else "normal"
detail = escape(st["detail"])
rows.append( rows.append(
f'<div style="margin-bottom:5px;">' '<tr>'
f'<span style="color:{color}; font-weight:{weight};">' f'<td width="42%" style="padding-bottom:5px; color:{color};'
f'{bullet}&nbsp;{escape(st["label"])}</span>' f' font-weight:{weight};">{bullet}&nbsp;{escape(st["label"])}</td>'
f'<br><span style="color:{theme.TEXT_MUTED}; font-size:11px;">' f'<td style="padding-bottom:5px; padding-left:10px;'
f'&nbsp;&nbsp;&nbsp;&nbsp;{detail}</span></div>' 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) failed = next((s for s in steps if s["state"] == "fail"), None)
if failed: if failed:
@@ -1623,74 +1698,6 @@ class AdminWindow(QDialog):
v.addWidget(note) v.addWidget(note)
dlg.exec() 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): def _run_dns_test(self):
if self._dns_worker is not None: if self._dns_worker is not None:
return return
@@ -1705,30 +1712,51 @@ class AdminWindow(QDialog):
def _on_dns_test_done(self, r: dict | None): def _on_dns_test_done(self, r: dict | None):
self._dns_worker = None self._dns_worker = None
self._btn_dns_test.setEnabled(True) self._btn_dns_test.setEnabled(True)
r = r or {"status": "unknown", "resolvers": [], "expected": ""} r = r or {"status": "unknown", "reason": "", "resolvers": [], "expected": ""}
lines = []
status = r["status"] status = r["status"]
if status == "ok": # Chaque verdict dit ce qu'il constate *et* ce qu'il reste à faire.
lines.append("✅ Aucune fuite DNS détectée") # « Statut inconnu » seul renvoyait l'utilisateur à lui-même, alors
state = "ok" # que les trois causes possibles appellent trois gestes distincts.
elif status == "leak": verdicts = {
lines.append("⚠️ Fuite DNS potentielle !") "ok": ("✅ Aucune fuite : les noms sont résolus par le DNS du tunnel.", "ok"),
state = "fail" "leak": (
elif status == "down": "⚠️ Fuite DNS : les noms partent au résolveur du réseau local,\n"
lines.append("🚫 Résolution DNS hors service — utilisez « Réparer le DNS »") " pas à celui du tunnel. Ce résolveur voit donc ce que ce poste\n"
state = "fail" " consulte, et peut répondre autre chose que le réseau distant.\n"
elif not r.get("tunnel_up", False): " → Vérifiez le champ DNS de l'onglet Tunnel, et que le système\n"
lines.append("️ Tunnel inactif : la comparaison n'a pas de sens") " dispose de resolvconf ou systemd-resolved.", "fail"),
state = "skip" "down": (
else: "🚫 Plus aucun nom ne se résout sur ce poste.\n"
lines.append("❓ Statut inconnu") " Souvent le reliquat d'un tunnel arrêté brutalement.\n"
state = "skip" " → 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"]: 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"]: 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"): 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.setPlainText("\n".join(lines))
self._dns_result.setStyleSheet(theme.result_view_style(state)) self._dns_result.setStyleSheet(theme.result_view_style(state))
+1 -1
View File
@@ -5,7 +5,7 @@
; ────────────────────────────────────────────── ; ──────────────────────────────────────────────
#define MyAppName "WGSecure" #define MyAppName "WGSecure"
#define MyAppVersion "0.9.1" #define MyAppVersion "0.10.0"
#define MyAppPublisher "WGSecure" #define MyAppPublisher "WGSecure"
#define MyAppExeName "wgsecure.exe" #define MyAppExeName "wgsecure.exe"