The principle of least privilege/authority requires that access to an environment is limited to only what is necessary. When we are running Virtual Machines in Azure, it is easy to suggest turning off SSH/RDP access from the internet and instead require a VPN, whitelisting the VPN's IP address in the Network Security Group rules. But what if we cannot afford to implement a VPN and want to simply allow access from a single IP (assigned to a physical office location)? Easy, we just whitelist that IP. But what if this IP is dynamic?

Problem: Limiting SSH access for a dynamic IP

I have an office location from where I would like to have access to a virtual machine running in Azure. Our business' ISP does provide static IPs but the business has decided to stick to the standard dynamic IP offering. When our IP address changes, we would have to manually update the relevant Azure Network Security Group rules to whitelist our new public IP.

Solution: Cloudflare DDNS, Azure Functions App, Azure Powershell Runbook

We already have a script running hourly on an in-house Linux VM pushing the office's public IP address to an A record in our DNS (e.g. sitea.domain.com). You can do this using Cloudflare DDNS scripts or your preferred DNS provider.

Once we have our public IP stored in our public DNS records, we need to create a Azure Functions App (using the HTTP trigger template) which queries and responds with our stored A record (our public IP address).

using namespace System.Net

# Input bindings are passed in via param block.
param($Request, $TriggerMetadata)

# Write to the Azure Functions log stream.
Write-Host "PowerShell HTTP trigger function processed a request."

# get IP only, strip all other columns, strip header
$Dyn_IP_siteA = Resolve-DnsName -Name siteA.domain.com -Type A -DnsOnly | Select IPAddress -ExpandProperty IPAddress

# Require a query parameter/value as a layer of abstraction/variability. This is not necessary but a) ensures anyone with your function URL has to know a parameter and its possible values, b) allows you to expand the function for multiple values.
$name = $Request.Query.Name
if (-not $name) {
    $name = $Request.Body.Name
}

if ($name -eq "Office1") {
    $status = [HttpStatusCode]::OK
    $body = $Dyn_IP_siteA
}
else {
    $status = [HttpStatusCode]::BadRequest
    $body = "Bad Request Logged"
}

# Associate values to output bindings by calling 'Push-OutputBinding'.
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
    StatusCode = $status
    Body = $body
})

With our Function App live, we can create a Azure Powershell Runbook using an Azure Automation Account. You will need to add the following modules to your automation account: AzureRM.Profile and AzureRM.Network first.

# Authenticate with Azure
$connectionName = "AzureRunAsConnection"
try
{
    # Get the connection "AzureRunAsConnection "
    $servicePrincipalConnection=Get-AutomationConnection -Name $connectionName         

    "Logging in to Azure..."
    Add-AzureRmAccount `
        -ServicePrincipal `
        -TenantId $servicePrincipalConnection.TenantId `
        -ApplicationId $servicePrincipalConnection.ApplicationId `
        -CertificateThumbprint $servicePrincipalConnection.CertificateThumbprint 
}
catch {
    if (!$servicePrincipalConnection)
    {
        $ErrorMessage = "Connection $connectionName not found."
        throw $ErrorMessage
    } else{
        Write-Error -Message $_.Exception
        throw $_.Exception
    }
}

# set Network Security Group name
$NetSecGrp = ""
# set Resource Group name
$ResGrp = ""
# set Network Security Group Rule name
$NetSecRul = ""
# get and store the IP using our Azure Function App's URL
$Dyn_IP = Invoke-WebRequest -URI "https://….azurewebsites.net/api/…&name=Office1" -UseBasicParsing -Method Get

# Get the network security group
$nsg = Get-AzureRmNetworkSecurityGroup -Name $NetSecGrp -ResourceGroupName $ResGrp
# Use the pipeline operator to pass the security group in $nsg to Get-AzureRmNetworkSecurityRuleConfig (the security rule configuration)
$nsg | Get-AzureRmNetworkSecurityRuleConfig -Name $NetSecRul
# Update the network security rule
Set-AzureRmNetworkSecurityRuleConfig -Name $NetSecRul -NetworkSecurityGroup $nsg -Access "Allow" -DestinationAddressPrefix * -DestinationPortRange 22 -Direction Inbound -Priority 350 -Protocol * -SourceAddressPrefix $Dyn_IP -SourcePortRange *  | Set-AzureRmNetworkSecurityGroup

And that's it! You can now setup a schedule for the runbook and be on your way.

The two scripts above can most definitely be improved in a few ways. To start with I would change the Azure function to allow passing the domain name as a query, in essence allowing the function to resolve for any FQDN. I have published these scripts on Github: https://github.com/captainhook/AzureNetworkSecurityDynamicIP