Cross-platform PowerShell remoting in action

Dr Scripto

December 29th, 2017

Summary: Learn how to configure and use cross-platform PowerShell remoting in PowerShell Core.

I’m Christoph Bergmeister, a London-based full stack .NET software developer with a passion for DevOps. To enable product testing of .NET Core apps across non-Windows machines in CI, I was one of the first people to use the new cross-platform remoting capabilities based on an integration of OpenSSH with PowerShell Core. In the early days, setup was very tedious, and as part of my journey I had to experiment a lot to get everything working together nicely. Today, I want to share with you some of what I learned.

Introduction

Some of you might have heard that the next generation of PowerShell is cross-platform. Currently it is known as PowerShell Core, has a version number of 6, and is available as a Beta on GitHub. At the moment, it offers a subset of cmdlet coverage compared to Windows PowerShell. But Microsoft has shifted their development effort towards PowerShell Core, and therefore at least the engine is already a superset. As part of making it cross-platform, the goal is also to allow remoting from any operating system to any operating system, by using a similar syntax and experience of using PSSessions and the Invoke-Command cmdlet. I have used this to create a cross-platform CI testing system. It executes PowerShell deployment scripts in an agnostic way, against remote machines that can be either Windows or Linux. I will showcase what is needed to wire everything up. Disclaimer: Although I learned quite a bit about OpenSSH and how it works, I am no expert, and all I will show you is how to configure it such that it works. I welcome comments on my setup procedure.

Configure PowerShell remoting

This example shows how to configure remoting from a Windows client to a Linux host, which is the most common scenario. The setup is similar in other configurations of Windows/Linux as a client/host.

Apart from installing PowerShell Core on the client and host machine, we also need to install OpenSSH on both machines. OpenSSH on Linux can be installed on Ubuntu/Debian machines as ‘sudo apt-get install openssh-server openssh-client’ or on RHEL/CentOS/Fedora using ‘yum -y install openssh-server openssh-client’. On Windows, the PowerShell team has created a port named Win32-OpenSSH, which is in pre-release state as well. See the detailed instructions here or here. (For example, you can use Chocolatey, although Chocolatey is a third-party tool, not officially supported by Microsoft.) When I did it the first time, I followed the whole manual process to understand the components better. But if you just want to install everything that you probably need, the following chocolatey command should do:

choco install -y openssh -params '"/SSHServerFeature /SSHAgentFeature"'

Now we still need to configure OpenSSH on the client and host side, by using RSA key based authentication.

Edit the file ‘sshd_config’ as an Administrator in the OpenSSH installation folder (which is something like ‘C:\Program Files\OpenSSH-Win64’). Uncomment the following lines (by removing the hash character):

RSAAuthentication yes

PubkeyAuthentication yes

PasswordAuthentication yes

Also add PowerShell Core as a subsystem in sshd_config by adding the following line (you can get the path to your PowerShell Core executable by using Resolve-Path “$($env:ProgramFiles)\PowerShell\*\*.exe”):

Subsystem powershell C:\Program Files\PowerShell\6.0.0-beta.9\pwsh.exe -sshs -NoLogo -NoProfile

Then, restart the sshd process (that is, the ssh daemon):

Restart-Service sshd

Now we need to generate a pair of RSA keys, as follows:

ssh-keygen -t rsa -f ReplaceThisWithYourDesiredRsaKeyFileName

This generates you 2 files: one with the ending ‘.pub’, and one without. The former is the public key that you will need to distribute, and the latter is the private key.

On the remote Linux machine, you need to configure OpenSSH as well. Edit the config file /etc/ssh/sshd_config, and, similar to the above, enable the three authentication methods (PasswordAuthentication, RSAAuthentication, and PubkeyAuthentication). Adding the subsystem has a slightly different syntax:

Subsystem powershell /usr/bin/pwsh -sshs -NoLogo -NoProfile

Then append the content of the public key that you generated before to the .ssh/authorized_keys file, and optionally create a folder and set the correct permissions. The following lines take care of everything, and all you need to do is insert the path to your public key file.

mkdir -p .ssh

chmod 700 .ssh

cat PathToPublicKeyOfSpecificWindowsMachineToAllowPasswordLessRemoting.pub >>

.ssh/authorized_keys

chmod 640 .ssh/authorized_keys

sudo service sshd restart

Now open PowerShell Core, and let’s test remoting the first time by using the new parameter set of ‘Invoke-Command’ for OpenSSH remoting:

Invoke-Command -ScriptBlock { “Hello from $hostname)” } -UserName $remoteMachineLogonUserName -HostName $IpAddressOfRemoteMachine -KeyFilePath $PathToPrivateRsaKeyFile

The first time you run this command, you will be prompted to confirm that you trust the connection. Choose ‘yes’, and this will add the connection to the known_hosts file. Should your remoting client get locked down after the first configuration, you can make it add a new machine to the known_hosts file via the command line, by using:

ssh -o StrictHostKeyChecking=no username@hostname

