Sous Windows, tout processus console lancé depuis une application GUI ouvre une fenêtre cmd. WGSecure interroge `ping` et `wireguard` à chaque cycle de rafraîchissement du statut, ce qui faisait clignoter des fenêtres DOS en permanence pendant toute la durée d'exécution. Tous les appels externes passent par run_command() : le drapeau CREATE_NO_WINDOW y est désormais posé sous Windows, doublé d'un startupinfo SW_HIDE pour les lanceurs qui l'ignorent. Sans effet sous Linux. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
9.7 KiB
Python
284 lines
9.7 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 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_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','<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 <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
|