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
+5
View File
@@ -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": [],
} }
+96
View File
@@ -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
View File
@@ -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):
+23 -2
View File
@@ -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,
+23
View File
@@ -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 []
+38
View File
@@ -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, 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