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:
+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 "")
|
||||
|
||||
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user