Dynamic DNS mit PowerDNS in Eigenregie: Der dynpower-Server

Der DNS-Server PowerDNS war bereits Gegenstand mehrerer Artikel hier im Blog. Und er läuft problemlos seit der Einrichtung und dem Deployment vor einiger Zeit, verrichtet unauffällig seine Dienste, was für alle Komponenten gilt – PowerDNS Authoritative Server, PowerDNS Recursor und dnsdist. Insofern lasse ich fast alle Domains von PowerDNS auflösen. Warum nur fast? Ganz einfach, für das beliebte “dynamische” DNS, also der Möglichkeit, einen Hostnamen für die jeweilige IP-Adresse, die einem der Zugangsprovider für den heimischen Internet-Zugang zur Verfügung stellt, zuzuweisen, habe ich bislang auf externe Dienste zurück gegriffen. Denn beim Wechsel der IP-Adresse – standardmäßig etwa bei DSL-Providern spätestens alle 24 Stunden – muss der DNS-Server benachrichtigt werden und die neue IP-Adresse möglichst zeitnah erhalten und an den bzw. die Secondary-DNS-Server verteilen.

Dynamic DNS damals und heute

Früher gab es etliche kostenlose Dynamic DNS Dienste, deren Manko war, dass sich damit keine eigenen Domains nutzen ließen. Zwar gibt es inzwischen Domain-Anbieter, die ebenfalls Dynamic DNS bereitstellen, andererseits werden DNS-Dienste auch weiterhin kostenlos bereit gestellt, etwa von Hurricane Electric oder DigitalOcean, aber wenn diese genutzt werden, obliegt die gesamte DNS-Verwaltung eben einem dieser Anbieter. Genau dies zu vermeiden, war jedoch eines der Ziele des Einsatzes von PowerDNS auf eigenen Servern bzw. VMs. Somit musste eine Lösung für Dynamic DNS mit dem PowerDNS Authoritative Server her.

Ein Blick in die Dokumentation verrät, dass PowerDNS dafür bereits Lösungen anbietet. Eine “Dynamic DNS Update” genannte Möglichkeit, die in RFC2136 definiert wird, und ein Update von Zonendaten mittels eigener HTTP API. Beides scheint – und das sei meine absolut eigene, subjektive Meinung – recht aufwändig zu sein. Beispielsweise lassen sich in der FRITZ!Box unterschiedliche Anbieter von Dynamic DNS Diensten konfigurieren, ebenso wie bei der hier genutzten Firewall-Appliance-Distribution OPNsense. Bei beiden habe ich zumindest bis zum aktuellen Zeitpunkt keinen RFC2136-Client gefunden. Und ddclient unterstützt mittels “nsupdate”-Protokoll einen Wrapper um das Kommandozeilentool namens nsupdate. Ebenso will PowerDNS dafür konfiguriert, für die betreffenden Domains müssen Keys generiert werden und spätestens beim Blick in das in Lua geschriebene Beispiel-Skript habe ich erstmal aufgegeben und nach Alternativen gesucht. Eine solche schien auf den ersten Blick die HTTP API von PowerDNS zu sein, mit der sich alle Zonendaten manipulieren, Domains anlegen lassen usw.. Die Admin-UI PowerDNS-Admin bedient sich genau dieser API, insofern besteht kein Zweifel an der Funktionalität der API. Genau darin liegt jedoch auch ein Problem – die API ist sehr mächtig, es lassen sich alle Daten ändern, es gibt Zonen-Objekte, Resource Record Sets, Records usw., was eine gewisse Komplexität mit sich bringt, und nicht zuletzt lassen sich alle diese Daten aller Domains, die vom jeweiligen PowerDNS-Server verwaltet werden, ändern, sofern man im Besitz des API-Keys ist. Und leider wird die PowerDNS-API weder von der FRITZ!Box, noch OPNsense und ebenfalls von ddclient nicht unterstützt. Da ich mir jedoch sicher war, dass es bereits einfachere Lösungen geben musste, die beispielsweise mit einem einfachen HTTP(S)-Request die Änderung der IP-Adresse für einen Hostnamen anstoßen, habe ich erstmal die Lieblings-Suchmaschine angeworfen und mir die Ergebnisse näher betrachtet.

