Cloudflare as Dynamic DNS [#cloudflare #mikrotik #script]

Background

I have, for a time, been using CloudFlare for CDN, Optimizations and DNS Management for this and a few other domains. At the same time, I’ve been using DynDNS to provide name resolution to my home network/lab. Browsing around the CloudFlare JSON API I noticed that I can update DNS records through some fairly simple HTTP GET requests, and since the RouterOS (the operating system used by Mikrotik’s routerboards) has support for some pretty decent scripting I decided to let my router update CloudFlare for some custom and free dynamic DNS resolution.

Attribution

My current script is a modified version of a script developed by Konstantin Antselovich.

Original script: http://konstant1n.livejournal.com/9759.html

Original Author website: http://konstantin.antselovich.com/

Thanks!

How you do it

What you need

  • RouterOS v6+ for HTTPS support
  • Cloudflare API key, aka “Token”, found at the account page
  • Cloudflare DNS Zone name
  • Cloudflare Record Id (more on that later)
  • Cloudflare subdomain name
  • Name of the external router interface

Getting the Record Id

To get the record id of the DNS record you want to update, I used the rec_load_all function of the API. And since I’m a windows admin, I use powershell for that.

$cfDomain = "homelab"
$cfToken = "<your API Key/Token>"
$cfZone = "example.com"
$cfEmail = "<your CF email address>"
$apiLoadAllRecordsURI = "https://www.cloudflare.com/api_json.html?a=rec_load_all&tkn=$cfToken&email=$cfEmail&z=$cfZone"
$restRequest = Invoke-RestMethod -Uri $apiLoadAllRecordsURI
$restRequest.response.recs.objs | ?{$_.display_name -eq $cfDomain} | fl -Property rec_id,name,display_name,type,ttl,service_mode

That should, given that you’ve replaced all the example variables produce something like this:

rec_id : 16606003
name : homelab.example.com
display_name : homelab
type : A
ttl : 1
service_mode : 0

And with that done, you should have everything necessary for the RouterOS script.

Building the Script

I’m not going to go into the syntax of ros scripts, but I’ll try to explain the different building block to some degree.

Defining our variables

First, we’ll define out variables used for the script. The global hostname_ variable is actually redundant as I could derive the FQDN from  _CFDomain and $CFZone, but it’s used in other scripts in my router so it’s included for that purpose alone.

######## Set and collect general variables #########
:global hostname "homelab.example.com"
:global resolvedIP ""
:global externalIP ""
:global WANInterface "eth1-ext-internet"

######## Set CloudFlare variables #################
:local CFemail "<same email as in the powershell script"
:local CFtkn "<your API key/token>"
:local CFzone "example.com"
:local CFid "16606003"
:local CFtype "A"
:local CFttl "1"
:local CFservicemode "0"
:local CFDomain "homelab"
:local CFDebug "true"

Take care to verify and compare the CFxx variables with the ones used in and received from the previous powershell script, but also the $hostname variable.

  • hostname: Used in the script to lookup the currently set IP-address.
  • WANInterface: The list name of the external interface, used to find the external IP-address.
  • CFemail: Your CloudFlare Login email.
  • CFtkn: Your API Key/Token.
  • CFzone: Your DNS Zone.
  • CFid: The rec_id from the powershell script.
  • CFtype: A for IPv4 host records, AAAA for IPv6.
  • CFttl: Record time-to-live, use “1” for automatic.
  • CFservicemode: Use “0” for direct traffic or “1” to go through their CDN, Optimizations and Security features. I recommend “0” for anything but websites.
  • CFDomain: Subdomain or rather hostname for your router.
  • CFDebug: Use “True” to print some informational stuff to the router logs.

Resolve IP-addresses

Next, we resolve two IP-addresses. First one, externalIP_, is the routers current external address. And the second, _resolvedIP, is the address currently set for our $hostname.

######## Resolve and set IP-variables ##########
:local currentIP [/ip address get [/ip address find interface=$WANInterface ] address];
:set externalIP [:pick $currentIP 0 [:find $currentIP "/"]];
:set resolvedIP [:resolve $hostname];

Build CF API Request and log debug info

Now, build the URI for the API call from our variables.

######## Build CF API Url #########################
:local CFurl "https://www.cloudflare.com/api_json.html\3F"
:set CFurl ($CFurl . "a=rec_edit&tkn=$CFtkn&id=$CFid");
:set CFurl ($CFurl . "&email=$CFemail&z=$CFzone&type=$CFtype");
:set CFurl ($CFurl . "&name=$CFDomain&service_mode=$CFservicemode&ttl=$CFttl");

And if the $CFdebug variable is set to “true”, write a little info to the router log.

######## Write debug info to log #################
:if ($CFDebug = "true") do={
:log info ("CF: hostname = $hostname")
:log info ("CF: resolvedIP = $resolvedIP")
:log info ("CF: currentIP = $currentIP")
:log info ("CF: externalIP = $externalIP")
:log info ("CF: CFurl = $CFurl&content=$externalIP")
};

Compare and Update

Last step is to compare externalIP_ with _resolvedIP. If they don’t match, our external IP-address has been changed and we need to update the DNS record. To execute the HTTP GET request we use a RouterOS tool called fetch to which we pass the $CFurl variable. The reason this will not work before RouterOS v6+ is that before that there was no support for HTTPS, and CloudFlare does not accept unencrypted traffic for their APIs.

The script block is pretty straight-forward. Compare, update, flush local DNS-cache and write to the log.

######## Compare and update CF if necessary #####
:if ($resolvedIP != $externalIP) do={
:log info ("CF: Updating CF, setting $CFDomain = $externalIP")
/tool fetch mode=https url="$CFurl&content=$externalIP" keep-result=no
/ip dns cache flush
} else={
:log info "CF: No Update Needed!"
}

Permissions/Policies

This part, I’m not entirely sure on. The script needs to do name resolution, read settings and set variables and it works for me with these policies configured:

  • Read
  • Write
  • Test
  • Sniff
  • Sensitive

It might be over-doing it a bit, but I have not had occasion to fiddle with them yet.

Scheduling

I have this script scheduled to run once every 20 minutes. As it will only try to update the DNS records if there is a difference between them and the current external IP-address I feel fairly sure that I won’t be tagged as a spammer or something by CloudFlare.

The schedule is configured with:

  • Start Time: startup
  • Interval: 00:20:00
  • On Event: /system script run <Name of your script>
  • Policy: Read,Write, Test, Sniff, Sensitive

This schedule is also a good place for a hairpin update if it’s needed.

The Copy-Paste part

Now, obviously, at least make sure you proof-read the script before putting it into production. I am not a network dude, and I rarely dabble with ROS scripts. I am certain that anyone with a bit more experience could create a smarter, prettier version of this script, so use at your own risk. Anyway, here’s the script in it’s entirety.

######## Set and collect general variables #########
:global hostname "homelab.example.com"
:global resolvedIP ""
:global externalIP ""
:global WANInterface "eth1-ext-internet"

######## Set CloudFlare variables #################
:local CFemail "<same email as in the powershell script"
:local CFtkn "<your API key/token>"
:local CFzone "example.com"
:local CFid "16606003"
:local CFtype "A"
:local CFttl "1"
:local CFservicemode "0"
:local CFDomain "homelab"
:local CFDebug "true"

######## Resolve and set IP-variables ##########
:local currentIP [/ip address get [/ip address find interface=$WANInterface ] address];
:set externalIP [:pick $currentIP 0 [:find $currentIP "/"]];
:set resolvedIP [:resolve $hostname];

######## Build CF API Url #########################
:local CFurl "https://www.cloudflare.com/api_json.html\3F"
:set CFurl ($CFurl . "a=rec_edit&tkn=$CFtkn&id=$CFid");
:set CFurl ($CFurl . "&email=$CFemail&z=$CFzone&type=$CFtype");
:set CFurl ($CFurl . "&name=$CFDomain&service_mode=$CFservicemode&ttl=$CFttl");

######## Write debug info to log #################
:if ($CFDebug = "true") do={
:log info ("CF: hostname = $hostname")
:log info ("CF: resolvedIP = $resolvedIP")
:log info ("CF: currentIP = $currentIP")
:log info ("CF: externalIP = $externalIP")
:log info ("CF: CFurl = $CFurl&content=$externalIP")
};

######## Compare and update CF if necessary #####
:if ($resolvedIP != $externalIP) do={
:log info ("CF: Updating CF, setting $CFDomain = $externalIP")
/tool fetch mode=https url="$CFurl&content=$externalIP" keep-result=no
/ip dns cache flush
} else={
:log info "CF: No Update Needed!"
}
Share