diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..60f3089 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.env +*.egg-info/ +dist/ +build/ +.venv/ +venv/ +*.spec diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5e038f0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog — WGSecure (WGS) + +Toutes les modifications notables de ce projet sont documentées ici. +Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/). +Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). + +--- + +## [0.1.0] — 2026-06-01 + +### Ajouté +- Interface graphique PyQt6 avec icône systray (Linux & Windows) +- **Mode User** (défaut) : connexion / déconnexion WireGuard en un clic, statut en temps réel +- **Mode Admin** (`--admin` ou via le menu) protégé par mot de passe +- Panneau Admin avec 5 onglets : + - **WireGuard** : configuration serveur (adresse, port UDP, clé publique, DNS, IP client) + - **Clés** : génération de paires Curve25519 (clé privée / publique), aperçu de la config exportable + - **MFA** : génération de secret TOTP, QR Code compatible Google Authenticator / Aegis + - **Test connexion** : vérification UDP du serveur avant tunnel + - **Sécurité** : gestion du mot de passe administrateur (SHA-256 + salt) +- Surcouche MFA : dialogue TOTP avec minuterie de validité du code (fenêtre glissante ±1) +- Génération de clés WireGuard via la bibliothèque `cryptography` (X25519, sans dépendance à `wg`) +- Écriture automatique du fichier `.conf` WireGuard avec permissions `0600` +- Connexion via `wg-quick` (Linux) ou `wireguard /installtunnel` (Windows) +- Statut temps réel : trafic RX/TX, dernier handshake +- Icônes générées programmatiquement (vert/rouge/orange) sans fichier binaire externe +- Réduction dans le systray à la fermeture de la fenêtre principale +- Option `--no-tray` pour fonctionner sans systray +- Configuration persistante en JSON (`~/.wgsecure/config.json`) + +--- + +*Prochaines versions planifiées :* +- `0.2.0` — Import/export de profils WireGuard (.conf), support multi-profils +- `0.3.0` — MFA par clé matérielle (FIDO2/YubiKey) +- `0.4.0` — Paquet installable (Windows .exe / Linux .deb) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..2624916 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1.0" +APP_NAME = "WGSecure" +APP_SHORT = "WGS" diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..ade9f51 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,132 @@ +import json +import os +import hashlib +import secrets +from typing import Any +from app.utils.platform_utils import get_config_dir + + +_CONFIG_FILE = "config.json" +_DEFAULT: dict[str, Any] = { + "version": "0.1.0", + "admin_password_hash": "", + "admin_salt": "", + "mfa_enabled": False, + "mfa_secret": "", + "wg": { + "interface_name": "wgs0", + "server_endpoint": "", + "server_port": 51820, + "server_public_key": "", + "client_private_key": "", + "client_public_key": "", + "client_address": "10.8.0.2/24", + "dns": "1.1.1.1", + "allowed_ips": "0.0.0.0/0", + "keepalive": 25, + }, + "ui": { + "minimize_to_tray": True, + "autostart": False, + "theme": "auto", + }, +} + + +class Config: + def __init__(self): + self._path = os.path.join(get_config_dir(), _CONFIG_FILE) + self._data: dict[str, Any] = {} + self.load() + + def load(self): + if os.path.exists(self._path): + try: + with open(self._path, "r", encoding="utf-8") as f: + saved = json.load(f) + self._data = self._merge(_DEFAULT, saved) + except Exception: + self._data = dict(_DEFAULT) + else: + self._data = dict(_DEFAULT) + + def save(self): + os.makedirs(os.path.dirname(self._path), exist_ok=True) + with open(self._path, "w", encoding="utf-8") as f: + json.dump(self._data, f, indent=2, ensure_ascii=False) + + def _merge(self, base: dict, override: dict) -> dict: + result = dict(base) + for k, v in override.items(): + if k in result and isinstance(result[k], dict) and isinstance(v, dict): + result[k] = self._merge(result[k], v) + else: + result[k] = v + return result + + def get(self, *keys: str, default=None) -> Any: + node = self._data + for k in keys: + if not isinstance(node, dict) or k not in node: + return default + node = node[k] + return node + + def set(self, *keys_and_value) -> None: + *keys, value = keys_and_value + node = self._data + for k in keys[:-1]: + node = node.setdefault(k, {}) + node[keys[-1]] = value + + # -- Admin password -- + + def set_admin_password(self, password: str): + if not password: + # Mot de passe vide = suppression de la protection + self._data["admin_salt"] = "" + self._data["admin_password_hash"] = "" + else: + salt = secrets.token_hex(16) + hashed = hashlib.sha256((salt + password).encode()).hexdigest() + self._data["admin_salt"] = salt + self._data["admin_password_hash"] = hashed + self.save() + + def check_admin_password(self, password: str) -> bool: + salt = self._data.get("admin_salt", "") + stored = self._data.get("admin_password_hash", "") + if not stored: + return True # Pas encore de mot de passe configuré + candidate = hashlib.sha256((salt + password).encode()).hexdigest() + return secrets.compare_digest(candidate, stored) + + def has_admin_password(self) -> bool: + return bool(self._data.get("admin_password_hash", "")) + + # -- Propriétés WireGuard -- + + @property + def wg(self) -> dict: + return self._data["wg"] + + @property + def mfa_enabled(self) -> bool: + return bool(self._data.get("mfa_enabled", False)) + + @mfa_enabled.setter + def mfa_enabled(self, value: bool): + self._data["mfa_enabled"] = value + + @property + def mfa_secret(self) -> str: + return self._data.get("mfa_secret", "") + + @mfa_secret.setter + def mfa_secret(self, value: str): + self._data["mfa_secret"] = value + + @property + def configured(self) -> bool: + wg = self._data["wg"] + return bool(wg.get("server_endpoint") and wg.get("client_private_key")) diff --git a/app/core/mfa.py b/app/core/mfa.py new file mode 100644 index 0000000..7c9fe6e --- /dev/null +++ b/app/core/mfa.py @@ -0,0 +1,48 @@ +import pyotp +import qrcode +import io +from PyQt6.QtGui import QPixmap, QImage + + +def generate_secret() -> str: + return pyotp.random_base32() + + +def get_totp(secret: str) -> pyotp.TOTP: + return pyotp.TOTP(secret) + + +def verify_code(secret: str, code: str) -> bool: + if not secret or not code: + return False + totp = pyotp.TOTP(secret) + return totp.verify(code.strip(), valid_window=1) + + +def get_current_code(secret: str) -> str: + return pyotp.TOTP(secret).now() + + +def time_remaining() -> int: + """Secondes restantes avant rotation du code TOTP.""" + import time + return 30 - (int(time.time()) % 30) + + +def get_provisioning_uri(secret: str, account: str = "WGSecure", issuer: str = "WGSecure") -> str: + totp = pyotp.TOTP(secret) + return totp.provisioning_uri(name=account, issuer_name=issuer) + + +def generate_qr_pixmap(secret: str, account: str = "WGSecure") -> QPixmap: + uri = get_provisioning_uri(secret, account) + qr = qrcode.QRCode(box_size=6, border=2) + qr.add_data(uri) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + data = buf.read() + qimage = QImage.fromData(data) + return QPixmap.fromImage(qimage) diff --git a/app/core/wireguard.py b/app/core/wireguard.py new file mode 100644 index 0000000..319bbdb --- /dev/null +++ b/app/core/wireguard.py @@ -0,0 +1,181 @@ +import base64 +import os +import socket +import time +from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey +from app.core.config import Config +from app.utils.platform_utils import ( + get_config_dir, + get_wg_config_dir, + is_windows, + run_command, + run_privileged, + wg_available, +) + + +def generate_keypair() -> tuple[str, str]: + """Retourne (private_key_b64, public_key_b64) au format WireGuard.""" + private = X25519PrivateKey.generate() + private_bytes = private.private_bytes_raw() + public_bytes = private.public_key().public_bytes_raw() + return ( + base64.b64encode(private_bytes).decode(), + base64.b64encode(public_bytes).decode(), + ) + + +def generate_preshared_key() -> str: + return base64.b64encode(os.urandom(32)).decode() + + +def build_client_config(cfg: Config) -> str: + wg = cfg.wg + lines = [ + "[Interface]", + f"PrivateKey = {wg['client_private_key']}", + f"Address = {wg['client_address']}", + f"DNS = {wg['dns']}", + "", + "[Peer]", + f"PublicKey = {wg['server_public_key']}", + f"AllowedIPs = {wg['allowed_ips']}", + f"Endpoint = {wg['server_endpoint']}:{wg['server_port']}", + f"PersistentKeepalive = {wg['keepalive']}", + ] + return "\n".join(lines) + "\n" + + +def get_client_config_path(cfg: Config) -> str: + name = cfg.wg.get("interface_name", "wgs0") + if is_windows(): + config_dir = get_config_dir() + else: + config_dir = get_wg_config_dir() + return os.path.join(config_dir, f"{name}.conf") + + +def write_client_config(cfg: Config) -> tuple[bool, str]: + content = build_client_config(cfg) + path = get_client_config_path(cfg) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(content) + if not is_windows(): + os.chmod(path, 0o600) + return True, path + except PermissionError: + # Écriture via sudo sur Linux + config_dir_path = get_config_dir() + tmp = os.path.join(config_dir_path, "wgs_tmp.conf") + with open(tmp, "w") as f: + f.write(content) + 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: + name = cfg.wg.get("interface_name", "wgs0") + if is_windows(): + code, out, _ = run_command(["sc", "query", f"WireGuardTunnel${name}"]) + return code == 0 and "RUNNING" in out + code, out, _ = run_command(["wg", "show", name]) + return code == 0 and bool(out) + + +def connect(cfg: Config) -> tuple[bool, str]: + if not cfg.configured: + return False, "WireGuard non configuré. Ouvrez le panneau Admin." + + ok, result = write_client_config(cfg) + if not ok: + return False, f"Impossible d'écrire la config : {result}" + + name = cfg.wg.get("interface_name", "wgs0") + + if is_windows(): + code, _, err = run_command(["wireguard", "/installtunnel", result]) + return (code == 0), (err or "Connecté") + else: + code, _, err = run_privileged(["wg-quick", "up", result]) + if code == 0: + return True, "Tunnel WireGuard activé" + return False, err or "Erreur lors de la connexion" + + +def disconnect(cfg: Config) -> tuple[bool, str]: + name = cfg.wg.get("interface_name", "wgs0") + + if is_windows(): + 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]) + if code == 0: + return True, "Tunnel WireGuard désactivé" + return False, err or "Erreur lors de la déconnexion" + + +def test_connection(cfg: Config, timeout: int = 5) -> tuple[bool, str]: + endpoint = cfg.wg.get("server_endpoint", "") + port = int(cfg.wg.get("server_port", 51820)) + if not endpoint: + return False, "Aucun serveur configuré" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + start = time.monotonic() + sock.connect((endpoint, port)) + sock.close() + latency = int((time.monotonic() - start) * 1000) + return True, f"Serveur joignable ({latency} ms)" + except socket.timeout: + return False, "Timeout — serveur inaccessible" + except OSError as e: + return False, str(e) + + +def get_status_info(cfg: Config) -> dict: + name = cfg.wg.get("interface_name", "wgs0") + info = { + "connected": False, + "interface": name, + "peer": "", + "rx_bytes": 0, + "tx_bytes": 0, + "last_handshake": "", + } + if is_windows(): + info["connected"] = is_connected(cfg) + return info + + code, out, _ = run_command(["wg", "show", name]) + if code != 0 or not out: + return info + + info["connected"] = True + for line in out.splitlines(): + line = line.strip() + if line.startswith("peer:"): + info["peer"] = line.split(":", 1)[1].strip()[:16] + "…" + elif line.startswith("transfer:"): + parts = line.split(":", 1)[1].strip().split(",") + try: + rx = parts[0].strip().split()[0] + tx = parts[1].strip().split()[0] + info["rx_bytes"] = rx + info["tx_bytes"] = tx + except Exception: + pass + elif line.startswith("latest handshake:"): + info["last_handshake"] = line.split(":", 1)[1].strip() + + return info diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py new file mode 100644 index 0000000..08aa7a9 --- /dev/null +++ b/app/ui/admin_window.py @@ -0,0 +1,535 @@ +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, + QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, + QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame, +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont + +from app.core.config import Config +from app.core import wireguard as wg_core +from app.core import 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; } +""" + + +class AdminWindow(QDialog): + def __init__(self, config: Config, parent=None): + super().__init__(parent) + self._cfg = config + self.setWindowTitle("WGSecure — Panneau Administrateur") + self.setWindowFlags( + Qt.WindowType.Dialog + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowCloseButtonHint + ) + self.setFixedSize(700, 560) + self._build_ui() + self._load_values() + + # ------------------------------------------------------------------ # + # Helper : page sombre avec bandeau coloré + # ------------------------------------------------------------------ # + def _dark_page(self, banner_text: str, banner_color: str): + """Retourne (page_widget, body_layout) : fond sombre + bandeau.""" + w = QWidget() + w.setStyleSheet(_TAB_CSS) + outer = QVBoxLayout(w) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + hdr = QLabel(f" {banner_text}") + hdr.setFixedHeight(36) + hdr.setStyleSheet( + f"background: {banner_color}; color: white; font-size: 13px;" + " font-weight: bold; padding-left: 8px;" + ) + outer.addWidget(hdr) + + body = QWidget() + body_layout = QVBoxLayout(body) + body_layout.setContentsMargins(14, 14, 14, 14) + body_layout.setSpacing(10) + outer.addWidget(body) + return w, body_layout + + # ------------------------------------------------------------------ # + # Construction principale + # ------------------------------------------------------------------ # + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + banner = QLabel(" WGSecure — Configuration Administrateur") + banner.setFixedHeight(40) + banner.setStyleSheet( + "background: #17202a; color: white; font-size: 14px; font-weight: bold;" + ) + layout.addWidget(banner) + + tabs = QTabWidget() + tabs.setStyleSheet(""" + QTabWidget::pane { border: none; } + QTabBar::tab { padding: 8px 16px; background: #1c2833; + color: rgba(255,255,255,0.6); border: none; } + QTabBar::tab:selected { background: #2e4057; color: white; + font-weight: bold; } + 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_admin(), "Sécurité") + tabs.addTab(self._tab_about(), "À propos") + layout.addWidget(tabs) + + # Barre de boutons + bar = QWidget() + bar.setStyleSheet("background: #17202a;") + btn_row = QHBoxLayout(bar) + btn_row.setContentsMargins(12, 8, 12, 10) + + btn_cancel = QPushButton("Fermer sans sauvegarder") + btn_cancel.setStyleSheet( + "QPushButton { padding: 8px 20px; background: #2e4057; color: white;" + " border-radius: 5px; border: none; }" + "QPushButton:hover { background: #3d5166; }" + ) + btn_cancel.clicked.connect(self.reject) + + btn_save = QPushButton("Enregistrer et fermer") + btn_save.setStyleSheet( + "QPushButton { padding: 8px 20px; background: #1e8449; color: white;" + " border-radius: 5px; font-weight: bold; border: none; }" + "QPushButton:hover { background: #27ae60; }" + ) + btn_save.clicked.connect(self._save_and_close) + + btn_row.addStretch() + btn_row.addWidget(btn_cancel) + btn_row.addWidget(btn_save) + layout.addWidget(bar) + + # ------------------------------------------------------------------ # + # Onglet WireGuard + # ------------------------------------------------------------------ # + def _tab_wireguard(self) -> QWidget: + w, lay = self._dark_page("Configuration WireGuard", "#154360") + + grp = QGroupBox("Serveur WireGuard") + form = QFormLayout(grp) + self._srv_endpoint = QLineEdit() + self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4") + form.addRow("Adresse serveur :", self._srv_endpoint) + self._srv_port = QSpinBox() + self._srv_port.setRange(1, 65535) + self._srv_port.setValue(51820) + form.addRow("Port UDP :", self._srv_port) + self._srv_pubkey = QLineEdit() + self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") + form.addRow("Clé publique serveur :", self._srv_pubkey) + lay.addWidget(grp) + + grp2 = QGroupBox("Interface client") + form2 = QFormLayout(grp2) + self._iface_name = QLineEdit() + self._iface_name.setPlaceholderText("wgs0") + form2.addRow("Nom interface :", self._iface_name) + self._client_addr = QLineEdit() + self._client_addr.setPlaceholderText("10.8.0.2/24") + form2.addRow("Adresse IP client :", self._client_addr) + self._dns = QLineEdit() + self._dns.setPlaceholderText("1.1.1.1") + form2.addRow("DNS :", self._dns) + self._allowed_ips = QLineEdit() + self._allowed_ips.setPlaceholderText("0.0.0.0/0") + form2.addRow("IPs autorisées :", self._allowed_ips) + self._keepalive = QSpinBox() + self._keepalive.setRange(0, 300) + self._keepalive.setValue(25) + form2.addRow("Keepalive (s) :", self._keepalive) + lay.addWidget(grp2) + lay.addStretch() + return w + + # ------------------------------------------------------------------ # + # 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") + form = QFormLayout(grp) + self._priv_key = QLineEdit() + self._priv_key.setEchoMode(QLineEdit.EchoMode.Password) + self._priv_key.setPlaceholderText("(générer ou coller)") + show_priv = QPushButton("Afficher") + show_priv.setFixedWidth(80) + show_priv.setCheckable(True) + show_priv.toggled.connect( + lambda checked: self._priv_key.setEchoMode( + QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password + ) + ) + priv_row = QHBoxLayout() + priv_row.addWidget(self._priv_key) + priv_row.addWidget(show_priv) + form.addRow("Clé privée :", priv_row) + self._pub_key = QLineEdit() + self._pub_key.setReadOnly(True) + self._pub_key.setPlaceholderText("(dérivée automatiquement)") + form.addRow("Clé publique :", self._pub_key) + btn_gen = QPushButton("Générer une nouvelle paire de clés") + btn_gen.clicked.connect(self._generate_keys) + form.addRow("", btn_gen) + lay.addWidget(grp) + + grp2 = QGroupBox("Aperçu config WireGuard") + v2 = QVBoxLayout(grp2) + self._config_preview = QTextEdit() + self._config_preview.setReadOnly(True) + self._config_preview.setFont(QFont("Courier", 9)) + self._config_preview.setFixedHeight(150) + v2.addWidget(self._config_preview) + btn_preview = QPushButton("Rafraîchir l'aperçu") + btn_preview.clicked.connect(self._refresh_preview) + v2.addWidget(btn_preview) + lay.addWidget(grp2) + lay.addStretch() + return w + + # ------------------------------------------------------------------ # + # Onglet MFA + # ------------------------------------------------------------------ # + def _tab_mfa(self) -> QWidget: + w, lay = self._dark_page("Authentification Multi-Facteurs (MFA / TOTP)", "#0e6655") + + self._mfa_enabled_cb = QCheckBox("Activer la vérification MFA avant connexion") + self._mfa_enabled_cb.setStyleSheet("color: white; font-weight: bold;") + lay.addWidget(self._mfa_enabled_cb) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("color: rgba(255,255,255,0.15);") + lay.addWidget(sep) + + grp = QGroupBox("Secret TOTP") + form = QFormLayout(grp) + self._mfa_secret = QLineEdit() + self._mfa_secret.setPlaceholderText("(générer ou coller votre secret TOTP)") + form.addRow("Secret :", self._mfa_secret) + btn_gen_secret = QPushButton("Générer un nouveau secret") + btn_gen_secret.clicked.connect(self._generate_mfa_secret) + form.addRow("", btn_gen_secret) + lay.addWidget(grp) + + grp2 = QGroupBox("QR Code — Scanner avec Google Authenticator / Aegis") + v2 = QVBoxLayout(grp2) + self._qr_label = QLabel("(générez un secret pour afficher le QR Code)") + self._qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._qr_label.setFixedHeight(180) + v2.addWidget(self._qr_label) + self._mfa_uri_label = QLabel("") + self._mfa_uri_label.setWordWrap(True) + self._mfa_uri_label.setStyleSheet("font-size: 9px; color: rgba(255,255,255,0.55);") + v2.addWidget(self._mfa_uri_label) + btn_show_qr = QPushButton("Afficher le QR Code") + btn_show_qr.clicked.connect(self._show_qr) + v2.addWidget(btn_show_qr) + lay.addWidget(grp2) + lay.addStretch() + return w + + # ------------------------------------------------------------------ # + # Onglet Test connexion + # ------------------------------------------------------------------ # + def _tab_test(self) -> QWidget: + w, lay = self._dark_page("Test de connectivité réseau", "#1a4a7a") + + info = QLabel( + "Teste la joignabilité du serveur WireGuard (port UDP) avant d'établir le tunnel." + ) + info.setWordWrap(True) + info.setStyleSheet("color: white; font-size: 13px;") + lay.addWidget(info) + + btn_test = QPushButton("Lancer le test de connexion") + btn_test.setStyleSheet( + "QPushButton { padding: 10px; background: #1e8449; color: white;" + " border-radius: 5px; font-weight: bold; border: none; }" + "QPushButton:hover { background: #27ae60; }" + ) + btn_test.clicked.connect(self._run_test) + lay.addWidget(btn_test) + + self._test_result = QLabel("") + self._test_result.setWordWrap(True) + self._test_result.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._test_result.setStyleSheet( + "color: white; padding: 12px; border-radius: 6px; font-size: 13px;" + ) + lay.addWidget(self._test_result) + lay.addStretch() + return w + + # ------------------------------------------------------------------ # + # Onglet Sécurité + # ------------------------------------------------------------------ # + def _tab_admin(self) -> QWidget: + w, lay = self._dark_page("Sécurité — Accès Administrateur", "#6e2e1c") + + note = QLabel( + "Définissez un mot de passe pour protéger l'accès au panneau Administrateur.\n" + "Laissez vide pour désactiver la protection." + ) + note.setWordWrap(True) + note.setStyleSheet("color: rgba(255,255,255,0.8); font-size: 12px;") + lay.addWidget(note) + + grp = QGroupBox("Mot de passe administrateur") + form = QFormLayout(grp) + self._admin_pw1 = QLineEdit() + self._admin_pw1.setEchoMode(QLineEdit.EchoMode.Password) + self._admin_pw1.setPlaceholderText("Nouveau mot de passe") + form.addRow("Mot de passe :", self._admin_pw1) + self._admin_pw2 = QLineEdit() + self._admin_pw2.setEchoMode(QLineEdit.EchoMode.Password) + self._admin_pw2.setPlaceholderText("Confirmer") + form.addRow("Confirmation :", self._admin_pw2) + btn_set_pw = QPushButton("Définir le mot de passe") + btn_set_pw.setStyleSheet( + "QPushButton { padding: 8px; background: #922b21; color: white;" + " border-radius: 5px; border: none; }" + "QPushButton:hover { background: #c0392b; }" + ) + btn_set_pw.clicked.connect(self._set_admin_password) + form.addRow("", btn_set_pw) + lay.addWidget(grp) + lay.addStretch() + return w + + # ------------------------------------------------------------------ # + # Onglet À propos + # ------------------------------------------------------------------ # + def _tab_about(self) -> QWidget: + w = QWidget() + w.setStyleSheet(_TAB_CSS) + w.setAutoFillBackground(True) + layout = QVBoxLayout(w) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.setSpacing(12) + layout.setContentsMargins(30, 24, 30, 24) + + from app.ui.icons import icon_app + logo_lbl = QLabel() + logo_lbl.setPixmap(icon_app().pixmap(86, 86)) + logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(logo_lbl) + + name_lbl = QLabel("WGSecure") + name_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + f = QFont(); f.setPointSize(22); f.setBold(True) + name_lbl.setFont(f) + name_lbl.setStyleSheet("color: white;") + layout.addWidget(name_lbl) + + version_lbl = QLabel("Version 0.1.0") + version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;") + layout.addWidget(version_lbl) + + sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("color: rgba(255,255,255,0.15);") + layout.addWidget(sep) + + desc_lbl = QLabel( + "Interface graphique WireGuard avec surcouche MFA TOTP.\n" + "Compatible Linux et Windows." + ) + desc_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + desc_lbl.setWordWrap(True) + desc_lbl.setStyleSheet("color: white; font-size: 13px;") + layout.addWidget(desc_lbl) + + sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet("color: rgba(255,255,255,0.15);") + layout.addWidget(sep2) + + author_lbl = QLabel("Développé par") + author_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + author_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 12px;") + layout.addWidget(author_lbl) + + brand_lbl = QLabel("JT-Tools by Johnny") + brand_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + fb = QFont(); fb.setPointSize(15); fb.setBold(True) + brand_lbl.setFont(fb) + brand_lbl.setStyleSheet("color: #5dade2;") + layout.addWidget(brand_lbl) + + date_lbl = QLabel("Juin 2026") + date_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + date_lbl.setStyleSheet("color: rgba(255,255,255,0.55); font-size: 12px;") + layout.addWidget(date_lbl) + + layout.addStretch() + return w + + # ------------------------------------------------------------------ # + # Chargement / sauvegarde + # ------------------------------------------------------------------ # + def _load_values(self): + wg = self._cfg.wg + self._srv_endpoint.setText(wg.get("server_endpoint", "")) + self._srv_port.setValue(int(wg.get("server_port", 51820))) + self._srv_pubkey.setText(wg.get("server_public_key", "")) + self._iface_name.setText(wg.get("interface_name", "wgs0")) + self._client_addr.setText(wg.get("client_address", "10.8.0.2/24")) + self._dns.setText(wg.get("dns", "1.1.1.1")) + self._allowed_ips.setText(wg.get("allowed_ips", "0.0.0.0/0")) + self._keepalive.setValue(int(wg.get("keepalive", 25))) + self._priv_key.setText(wg.get("client_private_key", "")) + self._pub_key.setText(wg.get("client_public_key", "")) + self._mfa_enabled_cb.setChecked(self._cfg.mfa_enabled) + self._mfa_secret.setText(self._cfg.mfa_secret) + + def _save_values(self): + self._cfg.set("wg", "server_endpoint", self._srv_endpoint.text().strip()) + self._cfg.set("wg", "server_port", self._srv_port.value()) + self._cfg.set("wg", "server_public_key", self._srv_pubkey.text().strip()) + self._cfg.set("wg", "interface_name", self._iface_name.text().strip() or "wgs0") + self._cfg.set("wg", "client_address", self._client_addr.text().strip()) + self._cfg.set("wg", "dns", self._dns.text().strip()) + self._cfg.set("wg", "allowed_ips", self._allowed_ips.text().strip()) + self._cfg.set("wg", "keepalive", self._keepalive.value()) + self._cfg.set("wg", "client_private_key", self._priv_key.text().strip()) + self._cfg.set("wg", "client_public_key", self._pub_key.text().strip()) + self._cfg.mfa_enabled = self._mfa_enabled_cb.isChecked() + self._cfg.mfa_secret = self._mfa_secret.text().strip() + self._cfg.save() + + def _save_and_close(self): + self._save_values() + QMessageBox.information(self, "Sauvegardé", "Configuration enregistrée avec succès.") + self.accept() + + # ------------------------------------------------------------------ # + # Actions + # ------------------------------------------------------------------ # + def _generate_keys(self): + priv, pub = wg_core.generate_keypair() + self._priv_key.setText(priv) + self._pub_key.setText(pub) + self._refresh_preview() + QMessageBox.information( + self, "Clés générées", + "Nouvelle paire de clés générée.\n\n" + "⚠ Copiez la clé publique sur votre serveur WireGuard." + ) + + def _refresh_preview(self): + self._save_values() + self._config_preview.setPlainText(wg_core.build_client_config(self._cfg)) + + def _generate_mfa_secret(self): + secret = mfa_core.generate_secret() + self._mfa_secret.setText(secret) + self._show_qr() + + def _show_qr(self): + secret = self._mfa_secret.text().strip() + if not secret: + QMessageBox.warning(self, "Erreur", "Aucun secret MFA renseigné.") + return + pixmap = mfa_core.generate_qr_pixmap(secret) + self._qr_label.setPixmap( + pixmap.scaled(160, 160, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + ) + self._mfa_uri_label.setText(mfa_core.get_provisioning_uri(secret)) + + def _run_test(self): + self._save_values() + self._test_result.setText("Test en cours…") + self._test_result.setStyleSheet( + "color: rgba(255,255,255,0.6); padding: 12px; border-radius: 6px; font-size: 13px;" + ) + from PyQt6.QtWidgets import QApplication + QApplication.processEvents() + ok, msg = wg_core.test_connection(self._cfg) + if ok: + self._test_result.setText(f"✓ {msg}") + self._test_result.setStyleSheet( + "padding: 12px; border-radius: 6px; font-size: 13px; font-weight: bold;" + "background: rgba(39,174,96,0.25); color: #a9dfbf;" + ) + else: + self._test_result.setText(f"✗ {msg}") + self._test_result.setStyleSheet( + "padding: 12px; border-radius: 6px; font-size: 13px; font-weight: bold;" + "background: rgba(231,76,60,0.25); color: #f1948a;" + ) + + def _set_admin_password(self): + p1 = self._admin_pw1.text() + p2 = self._admin_pw2.text() + if p1 != p2: + QMessageBox.warning(self, "Erreur", "Les mots de passe ne correspondent pas.") + return + self._cfg.set_admin_password(p1) + self._admin_pw1.clear() + self._admin_pw2.clear() + msg = "Mot de passe supprimé." if not p1 else "Mot de passe administrateur mis à jour." + QMessageBox.information(self, "Succès", msg) diff --git a/app/ui/icons.py b/app/ui/icons.py new file mode 100644 index 0000000..994040c --- /dev/null +++ b/app/ui/icons.py @@ -0,0 +1,106 @@ +"""Icônes WGSecure — bouclier VPN dessiné via QPainter.""" +from PyQt6.QtGui import ( + QIcon, QPixmap, QPainter, QColor, QBrush, QPen, + QFont, QPainterPath, QLinearGradient, +) +from PyQt6.QtCore import Qt, QRect, QPointF + + +def _shield( + size: int, + body_top: str, + body_bot: str, + dot_color: str, + dot: bool = True, +) -> QPixmap: + """Dessine un bouclier avec dégradé vertical et un indicateur coloré.""" + pix = QPixmap(size, size) + pix.fill(Qt.GlobalColor.transparent) + p = QPainter(pix) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + s = size + m = s * 0.06 # marge + + # --- Forme bouclier --- + path = QPainterPath() + path.moveTo(s / 2, m) + path.lineTo(s - m, s * 0.25) + path.cubicTo( + s - m, s * 0.65, + s * 0.72, s * 0.85, + s / 2, s - m, + ) + path.cubicTo( + s * 0.28, s * 0.85, + m, s * 0.65, + m, s * 0.25, + ) + path.closeSubpath() + + # Dégradé vertical corps du bouclier + grad = QLinearGradient(QPointF(s / 2, m), QPointF(s / 2, s - m)) + grad.setColorAt(0.0, QColor(body_top)) + grad.setColorAt(1.0, QColor(body_bot)) + p.setBrush(QBrush(grad)) + p.setPen(Qt.PenStyle.NoPen) + p.drawPath(path) + + # Liseré blanc semi-transparent + pen = QPen(QColor(255, 255, 255, 60)) + pen.setWidthF(s * 0.04) + p.setPen(pen) + p.setBrush(Qt.BrushStyle.NoBrush) + p.drawPath(path) + + # --- "W" centré --- + p.setPen(QPen(QColor(255, 255, 255, 230))) + font = QFont("Arial", max(7, int(s * 0.38)), QFont.Weight.Bold) + p.setFont(font) + rect = QRect(int(s * 0.08), int(s * 0.22), int(s * 0.84), int(s * 0.55)) + p.drawText(rect, Qt.AlignmentFlag.AlignCenter, "W") + + # --- Point de statut (coin bas-droit) --- + if dot: + dr = s * 0.22 + dx = s - m - dr * 0.5 + dy = s * 0.68 + # Halo blanc + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(QColor(255, 255, 255, 200))) + p.drawEllipse(QPointF(dx, dy), dr * 0.72, dr * 0.72) + # Dot coloré + p.setBrush(QBrush(QColor(dot_color))) + p.drawEllipse(QPointF(dx, dy), dr * 0.55, dr * 0.55) + + p.end() + return pix + + +def icon_connected(size: int = 32) -> QIcon: + return QIcon(_shield(size, "#1a7a4a", "#0d5c35", "#2ecc71", dot=True)) + + +def icon_disconnected(size: int = 32) -> QIcon: + return QIcon(_shield(size, "#2c3e50", "#1a252f", "#e74c3c", dot=True)) + + +def icon_connecting(size: int = 32) -> QIcon: + return QIcon(_shield(size, "#7d5a00", "#4a3500", "#f39c12", dot=True)) + + +def icon_app(size: int = 32) -> QIcon: + """Icône principale multi-tailles — bouclier bleu sans point de statut. + Plusieurs résolutions pour la barre des tâches, l'alt-tab et le systray.""" + ico = QIcon() + for s in (16, 24, 32, 48, 64, 128, 256): + ico.addPixmap(_shield(s, "#2980b9", "#1a5276", "#ffffff", dot=False)) + return ico + + +def icon_admin(size: int = 32) -> QIcon: + return QIcon(_shield(size, "#6c3483", "#4a235a", "#9b59b6", dot=True)) + + +def pixmap_app(size: int = 64) -> QPixmap: + return _shield(size, "#2980b9", "#1a5276", "#ffffff", dot=False) diff --git a/app/ui/main_window.py b/app/ui/main_window.py new file mode 100644 index 0000000..05906cd --- /dev/null +++ b/app/ui/main_window.py @@ -0,0 +1,297 @@ +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QFrame, QMessageBox, QInputDialog, + QLineEdit, +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QFont, QCloseEvent, QPalette, QColor + +from app.core.config import Config +from app.core import wireguard as wg_core +from app.core import mfa as mfa_core +from app.ui.mfa_dialog import MFADialog +from app.ui.admin_window import AdminWindow +from app.ui import icons + + +class MainWindow(QMainWindow): + def __init__(self, config: Config, parent=None): + super().__init__(parent) + self._cfg = config + self._connecting = False + self.setWindowTitle("WGSecure") + self.setFixedSize(400, 520) + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMinimizeButtonHint + ) + self.setWindowIcon(icons.icon_app()) + self._build_ui() + self._status_timer = QTimer(self) + self._status_timer.timeout.connect(self._refresh_status) + self._status_timer.start(3000) + self._refresh_status() + + # ------------------------------------------------------------------ # + # Construction UI + # ------------------------------------------------------------------ # + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # -- En-tête -------------------------------------------------- + header = QWidget() + header.setFixedHeight(72) + header.setStyleSheet("background: #2c3e50;") + h_layout = QHBoxLayout(header) + h_layout.setContentsMargins(16, 10, 16, 10) + + self._icon_label = QLabel() + self._icon_label.setFixedSize(44, 44) + self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + h_layout.addWidget(self._icon_label) + + title_col = QVBoxLayout() + title_col.setSpacing(2) + app_title = QLabel("WGSecure") + app_title.setStyleSheet("color: white; font-size: 17px; font-weight: bold; background: transparent;") + version_label = QLabel("v0.1.0 — WireGuard + MFA") + version_label.setStyleSheet("color: #95a5a6; font-size: 10px; background: transparent;") + title_col.addWidget(app_title) + title_col.addWidget(version_label) + h_layout.addLayout(title_col) + h_layout.addStretch() + layout.addWidget(header) + + # -- Zone statut ---------------------------------------------- + status_area = QWidget() + status_area.setFixedHeight(90) + status_area.setStyleSheet("background: #ecf0f1;") + s_layout = QVBoxLayout(status_area) + s_layout.setContentsMargins(10, 10, 10, 10) + s_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self._status_badge = QLabel("● Déconnecté") + self._status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status_badge.setStyleSheet("color: #e74c3c; font-size: 16px; font-weight: bold; background: transparent;") + s_layout.addWidget(self._status_badge) + + self._status_sub = QLabel("") + self._status_sub.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status_sub.setStyleSheet("color: #7f8c8d; font-size: 11px; background: transparent;") + s_layout.addWidget(self._status_sub) + layout.addWidget(status_area) + + # -- Séparateur ----------------------------------------------- + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("color: #bdc3c7;") + layout.addWidget(sep) + + # -- Section infos -------------------------------------------- + info_frame = QFrame() + info_frame.setFrameShape(QFrame.Shape.NoFrame) + info_frame.setStyleSheet("background-color: #ffffff;") + info_frame.setAutoFillBackground(True) + i_layout = QVBoxLayout(info_frame) + i_layout.setContentsMargins(16, 12, 16, 12) + i_layout.setSpacing(8) + + self._info_labels: dict[str, QLabel] = {} + rows = [ + ("server", "Serveur"), + ("iface", "Interface"), + ("addr", "Adresse IP"), + ("mfa", "MFA"), + ("rx_tx", "Transfert"), + ("handshake", "Dernier handshake"), + ] + for key, label in rows: + row_w = QWidget() + row_w.setStyleSheet("background: transparent;") + row_layout = QHBoxLayout(row_w) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(8) + + lbl_key = QLabel(f"{label} :") + lbl_key.setFixedWidth(130) + lbl_key.setStyleSheet("color: #7f8c8d; font-size: 12px; background: transparent;") + + lbl_val = QLabel("—") + lbl_val.setStyleSheet("color: #2c3e50; font-size: 12px; background: transparent;") + lbl_val.setWordWrap(True) + self._info_labels[key] = lbl_val + + row_layout.addWidget(lbl_key) + row_layout.addWidget(lbl_val, 1) + i_layout.addWidget(row_w) + + layout.addWidget(info_frame, 1) # stretch factor 1 — prend l'espace disponible + + # -- Séparateur bas ------------------------------------------- + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet("color: #bdc3c7;") + layout.addWidget(sep2) + + # -- Boutons -------------------------------------------------- + btn_area = QWidget() + btn_area.setStyleSheet("background: #f8f9fa;") + b_layout = QVBoxLayout(btn_area) + b_layout.setContentsMargins(16, 12, 16, 12) + b_layout.setSpacing(8) + + self._btn_connect = QPushButton("Se connecter") + self._btn_connect.setFixedHeight(42) + self._btn_connect.setStyleSheet( + "QPushButton { background: #27ae60; color: white; font-size: 14px; " + "font-weight: bold; border-radius: 6px; border: none; }" + "QPushButton:hover { background: #219a52; }" + "QPushButton:disabled { background: #bdc3c7; color: #ecf0f1; }" + ) + self._btn_connect.clicked.connect(self._on_connect) + b_layout.addWidget(self._btn_connect) + + btn_admin = QPushButton("Panneau Administrateur") + btn_admin.setFixedHeight(34) + btn_admin.setStyleSheet( + "QPushButton { background: #dde1e7; color: #2c3e50; border-radius: 6px; " + "font-size: 12px; border: none; }" + "QPushButton:hover { background: #bdc3c7; color: #2c3e50; }" + ) + btn_admin.clicked.connect(self._open_admin) + b_layout.addWidget(btn_admin) + layout.addWidget(btn_area) + + # ------------------------------------------------------------------ # + # Rafraîchissement du statut + # ------------------------------------------------------------------ # + def _refresh_status(self): + wg = self._cfg.wg + srv = wg.get("server_endpoint", "") + port = wg.get("server_port", "") + self._info_labels["server"].setText(f"{srv}:{port}" if srv else "—") + self._info_labels["iface"].setText(wg.get("interface_name", "wgs0")) + self._info_labels["addr"].setText(wg.get("client_address", "—")) + self._info_labels["mfa"].setText( + "Activé ✓" if self._cfg.mfa_enabled else "Désactivé" + ) + + try: + info = wg_core.get_status_info(self._cfg) + connected = info["connected"] + except Exception: + connected = False + info = {} + + if connected: + self._status_badge.setText("● Connecté") + self._status_badge.setStyleSheet( + "color: #27ae60; font-size: 16px; font-weight: bold; background: transparent;" + ) + self._btn_connect.setText("Se déconnecter") + self._btn_connect.setStyleSheet( + "QPushButton { background: #e74c3c; color: white; font-size: 14px; " + "font-weight: bold; border-radius: 6px; border: none; }" + "QPushButton:hover { background: #c0392b; }" + ) + self._info_labels["rx_tx"].setText( + f"↓ {info.get('rx_bytes','—')} ↑ {info.get('tx_bytes','—')}" + ) + self._info_labels["handshake"].setText(info.get("last_handshake", "—")) + self._status_sub.setText(f"Interface active : {info.get('interface','—')}") + self.setWindowIcon(icons.icon_connected()) + else: + self._status_badge.setText("● Déconnecté") + self._status_badge.setStyleSheet( + "color: #e74c3c; font-size: 16px; font-weight: bold; background: transparent;" + ) + self._btn_connect.setText("Se connecter") + self._btn_connect.setStyleSheet( + "QPushButton { background: #27ae60; color: white; font-size: 14px; " + "font-weight: bold; border-radius: 6px; border: none; }" + "QPushButton:hover { background: #219a52; }" + ) + self._info_labels["rx_tx"].setText("—") + self._info_labels["handshake"].setText("—") + self._status_sub.setText("") + self.setWindowIcon(icons.icon_disconnected()) + + pix = (icons.icon_connected() if connected else icons.icon_disconnected()).pixmap(36, 36) + self._icon_label.setPixmap(pix) + + # ------------------------------------------------------------------ # + # Actions + # ------------------------------------------------------------------ # + def _on_connect(self): + if self._connecting: + return + try: + connected = wg_core.is_connected(self._cfg) + except Exception: + connected = False + + if connected: + ok, msg = wg_core.disconnect(self._cfg) + if not ok: + QMessageBox.warning(self, "Erreur", f"Déconnexion échouée :\n{msg}") + else: + if not self._cfg.configured: + QMessageBox.information( + self, "Configuration manquante", + "WireGuard n'est pas encore configuré.\n" + "Ouvrez le panneau Administrateur pour paramétrer la connexion." + ) + return + if self._cfg.mfa_enabled: + if not self._cfg.mfa_secret: + QMessageBox.warning( + self, "MFA non configuré", + "Le MFA est activé mais aucun secret n'est configuré." + ) + return + dlg = MFADialog(self._cfg.mfa_secret, self) + if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified(): + return + + self._btn_connect.setEnabled(False) + self._btn_connect.setText("Connexion…") + self._connecting = True + from PyQt6.QtWidgets import QApplication + QApplication.processEvents() + + ok, msg = wg_core.connect(self._cfg) + self._connecting = False + self._btn_connect.setEnabled(True) + if not ok: + QMessageBox.warning(self, "Erreur de connexion", msg) + + self._refresh_status() + + def _open_admin(self): + if self._cfg.has_admin_password(): + pw, ok = QInputDialog.getText( + self, "Accès Administrateur", + "Mot de passe administrateur :", + QLineEdit.EchoMode.Password, + ) + if not ok: + return + if not self._cfg.check_admin_password(pw): + QMessageBox.warning(self, "Refusé", "Mot de passe incorrect.") + return + dlg = AdminWindow(self._cfg, self) + dlg.exec() + self._refresh_status() + + def closeEvent(self, event: QCloseEvent): + if self._cfg.get("ui", "minimize_to_tray"): + event.ignore() + self.hide() + else: + event.accept() diff --git a/app/ui/mfa_dialog.py b/app/ui/mfa_dialog.py new file mode 100644 index 0000000..e850df1 --- /dev/null +++ b/app/ui/mfa_dialog.py @@ -0,0 +1,130 @@ +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QProgressBar, QFrame, +) +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtGui import QFont +from app.core import mfa as mfa_core + + +class MFADialog(QDialog): + def __init__(self, secret: str, parent=None): + super().__init__(parent) + self._secret = secret + self._verified = False + self.setWindowTitle("WGSecure — Authentification MFA") + self.setFixedWidth(340) + self.setModal(True) + self._build_ui() + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + self._timer.start(500) + self._tick() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setSpacing(12) + layout.setContentsMargins(20, 20, 20, 20) + + title = QLabel("Vérification en deux étapes") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + f = QFont() + f.setPointSize(13) + f.setBold(True) + title.setFont(f) + layout.addWidget(title) + + sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.") + sub.setWordWrap(True) + sub.setAlignment(Qt.AlignmentFlag.AlignCenter) + sub.setStyleSheet("color: #555;") + layout.addWidget(sub) + + sep = QFrame() + sep.setFrameShape(QFrame.Shape.HLine) + sep.setStyleSheet("color: #ddd;") + layout.addWidget(sep) + + self._code_input = QLineEdit() + self._code_input.setPlaceholderText("000 000") + self._code_input.setMaxLength(7) + self._code_input.setAlignment(Qt.AlignmentFlag.AlignCenter) + f2 = QFont("Courier", 20) + f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4) + self._code_input.setFont(f2) + self._code_input.setStyleSheet( + "padding: 8px; border: 2px solid #3498db; border-radius: 6px;" + ) + self._code_input.returnPressed.connect(self._verify) + layout.addWidget(self._code_input) + + self._error_label = QLabel("") + self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._error_label.setStyleSheet("color: #e74c3c; font-weight: bold;") + layout.addWidget(self._error_label) + + self._progress = QProgressBar() + self._progress.setRange(0, 30) + self._progress.setTextVisible(False) + self._progress.setFixedHeight(6) + self._progress.setStyleSheet( + "QProgressBar { border-radius: 3px; background: #ecf0f1; }" + "QProgressBar::chunk { background: #3498db; border-radius: 3px; }" + ) + layout.addWidget(self._progress) + + self._timer_label = QLabel("") + self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._timer_label.setStyleSheet("color: #999; font-size: 11px;") + layout.addWidget(self._timer_label) + + btn_row = QHBoxLayout() + btn_cancel = QPushButton("Annuler") + btn_cancel.setStyleSheet( + "QPushButton { padding: 8px 16px; border-radius: 5px; " + "background: #ecf0f1; } QPushButton:hover { background: #bdc3c7; }" + ) + btn_cancel.clicked.connect(self.reject) + + self._btn_ok = QPushButton("Vérifier") + self._btn_ok.setDefault(True) + self._btn_ok.setStyleSheet( + "QPushButton { padding: 8px 20px; border-radius: 5px; " + "background: #3498db; color: white; font-weight: bold; }" + "QPushButton:hover { background: #2980b9; }" + ) + self._btn_ok.clicked.connect(self._verify) + btn_row.addWidget(btn_cancel) + btn_row.addStretch() + btn_row.addWidget(self._btn_ok) + layout.addLayout(btn_row) + + def _tick(self): + remaining = mfa_core.time_remaining() + self._progress.setValue(remaining) + self._timer_label.setText(f"Code valide encore {remaining}s") + + def _verify(self): + raw = self._code_input.text().replace(" ", "").replace("-", "") + if len(raw) != 6 or not raw.isdigit(): + self._error_label.setText("Entrez exactement 6 chiffres.") + self._code_input.setStyleSheet( + "padding: 8px; border: 2px solid #e74c3c; border-radius: 6px;" + ) + return + if mfa_core.verify_code(self._secret, raw): + self._verified = True + self.accept() + else: + self._error_label.setText("Code incorrect. Réessayez.") + self._code_input.clear() + self._code_input.setStyleSheet( + "padding: 8px; border: 2px solid #e74c3c; border-radius: 6px;" + ) + + def is_verified(self) -> bool: + return self._verified + + def closeEvent(self, event): + self._timer.stop() + super().closeEvent(event) diff --git a/app/ui/systray.py b/app/ui/systray.py new file mode 100644 index 0000000..7cadf88 --- /dev/null +++ b/app/ui/systray.py @@ -0,0 +1,97 @@ +from PyQt6.QtWidgets import QSystemTrayIcon, QMenu, QApplication +from PyQt6.QtGui import QAction +from PyQt6.QtCore import QTimer + +from app.core.config import Config +from app.core import wireguard as wg_core +from app.ui import icons + + +class SystemTray(QSystemTrayIcon): + def __init__(self, config: Config, main_window, parent=None): + super().__init__(icons.icon_disconnected(), parent) + self._cfg = config + self._win = main_window + self._connected = False + self._build_menu() + self.setToolTip("WGSecure — Déconnecté") + self.activated.connect(self._on_activated) + + self._poll = QTimer(parent) + self._poll.timeout.connect(self._update_status) + self._poll.start(5000) + self._update_status() + + def _build_menu(self): + menu = QMenu() + + # Ligne de statut (désactivée) + self._action_status = QAction("● Déconnecté", self) + self._action_status.setEnabled(False) + menu.addAction(self._action_status) + + menu.addSeparator() + + # Afficher la fenêtre + self._action_show = QAction(icons.icon_app(), "Afficher la fenêtre", self) + self._action_show.triggered.connect(self._show_window) + menu.addAction(self._action_show) + + menu.addSeparator() + + # Connexion / déconnexion + self._action_toggle = QAction(icons.icon_disconnected(), "Se connecter", self) + self._action_toggle.triggered.connect(self._toggle_connection) + menu.addAction(self._action_toggle) + + menu.addSeparator() + + # Panneau de configuration + self._action_config = QAction(icons.icon_admin(), "Configuration", self) + self._action_config.triggered.connect(self._win._open_admin) + menu.addAction(self._action_config) + + menu.addSeparator() + + # Quitter + self._action_quit = QAction("Quitter", self) + self._action_quit.triggered.connect(self._quit) + menu.addAction(self._action_quit) + + self.setContextMenu(menu) + + def _on_activated(self, reason: QSystemTrayIcon.ActivationReason): + if reason == QSystemTrayIcon.ActivationReason.Trigger: + self._show_window() + + def _show_window(self): + self._win.show() + self._win.raise_() + self._win.activateWindow() + + def _update_status(self): + try: + self._connected = wg_core.is_connected(self._cfg) + except Exception: + self._connected = False + + if self._connected: + self.setIcon(icons.icon_connected()) + self.setToolTip("WGSecure — Connecté") + self._action_status.setText("● Connecté") + self._action_toggle.setIcon(icons.icon_connected()) + self._action_toggle.setText("Se déconnecter") + else: + self.setIcon(icons.icon_disconnected()) + self.setToolTip("WGSecure — Déconnecté") + self._action_status.setText("● Déconnecté") + self._action_toggle.setIcon(icons.icon_disconnected()) + self._action_toggle.setText("Se connecter") + + def _toggle_connection(self): + self._win._on_connect() + self._update_status() + + def _quit(self): + self._poll.stop() + QApplication.quit() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/platform_utils.py b/app/utils/platform_utils.py new file mode 100644 index 0000000..76b575e --- /dev/null +++ b/app/utils/platform_utils.py @@ -0,0 +1,123 @@ +import sys +import os +import subprocess +import platform + + +def is_windows() -> bool: + return sys.platform == "win32" + + +def is_linux() -> bool: + return sys.platform.startswith("linux") + + +def get_config_dir() -> str: + if is_windows(): + base = os.environ.get("APPDATA", os.path.expanduser("~")) + path = os.path.join(base, "WGSecure") + else: + path = os.path.join(os.path.expanduser("~"), ".wgsecure") + os.makedirs(path, exist_ok=True) + return path + + +def get_wg_config_dir() -> str: + if is_windows(): + base = os.environ.get("PROGRAMDATA", r"C:\ProgramData") + return os.path.join(base, "WireGuard") + return "/etc/wireguard" + + +def has_root_privileges() -> bool: + if is_windows(): + try: + import ctypes + return ctypes.windll.shell32.IsUserAnAdmin() != 0 + except Exception: + return False + return os.geteuid() == 0 + + +def run_command(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]: + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + return result.returncode, result.stdout.strip(), result.stderr.strip() + except subprocess.TimeoutExpired: + return -1, "", "Timeout" + except FileNotFoundError: + return -1, "", f"Commande introuvable : {cmd[0]}" + except Exception as e: + return -1, "", str(e) + + +def run_privileged(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]: + if is_windows(): + return run_command(cmd, timeout) + if has_root_privileges(): + return run_command(cmd, timeout) + # Tente pkexec puis sudo + for elevator in ("pkexec", "sudo"): + code, out, err = run_command([elevator] + cmd, timeout) + if code != -1 or "introuvable" not in err: + return code, out, err + return -1, "", "Élévation de privilèges impossible" + + +def wg_available() -> bool: + code, _, _ = run_command(["wg", "--version"]) + return code == 0 + + +def wg_quick_available() -> bool: + if is_windows(): + code, _, _ = run_command(["wireguard", "--help"]) + return code == 0 + code, _, _ = run_command(["wg-quick", "--help"]) + return code == 0 + + +def install_desktop_entry(icon_png_path: str) -> None: + """Installe l'entrée .desktop et l'icône PNG pour la barre des tâches GNOME/KDE. + Appelé une fois au démarrage ; sans effet sur Windows.""" + if is_windows(): + return + try: + # Icône dans le thème hicolor + icon_dir = os.path.expanduser("~/.local/share/icons/hicolor/256x256/apps") + os.makedirs(icon_dir, exist_ok=True) + dest_icon = os.path.join(icon_dir, "wgsecure.png") + import shutil + shutil.copy2(icon_png_path, dest_icon) + + # Fichier .desktop + app_dir = os.path.expanduser("~/.local/share/applications") + os.makedirs(app_dir, exist_ok=True) + script = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "main.py") + ) + desktop = os.path.join(app_dir, "wgsecure.desktop") + content = ( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=WGSecure\n" + "Comment=WireGuard GUI avec MFA TOTP\n" + f"Exec=python3 {script}\n" + "Icon=wgsecure\n" + "Categories=Network;Security;\n" + "StartupWMClass=main\n" + "Terminal=false\n" + ) + with open(desktop, "w") as f: + f.write(content) + + # Mettre à jour le cache d'icônes si disponible + run_command(["gtk-update-icon-cache", "-f", "-t", + os.path.expanduser("~/.local/share/icons/hicolor")]) + except Exception: + pass diff --git a/main.py b/main.py new file mode 100644 index 0000000..4da72e4 --- /dev/null +++ b/main.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +WGSecure (WGS) — Interface graphique WireGuard avec MFA +Version 0.1.0 +""" +import sys +import argparse + +from PyQt6.QtWidgets import QApplication, QMessageBox, QInputDialog, QLineEdit, QSystemTrayIcon +from PyQt6.QtCore import Qt + +from app.core.config import Config +from app.ui.main_window import MainWindow +from app.ui.systray import SystemTray +from app.ui import icons +from app.utils.platform_utils import install_desktop_entry + + +def _install_taskbar_icon(app_icon) -> None: + """Exporte l'icône en PNG 256 px et installe l'entrée .desktop.""" + try: + import tempfile, os + tmp = os.path.join(tempfile.gettempdir(), "wgsecure_icon.png") + app_icon.pixmap(256, 256).save(tmp, "PNG") + install_desktop_entry(tmp) + except Exception: + pass + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="wgsecure", + description="WGSecure — WireGuard GUI avec MFA", + ) + parser.add_argument( + "--admin", + action="store_true", + help="Lancer directement en mode Administrateur", + ) + parser.add_argument( + "--no-tray", + action="store_true", + help="Désactiver l'icône systray", + ) + return parser.parse_args() + + +def require_admin_auth(config: Config, app: QApplication) -> bool: + """Vérifie le mot de passe admin si configuré. Retourne True si autorisé.""" + if not config.has_admin_password(): + return True + pw, ok = QInputDialog.getText( + None, + "Accès Administrateur", + "Mot de passe administrateur :", + QLineEdit.EchoMode.Password, + ) + if not ok: + return False + if config.check_admin_password(pw): + return True + QMessageBox.warning(None, "WGSecure", "Mot de passe incorrect.") + return False + + +def main(): + args = parse_args() + + # Support DPI haute résolution + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + app = QApplication(sys.argv) + app.setApplicationName("WGSecure") + app.setApplicationVersion("0.1.0") + app.setOrganizationName("WGS") + # Icône globale multi-tailles — barre des tâches + alt-tab + app_icon = icons.icon_app() + app.setWindowIcon(app_icon) + app.setDesktopFileName("wgsecure") + # Installe .desktop + PNG pour que GNOME/KDE affiche l'icône sur la taskbar + _install_taskbar_icon(app_icon) + # Empêche la fermeture quand toutes les fenêtres sont cachées (tray) + app.setQuitOnLastWindowClosed(False) + + config = Config() + + # Mode Admin direct (--admin) + if args.admin: + if not require_admin_auth(config, app): + sys.exit(0) + from app.ui.admin_window import AdminWindow + win = AdminWindow(config) + win.setWindowIcon(icons.icon_admin()) + win.show() + sys.exit(app.exec()) + + # Mode User (défaut) + window = MainWindow(config) + + # Systray + if not args.no_tray and QSystemTrayIcon_available(): + tray = SystemTray(config, window) + tray.show() + # Démarrer réduit si déjà configuré + if config.configured: + window.show() + else: + window.show() + tray.showMessage( + "WGSecure", + "Bienvenue ! Ouvrez le panneau Administrateur pour configurer WireGuard.", + QSystemTrayIcon.MessageIcon.Information, + 4000, + ) + else: + window.show() + + sys.exit(app.exec()) + + +def QSystemTrayIcon_available() -> bool: + from PyQt6.QtWidgets import QSystemTrayIcon + return QSystemTrayIcon.isSystemTrayAvailable() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..534f61e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyQt6>=6.4.0 +cryptography>=41.0.0 +pyotp>=2.9.0 +qrcode>=7.4.2 +Pillow>=10.0.0