Eigener Dynamic DNS Dienst “für den Hausgebrauch”

Und natürlich fanden sich einige Artikel, sogar die c’t beschäftigte sich vor einigen Jahren mit einer entsprechenden Lösung. Es stellten sich einige Gemeinsamkeiten heraus: Einerseits bedienten sich nahezu alle der vorgestellten Ansätze nicht der PowerDNS-API, sondern aktualisierten die Domain-Daten direkt in der zugrunde liegenden Datenbank, meist MySQL, aber auch SQLite kam zum Einsatz. Andererseits basierten die Lösungen auf PHP, Perl, oder sogar Shell-Skripten. Wenige Zeilen Programmcode genügten, um den Request entgegenzunehmen, die übermittelten Daten zu prüfen und anschließend den Host-Eintrag in der Datenbank zu ändern. Insofern schien diese direkte Änderung in der Datenbank anstatt die API zu nutzen ein durchaus gangbarer Weg zu sein. Mein erster Gedanke war somit, diese Idee zu übernehmen und ebenfalls PHP zu nutzen.

Dynamic DNS Service – Anforderungen

Nicht, dass die gefundenen Lösungen schlecht gewesen wären, aber aus meiner Sicht sollten folgende Anforderungen erfüllt werden:

  • Update erfolgt durch HTTP GET-Request, Authentifizierung mittels API-Key
  • der API-Key soll nicht global gültig sein, sondern nur jeweils für eine Domain
  • die zu aktualisierenden Hostnamen sollen in einer Allowlist/Positivliste Platz finden, so dass nur einzeln erlaubte Hostnamen geändert werden können
  • alle Elemente wie Host, Domain, API-Key sollen nicht hart codiert vorliegen, sondern flexibel konfiguriert werden können, wofür sich wiederum eine Datenbank empfiehlt

Docker und PHP, oder nicht PHP..?

Nun blieb die Frage – PHP oder vielleicht doch nicht? Und wenn ja, welches (Micro-)Framework? Oder der Einfachheit halber gar keines? Dazu kam, dass auf den für DNS genutzten Servern bereits alle Dienste “dockerized” sind, d.h. entweder als einfache Docker-Container oder als Docker-Services laufen. Die Dynamic-DNS-API sollte dabei keine Ausnahme machen, insofern als eigenständiger Dienst laufen, anstatt sich eines zentralen Web-Servers zu bedienen. Die paar Zeilen PHP hätten somit einen Docker-Container benötigt, in dem sich nicht nur das PHP-Skript befindet, sondern auch das PHP-FPM-Modul, diverse Libraries und nicht zu vergessen der Web-Server (die Nutzung des PHP-internen “Development”-Servers lassen wir mal außen vor). Alles müsste entsprechend konfiguriert werden usw., insgesamt eine Menge Overhead für eine sehr kleine Anwendung, die nur einen einzigen HTTP-Request zur Verfügung stellen soll. In diesem Kontext erinnerte ich mich an eine Aussage aus dem Golang Meetup, das ich vor einiger und in der Vor-Corona-Zeit besucht hatte. Denn das Ergebnis des Kompilierens mit dem Standard-Go-Compiler ist ein statisch gelinktes Binary, das einfach in ein Docker-Image verfrachtet und gestartet werden kann…

Dynpower – Neuland mit Golang

