如何提升PowerShell,同时保留当前工作目录并维护传递给脚本的所有参数? [英] How can I elevate Powershell while keeping the current working directory AND maintain all parameters passed to the script?
问题描述
function Test-IsAdministrator
{
$Identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$Principal = New-Object System.Security.Principal.WindowsPrincipal($Identity)
$Principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Test-IsUacEnabled
{
(Get-ItemProperty HKLM:SoftwareMicrosoftWindowsCurrentVersionPoliciesSystem).EnableLua -ne 0
}
if (!(Test-IsAdministrator))
{
if (Test-IsUacEnabled)
{
[string[]]$argList = @('-NoProfile', '-NoExit', '-File', $MyInvocation.MyCommand.Path)
$argList += $MyInvocation.BoundParameters.GetEnumerator() | ForEach-Object {"-$($_.Key)", "$($_.Value)"}
$argList += $MyInvocation.UnboundArguments
Start-Process PowerShell.exe -Verb Runas -WorkingDirectory $pwd -ArgumentList $argList
return
}
else
{
throw "You must be an administrator to run this script."
}
}
如果我运行上面的脚本,它会成功地生成另一个具有提升权限的PowerShell实例,但当前工作目录会丢失,并自动设置为C:WindowsSystem32
。绑定参数也会丢失或分析不正确。
在阅读了类似的问题后,我了解到在使用带有谓词运行方式的启动进程时,只有当目标可执行文件是.NET可执行文件时,才会使用-WorkingDirectory参数。出于某种原因,PowerShell 5不支持它:
截至撰写本文时,问题存在于PowerShell幕后使用的.NET API级别(请参阅System.Diagnotics.ProcessStartInfo)(.NET 6.0.0-preview.4.21253.7)。
在实践中--文档没有提到--如果您以管理权限启动一个进程(使用管理权限,这是动词RunAs-有点晦涩地--所做的事情),则不会遵循-WorkingDirectory参数:位置默认为$env:SYSTEMROOTsystem 32(通常为C:WindowsSystem32)。
因此,我见过的最常见的解决方案涉及使用-Command而不是-File。即:
Start-Process -FilePath powershell.exe -Verb Runas -ArgumentList '-Command', 'cd C:ws; & .script.ps1'
这看起来真的很像黑客,但很有效。唯一的问题是,我无法设法获得一个可以同时将绑定和未绑定参数传递给通过命令调用的脚本的实现。
我正在尽我最大的努力寻找最健壮的自我提升实现,这样我就可以很好地将其包装到函数中(并最终包装到我正在处理的模块中),如Request-AdminRights
,然后可以在需要管理员权限和/或升级的新脚本中立即干净地调用它。在每个需要管理员权限的脚本的开头粘贴相同的自动提升代码感觉很草率。
我还担心我可能考虑过多了,只将提升保留到脚本级别,而不是将其包装到函数中。
我们非常感谢您的任何意见。
推荐答案
注意:2021年11月15日修复了以下代码中的错误,以便使其与高级脚本一起正常工作-有关详细信息,请参阅this answer。
最接近支持的健壮、跨平台的自提升脚本解决方案:
- 位置参数(未命名)和命名参数
- 同时在PowerShell的序列化约束内保持类型保真度(请参见this answer)
- 保留调用者的工作目录。
- 仅在类Unix平台上:带退出代码报告的同步、同窗口执行(通过标准的
sudo
实用程序)。
是下面的怪物(我当然希望这能更容易些):
- 注意:
为了(相对)简洁,我省略了您的
的测试Test-IsUacEnabled
测试,并简化了当前会话是否已提升到[bool] (net.exe session 2>$null)
您可以将
# --- BEGIN: Helper function for self-elevation.
和# --- END: Helper function for self-elevation.
之间的所有内容放到任何脚本中以使其自我提升。- 如果您发现自己反复需要自我提升,在不同的脚本中,您可以将代码复制到您的
$PROFILE
file中,或者-更适合于更广泛的分发-将下面使用的动态(在内存中)模块转换为您的脚本可以(自动)加载的常规持久化模块。由于Ensure-Elevated
函数可通过自动加载模块使用,因此您只需在给定脚本中调用Ensure-Elevated
,不带参数(或使用-Verbose
获取详细输出)。
- 如果您发现自己反复需要自我提升,在不同的脚本中,您可以将代码复制到您的
# Sample script parameter declarations.
# Note: Since there is no [CmdletBinding()] attribute and no [Parameter()] attributes,
# the script also accepts *unbound* arguments.
param(
[object] $First,
[int] $Second,
[array] $Third
)
# --- BEGIN: Helper function for self-elevation.
# Define a dynamic (in-memory) module that exports a single function, Ensure-Elevated.
# Note:
# * In real life you would put this function in a regular, persisted module.
# * Technically, 'Ensure' is not an approved verb, but it seems like the best fit.
$null = New-Module -Name "SelfElevation_$PID" -ScriptBlock {
function Ensure-Elevated {
[CmdletBinding()]
param()
$isWin = $env:OS -eq 'Windows_NT'
# Simply return, if already elevated.
if (($isWin -and (net.exe session 2>$null)) -or (-not $isWin -and 0 -eq (id -u))) {
Write-Verbose "(Now) running as $(("superuser", "admin")[$isWin])."
return
}
# Get the relevant variable values from the calling script's scope.
$scriptPath = $PSCmdlet.GetVariableValue('PSCommandPath')
$scriptBoundParameters = $PSCmdlet.GetVariableValue('PSBoundParameters')
$scriptArgs = $PSCmdlet.GetVariableValue('args')
Write-Verbose ("This script, `"$scriptPath`", requires " + ("superuser privileges, ", "admin privileges, ")[$isWin] + ("re-invoking with sudo...", "re-invoking in a new window with elevation...")[$isWin])
# Note:
# * On Windows, the script invariably runs in a *new window*, and by design we let it run asynchronously, in a stay-open session.
# * On Unix, sudo runs in the *same window, synchronously*, and we return to the calling shell when the script exits.
# * -inputFormat xml -outputFormat xml are NOT used:
# * The use of -encodedArguments *implies* CLIXML serialization of the arguments; -inputFormat xml presumably only relates to *stdin* input.
# * On Unix, the CLIXML output created by -ouputFormat xml is not recognized by the calling PowerShell instance and passed through as text.
# * On Windows, the elevated session's working dir. is set to the same as the caller's (happens by default on Unix, and also in PS Core on Windows - but not in *WinPS*)
# Determine the full path of the PowerShell executable running this session.
# Note: The (obsolescent) ISE doesn't support the same CLI parameters as powershell.exe, so we use the latter.
$psExe = (Get-Process -Id $PID).Path -replace '_ise(?=.exe$)'
if (0 -ne ($scriptBoundParameters.Count + $scriptArgs.Count)) {
# ARGUMENTS WERE PASSED, so the CLI must be called with -encodedCommand and -encodedArguments, for robustness.
# !! To work around a bug in the deserialization of [switch] instances, replace them with Boolean values.
foreach ($key in @($scriptBoundParameters.Keys)) {
if (($val = $scriptBoundParameters[$key]) -is [switch]) { $null = $scriptBoundParameters.Remove($key); $null = $scriptBoundParameters.Add($key, $val.IsPresent) }
}
# Note: If the enclosing script is non-advanced, *both*
# $scriptBoundParameters and $scriptArgs may be present.
# !! Be sure to pass @() when $args is $null (advanced script), otherwise a scalar $null will be passed on reinvocation.
# Use the same serialization depth as the remoting infrastructure (1).
$serializedArgs = [System.Management.Automation.PSSerializer]::Serialize(($scriptBoundParameters, (@(), $scriptArgs)[$null -ne $scriptArgs]), 1)
# The command that receives the (deserialized) arguments.
# Note: Since the new window running the elevated session must remain open, we do *not* append `exit $LASTEXITCODE`, unlike on Unix.
$cmd = 'param($bound, $positional) Set-Location "{0}"; & "{1}" @bound @positional' -f (Get-Location -PSProvider FileSystem).ProviderPath, $scriptPath
if ($isWin) {
Start-Process -Verb RunAs $psExe ('-noexit -encodedCommand {0} -encodedArguments {1}' -f [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd)), [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serializedArgs)))
}
else {
sudo $psExe -encodedCommand ([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))) -encodedArguments ([Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($serializedArgs)))
}
}
else {
# NO ARGUMENTS were passed - simple reinvocation of the script with -c (-Command) is sufficient.
# Note: While -f (-File) would normally be sufficient, it leaves $args undefined, which could cause the calling script to break.
# Also, on WinPS we must set the working dir.
if ($isWin) {
Start-Process -Verb RunAs $psExe ('-noexit -c Set-Location "{0}"; & "{1}"' -f (Get-Location -PSProvider FileSystem).ProviderPath, $scriptPath)
}
else {
# Note: On Unix, the working directory is always automatically inherited.
sudo $psExe -c "& `"$scriptPath`"; exit $LASTEXITCODE"
}
}
# EXIT after reinvocation, passing the exit code through, if possible:
# On Windows, since Start-Process was invoked asynchronously, all we can report is whether *it* failed on invocation.
exit ($LASTEXITCODE, (1, 0)[$?])[$isWin]
}
}
# --- END: Helper function for self-elevation.
"Current location: $($PWD.ProviderPath)"
# Call the self-elevation helper function:
# * If this session is already elevated, the call is a no-op and execution continues,
# in the current console window.
# * Otherwise, the function exits the script and re-invokes it with elevation,
# passing all arguments through and preserving the working directory.
# * On Windows:
# * UAC will prompt for confirmation / adming credentials every time.
# * Of technical necessity, the elevated session runs in a *new* console window,
# asynchronously, and the window running the elevated session remains open.
# Note: The new window is a regular *console window*, irrespective of the
# environment you're calling from (including Windows Terminal, VSCode,
# or the (obsolescent) ISE).
# * Due to running asynchronously in a new window, the calling session won't know
# the elevated script call's exit code.
# * On Unix:
# * The `sudo` utility used for elevation will prompt for a password,
# and by default remember it for 5 minutes for repeat invocations.
# * The elevated script runs in the *current* window, *synchronously*,
# and $LASTEXITCODE reflects the elevated script's exit code.
# That is, the elevated script runs and returns control to the non-elevated caller.
# Note that $LASTEXITCODE is only meaningful if the elevated script
# sets its intentionally, via `exit $n`.
# Omit -Verbose to suppress verbose output.
Ensure-Elevated -Verbose
# For illustration:
# Print the arguments received in diagnostic form.
Write-Verbose -Verbose '== Arguments received:'
[PSCustomObject] @{
PSBoundParameters = $PSBoundParameters.GetEnumerator() | Select-Object Key, Value, @{ n='Type'; e={ $_.Value.GetType().Name } } | Out-String
# Only applies to non-advanced scripts
Args = $args | ForEach-Object { [pscustomobject] @{ Value = $_; Type = $_.GetType().Name } } | Out-String
CurrentLocation = $PWD.ProviderPath
} | Format-List
示例调用:
如果您将上述代码保存到文件script.ps1
并按如下方式调用它:
./script.ps1 -First (get-date) -Third ('foo', 'bar') -Second 42 @{ unbound=1 } 'last unbound'
您将看到以下内容:
在非提升会话中,触发UAC/
sudo
密码提示(Windows示例):Current location: C:Usersjdoesample VERBOSE: This script, "C:Usersjdoesamplescript.ps1", requires admin privileges, re-invoking in a new window with elevation...
在提升的会话中(在Unix上暂时在同一窗口中运行):
VERBOSE: (Now) running as admin. VERBOSE: == Arguments received: PSBoundParameters : Key Value Type --- ----- ---- First 10/30/2021 12:30:08 PM DateTime Third {foo, bar} Object[] Second 42 Int32 Args : Value Type ----- ---- {unbound} Hashtable last unbound String CurrentLocation : C:Usersjdoesample
这篇关于如何提升PowerShell,同时保留当前工作目录并维护传递给脚本的所有参数?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!