La v0.8.0 laissait cohabiter deux vérifications du code à 6 chiffres : la fenêtre MFA locale, qui contrôlait un code contre un secret détenu par le poste lui-même — et ne verrouillait donc que sa propre interface — puis la validation par le serveur, seule à décider de l'accès réseau. L'ancienne disparaît, avec le secret TOTP qu'elle stockait en clair dans config.json (effacé au premier lancement) et l'onglet MFA du panneau Administrateur. La saisie passe par une fenêtre dédiée qui relaie le refus exact du serveur et laisse réessayer, la requête partant dans un thread pour qu'un serveur muet ne fige pas l'application. Une ligne « Accès distant » distingue désormais l'état de l'autorisation de celui du tunnel : monté sans code validé, celui-ci ne mène nulle part. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
9.8 KiB
Python
256 lines
9.8 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 : 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": "",
|
|
"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)
|
|
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 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]
|
|
if not stale:
|
|
return
|
|
for key in stale:
|
|
del self._data[key]
|
|
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
|