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 has_root_privileges() -> bool: if is_windows(): try: import ctypes return ctypes.windll.shell32.IsUserAnAdmin() != 0 except Exception: return False return os.geteuid() == 0 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, ) 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_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) # 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: code, _, _ = run_command(["wg", "--version"]) return code == 0 def wg_quick_available() -> bool: if is_windows(): code, _, _ = run_command(["wireguard", "--help"]) return code == 0 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