Files
WGSecure/app/utils/platform_utils.py
T
2026-09-04 08:55:35 +02:00

450 lines
16 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 `wireguard.exe /uninstalltunnelservice`
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 run_privileged_readonly(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
"""Comme `run_privileged`, mais pour un sondage périodique en lecture seule.
N'ouvre jamais de dialogue (askpass/pkexec/UAC) : un sondage lancé toutes
les quelques secondes (état du tunnel, handshake) rendrait l'application
inutilisable si chaque appel pouvait faire apparaître une invite. Ne
fonctionne donc que si le process est déjà root/administrateur, ou si la
commande est couverte par une règle sudo NOPASSWD (`make setup-sudoers`).
Échoue silencieusement sinon, comme un manque de privilèges ordinaire.
"""
if has_root_privileges():
return run_command(cmd, timeout)
if is_windows():
return -1, "", "Élévation requise"
return run_command(["sudo", "-n"] + cmd, timeout, env=_c_locale_env())
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.
#
# `make setup-sudoers` installe des règles NOPASSWD restreintes à des motifs
# d'arguments précis (ex. `ip link delete dev *`), jamais à la commande nue.
# Les sondes doivent donc reproduire un appel réel qui matche ce motif :
# tester avec `--version`/`-V` interroge un appel que sudoers n'autorise
# jamais, et affiche « dialogue à chaque appel » même quand tout est
# correctement configuré.
_PRIVILEGE_PROBE_IFACE = "wgsecure-privcheck0"
def _install_probe_cmd() -> list[str]:
"""Reproduit le motif `install -D -m 600 -o root -g root * /etc/wireguard/*`.
Écrit un fichier vide et sans conséquence (`.wgsecure-privcheck`, jamais
lu par wg-quick) : c'est le seul moyen fiable de tester ce motif, `sudo
-n -l` ne le pouvant pas (cf. `_sudo_refused_auth`).
"""
dest = os.path.join(get_wg_config_dir(), ".wgsecure-privcheck")
return ["install", "-D", "-m", "600", "-o", "root", "-g", "root",
"/dev/null", dest]
def _privileged_commands() -> tuple[tuple[list[str], str], ...]:
return (
(["wg-quick", "--help"], "monter / démonter le tunnel"),
(_install_probe_cmd(), "écrire la config dans /etc/wireguard"),
(["ip", "link", "delete", "dev", _PRIVILEGE_PROBE_IFACE],
"supprimer une interface orpheline"),
(["resolvconf", "-d", _PRIVILEGE_PROBE_IFACE],
"retirer l'entrée DNS du tunnel"),
(["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE],
"purger le DNS systemd-resolved"),
(["resolvectl", "dns", _PRIVILEGE_PROBE_IFACE, "0.0.0.0"],
"configurer le DNS distant (split-DNS)"),
(["resolvectl", "domain", _PRIVILEGE_PROBE_IFACE, "~invalid"],
"configurer le domaine distant (split-DNS)"),
)
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