fix: DNS coupée après arrêt du tunnel, gel UI, dialogues sudo à répétition
- wg-quick down échoue silencieusement si l'interface a déjà disparu (crash, kill, veille) et ne retire donc jamais l'entrée DNS posée par wg-quick up : la résolution reste pointée sur un résolveur mort. Ajout d'un nettoyage DNS inconditionnel (app/core/dns.py) déclenché à la connexion, la déconnexion, la fermeture et au démarrage suivant. - is_connected() se basait sur `wg show`, qui rend un code 0 même en cas d'échec de permission pour un utilisateur non-root : l'app se croyait déconnectée en permanence et ne démontait donc jamais le tunnel à la fermeture. Détection réécrite via sysfs (sans privilèges). - Connexion/déconnexion et ping tournaient sur le thread Qt : gel de l'UI pendant les élévations de privilèges. Déportés dans des QThread (app/ui/worker.py). - Restauration réseau garantie sur toutes les sorties (croix, tray, SIGINT/SIGTERM, atexit, exception) via app/core/shutdown.py. - run_privileged() : la détection "sudo veut un mot de passe" ratait en session non-anglophone et sur les comptes ALL=(ALL) ALL ; réécrite pour se fier au message d'erreur réel plutôt qu'à `sudo -l`. - Écriture de la config WireGuard dans /etc/wireguard (root:root 0700) passe maintenant par install -D via la chaîne de privilèges complète au lieu d'un tee non autorisé par les sudoers. - Ajout d'un diagnostic des droits (make check-privileges, bouton Admin) et mise à jour de setup-sudoers pour couvrir toutes les commandes désormais utilisées.
This commit is contained in:
+109
-25
@@ -39,13 +39,15 @@ def has_root_privileges() -> bool:
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def run_command(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
|
||||
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:
|
||||
@@ -56,46 +58,86 @@ def run_command(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
|
||||
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 (NOPASSWD configuré dans sudoers — sans aucun dialogue)
|
||||
code, out, err = run_command(["sudo", "-n"] + 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
|
||||
# Si sudo a tourné mais la commande a échoué (pas un problème d'auth), retourner l'erreur
|
||||
if code != 1 or ("password" not in err.lower() and "passwd" not in err.lower()):
|
||||
if "sudo:" not in err.lower() and code not in (-1,):
|
||||
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 = os.environ.copy()
|
||||
env = _c_locale_env()
|
||||
env["SUDO_ASKPASS"] = askpass
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "-A"] + cmd,
|
||||
capture_output=True, text=True,
|
||||
timeout=timeout, env=env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
||||
except (subprocess.TimeoutExpired, Exception):
|
||||
pass
|
||||
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)
|
||||
if code != -1 or "introuvable" not in err:
|
||||
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 configurer wg-quick sans dialogue.\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\""
|
||||
@@ -123,6 +165,35 @@ def _find_askpass() -> str:
|
||||
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
|
||||
@@ -136,6 +207,22 @@ def wg_quick_available() -> bool:
|
||||
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."""
|
||||
@@ -152,16 +239,13 @@ def install_desktop_entry(icon_png_path: str) -> None:
|
||||
# Fichier .desktop
|
||||
app_dir = os.path.expanduser("~/.local/share/applications")
|
||||
os.makedirs(app_dir, exist_ok=True)
|
||||
script = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "main.py")
|
||||
)
|
||||
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=python3 {script}\n"
|
||||
f"Exec={launch_command()}\n"
|
||||
"Icon=wgsecure\n"
|
||||
"Categories=Network;Security;\n"
|
||||
"StartupWMClass=main\n"
|
||||
|
||||
Reference in New Issue
Block a user