routeros-scripts-custom/ddns-hetzner.rsc

236 lines
11 KiB
Text

#!rsc by RouterOS
# RouterOS script: ddns-hetzner
# Version 2.0.2
# 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;
# 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={
:local Records;
:local AnnouncedIP;
:onerror GetAnnouncedIPErr in={
$LogPrint debug $ScriptName ("GetAnnouncedIP - started");
:set Records ([:deserialize from=json value=([/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;
} do={
$LogPrint debug $ScriptName ("GetAnnouncedIP - Error Message: " . $GetAnnouncedIPErr);
:if ([:find "$GetAnnouncedIPErr" "status 404";] >= 1) do={
$LogPrint debug $ScriptName ("GetAnnouncedIP - Announced IP is not set");
:return false;
}
:error ("GetAnnouncedIP - API Error - $GetAnnouncedIPErr");
}
:return $AnnouncedIP;
}
:local APISetRecord do={
:local APIResponse;
:onerror APISetRecordErr in={
$LogPrint debug $ScriptName ("APISetRecord - started");
:local Records;
:local Record;
:local Payload;
:onerror GetRecordsErr in={
:set Records ([:deserialize from=json value=([/tool/fetch "$APIUrl/zones/$ZoneName/rrsets/$RecordName/$RecordType" http-method=get http-header-field="Authorization: Bearer $APIToken" output=user as-value]->"data")]->"rrset"->"records");
} do={
:if ([:find "$GetRecordsErr" "status 404";] >= 1) do={
:set Records [:toarray ""];
} else={
$LogPrint error $ScriptName ("APISetRecord - Could not get record from API - $GetRecordsErr");
}
}
$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);
$LogPrint debug $ScriptName ("APISetRecord - finished");
:return $APIResponse;
} do={
#TODO Send error via Notification system
$LogPrint error $ScriptName ("Could not set record - Zone: " . $ZoneName . ", RecordName: " . $RecordName . ", RecordType: " . $RecordType . " - API Error: " . $APISetRecordErr);
}
:return $APIResponse;
}
$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;
}