11 min read

Updating Azure DNS and SSL Certificate on Azure Functions via GitHub Actions

Justin Yoo

Throughout this series, I'm going to show how an Azure Functions instance can map APEX domains, add an SSL certificate and update its public inbound IP address to DNS.

In my previous post, we walked through how to link an SSL certificate issued by Let's Encrypt, with a custom APEX domain. Throughout this post, I'm going to discuss how to automatically update the A record of a DNS server when the inbound IP address of the Azure Functions instance is changed, update the SSL certificate through the GitHub Actions workflow.

All the GitHub Actions source codes used in this post can be found at this repository.

Azure Functions Inbound IP Address

If you use an Azure Functions instance under Consumption Plan, its inbound IP address is not static. In other words, the inbound IP address of an Azure Functions instance will be changing at any time, without prior notice. Actually, due to the serverless nature, we don't need to worry about the IP address change. If you see the instance details, it has more than one inbound IP address assignable.

Therefore, if you map a custom APEX domain to your Azure Function instance, your APEX domain has to be mapped to an A record of your DNS. And whenever the inbound IP address changes, your DNS must update the A record as well.

A Record Update on Azure DNS

If you use Azure PowerShell, you can get the inbound IP address of your Azure Function app instance.

$AppResourceGroupName = "[RESOURCE_GROUP_NAME_FOR_AZURE_FUNCTION_APP]"
AppName = "[NAME_OF_AZURE_FUNCTION_APP]"
$app = Get-AzResource `
-ResourceType Microsoft.Web/sites `
-ResourceGroupName $AppResourceGroupName `
-ResourceName $AppName
$newIp4Address = $app.Properties.inboundIpAddress

Then, check your DNS and find out the A record. Let's assume that you use Azure DNS service as your DNS. As there can be multiple A records registered, You'll take only the first one for now.

$ZoneResourceGroupName = "[RESOURCE_GROUP_NAME_FOR_AZURE_DNS]"
$ZoneName = "[NAME_OF_AZURE_DNS_ZONE]"
$rs = Get-AzDnsRecordSet `
-ResourceGroupName $ZoneResourceGroupName `
-ZoneName $ZoneName `
-Name "@" `
-RecordType A
$oldIp4Address = $rs.Records[0].Ipv4Address

Let's compare to each other. If the inbound IP and the A record are different, update the A record value of Azure DNS.

if ($oldIp4Address -ne $newIp4Address) {
$rs.Records[0].Ipv4Address = $newIp4Address
$updated = Set-AzDnsRecordSet -RecordSet $rs
}

We've got the A record update done.

SSL Certificate Update on Azure Key Vault

If the A record has been updated, the existing SSL certificate is not valid any longer. Therefore, you should also update the SSL certificate. In my previous post, I used the SSL certificate update tool, and it provides the HTTP API endpoint to renew the certificate. Now, you can send the HTTP API request to the endpoint, through PowerShell.

$ApiEndpoint = "[ACMEBOT_HTTP_API_ENDPOINT]"
$HostNames = "[COMMA_DELIMITED_HOST_NAMES]"
$dnsNames = $HostNames -split ","
$body = @{ DnsNames = $dnsNames }
$issued = Invoke-RestMethod `
-Method Post `
-Uri $ApiEndpoint `
-ContentType "application/json" `
-Body ($body | ConvertTo-Json)

Now, you got the renewed SSL certificate by reflecting the updated A record.

SSL Certificate Sync on Azure Functions

You got the SSL certificate renewed, but your Azure Function instance hasn't got the renewed certificate yet.

According to the doc, the renewed SSL certificate will be automatically synced in 48 hours. If you think it's too long to take, use the following PowerShell script to sync the renewed certificate manually. First of all, get the access token from the login context. If you use the Service Principal, you can get the access token by filtering the client ID of your Service Principal.

$tokenCachedItems = (Get-AzContext).TokenCache.ReadItems()
$tokenCachedItem = $tokenCachedItems | Where-Object { $_.ClientId -eq $clientId }
$accessToken = ConvertTo-SecureString -String $tokenCachedItem.AccessToken -AsPlainText -Force

Next, get the UNIX timestamp value in milliseconds.

$epoch = ([DateTimeOffset](Get-Date)).ToUnixTimeMilliseconds()

Then, make up the HTTP API endpoint to the certificate. As you've already logged in with your Service Principal, you already know the $subscriptionId value.

$CertificateResourceGroupName = "[RESOURCE_GROUP_NAME_FOR_CERTIFICATE]"
$CertificateName = "[NAME_OF_CERTIFICATE]"
$endpoint = "https://management.azure.com/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.Web/certificates/{2}" `
-f $subscriptionId, $CertificateResourceGroupName, $CertificateName

Call the endpoint to get the existing certificate details via the GET method.

$ApiVersion = "2018-11-01"
$cert = Invoke-RestMethod -Method GET `
-Uri ("{0}?api-version={1}&_={2}" -f $endpoint, $ApiVersion, $epoch) `
-ContentType "application/json" `
-Authentication Bearer `
-Token $accessToken

