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:
tuxgyver
2026-09-08 12:18:08 +02:00
co-authored by Claude Opus 5
parent dd899fadc6
commit b3cf706bb6
3 changed files with 370 additions and 299 deletions
+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,
# 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.
# Authentification auprès du serveur après montage du tunnel : identifiant
# du compte VPN tel que connu du serveur, et URL de son API. Laissée vide,
# celle-ci est déduite de l'adresse du client (cf. vpn_session.py).
"vpn_username": "",
# Authentification auprès du serveur après montage du tunnel : URL de son
# API. Laissée vide, elle est déduite de l'adresse du client et des IPs
# autorisées (cf. vpn_session.py). Aucun identifiant n'est stocké ni
# transmis : le serveur reconnaît le poste à l'IP source de son tunnel.
"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_domains": [],
}
@@ -85,7 +91,9 @@ class Config:
self._apply_autostart(self._data["ui"]["autostart"])
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é
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.
"""
stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data]
if not stale:
return
for key in stale:
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()
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",
f"{gateway} répond en {ms} ms"))
steps += _diag_split_dns(cfg)
if not dns_util.resolution_works():
steps.append(_step(_FAIL, "Résolution DNS dans le tunnel",
"Plus aucun nom ne se résout — utilisez "
@@ -1071,6 +1073,72 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
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, où 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]:
"""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 ─────────────────────────────────────────────────────────
def dns_leak_test(cfg: Config) -> dict:
"""
Teste si le DNS fuit en dehors du tunnel.
Retourne {"status": ok|leak|unknown|down, "resolvers": [...], "expected": str}
"""Les requêtes de noms empruntent-elles le tunnel ?
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()
@@ -1308,15 +1385,17 @@ def dns_leak_test(cfg: Config) -> dict:
if lines:
probe_ip = lines[-1]
reason = ""
if not dns_util.resolution_works():
status = "down"
elif not resolvers:
status = "unknown"
elif not tunnel_up:
# Tunnel inactif : parler de fuite n'a pas de sens.
status = "unknown"
# Tunnel inactif : parler de fuite n'a pas de sens, tout le trafic
# 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:
status = "unknown"
status, reason = "unknown", "no_expected"
elif any(expected_dns == r or expected_dns in r for r in resolvers):
status = "ok"
else:
@@ -1324,6 +1403,7 @@ def dns_leak_test(cfg: Config) -> dict:
return {
"status": status,
"reason": reason,
"resolvers": resolvers,
"expected": expected_dns,
"probe_ip": probe_ip,
@@ -1333,63 +1413,6 @@ def dns_leak_test(cfg: Config) -> dict:
# ── 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:
for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
if n >= div: