inital commit

This commit is contained in:
Philip Henning 2023-11-10 21:35:42 +01:00
commit b03d1ac14a
3 changed files with 377 additions and 0 deletions

21
LICENSE Normal file
View file

@ -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.

90
README.md Normal file
View file

@ -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

266
ddns-hetzner.rsc Executable file
View file

@ -0,0 +1,266 @@
# -------------------------------------------------------------------------------
# DDNS update script for Hetzner's DNS API
#
# by Philip 'ShokiNN' Henning <mail@philip-henning.com>
# 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";