commit b03d1ac14a938bcb8f0e2c4ea5be12242f08ac2c Author: shokinn Date: Fri Nov 10 21:35:42 2023 +0100 inital commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1fa64ce --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Philip Henning + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4adc348 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# hetzner-ddns-for-mikrotik + +This Mikrotik RouterOS script for updating DNS entries via Hetzner's DNS API. + +## Table of Contents + +- [hetzner-ddns-for-mikrotik](#hetzner-ddns-for-mikrotik) + - [Table of Contents](#table-of-contents) + - [How does is works?](#how-does-is-works) + - [Setup](#setup) + - [Script configuration data](#script-configuration-data) + - [`domainEntryConfig` array data sheet](#domainentryconfig-array-data-sheet) + +## How does is works? + +The scripts checks the defined interfaces' IP's for the configured [FQDN's](https://en.wikipedia.org/wiki/Fully_qualified_domain_name). +This is achieved via plain DNS. + +If the IP from the interface differs from the IP configures in the DNS record, the DNS record will be updated accordingly to the interfaces' IP. + +## Setup + +1. Dependencies + 1. This script requires [Winand](https://github.com/Winand)'s [mikrotik-json-parser](https://github.com/Winand/mikrotik-json-parser). +2. Create a new Script `System -> Scripts` + 1. Name: `ddns-hetzner` + 2. Policy: `read`, `write`, `test`, uncheck everything else + 3. Source: Copy the script here +3. Create a [API token for Hetzner's DNS API](https://dns.hetzner.com/settings/api-token) +4. Configure the script to your needs, check the description in the script or below for information how to configure it +5. Create another new script + 1. Name: `JParseFunctions` + 2. Policy: `read`, `write`, `test` uncheck everything else + 3. Source: The content of [mikrotik-json-parser](https://github.com/Winand/mikrotik-json-parser/blob/master/JParseFunctions) +6. Create a new Schedule `System -> Schedule` + 1. Name: `ddns-hetzner` + 2. Start Date: leave it as it is + 3. Start Time: leave it as it is + 4. Interval: `00:05:00` + 5. Policy: `read`, `write`, `test` uncheck everything else + 6. On Event: `ddns-hetzner` + +### Script configuration data + +| Variable name | Data type | Example | Description | +| ------------------: | :-------------------: | :------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `apiKey` | `string` | `:local apiKey "3su1OLc0gUhUdwxn1bmKFss5V19mBhBx";` | This variable requires a valid API token for the [Hetzner DNS API](https://dns.hetzner.com/api-docs). You can create an [API token here](https://dns.hetzner.com/settings/api-token). | +| `domainEntryConfig` | `array`s of `string`s | `:local domainEntryConfig {{"pppoe-out1";"";"domain.com";"A";"@";"300";};{"pppoe-out1";"pool-ipv6";"domain.com";"AAAA";"@";"300";};};` | 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` | `interface` | `string` | `"pppoe-out1"` | Name of the interface where the IP which is currently configured is fetched from. | +| `1` | `pool` | `string` | `"pool-ipv6"` | The prefix delegation pool which is used to automatically setup the IPv6 interface IP. Use "" when you don't use a pool to set your interface ip or for a type A record. | +| `2` | `zone` | `string` | `"domain.com"` | Zone which should be used to set a record to. | +| `3` | `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. | +| `4` | `record name` | `string` | `"@"` | The record name which should be updated. Use `@` for the root of your domain. | +| `5` | `record TTL` | `string` | `"300"` | TTL value of the record in seconds, for a dynamic entry a short lifetime like 300 is recommended. | + +Configuration example: + +``` +:local domainEntryConfig { + { + "pppoe-out1"; + ""; + "domain.com"; + "A"; + "@"; + "300"; + }; + {"pppoe-out1";"pool-ipv6";"domain.com";"AAAA";"@";"300";}; + {"pppoe-out1";"";"example.de";"A";"ddns";"300";}; + {"pppoe-out1";"pool-ipv6";"abc.xzy";"AAAA";"ddns";"300";}; +}; +``` + +This example will create & update those DNS records: +- domain.com + - IPv4 + - IPv6 +- example.de + - IPv4 +- abc.xzy + - IPv6 diff --git a/ddns-hetzner.rsc b/ddns-hetzner.rsc new file mode 100755 index 0000000..1aefa4d --- /dev/null +++ b/ddns-hetzner.rsc @@ -0,0 +1,266 @@ +# ------------------------------------------------------------------------------- +# DDNS update script for Hetzner's DNS API +# +# by Philip 'ShokiNN' Henning +# Version 1.0 +# last update: 10.11.2023 +# License: MIT +# ------------------------------------------------------------------------------- + + + + + + + +# --- Define variables ----------------------------------------------------------------------------------------- +# Enter all required variables and secrets here. -- All secrets are stored unencrypted! +## API Key to authenticate to Hetzners API +### Data Type: String +:local apiKey ""; # Example: "3su1OLc0gUhUdwxn1bmKFss5V19mBhBx"; -- This one is invalid, you don't need to try ;) + +## --- Domain config -------------------------------------------------------------------------------- +# Interface +# The interface name where the IP should be fetched from +# Data Type: String +# Example: "pppoe-out1"; +# +# Pool +# The prefix delegation pool which is used to automatically setup the IPv6 interface IP +# Use "" when you don't use a pool to set your interface ip or for a type A record +# Data Type: String +# Example: "pool-ipv6"; +# +# 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 +# { +# "pppoe-out1"; # Interface +# ""; # Pool +# "domain.com"; # Zone +# "A"; # Record type +# "@"; # Record name +# 300; # Record TTL +# }; +## -------------------------------------------------------------------------------------------------- +:local domainEntryConfig { + { + "pppoe-out1"; + ""; + "domain.com"; + "A"; + "@"; + "300"; + }; + {"pppoe-out1";"pool-ipv6";"domain.com";"AAAA";"@";"300";}; +}; +# --------------------------------------------------------------------------------------------------------------- + + + +:local logPrefix "[Hetzner DDNS]"; +:local apiUrl "https://dns.hetzner.com/api/v1"; + + + +:local getLocalIpv4 do={ + :local ip [/ip address get [:pick [find interface="$configInterface"] 0] address]; + :return [:pick $ip 0 [:find $ip /]]; +}; + +:local getLocalIpv6 do={ + :local ip [/ipv6 address get [:pick [find interface="$configInterface" from-pool="$configInterfacePool" !link-local] 0] address]; + :return [:pick $ip 0 [:find $ip /]]; +}; + +:local getRemoteIpv4 do={ + :do { + :local ip [:resolve "$configDomain"]; + :return "$ip"; + } on-error={ + return ""; + }; +}; + +:local getRemoveIpv6 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 apiGetZones do={ + [/system script run "JParseFunctions"; global JSONLoad; global JSONLoads; global JSONUnload]; + + :local apiPage -0; + :local apiNextPage 1; + :local apiLastPage 0; + :local apiResponse ""; + :local returnArr [:toarray ""]; + + :do { + :set apiResponse ([/tool/fetch "$apiUrl/zones?page=$apiNextPage&search_name=$configZone" http-method=get http-header-field="Auth-API-Token:$apiKey" output=user as-value]->"data"); + + :set apiPage ([$JSONLoads $apiResponse]->"meta"->"pagination"->"page"); + :set apiNextPage ([$JSONLoads $apiResponse]->"meta"->"pagination"->"next_page"); + :set apiLastPage ([$JSONLoads $apiResponse]->"meta"->"pagination"->"last_page"); + + :set returnArr ($returnArr , ([:toarray ([$JSONLoads $apiResponse]->"zones")])); + } while=($apiPage != $apiLastPage); + $JSONUnload; + + :return $returnArr; +}; + +:local apiGetZoneId do={ + :foreach responseZone in=$responseZones do={ + :if (($responseZone->"name") = $configZone) do={ + :return ($responseZone->"id"); + }; + }; +}; + +:local apiSetRecord do={ + #apiUrl=$apiUrl apiKey=$apiKey zoneId=$zoneId configType=$configType configRecord=$configRecord configTtl=$configTtl interfaceIp=$interfaceIp + [/system script run "JParseFunctions"; global JSONLoad; global JSONLoads; global JSONUnload]; + + :local recordId ""; + :local apiResponse ""; + :local payload "{\"zone_id\": \"$zoneId\",\"type\": \"$configType\",\"name\": \"$configRecord\",\"value\": \"$interfaceIp\",\"ttl\": $([:tonum $configTtl])}"; + :local records ([$JSONLoads ([/tool/fetch "$apiUrl/records?zone_id=$zoneId" http-method=get http-header-field="Auth-API-Token:$apiKey" output=user as-value]->"data")]->"records"); + + :foreach record in=$records do={ + :if ((($record->"name") = $configRecord) && (($record->"type") = $configType)) do={ + :set recordId ($record->"id"); + } + }; + + :if ($recordId != "") do={ + :set apiResponse ([/tool/fetch "$apiUrl/records/$recordId" http-method=put http-header-field="Content-Type:application/json,Auth-API-Token:$apiKey" http-data=$payload output=user as-value]->"status"); + } else={ + :set apiResponse ([/tool/fetch "$apiUrl/records" http-method=post http-header-field="Content-Type:application/json,Auth-API-Token:$apiKey" http-data=$payload output=user as-value]->"status"); + }; + + $JSONUnload; + return $apiResponse; +}; + + +# Log "run of script" +:log info "$logPrefix running"; + +:local index 0; +:foreach i in=$domainEntryConfig do={ + :local configInterface ("$($i->0)"); + :local configIpv6Pool ("$($i->1)"); + :local configZone ("$($i->2)"); + :local configType ("$($i->3)"); + :local configRecord ("$($i->4)"); + :local configTtl ("$($i->5)"); + :local configDomain ""; + :local interfaceIp ""; + :local dnsIp ""; + :local startLogMsg "$logPrefix Start configuring domain:"; + :local endLogMsg "$logPrefix Finished configuring domain:"; + + + :if ($configRecord = "@") do={ + :set configDomain ("$($i->2)"); + } else={ + :set configDomain ("$($i->4).$($i->2)"); + }; + + + :if ($configType = "A") do={ + :log info "$startLogMsg $configDomain - Type A record"; + + :set interfaceIp [$getLocalIpv4 configInterface=$configInterface]; + :set dnsIp [$getRemoteIpv4 configDomain=$configDomain]; + + :if ($interfaceIp != $dnsIp) do={ + :log info "$logPrefix $configDomain: local IP ($interfaceIp) differs from DNS IP ($dnsIp) - Updating entry"; + + :local responseZones [$apiGetZones apiUrl=$apiUrl apiKey=$apiKey configZone=$configZone]; + :local zoneId [$apiGetZoneId responseZones=$responseZones configZone=$configZone]; + :local responseSetRecord [$apiSetRecord apiUrl=$apiUrl apiKey=$apiKey zoneId=$zoneId configType=$configType configRecord=$configRecord configTtl=$configTtl interfaceIp=$interfaceIp]; + :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 [$getLocalIpv6 configInterface=$configInterface configInterfacePool=$configIpv6Pool]; + :set dnsIp [$getRemoveIpv6 $configDomain]; + + :if ($interfaceIp != $dnsIp) do={ + :log info "$logPrefix $configDomain: local IP ($interfaceIp) differs from DNS IP ($dnsIp) - Updating entry"; + + :local responseZones [$apiGetZones apiUrl=$apiUrl apiKey=$apiKey configZone=$configZone]; + :local zoneId [$apiGetZoneId responseZones=$responseZones configZone=$configZone]; + :local responseSetRecord [$apiSetRecord apiUrl=$apiUrl apiKey=$apiKey zoneId=$zoneId configType=$configType configRecord=$configRecord configTtl=$configTtl interfaceIp=$interfaceIp]; + :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";