Add Remote DNS

This commit is contained in:
2026-09-04 08:55:35 +02:00
parent 910c04f845
commit 902c177330
9 changed files with 876 additions and 599 deletions
+2
View File
@@ -235,6 +235,8 @@ cmds = [c for c in ( \
w('ip') + ' link delete dev *' if w('ip') else '', \ w('ip') + ' link delete dev *' if w('ip') else '', \
w('resolvconf') + ' -d *' if w('resolvconf') else '', \ w('resolvconf') + ' -d *' if w('resolvconf') else '', \
w('resolvectl') + ' revert *' if w('resolvectl') else '', \ w('resolvectl') + ' revert *' if w('resolvectl') else '', \
w('resolvectl') + ' dns *' if w('resolvectl') else '', \
w('resolvectl') + ' domain *' if w('resolvectl') else '', \
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); \
+73 -98
View File
@@ -175,7 +175,7 @@ _SPLIT_DNS_RULE_PREFIX = "WGSecure-SplitDNS-"
def _nrpt_add_split_dns_ps(server: str, domains: list[str], rule_name: str) -> str: def _nrpt_add_split_dns_ps(server: str, domains: list[str], rule_name: str) -> str:
"""PowerShell script to add a NRPT rule for split-DNS. """PowerShell script to add a NRPT rule for split-DNS.
Args: Args:
server: DNS server IP address server: DNS server IP address
domains: List of domain suffixes (e.g., ["local", "internal"]) domains: List of domain suffixes (e.g., ["local", "internal"])
@@ -183,13 +183,23 @@ def _nrpt_add_split_dns_ps(server: str, domains: list[str], rule_name: str) -> s
""" """
if not domains: if not domains:
return "" return ""
# Build namespace list for NRPT rule clean = [d.strip().strip(".") for d in domains if d.strip()]
namespace_list = ", ".join(f"'{d.strip().strip('.')}'" for d in domains if d.strip()) # NRPT : un namespace sans point de tête ('h3adm.lan') ne matche que ce
# nom exact, jamais ses sous-domaines ; un namespace avec point de tête
# ('.h3adm.lan') ne matche que les sous-domaines, jamais le domaine lui-
# même. Les deux formes sont nécessaires pour couvrir toute la zone —
# comme `~domaine` sous systemd-resolved (Linux) couvre déjà les deux.
namespace_list = ", ".join(f"'{d}', '.{d}'" for d in clean)
return ( return (
f"$rule = Get-DnsClientNrptRule -Name '{rule_name}' -ErrorAction SilentlyContinue; " f"$rule = Get-DnsClientNrptRule -Name '{rule_name}' -ErrorAction SilentlyContinue; "
f"if ($rule) {{ Remove-DnsClientNrptRule -Name '{rule_name}' -Force -ErrorAction SilentlyContinue }}; " f"if ($rule) {{ Remove-DnsClientNrptRule -Name '{rule_name}' -Force -ErrorAction SilentlyContinue }}; "
f"Add-DnsClientNrptRule -Name '{rule_name}' -Namespace @({namespace_list}) " f"Add-DnsClientNrptRule -Name '{rule_name}' -Namespace @({namespace_list}) "
f"-NameServer '{server}' -Comment 'WGSecure Split-DNS' -ErrorAction SilentlyContinue" f"-NameServer '{server}' -Comment 'WGSecure Split-DNS'; "
# Sans ce contrôle, une erreur réelle d'Add-DnsClientNrptRule restait
# invisible : le script se terminait quand même avec le code 0, et
# `setup_split_dns` rapportait un succès alors que la règle n'avait
# jamais été posée.
"if (-not $?) { exit 1 }"
) )
@@ -217,76 +227,36 @@ def _systemd_resolved_available() -> bool:
def _setup_split_dns_linux(server: str, domains: list[str], iface: str) -> tuple[bool, str]: def _setup_split_dns_linux(server: str, domains: list[str], iface: str) -> tuple[bool, str]:
"""Configure le split-DNS sous Linux via systemd-resolved. """Configure le split-DNS sous Linux via systemd-resolved, par lien.
Crée un fichier dans /etc/systemd/resolved.conf.d/ pour router Écrivait auparavant un fichier global dans /etc/systemd/resolved.conf.d/
les domaines vers le serveur DNS spécifié. et redémarrait tout le service — deux privilèges que `make setup-sudoers`
ne couvrait pas (échec silencieux ou dialogue à chaque connexion), pour
Note: Le redémarrage de systemd-resolved est nécessaire pour appliquer un réglage qui entrait en concurrence avec la configuration éventuelle
la configuration, mais il est effectué de manière asynchrone pour d'autres liens et survivait à un arrêt brutal du tunnel.
éviter de bloquer le handshake WireGuard.
`resolvectl dns`/`resolvectl domain` posent la même route mais rattachée
Args: à l'interface WireGuard elle-même — comme `wg-quick` le fait déjà pour le
server: Adresse IP du serveur DNS (ex: 192.168.1.210) DNS non split. `resolvectl revert <iface>` (déjà autorisé sans mot de
domains: Liste de domaines (ex: ["local", "internal"]) passe, cf. `make setup-sudoers`) l'annule d'un coup, et systemd-resolved
iface: Nom de l'interface WireGuard la retire de lui-même dès que l'interface disparaît : plus de résidu à
surveiller après un crash.
Returns:
(success, message)
""" """
if not shutil.which("resolvectl"):
return False, "resolvectl introuvable : split-DNS indisponible sur ce système"
if not _systemd_resolved_available(): if not _systemd_resolved_available():
return False, "systemd-resolved non disponible. Utilisez resolvconf ou configurez manuellement." return False, "systemd-resolved non actif : split-DNS indisponible"
# Créer le nom du fichier de config code, out, err = run_privileged(["resolvectl", "dns", iface, server], timeout=10)
config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf" if code != 0:
return False, f"Échec de configuration du DNS distant : {err or out or 'erreur inconnue'}"
# Contenu du fichier
domain_str = ", ".join(f"~{d.strip()}" for d in domains if d.strip()) routing_domains = [f"~{d}" for d in domains]
content = f"[Resolve]\nDNS={server}\nDomains={domain_str}\n" code, out, err = run_privileged(["resolvectl", "domain", iface, *routing_domains], timeout=10)
if code != 0:
# Écrire le fichier (nécessite sudo) return False, f"Échec de configuration du domaine distant : {err or out or 'erreur inconnue'}"
import tempfile
import os return True, f"Split-DNS configuré pour {server} (domaines : {', '.join(domains)})"
import subprocess
# Créer un fichier temporaire
with tempfile.NamedTemporaryFile(mode='w', suffix='.conf', delete=False) as f:
f.write(content)
temp_path = f.name
try:
# Copier le fichier temporaire vers /etc/systemd/resolved.conf.d/
code, out, err = run_privileged(
["sudo", "cp", temp_path, config_file],
timeout=10
)
if code != 0:
return False, f"Échec de la copie du fichier: {err or out}"
# Redémarrer systemd-resolved en arrière-plan pour ne pas bloquer
# Utiliser systemd-run pour un redémarrage asynchrone
restart_cmd = ["sudo", "systemctl", "restart", "systemd-resolved"]
try:
# Lancer en arrière-plan avec nohup pour éviter de bloquer
subprocess.Popen(
restart_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
except Exception:
pass # Ignorer l'erreur, le fichier est déjà en place
return True, (
f"Split-DNS configuré pour {server} (domaines: {', '.join(domains)}). "
f"Redémarrez systemd-resolved manuellement si nécessaire: "
f"`sudo systemctl restart systemd-resolved`"
)
finally:
try:
os.unlink(temp_path)
except Exception:
pass
def setup_split_dns(cfg: Config) -> tuple[bool, str]: def setup_split_dns(cfg: Config) -> tuple[bool, str]:
@@ -334,34 +304,19 @@ def setup_split_dns(cfg: Config) -> tuple[bool, str]:
def _cleanup_split_dns_linux(iface: str) -> tuple[bool, str]: def _cleanup_split_dns_linux(iface: str) -> tuple[bool, str]:
"""Supprime la configuration split-DNS sous Linux. """Annule la config split-DNS par-lien de cette interface.
Note: Ne redémarre PAS systemd-resolved pour éviter de casser `force_cleanup()` appelle ceci après avoir déjà supprimé l'interface la
la résolution DNS pendant une reconnexion. Le service sera plupart du temps (systemd-resolved retire alors sa config par-lien tout
redémarré manuellement par l'utilisateur si nécessaire. seul) : `resolvectl revert` échoue simplement sur une interface déjà
partie, ce qui n'est pas une erreur — rien à nettoyer, pas un échec.
Args:
iface: Nom de l'interface WireGuard
Returns:
(success, message)
""" """
config_file = f"/etc/systemd/resolved.conf.d/wgsecure-{iface}.conf" if not shutil.which("resolvectl"):
# Supprimer le fichier de config (nécessite sudo)
import os
if not os.path.exists(config_file):
return True, "" return True, ""
code, out, err = run_privileged(["resolvectl", "revert", iface], timeout=10)
code, out, err = run_privileged( if code == 0:
["sudo", "rm", "-f", config_file], return True, "Configuration split-DNS retirée"
timeout=10 return True, ""
)
if code != 0:
return False, f"Échec de la suppression du fichier split-DNS: {err or out}"
return True, "Configuration split-DNS supprimée (redémarrez systemd-resolved si nécessaire)"
def cleanup_split_dns(cfg: Config) -> tuple[bool, str]: def cleanup_split_dns(cfg: Config) -> tuple[bool, str]:
@@ -488,6 +443,26 @@ def resolution_works(timeout: float = 3.0) -> bool:
socket.setdefaulttimeout(old) socket.setdefaulttimeout(old)
def resolve_host(host: str, timeout: float = 3.0) -> tuple[bool, list[str]]:
"""Résout un nom via le résolveur système. Retourne (succès, IPs uniques).
Sert au split-DNS : `resolution_works()` ne confirme qu'un résolveur
quelconque répond, jamais que les noms du réseau distant (le domaine
configuré en split-DNS, ou un hôte à l'intérieur) résolvent bien vers ce
réseau plutôt que vers un NXDOMAIN ou une réponse publique inattendue.
"""
old = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
try:
infos = socket.getaddrinfo(host, None)
ips = list(dict.fromkeys(info[4][0] for info in infos))
return True, ips
except OSError:
return False, []
finally:
socket.setdefaulttimeout(old)
def repair_if_broken(iface: str, tunnel_dns: str = "") -> tuple[bool, str]: def repair_if_broken(iface: str, tunnel_dns: str = "") -> tuple[bool, str]:
"""Répare le DNS si le tunnel est absent mais une entrée résiduelle traîne. """Répare le DNS si le tunnel est absent mais une entrée résiduelle traîne.
+35 -8
View File
@@ -268,6 +268,20 @@ def _wait_connected(cfg: Config, timeout: int = 15) -> bool:
time.sleep(0.5) time.sleep(0.5)
def _post_connect_message(cfg: Config) -> str:
"""Message de succès de connexion, complété d'un avertissement si le
split-DNS configuré n'a pas pu être appliqué.
Son échec était auparavant ignoré silencieusement (`setup_split_dns`
appelé sans regarder son retour) : le tunnel se déclarait « activé »
même quand les noms du réseau distant ne se résolvaient jamais.
"""
ok, msg = dns_util.setup_split_dns(cfg)
if not ok:
return f"Tunnel WireGuard activé\n{msg}"
return "Tunnel WireGuard activé"
def connect(cfg: Config) -> tuple[bool, str]: def connect(cfg: Config) -> tuple[bool, str]:
if not cfg.configured: if not cfg.configured:
return False, "WireGuard non configuré. Ouvrez le panneau Admin." return False, "WireGuard non configuré. Ouvrez le panneau Admin."
@@ -302,9 +316,7 @@ def connect(cfg: Config) -> tuple[bool, str]:
# démarrage effectif du tunnel est asynchrone. Tester l'état # démarrage effectif du tunnel est asynchrone. Tester l'état
# immédiatement conclurait « échec » sur un tunnel qui monte. # immédiatement conclurait « échec » sur un tunnel qui monte.
if code == 0 and _wait_connected(cfg, timeout=15): if code == 0 and _wait_connected(cfg, timeout=15):
# Configurer le split-DNS si un serveur dédié est défini return True, _post_connect_message(cfg)
dns_util.setup_split_dns(cfg)
return True, "Tunnel WireGuard activé"
force_cleanup(cfg) force_cleanup(cfg)
return False, err or f"Échec de l'installation du tunnel (code {code})" return False, err or f"Échec de l'installation du tunnel (code {code})"
@@ -320,9 +332,7 @@ def connect(cfg: Config) -> tuple[bool, str]:
# Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement # Passer le nom d'interface (pas le chemin) : AppArmor autorise /etc/wireguard/ seulement
code, _, err = run_privileged(["wg-quick", "up", name], timeout=90) code, _, err = run_privileged(["wg-quick", "up", name], timeout=90)
if code == 0: if code == 0:
# Configurer le split-DNS si un serveur dédié est défini return True, _post_connect_message(cfg)
dns_util.setup_split_dns(cfg)
return True, "Tunnel WireGuard activé"
# Échec : wg-quick a pu s'arrêter après set_dns (ou être tué par le # Échec : wg-quick a pu s'arrêter après set_dns (ou être tué par le
# dépassement de délai avant son trap de nettoyage). On démonte # dépassement de délai avant son trap de nettoyage). On démonte
@@ -862,8 +872,25 @@ def _diag_tunnel(cfg: Config) -> list[dict]:
steps.append(_step(_FAIL, "Résolution DNS dans le tunnel", steps.append(_step(_FAIL, "Résolution DNS dans le tunnel",
"Plus aucun nom ne se résout — utilisez " "Plus aucun nom ne se résout — utilisez "
"« Réparer le DNS »")) "« Réparer le DNS »"))
else: return steps
steps.append(_step(_OK, "Résolution DNS dans le tunnel", "Fonctionnelle")) steps.append(_step(_OK, "Résolution DNS dans le tunnel", "Fonctionnelle"))
# Un résolveur qui répond ne dit rien sur le split-DNS lui-même : le nom
# peut très bien se résoudre... vers une réponse publique inattendue au
# lieu du réseau distant. On vérifie donc explicitement le(s) domaine(s)
# configurés, pas seulement qu'une résolution quelconque fonctionne.
domains = wg.get("split_dns_domains") or []
for domain in domains:
ok, ips = dns_util.resolve_host(domain, timeout=3.0)
if ok:
steps.append(_step(_OK, f"Résolution distante ({domain})",
"" + ", ".join(ips)))
else:
steps.append(_step(
_WARN, f"Résolution distante ({domain})",
"Ne se résout pas — vérifiez le serveur DNS distant "
f"({wg.get('split_dns_server', '?')}) et les règles NRPT/"
"systemd-resolved du split-DNS"))
return steps return steps
+423 -271
View File
File diff suppressed because it is too large Load Diff
+16 -19
View File
@@ -8,28 +8,25 @@ from PyQt6.QtCore import Qt
from PyQt6.QtGui import QColor, QFont from PyQt6.QtGui import QColor, QFont
from app.core import history as hist from app.core import history as hist
from app.ui import theme
_DARK = "#1c2833"
_DARK2 = "#17202a"
_CSS = f""" _CSS = f"""
QDialog {{ background: {_DARK2}; }} QDialog {{ background: {theme.BG_SUNKEN}; }}
QLabel {{ color: white; background: transparent; }} QLabel {{ color: {theme.TEXT}; background: transparent; }}
QTableWidget {{ QTableWidget {{
background: {_DARK}; color: white; gridline-color: #2e4057; background: {theme.BG}; color: {theme.TEXT}; gridline-color: {theme.BG_RAISED};
border: none; font-size: 11px; border: none; font-size: 11px;
}} }}
QHeaderView::section {{ QHeaderView::section {{
background: #2e4057; color: white; padding: 5px; background: {theme.BG_RAISED}; color: {theme.TEXT}; padding: 5px;
border: none; font-weight: bold; font-size: 11px; border: none; font-weight: bold; font-size: 11px;
}} }}
QTableWidget::item:selected {{ background: #2471a3; }} QTableWidget::item:selected {{ background: {theme.ACCENT}; }}
QPushButton {{ QPushButton {{
background: #2e4057; color: white; border-radius: 5px; background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 5px;
padding: 6px 14px; border: none; padding: 6px 14px; border: none;
}} }}
QPushButton:hover {{ background: #3d5166; }} QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}
""" """
@@ -51,7 +48,7 @@ class HistoryDialog(QDialog):
banner = QLabel(" 📊 Historique des sessions VPN") banner = QLabel(" 📊 Historique des sessions VPN")
banner.setFixedHeight(40) banner.setFixedHeight(40)
banner.setStyleSheet( banner.setStyleSheet(
f"background: {_DARK2}; color: white; font-size: 14px; font-weight: bold;" f"background: {theme.BG_SUNKEN}; color: {theme.TEXT}; font-size: 14px; font-weight: bold;"
) )
layout.addWidget(banner) layout.addWidget(banner)
@@ -68,14 +65,14 @@ class HistoryDialog(QDialog):
self._table.setAlternatingRowColors(True) self._table.setAlternatingRowColors(True)
self._table.setStyleSheet( self._table.setStyleSheet(
_CSS + _CSS +
"QTableWidget { alternate-background-color: #212f3d; }" f"QTableWidget {{ alternate-background-color: {theme.BG_RAISED}; }}"
) )
layout.addWidget(self._table) layout.addWidget(self._table)
# Barre infos + boutons # Barre infos + boutons
bar = QLabel("") bar = QLabel("")
bar.setFixedHeight(1) bar.setFixedHeight(1)
bar.setStyleSheet(f"background: #2e4057;") bar.setStyleSheet(f"background: {theme.BG_RAISED};")
layout.addWidget(bar) layout.addWidget(bar)
btn_row_w = QVBoxLayout() btn_row_w = QVBoxLayout()
@@ -85,15 +82,15 @@ class HistoryDialog(QDialog):
bottom.setContentsMargins(14, 8, 14, 12) bottom.setContentsMargins(14, 8, 14, 12)
self._summary_lbl = QLabel("") self._summary_lbl = QLabel("")
self._summary_lbl.setStyleSheet("color: #5d6d7e; font-size: 11px;") self._summary_lbl.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;")
bottom.addWidget(self._summary_lbl) bottom.addWidget(self._summary_lbl)
bottom.addStretch() bottom.addStretch()
btn_clear = QPushButton("🗑️ Effacer l'historique") btn_clear = QPushButton("🗑️ Effacer l'historique")
btn_clear.setStyleSheet( btn_clear.setStyleSheet(
"QPushButton { background: #6e2e1c; color: white; border-radius: 5px;" f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; border-radius: 5px;"
" padding: 6px 14px; border: none; }" " padding: 6px 14px; border: none; }"
"QPushButton:hover { background: #922b21; }" "QPushButton:hover { background: #e74c3c; }"
) )
btn_clear.clicked.connect(self._clear) btn_clear.clicked.connect(self._clear)
btn_close = QPushButton("Fermer") btn_close = QPushButton("Fermer")
@@ -102,7 +99,7 @@ class HistoryDialog(QDialog):
bottom.addWidget(btn_close) bottom.addWidget(btn_close)
bottom_w = QWidget() bottom_w = QWidget()
bottom_w.setStyleSheet(f"background: {_DARK2};") bottom_w.setStyleSheet(f"background: {theme.BG_SUNKEN};")
bottom_w.setLayout(bottom) bottom_w.setLayout(bottom)
layout.addWidget(bottom_w) layout.addWidget(bottom_w)
@@ -114,7 +111,7 @@ class HistoryDialog(QDialog):
for row, s in enumerate(sessions): for row, s in enumerate(sessions):
self._table.insertRow(row) self._table.insertRow(row)
end = s.get("end") end = s.get("end")
color = "#a9dfbf" if end else "#f9e79f" # vert=terminée, jaune=en cours color = theme.OK_TEXT if end else theme.WARN_TEXT # vert=terminée, jaune=en cours
cells = [ cells = [
s.get("start", ""), s.get("start", ""),
+254 -176
View File
@@ -2,8 +2,8 @@ from __future__ import annotations
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QFrame, QMessageBox, QInputDialog, QLabel, QPushButton, QFrame, QMessageBox, QInputDialog,
QLineEdit, QListWidget, QListWidgetItem, QApplication, QLineEdit, QListWidget, QListWidgetItem,
QComboBox, QSizePolicy, QComboBox,
) )
from PyQt6.QtCore import Qt, QTimer, QDateTime from PyQt6.QtCore import Qt, QTimer, QDateTime
from PyQt6.QtGui import QFont, QCloseEvent, QColor from PyQt6.QtGui import QFont, QCloseEvent, QColor
@@ -20,24 +20,7 @@ from app.ui.admin_window import AdminWindow
from app.ui.bw_graph import BandwidthGraph from app.ui.bw_graph import BandwidthGraph
from app.ui.history_dialog import HistoryDialog from app.ui.history_dialog import HistoryDialog
from app.ui import icons from app.ui import icons
from app.ui import theme
_DARK = "#1c2833"
_DARK2 = "#17202a"
_CARD = "#212f3d"
_LOG_COLORS = {
"success": "#a9dfbf",
"error": "#f1948a",
"warning": "#f9e79f",
"info": "#aed6f1",
}
_PING_CSS = {
"good": "background:#1e8449; color:white;",
"medium": "background:#d4ac0d; color:black;",
"bad": "background:#922b21; color:white;",
"offline": "background:#2e4057; color:#5d6d7e;",
}
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
@@ -58,7 +41,7 @@ class MainWindow(QMainWindow):
self._warned_foreign: set[str] = set() self._warned_foreign: set[str] = set()
self.setWindowTitle("WGSecure") self.setWindowTitle("WGSecure")
self.setFixedSize(430, 680) self.setFixedWidth(430)
self.setWindowFlags( self.setWindowFlags(
Qt.WindowType.Window Qt.WindowType.Window
| Qt.WindowType.WindowTitleHint | Qt.WindowType.WindowTitleHint
@@ -67,6 +50,11 @@ class MainWindow(QMainWindow):
) )
self.setWindowIcon(icons.icon_app()) self.setWindowIcon(icons.icon_app())
self._build_ui() self._build_ui()
# Repliée par défaut : au lancement, seuls statut et action de
# connexion sont utiles — le détail ne sert qu'à qui va le chercher.
self._details_expanded = False
self._details.setVisible(False)
self._sync_window_height()
# Timers # Timers
self._status_timer = QTimer(self) self._status_timer = QTimer(self)
@@ -98,198 +86,287 @@ class MainWindow(QMainWindow):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Construction UI # Construction UI
#
# Ordre pensé pour l'usage réel : statut et action de connexion sont
# immédiats (pas besoin de traverser la config ou le journal pour les
# atteindre) ; viennent ensuite les valeurs dynamiques (transfert,
# handshake), puis la config statique — consultée rarement une fois
# réglée — et enfin bande passante / journal, les plus techniques.
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _build_ui(self): def _build_ui(self):
central = QWidget() central = QWidget()
central.setStyleSheet(f"background: {_DARK};") central.setStyleSheet(f"background: {theme.BG};")
self.setCentralWidget(central) self.setCentralWidget(central)
root = QVBoxLayout(central) root = QVBoxLayout(central)
root.setContentsMargins(0, 0, 0, 0) root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0) root.setSpacing(0)
# ── En-tête ────────────────────────────────────────────────── root.addWidget(self._build_header())
root.addWidget(self._build_status_action())
root.addWidget(self._build_details())
# ── Pied de page ─────────────────────────────────────────────────
root.addWidget(self._build_footer())
# ── Section repliable : tout ce qui n'est pas statut/action ──────────
def _build_details(self) -> QWidget:
self._details = QWidget()
d = QVBoxLayout(self._details)
d.setContentsMargins(0, 0, 0, 0)
d.setSpacing(0)
d.addWidget(self._hsep())
d.addWidget(self._build_dynamic_details())
d.addWidget(self._hsep())
d.addWidget(self._build_config_summary())
d.addWidget(self._hsep())
# ── Bande passante ───────────────────────────────────────────────
d.addWidget(self._section_header("📊 Bande passante"))
self._bw_graph = BandwidthGraph()
self._bw_graph.setFixedHeight(75)
d.addWidget(self._bw_graph)
d.addWidget(self._hsep())
# ── Journal ───────────────────────────────────────────────────────
d.addWidget(self._section_header("📋 Journal", btn_text="Effacer",
btn_slot=self._clear_log))
self._log_list = QListWidget()
self._log_list.setFixedHeight(78)
self._log_list.setStyleSheet(
f"QListWidget {{ background: {theme.BG_SUNKEN}; color: {theme.ACCENT_LIGHT};"
" border: none; font-size: 10px; font-family: Courier; }"
"QListWidget::item { padding: 1px 6px; }"
)
self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
d.addWidget(self._log_list)
d.addWidget(self._hsep())
return self._details
# ── En-tête : identité + profil ─────────────────────────────────────
def _build_header(self) -> QWidget:
header = QWidget() header = QWidget()
header.setFixedHeight(72) header.setFixedHeight(64)
header.setStyleSheet(f"background: {_DARK2};") header.setStyleSheet(f"background: {theme.BG_SUNKEN};")
h = QHBoxLayout(header) h = QHBoxLayout(header)
h.setContentsMargins(14, 8, 14, 8) h.setContentsMargins(14, 8, 14, 8)
self._icon_label = QLabel() self._icon_label = QLabel()
self._icon_label.setFixedSize(40, 40) self._icon_label.setFixedSize(36, 36)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
h.addWidget(self._icon_label) h.addWidget(self._icon_label)
title_col = QVBoxLayout()
title_col.setSpacing(1)
title_lbl = QLabel("WGSecure") title_lbl = QLabel("WGSecure")
title_lbl.setStyleSheet( title_lbl.setStyleSheet(
"color: white; font-size: 16px; font-weight: bold; background: transparent;" f"color: {theme.TEXT}; font-size: 16px; font-weight: bold; background: transparent;"
) )
self._profile_combo = QComboBox() h.addWidget(title_lbl)
self._profile_combo.setFixedHeight(22)
self._profile_combo.setStyleSheet(
"QComboBox { background: #2e4057; color: #aed6f1; border: none;"
" border-radius: 3px; font-size: 10px; padding: 0 6px; }"
"QComboBox::drop-down { border: none; }"
"QComboBox QAbstractItemView { background: #2e4057; color: white; }"
)
self._profile_combo.currentTextChanged.connect(self._on_profile_changed)
title_col.addWidget(title_lbl)
title_col.addWidget(self._profile_combo)
h.addLayout(title_col)
h.addStretch() h.addStretch()
# Ping badge profile_col = QVBoxLayout()
profile_col.setSpacing(1)
profile_lbl = QLabel("Profil actif")
profile_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
profile_lbl.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 9px; background: transparent;"
)
profile_col.addWidget(profile_lbl)
self._profile_combo = QComboBox()
self._profile_combo.setFixedHeight(22)
self._profile_combo.setMinimumWidth(150)
self._profile_combo.setStyleSheet(
f"QComboBox {{ background: {theme.BG_RAISED}; color: {theme.ACCENT_LIGHT};"
" border: none; border-radius: 3px; font-size: 10px; padding: 0 6px; }"
"QComboBox::drop-down { border: none; }"
f"QComboBox QAbstractItemView {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; }}"
)
self._profile_combo.currentIndexChanged.connect(self._on_profile_changed)
profile_col.addWidget(self._profile_combo)
h.addLayout(profile_col)
return header
# ── Statut + action principale ───────────────────────────────────────
def _build_status_action(self) -> QWidget:
status_area = QWidget()
status_area.setStyleSheet(f"background: {theme.BG_RAISED};")
s = QVBoxLayout(status_area)
s.setContentsMargins(14, 12, 14, 12)
s.setSpacing(8)
top_row = QHBoxLayout()
top_row.setSpacing(8)
status_col = QVBoxLayout()
status_col.setSpacing(1)
self._status_badge = QLabel("● Déconnecté")
self._status_badge.setStyleSheet(
f"color: {theme.FAIL_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
)
status_col.addWidget(self._status_badge)
self._duration_label = QLabel("")
self._duration_label.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 11px; background: transparent;"
)
status_col.addWidget(self._duration_label)
top_row.addLayout(status_col)
top_row.addStretch()
# Badge de ping : santé de cette connexion, donc juste à côté de son
# statut plutôt que dans l'en-tête d'identité.
self._ping_badge = QLabel("— ms") self._ping_badge = QLabel("— ms")
self._ping_badge.setFixedSize(62, 22) self._ping_badge.setFixedSize(66, 24)
self._ping_badge.setAlignment(Qt.AlignmentFlag.AlignCenter) self._ping_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._ping_badge.setStyleSheet( self._ping_badge.setStyleSheet(
f"border-radius: 11px; font-size: 10px; font-weight: bold; {_PING_CSS['offline']}" f"border-radius: 12px; font-size: 10px; font-weight: bold;"
f" {theme.PING_CSS['offline']}"
) )
h.addWidget(self._ping_badge) top_row.addWidget(self._ping_badge, 0, Qt.AlignmentFlag.AlignVCenter)
s.addLayout(top_row)
# Copier clé publique self._btn_connect = QPushButton("Se connecter")
self._btn_copy_key = QPushButton("📋 Clé pub.") self._btn_connect.setFixedHeight(44)
self._btn_copy_key.setFixedHeight(26) self._btn_connect.setStyleSheet(
self._btn_copy_key.setStyleSheet( f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
"QPushButton { background: #2471a3; color: white; border-radius: 5px;" " font-weight: bold; border-radius: 6px; border: none; }"
" font-size: 10px; padding: 0 8px; border: none; }" "QPushButton:hover { background: #27ae60; }"
"QPushButton:hover { background: #1a5276; }" f"QPushButton:disabled {{ background: {theme.BG_RAISED}; color: {theme.TEXT_FAINT}; }}"
"QPushButton:disabled { background: #2e4057; color: #5d6d7e; }"
) )
self._btn_copy_key.clicked.connect(self._copy_public_key) self._btn_connect.clicked.connect(self._on_connect)
h.addWidget(self._btn_copy_key) s.addWidget(self._btn_connect)
root.addWidget(header)
# ── Statut ─────────────────────────────────────────────────── # Repli/dépli du détail (transfert, config, bande passante, journal) :
status_area = QWidget() # rattaché à l'action qu'il détaille, pas à l'en-tête.
status_area.setFixedHeight(80) self._btn_toggle_details = QPushButton("▾ Afficher le détail")
status_area.setStyleSheet(f"background: {_CARD};") self._btn_toggle_details.setFixedHeight(20)
s = QVBoxLayout(status_area) self._btn_toggle_details.setStyleSheet(
s.setContentsMargins(10, 6, 10, 6) f"QPushButton {{ background: transparent; color: {theme.TEXT_FAINT};"
s.setAlignment(Qt.AlignmentFlag.AlignCenter) " font-size: 10px; border: none; }"
f"QPushButton:hover {{ color: {theme.TEXT}; }}"
self._status_badge = QLabel("● Déconnecté")
self._status_badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._status_badge.setStyleSheet(
"color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;"
) )
s.addWidget(self._status_badge) self._btn_toggle_details.clicked.connect(self._toggle_details)
s.addWidget(self._btn_toggle_details)
self._duration_label = QLabel("") return status_area
self._duration_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._duration_label.setStyleSheet(
"color: #5d6d7e; font-size: 11px; background: transparent;"
)
s.addWidget(self._duration_label)
root.addWidget(status_area)
root.addWidget(self._hsep()) # ── Détails dynamiques : transfert + handshake ───────────────────────
def _build_dynamic_details(self) -> QWidget:
# ── Infos ───────────────────────────────────────────────────── frame = QWidget()
info_frame = QFrame() frame.setStyleSheet(f"background: {theme.BG};")
info_frame.setStyleSheet(f"background: {_DARK};") dl = QHBoxLayout(frame)
info_frame.setAutoFillBackground(True) dl.setContentsMargins(14, 8, 14, 8)
il = QVBoxLayout(info_frame) dl.setSpacing(16)
il.setContentsMargins(14, 8, 14, 8)
il.setSpacing(5)
self._info_labels: dict[str, QLabel] = {} self._info_labels: dict[str, QLabel] = {}
for key, icon_txt, label in [ for key, icon_txt, label in [
("server", "🌐", "Serveur"), ("rx_tx", "↕️", "Transfert"),
("iface", "🔌", "Interface"),
("addr", "📍", "Adresse IP"),
("mfa", "🔐", "MFA"),
("rx_tx", "↕️ ", "Transfert"),
("handshake", "🤝", "Handshake"), ("handshake", "🤝", "Handshake"),
]:
col = QVBoxLayout()
col.setSpacing(2)
hdr = QLabel(f"{icon_txt} {label}")
hdr.setStyleSheet(
f"color: {theme.TEXT_FAINT}; font-size: 10px; background: transparent;"
)
col.addWidget(hdr)
val = self._lbl("", color=theme.ACCENT_LIGHT, size=13)
val.setWordWrap(True)
self._info_labels[key] = val
col.addWidget(val)
dl.addLayout(col, 1)
return frame
# ── Config statique (repliée visuellement) ───────────────────────────
def _build_config_summary(self) -> QWidget:
frame = QFrame()
frame.setStyleSheet(f"background: {theme.BG};")
il = QVBoxLayout(frame)
il.setContentsMargins(14, 8, 14, 8)
il.setSpacing(4)
for key, icon_txt, label in [
("server", "🌐", "Serveur"),
("iface", "🔌", "Interface"),
("addr", "📍", "Adresse IP"),
("mfa", "🔐", "MFA"),
]: ]:
rw = QWidget() rw = QWidget()
rw.setStyleSheet("background: transparent;") rw.setStyleSheet("background: transparent;")
rl = QHBoxLayout(rw) rl = QHBoxLayout(rw)
rl.setContentsMargins(0, 0, 0, 0) rl.setContentsMargins(0, 0, 0, 0)
rl.setSpacing(5) rl.setSpacing(5)
rl.addWidget(self._lbl(icon_txt, fixed=20, color="#5d6d7e", size=11)) rl.addWidget(self._lbl(icon_txt, fixed=18, color=theme.TEXT_FAINT, size=10))
rl.addWidget(self._lbl(f"{label} :", fixed=100, color="#5d6d7e", size=12)) rl.addWidget(self._lbl(f"{label} :", fixed=90, color=theme.TEXT_FAINT, size=11))
val = self._lbl("", color="#aed6f1", size=12) val = self._lbl("", color=theme.TEXT_MUTED, size=11)
val.setWordWrap(True) val.setWordWrap(True)
self._info_labels[key] = val self._info_labels[key] = val
rl.addWidget(val, 1) rl.addWidget(val, 1)
il.addWidget(rw) il.addWidget(rw)
root.addWidget(info_frame) return frame
root.addWidget(self._hsep())
# ── Graphique bande passante ─────────────────────────────────── # ── Pied de page : accès secondaires ─────────────────────────────────
bw_header = self._section_header("📊 Bande passante") def _build_footer(self) -> QWidget:
root.addWidget(bw_header)
self._bw_graph = BandwidthGraph()
self._bw_graph.setFixedHeight(75)
root.addWidget(self._bw_graph)
root.addWidget(self._hsep())
# ── Journal ───────────────────────────────────────────────────
log_hdr = self._section_header("📋 Journal", btn_text="Effacer",
btn_slot=self._clear_log)
root.addWidget(log_hdr)
self._log_list = QListWidget()
self._log_list.setFixedHeight(78)
self._log_list.setStyleSheet(
f"QListWidget {{ background: {_DARK2}; color: #aed6f1;"
" border: none; font-size: 10px; font-family: Courier; }"
"QListWidget::item { padding: 1px 6px; }"
)
self._log_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
root.addWidget(self._log_list)
root.addWidget(self._hsep())
# ── Boutons ───────────────────────────────────────────────────
btn_area = QWidget() btn_area = QWidget()
btn_area.setStyleSheet(f"background: {_DARK2};") btn_area.setStyleSheet(f"background: {theme.BG_SUNKEN};")
bl = QVBoxLayout(btn_area) bl = QHBoxLayout(btn_area)
bl.setContentsMargins(14, 10, 14, 12) bl.setContentsMargins(14, 10, 14, 12)
bl.setSpacing(7) bl.setSpacing(6)
self._btn_connect = QPushButton("Se connecter")
self._btn_connect.setFixedHeight(42)
self._btn_connect.setStyleSheet(
"QPushButton { background: #1e8449; color: white; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #27ae60; }"
"QPushButton:disabled { background: #2e4057; color: #5d6d7e; }"
)
self._btn_connect.clicked.connect(self._on_connect)
bl.addWidget(self._btn_connect)
secondary_row = QHBoxLayout()
secondary_row.setSpacing(6)
btn_admin = QPushButton("⚙️ Administrateur") btn_admin = QPushButton("⚙️ Administrateur")
btn_admin.setFixedHeight(30) btn_admin.setFixedHeight(30)
btn_admin.setStyleSheet( btn_admin.setStyleSheet(
"QPushButton { background: #2e4057; color: white; border-radius: 6px;" f"QPushButton {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 6px;"
" font-size: 11px; border: none; }" " font-size: 11px; border: none; }"
"QPushButton:hover { background: #3d5166; }" f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
) )
btn_admin.clicked.connect(self._open_admin) btn_admin.clicked.connect(self._open_admin)
secondary_row.addWidget(btn_admin) bl.addWidget(btn_admin)
btn_history = QPushButton("📊 Historique") btn_history = QPushButton("📊 Historique")
btn_history.setFixedHeight(30) btn_history.setFixedHeight(30)
btn_history.setStyleSheet( btn_history.setStyleSheet(
"QPushButton { background: #2e4057; color: white; border-radius: 6px;" f"QPushButton {{ background: {theme.BG_RAISED}; color: {theme.TEXT}; border-radius: 6px;"
" font-size: 11px; border: none; }" " font-size: 11px; border: none; }"
"QPushButton:hover { background: #3d5166; }" f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
) )
btn_history.clicked.connect(self._open_history) btn_history.clicked.connect(self._open_history)
secondary_row.addWidget(btn_history) bl.addWidget(btn_history)
bl.addLayout(secondary_row) return btn_area
root.addWidget(btn_area)
# ── Repli/dépli du détail ─────────────────────────────────────────────
def _toggle_details(self):
# `QWidget.isVisible()` reflète la visibilité effective à l'écran,
# qui dépend de toute la chaîne de parents : avant que la fenêtre
# elle-même ne soit affichée, elle vaut toujours False. Un drapeau
# explicite évite ce piège.
self._details_expanded = not self._details_expanded
self._details.setVisible(self._details_expanded)
self._btn_toggle_details.setText(
"▴ Masquer le détail" if self._details_expanded else "▾ Afficher le détail"
)
self._sync_window_height()
def _sync_window_height(self):
"""Réajuste la hauteur fixe de la fenêtre à son contenu visible.
La fenêtre reste de taille fixe (pas de redimensionnement manuel),
mais cette taille change avec le repli/dépli du détail il faut
donc lever la contrainte précédente avant de laisser le layout
recalculer sa hauteur naturelle, puis la reverrouiller.
"""
self.setMinimumHeight(0)
self.setMaximumHeight(16777215)
self.centralWidget().adjustSize()
self.adjustSize()
self.setFixedHeight(self.sizeHint().height())
# ── Helpers UI ─────────────────────────────────────────────────────── # ── Helpers UI ───────────────────────────────────────────────────────
def _lbl(self, text: str, fixed: int = 0, color: str = "white", def _lbl(self, text: str, fixed: int = 0, color: str = theme.TEXT,
size: int = 12) -> QLabel: size: int = 12) -> QLabel:
lbl = QLabel(text) lbl = QLabel(text)
lbl.setStyleSheet( lbl.setStyleSheet(
@@ -303,18 +380,18 @@ class MainWindow(QMainWindow):
f = QFrame() f = QFrame()
f.setFrameShape(QFrame.Shape.HLine) f.setFrameShape(QFrame.Shape.HLine)
f.setFixedHeight(1) f.setFixedHeight(1)
f.setStyleSheet(f"background: {_DARK2};") f.setStyleSheet(f"background: {theme.BG_SUNKEN};")
return f return f
def _section_header(self, title: str, btn_text: str = "", def _section_header(self, title: str, btn_text: str = "",
btn_slot=None) -> QWidget: btn_slot=None) -> QWidget:
w = QWidget() w = QWidget()
w.setFixedHeight(24) w.setFixedHeight(24)
w.setStyleSheet(f"background: {_DARK2};") w.setStyleSheet(f"background: {theme.BG_SUNKEN};")
hl = QHBoxLayout(w) hl = QHBoxLayout(w)
hl.setContentsMargins(12, 0, 12, 0) hl.setContentsMargins(12, 0, 12, 0)
t = QLabel(title) t = QLabel(title)
t.setStyleSheet("color: #5d6d7e; font-size: 10px; font-weight: bold;" t.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 10px; font-weight: bold;"
" background: transparent;") " background: transparent;")
hl.addWidget(t) hl.addWidget(t)
hl.addStretch() hl.addStretch()
@@ -322,9 +399,9 @@ class MainWindow(QMainWindow):
b = QPushButton(btn_text) b = QPushButton(btn_text)
b.setFixedHeight(18) b.setFixedHeight(18)
b.setStyleSheet( b.setStyleSheet(
"QPushButton { background: transparent; color: #5d6d7e;" f"QPushButton {{ background: transparent; color: {theme.TEXT_FAINT};"
" font-size: 9px; border: none; padding: 0 4px; }" " font-size: 9px; border: none; padding: 0 4px; }"
"QPushButton:hover { color: white; }" f"QPushButton:hover {{ color: {theme.TEXT}; }}"
) )
b.clicked.connect(btn_slot) b.clicked.connect(btn_slot)
hl.addWidget(b) hl.addWidget(b)
@@ -336,16 +413,18 @@ class MainWindow(QMainWindow):
def _reload_profiles(self): def _reload_profiles(self):
self._profile_combo.blockSignals(True) self._profile_combo.blockSignals(True)
self._profile_combo.clear() self._profile_combo.clear()
self._profile_combo.addItem(f"{self._cfg.active_profile} (actif)") profiles = self._cfg.list_profiles()
for name in self._cfg.list_profiles(): active = self._cfg.active_profile
marker = "" if name == self._cfg.active_profile else " " if active not in profiles:
self._profile_combo.addItem(f"{marker}{name}") profiles = [active] + profiles
self._profile_combo.addItems(profiles)
idx = self._profile_combo.findText(active)
if idx >= 0:
self._profile_combo.setCurrentIndex(idx)
self._profile_combo.blockSignals(False) self._profile_combo.blockSignals(False)
def _on_profile_changed(self, text: str): def _on_profile_changed(self, index: int):
if "(actif)" in text: name = self._profile_combo.itemText(index)
return
name = text.lstrip("").strip()
if not name or name == self._cfg.active_profile: if not name or name == self._cfg.active_profile:
return return
if self._cfg.load_profile(name): if self._cfg.load_profile(name):
@@ -372,7 +451,7 @@ class MainWindow(QMainWindow):
self._add_log_item(ts, msg, kind) self._add_log_item(ts, msg, kind)
def _add_log_item(self, ts: str, msg: str, kind: str): def _add_log_item(self, ts: str, msg: str, kind: str):
color = _LOG_COLORS.get(kind, "#aed6f1") color = theme.LOG_COLORS.get(kind, theme.ACCENT_LIGHT)
item = QListWidgetItem(f"[{ts}] {msg}") item = QListWidgetItem(f"[{ts}] {msg}")
item.setForeground(QColor(color)) item.setForeground(QColor(color))
self._log_list.addItem(item) self._log_list.addItem(item)
@@ -446,8 +525,8 @@ class MainWindow(QMainWindow):
def _set_ping_badge(self, _ms, text: str, css_key: str): def _set_ping_badge(self, _ms, text: str, css_key: str):
self._ping_badge.setText(text) self._ping_badge.setText(text)
self._ping_badge.setStyleSheet( self._ping_badge.setStyleSheet(
"border-radius: 11px; font-size: 10px; font-weight: bold; " "border-radius: 12px; font-size: 10px; font-weight: bold; "
+ _PING_CSS[css_key] + theme.PING_CSS[css_key]
) )
def _check_reconnect(self): def _check_reconnect(self):
@@ -484,7 +563,6 @@ class MainWindow(QMainWindow):
self._info_labels["mfa"].setText( self._info_labels["mfa"].setText(
"Activé ✓" if self._cfg.mfa_enabled else "Désactivé" "Activé ✓" if self._cfg.mfa_enabled else "Désactivé"
) )
self._btn_copy_key.setEnabled(bool(wg.get("client_public_key", "")))
# Auto-reconnect timer # Auto-reconnect timer
if self._cfg.get("ui", "auto_reconnect"): if self._cfg.get("ui", "auto_reconnect"):
@@ -509,11 +587,11 @@ class MainWindow(QMainWindow):
self._connected_since = QDateTime.currentDateTime() self._connected_since = QDateTime.currentDateTime()
self._status_badge.setText("● Connecté") self._status_badge.setText("● Connecté")
self._status_badge.setStyleSheet( self._status_badge.setStyleSheet(
"color: #27ae60; font-size: 15px; font-weight: bold; background: transparent;" f"color: {theme.OK_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
) )
self._btn_connect.setText("Se déconnecter") self._btn_connect.setText("Se déconnecter")
self._btn_connect.setStyleSheet( self._btn_connect.setStyleSheet(
"QPushButton { background: #922b21; color: white; font-size: 14px;" f"QPushButton {{ background: {theme.FAIL_SOLID}; color: {theme.TEXT}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }" " font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #c0392b; }" "QPushButton:hover { background: #c0392b; }"
) )
@@ -522,7 +600,7 @@ class MainWindow(QMainWindow):
) )
self._info_labels["handshake"].setText(info.get("last_handshake", "")) self._info_labels["handshake"].setText(info.get("last_handshake", ""))
self._duration_label.setStyleSheet( self._duration_label.setStyleSheet(
"color: #a9dfbf; font-size: 11px; background: transparent;" f"color: {theme.OK_TEXT}; font-size: 11px; background: transparent;"
) )
self.setWindowIcon(icons.icon_connected()) self.setWindowIcon(icons.icon_connected())
else: else:
@@ -533,18 +611,18 @@ class MainWindow(QMainWindow):
self._prev_rx = self._prev_tx = None self._prev_rx = self._prev_tx = None
self._status_badge.setText("● Déconnecté") self._status_badge.setText("● Déconnecté")
self._status_badge.setStyleSheet( self._status_badge.setStyleSheet(
"color: #e74c3c; font-size: 15px; font-weight: bold; background: transparent;" f"color: {theme.FAIL_TEXT}; font-size: 15px; font-weight: bold; background: transparent;"
) )
self._btn_connect.setText("Se connecter") self._btn_connect.setText("Se connecter")
self._btn_connect.setStyleSheet( self._btn_connect.setStyleSheet(
"QPushButton { background: #1e8449; color: white; font-size: 14px;" f"QPushButton {{ background: {theme.OK_SOLID}; color: {theme.TEXT}; font-size: 14px;"
" font-weight: bold; border-radius: 6px; border: none; }" " font-weight: bold; border-radius: 6px; border: none; }"
"QPushButton:hover { background: #27ae60; }" "QPushButton:hover { background: #27ae60; }"
) )
self._info_labels["rx_tx"].setText("") self._info_labels["rx_tx"].setText("")
self._info_labels["handshake"].setText("") self._info_labels["handshake"].setText("")
self._duration_label.setStyleSheet( self._duration_label.setStyleSheet(
"color: #5d6d7e; font-size: 11px; background: transparent;" f"color: {theme.TEXT_FAINT}; font-size: 11px; background: transparent;"
) )
self.setWindowIcon(icons.icon_disconnected()) self.setWindowIcon(icons.icon_disconnected())
@@ -554,12 +632,6 @@ class MainWindow(QMainWindow):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Actions # Actions
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _copy_public_key(self):
pub = self._cfg.wg.get("client_public_key", "")
if pub:
QApplication.clipboard().setText(pub)
self._add_log("Clé publique copiée dans le presse-papiers", "info")
def _on_connect(self): def _on_connect(self):
"""Bascule connexion/déconnexion. Le travail réel part dans un thread.""" """Bascule connexion/déconnexion. Le travail réel part dans un thread."""
if self._worker is not None: if self._worker is not None:
@@ -635,6 +707,12 @@ class MainWindow(QMainWindow):
self._session_id = hist.start_session(endpoint, self._session_id = hist.start_session(endpoint,
self._cfg.active_profile) self._cfg.active_profile)
self._add_log(f"Connexion établie → {endpoint}", "success") self._add_log(f"Connexion établie → {endpoint}", "success")
# `connect()` ajoute une ligne d'avertissement au message de
# succès quand le split-DNS n'a pas pu être appliqué — sans
# quoi cet échec restait invisible.
base, _, extra = msg.partition("\n")
if extra:
self._add_log(extra, "warning")
QTimer.singleShot(5000, self._update_ping) QTimer.singleShot(5000, self._update_ping)
else: else:
self._reconnect_failures += 1 self._reconnect_failures += 1
+21 -23
View File
@@ -5,9 +5,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QTimer from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
from app.core import mfa as mfa_core from app.core import mfa as mfa_core
from app.ui import theme
_DARK = "#1c2833"
_DARK2 = "#17202a"
class MFADialog(QDialog): class MFADialog(QDialog):
@@ -19,8 +17,8 @@ class MFADialog(QDialog):
self.setFixedWidth(340) self.setFixedWidth(340)
self.setModal(True) self.setModal(True)
self.setStyleSheet(f""" self.setStyleSheet(f"""
QDialog {{ background: {_DARK2}; }} QDialog {{ background: {theme.BG_SUNKEN}; }}
QLabel {{ color: white; background: transparent; }} QLabel {{ color: {theme.TEXT}; background: transparent; }}
""") """)
self._build_ui() self._build_ui()
self._timer = QTimer(self) self._timer = QTimer(self)
@@ -44,12 +42,12 @@ class MFADialog(QDialog):
sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.") sub = QLabel("Entrez le code à 6 chiffres de votre application d'authentification.")
sub.setWordWrap(True) sub.setWordWrap(True)
sub.setAlignment(Qt.AlignmentFlag.AlignCenter) sub.setAlignment(Qt.AlignmentFlag.AlignCenter)
sub.setStyleSheet("color: rgba(255,255,255,0.6);") sub.setStyleSheet(f"color: {theme.TEXT_MUTED};")
layout.addWidget(sub) layout.addWidget(sub)
sep = QFrame() sep = QFrame()
sep.setFrameShape(QFrame.Shape.HLine) sep.setFrameShape(QFrame.Shape.HLine)
sep.setStyleSheet("color: rgba(255,255,255,0.15);") sep.setStyleSheet(f"color: {theme.BORDER_SOFT};")
layout.addWidget(sep) layout.addWidget(sep)
self._code_input = QLineEdit() self._code_input = QLineEdit()
@@ -60,15 +58,15 @@ class MFADialog(QDialog):
f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4) f2.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 4)
self._code_input.setFont(f2) self._code_input.setFont(f2)
self._code_input.setStyleSheet( self._code_input.setStyleSheet(
"QLineEdit { padding: 8px; border: 2px solid #2471a3;" f"QLineEdit {{ padding: 8px; border: 2px solid {theme.ACCENT};"
f" border-radius: 6px; background: {_DARK}; color: white; }}" f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}"
) )
self._code_input.returnPressed.connect(self._verify) self._code_input.returnPressed.connect(self._verify)
layout.addWidget(self._code_input) layout.addWidget(self._code_input)
self._error_label = QLabel("") self._error_label = QLabel("")
self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._error_label.setStyleSheet("color: #f1948a; font-weight: bold;") self._error_label.setStyleSheet(f"color: {theme.FAIL_TEXT}; font-weight: bold;")
layout.addWidget(self._error_label) layout.addWidget(self._error_label)
self._progress = QProgressBar() self._progress = QProgressBar()
@@ -76,31 +74,31 @@ class MFADialog(QDialog):
self._progress.setTextVisible(False) self._progress.setTextVisible(False)
self._progress.setFixedHeight(6) self._progress.setFixedHeight(6)
self._progress.setStyleSheet( self._progress.setStyleSheet(
f"QProgressBar {{ border-radius: 3px; background: {_DARK}; }}" f"QProgressBar {{ border-radius: 3px; background: {theme.BG}; }}"
"QProgressBar::chunk { background: #5dade2; border-radius: 3px; }" f"QProgressBar::chunk {{ background: {theme.ACCENT_LIGHT}; border-radius: 3px; }}"
) )
layout.addWidget(self._progress) layout.addWidget(self._progress)
self._timer_label = QLabel("") self._timer_label = QLabel("")
self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._timer_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._timer_label.setStyleSheet("color: #5d6d7e; font-size: 11px;") self._timer_label.setStyleSheet(f"color: {theme.TEXT_FAINT}; font-size: 11px;")
layout.addWidget(self._timer_label) layout.addWidget(self._timer_label)
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
btn_cancel = QPushButton("Annuler") btn_cancel = QPushButton("Annuler")
btn_cancel.setStyleSheet( btn_cancel.setStyleSheet(
"QPushButton { padding: 8px 16px; border-radius: 5px; border: none;" f"QPushButton {{ padding: 8px 16px; border-radius: 5px; border: none;"
f" background: #2e4057; color: white; }}" f" background: {theme.BG_RAISED}; color: {theme.TEXT}; }}"
"QPushButton:hover { background: #3d5166; }" f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
) )
btn_cancel.clicked.connect(self.reject) btn_cancel.clicked.connect(self.reject)
self._btn_ok = QPushButton("Vérifier") self._btn_ok = QPushButton("Vérifier")
self._btn_ok.setDefault(True) self._btn_ok.setDefault(True)
self._btn_ok.setStyleSheet( self._btn_ok.setStyleSheet(
"QPushButton { padding: 8px 20px; border-radius: 5px; border: none;" f"QPushButton {{ padding: 8px 20px; border-radius: 5px; border: none;"
" background: #2471a3; color: white; font-weight: bold; }" f" background: {theme.ACCENT}; color: {theme.TEXT}; font-weight: bold; }}"
"QPushButton:hover { background: #1a5276; }" f"QPushButton:hover {{ background: {theme.ACCENT_HOVER}; }}"
) )
self._btn_ok.clicked.connect(self._verify) self._btn_ok.clicked.connect(self._verify)
btn_row.addWidget(btn_cancel) btn_row.addWidget(btn_cancel)
@@ -118,8 +116,8 @@ class MFADialog(QDialog):
if len(raw) != 6 or not raw.isdigit(): if len(raw) != 6 or not raw.isdigit():
self._error_label.setText("Entrez exactement 6 chiffres.") self._error_label.setText("Entrez exactement 6 chiffres.")
self._code_input.setStyleSheet( self._code_input.setStyleSheet(
f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;" f"QLineEdit {{ padding: 8px; border: 2px solid {theme.FAIL_TEXT};"
f" border-radius: 6px; background: {_DARK}; color: white; }}" f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}"
) )
return return
if mfa_core.verify_code(self._secret, raw): if mfa_core.verify_code(self._secret, raw):
@@ -129,8 +127,8 @@ class MFADialog(QDialog):
self._error_label.setText("Code incorrect. Réessayez.") self._error_label.setText("Code incorrect. Réessayez.")
self._code_input.clear() self._code_input.clear()
self._code_input.setStyleSheet( self._code_input.setStyleSheet(
f"QLineEdit {{ padding: 8px; border: 2px solid #f1948a;" f"QLineEdit {{ padding: 8px; border: 2px solid {theme.FAIL_TEXT};"
f" border-radius: 6px; background: {_DARK}; color: white; }}" f" border-radius: 6px; background: {theme.BG}; color: {theme.TEXT}; }}"
) )
def is_verified(self) -> bool: def is_verified(self) -> bool:
+48 -4
View File
@@ -13,11 +13,13 @@ BG_SUNKEN = "#17202a" # zones en creux : configurations, blocs de code
BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires BG_RAISED = "#253545" # éléments posés sur le fond : listes, en-têtes secondaires
BORDER = "rgba(255,255,255,0.18)" BORDER = "rgba(255,255,255,0.18)"
BORDER_SOFT = "rgba(255,255,255,0.10)" BORDER_SOFT = "rgba(255,255,255,0.10)"
INPUT_BG = "rgba(255,255,255,0.10)" # fond des champs de saisie et listes
# ── Textes ─────────────────────────────────────────────────────────────────── # ── Textes ───────────────────────────────────────────────────────────────────
TEXT = "white" TEXT = "white"
TEXT_MUTED = "rgba(255,255,255,0.70)" TEXT_MUTED = "rgba(255,255,255,0.70)"
TEXT_FAINT = "rgba(255,255,255,0.50)" TEXT_FAINT = "rgba(255,255,255,0.50)"
TEXT_CODE = "#a8d8ea" # texte à chasse fixe (aperçus de config, rapports)
# ── Accent ─────────────────────────────────────────────────────────────────── # ── Accent ───────────────────────────────────────────────────────────────────
ACCENT = "#2471a3" ACCENT = "#2471a3"
@@ -32,6 +34,12 @@ WARN_TEXT, WARN_BG, WARN_SOLID = "#f7dc6f", "rgba(241,196,15,0.15)", "#b7950b"
FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b" FAIL_TEXT, FAIL_BG, FAIL_SOLID = "#f1948a", "rgba(231,76,60,0.18)", "#c0392b"
SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)" SKIP_TEXT, SKIP_BG = "rgba(255,255,255,0.45)", "rgba(255,255,255,0.05)"
# Teintes de survol des boutons pleins ok/fail — reprises telles quelles à
# plusieurs endroits (panneau admin, fenêtre principale) : un seul nom leur
# évite de dériver l'une de l'autre au fil des modifications.
OK_HOVER = "#27ae60"
FAIL_HOVER = "#e74c3c"
_STATE = { _STATE = {
"ok": (OK_TEXT, OK_BG), "ok": (OK_TEXT, OK_BG),
"warn": (WARN_TEXT, WARN_BG), "warn": (WARN_TEXT, WARN_BG),
@@ -40,6 +48,22 @@ _STATE = {
"idle": (TEXT_MUTED, "transparent"), "idle": (TEXT_MUTED, "transparent"),
} }
# ── Badge de ping (fenêtre principale) ──────────────────────────────────────
PING_CSS = {
"good": "background:#1e8449; color:white;",
"medium": "background:#d4ac0d; color:black;",
"bad": "background:#922b21; color:white;",
"offline": f"background:{BG_RAISED}; color:{TEXT_FAINT};",
}
# ── Entrées de journal (fenêtre principale) ─────────────────────────────────
LOG_COLORS = {
"success": OK_TEXT,
"error": FAIL_TEXT,
"warning": WARN_TEXT,
"info": ACCENT_LIGHT,
}
# ── Bandeaux d'onglets ─────────────────────────────────────────────────────── # ── Bandeaux d'onglets ───────────────────────────────────────────────────────
# Cinq teintes calées sur la même luminosité et la même saturation : les # Cinq teintes calées sur la même luminosité et la même saturation : les
# onglets restent distinguables sans que l'un paraisse plus clair ou plus # onglets restent distinguables sans que l'un paraisse plus clair ou plus
@@ -118,7 +142,7 @@ TAB_CSS = f"""
color: {TEXT}; color: {TEXT};
}} }}
QLineEdit {{ QLineEdit {{
background: rgba(255,255,255,0.10); background: {INPUT_BG};
color: {TEXT}; color: {TEXT};
border: 1px solid {BORDER}; border: 1px solid {BORDER};
border-radius: 4px; border-radius: 4px;
@@ -128,7 +152,7 @@ TAB_CSS = f"""
QLineEdit:disabled {{ background: rgba(255,255,255,0.04); QLineEdit:disabled {{ background: rgba(255,255,255,0.04);
color: {TEXT_FAINT}; }} color: {TEXT_FAINT}; }}
QSpinBox {{ QSpinBox {{
background: rgba(255,255,255,0.10); background: {INPUT_BG};
color: {TEXT}; color: {TEXT};
border: 1px solid {BORDER}; border: 1px solid {BORDER};
border-radius: 4px; border-radius: 4px;
@@ -137,9 +161,22 @@ TAB_CSS = f"""
QSpinBox::up-button, QSpinBox::down-button {{ QSpinBox::up-button, QSpinBox::down-button {{
background: rgba(255,255,255,0.15); background: rgba(255,255,255,0.15);
}} }}
QComboBox {{
background: {INPUT_BG};
color: {TEXT};
border: 1px solid {BORDER};
border-radius: 4px;
padding: 3px 8px;
}}
QComboBox::drop-down {{ border: none; }}
QComboBox QAbstractItemView {{
background: {BG_RAISED};
color: {TEXT};
selection-background-color: {ACCENT};
}}
QTextEdit {{ QTextEdit {{
background: {BG_SUNKEN}; background: {BG_SUNKEN};
color: #a8d8ea; color: {TEXT_CODE};
border: 1px solid {BORDER}; border: 1px solid {BORDER};
border-radius: 4px; border-radius: 4px;
}} }}
@@ -171,13 +208,20 @@ TABBAR_CSS = f"""
""" """
def primary_button_style(solid: str = OK_SOLID, hover: str = "#27ae60") -> str: def primary_button_style(solid: str = OK_SOLID, hover: str = OK_HOVER) -> str:
"""Bouton d'action principale d'une page (lancer un test, par exemple).""" """Bouton d'action principale d'une page (lancer un test, par exemple)."""
return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};" return (f"QPushButton {{ padding: 9px; background: {solid}; color: {TEXT};"
f" border-radius: 5px; font-weight: bold; border: none; }}" f" border-radius: 5px; font-weight: bold; border: none; }}"
f"QPushButton:hover {{ background: {hover}; }}") f"QPushButton:hover {{ background: {hover}; }}")
def secondary_button_style() -> str:
"""Bouton d'action secondaire d'une page (à côté d'une action principale)."""
return (f"QPushButton {{ padding: 9px; background: {BG_RAISED}; color: {TEXT};"
f" border-radius: 5px; border: none; }}"
f"QPushButton:hover {{ background: {ACCENT_HOVER}; }}")
def hint_style() -> str: def hint_style() -> str:
"""Encart d'introduction posé en tête de chaque onglet. """Encart d'introduction posé en tête de chaque onglet.
+4
View File
@@ -351,6 +351,10 @@ def _privileged_commands() -> tuple[tuple[list[str], str], ...]:
"retirer l'entrée DNS du tunnel"), "retirer l'entrée DNS du tunnel"),
(["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE], (["resolvectl", "revert", _PRIVILEGE_PROBE_IFACE],
"purger le DNS systemd-resolved"), "purger le DNS systemd-resolved"),
(["resolvectl", "dns", _PRIVILEGE_PROBE_IFACE, "0.0.0.0"],
"configurer le DNS distant (split-DNS)"),
(["resolvectl", "domain", _PRIVILEGE_PROBE_IFACE, "~invalid"],
"configurer le domaine distant (split-DNS)"),
) )