MikroTik DDNS with Cloudflare API v4

I’ve been using NowDNS to provide name resolution to my home network for quite some time. On top of that, I use Cloudflare to manage DNS for this and several other domains. I took advantage of both by pointing out some DNS records that need dynamic IP to NowDNS’s DDNS-domain from Cloudflare.

After I browsed the Cloudflare APIs, I discovered that they provide an API to update the DNS. I could utilize that by using RouterOS scripting, I thought. Yup, there are many RouterOS Cloudflare DDNS scripts on the internet, but mostly use the old deprecated API, so I decided to make my own using the latest API(v4).

Prerequisites

We’ll have to use this particular Update DNS Record API endpoint. To use it, we need to use at least RouterOS v6.44 for /tool fetch to support the http-header-field parameter, and three things from Cloudflare:

  • API Token
  • Zone Identifier
  • DNS Identifier

In addition, the MikroTik router must have a WAN interface that has a dynamic public IP address.

Getting API Token

Getting Zone Identifier

  • Go to one of your domain dashboards on Cloudflare
  • You should find it on the sidebar, right side of the page

Getting DNS Identifier

In this step, we need more effort to get the DNS Identifier. We have to get it from an API response, the List DNS Records endpoint.

Have you created an API Token and found your Zone Identifier yet? We’ll use that in the following API request.

curl -X GET "https://api.cloudflare.com/client/v4/zones/$ZONE_IDENTIFIER/dns_records?type=A&name=$DNS_NAME" \
     -H "Authorization: Bearer $API_TOKEN"

*DNS_NAME is the domain that will have a dynamic IP, ex: myddns.bayukurnia.com. If you don't have yet, create a new one from Cloudflare Dashboard.

The Script

Yay, the fun part. But before we get started, let’s make some goals for the final script.

I want my script to have the following behavior:

  • Make API request only if IP changed
  • Logs only if it’s necessary, saving more log space
  • Logs error details when DDNS fails

Let’s jump into the script. The first thing we’re going to do is to set some variables.

Variables

# global variables
# we'll update it on every ddns success
:global currentIp

# outgoing interface
:local wanInterface "indihome"

# cloudflare variables, adjust with yours
:local cfToken "aLszWiLWxxxxxxxxxxxxxxxxxxxxxx"
:local cfZoneId "1a4ee9660xxxxxxxxxxxxxxxxxxxxxxx"
:local cfDnsId "6b0e74xxxxxxxxxxxxxxxxxxxxxxxx"
:local dnsType "A"
:local dnsName "myddns.bayukurnia.com"
:local dnsTTL "1"
:local dnsProxied "false"

There are several dynamic variables based on the current router state. We need a few commands to set these variables.

Getting Current WAN Interface IP Address

Here’s MikroTik scripting examples page.

# get current $wanInterface IP
:local newIpCidr [/ip address get [find interface="$wanInterface"] address ]
# strip out netmask notation
:local newIp [:pick $newIpCidr 0 [:find $newIpCidr "/"]]

Composing API Variables

Go to Update DNS Record documentation if you want a clear explanation about the payload value constraints.

# compose endpoint
# docs: https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record
:local apiUrl "https://api.cloudflare.com/client/v4/zones/$cfZoneId/dns_records/$cfDnsId"
# headers & payload
:local headers "Authorization: Bearer $cfToken"
:local payload "{\"type\":\"$dnsType\",\"name\":\"$dnsName\",\"content\":\"$newIp\",\"ttl\":$dnsTTL,\"proxied\":$dnsProxied}"

The Main Thing

Due to make sure that our flow will execute only if the IP has changed, we’ll wrap the rest of the script inside an if condition. If this condition doesn’t meet, the script will exit without additional overhead.

  • Make API request only if IP changed ✅
:if ($newIp != $currentIp) do={
  ...
}

I wanted to log an API error response if the request failed until I realized that the RouterOS script would throw an error if /tool fetch’s HTTP response didn’t succeed, which broke the script.

After some browsing, I learned that the only way to handle these error are using the runtime error catch block, but still, there’s no way to get the error response. I feel a bit disappointed since we’ll log the error without the details.

  • Logs error details when DDNS fails ✅
:do {
  :local response [/tool fetch http-method="put" url=$apiUrl http-header-field=$headers http-data=$payload as-value output=user]
  ...
} on-error {
  :log error "DDNS: failed to change IP $currentIp to $newIp"
}

We’ll log if only the request succeeded, then update the currentIp variable.

  • Logs only if it’s necessary ✅
:if ($response->"status" = "finished") do={
    :log info "DDNS: changed $currentIp to $newIp"

    # update $currentIp with the new one
    :set currentIp $newIp
}

Final Script

Please note that I’m not an expert in this field. If you intend to use the script on production, you should test it yourself. There might be some bugs that I don’t realize yet. 👻

We have our final script with all the goals fulfilled. I think.

  • Make API request only if IP changed ✅
  • Logs only if it’s necessary ✅
  • Logs error details when DDNS fails ✅
#--------------------------------------------
# MikroTik DDNS Script | Cloudflare API v4
# bayukurnia.com
#--------------------------------------------

# global variables
# we'll update it on every ddns success
:global currentIp

# outgoing interface
:local wanInterface "indihome"

# get current $wanInterface IP
:local newIpCidr [/ip address get [find interface="$wanInterface"] address ]
:local newIp [:pick $newIpCidr 0 [:find $newIpCidr "/"]]

:if ($newIp != $currentIp) do={
  # cloudflare variables, adjust with yours
  :local cfToken "aJV8sCQqxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  :local cfZoneId "fb36edd6xxxxxxxxxxxxxxxxxxxxxxxx"
  :local cfDnsId "afc8b34dxxxxxxxxxxxxxxxxxxxxxxxx"
  :local dnsType "A"
  :local dnsName "myddns.bayukurnia.com"
  :local dnsTTL "1"
  :local dnsProxied "false"

  # compose endpoint
  # docs: https://api.cloudflare.com/#dns-records-for-a-zone-update-dns-record
  :local apiUrl "https://api.cloudflare.com/client/v4/zones/$cfZoneId/dns_records/$cfDnsId"

  # compose headers & payload
  :local headers "Authorization: Bearer $cfToken"
  :local payload "{\"type\":\"$dnsType\",\"name\":\"$dnsName\",\"content\":\"$newIp\",\"ttl\":$dnsTTL,\"proxied\":$dnsProxied}"

  # make API request
  :do {
    :local response [/tool fetch http-method="put" url=$apiUrl http-header-field=$headers http-data=$payload as-value output=user]

    :if ($response->"status" = "finished") do={
        :log info "DDNS: changed $currentIp to $newIp"

        # update $currentIp with the new one
        :set currentIp $newIp
    }
  } on-error {
    :log error "DDNS: failed to change IP $currentIp to $newIp"
  }
}

Conclusion

As a Cloudflare user, I’m happy they provide comprehensive APIs even for free-tier users like me. Thus there’s no need to use additional DDNS service. Hooray 🎉