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 : identifiant # du compte VPN tel que connu du serveur, et URL de son API. Laissée vide, # celle-ci est déduite de l'adresse du client (cf. vpn_session.py). "vpn_username": "", "api_url": "", "split_dns_server": "", "split_dns_domains": [], } _DEFAULT: dict[str, Any] = { "version": "0.7.3", "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