Da mir die Idee immer mehr gefiel und mich seit geraumer Zeit einmal mit Go beschäftigen wollte, schien mir diese Aufgabe für den Einstieg genau richtig. So ist dynpower entstanden – ein Server, der gemäß o.g. Anforderungen einen Request zwecks Update der IP-Adresse entgegen nimmt und den entsprechenden Host-Eintrag in der PowerDNS-Datenbank aktualisiert. Und da ich die Domains mit API-Key und deren Host nicht manuell in der dynpower-Datenbank eintragen wollte, gibt es zur Verwaltung dieser Einträge noch eine Anwendung für die Kommandozeile: dynpower-cli. Beides zusammen gibt es fertig zum Einsatz in einem Docker-Image: geschke/dynpower. Und Letzteres umfasst momentan gerade einmal knapp 16 MB…

Ein Disclaimer sei mir erlaubt – der Code zeigt sehr anschaulich meine ersten Gehversuche mit Go. Vermutlich werden sich gestandene Go-Programmierer die Haare raufen bei der Betrachtung, und genau das kann ich absolut nachvollziehen, ich bitte dafür um Verständnis. Sehr wahrscheinlich werde ich den Code auch noch mehrfach überarbeiten, Teile entfernen, ersetzen, hinzufügen oder ähnliches. Beispielsweise hatte ich die Parameter der CLI zunächst vom flag-Package parsen lassen, um dann auf die wesentlich leistungsfähigere Cobra-Library zu wechseln. Trotzdem wollte ich die Lösung insgesamt veröffentlichen und hiermit beschreiben, da sie sich von den bis dazu gefundenen Ansätzen doch ein wenig unterscheidet.

Konfiguration von dynpower

Voraussetzungen

Im Folgenden nun Hinweise zur Konfiguration, wie sie hier im Einsatz ist. Als Voraussetzung dient PowerDNS Authoritative Server mit einem MySQL-Backend zur Speicherung der Domain-Daten. Dynpower unterstützt momentan ausschließlich MySQL bzw. MariaDB. Auf den Betrieb außerhalb von Docker gehe ich nicht weiter ein, aber natürlich wäre es auch möglich, dynpower etwa als systemd-Dienst laufen zu lassen, einen Nginx-Proxy für https davor zu schalten usw.. In jedem Fall muss dynpower auf die PowerDNS-MySQL-Datenbank zugreifen können. Ebenfalls benötigt dynpower eine eigene Datenbank, diese kann sich auf derselben Instanz wie die PowerDNS-Datenbank befinden, muss es aber nicht.

Datenbank-Import

Da das Docker-Image von MariaDB einen SQL-Dump beim ersten Start automatisch importieren kann, wird im folgenden Beispiel davon Gebrauch gemacht. Der SQL-Dump befindet sich auf GitHub unter sql/synpower.sql. Aufgrund der einfacheren Konfiguration und eindeutigen Trennung soll dynpower eine eigene Datenbank-Instanz erhalten. Auf die Verzeichnisstruktur wurde im ersten Teil eingegangen, für die neue Datenbank kann darin ein entsprechendes Verzeichnis angelegt werden:

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ tree
.
├── dnsdist
│   ├── dnsdist.conf
├── mariadb_dynpower
│   ├── data [...]
│   └── sql
│       └── dynpower.sql
├── mariadb_powerdns
[...]
├── mariadb_powerdnsadmin
[...]
├── ns1.xyzcdn.xyz.compose.yml
└── recursor
    └── forward_zones

Die Dump-Datei dynpower.sql wurde hier in das neue Verzeichnis mariadb_dynpower/sql/ gelegt. Beim ersten Start des MariaDB-Containers wird die Datei einfach importiert, dazu muss das Verzeichnis innerhalb des Containers nach /docker-entrypoint-initdb.d gemountet werden.

Docker-Compose-File für dynpower

Dynpower braucht Zugriff auf den PowerDNS-MariaDB-Container, der den MySQL-Port jedoch nicht nach außen zur Verfügung stellt. Das Docker-interne Netzwerk für alle PowerDNS-relevanten Container wurde im Docker-Compose-File definiert. Da ich an dieser Konfiguration nichts ändern wollte, wurde das Docker-Compose-File einfach um die Services für dynpower ergänzt:

version: '3.7'
services:
[...]
  mariadb_dynpower:
    image: mariadb:latest
    restart: always
    volumes:
      - ./mariadb_dynpower/data:/var/lib/mysql
      - ./mariadb_dynpower/sql:/docker-entrypoint-initdb.d
    environment:
      MYSQL_ROOT_PASSWORD: <Root Passwort>
      MYSQL_DATABASE: dynpower
      MYSQL_USER: dynuser
      MYSQL_PASSWORD: <Datenbank Passwort>
    networks:
      dns_net:
        ipv4_address: 172.30.1.100
  dynpower:
    image: geschke/dynpower
    restart: always
    environment:
      DBHOST: mariadb_dynpower
      DBUSER: dynuser
      DBNAME: dynpower
      DBPASSWORD:  <Datenbank Passwort>
      PDNS_DBHOST: mariadb_powerdns
      PDNS_DBUSER: powerdnsuser
      PDNS_DBNAME: powerdns
      PDNS_DBPASSWORD: <Passwort von mariadb_powerdns>
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=traefik-public"
      - "traefik.constraint-label=traefik-public"

      - "traefik.http.routers.ns1xyzcdnxyzdynapi.service=ns1xyzcdnxyzdynapi-secured"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi.rule=Host(`ns1.xyzcdn.xyz`) && PathPrefix(`/dynapi`)"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi.priority=20"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi.entrypoints=http"
      - "traefik.http.middlewares.ns1xyzcdnxyzdynapi.redirectscheme.scheme=https"
      - "traefik.http.middlewares.ns1xyzcdnxyzdynapi.redirectscheme.permanent=true" 

      - "traefik.http.middlewares.def-ns1xyzcdnxyzdynapi.headers.customrequestheaders.X-Forwarded-Ssl=on"
      - "traefik.http.middlewares.def-ns1xyzcdnxyzdynapi.headers.customrequestheaders.X-Forwarded-Server=ns1.xyzcdn.xyz"
      - "traefik.http.middlewares.def-ns1xyzcdnxyzdynapi.headers.referrerPolicy=origin"
      - "traefik.http.middlewares.def-ns1xyzcdnxyzdynapi-strip.stripprefix.prefixes=/dynapi"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi.middlewares=https-redirect"

      - "traefik.http.routers.ns1xyzcdnxyzdynapi-secured.service=ns1xyzcdnxyzdynapi-secured"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi-secured.rule=Host(`ns1.xyzcdn.xyz`) && PathPrefix(`/dynapi`)"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi-secured.priority=20"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi-secured.entrypoints=https"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi-secured.tls.certresolver=le"
      - "traefik.http.services.ns1xyzcdnxyzdynapi-secured.loadbalancer.server.port=8080"
      - "traefik.http.routers.ns1xyzcdnxyzdynapi-secured.middlewares=secHeaders@file,def-ns1xyzcdnxyzdynapi-strip,def-ns1xyzcdnxyzdynapi,def-compress"

    networks:
      traefik-public:
      dns_net:
        ipv4_address: 172.30.1.101

Im Unterschied zur bisherigen Variante mit Nginx-Proxy-Container kommt auf dem Server inzwischen der Traefik Proxy zum Einsatz, auf dessen Konfiguration ich nur rudimentär eingehen möchte, evtl. mehr dazu in einem späteren Artikel.

Die Service-Beschreibung von mariadb_dynpower sieht den anderen Datenbank-Instanzen sehr ähnlich. Zusätzlich zu der Definition der anzulegenden Datenbank namens dynpower, des Users dynuser und den entsprechenden Root- und Datenbank-Passwörtern wird das Verzeichnis, in dem sich das Dump-File befindet, in das entsprechende Verzeichnis /docker-entrypoint-initdb.d innerhalb des Containers gemountet. Beim ersten Start wird die Datenbank mit den angegebenen Parametern angelegt und alle Dateien, die bestimmte Endungen tragen, importiert bzw. ausgeführt. Danach können sowohl die Dump-Datei als auch der Mount gelöscht werden.