You will have noticed that you also needed to specify the full path to the private RSA key, which is a bit annoying. We can get rid of that parameter, however, by using:

ssh-add.exe $PathToPrivateRsaKeyFile

One important note is that this command and the RSA key file generation command have to be executed as the user who will execute the PowerShell remoting commands. That is, if you want your co-workers or the build agent account to be able to use PowerShell OpenSSH remoting, you need to configure the public and private keys both on the client and host side for every user.

If you want to set up remoting in other configurations of Windows/Linux as client/host, the process is very similar. There is a lot of documentation already out there, especially on the Linux side.

Wrap up OpenSSH remoting in Windows PowerShell

Now that we solved the remoting problem, let’s write a wrapper so that we can use PowerShell Core from Windows PowerShell, which will run on the build agent. The first problem to be solved is hopping into PowerShell Core from a Windows PowerShell task on the Windows build agent:

<#

.Synopsis

Looks for the latest pre-release installation of PS6, starts it as a new process and passes the scriptblock to be executed.

.DESCRIPTION

The returned result is an output string because it is a different process. Note that you can only pass in the string value of variables but not the objects themselves.

In order to have in the passed in scriptblock, use [scriptblock]::Create("Write-Output $stringVariablefromouterScope; `$variableToBeDefinedHere = 'myvalue'; Write-Host `$variableToBeDefinedHere")

.EXAMPLE

Invoke-CommandInNewPowerShell6Process ([scriptblock]::Create("Write-Output $stringVariablefromouterScope; `$variableToBeDefinedHere = 'myvalue'; Write-Host `$variableToBeDefinedHere"))

#>

Function Invoke-CommandInNewPowerShell6Process

