Die Konfiguration von DNS-Zonen gehört bei den meisten (Web-)Software-Projekte dazu. Ob beim initialen Setup oder im Laufe der Zeit müssen immer wieder Domains registriert und DNS-Einträge eingerichtet oder angepasst werden. Das kann man zwar zumeist über das Webinterface des entsprechenden Anbieters von Hand erledigen, was aber fehleranfällig, schlecht nachvollziehbar und nicht zu automatisieren ist.

Nutzt man zur Provisionierung seiner Cloud-Infrastruktur Infrastructure-as-Code Tools wie Terraform, kann man die DNS-Konfiguration natürlich auch darüber steuern. Allerdings ist man damit bei der Auswahl des DNS-Providers auf die großen Hyperscaler beschränkt.

Einfacher und mit weniger Konfigurationsaufwand erlaubt es DNSControl, die DNS-Konfiguration für dutzende große und kleine Provider als Javascript-Code zu definieren. Dadurch lässt sich die DNS-Umgebung automatisiert und reproduzierbar aufsetzen und verwalten. Die Konfiguration ist versioniert in einem Git-Repository abgelegt und kann einem Code-Review unterzogen werden, was das Vorgehen perfekt in die DevOps-Toolchain integriert.

Installation

Die Installation von DNSControl erfolgt über einen Paket-Manager wie brew oder einfach als Download des Binaries von der Github-Seite des Projekts. Das in Go geschrieben Program ist für Windows, Linux und MacOS verfügbar. Darüber hinaus existiert ein fertiges Docker-Image.

Konfiguration

DNSControl kennt DNS-Registrars und DNS-Service-Provider (DSP). Bei einem Registrar lassen sich neue Domains registrieren und die zuständigen Nameserver festlegen. Ein DSP verwaltet die DNS-Zonen und bedient DNS-Anfragen. Häufig dient ein Registrar auch zugleich als DSP. Die Konfiguration muss dennoch für beide Entitäten erfolgen.

Credentials hinterlegen

In der Datei creds.json werden die genutzten Provider und Registrars hinterlegt. Die meisten Provider benötigen Zugangsdaten in Form eines API-Key oder Ähnlichem, die genaue Syntax lässt sich der Dokumentation des jeweiligen Providers entnehmen. Einigen Provider lassen sich nur als DSP nutzen, andere nur als Registrar. Zum Erzeugen eines lokalen Zone-Files für eine BIND-Konfiguration genügt die Angabe des Typs BIND. Nutzt man keinen Registrar, kann man den Typ NONE verwenden.

Um die Zugangsdaten nicht im Klartext in der Konfigurationsdatei zu hinterlegen, kann man hier Umgebungsvariablen nutzen. Diese werden in der Form $NAME in der Konfigurationsdatei referenziert.

Das folgende Beispiel konfiguriert BIND, einen Dummy-Eintrag und Hetzner als Provider.

{
    "local": {
        "TYPE": "BIND"
    },
    "none": {
        "TYPE": "NONE"
    },
    "hetzner": {
        "TYPE": "HETZNER",
        "api_key": "$DNSCONTROL_HETZNER_API_KEY"
    }
}

Ob die Konfiguration korrekt ist, kann DNSControl mittels check-creds prüfen. Über den Command get-zones lassen sich die DNS-Zonen, die beim DNS-Provider bereits hinterlegt sind, ausgeben. Als Formate stehen unter anderem tsv (tab separated value) und das BIND-Format zone zur Verfügung. nameonly zeigt nur die Namen der Zonen an.

# Check credentials
dnscontrol check-creds hetzner

# Get all zones from provider
dnscontrol get-zones --format=nameonly hetzner - all

DNS-Zonen konfigurieren

Die Konfiguration der DNS-Zonen erfolgt per Javascript-Code, standardmäßig in der Datei dnsconfig.js. Dadurch ist man sehr flexibel was wiederkehrende Einstellungen wie DNS- oder Mail-Server angeht.

