Dynamic DNS with Edgerouter and Azure
And some fun with Azure Functions and Powershell along the way
The Ubiquiti Edgerouters are nice and powerful routers. While I don't see the product line lasting very long in the future (we haven't seen new features for years and any firmware updates are only urgent fixes), they are feature rich and, for those of us that don't suffer from commandline-phobia, easy to operate.
This guide targets the Edgerouter series. I haven't tested it, but I assume this works exactly the same for the USG devices and perhaps with other vendors as well. Please let me know if you tried that out.
Update: Confirmed this works with USG. See Eduard's comment below. Thanks Ed 😀
One of the built-in features is Dynamic DNS, allowing you to automatically update a public DNS record with the external IP address of your router. Useful if you expect incoming traffic that needs to find its way home. Naturally I'm using Azure DNS for managing my domain's records. Unfortunately, there is currently no Dynamic DNS offering for Azure DNS. In this post, I'll take you through a simple solution that makes it work anyway.
Dynamic DNS on the Edgerouter relies on ddclient under the hood. When you set up dyndns through the command line or the GUI, a config file for ddclient is created automatically. It supports protocols of popular dns services, such as Cloudflare and DynDNS, for which you can provide a settings such as the host you want to update, the credentials to authenticate and...the server to talk to.
Can we just specify our own server and pretend we're the service for which ddclient supports the protocol? Turns out we can!
From the ddclient source code it was easy enough to reverse engineer the expected requests and responses and build our quick and dirty implementation of the IP Update protocol. From the list of supported services, I picked NOIP because I like the fields it sends in the request. The details of the request/response can be found in the ddclient source code .
Note: While I find the IP Update protocol referenced on the Internet left and right, it does not seem to be an official protocol. I went with the ddclient source code for details on the implementation.
Preparing the cloud
On the Azure side, I've chosen Azure Functions as the platform to host this simple service, because it's easy to set up and cheap. The code is written in Powershell. Powershell is awesome, but if you really want you can probably build something in C# or Python in a similar way.
Prerequisites
Since you have Azure DNS up and running with your domain, I assume you have an Azure subscription and know how to deploy resources, so we won't be covering that. Have an existing A record ready for the subdomain you want to update dynamically.
Creating an Azure Function
Create a new Azure Function resource. In this instance, we can run this function on a consumption plan. There will hardly be any requests and that is the most cost-effective option.
Before we dive into the actual function code, we have to take care of two things: The function needs permissions to access the DNS zone and we need to make sure the function has the right powershell modules installed.
Setting contributor permissions on the DNS zone
Setting up this access could be implemented using a service principal, a user managed identity or a system managed identity. We're going to use the latter because it's the most straightforward. Enabling the System Managed Identity of the Azure Function is done on the 'Identity' blade of the Azure Function:
Now that the identity has been created, we can assign it a role on the DNS zone. We're going to give it contributor access on just this single zone. Navigate to the zone in the Azure portal and from the 'Access Control' blade, find 'Add Role Assignment'
This will allow the Azure function to make changes to the DNS zone.
Installing powershell modules
The only module that is not installed by default is Az.Dns, which we'll need to edit the Azure DNS zone. We can specify this by editing the requirements.psd1 file:
Simply add 'Az.Dns' = '1.1.2' to the list of modules.
The Function code
After setting these prerequisites, start editing the main code. We'll walk through a few interesting snippets first. If you just want the whole thing, scroll down.
Authentication
Some of the services supported by ddclient send their tokens in the query string, others in the headers. None of them uses code as a key, so we can't make that work easily with Functions. The protocol we've picked uses Basic authentication, which Functions does not support natively. That doesn't mean we can't use it, but it does mean we manually have to validate the Authorization header and stop if it's invalid.
Basic headers are in the form of Authorization: Basic base64(user:password)
The value it compares to ($env:UpdaterCredential
) is taken from the AppSettings and contains the base64 value of default:mysecretpassword).
For this authentication scheme to work, we have to set the authorization level of the Function to anonymous, i.e. everyone may call it.
try {
$authparam = $Request.Headers.Authorization
$basicAuthString = ($authparam -split ' ')[1]
$credential = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($basicAuthString))
if($credential -ne $env:UpdaterCredential) {
throw "Bad credential"
}
}
catch
{
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
# The ip update protocol does not use HTTP status codes in returns, but has its own keywords in a 200 body.
StatusCode = [HttpStatusCode]::OK
Body = "$STATUS_UNAUTHORIZED $hostip"
})
exit
}
DNS Update
Now that we're sure the request is authorized, the actual request can be processed. We have the hostname and the new IP address in the query string. We assume the hostname is in the subdomain.domain.tld form, so that means that everything after the first period is the zone, before it is the name of the record. What we want is the record to edit, so we get it using a regular expression. In this case, the second match group is the subdomain (record name), the fourth match group is the domain (zone name):
$strZone = ($hostname | Select-String -Pattern "^([a-zA-Z]{1,})(.)([a-zA-Z.]{1,})" -AllMatches).Matches.Groups
$record = Get-AzDnsZone `
| where {$_.Name -eq $strZone[3]} `
| Get-AzDnsRecordSet -Name $strZone[1] -RecordType A
Once we've determined that the current record is different from the new one, we do the actual update. Note that an A record on Azure DNS may contain multiple IPv4 addresses, so we have to remove the existing one as well as add the new one:
$record `
| Remove-AzDnsRecordConfig -Ipv4Address $currentRecordIp `
| Add-AzDnsRecordConfig -Ipv4Address $hostip `
| Set-AzDnsRecordSet
Responses
Curiously, the IP Update protocol does not use HTTP status codes. It expects a custom keyword in the body of a 200 response to its GET request. Throughout the code, we set a status variable and return it in the end. The exception here is the authorization. We check that first and if it fails, we don't continue.
# All well, record updated:
$STATUS_SUCCESS = "good"
# AuthN or AuthZ failed:
$STATUS_UNAUTHORIZED = "badauth"
# Something went wrong during the update:
$STATUS_UPDATE_ERROR = "dnserr"
# No record or zone could be found:
$STATUS_NOZONE = "nohost"
# Current IP is the same as new IP. Nothing changed.
$STATUS_NOCHANGE = "nochg"
//// ---- ////
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Body = "$status $hostip"
})
All the Function code together
...looks like this:
using namespace System.Net
param($Request, $TriggerMetadata)
$STATUS_SUCCESS = "good"
$STATUS_UNAUTHORIZED = "badauth"
$STATUS_UPDATE_ERROR = "dnserr"
$STATUS_NOZONE = "nohost"
$STATUS_NOCHANGE = "nochg"
$hostname = $Request.Query.hostname
$hostip = $Request.Query.myip
try {
$authparam = $Request.Headers.Authorization
$basicAuthString = ($authparam -split ' ')[1]
$credential = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($basicAuthString))
if($credential -ne $env:UpdaterCredential) {
throw "Bad credential"
}
}
catch
{
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
# The ip update protocol does not use HTTP status codes in returns, but has its own keywords in a 200 body.
StatusCode = [HttpStatusCode]::OK
Body = "$STATUS_UNAUTHORIZED $hostip"
})
exit
}
if ($env:MSI_SECRET -and (Get-Module -ListAvailable Az.Accounts)) {
Connect-AzAccount -Identity
}
# Assume until the first . is the record name, everything after that is the zone name
$strZone = ($hostname | Select-String -Pattern "^([a-zA-Z]{1,})(.)([a-zA-Z.]{1,})" -AllMatches).Matches.Groups
$record = Get-AzDnsZone `
| where {$_.Name -eq $strZone[3]} `
| Get-AzDnsRecordSet -Name $strZone[1] -RecordType A
if($record)
{
$currentRecordIp = $record.Records[0].Ipv4Address
if($currentRecordIp -ne $hostip)
{
# Records don't match, so we'll remove the current one and add the new ip address
try {
$record `
| Remove-AzDnsRecordConfig -Ipv4Address $currentRecordIp `
| Add-AzDnsRecordConfig -Ipv4Address $hostip `
| Set-AzDnsRecordSet
$status = $STATUS_SUCCESS
}
catch {
$status = $STATUS_UPDATE_ERROR
}
}
else
{
$status = $STATUS_NOCHANGE
}
}
else # No zone or record!
{
$status = $STATUS_NOZONE
}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
# The ip update protocol does not use HTTP status codes in returns, but has its own keywords in a 200 body.
StatusCode = [HttpStatusCode]::OK
Body = "$status $hostip"
})
Setting up the router
Now that we have the Azure side ready, let's look at the router side. We can make this change through the command line, edit the Config Tree in the GUI or even use the Dynamic DNS page under Services/DNS. Note: Throughout this example, I assume eth0 is your WAN interface. Update accordingly if that's not the case.
IP Address source
There are several possible sources for the IP address that is used for the update. It can be taken off of the WAN interface of the router, but in some cases this may not be the actual IP address. For example, you may be behind Carrier Grade NAT, you may have a double NAT situation in your home because of an uncooperative ISP-supplied modem, or this router may be further downstream in the network for other reasons. In this case, you will need to obtain an IP address from an external source. This can be any website that returns your IP address in a structured response. See below for an example with ipinfo.io.
Command Line update
Important: If you use the commandline to set this, ensure the trailing question mark at the end of the server field value is copied correctly. Presumably because of a console setting, it is not always recorded. If you know a way around this, let me know. This question mark is important, because it forces the start of the query string part of the URL, i.e. anything ddclient sticks behind it will not be seen as the address. Additionally, don't include the https:// part of the URL in the server field.
set service dns dynamic interface eth0 service custom-azure host-name [yoursubdomain]
set service dns dynamic interface eth0 service custom-azure login default
set service dns dynamic interface eth0 service custom-azure password [mysecurepasswordinplaintext]
set service dns dynamic interface eth0 service custom-azure protocol noip
set service dns dynamic interface eth0 service custom-azure server [myfunctionname].azurewebsites.net/api/DnsUpdater?
If you want to send your real external IP address and are unable to obtain that locally, also include:
set service dns dynamic interface eth0 web https://ipinfo.io/json
set service dns dynamic interface eth0 web-skip "ip: "
If this is omitted, the current IP address of eth0 will be used.
Finally, save the new configuration
commit; save
GUI Setup
If you prefer to use the web interface of the Edgerouter, you can also do that. Navigate to the Services/DNS tab and create a new dynamic DNS entry. Again, ensure that the ? is at the end of the server field and the https:// part is not included.
Validation
If all goes well, you should see success on both the client and the server side. On the Azure Function, you can see the call coming in on the Log console:
On the Edgerouter, you can review the log by ssh-ing in and typing
cat /var/log/messages | grep ddclient
Troubleshooting
As usual (and hopefully as expected), this will not work right away the way you want it to. For troubleshooting the router side, enjoy these commands:
Remove cache file (required if last run was less than 5 minutes ago):
sudo rm /var/cache/ddclient/ddclient_eth0.conf
Alternatively, force an update through the CLI. This does the same as the command above, removing the cache file and waiting for the next cycle to run the update.
update dns dynamic interface eth0
Run ddclient in debug mode:
sudo /usr/sbin/ddclient -daemon=0 -debug -verbose -noquiet -file /etc/ddclient/ddclient_eth0.conf
Show latest dynamic DNS status
show dns dynamic status
On the Azure side of things, it may be helpful to add something like
Write-Host ($Request.Query | Out-String)
to show the contents of the query string.
A final word of caution
This code should be treated as a proof of concept for use in a home setup and should not be used in production environments without some serious improvements. Compromised DNS records may lead to serious security incidents.