MikroTik DynDNS/DDNS Script Hetzner API (IPv4 and/or IPv6)

I just had the problem, that the MikroTik build in Cloud DDNS service struggling in the last days. Sometime it was simple not reachable, but today they created a IPv6 (AAAA) entry for my cloud unique domain - but IPv6 is not enabled between my upstream modem and my MikroTik router. So I decided to create a simple script which updates only the IPv4 and/or IPv6 address of my recods directly on the Hetzner DNS API. To detect my current public IP address (external WAN adress) I added support for multiple services which will be tried in a row.

Basic setup

  1. Setup Hetzner DNS
    1. Create DNS A and/or AAAA records with a TTL of 30s in a Zone. Use @ for a full domain or a subdomain. This step is important to avoid DNS caching issues.
    2. Create a API token for Hetzner's DNS API
  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. Configure the script to your needs, check the description in the script or below for information how to configure it
  4. This script requires Winand's mikrotik-json-parser. Create another new script
    1. Name: JParseFunctions
    2. Policy: read, write, test uncheck everything else
    3. Source: The content of mikrotik-json-parser
  5. 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:02:00
    5. Policy: read, write, test uncheck everything else
    6. On Event: ddns-hetzner
  6. Setup script configuration:

Script configuration

Variable name Data type Example Description
apiKey string "3su1OLc0gUhUdwxn1bmKFss5V19mBhBx"; This variable requires a valid API token for the Hetzner DNS API. You can create an API token here.
ipv4detectList array of strings {"https://api4.ipify.org/?format=text", "https://api4.my-ip.io/ip.txt"; "http://v4.ipv6-test.com/api/myip.php"} Web services which returns the remote IPv4 adress as simple text. No need to change.
ipv6detectList array of strings {"https://api6.ipify.org/?format=text", "https://api6.my-ip.io/ip.txt"; "http://v6.ipv6-test.com/api/myip.php"} Web services which returns the remote IPv6 adress as simple text. No need to change.
domainEntryConfig arrays of strings :local domainEntryConfig {{"domain.com";"A";"@";"60";};{"domain.com";"AAAA";"@";"60";};}; 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 zone string "domain.com" Zone which should be used to set a record to.
1 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.
2 record name string "@" The record name which should be updated. Use @ for the root of your domain.
3 record TTL string "60" TTL value of the record in seconds, for a dynamic entry a short lifetime like 60s is recommended.

Configuration example:

:local domainEntryConfig {
    {"mydomain.tld";"A";"ddns";"60";}; # Example
    {"mydomain.tld";"AAAA";"ddns";"60";}; # Example
    {"mydns.com";"AAAA","@";"60";}; # Example
};

This example will create & update those DNS records:

  • mydomain.tld
    • IPv4
    • IPv6
  • mydns.com
    • IPv6

Script

# -------------------------------------------------------------------------------
# DDNS update script for Hetzner's DNS API
# with external IP detection
# 
# by foorschtbar (https://blog.fotto.de/mikrotik-dyndns-ddns-script-hetzner-api-ipv4-and-or-ipv6/)
# Version 1.0
#
# Credits:
# - 'ShokiNN (https://github.com/shokinn/hetzner-ddns-for-mikrotik)
# - kroteau (https://gist.github.com/kroteau/de05fa01c367a3329f85f99c0930e81)
# -------------------------------------------------------------------------------

#============= START OF CONFIG ===============
# --- Define variables --- 
# Enter all required variables and secrets here. -- All secrets are stored unencrypted!
# API Key to authenticate to Hetzners API
:local apiKey ""; # Example: "3su1OLc0gUhUdwxn1bmKFss5V19mBhBx"; -- This one is invalid, you don't need to try ;)
# Online services which respond with your IPv4
:local ipv4detectList {"https://api4.ipify.org/?format=text", "https://api4.my-ip.io/ip.txt"; "http://v4.ipv6-test.com/api/myip.php"} 
# Online services which respond with your IPv6
:local ipv6detectList {"https://api6.ipify.org/?format=text", "https://api6.my-ip.io/ip.txt"; "http://v6.ipv6-test.com/api/myip.php"}

# --- Domain config ---
# 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
# {
#     "domain.com"; # Zone
#     "A"; # Record type
#     "@"; # Record name
#     60; # Record TTL
# };
:local domainEntryConfig {
    {"mydomain.tld";"A";"ddns";"60";}; # Example
    {"mydomain.tld";"AAAA";"ddns";"60";}; # Example
    {"mydns.com";"AAAA","@";"60";}; # Example
};
#============= END OF CONFIG ===============

