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:
+6
-8
@@ -196,11 +196,10 @@ class Config:
|
||||
self.save()
|
||||
|
||||
def _apply_autostart(self, enabled: bool) -> None:
|
||||
import sys
|
||||
from app.utils.platform_utils import is_linux, is_windows
|
||||
script = os.path.abspath(os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "main.py"
|
||||
))
|
||||
from app.utils.platform_utils import is_linux, is_windows, launch_command
|
||||
# launch_command() gère le cas du binaire PyInstaller, où main.py
|
||||
# n'existe pas : l'ancien « python3 <…>/main.py » ne démarrait jamais.
|
||||
command = launch_command()
|
||||
if is_linux():
|
||||
autostart_dir = os.path.expanduser("~/.config/autostart")
|
||||
desktop = os.path.join(autostart_dir, "wgsecure.desktop")
|
||||
@@ -208,7 +207,7 @@ class Config:
|
||||
os.makedirs(autostart_dir, exist_ok=True)
|
||||
content = (
|
||||
"[Desktop Entry]\nType=Application\nName=WGSecure\n"
|
||||
f"Exec=python3 {script}\nIcon=wgsecure\n"
|
||||
f"Exec={command}\nIcon=wgsecure\n"
|
||||
"Hidden=false\nX-GNOME-Autostart-enabled=true\n"
|
||||
)
|
||||
with open(desktop, "w") as f:
|
||||
@@ -225,8 +224,7 @@ class Config:
|
||||
reg = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0,
|
||||
winreg.KEY_SET_VALUE)
|
||||
if enabled:
|
||||
winreg.SetValueEx(reg, "WGSecure", 0, winreg.REG_SZ,
|
||||
f'pythonw "{script}"')
|
||||
winreg.SetValueEx(reg, "WGSecure", 0, winreg.REG_SZ, command)
|
||||
else:
|
||||
winreg.DeleteValue(reg, "WGSecure")
|
||||
winreg.CloseKey(reg)
|
||||
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
"""Gestion et réparation de l'état DNS système.
|
||||
|
||||
Pourquoi ce module existe
|
||||
-------------------------
|
||||
``wg-quick up`` installe le DNS du tunnel via ``resolvconf -a <iface> -m 0 -x``
|
||||
(mode exclusif : le résolveur du tunnel devient le seul utilisé). Le retrait de
|
||||
cette entrée n'a lieu que dans ``wg-quick down``, qui commence par :
|
||||
|
||||
[[ " $(wg show interfaces) " == *" $INTERFACE "* ]] || die "... is not a WireGuard interface"
|
||||
|
||||
Autrement dit : si l'interface WireGuard a disparu sans passer par
|
||||
``wg-quick down`` (crash de l'application, kill, veille/reprise, redémarrage,
|
||||
délai d'attente sur l'élévation de privilèges qui tue wg-quick en plein vol),
|
||||
``wg-quick down`` échoue immédiatement et ``unset_dns`` n'est jamais appelé.
|
||||
L'entrée DNS reste enregistrée et pointe vers un résolveur devenu injoignable :
|
||||
plus aucune résolution de nom ne fonctionne, donc « plus d'internet ».
|
||||
|
||||
Ce module retire cette entrée de façon inconditionnelle et idempotente, quel
|
||||
que soit l'état de l'interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
|
||||
from app.utils.platform_utils import is_windows, run_command, run_privileged
|
||||
|
||||
# Domaine utilisé pour vérifier que la résolution DNS fonctionne encore.
|
||||
_PROBE_HOSTS = ("cloudflare.com", "example.com")
|
||||
|
||||
|
||||
def resolvconf_iface(iface: str) -> str:
|
||||
"""Nom d'interface tel que wg-quick l'enregistre auprès de resolvconf.
|
||||
|
||||
Reproduit ``resolvconf_iface_prefix()`` de wg-quick : le préfixe
|
||||
(``tun.`` sur Debian/openresolv) n'est utilisé que si
|
||||
/etc/resolvconf/interface-order existe ET que resolvconf n'est pas un lien
|
||||
symbolique (sur Ubuntu, /usr/sbin/resolvconf -> resolvectl, donc pas de
|
||||
préfixe).
|
||||
"""
|
||||
order = "/etc/resolvconf/interface-order"
|
||||
path = shutil.which("resolvconf")
|
||||
if not path or not os.path.isfile(order) or os.path.islink(path):
|
||||
return iface
|
||||
try:
|
||||
with open(order, "r", encoding="utf-8") as f:
|
||||
for raw in f:
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# wg-quick prend le premier motif se terminant par '*'
|
||||
if line.endswith("*"):
|
||||
return line[:-1] + iface
|
||||
except OSError:
|
||||
pass
|
||||
return iface
|
||||
|
||||
|
||||
def has_dns_backend() -> bool:
|
||||
"""True si wg-quick pourra appliquer la directive DNS = du fichier .conf."""
|
||||
if is_windows():
|
||||
return True
|
||||
return bool(shutil.which("resolvconf") or shutil.which("resolvectl"))
|
||||
|
||||
|
||||
def interface_exists(iface: str) -> bool:
|
||||
"""True si le périphérique réseau existe encore (indépendamment de wg)."""
|
||||
if is_windows():
|
||||
# Le service du tunnel survit à la fenêtre WireGuard : c'est lui qui
|
||||
# fait foi, pas la présence de l'adaptateur.
|
||||
code, out, _ = run_command(["sc", "query", f"WireGuardTunnel${iface}"], 10)
|
||||
return code == 0 and "1060" not in out
|
||||
return os.path.isdir(f"/sys/class/net/{iface}")
|
||||
|
||||
|
||||
def interface_is_up(iface: str) -> bool:
|
||||
"""True si l'interface existe et porte le drapeau IFF_UP.
|
||||
|
||||
Lecture directe de sysfs : contrairement à `wg show`, cela ne demande
|
||||
aucun privilège. `operstate` vaut « unknown » sur une interface WireGuard
|
||||
(point à point sans porteuse), il faut donc lire `flags`.
|
||||
"""
|
||||
try:
|
||||
with open(f"/sys/class/net/{iface}/flags", encoding="utf-8") as f:
|
||||
return bool(int(f.read().strip(), 16) & 0x1) # IFF_UP
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def list_wireguard_interfaces() -> list[str]:
|
||||
"""Interfaces WireGuard présentes sur la machine, sans privilèges.
|
||||
|
||||
`wg show` exige CAP_NET_ADMIN ; sysfs expose DEVTYPE=wireguard à tous.
|
||||
"""
|
||||
if is_windows():
|
||||
return []
|
||||
found: list[str] = []
|
||||
try:
|
||||
for name in sorted(os.listdir("/sys/class/net")):
|
||||
try:
|
||||
with open(f"/sys/class/net/{name}/uevent", encoding="utf-8") as f:
|
||||
if "DEVTYPE=wireguard" in f.read():
|
||||
found.append(name)
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
def _resolvconf_run_files(rc_iface: str) -> list[str]:
|
||||
"""Chemins où openresolv/resolvconf stocke l'entrée d'une interface."""
|
||||
return [
|
||||
f"/run/resolvconf/interface/{rc_iface}",
|
||||
f"/etc/resolvconf/run/interface/{rc_iface}",
|
||||
f"/var/run/resolvconf/interface/{rc_iface}",
|
||||
]
|
||||
|
||||
|
||||
def has_stale_entry(iface: str, tunnel_dns: str = "") -> bool:
|
||||
"""True s'il reste une trace DNS du tunnel alors que l'interface est morte.
|
||||
|
||||
Vérification purement locale : aucune commande privilégiée n'est lancée,
|
||||
pour ne pas déclencher de dialogue d'authentification inutile au démarrage.
|
||||
"""
|
||||
if is_windows():
|
||||
return _has_stale_nrpt_rule()
|
||||
if interface_exists(iface):
|
||||
return False
|
||||
|
||||
# 1. Entrée resolvconf/openresolv orpheline.
|
||||
rc_iface = resolvconf_iface(iface)
|
||||
for path in _resolvconf_run_files(rc_iface):
|
||||
if os.path.exists(path):
|
||||
return True
|
||||
|
||||
# 2. /etc/resolv.conf remplacé par un fichier simple pointant sur le DNS
|
||||
# du tunnel (cas des systèmes sans systemd-resolved).
|
||||
tunnel_dns = (tunnel_dns or "").split(",")[0].strip()
|
||||
if tunnel_dns and not os.path.islink("/etc/resolv.conf"):
|
||||
try:
|
||||
with open("/etc/resolv.conf", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "nameserver" \
|
||||
and parts[1] == tunnel_dns:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
_NRPT_LIST_PS = (
|
||||
"$r = Get-DnsClientNrptRule -ErrorAction SilentlyContinue | "
|
||||
"Where-Object { $_.Comment -like '*WireGuard*' }; "
|
||||
"if ($r) { 'STALE' }"
|
||||
)
|
||||
|
||||
_NRPT_CLEAN_PS = (
|
||||
"Get-DnsClientNrptRule -ErrorAction SilentlyContinue | "
|
||||
"Where-Object { $_.Comment -like '*WireGuard*' } | "
|
||||
"ForEach-Object { Remove-DnsClientNrptRule -Name $_.Name -Force "
|
||||
"-ErrorAction SilentlyContinue }"
|
||||
)
|
||||
|
||||
|
||||
def _powershell(script: str, timeout: int = 20) -> tuple[int, str, str]:
|
||||
return run_command(
|
||||
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
|
||||
timeout,
|
||||
)
|
||||
|
||||
|
||||
def _has_stale_nrpt_rule() -> bool:
|
||||
"""True s'il reste une règle NRPT posée par WireGuard.
|
||||
|
||||
Équivalent Windows de l'entrée resolvconf orpheline : quand le tunnel est
|
||||
arrêté brutalement, la règle NRPT redirige toujours la résolution vers le
|
||||
DNS du VPN, devenu injoignable — plus aucune résolution ne fonctionne.
|
||||
"""
|
||||
code, out, _ = _powershell(_NRPT_LIST_PS, 15)
|
||||
return code == 0 and "STALE" in out
|
||||
|
||||
|
||||
def _cleanup_windows(iface: str, force: bool) -> list[str]:
|
||||
done: list[str] = []
|
||||
|
||||
if force or _has_stale_nrpt_rule():
|
||||
code, _, _ = _powershell(_NRPT_CLEAN_PS, 30)
|
||||
if code == 0:
|
||||
done.append("Remove-DnsClientNrptRule (WireGuard)")
|
||||
|
||||
# Purge du cache : les échecs mis en cache pendant la panne survivraient.
|
||||
run_command(["ipconfig", "/flushdns"], 15)
|
||||
return done
|
||||
|
||||
|
||||
def cleanup(iface: str, force: bool = False) -> list[str]:
|
||||
"""Retire toute entrée DNS résiduelle laissée par le tunnel `iface`.
|
||||
|
||||
Par défaut, aucune commande privilégiée n'est lancée tant qu'une trace
|
||||
résiduelle n'a pas été constatée sans privilèges : sur un système
|
||||
systemd-resolved, la configuration DNS disparaît avec le lien réseau, et
|
||||
déclencher un dialogue polkit à chaque déconnexion serait inutile.
|
||||
Passer ``force=True`` pour forcer le retrait (interface encore présente).
|
||||
|
||||
Idempotent. Retourne la liste des actions réellement effectuées.
|
||||
"""
|
||||
if is_windows():
|
||||
return _cleanup_windows(iface, force)
|
||||
|
||||
done: list[str] = []
|
||||
|
||||
if force or has_stale_entry(iface):
|
||||
rc_iface = resolvconf_iface(iface)
|
||||
|
||||
# 1. resolvconf : c'est le chemin qu'emprunte wg-quick lui-même.
|
||||
if shutil.which("resolvconf"):
|
||||
# -f : ne pas échouer si l'entrée n'existe pas.
|
||||
code, _, _ = run_privileged(["resolvconf", "-d", rc_iface, "-f"],
|
||||
timeout=20)
|
||||
if code == 0:
|
||||
done.append(f"resolvconf -d {rc_iface}")
|
||||
|
||||
# 2. systemd-resolved : purge la configuration portée par le lien.
|
||||
# `resolvectl revert` échoue sans dommage si le lien a disparu.
|
||||
if shutil.which("resolvectl") and interface_exists(iface):
|
||||
code, _, _ = run_privileged(["resolvectl", "revert", iface], timeout=20)
|
||||
if code == 0:
|
||||
done.append(f"resolvectl revert {iface}")
|
||||
|
||||
# 3. Vider le cache : sans cela les NXDOMAIN obtenus pendant la panne
|
||||
# restent servis pendant leur TTL négatif. Non privilégié.
|
||||
if shutil.which("resolvectl"):
|
||||
run_command(["resolvectl", "flush-caches"], timeout=10)
|
||||
|
||||
return done
|
||||
|
||||
|
||||
def resolution_works(timeout: float = 3.0) -> bool:
|
||||
"""True si la résolution DNS système répond."""
|
||||
old = socket.getdefaulttimeout()
|
||||
socket.setdefaulttimeout(timeout)
|
||||
try:
|
||||
for host in _PROBE_HOSTS:
|
||||
try:
|
||||
socket.getaddrinfo(host, None)
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
finally:
|
||||
socket.setdefaulttimeout(old)
|
||||
|
||||
|
||||
def repair_if_broken(iface: str, tunnel_dns: str = "") -> tuple[bool, str]:
|
||||
"""Répare le DNS si le tunnel est absent mais une entrée résiduelle traîne.
|
||||
|
||||
Appelé au démarrage de l'application : c'est le cas typique après un crash
|
||||
ou un arrêt brutal de la session précédente, où ``wg-quick down`` n'a
|
||||
jamais tourné et où l'entrée DNS du tunnel reste active alors que le
|
||||
résolveur correspondant est injoignable.
|
||||
|
||||
Retourne (réparation_effectuée, message).
|
||||
"""
|
||||
if interface_exists(iface):
|
||||
# Le tunnel tourne : ne pas toucher à sa configuration DNS.
|
||||
return False, ""
|
||||
if not has_stale_entry(iface, tunnel_dns):
|
||||
return False, ""
|
||||
if resolution_works():
|
||||
# Entrée orpheline mais résolution fonctionnelle : nettoyage discret,
|
||||
# sans escalade de privilèges susceptible d'ouvrir un dialogue.
|
||||
return False, ""
|
||||
|
||||
actions = cleanup(iface)
|
||||
if not actions:
|
||||
return False, "Entrée DNS résiduelle détectée, nettoyage impossible"
|
||||
if resolution_works():
|
||||
return True, "DNS restauré (entrée résiduelle du tunnel retirée)"
|
||||
return True, "Entrée DNS résiduelle retirée, mais la résolution échoue encore"
|
||||
|
||||
|
||||
def active_resolvers() -> list[str]:
|
||||
"""Liste les résolveurs DNS effectivement utilisés par le système."""
|
||||
if is_windows():
|
||||
import re
|
||||
code, out, _ = run_command(["ipconfig", "/all"], 15)
|
||||
if code != 0:
|
||||
return []
|
||||
found: list[str] = []
|
||||
for block in re.findall(
|
||||
r"DNS Servers[ .]*:\s*(.*?)(?=\n\s*[A-Za-z][^\n]*[ .]+:|\Z)",
|
||||
out, re.DOTALL,
|
||||
):
|
||||
for line in block.splitlines():
|
||||
ip = line.strip()
|
||||
if re.fullmatch(r"[0-9a-fA-F.:]+", ip or ""):
|
||||
found.append(ip)
|
||||
return found
|
||||
|
||||
# systemd-resolved : /etc/resolv.conf ne contient que le stub 127.0.0.53,
|
||||
# les vrais résolveurs ne sont visibles que via resolvectl.
|
||||
resolvers: list[str] = []
|
||||
if shutil.which("resolvectl"):
|
||||
code, out, _ = run_command(["resolvectl", "dns"], 10)
|
||||
if code == 0:
|
||||
for line in out.splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
for ip in line.split(":", 1)[1].split():
|
||||
if ip and ip not in resolvers:
|
||||
resolvers.append(ip)
|
||||
|
||||
if not resolvers:
|
||||
try:
|
||||
with open("/etc/resolv.conf", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts) >= 2 and parts[0] == "nameserver":
|
||||
if parts[1] not in resolvers:
|
||||
resolvers.append(parts[1])
|
||||
except OSError:
|
||||
pass
|
||||
return resolvers
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Restauration garantie de l'état réseau à la fermeture de l'application.
|
||||
|
||||
Le tunnel WireGuard modifie deux choses globales au système : la table de
|
||||
routage et la résolution DNS. Tant que l'application ne les rend pas, la
|
||||
machine reste dans un état où « il n'y a plus internet » — c'est vrai sous
|
||||
Linux (entrée resolvconf orpheline) comme sous Windows (service de tunnel
|
||||
resté en place et règle NRPT résiduelle).
|
||||
|
||||
Ce module branche la même routine de restauration sur *toutes* les sorties :
|
||||
|
||||
* clic sur « Quitter » dans le systray ou fermeture de la fenêtre ;
|
||||
* signal SIGINT / SIGTERM (Ctrl-C, arrêt de session, `kill`) ;
|
||||
* SIGBREAK sous Windows (fermeture de la console) ;
|
||||
* `atexit`, qui couvre `sys.exit()` et la fin normale de l'interpréteur ;
|
||||
* exception non rattrapée, via `sys.excepthook`.
|
||||
|
||||
La restauration est idempotente : la déclencher plusieurs fois est sans effet.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
from app.core import wireguard as wg_core
|
||||
from app.core.config import Config
|
||||
|
||||
_lock = threading.Lock()
|
||||
_restored = False
|
||||
_cfg: Config | None = None
|
||||
_report: Callable[[str, str], None] | None = None
|
||||
_installed = False
|
||||
|
||||
|
||||
def _log(msg: str, kind: str = "info") -> None:
|
||||
if _report is not None:
|
||||
try:
|
||||
_report(msg, kind)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def restore_network(reason: str = "") -> list[str]:
|
||||
"""Rend le réseau à son état d'origine. Idempotent et sans exception.
|
||||
|
||||
Retourne la liste des actions effectuées (vide si rien n'était à faire).
|
||||
"""
|
||||
global _restored
|
||||
with _lock:
|
||||
if _restored or _cfg is None:
|
||||
return []
|
||||
_restored = True
|
||||
cfg = _cfg
|
||||
|
||||
actions: list[str] = []
|
||||
try:
|
||||
if wg_core.is_connected(cfg):
|
||||
ok, msg = wg_core.disconnect(cfg)
|
||||
actions.append("tunnel arrêté" if ok else f"échec arrêt tunnel : {msg}")
|
||||
_log(f"Fermeture{(' (' + reason + ')') if reason else ''} :"
|
||||
f" {actions[-1]}", "info" if ok else "error")
|
||||
else:
|
||||
# Le tunnel n'est plus là, mais sa configuration DNS peut avoir
|
||||
# survécu : c'est précisément le cas qui coupe la résolution.
|
||||
actions += wg_core.force_cleanup(cfg)
|
||||
if actions:
|
||||
_log("Résidus réseau nettoyés à la fermeture : "
|
||||
+ ", ".join(actions), "warning")
|
||||
except Exception as e: # noqa: BLE001
|
||||
_log(f"Restauration réseau incomplète : {e}", "error")
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def _signal_handler(signum, _frame):
|
||||
restore_network(f"signal {signum}")
|
||||
# Réinstaller le comportement par défaut puis se renvoyer le signal :
|
||||
# le code de sortie reste celui attendu par l'appelant (shell, systemd).
|
||||
try:
|
||||
signal.signal(signum, signal.SIG_DFL)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
sys.exit(128 + int(signum))
|
||||
|
||||
|
||||
def _excepthook(exc_type, exc, tb):
|
||||
restore_network("exception non rattrapée")
|
||||
sys.__excepthook__(exc_type, exc, tb)
|
||||
|
||||
|
||||
def install(cfg: Config, report: Callable[[str, str], None] | None = None,
|
||||
app=None) -> None:
|
||||
"""Branche la restauration sur toutes les voies de sortie du programme."""
|
||||
global _cfg, _report, _installed
|
||||
_cfg = cfg
|
||||
_report = report
|
||||
if _installed:
|
||||
return
|
||||
_installed = True
|
||||
|
||||
atexit.register(restore_network, "atexit")
|
||||
sys.excepthook = _excepthook
|
||||
|
||||
for name in ("SIGINT", "SIGTERM", "SIGBREAK", "SIGHUP"):
|
||||
sig = getattr(signal, name, None)
|
||||
if sig is None:
|
||||
continue
|
||||
try:
|
||||
signal.signal(sig, _signal_handler)
|
||||
except (ValueError, OSError):
|
||||
# Signal indisponible sur la plateforme ou hors thread principal.
|
||||
pass
|
||||
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(lambda: restore_network("aboutToQuit"))
|
||||
# Qt bloque dans du code C : sans réveil périodique, les gestionnaires
|
||||
# de signaux Python ne s'exécutent jamais pendant la boucle d'événements.
|
||||
from PyQt6.QtCore import QTimer
|
||||
heartbeat = QTimer(app)
|
||||
heartbeat.timeout.connect(lambda: None)
|
||||
heartbeat.start(400)
|
||||
app._wgs_signal_heartbeat = heartbeat # garder une référence vivante
|
||||
|
||||
|
||||
def arm() -> None:
|
||||
"""Réarme la restauration après une reconnexion volontaire."""
|
||||
global _restored
|
||||
with _lock:
|
||||
_restored = False
|
||||
+277
-90
@@ -3,6 +3,7 @@ import os
|
||||
import socket
|
||||
import time
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from app.core import dns as dns_util
|
||||
from app.core.config import Config
|
||||
from app.utils.platform_utils import (
|
||||
get_config_dir,
|
||||
@@ -35,7 +36,15 @@ def build_client_config(cfg: Config) -> str:
|
||||
"[Interface]",
|
||||
f"PrivateKey = {wg['client_private_key']}",
|
||||
f"Address = {wg['client_address']}",
|
||||
f"DNS = {wg['dns']}",
|
||||
]
|
||||
# Une directive DNS = sans backend resolvconf/resolvectl fait échouer
|
||||
# `wg-quick up` en plein milieu de son exécution : l'interface est déjà
|
||||
# créée, le trap de nettoyage laisse un état partiel. Mieux vaut monter le
|
||||
# tunnel sans toucher au DNS système.
|
||||
dns_value = (wg.get("dns") or "").strip()
|
||||
if dns_value and dns_util.has_dns_backend():
|
||||
lines.append(f"DNS = {dns_value}")
|
||||
lines += [
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {wg['server_public_key']}",
|
||||
@@ -54,8 +63,19 @@ def get_client_config_path(cfg: Config) -> str:
|
||||
|
||||
|
||||
def write_client_config(cfg: Config) -> tuple[bool, str]:
|
||||
"""Écrit la config client, en élevant les privilèges si nécessaire.
|
||||
|
||||
/etc/wireguard est en 0700 root:root : l'écriture directe échoue pour un
|
||||
utilisateur normal. On passe alors par un fichier temporaire dans le
|
||||
répertoire de configuration de l'utilisateur, installé ensuite par
|
||||
`install` — une seule commande privilégiée, qui pose contenu, propriétaire
|
||||
et permissions d'un coup, et qui traverse toute la chaîne
|
||||
sudo -n → askpass → pkexec de run_privileged().
|
||||
"""
|
||||
content = build_client_config(cfg)
|
||||
path = get_client_config_path(cfg)
|
||||
|
||||
# Chemin direct : Windows, ou exécution en root.
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
@@ -63,39 +83,123 @@ def write_client_config(cfg: Config) -> tuple[bool, str]:
|
||||
if not is_windows():
|
||||
os.chmod(path, 0o600)
|
||||
return True, path
|
||||
except PermissionError:
|
||||
# sudo -n tee (NOPASSWD si configuré, sinon pkexec cp)
|
||||
import subprocess as _sp
|
||||
try:
|
||||
r = _sp.run(["sudo", "-n", "tee", path],
|
||||
input=content, text=True,
|
||||
capture_output=True, timeout=10)
|
||||
if r.returncode == 0:
|
||||
_sp.run(["sudo", "-n", "chmod", "600", path],
|
||||
capture_output=True, timeout=5)
|
||||
return True, path
|
||||
except Exception:
|
||||
pass
|
||||
# Repli sur pkexec cp (dialogue graphique)
|
||||
tmp = os.path.join(get_config_dir(), "wgs_tmp.conf")
|
||||
with open(tmp, "w") as f:
|
||||
except (PermissionError, FileNotFoundError, OSError):
|
||||
if is_windows():
|
||||
return False, f"Écriture impossible dans {path}"
|
||||
|
||||
tmp = os.path.join(get_config_dir(), ".wgs_tmp.conf")
|
||||
try:
|
||||
# Créer en 0600 dès l'origine : la clé privée ne doit jamais être
|
||||
# lisible par les autres utilisateurs, même brièvement.
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(content)
|
||||
os.chmod(tmp, 0o600)
|
||||
code, _, err = run_privileged(["cp", tmp, path])
|
||||
os.unlink(tmp)
|
||||
|
||||
code, _, err = run_privileged(
|
||||
["install", "-D", "-m", "600", "-o", "root", "-g", "root", tmp, path],
|
||||
timeout=60,
|
||||
)
|
||||
if code == 0:
|
||||
run_privileged(["chmod", "600", path])
|
||||
return True, path
|
||||
return False, err
|
||||
return False, err or f"Écriture impossible dans {path}"
|
||||
except OSError as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _wg_show(name: str) -> str:
|
||||
"""Sortie de `wg show <iface>`, ou "" si les privilèges manquent.
|
||||
|
||||
Attention : sans CAP_NET_ADMIN, `wg show` écrit « Unable to access
|
||||
interface: Operation not permitted » sur stderr **et sort avec le code 0**.
|
||||
Un simple test `code == 0` conclut donc « déconnecté » pour tout
|
||||
utilisateur non root — c'était le cas de l'application entière.
|
||||
"""
|
||||
code, out, err = run_command(["wg", "show", name])
|
||||
if code != 0 or not out:
|
||||
return ""
|
||||
if "not permitted" in err.lower() or "unable to access" in err.lower():
|
||||
return ""
|
||||
return out
|
||||
|
||||
|
||||
def is_connected(cfg: Config) -> bool:
|
||||
"""True si le tunnel est monté.
|
||||
|
||||
Déterminé via sysfs, qui ne demande aucun privilège : `wg show` en exige,
|
||||
et son échec silencieux faisait afficher « Déconnecté » en permanence —
|
||||
d'où des tentatives de connexion sur un tunnel déjà monté, une
|
||||
auto-reconnexion en boucle, et surtout aucun démontage à la fermeture,
|
||||
laissant le DNS du tunnel en place.
|
||||
"""
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
if is_windows():
|
||||
code, out, _ = run_command(["sc", "query", f"WireGuardTunnel${name}"])
|
||||
return code == 0 and "RUNNING" in out
|
||||
code, out, _ = run_command(["wg", "show", name])
|
||||
return code == 0 and bool(out)
|
||||
return dns_util.interface_is_up(name)
|
||||
|
||||
|
||||
def foreign_wg_interfaces(cfg: Config) -> list[str]:
|
||||
"""Tunnels WireGuard actifs portant un autre nom que celui configuré.
|
||||
|
||||
Un tunnel monté hors de l'application (ancien profil, `wg-quick` lancé à
|
||||
la main) impose lui aussi son DNS : ni l'interface ni ce DNS ne seront
|
||||
retirés par WGSecure, qui ne connaît que son propre nom d'interface.
|
||||
"""
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
return [i for i in dns_util.list_wireguard_interfaces() if i != name]
|
||||
|
||||
|
||||
def interface_exists(cfg: Config) -> bool:
|
||||
"""True si le périphérique réseau existe, même s'il n'est plus fonctionnel."""
|
||||
return dns_util.interface_exists(cfg.wg.get("interface_name", "wgs0"))
|
||||
|
||||
|
||||
def force_cleanup(cfg: Config) -> list[str]:
|
||||
"""Démonte tout résidu du tunnel : entrée DNS orpheline + interface morte.
|
||||
|
||||
Idempotent et silencieux si rien ne traîne : aucune élévation de privilèges
|
||||
n'est demandée tant qu'un résidu n'a pas été constaté.
|
||||
|
||||
Indispensable parce que `wg-quick down` refuse de s'exécuter
|
||||
(« is not a WireGuard interface ») dès que l'interface a disparu — et
|
||||
n'exécute donc jamais son `unset_dns`, ce qui laisse la résolution DNS
|
||||
pointée sur un serveur devenu injoignable.
|
||||
"""
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
actions: list[str] = []
|
||||
|
||||
if is_windows():
|
||||
# Le service du tunnel restaure routes et DNS en s'arrêtant ; s'il est
|
||||
# bloqué, on le supprime avant de purger les règles NRPT résiduelles.
|
||||
if dns_util.interface_exists(name):
|
||||
svc = f"WireGuardTunnel${name}"
|
||||
run_command(["wireguard", "/uninstalltunnel", name], 30)
|
||||
if dns_util.interface_exists(name):
|
||||
run_privileged(["sc", "stop", svc], timeout=30)
|
||||
run_privileged(["sc", "delete", svc], timeout=30)
|
||||
actions.append(f"sc delete {svc}")
|
||||
actions += dns_util.cleanup(name)
|
||||
return actions
|
||||
|
||||
# 1. Supprimer d'abord l'interface : sous systemd-resolved la configuration
|
||||
# DNS est portée par le lien, la retirer suffit à la faire disparaître.
|
||||
# Une seule élévation de privilèges suffit donc dans le cas courant.
|
||||
if dns_util.interface_exists(name):
|
||||
code, _, _ = run_privileged(["ip", "link", "delete", "dev", name], timeout=20)
|
||||
if code == 0:
|
||||
actions.append(f"ip link delete {name}")
|
||||
|
||||
# 2. Retirer l'entrée DNS survivante — celle d'openresolv, notamment, qui
|
||||
# ne disparaît pas avec le lien. C'est la cause du « plus d'internet ».
|
||||
# Sans effet, et sans dialogue d'authentification, si rien ne traîne.
|
||||
actions += dns_util.cleanup(name)
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
def connect(cfg: Config) -> tuple[bool, str]:
|
||||
@@ -111,44 +215,104 @@ def connect(cfg: Config) -> tuple[bool, str]:
|
||||
if is_windows():
|
||||
code, _, err = run_command(["wireguard", "/installtunnel", result])
|
||||
return (code == 0), (err or "Connecté")
|
||||
else:
|
||||
# Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement
|
||||
code, _, err = run_privileged(["wg-quick", "up", name])
|
||||
if code == 0:
|
||||
return True, "Tunnel WireGuard activé"
|
||||
return False, err or "Erreur lors de la connexion"
|
||||
|
||||
# Déjà monté et fonctionnel : rien à faire.
|
||||
if is_connected(cfg):
|
||||
return True, "Tunnel WireGuard déjà actif"
|
||||
|
||||
# Interface fantôme d'une session précédente : `wg-quick up` mourrait sur
|
||||
# « `wgs0' already exists ». On nettoie avant de remonter.
|
||||
if dns_util.interface_exists(name):
|
||||
force_cleanup(cfg)
|
||||
|
||||
# Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement
|
||||
code, _, err = run_privileged(["wg-quick", "up", name], timeout=90)
|
||||
if code == 0:
|
||||
return True, "Tunnel WireGuard activé"
|
||||
|
||||
# Échec : wg-quick a pu s'arrêter après set_dns (ou être tué par le
|
||||
# dépassement de délai avant son trap de nettoyage). On démonte
|
||||
# explicitement pour ne pas laisser le DNS système détourné.
|
||||
force_cleanup(cfg)
|
||||
return False, err or "Erreur lors de la connexion"
|
||||
|
||||
|
||||
def disconnect(cfg: Config) -> tuple[bool, str]:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
|
||||
if is_windows():
|
||||
code, _, err = run_command(["wireguard", "/uninstalltunnel", name])
|
||||
return (code == 0), (err or "Déconnecté")
|
||||
else:
|
||||
code, _, err = run_privileged(["wg-quick", "down", name])
|
||||
if code == 0:
|
||||
return True, "Tunnel WireGuard désactivé"
|
||||
code, _, err = run_command(["wireguard", "/uninstalltunnel", name], 30)
|
||||
# Nettoyage inconditionnel : si le service reste ou qu'une règle NRPT
|
||||
# survit, la résolution DNS resterait détournée vers le DNS du tunnel.
|
||||
force_cleanup(cfg)
|
||||
if code == 0 or not dns_util.interface_exists(name):
|
||||
return True, "Déconnecté"
|
||||
return False, err or "Erreur lors de la déconnexion"
|
||||
|
||||
err = ""
|
||||
if is_connected(cfg):
|
||||
code, _, err = run_privileged(["wg-quick", "down", name], timeout=60)
|
||||
else:
|
||||
# wg-quick refuserait de tourner ici ; on passe directement au
|
||||
# démontage manuel, sinon l'entrée DNS du tunnel resterait active.
|
||||
code = -1
|
||||
|
||||
# Nettoyage inconditionnel : `wg-quick down` peut avoir réussi partiellement
|
||||
# (échec d'un PostDown, interface déjà supprimée) et laissé l'entrée DNS.
|
||||
force_cleanup(cfg)
|
||||
|
||||
if code == 0 or not dns_util.interface_exists(name):
|
||||
return True, "Tunnel WireGuard désactivé"
|
||||
return False, err or "Erreur lors de la déconnexion"
|
||||
|
||||
|
||||
def test_connection(cfg: Config, timeout: int = 5) -> tuple[bool, str]:
|
||||
"""Teste la joignabilité du serveur WireGuard.
|
||||
|
||||
Note : `socket.connect()` sur un socket UDP n'émet aucun paquet et réussit
|
||||
donc toujours — l'ancien test renvoyait « joignable » même serveur éteint.
|
||||
On résout le nom, on envoie un datagramme et on interprète l'absence de
|
||||
réponse ICMP « port unreachable » comme un port ouvert/filtré.
|
||||
"""
|
||||
endpoint = cfg.wg.get("server_endpoint", "")
|
||||
port = int(cfg.wg.get("server_port", 51820))
|
||||
if not endpoint:
|
||||
return False, "Aucun serveur configuré"
|
||||
|
||||
# 1. Résolution du nom — échoue aussi si le DNS système est cassé.
|
||||
try:
|
||||
infos = socket.getaddrinfo(endpoint, port, type=socket.SOCK_DGRAM)
|
||||
except socket.gaierror as e:
|
||||
return False, f"Nom « {endpoint} » non résolu : {e.strerror or e}"
|
||||
family, socktype, proto, _, addr = infos[0]
|
||||
|
||||
# 2. Datagramme sonde : un ICMP port unreachable remonte en
|
||||
# ConnectionRefusedError, ce qui prouve que rien n'écoute.
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
sock.settimeout(timeout)
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(timeout)
|
||||
start = time.monotonic()
|
||||
sock.connect((endpoint, port))
|
||||
sock.close()
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
return True, f"Serveur joignable ({latency} ms)"
|
||||
except socket.timeout:
|
||||
return False, "Timeout — serveur inaccessible"
|
||||
sock.sendto(b"\x00" * 32, addr)
|
||||
try:
|
||||
sock.recvfrom(1024)
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
return True, f"Serveur joignable ({latency} ms)"
|
||||
except socket.timeout:
|
||||
pass
|
||||
except ConnectionRefusedError:
|
||||
return False, f"Port UDP {port} fermé sur {addr[0]}"
|
||||
except OSError as e:
|
||||
return False, str(e)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
# 3. Pas de rejet ICMP : le port est ouvert ou filtré. On complète avec un
|
||||
# ping pour distinguer « hôte injoignable » de « serveur silencieux ».
|
||||
ms = ping_server(addr[0], timeout=2)
|
||||
if ms is None:
|
||||
return True, (f"Port UDP {port} ouvert ou filtré sur {addr[0]} "
|
||||
"(hôte ne répond pas au ping)")
|
||||
return True, f"Serveur joignable ({ms} ms, port UDP {port} non rejeté)"
|
||||
|
||||
|
||||
def parse_conf_file(path: str) -> dict | None:
|
||||
@@ -219,14 +383,26 @@ def export_conf_file(cfg: Config, dest_path: str) -> tuple[bool, str]:
|
||||
|
||||
|
||||
def get_interface_bytes(iface: str) -> tuple[int, int] | None:
|
||||
"""Retourne (rx_bytes, tx_bytes) depuis /proc/net/dev (Linux)."""
|
||||
"""Retourne (rx_bytes, tx_bytes) depuis /proc/net/dev (Linux).
|
||||
|
||||
Le nom d'interface est comparé exactement : un `in` sur la ligne entière
|
||||
confondait « wg0 » avec « wg0bis » et pouvait aussi matcher un compteur.
|
||||
Les colonnes sont lues après le « : », car sur un compteur très large
|
||||
/proc/net/dev colle le nom et la valeur (« wgs0:123456789 »).
|
||||
"""
|
||||
try:
|
||||
with open("/proc/net/dev") as f:
|
||||
for line in f:
|
||||
if iface in line:
|
||||
parts = line.split()
|
||||
return int(parts[1]), int(parts[9])
|
||||
except Exception:
|
||||
if ":" not in line:
|
||||
continue
|
||||
name, _, counters = line.partition(":")
|
||||
if name.strip() != iface:
|
||||
continue
|
||||
parts = counters.split()
|
||||
if len(parts) < 9:
|
||||
return None
|
||||
return int(parts[0]), int(parts[8])
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -258,54 +434,46 @@ def ping_server(host: str, timeout: int = 2) -> int | None:
|
||||
def dns_leak_test(cfg: Config) -> dict:
|
||||
"""
|
||||
Teste si le DNS fuit en dehors du tunnel.
|
||||
Retourne {"status": ok|leak|unknown, "resolvers": [...], "expected": str}
|
||||
Retourne {"status": ok|leak|unknown|down, "resolvers": [...], "expected": str}
|
||||
"""
|
||||
expected_dns = cfg.wg.get("dns", "")
|
||||
resolvers: list[str] = []
|
||||
expected_dns = (cfg.wg.get("dns", "") or "").split(",")[0].strip()
|
||||
|
||||
# 1. Lire /etc/resolv.conf (Linux) ou ipconfig /all (Windows)
|
||||
if not is_windows():
|
||||
try:
|
||||
with open("/etc/resolv.conf") as f:
|
||||
for line in f:
|
||||
if line.startswith("nameserver"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
resolvers.append(parts[1])
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
code, out, _ = run_command(["ipconfig", "/all"])
|
||||
if code == 0:
|
||||
import re
|
||||
resolvers = re.findall(r"DNS Servers.*?:(.*?)(?:\n\S|\Z)",
|
||||
out, re.DOTALL)
|
||||
# Sous systemd-resolved, /etc/resolv.conf ne contient que le stub
|
||||
# 127.0.0.53 : lire ce fichier signalait donc toujours une « fuite ».
|
||||
# dns_util.active_resolvers() interroge resolvectl quand il est présent.
|
||||
resolvers = dns_util.active_resolvers()
|
||||
resolvers = [r for r in resolvers if r not in ("127.0.0.53", "127.0.0.1")] or resolvers
|
||||
|
||||
# 2. Résoudre un domaine test via dig/nslookup pour voir quel serveur répond
|
||||
probe_ip: str = ""
|
||||
code, out, _ = run_command(["dig", "+short", "+time=3", "whoami.akamai.net"], 6)
|
||||
tunnel_up = is_connected(cfg)
|
||||
|
||||
# Sonde : quel résolveur récursif sort réellement sur le réseau.
|
||||
probe_ip = ""
|
||||
code, out, _ = run_command(["dig", "+short", "+time=3", "whoami.akamai.net"], 8)
|
||||
if code == 0:
|
||||
lines = [l.strip() for l in out.splitlines() if l.strip()]
|
||||
lines = [line.strip() for line in out.splitlines() if line.strip()]
|
||||
if lines:
|
||||
probe_ip = lines[-1]
|
||||
|
||||
if not resolvers:
|
||||
return {"status": "unknown", "resolvers": [], "expected": expected_dns,
|
||||
"probe_ip": probe_ip}
|
||||
|
||||
# 3. Comparer avec le DNS configuré
|
||||
if expected_dns and any(expected_dns in r for r in resolvers):
|
||||
status = "ok"
|
||||
elif expected_dns:
|
||||
status = "leak"
|
||||
else:
|
||||
if not dns_util.resolution_works():
|
||||
status = "down"
|
||||
elif not resolvers:
|
||||
status = "unknown"
|
||||
elif not tunnel_up:
|
||||
# Tunnel inactif : parler de fuite n'a pas de sens.
|
||||
status = "unknown"
|
||||
elif not expected_dns:
|
||||
status = "unknown"
|
||||
elif any(expected_dns == r or expected_dns in r for r in resolvers):
|
||||
status = "ok"
|
||||
else:
|
||||
status = "leak"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"resolvers": resolvers,
|
||||
"expected": expected_dns,
|
||||
"probe_ip": probe_ip,
|
||||
"tunnel_up": tunnel_up,
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +531,13 @@ def generate_server_config(
|
||||
}
|
||||
|
||||
|
||||
def _fmt_bytes(n: int) -> str:
|
||||
for unit, div in (("GiB", 1 << 30), ("MiB", 1 << 20), ("KiB", 1 << 10)):
|
||||
if n >= div:
|
||||
return f"{n / div:.2f} {unit}"
|
||||
return f"{n} B"
|
||||
|
||||
|
||||
def get_status_info(cfg: Config) -> dict:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
info = {
|
||||
@@ -377,11 +552,24 @@ def get_status_info(cfg: Config) -> dict:
|
||||
info["connected"] = is_connected(cfg)
|
||||
return info
|
||||
|
||||
code, out, _ = run_command(["wg", "show", name])
|
||||
if code != 0 or not out:
|
||||
info["connected"] = is_connected(cfg)
|
||||
if not info["connected"]:
|
||||
return info
|
||||
|
||||
# Compteurs : /proc/net/dev est lisible sans privilèges, contrairement à
|
||||
# `wg show`. Sert de repli quand l'application tourne en simple utilisateur.
|
||||
counters = get_interface_bytes(name)
|
||||
if counters:
|
||||
info["rx_bytes"] = _fmt_bytes(counters[0])
|
||||
info["tx_bytes"] = _fmt_bytes(counters[1])
|
||||
|
||||
out = _wg_show(name)
|
||||
if not out:
|
||||
# Détails du pair inaccessibles sans CAP_NET_ADMIN — l'état de la
|
||||
# connexion, lui, reste correct.
|
||||
info["last_handshake"] = "—"
|
||||
return info
|
||||
|
||||
info["connected"] = True
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("peer:"):
|
||||
@@ -389,10 +577,9 @@ def get_status_info(cfg: Config) -> dict:
|
||||
elif line.startswith("transfer:"):
|
||||
parts = line.split(":", 1)[1].strip().split(",")
|
||||
try:
|
||||
rx = parts[0].strip().split()[0]
|
||||
tx = parts[1].strip().split()[0]
|
||||
info["rx_bytes"] = rx
|
||||
info["tx_bytes"] = tx
|
||||
# « 1.23 MiB received » → « 1.23 MiB » (l'unité était perdue)
|
||||
info["rx_bytes"] = " ".join(parts[0].split()[:2])
|
||||
info["tx_bytes"] = " ".join(parts[1].split()[:2])
|
||||
except Exception:
|
||||
pass
|
||||
elif line.startswith("latest handshake:"):
|
||||
|
||||
Reference in New Issue
Block a user