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:
@@ -392,6 +392,25 @@ class AdminWindow(QDialog):
|
||||
self._row(form2, "DNS :", self._dns)
|
||||
|
||||
# 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.setPlaceholderText("192.168.1.210")
|
||||
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
|
||||
# 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")
|
||||
domains = wg.get("split_dns_domains") or ["h3adm.lan"]
|
||||
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())
|
||||
|
||||
# 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())
|
||||
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 []
|
||||
|
||||
@@ -9,6 +9,7 @@ from PyQt6.QtCore import Qt, QTimer, QDateTime
|
||||
from PyQt6.QtGui import QFont, QCloseEvent, QColor
|
||||
|
||||
from app.core.config import Config
|
||||
from app.core import vpn_session
|
||||
from app.core import wireguard as wg_core
|
||||
from app.core import dns as dns_util
|
||||
from app.core import log as conn_log
|
||||
@@ -692,6 +693,38 @@ class MainWindow(QMainWindow):
|
||||
self._worker.finished.connect(self._worker.deleteLater)
|
||||
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):
|
||||
self._connecting = True
|
||||
self._btn_connect.setEnabled(False)
|
||||
@@ -717,6 +750,11 @@ class MainWindow(QMainWindow):
|
||||
base, _, extra = msg.partition("\n")
|
||||
if extra:
|
||||
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)
|
||||
else:
|
||||
self._reconnect_failures += 1
|
||||
|
||||
Reference in New Issue
Block a user