From 7873fb543c9b3c6eba8bbc173fdc5e24b63c941b Mon Sep 17 00:00:00 2001 From: shokinn Date: Mon, 5 Jan 2026 01:56:09 +0100 Subject: [PATCH] Add new DDNS script for Hetzner --- README.md | 2 +- ddns-hetzner.rsc | 240 ++++++++++++++++++++++++++++++++++++++++++++ doc/ddns-hetzner.md | 146 +++++++++++++++++++++++++++ 3 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 ddns-hetzner.rsc create mode 100644 doc/ddns-hetzner.md diff --git a/README.md b/README.md index b1625fa..fc9e918 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ddns-hetzner.rsc b/ddns-hetzner.rsc new file mode 100644 index 0000000..cdeb4f2 --- /dev/null +++ b/ddns-hetzner.rsc @@ -0,0 +1,240 @@ +#!rsc by RouterOS +# RouterOS script: ddns-hetzner +# Version 2.0 +# Copyright (c) 2024-2026 Philip 'ShokiNN' Henning +# 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; +} diff --git a/doc/ddns-hetzner.md b/doc/ddns-hetzner.md new file mode 100644 index 0000000..4a628dc --- /dev/null +++ b/doc/ddns-hetzner.md @@ -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)