Sécurité : la clé privée ne subsiste plus en clair dans %APPDATA%. Le .conf généré est écrasé puis supprimé dès que wireguard.exe l'a consommé (il en garde sa propre copie chiffrée DPAPI), et l'import propose d'effacer le fichier source. Windows uniquement : sous Linux /etc/wireguard/<iface>.conf reste requis par wg-quick down. Corrige aussi ImpersonateNamedPipeClient, qui appartient à win32security et non à win32pipe (vérifié par introspection). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2048 lines
91 KiB
Python
2048 lines
91 KiB
Python
import re
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
|
||
QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox,
|
||
QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame,
|
||
QFileDialog, QSizePolicy, QComboBox, 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.ui.worker import ValueWorker
|
||
from app.utils.platform_utils import (
|
||
is_windows, run_command, run_privileged, secure_delete,
|
||
)
|
||
|
||
class AdminWindow(QDialog):
|
||
def __init__(self, config: Config, parent=None):
|
||
super().__init__(parent)
|
||
self._cfg = config
|
||
# Sondes réseau (ping, `dig`, handshake, sudo…) : jusqu'à plusieurs
|
||
# secondes chacune. Lancées telles quelles depuis un clic, elles
|
||
# gelaient toute la fenêtre (« Ne répond plus ») le temps de leur
|
||
# exécution — ces workers les déportent hors du thread Qt.
|
||
self._diag_worker: ValueWorker | None = None
|
||
self._dns_worker: ValueWorker | None = None
|
||
self._dns_fix_worker: ValueWorker | None = None
|
||
self._resolve_worker: ValueWorker | None = None
|
||
self._fields_locked = False
|
||
self.setWindowTitle("WGSecure — Panneau Administrateur")
|
||
self.setWindowFlags(
|
||
Qt.WindowType.Dialog
|
||
| Qt.WindowType.WindowTitleHint
|
||
| Qt.WindowType.WindowCloseButtonHint
|
||
)
|
||
# Le découpage en sous-onglets a supprimé l'empilement qui imposait une
|
||
# grande fenêtre : chaque page n'affiche plus qu'une section à la fois.
|
||
self.setMinimumSize(640, 520)
|
||
self.resize(720, 640)
|
||
self._build_ui()
|
||
self._load_values()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 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(theme.banner_style(banner_color))
|
||
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(theme.banner_style(theme.BG_SUNKEN))
|
||
layout.addWidget(banner)
|
||
|
||
# Onglets de premier niveau : config qui conditionne la connexion
|
||
# (WireGuard, Sécurité) d'abord, diagnostic et réglages d'appli
|
||
# ensuite, référence en dernier.
|
||
tabs = QTabWidget()
|
||
tabs.setStyleSheet(theme.TABBAR_CSS)
|
||
tabs.addTab(self._tab_wireguard(), "WireGuard")
|
||
tabs.addTab(self._tab_security(), "Sécurité")
|
||
tabs.addTab(self._tab_test(), "Test connexion")
|
||
tabs.addTab(self._tab_settings(), "Paramètres")
|
||
tabs.addTab(self._tab_about(), "À propos")
|
||
layout.addWidget(tabs)
|
||
|
||
# Barre de boutons
|
||
bar = QWidget()
|
||
bar.setStyleSheet(f"background: {theme.BG_SUNKEN};")
|
||
btn_row = QHBoxLayout(bar)
|
||
btn_row.setContentsMargins(12, 8, 12, 10)
|
||
|
||
btn_cancel = QPushButton("Fermer sans sauvegarder")
|
||
btn_cancel.setStyleSheet(theme.secondary_button_style())
|
||
btn_cancel.clicked.connect(self.reject)
|
||
|
||
btn_save = QPushButton("Enregistrer et fermer")
|
||
btn_save.setStyleSheet(theme.primary_button_style())
|
||
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(f"color: {theme.TEXT_MUTED}; 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.
|
||
|
||
Tunnel et Profils vivaient dans deux sous-onglets séparés alors
|
||
qu'ils décrivaient le même objet sous deux formes (champs éditables
|
||
d'un côté, aperçu texte en lecture seule de l'autre) — l'un
|
||
redisait ce que l'autre montrait déjà. Fusionnés : le profil actif,
|
||
les échanges de fichier et les champs du tunnel vivent maintenant
|
||
sur une seule page, dans l'ordre où on les utilise (quel profil →
|
||
quel fichier → quelles valeurs). Les clés restent à part : leur
|
||
cycle de vie (génération, régénération) est distinct.
|
||
"""
|
||
w, lay = self._dark_page("🛡️ WireGuard", theme.BANNER["wireguard"])
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
|
||
sub = QTabWidget()
|
||
sub.setStyleSheet(theme.TABBAR_CSS)
|
||
sub.addTab(self._subtab_tunnel(), "🔗 Tunnel & profils")
|
||
sub.addTab(self._subtab_keys(), "🔑 Clés")
|
||
lay.addWidget(sub)
|
||
|
||
self._refresh_profile_list()
|
||
return w
|
||
|
||
# ── Sous-onglet : tunnel & profils ────────────────────────────────────
|
||
def _subtab_tunnel(self) -> QWidget:
|
||
page, lay = self._scroll_page()
|
||
lay.addWidget(self._hint(
|
||
"Quel profil est actif, quel fichier l'a produit ou en sort, puis "
|
||
"les valeurs du tunnel lui-même. 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."
|
||
))
|
||
|
||
# Bandeau de verrouillage : posé après l'import d'un .conf, pour
|
||
# qu'une modification à la main ne diverge pas silencieusement du
|
||
# fichier fourni par l'administrateur du serveur.
|
||
self._lock_banner = QWidget()
|
||
self._lock_banner.setStyleSheet(
|
||
f"background: {theme.WARN_BG}; border-radius: 4px;"
|
||
)
|
||
lb = QHBoxLayout(self._lock_banner)
|
||
lb.setContentsMargins(10, 6, 10, 6)
|
||
lb_text = QLabel("🔒 Champs verrouillés — configuration importée depuis un .conf")
|
||
lb_text.setWordWrap(True)
|
||
lb_text.setStyleSheet(f"color: {theme.WARN_TEXT}; font-size: 11px; background: transparent;")
|
||
lb.addWidget(lb_text, 1)
|
||
# Pas de setFixedHeight ici : `secondary_button_style()` compte un
|
||
# padding vertical de 9px (18px cumulés) — une hauteur figée à 24px
|
||
# ailleurs dans ce fichier ne laisserait que 6px pour le texte, et le
|
||
# tronquerait. Tous les autres boutons utilisant ce style se passent
|
||
# de hauteur fixe pour la même raison.
|
||
btn_unlock = QPushButton("🔓 Déverrouiller")
|
||
btn_unlock.setStyleSheet(theme.secondary_button_style())
|
||
btn_unlock.clicked.connect(lambda: self._set_fields_locked(False))
|
||
lb.addWidget(btn_unlock)
|
||
self._lock_banner.setVisible(False)
|
||
lay.addWidget(self._lock_banner)
|
||
|
||
# ── Profil & fichier : deux barres compactes plutôt qu'un second
|
||
# formulaire — ce qu'elles pilotent (les champs ci-dessous) ne se
|
||
# répète pas ici.
|
||
profile_row = QHBoxLayout()
|
||
profile_row.setSpacing(6)
|
||
profile_lbl = QLabel("Profil :")
|
||
profile_lbl.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;")
|
||
profile_row.addWidget(profile_lbl)
|
||
self._profile_select = QComboBox()
|
||
self._profile_select.setFixedHeight(26)
|
||
profile_row.addWidget(self._profile_select, 1)
|
||
btn_load_p = QPushButton("✅ Charger")
|
||
btn_load_p.setFixedHeight(26)
|
||
btn_load_p.clicked.connect(self._load_profile)
|
||
btn_save_p = QPushButton("💾 Enregistrer sous…")
|
||
btn_save_p.setFixedHeight(26)
|
||
btn_save_p.clicked.connect(self._save_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: {theme.FAIL_HOVER}; }}"
|
||
)
|
||
btn_del_p.clicked.connect(self._delete_profile)
|
||
profile_row.addWidget(btn_load_p)
|
||
profile_row.addWidget(btn_save_p)
|
||
profile_row.addWidget(btn_del_p)
|
||
lay.addLayout(profile_row)
|
||
|
||
file_row = QHBoxLayout()
|
||
file_row.setSpacing(6)
|
||
btn_preview = QPushButton("👁 Aperçu")
|
||
btn_preview.setToolTip("Montre le fichier exact qui sera écrit à la connexion.")
|
||
btn_preview.clicked.connect(self._show_config_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)
|
||
file_row.addWidget(b)
|
||
lay.addLayout(file_row)
|
||
|
||
sep = QFrame()
|
||
sep.setFrameShape(QFrame.Shape.HLine)
|
||
sep.setStyleSheet(f"background: {theme.BORDER_SOFT}; border: none; max-height: 1px;")
|
||
lay.addWidget(sep)
|
||
|
||
# ── 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._srv_endpoint.setToolTip(
|
||
"Adresse IP ou nom d'hôte du serveur WireGuard. "
|
||
"Si vous utilisez un nom, assurez-vous qu'il est résolvable."
|
||
)
|
||
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._srv_pubkey.setToolTip(
|
||
"Clé publique du serveur (format base64). "
|
||
"Doit correspondre exactement à la clé publique du serveur."
|
||
)
|
||
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."
|
||
)
|
||
self._show_psk_btn = QPushButton("Afficher")
|
||
self._show_psk_btn.setFixedSize(70, 28)
|
||
self._show_psk_btn.setCheckable(True)
|
||
self._show_psk_btn.toggled.connect(
|
||
lambda checked: self._psk.setEchoMode(
|
||
QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password
|
||
)
|
||
)
|
||
self._gen_psk_btn = QPushButton("Générer")
|
||
self._gen_psk_btn.setFixedSize(70, 28)
|
||
self._gen_psk_btn.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(self._show_psk_btn)
|
||
psk_row.addWidget(self._gen_psk_btn)
|
||
self._row(form, "Valeur :", psk_row)
|
||
|
||
# Le champ et ses boutons suivent la case : rien à saisir tant que
|
||
# l'option est décochée.
|
||
self._use_psk.toggled.connect(self._sync_psk_enabled)
|
||
self._sync_psk_enabled(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._iface_name.setToolTip(
|
||
"Nom de l'interface réseau WireGuard. "
|
||
"Doit être unique et correspondre au nom du service sous Windows."
|
||
)
|
||
self._row(form2, "Nom interface :", self._iface_name)
|
||
|
||
self._client_addr = QLineEdit()
|
||
self._client_addr.setPlaceholderText("10.8.0.2/24")
|
||
self._client_addr.setToolTip(
|
||
"Adresse IP du client dans le réseau VPN. "
|
||
"Format: IP/Masque (ex: 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._dns.setToolTip(
|
||
"Serveur DNS principal utilisé par le tunnel. "
|
||
"Laisser vide si vous utilisez le split-DNS ou le DNS système."
|
||
)
|
||
self._row(form2, "DNS :", self._dns)
|
||
|
||
# Split-DNS : serveur dédié pour les noms du réseau distant
|
||
self._split_dns_server = QLineEdit()
|
||
self._split_dns_server.setPlaceholderText("192.168.1.210")
|
||
self._split_dns_server.setToolTip(
|
||
"Serveur DNS dédié pour résoudre les noms du réseau distant. "
|
||
"Laisser vide pour utiliser le DNS par défaut."
|
||
)
|
||
self._row(form2, "DNS pour réseau distant :", self._split_dns_server)
|
||
|
||
self._split_dns_domains = QLineEdit()
|
||
self._split_dns_domains.setPlaceholderText("local, internal")
|
||
self._split_dns_domains.setToolTip(
|
||
"Domaines à résoudre via le DNS distant (séparés par des virgules). "
|
||
"Ex: local, internal. Laisser vide pour désactiver le split-DNS."
|
||
)
|
||
self._row(form2, "Domaines distants :", self._split_dns_domains)
|
||
|
||
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.setToolTip(
|
||
"Intervalle (en secondes) pour envoyer un paquet keepalive. "
|
||
"0 = désactivé. Utile pour maintenir la connexion active à travers un NAT."
|
||
)
|
||
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 à 1280–1420 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()
|
||
|
||
# Champs verrouillés après import d'un .conf. Le split-DNS et le nom
|
||
# d'interface en restent exclus : ce sont des réglages propres à
|
||
# WGSecure, absents du fichier importé, donc jamais écrasés par lui.
|
||
self._locked_fields = [
|
||
self._srv_endpoint, self._srv_port, self._srv_pubkey, self._use_psk,
|
||
self._client_addr, self._dns, self._allowed_ips,
|
||
self._keepalive, self._mtu,
|
||
]
|
||
return page
|
||
|
||
def _sync_psk_enabled(self, checked: bool):
|
||
for wdg in (self._psk, self._show_psk_btn, self._gen_psk_btn):
|
||
wdg.setEnabled(checked)
|
||
|
||
def _set_fields_locked(self, locked: bool):
|
||
"""Verrouille (ou libère) les champs issus d'un import .conf.
|
||
|
||
Éditer ces champs à la main après import diverge silencieusement du
|
||
fichier fourni par l'administrateur du serveur — le même risque que
|
||
documentent leurs info-bulles individuelles (port, IP ou clé qui ne
|
||
produisent aucune erreur visible, juste un handshake qui n'arrive
|
||
jamais).
|
||
"""
|
||
self._fields_locked = locked
|
||
for wdg in self._locked_fields:
|
||
wdg.setEnabled(not locked)
|
||
if locked:
|
||
self._psk.setEnabled(False)
|
||
self._show_psk_btn.setEnabled(False)
|
||
self._gen_psk_btn.setEnabled(False)
|
||
else:
|
||
self._sync_psk_enabled(self._use_psk.isChecked())
|
||
self._lock_banner.setVisible(locked)
|
||
if hasattr(self, "_priv_key"):
|
||
self._priv_key.setEnabled(not locked)
|
||
self._show_priv_btn.setEnabled(not locked)
|
||
self._btn_gen_keys.setEnabled(not locked)
|
||
self._btn_gen_keys.setToolTip(
|
||
"Déverrouillez les champs dans l'onglet Tunnel pour régénérer les clés."
|
||
if locked else ""
|
||
)
|
||
|
||
# ── 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.setToolTip(
|
||
"Clé privée de ce client (format base64). "
|
||
"À garder SECRETE ! La clé publique est dérivée automatiquement."
|
||
)
|
||
self._priv_key.setFixedHeight(28)
|
||
self._show_priv_btn = QPushButton("Afficher")
|
||
self._show_priv_btn.setFixedSize(70, 28)
|
||
self._show_priv_btn.setCheckable(True)
|
||
self._show_priv_btn.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(self._show_priv_btn)
|
||
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)
|
||
|
||
self._btn_gen_keys = QPushButton("Générer une nouvelle paire de clés")
|
||
self._btn_gen_keys.setFixedHeight(28)
|
||
self._btn_gen_keys.clicked.connect(self._generate_keys)
|
||
self._row(form, "", self._btn_gen_keys)
|
||
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
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 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(f"color: {theme.TEXT}; font-weight: bold;")
|
||
lay.addWidget(self._mfa_enabled_cb)
|
||
|
||
sep = QFrame()
|
||
sep.setFrameShape(QFrame.Shape.HLine)
|
||
sep.setStyleSheet(f"color: {theme.BORDER_SOFT};")
|
||
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(190)
|
||
v2.addWidget(self._qr_label)
|
||
self._mfa_uri_label = QLabel("")
|
||
self._mfa_uri_label.setWordWrap(True)
|
||
self._mfa_uri_label.setStyleSheet(f"font-size: 9px; color: {theme.TEXT_FAINT};")
|
||
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.
|
||
|
||
Les droits d'exécution (sudo/UAC) ont migré vers l'onglet Sécurité :
|
||
ce n'est pas un diagnostic de connexion mais un réglage d'accès, au
|
||
même titre que le MFA ou le mot de passe administrateur.
|
||
"""
|
||
w, lay = self._dark_page("📡 Tests & diagnostic", theme.BANNER["test"])
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
|
||
sub = QTabWidget()
|
||
sub.setStyleSheet(theme.TABBAR_CSS)
|
||
sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion")
|
||
sub.addTab(self._subtab_dns(), "🔍 Fuite DNS")
|
||
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."
|
||
))
|
||
self._btn_diag_test = QPushButton("🚀 Lancer le test de connexion")
|
||
self._btn_diag_test.setStyleSheet(theme.primary_button_style())
|
||
self._btn_diag_test.clicked.connect(self._run_test)
|
||
g.addWidget(self._btn_diag_test)
|
||
|
||
# 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(200)
|
||
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."))
|
||
self._btn_dns_test = QPushButton("🔍 Analyser le DNS")
|
||
self._btn_dns_test.setStyleSheet(theme.primary_button_style())
|
||
self._btn_dns_test.clicked.connect(self._run_dns_test)
|
||
g.addWidget(self._btn_dns_test)
|
||
self._btn_dns_fix = QPushButton("🛠️ Réparer le DNS (tunnel arrêté)")
|
||
self._btn_dns_fix.setToolTip(
|
||
"Retire la configuration DNS laissée par un tunnel arrêté "
|
||
"brutalement, qui empêche toute résolution de noms."
|
||
)
|
||
self._btn_dns_fix.clicked.connect(self._repair_dns)
|
||
g.addWidget(self._btn_dns_fix)
|
||
|
||
self._dns_result = QTextEdit()
|
||
self._dns_result.setReadOnly(True)
|
||
self._dns_result.setMinimumHeight(150)
|
||
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)
|
||
|
||
# Résolution d'un nom précis : « résolution DNS fonctionnelle » (la
|
||
# sonde générique ci-dessus) ne dit rien sur le split-DNS lui-même —
|
||
# un nom du réseau distant peut se résoudre vers une réponse
|
||
# publique inattendue au lieu du réseau attendu. Le test de chaîne
|
||
# de connexion vérifie déjà automatiquement le(s) domaine(s)
|
||
# configuré(s) ; ce champ permet de tester n'importe quel nom précis
|
||
# (un hôte particulier, une forme courte via le suffixe de recherche…).
|
||
domains = self._cfg.wg.get("split_dns_domains") or []
|
||
example = f"hôte.{domains[0]}" if domains else "hôte.domaine.exemple"
|
||
grp2 = QGroupBox("🌐 Résoudre un nom précis")
|
||
g2 = QVBoxLayout(grp2)
|
||
g2.addWidget(QLabel(
|
||
f"Ex. : {example}, ou juste le nom court s'il est dans le "
|
||
"suffixe de recherche du réseau distant."
|
||
))
|
||
resolve_row = QHBoxLayout()
|
||
resolve_row.setSpacing(6)
|
||
self._resolve_input = QLineEdit()
|
||
self._resolve_input.setPlaceholderText(example)
|
||
self._resolve_input.returnPressed.connect(self._run_resolve_host)
|
||
resolve_row.addWidget(self._resolve_input, 1)
|
||
self._btn_resolve = QPushButton("🔍 Résoudre")
|
||
self._btn_resolve.clicked.connect(self._run_resolve_host)
|
||
resolve_row.addWidget(self._btn_resolve)
|
||
g2.addLayout(resolve_row)
|
||
self._resolve_result = QLabel("")
|
||
self._resolve_result.setWordWrap(True)
|
||
self._resolve_result.setStyleSheet(theme.result_style("idle"))
|
||
g2.addWidget(self._resolve_result)
|
||
v.addWidget(grp2)
|
||
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")
|
||
btn.setStyleSheet(theme.primary_button_style())
|
||
btn.clicked.connect(self._check_privileges)
|
||
g.addWidget(btn)
|
||
|
||
# Bouton pour configurer les permissions de l'utilisateur actuel
|
||
self._btn_grant_permissions = QPushButton(
|
||
"🔧 Autoriser l'utilisateur à gérer ses tunnels"
|
||
)
|
||
self._btn_grant_permissions.setStyleSheet(theme.secondary_button_style())
|
||
self._btn_grant_permissions.clicked.connect(self._grant_wireguard_permissions)
|
||
self._btn_grant_permissions.setToolTip(
|
||
"Configure les permissions pour permettre à l'utilisateur actuel de "
|
||
"créer et supprimer des tunnels WireGuard sans UAC. Nécessite des droits admin."
|
||
)
|
||
g.addWidget(self._btn_grant_permissions)
|
||
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")
|
||
|
||
if not is_windows():
|
||
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(150)
|
||
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):
|
||
# `userData` porte le nom brut du profil : plus de préfixe (★/espaces)
|
||
# à retirer à la main pour retrouver ce qui a été sélectionné.
|
||
self._profile_select.blockSignals(True)
|
||
self._profile_select.clear()
|
||
active = self._cfg.active_profile
|
||
for name in self._cfg.list_profiles():
|
||
marker = "★ " if name == active else " "
|
||
self._profile_select.addItem(f"{marker}{name}", name)
|
||
idx = self._profile_select.findData(active)
|
||
if idx >= 0:
|
||
self._profile_select.setCurrentIndex(idx)
|
||
self._profile_select.blockSignals(False)
|
||
|
||
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):
|
||
name = self._profile_select.currentData()
|
||
if not name:
|
||
return
|
||
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):
|
||
name = self._profile_select.currentData()
|
||
if not name:
|
||
return
|
||
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.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.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.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.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, mot de passe administrateur et droits d'exécution.
|
||
|
||
Les trois répondent à la même question — qui (ou quoi) a le droit de
|
||
se connecter et d'administrer. Les droits sudo/UAC y ont rejoint MFA
|
||
et mot de passe : ce n'est pas un test de connexion, c'est un réglage
|
||
d'accès du même ordre.
|
||
"""
|
||
w, lay = self._dark_page("🔒 Sécurité", theme.BANNER["security"])
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
|
||
sub = QTabWidget()
|
||
sub.setStyleSheet(theme.TABBAR_CSS)
|
||
sub.addTab(self._subtab_mfa(), "🔐 MFA (TOTP)")
|
||
sub.addTab(self._subtab_password(), "🔑 Mot de passe")
|
||
sub.addTab(self._subtab_privileges(), self._privileges_tab_title())
|
||
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(f"color: {theme.TEXT_MUTED}; 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, theme.FAIL_HOVER)
|
||
)
|
||
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:
|
||
"""Page « À propos », en cinq sous-onglets (modèle QuiOulé/TuxMarket).
|
||
|
||
Le contenu (fonctions, garanties, pile technique, auteur) vit dans
|
||
`app/app_info.py`, séparé de sa présentation ici — le rendre
|
||
modifiable sans toucher au Qt. Le journal est lu depuis
|
||
CHANGELOG.md à l'affichage, jamais recopié.
|
||
"""
|
||
from app import app_info
|
||
w, lay = self._dark_page("ℹ️ À propos", theme.BANNER["about"])
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(0)
|
||
|
||
lay.addWidget(self._about_header(app_info))
|
||
|
||
sub = QTabWidget()
|
||
sub.setStyleSheet(theme.TABBAR_CSS)
|
||
sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions")
|
||
sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties")
|
||
sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique")
|
||
sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal")
|
||
sub.addTab(self._subtab_about_author(app_info), "👤 Auteur")
|
||
lay.addWidget(sub)
|
||
return w
|
||
|
||
# ── En-tête identité : icône, nom, accroche, pastilles d'état ────────
|
||
def _about_header(self, info) -> QWidget:
|
||
from app.ui.icons import icon_app
|
||
box = QWidget()
|
||
box.setStyleSheet(f"background: {theme.BG_RAISED};")
|
||
v = QVBoxLayout(box)
|
||
v.setContentsMargins(20, 16, 20, 14)
|
||
v.setSpacing(6)
|
||
v.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
logo = QLabel()
|
||
logo.setPixmap(icon_app().pixmap(72, 72))
|
||
logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
v.addWidget(logo)
|
||
|
||
name = QLabel(info.APP_NAME)
|
||
name.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
nf = QFont(); nf.setPointSize(18); nf.setBold(True)
|
||
name.setFont(nf)
|
||
name.setStyleSheet(f"color: {theme.TEXT};")
|
||
v.addWidget(name)
|
||
|
||
tagline = QLabel(info.TAGLINE)
|
||
tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
tagline.setWordWrap(True)
|
||
tagline.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;")
|
||
v.addWidget(tagline)
|
||
|
||
pills = QWidget()
|
||
pills.setStyleSheet("background: transparent;")
|
||
ph = QHBoxLayout(pills)
|
||
ph.setContentsMargins(0, 4, 0, 0)
|
||
ph.setSpacing(8)
|
||
ph.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
for text, color in self._about_pills(info):
|
||
pill = QLabel(text)
|
||
pill.setStyleSheet(
|
||
f"background: {color}; color: {theme.TEXT}; border-radius: 9px;"
|
||
" padding: 3px 10px; font-size: 11px; font-weight: bold;"
|
||
)
|
||
ph.addWidget(pill)
|
||
v.addWidget(pills)
|
||
return box
|
||
|
||
def _about_pills(self, info) -> list[tuple[str, str]]:
|
||
"""Pastilles de version/licence, plus l'état réel de MFA et des
|
||
droits sudo — lu en direct, jamais une valeur supposée."""
|
||
from app.utils.platform_utils import (
|
||
has_root_privileges, is_windows, privilege_report,
|
||
)
|
||
pills = [
|
||
(f"v{info.VERSION}", theme.ACCENT),
|
||
(info.LICENSE, "#5b6b7a"),
|
||
]
|
||
if self._cfg.mfa_enabled:
|
||
pills.append(("🔐 MFA activée", theme.OK_SOLID))
|
||
else:
|
||
pills.append(("🔐 MFA désactivée", "#5b6b7a"))
|
||
|
||
if not is_windows():
|
||
if has_root_privileges():
|
||
pills.append(("⚠️ Exécuté en root", theme.WARN_SOLID))
|
||
else:
|
||
rows = privilege_report()
|
||
if rows and all(ok for _, ok, _ in rows):
|
||
pills.append(("✅ sudoers configuré", theme.OK_SOLID))
|
||
elif rows:
|
||
pills.append(("⚠️ dialogue à chaque appel", theme.WARN_SOLID))
|
||
return pills
|
||
|
||
# ── Carte partagée : titre/icône + corps, sur fond distinct de la page ──
|
||
def _about_card(self, body: str, title: str = "", icon: str = "") -> QWidget:
|
||
"""Un item de liste À propos, posé sur sa propre carte.
|
||
|
||
Fonctions, Garanties et Technique s'affichaient comme une simple
|
||
liste de libellés à 11-12px sur le fond de page, sans rien pour
|
||
distinguer un item du suivant — dense au point d'être difficile à
|
||
parcourir. Chaque item vit maintenant sur sa propre carte, avec sa
|
||
marge : l'œil sait où il commence et où il finit.
|
||
"""
|
||
card = QWidget()
|
||
card.setStyleSheet(f"background: {theme.BG_RAISED}; border-radius: 6px;")
|
||
outer = QHBoxLayout(card)
|
||
outer.setContentsMargins(12, 10, 12, 10)
|
||
outer.setSpacing(9)
|
||
if icon:
|
||
ic = QLabel(icon)
|
||
ic.setStyleSheet(f"color: {theme.TEXT}; font-size: 14px; background: transparent;")
|
||
ic.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||
outer.addWidget(ic)
|
||
col = QVBoxLayout()
|
||
col.setSpacing(3)
|
||
if title:
|
||
t = QLabel(title)
|
||
t.setWordWrap(True)
|
||
t.setStyleSheet(f"color: {theme.TEXT}; font-size: 13px; font-weight: bold; background: transparent;")
|
||
col.addWidget(t)
|
||
b = QLabel(body)
|
||
b.setWordWrap(True)
|
||
b.setStyleSheet(
|
||
f"color: {theme.TEXT_MUTED if title else theme.TEXT};"
|
||
" font-size: 12px; background: transparent;"
|
||
)
|
||
col.addWidget(b)
|
||
outer.addLayout(col, 1)
|
||
return card
|
||
|
||
def _about_card_list(self, items: list[QWidget]) -> QWidget:
|
||
"""Zone défilante empilant des cartes, espacées pour rester lisibles."""
|
||
area = QScrollArea()
|
||
area.setWidgetResizable(True)
|
||
area.setStyleSheet(theme.scrollarea_style())
|
||
inner = QWidget()
|
||
inner.setStyleSheet(f"background: {theme.BG};")
|
||
iv = QVBoxLayout(inner)
|
||
iv.setContentsMargins(0, 2, 8, 2)
|
||
iv.setSpacing(10)
|
||
for item in items:
|
||
iv.addWidget(item)
|
||
iv.addStretch()
|
||
area.setWidget(inner)
|
||
return area
|
||
|
||
# ── Sous-onglet : fonctions ───────────────────────────────────────────
|
||
def _subtab_about_features(self, info) -> QWidget:
|
||
page, v = self._test_page()
|
||
cards = [self._about_card(desc, title=title) for title, desc in info.FEATURES]
|
||
v.addWidget(self._about_card_list(cards))
|
||
return page
|
||
|
||
# ── Sous-onglet : garanties ───────────────────────────────────────────
|
||
def _subtab_about_guarantees(self, info) -> QWidget:
|
||
page, v = self._test_page()
|
||
v.addWidget(self._hint(info.GUARANTEES_INTRO))
|
||
cards = [self._about_card(item, icon="✅") for item in info.GUARANTEES]
|
||
v.addWidget(self._about_card_list(cards))
|
||
return page
|
||
|
||
# ── Sous-onglet : pile technique ──────────────────────────────────────
|
||
def _subtab_about_tech(self, info) -> QWidget:
|
||
page, v = self._test_page()
|
||
cards = [self._about_card(tool, title=layer) for layer, tool in info.TECH_STACK]
|
||
v.addWidget(self._about_card_list(cards))
|
||
return page
|
||
|
||
# ── Sous-onglet : journal (lu depuis CHANGELOG.md) ────────────────────
|
||
def _subtab_about_changelog(self, info) -> QWidget:
|
||
page, v = self._test_page()
|
||
view = QTextEdit()
|
||
view.setReadOnly(True)
|
||
view.setStyleSheet(theme.result_view_style("idle", font_size=12))
|
||
parts = []
|
||
for entry in info.parse_changelog():
|
||
# Marges généreuses entre versions et avant chaque section : les
|
||
# blocs se touchaient (2px), illisibles à la suite les uns des
|
||
# autres sans repère visuel entre deux versions.
|
||
parts.append(
|
||
f"<p style='color:{theme.ACCENT_LIGHT};font-weight:bold;"
|
||
f"font-size:14px;margin-top:14px;margin-bottom:6px'>{entry['title']}</p>"
|
||
)
|
||
for section in entry["sections"]:
|
||
parts.append(
|
||
f"<p style='color:{theme.TEXT};font-weight:bold;"
|
||
f"margin-bottom:2px'>{section['name']}</p>"
|
||
"<ul style='margin-top:0px; line-height:145%;'>"
|
||
)
|
||
for item in section["items"]:
|
||
parts.append(
|
||
"<li>" + re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", item) + "</li>"
|
||
)
|
||
parts.append("</ul>")
|
||
view.setHtml("".join(parts) or "<i>Aucun changelog trouvé.</i>")
|
||
v.addWidget(view)
|
||
return page
|
||
|
||
# ── Sous-onglet : auteur ───────────────────────────────────────────────
|
||
def _subtab_about_author(self, info) -> QWidget:
|
||
"""Carte auteur.
|
||
|
||
Bug réel, pas juste une histoire de couleur manquante : `card` pose
|
||
un style sans sélecteur (`background: …; border: 1px solid …;`).
|
||
Qt applique ce genre de règle au widget ET à tous ses descendants
|
||
qui ne redéfinissent pas eux-mêmes ces propriétés — chaque QLabel de
|
||
la carte héritait donc du contour arrondi de la carte elle-même,
|
||
rendu comme une pilule autour du texte. `theme.py` évite le piège en
|
||
scopant toujours ses règles à un sélecteur (`QLineEdit { … }`) ; ici,
|
||
faute de sélecteur possible sur un simple `QWidget`, chaque enfant
|
||
doit annuler `background`/`border` explicitement.
|
||
"""
|
||
page, v = self._test_page()
|
||
v.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
card = QWidget()
|
||
card.setFixedWidth(320)
|
||
card.setStyleSheet(
|
||
f"background: {theme.BG_RAISED}; border: 1px solid {theme.BORDER_SOFT};"
|
||
" border-radius: 8px;"
|
||
)
|
||
cv = QVBoxLayout(card)
|
||
cv.setContentsMargins(24, 18, 24, 18)
|
||
cv.setSpacing(6)
|
||
cv.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
initial = QLabel(info.AUTHOR_NAME[:1].upper())
|
||
initial.setFixedSize(56, 56)
|
||
initial.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
initial.setStyleSheet(
|
||
f"background: {theme.ACCENT}; color: {theme.TEXT}; border: none;"
|
||
" border-radius: 28px; font-size: 22px; font-weight: bold;"
|
||
)
|
||
cv.addWidget(initial, alignment=Qt.AlignmentFlag.AlignCenter)
|
||
|
||
name = QLabel(info.AUTHOR_NAME)
|
||
name.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
name.setStyleSheet(
|
||
f"color: {theme.TEXT}; font-size: 17px; font-weight: bold;"
|
||
" background: transparent; border: none;"
|
||
)
|
||
cv.addWidget(name)
|
||
|
||
role = QLabel(info.AUTHOR_ROLE)
|
||
role.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
role.setStyleSheet(
|
||
f"color: {theme.TEXT_MUTED}; font-size: 12px;"
|
||
" background: transparent; border: none;"
|
||
)
|
||
cv.addWidget(role)
|
||
|
||
sep = QFrame()
|
||
sep.setFrameShape(QFrame.Shape.HLine)
|
||
sep.setFixedWidth(80)
|
||
sep.setStyleSheet(f"background: {theme.BORDER_SOFT}; border: none;")
|
||
cv.addSpacing(2)
|
||
cv.addWidget(sep, alignment=Qt.AlignmentFlag.AlignCenter)
|
||
cv.addSpacing(2)
|
||
|
||
repo = QLineEdit(info.REPO_URL)
|
||
repo.setReadOnly(True)
|
||
repo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
repo.setStyleSheet(
|
||
f"QLineEdit {{ background: {theme.BG_SUNKEN}; color: {theme.TEXT_CODE};"
|
||
f" border: 1px solid {theme.BORDER_SOFT}; border-radius: 4px;"
|
||
" padding: 4px 6px; font-size: 11px; }"
|
||
)
|
||
cv.addWidget(repo)
|
||
|
||
disclosure = QLabel(
|
||
"Application développée avec l'assistance de l'IA, sous la "
|
||
f"direction et la supervision de {info.AUTHOR_NAME}."
|
||
)
|
||
disclosure.setWordWrap(True)
|
||
disclosure.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
disclosure.setStyleSheet(
|
||
f"color: {theme.TEXT_FAINT}; font-size: 11px;"
|
||
" background: transparent; border: none;"
|
||
)
|
||
cv.addWidget(disclosure)
|
||
|
||
v.addStretch()
|
||
v.addWidget(card, alignment=Qt.AlignmentFlag.AlignCenter)
|
||
v.addStretch()
|
||
return page
|
||
|
||
# ------------------------------------------------------------------ #
|
||
# 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))
|
||
|
||
# Split-DNS : réseau distant par défaut de ce déploiement (H3), tant
|
||
# qu'aucune valeur explicite n'a encore été saisie ou importée.
|
||
self._split_dns_server.setText(wg.get("split_dns_server") or "192.168.1.210")
|
||
domains = wg.get("split_dns_domains") or ["h3adm.lan"]
|
||
self._split_dns_domains.setText(", ".join(domains) if isinstance(domains, list) else domains)
|
||
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)
|
||
|
||
# Un chargement de profil repart d'une configuration de confiance
|
||
# (gérée par WGSecure) : seul un import .conf brut verrouille.
|
||
self._set_fields_locked(False)
|
||
|
||
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())
|
||
|
||
# Split-DNS
|
||
self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip())
|
||
domains_text = self._split_dns_domains.text().strip()
|
||
domains_list = [d.strip() for d in domains_text.split(",") if d.strip()] if domains_text else []
|
||
self._cfg.set("wg", "split_dns_domains", domains_list)
|
||
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()
|
||
# Un .conf importé décrit un pair déjà fonctionnel côté serveur :
|
||
# les champs se verrouillent pour qu'une modification ultérieure à
|
||
# la main ne diverge pas de lui sans avertissement.
|
||
self._set_fields_locked(True)
|
||
QMessageBox.information(
|
||
self, "Import réussi",
|
||
f"Profil « {name} » créé depuis :\n{path}\n\n"
|
||
"🔒 Les champs sont verrouillés pour éviter une modification "
|
||
"accidentelle — déverrouillez-les dans l'onglet Tunnel pour les "
|
||
"éditer."
|
||
)
|
||
self._offer_source_shred(path)
|
||
|
||
def _offer_source_shred(self, path: str):
|
||
"""Propose d'effacer le .conf source une fois importé.
|
||
|
||
Ce fichier porte la clé privée en clair : le laisser traîner dans
|
||
Téléchargements ou sur le Bureau annule une bonne part de l'intérêt
|
||
de l'application. La suppression est proposée plutôt qu'imposée —
|
||
c'est un fichier que l'utilisateur a choisi lui-même, et le détruire
|
||
sans demander serait irrattrapable si l'import s'avérait incorrect.
|
||
"""
|
||
reply = QMessageBox.question(
|
||
self, "Effacer le fichier source ?",
|
||
f"Le fichier importé contient votre clé privée en clair :\n{path}\n\n"
|
||
"Il n'est plus nécessaire : la configuration est enregistrée dans "
|
||
"le profil.\n\nL'effacer maintenant ?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
if secure_delete(path):
|
||
QMessageBox.information(self, "Fichier effacé",
|
||
f"Fichier source supprimé :\n{path}")
|
||
else:
|
||
QMessageBox.warning(
|
||
self, "Suppression impossible",
|
||
f"Le fichier n'a pas pu être supprimé :\n{path}\n\n"
|
||
"Supprimez-le manuellement : il contient votre clé privée.")
|
||
|
||
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._save_values()
|
||
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 _show_config_preview(self):
|
||
"""Aperçu à la demande du fichier .conf exact — plus dupliqué en
|
||
permanence à côté des champs qu'il ne fait que redire."""
|
||
self._save_values()
|
||
content = wg_core.build_client_config(self._cfg)
|
||
|
||
dlg = QDialog(self)
|
||
dlg.setWindowTitle("Aperçu — fichier de configuration client")
|
||
dlg.setMinimumSize(480, 360)
|
||
dlg.setStyleSheet(f"background: {theme.BG}; color: {theme.TEXT};")
|
||
v = QVBoxLayout(dlg)
|
||
|
||
view = QTextEdit()
|
||
view.setReadOnly(True)
|
||
view.setFont(QFont("Courier", 9))
|
||
view.setPlainText(content)
|
||
view.setStyleSheet(
|
||
f"background: {theme.BG_SUNKEN}; color: {theme.TEXT_CODE};"
|
||
" border: none; font-size: 11px;"
|
||
)
|
||
v.addWidget(view)
|
||
|
||
btn_close = QPushButton("Fermer")
|
||
btn_close.setStyleSheet(theme.secondary_button_style())
|
||
btn_close.clicked.connect(dlg.accept)
|
||
v.addWidget(btn_close)
|
||
dlg.exec()
|
||
|
||
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": ("✅", theme.OK_TEXT),
|
||
"warn": ("⚠️", theme.WARN_TEXT),
|
||
"fail": ("❌", theme.FAIL_TEXT),
|
||
"skip": ("⏭️", theme.SKIP_TEXT),
|
||
}
|
||
|
||
def _run_test(self):
|
||
if self._diag_worker is not None:
|
||
return
|
||
self._save_values()
|
||
self._test_result.setHtml(
|
||
f'<span style="color:{theme.TEXT_MUTED};">Diagnostic en cours…</span>'
|
||
)
|
||
self._test_result.setStyleSheet(theme.result_view_style("idle"))
|
||
self._btn_diag_test.setEnabled(False)
|
||
|
||
self._diag_worker = ValueWorker(wg_core.diagnose_connection, self._cfg, parent=self)
|
||
self._diag_worker.done.connect(self._on_diag_done)
|
||
self._diag_worker.finished.connect(self._diag_worker.deleteLater)
|
||
self._diag_worker.start()
|
||
|
||
def _on_diag_done(self, steps: list[dict] | None):
|
||
self._diag_worker = None
|
||
self._btn_diag_test.setEnabled(True)
|
||
steps = steps or []
|
||
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"], ("•", theme.TEXT))
|
||
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} {escape(st["label"])}</span>'
|
||
f'<br><span style="color:{theme.TEXT_MUTED}; font-size:11px;">'
|
||
f' {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):
|
||
# « Chaîne validée » auparavant : le mot laissait croire que tout
|
||
# avait été vérifié, alors que les étapes sautées sont justement
|
||
# celles qui échouent quand un tunnel refuse de négocier.
|
||
skipped = ", ".join(s["label"] for s in steps
|
||
if s["state"] == "skip")
|
||
verdict = ('<div style="margin-bottom:9px; font-weight:bold;">'
|
||
'➡️ Rien de bloquant détecté, mais le tunnel est '
|
||
'inactif</div>'
|
||
'<div style="margin-bottom:9px;">Non vérifié faute de '
|
||
f'tunnel monté : {escape(skipped)}. Connectez-vous, '
|
||
'puis relancez ce test.</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(f"background: {theme.BG}; color: {theme.TEXT};")
|
||
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(f"color: {theme.TEXT_MUTED}; 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(f"background: {theme.BG}; color: {theme.TEXT};")
|
||
v = QVBoxLayout(dlg)
|
||
tabs = QTabWidget()
|
||
tabs.setStyleSheet(theme.TABBAR_CSS)
|
||
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(
|
||
f"background: {theme.BG_SUNKEN}; color: {theme.TEXT_CODE};"
|
||
" 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(f"color: {theme.TEXT_MUTED}; 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(theme.primary_button_style())
|
||
|
||
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._save_values()
|
||
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(theme.secondary_button_style())
|
||
btn_close.clicked.connect(dlg.accept)
|
||
v.addWidget(btn_close)
|
||
dlg.exec()
|
||
|
||
def _run_dns_test(self):
|
||
if self._dns_worker is not None:
|
||
return
|
||
self._dns_result.setPlainText("Analyse en cours…")
|
||
self._btn_dns_test.setEnabled(False)
|
||
|
||
self._dns_worker = ValueWorker(wg_core.dns_leak_test, self._cfg, parent=self)
|
||
self._dns_worker.done.connect(self._on_dns_test_done)
|
||
self._dns_worker.finished.connect(self._dns_worker.deleteLater)
|
||
self._dns_worker.start()
|
||
|
||
def _on_dns_test_done(self, r: dict | None):
|
||
self._dns_worker = None
|
||
self._btn_dns_test.setEnabled(True)
|
||
r = r or {"status": "unknown", "resolvers": [], "expected": ""}
|
||
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 _run_resolve_host(self):
|
||
host = self._resolve_input.text().strip()
|
||
if not host or self._resolve_worker is not None:
|
||
return
|
||
self._resolve_result.setText("Résolution en cours…")
|
||
self._resolve_result.setStyleSheet(theme.result_style("idle"))
|
||
self._btn_resolve.setEnabled(False)
|
||
|
||
self._resolve_worker = ValueWorker(dns_util.resolve_host, host, parent=self)
|
||
self._resolve_worker.done.connect(
|
||
lambda result: self._on_resolve_host_done(host, result)
|
||
)
|
||
self._resolve_worker.finished.connect(self._resolve_worker.deleteLater)
|
||
self._resolve_worker.start()
|
||
|
||
def _on_resolve_host_done(self, host: str, result):
|
||
self._resolve_worker = None
|
||
self._btn_resolve.setEnabled(True)
|
||
ok, ips = result if result else (False, [])
|
||
if ok:
|
||
self._resolve_result.setText(f"✅ {host} → " + ", ".join(ips))
|
||
self._resolve_result.setStyleSheet(theme.result_style("ok"))
|
||
else:
|
||
self._resolve_result.setText(f"❌ {host} : échec de résolution")
|
||
self._resolve_result.setStyleSheet(theme.result_style("fail"))
|
||
|
||
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():
|
||
split_dns_active = bool(
|
||
(self._cfg.wg.get("split_dns_server") or "").strip()
|
||
and (self._cfg.wg.get("split_dns_domains") or [])
|
||
)
|
||
lines, state = self._windows_privilege_lines(
|
||
has_root_privileges(), wg_quick_available(), wireguard_exe(),
|
||
split_dns_active)
|
||
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,
|
||
split_dns_active: bool = False) -> 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é : si les permissions ACL du "
|
||
"service n'ont pas été")
|
||
lines.append(" accordées (bouton ci-dessous), Windows "
|
||
"demandera une confirmation UAC")
|
||
lines.append(" à chaque connexion et déconnexion du tunnel.")
|
||
lines.append("")
|
||
lines.append("Pour l'éviter : accordez les permissions ACL, ou "
|
||
"lancez WGSecure via un")
|
||
lines.append("raccourci coché « Exécuter en tant "
|
||
"qu'administrateur ».")
|
||
state = "warn"
|
||
|
||
if split_dns_active:
|
||
lines.append("")
|
||
lines.append("⚠️ Split-DNS configuré : les règles NRPT "
|
||
"(Add/Remove-DnsClientNrptRule) exigent")
|
||
lines.append(" toujours des droits administrateur — "
|
||
"aucune ACL ne permet de l'éviter. Une")
|
||
lines.append(" authentification admin séparée reste donc "
|
||
"demandée à chaque connexion")
|
||
lines.append(" et déconnexion tant que le split-DNS est "
|
||
"actif, même une fois les")
|
||
lines.append(" permissions ci-dessus accordées.")
|
||
if state == "ok":
|
||
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.
|
||
"""
|
||
if self._dns_fix_worker is not None:
|
||
return
|
||
self._dns_result.setPlainText("Réparation en cours…")
|
||
self._btn_dns_fix.setEnabled(False)
|
||
|
||
self._dns_fix_worker = ValueWorker(self._do_repair_dns, parent=self)
|
||
self._dns_fix_worker.done.connect(self._on_repair_dns_done)
|
||
self._dns_fix_worker.finished.connect(self._dns_fix_worker.deleteLater)
|
||
self._dns_fix_worker.start()
|
||
|
||
def _do_repair_dns(self) -> tuple[list[str], bool]:
|
||
"""Corps bloquant de `_repair_dns` — exécuté hors du thread Qt.
|
||
|
||
`force_cleanup` élève ses privilèges (sudo/askpass/pkexec) et peut
|
||
donc rester en attente d'une saisie utilisateur.
|
||
"""
|
||
actions = wg_core.force_cleanup(self._cfg)
|
||
works = dns_util.resolution_works()
|
||
return actions, works
|
||
|
||
def _on_repair_dns_done(self, result: tuple[list[str], bool] | None):
|
||
self._dns_fix_worker = None
|
||
self._btn_dns_fix.setEnabled(True)
|
||
actions, works = result if result else ([], False)
|
||
|
||
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)
|
||
|
||
def _grant_wireguard_permissions(self):
|
||
"""Configure les ACLs du service WireGuard pour permettre à l'utilisateur actuel
|
||
de gérer ses propres tunnels sans UAC.
|
||
|
||
Nécessite des droits administrateur.
|
||
"""
|
||
if not is_windows():
|
||
return
|
||
|
||
msg_box = QMessageBox(self)
|
||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||
msg_box.setWindowTitle("Confirmer l'opération administrative")
|
||
msg_box.setText(
|
||
"Cette opération va configurer les permissions du service WireGuard "
|
||
"pour permettre à l'utilisateur actuel de créer et supprimer des tunnels "
|
||
"sans invite UAC.\n\n"
|
||
"⚠️ Nécessite des droits administrateur et redémarre le service WireGuard."
|
||
)
|
||
msg_box.setStandardButtons(
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||
)
|
||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||
|
||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||
return
|
||
|
||
# Commande PowerShell pour modifier les ACLs du service WireGuard
|
||
# Ajoute à l'utilisateur actuel les droits de créer/supprimer des services WireGuardTunnel*
|
||
ps_script = r'''
|
||
$ErrorActionPreference = 'Stop'
|
||
$serviceName = "WireGuard"
|
||
$currentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||
|
||
# Récupérer la SDDL actuelle au format texte natif : "sc sdshow", pas WMI.
|
||
# Win32_Service.GetSecurityDescriptor().Descriptor.DACL est un TABLEAU
|
||
# d'objets Win32_ACE — .ToString() dessus ne produit pas une chaîne SDDL
|
||
# valide (ça donnait un texte type "System.Management.ManagementBaseObject[]",
|
||
# jamais vérifié ni signalé), donc "sc sdset" échouait silencieusement et
|
||
# le bouton annonçait un succès alors que l'ACL n'était jamais posée —
|
||
# d'où une invite d'élévation qui persistait pour le tunnel en plus de
|
||
# celle, inévitable, du split-DNS.
|
||
$sdshowLines = & sc.exe sdshow $serviceName
|
||
$currentSddl = ($sdshowLines | Where-Object { $_ -like 'D:*' } | Select-Object -First 1)
|
||
if (-not $currentSddl) {
|
||
throw "Lecture de la SDDL actuelle de '$serviceName' impossible (sc sdshow)"
|
||
}
|
||
$currentSddl = $currentSddl.Trim()
|
||
|
||
# Insérer l'ACE de l'utilisateur actuel à l'intérieur de la clause D:
|
||
# existante, jamais en l'ajoutant après toute la chaîne : la SDDL d'un
|
||
# service porte souvent aussi une clause S: (SACL — étiquette d'intégrité
|
||
# obligatoire notamment, ex. "S:(ML;;NW;;;LW)"). Un simple "+" à la fin
|
||
# insérait alors l'ACE *après* le S:, produisant une SDDL syntaxiquement
|
||
# invalide (erreur 1804, ConvertStringSecurityDescriptorToSecurityDescriptor)
|
||
# — d'où l'échec systématique constaté malgré une lecture correcte de la
|
||
# SDDL actuelle.
|
||
# CC = Create Child (créer des services enfants)
|
||
# LC = List Child (lister les services enfants)
|
||
# SW = Self Write (modifier ses propres services)
|
||
# LO = List Object (lister)
|
||
# CR = Control (contrôle)
|
||
if ($currentSddl -notmatch '^(D:[A-Z]*)((?:\([^)]*\))*)(S:.*)?$') {
|
||
throw "Format de SDDL inattendu pour '$serviceName' : $currentSddl"
|
||
}
|
||
$fullSddl = $matches[1] + $matches[2] + "(A;;CCLCSWLOCRRC;;;$currentUserSid)" + $matches[3]
|
||
|
||
$setOutput = & sc.exe sdset $serviceName $fullSddl
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "sc sdset a échoué (code $LASTEXITCODE) : $setOutput"
|
||
}
|
||
|
||
# Redémarrer le service pour que la nouvelle ACL soit prise en compte par
|
||
# le pipe IPC déjà ouvert (une SDDL mise à jour n'est pas relue par un
|
||
# process déjà démarré). Best-effort seulement : Windows refuse d'arrêter
|
||
# le service tant qu'un tunnel est actif (WireGuardTunnel$<nom> en dépend),
|
||
# et l'ACL/le registre ont déjà été appliqués à ce stade — un redémarrage
|
||
# raté ne doit donc pas faire échouer toute l'opération, juste prévenir
|
||
# que l'effet ne sera visible qu'après déconnexion + redémarrage manuel du
|
||
# service (ou redémarrage de la machine).
|
||
try {
|
||
Restart-Service -Name $serviceName -Force -ErrorAction Stop
|
||
} catch {
|
||
Write-Warning "Redémarrage du service WireGuard impossible ($($_.Exception.Message)) : déconnectez tout tunnel actif puis redémarrez le service WireGuard (ou la machine) pour que les nouvelles permissions prennent effet."
|
||
}
|
||
|
||
# Mécanisme officiel WireGuard (distinct de l'ACL ci-dessus, qui ne couvre
|
||
# que le service) : autorise les membres du groupe "Network Configuration
|
||
# Operators" à démarrer/arrêter leurs tunnels sans élévation.
|
||
# https://github.com/WireGuard/wireguard-windows/blob/master/docs/adminregistry.md
|
||
New-Item -Path 'HKLM:\Software\WireGuard' -Force | Out-Null
|
||
Set-ItemProperty -Path 'HKLM:\Software\WireGuard' -Name 'LimitedOperatorUI' -Value 1 -Type DWord
|
||
|
||
# SID fixe S-1-5-32-556 = "Network Configuration Operators" : le nom du
|
||
# groupe est localisé (FR : "Opérateurs de configuration réseau"), -SID est
|
||
# indépendant de la langue du système.
|
||
try {
|
||
Add-LocalGroupMember -SID 'S-1-5-32-556' -Member $currentUserSid -ErrorAction Stop
|
||
} catch {
|
||
# Détecter "déjà membre" via l'identifiant d'erreur stable
|
||
# (FullyQualifiedErrorId), pas via le texte du message : celui-ci est
|
||
# localisé ("is already a member" / "est déjà membre" en français) et
|
||
# un test sur la chaîne anglaise ne reconnaissait jamais ce cas normal
|
||
# sur un Windows en français, faisant échouer le bouton à chaque
|
||
# nouvelle tentative alors que le groupe était déjà bien accordé.
|
||
if ($_.FullyQualifiedErrorId -notlike 'MemberExists,*') { throw }
|
||
}
|
||
|
||
Write-Output "Permissions configurées pour l'utilisateur $currentUserSid"
|
||
'''
|
||
|
||
self._btn_grant_permissions.setEnabled(False)
|
||
self._priv_result.setPlainText("Configuration des permissions en cours…")
|
||
|
||
# Exécuter avec élévation (nécessite admin)
|
||
code, out, err = run_privileged(
|
||
["powershell", "-NoProfile", "-NonInteractive", "-Command", ps_script],
|
||
timeout=30
|
||
)
|
||
|
||
self._btn_grant_permissions.setEnabled(True)
|
||
|
||
if code == 0:
|
||
self._priv_result.setPlainText(
|
||
f"✅ Permissions configurées avec succès !\n\n"
|
||
f"L'utilisateur actuel peut désormais gérer ses tunnels WireGuard "
|
||
f"sans invite UAC (ACL du service + groupe « Opérateurs de "
|
||
f"configuration réseau »).\n\n"
|
||
f"Détails : {out.strip()}"
|
||
)
|
||
self._priv_result.setStyleSheet(theme.result_view_style("ok"))
|
||
else:
|
||
error_msg = err.strip() or out.strip() or "Erreur inconnue"
|
||
self._priv_result.setPlainText(
|
||
f"❌ Échec de la configuration des permissions:\n\n{error_msg}"
|
||
)
|
||
self._priv_result.setStyleSheet(theme.result_view_style("fail"))
|