Dynpower-Konfiguration mittels Environment-Variablen

Die Definition von dynpower besteht größtenteils aus dem Setzen der richtigen Umgebungsvariablen. Dies wären einerseits die Verbindungsparameter für den zuvor definierten MariaDB-Service mariadb_dynpower und andererseits die Parameter für den Zugriff auf die Datenbank von PowerDNS.

Traefik ersetzt Nginx & Co(mpanion)

Mit der Vielzahl von Labels wird der Traefik Proxy konfiguriert. Traefik dient als zentraler Proxy für alle auf dem Server befindlichen Services. Traefik sorgt dabei für das Routing und für die verschlüsselte Kommunikation mittels HTTPS. Ähnlich wie beim Zusammenspiel der Container Nginx-Proxy und Nginx-Proxy-Companion wird das Let’s-Encrypt-Zertifikat automatisch für den entsprechenden Host eingerichtet. Das Routing lässt sich vielfältig konfigurieren, im o.g. Beispiel sollte für die dynpower-API kein weiterer Hostname definiert werden, sondern alle DNS-relevanten Services vereint werden unter ns1.xyzcdn.xyz. Standardmäßig – und bislang ohne Möglichkeit einer Änderung – ist die dynpower-API unter dem Pfad /api/ erreichbar. Die PathPrefix-Regel von Traefik sorgt nun dafür, dass dynpower unter genau diesem Pfad erreichbar ist. Da die URL normalerweise komplett durchgereicht wird, würde dynpower ebenfalls die Pfadangabe /dynapi/api/... erhalten. Daher kommt die Traefik-Middleware StripPrefix zum Einsatz, die den Teil /dynapi vor dem Weiterleiten entfernt, so dass dynpower nur die jeweils relevanten Teile der URL erhält. Des Weiteren wird der Port 8080 angesprochen, da dynpower aktuell ausschließlich diesen Port bedient.

Start von dynpower

Zum Start kommt wie gewohnt docker-compose zum Einsatz.

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ docker-compose -f ns1.xyzcdn.xyz.compose.yml up -d

Der erste Start dauert angesichts des Anlegens der Datenbank etwas länger, aber danach sollte sich der Container in der Liste zeigen. Der dynpower-Server wurde somit gestartet, nun fehlen noch die Domain- und Host-Einträge.

Eintragen des Hosts in PowerDNS

Da PowerDNS und dynpower unabhängig voneinander agieren, d.h. dynpower nur für die Aktualisierung des IP-Adresse in der PowerDNS-Datenbank sorgt, dort aber ansonsten keine Änderungen vornimmt, muss – falls noch nicht geschehen – ein entsprechender Hostname für die Verwendung von Dynamic DNS eingetragen werden. Beispielsweise könnte für eine Domain “example.com” ein Host “home” verwendet werden. Dabei muss eine kurze TTL (Time To Live) gesetzt werden, PowerDNS-Admin bietet in der Default-Auswahl beispielsweise fünf Minuten (300 Sekunden) an, kürzere Werte sind ebenfalls möglich. Eine klare Empfehlung habe ich nicht gefunden, die Wikipedia gibt hingegen an, dass bei dynamischen DNS-Diensten meist eine TTL von einer Minute verwendet wird.

Konfiguration mittels dynpower-cli

Für das Eintragen von Domains und Hosts in die dynpower-eigene Datenbank kommt die CLI-Anwendung zum Einsatz, die einfach innerhalb des laufenden Containers gestartet werden kann. Da dabei die Environment-Variablen zum Einsatz kommen, die beim Start des Containers definiert wurden, genügt es, dynpower-cli als Kommando anzugeben:

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ docker exec -it ns1xyzcdnxyz_dynpower_1 dynpower-cli

 dynpower-cli is a small helper tool to manage the dynpower database.

