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;")