fix: la clé pré-partagée n'était écrite dans aucune config client L'assistant de génération produisait une PresharedKey affichée à l'écran mais stockée nulle part, et l'import d'un .conf ignorait la ligne. Face à un serveur qui en attend une, le tunnel montait et l'interface existait — sans qu'aucun handshake n'aboutisse jamais. La PSK devient un champ à part entière. fix: options de ligne de commande wireguard.exe incorrectes sous Windows /installtunnel et /uninstalltunnel n'existent pas ; les vrais verbes sont /installtunnelservice et /uninstalltunnelservice. S'y ajoutaient l'absence de résolution du chemin d'installation (WireGuard n'est pas dans le PATH), l'absence totale d'élévation UAC alors que ces commandes l'exigent, et une détection d'installation qui sondait une application graphique avec --help. feat: diagnostic de bout en bout de la chaîne de connexion Le bouton de test s'arrêtait à la joignabilité UDP et concluait « serveur joignable » sur un tunnel qui n'échangeait rien. Il déroule désormais quatorze étapes, de la configuration locale jusqu'au DNS dans le tunnel, et désigne l'étape qui bloque. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
8.3 KiB
Python
235 lines
8.3 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": "",
|
|
"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.4.5",
|
|
"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
|