Usage:
  dynpower-cli [command]

Available Commands:
  domain      Manage domain entries
  encrypt     Encrypt access key string to enter into database table.
  help        Help about any command
  host        Manage host entries

Flags:
  -h, --help   help for dynpower-cli

Use "dynpower-cli [command] --help" for more information about a command.

Zunächst wird eine Domain angelegt, dabei nimmt das add-Kommando den Domainnamen sowie einen selbst gewählten API-Key entgegen:

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ docker exec -it ns1xyzcdnxyz_dynpower_1 dynpower-cli domain add example.com mein_ganz_toller_api_key
Domain example.com added successfully

Achtung! Der API-Key wird verschlüsselt (bcrypt) in der Datenbank abgelegt, daher besteht keine Möglichkeit, diesen nachträglich wieder im Klartext sichtbar zu machen. Insofern empfiehlt es sich, den API-Key spätestens an dieser Stelle zu notieren.

Zur Prüfung des Erfolgs kann die Liste der vorhandenen Domains angezeigt werden:

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ docker exec -it ns1xyzcdnxyz_dynpower_1 dynpower-cli domain list
Domains in database:
Domain       Created              Updated
example.com  2020-10-09 14:38:02  2020-10-09 14:38:02
gncdn.xyz    2020-10-05 01:32:31  2020-10-05 01:32:31

Nun fehlt noch ein entsprechender Host für die soeben angelegte Domain:

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ docker exec -it ns1xyzcdnxyz_dynpower_1 dynpower-cli host list example.com
No host entry for domain example.com found.

Wie wäre es mit “home.example.com”?

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ docker exec -it ns1xyzcdnxyz_dynpower_1 dynpower-cli host add example.com home
Host home.example.com added successfully

Zur Überprüfung kann die Liste der eingetragenen Hosts für eine Domain angezeigt werden:

geschke@stralsund:~/services/ns1.xyzcdn.xyz$ docker exec -it ns1xyzcdnxyz_dynpower_1 dynpower-cli host  list example.com
Hosts in database:
Host              Created              Updated
home.example.com  2020-10-09 14:42:24  2020-10-09 14:42:24

Damit ist dynpower bereits fertig konfiguriert. Weitere Domains und Hosts können jederzeit hinzugefügt und natürlich auch wieder entfernt werden. Dabei bezieht sich das Hinzufügen oder Entfernen nur auf die dynpower-Datenbank, bei PowerDNS selbst wird nichts geändert!

Der Update-Request

Die URL zur Aktualisierung der Domain sieht nun wie folgt aus:

https://meinserver.tld/dynapi/api/update?key=mein_ganz_toller_api_key&host=home&domain=example.com

Die IP-Adresse wird standardmäßig automatisch ermittelt, kann aber auch mit einem weiteren Parameter direkt übermittelt werden, indem “ip=123.456.789.123” hinzugefügt wird. Weitere Pfade und damit Funktionen gibt es nicht, per HTTP-Request ist somit nur das Update eines bereits konfigurierten und existierenden Hosts möglich.

Wie geht es weiter?

Da dynpower gerade frisch geschlüpft ist, gibt es natürlich noch viel Raum für Verbesserungen und Ergänzungen, ein Problem ist mir beispielsweise beim Schreiben dieses Artikels aufgefallen… So läuft der dynpower-Server hier auch erst seit wenigen Tagen testweise und wird vom Dynamic-DNS-Aktualisierungsdienst von OPNsense verwendet – das funktioniert bislang ohne Probleme. Die Logs sind momentan auch noch sehr gesprächig, auch dies ist ein Punkt, der noch korrigiert werden muss. Und nicht zuletzt muss ich noch an meinen Go-Skills schrauben… Wer trotzdem gerne testen möchte, sei herzlich eingeladen, über Kommentare, Hinweise, Bug-Reports oder ähnliches würde ich mich natürlich sehr freuen!

 

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Tags: