La présence d'une clé pré-partagée se devinait au fait que le champ soit rempli. Un serveur qui n'en utilise pas — le cas par défaut de WireGuard — déclenchait donc un avertissement à chaque diagnostic, et une clé oubliée dans le champ finissait écrite dans la configuration alors que le serveur n'en attendait aucune, ce qui casse le handshake aussi sûrement qu'une clé manquante. Une case à cocher tranche désormais, et le diagnostic distingue les quatre états au lieu d'un avertissement unique. Le drapeau vaut None par défaut et non False : _merge() recopie le défaut dans toute configuration antérieure, et False aurait désactivé la PSK d'un tunnel qui fonctionnait. Refonte des onglets, qui passaient de sept à cinq : - WireGuard et Clés se chevauchaient — la clé publique du serveur se saisissait dans l'un pendant que l'assistant qui la produit vivait dans l'autre, et les quatre façons de sortir une configuration étaient réparties entre les deux. Fusionnés en sous-onglets Tunnel, Clés, Profils & fichiers. - Les trois tests s'empilaient sur une page unique où le rapport de diagnostic se retrouvait comprimé. Chacun a son sous-onglet. - MFA et Sécurité répondent à la même question et sont regroupés. - La section des droits parlait de sudo et de setup-sudoers sous Windows, où rien de tout cela n'existe, et concluait « privilèges root » sur un rapport simplement vide. Elle vérifie maintenant l'élévation UAC et WireGuard. Chaque onglet s'ouvre sur un encart expliquant ce qu'on y décide, et les couleurs — vingt-quatre valeurs en dur, cinq bandeaux sans rapport de teinte, des blocs de résultat au fond incohérent — passent par app/ui/theme.py.
245 lines
8.9 KiB
Python
245 lines
8.9 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.5.0",
|
|
"admin_password_hash": "",
|
|
"admin_salt": "",
|
|
"mfa_enabled": False,
|
|
"mfa_secret": "",
|
|
"active_profile": "H3",
|
|
"profiles": {},
|
|
"wg": deepcopy(_WG_DEFAULT),
|
|
"ui": {
|
|
"minimize_to_tray": True,
|
|
"autostart": False,
|
|
"auto_connect_on_startup": False,
|
|
"auto_reconnect": False,
|
|
"reconnect_interval": 30,
|
|
},
|
|
}
|
|
|
|
|
|
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:
|
|
self._data = deepcopy(_DEFAULT)
|
|
|
|
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
|