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
+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()