I just had the problem, that the MikroTik build in Cloud DDNS service struggling in the last days. Sometime it was simple not reachable, but today they created a IPv6 (AAAA) entry for my cloud unique domain - but IPv6 is not enabled between my upstream modem and my MikroTik router. So I decided to create a simple script which updates only the IPv4 and/or IPv6 address of my recods directly on the Hetzner DNS API. To detect my current public IP address (external WAN adress) I added support for multiple services which will be tried in a row.
Basic setup
- Setup Hetzner DNS
- Create DNS A and/or AAAA records with a TTL of 30s in a Zone. Use
@ for a full domain or a subdomain. This step is important to avoid DNS caching issues.
- Create a API token for Hetzner's API
- Create a new Script
System -> Scripts
- Name:
ddns-hetzner
- Policy:
read, write, test, uncheck everything else
- Source: Copy the script here
- Configure the script to your needs, check the description in the script or below for information how to configure it
- This script requires Winand's mikrotik-json-parser. Create another new script
- Name:
JParseFunctions
- Policy:
read, write, test uncheck everything else
- Source: The content of mikrotik-json-parser
- Create a new Schedule
System -> Schedule
- Name:
ddns-hetzner
- Start Date: leave it as it is
- Start Time: leave it as it is
- Interval:
00:02:00
- Policy:
read, write, test uncheck everything else
- On Event:
ddns-hetzner
- Setup script configuration:
Script configuration
| Variable name |
Data type |
Example |
Description |
apiKey |
string |
"3su1OLc0gUhUdwxn1bmKFss5V19mBhBx"; |
This variable requires a valid API token for the Hetzner DNS API. You can create an API token here. |
ipv4detectList |
array of strings |
{"https://api4.ipify.org/?format=text"; "https://api4.my-ip.io/ip.txt"; "http://v4.ipv6-test.com/api/myip.php"} |
Web services which returns the remote IPv4 adress as simple text. No need to change. |
ipv6detectList |
array of strings |
{"https://api6.ipify.org/?format=text"; "https://api6.my-ip.io/ip.txt"; "http://v6.ipv6-test.com/api/myip.php"} |
Web services which returns the remote IPv6 adress as simple text. No need to change. |
domainEntryConfig |
arrays of strings |
:local domainEntryConfig {{"domain.com";"A";"@";"60";};{"domain.com";"AAAA";"@";"60";};}; |
See below how to format the arrays correctly. |
domainEntryConfig array data sheet
The domainEntryConfig array consists of multiple arrays. Each of the is configuring a DNS record for a given domain in a zone.
The data sheet below describes the formatting of the DNS records arrays.
| Array index |
Data |
Data type |
Example |
Description |
0 |
zone |
string |
"domain.com" |
Zone which should be used to set a record to. |
1 |
record type |
string |
"A" |
Valid values A, AAAA. The type of record which will be set. Also determines which IP (v4/v6) will be fetched. |
2 |
record name |
string |
"@" |
The record name which should be updated. Use @ for the root of your domain. |
3 |
record TTL |
string |
"60" |
TTL value of the record in seconds, for a dynamic entry a short lifetime like 60s is recommended. |
Configuration example:
:local domainEntryConfig {
{"mydomain.tld";"A";"ddns";"60";};
{"mydomain.tld";"AAAA";"ddns";"60";};
{"mydns.com";"AAAA","@";"60";};
};
This example will create & update those DNS records:
Script
# -------------------------------------------------------------------------------
# DDNS update script for Hetzner Cloud API
# with external IP detection
#
# by foorschtbar (https://blog.fotto.de/mikrotik-dyndns-ddns-script-hetzner-api-ipv4-and-or-ipv6/)
# modified by jandi for Hetzner Cloud compatibility
# Version 2.0
#
# Mikrotik Script Policy: read, write, test
#
# Credits:
# - 'ShokiNN (https://github.com/shokinn/hetzner-ddns-for-mikrotik)
# - kroteau (https://gist.github.com/kroteau/de05fa01c367a3329f85f99c0930e81)
# -------------------------------------------------------------------------------
#============= START OF CONFIG ===============
# --- Define variables ---
# Enter all required variables and secrets here. -- All secrets are stored unencrypted!
# API Key to authenticate to Hetzners API
:local apiKey "mysecretapikey"; # Example: "3su1OLc0gUhUdwxn1bmKFss5V19mBhBx"; -- This one is invalid, you don't need to try ;)
# Online services which respond with your IPv4
:local ipv4detectList {"https://api4.ipify.org/?format=text";"https://api4.my-ip.io/ip.txt";"http://v4.ipv6-test.com/api/myip.php";};
# Online services which respond with your IPv6
:local ipv6detectList {"https://api6.ipify.org/?format=text";"https://api6.my-ip.io/ip.txt";"http://v6.ipv6-test.com/api/myip.php";};
# --- Domain config ---
# Zone
# Zone which should be used to set a record to
# Data Type: String
# Example: "domain.com";
#
# Record type
# The type of record which will be set
# Data Type: String
# Valid values: "A", "AAAA"
# Example: "A";
#
# Record name
# Record name to be used to set a DNS entry
# Data Type: String
# Example: "@"; -- use @ to setup an entry at the root of your domain, e.g. "domain.com"
#
# Record TTL
# TTL value of the record in seconds, for a dynamic entry a short lifetime like 300 is recommended
# Data Type: String
# Example: "300";
#
# Array structure
# {
# "domain.com"; # Zone
# "A"; # Record type
# "@"; # Record name
# 60; # Record TTL
# };
:local domainEntryConfig {
{"mydomain.example";"A";"myhost";"60";};
{"mydomain.example";"AAAA";"myhost";"60";};
};
#============= END OF CONFIG ===============
:local logPrefix "[Hetzner DDNS]";
:local apiUrl "https://api.hetzner.cloud/v1";
:local scriptVersion "2.0";
:local getLocalIp do={
#:local ip [/ip address get [:pick [find interface="$configInterface"] 0] address];
#:return [:pick $ip 0 [:find $ip /]];
# Parameters: 1 - list of urls
:local extIP;
:foreach url in=$1 do={
:do { :set extIP ([/tool fetch url=$url output=user as-value]->"data")} on-error={ :log error "$logPrefix Service $url failed" };
if ( [:len "$extIP"]>0 ) do={ :return $extIP };
};
};
:local getRemoteIpv4 do={
:do {
:local ip [:resolve "$configDomain"];
:return "$ip";
} on-error={
return "";
};
};
:local getRemoteIpv6 do={
:local result [:toarray ""]
:local maxwait 5
:local cnt 0
:local listname "tmp-resolve$cnt"
/ipv6 firewall address-list {
:do {
:while ([:len [find list=$listname]] > 0) do={
:set cnt ($cnt + 1);
:set listname "tmp-resolve$cnt";
};
:set cnt 0;
add list=$listname address=$1;
:while ([find list=$listname && dynamic] = "" && $cnt < $maxwait) do={
:delay 1;:set cnt ($cnt +1)
};
:foreach i in=[find list=$listname && dynamic] do={
:local rawip [get $i address];
:set result ($result, [:pick $rawip 0 [:find $rawip "/"]]);
};
remove [find list=$listname && !dynamic];
};
};
:return $result;
};
:local hetznerSetRecord do={
# apiUrl, apiKey, zoneName, rrsetName, rrsetType, rrsetTtl, recordValue, logPrefix
[/system script run "JParseFunctions"; global JSONLoad; global JSONLoads; global JSONUnload];
:local date [/system clock get date]
:local time [/system clock get time]
:local comment "Updated at $date $time";
# Check if rrset already exists
:local existingRrset;
:local foundRrsets ([$JSONLoads ([/tool/fetch "$apiUrl/zones/$zoneName/rrsets?name=$rrsetName&type=$rrsetType" http-method=get http-header-field="Authorization:Bearer $apiKey" output=user as-value]->"data")]->"rrsets");
if ([:len $foundRrsets] > 0) do={
:set existingRrset ($foundRrsets->0);
};
# No RRSet found, check error message
:if ([:typeof $existingRrset ] = "nothing") do={
:log info "$logPrefix $rrsetName.$zoneName/$rrsetType: No existing RRSet found, creating new one (ttl=$rrsetTtl, value=$recordValue)";
:local payload "{\"name\": \"$rrsetName\",\"type\": \"$rrsetType\",\"ttl\": $([:tonum $rrsetTtl]),\"records\": [{\"value\": \"$recordValue\", \"comment\": \"$comment\"}]}";
:local apiResponse ([/tool/fetch "$apiUrl/zones/$zoneName/rrsets" http-method=post http-header-field="Content-Type:application/json,Authorization:Bearer $apiKey" http-data=$payload output=user as-value]->"status");
} else={
:local existingTtl ($existingRrset->"ttl");
:local existingValue ($existingRrset->"records"->0->"value");
:log info "$logPrefix $rrsetName.$zoneName/$rrsetType: Existing RRSet found (ttl=$existingTtl, value=$existingValue)";
# Check if TTL is correct, if not, update it
:if (($existingTtl != [:tonum $rrsetTtl])) do={
:log info "$logPrefix $rrsetName.$zoneName/$rrsetType: TTL differs, updating from $existingTtl to $rrsetTtl";
:local payload "{\"ttl\": $([:tonum $rrsetTtl])}";
:local apiResponse ([/tool/fetch "$apiUrl/zones/$zoneName/rrsets/$rrsetName/$rrsetType/actions/change_ttl" http-method=post http-header-field="Content-Type:application/json,Authorization:Bearer $apiKey" http-data=$payload output=user as-value]->"status");
};
# Check if record value is correct, if not, update it (check first record)
:if (($existingValue != $recordValue)) do={
:log info "$logPrefix $rrsetName.$zoneName/$rrsetType: Record value differs, updating from $existingValue to $recordValue";
:local payload "{\"records\": [{\"value\": \"$recordValue\", \"comment\": \"$comment\"}]}";
:local apiResponse ([/tool/fetch "$apiUrl/zones/$zoneName/rrsets/$rrsetName/$rrsetType/actions/set_records" http-method=post http-header-field="Content-Type:application/json,Authorization:Bearer $apiKey" http-data=$payload output=user as-value]->"status");
};
};
$JSONUnload;
}
# Log "run of script"
:log info "$logPrefix running (version $scriptVersion)";
:local index 0;
:foreach i in=$domainEntryConfig do={
:local configZone ("$($i->0)");
:local configType ("$($i->1)");
:local configRecord ("$($i->2)");
:local configTtl ("$($i->3)");
:local configDomain "";
:local interfaceIp "";
:local dnsIp "";
:local startLogMsg "$logPrefix Start configuring domain:";
:local endLogMsg "$logPrefix Finished configuring domain:";
:if ($configRecord = "@") do={
:set configDomain ("$($i->0)");
} else={
:set configDomain ("$($i->2).$($i->0)");
};
:if ($configType = "A") do={
:log info "$startLogMsg $configDomain - Type A record";
:set interfaceIp [$getLocalIp $ipv4detectList];
:set dnsIp [$getRemoteIpv4 configDomain=$configDomain];
:if ($interfaceIp != $dnsIp) do={
:log info "$logPrefix $configDomain: local IP ($interfaceIp) differs from DNS IP ($dnsIp) - Updating entry";
:local responseSetRecord [$hetznerSetRecord apiUrl=$apiUrl apiKey=$apiKey zoneName=$configZone rrsetName=$configRecord rrsetType=$configType rrsetTtl=$configTtl recordValue=$interfaceIp logPrefix=$logPrefix];
:if ($responseSetRecord = "finished") do={
:log info "$logPrefix $configDomain: update successful"
};
} else={
:log info "$logPrefix $configDomain: local IP and DNS IP are equal - Nothing to do";
}
:log info "$endLogMsg $configDomain - Type A record";
};
:if ($configType = "AAAA") do={
:log info "$startLogMsg $configDomain - Type AAAA record";
:set interfaceIp [$getLocalIp $ipv6detectList];
:set dnsIp [$getRemoteIpv6 $configDomain];
:if ($interfaceIp != $dnsIp) do={
:log info "$logPrefix $configDomain: local IP ($interfaceIp) differs from DNS IP ($dnsIp) - Updating entry";
:local responseSetRecord [$hetznerSetRecord apiUrl=$apiUrl apiKey=$apiKey zoneName=$configZone rrsetName=$configRecord rrsetType=$configType rrsetTtl=$configTtl recordValue=$interfaceIp logPrefix=$logPrefix];
:if ($responseSetRecord = "finished") do={
:log info "$logPrefix $configDomain: update successful"
};
} else={
:log info "$logPrefix $configDomain: local IP and DNS IP are equal - Nothing to do";
}
:log info "$endLogMsg $configDomain - Type AAAA record";
};
:if (($configType != "A") && ($configType != "AAAA")) do={
:log error ("$logPrefix Wrong record type for array index number " . $index . " (Value: $configType)");
};
:set index ($index+1);
};
:set index;
:log info "$logPrefix finished";
Credits for the base scripts: