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