Files
WGSecure/app/ui/main_window.py
T
tuxgyverandClaude Opus 5 556f22e7c9 fix(auth): fiabilise la sortie de quarantaine
Audit du parcours complet — montage du tunnel, sondage de l'état, saisie
du code, ouverture de l'accès. Neuf défauts, dont plusieurs laissaient le
poste en quarantaine sans moyen visible d'en sortir : exactement ce que le
bouton « Saisir le code d'accès » existe pour éviter depuis la v0.8.2.

Adresse de l'API. api_base_url retranchait le dernier octet de l'adresse
du client, ce qui suppose un /24 et ignore les IPs autorisées. Or une
adresse client en /32 est la forme la plus courante et ne décrit aucun
réseau : un client en 10.6.1.7/32 sur un 10.6.0.0/16 était expédié vers
10.6.1.1, qui n'existe pas. La déduction passe désormais par
wireguard.tunnel_gateway, qui servait déjà au diagnostic et gère /32,
IPv6 et le repli sur les IPs autorisées. Une adresse IPv6 est encadrée de
crochets, sans quoi son « : » se confondait avec celui du port.

Révocation invisible. Le client ne sondait qu'au retour du tunnel : une
révocation avant terme — redémarrage du serveur, purge de sa table de
sessions, décision d'un administrateur — n'était annoncée par rien et
l'application affichait « Ouvert ✓ » devant un réseau muet, bouton
masqué, jusqu'à la chute du tunnel. Sans échéance annoncée, indéfiniment.
L'accès est reconfirmé chaque minute tant qu'il est cru ouvert.

Authentification perdue. « Plus tard » restait actif pendant la
vérification, Échap et la croix aussi. Fermer là rendait la main sur un
refus alors que le serveur pouvait encore accepter : sa réponse arrivait
après la sortie d'exec() et n'était lue par personne. L'accès était
ouvert côté serveur, l'application affichait la quarantaine, et le code
saisi était consommé pour rien. La fenêtre ne se ferme plus tant qu'une
requête est en vol.

Fenêtre survivante. Ni accept() ni reject() ne passent par closeEvent :
le minuteur de 500 ms continuait à battre après la fermeture, et la
fenêtre — parentée à la principale — survivait à sa disparition.
dispose() l'arrête et programme la destruction ; _access_dialog est remis
à None, son thread n'étant plus attendu à l'arrêt de l'application.

Repli 404 trompeur. La sonde d'accessibilité tenait pour preuve toute
réponse du serveur DNS du split-DNS, sans vérifier qu'il était joint par
le tunnel. Une adresse RFC1918 routée par le réseau local du poste
répondait donc hors tunnel, et le repli censé éviter le blocage le
provoquait : accès déclaré ouvert sans échéance, bouton masqué. La route
est vérifiée d'abord, sans émettre de paquet — connect() sur une socket
UDP suffit à connaître l'adresse source retenue par le noyau.

Proxy. urlopen emploie l'ouvreur par défaut, dont le ProxyHandler lit
http_proxy dans l'environnement : sur un poste d'entreprise la requête
vers l'adresse privée du tunnel partait au proxy, dont la réponse — 404
souvent — était prise pour celle du serveur, déclenchant le repli
ci-dessus. Panne intermittente parfaite : selon l'environnement de
lancement, ça passait ou non.

Silence pris pour un refus. Un ok faux couvrait « le serveur refuse » et
« le serveur n'a rien dit », traités pareil : un hoquet au montage du
tunnel faisait surgir la fenêtre et brûler un code alors que l'accès
était peut-être déjà ouvert. AuthResult porte désormais authoritative.
Un refus du serveur referme du premier coup, il en est seul juge ; un
silence déclenche un second sondage avant de déranger l'utilisateur, et
n'entame l'accès qu'après trois minutes sans réponse.

Annulation destructrice. _open_access_dialog écrivait l'état d'accès
quoi qu'il arrive. Un sondage abouti pendant que la fenêtre était ouverte
— les minuteries tournent dans la boucle imbriquée d'exec() — pouvait
établir que l'accès l'était, que « Plus tard » remettait aussitôt à
fermé. Renoncer à saisir un code ne referme rien côté serveur ; plus rien
ici non plus.

api_url. Le schéma est vérifié plutôt que repris tel quel : identifiant
et code partent dans cette URL. Reste ouvert, et hors de ce dépôt : l'API
par défaut est en clair et le serveur n'est pas authentifié.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:27:36 +02:00

1237 lines
56 KiB
Python

