This commit is contained in:
2026-06-01 23:22:18 +02:00
parent f061da654d
commit d23d0c15be
14 changed files with 1858 additions and 202 deletions
+42 -10
View File
@@ -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 ## [0.1.0] — 2026-06-01
### Ajouté ### 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 - **Mode Admin** (`--admin` ou via le menu) protégé par mot de passe
- Panneau Admin avec 5 onglets : - Panneau Admin avec 5 onglets :
- **WireGuard** : configuration serveur (adresse, port UDP, clé publique, DNS, IP client) - **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 - **MFA** : génération de secret TOTP, QR Code compatible Google Authenticator / Aegis
- **Test connexion** : vérification UDP du serveur avant tunnel - **Test connexion** : vérification UDP du serveur avant tunnel
- **Sécurité** : gestion du mot de passe administrateur (SHA-256 + salt) - **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) - Surcouche MFA : dialogue TOTP avec minuterie de validité (fenêtre glissante ±1)
- Génération de clés WireGuard via la bibliothèque `cryptography` (X25519, sans dépendance à `wg`) - Génération de clés WireGuard via `cryptography` (X25519, sans `wg` binaire)
- Écriture automatique du fichier `.conf` WireGuard avec permissions `0600` - Écriture automatique du fichier `.conf` WireGuard avec permissions `0600`
- Connexion via `wg-quick` (Linux) ou `wireguard /installtunnel` (Windows) - Connexion via `wg-quick` (Linux) ou `wireguard /installtunnel` (Windows)
- Statut temps réel : trafic RX/TX, dernier handshake - 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 - Réduction dans le systray à la fermeture de la fenêtre principale
- Option `--no-tray` pour fonctionner sans systray - Configuration persistante JSON (`~/.wgsecure/config.json`)
- Configuration persistante en JSON (`~/.wgsecure/config.json`)
--- ---
*Prochaines versions planifiées :* *Versions planifiées :*
- `0.2.0` — Import/export de profils WireGuard (.conf), support multi-profils - `0.3.0` — Import/export de profils WireGuard (.conf), support multi-profils
- `0.3.0` — MFA par clé matérielle (FIDO2/YubiKey) - `0.4.0` — MFA par clé matérielle (FIDO2/YubiKey)
- `0.4.0` — Paquet installable (Windows .exe / Linux .deb) - `0.5.0` — Paquet installable (Windows .exe / Linux .deb)
+124
View File
@@ -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 ""
+2 -2
View File
@@ -1,6 +1,6 @@
# 🛡️ WGSecure (WGS) # 🛡️ 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) ![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) ![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) ![Linux](https://img.shields.io/badge/Linux-compatible-fcc624?style=flat-square&logo=linux&logoColor=black)
@@ -135,4 +135,4 @@ WGSecure/
## 👤 Auteur ## 👤 Auteur
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools) 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
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.1.0" __version__ = "0.4.0"
APP_NAME = "WGSecure" APP_NAME = "WGSecure"
APP_SHORT = "WGS" APP_SHORT = "WGS"
+116 -18
View File
@@ -2,18 +2,14 @@ import json
import os import os
import hashlib import hashlib
import secrets import secrets
from copy import deepcopy
from typing import Any from typing import Any
from app.utils.platform_utils import get_config_dir from app.utils.platform_utils import get_config_dir
_CONFIG_FILE = "config.json" _CONFIG_FILE = "config.json"
_DEFAULT: dict[str, Any] = {
"version": "0.1.0", _WG_DEFAULT: dict[str, Any] = {
"admin_password_hash": "",
"admin_salt": "",
"mfa_enabled": False,
"mfa_secret": "",
"wg": {
"interface_name": "wgs0", "interface_name": "wgs0",
"server_endpoint": "", "server_endpoint": "",
"server_port": 51820, "server_port": 51820,
@@ -24,11 +20,22 @@ _DEFAULT: dict[str, Any] = {
"dns": "1.1.1.1", "dns": "1.1.1.1",
"allowed_ips": "0.0.0.0/0", "allowed_ips": "0.0.0.0/0",
"keepalive": 25, "keepalive": 25,
}, }
_DEFAULT: dict[str, Any] = {
"version": "0.4.0",
"admin_password_hash": "",
"admin_salt": "",
"mfa_enabled": False,
"mfa_secret": "",
"active_profile": "Défaut",
"profiles": {},
"wg": deepcopy(_WG_DEFAULT),
"ui": { "ui": {
"minimize_to_tray": True, "minimize_to_tray": True,
"autostart": False, "autostart": False,
"theme": "auto", "auto_reconnect": False,
"reconnect_interval": 30,
}, },
} }
@@ -46,9 +53,9 @@ class Config:
saved = json.load(f) saved = json.load(f)
self._data = self._merge(_DEFAULT, saved) self._data = self._merge(_DEFAULT, saved)
except Exception: except Exception:
self._data = dict(_DEFAULT) self._data = deepcopy(_DEFAULT)
else: else:
self._data = dict(_DEFAULT) self._data = deepcopy(_DEFAULT)
def save(self): def save(self):
os.makedirs(os.path.dirname(self._path), exist_ok=True) os.makedirs(os.path.dirname(self._path), exist_ok=True)
@@ -79,11 +86,10 @@ class Config:
node = node.setdefault(k, {}) node = node.setdefault(k, {})
node[keys[-1]] = value node[keys[-1]] = value
# -- Admin password -- # ── Mot de passe admin ────────────────────────────────────────────────
def set_admin_password(self, password: str): def set_admin_password(self, password: str):
if not password: if not password:
# Mot de passe vide = suppression de la protection
self._data["admin_salt"] = "" self._data["admin_salt"] = ""
self._data["admin_password_hash"] = "" self._data["admin_password_hash"] = ""
else: else:
@@ -97,19 +103,26 @@ class Config:
salt = self._data.get("admin_salt", "") salt = self._data.get("admin_salt", "")
stored = self._data.get("admin_password_hash", "") stored = self._data.get("admin_password_hash", "")
if not stored: if not stored:
return True # Pas encore de mot de passe configuré return True
candidate = hashlib.sha256((salt + password).encode()).hexdigest() candidate = hashlib.sha256((salt + password).encode()).hexdigest()
return secrets.compare_digest(candidate, stored) return secrets.compare_digest(candidate, stored)
def has_admin_password(self) -> bool: def has_admin_password(self) -> bool:
return bool(self._data.get("admin_password_hash", "")) return bool(self._data.get("admin_password_hash", ""))
# -- Propriétés WireGuard -- # ── WireGuard ─────────────────────────────────────────────────────────
@property @property
def wg(self) -> dict: def wg(self) -> dict:
return self._data["wg"] 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 @property
def mfa_enabled(self) -> bool: def mfa_enabled(self) -> bool:
return bool(self._data.get("mfa_enabled", False)) return bool(self._data.get("mfa_enabled", False))
@@ -126,7 +139,92 @@ class Config:
def mfa_secret(self, value: str): def mfa_secret(self, value: str):
self._data["mfa_secret"] = value self._data["mfa_secret"] = value
# ── Multi-profils ─────────────────────────────────────────────────────
@property @property
def configured(self) -> bool: def active_profile(self) -> str:
wg = self._data["wg"] return self._data.get("active_profile", "Défaut")
return bool(wg.get("server_endpoint") and wg.get("client_private_key"))
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
+90
View File
@@ -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
+50
View File
@@ -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
+275
View File
@@ -143,6 +143,281 @@ def test_connection(cfg: Config, timeout: int = 5) -> tuple[bool, str]:
return False, str(e) 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 = <SERVER_IP>:" + 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: def get_status_info(cfg: Config) -> dict:
name = cfg.wg.get("interface_name", "wgs0") name = cfg.wg.get("interface_name", "wgs0")
info = { info = {
+477 -41
View File
@@ -2,6 +2,7 @@ from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget, QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox, QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox,
QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame, QGroupBox, QFormLayout, QTextEdit, QMessageBox, QFrame,
QFileDialog, QSizePolicy,
) )
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
@@ -131,6 +132,8 @@ class AdminWindow(QDialog):
tabs.addTab(self._tab_keys(), "Clés") tabs.addTab(self._tab_keys(), "Clés")
tabs.addTab(self._tab_mfa(), "MFA") tabs.addTab(self._tab_mfa(), "MFA")
tabs.addTab(self._tab_test(), "Test connexion") 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_admin(), "Sécurité")
tabs.addTab(self._tab_about(), "À propos") tabs.addTab(self._tab_about(), "À propos")
layout.addWidget(tabs) layout.addWidget(tabs)
@@ -165,41 +168,79 @@ class AdminWindow(QDialog):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Onglet WireGuard # 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: 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") grp = QGroupBox("Serveur WireGuard")
form = QFormLayout(grp) form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._srv_endpoint = QLineEdit() self._srv_endpoint = QLineEdit()
self._srv_endpoint.setPlaceholderText("vpn.exemple.com ou 1.2.3.4") 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 = QSpinBox()
self._srv_port.setRange(1, 65535) self._srv_port.setRange(1, 65535)
self._srv_port.setValue(51820) 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 = QLineEdit()
self._srv_pubkey.setPlaceholderText("Clé publique du serveur (base64)") 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) lay.addWidget(grp)
grp2 = QGroupBox("Interface client") grp2 = QGroupBox("Interface client")
form2 = QFormLayout(grp2) form2 = QFormLayout(grp2)
form2.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._iface_name = QLineEdit() self._iface_name = QLineEdit()
self._iface_name.setPlaceholderText("wgs0") 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 = QLineEdit()
self._client_addr.setPlaceholderText("10.8.0.2/24") 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 = QLineEdit()
self._dns.setPlaceholderText("1.1.1.1") 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 = QLineEdit()
self._allowed_ips.setPlaceholderText("0.0.0.0/0") 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 = QSpinBox()
self._keepalive.setRange(0, 300) self._keepalive.setRange(0, 300)
self._keepalive.setValue(25) 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.addWidget(grp2)
lay.addStretch() lay.addStretch()
return w return w
@@ -208,15 +249,18 @@ class AdminWindow(QDialog):
# Onglet Clés # Onglet Clés
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _tab_keys(self) -> QWidget: 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") grp = QGroupBox("Paire de clés client")
form = QFormLayout(grp) form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._priv_key = QLineEdit() self._priv_key = QLineEdit()
self._priv_key.setEchoMode(QLineEdit.EchoMode.Password) self._priv_key.setEchoMode(QLineEdit.EchoMode.Password)
self._priv_key.setPlaceholderText("(générer ou coller)") self._priv_key.setPlaceholderText("(générer ou coller)")
self._priv_key.setFixedHeight(28)
show_priv = QPushButton("Afficher") show_priv = QPushButton("Afficher")
show_priv.setFixedWidth(80) show_priv.setFixedSize(70, 28)
show_priv.setCheckable(True) show_priv.setCheckable(True)
show_priv.toggled.connect( show_priv.toggled.connect(
lambda checked: self._priv_key.setEchoMode( lambda checked: self._priv_key.setEchoMode(
@@ -224,16 +268,19 @@ class AdminWindow(QDialog):
) )
) )
priv_row = QHBoxLayout() priv_row = QHBoxLayout()
priv_row.setSpacing(6)
priv_row.addWidget(self._priv_key) priv_row.addWidget(self._priv_key)
priv_row.addWidget(show_priv) 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 = QLineEdit()
self._pub_key.setReadOnly(True) self._pub_key.setReadOnly(True)
self._pub_key.setPlaceholderText("(dérivée automatiquement)") 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 = QPushButton("Générer une nouvelle paire de clés")
btn_gen.clicked.connect(self._generate_keys) btn_gen.setFixedHeight(28)
form.addRow("", btn_gen) self._row(form, "", btn_gen)
lay.addWidget(grp) lay.addWidget(grp)
grp2 = QGroupBox("Aperçu config WireGuard") grp2 = QGroupBox("Aperçu config WireGuard")
@@ -241,12 +288,28 @@ class AdminWindow(QDialog):
self._config_preview = QTextEdit() self._config_preview = QTextEdit()
self._config_preview.setReadOnly(True) self._config_preview.setReadOnly(True)
self._config_preview.setFont(QFont("Courier", 9)) self._config_preview.setFont(QFont("Courier", 9))
self._config_preview.setFixedHeight(150) self._config_preview.setFixedHeight(130)
v2.addWidget(self._config_preview) 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) 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) 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() lay.addStretch()
return w return w
@@ -254,7 +317,7 @@ class AdminWindow(QDialog):
# Onglet MFA # Onglet MFA
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _tab_mfa(self) -> QWidget: 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 = QCheckBox("Activer la vérification MFA avant connexion")
self._mfa_enabled_cb.setStyleSheet("color: white; font-weight: bold;") self._mfa_enabled_cb.setStyleSheet("color: white; font-weight: bold;")
@@ -267,12 +330,13 @@ class AdminWindow(QDialog):
grp = QGroupBox("Secret TOTP") grp = QGroupBox("Secret TOTP")
form = QFormLayout(grp) form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._mfa_secret = QLineEdit() self._mfa_secret = QLineEdit()
self._mfa_secret.setPlaceholderText("(générer ou coller votre secret TOTP)") 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 = QPushButton("Générer un nouveau secret")
btn_gen_secret.clicked.connect(self._generate_mfa_secret) btn_gen_secret.clicked.connect(self._generate_mfa_secret)
form.addRow("", btn_gen_secret) self._row(form, "", btn_gen_secret)
lay.addWidget(grp) lay.addWidget(grp)
grp2 = QGroupBox("QR Code — Scanner avec Google Authenticator / Aegis") grp2 = QGroupBox("QR Code — Scanner avec Google Authenticator / Aegis")
@@ -296,39 +360,227 @@ class AdminWindow(QDialog):
# Onglet Test connexion # Onglet Test connexion
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _tab_test(self) -> QWidget: 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( # Test UDP serveur
"Teste la joignabilité du serveur WireGuard (port UDP) avant d'établir le tunnel." grp1 = QGroupBox("Test UDP serveur")
) g1 = QVBoxLayout(grp1)
info.setWordWrap(True) g1.addWidget(QLabel("Vérifie la joignabilité UDP du serveur WireGuard."))
info.setStyleSheet("color: white; font-size: 13px;") btn_test = QPushButton("🚀 Lancer le test de connexion")
lay.addWidget(info)
btn_test = QPushButton("Lancer le test de connexion")
btn_test.setStyleSheet( 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; }" " border-radius: 5px; font-weight: bold; border: none; }"
"QPushButton:hover { background: #27ae60; }" "QPushButton:hover { background: #27ae60; }"
) )
btn_test.clicked.connect(self._run_test) btn_test.clicked.connect(self._run_test)
lay.addWidget(btn_test) g1.addWidget(btn_test)
self._test_result = QLabel("") self._test_result = QLabel("")
self._test_result.setWordWrap(True) self._test_result.setWordWrap(True)
self._test_result.setAlignment(Qt.AlignmentFlag.AlignCenter) self._test_result.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._test_result.setStyleSheet( 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() lay.addStretch()
return w 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é # Onglet Sécurité
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _tab_admin(self) -> QWidget: 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( note = QLabel(
"Définissez un mot de passe pour protéger l'accès au panneau Administrateur.\n" "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") grp = QGroupBox("Mot de passe administrateur")
form = QFormLayout(grp) form = QFormLayout(grp)
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
self._admin_pw1 = QLineEdit() self._admin_pw1 = QLineEdit()
self._admin_pw1.setEchoMode(QLineEdit.EchoMode.Password) self._admin_pw1.setEchoMode(QLineEdit.EchoMode.Password)
self._admin_pw1.setPlaceholderText("Nouveau mot de passe") 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 = QLineEdit()
self._admin_pw2.setEchoMode(QLineEdit.EchoMode.Password) self._admin_pw2.setEchoMode(QLineEdit.EchoMode.Password)
self._admin_pw2.setPlaceholderText("Confirmer") 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 = QPushButton("Définir le mot de passe")
btn_set_pw.setStyleSheet( btn_set_pw.setStyleSheet(
"QPushButton { padding: 8px; background: #922b21; color: white;" "QPushButton { background: #922b21; color: white;"
" border-radius: 5px; border: none; }" " border-radius: 5px; border: none; }"
"QPushButton:hover { background: #c0392b; }" "QPushButton:hover { background: #c0392b; }"
) )
btn_set_pw.clicked.connect(self._set_admin_password) btn_set_pw.clicked.connect(self._set_admin_password)
form.addRow("", btn_set_pw) self._row(form, "", btn_set_pw)
lay.addWidget(grp) lay.addWidget(grp)
lay.addStretch() lay.addStretch()
return w return w
@@ -385,7 +641,7 @@ class AdminWindow(QDialog):
name_lbl.setStyleSheet("color: white;") name_lbl.setStyleSheet("color: white;")
layout.addWidget(name_lbl) 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.setAlignment(Qt.AlignmentFlag.AlignCenter)
version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;") version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;")
layout.addWidget(version_lbl) layout.addWidget(version_lbl)
@@ -468,6 +724,54 @@ class AdminWindow(QDialog):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Actions # 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): def _generate_keys(self):
priv, pub = wg_core.generate_keypair() priv, pub = wg_core.generate_keypair()
self._priv_key.setText(priv) self._priv_key.setText(priv)
@@ -522,6 +826,138 @@ class AdminWindow(QDialog):
"background: rgba(231,76,60,0.25); color: #f1948a;" "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 <SERVER_IP> 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): def _set_admin_password(self):
p1 = self._admin_pw1.text() p1 = self._admin_pw1.text()
p2 = self._admin_pw2.text() p2 = self._admin_pw2.text()
+98
View File
@@ -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()
+158
View File
@@ -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()
+392 -111
View File
@@ -1,26 +1,52 @@
from __future__ import annotations
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QFrame, QMessageBox, QInputDialog, QLabel, QPushButton, QFrame, QMessageBox, QInputDialog,
QLineEdit, QLineEdit, QListWidget, QListWidgetItem, QApplication,
QComboBox, QSizePolicy,
) )
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer, QDateTime
from PyQt6.QtGui import QFont, QCloseEvent, QPalette, QColor from PyQt6.QtGui import QFont, QCloseEvent, QColor
from app.core.config import Config from app.core.config import Config
from app.core import wireguard as wg_core 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.mfa_dialog import MFADialog
from app.ui.admin_window import AdminWindow from app.ui.admin_window import AdminWindow
from app.ui.bw_graph import BandwidthGraph
from app.ui import icons 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): class MainWindow(QMainWindow):
def __init__(self, config: Config, parent=None): def __init__(self, config: Config, parent=None):
super().__init__(parent) super().__init__(parent)
self._cfg = config self._cfg = config
self._connecting = False 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.setWindowTitle("WGSecure")
self.setFixedSize(400, 520) self.setFixedSize(430, 680)
self.setWindowFlags( self.setWindowFlags(
Qt.WindowType.Window Qt.WindowType.Window
| Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowTitleHint
@@ -29,147 +55,364 @@ class MainWindow(QMainWindow):
) )
self.setWindowIcon(icons.icon_app()) self.setWindowIcon(icons.icon_app())
self._build_ui() self._build_ui()
# Timers
self._status_timer = QTimer(self) self._status_timer = QTimer(self)
self._status_timer.timeout.connect(self._refresh_status) self._status_timer.timeout.connect(self._refresh_status)
self._status_timer.start(3000) 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._refresh_status()
self._reload_log()
self._reload_profiles()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Construction UI # Construction UI
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _build_ui(self): def _build_ui(self):
central = QWidget() central = QWidget()
central.setStyleSheet(f"background: {_DARK};")
self.setCentralWidget(central) self.setCentralWidget(central)
layout = QVBoxLayout(central) root = QVBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) root.setSpacing(0)
# -- En-tête -------------------------------------------------- # ── En-tête ──────────────────────────────────────────────────
header = QWidget() header = QWidget()
header.setFixedHeight(72) header.setFixedHeight(72)
header.setStyleSheet("background: #2c3e50;") header.setStyleSheet(f"background: {_DARK2};")
h_layout = QHBoxLayout(header) h = QHBoxLayout(header)
h_layout.setContentsMargins(16, 10, 16, 10) h.setContentsMargins(14, 8, 14, 8)
self._icon_label = QLabel() self._icon_label = QLabel()
self._icon_label.setFixedSize(44, 44) self._icon_label.setFixedSize(40, 40)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
h_layout.addWidget(self._icon_label) h.addWidget(self._icon_label)
title_col = QVBoxLayout() title_col = QVBoxLayout()
title_col.setSpacing(2) title_col.setSpacing(1)
app_title = QLabel("WGSecure") title_lbl = QLabel("WGSecure")
app_title.setStyleSheet("color: white; font-size: 17px; font-weight: bold; background: transparent;") title_lbl.setStyleSheet(
version_label = QLabel("v0.1.0 — WireGuard + MFA") "color: white; font-size: 16px; font-weight: bold; background: transparent;"
version_label.setStyleSheet("color: #95a5a6; font-size: 10px; background: transparent;") )
title_col.addWidget(app_title) self._profile_combo = QComboBox()
title_col.addWidget(version_label) self._profile_combo.setFixedHeight(22)
h_layout.addLayout(title_col) self._profile_combo.setStyleSheet(
h_layout.addStretch() "QComboBox { background: #2e4057; color: #aed6f1; border: none;"
layout.addWidget(header) " 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 = QWidget()
status_area.setFixedHeight(90) status_area.setFixedHeight(80)
status_area.setStyleSheet("background: #ecf0f1;") status_area.setStyleSheet(f"background: {_CARD};")
s_layout = QVBoxLayout(status_area) s = QVBoxLayout(status_area)
s_layout.setContentsMargins(10, 10, 10, 10) s.setContentsMargins(10, 6, 10, 6)
s_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) 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.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._status_badge.setStyleSheet("color: #e74c3c; font-size: 16px; font-weight: bold; background: transparent;") self._status_badge.setStyleSheet(
s_layout.addWidget(self._status_badge) "color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;"
)
s.addWidget(self._status_badge)
self._status_sub = QLabel("") self._duration_label = QLabel("")
self._status_sub.setAlignment(Qt.AlignmentFlag.AlignCenter) self._duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._status_sub.setStyleSheet("color: #7f8c8d; font-size: 11px; background: transparent;") self._duration_label.setStyleSheet(
s_layout.addWidget(self._status_sub) "color: #5d6d7e; font-size: 11px; background: transparent;"
layout.addWidget(status_area) )
s.addWidget(self._duration_label)
root.addWidget(status_area)
# -- Séparateur ----------------------------------------------- root.addWidget(self._hsep())
sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: #bdc3c7;")
layout.addWidget(sep)
# -- Section infos -------------------------------------------- # ── Infos ─────────────────────────────────────────────────────
info_frame = QFrame() info_frame = QFrame()
info_frame.setFrameShape(QFrame.Shape.NoFrame) info_frame.setStyleSheet(f"background: {_DARK};")
info_frame.setStyleSheet("background-color: #ffffff;")
info_frame.setAutoFillBackground(True) info_frame.setAutoFillBackground(True)
i_layout = QVBoxLayout(info_frame) il = QVBoxLayout(info_frame)
i_layout.setContentsMargins(16, 12, 16, 12) il.setContentsMargins(14, 8, 14, 8)
i_layout.setSpacing(8) il.setSpacing(5)
self._info_labels: dict[str, QLabel] = {} self._info_labels: dict[str, QLabel] = {}
rows = [ for key, icon_txt, label in [
("server", "Serveur"), ("server", "🌐", "Serveur"),
("iface", "Interface"), ("iface", "🔌", "Interface"),
("addr", "Adresse IP"), ("addr", "📍", "Adresse IP"),
("mfa", "MFA"), ("mfa", "🔐", "MFA"),
("rx_tx", "Transfert"), ("rx_tx", "↕️ ", "Transfert"),
("handshake", "Dernier handshake"), ("handshake", "🤝", "Handshake"),
] ]:
for key, label in rows: rw = QWidget()
row_w = QWidget() rw.setStyleSheet("background: transparent;")
row_w.setStyleSheet("background: transparent;") rl = QHBoxLayout(rw)
row_layout = QHBoxLayout(row_w) rl.setContentsMargins(0, 0, 0, 0)
row_layout.setContentsMargins(0, 0, 0, 0) rl.setSpacing(5)
row_layout.setSpacing(8) 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} :") root.addWidget(info_frame)
lbl_key.setFixedWidth(130) root.addWidget(self._hsep())
lbl_key.setStyleSheet("color: #7f8c8d; font-size: 12px; background: transparent;")
lbl_val = QLabel("") # ── Graphique bande passante ───────────────────────────────────
lbl_val.setStyleSheet("color: #2c3e50; font-size: 12px; background: transparent;") bw_header = self._section_header("📊 Bande passante")
lbl_val.setWordWrap(True) root.addWidget(bw_header)
self._info_labels[key] = lbl_val
row_layout.addWidget(lbl_key) self._bw_graph = BandwidthGraph()
row_layout.addWidget(lbl_val, 1) self._bw_graph.setFixedHeight(75)
i_layout.addWidget(row_w) 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 ------------------------------------------- self._log_list = QListWidget()
sep2 = QFrame() self._log_list.setFixedHeight(78)
sep2.setFrameShape(QFrame.Shape.HLine) self._log_list.setStyleSheet(
sep2.setStyleSheet("color: #bdc3c7;") f"QListWidget {{ background: {_DARK2}; color: #aed6f1;"
layout.addWidget(sep2) " 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 = QWidget()
btn_area.setStyleSheet("background: #f8f9fa;") btn_area.setStyleSheet(f"background: {_DARK2};")
b_layout = QVBoxLayout(btn_area) bl = QVBoxLayout(btn_area)
b_layout.setContentsMargins(16, 12, 16, 12) bl.setContentsMargins(14, 10, 14, 12)
b_layout.setSpacing(8) bl.setSpacing(7)
self._btn_connect = QPushButton("Se connecter") self._btn_connect = QPushButton("Se connecter")
self._btn_connect.setFixedHeight(42) self._btn_connect.setFixedHeight(42)
self._btn_connect.setStyleSheet( self._btn_connect.setStyleSheet(
"QPushButton { background: #27ae60; color: white; font-size: 14px; " "QPushButton { background: #1e8449; color: white; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }" " font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #219a52; }" "QPushButton:hover { background: #27ae60; }"
"QPushButton:disabled { background: #bdc3c7; color: #ecf0f1; }" "QPushButton:disabled { background: #2e4057; color: #5d6d7e; }"
) )
self._btn_connect.clicked.connect(self._on_connect) 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 = QPushButton("⚙️ Panneau Administrateur")
btn_admin.setFixedHeight(34) btn_admin.setFixedHeight(30)
btn_admin.setStyleSheet( btn_admin.setStyleSheet(
"QPushButton { background: #dde1e7; color: #2c3e50; border-radius: 6px; " "QPushButton { background: #2e4057; color: white; border-radius: 6px;"
"font-size: 12px; border: none; }" " font-size: 11px; border: none; }"
"QPushButton:hover { background: #bdc3c7; color: #2c3e50; }" "QPushButton:hover { background: #3d5166; }"
) )
btn_admin.clicked.connect(self._open_admin) btn_admin.clicked.connect(self._open_admin)
b_layout.addWidget(btn_admin) bl.addWidget(btn_admin)
layout.addWidget(btn_area) 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): def _refresh_status(self):
wg = self._cfg.wg wg = self._cfg.wg
@@ -181,6 +424,15 @@ class MainWindow(QMainWindow):
self._info_labels["mfa"].setText( self._info_labels["mfa"].setText(
"Activé ✓" if self._cfg.mfa_enabled else "Désactivé" "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: try:
info = wg_core.get_status_info(self._cfg) info = wg_core.get_status_info(self._cfg)
@@ -190,13 +442,15 @@ class MainWindow(QMainWindow):
info = {} info = {}
if connected: if connected:
if self._connected_since is None:
self._connected_since = QDateTime.currentDateTime()
self._status_badge.setText("● Connecté") self._status_badge.setText("● Connecté")
self._status_badge.setStyleSheet( 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.setText("Se déconnecter")
self._btn_connect.setStyleSheet( self._btn_connect.setStyleSheet(
"QPushButton { background: #e74c3c; color: white; font-size: 14px; " "QPushButton { background: #922b21; color: white; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }" " font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #c0392b; }" "QPushButton:hover { background: #c0392b; }"
) )
@@ -204,22 +458,31 @@ class MainWindow(QMainWindow):
f"{info.get('rx_bytes','')}{info.get('tx_bytes','')}" f"{info.get('rx_bytes','')}{info.get('tx_bytes','')}"
) )
self._info_labels["handshake"].setText(info.get("last_handshake", "")) 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()) self.setWindowIcon(icons.icon_connected())
else: else:
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.setText("● Déconnecté")
self._status_badge.setStyleSheet( 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.setText("Se connecter")
self._btn_connect.setStyleSheet( self._btn_connect.setStyleSheet(
"QPushButton { background: #27ae60; color: white; font-size: 14px; " "QPushButton { background: #1e8449; color: white; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }" " font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #219a52; }" "QPushButton:hover { background: #27ae60; }"
) )
self._info_labels["rx_tx"].setText("") self._info_labels["rx_tx"].setText("")
self._info_labels["handshake"].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()) self.setWindowIcon(icons.icon_disconnected())
pix = (icons.icon_connected() if connected else icons.icon_disconnected()).pixmap(36, 36) pix = (icons.icon_connected() if connected else icons.icon_disconnected()).pixmap(36, 36)
@@ -228,6 +491,12 @@ class MainWindow(QMainWindow):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Actions # 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): def _on_connect(self):
if self._connecting: if self._connecting:
return return
@@ -238,37 +507,48 @@ class MainWindow(QMainWindow):
if connected: if connected:
ok, msg = wg_core.disconnect(self._cfg) ok, msg = wg_core.disconnect(self._cfg)
if not ok: if ok:
QMessageBox.warning(self, "Erreur", f"Déconnexion échouée :\n{msg}") self._add_log("Tunnel WireGuard désactivé", "warning")
else:
self._add_log(f"Erreur déconnexion : {msg}", "error")
QMessageBox.warning(self, "Erreur", msg)
else: else:
if not self._cfg.configured: if not self._cfg.configured:
QMessageBox.information( QMessageBox.information(
self, "Configuration manquante", self, "Configuration manquante",
"WireGuard n'est pas encore configuré.\n" "WireGuard n'est pas encore configuré.\n"
"Ouvrez le panneau Administrateur pour paramétrer la connexion." "Ouvrez le panneau Administrateur."
) )
return return
if self._cfg.mfa_enabled: if self._cfg.mfa_enabled:
if not self._cfg.mfa_secret: if not self._cfg.mfa_secret:
QMessageBox.warning( QMessageBox.warning(self, "MFA non configuré",
self, "MFA non configuré", "MFA activé mais aucun secret défini.")
"Le MFA est activé mais aucun secret n'est configuré."
)
return return
dlg = MFADialog(self._cfg.mfa_secret, self) dlg = MFADialog(self._cfg.mfa_secret, self)
if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified(): if dlg.exec() != MFADialog.DialogCode.Accepted or not dlg.is_verified():
self._add_log("MFA annulé ou échoué", "warning")
return return
self._add_log("Authentification MFA réussie ✓", "success")
self._btn_connect.setEnabled(False) self._btn_connect.setEnabled(False)
self._btn_connect.setText("Connexion…") self._btn_connect.setText("Connexion…")
self._connecting = True self._connecting = True
from PyQt6.QtWidgets import QApplication
QApplication.processEvents() QApplication.processEvents()
ok, msg = wg_core.connect(self._cfg) ok, msg = wg_core.connect(self._cfg)
self._connecting = False self._connecting = False
self._btn_connect.setEnabled(True) 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) QMessageBox.warning(self, "Erreur de connexion", msg)
self._refresh_status() self._refresh_status()
@@ -287,6 +567,7 @@ class MainWindow(QMainWindow):
return return
dlg = AdminWindow(self._cfg, self) dlg = AdminWindow(self._cfg, self)
dlg.exec() dlg.exec()
self._reload_profiles()
self._refresh_status() self._refresh_status()
def closeEvent(self, event: QCloseEvent): def closeEvent(self, event: QCloseEvent):
+14
View File
@@ -70,6 +70,7 @@ class SystemTray(QSystemTrayIcon):
self._win.activateWindow() self._win.activateWindow()
def _update_status(self): def _update_status(self):
prev = self._connected
try: try:
self._connected = wg_core.is_connected(self._cfg) self._connected = wg_core.is_connected(self._cfg)
except Exception: except Exception:
@@ -81,12 +82,25 @@ class SystemTray(QSystemTrayIcon):
self._action_status.setText("● Connecté") self._action_status.setText("● Connecté")
self._action_toggle.setIcon(icons.icon_connected()) self._action_toggle.setIcon(icons.icon_connected())
self._action_toggle.setText("Se déconnecter") 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: else:
self.setIcon(icons.icon_disconnected()) self.setIcon(icons.icon_disconnected())
self.setToolTip("WGSecure — Déconnecté") self.setToolTip("WGSecure — Déconnecté")
self._action_status.setText("● Déconnecté") self._action_status.setText("● Déconnecté")
self._action_toggle.setIcon(icons.icon_disconnected()) self._action_toggle.setIcon(icons.icon_disconnected())
self._action_toggle.setText("Se connecter") 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): def _toggle_connection(self):
self._win._on_connect() self._win._on_connect()
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
WGSecure (WGS) Interface graphique WireGuard avec MFA WGSecure (WGS) Interface graphique WireGuard avec MFA
Version 0.1.0 Version 0.4.0
""" """
import sys import sys
import argparse import argparse
@@ -72,7 +72,7 @@ def main():
) )
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("WGSecure") app.setApplicationName("WGSecure")
app.setApplicationVersion("0.1.0") app.setApplicationVersion("0.4.0")
app.setOrganizationName("WGS") app.setOrganizationName("WGS")
# Icône globale multi-tailles — barre des tâches + alt-tab # Icône globale multi-tailles — barre des tâches + alt-tab
app_icon = icons.icon_app() app_icon = icons.icon_app()