Files
WGSecure/app/core/config.py
T
tuxgyver 8e40812f76 v0.7.1 : diagnostics non-bloquants, DNS hors tunnel détecté
- Nouveau diagnostic : signale un DNS non couvert par les IPs autorisées
  sur un tunnel scindé (résolution forcée via une interface qui n'a pas
  de route vers ce serveur).
- Corrige le test de connexion, l'analyse DNS et la réparation DNS qui
  gelaient l'application (sondes lancées sur le thread graphique).
- Corrige le CHANGELOG absent de l'exécutable compilé (page « À propos »
  vide) et le badge de latence bloqué sur « hors ligne » quand l'ICMP est
  filtré alors que le tunnel fonctionne.
- wgsecure.iss installe désormais WireGuard for Windows automatiquement.
2026-09-01 22:05:20 +02:00

249 lines
9.2 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,
}
_DEFAULT: dict[str, Any] = {
"version": "0.7.1",
"admin_password_hash": "",
"admin_salt": "",
"mfa_enabled": False,
"mfa_secret": "",
"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,
},
}
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)
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)
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"))
# ── MFA ───────────────────────────────────────────────────────────────
@property
def mfa_enabled(self) -> bool:
return bool(self._data.get("mfa_enabled", False))
@mfa_enabled.setter
def mfa_enabled(self, value: bool):
self._data["mfa_enabled"] = value
@property
def mfa_secret(self) -> str:
return self._data.get("mfa_secret", "")
@mfa_secret.setter
def mfa_secret(self, value: str):
self._data["mfa_secret"] = value
# ── 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