Initial release
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
import os
|
||||
import hashlib
|
||||
import secrets
|
||||
from typing import Any
|
||||
from app.utils.platform_utils import get_config_dir
|
||||
|
||||
|
||||
_CONFIG_FILE = "config.json"
|
||||
_DEFAULT: dict[str, Any] = {
|
||||
"version": "0.1.0",
|
||||
"admin_password_hash": "",
|
||||
"admin_salt": "",
|
||||
"mfa_enabled": False,
|
||||
"mfa_secret": "",
|
||||
"wg": {
|
||||
"interface_name": "wgs0",
|
||||
"server_endpoint": "",
|
||||
"server_port": 51820,
|
||||
"server_public_key": "",
|
||||
"client_private_key": "",
|
||||
"client_public_key": "",
|
||||
"client_address": "10.8.0.2/24",
|
||||
"dns": "1.1.1.1",
|
||||
"allowed_ips": "0.0.0.0/0",
|
||||
"keepalive": 25,
|
||||
},
|
||||
"ui": {
|
||||
"minimize_to_tray": True,
|
||||
"autostart": False,
|
||||
"theme": "auto",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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 = dict(_DEFAULT)
|
||||
else:
|
||||
self._data = dict(_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
|
||||
|
||||
# -- Admin password --
|
||||
|
||||
def set_admin_password(self, password: str):
|
||||
if not password:
|
||||
# Mot de passe vide = suppression de la protection
|
||||
self._data["admin_salt"] = ""
|
||||
self._data["admin_password_hash"] = ""
|
||||
else:
|
||||
salt = secrets.token_hex(16)
|
||||
hashed = hashlib.sha256((salt + password).encode()).hexdigest()
|
||||
self._data["admin_salt"] = salt
|
||||
self._data["admin_password_hash"] = hashed
|
||||
self.save()
|
||||
|
||||
def check_admin_password(self, password: str) -> bool:
|
||||
salt = self._data.get("admin_salt", "")
|
||||
stored = self._data.get("admin_password_hash", "")
|
||||
if not stored:
|
||||
return True # Pas encore de mot de passe configuré
|
||||
candidate = hashlib.sha256((salt + password).encode()).hexdigest()
|
||||
return secrets.compare_digest(candidate, stored)
|
||||
|
||||
def has_admin_password(self) -> bool:
|
||||
return bool(self._data.get("admin_password_hash", ""))
|
||||
|
||||
# -- Propriétés WireGuard --
|
||||
|
||||
@property
|
||||
def wg(self) -> dict:
|
||||
return self._data["wg"]
|
||||
|
||||
@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
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
wg = self._data["wg"]
|
||||
return bool(wg.get("server_endpoint") and wg.get("client_private_key"))
|
||||
@@ -0,0 +1,48 @@
|
||||
import pyotp
|
||||
import qrcode
|
||||
import io
|
||||
from PyQt6.QtGui import QPixmap, QImage
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def get_totp(secret: str) -> pyotp.TOTP:
|
||||
return pyotp.TOTP(secret)
|
||||
|
||||
|
||||
def verify_code(secret: str, code: str) -> bool:
|
||||
if not secret or not code:
|
||||
return False
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code.strip(), valid_window=1)
|
||||
|
||||
|
||||
def get_current_code(secret: str) -> str:
|
||||
return pyotp.TOTP(secret).now()
|
||||
|
||||
|
||||
def time_remaining() -> int:
|
||||
"""Secondes restantes avant rotation du code TOTP."""
|
||||
import time
|
||||
return 30 - (int(time.time()) % 30)
|
||||
|
||||
|
||||
def get_provisioning_uri(secret: str, account: str = "WGSecure", issuer: str = "WGSecure") -> str:
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(name=account, issuer_name=issuer)
|
||||
|
||||
|
||||
def generate_qr_pixmap(secret: str, account: str = "WGSecure") -> QPixmap:
|
||||
uri = get_provisioning_uri(secret, account)
|
||||
qr = qrcode.QRCode(box_size=6, border=2)
|
||||
qr.add_data(uri)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
data = buf.read()
|
||||
qimage = QImage.fromData(data)
|
||||
return QPixmap.fromImage(qimage)
|
||||
@@ -0,0 +1,181 @@
|
||||
import base64
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from app.core.config import Config
|
||||
from app.utils.platform_utils import (
|
||||
get_config_dir,
|
||||
get_wg_config_dir,
|
||||
is_windows,
|
||||
run_command,
|
||||
run_privileged,
|
||||
wg_available,
|
||||
)
|
||||
|
||||
|
||||
def generate_keypair() -> tuple[str, str]:
|
||||
"""Retourne (private_key_b64, public_key_b64) au format WireGuard."""
|
||||
private = X25519PrivateKey.generate()
|
||||
private_bytes = private.private_bytes_raw()
|
||||
public_bytes = private.public_key().public_bytes_raw()
|
||||
return (
|
||||
base64.b64encode(private_bytes).decode(),
|
||||
base64.b64encode(public_bytes).decode(),
|
||||
)
|
||||
|
||||
|
||||
def generate_preshared_key() -> str:
|
||||
return base64.b64encode(os.urandom(32)).decode()
|
||||
|
||||
|
||||
def build_client_config(cfg: Config) -> str:
|
||||
wg = cfg.wg
|
||||
lines = [
|
||||
"[Interface]",
|
||||
f"PrivateKey = {wg['client_private_key']}",
|
||||
f"Address = {wg['client_address']}",
|
||||
f"DNS = {wg['dns']}",
|
||||
"",
|
||||
"[Peer]",
|
||||
f"PublicKey = {wg['server_public_key']}",
|
||||
f"AllowedIPs = {wg['allowed_ips']}",
|
||||
f"Endpoint = {wg['server_endpoint']}:{wg['server_port']}",
|
||||
f"PersistentKeepalive = {wg['keepalive']}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def get_client_config_path(cfg: Config) -> str:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
if is_windows():
|
||||
config_dir = get_config_dir()
|
||||
else:
|
||||
config_dir = get_wg_config_dir()
|
||||
return os.path.join(config_dir, f"{name}.conf")
|
||||
|
||||
|
||||
def write_client_config(cfg: Config) -> tuple[bool, str]:
|
||||
content = build_client_config(cfg)
|
||||
path = get_client_config_path(cfg)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
if not is_windows():
|
||||
os.chmod(path, 0o600)
|
||||
return True, path
|
||||
except PermissionError:
|
||||
# Écriture via sudo sur Linux
|
||||
config_dir_path = get_config_dir()
|
||||
tmp = os.path.join(config_dir_path, "wgs_tmp.conf")
|
||||
with open(tmp, "w") as f:
|
||||
f.write(content)
|
||||
code, _, err = run_privileged(["cp", tmp, path])
|
||||
os.unlink(tmp)
|
||||
if code == 0:
|
||||
run_privileged(["chmod", "600", path])
|
||||
return True, path
|
||||
return False, err
|
||||
|
||||
|
||||
def is_connected(cfg: Config) -> bool:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
if is_windows():
|
||||
code, out, _ = run_command(["sc", "query", f"WireGuardTunnel${name}"])
|
||||
return code == 0 and "RUNNING" in out
|
||||
code, out, _ = run_command(["wg", "show", name])
|
||||
return code == 0 and bool(out)
|
||||
|
||||
|
||||
def connect(cfg: Config) -> tuple[bool, str]:
|
||||
if not cfg.configured:
|
||||
return False, "WireGuard non configuré. Ouvrez le panneau Admin."
|
||||
|
||||
ok, result = write_client_config(cfg)
|
||||
if not ok:
|
||||
return False, f"Impossible d'écrire la config : {result}"
|
||||
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
|
||||
if is_windows():
|
||||
code, _, err = run_command(["wireguard", "/installtunnel", result])
|
||||
return (code == 0), (err or "Connecté")
|
||||
else:
|
||||
code, _, err = run_privileged(["wg-quick", "up", result])
|
||||
if code == 0:
|
||||
return True, "Tunnel WireGuard activé"
|
||||
return False, err or "Erreur lors de la connexion"
|
||||
|
||||
|
||||
def disconnect(cfg: Config) -> tuple[bool, str]:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
|
||||
if is_windows():
|
||||
code, _, err = run_command(["wireguard", "/uninstalltunnel", name])
|
||||
return (code == 0), (err or "Déconnecté")
|
||||
else:
|
||||
path = get_client_config_path(cfg)
|
||||
if not os.path.exists(path):
|
||||
path = name
|
||||
code, _, err = run_privileged(["wg-quick", "down", path])
|
||||
if code == 0:
|
||||
return True, "Tunnel WireGuard désactivé"
|
||||
return False, err or "Erreur lors de la déconnexion"
|
||||
|
||||
|
||||
def test_connection(cfg: Config, timeout: int = 5) -> tuple[bool, str]:
|
||||
endpoint = cfg.wg.get("server_endpoint", "")
|
||||
port = int(cfg.wg.get("server_port", 51820))
|
||||
if not endpoint:
|
||||
return False, "Aucun serveur configuré"
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(timeout)
|
||||
start = time.monotonic()
|
||||
sock.connect((endpoint, port))
|
||||
sock.close()
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
return True, f"Serveur joignable ({latency} ms)"
|
||||
except socket.timeout:
|
||||
return False, "Timeout — serveur inaccessible"
|
||||
except OSError as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def get_status_info(cfg: Config) -> dict:
|
||||
name = cfg.wg.get("interface_name", "wgs0")
|
||||
info = {
|
||||
"connected": False,
|
||||
"interface": name,
|
||||
"peer": "",
|
||||
"rx_bytes": 0,
|
||||
"tx_bytes": 0,
|
||||
"last_handshake": "",
|
||||
}
|
||||
if is_windows():
|
||||
info["connected"] = is_connected(cfg)
|
||||
return info
|
||||
|
||||
code, out, _ = run_command(["wg", "show", name])
|
||||
if code != 0 or not out:
|
||||
return info
|
||||
|
||||
info["connected"] = True
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("peer:"):
|
||||
info["peer"] = line.split(":", 1)[1].strip()[:16] + "…"
|
||||
elif line.startswith("transfer:"):
|
||||
parts = line.split(":", 1)[1].strip().split(",")
|
||||
try:
|
||||
rx = parts[0].strip().split()[0]
|
||||
tx = parts[1].strip().split()[0]
|
||||
info["rx_bytes"] = rx
|
||||
info["tx_bytes"] = tx
|
||||
except Exception:
|
||||
pass
|
||||
elif line.startswith("latest handshake:"):
|
||||
info["last_handshake"] = line.split(":", 1)[1].strip()
|
||||
|
||||
return info
|
||||
Reference in New Issue
Block a user