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:
tuxgyver
2026-08-31 11:16:04 +02:00
parent 2fa50a5873
commit 1b74719204
11 changed files with 1295 additions and 233 deletions
+97
View File
@@ -9,6 +9,7 @@ from PyQt6.QtGui import QFont
from app.core.config import Config
from app.core import wireguard as wg_core
from app.core import dns as dns_util
from app.core import mfa as mfa_core
# CSS sombre commun à tous les onglets
@@ -465,6 +466,13 @@ class AdminWindow(QDialog):
btn_dns = QPushButton("🔍 Analyser le DNS")
btn_dns.clicked.connect(self._run_dns_test)
g2.addWidget(btn_dns)
btn_dns_fix = QPushButton("🛠️ Réparer le DNS (tunnel arrêté)")
btn_dns_fix.setToolTip(
"Retire la configuration DNS laissée par un tunnel arrêté "
"brutalement, qui empêche toute résolution de noms."
)
btn_dns_fix.clicked.connect(self._repair_dns)
g2.addWidget(btn_dns_fix)
self._dns_result = QLabel("")
self._dns_result.setWordWrap(True)
self._dns_result.setStyleSheet(
@@ -472,6 +480,24 @@ class AdminWindow(QDialog):
)
g2.addWidget(self._dns_result)
lay.addWidget(grp2)
# Droits d'exécution
grp3 = QGroupBox("🔐 Droits d'exécution")
g3 = QVBoxLayout(grp3)
g3.addWidget(QLabel(
"Sans règle sudo, chaque connexion ouvre un dialogue d'authentification."
))
btn_priv = QPushButton("🔐 Vérifier les droits")
btn_priv.clicked.connect(self._check_privileges)
g3.addWidget(btn_priv)
self._priv_result = QLabel("")
self._priv_result.setWordWrap(True)
self._priv_result.setStyleSheet(
"color: white; padding: 8px; border-radius: 6px; font-size: 11px;"
)
g3.addWidget(self._priv_result)
lay.addWidget(grp3)
lay.addStretch()
return w
@@ -948,6 +974,12 @@ class AdminWindow(QDialog):
elif status == "leak":
lines.append("⚠️ Fuite DNS potentielle !")
css = "background: rgba(231,76,60,0.2); color: #f1948a;"
elif status == "down":
lines.append("🚫 Résolution DNS hors service — utilisez « Réparer le DNS »")
css = "background: rgba(231,76,60,0.2); color: #f1948a;"
elif not r.get("tunnel_up", False):
lines.append("️ Tunnel inactif : la comparaison n'a pas de sens")
css = "color: #aed6f1;"
else:
lines.append("❓ Statut inconnu")
css = "color: #aed6f1;"
@@ -962,6 +994,71 @@ class AdminWindow(QDialog):
f"padding: 8px; border-radius: 6px; font-size: 11px; {css}"
)
def _check_privileges(self):
"""Liste les commandes privilégiées et celles qui exigeront un dialogue."""
from PyQt6.QtWidgets import QApplication
from app.utils.platform_utils import privilege_report
self._priv_result.setText("Vérification en cours…")
QApplication.processEvents()
rows = privilege_report()
if not rows:
self._priv_result.setText(
"✅ Exécution avec les privilèges root : rien à configurer."
)
css = "background: rgba(39,174,96,0.2); color: #a9dfbf;"
else:
lines = [
("" if ok else "⚠️ ") + f"{name}{role}"
+ ("" if ok else " (dialogue à chaque appel)")
for name, ok, role in rows
]
missing = [n for n, ok, _ in rows if not ok]
if missing:
lines.append("")
lines.append("Pour supprimer ces dialogues : make setup-sudoers")
css = "background: rgba(241,196,15,0.2); color: #f9e79f;"
else:
css = "background: rgba(39,174,96,0.2); color: #a9dfbf;"
self._priv_result.setText("\n".join(lines))
self._priv_result.setStyleSheet(
f"padding: 8px; border-radius: 6px; font-size: 11px; {css}"
)
def _repair_dns(self):
"""Retire la configuration DNS résiduelle d'un tunnel mal arrêté.
`wg-quick down` refuse de s'exécuter dès que l'interface a disparu et
ne retire donc jamais l'entrée DNS qu'il avait posée : la résolution
reste dirigée vers un serveur injoignable.
"""
from PyQt6.QtWidgets import QApplication
self._dns_result.setText("Réparation en cours…")
QApplication.processEvents()
actions = wg_core.force_cleanup(self._cfg)
works = dns_util.resolution_works()
if works:
msg = "✅ Résolution DNS fonctionnelle"
if actions:
msg += "\nActions : " + ", ".join(actions)
css = "background: rgba(39,174,96,0.2); color: #a9dfbf;"
elif actions:
msg = ("⚠️ Résidus retirés (" + ", ".join(actions) +
") mais la résolution échoue toujours.\n"
"Vérifiez la connexion réseau physique.")
css = "background: rgba(241,196,15,0.2); color: #f9e79f;"
else:
msg = ("🚫 Aucun résidu du tunnel détecté et la résolution échoue :"
" le problème vient du réseau, pas de WGSecure.")
css = "background: rgba(231,76,60,0.2); color: #f1948a;"
self._dns_result.setText(msg)
self._dns_result.setStyleSheet(
f"padding: 8px; border-radius: 6px; font-size: 11px; {css}"
)
def _set_admin_password(self):
p1 = self._admin_pw1.text()
p2 = self._admin_pw2.text()
+208 -82
View File
@@ -10,8 +10,11 @@ from PyQt6.QtGui import QFont, QCloseEvent, QColor
from app.core.config import Config
from app.core import wireguard as wg_core
from app.core import dns as dns_util
from app.core import log as conn_log
from app.core import history as hist
from app.core import shutdown as shutdown_guard
from app.ui.worker import TaskWorker, ValueWorker
from app.ui.mfa_dialog import MFADialog
from app.ui.admin_window import AdminWindow
from app.ui.bw_graph import BandwidthGraph
@@ -47,6 +50,12 @@ class MainWindow(QMainWindow):
self._prev_tx: int | None = None
self._auto_reconnect_active = False
self._session_id: int | None = None
self._worker: TaskWorker | None = None
self._ping_worker: ValueWorker | None = None
self._is_connected = False
self._reconnect_failures = 0
self._quitting = False
self._warned_foreign: set[str] = set()
self.setWindowTitle("WGSecure")
self.setFixedSize(430, 680)
@@ -82,6 +91,7 @@ class MainWindow(QMainWindow):
self._refresh_status()
self._reload_log()
self._reload_profiles()
self._warn_foreign_interfaces()
if self._cfg.get("ui", "auto_connect_on_startup") and self._cfg.configured:
QTimer.singleShot(500, self._on_connect)
@@ -227,7 +237,7 @@ class MainWindow(QMainWindow):
self._log_list.setFixedHeight(78)
self._log_list.setStyleSheet(
f"QListWidget {{ background: {_DARK2}; color: #aed6f1;"
" border: none; font-size: 10px; font-family: Courier; }}"
" border: none; font-size: 10px; font-family: Courier; }"
"QListWidget::item { padding: 1px 6px; }"
)
self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
@@ -390,47 +400,68 @@ class MainWindow(QMainWindow):
rx_bps = max(0, rx_now - self._prev_rx) / dt
tx_bps = max(0, tx_now - self._prev_tx) / dt
self._prev_rx, self._prev_tx = rx_now, tx_now
if wg_core.is_connected(self._cfg):
self._bw_graph.push(rx_bps, tx_bps)
else:
self._bw_graph.push(0, 0)
# `_is_connected` est rafraîchi par _refresh_status : inutile de
# relancer un `wg show` (un sous-processus) toutes les 3 secondes.
self._bw_graph.push(rx_bps if self._is_connected else 0,
tx_bps if self._is_connected else 0)
def _update_ping(self):
host = self._cfg.wg.get("server_endpoint", "")
if not host or not wg_core.is_connected(self._cfg):
self._ping_badge.setText("— ms")
self._ping_badge.setStyleSheet(
f"border-radius: 11px; font-size: 10px; font-weight: bold; {_PING_CSS['offline']}"
)
if not host or not self._is_connected:
self._set_ping_badge(None, "— ms", "offline")
return
ms = wg_core.ping_server(host)
if ms is None:
css = _PING_CSS["bad"]
txt = "hors ligne"
if self._ping_worker is not None and self._ping_worker.isRunning():
return
# `ping` bloque jusqu'à 4 s : exécuté dans le thread Qt, il faisait
# saccader toute l'interface toutes les 10 secondes.
self._ping_worker = ValueWorker(wg_core.ping_server, host, 2, parent=self)
self._ping_worker.done.connect(self._on_ping_done)
# Détruire le QThread depuis `finished` et non depuis `done` : `done`
# est émis avant la sortie de run(), un deleteLater() à ce moment
# provoque « QThread: Destroyed while thread is still running ».
self._ping_worker.finished.connect(self._ping_worker.deleteLater)
self._ping_worker.start()
def _on_ping_done(self, ms):
self._ping_worker = None
if not self._is_connected:
self._set_ping_badge(None, "— ms", "offline")
elif ms is None:
self._set_ping_badge(None, "hors ligne", "bad")
elif ms < 50:
css = _PING_CSS["good"]
txt = f"{ms} ms"
self._set_ping_badge(ms, f"{ms} ms", "good")
elif ms < 200:
css = _PING_CSS["medium"]
txt = f"{ms} ms"
self._set_ping_badge(ms, f"{ms} ms", "medium")
else:
css = _PING_CSS["bad"]
txt = f"{ms} ms"
self._ping_badge.setText(txt)
self._set_ping_badge(ms, f"{ms} ms", "bad")
def _set_ping_badge(self, _ms, text: str, css_key: str):
self._ping_badge.setText(text)
self._ping_badge.setStyleSheet(
f"border-radius: 11px; font-size: 10px; font-weight: bold; {css}"
"border-radius: 11px; font-size: 10px; font-weight: bold; "
+ _PING_CSS[css_key]
)
def _check_reconnect(self):
if not self._cfg.get("ui", "auto_reconnect"):
if not self._cfg.get("ui", "auto_reconnect") or self._quitting:
return
try:
connected = wg_core.is_connected(self._cfg)
except Exception:
connected = False
if not connected and self._cfg.configured:
self._add_log("Auto-reconnexion…", "warning")
self._on_connect()
if self._worker is not None:
return # une (dé)connexion est déjà en cours
if self._cfg.mfa_enabled:
# Rouvrir une fenêtre MFA modale toutes les 30 s rendrait
# l'application inutilisable : la reconnexion doit rester manuelle.
return
if not self._cfg.configured or self._is_connected:
self._reconnect_failures = 0
return
if self._reconnect_failures >= 5:
# Arrêt du harcèlement : chaque tentative demande des privilèges.
if self._reconnect_failures == 5:
self._add_log("Auto-reconnexion suspendue après 5 échecs", "error")
self._reconnect_failures += 1
return
self._add_log("Auto-reconnexion…", "warning")
self._start_connect(silent=True)
# ------------------------------------------------------------------ #
# Statut WireGuard
@@ -461,6 +492,9 @@ class MainWindow(QMainWindow):
except Exception:
connected = False
info = {}
self._is_connected = connected
if connected:
self._reconnect_failures = 0
if connected:
if self._connected_since is None:
@@ -519,67 +553,129 @@ class MainWindow(QMainWindow):
self._add_log("Clé publique copiée dans le presse-papiers", "info")
def _on_connect(self):
if self._connecting:
return
try:
connected = wg_core.is_connected(self._cfg)
except Exception:
connected = False
if connected:
ok, msg = wg_core.disconnect(self._cfg)
if ok:
if self._session_id is not None:
rx = self._prev_rx or 0
tx = self._prev_tx or 0
hist.end_session(self._session_id, rx, tx)
self._session_id = None
self._add_log("Tunnel WireGuard désactivé", "warning")
else:
self._add_log(f"Erreur déconnexion : {msg}", "error")
QMessageBox.warning(self, "Erreur", msg)
"""Bascule connexion/déconnexion. Le travail réel part dans un thread."""
if self._worker is not None:
return # opération déjà en cours
if self._is_connected or wg_core.is_connected(self._cfg):
self._start_disconnect()
else:
if not self._cfg.configured:
self._start_connect(silent=False)
# ── Connexion ────────────────────────────────────────────────────────
def _start_connect(self, silent: bool = False):
if self._worker is not None:
return
if not self._cfg.configured:
if not silent:
QMessageBox.information(
self, "Configuration manquante",
"WireGuard n'est pas encore configuré.\n"
"Ouvrez le panneau Administrateur."
)
return
return
if self._cfg.mfa_enabled:
if not self._cfg.mfa_secret:
if self._cfg.mfa_enabled:
if not self._cfg.mfa_secret:
if not silent:
QMessageBox.warning(self, "MFA non configuré",
"MFA activé mais aucun secret défini.")
return
dlg = MFADialog(self._cfg.mfa_secret, self)
if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified():
self._add_log("MFA annulé ou échoué", "warning")
return
self._add_log("Authentification MFA réussie ✓", "success")
return
if silent:
return # jamais de fenêtre MFA sur minuterie
dlg = MFADialog(self._cfg.mfa_secret, self)
if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified():
self._add_log("MFA annulé ou échoué", "warning")
return
self._add_log("Authentification MFA réussie ✓", "success")
self._btn_connect.setEnabled(False)
self._btn_connect.setText("Connexion…")
self._connecting = True
QApplication.processEvents()
self._begin_task("Connexion…")
shutdown_guard.arm() # le tunnel devra être rendu à la sortie
self._worker = TaskWorker(wg_core.connect, self._cfg,
tag="connect" if not silent else "connect-silent",
parent=self)
self._worker.done.connect(self._on_task_done)
self._worker.finished.connect(self._worker.deleteLater)
self._worker.start()
ok, msg = wg_core.connect(self._cfg)
self._connecting = False
self._btn_connect.setEnabled(True)
# ── Déconnexion ──────────────────────────────────────────────────────
def _start_disconnect(self):
if self._worker is not None:
return
self._begin_task("Déconnexion…")
self._worker = TaskWorker(wg_core.disconnect, self._cfg,
tag="disconnect", parent=self)
self._worker.done.connect(self._on_task_done)
self._worker.finished.connect(self._worker.deleteLater)
self._worker.start()
def _begin_task(self, label: str):
self._connecting = True
self._btn_connect.setEnabled(False)
self._btn_connect.setText(label)
def _on_task_done(self, ok: bool, msg: str, tag: str):
self._worker = None
self._connecting = False
self._btn_connect.setEnabled(True)
silent = tag.endswith("-silent")
if tag.startswith("connect"):
if ok:
self._reconnect_failures = 0
self._connected_since = QDateTime.currentDateTime()
endpoint = self._cfg.wg.get("server_endpoint", "")
profile = self._cfg.active_profile
self._session_id = hist.start_session(endpoint, profile)
self._session_id = hist.start_session(endpoint,
self._cfg.active_profile)
self._add_log(f"Connexion établie → {endpoint}", "success")
QTimer.singleShot(5000, self._update_ping)
else:
self._reconnect_failures += 1
self._add_log(f"Erreur : {msg}", "error")
QMessageBox.warning(self, "Erreur de connexion", msg)
if not silent:
QMessageBox.warning(self, "Erreur de connexion", msg)
else: # disconnect
if ok:
if self._session_id is not None:
hist.end_session(self._session_id,
self._prev_rx or 0, self._prev_tx or 0)
self._session_id = None
self._add_log("Tunnel WireGuard désactivé", "warning")
else:
self._add_log(f"Erreur déconnexion : {msg}", "error")
if not silent:
QMessageBox.warning(self, "Erreur", msg)
self._check_dns_health()
self._warn_foreign_interfaces()
self._refresh_status()
def _warn_foreign_interfaces(self):
"""Signale un tunnel WireGuard monté hors de WGSecure.
Il impose son propre DNS au système et WGSecure ne le démontera pas :
c'est une cause fréquente de « plus d'accès internet » après coup.
"""
try:
foreign = wg_core.foreign_wg_interfaces(self._cfg)
except Exception:
return
for iface in foreign:
if iface in self._warned_foreign:
continue
self._warned_foreign.add(iface)
self._add_log(
f"Tunnel « {iface} » actif hors de WGSecure "
f"(interface configurée : {self._cfg.wg.get('interface_name','wgs0')})",
"warning",
)
def _check_dns_health(self):
"""Signale — et répare — une résolution DNS restée détournée."""
iface = self._cfg.wg.get("interface_name", "wgs0")
repaired, msg = dns_util.repair_if_broken(iface, self._cfg.wg.get("dns", ""))
if msg:
self._add_log(msg, "warning" if repaired else "error")
def _open_history(self):
HistoryDialog(self).exec()
@@ -600,19 +696,49 @@ class MainWindow(QMainWindow):
self._reload_profiles()
self._refresh_status()
# ------------------------------------------------------------------ #
# Fermeture — restitution de l'état réseau
# ------------------------------------------------------------------ #
def prepare_quit(self) -> None:
"""Arrête minuteries et threads, puis rend le réseau à son état initial.
Appelé par toutes les voies de sortie (croix de la fenêtre, « Quitter »
du systray, signal système). Synchrone : la restauration doit être
terminée avant que le processus ne disparaisse, sinon la configuration
DNS du tunnel survit à l'application et la machine perd la résolution
de noms.
"""
if self._quitting:
return
self._quitting = True
for timer in (self._status_timer, self._clock_timer, self._bw_timer,
self._ping_timer, self._reconnect_timer):
timer.stop()
# Laisser les threads en cours se terminer avant de démonter le tunnel :
# une connexion et une déconnexion simultanées laisseraient un état mixte.
for worker in (self._worker, self._ping_worker):
try:
if worker is not None and worker.isRunning():
worker.wait(15000)
except RuntimeError:
pass # objet Qt déjà détruit
self._worker = None
self._ping_worker = None
if self._session_id is not None:
hist.end_session(self._session_id, self._prev_rx or 0,
self._prev_tx or 0)
self._session_id = None
shutdown_guard.restore_network("fermeture de l'application")
def closeEvent(self, event: QCloseEvent):
if self._cfg.get("ui", "minimize_to_tray"):
if self._cfg.get("ui", "minimize_to_tray") and not self._quitting:
# Simple masquage : ce n'est pas une fermeture, le tunnel reste actif.
event.ignore()
self.hide()
else:
self._status_timer.stop()
self._clock_timer.stop()
self._bw_timer.stop()
self._ping_timer.stop()
self._reconnect_timer.stop()
try:
if wg_core.is_connected(self._cfg):
wg_core.disconnect(self._cfg)
except Exception:
pass
event.accept()
return
self.prepare_quit()
event.accept()
+8 -11
View File
@@ -104,18 +104,15 @@ class SystemTray(QSystemTrayIcon):
def _toggle_connection(self):
self._win._on_connect()
self._update_status()
# La (dé)connexion est désormais asynchrone : l'icône sera mise à jour
# par la scrutation périodique une fois l'opération terminée.
def _quit(self):
self._poll.stop()
try:
if wg_core.is_connected(self._cfg):
wg_core.disconnect(self._cfg)
except Exception:
pass
self._win._status_timer.stop()
self._win._clock_timer.stop()
self._win._bw_timer.stop()
self._win._ping_timer.stop()
self._win._reconnect_timer.stop()
self.hide()
# prepare_quit() arrête les minuteries, attend les threads en cours,
# démonte le tunnel et retire la configuration DNS qu'il avait posée.
# Sans cela, quitter en étant connecté laissait la machine sans
# résolution de noms.
self._win.prepare_quit()
QApplication.quit()
+54
View File
@@ -0,0 +1,54 @@
"""Exécution de tâches bloquantes hors du thread graphique.
`wg-quick up/down` passe par sudo ou pkexec : entre l'attente du dialogue
d'authentification et le montage du tunnel, l'appel peut durer plusieurs
dizaines de secondes. Lancé depuis le thread Qt, il gèle toute l'interface
(fenêtre grise, « ne répond pas »). Ces workers déportent l'appel.
"""
from __future__ import annotations
from typing import Any, Callable
from PyQt6.QtCore import QThread, pyqtSignal
class TaskWorker(QThread):
"""Exécute `fn(*args)` dans un thread et émet son résultat.
`done` porte (ok, message, tag) ; `tag` permet à l'appelant de savoir de
quelle tâche il s'agit lorsqu'un même slot en traite plusieurs.
"""
done = pyqtSignal(bool, str, str)
def __init__(self, fn: Callable[..., tuple[bool, str]], *args: Any,
tag: str = "", parent=None):
super().__init__(parent)
self._fn = fn
self._args = args
self._tag = tag
def run(self) -> None:
try:
ok, msg = self._fn(*self._args)
except Exception as e: # noqa: BLE001 — remonté à l'UI
ok, msg = False, str(e)
self.done.emit(bool(ok), str(msg), self._tag)
class ValueWorker(QThread):
"""Variante pour une fonction qui retourne une valeur quelconque."""
done = pyqtSignal(object)
def __init__(self, fn: Callable[..., Any], *args: Any, parent=None):
super().__init__(parent)
self._fn = fn
self._args = args
def run(self) -> None:
try:
result = self._fn(*self._args)
except Exception: # noqa: BLE001
result = None
self.done.emit(result)