Files
WGSecure/app/core/config.py
T
tuxgyverandClaude Opus 5 142eff5916 feat(ui): un thème clair, et plus une couleur hors du module qui les tient
Le module de thème publie deux palettes derrière les mêmes noms : `apply()`
rebranche `BG`, `TEXT`, `TAB_CSS` sur celle qui est choisie au lancement, et
les cent soixante-dix appels répartis dans l'interface continuent de lire
`theme.BG` sans rien savoir du thème actif. Aucun widget n'a eu à changer —
c'est ce que permettait le passage systématique par `theme.X` à l'usage.

Le choix est lu une seule fois, au démarrage. Les feuilles de style sont
posées dans les constructeurs des fenêtres ; les repeindre à chaud
supposerait de reconstruire celle qui porte l'état du tunnel, ses fils
d'exécution et ses minuteries. Le message de confirmation annonce donc la
prise d'effet au prochain lancement plutôt que de laisser croire le contraire.
Le thème sombre reste le défaut, et rend exactement les mêmes feuilles de
style qu'avant.

Un thème clair n'est pas une inversion, et trois familles de valeurs vivaient
en dur là où elles étaient jusqu'ici invisibles. Dans la feuille de style
commune, les variantes d'état des champs et des boutons étaient des voiles
blancs translucides — c'est-à-dire rien du tout sur un fond clair. Le texte
posé sur un aplat saturé — bouton plein, onglet actif, bandeau, sélection —
portait `TEXT`, qui devient presque noir en thème clair et disparaîtrait sur
l'accent : un jeton distinct le dit, blanc dans les deux thèmes. Enfin le
graphe de bande passante peignait son fond en dur, soit un panneau sombre au
milieu d'une page claire ; ses sept teintes sont devenues des jetons, comme
quatre couleurs de survol de la fenêtre principale.

Deux endroits figeaient par ailleurs le thème au chargement de leur module et
auraient gardé les teintes sombres quel que soit le réglage : la feuille de
style de la fenêtre d'historique, bâtie au niveau du module, et la table
état → couleur du diagnostic, évaluée comme attribut de classe. La première
devient une fonction, la seconde ne retient que la puce et demande la couleur
au thème au moment de l'affichage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 20:01:12 +02:00

280 lines
11 KiB
Python

