L'assistant « configuration serveur + client » disparaît. Il datait d'avant la v0.8.0 et produisait un serveur WireGuard nu — sans API, sans quarantaine, sans TOTP — incompatible avec le produit actuel, où le serveur enrôle le compte et fournit un .conf. Son bouton « Appliquer le volet client » écrasait clés client et clé publique du serveur : sur un poste déjà enrôlé, il détruisait le lien avec le serveur, qui déclare l'ancienne clé publique. L'identifiant VPN disparaît aussi, devenu sans emploi depuis que le serveur reconnaît le poste à l'adresse de son tunnel. Un champ qui ne fait rien est pire qu'un champ absent : il laisse croire qu'il compte. Les configurations existantes sont purgées au chargement, profils compris. La pastille « À propos » qui en déduisait « Code d'accès serveur » ou « Aucun code d'accès » n'affirme plus rien qu'elle ne sache : l'accès passe par le serveur dans tous les cas. Le sous-onglet Tunnel passe d'une liste de quinze champs à trois groupes : l'essentiel, le split-DNS nommé comme tel — trois champs y portaient « DNS » sans que rien ne dise lesquels allaient ensemble — et un repli « Avancé ». Celui-ci réunit ce qui vient du serveur (adresse, port, clé publique) et ce qui est correct par défaut (nom d'interface, URL de l'API, keepalive, MTU). Le verrouillage après import d'un .conf devient persistant. Il existait déjà, mais `_load_values` le levait sans condition : rouvrir le panneau ou changer de profil rendait éditables, en silence, les champs décrivant un pair déjà déclaré côté serveur. L'état est désormais enregistré et suit la configuration chargée. Le diagnostic gagne une étape « Serveur DNS du réseau distant », qui couvre un angle mort : le tunnel monte, le trafic passe, la résolution est au vert, et pas un nom interne ne se résout. Deux causes, l'une et l'autre muettes — le serveur DNS distant n'est couvert par aucune IP autorisée, ou il l'est mais son adresse tombe aussi dans une plage que porte une carte du poste, auquel cas la route locale l'emporte. Le second cas ne se déduit pas de la configuration : l'étape interroge la route réellement retenue par le noyau, sans émettre de paquet, et propose la route d'hôte qui reprend la main. Le sous-onglet « Fuite DNS » devient « Résolution des noms » : il en faisait trois choses sous un titre qui n'en annonçait qu'une. Son verdict « Statut inconnu » recouvrait trois causes appelant trois gestes différents ; chacune est maintenant nommée, avec l'action qui suit. Enfin, la fenêtre. Sa hauteur augmente de 15 %, et son plancher passe de 520 à 703 : la page de test réclame 569 px et se chevauchait en dessous. Le rapport de diagnostic passe en tableau à deux colonnes — il occupait 224 px quelle que soit la largeur de la fenêtre, soit jusqu'à 79 % de blanc à droite, et tient maintenant sur 38 % de hauteur en moins. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
11 KiB
Python
276 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,
|
|
},
|
|
}
|
|
|
|
|
|
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
|