New-AzJitSession.ps1
<#PSScriptInfo .VERSION 1.0.0 .GUID d6cd8d8f-d221-4990-b816-4a9f271629ce .AUTHOR James O'Neill .COMPANYNAME Mobula Consulting Ltd .COPYRIGHT (C) James O'Neill 2019 .TAGS .LICENSEURI https://opensource.org/licenses/MS-PL .PROJECTURI .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES .PRIVATEDATA #> #Requires -Module Az.Security #Requires -Module Az.Compute <# .Synopsis Enables an Azure just-in-time connect for remote powerShell, RDP or SSH and connects to it .DESCRIPTION Creates Just in time network connections for Azure VMs. The command accepts a VM name (with optional resource group name) or a VM Object, If the VM isn't started, the command will start it and wait for start up to complete. It finds the JIT policy for the machine and address presented by the local machine after any NAT, and requests a connection for a given port, or for the default ssh, RDP or Win-RM port. Optionally the duration of the connection can be specified, or the command can be told to reuse and existing request. It waits until the port appears to be open, and then connects an RDP, PowerShell remoting session; if ssh is specified PowerShell remoting, or a remote shell or a remote command can be run over the ssh connection. For remote PowerShell sessions it is possible to enter the session or invoke a command in the session .Example $WindowsVM | .\New-AzJitSession.ps1 -RDP $windowsVM contains an object which represents a an Azure VM. It is piped into the command which will start the VM if necessary, request a JIT connection for RDP and start the terminal serives client to give a remote desktop session to the VM. .Example $WindowsVM | New-AzJitSession.ps1 -Credential $Cred Two existing variables contain a Windows Virtual machine and a credential for it. The command accepts the VM via the pipeline and the Credential as a parameter, and creates a new PSSession over winrm. It will start the VM if necessary, makes a new JIT network session, create and return a PSSession object. .Example New-AzJitSession -ResourceGroupName MyRG -VMName MyWindowsVM -Credential $Cred -EnterSession Instead of using a pre-existing VM object the command is given the names of a resource group and vm; instead of returning the session object, this time the command enters the the PSSession. .Example New-AzJitSession.ps1 -VM $vm -Credential $Cred -Reuse -EnterSession In this version the command tries to re use an existing JIT session if one is available. This version also enters the session and passes the VM as a parameter. .Example New-AzJitSession.ps1 -EnterSession $vm $Cred -Reuse This is the same command as the prvious one, but "-VM" and "-Credential" will be assumed. .Example $vm | New-AzJitSession.ps1 -Credential $Cred -Reuse -command whoami Instead of entering the PS session this command runs a command in the session - the command can be a script block. .Example New-AzJitSession -RG MyRG -VM MyWindowsVM -rdp In this version of the command the names of the parameters have been shortened and a terminal services session is made .Example New-AzJitSession -VMName MyLinuxvm -SSHName james -KeyFilePath ~\wibble This time the vm is a Linux one, and we specify a username and an ssh keyfile to connect to an SSH session .Example New-AzJitSession -Reuse -id ~\wibble -SSHName "james@linux1" -command "pwsh -nologo" This time a a command is run in the ssh session, we start powerShell. .Example New-AzJitSession -SSHName "james@linux1" -KeyFilePath C:\Users\mcp\wibble -EnterSession -Reuse On versions of PowerShell which support remoting over ssh, we can specify -PsSession or -EnterSession with the ssh parameters to either create and return a remote powershell session, or create and enter session #> [cmdletbinding(DefaultParameterSetName='WinRM')] Param ( #Virtual machine to connect to, either as a name or a VM object. [parameter(ParameterSetName='WinRM',Position=0, ValueFromPipeline=$true,Mandatory=$true)] [parameter(ParameterSetName='RDP' ,Position=0, ValueFromPipeline=$true,Mandatory=$true)] [parameter(ParameterSetName='ssh' ,Position=0, ValueFromPipeline=$true,Mandatory=$false)] [Alias('VMName')] $VM, #Credential object to logon to a WinRM Session [parameter(ParameterSetName='WinRM',Position=1)] [PSCredential]$Credential , #If VM is a string specifies its resource group [Alias('RGName')] [string]$ResourceGroupName, #How long should the requested connection run for, in minutes. This cannot be more than 3 hours (180 mins) [ValidateRange(1,180)] [int]$Minutes = 179, #Port to connect to - defaults to 5985 for WIN-RM or to 22 if and -SSHName is specified or to 3389 for -RDP. [int]$Port , #If specified will look for an existing JIT session to use. [switch]$Reuse, #The name of the PsSession configuration for a WIN RM session, "Microsoft.PowerShell" by default. [parameter(ParameterSetName='WinRM')] [string]$ConfigurationName = 'microsoft.powershell', #If specified with an SSH name will establish a remote powerShell session over ssh. [parameter(ParameterSetName='WinRM')] [parameter(ParameterSetName='ssh')] [switch]$PsSession, #If specified enters the PSSession on connect, otherwise only returns the session object. [parameter(ParameterSetName='WinRM')] [parameter(ParameterSetName='ssh')] [switch]$EnterSession, #If specfied makes an RDP (terminal services) connection. [parameter(ParameterSetName='RDP')] [Alias('TS','TermServ')] [switch]$RDP, #If specfied makes an ssh connection as SSHName@VM. If the vm is specified this can just be a name, or it can be name@host. [parameter(ParameterSetName='ssh',Mandatory=$true)] [String]$SSHName, #Specifies an SSH identity file, [parameter(ParameterSetName='ssh')] [Alias('IDFile')] [String]$KeyFilePath, #Unless specified an RDP host will be added to registry, or an SSH host's key will be added to Known_hosts (if is not present already). [parameter(ParameterSetName='ssh')] [switch]$NoAddToKnownHosts, #ssh command (string) or remote powershell command (string or script block). [parameter(ParameterSetName='WinRM')] [parameter(ParameterSetName='ssh')] $Command , #If Sepecified SSH will run in the same window [parameter(ParameterSetName='ssh')] [switch]$NoNewWindow ) if ($SSHName -and ($PsSession -or $EnterSession) -and $PSVersionTable.PSVersion.Major -le 5) { Write-Warning -Message "PowerShell sessions over SSH are only supported on PS 6 and later" return } #region get a running VM and its JIT policy #if we have no VM and the sshName is user@host, put host into $vm and user into $sshName if (-not $vm -and $sshName -match "^(.+)@(.+)$") { $vm = $Matches[2] $SSHName = $Matches[1] } #We prefer a VM object, but if we got a VM name turn it into a VM object. if ($VM -is [string] -and -not $ResourceGroupName) { Write-Progress -Activity "Finding VM $VM" $VM = Get-AzVM -Name $VM -ErrorAction SilentlyContinue Write-Progress -Activity "Finding VM $VM." -Completed } elseif ($VM -is [string]) { Write-Progress -Activity "Finding VM $VM" $VM = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VM -ErrorAction SilentlyContinue Write-Progress -Activity "Finding VM $VM" -Completed } #If we have a VM find the JIT policy which applies to it, and make sure it is running. Bail out if we don't have VM or JIT policy. if ($VM -isnot [Microsoft.Azure.Commands.Compute.Models.PSVirtualMachine]) { Write-Warning -Message "Could not get a VM from the parameters provided" return } Write-Progress -Activity "Connecting to VM $($VM.name)" -CurrentOperation "Finding JIT Policy for VM" $JitPolicy = Get-AzJitNetworkAccessPolicy | Where-Object {$_.virtualMachines.id -contains $VM.id} if (-not $JitPolicy) { Write-Warning -Message "Could not get the JIT access policy for VM '$($VM.Name)'." Write-Progress -Activity "Connecting to VM $($VM.name)" -Completed return } else { Write-Verbose -Message "JIT Policy is '$($JitPolicy.Name)'."} #we would save a little time if we checked the JIT policy while the VM was starting but if we can't find the policy we'd have a VM starting... Write-Progress -Activity "Connecting to VM $($VM.name)" -CurrentOperation "Checking VM is running" if ( -not (($VM | Get-AzVM -Status).Statuses.DisplayStatus -match "Running|starting|Updating")) { $job = $VM | Start-AzVM -AsJob } #endregion #region figure out what the JIT request should look like #Setup the request , we need a port, an end time and an address to match. if (-not $Port -and $SSHName) {$Port = 22} elseif (-not $Port -and $RDP) {$Port = 3389} elseif (-not $Port) {$Port = 5985} $requestPort = @{ number = $Port endTimeUtc = [datetime]::UtcNow.AddMinutes($Minutes).tostring("O") allowedSourceAddressPrefix = @("*") #this will become our (NAT'ed) address - if we can find it } #We don't know what IP address to request access for: local IP is probably NATed so try sites which send back the real IP (post NAT) . Write-Progress -Activity "Connecting to VM $($VM.name)" -CurrentOperation "Checking NAT'ed address for this computer" foreach ($site in @("http://icanhazip.com", "http://whatismyip.akamai.com/", "http://www.whatismyip.org/")) { $response = (Invoke-WebRequest -Uri $site -Verbose:$false).Content #if response has an IP address, use that IP and break out without checking the other sites. if ($response -match "\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}") { $requestPort.allowedSourceAddressPrefix = @($Matches[0]) break; } } Write-Verbose -Message "Connecting to $($vm.name) from IP Address $($requestPort.allowedSourceAddressPrefix)" #endregion #region make sure the vm is running and get the IP address which we will want to connect to Write-Progress -Activity "Connecting to VM $($VM.name)" -CurrentOperation "Checking VM Status." $vmStatus = ($VM | Get-AzVM -Status).Statuses.DisplayStatus while ($vmStatus -match "starting|Updating") { Write-Progress -Activity "Connecting to VM $($VM.name)" -CurrentOperation "Waiting for VM to start." #don't loop too tightly. Start-Sleep -Seconds 1 $vmStatus = ($VM | Get-AzVM -Status).Statuses.DisplayStatus } if ( -not ($vmStatus -match "Running")) { Write-Warning -Message "Failed to start VM $($VM.Name)." Write-Progress -Activity "Connecting to VM $($VM.name)" -Completed return } #Get the public IP for the VM Write-Progress -Activity "Connecting to VM $($VM.name)" -CurrentOperation "Refreshing VM Network Data" $VM = $VM | Get-AzVM $vmIPConfigID = (($VM.NetworkProfile.NetworkInterfaces).id | Get-AzNetworkInterface).ipconfigurations.id $vmPublicIp = Get-AzPublicIpAddress -ResourceGroupName $VM.ResourceGroupName | Where-Object {$vmIPConfigID -contains $_.IpConfiguration.id} | Select-Object -ExpandProperty ipaddress -First 1 if (-not $vmPublicIp) { Write-Warning -Message "Could not get Public IP address for VM $($VM.Name)" Write-Progress -Activity "Connecting to VM $($VM.name)" -Completed return } else { Write-Verbose -Message "Public IP address for $($VM.Name) is $vmPublicIp" } #endregion #region if resuse is specified check we have a usable JIT request, if not - make one. if ($ReUse -and $JitPolicy.Requests.virtualmachines.where({$_.id -eq $vm.Id}).ports.where({ $_.number -eq $requestPort.number -and $_.status -eq "Initiated" -and $_.endtimeutc -gt [datetime]::UtcNow -and $_.AllowedSourceAddressPrefix -eq $requestPort.AllowedSourceAddressPrefix }) ) { Write-Verbose -Message "JIT Request already exists" Write-Progress -Activity "Connecting to VM $($VM.name)" -Completed } else { #finally... request the connection Write-Progress -Activity "Connecting to VM $($VM.name)" -CurrentOperation "Requesting Just-in-time Network access" $JitRequest = Start-AzJitNetworkAccessPolicy -ResourceId $JitPolicy.Id -VirtualMachine @(@{id=$VM.Id ; ports=$requestPort}) if (-not ($JitRequest.VirtualMachines.id -contains $vm.id) ) { Write-Warning -Message "JIT Request Failed" Write-Progress -Activity "Connecting to VM $($VM.name)" -Completed return } Write-Verbose -Message ("JIT network access enabled for {3}, from {0:t} to {1:t} local, under account {2}." -f $JitRequest.StartTimeUtc.ToLocalTime(),$JitRequest.StartTimeUtc.ToLocalTime().addminutes($minutes),$JitRequest.requestor,$VM.Name) Write-Progress -Activity "Connecting to VM $($VM.name)" -Completed # Test-NetConnection does its own progress #It can take a minute for the JIT request to allow access make multiple attempts to test the connection. $attempts = 0 $Result = $null while (-not $Result -and $attempts -lt 4 ) { try { $result = Test-NetConnection -ComputerName $vmPublicIp -Port $port -WarningAction SilentlyContinue -InformationLevel Quiet } catch { } finally { $attempts ++ switch ($attempts) { 1 {Write-Warning -message "There may be a delay while the JIT request takes effect." } 4 {} Default {Write-Warning -Message "Retry # $attempts" } } } } } #endregion #region with the jit request in place, connect over it $session = $null #make a rdp connection, a ssh one (in its own window), a remote powershell one over SSH or a remote powershell one over winrm. if ($rdp) { if (-not ($NoAddToKnownHosts -or (Get-ItemProperty -Path 'HKCU:\Software\Microsoft\Terminal Server Client\LocalDevices' -Name $vmPublicIp -ErrorAction SilentlyContinue))) { Write-Verbose -Message "Addding $vmPublicIp as a known server for Terminal Services" $null = New-ItemProperty -Path 'HKCU:\Software\Microsoft\Terminal Server Client\LocalDevices' -PropertyType dword -Name $vmPublicIp -Value 77 } mstsc.exe "/v:$vmPublicIp`:$port" } elseif ($SSHName) { #if the VM IP address is not in known hosts (unless NoAddToKnownHosts is specified) find the key and add it. if (-not $NoAddToKnownHosts) { $knownKey = Get-Content -Path "$env:USERPROFILE\.ssh\known_hosts" | Where-Object {$_ -match $vmPublicIp} if ($knownKey) { Write-Verbose -Message "$vmPublicIp is in known_hosts." } else { Write-Progress -Activity "Adding $vmPublicIp to known_hosts." #open ssh's ssh-keyscan writes info to standard error... redirect it to standard out and filter it out. ssh-keyscan.exe -t ecdsa-sha2-nistp256 $vmPublicIp 2>&1 | Where-Object {$_ -is [string]} | Out-File -Append -FilePath "$env:USERPROFILE\.ssh\known_hosts" -Encoding ascii Write-Progress -Activity "Adding $vmPublicIp to known_hosts." } } #Handle PSSessions over SSH if ($PsSession -or $EnterSession) { $sessionParams = @{HostName=$vmPublicIp ; UserName = $SSHName} if ($KeyFilePath) {$sessionParams['KeyFilePath'] = $KeyFilePath} $session = New-PSSession @sessionParams if (-not $session) { Write-Warning -Message "Could not get an SSH remote session to $vmPublicIp`:$port for $($VM.Name) " } } else { $ArgumentList = @("$SSHName`@$vmPublicIp") if ($KeyFilePath) {$ArgumentList = @( "-i",$KeyFilePath) + $ArgumentList } if ($Command) {$ArgumentList += $Command } #ssh isn't totally happy starting in all PowerShell hosts, launch in its own window, Unless specified to the contrary if ($NoNewWindow) {Invoke-Command -ScriptBlock {ssh.exe $args } -ArgumentList $ArgumentList} else {Start-Process -FilePath "ssh.exe" -ArgumentList $ArgumentList } return } } else { if (-not $Credential) { $Credential = (Get-Credential -Message 'Enter Credentials for remote machine') } $session = New-PSSession -ComputerName $vmPublicIp -Credential $Credential -Port $Port -ErrorAction Stop if (-not $session) { Write-Warning -Message "Could not get a WinRM remote session to $vmPublicIp`:$port for $($VM.Name) " } } if ($session) { if ($EnterSession) { Write-Verbose -Message "Created Session id $($session.Id) '$($session.Name)'" Enter-PSSession -Session $session } elseif($Command) { Write-Verbose -Message "Created Session id $($session.Id) '$($session.Name)'" $sb = [scriptblock]::Create($command) Invoke-Command -Session $session -ScriptBlock $sb } else {$session} } #endregion |