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