diff --git a/app/ui/admin_window.py b/app/ui/admin_window.py index 8ec10cf..f59e279 100644 --- a/app/ui/admin_window.py +++ b/app/ui/admin_window.py @@ -1739,8 +1739,13 @@ class AdminWindow(QDialog): QApplication.processEvents() 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( - 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.setStyleSheet(theme.result_view_style(state)) return @@ -1768,7 +1773,8 @@ class AdminWindow(QDialog): @staticmethod 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.""" lines: list[str] = [] if elevated: @@ -1788,6 +1794,20 @@ class AdminWindow(QDialog): "qu'administrateur ».") 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("") if wg_present: lines.append(f"✅ WireGuard installé — {wg_path}") @@ -1887,7 +1907,7 @@ class AdminWindow(QDialog): # Commande PowerShell pour modifier les ACLs du service WireGuard # Ajoute à l'utilisateur actuel les droits de créer/supprimer des services WireGuardTunnel* - ps_script = ''' + ps_script = r''' $serviceName = "WireGuard" $currentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value @@ -1909,6 +1929,22 @@ sc sdset $serviceName $fullSddl # Redémarrer le service pour appliquer les changements 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" ''' @@ -1927,7 +1963,8 @@ Write-Output "Permissions configurées pour l'utilisateur $currentUserSid" self._priv_result.setPlainText( f"✅ Permissions configurées avec succès !\n\n" 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()}" ) self._priv_result.setStyleSheet(theme.result_view_style("ok")) diff --git a/app/utils/platform_utils.py b/app/utils/platform_utils.py index b07ab1e..1159426 100644 --- a/app/utils/platform_utils.py +++ b/app/utils/platform_utils.py @@ -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())