DNS-Monitoring mit PHP: Änderungen per Cronjob erkennen

Aktualisiert: 6 Min. Lesezeit Redaktion The PHP Experts
Kurz gesagt

Für DNS-Monitoring mit PHP fragst du die wichtigen Records einer Domain regelmäßig mit dns_get_record() ab, speicherst sie normalisiert als JSON und vergleichst jeden Lauf mit dem letzten Stand. Ändert sich ein A-, MX-, NS- oder TXT-Record, verschickt das Skript eine Meldung. Ein Cronjob alle fünf bis fünfzehn Minuten reicht für die meisten Projekte.

Die unangenehmsten DNS-Probleme sind die, die niemand bemerkt. Ein Kollege stellt beim Umzug den MX-Record um und vergisst den alten SPF-Eintrag, ein Kunde ändert bei seinem Registrar die Nameserver, oder eine Domain fällt nach einer vergessenen Verlängerung aus der Zone. Mails kommen dann tagelang nicht an, und die erste Meldung ist eine verärgerte Nachricht vom Vertrieb.

Was ein DNS-Monitoring in PHP leisten soll

Ein brauchbares Monitoring beantwortet eine Frage: Hat sich seit dem letzten Lauf etwas an den Records geändert, die für Website und Mail zählen? Dafür reicht ein Skript, das einen Snapshot speichert und vergleicht. Wichtig sind dabei drei Eigenschaften:

AnforderungUmsetzung
keine Fehlalarme durch ReihenfolgeWerte je Typ sortieren
keine Fehlalarme durch TTLTTL nicht speichern
Ausfälle unterscheiden von Änderungenfalse von dns_get_record() getrennt melden
nachvollziehbare MeldungDiff mit + und - je Wert

Die Grundlagen zu dns_get_record(), etwa warum false und ein leeres Array etwas völlig anderes bedeuten, stehen im Artikel zu dns_get_record in PHP. Das Monitoring baut direkt darauf auf.

Records erfassen und normalisieren

Die Funktion snapshot() fragt fünf Typen ab und macht aus jedem Record einen einfachen String. Die TTL fällt weg, weil sie bei jedem Aufruf herunterzählt und sonst bei jedem Lauf eine „Änderung“ erzeugen würde. TXT-Records werden aus entries zusammengesetzt, damit lange SPF- oder DKIM-Einträge stabil bleiben.

<?php
declare(strict_types=1);

const TYPES = ['A' => DNS_A, 'AAAA' => DNS_AAAA, 'MX' => DNS_MX, 'NS' => DNS_NS, 'TXT' => DNS_TXT];

/** Liefert pro Typ eine sortierte Liste normalisierter Werte, TTL bewusst weggelassen. */
function snapshot(string $host): array
{
    $snap = [];
    foreach (TYPES as $name => $type) {
        $records = @dns_get_record($host, $type);
        if ($records === false) {
            throw new RuntimeException("DNS-Abfrage $name für $host fehlgeschlagen");
        }
        $values = array_map(fn(array $r): string => match ($r['type']) {
            'A'     => $r['ip'],
            'AAAA'  => $r['ipv6'],
            'MX'    => $r['pri'] . ' ' . $r['target'],
            'NS'    => strtolower($r['target']),
            'TXT'   => implode('', $r['entries']),
            default => json_encode($r),
        }, $records);
        sort($values);
        $snap[$name] = array_values(array_unique($values));
    }
    return $snap;
}

/** @return list<string> lesbare Änderungen */
function diff(array $old, array $new): array
{
    $changes = [];
    foreach (array_keys(TYPES) as $type) {
        $before = $old[$type] ?? [];
        $after  = $new[$type] ?? [];
        foreach (array_diff($after, $before) as $v) {
            $changes[] = "+ $type $v";
        }
        foreach (array_diff($before, $after) as $v) {
            $changes[] = "- $type $v";
        }
    }
    return $changes;
}

CAA fehlt in der Liste mit Absicht. Beim Testen hing eine CAA-Abfrage an einem langsamen Nameserver über eine Minute, und dns_get_record() hat keinen Timeout-Parameter. Wenn du CAA überwachen willst, gib dem Typ einen eigenen, seltener laufenden Job.

Das Monitoring-Skript für den Cronjob

