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:
2026-09-01 19:18:24 +02:00
parent 6251a3ea8e
commit 8c09c02362
13 changed files with 590 additions and 104 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
__version__ = "0.6.0"
__version__ = "0.7.0"
APP_NAME = "WGSecure"
APP_SHORT = "WGS"
+128
View File
@@ -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
View File
@@ -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)
+63
View File
@@ -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()
+9 -6
View File
@@ -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
View File
@@ -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
# ------------------------------------------------------------------ #
+1 -1
View File
@@ -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:
+51 -8
View File
@@ -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())