La v0.8.0 laissait cohabiter deux vérifications du code à 6 chiffres : la fenêtre MFA locale, qui contrôlait un code contre un secret détenu par le poste lui-même — et ne verrouillait donc que sa propre interface — puis la validation par le serveur, seule à décider de l'accès réseau. L'ancienne disparaît, avec le secret TOTP qu'elle stockait en clair dans config.json (effacé au premier lancement) et l'onglet MFA du panneau Administrateur. La saisie passe par une fenêtre dédiée qui relaie le refus exact du serveur et laisse réessayer, la requête partant dans un thread pour qu'un serveur muet ne fige pas l'application. Une ligne « Accès distant » distingue désormais l'état de l'autorisation de celui du tunnel : monté sans code validé, celui-ci ne mène nulle part. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2000 lines
89 KiB
Python
2000 lines
89 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.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
|
||
# Authentification auprès du serveur, après montage du tunnel.
|
||
self._vpn_username = QLineEdit()
|
||
self._vpn_username.setPlaceholderText("TestB")
|
||
self._vpn_username.setToolTip(
|
||
"Nom du compte VPN tel qu'il figure sur le serveur.\n"
|
||
"Il sert à valider votre code à 6 chiffres après la connexion.\n"
|
||
"Laissé vide, aucun code n'est demandé (serveur en mode permissif)."
|
||
)
|
||
self._row(form2, "Identifiant VPN :", self._vpn_username)
|
||
|
||
self._api_url = QLineEdit()
|
||
self._api_url.setPlaceholderText("déduit automatiquement")
|
||
self._api_url.setToolTip(
|
||
"URL du serveur WGSecure, joignable depuis le tunnel.\n"
|
||
"Laissée vide, elle est déduite de l'adresse du client\n"
|
||
"(10.6.0.6 → http://10.6.0.1:8000)."
|
||
)
|
||
self._row(form2, "Serveur d'authentification :", self._api_url)
|
||
|
||
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 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 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é : mot de passe administrateur et droits d'exécution.
|
||
|
||
Les deux répondent à la même question — qui a le droit d'administrer
|
||
ce poste. L'onglet MFA a disparu avec le secret TOTP local : le code
|
||
d'accès est désormais réglé sur le serveur, qui est le seul à le
|
||
connaître et le seul à ouvrir l'accès réseau.
|
||
"""
|
||
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_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 l'authentification
|
||
serveur 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.wg.get("vpn_username") or "").strip():
|
||
pills.append(("🔐 Code d'accès serveur", theme.OK_SOLID))
|
||
else:
|
||
pills.append(("🔐 Aucun code d'accès", "#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._vpn_username.setText(wg.get("vpn_username") or "")
|
||
self._api_url.setText(wg.get("api_url") or "")
|
||
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", ""))
|
||
|
||
# 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", "vpn_username", self._vpn_username.text().strip())
|
||
self._cfg.set("wg", "api_url", self._api_url.text().strip())
|
||
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.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()
|
||
|
||
#: 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"))
|