X-Git-Url: https://vilimpoc.org/repos/dotfiles/blobdiff_plain/7d4887fe245e7643a497e87e04fc8f97d8ca436f..99725a30bdb3eee44c38e1fd9a28c060341aa058:/setup-windows-with-uac.ps1 diff --git a/setup-windows-with-uac.ps1 b/setup-windows-with-uac.ps1 index 8d990f5..ff947c6 100644 --- a/setup-windows-with-uac.ps1 +++ b/setup-windows-with-uac.ps1 @@ -15,12 +15,30 @@ - Visual Studio 2022 Community (C++ desktop workload, Spectre libs, WDK VSIX, Win11 SDK 26100, Clang/LLVM, and the v141 + Windows XP targeting toolset) - Windows Driver Kit 10.0.26100 + - Windows Performance Toolkit - xperf, wpr and Windows Performance Analyzer + (wpa.exe) - on the machine PATH + - ETW collection rights for one ordinary account: Performance Log Users + membership plus the "Profile system performance" user right, so xperf and + wpr run WITHOUT elevation Change $VsInstallerUrl below to the Professional or Enterprise bootstrapper if needed: Professional : https://aka.ms/vs/17/release/vs_professional.exe Enterprise : https://aka.ms/vs/17/release/vs_enterprise.exe #> +param( + # Account to be granted non-elevated ETW collection rights (see the "ETW + # collection rights" step at the bottom). Defaults to the interactive + # console user, but setup-windows.bat passes it explicitly: with + # over-the-shoulder elevation THIS script runs as the administrator whose + # credentials went into the UAC prompt, not as the user who started the + # batch file, so $env:USERNAME here is the wrong answer. + # + # Pass an empty string to skip the group membership (the user right is still + # granted to the group, so adding an account later is one command). + [string] $TraceUser = '' +) + $ErrorActionPreference = 'Stop' function Write-Step([string]$Msg) { @@ -37,6 +55,155 @@ function Assert-ExitCode([int]$Code, [string]$Step) { } } +# --------------------------------------------------------------------------- +# User rights assignment (LSA account rights) +# +# Windows has no built-in cmdlet for "grant this SID this privilege". The two +# ways to script it are secedit (export the whole USER_RIGHTS area to an INF, +# edit one line, re-import) and the LSA API. The API is used here because it is +# surgical: LsaAddAccountRights adds exactly one right to exactly one SID and is +# a no-op when it is already held, where a secedit round-trip re-applies every +# user right on the box to fix one of them. The GUI equivalent, for a human, is +# secpol.msc > Local Policies > User Rights Assignment +# +# The type is compiled on first use; C# 5 only, since Windows PowerShell 5.1's +# Add-Type compiles with the in-box CodeDom compiler. +# --------------------------------------------------------------------------- +function Initialize-LsaRightsType { + if ('LsaRights' -as [type]) { return } + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; + +public static class LsaRights +{ + [StructLayout(LayoutKind.Sequential)] + private struct LSA_UNICODE_STRING + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct LSA_OBJECT_ATTRIBUTES + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaOpenPolicy(IntPtr systemName, + ref LSA_OBJECT_ATTRIBUTES objectAttributes, uint desiredAccess, out IntPtr policyHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaAddAccountRights(IntPtr policyHandle, byte[] accountSid, + LSA_UNICODE_STRING[] userRights, uint countOfRights); + + [DllImport("advapi32.dll", SetLastError = true)] + private static extern uint LsaEnumerateAccountRights(IntPtr policyHandle, byte[] accountSid, + out IntPtr userRights, out uint countOfRights); + + [DllImport("advapi32.dll")] + private static extern uint LsaClose(IntPtr policyHandle); + + [DllImport("advapi32.dll")] + private static extern uint LsaFreeMemory(IntPtr buffer); + + [DllImport("advapi32.dll")] + private static extern int LsaNtStatusToWinError(uint status); + + private const uint POLICY_VIEW_LOCAL_INFORMATION = 0x00000001; + private const uint POLICY_CREATE_ACCOUNT = 0x00000010; + private const uint POLICY_LOOKUP_NAMES = 0x00000800; + + // Returned by LsaEnumerateAccountRights when the SID holds no rights at all, + // which is an empty list rather than an error. + private const uint STATUS_OBJECT_NAME_NOT_FOUND = 0xC0000034; + + private static IntPtr OpenPolicy() + { + LSA_OBJECT_ATTRIBUTES attrs = new LSA_OBJECT_ATTRIBUTES(); + attrs.Length = Marshal.SizeOf(typeof(LSA_OBJECT_ATTRIBUTES)); + IntPtr handle; + uint status = LsaOpenPolicy(IntPtr.Zero, ref attrs, + POLICY_VIEW_LOCAL_INFORMATION | POLICY_CREATE_ACCOUNT | POLICY_LOOKUP_NAMES, out handle); + if (status != 0) { throw new Win32Exception(LsaNtStatusToWinError(status)); } + return handle; + } + + public static string[] Get(byte[] sid) + { + IntPtr policy = OpenPolicy(); + try + { + IntPtr rights; + uint count; + uint status = LsaEnumerateAccountRights(policy, sid, out rights, out count); + if (status == STATUS_OBJECT_NAME_NOT_FOUND) { return new string[0]; } + if (status != 0) { throw new Win32Exception(LsaNtStatusToWinError(status)); } + try + { + string[] result = new string[count]; + int stride = Marshal.SizeOf(typeof(LSA_UNICODE_STRING)); + for (int i = 0; i < count; i++) + { + LSA_UNICODE_STRING s = (LSA_UNICODE_STRING)Marshal.PtrToStructure( + new IntPtr(rights.ToInt64() + (long)i * stride), typeof(LSA_UNICODE_STRING)); + result[i] = Marshal.PtrToStringUni(s.Buffer, s.Length / 2); + } + return result; + } + finally { LsaFreeMemory(rights); } + } + finally { LsaClose(policy); } + } + + public static void Add(byte[] sid, string right) + { + IntPtr policy = OpenPolicy(); + try + { + LSA_UNICODE_STRING[] rights = new LSA_UNICODE_STRING[1]; + rights[0].Buffer = Marshal.StringToHGlobalUni(right); + // Length counts BYTES and excludes the terminator; MaximumLength includes it. + rights[0].Length = (ushort)(right.Length * 2); + rights[0].MaximumLength = (ushort)(right.Length * 2 + 2); + try + { + uint status = LsaAddAccountRights(policy, sid, rights, 1); + if (status != 0) { throw new Win32Exception(LsaNtStatusToWinError(status)); } + } + finally { Marshal.FreeHGlobal(rights[0].Buffer); } + } + finally { LsaClose(policy); } + } +} +'@ +} + +function Get-SidBytes([string]$Sid) { + $s = New-Object System.Security.Principal.SecurityIdentifier($Sid) + $bytes = New-Object byte[] $s.BinaryLength + $s.GetBinaryForm($bytes, 0) + return ,$bytes +} + +function Get-AccountRight([string]$Sid) { + Initialize-LsaRightsType + return [LsaRights]::Get((Get-SidBytes $Sid)) +} + +function Grant-AccountRight([string]$Sid, [string]$Right) { + Initialize-LsaRightsType + [LsaRights]::Add((Get-SidBytes $Sid), $Right) +} + function Show-VsSetupLogs { # The VS Installer writes dd_*.log to the invoking user's %TEMP%. Because # this script runs elevated, that %TEMP% belongs to the elevated user and is @@ -448,30 +615,48 @@ if ($WdkInstalledRoot -and $WdkInstalledRoot -match [regex]::Escape($WdkVersion) } # --------------------------------------------------------------------------- -# Windows Performance Toolkit (xperf / WPA / wpr) -- ETW CPU + loader profiling, -# used by the perf/ measurement scripts. WPT is an OPTIONAL Windows SDK feature -# that the VS "Windows 11 SDK" component does NOT select, so a fresh box lacks it. -# The Windows ADK bundles WPT and winget owns the (versioned) download URL, so it -# is the most reliable source. Idempotent (skips if xperf is already present in -# either the SDK or ADK location) and non-fatal so it never aborts provisioning. -# Lighter alternative if you don't want the full ADK: install the Windows SDK's -# "Windows Performance Toolkit" optional feature via winsdksetup.exe /features -# OptionId.WindowsPerformanceToolkit. +# Windows Performance Toolkit: xperf, wpr, and Windows Performance Analyzer +# (wpa.exe) -- ETW CPU + loader profiling and the GUI that reads the traces. +# +# WPA is NOT a Visual Studio component and has no relationship to VS's own +# Performance Profiler (a separate, .diagsession-based tool that cannot open an +# .etl). It ships in exactly two places: as an optional FEATURE of the Windows +# SDK ("Windows Performance Toolkit", OptionId.WindowsPerformanceToolkit), and +# in the Windows ADK, which bundles the same toolkit. Whether the SDK install +# that Visual Studio performs happens to select that feature varies with the VS +# and SDK version - when it does, WPT lands in +# %ProgramFiles(x86)%\Windows Kits\10\Windows Performance Toolkit and the SDK +# puts that directory on the machine PATH itself - so this step DETECTS first +# and only falls back to installing the ADK (winget owns the versioned download +# URL, which makes it the reliable source) when nothing is there. That fallback +# is a large download; to install just the toolkit instead, run the standalone +# SDK setup with +# winsdksetup.exe /features OptionId.WindowsPerformanceToolkit /q +# +# There is also a newer WPA in the Microsoft Store (`winget install --id +# 9N0W1B2BXGNZ --source msstore`), which updates independently of the SDK. It is +# not installed here: the Store package needs an interactive, signed-in session, +# which is exactly what this elevated, unattended half does not have. +# +# Idempotent and non-fatal - it never aborts provisioning. # --------------------------------------------------------------------------- -Write-Step 'Windows Performance Toolkit (xperf / WPA)' -$wptRoots = @( - (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit\xperf.exe'), - (Join-Path $env:ProgramFiles 'Windows Kits\10\Windows Performance Toolkit\xperf.exe'), - (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Windows Performance Toolkit\xperf.exe') +Write-Step 'Windows Performance Toolkit (xperf / wpr / WPA)' +$WptDirs = @( + (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Windows Performance Toolkit'), + (Join-Path $env:ProgramFiles 'Windows Kits\10\Windows Performance Toolkit'), + (Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\Assessment and Deployment Kit\Windows Performance Toolkit') ) -$xperf = $wptRoots | Where-Object { Test-Path $_ } | Select-Object -First 1 -if ($xperf) { - Write-Host " OK: WPT already present ($xperf)" -ForegroundColor Green +function Find-WptDir { $script:WptDirs | Where-Object { Test-Path (Join-Path $_ 'xperf.exe') } | Select-Object -First 1 } + +$WptDir = Find-WptDir +if ($WptDir) { + Write-Host " OK: WPT already present ($WptDir)" -ForegroundColor Green } else { try { winget install --id Microsoft.WindowsADK --exact --silent --disable-interactivity ` --accept-source-agreements --accept-package-agreements Write-Host ' Windows ADK (includes Windows Performance Toolkit) installed.' + $WptDir = Find-WptDir } catch { Write-Warning "WPT install failed: $($_.Exception.Message)" Write-Warning 'Install manually: winget install Microsoft.WindowsADK, or add the' @@ -479,6 +664,162 @@ if ($xperf) { } } +if ($WptDir) { + # Report what actually landed. wpa.exe is the piece people come looking for + # and it is the one that is absent if a trimmed toolkit ever shows up. + foreach ($tool in 'xperf.exe', 'wpr.exe', 'wpa.exe', 'wpaexporter.exe') { + $p = Join-Path $WptDir $tool + if (Test-Path $p) { + Write-Host " $tool $((Get-Item $p).VersionInfo.ProductVersion)" + } else { + Write-Warning "$tool is missing from $WptDir" + } + } + + # The WPT installer normally adds this to the machine PATH itself (and the + # Start Menu gets "Windows Kits > Windows Performance Toolkit" shortcuts for + # WPA and WPR). Re-assert it anyway: on the machine PATH rather than a user + # one so it also resolves for the non-interactive sshd sessions this box is + # driven through, which build their environment from the registry PATH. + # Compared trailing-backslash-insensitively - the installer's own entry has + # one, and adding a second spelling of the same directory is just noise. + $m = [Environment]::GetEnvironmentVariable('Path', 'Machine') + if (-not $m) { $m = '' } + $have = ($m -split ';') | Where-Object { $_.TrimEnd('\') -eq $WptDir.TrimEnd('\') } + if ($have) { + Write-Host " OK: $WptDir already in the machine PATH" + } else { + $new = if ($m.Trim()) { $m.TrimEnd(';') + ';' + $WptDir } else { $WptDir } + [Environment]::SetEnvironmentVariable('Path', $new, 'Machine') + Write-Host " Added $WptDir to the machine PATH (restart shells to pick it up)." + } +} + +# --------------------------------------------------------------------------- +# ETW collection rights for an ordinary account +# +# Out of the box, xperf and wpr only work elevated, and they fail in two +# different ways for a standard user - because two different things are missing: +# +# xperf -on base -> "NT Kernel Logger: Access is denied. (0x5)" +# wpr -start GeneralProfile +# -> "Failed to enable the policy to profile system +# performance." (0xc5585011) +# +# 1. Creating or controlling ANY event tracing session - even a user-mode one +# naming a single provider - is checked against the security descriptor ETW +# keeps per provider GUID under +# HKLM\SYSTEM\CurrentControlSet\Control\WMI\Security. The default grants the +# session-control rights (TRACELOG_CREATE_ONDISK, TRACELOG_CREATE_REALTIME, +# TRACELOG_GUID_ENABLE, TRACELOG_LOG_EVENT) to SYSTEM, Administrators, the +# service accounts, and BUILTIN\Performance Log Users - and to nobody else. +# That group is the supported hook; its own description says members "may +# ... enable trace providers, and collect event traces". +# +# 2. Switching on the kernel/system trace provider on top of that needs the +# SeSystemProfilePrivilege user right ("Profile system performance"), held by +# default only by Administrators and NT SERVICE\WdiServiceHost. That is the +# one wpr names in its error, and the one xperf trips over for -on base. +# +# So grant the privilege to the GROUP and then put the account in the group: +# membership alone becomes the switch, and enabling the next account is one +# `net localgroup` away with no policy edit. +# +# Deliberately NOT granted: SeDebugPrivilege. xperf needs it for neither CPU +# sampling nor walking stacks in your own processes, and it is equivalent to +# handing out administrator. +# +# THIS ONLY HELPS A NON-ADMIN ACCOUNT. Both a privilege and a group membership +# are baked into the access token at LOGON, and UAC hands an administrator a +# filtered token that keeps just five harmless privileges - so an admin's +# ordinary shell still cannot trace, however the policy reads. Running as a +# standard user is what makes this work. +# +# For the same reason nothing here takes effect in an already-open session: the +# account has to sign out and back in. Any NEW logon does it - an ssh login into +# this box is one, which is the quick way to check without dropping the desktop. +# +# Analysis never needed any of this: wpa.exe opens an existing .etl as a plain +# user. This step is only about collection. +# --------------------------------------------------------------------------- +Write-Step 'ETW collection rights (non-elevated xperf / wpr)' +$PerfLogUsersSid = 'S-1-5-32-559' # BUILTIN\Performance Log Users +try { + # --- The user right, granted to the group --- + $existing = Get-AccountRight $PerfLogUsersSid + if ($existing -contains 'SeSystemProfilePrivilege') { + Write-Host ' OK: Performance Log Users already holds SeSystemProfilePrivilege' + } else { + Grant-AccountRight $PerfLogUsersSid 'SeSystemProfilePrivilege' + Write-Host ' Granted SeSystemProfilePrivilege ("Profile system performance") to Performance Log Users' + } + + # --- The membership --- + # Fall back to the console user when the caller did not name one: with + # over-the-shoulder elevation that is the person who started + # setup-windows.bat, which is who wants to trace. + $target = $TraceUser + if (-not $target) { + $target = (Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).UserName + if ($target) { Write-Host " No -TraceUser given; using the console user $target" } + } + + if (-not $target) { + Write-Warning 'No account to add to Performance Log Users (pass -TraceUser DOMAIN\user).' + Write-Warning 'The user right is in place, so this is the only step left:' + Write-Warning ' net localgroup "Performance Log Users" DOMAIN\user /add' + } else { + # Resolve to a SID first: it validates the name, and it is what the + # membership check compares, so a member spelled ".\claude" in one place + # and "LATISLAB\claude" in another is still recognised as the same account. + $targetSid = (New-Object System.Security.Principal.NTAccount($target)).Translate( + [System.Security.Principal.SecurityIdentifier]) + + # By SID, never by name: "Performance Log Users" is localised, and + # Get-LocalGroup -SID is how this stays correct on a non-English box. + $group = Get-LocalGroup -SID $PerfLogUsersSid + + # Get-LocalGroupMember throws on a group holding a SID that no longer + # resolves (a known Windows 10 bug), so a failure to READ the membership + # must not stop us from writing it - fall through and let the add report. + $already = $false + try { + $already = @(Get-LocalGroupMember -SID $PerfLogUsersSid | + Where-Object { $_.SID.Value -eq $targetSid.Value }).Count -gt 0 + } catch { + Write-Host " (could not enumerate $($group.Name) members: $($_.Exception.Message))" -ForegroundColor DarkGray + } + + if ($already) { + Write-Host " OK: $target is already in $($group.Name)" + } else { + try { + Add-LocalGroupMember -SID $PerfLogUsersSid -Member $targetSid.Value + } catch { + # "already a member" is only reachable when the enumeration above + # failed, and is not an error. Matched on the type NAME rather + # than in a typed catch clause: catch types are resolved when the + # script is PARSED, before the LocalAccounts module has been + # autoloaded, so naming the type there is a parse error that + # would take the whole script down. + if ($_.Exception.GetType().Name -ne 'MemberExistsException') { throw } + } + Write-Host " Added $target to $($group.Name)" + } + + Write-Host '' + Write-Host " $target must sign out and back in before this takes effect." -ForegroundColor Yellow + Write-Host ' Then, from that account (NOT elevated):' -ForegroundColor Yellow + Write-Host ' whoami /priv | findstr SeSystemProfilePrivilege' -ForegroundColor Yellow + Write-Host ' xperf -on base ; xperf -stop C:\Temp\trace.etl' -ForegroundColor Yellow + } +} catch { + Write-Warning "ETW rights setup failed: $($_.Exception.Message)" + Write-Warning 'Grant them by hand: secpol.msc > Local Policies > User Rights Assignment >' + Write-Warning '"Profile system performance" > add Performance Log Users, then' + Write-Warning ' net localgroup "Performance Log Users" /add' +} + # --------------------------------------------------------------------------- Write-Host "`nAll done." -ForegroundColor Green Write-Host 'If a reboot was flagged above, restart before opening VS or building drivers.'