Über die Funktionen NewRegistrar() und NewDnsProvider() liesst man die konfigurierten Provider aus der creds.json Datei ein, die Einträge identifiziert man über den Namen.

Die Funktion D() definiert eine DNS-Zone. Als Parameter werden der Name der Zone, der Registrar, der DSP und eine Liste von DNS-Einträge übergeben. Für die unterschiedlichen Typen von Einträgen stehen jeweils Funktionen, sogenannte Domain Modifiers bereit, etwa A() und AAAA() für A- bzw. AAAA-Records.

var REG = NewRegistrar("none");;
var DNS = NewDnsProvider("hetzner");;

var HETZNER_NAMESERVER_RECORDS = [
    NAMESERVER("helium.ns.hetzner.de."),
    NAMESERVER("hydrogen.ns.hetzner.com."),
    NAMESERVER("oxygen.ns.hetzner.com.")
];

D("production-ready.de", REG, DnsProvider(DNS),
    A("@", "116.203.23.216"),
    A("www", "116.203.23.216"),
    AAAA("@", "2a01:4f8:c0c:bf6a::1"),
    MX("@", 10, "mail.example.com."),
    HETZNER_NAMESERVER_RECORDS
);

Zur Konstruktion von komplexeren Records stehen Builder-Funktionen bereit. Das Resultat kann einfach in die Liste der DNS-Einträge aufgenommen werden.

var CAA = CAA_BUILDER({
    label: "@",
    issue: ["letsencrypt.org"],
    iodef: "mailto:administrator@example.com",
    iodef_critical: true,
    issuewild: "none"
});

var DMARC = DMARC_BUILDER({
    policy: "none",
    subdomainPolicy: "none",
    rua: ["mailto:postmaster@example.de"],
    ruf: ["mailto:postmaster@example.de"],
    reportInterval: "7d"
});

D("example.com", REG, DnsProvider(DNS),
    A("@", "116.203.23.216"),
    ...
    CAA,
    DMARC
);

Hat man mehrere Domains zu verwalten, bietet es sich an, die Konfiguration auf mehrere Dateien aufzuteilen. Diese können in der Hauptdatei dnsconfig.js importiert werden.

require("domains/production-ready.de.js");

Vorhandene DNS-Zonen importieren

Möchte man bereits vorhandene DNS-Zonen in DNSControl überführen, kann man sich etwas Tipparbeit sparen, indem man get-zones mit dem Output-Format js nutzt und den Inhalt mittels --out [file].js in eine Datei schreiben lässt. Den generierten Code kann man nun als Ausgangspunkt für die eigene Konfiguration nutzen.

> dnscontrol get-zones --format js --out import.js hetzner - production-ready.de

var DSP_HETZNER = NewDnsProvider("hetzner");
var REG_CHANGEME = NewRegistrar("none");
D("production-ready.de", REG_CHANGEME,
        DnsProvider(DSP_HETZNER),
        DefaultTTL(3600),
        //NAMESERVER('oxygen.ns.hetzner.com.'),
        //NAMESERVER('hydrogen.ns.hetzner.com.'),
        //NAMESERVER('helium.ns.hetzner.de.'),
        A('@', '116.203.23.216'),
        A('www', '116.203.23.216'),
        ...
)

Type-Checking

Um in seinen Javascript-Dateien ein Type-Checking und Intellisense ala TypeScript zu erhalten, bietet DNSControl die nette Möglichkeit, ein TypeScript Declaration File zu generieren.

dnscontrol write-types

Am Anfang der dnsconfig.js referenziert man die Datei.

// @ts-check
/// <reference path="types-dnscontrol.d.ts" />

...

Änderungen prüfen und anwenden

Zur Validierung der Konfiguration bietet DNSControl den Befehl check. Dieser prüft die Javascript-Dateien auf Fehler, greift aber noch auf keinen DNS-Provider zu. Der Befehl preview vergleicht die aktuelle Konfiguration mit dem Stand der DNS-Zonen beim Provider und zeigt die potenziellen Änderungen an. Im folgenden Beispiel wird ein CNAME-Record hinzugefügt und ein A-Record geändert.

