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