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,
# 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.
# 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_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 "")
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]:
"""Lance `wireguard.exe /install…` ou `/uninstall…tunnelservice`.
@@ -365,8 +418,21 @@ def connect(cfg: Config) -> tuple[bool, str]:
# service WireGuardTunnel$<nom>.
if is_connected(cfg):
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]
try:
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")
if is_windows():
# force_cleanup() appelle wireguard.exe /uninstalltunnelservice si le service existe,
# puis nettoie les règles DNS résiduelles (NRPT).
force_cleanup(cfg)
if not dns_util.interface_exists(name):
return True, "Déconnecté"
# Si le service existe toujours, c'est qu'il n'a pas pu être supprimé.
return False, "Erreur lors de la déconnexion : le service n'a pas pu être arrêté"
# Arrêter, pas désinstaller : le service du tunnel reste en place
# pour la prochaine connexion. Le supprimer à chaque déconnexion
# obligeait à le recréer ensuite, et donc à redemander une
# authentification administrateur — alors que l'arrêter peut être
# délégué. Le service restitue routes et DNS en s'arrêtant.
ok, err = _control_tunnel_service(name, "stop", timeout=45)
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 = ""
if is_connected(cfg):