Working with and developing on cloud-hosted VMs is fun, but the process can be a bit tedious. It involves generating SSH-keys, configuring the VM, starting it online or using the console, looking up its IP-address or FQDN, configuring a local SSH-client… I didn’t sign up for this, time to up the geek factor. I decided to script the entire process from creation to connection. As a bit of polish, I opted to store my private key in Azure Key Vault. The private key is only retrieved temporarily when connecting to the machine.
In this post, I’ll lay out the steps for each of the scripts, why I made certain decisions and things I learned in the process.
Is it overengineered? Probably. Could things be more elegant? Maybe. Is Powershell the best way to do this? Debatable, but it works fine. One thing I only realized after the fact is that VS Code Remote - the best invention since sliced bread - requires a local SSH key. Oops. I’ll tackle that one some other time.
Both scripts can be found on Github: creation and connection.
Creating the VM
The creation process:
- Generate keys
- Store private key in Key Vault
- Configure VM components (Networking, OS, etc.)
- Deploy VM to Azure
Configuration
Let’s start with some basic configuration: resource names, VM size, OS image, etc. To reduce the amount of duplicate code and ensure consistent naming, string interpolation is used extensively.
Leave these be or adjust them to your liking.
## General
$ResourceGroupName = "thijs"
$LocationName = "westeurope"
## VM Configuration
$VMSize = "Standard_DS3_v2"
$VMName = "az-dev"
$VMUser = "vmadmin"
## Keyvault
$VaultName = "$ResourceGroupName-kv"
$SecretName = "$VMName-ssh"
## OS Image
$ImagePublisher = "Canonical"
$ImageOffer = "UbuntuServer"
$ImageSkus = "19.04"
$ImageVersion = "Latest"
We’ll check if the resource group already exists. If not, it will be created.
$ResourceGroupExists = $null -ne (Get-AzResourceGroup | `
Where-Object -Property ResourceGroupName -eq $ResourceGroupName)
if(-not $ResourceGroupExists) {
New-AzResourceGroup `
-Name $ResourceGroupName `
-Location $LocationName
}
Generate keys
We’ll be using ssh-keygen to generate a keypair. It was already installed and in my PATH. If you don’t already have it, ssh-keygen is part of Git for Windows. To start, we’ll create a temporary file and delete it. This may sound strange but it’s just the path we’re after. The temp-file is just a file in your Temp AppData folder. The ssh-keygen utility will ask to confirm before overwriting existing files (as it should!) but in this case that’s fine and we don’t want to be annoyed with petty prompts.
After generating a key, we read it into a SecureString object and upload it to Key Vault.
Write-Output "Creating keys"
$KeyFile = New-TemporaryFile
$KeyFile.Delete() # Avoids ssh-keygen prompting to overwrite
ssh-keygen -t rsa -f $KeyFile.FullName -q
$PrivateKeySecure = Get-Content $KeyFile.FullName | Out-String | `
ConvertTo-SecureString -AsPlainText -Force
Set-AzKeyVaultSecret `
-VaultName $vaultName `
-Name $SecretName `
-SecretValue $PrivateKeySecure
Network Components
We’ll create the required networking components and add a rule whitelisting port 22. In this example we’re whitelisting it for all addresses but only whitelisting specific IP-addresses/-ranges is a lot safer. Adjust $SshSourceAddress if you want to narrow this down.
I don’t have much to say about this section.
## Networking
$NetworkName = "$VMName-vnet"
$NICName = "$VMName-nic"
$IpName = "$VMName-ip"
$SubnetName = "$VMName-subnet"
$SubnetAddressPrefix = "10.0.0.0/24"
$VnetAddressPrefix = "10.0.0.0/16"
$NsgName = "$VMName-nsg"
$SshSourceAddress = "*" # Narrow down if desired
# Create a subnet configuration
$SubnetConfig = New-AzVirtualNetworkSubnetConfig `
-Name $SubnetName `
-AddressPrefix $SubnetAddressPrefix
# Create a virtual network
$Vnet = New-AzVirtualNetwork `
-ResourceGroupName $ResourceGroupName `
-Location $LocationName `
-Name $NetworkName `
-AddressPrefix $VnetAddressPrefix `
-Subnet $subnetConfig
# Create a public IP address and specify a DNS name
$PublicIp = New-AzPublicIpAddress `
-ResourceGroupName $ResourceGroupName `
-Location $LocationName `
-AllocationMethod Static `
-IdleTimeoutInMinutes 4 `
-Name $IpName
# Create an inbound network security group rule for port 22
$nsgRuleSSH = New-AzNetworkSecurityRuleConfig `
-Name "SSH" `
-Protocol "Tcp" `
-Direction "Inbound" `
-Priority 1000 `
-SourceAddressPrefix $SshSourceAddress `
-SourcePortRange * `
-DestinationAddressPrefix * `
-DestinationPortRange 22 `
-Access "Allow"
# Create a network security group
$nsg = New-AzNetworkSecurityGroup `
-ResourceGroupName $ResourceGroupName `
-Location $LocationName `
-Name $NsgName `
-SecurityRules $nsgRuleSSH
# Create a virtual network card and associate with public IP address and NSG
$nic = New-AzNetworkInterface `
-Name $NICName `
-ResourceGroupName $ResourceGroupName `
-Location $LocationName `
-SubnetId ($vnet.Subnets | `
Where-Object { $_.Name -eq $SubnetName } | `
Select-Object -ExpandProperty Id) `
-PublicIpAddressId $PublicIp.Id `
-NetworkSecurityGroupId $nsg.Id
Create VM
Our VM will have password-authentication disabled, but a PSCredential-object is required to set up the user account. We’ll create one with a dummy whitespace password.
# Define a credential object
$SecurePassword = ConvertTo-SecureString ' ' -AsPlainText -Force
$Credential = New-Object System.Management.Automation.PSCredential ($VMUser, $SecurePassword)
Time to get to the main point of this script: the VM. First, create a VM-config and pipe it through various functions to:
- Configure the operating system as Linux, add credentials and disable password -authentication
- Configure which source image to use
- Add the network-interface with our SSH-rule
- Retrieve and add our public key
Finally, deploy the VM to Azure. It will be in running state, so remember to shut it down if you don’t plan on using it right away.
# Create a virtual machine configuration
$VmConfig = New-AzVmConfig `
-VMName $VMName `
-VMSize $VMSize | `
Set-AzVMOperatingSystem `
-Linux `
-ComputerName $VMName `
-Credential $Credential `
-DisablePasswordAuthentication | `
Set-AzVMSourceImage `
-PublisherName $ImagePublisher `
-Offer $ImageOffer `
-Skus $ImageSkus `
-Version $ImageVersion | `
Add-AzVMNetworkInterface `
-Id $nic.Id | `
Add-AzVMSshPublicKey `
-VM $VmConfig `
-KeyData (ssh-keygen -e -f $KeyFile -q | Out-String) `
-Path "/home/$VmUser/.ssh/authorized_keys"
New-AzVM `
-ResourceGroupName $ResourceGroupName `
-Location $LocationName -VM $VmConfig
Connecting
On to the good stuff.
The connection process:
- Lookup VM
- Start if currently shut down
- Retrieve private key from Key Vault
- Connect to VM
Configuration
Nothing special here, just make sure to use the values used when creating the machine.
$ResourceGroupName = "thijs"
$VMName = "az-dev"
$VMUser = "vmadmin"
$VaultName = "$ResourceGroupName-kv"
$KeyFile = New-TemporaryFile
$SecretName = "$VMName-ssh"
Lookup VM
Finding the VM is easy enough, just query for it using the VM name and its resource group. To find out if it’s running, we take a look at the ‘PowerState’ status-property. Naturally we’ll start it if it isn’t running.
$vm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VMName
$status = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VMName -Status | `
Select-Object -ExpandProperty Statuses | `
Where-Object { $_.Code -like 'PowerState*' } | `
Select-Object -ExpandProperty DisplayStatus
if ($status -ne 'VM running') {
Write-Output 'Starting VM...'
Start-AzVm -Id $vm.Id -Confirm:$false
Write-Output 'VM Started'
}
else {
Write-Output "VM already running"
}
Retrieving IP
So, our VM is running so the next step is to retrieve its public IP-address. Should be easy and straightforward, right? Depends. There’s no way to find it directly on the $VM
, but let’s say we don’t. We’re going to pretend we only know the name of the VM.
Using Az CLI, a brief one-liner would suffice: az vm show -d -g rgName -n vmName --query publicIps
. But we aren’t using the CLI, we’re using Powershell. Unfortunately for us, that route isn’t quite as elegant. You have to traverse object trees and look up resources using Ids extracted from split strings. I tried finding a better way but came up empty - though I would very much appreciate being proven wrong. I tried to make the statement as concise as possible without sacrificing legibility.
function GetIp {
param {
[PSVirtualMachine]$vm
}
$Ip = $vm.NetworkProfile.NetworkInterfaces[0].Id.Split('/')[-1] | # Get network interface id
ForEach-Object { Get-AzNetworkInterface -Name $_ } | # Look up network interface
ForEach-Object { $_.IpConfigurations.PublicIpAddress.Id.Split('/')[-1] } | # Get resource id of public ip-address
ForEach-Object { Get-AzPublicIpAddress -Name $_ } | ` # Lookup PublicIpAddress resource
Select-Object -ExpandProperty IpAddress # Finally, the public IP Address
return $Ip
}
$ip = GetIp($vm)
A tale of line endings
After I wrote the initial script, I kept getting errors while trying to read the SSH-key I just retrieved about it being in an invalid
format. Stupid me figured Key Vault was the culprit, but that turned out to be a dead end. I then realized that OpenSSH is a Linux utility and something dawned on me. Something something line endings. I have no idea how we ended up with multiple types of line endings - undoubtedly an interesting story - I just know it’s a pain. I discovered that Powershell appends a Windows-style line ending (CRLF) when it outputs content to a file. Doesn’t seem like a big deal, and it usually isn’t, but the OpenSSH-utilities beg to differ. The solution? Write to a file, read it (which returns an array of lines), join the array with Unix-style newline-chars, append a trailing newline-char and write to the file. Tried to do it in a oneliner with Out-File -NoNewline
but I couldn’t get it to work. Sometimes good enough is perfect.
After all that nonsense, the glorious payoff awaits.
Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName | `
Select-Object -ExpandProperty SecretValueText | `
Out-File -NoNewline -FilePath $KeyFile.FullName
# Remove trailing CRLF
((Get-Content $KeyFile.FullName) -join "`n") + "`n" | `
Set-Content -NoNewline $KeyFile.FullName
ssh -i $KeyFile.FullName $VMUser@$ip
Next Steps
So, where do you go from here? Some ideas:
- Setup VS Code Remote automatically
- Add SSH-rule for current IP address