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>
403 lines
14 KiB
Python
403 lines
14 KiB
Python
import sys
|
|
import os
|
|
import subprocess
|
|
import platform
|
|
|
|
|
|
def is_windows() -> bool:
|
|
return sys.platform == "win32"
|
|
|
|
|
|
def is_linux() -> bool:
|
|
return sys.platform.startswith("linux")
|
|
|
|
|
|
def get_config_dir() -> str:
|
|
if is_windows():
|
|
base = os.environ.get("APPDATA", os.path.expanduser("~"))
|
|
path = os.path.join(base, "WGSecure")
|
|
else:
|
|
path = os.path.join(os.path.expanduser("~"), ".wgsecure")
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
def get_wg_config_dir() -> str:
|
|
if is_windows():
|
|
base = os.environ.get("PROGRAMDATA", r"C:\ProgramData")
|
|
return os.path.join(base, "WireGuard")
|
|
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:
|
|
import ctypes
|
|
return ctypes.windll.shell32.IsUserAnAdmin() != 0
|
|
except Exception:
|
|
return False
|
|
return os.geteuid() == 0
|
|
|
|
|
|
# Windows ouvre une fenêtre console pour chaque processus console lancé depuis
|
|
# une application GUI. WGSecure interroge `ping` et `wireguard` en boucle pour
|
|
# rafraîchir le statut : sans ce drapeau, l'utilisateur voit des fenêtres DOS
|
|
# apparaître et disparaître en permanence. CREATE_NO_WINDOW supprime la
|
|
# console ; startupinfo/SW_HIDE couvre les lanceurs qui l'ignorent.
|
|
_CREATE_NO_WINDOW = 0x08000000
|
|
|
|
|
|
def _no_window_kwargs() -> dict:
|
|
"""Options subprocess empêchant l'apparition d'une console sous Windows."""
|
|
if not is_windows():
|
|
return {}
|
|
startupinfo = subprocess.STARTUPINFO()
|
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
return {
|
|
"creationflags": _CREATE_NO_WINDOW,
|
|
"startupinfo": startupinfo,
|
|
}
|
|
|
|
|
|
def run_command(cmd: list[str], timeout: int = 10,
|
|
env: dict | None = None) -> tuple[int, str, str]:
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout,
|
|
env=env,
|
|
**_no_window_kwargs(),
|
|
)
|
|
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
except subprocess.TimeoutExpired:
|
|
return -1, "", "Timeout"
|
|
except FileNotFoundError:
|
|
return -1, "", f"Commande introuvable : {cmd[0]}"
|
|
except Exception as e:
|
|
return -1, "", str(e)
|
|
|
|
|
|
def _c_locale_env() -> dict:
|
|
"""Environnement forçant les messages système en anglais.
|
|
|
|
Sans cela, la détection « sudo demande un mot de passe » échoue sur une
|
|
session en français (« mot de passe » au lieu de « password »), et l'appel
|
|
repart avec le code d'erreur de sudo au lieu de basculer sur pkexec.
|
|
"""
|
|
env = os.environ.copy()
|
|
env["LC_ALL"] = "C"
|
|
env["LANG"] = "C"
|
|
return env
|
|
|
|
|
|
# Messages que sudo émet quand il refuse *avant* de lancer la commande.
|
|
# Comparés en anglais : _c_locale_env() force LC_ALL=C pour que cette
|
|
# détection ne dépende pas de la langue de la session.
|
|
_SUDO_AUTH_MARKERS = (
|
|
"a password is required",
|
|
"no askpass program specified",
|
|
"no tty present",
|
|
"must have a tty",
|
|
"a terminal is required",
|
|
"is not allowed to run",
|
|
"may not run sudo",
|
|
"authentication failure",
|
|
)
|
|
|
|
|
|
def _sudo_refused_auth(err: str) -> bool:
|
|
"""True si sudo a refusé faute d'authentification, sans lancer la commande.
|
|
|
|
Distinguer ce cas d'un échec de la commande elle-même est indispensable :
|
|
dans le premier, il faut basculer sur askpass/pkexec ; dans le second, il
|
|
faut remonter l'erreur telle quelle à l'utilisateur.
|
|
|
|
Interroger `sudo -l` ne convient pas : pour un compte membre du groupe
|
|
sudo (`(ALL : ALL) ALL`), `sudo -n -l -- <cmd>` répond « autorisé » pour
|
|
*toutes* les commandes, y compris celles qui exigeront un mot de passe.
|
|
"""
|
|
low = err.lower()
|
|
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():
|
|
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)
|
|
|
|
# 1. sudo -n : réussit sans aucun dialogue si une règle NOPASSWD couvre la
|
|
# commande (cf. `make setup-sudoers`).
|
|
code, out, err = run_command(["sudo", "-n"] + cmd, timeout,
|
|
env=_c_locale_env())
|
|
if code == 0:
|
|
return code, out, err
|
|
if not _sudo_refused_auth(err):
|
|
# sudo a bien lancé la commande : c'est elle qui a échoué. Inutile de
|
|
# redemander des privilèges, l'erreur est remontée telle quelle.
|
|
return code, out, err
|
|
|
|
# 2. sudo avec programme askpass graphique (pas de TTY dans une app Qt)
|
|
askpass = _find_askpass()
|
|
if askpass:
|
|
env = _c_locale_env()
|
|
env["SUDO_ASKPASS"] = askpass
|
|
code, out, err = run_command(["sudo", "-A"] + cmd, timeout, env=env)
|
|
if code == 0:
|
|
return code, out, err
|
|
|
|
# 3. pkexec — dialogue graphique polkit (GNOME/KDE)
|
|
code, out, err = run_command(["pkexec"] + cmd, timeout, env=_c_locale_env())
|
|
# 126 = authentification refusée/annulée, 127 = pkexec absent : on préfère
|
|
# le message d'aide au code opaque.
|
|
if code not in (-1, 126, 127):
|
|
return code, out, err
|
|
|
|
return (
|
|
-1, "",
|
|
"Élévation de privilèges impossible.\n"
|
|
"Exécutez make setup-sudoers pour autoriser WGSecure sans dialogue.\n"
|
|
"Ou manuellement :\n"
|
|
f" sudo bash -c \"echo '{os.environ.get('USER','<user>')} ALL=(ALL) NOPASSWD: /usr/bin/wg-quick'"
|
|
" > /etc/sudoers.d/wgsecure && chmod 440 /etc/sudoers.d/wgsecure\""
|
|
)
|
|
|
|
|
|
def _find_askpass() -> str:
|
|
"""Cherche un programme askpass graphique disponible."""
|
|
candidates = [
|
|
os.environ.get("SUDO_ASKPASS", ""),
|
|
"/usr/lib/openssh/gnome-ssh-askpass",
|
|
"/usr/bin/ssh-askpass",
|
|
"/usr/bin/x11-ssh-askpass",
|
|
"/usr/libexec/ssh-askpass",
|
|
]
|
|
for p in candidates:
|
|
if p and os.path.isfile(p) and os.access(p, os.X_OK):
|
|
return p
|
|
# Cherche aussi via shutil
|
|
import shutil
|
|
for name in ("ssh-askpass", "x11-ssh-askpass", "gnome-ssh-askpass"):
|
|
found = shutil.which(name)
|
|
if found:
|
|
return found
|
|
return ""
|
|
|
|
|
|
# Commandes que WGSecure lance avec des privilèges, et ce qu'elles servent.
|
|
_PRIVILEGED_COMMANDS = (
|
|
(["wg-quick", "--help"], "monter / démonter le tunnel"),
|
|
(["install", "--version"], "écrire la config dans /etc/wireguard"),
|
|
(["ip", "-V"], "supprimer une interface orpheline"),
|
|
(["resolvconf", "--version"], "retirer l'entrée DNS du tunnel"),
|
|
(["resolvectl", "--version"], "purger le DNS systemd-resolved"),
|
|
)
|
|
|
|
|
|
def privilege_report() -> list[tuple[str, bool, str]]:
|
|
"""État des droits pour chaque commande privilégiée utilisée.
|
|
|
|
Retourne [(commande, sans_mot_de_passe, rôle)]. Une commande absente du
|
|
système est ignorée. Sert à expliquer pourquoi un dialogue polkit apparaît
|
|
à chaque connexion plutôt que de laisser l'utilisateur deviner.
|
|
"""
|
|
import shutil as _sh
|
|
report: list[tuple[str, bool, str]] = []
|
|
if is_windows() or has_root_privileges():
|
|
return report
|
|
for probe, role in _PRIVILEGED_COMMANDS:
|
|
if not _sh.which(probe[0]):
|
|
continue
|
|
code, _, err = run_command(["sudo", "-n"] + probe, 8, env=_c_locale_env())
|
|
report.append((probe[0], not _sudo_refused_auth(err), role))
|
|
return report
|
|
|
|
|
|
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():
|
|
return os.path.isfile(wireguard_exe())
|
|
code, _, _ = run_command(["wg-quick", "--help"])
|
|
return code == 0
|
|
|
|
|
|
def launch_command() -> str:
|
|
"""Commande à écrire dans .desktop / registre Run pour relancer l'appli.
|
|
|
|
Dans un binaire PyInstaller, `main.py` n'est pas déployé : écrire
|
|
« python3 <chemin>/main.py » produisait un lanceur et un démarrage
|
|
automatique qui ne fonctionnaient jamais depuis un paquet installé.
|
|
"""
|
|
if getattr(sys, "frozen", False):
|
|
return f'"{sys.executable}"' if " " in sys.executable else sys.executable
|
|
script = os.path.abspath(
|
|
os.path.join(os.path.dirname(__file__), "..", "..", "main.py")
|
|
)
|
|
exe = "pythonw" if is_windows() else "python3"
|
|
return f'{exe} "{script}"' if " " in script else f"{exe} {script}"
|
|
|
|
|
|
def install_desktop_entry(icon_png_path: str) -> None:
|
|
"""Installe l'entrée .desktop et l'icône PNG pour la barre des tâches GNOME/KDE.
|
|
Appelé une fois au démarrage ; sans effet sur Windows."""
|
|
if is_windows():
|
|
return
|
|
try:
|
|
# Icône dans le thème hicolor
|
|
icon_dir = os.path.expanduser("~/.local/share/icons/hicolor/256x256/apps")
|
|
os.makedirs(icon_dir, exist_ok=True)
|
|
dest_icon = os.path.join(icon_dir, "wgsecure.png")
|
|
import shutil
|
|
shutil.copy2(icon_png_path, dest_icon)
|
|
|
|
# Fichier .desktop
|
|
app_dir = os.path.expanduser("~/.local/share/applications")
|
|
os.makedirs(app_dir, exist_ok=True)
|
|
desktop = os.path.join(app_dir, "wgsecure.desktop")
|
|
content = (
|
|
"[Desktop Entry]\n"
|
|
"Type=Application\n"
|
|
"Name=WGSecure\n"
|
|
"Comment=WireGuard GUI avec MFA TOTP\n"
|
|
f"Exec={launch_command()}\n"
|
|
"Icon=wgsecure\n"
|
|
"Categories=Network;Security;\n"
|
|
"StartupWMClass=main\n"
|
|
"Terminal=false\n"
|
|
)
|
|
with open(desktop, "w") as f:
|
|
f.write(content)
|
|
|
|
# Mettre à jour le cache d'icônes si disponible
|
|
run_command(["gtk-update-icon-cache", "-f", "-t",
|
|
os.path.expanduser("~/.local/share/icons/hicolor")])
|
|
except Exception:
|
|
pass
|