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
+88
-65
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user