Files
WGSecure/app/ui/admin_window.py
T
tuxgyver 1ad1df361e feat: option clé pré-partagée et refonte des onglets du panneau admin
La présence d'une clé pré-partagée se devinait au fait que le champ soit
rempli. Un serveur qui n'en utilise pas — le cas par défaut de WireGuard —
déclenchait donc un avertissement à chaque diagnostic, et une clé oubliée dans
le champ finissait écrite dans la configuration alors que le serveur n'en
attendait aucune, ce qui casse le handshake aussi sûrement qu'une clé
manquante. Une case à cocher tranche désormais, et le diagnostic distingue les
quatre états au lieu d'un avertissement unique.

Le drapeau vaut None par défaut et non False : _merge() recopie le défaut dans
toute configuration antérieure, et False aurait désactivé la PSK d'un tunnel
qui fonctionnait.

Refonte des onglets, qui passaient de sept à cinq :

- WireGuard et Clés se chevauchaient — la clé publique du serveur se saisissait
  dans l'un pendant que l'assistant qui la produit vivait dans l'autre, et les
  quatre façons de sortir une configuration étaient réparties entre les deux.
  Fusionnés en sous-onglets Tunnel, Clés, Profils & fichiers.
- Les trois tests s'empilaient sur une page unique où le rapport de diagnostic
  se retrouvait comprimé. Chacun a son sous-onglet.
- MFA et Sécurité répondent à la même question et sont regroupés.
- La section des droits parlait de sudo et de setup-sudoers sous Windows, où
  rien de tout cela n'existe, et concluait « privilèges root » sur un rapport
  simplement vide. Elle vérifie maintenant l'élévation UAC et WireGuard.

Chaque onglet s'ouvre sur un encart expliquant ce qu'on y décide, et les
couleurs — vingt-quatre valeurs en dur, cinq bandeaux sans rapport de teinte,
des blocs de résultat au fond incohérent — passent par app/ui/theme.py.
2026-09-01 14:25:15 +02:00

