Add new DDNS script for Hetzner

This commit is contained in:
Philip Henning 2026-01-05 01:56:09 +01:00
parent 6b24c409f0
commit 7873fb543c
3 changed files with 387 additions and 1 deletions

View file

@ -87,7 +87,7 @@ $ScriptInstallUpdate ddns-hetzner,dns-to-ipv6-subnet-resolver "base-url=https://
## Available scripts
- [Hello World](doc/hello-world.md)
- [DDNS (DynDNS) Hetzner update script](doc/ddns-hetzner.md)
- [DNS to IPv6 subnet resolver](doc/dns-to-ipv6-subnet-resolver.md)
## License and warranty

240
ddns-hetzner.rsc Normal file
View file

@ -0,0 +1,240 @@
#!rsc by RouterOS
# RouterOS script: ddns-hetzner
# Version 2.0
# Copyright (c) 2024-2026 Philip 'ShokiNN' Henning <mail@philip-henning.com>
# https://git.s1q.dev/phg/routeros-scripts-custom/about/COPYING.md
#
# requires RouterOS, version=7.18
#
# Updates periodically DNS entries on Hetzner's DNS service with the Router's public IPs
# https://git.s1q.dev/phg/routeros-scripts-custom/src/branch/main/doc/ddns-hetzner.md
:local ExitOK false;
onerror Err {
:global GlobalConfigReady; :global GlobalFunctionsReady; :global GlobalFunctionsCustomPhgReady;
:retry { :if ($GlobalConfigReady != true || $GlobalFunctionsReady != true || $GlobalFunctionsCustomPhgReady != true) \
do={ :error ("Global config and/or functions not ready."); };
} delay=500ms max=50;
:local ScriptName [ :jobname ];
:global LogPrint;
:global ParseKeyValueStore;
:global ScriptLock;
:global SafeResolve;
# Local/global script specific variables
:global PhgDDNSHetznerAPIToken;
:global PhgDDNSHetznerDomainEntryConfig;
:local APIUrl "https://api.hetzner.cloud/v1";
:if ([ $ScriptLock $ScriptName ] = false) do={
:set ExitOK true;
:error false;
}
:local GetLocalIPv4 do={
:local IP [/ip/address/get [:pick [find interface="$WANInterface"] 0] address];
:return [:pick $IP 0 [:find $IP /]];
}
:local GetLocalIPv6 do={
:local IP [/ipv6/address/get [:pick [find interface="$WANInterface" from-pool="$PublicIPv6Pool" !link-local] 0] address];
:return [:pick $IP 0 [:find $IP /]];
}
:local GetAnnouncedIP do={
:do {
$LogPrint debug $ScriptName ("GetAnnouncedIP - started");
[/system/script/run "JParseFunctions"; global JSONLoad; global JSONLoads; global JSONUnload];
$LogPrint debug $ScriptName ("GetAnnouncedIP - JParseFunctions loaded");
:local Records;
:local AnnouncedIP;
:set Records ([$JSONLoads ([/tool/fetch "$APIUrl/zones/$ZoneName/rrsets/$RecordName/$RecordType" http-method=get http-header-field="Authorization: Bearer $APIToken" output=user as-value]->"data")]->"rrset"->"records");
$LogPrint debug $ScriptName ("GetAnnouncedIP - Records received: " . [:len $Records]);
foreach rec in=$Records do={
$LogPrint debug $ScriptName ("GetAnnouncedIP - Record: Name: \"" . $RecordName . "\", Type: \"" . $RecordType . "\", Value: \"" . ($rec->"value") . "\", Comment: \"" . ($rec->"comment") . "\"");
}
:if ([:len $Records] > 1) do={
:error ("Multiple records found for \"$RecordName.$ZoneName\", RecordType: $RecordType. This is not supported.");
} else={
:if ([:len $Records] = 1) do={
:set AnnouncedIP ($Records->0->"value");
}
}
$LogPrint debug $ScriptName ("GetAnnouncedIP - Announced IP is: " . $AnnouncedIP);
:return $AnnouncedIP;
} on-error={
:local Err $message;
:if ([:find $Err "404"] != -1) do={
$LogPrint debug $ScriptName ("GetAnnouncedIP - Announced IP is not set");
:return false;
}
:error ("GetAnnouncedIP - API Error - $Err");
}
}
:local APISetRecord do={
:do {
$LogPrint debug $ScriptName ("APISetRecord - started");
[/system/script/run "JParseFunctions"; global JSONLoad; global JSONLoads; global JSONUnload];
$LogPrint debug $ScriptName ("APISetRecord - JParseFunctions loaded");
:local Records;
:local Record;
:local APIResponse;
:local Payload;
:do {
:set Records ([$JSONLoads ([/tool/fetch "$APIUrl/zones/$ZoneName/rrsets/$RecordName/$RecordType" http-method=get http-header-field="Authorization: Bearer $APIToken" output=user as-value]->"data")]->"rrset"->"records");
} on-error={
:if ([:find $message "404"] != -1) do={
:set Records [:toarray ""];
} else={
$LogPrint error $ScriptName ("APISetRecord - Could not get record from API - $message");
}
}
$LogPrint debug $ScriptName ("APISetRecord - Records received: " . [:len $Records]);
foreach rec in=$Records do={
$LogPrint debug $ScriptName ("APISetRecord - Record: Name: \"" . $RecordName . "\", Type: \"" . $RecordType . "\", Value: \"" . ($rec->"value") . "\", Comment: \"" . ($rec->"comment") . "\"");
}
:if ([:len $Records] > 1) do={
:error ("Multiple records found for \"$RecordName.$ZoneName\", RecordType: $RecordType. This is not supported.");
} else={
:if ([:len $Records] = 1) do={
:set Record ($Records->0);
}
}
:local RecordDebugLogOutput;
foreach key,value in=$Record do={
:if ([:typeof $RecordDebugLogOutput ] != "str" || $RecordDebugLogOutput = "") do={
:set RecordDebugLogOutput ($key . ": \"" . $value . "\"");
} else={
:set RecordDebugLogOutput ($RecordDebugLogOutput . ", " . $key . ": \"" . $value . "\"");
}
}
$LogPrint debug $ScriptName ("APISetRecord - Picked Record: " . $RecordDebugLogOutput);
:if ([:typeof $Record] != "nothing") do={
:set Payload "{\"records\":[{\"value\":\"$InterfaceIP\",\"comment\":\"Updated by RouterOS DDNS Script\"}]}";
$LogPrint debug $ScriptName ("APISetRecord - Payload: " . $Payload);
$LogPrint debug $ScriptName ("APISetRecord - Updating existing record - URL: $APIUrl/zones/$ZoneName/rrsets/$RecordName/$RecordType/actions/set_records");
:set APIResponse ([/tool/fetch "$APIUrl/zones/$ZoneName/rrsets/$RecordName/$RecordType/actions/set_records" http-method=post http-header-field="Content-Type: application/json,Authorization: Bearer $APIToken" http-data=$Payload output=user as-value]->"status");
} else={
:set Payload "{\"name\":\"$RecordName\",\"type\":\"$RecordType\",\"ttl\":$([:tonum $RecordTTL]),\"records\":[{\"value\":\"$InterfaceIP\",\"comment\":\"Updated by RouterOS DDNS Script\"}]}";
$LogPrint debug $ScriptName ("APISetRecord - Payload: " . $Payload);
$LogPrint debug $ScriptName ("APISetRecord - Creating new record - URL: $APIUrl/zones/$ZoneName/rrsets");
:set APIResponse ([/tool/fetch "$APIUrl/zones/$ZoneName/rrsets" http-method=post http-header-field="Content-Type: application/json,Authorization: Bearer $APIToken" http-data=$Payload output=user as-value]->"status");
}
$LogPrint debug $ScriptName ("APISetRecord - APIResponse: " . $APIResponse);
$JSONUnload;
$LogPrint debug $ScriptName ("APISetRecord - JSONUnload done");
$LogPrint debug $ScriptName ("APISetRecord - finished");
return $APIResponse;
} on-error={
#TODO Send error via Notification system
$LogPrint error $ScriptName ("Could not set record - $message");
}
}
$LogPrint debug $ScriptName ("Begin DDNS update process");
:local index 0;
:foreach i in=$PhgDDNSHetznerDomainEntryConfig do={
:local WANInterface ("$($i->0)");
:local PublicIPv6Pool ("$($i->1)");
:local ZoneName ("$($i->2)");
:local RecordType ("$($i->3)");
:local RecordName ("$($i->4)");
:local RecordTTL ("$($i->5)");
:local FQDN;
:local InterfaceIP;
:local DNSIP;
:local StartLogMsg "Start configuring domain: ";
:local EndLogMsg "Finished configuring domain: ";
:if ($RecordName = "@") do={
:set FQDN ("$($i->2)");
} else={
:set FQDN ("$($i->4).$($i->2)");
}
:if ($RecordType = "A") do={
$LogPrint debug $ScriptName ($StartLogMsg . $FQDN . " - Type A Record");
:set InterfaceIP [$GetLocalIPv4 WANInterface=$WANInterface];
:set DNSIP [$GetAnnouncedIP APIUrl=$APIUrl APIToken=$PhgDDNSHetznerAPIToken ZoneName=$ZoneName RecordType=$RecordType RecordName=$RecordName LogPrint=$LogPrint ScriptName=$ScriptName];
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - Announced DNS IP: " . $DNSIP);
:if ($InterfaceIP != $DNSIP) do={
:if ($DNSIP = false) do={
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - local IP: " . $InterfaceIP . ", differs from DNS IP: none");
} else={
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - local IP: " . $InterfaceIP . ", differs from DNS IP: " . $DNSIP);
}
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - Updating A Record to " . $InterfaceIP);
:local ResponseSetRecord [$APISetRecord APIUrl=$APIUrl APIToken=$PhgDDNSHetznerAPIToken ZoneName=$ZoneName RecordType=$RecordType RecordName=$RecordName RecordTTL=$RecordTTL InterfaceIP=$InterfaceIP LogPrint=$LogPrint ScriptName=$ScriptName];
$LogPrint debug $ScriptName ("ResponseSetRecord: " . $ResponseSetRecord);
:if ($ResponseSetRecord = "finished") do={
$LogPrint info $ScriptName ("Domain: " . $FQDN . " - Updating A Record to " . $InterfaceIP . " successful");
}
} else={
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - local IP: " . $InterfaceIP . ", is equal to DNS IP: " . $DNSIP . " - Nothing to do");
}
$LogPrint debug $ScriptName ($EndLogMsg . $FQDN . " - Type A Record");
}
:if ($RecordType = "AAAA") do={
$LogPrint debug $ScriptName ($StartLogMsg . $FQDN . " - Type AAAA Record");
:set InterfaceIP [$GetLocalIPv6 WANInterface=$WANInterface PublicIPv6Pool=$PublicIPv6Pool];
:set DNSIP [$GetAnnouncedIP APIUrl=$APIUrl APIToken=$PhgDDNSHetznerAPIToken ZoneName=$ZoneName RecordType=$RecordType RecordName=$RecordName LogPrint=$LogPrint ScriptName=$ScriptName];
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - Announced DNS IP: " . $DNSIP);
:if ($InterfaceIP != $DNSIP) do={
:if ($DNSIP = false) do={
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - local IP: " . $InterfaceIP . ", differs from DNS IP: none");
} else={
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - local IP: " . $InterfaceIP . ", differs from DNS IP: " . $DNSIP);
}
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - Updating AAAA Record to " . $InterfaceIP);
:local ResponseSetRecord [$APISetRecord APIUrl=$APIUrl APIToken=$PhgDDNSHetznerAPIToken ZoneName=$ZoneName RecordType=$RecordType RecordName=$RecordName RecordTTL=$RecordTTL InterfaceIP=$InterfaceIP LogPrint=$LogPrint ScriptName=$ScriptName];
$LogPrint debug $ScriptName ("ResponseSetRecord: " . $ResponseSetRecord);
:if ($ResponseSetRecord = "finished") do={
$LogPrint info $ScriptName ("Domain: " . $FQDN . " - Updating AAAA Record to " . $InterfaceIP . " successful");
}
} else={
$LogPrint debug $ScriptName ("Domain: " . $FQDN . " - local IP: " . $InterfaceIP . ", is equal to DNS IP: " . $DNSIP . " - Nothing to do");
}
$LogPrint debug $ScriptName ($EndLogMsg . $FQDN . " - Type AAAA Record");
}
:if (($RecordType != "A") && ($RecordType != "AAAA")) do={
$LogPrint error $ScriptName ("Wrong Record type for array index number " . $index . " (Value: " . $RecordType . ")");
}
:set index ($index+1);
}
:set index;
$LogPrint debug $ScriptName ("Finished DDNS update process");
} do={
:global ExitError; $ExitError $ExitOK [ :jobname ] $Err;
}

