Inside a powershell script, I'm running a command which starts a new powershell as admin (if I'm not and if needed, depending on $arg
) and then runs the script.
I'm trying to redirect stdout and stderr to the first terminal.
Not trying to make things easier, there are arguments too.
param([string]$arg="help")
if($arg -eq "start" -Or $arg -eq "stop")
{
if(![bool](([System.Security.Principal.WindowsIdentity]::GetCurrent()).groups -match "S-1-5-32-544"))
{
Start-Process powershell -Verb runas -ArgumentList " -file servicemssql.ps1 $arg"
exit
}
}
$Services = "MSSQLSERVER", "SQLSERVERAGENT", "MSSQLServerOLAPService", "SSASTELEMETRY", "SQLBrowser", `
"SQLTELEMETRY", "MSSQLLaunchpad", "SQLWriter", "MSSQLFDLauncher"
function startsql {
"starting SQL services"
Foreach ($s in $Services) {
"starting $s"
Start-Service -Name "$s"
}
}
function stopsql {
"stopping SQL services"
Foreach ($s in $Services) {
"stopping $s"
Stop-Service -Force -Name "$s"
}
}
function statussql {
"getting SQL services status"
Foreach ($s in $Services) {
Get-Service -Name "$s"
}
}
function help {
"usage: StartMssql [status|start|stop]"
}
Switch ($arg) {
"start" { startsql }
"stop" { stopsql }
"status" { statussql }
"help" { help }
"h" { help }
}
Using the following answers on SO doesn't work:
How to deal with the double quote inside double quote while preserving the variable ($arg
) expansion ?
PowerShell's Start-Process
cmdlet:
-RedirectStandardOut
and -RedirectStandardError
parameters,-Verb Runas
, the argument required to start a process elevated (with administrative privileges).This constraint is also reflected in the underlying .NET API, where setting the .UseShellExecute
property on a System.Diagnostics.ProcessStartInfo
instance to true
- the prerequisite for being able to use .Verb = "RunAs"
in order to run elevated - means that you cannot use the .RedirectStandardOutput
and .RedirectStandardError
properties.
Overall, this suggests that you cannot directly capture an elevated process' output streams from a non-elevated process - at least not with built-in .NET APIs.
A pure PowerShell workaround is not trivial; it involves using redirections from inside the elevated process instead, which in turn requires calling via a shell (which is already the case here):
param([string] $arg='help')
if ($arg -in 'start', 'stop') {
if (-not (([System.Security.Principal.WindowsPrincipal] [System.Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrators'))) {
# Invoke the script via -Command rather than -File, so that
# a redirection can be specified.
$passThruArgs = '-command', "& \`"$PSScriptRoot\servicemssql.ps1\`" $arg *>\`"$PSScriptRoot\out.txt\`""
Start-Process powershell -Wait -Verb RunAs -ArgumentList $passThruArgs
# Retrieve the captured output streams here:
Get-Content "$PSScriptRoot\out.txt"
exit
}
}
# ...
Instead of -File
, -Command
is used to invoke the script, because that allows appending a redirection to the command: *>
redirects all output streams.
@soleil suggests using Tee-Object
as an alternative so that the output produced by the elevated process is not only captured, but also printed to the (invariably new window's) console as it is being produced:
"... $arg | Tee-Object -FilePath \`"$PSScriptRoot\out.txt\`""
NOTE: Arguments are parsed differently by the -File
and -Command
CLI parameters; in a nutshell, with -File
, the arguments following the script name are treated as literals, whereas the arguments following -Command
form a command that is evaluated according to normal PowerShell rules in the target session, which has implications for escaping, notably of pass-through "
chars. as well as for trying to run script files with quoted paths and/or in the current directory - see this answer for details.
The $PSScriptRoot\
path component in both the *.ps1
script to invoke as well as the output-capture file ensures that the files are read from / created in the same folder in which the calling script resides (elevated processes default to $env:SystemRoot\System32
as the working dir.) If you want to use the caller's current directory instead, use $PWD\
The above-mentioned differences between -Command
and -File
also necessitate:
using &
to invoke the *.ps1
file, since its path is now double-quoted for robustness.
using \"
(escaped as \`"
inside "..."
) to ensure that the "
characters are passed through as part of the PowerShell command to execute.
-Wait
ensures that control doesn't return until the elevated process has exited, at which point file $PSScriptRoot\out.txt
can be examined.
As for the follow-up question:
To go even further, could we have a way to have the admin shell running non visible, and read the file as we go with the Unix equivalent of
tail -f
from the non -privileged shell ?
It is possible to run the elevated process itself invisibly, but note that you'll still get the UAC confirmation prompt. (If you were to turn UAC off (not recommended), you could use Start-Process -NoNewWindow
to run the process in the same window.)
To also monitor output as it is being produced, tail -f
-style, a PowerShell-only solution is both nontrivial and not the most efficient; to wit:
param([string]$arg='help')
if ($arg -in 'start', 'stop') {
if (-not (([System.Security.Principal.WindowsPrincipal] [System.Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrators'))) {
# Delete any old capture file.
$captureFile = "$PSScriptRoot\out.txt"
Remove-Item -ErrorAction Ignore $captureFile
# Start the elevated process *hidden and asynchronously*, passing
# a [System.Diagnostics.Process] instance representing the new process out, which can be used
# to monitor the process
$passThruArgs = '-command', "& \`"$PSScriptRoot\servicemssql.ps1\`" $arg *>\`"$captureFile\`""
$ps = Start-Process powershell -WindowStyle Hidden -PassThru -Verb RunAs -ArgumentList $passThruArgs
# Wait for the capture file to appear, so we can start
# "tailing" it.
While (-not $ps.HasExited -and -not (Test-Path -LiteralPath $captureFile)) {
Start-Sleep -Milliseconds 100
}
# Start an aux. background that removes the capture file when the elevated
# process exits. This will make Get-Content -Wait below stop waiting.
$jb = Start-Job {
# Wait for the process to exit.
# Note: $using:ps cannot be used directly, because, due to
# serialization/deserialization, it is not a live object.
$ps = (Get-Process -Id $using:ps.Id)
while (-not $ps.HasExited) { Start-Sleep -Milliseconds 100 }
# Get-Content -Wait only checks once every second, so we must make
# sure that it has seen the latest content before we delete the file.
Start-Sleep -Milliseconds 1100
# Delete the file, which will make Get-Content -Wait exit (with an error).
Remove-Item -LiteralPath $using:captureFile
}
# Output the content of $captureFile and wait for new content to appear
# (-Wait), similar to tail -f.
# `-OutVariable capturedLines` collects all output in
# variable $capturedLines for later inspection.
Get-Content -ErrorAction SilentlyContinue -Wait -OutVariable capturedLines -LiteralPath $captureFile
Remove-Job -Force $jb # Remove the aux. job
Write-Verbose -Verbose "$($capturedLines.Count) line(s) captured."
exit
}
}
# ...