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:
2026-09-06 12:40:34 +02:00
co-authored by Claude Opus 5
parent e272b9397e
commit a7ac6d1375
6 changed files with 264 additions and 11 deletions
+23
View File
@@ -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 []