chore(release): v0.7.12

Ajoute wgsecure-helper, un service Windows privilégié (LocalSystem)
qui installe/désinstalle le tunnel et pose/retire les règles NRPT
sans invite d'authentification répétée. L'ACL du service WireGuard
(v0.7.6) ne délègue que démarrer/arrêter un tunnel déjà installé,
jamais le créer/supprimer, ce que WGSecure fait pourtant à chaque
connexion/déconnexion — d'où les prompts qui persistaient malgré
v0.7.6-v0.7.11. Corrige aussi le handshake, qui échouait pour la même
raison (wg show sans élévation complète).

Purement additif : l'app retombe sur son chemin élevé existant si le
service est absent (composant optionnel dans l'installeur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:15:08 +02:00
co-authored by Claude Sonnet 5
parent 9dca066239
commit 7beb6deb7a
11 changed files with 546 additions and 16 deletions
+49 -2
View File
@@ -7,6 +7,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from app.core import dns as dns_util
from app.core import wg_handshake
from app.core.config import Config
from app.utils.helper_ipc import call_helper
from app.utils.platform_utils import (
get_config_dir,
has_root_privileges,
@@ -168,7 +169,14 @@ def _wg_show(name: str) -> str:
`run_privileged_readonly` : sans lui, le handshake et les compteurs de
trafic restent vides dès que l'application ne tourne plus en root (le cas
normal une fois `make setup-sudoers` en place pour wg-quick).
Sous Windows, `wg.exe show` échoue même avec les délégations existantes
(ACL du service, LimitedOperatorUI) — délègue donc d'abord à
wgsecure-helper (service SYSTEM), lecture seule, si disponible.
"""
resp = call_helper({"cmd": "get_status", "name": name})
if resp is not None:
return resp.get("message") or "" if resp.get("ok") else ""
code, out, err = run_privileged_readonly([wg_exe(), "show", name])
if code != 0 or not out:
return ""
@@ -213,6 +221,36 @@ def interface_exists(cfg: Config) -> bool:
return dns_util.interface_exists(cfg.wg.get("interface_name", "wgs0"))
def _try_helper_tunnel_cmd(cmd: list[str]) -> tuple[int, str, str] | None:
"""Délègue install/uninstalltunnelservice à wgsecure-helper (service
SYSTEM) s'il est installé — aucune invite d'authentification, y compris
sans ACL accordée. `None` si le service est absent/indisponible :
l'appelant bascule alors sur le chemin existant (ACL puis élévation).
L'ACL du service WireGuard (bouton « Configurer les permissions ») ne
délègue que démarrer/arrêter un tunnel déjà installé, jamais le créer/
supprimer — `/installtunnelservice` et `/uninstalltunnelservice` (qui
font justement l'un ou l'autre à chaque connexion/déconnexion, cf.
`connect()`/`force_cleanup()`) continuaient donc à réclamer une
élévation même une fois l'ACL en place. D'où ce service séparé.
"""
if len(cmd) < 3:
return None
verb = cmd[1]
if verb == "/installtunnelservice":
config_path = cmd[2]
name = os.path.splitext(os.path.basename(config_path))[0]
request = {"cmd": "install_tunnel", "config_path": config_path, "name": name}
elif verb == "/uninstalltunnelservice":
request = {"cmd": "uninstall_tunnel", "name": cmd[2]}
else:
return None
resp = call_helper(request)
if resp is None:
return None
return (0 if resp.get("ok") else 1), "", str(resp.get("message") or "")
def _run_wireguard_service_cmd(cmd: list[str], timeout: int) -> tuple[int, str, str]:
"""Lance `wireguard.exe /install…` ou `/uninstall…tunnelservice`.
@@ -228,6 +266,9 @@ def _run_wireguard_service_cmd(cmd: list[str], timeout: int) -> tuple[int, str,
`run_privileged`, seul à passer par ShellExecuteEx "runas" pour obtenir une
vraie invite UAC.
"""
resp = _try_helper_tunnel_cmd(cmd)
if resp is not None:
return resp
code, out, err = run_command(cmd, timeout)
if code == 0:
return code, out, err
@@ -451,8 +492,14 @@ def last_handshake_age(name: str) -> int | None:
Retourne -1 si aucun handshake n'a jamais eu lieu, None si l'information
est inaccessible (privilèges insuffisants, `wg` absent).
"""
code, out, _ = run_privileged_readonly([wg_exe(), "show", name, "latest-handshakes"], 10)
if code != 0 or not out.strip():
resp = call_helper({"cmd": "get_status", "name": name, "latest_handshakes": True})
if resp is not None:
out = resp.get("message") or "" if resp.get("ok") else ""
else:
code, out, _ = run_privileged_readonly([wg_exe(), "show", name, "latest-handshakes"], 10)
if code != 0:
out = ""
if not out.strip():
return None
newest = 0
for line in out.splitlines():