From 5f46605a2176eac2836d4c6d1119f66c7e4e4255 Mon Sep 17 00:00:00 2001 From: Paul Higinbotham Date: Wed, 11 Mar 2020 14:45:14 -0700 Subject: [PATCH] Add Ubuntu SSH remoting tests CI (#12033) * Add SSH remoting CI * Fix typo * Add install git to Ubuntu CI * Update .vsts-ci/sshremoting-tests.yml Co-Authored-By: Aditya Patwardhan * Fix install git 1 * Add missing tools module import * Change ubuntu service restart * Update ssh install * fix module path * fix module path * change module import * Add tracing * Add service start retry * Fix service restart * Fix options restore * Fix Restore-PSOptions path * Fix Pester test output * fix typo * Fix test output path * Debug 1 * Debug 2 * Debug 3 * Change results path * Fix result publish to use build artifacts directory * Add more New-PSSession tests * Remove User test * Remove env:USER * Add API tests * Fix type for Subsytem API test * Update .vsts-ci/sshremoting-tests.yml Co-Authored-By: Travis Plunk * Update .vsts-ci/sshremoting-tests.yml Co-Authored-By: Travis Plunk * Update .vsts-ci/sshremoting-tests.yml Co-Authored-By: Travis Plunk * Apply suggestions from code review Co-authored-by: Aditya Patwardhan Co-authored-by: Travis Plunk --- .vsts-ci/sshremoting-tests.yml | 86 +++ build.psm1 | 7 +- test/SSHRemoting/SSHRemoting.Basic.Tests.ps1 | 187 +++++++ .../HelpersRemoting/HelpersRemoting.psm1 | 57 +- .../Microsoft.PowerShell.RemotingTools.psd1 | 50 ++ .../Microsoft.PowerShell.RemotingTools.psm1 | 508 ++++++++++++++++++ 6 files changed, 891 insertions(+), 4 deletions(-) create mode 100644 .vsts-ci/sshremoting-tests.yml create mode 100644 test/SSHRemoting/SSHRemoting.Basic.Tests.ps1 create mode 100644 test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psd1 create mode 100644 test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psm1 diff --git a/.vsts-ci/sshremoting-tests.yml b/.vsts-ci/sshremoting-tests.yml new file mode 100644 index 000000000..016f3bfdd --- /dev/null +++ b/.vsts-ci/sshremoting-tests.yml @@ -0,0 +1,86 @@ +name: PR-$(System.PullRequest.PullRequestNumber)-$(Date:yyyyMMdd)$(Rev:.rr) +trigger: + # Batch merge builds together while a merge build is running + batch: true + branches: + include: + - master + - release* + - feature* + paths: + include: + - '/src/System.Management.Automation/engine/*' + - '/test/SSHRemoting/*' +pr: + branches: + include: + - master + - release* + - feature* + paths: + include: + - '/src/System.Management.Automation/engine/*' + - '/test/SSHRemoting/*' + +variables: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + POWERSHELL_TELEMETRY_OPTOUT: 1 + # Avoid expensive initialization of dotnet cli, see: https://donovanbrown.com/post/Stop-wasting-time-during-NET-Core-builds + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + __SuppressAnsiEscapeSequences: 1 + +resources: +- repo: self + clean: true +jobs: +- job: SSHRemotingTests + container: mcr.microsoft.com/powershell/test-deps:ubuntu-18.04 + displayName: SSH Remoting Tests + + steps: + - pwsh: | + Get-ChildItem -Path env: + displayName: Capture Environment + condition: succeededOrFailed() + + - pwsh: Write-Host "##vso[build.updatebuildnumber]$env:BUILD_SOURCEBRANCHNAME-$env:BUILD_SOURCEVERSION-$((get-date).ToString("yyyyMMddhhmmss"))" + displayName: Set Build Name for Non-PR + condition: ne(variables['Build.Reason'], 'PullRequest') + + - template: /tools/releaseBuild/azureDevOps/templates/insert-nuget-config-azfeed.yml + + - pwsh: | + sudo apt-get update + sudo apt-get install -y git + displayName: Install Github + condition: succeeded() + + - pwsh: | + Import-Module .\tools\ci.psm1 + Invoke-CIInstall -SkipUser + displayName: Bootstrap + condition: succeededOrFailed() + + - pwsh: | + Import-Module .\tools\ci.psm1 + Invoke-CIBuild + displayName: Build + condition: succeeded() + + - pwsh: | + Import-Module .\tools\ci.psm1 + Restore-PSOptions + $options = (Get-PSOptions) + Import-Module .\test\tools\Modules\HelpersRemoting + Install-SSHRemoting -PowerShellFilePath $options.Output + displayName: Install SSH Remoting + condition: succeeded() + + - pwsh: | + Import-Module .\tools\ci.psm1 + Restore-PSOptions + $options = (Get-PSOptions) + Import-Module .\build.psm1 + Start-PSPester -Path test/SSHRemoting -powershell $options.Output -OutputFile "$PWD/sshTestResults.xml" + displayName: Test + condition: succeeded() diff --git a/build.psm1 b/build.psm1 index b8b10de0c..482edfcdc 100644 --- a/build.psm1 +++ b/build.psm1 @@ -1364,7 +1364,12 @@ function Publish-TestResults if($env:TF_BUILD) { $fileName = Split-Path -Leaf -Path $Path - $tempFilePath = Join-Path ([system.io.path]::GetTempPath()) -ChildPath $fileName + $tempPath = $env:BUILD_ARTIFACTSTAGINGDIRECTORY + if (! $tempPath) + { + $tempPath = [system.io.path]::GetTempPath() + } + $tempFilePath = Join-Path -Path $tempPath -ChildPath $fileName # NUnit allowed values are: Passed, Failed, Inconclusive or Ignored (the spec says Skipped but it doesn' work with Azure DevOps) # https://github.com/nunit/docs/wiki/Test-Result-XML-Format diff --git a/test/SSHRemoting/SSHRemoting.Basic.Tests.ps1 b/test/SSHRemoting/SSHRemoting.Basic.Tests.ps1 new file mode 100644 index 000000000..9e8bd379e --- /dev/null +++ b/test/SSHRemoting/SSHRemoting.Basic.Tests.ps1 @@ -0,0 +1,187 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +Describe "SSHRemoting Basic Tests" -tags CI { + + # SSH remoting is set up to automatically authenticate current user via SSH keys + # All tests connect back to localhost machine + + function VerifySession { + param ( + [System.Management.Automation.Runspaces.PSSession] $session + ) + + $session.State | Should -BeExactly 'Opened' + $session.ComputerName | Should -BeExactly 'localhost' + $session.Transport | Should -BeExactly 'SSH' + Invoke-Command -Session $session -ScriptBlock { whoami } | Should -BeExactly $(whoami) + $psRemoteVersion = Invoke-Command -Session $session -ScriptBlock { $PSSenderInfo.ApplicationArguments.PSVersionTable.PSVersion } + $psRemoteVersion.Major | Should -BeExactly $PSVersionTable.PSVersion.Major + $psRemoteVersion.Minor | Should -BeExactly $PSVersionTable.PSVersion.Minor + } + + Context "New-PSSession Tests" { + + AfterEach { + if ($script:session -ne $null) { Remove-PSSession -session $script:session } + if ($script:sessions -ne $null) { Remove-PSSession -session $script:sessions } + } + + It "Verifies new connection with implicit current User" { + $script:session = New-PSSession -HostName localhost -ErrorVariable err + $err | Should -HaveCount 0 + VerifySession $script:session + } + + It "Verifies new connection with explicit User parameter" { + $script:session = New-PSSession -HostName localhost -UserName (whoami) -ErrorVariable err + $err | Should -HaveCount 0 + VerifySession $script:session + } + + It "Verifies explicit Name parameter" { + $sessionName = 'TestSessionNameA' + $script:session = New-PSSession -HostName localhost -Name $sessionName -ErrorVariable err + $err | Should -HaveCount 0 + VerifySession $script:session + $script:session.Name | Should -BeExactly $sessionName + } + + It "Verifies explicit Port parameter" { + $portNum = 22 + $script:session = New-PSSession -HostName localhost -Port $portNum -ErrorVariable err + $err | Should -HaveCount 0 + VerifySession $script:session + } + + It "Verifies explicit Subsystem parameter" { + $portNum = 22 + $subSystem = 'powershell' + $script:session = New-PSSession -HostName localhost -Port $portNum -SubSystem $subSystem -ErrorVariable err + $err | Should -HaveCount 0 + VerifySession $script:session + } + + It "Verifies explicit KeyFilePath parameter" { + $keyFilePath = "$HOME/.ssh/id_rsa" + $portNum = 22 + $subSystem = 'powershell' + $script:session = New-PSSession -HostName localhost -Port $portNum -SubSystem $subSystem -KeyFilePath $keyFilePath -ErrorVariable err + $err | Should -HaveCount 0 + VerifySession $script:session + } + + It "Verifies SSHConnection hash table parameters" { + $sshConnection = @( + @{ + HostName = 'localhost' + UserName = whoami + Port = 22 + KeyFilePath = "$HOME/.ssh/id_rsa" + Subsystem = 'powershell' + }, + @{ + HostName = 'localhost' + KeyFilePath = "$HOME/.ssh/id_rsa" + Subsystem = 'powershell' + }) + $script:sessions = New-PSSession -SSHConnection $sshConnection -Name 'Connection1','Connection2' -ErrorVariable err + $err | Should -HaveCount 0 + $script:sessions | Should -HaveCount 2 + $script:sessions[0].Name | Should -BeLike 'Connection*' + $script:sessions[1].Name | Should -BeLike 'Connection*' + VerifySession $script:sessions[0] + VerifySession $script:sessions[1] + } + } + + function VerifyRunspace { + param ( + [runspace] $rs + ) + + $rs.RunspaceStateInfo.State | Should -BeExactly 'Opened' + $rs.RunspaceAvailability | Should -BeExactly 'Available' + $rs.RunspaceIsRemote | Should -BeTrue + $ps = [powershell]::Create() + try + { + $ps.Runspace = $rs + $psRemoteVersion = $ps.AddScript('$PSSenderInfo.ApplicationArguments.PSVersionTable.PSVersion').Invoke() + $psRemoteVersion.Major | Should -BeExactly $PSVersionTable.PSVersion.Major + $psRemoteVersion.Minor | Should -BeExactly $PSVersionTable.PSVersion.Minor + + $ps.Commands.Clear() + $ps.AddScript('whoami').Invoke() | Should -BeExactly $(whoami) + } + finally + { + $ps.Dispose() + } + } + + Context "SSH Remoting API Tests" { + + AfterEach { + if ($script:rs -ne $null) { $script:rs.Dispose() } + } + + $testCases = @( + @{ + testName = 'Verifies connection with implicit user' + UserName = $null + ComputerName = 'localhost' + KeyFilePath = $null + Port = 0 + Subsystem = $null + }, + @{ + testName = 'Verifies connection with UserName' + UserName = whoami + ComputerName = 'localhost' + KeyFilePath = $null + Port = 0 + Subsystem = $null + }, + @{ + testName = 'Verifies connection with KeyFilePath' + UserName = whoami + ComputerName = 'localhost' + KeyFilePath = "$HOME/.ssh/id_rsa" + Port = 0 + Subsystem = $null + }, + @{ + testName = 'Verifies connection with Port specified' + UserName = whoami + ComputerName = 'localhost' + KeyFilePath = "$HOME/.ssh/id_rsa" + Port = 22 + Subsystem = $null + }, + @{ + testName = 'Verifies connection with Subsystem specified' + UserName = whoami + ComputerName = 'localhost' + KeyFilePath = "$HOME/.ssh/id_rsa" + Port = 22 + Subsystem = 'powershell' + } + ) + + It "" -TestCases $testCases { + param ( + $UserName, + $ComputerName, + $KeyFilePath, + $Port, + $SubSystem + ) + + $ci = [System.Management.Automation.Runspaces.SSHConnectionInfo]::new($UserName, $ComputerName, $KeyFilePath, $Port, $Subsystem) + $script:rs = [runspacefactory]::CreateRunspace($host, $ci) + $script:rs.Open() + VerifyRunspace $script:rs + } + } +} diff --git a/test/tools/Modules/HelpersRemoting/HelpersRemoting.psm1 b/test/tools/Modules/HelpersRemoting/HelpersRemoting.psm1 index b056819f8..11f4af410 100644 --- a/test/tools/Modules/HelpersRemoting/HelpersRemoting.psm1 +++ b/test/tools/Modules/HelpersRemoting/HelpersRemoting.psm1 @@ -466,6 +466,26 @@ function Install-SSHRemotingOnWindows } } +function WriteVerboseSSHDStatus +{ + param ( + [string] $Msg = 'SSHD service status' + ) + + $sshdStatus = sudo service ssh status + Write-Verbose -Verbose "${Msg}: $sshdStatus" +} + +function DumpTextFile +{ + param ( + [string] $FilePath = '/etc/ssh/sshd_config' + ) + + $content = Get-Content -Path $FilePath -Raw + Write-Verbose -Verbose $content +} + function Install-SSHRemotingOnLinux { param ( @@ -478,7 +498,11 @@ function Install-SSHRemotingOnLinux { Write-Verbose -Verbose "Installing openssh-server ..." sudo apt-get install --yes openssh-server - sudo systemctl restart ssh + + Write-Verbose -Verbose "Restarting sshd service after install ..." + WriteVerboseSSHDStatus "SSHD service status before restart" + sudo service ssh restart + WriteVerboseSSHDStatus "SSHD service status after restart" } if (! (Test-Path -Path /etc/ssh/sshd_config)) { @@ -519,19 +543,40 @@ function Install-SSHRemotingOnLinux Write-Verbose -Verbose "Updating known_hosts ..." ssh-keyscan -H localhost | Set-Content -Path "$HOME/.ssh/known_hosts" -Force + <# # Install Microsoft.PowerShell.RemotingTools module. if ($null -eq (Get-Module -Name Microsoft.PowerShell.RemotingTools -ListAvailable)) { Write-Verbose -Verbose "Installing Microsoft.PowerShell.RemotingTools ..." Install-Module -Name Microsoft.PowerShell.RemotingTools -Force -SkipPublisherCheck } + #> # Add PowerShell endpoint to SSHD. Write-Verbose -Verbose "Running Enable-SSHRemoting ..." - sudo pwsh -c 'Enable-SSHRemoting -SSHDConfigFilePath /etc/ssh/sshd_config -PowerShellFilePath $PowerShellPath -Force' + Write-Verbose -Verbose "PSScriptRoot: $PSScriptRoot" + $modulePath = "${PSScriptRoot}\..\Microsoft.PowerShell.RemotingTools\Microsoft.PowerShell.RemotingTools.psd1" + $cmdLine = "Import-Module ${modulePath}; Enable-SSHRemoting -SSHDConfigFilePath /etc/ssh/sshd_config -PowerShellFilePath $PowerShellPath -Force" + Write-Verbose -Verbose "CmdLine: $cmdLine" + sudo pwsh -c $cmdLine + # Restart SSHD service for changes to take effect. + Start-Sleep -Seconds 1 + WriteVerboseSSHDStatus "SSHD service status before restart" Write-Verbose -Verbose "Restarting sshd ..." - sudo systemctl restart ssh + sudo service ssh restart + WriteVerboseSSHDStatus "SSHD service status after restart" + + # Try starting again if needed. + $status = sudo service ssh status + $result = $status | Where-Object { ($_ -like '*not running*') -or ($_ -like '*stopped*') } + if ($null -ne $result) + { + Start-Sleep -Seconds 1 + Write-Verbose -Verbose "Starting sshd again ..." + sudo service ssh start + WriteVerboseSSHDStatus "SSHD service status after second start attempt" + } # Test SSH remoting. Write-Verbose -Verbose "Testing SSH remote connection ..." @@ -542,6 +587,10 @@ function Install-SSHRemotingOnLinux { throw "Could not successfully create SSH remoting connection." } + else + { + Write-Verbose -Verbose "SUCCESS: SSH remote connection" + } } finally { @@ -569,6 +618,8 @@ function Install-SSHRemoting [string] $PowerShellFilePath ) + Write-Verbose -Verbose "Install-SSHRemoting called with PowerShell file path: $PowerShellFilePath" + if ($IsWindows) { if ([string]::IsNullOrEmpty($PowerShellFilePath)) { $PowerShellFilePath = "$PSHOME/pwsh.exe" } diff --git a/test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psd1 b/test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psd1 new file mode 100644 index 000000000..5a6ee4dba --- /dev/null +++ b/test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psd1 @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +@{ + +RootModule = './Microsoft.PowerShell.RemotingTools.psm1' + +ModuleVersion = '0.1.0' + +GUID = 'e11d52a1-d5a0-4e4d-92cd-e87114bf4a5c' + +Author = 'Microsoft Corporation' +CompanyName = 'Microsoft Corporation' +Copyright = '(c) Microsoft Corporation. All rights reserved.' + +Description = ' +This module contains remoting tool cmdlets. + +Enable-SSHRemoting cmdlet: +-------------------------- +PowerShell SSH remoting was implemented in PowerShell 6.0 but requries SSH (client) and SSHD (service) components +to be installed. In addition the sshd_config configuration file must be updated to define a PowerShell endpoint +as a subsystem. Once this is done PowerShell remoting cmdlets can be used to establish a PowerShell remoting +session over SSH that works across platforms. + +$session = New-PSSession -HostName LinuxComputer1 -UserName UserA -SSHTransport + +There are a number of requirements that must be satisfied for PowerShell SSH based remoting: + a. PowerShell 6.0 or greater must be installed on the system. + Since multiple PowerShell installations can appear on a single system, a specific installation can be selected. + b. SSH client must be installed on the system as PowerShell uses it for outgoing connections. + c. SSHD (ssh daemon) must be installed on the system for PowerShell to receive SSH connections. + d. SSHD must be configured with a Subsystem that serves as the PowerShell remoting endpoint. + +The Enable-SSHRemoting cmdlet will do the following: + a. Detect the underlying platform (Windows, Linux, macOS). + b. Detect an installed SSH client, and emit a warning if not found. + c. Detect an installed SSHD daemon, and emit a warning if not found. + d. Accept a PowerShell (pwsh) path to be run as a remoting PowerShell session endpoint. + Or try to use the currently running PowerShell. + e. Update the SSHD configuration file to add a PowerShell subsystem endpoint entry. + +If all of the conditions are satisfied then PowerShell SSH remoting will work to and from the local system. +' + +PowerShellVersion = '6.0' + +FunctionsToExport = 'Enable-SSHRemoting' + +} diff --git a/test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psm1 b/test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psm1 new file mode 100644 index 000000000..5d42ff861 --- /dev/null +++ b/test/tools/Modules/Microsoft.PowerShell.RemotingTools/Microsoft.PowerShell.RemotingTools.psm1 @@ -0,0 +1,508 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +## +## Enable-SSHRemoting Cmdlet +## + +class PlatformInfo +{ + [bool] $isCoreCLR + [bool] $isLinux + [bool] $isOSX + [bool] $isWindows + + [bool] $isAdmin + + [bool] $isUbuntu + [bool] $isUbuntu14 + [bool] $isUbuntu16 + [bool] $isCentOS + [bool] $isFedora + [bool] $isOpenSUSE + [bool] $isOpenSUSE13 + [bool] $isOpenSUSE42_1 + [bool] $isRedHatFamily +} + +function DetectPlatform +{ + param ( + [ValidateNotNull()] + [PlatformInfo] $PlatformInfo + ) + + try + { + $Runtime = [System.Runtime.InteropServices.RuntimeInformation] + $OSPlatform = [System.Runtime.InteropServices.OSPlatform] + + $platformInfo.isCoreCLR = $true + $platformInfo.isLinux = $Runtime::IsOSPlatform($OSPlatform::Linux) + $platformInfo.isOSX = $Runtime::IsOSPlatform($OSPlatform::OSX) + $platformInfo.isWindows = $Runtime::IsOSPlatform($OSPlatform::Windows) + } + catch + { + $platformInfo.isCoreCLR = $false + $platformInfo.isLinux = $false + $platformInfo.isOSX = $false + $platformInfo.isWindows = $true + } + + if ($platformInfo.isWindows) + { + $platformInfo.isAdmin = ([System.Security.Principal.WindowsPrincipal]::new([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole( ` + [System.Security.Principal.WindowsBuiltInRole]::Administrator) + } + + if ($platformInfo.isLinux) + { + $LinuxInfo = Get-Content /etc/os-release -Raw | ConvertFrom-StringData + + $platformInfo.isUbuntu = $LinuxInfo.ID -match 'ubuntu' + $platformInfo.isUbuntu14 = $platformInfo.isUbuntu -and ($LinuxInfo.VERSION_ID -match '14.04') + $platformInfo.isUbuntu16 = $platformInfo.isUbuntu -and ($LinuxInfo.VERSION_ID -match '16.04') + $platformInfo.isCentOS = ($LinuxInfo.ID -match 'centos') -and ($LinuxInfo.VERSION_ID -match '7') + $platformInfo.isFedora = ($LinuxInfo.ID -match 'fedora') -and ($LinuxInfo.VERSION_ID -ge '24') + $platformInfo.isOpenSUSE = $LinuxInfo.ID -match 'opensuse' + $platformInfo.isOpenSUSE13 = $platformInfo.isOpenSUSE -and ($LinuxInfo.VERSION_ID -match '13') + $platformInfo.isOpenSUSE42_1 = $platformInfo.isOpenSUSE -and ($LinuxInfo.VERSION_ID -match '42.1') + $platformInfo.isRedHatFamily = $platformInfo.isCentOS -or $platformInfo.isFedora -or $platformInfo.isOpenSUSE + } +} + +class SSHSubSystemEntry +{ + [string] $subSystemLine + [string] $subSystemName + [string] $subSystemCommand + [string[]] $subSystemCommandArgs +} + +class SSHRemotingConfig +{ + [PlatformInfo] $platformInfo + [SSHSubSystemEntry[]] $psSubSystemEntries = @() + [string] $configFilePath + $configComponents = @() + + SSHRemotingConfig( + [PlatformInfo] $platInfo, + [string] $configFilePath) + { + $this.platformInfo = $platInfo + $this.configFilePath = $configFilePath + $this.ParseSSHRemotingConfig() + } + + [string[]] SplitConfigLine([string] $line) + { + $line = $line.Trim() + $lineLength = $line.Length + $rtnStrArray = [System.Collections.Generic.List[string]]::new() + + for ($i=0; $i -lt $lineLength; ) + { + $startIndex = $i + while (($i -lt $lineLength) -and ($line[$i] -ne " ") -and ($line[$i] -ne "`t")) { $i++ } + $rtnStrArray.Add($line.Substring($startIndex, ($i - $startIndex))) + while (($i -lt $lineLength) -and ($line[$i] -eq " ") -or ($line[$i] -eq "`t")) { $i++ } + } + + return $rtnStrArray.ToArray() + } + + ParseSSHRemotingConfig() + { + [string[]] $contents = Get-Content -Path $this.configFilePath + foreach ($line in $contents) + { + $components = $this.SplitConfigLine($line) + $this.configComponents += @{ Line = $line; Components = $components } + + if (($components[0] -eq "Subsystem") -and ($components[1] -eq "powershell")) + { + $entry = [SSHSubSystemEntry]::New() + $entry.subSystemLine = $line + $entry.subSystemName = $components[1] + $entry.subSystemCommand = $components[2] + $entry.subSystemCommandArgs = @() + for ($i=3; $i -lt $components.Count; $i++) + { + $entry.subSystemCommandArgs += $components[$i] + } + + $this.psSubSystemEntries += $entry + } + } + } +} + +function UpdateConfiguration +{ + param ( + [SSHRemotingConfig] $config, + [string] $PowerShellPath + ) + + # + # Update and re-write config file with existing settings plus new PowerShell remoting settings + # + + # Subsystem + [System.Collections.Generic.List[string]] $newContents = [System.Collections.Generic.List[string]]::new() + $psSubSystemEntry = "Subsystem powershell {0} {1} {2} {3}" -f $powerShellPath, "-SSHS", "-NoProfile", "-NoLogo" + $subSystemAdded = $false + + foreach ($lineItem in $config.configComponents) + { + $line = $lineItem.Line + $components = $lineItem.Components + + if ($components[0] -eq "SubSystem") + { + if (! $subSystemAdded) + { + # Add new powershell subsystem entry + $newContents.Add($psSubSystemEntry) + $subSystemAdded = $true + } + + if ($components[1] -eq "powershell") + { + # Remove all existing powershell subsystem entries + continue + } + + # Include existing subsystem entries. + $newContents.Add($line) + } + else + { + # Include all other configuration lines + $newContents.Add($line) + } + } + + if (! $subSystemAdded) + { + $newContents.Add($psSubSystemEntry) + } + + # Copy existing file to a backup version + $uniqueName = [System.IO.Path]::GetFileNameWithoutExtension([System.IO.Path]::GetRandomFileName()) + $backupFilePath = $config.configFilePath + "_backup_" + $uniqueName + Copy-Item -Path $config.configFilePath -Destination $backupFilePath + if ($?) + { + WriteLine "A backup copy of the old sshd_config configuration file has been created at:" + WriteLine $backupFilePath + } + + Set-Content -Path $config.configFilePath -Value $newContents.ToArray() -ErrorAction Stop +} + +function CheckPowerShellVersion +{ + param ( + [string] $FilePath + ) + + if (! (Test-Path $FilePath)) + { + throw "CheckPowerShellVersion failed with invalid path: $FilePath" + } + + $commandToExec = "& '$FilePath' -noprofile -noninteractive -c '`$PSVersionTable.PSVersion.Major'" + $sb = [scriptblock]::Create($commandToExec) + + try + { + $psVersionMajor = [int] (& $sb) 2>$null + Write-Verbose "" + Write-Verbose "CheckPowerShellVersion: $psVersionMajor for FilePath: $FilePath" + } + catch + { + $psVersionMajor = 0 + } + + if ($psVersionMajor -ge 6) + { + return $true + } + else + { + return $false + } +} + +function WriteLine +{ + param ( + [string] $Message, + [int] $PrependLines = 0, + [int] $AppendLines = 0 + ) + + for ($i=0; $i -lt $PrependLines; $i++) + { + Write-Output "" + } + + Write-Output $Message + + for ($i=0; $i -lt $AppendLines; $i++) + { + Write-Output "" + } +} + +# Windows only GetShortPathName PInvoke +$typeDef = @' + using System; + using System.Runtime.InteropServices; + using System.Text; + + namespace NativeUtils + { + public class Path + { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern int GetShortPathName( + [MarshalAs(UnmanagedType.LPTStr)] + string path, + [MarshalAs(UnmanagedType.LPTStr)] + StringBuilder shortPath, + int shortPathLength); + + public static string ConvertToShortPath( + string longPath) + { + int shortPathLength = 2048; + StringBuilder shortPath = new StringBuilder(shortPathLength); + GetShortPathName( + path: longPath, + shortPath: shortPath, + shortPathLength: shortPathLength); + + return shortPath.ToString(); + } + } + } +'@ + +<# +.Synopsis + Enables PowerShell SSH remoting endpoint on local system +.Description + This cmdlet will set up an SSH based remoting endpoint on the local system, based on + the PowerShell executable file path passed in. Or if no PowerShell file path is provided then + the currently running PowerShell file path is used. + The end point is enabled by adding a 'powershell' subsystem entry to the SSHD configuration, using + the provided or current PowerShell file path. + Both the SSH client and SSHD server components are detected and if not found a terminating + error is emitted, asking the user to install the components. + Then the sshd_config is parsed, and if a new 'powershell' subsystem entry is added. +.Parameter SSHDConfigFilePath + File path to the SSHD service configuration file. This file will be updated to include a + 'powershell' subsystem entry to define a PowerShell SSH remoting endpoint, so current credentials + must have write access to the file. +.Parameter PowerShellFilePath + Specifies the file path to the PowerShell command used to host the SSH remoting PowerShell + endpoint. If no value is specified then the currently running PowerShell executable path is used + in the subsytem command. +.Parameter Force + When true, this cmdlet will update the sshd_config configuration file without prompting. +#> +function Enable-SSHRemoting +{ + [CmdletBinding()] + param ( + [string] $SSHDConfigFilePath, + + [string] $PowerShellFilePath, + + [switch] $Force + ) + + # Detect platform + $platformInfo = [PlatformInfo]::new() + DetectPlatform $platformInfo + Write-Verbose "Platform information" + Write-Verbose "$($platformInfo | Out-String)" + + # Non-Windows platforms must run this cmdlet as 'root' + if (!$platformInfo.isWindows) + { + $user = whoami + if ($user -ne 'root') + { + if (! $PSCmdlet.ShouldContinue("This cmdlet must be run as 'root'. If you continue, PowerShell will restart under 'root'. Do you wish to continue?", "Enable-SSHRemoting")) + { + return + } + + # Spawn new PowerShell with sudo and exit this session. + $modFilePath = (Get-Module -Name Microsoft.PowerShell.RemotingTools | Select-Object -Property Path).Path + $modName = [System.IO.Path]::GetFileNameWithoutExtension($modFilePath) + $modFilePath = Join-Path -Path (Split-Path -Path $modFilePath -Parent) -ChildPath "${modName}.psd1" + + $parameters = "" + foreach ($key in $PSBoundParameters.Keys) + { + $parameters += "-${key} " + $value = $PSBoundParameters[$key] + if ($value -is [string]) + { + $parameters += "'$value' " + } + } + + & sudo "$PSHOME/pwsh" -NoExit -c "Import-Module -Name $modFilePath; Enable-SSHRemoting $parameters" + exit + } + } + + # Detect SSH client installation + if (! (Get-Command -Name ssh -ErrorAction SilentlyContinue)) + { + Write-Warning "SSH client is not installed or not discoverable on this machine. SSH client must be installed before PowerShell SSH based remoting can be enabled." + } + + # Detect SSHD server installation + $SSHDFound = $false + if ($platformInfo.IsWindows) + { + $SSHDFound = $null -ne (Get-Service -Name sshd -ErrorAction SilentlyContinue) + } + elseif ($platformInfo.IsLinux) + { + $sshdStatus = sudo service ssh status + $SSHDFound = $null -ne $sshdStatus + } + else + { + # macOS + $SSHDFound = ($null -ne (launchctl list | Select-String 'com.openssh.sshd')) + } + if (! $SSHDFound) + { + Write-Warning "SSHD service is not found on this machine. SSHD service must be installed and running before PowerShell SSH based remoting can be enabled." + } + + # Validate a SSHD configuration file path + if ([string]::IsNullOrEmpty($SSHDConfigFilePath)) + { + Write-Warning "-SSHDConfigFilePath not provided. Using default configuration file location." + + if ($platformInfo.IsWindows) + { + $SSHDConfigFilePath = Join-Path -Path $env:ProgramData -ChildPath 'ssh' -AdditionalChildPath 'sshd_config' + } + elseif ($platformInfo.isLinux) + { + $SSHDConfigFilePath = '/etc/ssh/sshd_config' + } + else + { + # macOS + $SSHDConfigFilePath = '/private/etc/ssh/sshd_config' + } + } + + # Validate a PowerShell command to use for endpoint + $PowerShellToUse = $PowerShellFilePath + if (! [string]::IsNullOrEmpty($PowerShellToUse)) + { + WriteLine "Validating provided -PowerShellFilePath argument." -AppendLines 1 -PrependLines 1 + + if (! (Test-Path $PowerShellToUse)) + { + throw "The provided PowerShell file path is invalid: $PowerShellToUse" + } + + if (! (CheckPowerShellVersion $PowerShellToUse)) + { + throw "The provided PowerShell file path is an unsupported version of PowerShell. PowerShell version 6.0 or greater is required." + } + } + else + { + WriteLine "Validating current PowerShell to use as endpoint subsystem." -AppendLines 1 + + # Try currently running PowerShell + $PowerShellToUse = Get-Command -Name "$PSHome/pwsh" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + if (! $PowerShellToUse -or ! (CheckPowerShellVersion $PowerShellToUse)) + { + throw "Current running PowerShell version is not valid for SSH remoting endpoint. SSH remoting is only supported for PowerShell version 6.0 and higher. Specify a valid PowerShell 6.0+ file path with the -PowerShellFilePath parameter." + } + } + + # SSHD configuration file uses the space character as a delimiter. + # Consequently, the configuration Subsystem entry will not allow argument paths containing space characters. + # For Windows platforms, we can a short cut path. + # But for non-Windows platforms, we currently throw an error. + # One possible solution is to crete a symbolic link + # New-Item -ItemType SymbolicLink -Path -Value $ + if ($PowerShellToUse.Contains(' ')) + { + if ($platformInfo.IsWindows) + { + Add-Type -TypeDefinition $typeDef + $PowerShellToUse = [NativeUtils.Path]::ConvertToShortPath($PowerShellToUse) + if (! (Test-Path -Path $PowerShellToUse)) + { + throw "Converting long Windows file path resulted in an invalid path: ${PowerShellToUse}." + } + } + else + { + throw "The PowerShell executable (pwsh) selected for hosting the remoting endpoint has a file path containing space characters, which cannot be used with SSHD configuration." + } + } + + WriteLine "Using PowerShell at this path for SSH remoting endpoint:" + WriteLine "$PowerShellToUse" -AppendLines 1 + + # Validate the SSHD configuration file path + if (! (Test-Path -Path $SSHDConfigFilePath)) + { + throw "The provided SSHDConfigFilePath parameter, $SSHDConfigFilePath, is not a valid path." + } + WriteLine "Modifying SSHD configuration file at this location:" + WriteLine "$SSHDConfigFilePath" -AppendLines 1 + + # Get the SSHD configurtion + $sshdConfig = [SSHRemotingConfig]::new($platformInfo, $SSHDConfigFilePath) + + if ($sshdConfig.psSubSystemEntries.Count -gt 0) + { + WriteLine "The following PowerShell subsystems were found in the sshd_config file:" + foreach ($entry in $sshdConfig.psSubSystemEntries) + { + WriteLine $entry.subSystemLine + } + Writeline "Continuing will overwrite any existing PowerShell subsystem entries with the new subsystem." -PrependLines 1 + WriteLine "The new SSH remoting endpoint will use this PowerShell executable path:" + WriteLine "$PowerShellToUse" -AppendLines 1 + } + + $shouldContinue = $Force + if (! $shouldContinue) + { + $shouldContinue = $PSCmdlet.ShouldContinue("The SSHD service configuration file (sshd_config) will now be updated to enable PowerShell remoting over SSH. Do you wish to continue?", "Enable-SSHRemoting") + } + + if ($shouldContinue) + { + WriteLine "Updating configuration file ..." -PrependLines 1 -AppendLines 1 + + UpdateConfiguration $sshdConfig $PowerShellToUse + + WriteLine "The configuration file has been updated:" -PrependLines 1 + WriteLine $sshdConfig.configFilePath -AppendLines 1 + WriteLine "You must restart the SSHD service for the changes to take effect." -AppendLines 1 + } +}