import json
import os
import hashlib
import secrets
import bcrypt
from copy import deepcopy
from typing import Any
from app.utils.platform_utils import get_config_dir
_CONFIG_FILE = "config.json"
_WG_DEFAULT: dict[str, Any] = {
"interface_name": "wgs0",
"server_endpoint": "",
"server_port": 51820,
"server_public_key": "",
# Drapeau explicite : une PSK vide ne veut pas dire « oubliée ». Sans lui,
# le diagnostic criait au champ manquant sur tout serveur qui n'en utilise
# pas — c'est-à-dire la configuration WireGuard par défaut.
#
# Le défaut est `None`, pas `False` : `_merge()` recopie le défaut dans
# toute configuration antérieure au drapeau, et `False` aurait désactivé
# la clé pré-partagée d'un tunnel qui fonctionnait. `None` signifie
# « jamais renseigné » et laisse `uses_preshared_key()` déduire l'état de
# la présence de la clé.
"use_preshared_key": None,
"preshared_key": "",
"client_private_key": "",
"client_public_key": "",
"client_address": "10.8.0.2/24",
"dns": "1.1.1.1",
"allowed_ips": "10.8.0.0/24",
"keepalive": 25,
"mtu": 0,
# Split-DNS : serveur DNS dédié pour les noms du réseau distant,
# avec liste de domaines à résoudre via ce serveur (ex: ["local", "internal"]).
# Laisser vide pour utiliser le DNS par défaut pour tout le trafic.
# Authentification auprès du serveur après montage du tunnel : URL de son
# API. Laissée vide, elle est déduite de l'adresse du client et des IPs
# autorisées (cf. vpn_session.py). Aucun identifiant n'est stocké ni
# transmis : le serveur reconnaît le poste à l'IP source de son tunnel.
"api_url": "",
# Champs décrivant le pair tel que le serveur le connaît (adresse, port,
# clé publique, IPs autorisées…) verrouillés parce qu'ils viennent d'un
# `.conf` importé. Persisté : le verrou ne tenait que le temps d'une
# session du panneau, et rouvrir celui-ci rendait ces champs éditables
# sans que rien ne le signale.
"config_locked": False,
"split_dns_server": "",
"split_dns_domains": [],
}
_DEFAULT: dict[str, Any] = {
"version": "0.7.3",
"admin_password_hash": "",
"admin_salt": "",
"active_profile": "H3",
"profiles": {},
"wg": deepcopy(_WG_DEFAULT),
"ui": {
"minimize_to_tray": True,
"autostart": True,
"auto_connect_on_startup": False,
"auto_reconnect": True,
"reconnect_interval": 15,
# « dark » ou « light ». Lu une seule fois, au lancement : les feuilles
# de style sont posées dans les constructeurs des fenêtres (cf.
# app/ui/theme.py). Une valeur inconnue retombe sur « dark ».
"theme": "dark",
},
}
class Config:
def __init__(self):
self._path = os.path.join(get_config_dir(), _CONFIG_FILE)
self._data: dict[str, Any] = {}
self.load()
def load(self):
if os.path.exists(self._path):
try:
with open(self._path, "r", encoding="utf-8") as f:
saved = json.load(f)
self._data = self._merge(_DEFAULT, saved)
self._drop_local_mfa()
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 _drop_local_mfa(self) -> None:
"""Efface les réglages d'authentification devenus caducs.
Le secret TOTP local hérité des versions ≤ 0.8.0.
Ce secret ne sert plus : le code à 6 chiffres est désormais validé
par le serveur, contre un secret que le poste client ne détient pas.
Le laisser en place serait un secret d'authentification en clair
dans `config.json`, sans rien qui le justifie — d'où l'effacement
au chargement plutôt qu'un simple abandon des clés.
"""
stale = [k for k in ("mfa_enabled", "mfa_secret") if k in self._data]
for key in stale:
del self._data[key]
# `vpn_username` a disparu en v0.9.2 : le serveur reconnaît le poste à
# l'IP source de son tunnel, l'identifiant ne lui est plus transmis.
# Le retirer plutôt que l'ignorer, sinon `config.json` conserverait
# indéfiniment un réglage que plus rien ne lit et que l'utilisateur
# croirait encore actif.
if "vpn_username" in self._data.get("wg", {}):
del self._data["wg"]["vpn_username"]
stale.append("wg.vpn_username")
for profile in self._data.get("profiles", {}).values():
if isinstance(profile, dict) and "vpn_username" in profile:
del profile["vpn_username"]
stale.append("profil")
if not stale:
return
self.save()
def save(self):
os.makedirs(os.path.dirname(self._path), exist_ok=True)
with open(self._path, "w", encoding="utf-8") as f:
json.dump(self._data, f, indent=2, ensure_ascii=False)
def _merge(self, base: dict, override: dict) -> dict:
result = dict(base)
for k, v in override.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = self._merge(result[k], v)
else:
result[k] = v
return result
def get(self, *keys: str, default=None) -> Any:
node = self._data
for k in keys:
if not isinstance(node, dict) or k not in node:
return default
node = node[k]
return node
def set(self, *keys_and_value) -> None:
*keys, value = keys_and_value
node = self._data
for k in keys[:-1]:
node = node.setdefault(k, {})
node[keys[-1]] = value
# ── Mot de passe admin ────────────────────────────────────────────────
def set_admin_password(self, password: str):
if not password:
self._data["admin_salt"] = ""
self._data["admin_password_hash"] = ""
else:
hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12))
self._data["admin_salt"] = ""
self._data["admin_password_hash"] = hashed.decode("utf-8")
self.save()
def check_admin_password(self, password: str) -> bool:
stored = self._data.get("admin_password_hash", "")
if not stored:
return True
# Migration transparente : anciens hashes SHA-256 (64 hex chars, pas de $2b$)
if not stored.startswith("$2"):
salt = self._data.get("admin_salt", "")
candidate = hashlib.sha256((salt + password).encode()).hexdigest()
return secrets.compare_digest(candidate, stored)
return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8"))
def has_admin_password(self) -> bool:
return bool(self._data.get("admin_password_hash", ""))
# ── WireGuard ─────────────────────────────────────────────────────────
@property
def wg(self) -> dict:
return self._data["wg"]
@property
def configured(self) -> bool:
wg = self._data["wg"]
return bool(wg.get("server_endpoint") and wg.get("client_private_key"))
# ── Multi-profils ─────────────────────────────────────────────────────
@property
def active_profile(self) -> str:
return self._data.get("active_profile", "H3")
def list_profiles(self) -> list[str]:
return list(self._data.get("profiles", {}).keys())
def save_profile(self, name: str) -> None:
"""Sauvegarde la config WireGuard courante sous un nom de profil."""
self._data.setdefault("profiles", {})[name] = deepcopy(self._data["wg"])
self._data["active_profile"] = name
self.save()
def load_profile(self, name: str) -> bool:
"""Charge un profil sauvegardé dans la config active."""
profiles = self._data.get("profiles", {})
if name not in profiles:
return False
self._data["wg"] = deepcopy(profiles[name])
self._data["active_profile"] = name
self.save()
return True
def delete_profile(self, name: str) -> bool:
profiles = self._data.get("profiles", {})
if name not in profiles:
return False
del profiles[name]
if self._data.get("active_profile") == name:
self._data["active_profile"] = "H3"
self.save()
return True
def rename_profile(self, old: str, new: str) -> bool:
profiles = self._data.get("profiles", {})
if old not in profiles or new in profiles:
return False
profiles[new] = profiles.pop(old)
if self._data.get("active_profile") == old:
self._data["active_profile"] = new
self.save()
return True
# ── Autostart ─────────────────────────────────────────────────────────
def set_autostart(self, enabled: bool) -> None:
self._data["ui"]["autostart"] = enabled
self._apply_autostart(enabled)
self.save()
def _apply_autostart(self, enabled: bool) -> None:
from app.utils.platform_utils import is_linux, is_windows, launch_command
# launch_command() gère le cas du binaire PyInstaller, où main.py
# n'existe pas : l'ancien « python3 <…>/main.py » ne démarrait jamais.
command = launch_command()
if is_linux():
autostart_dir = os.path.expanduser("~/.config/autostart")
desktop = os.path.join(autostart_dir, "wgsecure.desktop")
if enabled:
os.makedirs(autostart_dir, exist_ok=True)
content = (
"[Desktop Entry]\nType=Application\nName=WGSecure\n"
f"Exec={command}\nIcon=wgsecure\n"
"Hidden=false\nX-GNOME-Autostart-enabled=true\n"
)
with open(desktop, "w") as f:
f.write(content)
else:
try:
os.unlink(desktop)
except FileNotFoundError:
pass
elif is_windows():
import winreg
key = r"Software\Microsoft\Windows\CurrentVersion\Run"
try:
reg = winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0,
winreg.KEY_SET_VALUE)
if enabled:
winreg.SetValueEx(reg, "WGSecure", 0, winreg.REG_SZ, command)
else:
winreg.DeleteValue(reg, "WGSecure")
winreg.CloseKey(reg)
except Exception:
pass