New-AzJitSession.ps1


<#PSScriptInfo
 
.VERSION 1.0.1
 
.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 1.0.1 has Improvements to help. Code unchanged from 1.0.0
 
.PRIVATEDATA
 
#>


#Requires -Module Az.Security
#Requires -Module Az.Compute

<#
  .Synopsis
    Enables an Azure just-in-time connection 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 WinRM port.
    Optionally the duration of the connection can be specified, or the command can be told to reuse
    an existing request.
    It waits until the port appears to be open, and then connects an RDP or 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 an Azure VM. It is piped into the command which
    will start the VM if necessary, request a JIT connection for RDP and then start the Microsoft
    terminal services client (mstsc.exe) 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 a credential as a parameter,
    and creates a new PSSession over WinRM, authenticated using the credential.
    It will start the VM if necessary, before making a new JIT network session, and creating
    and returning 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 PSSession.
  .Example
    New-AzJitSession.ps1 -VM $vm -Credential $Cred -Reuse -EnterSession
    In this version the command tries to reuse an existing JIT session if one is available.
    This version also enters the remote PowerShell session and passes the VM as a parameter.
  .Example
    New-AzJitSession.ps1 -EnterSession $vm $Cred -Reuse
    This is the same command as the previous 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 -rdp MyWindowsVM -rg MyRgName
    In this version of the command is the shortest form to make terminal services session is made.
    Note that VMName is optional form for VM, but it will be assumed. ResourceGroupName has an alias
    of RGName which can be shortened to RG
  .Example
    New-AzJitSession -VMName MyLinuxvm -SSHName james -KeyFilePath ~\wibble
    This time the VM running linux and an ssh session is opened in a new window, by specifying a
    username and an ssh keyfile to authenticate.
  .Example
    New-AzJitSession -Reuse -id ~\wibble -SSHName "james@linux1" -command "pwsh -nologo"
    This time, a command is run in the ssh session (we start PowerShell with no logo).
  .Example
    . New-AzJitSession -SSHName "james@linux1" -KeyFilePath C:\Users\mcp\wibble -EnterSession -Reuse
    On PowerShell 6 and later which supports remoting over ssh, we can specify
    -PSSession or -EnterSession with the ssh parameters to create a remote powershell session,
    and either return the session object or enter the session. If -PSSession is combined with -command
    then a PowerShell command can be run in the session.
    If this version -Reuse tells the command to use an existing JIT Access request if one is available.
    By Dot-sourcing the command, its variables will be placed in the current scope, so after the command
    runs, $Session holds# the remote PowerShell session, $vm holds the VM object, $VMPublicIP holds its
    ip address, $JITPolicy holds the policy.
#>
 
[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 the VMs Resource Group - only required if a name is used in more than 1 RG
    [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 WinRM or to 22 if -SSHName is specified or to 3389 for -RDP.
    [int]$Port  , 
    #If specified, will look for an existing JIT session to use; if not a new request will be made.
    [switch]$Reuse,
    #The name of the PSSession configuration for a WinRM 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. Allowed, but redundant for WinRM remoting
    [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 specified makes a connection using the Remote Desktop Protocol (terminal services).
    [parameter(ParameterSetName='RDP')]
    [Alias('TS','TermServ')]    
    [switch]$RDP,
    #If specified makes an ssh connection as SSHName@VM. If the VM is specified this must just be a name, or it can be name@host and the VM parameter omitted.
    [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 specified SSH will run in the same window. This can cause problems, but is useful for returning the result of an ssh command to PowerShell.
    [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