{

[CmdletBinding()]

Param

(

[Parameter(Mandatory=$true)]

[scriptblock]$ScriptBlock,

[Parameter(Mandatory=$false)]

$WorkingDirectory

)

$powerShell6 = Resolve-path "$env:ProgramFiles\PowerShell\*\*.exe" | Sort-Object -Descending | Select-Object -First 1 -ExpandProperty Path

$psi = New-object System.Diagnostics.ProcessStartInfo

$psi.CreateNoWindow = $true

$psi.UseShellExecute = $false

$psi.RedirectStandardOutput = $true

$psi.RedirectStandardError = $true

$psi.FileName = $powerShell6

$psi.WorkingDirectory = $WorkingDirectory

# To pass double quotes correctly when using ProcessStartInfo, one needs to replace double quotes with 3 double quotes". See: https://msdn.microsoft.com/en-us/library/system.diagnostics.processstartinfo.arguments(v=vs.110).aspx

$ScriptBlock = [scriptblock]::Create($ScriptBlock.ToString().Replace("`"", "`"`"`""))

if ($powerShell6.contains('6.0.0-alpha'))

{

$psi.Arguments = $ScriptBlock

}

else

{

$psi.Arguments = "-noprofile -command & {$ScriptBlock}"

}

$process = New-Object System.Diagnostics.Process

$process.StartInfo = $psi

Write-Verbose "Invoking PowerShell 6 $powerShell6 with scriptblock $ScriptBlock"

# Creating string builders to store stdout and stderr.

$stdOutBuilder = New-Object -TypeName System.Text.StringBuilder

$stdErrBuilder = New-Object -TypeName System.Text.StringBuilder

# Adding event handers for stdout and stderr.

$eventHandler = {

if (! [String]::IsNullOrEmpty($EventArgs.Data)) {

$Event.MessageData.AppendLine($EventArgs.Data)

}

}

$stdOutEvent = Register-ObjectEvent -InputObject $process `

-Action $eventHandler -EventName 'OutputDataReceived' `

-MessageData $stdOutBuilder

$stdErrEvent = Register-ObjectEvent -InputObject $process `

-Action $eventHandler -EventName 'ErrorDataReceived' `

-MessageData $stdErrBuilder

[void]$process.Start()

# begin reading stdout and stderr asynchronously to avoid deadlocks: https://msdn.microsoft.com/en-us/library/system.diagnostics.process.standardoutput%28v=vs.110%29.aspx?f=255&MSPPError=-2147217396

$process.BeginOutputReadLine()

$process.BeginErrorReadLine()

$process.WaitForExit()

Unregister-Event -SourceIdentifier $stdOutEvent.Name

Unregister-Event -SourceIdentifier $stdErrEvent.Name

$stdOutput = $stdOutBuilder.ToString().TrimEnd("`r", "`n"); # remove last newline in case only one string/line gets returned

$stdError = $stdErrBuilder.ToString()

Write-Verbose "StandardOutput:"

Write-Output $stdOutput

If (![string]::IsNullOrWhiteSpace($stdError))

{

# 'Continue' is the default error preference

If ($ErrorActionPreference -ne [System.Management.Automation.ActionPreference]::Continue)

{

Write-Output "StandardError (suppressed due to ActionPreference $ErrorActionPreference): $stdError"

}

else

{

Write-Error "StandardError: $stdError"

}

}

Write-Verbose "PowerShell 6 invocation finished"

}

The function above is complex because of using the ProcessStartInfo .NET class, to be able to retrieve stderr and stdout without deadlocks, and to pass double quotes correctly to it. I decided not to use Start-Process, because this cmdlet writes to disk for capturing stderr and stdout.

Execute platform-specific commands

Using PowerShell Core remoting, we can now start writing PowerShell code that can be executed from any platform on any other platform. You don’t even need to know what the remote platform is! It’s like Xamarin for PowerShell. However, sometimes you will want to do something very specific on a certain platform (for example, I decided to fall back use WinRM-based remoting for Windows hosts, but also needed to execute commands as ‘sudo’ on Linux). So, I first needed to figure out what type of platform the remote machine is, which I did by using TTL (TimeToLive) values. It might not be the ideal method, but it worked reliably for me and was fast to implement. It is based on the fact that Linux systems have TTL values around 64 ms and Windows has TTL values around 128ms. It should work for most modern and commonly used operating systems, but I am sure there are special cases where it does not. So just experiment to see what works for you.

Enum OS

{

Linux = 1

Windows = 2

}

Function Get-OperatingSystemOfRemoteMachine

{

[CmdletBinding()]

Param

(

$remoteHost

)

[int]$responseTimeToLive = Test-Connection $remoteHost -Count 1 | Select-Object -ExpandProperty ResponseTimeToLive

$os = [System.math]::Round($responseTimeToLive/64) # TTL values are not 100% accurate -> round to get a range of +/-32

if($os -eq 1) #Linux (TTL should be around 64

{

return [OS]::Linux

}

elseif($os -eq 2) #Windows (TTL should be around 128)

{

return [OS]::Windows

}

else

{

Throw "OS of remote machine $remoteHost could not be determined by TTL value. TTL value was: $responseTimeToLive"

}

}

Execute commands as sudo

Armed with this knowledge, we can now make platform-specific decisions, and, for example, build up our scriptblocks. But how can we execute sudo commands? PowerShell Core itself supports native Linux commands when executed locally, but executing commands by using sudo rights remotely is not fully baked yet (see the tracking issue). So, putting ‘sudo whoami’ in your ScriptBlock will give you an error. But I found a workaround, which is based on the fact that the sudo password can be piped into sudo using the -S option. Therefore, the following command works, executed remotely:

echo InsertSudoPasswordHere | sudo -S whoami

Yes, you need to be careful about security here, but depending on your use case, this might be OK.

Practical tips

Most of the remoting is based on scriptblocks. You can inject variables (as a string) into it by using the scriptblock constructor, but also take care to escape characters if you want to use variables:

[scriptblock]::Create(“Write-Host $variableNameThatIsDefinedOnTheClient”; `$meaningOfTheUniverseAndEverything = 40+2; Write-Host `$ meaningOfTheUniverseAndEverything”)

Should your code get more complex, then I suggest defining a PowerShell function that takes a PSSession as an argument. This is because you can also create a PSSession by using the new parameter set shown above. The idea is that all the scriptblock does is re-import the necessary modules, and then execute a top-level function that takes a PSSession:

$myscriptBlock = [scriptblock]::Create("Import-Module $FullPathToMyRequiredModule; Invoke-MyComand -PSSession `$session”)

$scriptBlockToCreateSession = [scriptblock]::Create("`$VerbosePreference = '$VerbosePreference'; `$session = New-PSSession -HostName $HostName -UserName $UserName")

$scriptBlockMain = [scriptblock]::Create("$scriptBlockToCreateSession; Invoke-Command -ScriptBlock { $ScriptBlock } -Session `$session;")

The above example also shows how to correctly propagate the $VerbosePreference, which Invoke-Command currently does not do (see this GitHub issue for tracking).

In our builds, we need to copy our deliverables to our system under test, but I did not want the deployment/installation scripts to be platform specific. I needed to solve problems such as finding a common path. I sniff the home directory, and then create the path on the remote machine:

$homeDirectoryOnRemoteMachine = Invoke-Command -Session $Session -ScriptBlock { (Get-Location).Path }

$destinationPathLocalToRemoteMachine = [System.IO.Path]::Combine($homeDirectoryOnRemoteMachine, $FolderNameOnRemoteMachine)

Conclusion

We have seen several useful pieces that you can wire together for your needs, which could be:

Setting up OpenSSH remoting, without passwords, to be able to use it for CI purposes, for example.

Calling PowerShell Core from Windows PowerShell. This could also be used for CI machines, for example, or for convenience to do cross-platform remoting from Windows PowerShell.

Determining the operating system type of a remote machine to decide whether an OpenSSH or WinRM PSSession should be created. I have used this to write an Invoke-CommandCrossPlatform cmdlet that also wraps the complex logic of concatenating various scriptblocks.

Overcoming current limitations of OpenSSH remoting, to execute remote commands as sudo.

If you have any questions, suggestions, or want to share your experience, comment below, or feel free to contact me.

Christoph Bergmeister, guest blogger