chore(release): v0.4.5
fix: la clé pré-partagée n'était écrite dans aucune config client L'assistant de génération produisait une PresharedKey affichée à l'écran mais stockée nulle part, et l'import d'un .conf ignorait la ligne. Face à un serveur qui en attend une, le tunnel montait et l'interface existait — sans qu'aucun handshake n'aboutisse jamais. La PSK devient un champ à part entière. fix: options de ligne de commande wireguard.exe incorrectes sous Windows /installtunnel et /uninstalltunnel n'existent pas ; les vrais verbes sont /installtunnelservice et /uninstalltunnelservice. S'y ajoutaient l'absence de résolution du chemin d'installation (WireGuard n'est pas dans le PATH), l'absence totale d'élévation UAC alors que ces commandes l'exigent, et une détection d'installation qui sondait une application graphique avec --help. feat: diagnostic de bout en bout de la chaîne de connexion Le bouton de test s'arrêtait à la joignabilité UDP et concluait « serveur joignable » sur un tunnel qui n'échangeait rien. Il déroule désormais quatorze étapes, de la configuration locale jusqu'au DNS dans le tunnel, et désigne l'étape qui bloque. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
09724f4e4f
commit
9d785c00cc
+3
-1
@@ -15,16 +15,18 @@ _WG_DEFAULT: dict[str, Any] = {
|
||||
"server_endpoint": "",
|
||||
"server_port": 51820,
|
||||
"server_public_key": "",
|
||||
"preshared_key": "",
|
||||
"client_private_key": "",
|
||||
"client_public_key": "",
|
||||
"client_address": "10.8.0.2/24",
|
||||
"dns": "1.1.1.1",
|
||||
"allowed_ips": "10.8.0.0/24",
|
||||
"keepalive": 25,
|
||||
"mtu": 0,
|
||||
}
|
||||
|
||||
_DEFAULT: dict[str, Any] = {
|
||||
"version": "0.4.3",
|
||||
"version": "0.4.5",
|
||||
"admin_password_hash": "",
|
||||
"admin_salt": "",
|
||||
"mfa_enabled": False,
|
||||
|
||||
+470
-36
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
@@ -7,11 +8,15 @@ from app.core import dns as dns_util
|
||||
from app.core.config import Config
|
||||
from app.utils.platform_utils import (
|
||||
get_config_dir,
|
||||
has_root_privileges,
|
||||
get_wg_config_dir,
|
||||
is_windows,
|
||||
run_command,
|
||||
run_privileged,
|
||||
wg_available,
|
||||
wg_exe,
|
||||
wg_quick_available,
|
||||
wireguard_exe,
|
||||
)
|
||||
|
||||
|
||||
@@ -44,10 +49,23 @@ def build_client_config(cfg: Config) -> str:
|
||||
dns_value = (wg.get("dns") or "").strip()
|
||||
if dns_value and dns_util.has_dns_backend():
|
||||
lines.append(f"DNS = {dns_value}")
|
||||
mtu = int(wg.get("mtu") or 0)
|
||||
if mtu:
|
||||
lines.append(f"MTU = {mtu}")
|
||||
lines += [
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {wg['server_public_key']}",
|
||||
]
|
||||
# Sans cette ligne, un serveur configuré avec une clé pré-partagée rejette
|
||||
# silencieusement toutes nos tentatives : le service monte, l'interface
|
||||
# existe, l'application affiche « Connecté » — et le handshake n'aboutit
|
||||
# jamais. La PSK était générée par l'assistant puis perdue, ni écrite dans
|
||||
# la config client ni relue à l'import d'un .conf.
|
||||
psk = (wg.get("preshared_key") or "").strip()
|
||||
if psk:
|
||||
lines.append(f"PresharedKey = {psk}")
|
||||
lines += [
|
||||
f"AllowedIPs = {wg['allowed_ips']}",
|
||||
f"Endpoint = {wg['server_endpoint']}:{wg['server_port']}",
|
||||
f"PersistentKeepalive = {wg['keepalive']}",
|
||||
@@ -119,7 +137,7 @@ def _wg_show(name: str) -> str:
|
||||
Un simple test `code == 0` conclut donc « déconnecté » pour tout
|
||||
utilisateur non root — c'était le cas de l'application entière.
|
||||
"""
|
||||
code, out, err = run_command(["wg", "show", name])
|
||||
code, out, err = run_command([wg_exe(), "show", name])
|
||||
if code != 0 or not out:
|
||||
return ""
|
||||
if "not permitted" in err.lower() or "unable to access" in err.lower():
|
||||
@@ -178,7 +196,8 @@ def force_cleanup(cfg: Config) -> list[str]:
|
||||
# bloqué, on le supprime avant de purger les règles NRPT résiduelles.
|
||||
if dns_util.interface_exists(name):
|
||||
svc = f"WireGuardTunnel${name}"
|
||||
run_command(["wireguard", "/uninstalltunnel", name], 30)
|
||||
run_privileged([wireguard_exe(), "/uninstalltunnelservice", name],
|
||||
timeout=60)
|
||||
if dns_util.interface_exists(name):
|
||||
run_privileged(["sc", "stop", svc], timeout=30)
|
||||
run_privileged(["sc", "delete", svc], timeout=30)
|
||||
@@ -202,6 +221,17 @@ def force_cleanup(cfg: Config) -> list[str]:
|
||||
return actions
|
||||
|
||||
|
||||
def _wait_connected(cfg: Config, timeout: int = 15) -> bool:
|
||||
"""Attend que le service du tunnel passe à RUNNING, ou expire."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
if is_connected(cfg):
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
return False
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def connect(cfg: Config) -> tuple[bool, str]:
|
||||
if not cfg.configured:
|
||||
return False, "WireGuard non configuré. Ouvrez le panneau Admin."
|
||||
@@ -213,8 +243,28 @@ def connect(cfg: Config) -> tuple[bool, str]:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
|
||||
if is_windows():
|
||||
code, _, err = run_command(["wireguard", "/installtunnel", result])
|
||||
return (code == 0), (err or "Connecté")
|
||||
# Verbes réels de wireguard.exe : /installtunnelservice <chemin .conf>
|
||||
# et /uninstalltunnelservice <nom>. Les formes /installtunnel et
|
||||
# /uninstalltunnel n'existent pas — wireguard.exe affichait sa boîte
|
||||
# d'aide et sortait sans jamais monter le tunnel.
|
||||
#
|
||||
# Le nom du tunnel est le nom du fichier sans extension : il doit
|
||||
# correspondre à interface_name pour que is_connected() retrouve le
|
||||
# service WireGuardTunnel$<nom>.
|
||||
if is_connected(cfg):
|
||||
return True, "Tunnel WireGuard déjà actif"
|
||||
if dns_util.interface_exists(name):
|
||||
force_cleanup(cfg)
|
||||
code, _, err = run_privileged(
|
||||
[wireguard_exe(), "/installtunnelservice", result], timeout=90
|
||||
)
|
||||
# /installtunnelservice enregistre le service et rend la main : le
|
||||
# démarrage effectif du tunnel est asynchrone. Tester l'état
|
||||
# immédiatement conclurait « échec » sur un tunnel qui monte.
|
||||
if code == 0 and _wait_connected(cfg, timeout=15):
|
||||
return True, "Tunnel WireGuard activé"
|
||||
force_cleanup(cfg)
|
||||
return False, err or f"Échec de l'installation du tunnel (code {code})"
|
||||
|
||||
# Déjà monté et fonctionnel : rien à faire.
|
||||
if is_connected(cfg):
|
||||
@@ -241,7 +291,9 @@ def disconnect(cfg: Config) -> tuple[bool, str]:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
|
||||
if is_windows():
|
||||
code, _, err = run_command(["wireguard", "/uninstalltunnel", name], 30)
|
||||
code, _, err = run_privileged(
|
||||
[wireguard_exe(), "/uninstalltunnelservice", name], timeout=60
|
||||
)
|
||||
# Nettoyage inconditionnel : si le service reste ou qu'une règle NRPT
|
||||
# survit, la résolution DNS resterait détournée vers le DNS du tunnel.
|
||||
force_cleanup(cfg)
|
||||
@@ -315,6 +367,366 @@ def test_connection(cfg: Config, timeout: int = 5) -> tuple[bool, str]:
|
||||
return True, f"Serveur joignable ({ms} ms, port UDP {port} non rejeté)"
|
||||
|
||||
|
||||
# ── Diagnostic de bout en bout ────────────────────────────────────────────
|
||||
|
||||
# États d'une étape : ok = validée, warn = suspecte mais non bloquante,
|
||||
# fail = point de rupture, skip = non évaluable (dépend d'une étape échouée).
|
||||
_OK, _WARN, _FAIL, _SKIP = "ok", "warn", "fail", "skip"
|
||||
|
||||
|
||||
def _step(state: str, label: str, detail: str = "") -> dict:
|
||||
return {"state": state, "label": label, "detail": detail}
|
||||
|
||||
|
||||
def last_handshake_age(name: str) -> int | None:
|
||||
"""Secondes écoulées depuis le dernier handshake.
|
||||
|
||||
Retourne -1 si aucun handshake n'a jamais eu lieu, None si l'information
|
||||
est inaccessible (privilèges insuffisants, `wg` absent).
|
||||
"""
|
||||
code, out, _ = run_command([wg_exe(), "show", name, "latest-handshakes"], 10)
|
||||
if code != 0 or not out.strip():
|
||||
return None
|
||||
newest = 0
|
||||
for line in out.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
newest = max(newest, int(parts[-1]))
|
||||
except ValueError:
|
||||
continue
|
||||
if newest == 0:
|
||||
return -1
|
||||
return max(0, int(time.time()) - newest)
|
||||
|
||||
|
||||
def _networks(value: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]:
|
||||
"""Découpe une liste « a/24, b/16 » en réseaux, en ignorant les entrées
|
||||
invalides."""
|
||||
nets = []
|
||||
for part in (value or "").split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
nets.append(ipaddress.ip_network(part, strict=False))
|
||||
except ValueError:
|
||||
continue
|
||||
return nets
|
||||
|
||||
|
||||
def _tunnel_gateway(client_address: str, allowed_ips: str = "") -> str:
|
||||
"""Première IP utilisable du réseau du tunnel (typiquement le serveur).
|
||||
|
||||
Une adresse client en /32 — la forme la plus courante — ne décrit aucun
|
||||
réseau : on se replie alors sur le premier réseau des « IPs autorisées »,
|
||||
qui contient le pair distant.
|
||||
"""
|
||||
try:
|
||||
iface = ipaddress.ip_interface(client_address.split(",")[0].strip())
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
candidates = []
|
||||
if iface.network.prefixlen < iface.network.max_prefixlen - 1:
|
||||
candidates.append(iface.network)
|
||||
candidates += [n for n in _networks(allowed_ips)
|
||||
if n.prefixlen < n.max_prefixlen - 1
|
||||
and n.version == iface.ip.version]
|
||||
|
||||
for net in candidates:
|
||||
for host in net.hosts():
|
||||
if host != iface.ip:
|
||||
return str(host)
|
||||
return ""
|
||||
|
||||
|
||||
def _local_route_source(ip: str, port: int) -> str:
|
||||
"""IP source que le système emploierait pour joindre `ip`.
|
||||
|
||||
Aucun paquet n'est émis : `connect()` sur un socket UDP ne fait que fixer
|
||||
la route. Sert à détecter le cas où l'endpoint du serveur est lui-même
|
||||
routé dans le tunnel — le tunnel a alors besoin de lui-même pour se
|
||||
maintenir et le handshake s'arrête net après la connexion.
|
||||
"""
|
||||
try:
|
||||
family = socket.AF_INET6 if ":" in ip else socket.AF_INET
|
||||
with socket.socket(family, socket.SOCK_DGRAM) as sock:
|
||||
sock.connect((ip, port or 51820))
|
||||
return sock.getsockname()[0]
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _diag_config(cfg: Config) -> list[dict]:
|
||||
wg = cfg.wg
|
||||
steps: list[dict] = []
|
||||
|
||||
missing = [
|
||||
label for label, key in (
|
||||
("adresse du serveur", "server_endpoint"),
|
||||
("clé publique du serveur", "server_public_key"),
|
||||
("clé privée du client", "client_private_key"),
|
||||
("adresse IP du client", "client_address"),
|
||||
) if not (wg.get(key) or "").strip()
|
||||
]
|
||||
if missing:
|
||||
steps.append(_step(_FAIL, "Configuration",
|
||||
"Champs manquants : " + ", ".join(missing)))
|
||||
return steps
|
||||
steps.append(_step(_OK, "Configuration", "Tous les champs requis sont remplis"))
|
||||
|
||||
# Clé publique client cohérente avec la clé privée : une clé publique
|
||||
# obsolète est déclarée au serveur et le handshake est refusé.
|
||||
try:
|
||||
priv = X25519PrivateKey.from_private_bytes(
|
||||
base64.b64decode(wg["client_private_key"])
|
||||
)
|
||||
derived = base64.b64encode(
|
||||
priv.public_key().public_bytes_raw()
|
||||
).decode()
|
||||
except Exception:
|
||||
steps.append(_step(_FAIL, "Clé privée du client",
|
||||
"Clé illisible : ce n'est pas une clé X25519 base64"))
|
||||
return steps
|
||||
declared = (wg.get("client_public_key") or "").strip()
|
||||
if declared and declared != derived:
|
||||
steps.append(_step(
|
||||
_FAIL, "Paire de clés du client",
|
||||
f"La clé publique enregistrée ne correspond pas à la clé privée. "
|
||||
f"Le serveur doit connaître : {derived}"))
|
||||
else:
|
||||
steps.append(_step(_OK, "Paire de clés du client",
|
||||
f"Clé publique déclarée au serveur : {derived[:20]}…"))
|
||||
|
||||
psk = (wg.get("preshared_key") or "").strip()
|
||||
if psk:
|
||||
try:
|
||||
valid = len(base64.b64decode(psk)) == 32
|
||||
except Exception:
|
||||
valid = False
|
||||
steps.append(_step(_OK if valid else _FAIL, "Clé pré-partagée",
|
||||
"Présente (doit être identique côté serveur)" if valid
|
||||
else "Format invalide : 32 octets en base64 attendus"))
|
||||
else:
|
||||
steps.append(_step(_WARN, "Clé pré-partagée",
|
||||
"Absente — à renseigner si le serveur en attend une, "
|
||||
"sinon le handshake n'aboutira jamais"))
|
||||
return steps
|
||||
|
||||
|
||||
def _diag_reachability(cfg: Config, timeout: int) -> tuple[list[dict], str]:
|
||||
"""Étapes réseau jusqu'au serveur. Retourne (étapes, ip_résolue)."""
|
||||
wg = cfg.wg
|
||||
endpoint = (wg.get("server_endpoint") or "").strip()
|
||||
port = int(wg.get("server_port", 51820))
|
||||
steps: list[dict] = []
|
||||
|
||||
try:
|
||||
infos = socket.getaddrinfo(endpoint, port, type=socket.SOCK_DGRAM)
|
||||
except socket.gaierror as e:
|
||||
steps.append(_step(_FAIL, "Résolution du nom du serveur",
|
||||
f"« {endpoint} » non résolu : {e.strerror or e}"))
|
||||
return steps, ""
|
||||
family, socktype, proto, _, addr = infos[0]
|
||||
server_ip = addr[0]
|
||||
steps.append(_step(_OK, "Résolution du nom du serveur",
|
||||
f"{endpoint} → {server_ip}"))
|
||||
|
||||
# Route empruntée : si elle sort par l'adresse du tunnel, le trafic du
|
||||
# tunnel repasse par le tunnel et rien ne circule plus.
|
||||
src = _local_route_source(server_ip, port)
|
||||
client_ip = (wg.get("client_address") or "").split("/")[0].strip()
|
||||
if not src:
|
||||
steps.append(_step(_FAIL, "Route vers le serveur",
|
||||
f"Aucune route vers {server_ip}"))
|
||||
elif src == client_ip:
|
||||
steps.append(_step(
|
||||
_FAIL, "Route vers le serveur",
|
||||
f"Le serveur ({server_ip}) est routé dans le tunnel lui-même "
|
||||
f"(source {src}). Excluez l'IP du serveur des « IPs autorisées »."))
|
||||
else:
|
||||
steps.append(_step(_OK, "Route vers le serveur",
|
||||
f"Sortie par {src}"))
|
||||
|
||||
allowed = _networks(wg.get("allowed_ips", ""))
|
||||
|
||||
# L'endpoint capturé par AllowedIPs : le tunnel se coupe l'herbe sous le
|
||||
# pied dès qu'il monte, et le handshake suivant n'arrive jamais.
|
||||
try:
|
||||
server_addr = ipaddress.ip_address(server_ip)
|
||||
except ValueError:
|
||||
server_addr = None
|
||||
if server_addr and any(server_addr in n for n in allowed):
|
||||
steps.append(_step(
|
||||
_FAIL, "Cohérence des « IPs autorisées »",
|
||||
f"{server_ip} (le serveur) est inclus dans les IPs autorisées : "
|
||||
f"une fois monté, le tunnel s'auto-route et le handshake cesse. "
|
||||
f"Retirez cette plage ou excluez {server_ip}/32."))
|
||||
elif src and allowed:
|
||||
try:
|
||||
local_addr = ipaddress.ip_address(src)
|
||||
except ValueError:
|
||||
local_addr = None
|
||||
clash = [str(n) for n in allowed if local_addr and local_addr in n]
|
||||
if clash:
|
||||
steps.append(_step(
|
||||
_WARN, "Cohérence des « IPs autorisées »",
|
||||
f"Votre réseau local ({src}) est couvert par {', '.join(clash)} : "
|
||||
f"une fois le tunnel monté, imprimante, NAS et box locaux "
|
||||
f"passeront par le VPN"))
|
||||
else:
|
||||
steps.append(_step(_OK, "Cohérence des « IPs autorisées »",
|
||||
f"{len(allowed)} plage(s), sans conflit avec le "
|
||||
f"réseau local"))
|
||||
|
||||
ms = ping_server(server_ip, timeout=2)
|
||||
if ms is None:
|
||||
steps.append(_step(_WARN, "Ping du serveur (ICMP)",
|
||||
"Pas de réponse — normal si l'ICMP est filtré"))
|
||||
else:
|
||||
steps.append(_step(_OK, "Ping du serveur (ICMP)", f"{ms} ms"))
|
||||
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock.sendto(b"\x00" * 32, addr)
|
||||
try:
|
||||
sock.recvfrom(1024)
|
||||
steps.append(_step(_OK, f"Port UDP {port}", "Réponse reçue"))
|
||||
except socket.timeout:
|
||||
steps.append(_step(
|
||||
_OK if ms is not None else _WARN, f"Port UDP {port}",
|
||||
"Ouvert ou filtré : aucun rejet ICMP (un serveur WireGuard "
|
||||
"ignore silencieusement les paquets non authentifiés, c'est "
|
||||
"le résultat attendu)"))
|
||||
except ConnectionRefusedError:
|
||||
steps.append(_step(_FAIL, f"Port UDP {port}",
|
||||
f"Fermé sur {server_ip} : rien n'écoute, ou un "
|
||||
f"pare-feu rejette le trafic"))
|
||||
except OSError as e:
|
||||
steps.append(_step(_FAIL, f"Port UDP {port}", str(e)))
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
return steps, server_ip
|
||||
|
||||
|
||||
def _diag_tunnel(cfg: Config) -> list[dict]:
|
||||
"""Étapes portant sur le tunnel lui-même : montage, handshake, trafic."""
|
||||
wg = cfg.wg
|
||||
name = wg.get("interface_name", "wgs0")
|
||||
steps: list[dict] = []
|
||||
|
||||
if not is_connected(cfg):
|
||||
steps.append(_step(_SKIP, "Tunnel monté",
|
||||
"Tunnel inactif — connectez-vous pour tester la "
|
||||
"suite de la chaîne"))
|
||||
return steps
|
||||
steps.append(_step(_OK, "Tunnel monté",
|
||||
f"Service actif sur l'interface {name}"))
|
||||
|
||||
age = last_handshake_age(name)
|
||||
if age is None:
|
||||
steps.append(_step(
|
||||
_WARN, "Handshake",
|
||||
"État inaccessible : `wg show` exige des droits "
|
||||
+ ("administrateur" if is_windows() else "root")))
|
||||
return steps
|
||||
if age < 0:
|
||||
steps.append(_step(
|
||||
_FAIL, "Handshake",
|
||||
"Jamais négocié. Le tunnel est monté mais le serveur ne répond "
|
||||
"pas à nos paquets. Causes usuelles : clé publique du client "
|
||||
"absente côté serveur, clé publique du serveur erronée, clé "
|
||||
"pré-partagée manquante ou différente, port UDP bloqué."))
|
||||
return steps
|
||||
if age > 180:
|
||||
steps.append(_step(_WARN, "Handshake",
|
||||
f"Dernier échange il y a {age} s — le pair ne "
|
||||
f"répond plus (keepalive recommandé : 25 s)"))
|
||||
else:
|
||||
steps.append(_step(_OK, "Handshake", f"Négocié il y a {age} s"))
|
||||
|
||||
info = get_status_info(cfg)
|
||||
rx, tx = info.get("rx_bytes"), info.get("tx_bytes")
|
||||
if rx in (0, "0", "", None) and tx in (0, "0", "", None):
|
||||
steps.append(_step(_WARN, "Trafic chiffré", "Aucun octet échangé"))
|
||||
else:
|
||||
steps.append(_step(_OK, "Trafic chiffré", f"↓ {rx} ↑ {tx}"))
|
||||
|
||||
gateway = _tunnel_gateway(wg.get("client_address", ""),
|
||||
wg.get("allowed_ips", ""))
|
||||
if not gateway:
|
||||
steps.append(_step(_SKIP, "Pair joignable dans le tunnel",
|
||||
"Adresse du tunnel non exploitable"))
|
||||
else:
|
||||
ms = ping_server(gateway, timeout=2)
|
||||
if ms is None:
|
||||
steps.append(_step(
|
||||
_WARN, "Pair joignable dans le tunnel",
|
||||
f"{gateway} ne répond pas — ICMP filtré côté serveur, ou "
|
||||
f"« IPs autorisées » ne couvre pas ce réseau"))
|
||||
else:
|
||||
steps.append(_step(_OK, "Pair joignable dans le tunnel",
|
||||
f"{gateway} répond en {ms} ms"))
|
||||
|
||||
if not dns_util.resolution_works():
|
||||
steps.append(_step(_FAIL, "Résolution DNS dans le tunnel",
|
||||
"Plus aucun nom ne se résout — utilisez "
|
||||
"« Réparer le DNS »"))
|
||||
else:
|
||||
steps.append(_step(_OK, "Résolution DNS dans le tunnel", "Fonctionnelle"))
|
||||
|
||||
return steps
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Le test précédent s'arrêtait à la joignabilité UDP du serveur : il
|
||||
concluait « serveur joignable » sur un tunnel qui n'échangeait rien, sans
|
||||
jamais dire où ça coinçait. Les étapes vont ici de la configuration locale
|
||||
jusqu'à la résolution DNS à l'intérieur du tunnel.
|
||||
|
||||
Retourne une liste de {"state", "label", "detail"}, dans l'ordre de la
|
||||
chaîne. Une étape `fail` arrête la progression : les suivantes seraient
|
||||
ininterprétables.
|
||||
"""
|
||||
steps: list[dict] = []
|
||||
|
||||
if not wg_quick_available():
|
||||
steps.append(_step(
|
||||
_FAIL, "WireGuard installé",
|
||||
"wireguard.exe introuvable — installez WireGuard for Windows"
|
||||
if is_windows() else
|
||||
"wg-quick introuvable — installez le paquet wireguard-tools"))
|
||||
return steps
|
||||
if not wg_available():
|
||||
steps.append(_step(_WARN, "Outils WireGuard",
|
||||
"`wg` introuvable : l'état du handshake ne pourra "
|
||||
"pas être lu"))
|
||||
else:
|
||||
steps.append(_step(_OK, "WireGuard installé", "Binaires présents"))
|
||||
|
||||
if not is_windows() and not has_root_privileges():
|
||||
steps.append(_step(_WARN, "Privilèges",
|
||||
"Exécution sans root : certains détails du tunnel "
|
||||
"resteront masqués"))
|
||||
|
||||
steps += _diag_config(cfg)
|
||||
if any(s["state"] == _FAIL for s in steps):
|
||||
return steps
|
||||
|
||||
net_steps, _ = _diag_reachability(cfg, timeout)
|
||||
steps += net_steps
|
||||
if any(s["state"] == _FAIL for s in net_steps):
|
||||
return steps
|
||||
|
||||
steps += _diag_tunnel(cfg)
|
||||
return steps
|
||||
|
||||
|
||||
def parse_conf_file(path: str) -> dict | None:
|
||||
"""Parse un fichier .conf WireGuard et retourne les valeurs extraites."""
|
||||
result: dict = {}
|
||||
@@ -338,9 +750,18 @@ def parse_conf_file(path: str) -> dict | None:
|
||||
result["client_address"] = val.split(",")[0].strip()
|
||||
elif key == "DNS":
|
||||
result["dns"] = val.split(",")[0].strip()
|
||||
elif key == "MTU":
|
||||
try:
|
||||
result["mtu"] = int(val)
|
||||
except ValueError:
|
||||
pass
|
||||
elif section == "peer":
|
||||
if key == "PublicKey":
|
||||
result["server_public_key"] = val
|
||||
elif key == "PresharedKey":
|
||||
# Ignorée jusqu'ici : le .conf importé perdait sa PSK
|
||||
# et le tunnel ne négociait plus rien.
|
||||
result["preshared_key"] = val
|
||||
elif key == "Endpoint" and ":" in val:
|
||||
host, port = val.rsplit(":", 1)
|
||||
result["server_endpoint"] = host.strip("[]")
|
||||
@@ -423,10 +844,18 @@ def ping_server(host: str, timeout: int = 2) -> int | None:
|
||||
return None
|
||||
# Parser "time=X.X ms" ou "temps=X.X ms"
|
||||
import re
|
||||
m = re.search(r"[Tt]ime[<=]([\d.]+)\s*ms", out)
|
||||
# `ping` est traduit : « temps=12ms » sur un Windows français, « tempo »
|
||||
# sur un italien. Ne matcher que « time » faisait conclure « hôte
|
||||
# injoignable » alors que la commande avait réussi (code 0).
|
||||
m = re.search(r"(?:time|temps|tempo|Zeit|tiempo)[<=]\s*([\d.,]+)\s*m?s",
|
||||
out, re.IGNORECASE)
|
||||
if m:
|
||||
return int(float(m.group(1)))
|
||||
return None
|
||||
try:
|
||||
return int(float(m.group(1).replace(",", ".")))
|
||||
except ValueError:
|
||||
pass
|
||||
# Réponse reçue mais latence non analysable : 0 ms plutôt que « injoignable ».
|
||||
return 0
|
||||
|
||||
|
||||
# ── Test DNS leak ─────────────────────────────────────────────────────────
|
||||
@@ -538,37 +967,15 @@ def _fmt_bytes(n: int) -> str:
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def get_status_info(cfg: Config) -> dict:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
info = {
|
||||
"connected": False,
|
||||
"interface": name,
|
||||
"peer": "",
|
||||
"rx_bytes": 0,
|
||||
"tx_bytes": 0,
|
||||
"last_handshake": "",
|
||||
}
|
||||
if is_windows():
|
||||
info["connected"] = is_connected(cfg)
|
||||
return info
|
||||
def _fill_peer_stats(info: dict, out: str) -> None:
|
||||
"""Complète `info` avec la sortie de `wg show <iface>`.
|
||||
|
||||
info["connected"] = is_connected(cfg)
|
||||
if not info["connected"]:
|
||||
return info
|
||||
|
||||
# Compteurs : /proc/net/dev est lisible sans privilèges, contrairement à
|
||||
# `wg show`. Sert de repli quand l'application tourne en simple utilisateur.
|
||||
counters = get_interface_bytes(name)
|
||||
if counters:
|
||||
info["rx_bytes"] = _fmt_bytes(counters[0])
|
||||
info["tx_bytes"] = _fmt_bytes(counters[1])
|
||||
|
||||
out = _wg_show(name)
|
||||
Sans privilèges (Linux) ou sans droits administrateur (Windows), `wg show`
|
||||
ne renvoie rien : l'état de la connexion, lui, reste correct.
|
||||
"""
|
||||
if not out:
|
||||
# Détails du pair inaccessibles sans CAP_NET_ADMIN — l'état de la
|
||||
# connexion, lui, reste correct.
|
||||
info["last_handshake"] = "—"
|
||||
return info
|
||||
return
|
||||
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
@@ -585,4 +992,31 @@ def get_status_info(cfg: Config) -> dict:
|
||||
elif line.startswith("latest handshake:"):
|
||||
info["last_handshake"] = line.split(":", 1)[1].strip()
|
||||
|
||||
|
||||
def get_status_info(cfg: Config) -> dict:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
info = {
|
||||
"connected": False,
|
||||
"interface": name,
|
||||
"peer": "",
|
||||
"rx_bytes": 0,
|
||||
"tx_bytes": 0,
|
||||
"last_handshake": "",
|
||||
}
|
||||
info["connected"] = is_connected(cfg)
|
||||
if is_windows():
|
||||
if info["connected"]:
|
||||
_fill_peer_stats(info, _wg_show(name))
|
||||
return info
|
||||
if not info["connected"]:
|
||||
return info
|
||||
|
||||
# Compteurs : /proc/net/dev est lisible sans privilèges, contrairement à
|
||||
# `wg show`. Sert de repli quand l'application tourne en simple utilisateur.
|
||||
counters = get_interface_bytes(name)
|
||||
if counters:
|
||||
info["rx_bytes"] = _fmt_bytes(counters[0])
|
||||
info["tx_bytes"] = _fmt_bytes(counters[1])
|
||||
|
||||
_fill_peer_stats(info, _wg_show(name))
|
||||
return info
|
||||
|
||||
Reference in New Issue
Block a user