Call the same endpoint with the existing certificate details through the PUT method. Then, the renewed certificate is synced.

$result = Invoke-RestMethod -Method PUT `
-Uri ("{0}?api-version={1}" -f $endpoint, $ApiVersion) `
-ContentType "application/json" `
-Authentication Bearer `
-Token $accessToken `
-Body ($cert | ConvertTo-Json)

The $result object contains the result of the sync process. Both $result.properties.thumbprint value and $cert.properties.thumbprint value MUST be different. Otherwise, it's not synced yet. Once the sync process is over, you can find out the renewed thumbprint value on Azure Portal.

GitHub Actions Workflow for Automation

Now, we got three jobs for SSL certificate update. Let's build each job as a GitHub Action. By the way, why do I need GitHub Actions for this automation?

  • GitHub Actions is not exactly the same, but it has the same nature of serverless – triggered by events and no need to set up the infrastructure.
  • Unlike other serverless services, GitHub Actions doesn't need any infrastructure or instance setup or configuration because we only need a repository to run the GitHub Actions workflow.
  • GitHub Actions is free of charge, as long as your repository is public (or open-source).

As all GitHub Actions are running Azure PowerShell scripts, we can simply define the common Dockerfile.

# Azure PowerShell base image
FROM mcr.microsoft.com/azure-powershell:latest
ADD entrypoint.ps1 /entrypoint.ps1
RUN chmod +x /entrypoint.ps1
ENTRYPOINT ["pwsh", "-File", "/entrypoint.ps1"]

The entrypoint.ps1 file of each Action makes use of the logic stated above.

A Record Update

This is the Action that updates A record on Azure DNS. It returns an output value indicating whether the A record has been updated or not. With this output value, we can decide whether to take further steps or not (line #27, 38).

Param(
[string] [Parameter(Mandatory=$true)] $AppResourceGroupName,
[string] [Parameter(Mandatory=$true)] $AppName,
[string] [Parameter(Mandatory=$true)] $ZoneResourceGroupName,
[string] [Parameter(Mandatory=$true)] $ZoneName
)
$clientId = ($env:AZURE_CREDENTIALS | ConvertFrom-Json).clientId
$clientSecret = ($env:AZURE_CREDENTIALS | ConvertFrom-Json).clientSecret | ConvertTo-SecureString -AsPlainText -Force
$tenantId = ($env:AZURE_CREDENTIALS | ConvertFrom-Json).tenantId
$credentials = New-Object System.Management.Automation.PSCredential($clientId, $clientSecret)
$connected = Connect-AzAccount -ServicePrincipal -Credential $credentials -Tenant $tenantId
# Add/Update A Record
$app = Get-AzResource -ResourceType Microsoft.Web/sites -ResourceGroupName $AppResourceGroupName -ResourceName $AppName
$newIp4Address = $app.Properties.inboundIpAddress
$rs = Get-AzDnsRecordSet -ResourceGroupName $ZoneResourceGroupName -ZoneName $ZoneName -Name "@" -RecordType A
$oldIp4Address = $rs.Records[0].Ipv4Address
if ($oldIp4Address -eq $newIp4Address) {
Write-Output "No need to update A record"
# Set Output
Write-Output "::set-output name=updated::$false"
return
}
$rs.Records[0].Ipv4Address = $newIp4Address
$updated = Set-AzDnsRecordSet -RecordSet $rs
Write-Output "A record has been updated"
# Set Output
Write-Output "::set-output name=updated::$true"
return

SSL Certificate Update

This is the Action that updates the SSL certificate on Azure Key Vault. It also returns an output value indicating whether the update is successful or not (line #14).

Param(
[string] [Parameter(Mandatory=$true)] $ApiEndpoint,
[string] [Parameter(Mandatory=$true)] $HostNames
)
# Issue new SSL certificate
$dnsNames = $HostNames -split ","
$body = @{ DnsNames = $dnsNames }
$issued = Invoke-RestMethod -Method Post -Uri $ApiEndpoint -ContentType "application/json" -Body ($body | ConvertTo-Json)
Write-Output "New SSL certificate has been issued to $HostNames"
# Set Output
Write-Output "::set-output name=updated::$true"

SSL Certificate Sync

This is the Action that syncs the certificate on Azure Functions. It also returns an output indicating whether the sync is successful or not (line #44).

Param(
[string] [Parameter(Mandatory=$true)] $CertificateResourceGroupName,
[string] [Parameter(Mandatory=$true)] $CertificateName,
[string] [Parameter(Mandatory=$false)] $ApiVersion = "2018-11-01"
)
$clientId = ($env:AZURE_CREDENTIALS | ConvertFrom-Json).clientId
$clientSecret = ($env:AZURE_CREDENTIALS | ConvertFrom-Json).clientSecret | ConvertTo-SecureString -AsPlainText -Force
$tenantId = ($env:AZURE_CREDENTIALS | ConvertFrom-Json).tenantId
$subscriptionId = ($env:AZURE_CREDENTIALS | ConvertFrom-Json).subscriptionId
$credentials = New-Object System.Management.Automation.PSCredential($clientId, $clientSecret)
$connected = Connect-AzAccount -ServicePrincipal -Credential $credentials -Tenant $tenantId
# Get Access Token
$tokenCachedItems = (Get-AzContext).TokenCache.ReadItems()
$tokenCachedItem = $tokenCachedItems | Where-Object { $_.ClientId -eq $clientId }
$accessToken = ConvertTo-SecureString -String $tokenCachedItem.AccessToken -AsPlainText -Force
# Get Existing Certificate Details
$epoch = ([DateTimeOffset](Get-Date)).ToUnixTimeMilliseconds()
$endpoint = "https://management.azure.com/subscriptions/{0}/resourceGroups/{1}/providers/Microsoft.Web/certificates/{2}" -f $subscriptionId, $CertificateResourceGroupName, $CertificateName
$cert = Invoke-RestMethod -Method GET `
-Uri ("{0}?api-version={1}&_={2}" -f $endpoint, $ApiVersion, $epoch) `
-ContentType "application/json" `
-Authentication Bearer `
-Token $accessToken
$certJson = $cert | ConvertTo-Json
# Sync Certificate
$result = Invoke-RestMethod -Method PUT `
-Uri ("{0}?api-version={1}" -f $endpoint, $ApiVersion) `
-ContentType "application/json" `
-Authentication Bearer `
-Token $accessToken `
-Body $certJson
$updated = $cert.properties.thumbprint -ne $result.properties.thumbprint
# Set Output
Write-Output "::set-output name=updated::$updated"

