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
+41 -4
View File
@@ -1739,8 +1739,13 @@ class AdminWindow(QDialog):
QApplication.processEvents() QApplication.processEvents()
if is_windows(): if is_windows():
split_dns_active = bool(
(self._cfg.wg.get("split_dns_server") or "").strip()
and (self._cfg.wg.get("split_dns_domains") or [])
)
lines, state = self._windows_privilege_lines( lines, state = self._windows_privilege_lines(
has_root_privileges(), wg_quick_available(), wireguard_exe()) has_root_privileges(), wg_quick_available(), wireguard_exe(),
split_dns_active)
self._priv_result.setPlainText("\n".join(lines)) self._priv_result.setPlainText("\n".join(lines))
self._priv_result.setStyleSheet(theme.result_view_style(state)) self._priv_result.setStyleSheet(theme.result_view_style(state))
return return
@@ -1768,7 +1773,8 @@ class AdminWindow(QDialog):
@staticmethod @staticmethod
def _windows_privilege_lines(elevated: bool, wg_present: bool, def _windows_privilege_lines(elevated: bool, wg_present: bool,
wg_path: str) -> tuple[list[str], str]: wg_path: str,
split_dns_active: bool = False) -> tuple[list[str], str]:
"""Rapport d'élévation Windows. Séparé pour rester testable.""" """Rapport d'élévation Windows. Séparé pour rester testable."""
lines: list[str] = [] lines: list[str] = []
if elevated: if elevated:
@@ -1788,6 +1794,20 @@ class AdminWindow(QDialog):
"qu'administrateur ».") "qu'administrateur ».")
state = "warn" state = "warn"
if split_dns_active:
lines.append("")
lines.append("⚠️ Split-DNS configuré : les règles NRPT "
"(Add/Remove-DnsClientNrptRule) exigent")
lines.append(" toujours des droits administrateur — "
"aucune ACL ne permet de l'éviter. Une")
lines.append(" authentification admin séparée reste donc "
"demandée à chaque connexion")
lines.append(" et déconnexion tant que le split-DNS est "
"actif, même une fois les")
lines.append(" permissions ci-dessus accordées.")
if state == "ok":
state = "warn"
lines.append("") lines.append("")
if wg_present: if wg_present:
lines.append(f"✅ WireGuard installé — {wg_path}") lines.append(f"✅ WireGuard installé — {wg_path}")
@@ -1887,7 +1907,7 @@ class AdminWindow(QDialog):
# Commande PowerShell pour modifier les ACLs du service WireGuard # Commande PowerShell pour modifier les ACLs du service WireGuard
# Ajoute à l'utilisateur actuel les droits de créer/supprimer des services WireGuardTunnel* # Ajoute à l'utilisateur actuel les droits de créer/supprimer des services WireGuardTunnel*
ps_script = ''' ps_script = r'''
$serviceName = "WireGuard" $serviceName = "WireGuard"
$currentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value $currentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
@@ -1909,6 +1929,22 @@ sc sdset $serviceName $fullSddl
# Redémarrer le service pour appliquer les changements # Redémarrer le service pour appliquer les changements
Restart-Service -Name $serviceName -Force -ErrorAction SilentlyContinue Restart-Service -Name $serviceName -Force -ErrorAction SilentlyContinue
# Mécanisme officiel WireGuard (distinct de l'ACL ci-dessus, qui ne couvre
# que le service) : autorise les membres du groupe "Network Configuration
# Operators" à démarrer/arrêter leurs tunnels sans élévation.
# https://github.com/WireGuard/wireguard-windows/blob/master/docs/adminregistry.md
New-Item -Path 'HKLM:\Software\WireGuard' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\Software\WireGuard' -Name 'LimitedOperatorUI' -Value 1 -Type DWord
# SID fixe S-1-5-32-556 = "Network Configuration Operators" : le nom du
# groupe est localisé (FR : "Opérateurs de configuration réseau"), -SID est
# indépendant de la langue du système.
try {
Add-LocalGroupMember -SID 'S-1-5-32-556' -Member $currentUserSid -ErrorAction Stop
} catch {
if ($_.Exception.Message -notmatch 'already a member') { throw }
}
Write-Output "Permissions configurées pour l'utilisateur $currentUserSid" Write-Output "Permissions configurées pour l'utilisateur $currentUserSid"
''' '''
@@ -1927,7 +1963,8 @@ Write-Output "Permissions configurées pour l'utilisateur $currentUserSid"
self._priv_result.setPlainText( self._priv_result.setPlainText(
f"✅ Permissions configurées avec succès !\n\n" f"✅ Permissions configurées avec succès !\n\n"
f"L'utilisateur actuel peut désormais gérer ses tunnels WireGuard " f"L'utilisateur actuel peut désormais gérer ses tunnels WireGuard "
f"sans invite UAC (si le service WireGuard est démarré).\n\n" f"sans invite UAC (ACL du service + groupe « Opérateurs de "
f"configuration réseau »).\n\n"
f"Détails : {out.strip()}" f"Détails : {out.strip()}"
) )
self._priv_result.setStyleSheet(theme.result_view_style("ok")) self._priv_result.setStyleSheet(theme.result_view_style("ok"))
+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) 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]: 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. """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 avec le verbe « runas » est le seul moyen d'obtenir l'élévation depuis un
processus déjà démarré. processus déjà démarré.
Un processus élevé n'hérite pas de nos tubes : stdout/stderr sont perdus, Un processus élevé (démarré par le courtier UAC, hors relation parent/
seul le code de sortie remonte. 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 import ctypes
from ctypes import wintypes from ctypes import wintypes
import tempfile
import uuid
SEE_MASK_NOCLOSEPROCESS = 0x00000040 SEE_MASK_NOCLOSEPROCESS = 0x00000040
SEE_MASK_NOASYNC = 0x00000100 SEE_MASK_NOASYNC = 0x00000100
@@ -202,12 +214,19 @@ def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str,
("hProcess", wintypes.HANDLE), ("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 = SHELLEXECUTEINFOW()
info.cbSize = ctypes.sizeof(info) info.cbSize = ctypes.sizeof(info)
info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC
info.lpVerb = "runas" info.lpVerb = "runas"
info.lpFile = cmd[0] info.lpFile = elevated_cmd[0]
info.lpParameters = subprocess.list2cmdline(cmd[1:]) info.lpParameters = subprocess.list2cmdline(elevated_cmd[1:])
info.nShow = SW_HIDE info.nShow = SW_HIDE
if not ctypes.windll.shell32.ShellExecuteExW(ctypes.byref(info)): 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]}" return -1, "", f"Élévation impossible (code {err}) : {cmd[0]}"
handle = info.hProcess 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: if not handle:
# Commande lancée sans handle exploitable : succès non vérifiable. # Commande lancée sans handle exploitable : succès non vérifiable.
return 0, "", "" return 0, _read_output(), ""
try: try:
if ctypes.windll.kernel32.WaitForSingleObject( if ctypes.windll.kernel32.WaitForSingleObject(
handle, int(timeout * 1000) handle, int(timeout * 1000)
@@ -227,7 +259,7 @@ def _run_elevated_windows(cmd: list[str], timeout: int = 60) -> tuple[int, str,
return -1, "", "Timeout" return -1, "", "Timeout"
code = wintypes.DWORD() code = wintypes.DWORD()
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code)) ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
return code.value, "", "" return code.value, _read_output(), ""
finally: finally:
ctypes.windll.kernel32.CloseHandle(handle) 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(): if has_root_privileges():
return run_command(cmd, timeout) return run_command(cmd, timeout)
if is_windows(): 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()) return run_command(["sudo", "-n"] + cmd, timeout, env=_c_locale_env())