# Check and validate dnsconfig.js
> dnscontrol check
No errors.

# Preview changes
> dnscontrol preview
******************** Domain: production-ready.de
2 corrections (hetzner)
#1: Batch creation of records:
   + CREATE CNAME www2.production-ready.de www.production-ready.de. ttl=3600
#2: Batch modification of records:
   ± MODIFY A production-ready.de: (116.203.23.216 ttl=3600) -> (192.168.0.1 ttl=3600)
Done. 2 corrections.

Entsprechen die Änderungen dem erwarteten Ergebnis, kann man sie mittels push anwenden.

> dnscontrol push

2 corrections (hetzner)
#1: Batch creation of records:
   + CREATE CNAME www2.production-ready.de www.production-ready.de. ttl=3600
SUCCESS!
#2: Batch modification of records:
   ± MODIFY A production-ready.de: (116.203.23.216 ttl=3600) -> (192.168.0.1 ttl=3600)
SUCCESS!
Done. 2 corrections.

Damit ist man in der Lage, eine gesamte DNS-Landschaft mit einem Infrastructure-as-Code-Ansatz zuverlässig und nachvollziehbar zu verwalten.

Azure DevOps Pipeline

Um Änderungen an der DNS-Konfiguration nun nicht lokal ausführen zu müssen, bietet es sich an, eine Build-Pipeline zu erstellen. Diese kann auch zum Validieren von Pull-Requests mit DSN-Änderungen genutzt werden. Der hier gezeigte Code nutzt Azure DevOps, für andere CI/CD-Systeme sollte sich aber ein ähnlicher Ansatz finden lassen.

Hat man DNSControl auf seinem Build-Agent nicht installiert, z.B. weil man Hosted Agents von Microsoft oder eigene Docker-Basierte Agents nutzt, kann man dies mit einem einfachen CmdLine-Task erledigen. Ordner erzeugen, Datei herunterladen und entpacken und das Binary ausführbar machen.

- task: CmdLine@2
  displayName: "Install DNSControl"
  inputs:
      script: |
        mkdir -p $(Agent.TempDirectory)/bin
        cd $(Agent.TempDirectory)/bin
        wget -q https://github.com/StackExchange/dnscontrol/releases/download/v4.1.1/dnscontrol_4.1.1_linux_amd64.tar.gz -O dnscontrol.tar.gz
        tar -xf dnscontrol.tar.gz dnscontrol
        chmod +x dnscontrol

Anschließend kann man die Konfiguration validieren und die Änderungen anzeigen lassen. Den API-Key für den Provider kann man als Secret-Variable in der Pipeline-Konfiguration hinterlegen oder etwa aus einem Azure KeyVault beziehen. Bei der Verwendung einer Variable ist das explizite Mapping auf eine Umgebungsvariable wichtig, ansonsten ist der API-Key im Script nicht verfügbar.

- task: CmdLine@2
  displayName: "Run DNSControl check"
  inputs: 
    script: $(Agent.TempDirectory)/bin/dnscontrol check

- task: CmdLine@2
  displayName: "Run DNSControl preview"
  inputs: 
    script: $(Agent.TempDirectory)/bin/dnscontrol preview
  env:
    DNSCONTROL_HETZNER_API_KEY: $(DNSCONTROL_HETZNER_API_KEY)

Entsprechen die Änderungen dem gewünschten Ergebnis, kann man diese in einem Deployment-Step anwenden.

jobs:
  - deployment: 
    displayName: "Apply DNS Update"
    environment: HETZNER_DNS_PROD
    strategy:
      runOnce:
        deploy:
          steps:
            - task: CmdLine@2
              displayName: "Run DNSControl push"
              inputs: 
                script: $(Agent.TempDirectory)/bin/dnscontrol push
              env:
                DNSCONTROL_HETZNER_API_KEY: $(DNSCONTROL_HETZNER_API_KEY)
DNSControl in Azure DevOps Pipeline