from __future__ import annotations
from datetime import datetime, timedelta, timezone
from PyQt6.QtWidgets import (
QApplication,
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QFrame, QMessageBox, QInputDialog,
QLineEdit, QListWidget, QListWidgetItem,
QComboBox,
)
from PyQt6.QtCore import Qt, QTimer, QDateTime, QEvent
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.core import vpn_session
from app.ui.worker import TaskWorker, ValueWorker
from app.ui.access_code_dialog import AccessCodeDialog
from app.ui.admin_window import AdminWindow
from app.ui.bw_graph import BandwidthGraph
from app.ui.history_dialog import HistoryDialog
from app.ui import icons
from app.ui import theme
# Intervalle entre deux re-vérifications de l'accès auprès du serveur, tant
# qu'il est cru ouvert. Assez court pour qu'une révocation ne laisse pas
# l'utilisateur devant un réseau muet plus d'une minute, assez long pour ne
# pas transformer la scrutation de statut (3 s) en martèlement de l'API.
_ACCESS_RECHECK = timedelta(seconds=60)
# Nombre de sondages sans réponse d'affilée avant de renoncer à croire l'accès
# encore ouvert. Un refus venant du serveur, lui, fait autorité et referme du
# premier coup ; ceci ne vaut que pour le doute — serveur injoignable, réponse
# illisible — où refermer aussitôt ferait clignoter l'état entre « Ouvert » et
# « Quarantaine » au moindre hoquet réseau. Trois sondages, soit trois minutes
# de silence.
_ACCESS_UNKNOWN_BEFORE_CLOSE = 3
# Délai avant de resonder quand le serveur n'a rien dit alors qu'une saisie
# devait être proposée. Le tunnel vient de monter : ses routes peuvent n'être
# pas encore posées, et la fenêtre surgirait pour un code déjà valide.
_PROBE_RETRY_MS = 3000
class MainWindow(QMainWindow):
def __init__(self, config: Config, parent=None):
super().__init__(parent)
self._cfg = config
self._connecting = False
self._connected_since: QDateTime | None = None
self._prev_rx: int | None = None
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._access_open = False
# Échéance annoncée par le serveur pour l'autorisation en cours. None
# signifie « aucune échéance connue » — soit aucun accès ouvert, soit
# un serveur qui n'en annonce pas — et jamais « expire maintenant ».
self._access_until: datetime | None = None
# Sondage de l'état de l'accès auprès du serveur. `_probe_prompt`
# retient qu'une saisie devra être proposée si le sondage en cours
# revient « fermé » : la demande peut arriver alors qu'un sondage
# déclenché sans invite est déjà parti.
self._probe_worker: ValueWorker | None = None
self._probe_prompt = False
# Date du dernier sondage abouti, et nombre de sondages consécutifs
# ayant trouvé fermé un accès qu'on croyait ouvert (cf.
# `_access_stale` et `_on_probe_reply`). `_access_message` évite de
# répéter la même ligne de journal à chaque re-vérification.
self._access_probed_at: datetime | None = None
self._access_denials = 0
self._access_message = ""
# Un seul nouvel essai quand le serveur reste muet alors qu'une saisie
# devait être proposée : au-delà, le bouton prend le relais plutôt que
# de faire tourner un sondage en boucle.
self._probe_retried = False
# Conservée après fermeture : la fenêtre reste parentée à celle-ci, et
# son thread doit être attendu avant destruction (cf. prepare_quit).
self._access_dialog: AccessCodeDialog | None = None
self._reconnect_failures = 0
self._quitting = False
self._warned_foreign: set[str] = set()
self.setWindowTitle("WGSecure")
self.setFixedWidth(430)
self.setWindowFlags(
Qt.WindowType.Window
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowCloseButtonHint
| Qt.WindowType.WindowMinimizeButtonHint
)
self.setWindowIcon(icons.icon_app())
self._build_ui()
# Repliée par défaut : au lancement, seuls statut et action de
# connexion sont utiles — le détail ne sert qu'à qui va le chercher.
self._details_expanded = False
self._details.setVisible(False)
self._sync_window_height()
# Timers
self._status_timer = QTimer(self)
self._status_timer.timeout.connect(self._refresh_status)
self._status_timer.start(3000)
self._clock_timer = QTimer(self)
self._clock_timer.timeout.connect(self._tick_duration)
self._clock_timer.start(1000)
self._bw_timer = QTimer(self)
self._bw_timer.timeout.connect(self._update_bw)
self._bw_timer.start(3000)
self._ping_timer = QTimer(self)
self._ping_timer.timeout.connect(self._update_ping)
self._ping_timer.start(10000)
self._reconnect_timer = QTimer(self)
self._reconnect_timer.timeout.connect(self._check_reconnect)
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)
# ------------------------------------------------------------------ #
# Construction UI
#
# Ordre pensé pour l'usage réel : statut et action de connexion sont
# immédiats (pas besoin de traverser la config ou le journal pour les
# atteindre) ; viennent ensuite les valeurs dynamiques (transfert,
# handshake), puis la config statique — consultée rarement une fois
# réglée — et enfin bande passante / journal, les plus techniques.
# ------------------------------------------------------------------ #
def _build_ui(self):
central = QWidget()
central.setStyleSheet(f"background: {theme.BG};")
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
root.addWidget(self._build_header())
root.addWidget(self._build_status_action())
root.addWidget(self._build_details())
# ── Pied de page ─────────────────────────────────────────────────
root.addWidget(self._build_footer())
# ── Section repliable : tout ce qui n'est pas statut/action ──────────
def _build_details(self) -> QWidget:
self._details = QWidget()
d = QVBoxLayout(self._details)
d.setContentsMargins(0, 0, 0, 0)
d.setSpacing(0)
d.addWidget(self._hsep())
d.addWidget(self._build_dynamic_details())
d.addWidget(self._hsep())
d.addWidget(self._build_config_summary())
d.addWidget(self._hsep())
# ── Bande passante ───────────────────────────────────────────────
d.addWidget(self._section_header("📊 Bande passante"))
self._bw_graph = BandwidthGraph()
self._bw_graph.setFixedHeight(75)
d.addWidget(self._bw_graph)
d.addWidget(self._hsep())
# ── Journal ───────────────────────────────────────────────────────
d.addWidget(self._section_header("📋 Journal", btn_text="Effacer",
btn_slot=self._clear_log))
self._log_list = QListWidget()
self._log_list.setFixedHeight(78)
self._log_list.setStyleSheet(
f"QListWidget {{ background: {theme.BG_SUNKEN}; color: {theme.ACCENT_LIGHT};"
" border: none; font-size: 10px; font-family: Courier; }"
"QListWidget::item { padding: 1px 6px; }"
)
self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
d.addWidget(self._log_list)
d.addWidget(self._hsep())
return self._details
# ── En-tête : identité + profil ─────────────────────────────────────
def _build_header(self) -> QWidget:
header = QWidget()
header.setFixedHeight(64)
header.setStyleSheet(f"background: {theme.BG_SUNKEN};")
h = QHBoxLayout(header)
h.setContentsMargins(14, 8, 14, 8)
self._icon_label = QLabel()
self._icon_label.setFixedSize(36, 36)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
h.addWidget(self._icon_label)
title_lbl = QLabel("WGSecure")
title_lbl.setStyleSheet(
f"color: {theme.TEXT}; font-size: 16px; font-weight: bold; background: transparent;"
)
h.addWidget(title_lbl)
h.addStretch()
profile_col = QVBoxLayout()
profile_col.setSpacing(1)
profile_lbl = QLabel("Profil actif")
profile_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
profile_lbl.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 9px; background: transparent;"
)
profile_col.addWidget(profile_lbl)
self._profile_combo = QComboBox()
self._profile_combo.setFixedHeight(22)
self._profile_combo.setMinimumWidth(150)
self._profile_combo.setStyleSheet(
f"QComboBox {{ background: {theme.BG_RAISED}; color: {theme.ACCENT_LIGHT};"
" border: none; border-radius: 3px; font-size: 10px; padding: 0 6px; }"
"QComboBox::drop-down { border: none; }"
f"QComboBox QAbstractItemView {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; }}"
)
self._profile_combo.currentIndexChanged.connect(self._on_profile_changed)
profile_col.addWidget(self._profile_combo)
h.addLayout(profile_col)
return header
# ── Statut + action principale ───────────────────────────────────────
def _build_status_action(self) -> QWidget:
status_area = QWidget()
status_area.setStyleSheet(f"background: {theme.BG_RAISED};")
s = QVBoxLayout(status_area)
s.setContentsMargins(14, 12, 14, 12)
s.setSpacing(8)
top_row = QHBoxLayout()
top_row.setSpacing(8)
status_col = QVBoxLayout()
status_col.setSpacing(1)
self._status_badge = QLabel("● Déconnecté")
self._status_badge.setStyleSheet(
f"color: {theme.FAIL_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
)
status_col.addWidget(self._status_badge)
self._duration_label = QLabel("")
self._duration_label.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 11px; background: transparent;"
)
status_col.addWidget(self._duration_label)
top_row.addLayout(status_col)
top_row.addStretch()
# Badge de ping : santé de cette connexion, donc juste à côté de son
# statut plutôt que dans l'en-tête d'identité.
self._ping_badge = QLabel("— ms")
self._ping_badge.setFixedSize(66, 24)
self._ping_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._ping_badge.setStyleSheet(
f"border-radius: 12px; font-size: 10px; font-weight: bold;"
f" {theme.PING_CSS['offline']}"
)
top_row.addWidget(self._ping_badge, 0, Qt.AlignmentFlag.AlignVCenter)
s.addLayout(top_row)
self._btn_connect = QPushButton("Se connecter")
self._btn_connect.setFixedHeight(44)
self._btn_connect.setStyleSheet(
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #27ae60; }"
f"QPushButton:disabled {{ background: {theme.BG_RAISED}; color: {theme.TEXT_FAINT}; }}"
)
self._btn_connect.clicked.connect(self._on_connect)
s.addWidget(self._btn_connect)
# Saisie du code d'accès, atteignable à tout moment. Le tunnel monte
# en quarantaine et l'autorisation obtenue expire d'elle-même : sans
# ce bouton, un code refusé, une reconnexion automatique ou une
# autorisation échue ne laissaient d'autre issue que se déconnecter
# puis se reconnecter. Masqué tant qu'il n'y a rien à saisir, pour ne
# rien ajouter aux installations sans authentification serveur.
self._btn_access = QPushButton("🔐 Saisir le code d'accès")
self._btn_access.setFixedHeight(32)
self._btn_access.clicked.connect(self.request_access_code)
self._btn_access.setVisible(False)
s.addWidget(self._btn_access)
self._style_access_button(False)
# Repli/dépli du détail (transfert, config, bande passante, journal) :
# rattaché à l'action qu'il détaille, pas à l'en-tête.
self._btn_toggle_details = QPushButton("▾ Afficher le détail")
self._btn_toggle_details.setFixedHeight(20)
self._btn_toggle_details.setStyleSheet(
f"QPushButton {{ background: transparent; color: {theme.TEXT_FAINT};"
" font-size: 10px; border: none; }"
f"QPushButton:hover {{ color: {theme.TEXT}; }}"
)
self._btn_toggle_details.clicked.connect(self._toggle_details)
s.addWidget(self._btn_toggle_details)
return status_area
# ── Détails dynamiques : transfert + handshake ───────────────────────
def _build_dynamic_details(self) -> QWidget:
frame = QWidget()
frame.setStyleSheet(f"background: {theme.BG};")
dl = QHBoxLayout(frame)
dl.setContentsMargins(14, 8, 14, 8)
dl.setSpacing(16)
self._info_labels: dict[str, QLabel] = {}
for key, icon_txt, label in [
("rx_tx", "↕️", "Transfert"),
("handshake", "🤝", "Handshake"),
]:
col = QVBoxLayout()
col.setSpacing(2)
hdr = QLabel(f"{icon_txt} {label}")
hdr.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 10px; background: transparent;"
)
col.addWidget(hdr)
val = self._lbl("—", color=theme.ACCENT_LIGHT, size=13)
val.setWordWrap(True)
self._info_labels[key] = val
col.addWidget(val)
dl.addLayout(col, 1)
return frame
# ── Config statique (repliée visuellement) ───────────────────────────
def _build_config_summary(self) -> QWidget:
frame = QFrame()
frame.setStyleSheet(f"background: {theme.BG};")
il = QVBoxLayout(frame)
il.setContentsMargins(14, 8, 14, 8)
il.setSpacing(4)
for key, icon_txt, label in [
("server", "🌐", "Serveur"),
("iface", "🔌", "Interface"),
("addr", "📍", "Adresse IP"),
("access", "🔐", "Accès distant"),
]:
rw = QWidget()
rw.setStyleSheet("background: transparent;")
rl = QHBoxLayout(rw)
rl.setContentsMargins(0, 0, 0, 0)
rl.setSpacing(5)
rl.addWidget(self._lbl(icon_txt, fixed=18, color=theme.TEXT_FAINT, size=10))
rl.addWidget(self._lbl(f"{label} :", fixed=90, color=theme.TEXT_FAINT, size=11))
val = self._lbl("—", color=theme.TEXT_MUTED, size=11)
val.setWordWrap(True)
self._info_labels[key] = val
rl.addWidget(val, 1)
il.addWidget(rw)
return frame
# ── Pied de page : accès secondaires ─────────────────────────────────
def _build_footer(self) -> QWidget:
btn_area = QWidget()
btn_area.setStyleSheet(f"background: {theme.BG_SUNKEN};")
bl = QHBoxLayout(btn_area)
bl.setContentsMargins(14, 10, 14, 12)
bl.setSpacing(6)
btn_admin = QPushButton("⚙️ Administrateur")
btn_admin.setFixedHeight(30)
btn_admin.setStyleSheet(
f"QPushButton {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 6px;"
" font-size: 11px; border: none; }"
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
)
btn_admin.clicked.connect(self._open_admin)
bl.addWidget(btn_admin)
btn_history = QPushButton("📊 Historique")
btn_history.setFixedHeight(30)
btn_history.setStyleSheet(
f"QPushButton {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 6px;"
" font-size: 11px; border: none; }"
f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
)
btn_history.clicked.connect(self._open_history)
bl.addWidget(btn_history)
return btn_area
# ── Repli/dépli du détail ─────────────────────────────────────────────
def _toggle_details(self):
# `QWidget.isVisible()` reflète la visibilité effective à l'écran,
# qui dépend de toute la chaîne de parents : avant que la fenêtre
# elle-même ne soit affichée, elle vaut toujours False. Un drapeau
# explicite évite ce piège.
self._details_expanded = not self._details_expanded
self._details.setVisible(self._details_expanded)
self._btn_toggle_details.setText(
"▴ Masquer le détail" if self._details_expanded else "▾ Afficher le détail"
)
self._sync_window_height()
def _style_access_button(self, expected: bool):
"""Met le bouton en avant seulement quand un code est réellement dû."""
bg, hover = ((theme.WARN_SOLID, "#d68910") if expected
else (theme.BG_RAISED, theme.ACCENT_HOVER))
weight = "bold" if expected else "normal"
self._btn_access.setStyleSheet(
f"QPushButton {{ background: {bg}; color: {theme.TEXT};"
f" font-size: 12px; font-weight: {weight}; border-radius: 6px;"
" border: none; }"
f"QPushButton:hover {{ background: {hover}; }}"
)
def _sync_window_height(self):
"""Réajuste la hauteur fixe de la fenêtre à son contenu visible.
La fenêtre reste de taille fixe (pas de redimensionnement manuel),
mais cette taille change avec le repli/dépli du détail et avec
l'apparition du bouton de code d'accès — il faut donc lever la
contrainte précédente avant de laisser le layout recalculer sa
hauteur naturelle, puis la reverrouiller.
Le rendre visible ne suffit pas à mettre les layouts à jour : Qt
poste un `LayoutRequest` traité au tour de boucle suivant, et
mesurer avant ce traitement rend une hauteur en retard d'un cycle.
La fenêtre gardait alors sa taille précédente et rognait le widget
qui venait d'apparaître — le bouton « Saisir le code d'accès »
existait, mais sous le bord inférieur. D'où la remise à plat
explicite des layouts avant toute mesure.
"""
self.setMinimumHeight(0)
self.setMaximumHeight(16777215)
central = self.centralWidget()
QApplication.sendPostedEvents(None, QEvent.Type.LayoutRequest)
central.layout().activate()
central.adjustSize()
self.adjustSize()
self.setFixedHeight(self.sizeHint().height())
# ── Helpers UI ───────────────────────────────────────────────────────
def _lbl(self, text: str, fixed: int = 0, color: str = theme.TEXT,
size: int = 12) -> QLabel:
lbl = QLabel(text)
lbl.setStyleSheet(
f"color: {color}; font-size: {size}px; background: transparent;"
)
if fixed:
lbl.setFixedWidth(fixed)
return lbl
def _hsep(self) -> QFrame:
f = QFrame()
f.setFrameShape(QFrame.Shape.HLine)
f.setFixedHeight(1)
f.setStyleSheet(f"background: {theme.BG_SUNKEN};")
return f
def _section_header(self, title: str, btn_text: str = "",
btn_slot=None) -> QWidget:
w = QWidget()
w.setFixedHeight(24)
w.setStyleSheet(f"background: {theme.BG_SUNKEN};")
hl = QHBoxLayout(w)
hl.setContentsMargins(12, 0, 12, 0)
t = QLabel(title)
t.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 10px; font-weight: bold;"
" background: transparent;")
hl.addWidget(t)
hl.addStretch()
if btn_text and btn_slot:
b = QPushButton(btn_text)
b.setFixedHeight(18)
b.setStyleSheet(
f"QPushButton {{ background: transparent; color: {theme.TEXT_FAINT};"
" font-size: 9px; border: none; padding: 0 4px; }"
f"QPushButton:hover {{ color: {theme.TEXT}; }}"
)
b.clicked.connect(btn_slot)
hl.addWidget(b)
return w
# ------------------------------------------------------------------ #
# Profils
# ------------------------------------------------------------------ #
def _reload_profiles(self):
self._profile_combo.blockSignals(True)
self._profile_combo.clear()
profiles = self._cfg.list_profiles()
active = self._cfg.active_profile
if active not in profiles:
profiles = [active] + profiles
self._profile_combo.addItems(profiles)
idx = self._profile_combo.findText(active)
if idx >= 0:
self._profile_combo.setCurrentIndex(idx)
self._profile_combo.blockSignals(False)
def _on_profile_changed(self, index: int):
name = self._profile_combo.itemText(index)
if not name or name == self._cfg.active_profile:
return
if self._cfg.load_profile(name):
self._add_log(f"Profil chargé : {name}", "info")
self._reload_profiles()
self._refresh_status()
# ------------------------------------------------------------------ #
# Journal
# ------------------------------------------------------------------ #
def _reload_log(self):
self._log_list.clear()
for ev in conn_log.get_events(30):
self._add_log_item(ev["ts"], ev["msg"], ev["kind"])
self._log_list.scrollToBottom()
def _clear_log(self):
conn_log.clear()
self._log_list.clear()
def _add_log(self, msg: str, kind: str = "info"):
conn_log.log_event(msg, kind)
ts = QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss")
self._add_log_item(ts, msg, kind)
def _add_log_item(self, ts: str, msg: str, kind: str):
color = theme.LOG_COLORS.get(kind, theme.ACCENT_LIGHT)
item = QListWidgetItem(f"[{ts}] {msg}")
item.setForeground(QColor(color))
# Ligne trop longue pour tenir dans le panneau : barre de défilement
# horizontale (ci-dessus) + tooltip pour lire le message complet
# sans avoir à défiler.
item.setToolTip(msg)
self._log_list.addItem(item)
self._log_list.scrollToBottom()
# ------------------------------------------------------------------ #
# Timers
# ------------------------------------------------------------------ #
def _tick_duration(self):
if self._connected_since is None:
return
secs = self._connected_since.secsTo(QDateTime.currentDateTime())
h, rem = divmod(secs, 3600)
m, s = divmod(rem, 60)
self._duration_label.setText(f"Connecté depuis {h:02d}h {m:02d}m {s:02d}s")
def _update_bw(self):
iface = self._cfg.wg.get("interface_name", "wgs0")
result = wg_core.get_interface_bytes(iface)
if result is None or self._prev_rx is None:
self._prev_rx, self._prev_tx = (result or (0, 0))
return
rx_now, tx_now = result
dt = 3.0
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
# `_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 self._is_connected:
self._set_ping_badge(None, "— ms", "offline")
return
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_or_handshake, self._cfg, 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, result):
self._ping_worker = None
ms, source = result if result else (None, "down")
if not self._is_connected:
self._set_ping_badge(None, "— ms", "offline")
elif source == "down":
self._set_ping_badge(None, "hors ligne", "bad")
elif source == "stale":
self._set_ping_badge(None, "handshake ancien", "bad")
elif source == "handshake":
# ICMP bloqué par le serveur/pare-feu mais handshake WireGuard
# récent : le tunnel fonctionne, la latence réelle est juste
# invisible depuis ce test.
self._set_ping_badge(None, f"actif ({ms}s)", "medium")
elif ms < 50:
self._set_ping_badge(ms, f"{ms} ms", "good")
elif ms < 200:
self._set_ping_badge(ms, f"{ms} ms", "medium")
else:
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(
"border-radius: 12px; font-size: 10px; font-weight: bold; "
+ theme.PING_CSS[css_key]
)
def _check_reconnect(self):
if not self._cfg.get("ui", "auto_reconnect") or self._quitting:
return
if self._worker is not None:
return # une (dé)connexion est déjà en cours
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
# ------------------------------------------------------------------ #
def _access_valid(self) -> bool:
"""Vrai tant que l'autorisation obtenue court encore.
L'échéance vient du serveur et de lui seul : le client la relit, il
ne la calcule pas — sa durée est un réglage serveur, modifiable sans
que le poste en sache rien. Un serveur qui n'annonce aucune échéance
laisse l'autorisation valable jusqu'à la chute du tunnel, faute de
terme connu ; en inventer un fermerait un accès encore ouvert.
"""
if not self._access_open:
return False
if self._access_until is None:
return True
return datetime.now(timezone.utc) < self._access_until
def _access_stale(self) -> bool:
"""L'état de l'accès mérite-t-il d'être redemandé au serveur ?
L'échéance annoncée n'est pas la seule façon dont un accès se referme :
le serveur peut révoquer avant terme — redémarrage, purge de sa table
de sessions, décision d'un administrateur — sans que rien n'en
parvienne au poste. Le client ne sondait qu'au retour du tunnel : il
affichait donc « Ouvert ✓ » et masquait le bouton devant un réseau
déjà muet, jusqu'à la chute du tunnel. Sans échéance annoncée, cet
état pouvait durer indéfiniment.
"""
if self._access_probed_at is None:
return True
return (datetime.now(timezone.utc)
- self._access_probed_at) >= _ACCESS_RECHECK
def _remaining_text(self) -> str:
"""Temps restant avant l'échéance, tel qu'on le lit d'un coup d'œil.
Rafraîchi au rythme du sondage de statut (3 s) : la minute affichée
est donc juste, ce qui suffit pour une fenêtre qui se compte en
heures. Aucun minuteur supplémentaire pour l'entretenir.
"""
seconds = int((self._access_until
- datetime.now(timezone.utc)).total_seconds())
# Arrondi à la minute, et non tronqué : une fenêtre de 8 h qui vient
# de s'ouvrir doit se lire « 8 h 00 min », pas « 7 h 59 min ».
minutes = (seconds + 30) // 60
if minutes >= 60:
return f"{minutes // 60} h {minutes % 60:02d} min"
if minutes >= 1:
return f"{minutes} min"
return f"{max(seconds, 0)} s"
def _access_state(self) -> str:
"""État de l'autorisation réseau accordée par le serveur.
Distinct de l'état du tunnel : monté, celui-ci ne mène nulle part
tant que le serveur n'a pas levé la quarantaine. Sans cette ligne,
un utilisateur ayant refusé la saisie du code verrait « Connecté »
et un réseau inexplicablement muet.
Sans identifiant VPN, l'état est « non vérifié » et non « non géré » :
le client n'en sait rien. Il l'affirmait, et se trompait — le serveur
peut parfaitement tenir ce pair en quarantaine pendant que la
configuration locale, elle, ne mentionne aucun serveur.
"""
if not self._is_connected:
return "—"
if self._access_valid():
if self._access_until is None:
return "Ouvert ✓"
fin = self._access_until.astimezone()
# La date n'est rappelée que si l'échéance n'est pas aujourd'hui :
# la durée est réglable jusqu'à 168 h côté serveur, « jusqu'à
# 02:30 » serait alors ambigu.
quand = (f"{fin:%H:%M}" if fin.date() == datetime.now().date()
else f"{fin:%d/%m à %H:%M}")
return f"Ouvert ✓ jusqu'à {quand} (reste {self._remaining_text()})"
if self._access_open:
# L'autorisation a couru puis expiré : le dire, plutôt que de
# laisser croire qu'aucun code n'a jamais été saisi.
return "Autorisation expirée (code requis)"
if (self._cfg.wg.get("vpn_username") or "").strip():
return "Quarantaine (code requis)"
return "Non vérifié"
def _refresh_status(self):
wg = self._cfg.wg
srv = wg.get("server_endpoint", "")
port = wg.get("server_port", "")
self._info_labels["server"].setText(f"{srv}:{port}" if srv else "—")
self._info_labels["iface"].setText(wg.get("interface_name", "wgs0"))
self._info_labels["addr"].setText(wg.get("client_address", "—"))
# Auto-reconnect timer
if self._cfg.get("ui", "auto_reconnect"):
interval = int(self._cfg.get("ui", "reconnect_interval") or 15) * 1000
if not self._reconnect_timer.isActive():
self._reconnect_timer.start(interval)
else:
self._reconnect_timer.stop()
try:
info = wg_core.get_status_info(self._cfg)
connected = info["connected"]
except Exception:
connected = False
info = {}
was_connected = self._is_connected
self._is_connected = connected
if connected:
self._reconnect_failures = 0
if not was_connected:
# Tunnel monté à l'instant — démarrage de l'application sur un
# tunnel déjà actif, reconnexion automatique, ou montage fait
# hors de l'application. Demander au serveur avant de conclure
# quoi que ce soit : lui seul sait si l'accès est encore ouvert.
self._probe_access(prompt_if_closed=False)
elif self._access_open and self._access_stale():
# Accès cru ouvert : le reconfirmer périodiquement, une
# révocation côté serveur n'étant annoncée par rien.
self._probe_access(prompt_if_closed=False)
else:
# Tunnel tombé : le client oublie ce qu'il croyait savoir. Le
# serveur, lui, n'oublie rien — l'autorisation porte sur l'adresse
# du pair, qui ne change pas d'une reconnexion à l'autre, et elle
# court jusqu'à son échéance. D'où le sondage au retour du tunnel
# plutôt qu'une remise à zéro tenue pour définitive : c'est cette
# confusion qui faisait réclamer un code déjà validé.
self._access_open = False
self._access_until = None
self._access_probed_at = None
self._access_denials = 0
self._access_message = ""
self._probe_retried = False
self._info_labels["access"].setText(self._access_state())
# Le bouton n'apparaît que lorsqu'il y a réellement un code à saisir ;
# sa hauteur entre et sort du calcul de la fenêtre, d'où le
# réajustement. `isHidden()` et non `isVisible()` : cette dernière est
# fausse dès que la fenêtre est repliée dans le systray, ce qui
# relancerait le calcul de hauteur à chaque cycle de rafraîchissement.
needed = self.access_code_needed()
if needed == self._btn_access.isHidden():
self._btn_access.setVisible(needed)
self._sync_window_height()
if needed:
# Un code est attendu de façon certaine quand l'identifiant VPN
# est connu ; sinon le bouton reste disponible mais discret, pour
# ne pas réclamer une action à une installation qui n'a pas de
# serveur d'authentification.
self._style_access_button(
bool((self._cfg.wg.get("vpn_username") or "").strip()))
if connected:
if self._connected_since is None:
self._connected_since = QDateTime.currentDateTime()
self._status_badge.setText("● Connecté")
self._status_badge.setStyleSheet(
f"color: {theme.OK_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
)
self._btn_connect.setText("Se déconnecter")
self._btn_connect.setStyleSheet(
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #c0392b; }"
)
self._info_labels["rx_tx"].setText(
f"↓ {info.get('rx_bytes','—')}{info.get('tx_bytes','—')}"
)
self._info_labels["handshake"].setText(info.get("last_handshake", "—"))
self._duration_label.setStyleSheet(
f"color: {theme.OK_TEXT}; font-size: 11px; background: transparent;"
)
self.setWindowIcon(icons.icon_connected())
else:
if self._connected_since is not None:
self._connected_since = None
self._duration_label.setText("")
self._bw_graph.reset()
self._prev_rx = self._prev_tx = None
self._status_badge.setText("● Déconnecté")
self._status_badge.setStyleSheet(
f"color: {theme.FAIL_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
)
self._btn_connect.setText("Se connecter")
self._btn_connect.setStyleSheet(
f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #27ae60; }"
)
self._info_labels["rx_tx"].setText("—")
self._info_labels["handshake"].setText("—")
self._duration_label.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 11px; background: transparent;"
)
self.setWindowIcon(icons.icon_disconnected())
pix = (icons.icon_connected() if connected else icons.icon_disconnected()).pixmap(36, 36)
self._icon_label.setPixmap(pix)
# ------------------------------------------------------------------ #
# Actions
# ------------------------------------------------------------------ #
def _on_connect(self):
"""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:
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
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()
# ── 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 access_code_needed(self) -> bool:
"""Vrai quand une saisie de code aurait un sens ici et maintenant.
Deux conditions seulement : un tunnel monté — l'API du serveur n'est
joignable que par l'intérieur — et aucune autorisation en cours.
« En cours » et non « obtenue » : une autorisation a un terme, fixé
par le serveur. Passé celui-ci le pair retourne en quarantaine sans
que rien ne bouge côté poste, et le bouton doit reparaître — sans
quoi l'utilisateur retrouve un réseau muet et un bouton masqué,
exactement la situation que ce bouton existe pour éviter.
L'identifiant VPN n'en fait volontairement pas partie. Il en faisait
partie, et c'était un piège : un poste bel et bien tenu en quarantaine
par le serveur, mais dont ce champ n'avait jamais été renseigné,
n'affichait aucun moyen de saisir son code — précisément dans la
situation où il en avait besoin. La fenêtre de saisie demande
l'identifiant quand il manque.
"""
return self._is_connected and not self._access_valid()
def request_access_code(self):
"""Ouvre la saisie du code, à la demande de l'utilisateur.
Distincte de l'enchaînement automatique après connexion : le tunnel
monte en quarantaine et l'autorisation expire d'elle-même, il faut
donc pouvoir revenir saisir un code à tout moment — après un refus,
après une reconnexion automatique, ou une fois l'autorisation échue.
"""
if not self._is_connected:
QMessageBox.information(
self, "Tunnel inactif",
"Le serveur d'authentification n'est joignable que depuis "
"l'intérieur du tunnel.\nConnectez-vous d'abord.")
return
self._open_access_dialog()
def _authenticate_with_server(self, silent: bool):
"""Invite spontanée à saisir le code, juste après une connexion.
Sans identifiant VPN configuré, l'invite est tue : le serveur peut
très bien tourner en mode permissif, où un compte non enrôlé conserve
son accès, et une installation sans serveur d'authentification n'a
aucune raison de voir surgir cette fenêtre à chaque connexion. Cela
ne vaut que pour l'invite : la saisie reste atteignable à la demande,
par le bouton et par le systray, quelle que soit la configuration.
"""
# L'invite ne part qu'après la réponse du serveur : le tunnel qui
# remonte ne révoque pas l'autorisation en cours, et réclamer un code
# sans avoir demandé revenait à en redemander un déjà validé.
#
# Une reconnexion automatique ne fait jamais surgir de fenêtre modale
# — personne n'est devant l'écran, l'application resterait figée ; le
# bouton prend le relais. Sans identifiant VPN configuré non plus :
# une installation sans serveur d'authentification n'a pas à voir
# cette fenêtre à chaque connexion.
known = bool((self._cfg.wg.get("vpn_username") or "").strip())
self._probe_access(prompt_if_closed=(not silent) and known)
def _probe_access(self, prompt_if_closed: bool):
"""Interroge le serveur sur l'état de l'accès, sans bloquer l'écran.
Un seul sondage à la fois : les deux déclencheurs — front montant du
tunnel et fin d'une connexion — se produisent souvent coup sur coup.
L'intention d'inviter à saisir un code survit à cette fusion, sans
quoi une connexion manuelle pourrait n'ouvrir aucune fenêtre parce
qu'un sondage silencieux serait parti une fraction de seconde plus tôt.
"""
self._probe_prompt = self._probe_prompt or prompt_if_closed
if self._probe_worker is not None:
return
self._probe_worker = ValueWorker(vpn_session.session_status, self._cfg,
parent=self)
self._probe_worker.done.connect(self._on_probe_reply)
self._probe_worker.finished.connect(self._probe_worker.deleteLater)
self._probe_worker.start()
def _retry_probe(self):
"""Second sondage, après un silence du serveur (cf. `_on_probe_reply`).
Le différé peut arriver dans un monde qui a changé : tunnel retombé,
application en train de se fermer. Dans les deux cas il n'y a plus rien
à demander, et surtout plus personne à qui montrer une fenêtre.
"""
if self._quitting or not self._is_connected:
self._probe_prompt = False
return
self._probe_access(prompt_if_closed=True)
def _on_probe_reply(self, result):
"""Adopte l'état renvoyé par le serveur, ou réclame un code."""
self._probe_worker = None
prompt = self._probe_prompt
self._probe_prompt = False
was_open = self._access_open
self._access_probed_at = datetime.now(timezone.utc)
if result is not None and result.ok:
self._access_open = True
self._access_until = result.access_until
self._access_denials = 0
self._probe_retried = False
# Journalisé au changement seulement : la re-vérification repasse
# ici chaque minute, et répéter la même ligne noierait le journal.
if not was_open or result.message != self._access_message:
self._add_log(result.message, "success")
self._access_message = result.message
self._refresh_status()
return
# Le serveur a-t-il seulement parlé ? Un worker en échec rend None, et
# `authoritative` distingue le refus du silence.
spoke = result is not None and result.authoritative
if was_open:
# Re-vérification d'un accès qu'on croyait ouvert.
if spoke:
# Le serveur affirme que l'accès ne l'est plus : inutile
# d'attendre confirmation, il est seul juge.
self._access_open = False
self._access_until = None
self._access_message = ""
self._access_denials = 0
self._add_log(result.message or "Accès distant refermé par le "
"serveur — code à ressaisir", "warning")
else:
# Silence : n'en rien conclure tout de suite, sinon l'état
# clignoterait au moindre hoquet. Après plusieurs minutes sans
# réponse, mieux vaut rendre le bouton que d'afficher un
# « Ouvert ✓ » que plus rien n'étaye.
self._access_denials += 1
if self._access_denials >= _ACCESS_UNKNOWN_BEFORE_CLOSE:
self._access_open = False
self._access_until = None
self._access_message = ""
self._add_log("Serveur d'authentification sans réponse — "
"accès distant à reconfirmer", "warning")
self._refresh_status()
return
# Accès jamais ouvert de cette session. Une invite était-elle attendue ?
if prompt and not spoke and not self._probe_retried:
# Le serveur n'a rien dit : faire surgir la fenêtre maintenant
# ferait saisir un code peut-être inutile — l'accès est peut-être
# déjà ouvert, et le tunnel vient de monter. Un second essai avant
# de déranger l'utilisateur.
self._probe_retried = True
self._probe_prompt = True
QTimer.singleShot(_PROBE_RETRY_MS, self._retry_probe)
self._refresh_status()
return
self._probe_retried = False
if prompt:
self._open_access_dialog()
return
self._add_log("Code d'accès requis — bouton « Saisir le code "
"d'accès »", "warning")
self._refresh_status()
def _open_access_dialog(self):
"""Ouvre la fenêtre de saisie et journalise la réponse du serveur.
La fenêtre est lue puis détruite dans la foulée : parentée à celle-ci,
elle survivait autrement à chaque saisie — minuteur compris — et
`_access_dialog` écrasait la précédente, dont le thread n'était alors
plus attendu à l'arrêt de l'application.
"""
dlg = AccessCodeDialog(self._cfg, self)
self._access_dialog = dlg
try:
accepted = dlg.exec() == AccessCodeDialog.DialogCode.Accepted
# Lus avant destruction : ils ne survivent pas à `dispose()`.
message = dlg.message()
until = dlg.access_until()
finally:
self._access_dialog = None
dlg.dispose()
if accepted:
self._access_open = True
self._access_until = until
self._access_message = message
# Le serveur vient de répondre : cela vaut sondage, et remet à
# zéro le décompte des silences qui finit par refermer l'accès.
self._access_probed_at = datetime.now(timezone.utc)
self._access_denials = 0
self._probe_retried = False
self._add_log(message, "success")
else:
# Abandon : l'état d'accès n'est pas touché. Il l'était, et à tort
# — un sondage abouti pendant que la fenêtre était ouverte (les
# minuteries tournent dans la boucle imbriquée d'`exec()`) pouvait
# établir que l'accès était ouvert, que « Plus tard » remettait
# aussitôt à fermé. Renoncer à saisir un code ne referme rien côté
# serveur ; ça ne doit rien refermer ici non plus.
#
# La fenêtre affiche déjà le refus du serveur et laisse réessayer :
# y superposer une boîte d'alerte n'apprendrait rien de plus.
self._add_log(message or "Authentification abandonnée — "
"accès distant fermé", "warning")
self._refresh_status()
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", "")
self._session_id = hist.start_session(endpoint,
self._cfg.active_profile)
self._add_log(f"Connexion établie → {endpoint}", "success")
# `connect()` ajoute une ligne d'avertissement au message de
# succès quand le split-DNS n'a pas pu être appliqué — sans
# quoi cet échec restait invisible.
base, _, extra = msg.partition("\n")
if extra:
self._add_log(extra, "warning")
# Le tunnel est monté mais ne mène encore nulle part : le
# serveur garde le pair en quarantaine tant qu'un code n'a
# pas été validé. C'est seulement maintenant que l'API est
# joignable — elle ne l'est que depuis l'intérieur du tunnel.
self._authenticate_with_server(silent)
QTimer.singleShot(5000, self._update_ping)
else:
self._reconnect_failures += 1
self._add_log(f"Erreur : {msg}", "error")
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()
def _open_admin(self):
if self._cfg.has_admin_password():
pw, ok = QInputDialog.getText(
self, "Accès Administrateur",
"Mot de passe administrateur :",
QLineEdit.EchoMode.Password,
)
if not ok:
return
if not self._cfg.check_admin_password(pw):
QMessageBox.warning(self, "Refusé", "Mot de passe incorrect.")
return
dlg = AdminWindow(self._cfg, self)
dlg.exec()
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, self._probe_worker):
try:
if worker is not None and worker.isRunning():
worker.wait(15000)
except RuntimeError:
pass # objet Qt déjà détruit
if self._access_dialog is not None:
# Une validation peut être partie puis la fenêtre fermée aussitôt :
# son thread survit à la fermeture et ne doit pas être détruit avec
# la fenêtre principale.
self._access_dialog.wait_for_worker(15000)
self._access_dialog = None
self._worker = None
self._ping_worker = None
self._probe_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") and not self._quitting:
# Simple masquage : ce n'est pas une fermeture, le tunnel reste actif.
event.ignore()
self.hide()
return
self.prepare_quit()
event.accept()