diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e038f0..d83749d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). --- +## [0.2.0] — 2026-06-01 + +### Ajouté +- **Onglet "À propos"** dans le panneau admin : logo bouclier, version, branding JT-Tools by Johnny +- **Icône bouclier vectorielle** générée par QPainter (7 résolutions : 16→256 px) + - Vert connecté / Rouge déconnecté / Orange connexion en cours / Bleu app / Violet admin +- **Icône barre des tâches** : installation automatique de `~/.local/share/icons/wgsecure.png` + et `~/.local/share/applications/wgsecure.desktop` au premier démarrage +- **Thème sombre uniforme** sur les 6 onglets du panneau admin (CSS partagé `_TAB_CSS`) + - Fond `#1c2833`, texte blanc, champs semi-transparents + - Bandeau coloré par onglet (bleu/navy/teal/bleu/rouge) + - TabBar cohérente avec onglet actif mis en évidence +- **Makefile** : compilation binaire Linux (`make linux`) et Windows (`make windows` via Wine) +- **README** enrichi avec badges shields.io (version, Python, PyQt6, plateformes) et emojis + +### Corrigé +- **Bug MDP vide** : `set_admin_password("")` effaçait incorrectement — hashait la chaîne vide, + forçant une demande de mot de passe vide après suppression +- **Menu systray incomplet** : `QAction` en variables locales GC'd par Python avant affichage → + toutes les actions passent désormais `self` comme parent Qt +- **Section infos invisible** : `QWidget` sans `autoFillBackground` ne peignait pas son fond ; + remplacé par `QFrame` avec `setAutoFillBackground(True)` et couleurs explicites +- **Bouton agrandir persistant** : `setWindowFlag` seul ignoré par Mutter/XWayland → + `setWindowFlags()` complet sans `WindowMaximizeButtonHint` + +### Amélioré +- Fenêtre principale agrandie 380×460 → 400×520 pour afficher les 6 lignes d'infos +- Police des labels infos : 11 → 12 px, couleurs explicites `#2c3e50` +- Panneau admin : largeur 620 → 700 px pour éviter le débordement des onglets +- `app.setWindowIcon()` posé sur `QApplication` (multi-tailles) + `setDesktopFileName()` +- Page About : icône 96 → 86 px (−10 %), texte en blanc sur fond sombre `#1c2833` + +--- + ## [0.1.0] — 2026-06-01 ### Ajouté @@ -14,23 +48,21 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/). - **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 + - **Clés** : génération de paires Curve25519 (clé privée / publique), aperçu 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`) +- Surcouche MFA : dialogue TOTP avec minuterie de validité (fenêtre glissante ±1) +- Génération de clés WireGuard via `cryptography` (X25519, sans `wg` binaire) - É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`) +- Configuration persistante 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) +*Versions planifiées :* +- `0.3.0` — Import/export de profils WireGuard (.conf), support multi-profils +- `0.4.0` — MFA par clé matérielle (FIDO2/YubiKey) +- `0.5.0` — Paquet installable (Windows .exe / Linux .deb) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c491908 --- /dev/null +++ b/Makefile @@ -0,0 +1,124 @@ +## ────────────────────────────────────────────── +## WGSecure (WGS) — Makefile +## Compilation Linux & Windows via PyInstaller +## ────────────────────────────────────────────── + +APP := wgsecure +VERSION := 0.4.0 +PYTHON := python3 +SRC := main.py +DIST := dist +BUILD := build +ICON := /tmp/wgsecure_icon_build.png + +# Options PyInstaller communes aux deux plateformes +PI_OPTS := \ + --noconfirm \ + --clean \ + --name $(APP) \ + --hidden-import pyotp \ + --hidden-import qrcode \ + --hidden-import qrcode.image.pil \ + --hidden-import PIL \ + --hidden-import PIL.Image \ + --hidden-import cryptography \ + --hidden-import cryptography.hazmat.primitives.asymmetric.x25519 \ + --collect-submodules PyQt6 + +.PHONY: all linux windows release install run run-admin icon clean help + +# ── Cible par défaut ──────────────────────────────────────────────────────── +all: linux + +# ── Génération de l'icône PNG (utilisée par PyInstaller) ──────────────────── +icon: + @$(PYTHON) -c "\ +import sys; sys.path.insert(0,'.');\ +from PyQt6.QtWidgets import QApplication; app=QApplication(sys.argv);\ +from app.ui.icons import icon_app;\ +pix=icon_app().pixmap(256,256); pix.save('$(ICON)','PNG');\ +print(' Icône générée →', '$(ICON)')" 2>/dev/null + @test -f $(ICON) || (echo " ❌ Génération icône échouée"; exit 1) + +# ── Binaire Linux ──────────────────────────────────────────────────────────── +linux: icon + @echo "" + @echo " 🐧 Compilation Linux (PyInstaller onefile)…" + @echo "" + $(PYTHON) -m PyInstaller $(PI_OPTS) \ + --onefile \ + --icon=$(ICON) \ + $(SRC) + @echo "" + @echo " ✅ Binaire Linux → $(DIST)/$(APP)" + @echo "" + +# ── Binaire Windows (via Wine + Python Windows) ────────────────────────────── +windows: icon + @echo "" + @echo " 🪟 Compilation Windows (Wine + PyInstaller)…" + @echo "" + @which wine > /dev/null 2>&1 \ + || (echo " ❌ Wine non installé → sudo apt install wine"; exit 1) + @wine python -m PyInstaller $(PI_OPTS) \ + --onefile \ + --windowed \ + --icon=$(ICON) \ + $(SRC) \ + || (echo ""; \ + echo " ❌ Échec : Python Windows introuvable dans Wine."; \ + echo " → Installez-le avec : make setup-wine"; \ + echo ""; exit 1) + @echo "" + @echo " ✅ Binaire Windows → $(DIST)/$(APP).exe" + @echo "" + +# ── Installation de Python dans Wine ───────────────────────────────────────── +setup-wine: + @echo " 📦 Installation Python + pip dans Wine…" + winetricks python3 + wine pip install -r requirements.txt + wine pip install pyinstaller + +# ── Release (Linux renommé avec version) ───────────────────────────────────── +release: clean linux + @mv $(DIST)/$(APP) $(DIST)/$(APP)-$(VERSION)-linux-x86_64 + @echo " 📦 Release → $(DIST)/$(APP)-$(VERSION)-linux-x86_64" + +# ── Dépendances Python ──────────────────────────────────────────────────────── +install: + $(PYTHON) -m pip install -r requirements.txt + $(PYTHON) -m pip install pyinstaller + @echo " ✅ Dépendances installées" + +# ── Lancer l'application ───────────────────────────────────────────────────── +run: + DISPLAY=:0 $(PYTHON) $(SRC) + +run-admin: + DISPLAY=:0 $(PYTHON) $(SRC) --admin + +# ── Nettoyage ──────────────────────────────────────────────────────────────── +clean: + @echo " 🧹 Nettoyage…" + @rm -rf $(BUILD) $(DIST) *.spec + @find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null; true + @find . -name "*.pyc" -delete 2>/dev/null; true + @echo " ✅ Artefacts supprimés" + +# ── Aide ───────────────────────────────────────────────────────────────────── +help: + @echo "" + @echo " ╔════════════════════════════════════════════╗" + @echo " ║ 🛡️ WGSecure v$(VERSION) — Makefile ║" + @echo " ╠════════════════════════════════════════════╣" + @echo " ║ make install Installe les dépendances ║" + @echo " ║ make linux Binaire Linux (onefile) ║" + @echo " ║ make windows Binaire Windows (Wine) ║" + @echo " ║ make release Linux + nommage release ║" + @echo " ║ make setup-wine Python Windows dans Wine ║" + @echo " ║ make run Lance l'application ║" + @echo " ║ make run-admin Lance en mode admin ║" + @echo " ║ make clean Supprime les artefacts ║" + @echo " ╚════════════════════════════════════════════╝" + @echo "" diff --git a/README.md b/README.md index d53270f..f78005b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🛡️ WGSecure (WGS) -![Version](https://img.shields.io/badge/version-0.1.0-blue?style=flat-square) +![Version](https://img.shields.io/badge/version-0.4.0-blue?style=flat-square) ![Python](https://img.shields.io/badge/Python-3.11+-3776ab?style=flat-square&logo=python&logoColor=white) ![PyQt6](https://img.shields.io/badge/PyQt6-6.4+-41cd52?style=flat-square&logo=qt&logoColor=white) ![Linux](https://img.shields.io/badge/Linux-compatible-fcc624?style=flat-square&logo=linux&logoColor=black) @@ -135,4 +135,4 @@ WGSecure/ ## 👤 Auteur Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) -Version : ![v0.1.0](https://img.shields.io/badge/v0.1.0-juin%202026-2980b9?style=flat-square) — Juin 2026 +Version : ![v0.4.0](https://img.shields.io/badge/v0.4.0-juin%202026-2980b9?style=flat-square) — Juin 2026 diff --git a/app/__init__.py b/app/__init__.py index 2624916..1375a85 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.1.0" +__version__ = "0.4.0" APP_NAME = "WGSecure" APP_SHORT = "WGS" diff --git a/app/core/config.py b/app/core/config.py index ade9f51..7ff6a32 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -2,33 +2,40 @@ import json import os import hashlib import secrets +from copy import deepcopy from typing import Any from app.utils.platform_utils import get_config_dir _CONFIG_FILE = "config.json" + +_WG_DEFAULT: dict[str, Any] = { + "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, +} + _DEFAULT: dict[str, Any] = { - "version": "0.1.0", + "version": "0.4.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, - }, + "active_profile": "Défaut", + "profiles": {}, + "wg": deepcopy(_WG_DEFAULT), "ui": { "minimize_to_tray": True, "autostart": False, - "theme": "auto", + "auto_reconnect": False, + "reconnect_interval": 30, }, } @@ -46,9 +53,9 @@ class Config: saved = json.load(f) self._data = self._merge(_DEFAULT, saved) except Exception: - self._data = dict(_DEFAULT) + self._data = deepcopy(_DEFAULT) else: - self._data = dict(_DEFAULT) + self._data = deepcopy(_DEFAULT) def save(self): os.makedirs(os.path.dirname(self._path), exist_ok=True) @@ -79,11 +86,10 @@ class Config: node = node.setdefault(k, {}) node[keys[-1]] = value - # -- Admin password -- + # ── Mot de passe admin ──────────────────────────────────────────────── 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: @@ -97,19 +103,26 @@ class Config: 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é + return True 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 -- + # ── WireGuard ───────────────────────────────────────────────────────── @property def wg(self) -> dict: return self._data["wg"] + @property + def configured(self) -> bool: + wg = self._data["wg"] + return bool(wg.get("server_endpoint") and wg.get("client_private_key")) + + # ── MFA ─────────────────────────────────────────────────────────────── + @property def mfa_enabled(self) -> bool: return bool(self._data.get("mfa_enabled", False)) @@ -126,7 +139,92 @@ class Config: def mfa_secret(self, value: str): self._data["mfa_secret"] = value + # ── Multi-profils ───────────────────────────────────────────────────── + @property - def configured(self) -> bool: - wg = self._data["wg"] - return bool(wg.get("server_endpoint") and wg.get("client_private_key")) + def active_profile(self) -> str: + return self._data.get("active_profile", "Défaut") + + def list_profiles(self) -> list[str]: + return list(self._data.get("profiles", {}).keys()) + + def save_profile(self, name: str) -> None: + """Sauvegarde la config WireGuard courante sous un nom de profil.""" + self._data.setdefault("profiles", {})[name] = deepcopy(self._data["wg"]) + self._data["active_profile"] = name + self.save() + + def load_profile(self, name: str) -> bool: + """Charge un profil sauvegardé dans la config active.""" + profiles = self._data.get("profiles", {}) + if name not in profiles: + return False + self._data["wg"] = deepcopy(profiles[name]) + self._data["active_profile"] = name + self.save() + return True + + def delete_profile(self, name: str) -> bool: + profiles = self._data.get("profiles", {}) + if name not in profiles: + return False + del profiles[name] + if self._data.get("active_profile") == name: + self._data["active_profile"] = "Défaut" + self.save() + return True + + def rename_profile(self, old: str, new: str) -> bool: + profiles = self._data.get("profiles", {}) + if old not in profiles or new in profiles: + return False + profiles[new] = profiles.pop(old) + if self._data.get("active_profile") == old: + self._data["active_profile"] = new + self.save() + return True + + # ── Autostart ───────────────────────────────────────────────────────── + + def set_autostart(self, enabled: bool) -> None: + self._data["ui"]["autostart"] = enabled + self._apply_autostart(enabled) + self.save() + + def _apply_autostart(self, enabled: bool) -> None: + import sys + from app.utils.platform_utils import is_linux, is_windows + script = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "..", "main.py" + )) + if is_linux(): + autostart_dir = os.path.expanduser("~/.config/autostart") + desktop = os.path.join(autostart_dir, "wgsecure.desktop") + if enabled: + os.makedirs(autostart_dir, exist_ok=True) + content = ( + "[Desktop Entry]\nType=Application\nName=WGSecure\n" + f"Exec=python3 {script}\nIcon=wgsecure\n" + "Hidden=false\nX-GNOME-Autostart-enabled=true\n" + ) + with open(desktop, "w") as f: + f.write(content) + else: + try: + os.unlink(desktop) + except FileNotFoundError: + pass + elif is_windows(): + import winreg + key = r"Software\Microsoft\Windows\CurrentVersion\Run" + try: + reg = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, + winreg.KEY_SET_VALUE) + if enabled: + winreg.SetValueEx(reg, "WGSecure", 0, winreg.REG_SZ, + f'pythonw "{script}"') + else: + winreg.DeleteValue(reg, "WGSecure") + winreg.CloseKey(reg) + except Exception: + pass diff --git a/app/core/history.py b/app/core/history.py new file mode 100644 index 0000000..c9e1e5e --- /dev/null +++ b/app/core/history.py @@ -0,0 +1,90 @@ +"""Historique des sessions de connexion WGSecure.""" +import json +import os +from datetime import datetime +from app.utils.platform_utils import get_config_dir + +_FILE = "sessions.json" +_MAX = 200 + + +def _path() -> str: + return os.path.join(get_config_dir(), _FILE) + + +def start_session(server: str, profile: str) -> int: + """Démarre une nouvelle session, retourne son id.""" + sid = int(datetime.now().timestamp()) + sessions = _load() + sessions.append({ + "id": sid, + "start": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "end": None, + "duration": None, + "server": server, + "profile": profile, + "rx": 0, + "tx": 0, + }) + _save(sessions) + return sid + + +def end_session(sid: int, rx: int = 0, tx: int = 0) -> None: + sessions = _load() + for s in sessions: + if s.get("id") == sid: + start_dt = datetime.strptime(s["start"], "%Y-%m-%d %H:%M:%S") + secs = int((datetime.now() - start_dt).total_seconds()) + s["end"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + s["duration"] = secs + s["rx"] = rx + s["tx"] = tx + break + _save(sessions) + + +def get_sessions(n: int = 50) -> list[dict]: + return list(reversed(_load()[-n:])) + + +def clear() -> None: + _save([]) + + +def fmt_duration(secs: int | None) -> str: + if secs is None: + return "—" + h, r = divmod(int(secs), 3600) + m, s = divmod(r, 60) + if h: + return f"{h}h {m:02d}m" + if m: + return f"{m}m {s:02d}s" + return f"{s}s" + + +def fmt_bytes(n: int) -> str: + for unit, div in [("GB", 1_073_741_824), ("MB", 1_048_576), ("KB", 1024)]: + if n >= div: + return f"{n/div:.1f} {unit}" + return f"{n} B" + + +def _load() -> list[dict]: + p = _path() + if not os.path.exists(p): + return [] + try: + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return [] + + +def _save(sessions: list[dict]) -> None: + try: + with open(_path(), "w", encoding="utf-8") as f: + json.dump(sessions[-_MAX:], f, indent=2, ensure_ascii=False) + except Exception: + pass diff --git a/app/core/log.py b/app/core/log.py new file mode 100644 index 0000000..b6c5455 --- /dev/null +++ b/app/core/log.py @@ -0,0 +1,50 @@ +"""Journal des événements de connexion WGSecure.""" +import os +import json +from datetime import datetime +from app.utils.platform_utils import get_config_dir + +_FILE = "events.json" +_MAX_ENTRIES = 200 + + +def _path() -> str: + return os.path.join(get_config_dir(), _FILE) + + +def log_event(msg: str, kind: str = "info") -> None: + """Ajoute un événement horodaté au journal.""" + events = _load() + events.append({ + "ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "kind": kind, # info | success | error | warning + "msg": msg, + }) + _save(events[-_MAX_ENTRIES:]) + + +def get_events(n: int = 50) -> list[dict]: + return _load()[-n:] + + +def clear() -> None: + _save([]) + + +def _load() -> list[dict]: + p = _path() + if not os.path.exists(p): + return [] + try: + with open(p, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return [] + + +def _save(events: list[dict]) -> None: + try: + with open(_path(), "w", encoding="utf-8") as f: + json.dump(events, f, ensure_ascii=False) + except Exception: + pass diff --git a/app/core/wireguard.py b/app/core/wireguard.py index 319bbdb..5016459 100644 --- a/app/core/wireguard.py +++ b/app/core/wireguard.py @@ -143,6 +143,281 @@ def test_connection(cfg: Config, timeout: int = 5) -> tuple[bool, str]: return False, str(e) +def parse_conf_file(path: str) -> dict | None: + """Parse un fichier .conf WireGuard et retourne les valeurs extraites.""" + result: dict = {} + section = None + try: + with open(path, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("["): + section = line.strip("[]").lower() + continue + if "=" not in line: + continue + key, val = (x.strip() for x in line.split("=", 1)) + if section == "interface": + if key == "PrivateKey": + result["client_private_key"] = val + elif key == "Address": + result["client_address"] = val.split(",")[0].strip() + elif key == "DNS": + result["dns"] = val.split(",")[0].strip() + elif section == "peer": + if key == "PublicKey": + result["server_public_key"] = val + elif key == "Endpoint" and ":" in val: + host, port = val.rsplit(":", 1) + result["server_endpoint"] = host.strip("[]") + try: + result["server_port"] = int(port) + except ValueError: + pass + elif key == "AllowedIPs": + result["allowed_ips"] = val + elif key == "PersistentKeepalive": + try: + result["keepalive"] = int(val) + except ValueError: + pass + # Dériver la clé publique depuis la clé privée importée + if "client_private_key" in result and "client_public_key" not in result: + try: + priv_bytes = base64.b64decode(result["client_private_key"]) + priv = X25519PrivateKey.from_private_bytes(priv_bytes) + pub_bytes = priv.public_key().public_bytes_raw() + result["client_public_key"] = base64.b64encode(pub_bytes).decode() + except Exception: + pass + return result or None + except Exception: + return None + + +def export_conf_file(cfg: Config, dest_path: str) -> tuple[bool, str]: + """Exporte la configuration courante vers un fichier .conf.""" + try: + content = build_client_config(cfg) + with open(dest_path, "w") as f: + f.write(content) + if not is_windows(): + os.chmod(dest_path, 0o600) + return True, dest_path + except Exception as e: + return False, str(e) + + +def get_interface_bytes(iface: str) -> tuple[int, int] | None: + """Retourne (rx_bytes, tx_bytes) depuis /proc/net/dev (Linux).""" + try: + with open("/proc/net/dev") as f: + for line in f: + if iface in line: + parts = line.split() + return int(parts[1]), int(parts[9]) + except Exception: + pass + return None + + +def ping_server(host: str, timeout: int = 2) -> int | None: + """Ping ICMP du serveur. Retourne la latence en ms, ou None si inaccessible.""" + if not host: + return None + if is_windows(): + code, out, _ = run_command( + ["ping", "-n", "1", "-w", str(timeout * 1000), host], timeout + 2 + ) + else: + code, out, _ = run_command( + ["ping", "-c", "1", "-W", str(timeout), host], timeout + 2 + ) + if code != 0: + return None + # Parser "time=X.X ms" ou "temps=X.X ms" + import re + m = re.search(r"[Tt]ime[<=]([\d.]+)\s*ms", out) + if m: + return int(float(m.group(1))) + return None + + +# ── Kill switch ────────────────────────────────────────────────────────── + +_KS_CHAIN = "WGS_KILLSWITCH" + + +def enable_kill_switch(cfg: Config) -> tuple[bool, str]: + """Active le kill switch : bloque tout trafic hors du tunnel WireGuard.""" + if is_windows(): + return False, "Kill switch non disponible sur Windows (utilisez le pare-feu Windows)" + + iface = cfg.wg.get("interface_name", "wgs0") + server = cfg.wg.get("server_endpoint", "") + port = str(cfg.wg.get("server_port", 51820)) + + # Créer la chaîne dédiée (idempotent) + run_privileged(["iptables", "-N", _KS_CHAIN]) + run_privileged(["iptables", "-F", _KS_CHAIN]) + + rules = [ + ["iptables", "-A", _KS_CHAIN, "-o", "lo", "-j", "ACCEPT"], + ["iptables", "-A", _KS_CHAIN, "-o", iface, "-j", "ACCEPT"], + ["iptables", "-A", _KS_CHAIN, "-m", "state", + "--state", "ESTABLISHED,RELATED", "-j", "ACCEPT"], + ] + if server: + rules.append(["iptables", "-A", _KS_CHAIN, + "-d", server, "-p", "udp", "--dport", port, "-j", "ACCEPT"]) + rules.append(["iptables", "-A", _KS_CHAIN, "-j", "REJECT", + "--reject-with", "icmp-net-unreachable"]) + + for rule in rules: + code, _, err = run_privileged(rule) + if code != 0: + disable_kill_switch(cfg) + return False, f"Erreur iptables : {err}" + + # Insérer la chaîne dans OUTPUT (évite les doublons) + run_privileged(["iptables", "-D", "OUTPUT", "-j", _KS_CHAIN]) + code, _, err = run_privileged(["iptables", "-I", "OUTPUT", "-j", _KS_CHAIN]) + if code != 0: + disable_kill_switch(cfg) + return False, err + + return True, f"Kill switch activé (interface {iface})" + + +def disable_kill_switch(cfg: Config) -> tuple[bool, str]: + """Désactive le kill switch.""" + if is_windows(): + return True, "N/A" + run_privileged(["iptables", "-D", "OUTPUT", "-j", _KS_CHAIN]) + run_privileged(["iptables", "-F", _KS_CHAIN]) + run_privileged(["iptables", "-X", _KS_CHAIN]) + return True, "Kill switch désactivé" + + +def kill_switch_active() -> bool: + if is_windows(): + return False + code, out, _ = run_command(["iptables", "-L", "OUTPUT", "-n"]) + return _KS_CHAIN in out + + +# ── Test DNS leak ───────────────────────────────────────────────────────── + +def dns_leak_test(cfg: Config) -> dict: + """ + Teste si le DNS fuit en dehors du tunnel. + Retourne {"status": ok|leak|unknown, "resolvers": [...], "expected": str} + """ + expected_dns = cfg.wg.get("dns", "") + resolvers: list[str] = [] + + # 1. Lire /etc/resolv.conf (Linux) ou ipconfig /all (Windows) + if not is_windows(): + try: + with open("/etc/resolv.conf") as f: + for line in f: + if line.startswith("nameserver"): + parts = line.split() + if len(parts) >= 2: + resolvers.append(parts[1]) + except Exception: + pass + else: + code, out, _ = run_command(["ipconfig", "/all"]) + if code == 0: + import re + resolvers = re.findall(r"DNS Servers.*?:(.*?)(?:\n\S|\Z)", + out, re.DOTALL) + + # 2. Résoudre un domaine test via dig/nslookup pour voir quel serveur répond + probe_ip: str = "" + code, out, _ = run_command(["dig", "+short", "+time=3", "whoami.akamai.net"], 6) + if code == 0: + lines = [l.strip() for l in out.splitlines() if l.strip()] + if lines: + probe_ip = lines[-1] + + if not resolvers: + return {"status": "unknown", "resolvers": [], "expected": expected_dns, + "probe_ip": probe_ip} + + # 3. Comparer avec le DNS configuré + if expected_dns and any(expected_dns in r for r in resolvers): + status = "ok" + elif expected_dns: + status = "leak" + else: + status = "unknown" + + return { + "status": status, + "resolvers": resolvers, + "expected": expected_dns, + "probe_ip": probe_ip, + } + + +# ── Génération config serveur ───────────────────────────────────────────── + +def generate_server_config( + server_port: int = 51820, + 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", +) -> 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. + """ + srv_priv, srv_pub = generate_keypair() + cli_priv, cli_pub = generate_keypair() + psk = generate_preshared_key() + + server_conf = "\n".join([ + "[Interface]", + f"PrivateKey = {srv_priv}", + f"Address = {server_address}", + f"ListenPort = {server_port}", + "PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE", + "PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE", + "", + "# === Peer client ===", + "[Peer]", + f"PublicKey = {cli_pub}", + f"PresharedKey = {psk}", + f"AllowedIPs = {client_allowed_ips}", + ]) + + client_conf = "\n".join([ + "[Interface]", + f"PrivateKey = {cli_priv}", + f"Address = {client_address}", + "DNS = 1.1.1.1", + "", + "[Peer]", + f"PublicKey = {srv_pub}", + f"PresharedKey = {psk}", + "AllowedIPs = 0.0.0.0/0", + "Endpoint = :" + str(server_port), + "PersistentKeepalive = 25", + ]) + + return { + "server_priv": srv_priv, "server_pub": srv_pub, + "client_priv": cli_priv, "client_pub": cli_pub, + "psk": psk, + "server_conf": server_conf, + "client_conf": client_conf, + } + + def get_status_info(cfg: Config) -> dict: name = cfg.wg.get("interface_name", "wgs0") info = { diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 08aa7a9..9091825 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -2,6 +2,7 @@ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame, + QFileDialog, QSizePolicy, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont @@ -127,12 +128,14 @@ class AdminWindow(QDialog): 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") + 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_profiles(), "Profils") + tabs.addTab(self._tab_settings(), "Paramètres") + tabs.addTab(self._tab_admin(), "Sécurité") + tabs.addTab(self._tab_about(), "À propos") layout.addWidget(tabs) # Barre de boutons @@ -165,41 +168,79 @@ class AdminWindow(QDialog): # ------------------------------------------------------------------ # # Onglet WireGuard # ------------------------------------------------------------------ # + @staticmethod + def _row(form: QFormLayout, text: str, widget, + label_w: int = 148, field_h: int = 28) -> None: + """Ligne de formulaire : label largeur fixe + champ hauteur uniforme. + widget peut être un QWidget ou un QHBoxLayout (sans setFixedHeight).""" + from PyQt6.QtWidgets import QLayout + lbl = QLabel(text) + lbl.setFixedWidth(label_w) + lbl.setFixedHeight(field_h) + lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + lbl.setStyleSheet("color: rgba(255,255,255,0.75); background: transparent;") + if not isinstance(widget, QLayout): + widget.setFixedHeight(field_h) + form.addRow(lbl, widget) + def _tab_wireguard(self) -> QWidget: - w, lay = self._dark_page("Configuration WireGuard", "#154360") + w, lay = self._dark_page("🛡️ Configuration WireGuard", "#154360") + + # Barre import / export + io_row = QHBoxLayout() + 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) + io_row.addWidget(btn_import) + io_row.addWidget(btn_export) + io_row.addStretch() + lay.addLayout(io_row) grp = QGroupBox("Serveur WireGuard") form = QFormLayout(grp) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + self._srv_endpoint = QLineEdit() self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4") - form.addRow("Adresse serveur :", self._srv_endpoint) + self._row(form, "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_port.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._row(form, "Port UDP :", self._srv_port) + self._srv_pubkey = QLineEdit() self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") - form.addRow("Clé publique serveur :", self._srv_pubkey) + self._row(form, "Clé publique serveur :", self._srv_pubkey) lay.addWidget(grp) grp2 = QGroupBox("Interface client") form2 = QFormLayout(grp2) + form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + self._iface_name = QLineEdit() self._iface_name.setPlaceholderText("wgs0") - form2.addRow("Nom interface :", self._iface_name) + self._row(form2, "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._row(form2, "Adresse IP client :", self._client_addr) + self._dns = QLineEdit() self._dns.setPlaceholderText("1.1.1.1") - form2.addRow("DNS :", self._dns) + self._row(form2, "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._row(form2, "IPs autorisées :", self._allowed_ips) + self._keepalive = QSpinBox() self._keepalive.setRange(0, 300) self._keepalive.setValue(25) - form2.addRow("Keepalive (s) :", self._keepalive) + self._keepalive.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._row(form2, "Keepalive (s) :", self._keepalive) lay.addWidget(grp2) lay.addStretch() return w @@ -208,15 +249,18 @@ class AdminWindow(QDialog): # Onglet Clés # ------------------------------------------------------------------ # def _tab_keys(self) -> QWidget: - w, lay = self._dark_page("Gestion des clés Curve25519", "#1a3a52") + w, lay = self._dark_page("🔑 Gestion des clés Curve25519", "#1a3a52") grp = QGroupBox("Paire de clés client") form = QFormLayout(grp) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + self._priv_key = QLineEdit() self._priv_key.setEchoMode(QLineEdit.EchoMode.Password) self._priv_key.setPlaceholderText("(générer ou coller)") + self._priv_key.setFixedHeight(28) show_priv = QPushButton("Afficher") - show_priv.setFixedWidth(80) + show_priv.setFixedSize(70, 28) show_priv.setCheckable(True) show_priv.toggled.connect( lambda checked: self._priv_key.setEchoMode( @@ -224,16 +268,19 @@ class AdminWindow(QDialog): ) ) priv_row = QHBoxLayout() + priv_row.setSpacing(6) priv_row.addWidget(self._priv_key) priv_row.addWidget(show_priv) - form.addRow("Clé privée :", priv_row) + self._row(form, "Clé privée :", priv_row) + self._pub_key = QLineEdit() self._pub_key.setReadOnly(True) self._pub_key.setPlaceholderText("(dérivée automatiquement)") - form.addRow("Clé publique :", self._pub_key) + self._row(form, "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) + btn_gen.setFixedHeight(28) + self._row(form, "", btn_gen) lay.addWidget(grp) grp2 = QGroupBox("Aperçu config WireGuard") @@ -241,12 +288,28 @@ class AdminWindow(QDialog): self._config_preview = QTextEdit() self._config_preview.setReadOnly(True) self._config_preview.setFont(QFont("Courier", 9)) - self._config_preview.setFixedHeight(150) + self._config_preview.setFixedHeight(130) v2.addWidget(self._config_preview) - btn_preview = QPushButton("Rafraîchir l'aperçu") + btn_row2 = QHBoxLayout() + btn_preview = QPushButton("🔄 Rafraîchir") btn_preview.clicked.connect(self._refresh_preview) - v2.addWidget(btn_preview) + btn_qr = QPushButton("📱 Exporter QR Code") + 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 @@ -254,7 +317,7 @@ class AdminWindow(QDialog): # Onglet MFA # ------------------------------------------------------------------ # def _tab_mfa(self) -> QWidget: - w, lay = self._dark_page("Authentification Multi-Facteurs (MFA / TOTP)", "#0e6655") + 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;") @@ -267,12 +330,13 @@ class AdminWindow(QDialog): grp = QGroupBox("Secret TOTP") form = QFormLayout(grp) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) self._mfa_secret = QLineEdit() self._mfa_secret.setPlaceholderText("(générer ou coller votre secret TOTP)") - form.addRow("Secret :", self._mfa_secret) + self._row(form, "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) + self._row(form, "", btn_gen_secret) lay.addWidget(grp) grp2 = QGroupBox("QR Code — Scanner avec Google Authenticator / Aegis") @@ -296,39 +360,227 @@ class AdminWindow(QDialog): # Onglet Test connexion # ------------------------------------------------------------------ # def _tab_test(self) -> QWidget: - w, lay = self._dark_page("Test de connectivité réseau", "#1a4a7a") + 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") + # Test UDP serveur + grp1 = QGroupBox("Test UDP serveur") + g1 = QVBoxLayout(grp1) + g1.addWidget(QLabel("Vérifie la joignabilité UDP du serveur WireGuard.")) + btn_test = QPushButton("🚀 Lancer le test de connexion") btn_test.setStyleSheet( - "QPushButton { padding: 10px; background: #1e8449; color: white;" + "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) - lay.addWidget(btn_test) - + g1.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;" + "color: white; padding: 8px; border-radius: 6px; font-size: 12px;" ) - lay.addWidget(self._test_result) + g1.addWidget(self._test_result) + lay.addWidget(grp1) + + # 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) + 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) 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 + for name in self._cfg.list_profiles(): + marker = "★" if name == active else " " + self._profile_list.addItem(f"{marker} {name}") + + def _save_profile(self): + from PyQt6.QtWidgets import QInputDialog + self._save_values() + name, ok = QInputDialog.getText(self, "Sauvegarder le profil", + "Nom du profil :") + if not ok or not name.strip(): + return + self._cfg.save_profile(name.strip()) + self._refresh_profile_list() + QMessageBox.information(self, "Profil sauvegardé", + f"Profil « {name.strip()} » sauvegardé.") + + def _load_profile(self): + item = self._profile_list.currentItem() + if not item: + return + name = item.text().lstrip("★ ").strip() + if self._cfg.load_profile(name): + self._load_values() + self._refresh_profile_list() + QMessageBox.information(self, "Profil chargé", + f"Profil « {name} » chargé.") + + def _delete_profile(self): + item = self._profile_list.currentItem() + if not item: + return + name = item.text().lstrip("★ ").strip() + reply = QMessageBox.question( + self, "Confirmer", + f"Supprimer le profil « {name} » ?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self._cfg.delete_profile(name) + self._refresh_profile_list() + + # ------------------------------------------------------------------ # + # Onglet Paramètres + # ------------------------------------------------------------------ # + def _tab_settings(self) -> QWidget: + w, lay = self._dark_page("⚙️ Paramètres de l'application", "#2a1a4a") + + grp1 = QGroupBox("Comportement") + form1 = QFormLayout(grp1) + + self._chk_tray = QCheckBox("Réduire dans le systray à la fermeture") + self._chk_tray.setStyleSheet("color: white;") + self._chk_tray.setChecked(bool(self._cfg.get("ui", "minimize_to_tray"))) + form1.addRow("", self._chk_tray) + + self._chk_autostart = QCheckBox("Lancer WGSecure au démarrage du système") + self._chk_autostart.setStyleSheet("color: white;") + self._chk_autostart.setChecked(bool(self._cfg.get("ui", "autostart"))) + form1.addRow("", self._chk_autostart) + + lay.addWidget(grp1) + + grp_ks = QGroupBox("🔒 Kill Switch (Linux)") + g_ks = QVBoxLayout(grp_ks) + note_ks = QLabel( + "Bloque tout trafic internet si le tunnel VPN tombe.\n" + "Nécessite des droits root (iptables)." + ) + note_ks.setWordWrap(True) + note_ks.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 11px;") + g_ks.addWidget(note_ks) + ks_row = QHBoxLayout() + self._btn_ks_enable = QPushButton("🔒 Activer") + self._btn_ks_enable.clicked.connect(self._enable_kill_switch) + self._btn_ks_disable = QPushButton("🔓 Désactiver") + self._btn_ks_disable.setStyleSheet( + "QPushButton { background: #6e2e1c; border-radius: 5px; padding: 7px 12px;" + " color: white; border: none; } QPushButton:hover { background: #922b21; }" + ) + self._btn_ks_disable.clicked.connect(self._disable_kill_switch) + self._ks_status = QLabel("—") + self._ks_status.setStyleSheet("font-size: 11px; color: #aed6f1;") + ks_row.addWidget(self._btn_ks_enable) + ks_row.addWidget(self._btn_ks_disable) + ks_row.addWidget(self._ks_status) + ks_row.addStretch() + g_ks.addLayout(ks_row) + lay.addWidget(grp_ks) + self._refresh_ks_status() + + grp2 = QGroupBox("Auto-reconnexion") + form2 = QFormLayout(grp2) + + self._chk_autorecon = QCheckBox("Reconnecter automatiquement si le tunnel tombe") + self._chk_autorecon.setStyleSheet("color: white;") + self._chk_autorecon.setChecked(bool(self._cfg.get("ui", "auto_reconnect"))) + form2.addRow("", self._chk_autorecon) + + self._spin_interval = QSpinBox() + self._spin_interval.setRange(10, 300) + self._spin_interval.setValue(int(self._cfg.get("ui", "reconnect_interval") or 30)) + self._spin_interval.setSuffix(" s") + self._spin_interval.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._row(form2, "Intervalle :", self._spin_interval) + + lay.addWidget(grp2) + + btn_apply = QPushButton("✅ Appliquer les paramètres") + btn_apply.clicked.connect(self._apply_settings) + lay.addWidget(btn_apply) + lay.addStretch() + return w + + def _apply_settings(self): + self._cfg.set("ui", "minimize_to_tray", self._chk_tray.isChecked()) + self._cfg.set("ui", "auto_reconnect", self._chk_autorecon.isChecked()) + self._cfg.set("ui", "reconnect_interval", self._spin_interval.value()) + autostart = self._chk_autostart.isChecked() + if autostart != bool(self._cfg.get("ui", "autostart")): + self._cfg.set_autostart(autostart) + else: + self._cfg.save() + QMessageBox.information(self, "Paramètres", "Paramètres enregistrés.") + # ------------------------------------------------------------------ # # Onglet Sécurité # ------------------------------------------------------------------ # def _tab_admin(self) -> QWidget: - w, lay = self._dark_page("Sécurité — Accès Administrateur", "#6e2e1c") + 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" @@ -340,22 +592,26 @@ class AdminWindow(QDialog): grp = QGroupBox("Mot de passe administrateur") form = QFormLayout(grp) + form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow) + self._admin_pw1 = QLineEdit() self._admin_pw1.setEchoMode(QLineEdit.EchoMode.Password) self._admin_pw1.setPlaceholderText("Nouveau mot de passe") - form.addRow("Mot de passe :", self._admin_pw1) + self._row(form, "Mot de passe :", self._admin_pw1) + self._admin_pw2 = QLineEdit() self._admin_pw2.setEchoMode(QLineEdit.EchoMode.Password) self._admin_pw2.setPlaceholderText("Confirmer") - form.addRow("Confirmation :", self._admin_pw2) + self._row(form, "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;" + "QPushButton { 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) + self._row(form, "", btn_set_pw) lay.addWidget(grp) lay.addStretch() return w @@ -385,7 +641,7 @@ class AdminWindow(QDialog): name_lbl.setStyleSheet("color: white;") layout.addWidget(name_lbl) - version_lbl = QLabel("Version 0.1.0") + version_lbl = QLabel("Version 0.3.0") version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;") layout.addWidget(version_lbl) @@ -468,6 +724,54 @@ class AdminWindow(QDialog): # ------------------------------------------------------------------ # # Actions # ------------------------------------------------------------------ # + def _import_conf(self): + path, _ = QFileDialog.getOpenFileName( + self, "Importer un fichier WireGuard", + "", "WireGuard Config (*.conf);;Tous les fichiers (*)" + ) + if not path: + return + values = wg_core.parse_conf_file(path) + if not values: + QMessageBox.warning(self, "Erreur", "Impossible de lire ce fichier .conf") + return + mapping = { + "server_endpoint": self._srv_endpoint, + "server_public_key": self._srv_pubkey, + "client_address": self._client_addr, + "dns": self._dns, + "allowed_ips": self._allowed_ips, + "client_private_key": self._priv_key, + "client_public_key": self._pub_key, + } + for k, widget in mapping.items(): + if k in values: + widget.setText(str(values[k])) + if "server_port" in values: + self._srv_port.setValue(int(values["server_port"])) + if "keepalive" in values: + self._keepalive.setValue(int(values["keepalive"])) + self._save_values() + QMessageBox.information( + self, "Import réussi", + f"Configuration importée depuis :\n{path}" + ) + + def _export_conf(self): + self._save_values() + path, _ = QFileDialog.getSaveFileName( + self, "Exporter la configuration WireGuard", + f"{self._cfg.wg.get('interface_name','wgs0')}.conf", + "WireGuard Config (*.conf)" + ) + if not path: + return + ok, result = wg_core.export_conf_file(self._cfg, path) + if ok: + QMessageBox.information(self, "Export réussi", f"Config exportée :\n{result}") + else: + QMessageBox.warning(self, "Erreur", f"Export échoué :\n{result}") + def _generate_keys(self): priv, pub = wg_core.generate_keypair() self._priv_key.setText(priv) @@ -522,6 +826,138 @@ class AdminWindow(QDialog): "background: rgba(231,76,60,0.25); color: #f1948a;" ) + def _export_qr(self): + """Exporte la config client comme QR Code pour import mobile.""" + self._save_values() + if not self._cfg.configured: + QMessageBox.warning(self, "Erreur", "Config WireGuard incomplète.") + return + from PyQt6.QtWidgets import QDialog, QLabel, QVBoxLayout + import qrcode, io + from PyQt6.QtGui import QImage, QPixmap + + content = wg_core.build_client_config(self._cfg) + qr = qrcode.QRCode(box_size=5, border=2, + error_correction=qrcode.constants.ERROR_CORRECT_L) + qr.add_data(content) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + buf = io.BytesIO() + img.save(buf, "PNG") + buf.seek(0) + pix = QPixmap.fromImage(QImage.fromData(buf.read())) + + dlg = QDialog(self) + dlg.setWindowTitle("QR Code — Config WireGuard client") + dlg.setStyleSheet("background: #1c2833; color: white;") + v = QVBoxLayout(dlg) + lbl = QLabel() + lbl.setPixmap(pix.scaled(300, 300, Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation)) + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + note = QLabel("Scannez avec l'application WireGuard sur mobile.") + note.setAlignment(Qt.AlignmentFlag.AlignCenter) + note.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 11px;") + v.addWidget(lbl) + v.addWidget(note) + dlg.exec() + + def _generate_server_config(self): + """Génère et affiche une paire complète server + client.""" + from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTabWidget, QTextEdit, QPushButton + result = wg_core.generate_server_config( + server_port=self._srv_port.value() if hasattr(self, '_srv_port') else 51820 + ) + dlg = QDialog(self) + dlg.setWindowTitle("Config générée — Server + Client") + dlg.setMinimumSize(600, 420) + dlg.setStyleSheet("background: #1c2833; color: white;") + v = QVBoxLayout(dlg) + tabs = QTabWidget() + tabs.setStyleSheet( + "QTabBar::tab { background:#2e4057; color:white; padding:6px 14px; }" + "QTabBar::tab:selected { background:#2471a3; }" + ) + for title, content in [("🖥️ Serveur", result["server_conf"]), + ("💻 Client", result["client_conf"])]: + te = QTextEdit() + te.setReadOnly(True) + te.setFont(QFont("Courier", 9)) + te.setPlainText(content) + te.setStyleSheet( + "background: #17202a; color: #a8d8ea; border: none; font-size: 11px;" + ) + tabs.addTab(te, title) + v.addWidget(tabs) + + note = QLabel( + f"🔑 Clé pub. serveur : {result['server_pub'][:32]}…\n" + f"🔑 Clé pub. client : {result['client_pub'][:32]}…\n" + "⚠️ Remplacez dans la config client par l'IP publique du serveur." + ) + note.setWordWrap(True) + note.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 10px; padding: 6px;") + v.addWidget(note) + + btn_close = QPushButton("Fermer") + btn_close.setStyleSheet( + "QPushButton { background:#2471a3; color:white; border-radius:5px;" + " padding:7px 16px; border:none; } QPushButton:hover { background:#1a5276; }" + ) + btn_close.clicked.connect(dlg.accept) + v.addWidget(btn_close) + dlg.exec() + + def _run_dns_test(self): + from PyQt6.QtWidgets import QApplication + self._dns_result.setText("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;" + elif status == "leak": + lines.append("⚠️ Fuite DNS potentielle !") + css = "background: rgba(231,76,60,0.2); color: #f1948a;" + else: + lines.append("❓ Statut inconnu") + css = "color: #aed6f1;" + 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}" + ) + + def _refresh_ks_status(self): + active = wg_core.kill_switch_active() + if active: + self._ks_status.setText("🔒 Actif") + self._ks_status.setStyleSheet("color: #a9dfbf; font-size: 11px;") + else: + self._ks_status.setText("🔓 Inactif") + self._ks_status.setStyleSheet("color: #f1948a; font-size: 11px;") + + def _enable_kill_switch(self): + self._save_values() + ok, msg = wg_core.enable_kill_switch(self._cfg) + self._refresh_ks_status() + if ok: + QMessageBox.information(self, "Kill Switch", msg) + else: + QMessageBox.warning(self, "Kill Switch", msg) + + def _disable_kill_switch(self): + ok, msg = wg_core.disable_kill_switch(self._cfg) + self._refresh_ks_status() + QMessageBox.information(self, "Kill Switch", msg) + def _set_admin_password(self): p1 = self._admin_pw1.text() p2 = self._admin_pw2.text() diff --git a/app/ui/bw_graph.py b/app/ui/bw_graph.py new file mode 100644 index 0000000..3b02fea --- /dev/null +++ b/app/ui/bw_graph.py @@ -0,0 +1,98 @@ +"""Widget graphique bande passante RX/TX (QPainter, sans dépendance externe).""" +from __future__ import annotations +from collections import deque +from PyQt6.QtWidgets import QWidget +from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QFont, QPainterPath +from PyQt6.QtCore import Qt, QRect, QPointF + + +def _fmt(bps: float) -> str: + if bps >= 1_048_576: + return f"{bps/1_048_576:.1f} MB/s" + if bps >= 1_024: + return f"{bps/1_024:.0f} KB/s" + return f"{bps:.0f} B/s" + + +class BandwidthGraph(QWidget): + """Affiche RX (bleu) et TX (vert) sur les N dernières secondes.""" + + POINTS = 30 # nombre de points conservés + + def __init__(self, parent=None): + super().__init__(parent) + self._rx: deque[float] = deque([0.0] * self.POINTS, maxlen=self.POINTS) + self._tx: deque[float] = deque([0.0] * self.POINTS, maxlen=self.POINTS) + self.setMinimumHeight(70) + self.setStyleSheet("background: transparent;") + + def push(self, rx_bps: float, tx_bps: float) -> None: + self._rx.append(max(0.0, rx_bps)) + self._tx.append(max(0.0, tx_bps)) + self.update() + + def reset(self) -> None: + self._rx = deque([0.0] * self.POINTS, maxlen=self.POINTS) + self._tx = deque([0.0] * self.POINTS, maxlen=self.POINTS) + self.update() + + def paintEvent(self, _): + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + pad_l, pad_r, pad_t, pad_b = 42, 8, 6, 20 + + # Fond + p.fillRect(0, 0, w, h, QColor("#17202a")) + + plot_w = w - pad_l - pad_r + plot_h = h - pad_t - pad_b + + max_val = max(max(self._rx), max(self._tx), 1.0) + + def _path(data: deque, color: str, fill: str): + pts = list(data) + path = QPainterPath() + xs = [pad_l + i * plot_w / (len(pts) - 1) for i in range(len(pts))] + ys = [pad_t + plot_h - (v / max_val) * plot_h for v in pts] + path.moveTo(QPointF(xs[0], pad_t + plot_h)) + path.lineTo(QPointF(xs[0], ys[0])) + for x, y in zip(xs[1:], ys[1:]): + path.lineTo(QPointF(x, y)) + path.lineTo(QPointF(xs[-1], pad_t + plot_h)) + path.closeSubpath() + p.fillPath(path, QBrush(QColor(fill))) + pen = QPen(QColor(color)) + pen.setWidthF(1.5) + p.setPen(pen) + line = QPainterPath() + line.moveTo(QPointF(xs[0], ys[0])) + for x, y in zip(xs[1:], ys[1:]): + line.lineTo(QPointF(x, y)) + p.drawPath(line) + + _path(self._rx, "#5dade2", "#1a3a52") # RX bleu + _path(self._tx, "#58d68d", "#1a3d2b") # TX vert + + # Axes + p.setPen(QPen(QColor("#2e4057"))) + p.drawLine(pad_l, pad_t, pad_l, pad_t + plot_h) + p.drawLine(pad_l, pad_t + plot_h, w - pad_r, pad_t + plot_h) + + # Labels Y + font = QFont("Arial", 8) + p.setFont(font) + p.setPen(QPen(QColor("#5d6d7e"))) + for frac, label in [(0.0, _fmt(max_val)), (0.5, _fmt(max_val / 2)), (1.0, "0")]: + y = int(pad_t + frac * plot_h) + p.drawText(QRect(0, y - 8, pad_l - 2, 16), + Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + label) + + # Légende + p.setPen(QPen(QColor("#5dade2"))) + p.drawText(pad_l + 4, pad_t + 12, f"↓ {_fmt(self._rx[-1])}") + p.setPen(QPen(QColor("#58d68d"))) + p.drawText(pad_l + 90, pad_t + 12, f"↑ {_fmt(self._tx[-1])}") + + p.end() diff --git a/app/ui/history_dialog.py b/app/ui/history_dialog.py new file mode 100644 index 0000000..f681240 --- /dev/null +++ b/app/ui/history_dialog.py @@ -0,0 +1,158 @@ +"""Fenêtre d'historique des sessions WGSecure.""" +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QTableWidget, + QTableWidgetItem, QPushButton, QLabel, QHeaderView, + QMessageBox, +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor, QFont + +from app.core import history as hist + + +_DARK = "#1c2833" +_DARK2 = "#17202a" + +_CSS = f""" + QDialog {{ background: {_DARK2}; }} + QLabel {{ color: white; background: transparent; }} + QTableWidget {{ + background: {_DARK}; color: white; gridline-color: #2e4057; + border: none; font-size: 11px; + }} + QHeaderView::section {{ + background: #2e4057; color: white; padding: 5px; + border: none; font-weight: bold; font-size: 11px; + }} + QTableWidget::item:selected {{ background: #2471a3; }} + QPushButton {{ + background: #2e4057; color: white; border-radius: 5px; + padding: 6px 14px; border: none; + }} + QPushButton:hover {{ background: #3d5166; }} +""" + + +class HistoryDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("WGSecure — Historique des sessions") + self.setMinimumSize(680, 420) + self.setStyleSheet(_CSS) + self._build_ui() + self._load() + + def _build_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Bannière + banner = QLabel(" 📊 Historique des sessions VPN") + banner.setFixedHeight(40) + banner.setStyleSheet( + f"background: {_DARK2}; color: white; font-size: 14px; font-weight: bold;" + ) + layout.addWidget(banner) + + # Tableau + self._table = QTableWidget(0, 6) + self._table.setHorizontalHeaderLabels([ + "Date début", "Serveur", "Profil", "Durée", "↓ Reçu", "↑ Envoyé" + ]) + self._table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self._table.verticalHeader().setVisible(False) + self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self._table.setAlternatingRowColors(True) + self._table.setStyleSheet( + _CSS + + "QTableWidget { alternate-background-color: #212f3d; }" + ) + layout.addWidget(self._table) + + # Barre infos + boutons + bar = QLabel("") + bar.setFixedHeight(1) + bar.setStyleSheet(f"background: #2e4057;") + layout.addWidget(bar) + + btn_row_w = QVBoxLayout() + btn_row_w.setContentsMargins(0, 0, 0, 0) + + bottom = QHBoxLayout() + bottom.setContentsMargins(14, 8, 14, 12) + + self._summary_lbl = QLabel("") + self._summary_lbl.setStyleSheet("color: #5d6d7e; font-size: 11px;") + bottom.addWidget(self._summary_lbl) + bottom.addStretch() + + btn_clear = QPushButton("🗑️ Effacer l'historique") + btn_clear.setStyleSheet( + "QPushButton { background: #6e2e1c; color: white; border-radius: 5px;" + " padding: 6px 14px; border: none; }" + "QPushButton:hover { background: #922b21; }" + ) + btn_clear.clicked.connect(self._clear) + btn_close = QPushButton("Fermer") + btn_close.clicked.connect(self.accept) + bottom.addWidget(btn_clear) + bottom.addWidget(btn_close) + + bottom_w = QWidget() + bottom_w.setStyleSheet(f"background: {_DARK2};") + bottom_w.setLayout(bottom) + layout.addWidget(bottom_w) + + def _load(self): + self._table.setRowCount(0) + sessions = hist.get_sessions(100) + total_rx = total_tx = total_s = 0 + + for row, s in enumerate(sessions): + self._table.insertRow(row) + end = s.get("end") + color = "#a9dfbf" if end else "#f9e79f" # vert=terminée, jaune=en cours + + cells = [ + s.get("start", "—"), + s.get("server", "—"), + s.get("profile", "Défaut"), + hist.fmt_duration(s.get("duration")), + hist.fmt_bytes(s.get("rx", 0)), + hist.fmt_bytes(s.get("tx", 0)), + ] + for col, text in enumerate(cells): + item = QTableWidgetItem(text) + item.setForeground(QColor(color)) + item.setTextAlignment( + Qt.AlignmentFlag.AlignVCenter | + (Qt.AlignmentFlag.AlignRight if col >= 3 + else Qt.AlignmentFlag.AlignLeft) + ) + self._table.setItem(row, col, item) + + total_rx += s.get("rx", 0) + total_tx += s.get("tx", 0) + if s.get("duration"): + total_s += s["duration"] + + n = len(sessions) + self._summary_lbl.setText( + f"{n} session{'s' if n > 1 else ''} • " + f"Total ↓ {hist.fmt_bytes(total_rx)} " + f"↑ {hist.fmt_bytes(total_tx)} • " + f"Durée cumulée : {hist.fmt_duration(total_s)}" + ) + + def _clear(self): + reply = QMessageBox.question( + self, "Confirmer", + "Effacer tout l'historique des sessions ?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + hist.clear() + self._load() diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 05906cd..17bfb96 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,26 +1,52 @@ +from __future__ import annotations from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame, QMessageBox, QInputDialog, - QLineEdit, + QLineEdit, QListWidget, QListWidgetItem, QApplication, + QComboBox, QSizePolicy, ) -from PyQt6.QtCore import Qt, QTimer -from PyQt6.QtGui import QFont, QCloseEvent, QPalette, QColor +from PyQt6.QtCore import Qt, QTimer, QDateTime +from PyQt6.QtGui import QFont, QCloseEvent, 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.core import log as conn_log from app.ui.mfa_dialog import MFADialog from app.ui.admin_window import AdminWindow +from app.ui.bw_graph import BandwidthGraph from app.ui import icons +_DARK = "#1c2833" +_DARK2 = "#17202a" +_CARD = "#212f3d" + +_LOG_COLORS = { + "success": "#a9dfbf", + "error": "#f1948a", + "warning": "#f9e79f", + "info": "#aed6f1", +} + +_PING_CSS = { + "good": "background:#1e8449; color:white;", + "medium": "background:#d4ac0d; color:black;", + "bad": "background:#922b21; color:white;", + "offline": "background:#2e4057; color:#5d6d7e;", +} + class MainWindow(QMainWindow): def __init__(self, config: Config, parent=None): super().__init__(parent) self._cfg = config self._connecting = False + self._connected_since: QDateTime | None = None + self._prev_rx: int | None = None + self._prev_tx: int | None = None + self._auto_reconnect_active = False + self.setWindowTitle("WGSecure") - self.setFixedSize(400, 520) + self.setFixedSize(430, 680) self.setWindowFlags( Qt.WindowType.Window | Qt.WindowType.WindowTitleHint @@ -29,147 +55,364 @@ class MainWindow(QMainWindow): ) self.setWindowIcon(icons.icon_app()) self._build_ui() + + # Timers self._status_timer = QTimer(self) self._status_timer.timeout.connect(self._refresh_status) self._status_timer.start(3000) + + self._clock_timer = QTimer(self) + self._clock_timer.timeout.connect(self._tick_duration) + self._clock_timer.start(1000) + + self._bw_timer = QTimer(self) + self._bw_timer.timeout.connect(self._update_bw) + self._bw_timer.start(3000) + + self._ping_timer = QTimer(self) + self._ping_timer.timeout.connect(self._update_ping) + self._ping_timer.start(10000) + + self._reconnect_timer = QTimer(self) + self._reconnect_timer.timeout.connect(self._check_reconnect) + self._refresh_status() + self._reload_log() + self._reload_profiles() # ------------------------------------------------------------------ # # Construction UI # ------------------------------------------------------------------ # def _build_ui(self): central = QWidget() + central.setStyleSheet(f"background: {_DARK};") self.setCentralWidget(central) - layout = QVBoxLayout(central) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) + root = QVBoxLayout(central) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) - # -- En-tête -------------------------------------------------- + # ── En-tête ────────────────────────────────────────────────── header = QWidget() header.setFixedHeight(72) - header.setStyleSheet("background: #2c3e50;") - h_layout = QHBoxLayout(header) - h_layout.setContentsMargins(16, 10, 16, 10) + header.setStyleSheet(f"background: {_DARK2};") + h = QHBoxLayout(header) + h.setContentsMargins(14, 8, 14, 8) self._icon_label = QLabel() - self._icon_label.setFixedSize(44, 44) + self._icon_label.setFixedSize(40, 40) self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - h_layout.addWidget(self._icon_label) + h.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) + title_col.setSpacing(1) + title_lbl = QLabel("WGSecure") + title_lbl.setStyleSheet( + "color: white; font-size: 16px; font-weight: bold; background: transparent;" + ) + self._profile_combo = QComboBox() + self._profile_combo.setFixedHeight(22) + self._profile_combo.setStyleSheet( + "QComboBox { background: #2e4057; color: #aed6f1; border: none;" + " border-radius: 3px; font-size: 10px; padding: 0 6px; }" + "QComboBox::drop-down { border: none; }" + "QComboBox QAbstractItemView { background: #2e4057; color: white; }" + ) + self._profile_combo.currentTextChanged.connect(self._on_profile_changed) + title_col.addWidget(title_lbl) + title_col.addWidget(self._profile_combo) + h.addLayout(title_col) + h.addStretch() - # -- Zone statut ---------------------------------------------- + # Ping badge + self._ping_badge = QLabel("— ms") + self._ping_badge.setFixedSize(62, 22) + self._ping_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._ping_badge.setStyleSheet( + f"border-radius: 11px; font-size: 10px; font-weight: bold; {_PING_CSS['offline']}" + ) + h.addWidget(self._ping_badge) + + # Copier clé publique + self._btn_copy_key = QPushButton("📋 Clé pub.") + self._btn_copy_key.setFixedHeight(26) + self._btn_copy_key.setStyleSheet( + "QPushButton { background: #2471a3; color: white; border-radius: 5px;" + " font-size: 10px; padding: 0 8px; border: none; }" + "QPushButton:hover { background: #1a5276; }" + "QPushButton:disabled { background: #2e4057; color: #5d6d7e; }" + ) + self._btn_copy_key.clicked.connect(self._copy_public_key) + h.addWidget(self._btn_copy_key) + root.addWidget(header) + + # ── 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) + status_area.setFixedHeight(80) + status_area.setStyleSheet(f"background: {_CARD};") + s = QVBoxLayout(status_area) + s.setContentsMargins(10, 6, 10, 6) + s.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._status_badge = QLabel("● Déconnecté") + 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_badge.setStyleSheet( + "color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;" + ) + s.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) + self._duration_label = QLabel("") + self._duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._duration_label.setStyleSheet( + "color: #5d6d7e; font-size: 11px; background: transparent;" + ) + s.addWidget(self._duration_label) + root.addWidget(status_area) - # -- Séparateur ----------------------------------------------- - sep = QFrame() - sep.setFrameShape(QFrame.Shape.HLine) - sep.setStyleSheet("color: #bdc3c7;") - layout.addWidget(sep) + root.addWidget(self._hsep()) - # -- Section infos -------------------------------------------- + # ── Infos ───────────────────────────────────────────────────── info_frame = QFrame() - info_frame.setFrameShape(QFrame.Shape.NoFrame) - info_frame.setStyleSheet("background-color: #ffffff;") + info_frame.setStyleSheet(f"background: {_DARK};") info_frame.setAutoFillBackground(True) - i_layout = QVBoxLayout(info_frame) - i_layout.setContentsMargins(16, 12, 16, 12) - i_layout.setSpacing(8) + il = QVBoxLayout(info_frame) + il.setContentsMargins(14, 8, 14, 8) + il.setSpacing(5) 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) + for key, icon_txt, label in [ + ("server", "🌐", "Serveur"), + ("iface", "🔌", "Interface"), + ("addr", "📍", "Adresse IP"), + ("mfa", "🔐", "MFA"), + ("rx_tx", "↕️ ", "Transfert"), + ("handshake", "🤝", "Handshake"), + ]: + rw = QWidget() + rw.setStyleSheet("background: transparent;") + rl = QHBoxLayout(rw) + rl.setContentsMargins(0, 0, 0, 0) + rl.setSpacing(5) + rl.addWidget(self._lbl(icon_txt, fixed=20, color="#5d6d7e", size=11)) + rl.addWidget(self._lbl(f"{label} :", fixed=100, color="#5d6d7e", size=12)) + val = self._lbl("—", color="#aed6f1", size=12) + val.setWordWrap(True) + self._info_labels[key] = val + rl.addWidget(val, 1) + il.addWidget(rw) - lbl_key = QLabel(f"{label} :") - lbl_key.setFixedWidth(130) - lbl_key.setStyleSheet("color: #7f8c8d; font-size: 12px; background: transparent;") + root.addWidget(info_frame) + root.addWidget(self._hsep()) - lbl_val = QLabel("—") - lbl_val.setStyleSheet("color: #2c3e50; font-size: 12px; background: transparent;") - lbl_val.setWordWrap(True) - self._info_labels[key] = lbl_val + # ── Graphique bande passante ─────────────────────────────────── + bw_header = self._section_header("📊 Bande passante") + root.addWidget(bw_header) - row_layout.addWidget(lbl_key) - row_layout.addWidget(lbl_val, 1) - i_layout.addWidget(row_w) + self._bw_graph = BandwidthGraph() + self._bw_graph.setFixedHeight(75) + root.addWidget(self._bw_graph) + root.addWidget(self._hsep()) - layout.addWidget(info_frame, 1) # stretch factor 1 — prend l'espace disponible + # ── Journal ─────────────────────────────────────────────────── + log_hdr = self._section_header("📋 Journal", btn_text="Effacer", + btn_slot=self._clear_log) + root.addWidget(log_hdr) - # -- Séparateur bas ------------------------------------------- - sep2 = QFrame() - sep2.setFrameShape(QFrame.Shape.HLine) - sep2.setStyleSheet("color: #bdc3c7;") - layout.addWidget(sep2) + self._log_list = QListWidget() + self._log_list.setFixedHeight(78) + self._log_list.setStyleSheet( + f"QListWidget {{ background: {_DARK2}; color: #aed6f1;" + " border: none; font-size: 10px; font-family: Courier; }}" + "QListWidget::item { padding: 1px 6px; }" + ) + self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + root.addWidget(self._log_list) + root.addWidget(self._hsep()) - # -- Boutons -------------------------------------------------- + # ── 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) + btn_area.setStyleSheet(f"background: {_DARK2};") + bl = QVBoxLayout(btn_area) + bl.setContentsMargins(14, 10, 14, 12) + bl.setSpacing(7) 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; }" + "QPushButton { background: #1e8449; color: white; font-size: 14px;" + " font-weight: bold; border-radius: 6px; border: none; }" + "QPushButton:hover { background: #27ae60; }" + "QPushButton:disabled { background: #2e4057; color: #5d6d7e; }" ) self._btn_connect.clicked.connect(self._on_connect) - b_layout.addWidget(self._btn_connect) + bl.addWidget(self._btn_connect) - btn_admin = QPushButton("Panneau Administrateur") - btn_admin.setFixedHeight(34) + btn_admin = QPushButton("⚙️ Panneau Administrateur") + btn_admin.setFixedHeight(30) btn_admin.setStyleSheet( - "QPushButton { background: #dde1e7; color: #2c3e50; border-radius: 6px; " - "font-size: 12px; border: none; }" - "QPushButton:hover { background: #bdc3c7; color: #2c3e50; }" + "QPushButton { background: #2e4057; color: white; border-radius: 6px;" + " font-size: 11px; border: none; }" + "QPushButton:hover { background: #3d5166; }" ) btn_admin.clicked.connect(self._open_admin) - b_layout.addWidget(btn_admin) - layout.addWidget(btn_area) + bl.addWidget(btn_admin) + root.addWidget(btn_area) + + # ── Helpers UI ─────────────────────────────────────────────────────── + def _lbl(self, text: str, fixed: int = 0, color: str = "white", + size: int = 12) -> QLabel: + lbl = QLabel(text) + lbl.setStyleSheet( + f"color: {color}; font-size: {size}px; background: transparent;" + ) + if fixed: + lbl.setFixedWidth(fixed) + return lbl + + def _hsep(self) -> QFrame: + f = QFrame() + f.setFrameShape(QFrame.Shape.HLine) + f.setFixedHeight(1) + f.setStyleSheet(f"background: {_DARK2};") + return f + + def _section_header(self, title: str, btn_text: str = "", + btn_slot=None) -> QWidget: + w = QWidget() + w.setFixedHeight(24) + w.setStyleSheet(f"background: {_DARK2};") + hl = QHBoxLayout(w) + hl.setContentsMargins(12, 0, 12, 0) + t = QLabel(title) + t.setStyleSheet("color: #5d6d7e; font-size: 10px; font-weight: bold;" + " background: transparent;") + hl.addWidget(t) + hl.addStretch() + if btn_text and btn_slot: + b = QPushButton(btn_text) + b.setFixedHeight(18) + b.setStyleSheet( + "QPushButton { background: transparent; color: #5d6d7e;" + " font-size: 9px; border: none; padding: 0 4px; }" + "QPushButton:hover { color: white; }" + ) + b.clicked.connect(btn_slot) + hl.addWidget(b) + return w # ------------------------------------------------------------------ # - # Rafraîchissement du statut + # Profils + # ------------------------------------------------------------------ # + def _reload_profiles(self): + self._profile_combo.blockSignals(True) + self._profile_combo.clear() + self._profile_combo.addItem("⭐ Défaut (actif)") + for name in self._cfg.list_profiles(): + marker = "✓ " if name == self._cfg.active_profile else " " + self._profile_combo.addItem(f"{marker}{name}") + self._profile_combo.blockSignals(False) + + def _on_profile_changed(self, text: str): + if "Défaut" in text: + return + name = text.lstrip("✓ ").strip() + if not name or name == self._cfg.active_profile: + return + if self._cfg.load_profile(name): + self._add_log(f"Profil chargé : {name}", "info") + self._reload_profiles() + self._refresh_status() + + # ------------------------------------------------------------------ # + # Journal + # ------------------------------------------------------------------ # + def _reload_log(self): + self._log_list.clear() + for ev in conn_log.get_events(30): + self._add_log_item(ev["ts"], ev["msg"], ev["kind"]) + self._log_list.scrollToBottom() + + def _clear_log(self): + conn_log.clear() + self._log_list.clear() + + def _add_log(self, msg: str, kind: str = "info"): + conn_log.log_event(msg, kind) + ts = QDateTime.currentDateTime().toString("yyyy-MM-dd hh:mm:ss") + self._add_log_item(ts, msg, kind) + + def _add_log_item(self, ts: str, msg: str, kind: str): + color = _LOG_COLORS.get(kind, "#aed6f1") + item = QListWidgetItem(f"[{ts}] {msg}") + item.setForeground(QColor(color)) + self._log_list.addItem(item) + self._log_list.scrollToBottom() + + # ------------------------------------------------------------------ # + # Timers + # ------------------------------------------------------------------ # + def _tick_duration(self): + if self._connected_since is None: + return + secs = self._connected_since.secsTo(QDateTime.currentDateTime()) + h, rem = divmod(secs, 3600) + m, s = divmod(rem, 60) + self._duration_label.setText(f"Connecté depuis {h:02d}h {m:02d}m {s:02d}s") + + def _update_bw(self): + iface = self._cfg.wg.get("interface_name", "wgs0") + result = wg_core.get_interface_bytes(iface) + if result is None or self._prev_rx is None: + self._prev_rx, self._prev_tx = (result or (0, 0)) + return + rx_now, tx_now = result + dt = 3.0 + rx_bps = max(0, rx_now - self._prev_rx) / dt + tx_bps = max(0, tx_now - self._prev_tx) / dt + self._prev_rx, self._prev_tx = rx_now, tx_now + if wg_core.is_connected(self._cfg): + self._bw_graph.push(rx_bps, tx_bps) + else: + self._bw_graph.push(0, 0) + + def _update_ping(self): + host = self._cfg.wg.get("server_endpoint", "") + if not host or not wg_core.is_connected(self._cfg): + self._ping_badge.setText("— ms") + self._ping_badge.setStyleSheet( + f"border-radius: 11px; font-size: 10px; font-weight: bold; {_PING_CSS['offline']}" + ) + return + ms = wg_core.ping_server(host) + if ms is None: + css = _PING_CSS["bad"] + txt = "hors ligne" + elif ms < 50: + css = _PING_CSS["good"] + txt = f"{ms} ms" + elif ms < 200: + css = _PING_CSS["medium"] + txt = f"{ms} ms" + else: + css = _PING_CSS["bad"] + txt = f"{ms} ms" + self._ping_badge.setText(txt) + self._ping_badge.setStyleSheet( + f"border-radius: 11px; font-size: 10px; font-weight: bold; {css}" + ) + + def _check_reconnect(self): + if not self._cfg.get("ui", "auto_reconnect"): + return + try: + connected = wg_core.is_connected(self._cfg) + except Exception: + connected = False + if not connected and self._cfg.configured: + self._add_log("Auto-reconnexion…", "warning") + self._on_connect() + + # ------------------------------------------------------------------ # + # Statut WireGuard # ------------------------------------------------------------------ # def _refresh_status(self): wg = self._cfg.wg @@ -181,6 +424,15 @@ class MainWindow(QMainWindow): self._info_labels["mfa"].setText( "Activé ✓" if self._cfg.mfa_enabled else "Désactivé" ) + self._btn_copy_key.setEnabled(bool(wg.get("client_public_key", ""))) + + # Auto-reconnect timer + if self._cfg.get("ui", "auto_reconnect"): + interval = int(self._cfg.get("ui", "reconnect_interval") or 30) * 1000 + if not self._reconnect_timer.isActive(): + self._reconnect_timer.start(interval) + else: + self._reconnect_timer.stop() try: info = wg_core.get_status_info(self._cfg) @@ -190,36 +442,47 @@ class MainWindow(QMainWindow): info = {} if connected: - self._status_badge.setText("● Connecté") + if self._connected_since is None: + self._connected_since = QDateTime.currentDateTime() + self._status_badge.setText("● Connecté") self._status_badge.setStyleSheet( - "color: #27ae60; font-size: 16px; font-weight: bold; background: transparent;" + "color: #27ae60; font-size: 15px; 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 { background: #922b21; 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._duration_label.setStyleSheet( + "color: #a9dfbf; font-size: 11px; background: transparent;" + ) self.setWindowIcon(icons.icon_connected()) else: - self._status_badge.setText("● Déconnecté") + if self._connected_since is not None: + self._connected_since = None + self._duration_label.setText("") + self._bw_graph.reset() + self._prev_rx = self._prev_tx = None + self._status_badge.setText("● Déconnecté") self._status_badge.setStyleSheet( - "color: #e74c3c; font-size: 16px; font-weight: bold; background: transparent;" + "color: #e74c3c; font-size: 15px; 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; }" + "QPushButton { background: #1e8449; color: white; font-size: 14px;" + " font-weight: bold; border-radius: 6px; border: none; }" + "QPushButton:hover { background: #27ae60; }" ) self._info_labels["rx_tx"].setText("—") self._info_labels["handshake"].setText("—") - self._status_sub.setText("") + self._duration_label.setStyleSheet( + "color: #5d6d7e; font-size: 11px; background: transparent;" + ) self.setWindowIcon(icons.icon_disconnected()) pix = (icons.icon_connected() if connected else icons.icon_disconnected()).pixmap(36, 36) @@ -228,6 +491,12 @@ class MainWindow(QMainWindow): # ------------------------------------------------------------------ # # Actions # ------------------------------------------------------------------ # + def _copy_public_key(self): + pub = self._cfg.wg.get("client_public_key", "") + if pub: + QApplication.clipboard().setText(pub) + self._add_log("Clé publique copiée dans le presse-papiers", "info") + def _on_connect(self): if self._connecting: return @@ -238,37 +507,48 @@ class MainWindow(QMainWindow): if connected: ok, msg = wg_core.disconnect(self._cfg) - if not ok: - QMessageBox.warning(self, "Erreur", f"Déconnexion échouée :\n{msg}") + if ok: + self._add_log("Tunnel WireGuard désactivé", "warning") + else: + self._add_log(f"Erreur déconnexion : {msg}", "error") + QMessageBox.warning(self, "Erreur", 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." + "Ouvrez le panneau Administrateur." ) 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é." - ) + QMessageBox.warning(self, "MFA non configuré", + "MFA activé mais aucun secret défini.") return dlg = MFADialog(self._cfg.mfa_secret, self) if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified(): + self._add_log("MFA annulé ou échoué", "warning") return + self._add_log("Authentification MFA réussie ✓", "success") 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: + + if ok: + self._connected_since = QDateTime.currentDateTime() + self._add_log( + f"Connexion établie → {self._cfg.wg.get('server_endpoint','')}", "success" + ) + QTimer.singleShot(5000, self._update_ping) + else: + self._add_log(f"Erreur : {msg}", "error") QMessageBox.warning(self, "Erreur de connexion", msg) self._refresh_status() @@ -287,6 +567,7 @@ class MainWindow(QMainWindow): return dlg = AdminWindow(self._cfg, self) dlg.exec() + self._reload_profiles() self._refresh_status() def closeEvent(self, event: QCloseEvent): diff --git a/app/ui/systray.py b/app/ui/systray.py index 7cadf88..016030a 100644 --- a/app/ui/systray.py +++ b/app/ui/systray.py @@ -70,6 +70,7 @@ class SystemTray(QSystemTrayIcon): self._win.activateWindow() def _update_status(self): + prev = self._connected try: self._connected = wg_core.is_connected(self._cfg) except Exception: @@ -81,12 +82,25 @@ class SystemTray(QSystemTrayIcon): self._action_status.setText("● Connecté") self._action_toggle.setIcon(icons.icon_connected()) self._action_toggle.setText("Se déconnecter") + if not prev: + srv = self._cfg.wg.get("server_endpoint", "") + self.showMessage( + "WGSecure — Connecté ✓", + f"Tunnel WireGuard actif{(' → ' + srv) if srv else ''}", + QSystemTrayIcon.MessageIcon.Information, 3000, + ) 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") + if prev: + self.showMessage( + "WGSecure — Déconnecté", + "Le tunnel WireGuard a été désactivé.", + QSystemTrayIcon.MessageIcon.Warning, 3000, + ) def _toggle_connection(self): self._win._on_connect() diff --git a/main.py b/main.py index 4da72e4..ced0a18 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ WGSecure (WGS) — Interface graphique WireGuard avec MFA -Version 0.1.0 +Version 0.4.0 """ import sys import argparse @@ -72,7 +72,7 @@ def main(): ) app = QApplication(sys.argv) app.setApplicationName("WGSecure") - app.setApplicationVersion("0.1.0") + app.setApplicationVersion("0.4.0") app.setOrganizationName("WGS") # Icône globale multi-tailles — barre des tâches + alt-tab app_icon = icons.icon_app()