Update
This commit is contained in:
+42
-10
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
@@ -1,6 +1,6 @@
|
||||
# 🛡️ WGSecure (WGS)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -135,4 +135,4 @@ WGSecure/
|
||||
## 👤 Auteur
|
||||
|
||||
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
|
||||
Version :  — Juin 2026
|
||||
Version :  — Juin 2026
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.4.0"
|
||||
APP_NAME = "WGSecure"
|
||||
APP_SHORT = "WGS"
|
||||
|
||||
+116
-18
@@ -2,18 +2,14 @@ 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"
|
||||
_DEFAULT: dict[str, Any] = {
|
||||
"version": "0.1.0",
|
||||
"admin_password_hash": "",
|
||||
"admin_salt": "",
|
||||
"mfa_enabled": False,
|
||||
"mfa_secret": "",
|
||||
"wg": {
|
||||
|
||||
_WG_DEFAULT: dict[str, Any] = {
|
||||
"interface_name": "wgs0",
|
||||
"server_endpoint": "",
|
||||
"server_port": 51820,
|
||||
@@ -24,11 +20,22 @@ _DEFAULT: dict[str, Any] = {
|
||||
"dns": "1.1.1.1",
|
||||
"allowed_ips": "0.0.0.0/0",
|
||||
"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": {
|
||||
"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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = <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:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
info = {
|
||||
|
||||
+477
-41
@@ -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
|
||||
@@ -131,6 +132,8 @@ class AdminWindow(QDialog):
|
||||
tabs.addTab(self._tab_keys(), "Clés")
|
||||
tabs.addTab(self._tab_mfa(), "MFA")
|
||||
tabs.addTab(self._tab_test(), "Test connexion")
|
||||
tabs.addTab(self._tab_profiles(), "Profils")
|
||||
tabs.addTab(self._tab_settings(), "Paramètres")
|
||||
tabs.addTab(self._tab_admin(), "Sécurité")
|
||||
tabs.addTab(self._tab_about(), "À propos")
|
||||
layout.addWidget(tabs)
|
||||
@@ -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 <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):
|
||||
p1 = self._admin_pw1.text()
|
||||
p2 = self._admin_pw2.text()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.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; "
|
||||
"QPushButton { background: #1e8449; color: white; font-size: 14px;"
|
||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||
"QPushButton:hover { background: #219a52; }"
|
||||
"QPushButton:disabled { background: #bdc3c7; color: #ecf0f1; }"
|
||||
"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,13 +442,15 @@ class MainWindow(QMainWindow):
|
||||
info = {}
|
||||
|
||||
if connected:
|
||||
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; "
|
||||
"QPushButton { background: #922b21; color: white; font-size: 14px;"
|
||||
" font-weight: bold; border-radius: 6px; border: none; }"
|
||||
"QPushButton:hover { background: #c0392b; }"
|
||||
)
|
||||
@@ -204,22 +458,31 @@ class MainWindow(QMainWindow):
|
||||
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:
|
||||
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; "
|
||||
"QPushButton { background: #1e8449; color: white; font-size: 14px;"
|
||||
" 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["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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user