From d03a75057eaf5e24a3e5b5d53fe2f1be6f3c72d5 Mon Sep 17 00:00:00 2001 From: tuxgyver Date: Tue, 21 Jul 2026 10:21:13 +0200 Subject: [PATCH] Windows --- Makefile | 11 ++- app/core/wireguard.py | 38 ++++++++--- app/ui/admin_window.py | 133 +++++++++++++++++++++--------------- app/utils/platform_utils.py | 33 +++++---- wgsecure.iss | 52 ++++++++++++++ 5 files changed, 187 insertions(+), 80 deletions(-) create mode 100644 wgsecure.iss diff --git a/Makefile b/Makefile index 15767a1..2b4da09 100644 --- a/Makefile +++ b/Makefile @@ -154,12 +154,17 @@ setup-sudoers: @$(PYTHON) -c "\ import os, subprocess; \ user = os.environ.get('USER', os.path.basename(os.path.expanduser('~'))); \ -rule = user + ' ALL=(ALL) NOPASSWD: /usr/bin/wg-quick\n'; \ +rules = ( \ + user + ' ALL=(ALL) NOPASSWD: /usr/bin/wg-quick\n' \ + + user + ' ALL=(ALL) NOPASSWD: /usr/bin/tee /etc/wireguard/*\n' \ + + user + ' ALL=(ALL) NOPASSWD: /bin/chmod 600 /etc/wireguard/*\n' \ + + user + ' ALL=(ALL) NOPASSWD: /usr/bin/chmod 600 /etc/wireguard/*\n' \ +); \ tmp = '/tmp/wgsecure_sudoers_tmp'; \ -open(tmp, 'w').write(rule); \ +open(tmp, 'w').write(rules); \ r = subprocess.run(['pkexec', 'bash', '-c', 'cp ' + tmp + ' /etc/sudoers.d/wgsecure && chmod 440 /etc/sudoers.d/wgsecure']); \ os.unlink(tmp); \ -print(' ✅ /etc/sudoers.d/wgsecure configuré — WGSecure n\\'a plus besoin de dialogue admin.' if r.returncode == 0 else ' ❌ Échec. Exécutez manuellement : sudo bash -c \"echo ' + user + ' ALL=(ALL) NOPASSWD: /usr/bin/wg-quick > /etc/sudoers.d/wgsecure && chmod 440 /etc/sudoers.d/wgsecure\"')" +print(' ✅ /etc/sudoers.d/wgsecure configuré — plus de dialogue admin.' if r.returncode == 0 else ' ❌ Échec.')" @echo "" # ── Réinitialisation du mot de passe administrateur ────────────────────────── diff --git a/app/core/wireguard.py b/app/core/wireguard.py index b996b76..9b2b0d3 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -48,7 +48,9 @@ def build_client_config(cfg: Config) -> str: def get_client_config_path(cfg: Config) -> str: name = cfg.wg.get("interface_name", "wgs0") - return os.path.join(get_config_dir(), f"{name}.conf") + if is_windows(): + return os.path.join(get_config_dir(), f"{name}.conf") + return os.path.join(get_wg_config_dir(), f"{name}.conf") # /etc/wireguard/ def write_client_config(cfg: Config) -> tuple[bool, str]: @@ -61,8 +63,30 @@ def write_client_config(cfg: Config) -> tuple[bool, str]: if not is_windows(): os.chmod(path, 0o600) return True, path - except Exception as e: - return False, str(e) + except PermissionError: + # sudo -n tee (NOPASSWD si configuré, sinon pkexec cp) + import subprocess as _sp + try: + r = _sp.run(["sudo", "-n", "tee", path], + input=content, text=True, + capture_output=True, timeout=10) + if r.returncode == 0: + _sp.run(["sudo", "-n", "chmod", "600", path], + capture_output=True, timeout=5) + return True, path + except Exception: + pass + # Repli sur pkexec cp (dialogue graphique) + tmp = os.path.join(get_config_dir(), "wgs_tmp.conf") + with open(tmp, "w") as f: + f.write(content) + os.chmod(tmp, 0o600) + code, _, err = run_privileged(["cp", tmp, path]) + os.unlink(tmp) + if code == 0: + run_privileged(["chmod", "600", path]) + return True, path + return False, err def is_connected(cfg: Config) -> bool: @@ -88,7 +112,8 @@ def connect(cfg: Config) -> tuple[bool, str]: code, _, err = run_command(["wireguard", "/installtunnel", result]) return (code == 0), (err or "Connecté") else: - code, _, err = run_privileged(["wg-quick", "up", result]) + # Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement + code, _, err = run_privileged(["wg-quick", "up", name]) if code == 0: return True, "Tunnel WireGuard activé" return False, err or "Erreur lors de la connexion" @@ -101,10 +126,7 @@ def disconnect(cfg: Config) -> tuple[bool, str]: code, _, err = run_command(["wireguard", "/uninstalltunnel", name]) return (code == 0), (err or "Déconnecté") else: - path = get_client_config_path(cfg) - if not os.path.exists(path): - path = name - code, _, err = run_privileged(["wg-quick", "down", path]) + code, _, err = run_privileged(["wg-quick", "down", name]) if code == 0: return True, "Tunnel WireGuard désactivé" return False, err or "Erreur lors de la déconnexion" diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 78c7347..5a9a250 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -132,7 +132,6 @@ class AdminWindow(QDialog): tabs.addTab(self._tab_keys(), "Clés") tabs.addTab(self._tab_mfa(), "MFA") tabs.addTab(self._tab_test(), "Test connexion") - tabs.addTab(self._tab_profiles(), "Profils") tabs.addTab(self._tab_settings(), "Paramètres") tabs.addTab(self._tab_admin(), "Sécurité") tabs.addTab(self._tab_about(), "À propos") @@ -184,19 +183,88 @@ class AdminWindow(QDialog): form.addRow(lbl, widget) def _tab_wireguard(self) -> QWidget: - w, lay = self._dark_page("🛡️ Configuration WireGuard", "#154360") + from PyQt6.QtWidgets import QScrollArea, QListWidget - # Barre import / export + outer = QWidget() + outer.setStyleSheet(_TAB_CSS) + outer_lay = QVBoxLayout(outer) + outer_lay.setContentsMargins(0, 0, 0, 0) + outer_lay.setSpacing(0) + + 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) + + 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) + lay.setContentsMargins(14, 12, 14, 12) + lay.setSpacing(10) + + # ── Profils ────────────────────────────────────────────────────── + 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(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) + + 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) + + # ── 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) + # ── Serveur ────────────────────────────────────────────────────── grp = QGroupBox("Serveur WireGuard") form = QFormLayout(grp) form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) @@ -216,6 +284,7 @@ class AdminWindow(QDialog): self._row(form, "Clé publique serveur :", self._srv_pubkey) lay.addWidget(grp) + # ── Interface client ───────────────────────────────────────────── grp2 = QGroupBox("Interface client") form2 = QFormLayout(grp2) form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) @@ -233,7 +302,7 @@ class AdminWindow(QDialog): self._row(form2, "DNS :", self._dns) self._allowed_ips = QLineEdit() - self._allowed_ips.setPlaceholderText("0.0.0.0/0") + self._allowed_ips.setPlaceholderText("10.8.0.0/24") self._row(form2, "IPs autorisées :", self._allowed_ips) self._keepalive = QSpinBox() @@ -243,7 +312,12 @@ class AdminWindow(QDialog): self._row(form2, "Keepalive (s) :", self._keepalive) lay.addWidget(grp2) lay.addStretch() - return w + + scroll.setWidget(content) + outer_lay.addWidget(scroll) + + self._refresh_profile_list() + return outer # ------------------------------------------------------------------ # # Onglet Clés @@ -401,55 +475,6 @@ class AdminWindow(QDialog): lay.addStretch() return w - # ------------------------------------------------------------------ # - # Onglet Profils - # ------------------------------------------------------------------ # - def _tab_profiles(self) -> QWidget: - from PyQt6.QtWidgets import QListWidget, QListWidgetItem, QInputDialog - w, lay = self._dark_page("👤 Gestion des profils", "#1a4a2a") - - info = QLabel( - "Sauvegardez la configuration WireGuard courante sous un nom de profil,\n" - "puis basculez entre profils sans passer par la configuration." - ) - info.setWordWrap(True) - info.setStyleSheet("color: rgba(255,255,255,0.75); font-size: 12px;") - lay.addWidget(info) - - grp = QGroupBox("Profils sauvegardés") - gv = QVBoxLayout(grp) - - self._profile_list = QListWidget() - self._profile_list.setFixedHeight(130) - 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; }" - ) - gv.addWidget(self._profile_list) - - btn_row = QHBoxLayout() - btn_save_p = QPushButton("💾 Sauvegarder sous…") - btn_save_p.clicked.connect(self._save_profile) - btn_load_p = QPushButton("✅ Charger") - btn_load_p.clicked.connect(self._load_profile) - btn_del_p = QPushButton("🗑️ Supprimer") - btn_del_p.setStyleSheet( - "QPushButton { background: #6e2e1c; color: white; border-radius: 5px;" - " padding: 7px 12px; border: none; }" - "QPushButton:hover { background: #922b21; }" - ) - btn_del_p.clicked.connect(self._delete_profile) - btn_row.addWidget(btn_save_p) - btn_row.addWidget(btn_load_p) - btn_row.addWidget(btn_del_p) - gv.addLayout(btn_row) - lay.addWidget(grp) - lay.addStretch() - - self._refresh_profile_list() - return w - def _refresh_profile_list(self): self._profile_list.clear() active = self._cfg.active_profile diff --git a/app/utils/platform_utils.py b/app/utils/platform_utils.py index cdc5da5..797d281 100644 --- a/app/utils/platform_utils.py +++ b/app/utils/platform_utils.py @@ -62,10 +62,14 @@ def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: if has_root_privileges(): return run_command(cmd, timeout) - # 1. pkexec — dialogue graphique polkit (GNOME/KDE), timeout long pour la saisie - code, out, err = run_command(["pkexec"] + cmd, timeout) - if code != -1 or "introuvable" not in err: + # 1. sudo -n (NOPASSWD configuré dans sudoers — sans aucun dialogue) + code, out, err = run_command(["sudo", "-n"] + cmd, timeout) + if code == 0: return code, out, err + # Si sudo a tourné mais la commande a échoué (pas un problème d'auth), retourner l'erreur + if code != 1 or ("password" not in err.lower() and "passwd" not in err.lower()): + if "sudo:" not in err.lower() and code not in (-1,): + return code, out, err # 2. sudo avec programme askpass graphique (pas de TTY dans une app Qt) askpass = _find_askpass() @@ -78,24 +82,23 @@ def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]: capture_output=True, text=True, timeout=timeout, env=env, ) - return result.returncode, result.stdout.strip(), result.stderr.strip() - except subprocess.TimeoutExpired: - return -1, "", "Timeout élévation sudo" - except Exception as e: - return -1, "", str(e) + if result.returncode == 0: + return result.returncode, result.stdout.strip(), result.stderr.strip() + except (subprocess.TimeoutExpired, Exception): + pass - # 3. sudo classique (fonctionne si NOPASSWD configuré dans sudoers) - code, out, err = run_command(["sudo", "-n"] + cmd, 10) - if code != -1: + # 3. pkexec — dialogue graphique polkit (GNOME/KDE) + code, out, err = run_command(["pkexec"] + cmd, timeout) + if code != -1 or "introuvable" not in err: return code, out, err return ( -1, "", "Élévation de privilèges impossible.\n" - "Solutions :\n" - " • Installer pkexec (polkit) pour le dialogue graphique\n" - " • Ou ajouter dans /etc/sudoers :\n" - f" {os.environ.get('USER','')} ALL=(ALL) NOPASSWD: /usr/bin/wg-quick" + "Exécutez make setup-sudoers pour configurer wg-quick sans dialogue.\n" + "Ou manuellement :\n" + f" sudo bash -c \"echo '{os.environ.get('USER','')} ALL=(ALL) NOPASSWD: /usr/bin/wg-quick'" + " > /etc/sudoers.d/wgsecure && chmod 440 /etc/sudoers.d/wgsecure\"" ) diff --git a/wgsecure.iss b/wgsecure.iss new file mode 100644 index 0000000..1424e12 --- /dev/null +++ b/wgsecure.iss @@ -0,0 +1,52 @@ +; ────────────────────────────────────────────── +; WGSecure (WGS) — Script Inno Setup +; Installeur Windows (bundle VC++ Redistributable) +; ────────────────────────────────────────────── + +#define MyAppName "WGSecure" +#define MyAppVersion "0.4.0" +#define MyAppPublisher "WGSecure" +#define MyAppExeName "wgsecure.exe" + +[Setup] +AppId={{8F3B2C1A-7D4E-4A9B-9C6F-1E2D3F4A5B6C} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputDir=dist +OutputBaseFilename=wgsecure-{#MyAppVersion}-setup +Compression=lzma2 +SolidCompression=yes +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +PrivilegesRequired=admin + +[Languages] +Name: "french"; MessagesFile: "compiler:Languages\French.isl" + +[Files] +Source: "dist\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion +; VC++ Redistributable x64 — à télécharger avant compilation : +; https://aka.ms/vs/17/release/vc_redist.x64.exe → installer\vc_redist.x64.exe +Source: "installer\vc_redist.x64.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" + +[Run] +; Installation silencieuse du VC++ Redistributable si absent (corrige "Failed to load Python DLL: python312.dll") +Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /quiet /norestart"; StatusMsg: "Installation du composant Visual C++ Redistributable…"; Check: VCRedistNeedsInstall +Filename: "{app}\{#MyAppExeName}"; Description: "Lancer {#MyAppName}"; Flags: nowait postinstall skipifsilent + +[Code] +function VCRedistNeedsInstall: Boolean; +var + Version: String; +begin + // Clé présente si VC++ 2015-2022 x64 (v14+) déjà installé + Result := not RegQueryStringValue(HKLM64, 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\X64', 'Version', Version); +end;