v0.7.0 : instance unique, handshake ré-affiché, sudoers fiabilisé, À propos refaite
- Instance unique : un second lancement réveille l'instance active. - Autostart et reconnexion auto activés par défaut (nouvelle installation). - Page « À propos » refaite en 5 onglets (contenu séparé dans app_info.py). - Corrige le handshake/RX-TX qui ne s'affichaient plus (wg show sans élévation). - Corrige le diagnostic sudo trompeur (sondes qui ne matchaient jamais les règles). - Corrige make setup-sudoers bloqué hors session graphique (pkexec -> sudo), idempotent. - Corrige le README : Kill Switch inexistant retiré, ordre d'élévation réel documenté.
This commit is contained in:
@@ -6,6 +6,19 @@ Ce projet suit le [Versionnage Sémantique](https://semver.org/lang/fr/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-09-01
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- **Instance unique.** Un second lancement (icône du menu, raccourci double-cliqué, autostart concurrent d'un lancement manuel) réveille désormais l'instance déjà active au lieu d'ouvrir une deuxième fenêtre sur le même tunnel. Implémenté via un socket local nommé (`app/core/single_instance.py`), portable Linux/Windows.
|
||||||
|
- **Lancement automatique et reconnexion automatique activés par défaut** pour une nouvelle installation (reconnexion à 15 s). Une configuration existante n'est jamais modifiée sans action explicite.
|
||||||
|
- **Page « À propos » refaite** sur le modèle QuiOulé/TuxMarket : cinq onglets (Fonctions, Garanties, Technique, Journal, Auteur), en-tête avec pastilles d'état vivantes (MFA, configuration sudoers). Contenu déplacé dans `app/app_info.py`, séparé de la présentation ; version lue dynamiquement depuis `app.__version__`.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
- **Dernier handshake et compteurs RX/TX qui ne s'affichaient plus.** `wg show` exige les droits root même en lecture seule ; l'appel se faisait sans élévation et échouait silencieusement dès que l'application ne tournait plus en root (le cas normal une fois `make setup-sudoers` en place). Nouvelle fonction `run_privileged_readonly` : élévation silencieuse via `sudo -n` uniquement, jamais de dialogue sur un sondage exécuté toutes les 3 secondes.
|
||||||
|
- **Diagnostic des droits sudo trompeur.** `make check-privileges` testait chaque commande avec `--version`/`-V`, des appels qui ne correspondent à aucune règle NOPASSWD restreinte par motif (ex. `ip link delete dev *`) : le diagnostic affichait « dialogue à chaque appel » même quand tout était correctement configuré. Les sondes reproduisent maintenant un appel réel couvert par les règles installées.
|
||||||
|
- **`make setup-sudoers` restait bloqué indéfiniment** hors session graphique (SSH, tmux, terminal sans agent polkit) : `pkexec` attendait une réponse d'un agent d'authentification absent. Remplacé par `sudo`, qui invite directement dans le terminal. La cible est aussi rendue idempotente : une relance ne redemande le mot de passe que si les règles ont changé.
|
||||||
|
- **README obsolète** : retrait des mentions d'un Kill Switch iptables absent du code actuel (probablement retiré lors d'un refactor sans mise à jour de la doc) ; section sur l'élévation de privilèges corrigée pour refléter l'ordre réel (`sudo -n` → askpass → `pkexec`, et non l'inverse).
|
||||||
|
|
||||||
## [0.6.0] — 2026-09-01
|
## [0.6.0] — 2026-09-01
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
## ──────────────────────────────────────────────
|
## ──────────────────────────────────────────────
|
||||||
|
|
||||||
APP := wgsecure
|
APP := wgsecure
|
||||||
VERSION := 0.6.0
|
VERSION := 0.7.0
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
PYTHON := $(VENV)/bin/python3
|
PYTHON := $(VENV)/bin/python3
|
||||||
PIP := $(VENV)/bin/pip
|
PIP := $(VENV)/bin/pip
|
||||||
@@ -193,12 +193,20 @@ print() if not rows else print(chr(10) + ' ⚠️ → lancez make setup-su
|
|||||||
@echo ""
|
@echo ""
|
||||||
|
|
||||||
# ── Droits sudo wg-quick sans dialogue de mot de passe ───────────────────────
|
# ── Droits sudo wg-quick sans dialogue de mot de passe ───────────────────────
|
||||||
|
# `pkexec` (utilisé jusqu'ici) exige un agent d'authentification polkit
|
||||||
|
# graphique : absent d'un terminal simple (SSH, tmux, session sans agent), il
|
||||||
|
# reste bloqué en attente d'une réponse qui n'arrive jamais. `sudo` n'a pas ce
|
||||||
|
# problème : lancé depuis un terminal, il y invite directement le mot de
|
||||||
|
# passe, avec ou sans polkit. La cible est idempotente : elle recalcule les
|
||||||
|
# règles à chaque lancement et n'invite pour un mot de passe que si le
|
||||||
|
# contenu déjà installé diffère (donc jamais lors d'une relance sans
|
||||||
|
# changement).
|
||||||
setup-sudoers:
|
setup-sudoers:
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo " 🔧 Configuration sudoers (wg-quick + nettoyage réseau, sans mot de passe)…"
|
@echo " 🔧 Configuration sudoers (wg-quick + nettoyage réseau, sans mot de passe)…"
|
||||||
@$(PYTHON) -c "\
|
@$(PYTHON) -c "\
|
||||||
import os, subprocess, tempfile, shutil; \
|
import os, subprocess, tempfile, shutil; \
|
||||||
user = os.environ.get('USER', os.path.basename(os.path.expanduser('~'))); \
|
user = os.environ.get('SUDO_USER') or os.environ.get('USER') or os.path.basename(os.path.expanduser('~')); \
|
||||||
w = lambda n: shutil.which(n) or ''; \
|
w = lambda n: shutil.which(n) or ''; \
|
||||||
cmds = [c for c in ( \
|
cmds = [c for c in ( \
|
||||||
w('wg-quick'), \
|
w('wg-quick'), \
|
||||||
@@ -209,14 +217,19 @@ cmds = [c for c in ( \
|
|||||||
w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \
|
w('install') + ' -D -m 600 -o root -g root * /etc/wireguard/*' if w('install') else '', \
|
||||||
) if c]; \
|
) if c]; \
|
||||||
rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \
|
rules = ''.join(user + ' ALL=(ALL) NOPASSWD: ' + c + chr(10) for c in cmds); \
|
||||||
fd, tmp = tempfile.mkstemp(prefix='wgsecure_sudoers_', dir=os.path.expanduser('~')); \
|
dest = '/etc/sudoers.d/wgsecure'; \
|
||||||
|
current = subprocess.run(['sudo', '-n', 'cat', dest], capture_output=True, text=True); \
|
||||||
|
already_ok = current.returncode == 0 and current.stdout == rules; \
|
||||||
|
print(' ✅ Déjà configuré pour ' + user + ' — rien à faire.') if already_ok else None; \
|
||||||
|
import sys; sys.exit(0) if already_ok else None; \
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix='wgsecure_sudoers_'); \
|
||||||
os.write(fd, rules.encode()); os.close(fd); os.chmod(tmp, 0o600); \
|
os.write(fd, rules.encode()); os.close(fd); os.chmod(tmp, 0o600); \
|
||||||
chk = subprocess.run(['visudo', '-cqf', tmp], capture_output=True); \
|
chk = subprocess.run(['visudo', '-cqf', tmp], capture_output=True); \
|
||||||
ok = chk.returncode == 0; \
|
ok = chk.returncode == 0; \
|
||||||
r = subprocess.run(['pkexec', 'bash', '-c', 'install -m 440 -o root -g root ' + tmp + ' /etc/sudoers.d/wgsecure']) if ok else None; \
|
r = subprocess.run(['sudo', 'install', '-m', '440', '-o', 'root', '-g', 'root', tmp, dest]) if ok else None; \
|
||||||
os.unlink(tmp); \
|
os.unlink(tmp); \
|
||||||
print(' ❌ Règles sudoers invalides, rien installé : ' + chk.stderr.decode().strip()) if not ok else \
|
print(' ❌ Règles sudoers invalides, rien installé : ' + chk.stderr.decode().strip()) if not ok else \
|
||||||
print(' ✅ /etc/sudoers.d/wgsecure configuré — plus de dialogue admin.' if r.returncode == 0 else ' ❌ Échec.')"
|
print(' ✅ ' + dest + ' configuré pour ' + user + ' — plus de dialogue admin.' if r.returncode == 0 else ' ❌ Échec.')"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
||||||
# ── Réinitialisation du mot de passe administrateur ──────────────────────────
|
# ── Réinitialisation du mot de passe administrateur ──────────────────────────
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 🛡️ WGSecure (WGS)
|
# 🛡️ WGSecure (WGS)
|
||||||
|
|
||||||
🚀 v0.6.0 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard
|
🚀 v0.7.0 · 🐍 · 🪟🐧 · 🔐 MFA TOTP · 🛡️ WireGuard
|
||||||
|
|
||||||
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP).
|
**WGSecure** est une interface graphique multiplateforme (Windows & Linux) pour gérer une connexion WireGuard avec une surcouche MFA (authentification à deux facteurs TOTP).
|
||||||
|
|
||||||
@@ -20,11 +20,12 @@
|
|||||||
| 👤 | **Multi-profils** | Sauvegarde / bascule entre profils, import/export `.conf` |
|
| 👤 | **Multi-profils** | Sauvegarde / bascule entre profils, import/export `.conf` |
|
||||||
| 🛡️ | **Configuration WireGuard** | Serveur, port UDP, DNS, IP client, keepalive |
|
| 🛡️ | **Configuration WireGuard** | Serveur, port UDP, DNS, IP client, keepalive |
|
||||||
| 🔐 | **MFA TOTP** | Compatible Bitwarden + QR Code |
|
| 🔐 | **MFA TOTP** | Compatible Bitwarden + QR Code |
|
||||||
| 🔒 | **Kill Switch** | Bloque tout trafic hors tunnel via iptables (Linux) |
|
|
||||||
| 🔍 | **Test DNS leak** | Vérifie que le DNS passe bien par le tunnel |
|
| 🔍 | **Test DNS leak** | Vérifie que le DNS passe bien par le tunnel |
|
||||||
| 🏓 | **Ping en temps réel** | Badge de latence dans l'interface principale |
|
| 🏓 | **Ping en temps réel** | Badge de latence dans l'interface principale |
|
||||||
| 🔄 | **Auto-reconnexion** | Surveillance et reconnexion automatique si le tunnel tombe |
|
| 🔄 | **Auto-reconnexion** | Surveillance et reconnexion automatique si le tunnel tombe — activée par défaut (15 s) |
|
||||||
| 📡 | **Test de connexion** | Vérification UDP du serveur avant établissement du tunnel |
|
| 📡 | **Test de connexion** | Handshake WireGuard réel, sans monter le tunnel, avec détection du filtrage sur port source |
|
||||||
|
| 🚀 | **Démarrage automatique** | Lancement au démarrage du système — activé par défaut |
|
||||||
|
| 1️⃣ | **Instance unique** | Un second lancement réveille l'instance déjà active plutôt que d'en ouvrir une seconde |
|
||||||
| 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) |
|
| 🪟🐧 | **Cross-platform** | Windows 10+ et Linux (wg-quick) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -110,30 +111,47 @@ python main.py --no-tray
|
|||||||
|
|
||||||
## 🐧 Élévation de privilèges Linux
|
## 🐧 Élévation de privilèges Linux
|
||||||
|
|
||||||
`wg-quick` nécessite les droits root pour monter/démonter le tunnel.
|
`wg-quick`, la lecture de l'état du tunnel (`wg show`) et le nettoyage réseau
|
||||||
WGSecure tente dans l'ordre :
|
(interface orpheline, DNS) nécessitent les droits root. WGSecure tente dans
|
||||||
|
l'ordre :
|
||||||
|
|
||||||
1. **pkexec** (polkit) — dialogue graphique GNOME/KDE, aucune configuration requise
|
1. **sudo -n** — silencieux, fonctionne si une règle NOPASSWD est en place
|
||||||
|
(voir ci-dessous)
|
||||||
2. **sudo -A** avec programme askpass graphique (`ssh-askpass`, etc.)
|
2. **sudo -A** avec programme askpass graphique (`ssh-askpass`, etc.)
|
||||||
3. **sudo -n** — fonctionne si une règle NOPASSWD est en place
|
3. **pkexec** (polkit) — dialogue graphique GNOME/KDE ; nécessite un agent
|
||||||
|
d'authentification actif, absent d'un simple terminal (SSH, tmux)
|
||||||
|
|
||||||
### Supprimer la demande de mot de passe (optionnel)
|
Le sondage périodique de l'état du tunnel (toutes les 3 s) ne tente que
|
||||||
|
l'étape 1 : sans règle NOPASSWD, aucun dialogue n'apparaît, le handshake et
|
||||||
|
les compteurs RX/TX restent simplement vides.
|
||||||
|
|
||||||
Pour éviter toute invite à chaque connexion, créer une règle sudoers dédiée :
|
### Supprimer la demande de mot de passe (recommandé)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make setup-sudoers
|
||||||
|
```
|
||||||
|
|
||||||
|
Invite le mot de passe une fois dans le terminal (via `sudo`, plus fiable que
|
||||||
|
`pkexec` hors session graphique), puis installe une règle NOPASSWD limitée à
|
||||||
|
`wg-quick`, `wg`, et au nettoyage d'une interface/DNS orpheline — jamais un
|
||||||
|
accès root généralisé. Idempotent : une relance ne redemande le mot de passe
|
||||||
|
que si le contenu a changé. `make check-privileges` affiche l'état courant.
|
||||||
|
|
||||||
|
Pour le faire à la main :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo visudo -f /etc/sudoers.d/wgsecure
|
sudo visudo -f /etc/sudoers.d/wgsecure
|
||||||
```
|
```
|
||||||
|
|
||||||
Ajouter la ligne suivante (remplacer `<utilisateur>` par votre login) :
|
|
||||||
|
|
||||||
```
|
```
|
||||||
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/wg-quick
|
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/wg-quick
|
||||||
|
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/wg
|
||||||
|
<utilisateur> ALL=(ALL) NOPASSWD: /usr/sbin/ip link delete dev *
|
||||||
|
<utilisateur> ALL=(ALL) NOPASSWD: /usr/sbin/resolvconf -d *
|
||||||
|
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/resolvectl revert *
|
||||||
|
<utilisateur> ALL=(ALL) NOPASSWD: /usr/bin/install -D -m 600 -o root -g root * /etc/wireguard/*
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ Cette règle accorde l'exécution de `wg-quick` sans mot de passe uniquement,
|
|
||||||
> et non un accès root général.
|
|
||||||
|
|
||||||
### Réinitialiser le mot de passe admin oublié
|
### Réinitialiser le mot de passe admin oublié
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -151,9 +169,12 @@ WGSecure/
|
|||||||
├── Makefile
|
├── Makefile
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── __init__.py # Version
|
│ ├── __init__.py # Version
|
||||||
|
│ ├── app_info.py # ℹ️ Contenu de la page « À propos »
|
||||||
│ ├── core/
|
│ ├── core/
|
||||||
│ │ ├── config.py # 💾 Configuration JSON persistante + bcrypt
|
│ │ ├── config.py # 💾 Configuration JSON persistante + bcrypt
|
||||||
│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut, kill switch, DNS leak
|
│ │ ├── wireguard.py # 🛡️ Clés, tunnel, statut, DNS leak
|
||||||
|
│ │ ├── wg_handshake.py # 🤝 Sonde de handshake réel, sans monter le tunnel
|
||||||
|
│ │ ├── single_instance.py # 1️⃣ Garde une seule instance active
|
||||||
│ │ ├── history.py # 🕘 Historique des sessions (sessions.json)
|
│ │ ├── history.py # 🕘 Historique des sessions (sessions.json)
|
||||||
│ │ ├── log.py # 📋 Journal d'événements (events.json)
|
│ │ ├── log.py # 📋 Journal d'événements (events.json)
|
||||||
│ │ └── mfa.py # 🔐 TOTP / QR Code
|
│ │ └── mfa.py # 🔐 TOTP / QR Code
|
||||||
@@ -179,7 +200,6 @@ WGSecure/
|
|||||||
| 🗝️ Clé privée WireGuard | `~/.wgsecure/config.json` (Linux) / `%APPDATA%\WGSecure\` (Windows) |
|
| 🗝️ Clé privée WireGuard | `~/.wgsecure/config.json` (Linux) / `%APPDATA%\WGSecure\` (Windows) |
|
||||||
| 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local |
|
| 🔐 Secret MFA TOTP | Stocké dans le fichier de configuration local |
|
||||||
| 📄 Fichier `.conf` WireGuard | Permissions `0600` (lecture propriétaire uniquement) |
|
| 📄 Fichier `.conf` WireGuard | Permissions `0600` (lecture propriétaire uniquement) |
|
||||||
| 🔒 Kill Switch | Chaîne iptables dédiée `WGS_KILLSWITCH` — isolée du pare-feu système |
|
|
||||||
|
|
||||||
> ⚠️ **Recommandation** : chiffrez votre disque pour protéger le fichier de configuration.
|
> ⚠️ **Recommandation** : chiffrez votre disque pour protéger le fichier de configuration.
|
||||||
|
|
||||||
@@ -188,4 +208,4 @@ WGSecure/
|
|||||||
## 👤 Auteur
|
## 👤 Auteur
|
||||||
|
|
||||||
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
|
Développé par **Johnny** — [JT-Tools](https://github.com/JT-Tools)
|
||||||
Version :  — Septembre 2026
|
Version :  — Septembre 2026
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
__version__ = "0.6.0"
|
__version__ = "0.7.0"
|
||||||
APP_NAME = "WGSecure"
|
APP_NAME = "WGSecure"
|
||||||
APP_SHORT = "WGS"
|
APP_SHORT = "WGS"
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
"""Contenu de la page « À propos ».
|
||||||
|
|
||||||
|
Source de vérité unique pour les textes de la page : l'onglet Qt
|
||||||
|
(app/ui/admin_window.py, méthode `_tab_about`) ne fait que les mettre en
|
||||||
|
forme. La version, elle, vient de `app.__version__` (app/__init__.py), le
|
||||||
|
seul endroit où elle est bumpée à chaque release — jamais recopiée en dur ici.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
|
|
||||||
|
_CHANGELOG_PATH = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "CHANGELOG.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
APP_NAME = "WGSecure"
|
||||||
|
TAGLINE = "Client WireGuard avec surcouche MFA TOTP, pour Linux et Windows"
|
||||||
|
VERSION = __version__
|
||||||
|
LICENSE = "Usage personnel"
|
||||||
|
REPO_URL = "https://git.tuxtech.fr/tuxgyver/WGSecure"
|
||||||
|
AUTHOR_NAME = "Johnny"
|
||||||
|
AUTHOR_ROLE = "Auteur"
|
||||||
|
|
||||||
|
FEATURES = [
|
||||||
|
(
|
||||||
|
"Connexion en un clic",
|
||||||
|
"Icône dans la barre système, montage/démontage du tunnel WireGuard "
|
||||||
|
"sans quitter le systray.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Suivi en direct",
|
||||||
|
"Statut RX/TX, dernier handshake et latence affichés en continu ; "
|
||||||
|
"graphique de bande passante sur les 30 dernières secondes.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Journal et historique",
|
||||||
|
"Horodatage de chaque connexion, déconnexion et erreur ; historique "
|
||||||
|
"des sessions passées (durée, volume transféré, serveur).",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Multi-profils",
|
||||||
|
"Plusieurs configurations WireGuard sauvegardées et basculables, "
|
||||||
|
"avec import/export de fichiers .conf standards.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Génération de clés",
|
||||||
|
"Paire de clés Curve25519 générée nativement en Python, sans "
|
||||||
|
"dépendre du binaire `wg` pour cette étape.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Mode Admin protégé",
|
||||||
|
"Configuration complète (serveur, DNS, IP, keepalive, MTU) derrière "
|
||||||
|
"un mot de passe et, en option, une double authentification TOTP.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Diagnostic réseau",
|
||||||
|
"Test de handshake réel sans monter le tunnel, détection du "
|
||||||
|
"filtrage sur port source, et test de fuite DNS.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Auto-reconnexion",
|
||||||
|
"Surveillance du tunnel et reconnexion automatique en cas de "
|
||||||
|
"coupure, à l'intervalle choisi.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
GUARANTEES_INTRO = "Ce que l'application garantit, quel que soit le profil configuré :"
|
||||||
|
GUARANTEES = [
|
||||||
|
"Le mot de passe du mode Admin n'est jamais stocké en clair : il est "
|
||||||
|
"haché avec bcrypt (12 tours) avant d'être écrit dans la configuration.",
|
||||||
|
"La clé privée WireGuard est toujours écrite avec des permissions "
|
||||||
|
"restrictives (600) — aussi bien dans la configuration locale que dans "
|
||||||
|
"le fichier installé sous /etc/wireguard.",
|
||||||
|
"Le test de handshake et le sondage de statut (dernier handshake, "
|
||||||
|
"RX/TX) n'ouvrent jamais de tunnel et ne modifient aucun fichier : ce "
|
||||||
|
"sont des lectures ou un simple datagramme UDP jetable.",
|
||||||
|
"Les commandes système nécessitant les privilèges root sont exécutées "
|
||||||
|
"une par une, jamais l'application entière : `make setup-sudoers` "
|
||||||
|
"autorise sans mot de passe uniquement wg-quick, wg, et la suppression "
|
||||||
|
"d'interface/DNS orpheline — jamais un accès root généralisé.",
|
||||||
|
"Le sondage périodique de l'état du tunnel (toutes les 3 secondes) "
|
||||||
|
"n'ouvre jamais de dialogue de mot de passe ou d'UAC : il échoue "
|
||||||
|
"silencieusement si les privilèges nécessaires ne sont pas déjà "
|
||||||
|
"accordés.",
|
||||||
|
"Le test de fuite DNS vérifie que les requêtes sortent bien par le "
|
||||||
|
"resolver attendu une fois le tunnel monté, plutôt que de supposer la "
|
||||||
|
"configuration correcte.",
|
||||||
|
]
|
||||||
|
|
||||||
|
TECH_STACK = [
|
||||||
|
("Interface", "PyQt6"),
|
||||||
|
("Cryptographie", "cryptography (X25519, ChaCha20-Poly1305, bcrypt)"),
|
||||||
|
("MFA", "pyotp (TOTP) + qrcode/Pillow pour le QR code"),
|
||||||
|
("Tunnel", "wg-quick / wg (Linux), WireGuard for Windows"),
|
||||||
|
("Élévation de privilèges", "sudo NOPASSWD ciblé, repli askpass puis pkexec"),
|
||||||
|
("Empaquetage", "PyInstaller (Linux/Windows) + Inno Setup (installeur Windows)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_changelog() -> list[dict]:
|
||||||
|
"""Lit CHANGELOG.md : titres `## [x.y.z] — date`, sous-titres `### ...`
|
||||||
|
et puces `- ...` (avec leurs lignes de continuation) uniquement — le
|
||||||
|
format effectivement utilisé par ce fichier, sans dépendance Markdown.
|
||||||
|
"""
|
||||||
|
entries: list[dict] = []
|
||||||
|
version = None
|
||||||
|
section = None
|
||||||
|
try:
|
||||||
|
with open(_CHANGELOG_PATH, encoding="utf-8") as f:
|
||||||
|
for raw in f:
|
||||||
|
line = raw.rstrip("\n")
|
||||||
|
if line.startswith("## "):
|
||||||
|
version = {"title": line[3:].strip(), "sections": []}
|
||||||
|
entries.append(version)
|
||||||
|
section = None
|
||||||
|
elif line.startswith("### ") and version is not None:
|
||||||
|
section = {"name": line[4:].strip(), "items": []}
|
||||||
|
version["sections"].append(section)
|
||||||
|
elif line.startswith("- ") and section is not None:
|
||||||
|
section["items"].append(line[2:].strip())
|
||||||
|
elif (line.strip() and section is not None and section["items"]
|
||||||
|
and not line.startswith(("#", "---"))):
|
||||||
|
# Ligne de continuation d'une puce étalée sur plusieurs lignes.
|
||||||
|
section["items"][-1] += " " + line.strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return entries
|
||||||
+8
-4
@@ -36,7 +36,7 @@ _WG_DEFAULT: dict[str, Any] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_DEFAULT: dict[str, Any] = {
|
_DEFAULT: dict[str, Any] = {
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"admin_password_hash": "",
|
"admin_password_hash": "",
|
||||||
"admin_salt": "",
|
"admin_salt": "",
|
||||||
"mfa_enabled": False,
|
"mfa_enabled": False,
|
||||||
@@ -46,10 +46,10 @@ _DEFAULT: dict[str, Any] = {
|
|||||||
"wg": deepcopy(_WG_DEFAULT),
|
"wg": deepcopy(_WG_DEFAULT),
|
||||||
"ui": {
|
"ui": {
|
||||||
"minimize_to_tray": True,
|
"minimize_to_tray": True,
|
||||||
"autostart": False,
|
"autostart": True,
|
||||||
"auto_connect_on_startup": False,
|
"auto_connect_on_startup": False,
|
||||||
"auto_reconnect": False,
|
"auto_reconnect": True,
|
||||||
"reconnect_interval": 30,
|
"reconnect_interval": 15,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,11 @@ class Config:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self._data = deepcopy(_DEFAULT)
|
self._data = deepcopy(_DEFAULT)
|
||||||
else:
|
else:
|
||||||
|
# Première exécution : le défaut « autostart activé » n'a d'effet
|
||||||
|
# que si on l'applique réellement (fichier .desktop / clé de
|
||||||
|
# registre), sinon la case reste cochée sans rien avoir installé.
|
||||||
self._data = deepcopy(_DEFAULT)
|
self._data = deepcopy(_DEFAULT)
|
||||||
|
self._apply_autostart(self._data["ui"]["autostart"])
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
os.makedirs(os.path.dirname(self._path), exist_ok=True)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Garantit une seule instance de WGSecure en cours d'exécution.
|
||||||
|
|
||||||
|
Avec le lancement automatique au démarrage désormais activé par défaut
|
||||||
|
([[wg-autostart-reconnect]]), un second lancement manuel (icône du menu,
|
||||||
|
raccourci double-cliqué) peut facilement se superposer à l'instance déjà
|
||||||
|
présente en systray — deux fenêtres sur le même tunnel, deux timers de
|
||||||
|
reconnexion concurrents. Le second lancement doit simplement réveiller le
|
||||||
|
premier et s'arrêter.
|
||||||
|
|
||||||
|
Implémenté via un socket local nommé (QLocalServer/QLocalSocket) : le
|
||||||
|
mécanisme Qt standard pour ça, portable Linux/Windows sans dépendance
|
||||||
|
supplémentaire. Le premier lancement écoute ; tout lancement suivant s'y
|
||||||
|
connecte, signale sa présence, puis quitte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtNetwork import QLocalServer, QLocalSocket
|
||||||
|
|
||||||
|
_SERVER_NAME = "wgsecure-single-instance"
|
||||||
|
|
||||||
|
|
||||||
|
class SingleInstanceGuard:
|
||||||
|
def __init__(self):
|
||||||
|
self._server: QLocalServer | None = None
|
||||||
|
self._on_activate = None
|
||||||
|
|
||||||
|
def try_acquire(self) -> bool:
|
||||||
|
"""True si cette instance est la première et doit continuer.
|
||||||
|
|
||||||
|
False si une autre tourne déjà : celle-ci lui a signalé sa présence
|
||||||
|
et doit se terminer sans ouvrir la moindre fenêtre.
|
||||||
|
"""
|
||||||
|
probe = QLocalSocket()
|
||||||
|
probe.connectToServer(_SERVER_NAME)
|
||||||
|
if probe.waitForConnected(200):
|
||||||
|
probe.write(b"activate")
|
||||||
|
probe.waitForBytesWritten(200)
|
||||||
|
probe.disconnectFromServer()
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Un crash précédent peut laisser le socket enregistré sans qu'aucun
|
||||||
|
# serveur ne l'écoute : listen() échouerait indéfiniment sinon.
|
||||||
|
QLocalServer.removeServer(_SERVER_NAME)
|
||||||
|
self._server = QLocalServer()
|
||||||
|
self._server.newConnection.connect(self._handle_connection)
|
||||||
|
self._server.listen(_SERVER_NAME)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def on_activate_requested(self, callback) -> None:
|
||||||
|
"""`callback` (sans argument) est appelé quand un second lancement
|
||||||
|
tente de démarrer — à connecter au réaffichage de la fenêtre."""
|
||||||
|
self._on_activate = callback
|
||||||
|
|
||||||
|
def _handle_connection(self) -> None:
|
||||||
|
sock = self._server.nextPendingConnection()
|
||||||
|
if sock is None:
|
||||||
|
return
|
||||||
|
sock.readyRead.connect(lambda: self._consume(sock))
|
||||||
|
|
||||||
|
def _consume(self, sock: QLocalSocket) -> None:
|
||||||
|
sock.readAll()
|
||||||
|
if self._on_activate:
|
||||||
|
self._on_activate()
|
||||||
|
sock.disconnectFromServer()
|
||||||
@@ -14,6 +14,7 @@ from app.utils.platform_utils import (
|
|||||||
is_windows,
|
is_windows,
|
||||||
run_command,
|
run_command,
|
||||||
run_privileged,
|
run_privileged,
|
||||||
|
run_privileged_readonly,
|
||||||
wg_available,
|
wg_available,
|
||||||
wg_exe,
|
wg_exe,
|
||||||
wg_quick_available,
|
wg_quick_available,
|
||||||
@@ -150,12 +151,14 @@ def write_client_config(cfg: Config) -> tuple[bool, str]:
|
|||||||
def _wg_show(name: str) -> str:
|
def _wg_show(name: str) -> str:
|
||||||
"""Sortie de `wg show <iface>`, ou "" si les privilèges manquent.
|
"""Sortie de `wg show <iface>`, ou "" si les privilèges manquent.
|
||||||
|
|
||||||
Attention : sans CAP_NET_ADMIN, `wg show` écrit « Unable to access
|
`wg show` exige CAP_NET_ADMIN (root) pour lire l'état d'une interface,
|
||||||
interface: Operation not permitted » sur stderr **et sort avec le code 0**.
|
même en lecture seule — un utilisateur ordinaire reçoit « Unable to
|
||||||
Un simple test `code == 0` conclut donc « déconnecté » pour tout
|
access interface: Operation not permitted ». D'où l'appel via
|
||||||
utilisateur non root — c'était le cas de l'application entière.
|
`run_privileged_readonly` : sans lui, le handshake et les compteurs de
|
||||||
|
trafic restent vides dès que l'application ne tourne plus en root (le cas
|
||||||
|
normal une fois `make setup-sudoers` en place pour wg-quick).
|
||||||
"""
|
"""
|
||||||
code, out, err = run_command([wg_exe(), "show", name])
|
code, out, err = run_privileged_readonly([wg_exe(), "show", name])
|
||||||
if code != 0 or not out:
|
if code != 0 or not out:
|
||||||
return ""
|
return ""
|
||||||
if "not permitted" in err.lower() or "unable to access" in err.lower():
|
if "not permitted" in err.lower() or "unable to access" in err.lower():
|
||||||
@@ -402,7 +405,7 @@ def last_handshake_age(name: str) -> int | None:
|
|||||||
Retourne -1 si aucun handshake n'a jamais eu lieu, None si l'information
|
Retourne -1 si aucun handshake n'a jamais eu lieu, None si l'information
|
||||||
est inaccessible (privilèges insuffisants, `wg` absent).
|
est inaccessible (privilèges insuffisants, `wg` absent).
|
||||||
"""
|
"""
|
||||||
code, out, _ = run_command([wg_exe(), "show", name, "latest-handshakes"], 10)
|
code, out, _ = run_privileged_readonly([wg_exe(), "show", name, "latest-handshakes"], 10)
|
||||||
if code != 0 or not out.strip():
|
if code != 0 or not out.strip():
|
||||||
return None
|
return None
|
||||||
newest = 0
|
newest = 0
|
||||||
|
|||||||
+241
-58
@@ -1,3 +1,5 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
|
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
|
||||||
QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox,
|
QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox,
|
||||||
@@ -861,69 +863,250 @@ class AdminWindow(QDialog):
|
|||||||
# Onglet À propos
|
# Onglet À propos
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
def _tab_about(self) -> QWidget:
|
def _tab_about(self) -> QWidget:
|
||||||
w = QWidget()
|
"""Page « À propos », en cinq sous-onglets (modèle QuiOulé/TuxMarket).
|
||||||
w.setStyleSheet(theme.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
|
Le contenu (fonctions, garanties, pile technique, auteur) vit dans
|
||||||
logo_lbl = QLabel()
|
`app/app_info.py`, séparé de sa présentation ici — le rendre
|
||||||
logo_lbl.setPixmap(icon_app().pixmap(86, 86))
|
modifiable sans toucher au Qt. Le journal est lu depuis
|
||||||
logo_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
CHANGELOG.md à l'affichage, jamais recopié.
|
||||||
layout.addWidget(logo_lbl)
|
"""
|
||||||
|
from app import app_info
|
||||||
|
w, lay = self._dark_page("ℹ️ À propos", theme.BANNER["about"])
|
||||||
|
lay.setContentsMargins(0, 0, 0, 0)
|
||||||
|
lay.setSpacing(0)
|
||||||
|
|
||||||
name_lbl = QLabel("WGSecure")
|
lay.addWidget(self._about_header(app_info))
|
||||||
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.6.0")
|
sub = QTabWidget()
|
||||||
version_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
sub.setStyleSheet(theme.TABBAR_CSS)
|
||||||
version_lbl.setStyleSheet("color: rgba(255,255,255,0.7); font-size: 13px;")
|
sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions")
|
||||||
layout.addWidget(version_lbl)
|
sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties")
|
||||||
|
sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique")
|
||||||
sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
|
sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal")
|
||||||
sep.setStyleSheet("color: rgba(255,255,255,0.15);")
|
sub.addTab(self._subtab_about_author(app_info), "👤 Auteur")
|
||||||
layout.addWidget(sep)
|
lay.addWidget(sub)
|
||||||
|
|
||||||
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("Septembre 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
|
return w
|
||||||
|
|
||||||
|
# ── En-tête identité : icône, nom, accroche, pastilles d'état ────────
|
||||||
|
def _about_header(self, info) -> QWidget:
|
||||||
|
from app.ui.icons import icon_app
|
||||||
|
box = QWidget()
|
||||||
|
box.setStyleSheet(f"background: {theme.BG_RAISED};")
|
||||||
|
v = QVBoxLayout(box)
|
||||||
|
v.setContentsMargins(20, 16, 20, 14)
|
||||||
|
v.setSpacing(6)
|
||||||
|
v.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
logo = QLabel()
|
||||||
|
logo.setPixmap(icon_app().pixmap(72, 72))
|
||||||
|
logo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
v.addWidget(logo)
|
||||||
|
|
||||||
|
name = QLabel(info.APP_NAME)
|
||||||
|
name.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
nf = QFont(); nf.setPointSize(18); nf.setBold(True)
|
||||||
|
name.setFont(nf)
|
||||||
|
name.setStyleSheet(f"color: {theme.TEXT};")
|
||||||
|
v.addWidget(name)
|
||||||
|
|
||||||
|
tagline = QLabel(info.TAGLINE)
|
||||||
|
tagline.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
tagline.setWordWrap(True)
|
||||||
|
tagline.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 12px;")
|
||||||
|
v.addWidget(tagline)
|
||||||
|
|
||||||
|
pills = QWidget()
|
||||||
|
pills.setStyleSheet("background: transparent;")
|
||||||
|
ph = QHBoxLayout(pills)
|
||||||
|
ph.setContentsMargins(0, 4, 0, 0)
|
||||||
|
ph.setSpacing(8)
|
||||||
|
ph.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
for text, color in self._about_pills(info):
|
||||||
|
pill = QLabel(text)
|
||||||
|
pill.setStyleSheet(
|
||||||
|
f"background: {color}; color: white; border-radius: 9px;"
|
||||||
|
" padding: 3px 10px; font-size: 11px; font-weight: bold;"
|
||||||
|
)
|
||||||
|
ph.addWidget(pill)
|
||||||
|
v.addWidget(pills)
|
||||||
|
return box
|
||||||
|
|
||||||
|
def _about_pills(self, info) -> list[tuple[str, str]]:
|
||||||
|
"""Pastilles de version/licence, plus l'état réel de MFA et des
|
||||||
|
droits sudo — lu en direct, jamais une valeur supposée."""
|
||||||
|
from app.utils.platform_utils import (
|
||||||
|
has_root_privileges, is_windows, privilege_report,
|
||||||
|
)
|
||||||
|
pills = [
|
||||||
|
(f"v{info.VERSION}", theme.ACCENT),
|
||||||
|
(info.LICENSE, "#5b6b7a"),
|
||||||
|
]
|
||||||
|
if self._cfg.mfa_enabled:
|
||||||
|
pills.append(("🔐 MFA activée", theme.OK_SOLID))
|
||||||
|
else:
|
||||||
|
pills.append(("🔐 MFA désactivée", "#5b6b7a"))
|
||||||
|
|
||||||
|
if not is_windows():
|
||||||
|
if has_root_privileges():
|
||||||
|
pills.append(("⚠️ Exécuté en root", theme.WARN_SOLID))
|
||||||
|
else:
|
||||||
|
rows = privilege_report()
|
||||||
|
if rows and all(ok for _, ok, _ in rows):
|
||||||
|
pills.append(("✅ sudoers configuré", theme.OK_SOLID))
|
||||||
|
elif rows:
|
||||||
|
pills.append(("⚠️ dialogue à chaque appel", theme.WARN_SOLID))
|
||||||
|
return pills
|
||||||
|
|
||||||
|
# ── Sous-onglet : fonctions ───────────────────────────────────────────
|
||||||
|
def _subtab_about_features(self, info) -> QWidget:
|
||||||
|
page, v = self._test_page()
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidgetResizable(True)
|
||||||
|
area.setStyleSheet(theme.scrollarea_style())
|
||||||
|
inner = QWidget()
|
||||||
|
inner.setStyleSheet(f"background: {theme.BG};")
|
||||||
|
iv = QVBoxLayout(inner)
|
||||||
|
iv.setContentsMargins(0, 0, 8, 0)
|
||||||
|
iv.setSpacing(10)
|
||||||
|
for title, desc in info.FEATURES:
|
||||||
|
iv.addWidget(self._feature_row(title, desc))
|
||||||
|
iv.addStretch()
|
||||||
|
area.setWidget(inner)
|
||||||
|
v.addWidget(area)
|
||||||
|
return page
|
||||||
|
|
||||||
|
def _feature_row(self, title: str, desc: str) -> QWidget:
|
||||||
|
row = QWidget()
|
||||||
|
row.setStyleSheet("background: transparent;")
|
||||||
|
rv = QVBoxLayout(row)
|
||||||
|
rv.setContentsMargins(0, 0, 0, 0)
|
||||||
|
rv.setSpacing(2)
|
||||||
|
t = QLabel(title)
|
||||||
|
tf = QFont(); tf.setBold(True)
|
||||||
|
t.setFont(tf)
|
||||||
|
t.setStyleSheet(f"color: {theme.TEXT};")
|
||||||
|
rv.addWidget(t)
|
||||||
|
d = QLabel(desc)
|
||||||
|
d.setWordWrap(True)
|
||||||
|
d.setStyleSheet(f"color: {theme.TEXT_MUTED}; font-size: 11px;")
|
||||||
|
rv.addWidget(d)
|
||||||
|
return row
|
||||||
|
|
||||||
|
# ── Sous-onglet : garanties ───────────────────────────────────────────
|
||||||
|
def _subtab_about_guarantees(self, info) -> QWidget:
|
||||||
|
page, v = self._test_page()
|
||||||
|
v.addWidget(self._hint(info.GUARANTEES_INTRO))
|
||||||
|
area = QScrollArea()
|
||||||
|
area.setWidgetResizable(True)
|
||||||
|
area.setStyleSheet(theme.scrollarea_style())
|
||||||
|
inner = QWidget()
|
||||||
|
inner.setStyleSheet(f"background: {theme.BG};")
|
||||||
|
iv = QVBoxLayout(inner)
|
||||||
|
iv.setContentsMargins(0, 0, 8, 0)
|
||||||
|
iv.setSpacing(8)
|
||||||
|
for item in info.GUARANTEES:
|
||||||
|
lbl = QLabel("• " + item)
|
||||||
|
lbl.setWordWrap(True)
|
||||||
|
lbl.setStyleSheet(f"color: {theme.TEXT}; font-size: 12px;")
|
||||||
|
iv.addWidget(lbl)
|
||||||
|
iv.addStretch()
|
||||||
|
area.setWidget(inner)
|
||||||
|
v.addWidget(area)
|
||||||
|
return page
|
||||||
|
|
||||||
|
# ── Sous-onglet : pile technique ──────────────────────────────────────
|
||||||
|
def _subtab_about_tech(self, info) -> QWidget:
|
||||||
|
page, v = self._test_page()
|
||||||
|
grp = QGroupBox("Pile technique")
|
||||||
|
g = QFormLayout(grp)
|
||||||
|
for layer, tool in info.TECH_STACK:
|
||||||
|
layer_lbl = QLabel(layer)
|
||||||
|
lf = QFont(); lf.setBold(True)
|
||||||
|
layer_lbl.setFont(lf)
|
||||||
|
tool_lbl = QLabel(tool)
|
||||||
|
tool_lbl.setWordWrap(True)
|
||||||
|
tool_lbl.setStyleSheet(f"color: {theme.TEXT_MUTED};")
|
||||||
|
g.addRow(layer_lbl, tool_lbl)
|
||||||
|
v.addWidget(grp)
|
||||||
|
v.addStretch()
|
||||||
|
return page
|
||||||
|
|
||||||
|
# ── Sous-onglet : journal (lu depuis CHANGELOG.md) ────────────────────
|
||||||
|
def _subtab_about_changelog(self, info) -> QWidget:
|
||||||
|
page, v = self._test_page()
|
||||||
|
view = QTextEdit()
|
||||||
|
view.setReadOnly(True)
|
||||||
|
view.setStyleSheet(theme.result_view_style("idle", font_size=12))
|
||||||
|
parts = []
|
||||||
|
for entry in info.parse_changelog():
|
||||||
|
parts.append(
|
||||||
|
f"<p style='color:{theme.ACCENT_LIGHT};font-weight:bold;"
|
||||||
|
f"font-size:13px;margin-bottom:2px'>{entry['title']}</p>"
|
||||||
|
)
|
||||||
|
for section in entry["sections"]:
|
||||||
|
parts.append(f"<b>{section['name']}</b><ul style='margin-top:2px'>")
|
||||||
|
for item in section["items"]:
|
||||||
|
parts.append(
|
||||||
|
"<li>" + re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", item) + "</li>"
|
||||||
|
)
|
||||||
|
parts.append("</ul>")
|
||||||
|
view.setHtml("".join(parts) or "<i>Aucun changelog trouvé.</i>")
|
||||||
|
v.addWidget(view)
|
||||||
|
return page
|
||||||
|
|
||||||
|
# ── Sous-onglet : auteur ───────────────────────────────────────────────
|
||||||
|
def _subtab_about_author(self, info) -> QWidget:
|
||||||
|
page, v = self._test_page()
|
||||||
|
v.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
card = QWidget()
|
||||||
|
card.setStyleSheet(f"background: {theme.BG_RAISED}; border-radius: 8px;")
|
||||||
|
cv = QVBoxLayout(card)
|
||||||
|
cv.setContentsMargins(24, 20, 24, 20)
|
||||||
|
cv.setSpacing(6)
|
||||||
|
cv.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
initial = QLabel(info.AUTHOR_NAME[:1].upper())
|
||||||
|
initial.setFixedSize(56, 56)
|
||||||
|
initial.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
initial.setStyleSheet(
|
||||||
|
f"background: {theme.ACCENT}; color: white; border-radius: 28px;"
|
||||||
|
" font-size: 22px; font-weight: bold;"
|
||||||
|
)
|
||||||
|
cv.addWidget(initial, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
name = QLabel(info.AUTHOR_NAME)
|
||||||
|
nf = QFont(); nf.setPointSize(14); nf.setBold(True)
|
||||||
|
name.setFont(nf)
|
||||||
|
name.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
cv.addWidget(name)
|
||||||
|
|
||||||
|
role = QLabel(info.AUTHOR_ROLE)
|
||||||
|
role.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
role.setStyleSheet(f"color: {theme.TEXT_MUTED};")
|
||||||
|
cv.addWidget(role)
|
||||||
|
|
||||||
|
repo = QLineEdit(info.REPO_URL)
|
||||||
|
repo.setReadOnly(True)
|
||||||
|
repo.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
repo.setFixedWidth(320)
|
||||||
|
cv.addWidget(repo, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
||||||
|
disclosure = QLabel(
|
||||||
|
"Application développée avec l'assistance de l'IA, sous la "
|
||||||
|
f"direction et la supervision de {info.AUTHOR_NAME}."
|
||||||
|
)
|
||||||
|
disclosure.setWordWrap(True)
|
||||||
|
disclosure.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
disclosure.setFixedWidth(320)
|
||||||
|
disclosure.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;")
|
||||||
|
cv.addWidget(disclosure)
|
||||||
|
|
||||||
|
v.addStretch()
|
||||||
|
v.addWidget(card, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||||
|
v.addStretch()
|
||||||
|
return page
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Chargement / sauvegarde
|
# Chargement / sauvegarde
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# Auto-reconnect timer
|
# Auto-reconnect timer
|
||||||
if self._cfg.get("ui", "auto_reconnect"):
|
if self._cfg.get("ui", "auto_reconnect"):
|
||||||
interval = int(self._cfg.get("ui", "reconnect_interval") or 30) * 1000
|
interval = int(self._cfg.get("ui", "reconnect_interval") or 15) * 1000
|
||||||
if not self._reconnect_timer.isActive():
|
if not self._reconnect_timer.isActive():
|
||||||
self._reconnect_timer.start(interval)
|
self._reconnect_timer.start(interval)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -280,6 +280,23 @@ def run_privileged(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_privileged_readonly(cmd: list[str], timeout: int = 10) -> tuple[int, str, str]:
|
||||||
|
"""Comme `run_privileged`, mais pour un sondage périodique en lecture seule.
|
||||||
|
|
||||||
|
N'ouvre jamais de dialogue (askpass/pkexec/UAC) : un sondage lancé toutes
|
||||||
|
les quelques secondes (état du tunnel, handshake) rendrait l'application
|
||||||
|
inutilisable si chaque appel pouvait faire apparaître une invite. Ne
|
||||||
|
fonctionne donc que si le process est déjà root/administrateur, ou si la
|
||||||
|
commande est couverte par une règle sudo NOPASSWD (`make setup-sudoers`).
|
||||||
|
Échoue silencieusement sinon, comme un manque de privilèges ordinaire.
|
||||||
|
"""
|
||||||
|
if has_root_privileges():
|
||||||
|
return run_command(cmd, timeout)
|
||||||
|
if is_windows():
|
||||||
|
return -1, "", "Élévation requise"
|
||||||
|
return run_command(["sudo", "-n"] + cmd, timeout, env=_c_locale_env())
|
||||||
|
|
||||||
|
|
||||||
def _find_askpass() -> str:
|
def _find_askpass() -> str:
|
||||||
"""Cherche un programme askpass graphique disponible."""
|
"""Cherche un programme askpass graphique disponible."""
|
||||||
candidates = [
|
candidates = [
|
||||||
@@ -302,13 +319,39 @@ def _find_askpass() -> str:
|
|||||||
|
|
||||||
|
|
||||||
# Commandes que WGSecure lance avec des privilèges, et ce qu'elles servent.
|
# Commandes que WGSecure lance avec des privilèges, et ce qu'elles servent.
|
||||||
_PRIVILEGED_COMMANDS = (
|
#
|
||||||
|
# `make setup-sudoers` installe des règles NOPASSWD restreintes à des motifs
|
||||||
|
# d'arguments précis (ex. `ip link delete dev *`), jamais à la commande nue.
|
||||||
|
# Les sondes doivent donc reproduire un appel réel qui matche ce motif :
|
||||||
|
# tester avec `--version`/`-V` interroge un appel que sudoers n'autorise
|
||||||
|
# jamais, et affiche « dialogue à chaque appel » même quand tout est
|
||||||
|
# correctement configuré.
|
||||||
|
_PRIVILEGE_PROBE_IFACE = "wgsecure-privcheck0"
|
||||||
|
|
||||||
|
|
||||||
|
def _install_probe_cmd() -> list[str]:
|
||||||
|
"""Reproduit le motif `install -D -m 600 -o root -g root * /etc/wireguard/*`.
|
||||||
|
|
||||||
|
Écrit un fichier vide et sans conséquence (`.wgsecure-privcheck`, jamais
|
||||||
|
lu par wg-quick) : c'est le seul moyen fiable de tester ce motif, `sudo
|
||||||
|
-n -l` ne le pouvant pas (cf. `_sudo_refused_auth`).
|
||||||
|
"""
|
||||||
|
dest = os.path.join(get_wg_config_dir(), ".wgsecure-privcheck")
|
||||||
|
return ["install", "-D", "-m", "600", "-o", "root", "-g", "root",
|
||||||
|
"/dev/null", dest]
|
||||||
|
|
||||||
|
|
||||||
|
def _privileged_commands() -> tuple[tuple[list[str], str], ...]:
|
||||||
|
return (
|
||||||
(["wg-quick", "--help"], "monter / démonter le tunnel"),
|
(["wg-quick", "--help"], "monter / démonter le tunnel"),
|
||||||
(["install", "--version"], "écrire la config dans /etc/wireguard"),
|
(_install_probe_cmd(), "écrire la config dans /etc/wireguard"),
|
||||||
(["ip", "-V"], "supprimer une interface orpheline"),
|
(["ip", "link", "delete", "dev", _PRIVILEGE_PROBE_IFACE],
|
||||||
(["resolvconf", "--version"], "retirer l'entrée DNS du tunnel"),
|
"supprimer une interface orpheline"),
|
||||||
(["resolvectl", "--version"], "purger le DNS systemd-resolved"),
|
(["resolvconf", "-d", _PRIVILEGE_PROBE_IFACE],
|
||||||
)
|
"retirer l'entrée DNS du tunnel"),
|
||||||
|
(["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE],
|
||||||
|
"purger le DNS systemd-resolved"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def privilege_report() -> list[tuple[str, bool, str]]:
|
def privilege_report() -> list[tuple[str, bool, str]]:
|
||||||
@@ -322,7 +365,7 @@ def privilege_report() -> list[tuple[str, bool, str]]:
|
|||||||
report: list[tuple[str, bool, str]] = []
|
report: list[tuple[str, bool, str]] = []
|
||||||
if is_windows() or has_root_privileges():
|
if is_windows() or has_root_privileges():
|
||||||
return report
|
return report
|
||||||
for probe, role in _PRIVILEGED_COMMANDS:
|
for probe, role in _privileged_commands():
|
||||||
if not _sh.which(probe[0]):
|
if not _sh.which(probe[0]):
|
||||||
continue
|
continue
|
||||||
code, _, err = run_command(["sudo", "-n"] + probe, 8, env=_c_locale_env())
|
code, _, err = run_command(["sudo", "-n"] + probe, 8, env=_c_locale_env())
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
WGSecure (WGS) — Interface graphique WireGuard avec MFA
|
WGSecure (WGS) — Interface graphique WireGuard avec MFA
|
||||||
Version 0.6.0
|
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
@@ -9,10 +8,12 @@ import argparse
|
|||||||
from PyQt6.QtWidgets import QApplication, QMessageBox, QInputDialog, QLineEdit, QSystemTrayIcon
|
from PyQt6.QtWidgets import QApplication, QMessageBox, QInputDialog, QLineEdit, QSystemTrayIcon
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
from app.core.config import Config
|
from app.core.config import Config
|
||||||
from app.core import dns as dns_util
|
from app.core import dns as dns_util
|
||||||
from app.core import log as conn_log
|
from app.core import log as conn_log
|
||||||
from app.core import shutdown as shutdown_guard
|
from app.core import shutdown as shutdown_guard
|
||||||
|
from app.core.single_instance import SingleInstanceGuard
|
||||||
from app.ui.main_window import MainWindow
|
from app.ui.main_window import MainWindow
|
||||||
from app.ui.systray import SystemTray
|
from app.ui.systray import SystemTray
|
||||||
from app.ui import icons
|
from app.ui import icons
|
||||||
@@ -92,8 +93,14 @@ def main():
|
|||||||
)
|
)
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("WGSecure")
|
app.setApplicationName("WGSecure")
|
||||||
app.setApplicationVersion("0.6.0")
|
app.setApplicationVersion(__version__)
|
||||||
app.setOrganizationName("WGS")
|
app.setOrganizationName("WGS")
|
||||||
|
|
||||||
|
# Une seule instance à la fois : un second lancement réveille la
|
||||||
|
# première plutôt que d'ouvrir une deuxième fenêtre sur le même tunnel.
|
||||||
|
guard = SingleInstanceGuard()
|
||||||
|
if not guard.try_acquire():
|
||||||
|
sys.exit(0)
|
||||||
# Icône globale multi-tailles — barre des tâches + alt-tab
|
# Icône globale multi-tailles — barre des tâches + alt-tab
|
||||||
app_icon = icons.icon_app()
|
app_icon = icons.icon_app()
|
||||||
app.setWindowIcon(app_icon)
|
app.setWindowIcon(app_icon)
|
||||||
@@ -122,10 +129,12 @@ def main():
|
|||||||
win = AdminWindow(config)
|
win = AdminWindow(config)
|
||||||
win.setWindowIcon(icons.icon_admin())
|
win.setWindowIcon(icons.icon_admin())
|
||||||
win.show()
|
win.show()
|
||||||
|
guard.on_activate_requested(lambda: _raise_window(win))
|
||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
# Mode User (défaut)
|
# Mode User (défaut)
|
||||||
window = MainWindow(config)
|
window = MainWindow(config)
|
||||||
|
guard.on_activate_requested(lambda: _raise_window(window))
|
||||||
|
|
||||||
# Systray
|
# Systray
|
||||||
if not args.no_tray and QSystemTrayIcon_available():
|
if not args.no_tray and QSystemTrayIcon_available():
|
||||||
@@ -148,6 +157,13 @@ def main():
|
|||||||
sys.exit(app.exec())
|
sys.exit(app.exec())
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_window(window) -> None:
|
||||||
|
"""Ramène une fenêtre existante au premier plan (second lancement)."""
|
||||||
|
window.showNormal()
|
||||||
|
window.raise_()
|
||||||
|
window.activateWindow()
|
||||||
|
|
||||||
|
|
||||||
def QSystemTrayIcon_available() -> bool:
|
def QSystemTrayIcon_available() -> bool:
|
||||||
from PyQt6.QtWidgets import QSystemTrayIcon
|
from PyQt6.QtWidgets import QSystemTrayIcon
|
||||||
return QSystemTrayIcon.isSystemTrayAvailable()
|
return QSystemTrayIcon.isSystemTrayAvailable()
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
; ──────────────────────────────────────────────
|
; ──────────────────────────────────────────────
|
||||||
|
|
||||||
#define MyAppName "WGSecure"
|
#define MyAppName "WGSecure"
|
||||||
#define MyAppVersion "0.6.0"
|
#define MyAppVersion "0.7.0"
|
||||||
#define MyAppPublisher "WGSecure"
|
#define MyAppPublisher "WGSecure"
|
||||||
#define MyAppExeName "wgsecure.exe"
|
#define MyAppExeName "wgsecure.exe"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user