powershellstart-process

redirect stdout, stderr from powershell script as admin through start-process


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 ?


Solution

  • PowerShell's Start-Process cmdlet:

    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
      }
    }
    
    # ...
    

    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
      }
    }
    
    # ...