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 -- ` 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','')} 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 /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