feat(windows): tunnel installé une fois, puis démarré — et code validé par le serveur
Deux changements liés, qui achèvent le déplacement de la sécurité vers le serveur. 1. Le tunnel n'est plus recréé à chaque connexion. Créer ou supprimer un service Windows n'est jamais délégable à un utilisateur standard, le démarrer peut l'être : c'est cette réinstallation systématique qui imposait une authentification administrateur à chaque connexion ET à chaque déconnexion. Désormais connect() démarre le service s'il existe déjà, disconnect() se contente de l'arrêter, et le tunnel reste installé entre les deux. Trois voies en cascade pour ce démarrage/arrêt : sc.exe sans élévation, puis le service wgsecure-helper, puis l'élévation en dernier recours. Le helper gagne donc deux commandes (start/stop_tunnel_service). Cette cascade tient quelle que soit la configuration du poste — le test en session standard a montré que l'ACL posée sur le service WireGuard ne donne aucun droit sur le service du tunnel, qui est un objet distinct. 2. Après le montage, l'application demande le code à 6 chiffres et le fait valider PAR LE SERVEUR (app/core/vpn_session.py). Jusqu'ici le code était vérifié localement, contre un secret que l'application détenait : elle validait donc un code qu'elle pouvait produire, et ne verrouillait que sa propre interface. L'ordre est imposé par le réseau — l'API n'est joignable que depuis le tunnel, qui sert de réseau de quarantaine tant que le code n'est pas passé. L'URL de l'API est déduite de l'adresse du client (10.6.0.6 → 10.6.0.1) faute de valeur explicite : c'est l'adresse du serveur lui-même, la seule jamais filtrée depuis la quarantaine. Sans identifiant VPN configuré, l'étape est ignorée : le serveur peut tourner en mode permissif, où un compte non enrôlé garde son accès, et imposer la saisie bloquerait des installations qui fonctionnent. Chemins Linux inchangés (branches is_windows()). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,11 @@ _WG_DEFAULT: dict[str, Any] = {
|
|||||||
# Split-DNS : serveur DNS dédié pour les noms du réseau distant,
|
# 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"]).
|
# 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.
|
# 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_server": "",
|
||||||
"split_dns_domains": [],
|
"split_dns_domains": [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Authentification auprès du serveur, après montage du tunnel.
|
||||||
|
|
||||||
|
Le tunnel qui monte ne donne accès à rien : le serveur place le pair en
|
||||||
|
quarantaine et ne laisse passer son trafic vers le réseau distant qu'une
|
||||||
|
fois un code à 6 chiffres validé. C'est ce que fait ce module.
|
||||||
|
|
||||||
|
Le code est vérifié **par le serveur**, contre un secret que cette
|
||||||
|
application ne détient pas — contrairement au MFA local d'origine, qui
|
||||||
|
validait un code qu'elle pouvait elle-même produire, et ne verrouillait
|
||||||
|
donc que sa propre interface.
|
||||||
|
|
||||||
|
L'ordre est imposé par le réseau : l'API du serveur n'est joignable que
|
||||||
|
depuis l'intérieur du tunnel. On monte donc d'abord, on s'authentifie
|
||||||
|
ensuite — le tunnel jouant le rôle d'un réseau de quarantaine tant que le
|
||||||
|
code n'est pas passé.
|
||||||
|
|
||||||
|
`urllib` plutôt qu'une bibliothèque HTTP tierce : un seul appel POST, aucune
|
||||||
|
raison d'alourdir les dépendances de l'application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from app.core.config import Config
|
||||||
|
|
||||||
|
_TIMEOUT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def api_base_url(cfg: Config) -> str:
|
||||||
|
"""URL de l'API du serveur, telle qu'atteignable depuis le tunnel.
|
||||||
|
|
||||||
|
À défaut d'URL explicite, elle est déduite de l'adresse du client : le
|
||||||
|
serveur occupe la première adresse de ce réseau (10.6.0.1 pour un client
|
||||||
|
en 10.6.0.6). C'est l'adresse la plus fiable depuis la quarantaine — elle
|
||||||
|
désigne le serveur lui-même, dont l'accès n'est jamais filtré, alors
|
||||||
|
qu'une adresse du réseau distant le serait.
|
||||||
|
"""
|
||||||
|
explicit = (cfg.wg.get("api_url") or "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit.rstrip("/")
|
||||||
|
|
||||||
|
client_address = (cfg.wg.get("client_address") or "").split("/")[0].strip()
|
||||||
|
try:
|
||||||
|
host = ipaddress.ip_address(client_address)
|
||||||
|
except ValueError:
|
||||||
|
return ""
|
||||||
|
gateway = ipaddress.ip_address(int(host) - (int(host) & 0xFF) + 1)
|
||||||
|
return f"http://{gateway}:8000"
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(cfg: Config, code: str) -> tuple[bool, str]:
|
||||||
|
"""Valide un code TOTP auprès du serveur et ouvre l'accès réseau.
|
||||||
|
|
||||||
|
Retourne (succès, message). Un échec n'est jamais silencieux : sans
|
||||||
|
cette validation le tunnel reste monté mais ne mène nulle part, et
|
||||||
|
l'utilisateur doit comprendre pourquoi plutôt que de constater un
|
||||||
|
réseau inexplicablement muet.
|
||||||
|
"""
|
||||||
|
base = api_base_url(cfg)
|
||||||
|
if not base:
|
||||||
|
return False, ("Adresse du serveur d'authentification inconnue. "
|
||||||
|
"Renseignez-la dans le panneau Administrateur.")
|
||||||
|
|
||||||
|
username = (cfg.wg.get("vpn_username") or "").strip()
|
||||||
|
if not username:
|
||||||
|
return False, ("Aucun identifiant VPN configuré. Renseignez-le dans "
|
||||||
|
"le panneau Administrateur.")
|
||||||
|
|
||||||
|
payload = json.dumps({"username": username, "code": code}).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{base}/api/session", data=payload,
|
||||||
|
headers={"Content-Type": "application/json"}, method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
|
||||||
|
body = json.loads(response.read().decode("utf-8"))
|
||||||
|
until = (body.get("access_until") or "").replace("T", " ")[:16]
|
||||||
|
return True, f"Accès réseau ouvert jusqu'à {until}" if until else "Accès réseau ouvert"
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
# Le serveur explique lui-même le refus (code invalide, compte
|
||||||
|
# verrouillé, expiré…) : le relayer tel quel vaut mieux qu'un
|
||||||
|
# message générique qui obligerait à consulter les journaux.
|
||||||
|
try:
|
||||||
|
detail = json.loads(exc.read().decode("utf-8")).get("detail", "")
|
||||||
|
except (ValueError, OSError):
|
||||||
|
detail = ""
|
||||||
|
return False, detail or f"Authentification refusée (code {exc.code})"
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
return False, (f"Serveur d'authentification injoignable ({base}) : "
|
||||||
|
f"{exc.reason}")
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
return False, f"Échec de l'authentification : {exc}"
|
||||||
+79
-9
@@ -252,6 +252,59 @@ def _try_helper_tunnel_cmd(cmd: list[str]) -> tuple[int, str, str] | None:
|
|||||||
return (0 if resp.get("ok") else 1), "", str(resp.get("message") or "")
|
return (0 if resp.get("ok") else 1), "", str(resp.get("message") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _tunnel_service_name(name: str) -> str:
|
||||||
|
"""Nom du service Windows créé par wireguard.exe pour ce tunnel."""
|
||||||
|
return f"WireGuardTunnel${name}"
|
||||||
|
|
||||||
|
|
||||||
|
def tunnel_service_installed(name: str) -> bool:
|
||||||
|
"""True si le service du tunnel existe, qu'il tourne ou non.
|
||||||
|
|
||||||
|
Distinct de `is_connected()`, qui n'est vrai que s'il tourne : c'est
|
||||||
|
cette différence qui permet d'installer le tunnel une seule fois puis de
|
||||||
|
se contenter de le démarrer et l'arrêter — au lieu de le recréer et le
|
||||||
|
détruire à chaque connexion, ce qui imposait une élévation à chaque fois.
|
||||||
|
"""
|
||||||
|
if not is_windows():
|
||||||
|
return False
|
||||||
|
code, out, _ = run_command(
|
||||||
|
["powershell", "-NoProfile", "-NonInteractive", "-Command",
|
||||||
|
f"if (Get-Service -Name '{_tunnel_service_name(name)}' "
|
||||||
|
f"-ErrorAction SilentlyContinue) {{ 'OUI' }}"],
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
return code == 0 and "OUI" in out
|
||||||
|
|
||||||
|
|
||||||
|
def _control_tunnel_service(name: str, action: str, timeout: int = 45) -> tuple[bool, str]:
|
||||||
|
"""Démarre ou arrête le service d'un tunnel déjà installé.
|
||||||
|
|
||||||
|
Trois voies, de la moins intrusive à la plus lourde :
|
||||||
|
|
||||||
|
1. `sc.exe` sans élévation — fonctionne si les droits de démarrage ont
|
||||||
|
été accordés sur *ce service* (le bouton « Configurer les
|
||||||
|
permissions » les pose sur le service WireGuard, qui est un autre
|
||||||
|
objet : accorder l'un ne donne rien sur l'autre) ;
|
||||||
|
2. le service privilégié wgsecure-helper, s'il est installé ;
|
||||||
|
3. l'élévation UAC, en dernier recours.
|
||||||
|
"""
|
||||||
|
service = _tunnel_service_name(name)
|
||||||
|
verb = "start" if action == "start" else "stop"
|
||||||
|
|
||||||
|
code, out, err = run_command(["sc.exe", verb, service], timeout=timeout)
|
||||||
|
if code == 0:
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
resp = call_helper({"cmd": f"{verb}_tunnel_service", "name": name})
|
||||||
|
if resp is not None:
|
||||||
|
return bool(resp.get("ok")), str(resp.get("message") or "")
|
||||||
|
|
||||||
|
code, out, err = run_privileged(["sc.exe", verb, service], timeout=timeout)
|
||||||
|
if code == 0:
|
||||||
|
return True, ""
|
||||||
|
return False, err or out or f"Échec de « sc {verb} {service} » (code {code})"
|
||||||
|
|
||||||
|
|
||||||
def _run_wireguard_service_cmd(cmd: list[str], timeout: int) -> tuple[int, str, str]:
|
def _run_wireguard_service_cmd(cmd: list[str], timeout: int) -> tuple[int, str, str]:
|
||||||
"""Lance `wireguard.exe /install…` ou `/uninstall…tunnelservice`.
|
"""Lance `wireguard.exe /install…` ou `/uninstall…tunnelservice`.
|
||||||
|
|
||||||
@@ -365,8 +418,21 @@ def connect(cfg: Config) -> tuple[bool, str]:
|
|||||||
# service WireGuardTunnel$<nom>.
|
# service WireGuardTunnel$<nom>.
|
||||||
if is_connected(cfg):
|
if is_connected(cfg):
|
||||||
return True, "Tunnel WireGuard déjà actif"
|
return True, "Tunnel WireGuard déjà actif"
|
||||||
if dns_util.interface_exists(name):
|
|
||||||
force_cleanup(cfg)
|
# Tunnel déjà installé : il suffit de le démarrer. C'est le cas
|
||||||
|
# courant, et le seul qui ne demande aucune élévation — à condition
|
||||||
|
# que les droits de démarrage aient été accordés sur ce service.
|
||||||
|
# Réinstaller à chaque connexion, comme auparavant, imposait une
|
||||||
|
# authentification administrateur à chaque fois : créer ou supprimer
|
||||||
|
# un service n'est jamais délégable, le démarrer peut l'être.
|
||||||
|
if tunnel_service_installed(name):
|
||||||
|
ok, err = _control_tunnel_service(name, "start", timeout=45)
|
||||||
|
if ok and _wait_connected(cfg, timeout=15):
|
||||||
|
return True, _post_connect_message(cfg)
|
||||||
|
if not ok:
|
||||||
|
return False, f"Impossible de démarrer le tunnel : {err}"
|
||||||
|
return False, "Le tunnel a démarré mais n'est pas monté"
|
||||||
|
|
||||||
cmd = [wireguard_exe(), "/installtunnelservice", result]
|
cmd = [wireguard_exe(), "/installtunnelservice", result]
|
||||||
try:
|
try:
|
||||||
code, _, err = _run_wireguard_service_cmd(cmd, timeout=90)
|
code, _, err = _run_wireguard_service_cmd(cmd, timeout=90)
|
||||||
@@ -417,13 +483,17 @@ def disconnect(cfg: Config) -> tuple[bool, str]:
|
|||||||
name = cfg.wg.get("interface_name", "wgs0")
|
name = cfg.wg.get("interface_name", "wgs0")
|
||||||
|
|
||||||
if is_windows():
|
if is_windows():
|
||||||
# force_cleanup() appelle wireguard.exe /uninstalltunnelservice si le service existe,
|
# Arrêter, pas désinstaller : le service du tunnel reste en place
|
||||||
# puis nettoie les règles DNS résiduelles (NRPT).
|
# pour la prochaine connexion. Le supprimer à chaque déconnexion
|
||||||
force_cleanup(cfg)
|
# obligeait à le recréer ensuite, et donc à redemander une
|
||||||
if not dns_util.interface_exists(name):
|
# authentification administrateur — alors que l'arrêter peut être
|
||||||
return True, "Déconnecté"
|
# délégué. Le service restitue routes et DNS en s'arrêtant.
|
||||||
# Si le service existe toujours, c'est qu'il n'a pas pu être supprimé.
|
ok, err = _control_tunnel_service(name, "stop", timeout=45)
|
||||||
return False, "Erreur lors de la déconnexion : le service n'a pas pu être arrêté"
|
dns_util.cleanup_split_dns(cfg)
|
||||||
|
dns_util.cleanup(name)
|
||||||
|
if ok or not is_connected(cfg):
|
||||||
|
return True, "Tunnel WireGuard désactivé"
|
||||||
|
return False, f"Erreur lors de la déconnexion : {err}"
|
||||||
|
|
||||||
err = ""
|
err = ""
|
||||||
if is_connected(cfg):
|
if is_connected(cfg):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Service Windows privilégié pour WGSecure.
|
"""Service Windows privilégié pour WGSecure.
|
||||||
|
|
||||||
Installe/désinstalle le tunnel WireGuard, pose/retire les règles NRPT
|
Installe, démarre, arrête et désinstalle le tunnel WireGuard, pose/retire les règles NRPT
|
||||||
(split-DNS) et lit l'état du tunnel (`wg show`) sur demande de l'app — non
|
(split-DNS) et lit l'état du tunnel (`wg show`) sur demande de l'app — non
|
||||||
privilégiée — pour éviter une invite d'authentification à chaque connexion
|
privilégiée — pour éviter une invite d'authentification à chaque connexion
|
||||||
et déconnexion. Tourne en LocalSystem.
|
et déconnexion. Tourne en LocalSystem.
|
||||||
@@ -11,7 +11,7 @@ le pipe nommé (`app/utils/helper_ipc.py`, ctypes pur, sans dépendance à
|
|||||||
pywin32).
|
pywin32).
|
||||||
|
|
||||||
Invariant de sécurité central : le pipe reste accessible à tout process
|
Invariant de sécurité central : le pipe reste accessible à tout process
|
||||||
d'un utilisateur local, pas seulement WGSecure — donc CINQ commandes fixes
|
d'un utilisateur local, pas seulement WGSecure — donc SEPT commandes fixes
|
||||||
seulement, jamais de script/commande arbitraire. Chaque commande valide
|
seulement, jamais de script/commande arbitraire. Chaque commande valide
|
||||||
strictement ses paramètres avant d'exécuter quoi que ce soit.
|
strictement ses paramètres avant d'exécuter quoi que ce soit.
|
||||||
"""
|
"""
|
||||||
@@ -183,6 +183,25 @@ def _handle_uninstall_tunnel(req: dict, handle) -> dict:
|
|||||||
return {"ok": code == 0, "message": msg}
|
return {"ok": code == 0, "message": msg}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_start_tunnel_service(req: dict, handle) -> dict:
|
||||||
|
"""Démarre le service d'un tunnel déjà installé.
|
||||||
|
|
||||||
|
Complète install/uninstall : dans le fonctionnement courant le tunnel
|
||||||
|
est installé une fois, puis seulement démarré et arrêté. Le nom est
|
||||||
|
validé, et le préfixe « WireGuardTunnel$ » est ajouté ici — l'appelant
|
||||||
|
ne choisit donc jamais quel service piloter.
|
||||||
|
"""
|
||||||
|
name = _validate_interface_name(req.get("name"))
|
||||||
|
code, msg = _run(["sc.exe", "start", f"WireGuardTunnel${name}"], timeout=45)
|
||||||
|
return {"ok": code == 0, "message": msg}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_stop_tunnel_service(req: dict, handle) -> dict:
|
||||||
|
name = _validate_interface_name(req.get("name"))
|
||||||
|
code, msg = _run(["sc.exe", "stop", f"WireGuardTunnel${name}"], timeout=45)
|
||||||
|
return {"ok": code == 0, "message": msg}
|
||||||
|
|
||||||
|
|
||||||
def _handle_set_split_dns(req: dict, handle) -> dict:
|
def _handle_set_split_dns(req: dict, handle) -> dict:
|
||||||
server = _validate_server(req.get("server"))
|
server = _validate_server(req.get("server"))
|
||||||
domains = _validate_domains(req.get("domains"))
|
domains = _validate_domains(req.get("domains"))
|
||||||
@@ -217,6 +236,8 @@ def _handle_get_status(req: dict, handle) -> dict:
|
|||||||
_HANDLERS = {
|
_HANDLERS = {
|
||||||
"install_tunnel": _handle_install_tunnel,
|
"install_tunnel": _handle_install_tunnel,
|
||||||
"uninstall_tunnel": _handle_uninstall_tunnel,
|
"uninstall_tunnel": _handle_uninstall_tunnel,
|
||||||
|
"start_tunnel_service": _handle_start_tunnel_service,
|
||||||
|
"stop_tunnel_service": _handle_stop_tunnel_service,
|
||||||
"set_split_dns": _handle_set_split_dns,
|
"set_split_dns": _handle_set_split_dns,
|
||||||
"clear_split_dns": _handle_clear_split_dns,
|
"clear_split_dns": _handle_clear_split_dns,
|
||||||
"get_status": _handle_get_status,
|
"get_status": _handle_get_status,
|
||||||
|
|||||||
@@ -392,6 +392,25 @@ class AdminWindow(QDialog):
|
|||||||
self._row(form2, "DNS :", self._dns)
|
self._row(form2, "DNS :", self._dns)
|
||||||
|
|
||||||
# Split-DNS : serveur dédié pour les noms du réseau distant
|
# Split-DNS : serveur dédié pour les noms du réseau distant
|
||||||
|
# Authentification auprès du serveur, après montage du tunnel.
|
||||||
|
self._vpn_username = QLineEdit()
|
||||||
|
self._vpn_username.setPlaceholderText("TestB")
|
||||||
|
self._vpn_username.setToolTip(
|
||||||
|
"Nom du compte VPN tel qu'il figure sur le serveur.\n"
|
||||||
|
"Il sert à valider votre code à 6 chiffres après la connexion.\n"
|
||||||
|
"Laissé vide, aucun code n'est demandé (serveur en mode permissif)."
|
||||||
|
)
|
||||||
|
self._row(form2, "Identifiant VPN :", self._vpn_username)
|
||||||
|
|
||||||
|
self._api_url = QLineEdit()
|
||||||
|
self._api_url.setPlaceholderText("déduit automatiquement")
|
||||||
|
self._api_url.setToolTip(
|
||||||
|
"URL du serveur WGSecure, joignable depuis le tunnel.\n"
|
||||||
|
"Laissée vide, elle est déduite de l'adresse du client\n"
|
||||||
|
"(10.6.0.6 → http://10.6.0.1:8000)."
|
||||||
|
)
|
||||||
|
self._row(form2, "Serveur d'authentification :", self._api_url)
|
||||||
|
|
||||||
self._split_dns_server = QLineEdit()
|
self._split_dns_server = QLineEdit()
|
||||||
self._split_dns_server.setPlaceholderText("192.168.1.210")
|
self._split_dns_server.setPlaceholderText("192.168.1.210")
|
||||||
self._split_dns_server.setToolTip(
|
self._split_dns_server.setToolTip(
|
||||||
@@ -1295,6 +1314,8 @@ class AdminWindow(QDialog):
|
|||||||
|
|
||||||
# Split-DNS : réseau distant par défaut de ce déploiement (H3), tant
|
# Split-DNS : réseau distant par défaut de ce déploiement (H3), tant
|
||||||
# qu'aucune valeur explicite n'a encore été saisie ou importée.
|
# qu'aucune valeur explicite n'a encore été saisie ou importée.
|
||||||
|
self._vpn_username.setText(wg.get("vpn_username") or "")
|
||||||
|
self._api_url.setText(wg.get("api_url") or "")
|
||||||
self._split_dns_server.setText(wg.get("split_dns_server") or "192.168.1.210")
|
self._split_dns_server.setText(wg.get("split_dns_server") or "192.168.1.210")
|
||||||
domains = wg.get("split_dns_domains") or ["h3adm.lan"]
|
domains = wg.get("split_dns_domains") or ["h3adm.lan"]
|
||||||
self._split_dns_domains.setText(", ".join(domains) if isinstance(domains, list) else domains)
|
self._split_dns_domains.setText(", ".join(domains) if isinstance(domains, list) else domains)
|
||||||
@@ -1323,6 +1344,8 @@ class AdminWindow(QDialog):
|
|||||||
self._cfg.set("wg", "client_public_key", self._pub_key.text().strip())
|
self._cfg.set("wg", "client_public_key", self._pub_key.text().strip())
|
||||||
|
|
||||||
# Split-DNS
|
# Split-DNS
|
||||||
|
self._cfg.set("wg", "vpn_username", self._vpn_username.text().strip())
|
||||||
|
self._cfg.set("wg", "api_url", self._api_url.text().strip())
|
||||||
self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip())
|
self._cfg.set("wg", "split_dns_server", self._split_dns_server.text().strip())
|
||||||
domains_text = self._split_dns_domains.text().strip()
|
domains_text = self._split_dns_domains.text().strip()
|
||||||
domains_list = [d.strip() for d in domains_text.split(",") if d.strip()] if domains_text else []
|
domains_list = [d.strip() for d in domains_text.split(",") if d.strip()] if domains_text else []
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from PyQt6.QtCore import Qt, QTimer, QDateTime
|
|||||||
from PyQt6.QtGui import QFont, QCloseEvent, QColor
|
from PyQt6.QtGui import QFont, QCloseEvent, QColor
|
||||||
|
|
||||||
from app.core.config import Config
|
from app.core.config import Config
|
||||||
|
from app.core import vpn_session
|
||||||
from app.core import wireguard as wg_core
|
from app.core import wireguard as wg_core
|
||||||
from app.core import dns as dns_util
|
from app.core import dns as dns_util
|
||||||
from app.core import log as conn_log
|
from app.core import log as conn_log
|
||||||
@@ -692,6 +693,38 @@ class MainWindow(QMainWindow):
|
|||||||
self._worker.finished.connect(self._worker.deleteLater)
|
self._worker.finished.connect(self._worker.deleteLater)
|
||||||
self._worker.start()
|
self._worker.start()
|
||||||
|
|
||||||
|
def _authenticate_with_server(self, silent: bool):
|
||||||
|
"""Demande le code à 6 chiffres et le fait valider par le serveur.
|
||||||
|
|
||||||
|
Sans identifiant VPN configuré, l'étape est ignorée : le serveur
|
||||||
|
peut très bien tourner en mode permissif, où un compte non enrôlé
|
||||||
|
conserve son accès. Imposer la saisie dans ce cas bloquerait des
|
||||||
|
installations qui fonctionnent parfaitement.
|
||||||
|
"""
|
||||||
|
if not (self._cfg.wg.get("vpn_username") or "").strip():
|
||||||
|
return
|
||||||
|
if silent:
|
||||||
|
# Reconnexion automatique : ouvrir une fenêtre modale sans
|
||||||
|
# personne devant l'écran laisserait l'application figée.
|
||||||
|
self._add_log("Code d'accès requis — reconnectez-vous manuellement",
|
||||||
|
"warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
code, accepted = QInputDialog.getText(
|
||||||
|
self, "Code d'accès",
|
||||||
|
"Le tunnel est monté, mais l'accès aux ressources distantes\n"
|
||||||
|
"reste fermé tant que votre code n'est pas validé.\n\n"
|
||||||
|
"Code à 6 chiffres :",
|
||||||
|
)
|
||||||
|
if not accepted or not code.strip():
|
||||||
|
self._add_log("Authentification annulée — accès distant fermé", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
ok, message = vpn_session.authenticate(self._cfg, code.strip())
|
||||||
|
self._add_log(message, "success" if ok else "error")
|
||||||
|
if not ok:
|
||||||
|
QMessageBox.warning(self, "Accès refusé", message)
|
||||||
|
|
||||||
def _begin_task(self, label: str):
|
def _begin_task(self, label: str):
|
||||||
self._connecting = True
|
self._connecting = True
|
||||||
self._btn_connect.setEnabled(False)
|
self._btn_connect.setEnabled(False)
|
||||||
@@ -717,6 +750,11 @@ class MainWindow(QMainWindow):
|
|||||||
base, _, extra = msg.partition("\n")
|
base, _, extra = msg.partition("\n")
|
||||||
if extra:
|
if extra:
|
||||||
self._add_log(extra, "warning")
|
self._add_log(extra, "warning")
|
||||||
|
# Le tunnel est monté mais ne mène encore nulle part : le
|
||||||
|
# serveur garde le pair en quarantaine tant qu'un code n'a
|
||||||
|
# pas été validé. C'est seulement maintenant que l'API est
|
||||||
|
# joignable — elle ne l'est que depuis l'intérieur du tunnel.
|
||||||
|
self._authenticate_with_server(silent)
|
||||||
QTimer.singleShot(5000, self._update_ping)
|
QTimer.singleShot(5000, self._update_ping)
|
||||||
else:
|
else:
|
||||||
self._reconnect_failures += 1
|
self._reconnect_failures += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user