From 1ad1df361ef52b1a762fe131a0f23d9d2a69493d Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Tue, 1 Sep 2026 14:25:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20option=20cl=C3=A9=20pr=C3=A9-partag?= =?UTF-8?q?=C3=A9e=20et=20refonte=20des=20onglets=20du=20panneau=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La présence d'une clé pré-partagée se devinait au fait que le champ soit rempli. Un serveur qui n'en utilise pas — le cas par défaut de WireGuard — déclenchait donc un avertissement à chaque diagnostic, et une clé oubliée dans le champ finissait écrite dans la configuration alors que le serveur n'en attendait aucune, ce qui casse le handshake aussi sûrement qu'une clé manquante. Une case à cocher tranche désormais, et le diagnostic distingue les quatre états au lieu d'un avertissement unique. Le drapeau vaut None par défaut et non False : _merge() recopie le défaut dans toute configuration antérieure, et False aurait désactivé la PSK d'un tunnel qui fonctionnait. Refonte des onglets, qui passaient de sept à cinq : - WireGuard et Clés se chevauchaient — la clé publique du serveur se saisissait dans l'un pendant que l'assistant qui la produit vivait dans l'autre, et les quatre façons de sortir une configuration étaient réparties entre les deux. Fusionnés en sous-onglets Tunnel, Clés, Profils & fichiers. - Les trois tests s'empilaient sur une page unique où le rapport de diagnostic se retrouvait comprimé. Chacun a son sous-onglet. - MFA et Sécurité répondent à la même question et sont regroupés. - La section des droits parlait de sudo et de setup-sudoers sous Windows, où rien de tout cela n'existe, et concluait « privilèges root » sur un rapport simplement vide. Elle vérifie maintenant l'élévation UAC et WireGuard. Chaque onglet s'ouvre sur un encart expliquant ce qu'on y décide, et les couleurs — vingt-quatre valeurs en dur, cinq bandeaux sans rapport de teinte, des blocs de résultat au fond incohérent — passent par app/ui/theme.py. --- app/core/config.py | 12 +- app/core/wireguard.py | 74 +++- app/ui/admin_window.py | 789 ++++++++++++++++++++++++++--------------- app/ui/theme.py | 190 ++++++++++ 4 files changed, 757 insertions(+), 308 deletions(-) create mode 100644 app/ui/theme.py diff --git a/app/core/config.py b/app/core/config.py index bc1cf5e..e44cead 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -15,6 +15,16 @@ _WG_DEFAULT: dict[str, Any] = { "server_endpoint": "", "server_port": 51820, "server_public_key": "", + # Drapeau explicite : une PSK vide ne veut pas dire « oubliée ». Sans lui, + # le diagnostic criait au champ manquant sur tout serveur qui n'en utilise + # pas — c'est-à-dire la configuration WireGuard par défaut. + # + # Le défaut est `None`, pas `False` : `_merge()` recopie le défaut dans + # toute configuration antérieure au drapeau, et `False` aurait désactivé + # la clé pré-partagée d'un tunnel qui fonctionnait. `None` signifie + # « jamais renseigné » et laisse `uses_preshared_key()` déduire l'état de + # la présence de la clé. + "use_preshared_key": None, "preshared_key": "", "client_private_key": "", "client_public_key": "", @@ -26,7 +36,7 @@ _WG_DEFAULT: dict[str, Any] = { } _DEFAULT: dict[str, Any] = { - "version": "0.4.6", + "version": "0.5.0", "admin_password_hash": "", "admin_salt": "", "mfa_enabled": False, diff --git a/app/core/wireguard.py b/app/core/wireguard.py index eff8f3c..67a0c23 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -35,6 +35,19 @@ def generate_preshared_key() -> str: return base64.b64encode(os.urandom(32)).decode() +def uses_preshared_key(wg: dict) -> bool: + """La configuration met-elle une clé pré-partagée en jeu ? + + Le drapeau `use_preshared_key` fait foi. Une configuration importée avant + son introduction n'a que la clé : une PSK non vide vaut alors activation, + sinon un import de .conf perdrait sa PSK au premier enregistrement. + """ + if wg.get("use_preshared_key"): + return True + return bool(wg.get("use_preshared_key") is None + and (wg.get("preshared_key") or "").strip()) + + def build_client_config(cfg: Config) -> str: wg = cfg.wg lines = [ @@ -62,8 +75,12 @@ def build_client_config(cfg: Config) -> str: # existe, l'application affiche « Connecté » — et le handshake n'aboutit # jamais. La PSK était générée par l'assistant puis perdue, ni écrite dans # la config client ni relue à l'import d'un .conf. + # + # Symétriquement, écrire une PSK que le serveur n'attend pas casse le + # handshake tout aussi silencieusement : le drapeau tranche, la présence + # d'un reliquat dans le champ ne suffit pas. psk = (wg.get("preshared_key") or "").strip() - if psk: + if psk and uses_preshared_key(wg): lines.append(f"PresharedKey = {psk}") lines += [ f"AllowedIPs = {wg['allowed_ips']}", @@ -529,19 +546,36 @@ def _diag_config(cfg: Config) -> list[dict]: steps.append(_step(_OK, "Paire de clés du client", f"Clé publique déclarée au serveur : {derived[:20]}…")) + # L'ancien message avertissait sur toute PSK vide : sur un serveur qui + # n'en utilise pas — le cas par défaut de WireGuard — le diagnostic + # pointait un coupable inexistant à chaque exécution. psk = (wg.get("preshared_key") or "").strip() - if psk: + if not uses_preshared_key(wg): + if psk: + steps.append(_step( + _WARN, "Clé pré-partagée", + "Désactivée, mais une clé subsiste dans le champ : elle n'est " + "pas écrite dans la configuration. Cochez l'option si le " + "serveur l'attend.")) + else: + steps.append(_step( + _OK, "Clé pré-partagée", + "Non utilisée — le serveur ne doit pas en déclarer une pour " + "ce pair")) + elif not psk: + steps.append(_step( + _FAIL, "Clé pré-partagée", + "Option activée mais champ vide : le pair déclare une PSK au " + "serveur sans en fournir. Renseignez-la, ou décochez l'option si " + "le serveur n'en utilise pas.")) + else: try: valid = len(base64.b64decode(psk)) == 32 except Exception: valid = False steps.append(_step(_OK if valid else _FAIL, "Clé pré-partagée", - "Présente (doit être identique côté serveur)" if valid + "Activée (doit être identique côté serveur)" if valid else "Format invalide : 32 octets en base64 attendus")) - else: - steps.append(_step(_WARN, "Clé pré-partagée", - "Absente — à renseigner si le serveur en attend une, " - "sinon le handshake n'aboutira jamais")) return steps @@ -685,12 +719,19 @@ def _diag_tunnel(cfg: Config) -> list[dict]: + ("administrateur" if is_windows() else "root"))) return steps if age < 0: + if uses_preshared_key(wg): + psk_cause = ("clé pré-partagée différente de celle du serveur (ou " + "absente côté serveur)") + else: + psk_cause = ("clé pré-partagée attendue par le serveur alors que " + "l'option est décochée ici") steps.append(_step( _FAIL, "Handshake", "Jamais négocié. Le tunnel est monté mais le serveur ne répond " "pas à nos paquets. Causes usuelles : clé publique du client " - "absente côté serveur, clé publique du serveur erronée, clé " - "pré-partagée manquante ou différente, port UDP bloqué.")) + f"absente côté serveur, clé publique du serveur erronée, " + f"{psk_cause}, port UDP du serveur différent de celui configuré, " + "port UDP bloqué en chemin.")) return steps if age > 180: steps.append(_step(_WARN, "Handshake", @@ -811,8 +852,10 @@ def parse_conf_file(path: str) -> dict | None: result["server_public_key"] = val elif key == "PresharedKey": # Ignorée jusqu'ici : le .conf importé perdait sa PSK - # et le tunnel ne négociait plus rien. + # et le tunnel ne négociait plus rien. La présence de + # la directive vaut activation de l'option. result["preshared_key"] = val + result["use_preshared_key"] = True elif key == "Endpoint" and ":" in val: host, port = val.rsplit(":", 1) result["server_endpoint"] = host.strip("[]") @@ -964,14 +1007,19 @@ def generate_server_config( server_address: str = "10.8.0.1/24", client_address: str = "10.8.0.2/24", client_allowed_ips: str = "10.8.0.2/32", + use_psk: bool = True, ) -> dict: """ Génère une paire complète (server_conf, client_conf) avec de nouvelles clés. Retourne un dict avec server_priv/pub, client_priv/pub, server_conf, client_conf. + + `use_psk=False` produit les deux configurations sans clé pré-partagée : + l'assistant en imposait une des deux côtés, sans moyen de s'en passer. """ srv_priv, srv_pub = generate_keypair() cli_priv, cli_pub = generate_keypair() - psk = generate_preshared_key() + psk = generate_preshared_key() if use_psk else "" + psk_line = [f"PresharedKey = {psk}"] if use_psk else [] server_conf = "\n".join([ "[Interface]", @@ -984,7 +1032,7 @@ def generate_server_config( "# === Peer client ===", "[Peer]", f"PublicKey = {cli_pub}", - f"PresharedKey = {psk}", + *psk_line, f"AllowedIPs = {client_allowed_ips}", ]) @@ -996,7 +1044,7 @@ def generate_server_config( "", "[Peer]", f"PublicKey = {srv_pub}", - f"PresharedKey = {psk}", + *psk_line, "AllowedIPs = 10.8.0.0/24", "Endpoint = :" + str(server_port), "PersistentKeepalive = 25", diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 74903ff..211e3e9 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -2,7 +2,7 @@ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame, - QFileDialog, QSizePolicy, + QFileDialog, QSizePolicy, QListWidget, QScrollArea, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont @@ -11,70 +11,8 @@ 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 - -# CSS sombre commun à tous les onglets -_TAB_CSS = """ - QWidget { background-color: #1c2833; color: white; } - QLabel { color: white; background: transparent; } - QCheckBox { color: white; } - QGroupBox { - color: white; - border: 1px solid rgba(255,255,255,0.2); - border-radius: 5px; - margin-top: 10px; - font-weight: bold; - } - QGroupBox::title { - subcontrol-origin: margin; - left: 10px; - padding: 0 4px; - color: white; - } - QLineEdit { - background: rgba(255,255,255,0.1); - color: white; - border: 1px solid rgba(255,255,255,0.25); - border-radius: 4px; - padding: 4px 6px; - } - QLineEdit:read-only { background: rgba(255,255,255,0.06); } - QSpinBox { - background: rgba(255,255,255,0.1); - color: white; - border: 1px solid rgba(255,255,255,0.25); - border-radius: 4px; - padding: 3px 6px; - } - QSpinBox::up-button, QSpinBox::down-button { - background: rgba(255,255,255,0.15); - } - QTextEdit { - background: rgba(255,255,255,0.07); - color: #a8d8ea; - border: 1px solid rgba(255,255,255,0.2); - border-radius: 4px; - } - QPushButton { - background: #2471a3; - color: white; - border-radius: 5px; - padding: 7px 12px; - border: none; - } - QPushButton:hover { background: #1a5276; } - QPushButton:checked { background: #154360; } -""" - - -_RESULT_CSS = ( - "QTextEdit { border: 1px solid rgba(255,255,255,0.12); border-radius: 6px;" - " padding: 8px; font-size: 12px; }" - "QScrollBar:vertical { background: transparent; width: 8px; border: none; }" - "QScrollBar::handle:vertical { background: #2e4057; border-radius: 4px; }" - "QScrollBar::add-line:vertical," - "QScrollBar::sub-line:vertical { height: 0; }" -) - +from app.ui import theme +from app.utils.platform_utils import is_windows class AdminWindow(QDialog): def __init__(self, config: Config, parent=None): @@ -92,6 +30,9 @@ class AdminWindow(QDialog): self.resize(880, 740) self._build_ui() self._load_values() + # L'aperçu s'ouvrait vide : il fallait cliquer « Rafraîchir » pour + # voir la configuration que l'application allait pourtant écrire. + self._refresh_preview() # ------------------------------------------------------------------ # # Helper : page sombre avec bandeau coloré @@ -105,7 +46,7 @@ class AdminWindow(QDialog): diagnostic), sinon les widgets se compriment jusqu'à l'illisible. """ w = QWidget() - w.setStyleSheet(_TAB_CSS) + w.setStyleSheet(theme.TAB_CSS) outer = QVBoxLayout(w) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) @@ -124,19 +65,9 @@ class AdminWindow(QDialog): body_layout.setSpacing(10) if scroll: - from PyQt6.QtWidgets import QScrollArea - body.setStyleSheet("background: #1c2833;") area = QScrollArea() area.setWidgetResizable(True) - area.setStyleSheet( - "QScrollArea { border: none; background: #1c2833; }" - "QScrollBar:vertical { background: #1c2833; width: 8px;" - " border: none; }" - "QScrollBar::handle:vertical { background: #2e4057;" - " border-radius: 4px; }" - "QScrollBar::add-line:vertical," - "QScrollBar::sub-line:vertical { height: 0; }" - ) + area.setStyleSheet(theme.scrollarea_style()) area.setWidget(body) outer.addWidget(area) else: @@ -167,11 +98,9 @@ class AdminWindow(QDialog): QTabBar::tab:hover { background: #253545; color: white; } """) tabs.addTab(self._tab_wireguard(), "WireGuard") - tabs.addTab(self._tab_keys(), "Clés") - tabs.addTab(self._tab_mfa(), "MFA") tabs.addTab(self._tab_test(), "Test connexion") tabs.addTab(self._tab_settings(), "Paramètres") - tabs.addTab(self._tab_admin(), "Sécurité") + tabs.addTab(self._tab_security(), "Sécurité") tabs.addTab(self._tab_about(), "À propos") layout.addWidget(tabs) @@ -220,87 +149,75 @@ class AdminWindow(QDialog): widget.setFixedHeight(field_h) form.addRow(lbl, widget) - def _tab_wireguard(self) -> QWidget: - from PyQt6.QtWidgets import QScrollArea, QListWidget + @staticmethod + def _hint(text: str) -> QLabel: + """Encart expliquant à quoi sert l'onglet où il figure. - outer = QWidget() - outer.setStyleSheet(_TAB_CSS) - outer_lay = QVBoxLayout(outer) - outer_lay.setContentsMargins(0, 0, 0, 0) - outer_lay.setSpacing(0) + 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 - hdr = QLabel(" 🛡️ WireGuard — Configuration & Profils") - hdr.setFixedHeight(36) - hdr.setStyleSheet( - "background: #154360; color: white; font-size: 13px;" - " font-weight: bold; padding-left: 8px;" - ) - outer_lay.addWidget(hdr) + 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) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setStyleSheet( - "QScrollArea { border: none; background: #1c2833; }" - "QScrollBar:vertical { background: #1c2833; width: 8px; border: none; }" - "QScrollBar::handle:vertical { background: #2e4057; border-radius: 4px; }" - "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }" - ) - - content = QWidget() - content.setStyleSheet("background: #1c2833;") - lay = QVBoxLayout(content) + 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 - # ── Profils ────────────────────────────────────────────────────── - grp_p = QGroupBox("Profils") - gp = QVBoxLayout(grp_p) - gp.setSpacing(6) - gp.setContentsMargins(10, 14, 10, 10) + def _tab_wireguard(self) -> QWidget: + """Configuration WireGuard, en sous-onglets. - self._profile_list = QListWidget() - self._profile_list.setFixedHeight(72) - self._profile_list.setStyleSheet( - "QListWidget { background: rgba(255,255,255,0.07); color: white;" - " border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }" - "QListWidget::item:selected { background: #2471a3; }" - ) - gp.addWidget(self._profile_list) + « WireGuard » et « Clés » étaient deux onglets de premier niveau qui se + chevauchaient : la clé publique du serveur se saisissait dans l'un + pendant que l'assistant qui la produit vivait dans l'autre, et les + quatre façons de sortir une configuration (aperçu, .conf, QR Code, + volet serveur) étaient réparties entre les deux sans logique. Le + découpage suit maintenant ce qu'on manipule : les paramètres du + tunnel, les clés, puis les fichiers. + """ + w, lay = self._dark_page("🛡️ WireGuard", theme.BANNER["wireguard"]) + lay.setContentsMargins(10, 10, 10, 0) + lay.addWidget(self._hint( + "Tout ce qui définit le tunnel : les paramètres du pair, vos clés, " + "et les profils enregistrés. Les modifications ne prennent effet " + "qu'après « Enregistrer et fermer »." + )) - p_btn_row = QHBoxLayout() - p_btn_row.setSpacing(6) - btn_save_p = QPushButton("💾 Sauvegarder sous…") - btn_save_p.setFixedHeight(26) - btn_save_p.clicked.connect(self._save_profile) - btn_load_p = QPushButton("✅ Charger") - btn_load_p.setFixedHeight(26) - btn_load_p.clicked.connect(self._load_profile) - btn_del_p = QPushButton("🗑️ Supprimer") - btn_del_p.setFixedHeight(26) - btn_del_p.setStyleSheet( - "QPushButton { background: #6e2e1c; color: white; border-radius: 5px;" - " padding: 0 12px; border: none; }" - "QPushButton:hover { background: #922b21; }" - ) - btn_del_p.clicked.connect(self._delete_profile) - p_btn_row.addWidget(btn_save_p) - p_btn_row.addWidget(btn_load_p) - p_btn_row.addWidget(btn_del_p) - gp.addLayout(p_btn_row) - lay.addWidget(grp_p) + sub = QTabWidget() + sub.setStyleSheet(theme.TABBAR_CSS) + sub.addTab(self._subtab_tunnel(), "🔗 Tunnel") + sub.addTab(self._subtab_keys(), "🔑 Clés") + sub.addTab(self._subtab_profiles(), "📂 Profils & fichiers") + lay.addWidget(sub) - # ── Import / Export ────────────────────────────────────────────── - io_row = QHBoxLayout() - btn_import = QPushButton("📂 Importer un .conf") - btn_import.setFixedHeight(26) - btn_import.clicked.connect(self._import_conf) - btn_export = QPushButton("💾 Exporter en .conf") - btn_export.setFixedHeight(26) - btn_export.clicked.connect(self._export_conf) - io_row.addWidget(btn_import) - io_row.addWidget(btn_export) - io_row.addStretch() - lay.addLayout(io_row) + self._refresh_profile_list() + return w + + # ── Sous-onglet : paramètres du tunnel ─────────────────────────────── + def _subtab_tunnel(self) -> QWidget: + page, lay = self._scroll_page() + lay.addWidget(self._hint( + "Décrit le pair à joindre et la façon dont le trafic y entre. Ces " + "valeurs doivent correspondre exactement à ce que le serveur " + "déclare pour vous : une adresse, un port ou une clé qui divergent " + "ne produisent aucune erreur — le tunnel monte et le handshake " + "n'arrive jamais." + )) # ── Serveur ────────────────────────────────────────────────────── grp = QGroupBox("Serveur WireGuard") @@ -314,6 +231,11 @@ class AdminWindow(QDialog): 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) @@ -321,15 +243,52 @@ class AdminWindow(QDialog): self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") self._row(form, "Clé publique serveur :", self._srv_pubkey) + # Un champ vide ne disait pas si la PSK était oubliée ou volontairement + # absente : le diagnostic avertissait donc sur tout serveur qui n'en + # utilise pas, c'est-à-dire la configuration WireGuard par défaut. + self._use_psk = QCheckBox("Le serveur utilise une clé pré-partagée") + self._use_psk.setToolTip( + "À cocher uniquement si le serveur déclare une PresharedKey pour ce\n" + "pair. Une PSK écrite alors que le serveur n'en attend pas casse le\n" + "handshake aussi sûrement qu'une PSK manquante." + ) + self._row(form, "Clé pré-partagée :", self._use_psk) + self._psk = QLineEdit() self._psk.setEchoMode(QLineEdit.EchoMode.Password) - self._psk.setPlaceholderText("(vide si le serveur n'en utilise pas)") + 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._row(form, "Clé pré-partagée :", self._psk) + show_psk = QPushButton("Afficher") + show_psk.setFixedSize(70, 28) + show_psk.setCheckable(True) + show_psk.toggled.connect( + lambda checked: self._psk.setEchoMode( + QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password + ) + ) + btn_gen_psk = QPushButton("Générer") + btn_gen_psk.setFixedSize(70, 28) + btn_gen_psk.clicked.connect( + lambda: self._psk.setText(wg_core.generate_preshared_key()) + ) + psk_row = QHBoxLayout() + psk_row.setSpacing(6) + psk_row.addWidget(self._psk) + psk_row.addWidget(show_psk) + psk_row.addWidget(btn_gen_psk) + self._row(form, "Valeur :", psk_row) + + # Le champ et ses boutons suivent la case : rien à saisir tant que + # l'option est décochée. + def _sync_psk(checked: bool): + for wdg in (self._psk, show_psk, btn_gen_psk): + wdg.setEnabled(checked) + self._use_psk.toggled.connect(_sync_psk) + _sync_psk(False) lay.addWidget(grp) # ── Interface client ───────────────────────────────────────────── @@ -351,6 +310,11 @@ class AdminWindow(QDialog): 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() @@ -370,20 +334,19 @@ class AdminWindow(QDialog): self._row(form2, "MTU :", self._mtu) lay.addWidget(grp2) lay.addStretch() + return page - scroll.setWidget(content) - outer_lay.addWidget(scroll) + # ── 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." + )) - self._refresh_profile_list() - return outer - - # ------------------------------------------------------------------ # - # Onglet Clés - # ------------------------------------------------------------------ # - def _tab_keys(self) -> QWidget: - w, lay = self._dark_page("🔑 Gestion des clés Curve25519", "#1a3a52") - - grp = QGroupBox("Paire de clés client") + grp = QGroupBox("Paire de clés client (Curve25519)") form = QFormLayout(grp) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) @@ -408,6 +371,9 @@ class AdminWindow(QDialog): self._pub_key = QLineEdit() self._pub_key.setReadOnly(True) self._pub_key.setPlaceholderText("(dérivée automatiquement)") + self._pub_key.setToolTip( + "C'est cette clé que le serveur doit déclarer pour ce pair." + ) self._row(form, "Clé publique :", self._pub_key) btn_gen = QPushButton("Générer une nouvelle paire de clés") @@ -416,41 +382,111 @@ class AdminWindow(QDialog): self._row(form, "", btn_gen) lay.addWidget(grp) - grp2 = QGroupBox("Aperçu config WireGuard") + grp2 = QGroupBox("🖥️ Assistant : configuration serveur + client") + v2 = QVBoxLayout(grp2) + note = QLabel( + "Génère une paire complète prête à déployer : nouvelles clés des " + "deux côtés,\nclé pré-partagée si l'option est cochée dans " + "l'onglet Tunnel." + ) + note.setWordWrap(True) + note.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;") + v2.addWidget(note) + btn_gen_srv = QPushButton("⚡ Générer config serveur + client") + btn_gen_srv.clicked.connect(self._generate_server_config) + v2.addWidget(btn_gen_srv) + lay.addWidget(grp2) + lay.addStretch() + return page + + # ── Sous-onglet : profils et fichiers ──────────────────────────────── + def _subtab_profiles(self) -> QWidget: + page, lay = self._scroll_page() + lay.addWidget(self._hint( + "Conserve plusieurs configurations complètes et bascule de l'une à " + "l'autre. L'aperçu montre le fichier exact qui sera écrit au " + "moment de la connexion — c'est lui qu'on importe, exporte ou " + "transmet par QR Code." + )) + + grp_p = QGroupBox("Profils") + gp = QVBoxLayout(grp_p) + gp.setSpacing(6) + gp.setContentsMargins(10, 14, 10, 10) + + self._profile_list = QListWidget() + self._profile_list.setFixedHeight(96) + self._profile_list.setStyleSheet( + f"QListWidget {{ background: rgba(255,255,255,0.07); color: {theme.TEXT};" + f" border: 1px solid {theme.BORDER}; border-radius: 4px; }}" + f"QListWidget::item:selected {{ background: {theme.ACCENT}; }}" + ) + gp.addWidget(self._profile_list) + + p_btn_row = QHBoxLayout() + p_btn_row.setSpacing(6) + btn_save_p = QPushButton("💾 Sauvegarder sous…") + btn_save_p.setFixedHeight(26) + btn_save_p.clicked.connect(self._save_profile) + btn_load_p = QPushButton("✅ Charger") + btn_load_p.setFixedHeight(26) + btn_load_p.clicked.connect(self._load_profile) + btn_del_p = QPushButton("🗑️ Supprimer") + btn_del_p.setFixedHeight(26) + btn_del_p.setStyleSheet( + f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT};" + f" border-radius: 5px; padding: 0 12px; border: none; }}" + f"QPushButton:hover {{ background: #e74c3c; }}" + ) + btn_del_p.clicked.connect(self._delete_profile) + p_btn_row.addWidget(btn_save_p) + p_btn_row.addWidget(btn_load_p) + p_btn_row.addWidget(btn_del_p) + gp.addLayout(p_btn_row) + lay.addWidget(grp_p) + + # Aperçu, import, export et QR Code étaient dispersés entre les deux + # anciens onglets : ils décrivent pourtant le même objet, le fichier + # de configuration du pair. + grp2 = QGroupBox("Configuration générée") v2 = QVBoxLayout(grp2) self._config_preview = QTextEdit() self._config_preview.setReadOnly(True) self._config_preview.setFont(QFont("Courier", 9)) - self._config_preview.setFixedHeight(130) - v2.addWidget(self._config_preview) - btn_row2 = QHBoxLayout() + self._config_preview.setMinimumHeight(170) + self._config_preview.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + v2.addWidget(self._config_preview, 1) + + btn_row = QHBoxLayout() + btn_row.setSpacing(6) btn_preview = QPushButton("🔄 Rafraîchir") btn_preview.clicked.connect(self._refresh_preview) - btn_qr = QPushButton("📱 Exporter QR Code") + 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) - btn_row2.addWidget(btn_preview) - btn_row2.addWidget(btn_qr) - v2.addLayout(btn_row2) - lay.addWidget(grp2) - - # Config serveur - grp3 = QGroupBox("🖥️ Génération config serveur + client") - v3 = QVBoxLayout(grp3) - note = QLabel("Génère une paire complète prête à déployer (nouvelles clés, PSK).") - note.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 11px;") - v3.addWidget(note) - btn_gen_srv = QPushButton("⚡ Générer config serveur + client") - btn_gen_srv.clicked.connect(self._generate_server_config) - v3.addWidget(btn_gen_srv) - lay.addWidget(grp3) - lay.addStretch() - return w + for b in (btn_preview, btn_import, btn_export, btn_qr): + b.setFixedHeight(26) + btn_row.addWidget(b) + v2.addLayout(btn_row) + lay.addWidget(grp2, 1) + return page # ------------------------------------------------------------------ # # Onglet MFA # ------------------------------------------------------------------ # - def _tab_mfa(self) -> QWidget: - w, lay = self._dark_page("🔐 Authentification Multi-Facteurs (MFA / TOTP)", "#0e6655") + def _subtab_mfa(self) -> QWidget: + page, lay = self._test_page() + lay.addWidget(self._hint( + "Exige un code à usage unique avant chaque connexion. Le secret se " + "scanne dans Bitwarden ou toute application TOTP ; conservez-le " + "ailleurs qu'ici, il est le seul moyen de régénérer les codes." + )) self._mfa_enabled_cb = QCheckBox("Activer la vérification MFA avant connexion") self._mfa_enabled_cb.setStyleSheet("color: white; font-weight: bold;") @@ -487,31 +523,66 @@ class AdminWindow(QDialog): v2.addWidget(btn_show_qr) lay.addWidget(grp2) lay.addStretch() - return w + return page # ------------------------------------------------------------------ # # Onglet Test connexion # ------------------------------------------------------------------ # def _tab_test(self) -> QWidget: - w, lay = self._dark_page("📡 Test de connectivité réseau", "#1a4a7a", - scroll=True) + """Page de tests, découpée en sous-onglets. - # Test UDP serveur - grp1 = QGroupBox("Diagnostic de la chaîne de connexion") - g1 = QVBoxLayout(grp1) - g1.addWidget(QLabel( + Les trois tests s'empilaient sur une page unique : le rapport de + diagnostic, le plus long des trois, se retrouvait comprimé entre les + deux autres et la page réclamait un défilement permanent. Chacun + dispose maintenant de toute la hauteur. + """ + w, lay = self._dark_page("📡 Tests & diagnostic", theme.BANNER["test"]) + lay.setContentsMargins(10, 10, 10, 0) + lay.addWidget(self._hint( + "Trois vérifications indépendantes : où la chaîne de connexion se " + "rompt, si le DNS fuit hors du tunnel, et si les droits " + "nécessaires au montage sont réunis." + )) + + sub = QTabWidget() + sub.setStyleSheet(theme.TABBAR_CSS) + sub.addTab(self._subtab_diagnostic(), "🔗 Chaîne de connexion") + sub.addTab(self._subtab_dns(), "🔍 Fuite DNS") + sub.addTab(self._subtab_privileges(), self._privileges_tab_title()) + lay.addWidget(sub) + return w + + def _test_page(self) -> tuple[QWidget, QVBoxLayout]: + """Corps d'un sous-onglet de test : fond de page, marges homogènes.""" + page = QWidget() + page.setStyleSheet(theme.TAB_CSS) + v = QVBoxLayout(page) + v.setContentsMargins(14, 14, 14, 14) + v.setSpacing(10) + return page, v + + # ── Sous-onglet : chaîne de connexion ──────────────────────────────── + def _subtab_diagnostic(self) -> QWidget: + page, v = self._test_page() + v.addWidget(self._hint( + "Parcourt la chaîne complète, de la configuration locale jusqu'à " + "la résolution DNS dans le tunnel, et s'arrête sur la première " + "étape qui bloque. À lancer en premier quand la connexion échoue " + "sans message." + )) + + grp = QGroupBox("Diagnostic de la chaîne de connexion") + g = QVBoxLayout(grp) + g.addWidget(QLabel( "Vérifie toute la chaîne : configuration, clés, route, port UDP,\n" "montage du tunnel, handshake, trafic et DNS — et désigne l'étape\n" "qui bloque." )) - btn_test = QPushButton("🚀 Lancer le test de connexion") - btn_test.setStyleSheet( - "QPushButton { padding: 9px; background: #1e8449; color: white;" - " border-radius: 5px; font-weight: bold; border: none; }" - "QPushButton:hover { background: #27ae60; }" - ) - btn_test.clicked.connect(self._run_test) - g1.addWidget(btn_test) + btn = QPushButton("🚀 Lancer le test de connexion") + btn.setStyleSheet(theme.primary_button_style()) + btn.clicked.connect(self._run_test) + g.addWidget(btn) + # QLabel auparavant : le rapport fait une dizaine d'étapes avec un # détail par étape, et un QLabel se laisse écraser par le layout — # le texte était rogné à quelques lignes illisibles. Une vue en @@ -519,56 +590,108 @@ class AdminWindow(QDialog): # entier quelle que soit la taille de la fenêtre. self._test_result = QTextEdit() self._test_result.setReadOnly(True) - self._test_result.setMinimumHeight(320) + self._test_result.setMinimumHeight(300) self._test_result.setSizePolicy( QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding ) - self._test_result.setStyleSheet(_RESULT_CSS + "color: white;") - g1.addWidget(self._test_result, 1) - # Facteur d'étirement : l'espace vertical libre revient au rapport de - # diagnostic plutôt qu'au ressort de fin de page. - lay.addWidget(grp1, 1) + self._test_result.setStyleSheet(theme.result_view_style()) + g.addWidget(self._test_result, 1) + v.addWidget(grp, 1) + return page - # Test DNS leak - grp2 = QGroupBox("🔍 Test de fuite DNS") - g2 = QVBoxLayout(grp2) - g2.addWidget(QLabel("Vérifie que le DNS passe bien par le tunnel VPN.")) - btn_dns = QPushButton("🔍 Analyser le DNS") - btn_dns.clicked.connect(self._run_dns_test) - g2.addWidget(btn_dns) - btn_dns_fix = QPushButton("🛠️ Réparer le DNS (tunnel arrêté)") - btn_dns_fix.setToolTip( + # ── Sous-onglet : fuite DNS ────────────────────────────────────────── + def _subtab_dns(self) -> QWidget: + page, v = self._test_page() + v.addWidget(self._hint( + "Vérifie que les requêtes de noms empruntent bien le tunnel plutôt " + "que le résolveur du réseau local. La réparation retire une " + "configuration DNS laissée par un tunnel arrêté brutalement, qui " + "empêche toute résolution." + )) + + grp = QGroupBox("🔍 Test de fuite DNS") + g = QVBoxLayout(grp) + g.addWidget(QLabel("Vérifie que le DNS passe bien par le tunnel VPN.")) + btn = QPushButton("🔍 Analyser le DNS") + btn.setStyleSheet(theme.primary_button_style()) + btn.clicked.connect(self._run_dns_test) + g.addWidget(btn) + btn_fix = QPushButton("🛠️ Réparer le DNS (tunnel arrêté)") + btn_fix.setToolTip( "Retire la configuration DNS laissée par un tunnel arrêté " "brutalement, qui empêche toute résolution de noms." ) - btn_dns_fix.clicked.connect(self._repair_dns) - g2.addWidget(btn_dns_fix) - self._dns_result = QLabel("") - self._dns_result.setWordWrap(True) - self._dns_result.setStyleSheet( - "color: white; padding: 8px; border-radius: 6px; font-size: 11px;" - ) - g2.addWidget(self._dns_result) - lay.addWidget(grp2) + btn_fix.clicked.connect(self._repair_dns) + g.addWidget(btn_fix) - # Droits d'exécution - grp3 = QGroupBox("🔐 Droits d'exécution") - g3 = QVBoxLayout(grp3) - g3.addWidget(QLabel( - "Sans règle sudo, chaque connexion ouvre un dialogue d'authentification." + self._dns_result = QTextEdit() + self._dns_result.setReadOnly(True) + self._dns_result.setMinimumHeight(220) + self._dns_result.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self._dns_result.setStyleSheet(theme.result_view_style()) + g.addWidget(self._dns_result, 1) + v.addWidget(grp, 1) + return page + + # ── Sous-onglet : droits d'exécution ───────────────────────────────── + def _privileges_tab_title(self) -> str: + return "🛡️ Élévation" if is_windows() else "🔐 Droits sudo" + + def _subtab_privileges(self) -> QWidget: + """Droits nécessaires pour monter le tunnel. + + La page parlait de règles sudo sur les deux plateformes. Sous Windows + sudo n'existe pas : le texte, le bouton et le conseil « make + setup-sudoers » ne désignaient rien, et l'utilisateur n'avait aucun + moyen de savoir pourquoi une fenêtre UAC s'ouvrait à chaque connexion. + """ + page, v = self._test_page() + v.addWidget(self._hint( + "Monter un tunnel WireGuard demande des droits que l'application " + "n'a pas par défaut. Cette page dit lesquels manquent, et comment " + "éviter la fenêtre d'authentification qui s'ouvre sinon à chaque " + "connexion." + if not is_windows() else + "Installer ou retirer le service du tunnel demande les droits " + "administrateur. Cette page dit si le processus les possède, et " + "comment éviter la fenêtre UAC qui s'ouvre sinon à chaque " + "connexion." )) - btn_priv = QPushButton("🔐 Vérifier les droits") - btn_priv.clicked.connect(self._check_privileges) - g3.addWidget(btn_priv) - self._priv_result = QLabel("") - self._priv_result.setWordWrap(True) - self._priv_result.setStyleSheet( - "color: white; padding: 8px; border-radius: 6px; font-size: 11px;" - ) - g3.addWidget(self._priv_result) - lay.addWidget(grp3) - return w + if is_windows(): + grp = QGroupBox("🛡️ Élévation Windows (UAC)") + g = QVBoxLayout(grp) + g.addWidget(QLabel( + "Installer ou retirer le service du tunnel exige les droits " + "administrateur.\nSans élévation, Windows ouvre une fenêtre " + "UAC à chaque connexion." + )) + btn = QPushButton("🛡️ Vérifier l'élévation") + else: + grp = QGroupBox("🔐 Droits d'exécution (sudo)") + g = QVBoxLayout(grp) + g.addWidget(QLabel( + "Sans règle sudo, chaque connexion ouvre un dialogue " + "d'authentification." + )) + btn = QPushButton("🔐 Vérifier les droits") + + btn.setStyleSheet(theme.primary_button_style()) + btn.clicked.connect(self._check_privileges) + g.addWidget(btn) + + self._priv_result = QTextEdit() + self._priv_result.setReadOnly(True) + self._priv_result.setMinimumHeight(220) + self._priv_result.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding + ) + self._priv_result.setStyleSheet(theme.result_view_style()) + g.addWidget(self._priv_result, 1) + v.addWidget(grp, 1) + return page def _refresh_profile_list(self): self._profile_list.clear() @@ -618,7 +741,12 @@ class AdminWindow(QDialog): # Onglet Paramètres # ------------------------------------------------------------------ # def _tab_settings(self) -> QWidget: - w, lay = self._dark_page("⚙️ Paramètres de l'application", "#2a1a4a") + 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) @@ -682,8 +810,34 @@ class AdminWindow(QDialog): # ------------------------------------------------------------------ # # Onglet Sécurité # ------------------------------------------------------------------ # - def _tab_admin(self) -> QWidget: - w, lay = self._dark_page("🔒 Sécurité — Accès Administrateur", "#6e2e1c") + def _tab_security(self) -> QWidget: + """Sécurité : MFA et mot de passe administrateur. + + Les deux vivaient dans des onglets de premier niveau séparés alors + qu'ils répondent à la même question — qui a le droit de se connecter + et d'administrer. Les regrouper libère aussi un onglet dans une barre + qui en comptait sept. + """ + w, lay = self._dark_page("🔒 Sécurité", theme.BANNER["security"]) + lay.setContentsMargins(10, 10, 10, 0) + lay.addWidget(self._hint( + "Qui a le droit de monter le tunnel, et qui a le droit " + "d'administrer WGSecure." + )) + + sub = QTabWidget() + sub.setStyleSheet(theme.TABBAR_CSS) + sub.addTab(self._subtab_mfa(), "🔐 MFA (TOTP)") + sub.addTab(self._subtab_password(), "🔑 Mot de passe") + lay.addWidget(sub) + return w + + def _subtab_password(self) -> QWidget: + page, lay = self._test_page() + lay.addWidget(self._hint( + "Protège l'accès à ce panneau. Sans mot de passe, n'importe qui " + "sur cette session peut lire la clé privée et modifier le tunnel." + )) note = QLabel( "Définissez un mot de passe pour protéger l'accès au panneau Administrateur.\n" @@ -709,22 +863,20 @@ class AdminWindow(QDialog): btn_set_pw = QPushButton("Définir le mot de passe") btn_set_pw.setStyleSheet( - "QPushButton { background: #922b21; color: white;" - " border-radius: 5px; border: none; }" - "QPushButton:hover { background: #c0392b; }" + theme.primary_button_style(theme.FAIL_SOLID, "#e74c3c") ) btn_set_pw.clicked.connect(self._set_admin_password) self._row(form, "", btn_set_pw) lay.addWidget(grp) lay.addStretch() - return w + return page # ------------------------------------------------------------------ # # Onglet À propos # ------------------------------------------------------------------ # def _tab_about(self) -> QWidget: w = QWidget() - w.setStyleSheet(_TAB_CSS) + w.setStyleSheet(theme.TAB_CSS) w.setAutoFillBackground(True) layout = QVBoxLayout(w) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -744,7 +896,7 @@ class AdminWindow(QDialog): name_lbl.setStyleSheet("color: white;") layout.addWidget(name_lbl) - version_lbl = QLabel("Version 0.4.6") + version_lbl = QLabel("Version 0.5.0") version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;") layout.addWidget(version_lbl) @@ -778,7 +930,7 @@ class AdminWindow(QDialog): brand_lbl.setStyleSheet("color: #5dade2;") layout.addWidget(brand_lbl) - date_lbl = QLabel("Juin 2026") + date_lbl = QLabel("Septembre 2026") date_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) date_lbl.setStyleSheet("color: rgba(255,255,255,0.55); font-size: 12px;") layout.addWidget(date_lbl) @@ -794,6 +946,7 @@ class AdminWindow(QDialog): 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")) @@ -810,6 +963,7 @@ class AdminWindow(QDialog): 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()) @@ -870,6 +1024,12 @@ class AdminWindow(QDialog): 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: @@ -947,7 +1107,7 @@ class AdminWindow(QDialog): self._test_result.setHtml( 'Diagnostic en cours…' ) - self._test_result.setStyleSheet(_RESULT_CSS + "color: rgba(255,255,255,0.6);") + self._test_result.setStyleSheet(theme.result_view_style("idle")) QApplication.processEvents() steps = wg_core.diagnose_connection(self._cfg) @@ -955,12 +1115,12 @@ class AdminWindow(QDialog): states = [s["state"] for s in steps] if "fail" in states: - css = "background: rgba(231,76,60,0.20); color: #f1948a;" + verdict = "fail" elif "warn" in states or "skip" in states: - css = "background: rgba(241,196,15,0.15); color: #f7dc6f;" + verdict = "warn" else: - css = "background: rgba(39,174,96,0.20); color: #a9dfbf;" - self._test_result.setStyleSheet(_RESULT_CSS + "QTextEdit { " + css + " }") + 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.""" @@ -1031,7 +1191,8 @@ class AdminWindow(QDialog): """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 + 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") @@ -1077,13 +1238,15 @@ class AdminWindow(QDialog): self._priv_key.setText(result["client_priv"]) self._pub_key.setText(result["client_pub"]) self._srv_pubkey.setText(result["server_pub"]) + self._use_psk.setChecked(bool(result["psk"])) self._psk.setText(result["psk"]) self._refresh_preview() dlg.accept() QMessageBox.information( self, "Config client appliquée", - "Clés client, clé publique du serveur et clé pré-partagée " - "reportées dans la configuration.\n\n" + "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." ) @@ -1102,67 +1265,107 @@ class AdminWindow(QDialog): def _run_dns_test(self): from PyQt6.QtWidgets import QApplication - self._dns_result.setText("Analyse en cours…") + self._dns_result.setPlainText("Analyse en cours…") QApplication.processEvents() r = wg_core.dns_leak_test(self._cfg) lines = [] status = r["status"] if status == "ok": lines.append("✅ Aucune fuite DNS détectée") - css = "background: rgba(39,174,96,0.2); color: #a9dfbf;" + state = "ok" elif status == "leak": lines.append("⚠️ Fuite DNS potentielle !") - css = "background: rgba(231,76,60,0.2); color: #f1948a;" + state = "fail" elif status == "down": lines.append("🚫 Résolution DNS hors service — utilisez « Réparer le DNS »") - css = "background: rgba(231,76,60,0.2); color: #f1948a;" + state = "fail" elif not r.get("tunnel_up", False): lines.append("ℹ️ Tunnel inactif : la comparaison n'a pas de sens") - css = "color: #aed6f1;" + state = "skip" else: lines.append("❓ Statut inconnu") - css = "color: #aed6f1;" + 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.setText("\n".join(lines)) - self._dns_result.setStyleSheet( - f"padding: 8px; border-radius: 6px; font-size: 11px; {css}" - ) + self._dns_result.setPlainText("\n".join(lines)) + self._dns_result.setStyleSheet(theme.result_view_style(state)) def _check_privileges(self): - """Liste les commandes privilégiées et celles qui exigeront un dialogue.""" + """É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 - self._priv_result.setText("Vérification en cours…") + from app.utils.platform_utils import ( + privilege_report, has_root_privileges, wg_quick_available, + wireguard_exe, + ) + self._priv_result.setPlainText("Vérification en cours…") QApplication.processEvents() + if is_windows(): + lines, state = self._windows_privilege_lines( + has_root_privileges(), wg_quick_available(), wireguard_exe()) + self._priv_result.setPlainText("\n".join(lines)) + self._priv_result.setStyleSheet(theme.result_view_style(state)) + return + rows = privilege_report() if not rows: - self._priv_result.setText( + self._priv_result.setPlainText( "✅ Exécution avec les privilèges root : rien à configurer." ) - css = "background: rgba(39,174,96,0.2); color: #a9dfbf;" + state = "ok" else: lines = [ ("✅ " if ok else "⚠️ ") + f"{name} — {role}" + ("" if ok else " (dialogue à chaque appel)") for name, ok, role in rows ] - missing = [n for n, ok, _ in rows if not ok] - if missing: + if any(not ok for _, ok, _ in rows): lines.append("") lines.append("Pour supprimer ces dialogues : make setup-sudoers") - css = "background: rgba(241,196,15,0.2); color: #f9e79f;" + state = "warn" else: - css = "background: rgba(39,174,96,0.2); color: #a9dfbf;" - self._priv_result.setText("\n".join(lines)) - self._priv_result.setStyleSheet( - f"padding: 8px; border-radius: 6px; font-size: 11px; {css}" - ) + state = "ok" + self._priv_result.setPlainText("\n".join(lines)) + self._priv_result.setStyleSheet(theme.result_view_style(state)) + + @staticmethod + def _windows_privilege_lines(elevated: bool, wg_present: bool, + wg_path: str) -> tuple[list[str], str]: + """Rapport d'élévation Windows. Séparé pour rester testable.""" + lines: list[str] = [] + if elevated: + lines.append("✅ Processus élevé (administrateur) — aucune " + "fenêtre UAC à la connexion.") + state = "ok" + else: + lines.append("⚠️ Processus non élevé : Windows demandera une " + "confirmation UAC à chaque") + lines.append(" connexion et déconnexion du tunnel.") + lines.append("") + lines.append("Pour l'éviter : lancez WGSecure via un raccourci " + "coché « Exécuter en tant") + lines.append("qu'administrateur », ou une tâche planifiée avec " + "les privilèges les plus élevés.") + state = "warn" + + lines.append("") + if wg_present: + lines.append(f"✅ WireGuard installé — {wg_path}") + else: + lines.append("❌ wireguard.exe introuvable : installez WireGuard " + "for Windows.") + state = "fail" + return lines, state def _repair_dns(self): """Retire la configuration DNS résiduelle d'un tunnel mal arrêté. @@ -1172,7 +1375,7 @@ class AdminWindow(QDialog): reste dirigée vers un serveur injoignable. """ from PyQt6.QtWidgets import QApplication - self._dns_result.setText("Réparation en cours…") + self._dns_result.setPlainText("Réparation en cours…") QApplication.processEvents() actions = wg_core.force_cleanup(self._cfg) @@ -1182,21 +1385,19 @@ class AdminWindow(QDialog): msg = "✅ Résolution DNS fonctionnelle" if actions: msg += "\nActions : " + ", ".join(actions) - css = "background: rgba(39,174,96,0.2); color: #a9dfbf;" + 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.") - css = "background: rgba(241,196,15,0.2); color: #f9e79f;" + 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.") - css = "background: rgba(231,76,60,0.2); color: #f1948a;" + state = "fail" - self._dns_result.setText(msg) - self._dns_result.setStyleSheet( - f"padding: 8px; border-radius: 6px; font-size: 11px; {css}" - ) + self._dns_result.setPlainText(msg) + self._dns_result.setStyleSheet(theme.result_view_style(state)) def _set_admin_password(self): p1 = self._admin_pw1.text() diff --git a/app/ui/theme.py b/app/ui/theme.py new file mode 100644 index 0000000..5196323 --- /dev/null +++ b/app/ui/theme.py @@ -0,0 +1,190 @@ +"""Jetons de couleur et fragments de feuille de style partagés. + +Les couleurs étaient écrites en dur au fil des widgets : vingt-quatre valeurs +distinctes dans le seul panneau d'administration, cinq bandeaux d'onglets sans +rapport de teinte entre eux, et des blocs de résultat dont le fond ne +correspondait ni à celui de la page ni à celui de leurs voisins. Tout passe +désormais par ce module — une couleur ne se choisit plus au cas par cas. +""" + +# ── Fonds ──────────────────────────────────────────────────────────────────── +BG = "#1c2833" # fond de l'application et de toutes les pages +BG_SUNKEN = "#17202a" # zones en creux : configurations, blocs de code +BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires +BORDER = "rgba(255,255,255,0.18)" +BORDER_SOFT = "rgba(255,255,255,0.10)" + +# ── Textes ─────────────────────────────────────────────────────────────────── +TEXT = "white" +TEXT_MUTED = "rgba(255,255,255,0.70)" +TEXT_FAINT = "rgba(255,255,255,0.50)" + +# ── Accent ─────────────────────────────────────────────────────────────────── +ACCENT = "#2471a3" +ACCENT_HOVER = "#1a5276" +ACCENT_ACTIVE = "#154360" +ACCENT_LIGHT = "#5dade2" + +# ── États ──────────────────────────────────────────────────────────────────── +# Chaque état porte un trio : teinte du texte, fond translucide, bouton plein. +OK_TEXT, OK_BG, OK_SOLID = "#a9dfbf", "rgba(39,174,96,0.18)", "#1e8449" +WARN_TEXT, WARN_BG, WARN_SOLID = "#f7dc6f", "rgba(241,196,15,0.15)", "#b7950b" +FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b" +SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)" + +_STATE = { + "ok": (OK_TEXT, OK_BG), + "warn": (WARN_TEXT, WARN_BG), + "fail": (FAIL_TEXT, FAIL_BG), + "skip": (SKIP_TEXT, SKIP_BG), + "idle": (TEXT_MUTED, "transparent"), +} + +# ── Bandeaux d'onglets ─────────────────────────────────────────────────────── +# Cinq teintes calées sur la même luminosité et la même saturation : les +# onglets restent distinguables sans que l'un paraisse plus clair ou plus +# saturé que les autres, ce qui était le cas des valeurs choisies à la main. +BANNER = { + "wireguard": "#1a5276", + "keys": "#1a4a6e", + "mfa": "#4a2a6e", + "test": "#1a4a7a", + "settings": "#155e52", + "security": "#7a3b1a", + "about": "#243b53", +} + + +def result_style(state: str, font_size: int = 11) -> str: + """Feuille de style d'un bloc de résultat, selon son état. + + Les blocs affichaient chacun leur propre fond rgba, recopié à la main : + même état, teintes différentes d'un widget à l'autre. + """ + color, bg = _STATE.get(state, _STATE["idle"]) + return (f"padding: 8px; border-radius: 6px; font-size: {font_size}px;" + f" background: {bg}; color: {color};") + + +def result_view_style(state: str = "idle", font_size: int = 12) -> str: + """Même chose pour un QTextEdit en lecture seule (rapport défilant).""" + color, bg = _STATE.get(state, _STATE["idle"]) + return ( + f"QTextEdit {{ background: {bg}; color: {color};" + f" border: 1px solid {BORDER_SOFT}; border-radius: 6px;" + f" padding: 8px; font-size: {font_size}px; }}" + f"QScrollBar:vertical {{ background: transparent; width: 8px;" + f" border: none; }}" + f"QScrollBar::handle:vertical {{ background: {BG_RAISED};" + f" border-radius: 4px; }}" + "QScrollBar::add-line:vertical," + "QScrollBar::sub-line:vertical { height: 0; }" + ) + + +def scrollarea_style() -> str: + return ( + f"QScrollArea {{ border: none; background: {BG}; }}" + f"QScrollBar:vertical {{ background: {BG}; width: 8px; border: none; }}" + f"QScrollBar::handle:vertical {{ background: {BG_RAISED};" + f" border-radius: 4px; }}" + "QScrollBar::add-line:vertical," + "QScrollBar::sub-line:vertical { height: 0; }" + ) + + +def banner_style(color: str) -> str: + return (f"background: {color}; color: {TEXT}; font-size: 13px;" + " font-weight: bold; padding-left: 8px;") + + +#: CSS sombre commun à toutes les pages. +TAB_CSS = f""" + QWidget {{ background-color: {BG}; color: {TEXT}; }} + QLabel {{ color: {TEXT}; background: transparent; }} + QCheckBox {{ color: {TEXT}; background: transparent; }} + QGroupBox {{ + color: {TEXT}; + background: transparent; + border: 1px solid {BORDER}; + border-radius: 5px; + margin-top: 10px; + font-weight: bold; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: {TEXT}; + }} + QLineEdit {{ + background: rgba(255,255,255,0.10); + color: {TEXT}; + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 4px 6px; + }} + QLineEdit:read-only {{ background: rgba(255,255,255,0.06); }} + QLineEdit:disabled {{ background: rgba(255,255,255,0.04); + color: {TEXT_FAINT}; }} + QSpinBox {{ + background: rgba(255,255,255,0.10); + color: {TEXT}; + border: 1px solid {BORDER}; + border-radius: 4px; + padding: 3px 6px; + }} + QSpinBox::up-button, QSpinBox::down-button {{ + background: rgba(255,255,255,0.15); + }} + QTextEdit {{ + background: {BG_SUNKEN}; + color: #a8d8ea; + border: 1px solid {BORDER}; + border-radius: 4px; + }} + QPushButton {{ + background: {ACCENT}; + color: {TEXT}; + border-radius: 5px; + padding: 7px 12px; + border: none; + }} + QPushButton:hover {{ background: {ACCENT_HOVER}; }} + QPushButton:checked {{ background: {ACCENT_ACTIVE}; }} + QPushButton:disabled {{ background: rgba(255,255,255,0.08); + color: {TEXT_FAINT}; }} +""" + +#: Barre d'onglets, utilisée par le panneau principal et les sous-onglets. +TABBAR_CSS = f""" + QTabWidget::pane {{ border: none; background: {BG}; }} + QTabBar::tab {{ + background: {BG_RAISED}; + color: {TEXT_MUTED}; + padding: 6px 14px; + border: none; + }} + QTabBar::tab:selected {{ background: {ACCENT}; color: {TEXT}; }} + QTabBar::tab:hover:!selected {{ background: {ACCENT_HOVER}; + color: {TEXT}; }} +""" + + +def primary_button_style(solid: str = OK_SOLID, hover: str = "#27ae60") -> str: + """Bouton d'action principale d'une page (lancer un test, par exemple).""" + return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};" + f" border-radius: 5px; font-weight: bold; border: none; }}" + f"QPushButton:hover {{ background: {hover}; }}") + + +def hint_style() -> str: + """Encart d'introduction posé en tête de chaque onglet. + + Assez contrasté pour se détacher du fond de page, assez sobre pour ne pas + concurrencer le contenu : un liseré d'accent à gauche, pas de cadre plein. + """ + return (f"background: rgba(36,113,163,0.16);" + f" border-left: 3px solid {ACCENT_LIGHT};" + f" border-radius: 4px; padding: 9px 11px;" + f" color: {TEXT_MUTED}; font-size: 11px;")