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:
tuxgyver
2026-08-31 15:41:43 +02:00
co-authored by Claude Opus 5
parent 09724f4e4f
commit 9d785c00cc
10 changed files with 777 additions and 64 deletions
+122 -3
View File
@@ -29,6 +29,45 @@ def get_wg_config_dir() -> str:
return "/etc/wireguard"
def _windows_wg_dir() -> str:
"""Répertoire d'installation de WireGuard for Windows."""
for base in (os.environ.get("ProgramFiles", r"C:\Program Files"),
os.environ.get("ProgramW6432", r"C:\Program Files"),
os.environ.get("ProgramFiles(x86)", "")):
if not base:
continue
path = os.path.join(base, "WireGuard")
if os.path.isdir(path):
return path
return ""
def _windows_exe(name: str) -> str:
"""Chemin absolu de wireguard.exe / wg.exe.
L'installateur WireGuard n'ajoute pas son répertoire au PATH : invoquer
« wireguard » ou « wg » tel quel remonte « Commande introuvable » sur une
machine où WireGuard est pourtant installé.
"""
import shutil as _sh
d = _windows_wg_dir()
if d:
path = os.path.join(d, name)
if os.path.isfile(path):
return path
return _sh.which(name) or name
def wireguard_exe() -> str:
"""Binaire du gestionnaire WireGuard (installe/désinstalle les tunnels)."""
return _windows_exe("wireguard.exe") if is_windows() else "wg-quick"
def wg_exe() -> str:
"""Binaire `wg` (interrogation d'un tunnel actif)."""
return _windows_exe("wg.exe") if is_windows() else "wg"
def has_root_privileges() -> bool:
if is_windows():
try:
@@ -123,9 +162,84 @@ def _sudo_refused_auth(err: str) -> bool:
return any(marker in low for marker in _SUDO_AUTH_MARKERS)
def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]:
"""Relance `cmd` derrière une invite UAC et attend sa fin.
`wireguard.exe /installtunnelservice` et `sc create/delete` exigent les
droits administrateur : lancés depuis une session standard, ils échouaient
sur « Access is denied » sans que l'application le sache. ShellExecuteEx
avec le verbe « runas » est le seul moyen d'obtenir l'élévation depuis un
processus déjà démarré.
Un processus élevé n'hérite pas de nos tubes : stdout/stderr sont perdus,
seul le code de sortie remonte.
"""
import ctypes
from ctypes import wintypes
SEE_MASK_NOCLOSEPROCESS = 0x00000040
SEE_MASK_NOASYNC = 0x00000100
SW_HIDE = 0
ERROR_CANCELLED = 1223
WAIT_TIMEOUT = 0x00000102
class SHELLEXECUTEINFOW(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.DWORD),
("fMask", ctypes.c_ulong),
("hwnd", wintypes.HWND),
("lpVerb", wintypes.LPCWSTR),
("lpFile", wintypes.LPCWSTR),
("lpParameters", wintypes.LPCWSTR),
("lpDirectory", wintypes.LPCWSTR),
("nShow", ctypes.c_int),
("hInstApp", wintypes.HINSTANCE),
("lpIDList", ctypes.c_void_p),
("lpClass", wintypes.LPCWSTR),
("hkeyClass", wintypes.HKEY),
("dwHotKey", wintypes.DWORD),
("hIconOrMonitor", wintypes.HANDLE),
("hProcess", wintypes.HANDLE),
]
info = SHELLEXECUTEINFOW()
info.cbSize = ctypes.sizeof(info)
info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC
info.lpVerb = "runas"
info.lpFile = cmd[0]
info.lpParameters = subprocess.list2cmdline(cmd[1:])
info.nShow = SW_HIDE
if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(info)):
err = ctypes.get_last_error() or ctypes.GetLastError()
if err == ERROR_CANCELLED:
return -1, "", "Élévation refusée par l'utilisateur"
return -1, "", f"Élévation impossible (code {err}) : {cmd[0]}"
handle = info.hProcess
if not handle:
# Commande lancée sans handle exploitable : succès non vérifiable.
return 0, "", ""
try:
if ctypes.windll.kernel32.WaitForSingleObject(
handle, int(timeout * 1000)
) == WAIT_TIMEOUT:
return -1, "", "Timeout"
code = wintypes.DWORD()
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
return code.value, "", ""
finally:
ctypes.windll.kernel32.CloseHandle(handle)
def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]:
if is_windows():
return run_command(cmd, timeout)
if has_root_privileges():
return run_command(cmd, timeout)
try:
return _run_elevated_windows(cmd, timeout)
except Exception as e:
return -1, "", str(e)
if has_root_privileges():
return run_command(cmd, timeout)
@@ -217,14 +331,19 @@ def privilege_report() -> list[tuple[str, bool, str]]:
def wg_available() -> bool:
if is_windows():
return os.path.isfile(wg_exe())
code, _, _ = run_command(["wg", "--version"])
return code == 0
def wg_quick_available() -> bool:
# wireguard.exe est une application graphique : elle n'accepte pas
# « --help » et n'écrit rien sur stdout. Le sonder comme un outil console
# concluait « WireGuard absent » sur toute machine Windows. Sa présence sur
# le disque est le seul test fiable.
if is_windows():
code, _, _ = run_command(["wireguard", "--help"])
return code == 0
return os.path.isfile(wireguard_exe())
code, _, _ = run_command(["wg-quick", "--help"])
return code == 0