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:
+208
-82
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user