:local logPrefix "[Hetzner DDNS]";
:local apiUrl "https://dns.hetzner.com/api/v1";

:local getLocalIp do={
    #:local ip [/ip address get [:pick [find interface="$configInterface"] 0] address];
    #:return [:pick $ip 0 [:find $ip /]];
     # Parameters: 1 - list of urls
    :local extIP;
    :foreach url in=$1 do={
        :do { :set extIP ([/tool fetch url=$url output=user as-value]->"data")} on-error={ :log error "DDNS: Service $url failed" };
        if ( [:len "$extIP"]>0 ) do={ :return $extIP };
    };
};

:local getRemoteIpv4 do={
    :do {
        :local ip [:resolve "$configDomain"];
        :return "$ip";
    } on-error={
        return "";
    };
};

:local getRemoteIpv6 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 configZone ("$($i->0)");
    :local configType ("$($i->1)");
    :local configRecord ("$($i->2)");
    :local configTtl ("$($i->3)");
    :local configDomain "";
    :local interfaceIp "";
    :local dnsIp "";
    :local startLogMsg "$logPrefix Start configuring domain:";
    :local endLogMsg "$logPrefix Finished configuring domain:";

    :if ($configRecord = "@") do={
        :set configDomain ("$($i->0)");
    } else={
        :set configDomain ("$($i->2).$($i->0)");
    };

    :if ($configType = "A") do={
        :log info "$startLogMsg $configDomain - Type A record";

        :set interfaceIp [$getLocalIp $ipv4detectList];
        :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 [$getLocalIp $ipv6detectList];
        :set dnsIp [$getRemoteIpv6 $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";

Credits for the base scripts:

MikroTik Conditional DNS Zone Forwarding

MikroTik does not have a built-in feature to forward individual DNS zones to different external DNS servers. This functionality is often necessary when connecting multiple sites via VPN and utilizing multiple internal DNS servers. However, with the following workaround, you can achieve conditional DNS zone forwarding without the need for scripting:

/ip firewall layer7-protocol
add name="MyDomain DNS port forward" regexp="my.local.domain|[0-9]+.[0-9]+.168.192.in-addr.arpa"
/ip firewall nat add action=masquerade chain=srcnat comment="NAT to MyDomain DNS" disabled=no dst-address=192.168.0.1/32 dst-port=53 protocol=udp
/ip firewall nat add action=dst-nat chain=dstnat disabled=no dst-address-type=local dst-port=53 layer7-protocol="MyDomain DNS port forward" protocol=udp to-addresses=192.168.0.1 to-ports=53

A little tip: the name of the protocol in the first command is used again in the last command, so they must be identical

You may need to allow remote DNS queries on the remote DNS server. if it is also a MikroTik you could use the following command:

/ip dns set allow-remote-requests=yes

Source 1, 2, 3

Quick and easy: Debian and Ubuntu major upgrade

Ubuntu

  1. Update system: sudo apt update && sudo apt upgrade
  2. Remove unnecessary packages and configuration files: sudo apt autoremove --purge
  3. Reboot sudo reboot
  4. Install update manager (mostly already installed): sudo apt install update-manager-core
  5. Check for new Version: sudo do-release-upgrade -c
  6. Start upgrade: sudo do-release-upgrade and follow terminal prompts

Debian

  1. Update System sudo apt update && sudo apt full-upgrade
  2. Reboot sudo reboot
  3. Update release package repos: sudo sed -i'.bak' 's/bullseye/bookworm/g' /etc/apt/sources.list (for 11 to 12, replace names with the right versions!)
  4. Start upgrade: sudo apt update && sudo apt dist-upgrade
  5. Reboot: sudo reboot

Quick and easy: Reset root password

  1. Reboot machine and press <shift> to configure GRUB
  2. Select the entry you normaly boot, e.g. Ubuntu and press <e> to temporary modify the entry
  3. Search the line which begins with linux /boot/vmlinuz[...]
  4. Remove everything and including the ro, e.g. ro quiet splashor ro maybe-ubiquity
  5. Replace it with rw init=/bin/bash
  6. Press F10 to boot the system
  7. Use passwdto change the root password

Fix register protocol handler for mailto links in Firefox and Roundcube

I recently played with Mailcow and also tried out SOGo briefly. After logging in, SOGo triggers a infobar message in the browser asking whether you want to set SOGo as the default application for mailto links in the browser. No, I just don't want to it at the moment. So I quickly clicked on "Not now". After a few days I then migrated my Roundcube and wanted to set it as the default application for mailto links, but no popup appears. Even manual calls via the browser console do not help:

window.navigator.registerProtocolHandler('mailto', location.href.split('?') [0] + '?_task=mail&_action=compose&_to=%s', “test”)

Resetting the page settings, cookies, permessions etc. does not help either. It turns out that Firefox saves the "hide message" in the permissions.sqlite file and doenst clear the entry or i dont know how. FF enters a mailto-infobar-dismissed for the domain there. I removed line with SQLite editor, restarted Firefox and tada, it works.

tl;dr:

Register mailto handler doen't work with Roundcube for a domain after the popup (infobar) has been clicked away? Removed the mailto-infobar-dismissed for the (sub-)domain from permissions.sqlite

FileWare Apple VPP Token “The server has revoked the sToken.”

If you get this error The server has revoked the sToken from FileWave while syncing, the ABM portal password recently changed. So the server tokens becomes invalid and user has to renew the location tokens (Source).

To renew the token, login into ABM, go to Payments and Billing and download the Content Tokens. In FileWave go to Token configuration, Double Click the current token and import the file. Sync and you are done. Do not try to add a new token, this won´t work.

Add Let’s Encrypt (certbot) to FileWave MDM on Debian

  1. Install certbot
    apt update && apt install certbot

  2. Make sure http (80) is open on the machine

  3. Run sudo certbot certonly --standalone and follow the assistant

  4. Create script /usr/local/bin/certbot-renew.sh with the following content:

    #!/bin/bash
    FQDN="filewave.example.com"
    /bin/certbot renew
    cp -uf /etc/letsencrypt/live/${FQDN}/fullchain.pem /usr/local/filewave/certs/server.crt
    cp -uf /etc/letsencrypt/live/${FQDN}/privkey.pem /usr/local/filewave/certs/server.key
    yes | /usr/local/filewave/python/bin/python /usr/local/filewave/django/manage.pyc update_dep_profile_certs
    /usr/local/bin/fwcontrol server restart
    exit 0
  5. Make script excutable with sudo chmod +x /usr/local/bin/certbot-renew.sh

  6. Run script for testing /usr/local/bin/certbot-renew.sh

  7. Add new job to /etc/crontab:

    0 5 * * 6 root /usr/local/bin/certbot-renew.sh

    [via]https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-debian-11[/via]
    [via]https://www.reviewmynotes.com/2022/10/filewave-and-lets-encrypt.html[/via]

TIL: Python tips and tricks collection

This blog post is a collection of Python tips and tricks that I have found useful over the years. I will keep updating this entry as I learn more.

  • How to add a custom CA Root certificate to the CA Store used by pip in Windows?

    pip config set global.cert path/to/ca-bundle.crt
    pip config list
  • Generate requiements.txt
    Use pipreqs

    pip install pipreqs
    pipreqs /path/to/project

    or pip freeze. But, freeze saves all packages in the environment including those that you don´t use in your current project!

    pip freeze

Counter Strike: Source won’t start on NixOS

After the latest update of my NixOS machine, Counter Strike: Source wont start. Starting Steam from console shows the following error message

[...]
src/tcmalloc.cc:278] Attempt to free invalid pointer 0x94d1af0 
/home/user/.local/share/Steam/steamapps/common/Counter-Strike Source/hl2.sh: line 73: 14550 Aborted                 (core dumped) ${GAME_DEBUGGER} "${GAMEROOT}"/${GAMEEXE} "$@

I could fixed the problem by

  • copy the libmimalloc.so from Half-Life 2 bin-folder (/home/user/.local/share/Steam/steamapps/common/Half-Life 2/bin/libmimalloc.so) to Conter-Strike: Source bin-folder
  • Rename existing libtcmalloc_minimal.so.4 to libtcmalloc_minimal.so.4~ or similar
  • rename libmimalloc.so to ibtcmalloc_minimal.so.4

The game now starts 🙂

Synology HyperBackup to Hetzner Storage Box

Its possible to use Hetzner Storage Box as HyperBackup target with the following settings.

Preparations

  • Log in to StorageBox Administration from Hetzner
  • Select StorageBox
  • Create SubAccount (Optional)
    • Activate: Allow SSH
    • Activate: Allow external accessibility
    • User name and password will be displayed after saving (ONLY ONCE!)

Setup

  • Install HyperBackup package
  • Backup destination > File server > rsync
    • ServerType: rsync-compatible server
    • Server name: <uXXXX.your-storagebox.de>
    • Transmission encryption: On
    • Port: 23
    • Username: uXXX-subXXX
    • Password: XXX
    • Backup module: /home/
    • Directory: <backup name>