GitHub Actions Workflow

The ideal way to trigger the GitHub Actions workflow should be the event – when an inbound IP address changes on Azure Function instance, it should raise an event that triggers the GitHub Actions workflow. Unfortunately, at the time of this writing, there is no such event from Azure App Service. Therefore, you should use the scheduler instead. With the timer event of GitHub Actions, you can regularly check the inbound IP address change.

  • As the scheduler is the main event trigger, set up the CRON scheduler (line #4-5). Here in the sample code, I run the scheduler for every 30 mins.
  • As I use all the actions privately, not publicly, whenever the scheduler is triggered, checkout the code first (line #14-15).
  • Update the A record of Azure DNS.
  • Depending on the result of the A record update (line #29), it updates the SSL certificate.
  • Depending on the result of the SSL certificate renewal (line #37), it syncs the SSL certificate with Azure Functions instance.
  • Depending on the result of the SSL certificate sync (line #47), it sends a notification email to administrators.
name: Update DNS & SSL Certificate
on:
schedule:
- cron: '0/30 * * * *'
jobs:
update_dns_and_ssl_certificate:
name: 'PROD: Update DNS and SSL Certificate'
runs-on: ubuntu-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v2
- name: Update A record
id: arecord
uses: ./actions/dns-update
env:
AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
with:
appServiceResourceGroup: ${{ secrets.RESOURCE_GROUP_NAME_APP }}
appName: ${{ secrets.RESOURCE_NAME_FUNCTIONAPP }}
dnsZoneResourceGroup: ${{ secrets.RESOURCE_GROUP_NAME_ZONE }}
dnsZoneName: ${{ secrets.RESOURCE_NAME_ZONE }}
- name: Update SSL Certificate
if: steps.arecord.outputs.updated == 'true'
id: certificate
uses: ./actions/ssl-update
with:
apiEndpoint: ${{ secrets.SSL_RENEW_ENDPOINT }}
hostNames: ${{ secrets.SSL_HOST_NAMES }}
- name: Sync SSL Certificate
if: steps.certificate.outputs.updated == 'true'
id: sync
uses: ./actions/ssl-update
env:
AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}
with:
certificateResourceGroup: ${{ secrets.RESOURCE_GROUP_NAME_CERTIFICATE }}
certificateName: ${{ secrets.RESOURCE_NAME_CERTIFICATE }}
- name: Send Email Notification
if: steps.sync.outputs.updated == 'true'
uses: dawidd6/action-send-mail@v2
with:
server_address: ${{ secrets.MAIL_SMTP_SERVER }}
server_port: ${{ secrets.MAIL_SMTP_PORT }}
username: ${{ secrets.MAIL_SMTP_USERNAME }}
password: ${{ secrets.MAIL_SMTP_PASSWORD }}
subject: '[${{ secrets.SSL_HOST_NAMES }}] SSL Certificate Updated'
body: 'SSL certificate for ${{ secrets.SSL_HOST_NAMES }} has been updated'
to: ${{ secrets.MAIL_RECIPIENTS }}
from: ${{ secrets.MAIL_SENDER }}

After the workflow runs, we can see the result like below:

This is the notification email.

If the A record is up-to-date, the workflow stops there and doesn't take the further steps.


So far, we use GitHub Actions workflow to regularly check the inbound IP address of the Azure Functions instance and update the change to Azure DNS, renew an SSL certificate, and sync the certificate with Azure Functions instance. In the next post, I'll discuss how to deploy the Azure Functions app through GitHub Actions without having to know the publish profile.