fix(windows): capture stdout/stderr élevé, tente wg show non élevé, étend la délégation

- _run_elevated_windows() capture désormais la sortie réelle d'une commande
  élevée (fichier temp écrit par le process élevé lui-même) : ShellExecuteEx
  runas ne transmet jamais les tubes hérités, donc tout message d'échec
  élevé (split-DNS NRPT, install/uninstall du tunnel) restait "erreur
  inconnue" jusqu'ici.
- run_privileged_readonly() tente réellement `wg show` sous Windows non élevé
  au lieu d'abandonner sans essayer — le handshake ne se peuplait jamais.
- Le bouton "Configurer les permissions" active aussi le mécanisme officiel
  WireGuard (LimitedOperatorUI + groupe Network Configuration Operators via
  son SID, le nom étant localisé) en plus de l'ACL du service déjà en place.
- Le diagnostic Windows précise désormais que le split-DNS (NRPT) exige
  toujours une authentification admin séparée à la connexion/déconnexion,
  aucune ACL ne pouvant le déléguer (aucun mécanisme documenté trouvé).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 16:21:35 +02:00
co-authored by Claude Sonnet 5
parent 7a8a5de1a4
commit 35e5fe1ab4
2 changed files with 86 additions and 11 deletions
+45 -7
View File
@@ -162,6 +162,11 @@ def _sudo_refused_auth(err: str) -> bool:
return any(marker in low for marker in _SUDO_AUTH_MARKERS)
def _ps_quote(s: str) -> str:
"""Échappe une chaîne pour une chaîne litérale PowerShell entre quotes simples."""
return "'" + s.replace("'", "''") + "'"
def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str, str]:
"""Relance `cmd` derrière une invite UAC et attend sa fin.
@@ -171,11 +176,18 @@ def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str,
avec le verbe « runas » est le seul moyen d'obtenir l'élévation depuis un
processus déjà démarré.
Un processus élevé n'hérite pas de nos tubes : stdout/stderr sont perdus,
seul le code de sortie remonte.
Un processus élevé (démarré par le courtier UAC, hors relation parent/
enfant) n'hérite jamais de nos tubes : stdout/stderr ne peuvent pas
remonter par héritage, avec ou sans redirection côté appelant (même
`Start-Process -Verb RunAs -RedirectStandardOutput` échoue pour la même
raison). Le seul moyen fiable : élever `powershell.exe` et lui faire
écrire *lui-même*, une fois élevé, la sortie de `cmd` dans un fichier
temporaire, puis relire ce fichier une fois le process terminé.
"""
import ctypes
from ctypes import wintypes
import tempfile
import uuid
SEE_MASK_NOCLOSEPROCESS = 0x00000040
SEE_MASK_NOASYNC = 0x00000100
@@ -202,12 +214,19 @@ def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str,
("hProcess", wintypes.HANDLE),
]
out_path = os.path.join(tempfile.gettempdir(), f"wgsecure-elev-{uuid.uuid4().hex}.log")
# `*>` fusionne tous les flux (succès, erreur, avertissement…) — cohérent
# avec l'usage existant qui traite `err or out` indifféremment.
call_expr = "& " + " ".join(_ps_quote(a) for a in cmd)
wrapped_script = f"{call_expr} *> {_ps_quote(out_path)}; exit $LASTEXITCODE"
elevated_cmd = ["powershell", "-NoProfile", "-NonInteractive", "-Command", wrapped_script]
info = SHELLEXECUTEINFOW()
info.cbSize = ctypes.sizeof(info)
info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC
info.lpVerb = "runas"
info.lpFile = cmd[0]
info.lpParameters = subprocess.list2cmdline(cmd[1:])
info.lpFile = elevated_cmd[0]
info.lpParameters = subprocess.list2cmdline(elevated_cmd[1:])
info.nShow = SW_HIDE
if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(info)):
@@ -217,9 +236,22 @@ def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str,
return -1, "", f"Élévation impossible (code {err}) : {cmd[0]}"
handle = info.hProcess
def _read_output() -> str:
try:
with open(out_path, encoding="utf-8", errors="replace") as f:
return f.read().strip()
except OSError:
return ""
finally:
try:
os.unlink(out_path)
except OSError:
pass
if not handle:
# Commande lancée sans handle exploitable : succès non vérifiable.
return 0, "", ""
return 0, _read_output(), ""
try:
if ctypes.windll.kernel32.WaitForSingleObject(
handle, int(timeout * 1000)
@@ -227,7 +259,7 @@ def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str,
return -1, "", "Timeout"
code = wintypes.DWORD()
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
return code.value, "", ""
return code.value, _read_output(), ""
finally:
ctypes.windll.kernel32.CloseHandle(handle)
@@ -293,7 +325,13 @@ def run_privileged_readonly(cmd: list[str], timeout: int = 10) -> tuple[int, str
if has_root_privileges():
return run_command(cmd, timeout)
if is_windows():
return -1, "", "Élévation requise"
# `run_command` n'élève jamais (pas de fenêtre UAC) : la tentative
# est donc sans risque, et peut réussir si l'utilisateur a le
# mécanisme LimitedOperatorUI + groupe « Network Configuration
# Operators » (cf. _grant_wireguard_permissions dans admin_window.py).
# Un abandon systématique sans essayer condamnait le handshake à
# rester vide même quand l'accès non élevé fonctionne réellement.
return run_command(cmd, timeout)
return run_command(["sudo", "-n"] + cmd, timeout, env=_c_locale_env())