146
doc/ddns-hetzner.md Normal file
View file

@ -0,0 +1,146 @@
# DDNS (DynDNS) Hetzner update script
[⬅️ Go back to main README](../README.md)
> **Info**: This script can not be used on its own but requires the base
> installation. See [main README](../README.md) for details.
## Table of Contents
- [DDNS (DynDNS) Hetzner update script](#ddns-dyndns-hetzner-update-script)
- [Table of Contents](#table-of-contents)
- [Description](#description)
- [Requirements and installation](#requirements-and-installation)
- [Dependencies](#dependencies)
- [Installation](#installation)
- [Pre requisites](#pre-requisites)
- [Installation](#installation-1)
- [Configuration](#configuration)
- [`PhgDDNSHetznerAPIToken`](#phgddnshetznerapitoken)
- [`PhgDDNSHetznerDomainEntryConfig`](#phgddnshetznerdomainentryconfig)
- [Usage and invocation](#usage-and-invocation)
- [See also](#see-also)
## Description
This Mikrotik RouterOS 7 script for updating DNS entries via Hetzner's Cloud API.
The script is currently only compatible with RouterOS 7.
RouterOS 6 isn't and won't be supported!
## Requirements and installation
### Dependencies
This script requires [Winand](https://github.com/Winand)'s [mikrotik-json-parser](https://github.com/Winand/mikrotik-json-parser) to be installed.
#### Installation
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)
### Pre requisites
> [!IMPORTANT]
> **It's strongly recommended to create a separate Project just for your DNS Zone!**
> Because the API Token you will create will have Read/Write access to the whole Project it can't be restricted to specific services like DNS.
Create a [API token for Hetzner's Cloud API](https://docs.hetzner.cloud/reference/cloud#getting-started).
The API token can be created at:
`Your cloud project` -> `Security` -> `API-Tokens`
### Installation
Just install the script:
```rsc
$ScriptInstallUpdate ddns-hetzner "base-url=https://git.s1q.dev/phg/routeros-scripts-custom/raw/branch/main/";
/system/script/set [find name="ddns-hetzner"] policy=read,write,test
```
## Configuration
Edit `global-config-overlay` and add the following variables.
| Variable name | Requried | Data type | Example | Description |
| :-------------------------------- | :------- | :-------------------- | :------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PhgDDNSHetznerAPIToken` | true | `string` | `LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj` | This variable requires a valid API token for the [Hetzner DNS API](https://docs.hetzner.cloud/reference/cloud#getting-started). You can create an API token in you project settings. |
| `PhgDDNSHetznerDomainEntryConfig` | true | `array`s of `string`s | `{{"pppoe-out1";"";"example.com";"A";"@";"300";};{"pppoe-out1";"pool-ipv6";"example.com";"AAAA";"@";"300";};}` | See below how to format the arrays correctly. |
### `PhgDDNSHetznerAPIToken`
Example:
```rsc
:global PhgDDNSHetznerAPIToken "LRK9DAWQ1ZAEFSrCNEEzLCUwhYX1U3g7wMg4dTlkkDC96fyDuyJ39nVbVjCKSDfj";
```
### `PhgDDNSHetznerDomainEntryConfig`
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. |
Example:
```rsc
:global PhgDDNSHetznerDomainEntryConfig {
{
"pppoe-out1";
"";
"example.com";
"A";
"@";
"300";
};
{"pppoe-out1";"pool-ipv6";"example.com";"AAAA";"@";"300";};
{"pppoe-out1";"";"example.net";"A";"ddns";"300";};
{"pppoe-out1";"pool-ipv6";"example.org";"AAAA";"ddns";"300";};
};
```
This example will create & update those DNS records:
- example.com
- IPv4
- IPv6
- example.net
- IPv4
- example.org
- IPv6
## Usage and invocation
How to run the script manually:
```rsc
/system/script/run ddns-hetzner;
```
Setup a Scheduler to run the script regularly:
```rsc
/system/scheduler/add name="ddns-hetzner" interval="00:05:00" policy="read,write,test" on-event="/system/script/run ddns-hetzner;";
```
## See also
* ...
---
[⬅️ Go back to main README](../README.md)
[⬆️ Go back to top](#top)