1413 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox,
QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame,
QFileDialog, QSizePolicy, QListWidget, QScrollArea,
)
from PyQt6.QtCore import Qt
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
from app.ui import theme
from app.utils.platform_utils import is_windows
class AdminWindow(QDialog):
def __init__(self, config: Config, parent=None):
super().__init__(parent)
self._cfg = config
self.setWindowTitle("WGSecure — Panneau Administrateur")
self.setWindowFlags(
Qt.WindowType.Dialog
| Qt.WindowType.WindowTitleHint
| Qt.WindowType.WindowCloseButtonHint
)
# Taille figée auparavant : le rapport de diagnostic, long d'une
# dizaine d'étapes détaillées, était comprimé en une bande illisible.
self.setMinimumSize(720, 620)
self.resize(880, 740)
self._build_ui()
self._load_values()
# L'aperçu s'ouvrait vide : il fallait cliquer « Rafraîchir » pour
# voir la configuration que l'application allait pourtant écrire.
self._refresh_preview()
# ------------------------------------------------------------------ #
# Helper : page sombre avec bandeau coloré
# ------------------------------------------------------------------ #
def _dark_page(self, banner_text: str, banner_color: str,
scroll: bool = False):
"""Retourne (page_widget, body_layout) : fond sombre + bandeau.
`scroll=True` place le corps dans une zone défilante : indispensable
pour les pages dont le contenu grandit à l'usage (rapport de
diagnostic), sinon les widgets se compriment jusqu'à l'illisible.
"""
w = QWidget()
w.setStyleSheet(theme.TAB_CSS)
outer = QVBoxLayout(w)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
hdr = QLabel(f" {banner_text}")
hdr.setFixedHeight(36)
hdr.setStyleSheet(
f"background: {banner_color}; color: white; font-size: 13px;"
" font-weight: bold; padding-left: 8px;"
)
outer.addWidget(hdr)
body = QWidget()
body_layout = QVBoxLayout(body)
body_layout.setContentsMargins(14, 14, 14, 14)
body_layout.setSpacing(10)
if scroll:
area = QScrollArea()
area.setWidgetResizable(True)
area.setStyleSheet(theme.scrollarea_style())
area.setWidget(body)
outer.addWidget(area)
else:
outer.addWidget(body)
return w, body_layout
# ------------------------------------------------------------------ #
# Construction principale
# ------------------------------------------------------------------ #
def _build_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
banner = QLabel(" WGSecure — Configuration Administrateur")
banner.setFixedHeight(40)
banner.setStyleSheet(
"background: #17202a; color: white; font-size: 14px; font-weight: bold;"
)
layout.addWidget(banner)
tabs = QTabWidget()
tabs.setStyleSheet("""
QTabWidget::pane { border: none; }
QTabBar::tab { padding: 8px 16px; background: #1c2833;
color: rgba(255,255,255,0.6); border: none; }
QTabBar::tab:selected { background: #2e4057; color: white;
font-weight: bold; }
QTabBar::tab:hover { background: #253545; color: white; }
""")
tabs.addTab(self._tab_wireguard(), "WireGuard")
tabs.addTab(self._tab_test(), "Test connexion")
tabs.addTab(self._tab_settings(), "Paramètres")
tabs.addTab(self._tab_security(), "Sécurité")
tabs.addTab(self._tab_about(), "À propos")
layout.addWidget(tabs)
# Barre de boutons
bar = QWidget()
bar.setStyleSheet("background: #17202a;")
btn_row = QHBoxLayout(bar)
btn_row.setContentsMargins(12, 8, 12, 10)
btn_cancel = QPushButton("Fermer sans sauvegarder")
btn_cancel.setStyleSheet(
"QPushButton { padding: 8px 20px; background: #2e4057; color: white;"
" border-radius: 5px; border: none; }"
"QPushButton:hover { background: #3d5166; }"
)
btn_cancel.clicked.connect(self.reject)
btn_save = QPushButton("Enregistrer et fermer")
btn_save.setStyleSheet(
"QPushButton { padding: 8px 20px; background: #1e8449; color: white;"
" border-radius: 5px; font-weight: bold; border: none; }"
"QPushButton:hover { background: #27ae60; }"
)
btn_save.clicked.connect(self._save_and_close)
btn_row.addStretch()
btn_row.addWidget(btn_cancel)
btn_row.addWidget(btn_save)
layout.addWidget(bar)
# ------------------------------------------------------------------ #
# Onglet WireGuard
# ------------------------------------------------------------------ #
@staticmethod
def _row(form: QFormLayout, text: str, widget,
label_w: int = 148, field_h: int = 28) -> None:
"""Ligne de formulaire : label largeur fixe + champ hauteur uniforme.
widget peut être un QWidget ou un QHBoxLayout (sans setFixedHeight)."""
from PyQt6.QtWidgets import QLayout
lbl = QLabel(text)
lbl.setFixedWidth(label_w)
lbl.setFixedHeight(field_h)
lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
lbl.setStyleSheet("color: rgba(255,255,255,0.75); background: transparent;")
if not isinstance(widget, QLayout):
widget.setFixedHeight(field_h)
form.addRow(lbl, widget)
@staticmethod
def _hint(text: str) -> QLabel:
"""Encart expliquant à quoi sert l'onglet où il figure.
Les pages s'ouvraient sur un formulaire nu : rien ne disait ce qu'on y
décidait, ni ce qui se passait si on n'y touchait pas.
"""
lbl = QLabel(text)
lbl.setWordWrap(True)
lbl.setStyleSheet(theme.hint_style())
return lbl
def _scroll_page(self) -> tuple[QWidget, QVBoxLayout]:
"""Sous-onglet défilant : fond de page, marges homogènes."""
page = QWidget()
page.setStyleSheet(theme.TAB_CSS)
outer = QVBoxLayout(page)
outer.setContentsMargins(0, 0, 0, 0)
area = QScrollArea()
area.setWidgetResizable(True)
area.setStyleSheet(theme.scrollarea_style())
body = QWidget()
lay = QVBoxLayout(body)
lay.setContentsMargins(14, 12, 14, 12)
lay.setSpacing(10)
area.setWidget(body)
outer.addWidget(area)
return page, lay
def _tab_wireguard(self) -> QWidget:
"""Configuration WireGuard, en sous-onglets.
« WireGuard » et « Clés » étaient deux onglets de premier niveau qui se
chevauchaient : la clé publique du serveur se saisissait dans l'un
pendant que l'assistant qui la produit vivait dans l'autre, et les
quatre façons de sortir une configuration (aperçu, .conf, QR Code,
volet serveur) étaient réparties entre les deux sans logique. Le
découpage suit maintenant ce qu'on manipule : les paramètres du
tunnel, les clés, puis les fichiers.
"""
w, lay = self._dark_page("🛡️ WireGuard", theme.BANNER["wireguard"])
lay.setContentsMargins(10, 10, 10, 0)
lay.addWidget(self._hint(
"Tout ce qui définit le tunnel : les paramètres du pair, vos clés, "
"et les profils enregistrés. Les modifications ne prennent effet "
"qu'après « Enregistrer et fermer »."
))
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
sub.addTab(self._subtab_tunnel(), "🔗 Tunnel")
sub.addTab(self._subtab_keys(), "🔑 Clés")
sub.addTab(self._subtab_profiles(), "📂 Profils & fichiers")
lay.addWidget(sub)
self._refresh_profile_list()
return w
# ── Sous-onglet : paramètres du tunnel ───────────────────────────────
def _subtab_tunnel(self) -> QWidget:
page, lay = self._scroll_page()
lay.addWidget(self._hint(
"Décrit le pair à joindre et la façon dont le trafic y entre. Ces "
"valeurs doivent correspondre exactement à ce que le serveur "
"déclare pour vous : une adresse, un port ou une clé qui divergent "
"ne produisent aucune erreur — le tunnel monte et le handshake "
"n'arrive jamais."
))
# ── Serveur ──────────────────────────────────────────────────────
grp = QGroupBox("Serveur WireGuard")
form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._srv_endpoint = QLineEdit()
self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4")
self._row(form, "Adresse serveur :", self._srv_endpoint)
self._srv_port = QSpinBox()
self._srv_port.setRange(1, 65535)
self._srv_port.setValue(51820)
self._srv_port.setToolTip(
"Port UDP sur lequel le serveur écoute. Un port erroné ne produit\n"
"aucune erreur visible : WireGuard ignore silencieusement les\n"
"paquets, le tunnel monte et le handshake n'arrive jamais."
)
self._srv_port.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form, "Port UDP :", self._srv_port)
self._srv_pubkey = QLineEdit()
self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)")
self._row(form, "Clé publique serveur :", self._srv_pubkey)
# Un champ vide ne disait pas si la PSK était oubliée ou volontairement
# absente : le diagnostic avertissait donc sur tout serveur qui n'en
# utilise pas, c'est-à-dire la configuration WireGuard par défaut.
self._use_psk = QCheckBox("Le serveur utilise une clé pré-partagée")
self._use_psk.setToolTip(
"À cocher uniquement si le serveur déclare une PresharedKey pour ce\n"
"pair. Une PSK écrite alors que le serveur n'en attend pas casse le\n"
"handshake aussi sûrement qu'une PSK manquante."
)
self._row(form, "Clé pré-partagée :", self._use_psk)
self._psk = QLineEdit()
self._psk.setEchoMode(QLineEdit.EchoMode.Password)
self._psk.setPlaceholderText("(clé pré-partagée, 32 octets en base64)")
self._psk.setToolTip(
"Clé pré-partagée (PresharedKey). Doit être identique des deux côtés :\n"
"si le serveur en attend une et qu'elle manque ici, le tunnel monte\n"
"mais aucun handshake n'aboutit."
)
show_psk = QPushButton("Afficher")
show_psk.setFixedSize(70, 28)
show_psk.setCheckable(True)
show_psk.toggled.connect(
lambda checked: self._psk.setEchoMode(
QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password
)
)
btn_gen_psk = QPushButton("Générer")
btn_gen_psk.setFixedSize(70, 28)
btn_gen_psk.clicked.connect(
lambda: self._psk.setText(wg_core.generate_preshared_key())
)
psk_row = QHBoxLayout()
psk_row.setSpacing(6)
psk_row.addWidget(self._psk)
psk_row.addWidget(show_psk)
psk_row.addWidget(btn_gen_psk)
self._row(form, "Valeur :", psk_row)
# Le champ et ses boutons suivent la case : rien à saisir tant que
# l'option est décochée.
def _sync_psk(checked: bool):
for wdg in (self._psk, show_psk, btn_gen_psk):
wdg.setEnabled(checked)
self._use_psk.toggled.connect(_sync_psk)
_sync_psk(False)
lay.addWidget(grp)
# ── Interface client ─────────────────────────────────────────────
grp2 = QGroupBox("Interface client")
form2 = QFormLayout(grp2)
form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._iface_name = QLineEdit()
self._iface_name.setPlaceholderText("wgs0")
self._row(form2, "Nom interface :", self._iface_name)
self._client_addr = QLineEdit()
self._client_addr.setPlaceholderText("10.8.0.2/24")
self._row(form2, "Adresse IP client :", self._client_addr)
self._dns = QLineEdit()
self._dns.setPlaceholderText("1.1.1.1")
self._row(form2, "DNS :", self._dns)
self._allowed_ips = QLineEdit()
self._allowed_ips.setPlaceholderText("10.8.0.0/24")
self._allowed_ips.setToolTip(
"Réseaux routés dans le tunnel. N'y mettez pas le réseau local qui\n"
"porte la passerelle vers le serveur : elle deviendrait joignable\n"
"par le tunnel seul, et le handshake n'aboutirait plus."
)
self._row(form2, "IPs autorisées :", self._allowed_ips)
self._keepalive = QSpinBox()
self._keepalive.setRange(0, 300)
self._keepalive.setValue(25)
self._keepalive.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form2, "Keepalive (s) :", self._keepalive)
self._mtu = QSpinBox()
self._mtu.setRange(0, 9000)
self._mtu.setSpecialValueText("automatique")
self._mtu.setToolTip(
"0 = laisser WireGuard décider. Abaisser à 12801420 si le tunnel\n"
"monte mais que les gros paquets ne passent pas."
)
self._mtu.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form2, "MTU :", self._mtu)
lay.addWidget(grp2)
lay.addStretch()
return page
# ── Sous-onglet : clés ───────────────────────────────────────────────
def _subtab_keys(self) -> QWidget:
page, lay = self._scroll_page()
lay.addWidget(self._hint(
"Votre identité auprès du serveur. La clé privée ne quitte jamais "
"cette machine ; c'est la clé publique, dérivée d'elle, que "
"l'administrateur du serveur doit inscrire pour ce pair. Régénérer "
"la paire invalide l'accès tant que le serveur n'a pas la nouvelle."
))
grp = QGroupBox("Paire de clés client (Curve25519)")
form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._priv_key = QLineEdit()
self._priv_key.setEchoMode(QLineEdit.EchoMode.Password)
self._priv_key.setPlaceholderText("(générer ou coller)")
self._priv_key.setFixedHeight(28)
show_priv = QPushButton("Afficher")
show_priv.setFixedSize(70, 28)
show_priv.setCheckable(True)
show_priv.toggled.connect(
lambda checked: self._priv_key.setEchoMode(
QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password
)
)
priv_row = QHBoxLayout()
priv_row.setSpacing(6)
priv_row.addWidget(self._priv_key)
priv_row.addWidget(show_priv)
self._row(form, "Clé privée :", priv_row)
self._pub_key = QLineEdit()
self._pub_key.setReadOnly(True)
self._pub_key.setPlaceholderText("(dérivée automatiquement)")
self._pub_key.setToolTip(
"C'est cette clé que le serveur doit déclarer pour ce pair."
)
self._row(form, "Clé publique :", self._pub_key)
btn_gen = QPushButton("Générer une nouvelle paire de clés")
btn_gen.setFixedHeight(28)
btn_gen.clicked.connect(self._generate_keys)
self._row(form, "", btn_gen)
lay.addWidget(grp)
grp2 = QGroupBox("🖥️ Assistant : configuration serveur + client")
v2 = QVBoxLayout(grp2)
note = QLabel(
"Génère une paire complète prête à déployer : nouvelles clés des "
"deux côtés,\nclé pré-partagée si l'option est cochée dans "
"l'onglet Tunnel."
)
note.setWordWrap(True)
note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;")
v2.addWidget(note)
btn_gen_srv = QPushButton("⚡ Générer config serveur + client")
btn_gen_srv.clicked.connect(self._generate_server_config)
v2.addWidget(btn_gen_srv)
lay.addWidget(grp2)
lay.addStretch()
return page
# ── Sous-onglet : profils et fichiers ────────────────────────────────
def _subtab_profiles(self) -> QWidget:
page, lay = self._scroll_page()
lay.addWidget(self._hint(
"Conserve plusieurs configurations complètes et bascule de l'une à "
"l'autre. L'aperçu montre le fichier exact qui sera écrit au "
"moment de la connexion — c'est lui qu'on importe, exporte ou "
"transmet par QR Code."
))
grp_p = QGroupBox("Profils")
gp = QVBoxLayout(grp_p)
gp.setSpacing(6)
gp.setContentsMargins(10, 14, 10, 10)
self._profile_list = QListWidget()
self._profile_list.setFixedHeight(96)
self._profile_list.setStyleSheet(
f"QListWidget {{ background: rgba(255,255,255,0.07); color: {theme.TEXT};"
f" border: 1px solid {theme.BORDER}; border-radius: 4px; }}"
f"QListWidget::item:selected {{ background: {theme.ACCENT}; }}"
)
gp.addWidget(self._profile_list)
p_btn_row = QHBoxLayout()
p_btn_row.setSpacing(6)
btn_save_p = QPushButton("💾 Sauvegarder sous…")
btn_save_p.setFixedHeight(26)
btn_save_p.clicked.connect(self._save_profile)
btn_load_p = QPushButton("✅ Charger")
btn_load_p.setFixedHeight(26)
btn_load_p.clicked.connect(self._load_profile)
btn_del_p = QPushButton("🗑️ Supprimer")
btn_del_p.setFixedHeight(26)
btn_del_p.setStyleSheet(
f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT};"
f" border-radius: 5px; padding: 0 12px; border: none; }}"
f"QPushButton:hover {{ background: #e74c3c; }}"
)
btn_del_p.clicked.connect(self._delete_profile)
p_btn_row.addWidget(btn_save_p)
p_btn_row.addWidget(btn_load_p)
p_btn_row.addWidget(btn_del_p)
gp.addLayout(p_btn_row)
lay.addWidget(grp_p)
# Aperçu, import, export et QR Code étaient dispersés entre les deux
# anciens onglets : ils décrivent pourtant le même objet, le fichier
# de configuration du pair.
grp2 = QGroupBox("Configuration générée")
v2 = QVBoxLayout(grp2)
self._config_preview = QTextEdit()
self._config_preview.setReadOnly(True)
self._config_preview.setFont(QFont("Courier", 9))
self._config_preview.setMinimumHeight(170)
self._config_preview.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
v2.addWidget(self._config_preview, 1)
btn_row = QHBoxLayout()
btn_row.setSpacing(6)
btn_preview = QPushButton("🔄 Rafraîchir")
btn_preview.clicked.connect(self._refresh_preview)
btn_import = QPushButton("📂 Importer un .conf")
btn_import.clicked.connect(self._import_conf)
btn_export = QPushButton("💾 Exporter en .conf")
btn_export.clicked.connect(self._export_conf)
btn_qr = QPushButton("📱 QR Code")
btn_qr.setToolTip("Exporte la configuration pour l'application mobile.")
btn_qr.clicked.connect(self._export_qr)
for b in (btn_preview, btn_import, btn_export, btn_qr):
b.setFixedHeight(26)
btn_row.addWidget(b)
v2.addLayout(btn_row)
lay.addWidget(grp2, 1)
return page
# ------------------------------------------------------------------ #
# Onglet MFA
# ------------------------------------------------------------------ #
def _subtab_mfa(self) -> QWidget:
page, lay = self._test_page()
lay.addWidget(self._hint(
"Exige un code à usage unique avant chaque connexion. Le secret se "
"scanne dans Bitwarden ou toute application TOTP ; conservez-le "
"ailleurs qu'ici, il est le seul moyen de régénérer les codes."
))
self._mfa_enabled_cb = QCheckBox("Activer la vérification MFA avant connexion")
self._mfa_enabled_cb.setStyleSheet("color: white; font-weight: bold;")
lay.addWidget(self._mfa_enabled_cb)
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: rgba(255,255,255,0.15);")
lay.addWidget(sep)
grp = QGroupBox("Secret TOTP")
form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._mfa_secret = QLineEdit()
self._mfa_secret.setPlaceholderText("(générer ou coller votre secret TOTP)")
self._row(form, "Secret :", self._mfa_secret)
btn_gen_secret = QPushButton("Générer un nouveau secret")
btn_gen_secret.clicked.connect(self._generate_mfa_secret)
self._row(form, "", btn_gen_secret)
lay.addWidget(grp)
grp2 = QGroupBox("QR Code — Scanner avec Bitwarden")
v2 = QVBoxLayout(grp2)
self._qr_label = QLabel("(générez un secret pour afficher le QR Code)")
self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._qr_label.setFixedHeight(230)
v2.addWidget(self._qr_label)
self._mfa_uri_label = QLabel("")
self._mfa_uri_label.setWordWrap(True)
self._mfa_uri_label.setStyleSheet("font-size: 9px; color: rgba(255,255,255,0.55);")
v2.addWidget(self._mfa_uri_label)
btn_show_qr = QPushButton("Afficher le QR Code")
btn_show_qr.clicked.connect(self._show_qr)
v2.addWidget(btn_show_qr)
lay.addWidget(grp2)
lay.addStretch()
return page
# ------------------------------------------------------------------ #
# Onglet Test connexion
# ------------------------------------------------------------------ #
def _tab_test(self) -> QWidget:
"""Page de tests, découpée en sous-onglets.
Les trois tests s'empilaient sur une page unique : le rapport de
diagnostic, le plus long des trois, se retrouvait comprimé entre les
deux autres et la page réclamait un défilement permanent. Chacun
dispose maintenant de toute la hauteur.
"""
w, lay = self._dark_page("📡 Tests & diagnostic", theme.BANNER["test"])
lay.setContentsMargins(10, 10, 10, 0)
lay.addWidget(self._hint(
"Trois vérifications indépendantes : où la chaîne de connexion se "
"rompt, si le DNS fuit hors du tunnel, et si les droits "
"nécessaires au montage sont réunis."
))
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion")
sub.addTab(self._subtab_dns(), "🔍 Fuite DNS")
sub.addTab(self._subtab_privileges(), self._privileges_tab_title())
lay.addWidget(sub)
return w
def _test_page(self) -> tuple[QWidget, QVBoxLayout]:
"""Corps d'un sous-onglet de test : fond de page, marges homogènes."""
page = QWidget()
page.setStyleSheet(theme.TAB_CSS)
v = QVBoxLayout(page)
v.setContentsMargins(14, 14, 14, 14)
v.setSpacing(10)
return page, v
# ── Sous-onglet : chaîne de connexion ────────────────────────────────
def _subtab_diagnostic(self) -> QWidget:
page, v = self._test_page()
v.addWidget(self._hint(
"Parcourt la chaîne complète, de la configuration locale jusqu'à "
"la résolution DNS dans le tunnel, et s'arrête sur la première "
"étape qui bloque. À lancer en premier quand la connexion échoue "
"sans message."
))
grp = QGroupBox("Diagnostic de la chaîne de connexion")
g = QVBoxLayout(grp)
g.addWidget(QLabel(
"Vérifie toute la chaîne : configuration, clés, route, port UDP,\n"
"montage du tunnel, handshake, trafic et DNS — et désigne l'étape\n"
"qui bloque."
))
btn = QPushButton("🚀 Lancer le test de connexion")
btn.setStyleSheet(theme.primary_button_style())
btn.clicked.connect(self._run_test)
g.addWidget(btn)
# QLabel auparavant : le rapport fait une dizaine d'étapes avec un
# détail par étape, et un QLabel se laisse écraser par le layout —
# le texte était rogné à quelques lignes illisibles. Une vue en
# lecture seule porte sa propre barre de défilement : le rapport reste
# entier quelle que soit la taille de la fenêtre.
self._test_result = QTextEdit()
self._test_result.setReadOnly(True)
self._test_result.setMinimumHeight(300)
self._test_result.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self._test_result.setStyleSheet(theme.result_view_style())
g.addWidget(self._test_result, 1)
v.addWidget(grp, 1)
return page
# ── Sous-onglet : fuite DNS ──────────────────────────────────────────
def _subtab_dns(self) -> QWidget:
page, v = self._test_page()
v.addWidget(self._hint(
"Vérifie que les requêtes de noms empruntent bien le tunnel plutôt "
"que le résolveur du réseau local. La réparation retire une "
"configuration DNS laissée par un tunnel arrêté brutalement, qui "
"empêche toute résolution."
))
grp = QGroupBox("🔍 Test de fuite DNS")
g = QVBoxLayout(grp)
g.addWidget(QLabel("Vérifie que le DNS passe bien par le tunnel VPN."))
btn = QPushButton("🔍 Analyser le DNS")
btn.setStyleSheet(theme.primary_button_style())
btn.clicked.connect(self._run_dns_test)
g.addWidget(btn)
btn_fix = QPushButton("🛠️ Réparer le DNS (tunnel arrêté)")
btn_fix.setToolTip(
"Retire la configuration DNS laissée par un tunnel arrêté "
"brutalement, qui empêche toute résolution de noms."
)
btn_fix.clicked.connect(self._repair_dns)
g.addWidget(btn_fix)
self._dns_result = QTextEdit()
self._dns_result.setReadOnly(True)
self._dns_result.setMinimumHeight(220)
self._dns_result.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self._dns_result.setStyleSheet(theme.result_view_style())
g.addWidget(self._dns_result, 1)
v.addWidget(grp, 1)
return page
# ── Sous-onglet : droits d'exécution ─────────────────────────────────
def _privileges_tab_title(self) -> str:
return "🛡️ Élévation" if is_windows() else "🔐 Droits sudo"
def _subtab_privileges(self) -> QWidget:
"""Droits nécessaires pour monter le tunnel.
La page parlait de règles sudo sur les deux plateformes. Sous Windows
sudo n'existe pas : le texte, le bouton et le conseil « make
setup-sudoers » ne désignaient rien, et l'utilisateur n'avait aucun
moyen de savoir pourquoi une fenêtre UAC s'ouvrait à chaque connexion.
"""
page, v = self._test_page()
v.addWidget(self._hint(
"Monter un tunnel WireGuard demande des droits que l'application "
"n'a pas par défaut. Cette page dit lesquels manquent, et comment "
"éviter la fenêtre d'authentification qui s'ouvre sinon à chaque "
"connexion."
if not is_windows() else
"Installer ou retirer le service du tunnel demande les droits "
"administrateur. Cette page dit si le processus les possède, et "
"comment éviter la fenêtre UAC qui s'ouvre sinon à chaque "
"connexion."
))
if is_windows():
grp = QGroupBox("🛡️ Élévation Windows (UAC)")
g = QVBoxLayout(grp)
g.addWidget(QLabel(
"Installer ou retirer le service du tunnel exige les droits "
"administrateur.\nSans élévation, Windows ouvre une fenêtre "
"UAC à chaque connexion."
))
btn = QPushButton("🛡️ Vérifier l'élévation")
else:
grp = QGroupBox("🔐 Droits d'exécution (sudo)")
g = QVBoxLayout(grp)
g.addWidget(QLabel(
"Sans règle sudo, chaque connexion ouvre un dialogue "
"d'authentification."
))
btn = QPushButton("🔐 Vérifier les droits")
btn.setStyleSheet(theme.primary_button_style())
btn.clicked.connect(self._check_privileges)
g.addWidget(btn)
self._priv_result = QTextEdit()
self._priv_result.setReadOnly(True)
self._priv_result.setMinimumHeight(220)
self._priv_result.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
self._priv_result.setStyleSheet(theme.result_view_style())
g.addWidget(self._priv_result, 1)
v.addWidget(grp, 1)
return page
def _refresh_profile_list(self):
self._profile_list.clear()
active = self._cfg.active_profile
for name in self._cfg.list_profiles():
marker = "★" if name == active else " "
self._profile_list.addItem(f"{marker} {name}")
def _save_profile(self):
from PyQt6.QtWidgets import QInputDialog
self._save_values()
name, ok = QInputDialog.getText(self, "Sauvegarder le profil",
"Nom du profil :")
if not ok or not name.strip():
return
self._cfg.save_profile(name.strip())
self._refresh_profile_list()
QMessageBox.information(self, "Profil sauvegardé",
f"Profil « {name.strip()} » sauvegardé.")
def _load_profile(self):
item = self._profile_list.currentItem()
if not item:
return
name = item.text().lstrip("★ ").strip()
if self._cfg.load_profile(name):
self._load_values()
self._refresh_profile_list()
QMessageBox.information(self, "Profil chargé",
f"Profil « {name} » chargé.")
def _delete_profile(self):
item = self._profile_list.currentItem()
if not item:
return
name = item.text().lstrip("★ ").strip()
reply = QMessageBox.question(
self, "Confirmer",
f"Supprimer le profil « {name} » ?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
self._cfg.delete_profile(name)
self._refresh_profile_list()
# ------------------------------------------------------------------ #
# Onglet Paramètres
# ------------------------------------------------------------------ #
def _tab_settings(self) -> QWidget:
w, lay = self._dark_page("⚙️ Paramètres de l'application", theme.BANNER["settings"])
lay.addWidget(self._hint(
"Comportement de WGSecure lui-même : présence dans le systray, "
"démarrage automatique, surveillance et reconnexion du tunnel. "
"Rien ici ne modifie la configuration WireGuard."
))
grp1 = QGroupBox("Comportement")
form1 = QFormLayout(grp1)
self._chk_tray = QCheckBox("Réduire dans le systray à la fermeture")
self._chk_tray.setStyleSheet("color: white;")
self._chk_tray.setChecked(bool(self._cfg.get("ui", "minimize_to_tray")))
form1.addRow("", self._chk_tray)
self._chk_autostart = QCheckBox("Lancer WGSecure au démarrage du système")
self._chk_autostart.setStyleSheet("color: white;")
self._chk_autostart.setChecked(bool(self._cfg.get("ui", "autostart")))
form1.addRow("", self._chk_autostart)
self._chk_auto_connect = QCheckBox(
"Se connecter automatiquement au démarrage (profil actif)"
)
self._chk_auto_connect.setStyleSheet("color: white;")
self._chk_auto_connect.setChecked(
bool(self._cfg.get("ui", "auto_connect_on_startup"))
)
form1.addRow("", self._chk_auto_connect)
lay.addWidget(grp1)
grp2 = QGroupBox("Auto-reconnexion")
form2 = QFormLayout(grp2)
self._chk_autorecon = QCheckBox("Reconnecter automatiquement si le tunnel tombe")
self._chk_autorecon.setStyleSheet("color: white;")
self._chk_autorecon.setChecked(bool(self._cfg.get("ui", "auto_reconnect")))
form2.addRow("", self._chk_autorecon)
self._spin_interval = QSpinBox()
self._spin_interval.setRange(10, 300)
self._spin_interval.setValue(int(self._cfg.get("ui", "reconnect_interval") or 30))
self._spin_interval.setSuffix(" s")
self._spin_interval.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._row(form2, "Intervalle :", self._spin_interval)
lay.addWidget(grp2)
btn_apply = QPushButton("✅ Appliquer les paramètres")
btn_apply.clicked.connect(self._apply_settings)
lay.addWidget(btn_apply)
lay.addStretch()
return w
def _apply_settings(self):
self._cfg.set("ui", "minimize_to_tray", self._chk_tray.isChecked())
self._cfg.set("ui", "auto_reconnect", self._chk_autorecon.isChecked())
self._cfg.set("ui", "reconnect_interval", self._spin_interval.value())
self._cfg.set("ui", "auto_connect_on_startup", self._chk_auto_connect.isChecked())
autostart = self._chk_autostart.isChecked()
if autostart != bool(self._cfg.get("ui", "autostart")):
self._cfg.set_autostart(autostart)
else:
self._cfg.save()
QMessageBox.information(self, "Paramètres", "Paramètres enregistrés.")
# ------------------------------------------------------------------ #
# Onglet Sécurité
# ------------------------------------------------------------------ #
def _tab_security(self) -> QWidget:
"""Sécurité : MFA et mot de passe administrateur.
Les deux vivaient dans des onglets de premier niveau séparés alors
qu'ils répondent à la même question — qui a le droit de se connecter
et d'administrer. Les regrouper libère aussi un onglet dans une barre
qui en comptait sept.
"""
w, lay = self._dark_page("🔒 Sécurité", theme.BANNER["security"])
lay.setContentsMargins(10, 10, 10, 0)
lay.addWidget(self._hint(
"Qui a le droit de monter le tunnel, et qui a le droit "
"d'administrer WGSecure."
))
sub = QTabWidget()
sub.setStyleSheet(theme.TABBAR_CSS)
sub.addTab(self._subtab_mfa(), "🔐 MFA (TOTP)")
sub.addTab(self._subtab_password(), "🔑 Mot de passe")
lay.addWidget(sub)
return w
def _subtab_password(self) -> QWidget:
page, lay = self._test_page()
lay.addWidget(self._hint(
"Protège l'accès à ce panneau. Sans mot de passe, n'importe qui "
"sur cette session peut lire la clé privée et modifier le tunnel."
))
note = QLabel(
"Définissez un mot de passe pour protéger l'accès au panneau Administrateur.\n"
"Laissez vide pour désactiver la protection."
)
note.setWordWrap(True)
note.setStyleSheet("color: rgba(255,255,255,0.8); font-size: 12px;")
lay.addWidget(note)
grp = QGroupBox("Mot de passe administrateur")
form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._admin_pw1 = QLineEdit()
self._admin_pw1.setEchoMode(QLineEdit.EchoMode.Password)
self._admin_pw1.setPlaceholderText("Nouveau mot de passe")
self._row(form, "Mot de passe :", self._admin_pw1)
self._admin_pw2 = QLineEdit()
self._admin_pw2.setEchoMode(QLineEdit.EchoMode.Password)
self._admin_pw2.setPlaceholderText("Confirmer")
self._row(form, "Confirmation :", self._admin_pw2)
btn_set_pw = QPushButton("Définir le mot de passe")
btn_set_pw.setStyleSheet(
theme.primary_button_style(theme.FAIL_SOLID, "#e74c3c")
)
btn_set_pw.clicked.connect(self._set_admin_password)
self._row(form, "", btn_set_pw)
lay.addWidget(grp)
lay.addStretch()
return page
# ------------------------------------------------------------------ #
# Onglet À propos
# ------------------------------------------------------------------ #
def _tab_about(self) -> QWidget:
w = QWidget()
w.setStyleSheet(theme.TAB_CSS)
w.setAutoFillBackground(True)
layout = QVBoxLayout(w)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(12)
layout.setContentsMargins(30, 24, 30, 24)
from app.ui.icons import icon_app
logo_lbl = QLabel()
logo_lbl.setPixmap(icon_app().pixmap(86, 86))
logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(logo_lbl)
name_lbl = QLabel("WGSecure")
name_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
f = QFont(); f.setPointSize(22); f.setBold(True)
name_lbl.setFont(f)
name_lbl.setStyleSheet("color: white;")
layout.addWidget(name_lbl)
version_lbl = QLabel("Version 0.5.0")
version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;")
layout.addWidget(version_lbl)
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: rgba(255,255,255,0.15);")
layout.addWidget(sep)
desc_lbl = QLabel(
"Interface graphique WireGuard avec surcouche MFA TOTP.\n"
"Compatible Linux et Windows."
)
desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
desc_lbl.setWordWrap(True)
desc_lbl.setStyleSheet("color: white; font-size: 13px;")
layout.addWidget(desc_lbl)
sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine)
sep2.setStyleSheet("color: rgba(255,255,255,0.15);")
layout.addWidget(sep2)
author_lbl = QLabel("Développé par")
author_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
author_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 12px;")
layout.addWidget(author_lbl)
brand_lbl = QLabel("JT-Tools by Johnny")
brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
fb = QFont(); fb.setPointSize(15); fb.setBold(True)
brand_lbl.setFont(fb)
brand_lbl.setStyleSheet("color: #5dade2;")
layout.addWidget(brand_lbl)
date_lbl = QLabel("Septembre 2026")
date_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
date_lbl.setStyleSheet("color: rgba(255,255,255,0.55); font-size: 12px;")
layout.addWidget(date_lbl)
layout.addStretch()
return w
# ------------------------------------------------------------------ #
# Chargement / sauvegarde
# ------------------------------------------------------------------ #
def _load_values(self):
wg = self._cfg.wg
self._srv_endpoint.setText(wg.get("server_endpoint", ""))
self._srv_port.setValue(int(wg.get("server_port", 51820)))
self._srv_pubkey.setText(wg.get("server_public_key", ""))
self._use_psk.setChecked(wg_core.uses_preshared_key(wg))
self._psk.setText(wg.get("preshared_key", ""))
self._iface_name.setText(wg.get("interface_name", "wgs0"))
self._client_addr.setText(wg.get("client_address", "10.8.0.2/24"))
self._dns.setText(wg.get("dns", "1.1.1.1"))
self._allowed_ips.setText(wg.get("allowed_ips", "0.0.0.0/0"))
self._keepalive.setValue(int(wg.get("keepalive", 25)))
self._mtu.setValue(int(wg.get("mtu") or 0))
self._priv_key.setText(wg.get("client_private_key", ""))
self._pub_key.setText(wg.get("client_public_key", ""))
self._mfa_enabled_cb.setChecked(self._cfg.mfa_enabled)
self._mfa_secret.setText(self._cfg.mfa_secret)
def _save_values(self):
self._cfg.set("wg", "server_endpoint", self._srv_endpoint.text().strip())
self._cfg.set("wg", "server_port", self._srv_port.value())
self._cfg.set("wg", "server_public_key", self._srv_pubkey.text().strip())
self._cfg.set("wg", "use_preshared_key", self._use_psk.isChecked())
self._cfg.set("wg", "preshared_key", self._psk.text().strip())
self._cfg.set("wg", "interface_name", self._iface_name.text().strip() or "wgs0")
self._cfg.set("wg", "client_address", self._client_addr.text().strip())
self._cfg.set("wg", "dns", self._dns.text().strip())
self._cfg.set("wg", "allowed_ips", self._allowed_ips.text().strip())
self._cfg.set("wg", "keepalive", self._keepalive.value())
self._cfg.set("wg", "mtu", self._mtu.value())
self._cfg.set("wg", "client_private_key", self._priv_key.text().strip())
self._cfg.set("wg", "client_public_key", self._pub_key.text().strip())
self._cfg.mfa_enabled = self._mfa_enabled_cb.isChecked()
self._cfg.mfa_secret = self._mfa_secret.text().strip()
self._cfg.save()
def _save_and_close(self):
self._save_values()
QMessageBox.information(self, "Sauvegardé", "Configuration enregistrée avec succès.")
self.accept()
# ------------------------------------------------------------------ #
# Actions
# ------------------------------------------------------------------ #
def _import_conf(self):
from PyQt6.QtWidgets import QInputDialog
import os as _os
path, _ = QFileDialog.getOpenFileName(
self, "Importer un fichier WireGuard",
"", "WireGuard Config (*.conf);;Tous les fichiers (*)"
)
if not path:
return
values = wg_core.parse_conf_file(path)
if not values:
QMessageBox.warning(self, "Erreur", "Impossible de lire ce fichier .conf")
return
# Demander le nom du profil (défaut = nom du fichier sans extension)
default_name = _os.path.splitext(_os.path.basename(path))[0]
name, ok = QInputDialog.getText(
self, "Nom du profil",
"Nom du nouveau profil :",
text=default_name,
)
if not ok or not name.strip():
return
name = name.strip()
# Appliquer les valeurs dans les champs
mapping = {
"server_endpoint": self._srv_endpoint,
"server_public_key": self._srv_pubkey,
"client_address": self._client_addr,
"dns": self._dns,
"allowed_ips": self._allowed_ips,
"client_private_key": self._priv_key,
"client_public_key": self._pub_key,
"preshared_key": self._psk,
}
for k, widget in mapping.items():
if k in values:
widget.setText(str(values[k]))
# Un .conf sans directive PresharedKey décrit un serveur qui n'en
# utilise pas : l'option doit suivre, sinon l'import héritait de
# l'état affiché avant lui.
self._use_psk.setChecked(bool(values.get("use_preshared_key")))
if not values.get("use_preshared_key"):
self._psk.clear()
if "server_port" in values:
self._srv_port.setValue(int(values["server_port"]))
if "keepalive" in values:
self._keepalive.setValue(int(values["keepalive"]))
if "mtu" in values:
self._mtu.setValue(int(values["mtu"]))
# Sauvegarder en tant que nouveau profil
self._save_values()
self._cfg.save_profile(name)
self._refresh_profile_list()
QMessageBox.information(
self, "Import réussi",
f"Profil « {name} » créé depuis :\n{path}"
)
def _export_conf(self):
self._save_values()
path, _ = QFileDialog.getSaveFileName(
self, "Exporter la configuration WireGuard",
f"{self._cfg.wg.get('interface_name','wgs0')}.conf",
"WireGuard Config (*.conf)"
)
if not path:
return
ok, result = wg_core.export_conf_file(self._cfg, path)
if ok:
QMessageBox.information(self, "Export réussi", f"Config exportée :\n{result}")
else:
QMessageBox.warning(self, "Erreur", f"Export échoué :\n{result}")
def _generate_keys(self):
priv, pub = wg_core.generate_keypair()
self._priv_key.setText(priv)
self._pub_key.setText(pub)
self._refresh_preview()
QMessageBox.information(
self, "Clés générées",
"Nouvelle paire de clés générée.\n\n"
"⚠ Copiez la clé publique sur votre serveur WireGuard."
)
def _refresh_preview(self):
self._save_values()
self._config_preview.setPlainText(wg_core.build_client_config(self._cfg))
def _generate_mfa_secret(self):
secret = mfa_core.generate_secret()
self._mfa_secret.setText(secret)
self._show_qr()
def _show_qr(self):
secret = self._mfa_secret.text().strip()
if not secret:
QMessageBox.warning(self, "Erreur", "Aucun secret MFA renseigné.")
return
pixmap = mfa_core.generate_qr_pixmap(secret)
self._qr_label.setPixmap(
pixmap.scaled(210, 210, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
)
self._mfa_uri_label.setText(mfa_core.get_provisioning_uri(secret))
#: Rendu d'une étape de diagnostic : puce, couleur du libellé.
_STEP_STYLE = {
"ok": ("✅", "#a9dfbf"),
"warn": ("⚠️", "#f7dc6f"),
"fail": ("❌", "#f1948a"),
"skip": ("⏭️", "rgba(255,255,255,0.45)"),
}
def _run_test(self):
from PyQt6.QtWidgets import QApplication
self._save_values()
self._test_result.setHtml(
'<span style="color:rgba(255,255,255,0.6);">Diagnostic en cours…</span>'
)
self._test_result.setStyleSheet(theme.result_view_style("idle"))
QApplication.processEvents()
steps = wg_core.diagnose_connection(self._cfg)
self._test_result.setHtml(self._format_diagnostic(steps))
states = [s["state"] for s in steps]
if "fail" in states:
verdict = "fail"
elif "warn" in states or "skip" in states:
verdict = "warn"
else:
verdict = "ok"
self._test_result.setStyleSheet(theme.result_view_style(verdict))
def _format_diagnostic(self, steps: list[dict]) -> str:
"""Met en forme les étapes ; la première en échec porte le verdict."""
from html import escape
rows = []
for st in steps:
bullet, color = self._STEP_STYLE.get(st["state"], ("•", "white"))
weight = "bold" if st["state"] == "fail" else "normal"
detail = escape(st["detail"])
rows.append(
f'<div style="margin-bottom:5px;">'
f'<span style="color:{color}; font-weight:{weight};">'
f'{bullet}&nbsp;{escape(st["label"])}</span>'
f'<br><span style="color:rgba(255,255,255,0.65); font-size:11px;">'
f'&nbsp;&nbsp;&nbsp;&nbsp;{detail}</span></div>'
)
failed = next((s for s in steps if s["state"] == "fail"), None)
if failed:
verdict = (f'<div style="margin-bottom:9px; font-weight:bold;">'
f'⛔ Blocage : {escape(failed["label"])}</div>')
elif any(s["state"] == "skip" for s in steps):
verdict = ('<div style="margin-bottom:9px; font-weight:bold;">'
'➡️ Chaîne validée jusqu\'au tunnel — reste à le monter'
'</div>')
else:
verdict = ('<div style="margin-bottom:9px; font-weight:bold;">'
'✅ Chaîne de connexion complète</div>')
return verdict + "".join(rows)
def _export_qr(self):
"""Exporte la config client comme QR Code pour import mobile."""
self._save_values()
if not self._cfg.configured:
QMessageBox.warning(self, "Erreur", "Config WireGuard incomplète.")
return
from PyQt6.QtWidgets import QDialog, QLabel, QVBoxLayout
import qrcode, io
from PyQt6.QtGui import QImage, QPixmap
content = wg_core.build_client_config(self._cfg)
qr = qrcode.QRCode(box_size=5, border=2,
error_correction=qrcode.constants.ERROR_CORRECT_L)
qr.add_data(content)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, "PNG")
buf.seek(0)
pix = QPixmap.fromImage(QImage.fromData(buf.read()))
dlg = QDialog(self)
dlg.setWindowTitle("QR Code — Config WireGuard client")
dlg.setStyleSheet("background: #1c2833; color: white;")
v = QVBoxLayout(dlg)
lbl = QLabel()
lbl.setPixmap(pix.scaled(300, 300, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation))
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
note = QLabel("Scannez avec l'application WireGuard sur mobile.")
note.setAlignment(Qt.AlignmentFlag.AlignCenter)
note.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 11px;")
v.addWidget(lbl)
v.addWidget(note)
dlg.exec()
def _generate_server_config(self):
"""Génère et affiche une paire complète server + client."""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTabWidget, QTextEdit, QPushButton
result = wg_core.generate_server_config(
server_port=self._srv_port.value() if hasattr(self, '_srv_port') else 51820,
use_psk=self._use_psk.isChecked(),
)
dlg = QDialog(self)
dlg.setWindowTitle("Config générée — Server + Client")
dlg.setMinimumSize(600, 420)
dlg.setStyleSheet("background: #1c2833; color: white;")
v = QVBoxLayout(dlg)
tabs = QTabWidget()
tabs.setStyleSheet(
"QTabBar::tab { background:#2e4057; color:white; padding:6px 14px; }"
"QTabBar::tab:selected { background:#2471a3; }"
)
for title, content in [("🖥️ Serveur", result["server_conf"]),
("💻 Client", result["client_conf"])]:
te = QTextEdit()
te.setReadOnly(True)
te.setFont(QFont("Courier", 9))
te.setPlainText(content)
te.setStyleSheet(
"background: #17202a; color: #a8d8ea; border: none; font-size: 11px;"
)
tabs.addTab(te, title)
v.addWidget(tabs)
note = QLabel(
f"🔑 Clé pub. serveur : {result['server_pub'][:32]}\n"
f"🔑 Clé pub. client : {result['client_pub'][:32]}\n"
"⚠️ Remplacez <SERVER_IP> dans la config client par l'IP publique du serveur."
)
note.setWordWrap(True)
note.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 10px; padding: 6px;")
v.addWidget(note)
# Sans ce bouton, la PSK générée n'existait que dans ce texte : elle
# n'était reportée nulle part, et le tunnel monté avec les clés
# affichées ne négociait jamais avec le serveur.
btn_apply = QPushButton("📥 Appliquer le volet client à cette configuration")
btn_apply.setStyleSheet(
"QPushButton { background:#1e8449; color:white; border-radius:5px;"
" padding:7px 16px; border:none; } QPushButton:hover { background:#27ae60; }"
)
def _apply():
self._priv_key.setText(result["client_priv"])
self._pub_key.setText(result["client_pub"])
self._srv_pubkey.setText(result["server_pub"])
self._use_psk.setChecked(bool(result["psk"]))
self._psk.setText(result["psk"])
self._refresh_preview()
dlg.accept()
QMessageBox.information(
self, "Config client appliquée",
"Clés client, clé publique du serveur"
+ (" et clé pré-partagée" if result["psk"] else "")
+ " reportées dans la configuration.\n\n"
"⚠ Déployez le volet serveur sur la machine distante et "
"renseignez son adresse publique."
)
btn_apply.clicked.connect(_apply)
v.addWidget(btn_apply)
btn_close = QPushButton("Fermer")
btn_close.setStyleSheet(
"QPushButton { background:#2471a3; color:white; border-radius:5px;"
" padding:7px 16px; border:none; } QPushButton:hover { background:#1a5276; }"
)
btn_close.clicked.connect(dlg.accept)
v.addWidget(btn_close)
dlg.exec()
def _run_dns_test(self):
from PyQt6.QtWidgets import QApplication
self._dns_result.setPlainText("Analyse en cours…")
QApplication.processEvents()
r = wg_core.dns_leak_test(self._cfg)
lines = []
status = r["status"]
if status == "ok":
lines.append("✅ Aucune fuite DNS détectée")
state = "ok"
elif status == "leak":
lines.append("⚠️ Fuite DNS potentielle !")
state = "fail"
elif status == "down":
lines.append("🚫 Résolution DNS hors service — utilisez « Réparer le DNS »")
state = "fail"
elif not r.get("tunnel_up", False):
lines.append("️ Tunnel inactif : la comparaison n'a pas de sens")
state = "skip"
else:
lines.append("❓ Statut inconnu")
state = "skip"
if r["resolvers"]:
lines.append("Serveurs DNS actifs : " + " • ".join(r["resolvers"]))
if r["expected"]:
lines.append(f"DNS configuré (VPN) : {r['expected']}")
if r.get("probe_ip"):
lines.append(f"IP retournée par whoami.akamai.net : {r['probe_ip']}")
self._dns_result.setPlainText("\n".join(lines))
self._dns_result.setStyleSheet(theme.result_view_style(state))
def _check_privileges(self):
"""État des droits nécessaires pour monter le tunnel.
`privilege_report()` ne rend jamais de lignes sous Windows : la page
concluait alors « privilèges root, rien à configurer », un verdict qui
ne veut rien dire sur cette plateforme. Les deux cas sont désormais
traités séparément.
"""
from PyQt6.QtWidgets import QApplication
from app.utils.platform_utils import (
privilege_report, has_root_privileges, wg_quick_available,
wireguard_exe,
)
self._priv_result.setPlainText("Vérification en cours…")
QApplication.processEvents()
if is_windows():
lines, state = self._windows_privilege_lines(
has_root_privileges(), wg_quick_available(), wireguard_exe())
self._priv_result.setPlainText("\n".join(lines))
self._priv_result.setStyleSheet(theme.result_view_style(state))
return
rows = privilege_report()
if not rows:
self._priv_result.setPlainText(
"✅ Exécution avec les privilèges root : rien à configurer."
)
state = "ok"
else:
lines = [
("✅ " if ok else "⚠️ ") + f"{name}{role}"
+ ("" if ok else " (dialogue à chaque appel)")
for name, ok, role in rows
]
if any(not ok for _, ok, _ in rows):
lines.append("")
lines.append("Pour supprimer ces dialogues : make setup-sudoers")
state = "warn"
else:
state = "ok"
self._priv_result.setPlainText("\n".join(lines))
self._priv_result.setStyleSheet(theme.result_view_style(state))
@staticmethod
def _windows_privilege_lines(elevated: bool, wg_present: bool,
wg_path: str) -> tuple[list[str], str]:
"""Rapport d'élévation Windows. Séparé pour rester testable."""
lines: list[str] = []
if elevated:
lines.append("✅ Processus élevé (administrateur) — aucune "
"fenêtre UAC à la connexion.")
state = "ok"
else:
lines.append("⚠️ Processus non élevé : Windows demandera une "
"confirmation UAC à chaque")
lines.append(" connexion et déconnexion du tunnel.")
lines.append("")
lines.append("Pour l'éviter : lancez WGSecure via un raccourci "
"coché « Exécuter en tant")
lines.append("qu'administrateur », ou une tâche planifiée avec "
"les privilèges les plus élevés.")
state = "warn"
lines.append("")
if wg_present:
lines.append(f"✅ WireGuard installé — {wg_path}")
else:
lines.append("❌ wireguard.exe introuvable : installez WireGuard "
"for Windows.")
state = "fail"
return lines, state
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.setPlainText("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)
state = "ok"
elif actions:
msg = ("⚠️ Résidus retirés (" + ", ".join(actions) +
") mais la résolution échoue toujours.\n"
"Vérifiez la connexion réseau physique.")
state = "warn"
else:
msg = ("🚫 Aucun résidu du tunnel détecté et la résolution échoue :"
" le problème vient du réseau, pas de WGSecure.")
state = "fail"
self._dns_result.setPlainText(msg)
self._dns_result.setStyleSheet(theme.result_view_style(state))
def _set_admin_password(self):
p1 = self._admin_pw1.text()
p2 = self._admin_pw2.text()
if p1 != p2:
QMessageBox.warning(self, "Erreur", "Les mots de passe ne correspondent pas.")
return
self._cfg.set_admin_password(p1)
self._admin_pw1.clear()
self._admin_pw2.clear()
msg = "Mot de passe supprimé." if not p1 else "Mot de passe administrateur mis à jour."
QMessageBox.information(self, "Succès", msg)