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:
+1
-1
@@ -1,3 +1,3 @@
|
||||
__version__ = "0.6.0"
|
||||
__version__ = "0.7.0"
|
||||
APP_NAME = "WGSecure"
|
||||
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] = {
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"admin_password_hash": "",
|
||||
"admin_salt": "",
|
||||
"mfa_enabled": False,
|
||||
@@ -46,10 +46,10 @@ _DEFAULT: dict[str, Any] = {
|
||||
"wg": deepcopy(_WG_DEFAULT),
|
||||
"ui": {
|
||||
"minimize_to_tray": True,
|
||||
"autostart": False,
|
||||
"autostart": True,
|
||||
"auto_connect_on_startup": False,
|
||||
"auto_reconnect": False,
|
||||
"reconnect_interval": 30,
|
||||
"auto_reconnect": True,
|
||||
"reconnect_interval": 15,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ class Config:
|
||||
except Exception:
|
||||
self._data = deepcopy(_DEFAULT)
|
||||
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._apply_autostart(self._data["ui"]["autostart"])
|
||||
|
||||
def save(self):
|
||||
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,
|
||||
run_command,
|
||||
run_privileged,
|
||||
run_privileged_readonly,
|
||||
wg_available,
|
||||
wg_exe,
|
||||
wg_quick_available,
|
||||
@@ -150,12 +151,14 @@ def write_client_config(cfg: Config) -> tuple[bool, str]:
|
||||
def _wg_show(name: str) -> str:
|
||||
"""Sortie de `wg show <iface>`, ou "" si les privilèges manquent.
|
||||
|
||||
Attention : sans CAP_NET_ADMIN, `wg show` écrit « Unable to access
|
||||
interface: Operation not permitted » sur stderr **et sort avec le code 0**.
|
||||
Un simple test `code == 0` conclut donc « déconnecté » pour tout
|
||||
utilisateur non root — c'était le cas de l'application entière.
|
||||
`wg show` exige CAP_NET_ADMIN (root) pour lire l'état d'une interface,
|
||||
même en lecture seule — un utilisateur ordinaire reçoit « Unable to
|
||||
access interface: Operation not permitted ». D'où l'appel via
|
||||
`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:
|
||||
return ""
|
||||
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
|
||||
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():
|
||||
return None
|
||||
newest = 0
|
||||
|
||||
+241
-58
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
|
||||
QLabel, QLineEdit, QPushButton, QSpinBox, QCheckBox,
|
||||
@@ -861,69 +863,250 @@ class AdminWindow(QDialog):
|
||||
# Onglet À propos
|
||||
# ------------------------------------------------------------------ #
|
||||
def _tab_about(self) -> QWidget:
|
||||
w = QWidget()
|
||||
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)
|
||||
"""Page « À propos », en cinq sous-onglets (modèle QuiOulé/TuxMarket).
|
||||
|
||||
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)
|
||||
Le contenu (fonctions, garanties, pile technique, auteur) vit dans
|
||||
`app/app_info.py`, séparé de sa présentation ici — le rendre
|
||||
modifiable sans toucher au Qt. Le journal est lu depuis
|
||||
CHANGELOG.md à l'affichage, jamais recopié.
|
||||
"""
|
||||
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")
|
||||
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)
|
||||
lay.addWidget(self._about_header(app_info))
|
||||
|
||||
version_lbl = QLabel("Version 0.6.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("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()
|
||||
sub = QTabWidget()
|
||||
sub.setStyleSheet(theme.TABBAR_CSS)
|
||||
sub.addTab(self._subtab_about_features(app_info), "⭐ Fonctions")
|
||||
sub.addTab(self._subtab_about_guarantees(app_info), "🛡️ Garanties")
|
||||
sub.addTab(self._subtab_about_tech(app_info), "🧱 Technique")
|
||||
sub.addTab(self._subtab_about_changelog(app_info), "🕓 Journal")
|
||||
sub.addTab(self._subtab_about_author(app_info), "👤 Auteur")
|
||||
lay.addWidget(sub)
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -480,7 +480,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Auto-reconnect timer
|
||||
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():
|
||||
self._reconnect_timer.start(interval)
|
||||
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:
|
||||
"""Cherche un programme askpass graphique disponible."""
|
||||
candidates = [
|
||||
@@ -302,13 +319,39 @@ def _find_askpass() -> str:
|
||||
|
||||
|
||||
# Commandes que WGSecure lance avec des privilèges, et ce qu'elles servent.
|
||||
_PRIVILEGED_COMMANDS = (
|
||||
(["wg-quick", "--help"], "monter / démonter le tunnel"),
|
||||
(["install", "--version"], "écrire la config dans /etc/wireguard"),
|
||||
(["ip", "-V"], "supprimer une interface orpheline"),
|
||||
(["resolvconf", "--version"], "retirer l'entrée DNS du tunnel"),
|
||||
(["resolvectl", "--version"], "purger le DNS systemd-resolved"),
|
||||
)
|
||||
#
|
||||
# `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"),
|
||||
(_install_probe_cmd(), "écrire la config dans /etc/wireguard"),
|
||||
(["ip", "link", "delete", "dev", _PRIVILEGE_PROBE_IFACE],
|
||||
"supprimer une interface orpheline"),
|
||||
(["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]]:
|
||||
@@ -322,7 +365,7 @@ def privilege_report() -> list[tuple[str, bool, str]]:
|
||||
report: list[tuple[str, bool, str]] = []
|
||||
if is_windows() or has_root_privileges():
|
||||
return report
|
||||
for probe, role in _PRIVILEGED_COMMANDS:
|
||||
for probe, role in _privileged_commands():
|
||||
if not _sh.which(probe[0]):
|
||||
continue
|
||||
code, _, err = run_command(["sudo", "-n"] + probe, 8, env=_c_locale_env())
|
||||
|
||||
Reference in New Issue
Block a user