Der Hauptteil lädt für jede Domain den gespeicherten Stand, vergleicht und schreibt den neuen Snapshot. Er fragt jede Domain zweimal ab und arbeitet nur weiter, wenn beide Ergebnisse gleich sind. Das fängt Round-Robin-Effekte und flüchtige Resolver-Aussetzer ab, ohne dass du Schwellwerte pflegen musst.

<?php
declare(strict_types=1);

$hosts   = ['php.net', 'denic.de'];
$dir     = __DIR__ . '/dns-state';
$changes = [];
is_dir($dir) || mkdir($dir, 0700, true);

foreach ($hosts as $host) {
    $file = "$dir/$host.json";
    try {
        // Zweimal abfragen: Round-Robin und flüchtige Resolver-Fehler sollen keinen Alarm auslösen
        $first  = snapshot($host);
        $second = snapshot($host);
        if ($first !== $second) {
            continue;
        }
    } catch (RuntimeException $e) {
        $changes[$host] = ['FEHLER: ' . $e->getMessage()];
        continue;
    }
    if (is_file($file)) {
        $old  = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
        $diff = diff($old, $first);
        if ($diff !== []) {
            $changes[$host] = $diff;
        }
    }
    file_put_contents($file, json_encode($first, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
}

if ($changes !== []) {
    $text = '';
    foreach ($changes as $host => $lines) {
        $text .= "$host\n  " . implode("\n  ", $lines) . "\n";
    }
    echo $text;
    // mail('[email protected]', 'DNS-Änderung erkannt', $text);
    exit(1);
}
echo "keine Änderungen\n";

Um den Vergleich zu testen, habe ich im gespeicherten Snapshot von php.net den MX-Eintrag von Hand auf einen erfundenen Wert gesetzt. Der nächste Lauf meldete:

php.net
  + MX 0 php-smtp4-ip4.php.net
  - MX 10 mail.alt.example

Der Exit-Code 1 bei Änderungen ist nützlich, wenn ein übergeordnetes System wie ein CI-Job oder ein Health-Check das Skript startet. In der crontab sieht ein Eintrag alle zehn Minuten so aus:

*/10 * * * * /usr/bin/php /opt/dns-monitor/monitor.php >> /var/log/dns-monitor.log 2>&1

Meldungen verschicken: Mail oder Webhook

Die auskommentierte mail()-Zeile funktioniert nur, wenn auf dem Server ein Mailer wie sendmail oder msmtp eingerichtet ist. Auf vielen Containern und VServern ist das nicht der Fall, und mail() gibt dann false zurück, ohne dass jemand es merkt. Zuverlässiger ist ein Webhook in einen Chat-Kanal, den das Team ohnehin liest:

<?php
declare(strict_types=1);

function notify(string $webhookUrl, string $text): void
{
    $ch = curl_init($webhookUrl);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(['text' => "DNS-Änderung erkannt\n" . $text], JSON_THROW_ON_ERROR),
        CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);
    if (curl_exec($ch) === false || curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 300) {
        // Fallback: ins Log schreiben, damit die Meldung nicht verloren geht
        error_log('DNS-Monitor: Webhook fehlgeschlagen, Inhalt: ' . $text);
    }
}

Das Feld text erwarten unter anderem Slack- und Mattermost-Webhooks, andere Dienste brauchen ein anderes JSON-Format. Wichtig ist der Fallback: Ein Monitoring, das seine eigenen Fehlschläge verschluckt, ist schlechter als keines, weil es Sicherheit vortäuscht.

Ein Detail noch zur Ablage: Leg das Verzeichnis dns-state außerhalb des Document Root an. Die Snapshots enthalten TXT-Records mit Verifizierungs-Tokens, die nicht öffentlich abrufbar sein sollten.

Fehlalarme vermeiden

Selbst mit Sortierung und Doppelabfrage gibt es Domains, die sich ständig „ändern“, ohne dass jemand etwas angefasst hat. Das ist kein Fehler im Skript, sondern Absicht der Betreiber. Typisch sind Hosts hinter einem CDN, das bei jeder Abfrage andere IP-Adressen ausliefert. Für solche Hosts überwachst du besser den CNAME oder die NS-Records als die A-Records. Ein zweiter Klassiker sind Verifizierungs-TXT-Einträge, die Dienste anlegen und wieder löschen. Wenn dich die nicht interessieren, filtere TXT auf Präfixe wie v=spf1.

Die Meldung „FEHLER“ ist kein Fehlalarm, sondern eine andere Art Information: Dein Resolver konnte nicht antworten. Tritt sie einmal auf, ist das meist Rauschen. Tritt sie bei allen Domains gleichzeitig auf, liegt das Problem bei deinem Server, nicht bei den Domains.

Welche Hosts gehören in die Liste? Nicht nur die Hauptdomain. Überwache auch www, die Subdomain deiner API und die Namen, an denen Mail-Authentifizierung hängt, also _dmarc.<domain> und den DKIM-Selektor wie selector1._domainkey.<domain>. Gerade DKIM-Schlüssel verschwinden gern bei einem Wechsel des Mailanbieters, und dann landen Newsletter still im Spam.

Grenzen: Cache, Resolver und Ablaufdaten

dns_get_record() fragt den lokalen Resolver, und der cacht. Eine Änderung siehst du also frühestens, wenn die TTL des alten Eintrags abgelaufen ist, bei 3.600 Sekunden also bis zu einer Stunde später. Wer schneller sein muss, fragt die autoritativen Nameserver direkt, etwa mit pear/net_dns2, das einen Nameserver als Parameter annimmt.

Was DNS gar nicht zeigt, ist das Ablaufdatum der Domain. Das steht in der Registry. Ein täglicher Job mit dem Client aus dem Beitrag RDAP in PHP, der 30 Tage vor expiration warnt, schließt diese Lücke für die meisten gTLDs. Bei .de liefert die DENIC kein Ablaufdatum, dort hilft nur die Auto-Verlängerung beim Registrar.

Für Mailserver lohnt ein gezielter Blick auf MX, Null-MX und den Fallback auf A-Records; das behandelt der Beitrag zum MX-Record prüfen mit PHP.

Wer seine Domains bei betadomain verwaltet, kann die automatische Verlängerung abschalten oder anlassen und bekommt vor jeder Verlängerung eine Erinnerung. Die Domains selbst listet das MCP-Tool list_my_domains samt Ablaufdatum, beschrieben in der Entwicklerdokumentation. Fehlt dir noch eine Domain für dein Projekt, findest du sie in der Domainsuche von betadomain; die .eu-Endung kostet dort aktuell 10,99 € im Jahr.

Schritt für Schritt

  1. Records abfragen und normalisierenFür jede Domain A, AAAA, MX, NS und TXT mit dns_get_record() lesen, TTL weglassen, Werte sortieren.
  2. Doppelt prüfenDen Snapshot zweimal hintereinander erzeugen und nur weitermachen, wenn beide gleich sind.
  3. Mit letztem Stand vergleichenDen gespeicherten JSON-Snapshot laden, mit array_diff() Unterschiede je Typ bestimmen und neuen Stand speichern.
  4. Melden und einplanenBei Änderungen Mail oder Webhook auslösen und das Skript per Cron alle fünf bis fünfzehn Minuten starten.

Domain gefunden? Prüfen und registrieren kannst du sie direkt bei Domain registrieren – Jahrespreis ab 2,99 €.

Häufige Fragen

Wie oft sollte ein DNS-Monitoring laufen?

Alle fünf bis fünfzehn Minuten ist ein guter Rhythmus. Häufiger bringt wenig, weil der Resolver Antworten ohnehin für die Dauer der TTL cacht. Wer Änderungen sofort sehen will, muss die autoritativen Nameserver direkt abfragen.

Warum meldet mein Monitoring Änderungen, die es nicht gibt?

Häufige Ursachen sind Round-Robin-Antworten in wechselnder Reihenfolge, CDNs mit rotierenden IP-Adressen und kurzzeitige Resolver-Fehler. Sortieren, doppelt abfragen und CDN-Hosts gezielt ausnehmen beseitigt die meisten Fehlalarme.

Kann ich mit PHP auch das Ablaufdatum einer Domain überwachen?

Ja, aber nicht über DNS. Das Ablaufdatum steht in RDAP oder Whois. Eine tägliche RDAP-Abfrage mit Warnung 30 Tage vor Ablauf ergänzt das DNS-Monitoring sinnvoll, bei .de liefert die DENIC allerdings kein Ablaufdatum.

Brauche ich dafür einen externen Dienst?

Nein, für eine Handvoll Domains reicht ein PHP-Skript mit Cronjob. Externe Dienste lohnen sich, wenn du aus mehreren Regionen messen willst oder dein eigener Server selbst ausfallen kann und dann niemand mehr warnt.