Initial release

This commit is contained in:
2026-06-01 22:31:22 +02:00
parent ab5ba103fe
commit 0d2646a9ad
17 changed files with 1831 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.env
*.egg-info/
dist/
build/
.venv/
venv/
*.spec
+36
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
__version__ = "0.1.0"
APP_NAME = "WGSecure"
APP_SHORT = "WGS"
View File
+132
View File
@@ -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"))
+48
View File
@@ -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)
+181
View File
@@ -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
View File
+535
View File
@@ -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
View File
@@ -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)
+297
View File
@@ -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()
+130
View File
@@ -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)
+97
View File
@@ -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()
View File
+123
View File
@@ -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
+128
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
PyQt6>=6.4.0
cryptography>=41.0.0
pyotp>=2.9.0
qrcode>=7.4.2
Pillow>=10.0.0