Wie kann ich DNS über eine HTTP - API ändern?

Sie können die komplette DNS-Zone auch über CURL oder ähnliches Programm abändern. Unser eigener Opensource-Client wird aktuell überholt. Hier eine kurze Anleitung, wie die Nutzung über CURL funktioniert. Für die Nutzung müssen Sie zuvor die API-ID der jeweiligen Domain aus dem Kundenportal generieren.

Schritt 1: Zone mit API-ID abfragen

Zone mit API-ID abfragen (Achtung, abschliessender /)

curl https://domainexpress.de:443/api/dns_zone/my_zone/YRAe7AYw06JKM1Pc8plp14_SoLgEyYItM3u5TxabJUyBuFwE6uCAxtGYfsHBCQbI/
a::1/source: live
a::1/target: 31.16.98.28
mx::1/prio: 10
mx::1/source: @
mx::1/target: x103.domainexpress.de
ns::1: ns1.hofmeirmedia.net
ns::2: ns2.hofmeirmedia.net
txt::1/source: @
txt::1/target: v=spf1 mx a ~all
txt::2/source: @
txt::2/target: v=DMARC1;p=quarantine;pct=100;rua=mailto:support@domainexpress.de

Schritt 2: Zone in Datei schreiben oder umlenken

curl https://domainexpress.de:443/api/dns_zone/my_zone/YRAe7AYw06JKM1Pc8plp14_SoLgEyYItM3u5TxabJUyBuFwE6uCAxtGYfsHBCQbI/ > zone.txt

Schritt 3: Zone editieren, anhängen / tauschen

Zum Beispiel anfügen:

txt::3/source: @
txt::3/target: diesisteintest

Schritt 4: Zone zurückspielen mit CURL

curl https://domainexpress.de:443/api/dns_zone/my_YItM3u5TxabJUyBuFwE6uCAxtGYfsHBCQbI/ -X POST --data-binary @zone.txt

Extra: Benachrichtigung via Email

Wenn Sie eine Benachrichtigung via Email wünschen, nachdem der DNS-Eintrag beim DNS-Server geändert wurde, fügen Sie bitte folgende Zeile mit dem Label email::1/notify: und Ihrer Mailadresse als weiteren Zonen-Eintrag in die zone.txt-Datei hinzu.

email::1/notify: ihreemail@ihreemail

Beispielhaftes Skript um eine Subdomain auf eine dynamische, externe IP zeigen zu lassen:

#!/bin/bash

# Stoppe bei Fehlern, z.B. kein Internetaccess
set -euo pipefail

# Optionale Verifikation per DNS-TXT-Record last_change (kann per ENV/CLI überschrieben werden)
VERIFY_LOOP="${VERIFY_LOOP:-0}"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --verify)
      VERIFY_LOOP=1
      shift
      ;;
    -h|--help)
      echo "Usage: $0 [--verify]"
      echo "  --verify   Warte nach dem Update darauf, dass der TXT-Record last_change im DNS erscheint."
      exit 0
      ;;
    *)
      echo "Unbekannte Option: $1"
      exit 1
      ;;
  esac
done

# Prüfen, ob benötigte Programme vorhanden sind
for cmd in curl dig mktemp date; do
  if ! command -v "$cmd" >/dev/null 2>&1; then
    echo "Fehler: benötigtes Programm '$cmd' wurde nicht gefunden."
    exit 1
  fi
done

# Konfiguration (über ENV-Variablen überschreibbar)
ZONE="${ZONE:-liveconfig-bei-domainexpress.de}"   # Zonename / Domain (für DNS-Abfragen)
TARGETSUB="${TARGETSUB:-www3}"                    # zu aktualisierender Host (source)
API_ID="${API_ID:-API_ID}"      # API-ID aus dem Kundenportal
EMAIL_NOTIFY="${EMAIL_NOTIFY:-}"                  # optional: Mailadresse für Benachrichtigung, z.B. "user@example.com"
NS="${NS:-ns1.hofmeirmedia.net}"                  # autoritativer Nameserver für Prüfungen

# Die eigentliche Zone wird ausschließlich über die API-ID identifiziert.
ZONE_URL="https://domainexpress.de:443/api/dns_zone/my_zone/$API_ID/"

# "Aktuelle" IP aus DNS (direkt beim Authoritativen) und externe IP holen
IP_NOW=$(dig @"$NS" +short A "$TARGETSUB.$ZONE" | head -n1 || true)
IP_NOW=${IP_NOW%$'\n'}
IP=$(curl -fsS http://v4.liveconfig-bei-domainexpress.eu/)
# Für IPv6 stattdessen:
# IP=$(curl -s http://v6.liveconfig-bei-domainexpress.eu/)

if [ -z "$IP" ]; then
  echo "Fehler: Konnte externe IPv4-Adresse nicht ermitteln." >&2
  exit 1
fi

if [ "$IP_NOW" = "$IP" ] && [ -n "$IP" ]; then
  echo "DNS bereits aktuell ($IP)"
  exit 0
fi

echo "Starte Update auf $IP für $TARGETSUB.$ZONE"

TMPFILE="$(mktemp)"
trap 'rm -f "$TMPFILE"' EXIT

# Schritt 1+2: Zone mit API-ID abfragen und in Datei schreiben
curl -fsS "$ZONE_URL" -o "$TMPFILE"

# Passenden A-Record für TARGETSUB suchen
RECORD_ID=$(grep -E "^a::[0-9]+/source: $TARGETSUB\$" "$TMPFILE" | sed -E 's/^a::([0-9]+)\/source.*/\1/' | head -n1 || true)

if [ -n "$RECORD_ID" ]; then
  echo "Gefundener bestehender A-Record mit ID $RECORD_ID – aktualisiere target"
  # target-Zeile für diesen Record ersetzen
  sed -E "s/^(a::$RECORD_ID\/target: ).*/\1$IP/" "$TMPFILE" > "${TMPFILE}.new"
  mv "${TMPFILE}.new" "$TMPFILE"
else
  echo "Kein bestehender A-Record für $TARGETSUB gefunden – füge neuen Eintrag hinzu"
  LAST_ID=$(grep -E "^a::[0-9]+/source:" "$TMPFILE" | sed -E 's/^a::([0-9]+)\/.*/\1/' | sort -n | tail -n1)
  if [ -z "$LAST_ID" ]; then
    RECORD_ID=1
  else
    RECORD_ID=$((LAST_ID + 1))
  fi
  {
    echo "a::${RECORD_ID}/source: $TARGETSUB"
    echo "a::${RECORD_ID}/target: $IP"
  } >> "$TMPFILE"
fi

# TXT-Record last_change setzen/aktualisieren
LAST_CHANGE_VALUE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
TXT_ID=$(grep -E "^txt::[0-9]+/source: last_change\$" "$TMPFILE" | sed -E 's/^txt::([0-9]+)\/source.*/\1/' | head -n1 || true)

if [ -n "$TXT_ID" ]; then
  echo "Aktualisiere TXT-Record last_change (ID $TXT_ID) auf $LAST_CHANGE_VALUE"
  sed -E "s/^(txt::$TXT_ID\/target: ).*/\1$LAST_CHANGE_VALUE/" "$TMPFILE" > "${TMPFILE}.new"
  mv "${TMPFILE}.new" "$TMPFILE"
else
  echo "Füge neuen TXT-Record last_change mit Wert $LAST_CHANGE_VALUE hinzu"
  LAST_TXT_ID=$(grep -E "^txt::[0-9]+/source:" "$TMPFILE" | sed -E 's/^txt::([0-9]+)\/.*/\1/' | sort -n | tail -n1)
  if [ -z "$LAST_TXT_ID" ]; then
    TXT_ID=1
  else
    TXT_ID=$((LAST_TXT_ID + 1))
  fi
  {
    echo "txt::${TXT_ID}/source: last_change"
    echo "txt::${TXT_ID}/target: $LAST_CHANGE_VALUE"
  } >> "$TMPFILE"
fi

# Optional: Email-Benachrichtigung hinzufügen
if [ -n "$EMAIL_NOTIFY" ]; then
  if ! grep -q "^email::1/notify:" "$TMPFILE"; then
    echo "email::1/notify: $EMAIL_NOTIFY" >> "$TMPFILE"
  fi
fi

# Schritt 4: Zone zurückspielen mit CURL
curl -sS "$ZONE_URL" -X POST --data-binary @"$TMPFILE"
echo
echo "Update abgeschlossen."

# Optional: Warten, bis last_change-TXT im DNS sichtbar ist
if [ "$VERIFY_LOOP" -eq 1 ]; then
  echo "Prüfe, ob TXT last_change im DNS erscheint ..."
  # MAX_WAIT 5 Minuten bei direkter Abfrage am Authoritativen
  MAX_WAIT=300    # maximale Wartezeit in Sekunden
  INTERVAL=15      # Sekunden zwischen den Prüfungen
  waited=0

  while [ "$waited" -lt "$MAX_WAIT" ]; do
    DNS_VALUE=$(dig @"$NS" +short TXT "last_change.$ZONE" | head -n1 | sed -e 's/^"//' -e 's/"$//')
    if [ "$DNS_VALUE" = "$LAST_CHANGE_VALUE" ]; then
      echo "Änderung im DNS bestätigt: last_change=$DNS_VALUE"
      break
    fi
    sleep "$INTERVAL"
    waited=$((waited + INTERVAL))
  done

  if [ "$waited" -ge "$MAX_WAIT" ]; then
    echo "Warnung: last_change-TXT wurde nach ${MAX_WAIT}s noch nicht im DNS gesehen."
  fi
fi

Sowie Perl-Variante:

#!/usr/bin/env perl
# -*- coding: utf-8 -*-
# dexdns.pl – DynDNS-Update für Domainexpress API (Zonen-basiert)
# Konfiguration über ENV: ZONE, TARGETSUB, API_ID, EMAIL_NOTIFY, NS, VERIFY_LOOP

use strict;
use warnings;
use Getopt::Long qw(GetOptions);
use File::Temp qw(tempfile);
use HTTP::Tiny;
use POSIX qw(strftime);
use Socket qw(inet_ntoa);

my $VERIFY_LOOP = $ENV{VERIFY_LOOP} // 0;
my $DEBUG       = $ENV{DEBUG}       // 0;
my $opt_ip      = '';
my $opt_ipv6    = 0;

GetOptions(
    'verify!'   => \$VERIFY_LOOP,
    'debug!'    => \$DEBUG,
    'ip=s'      => \$opt_ip,
    'ipv6|6'    => \$opt_ipv6,
    'help|h'    => sub {
        print "Usage: $0 [--verify] [--no-verify] [--ip ADRESSE] [--ipv6]\n";
        print "  --verify    Nach dem Update warten, bis last_change im DNS erscheint.\n";
        print "  --no-verify Verifikation aus (Default).\n";
        print "  --ip ADDR   Diese IP verwenden statt externe IP abzufragen.\n";
        print "  --ipv6, -6  IPv6 (AAAA) verwenden statt IPv4 (A).\n";
        print "\nKonfiguration über Umgebungsvariablen: ZONE, TARGETSUB, API_ID, EMAIL_NOTIFY, NS\n";
        exit 0;
    },
) or exit 1;

# Konfiguration (ENV mit Defaults)
my $ZONE        = $ENV{ZONE}        // 'liveconfig-bei-domainexpress.eu';
my $TARGETSUB   = $ENV{TARGETSUB}   // 'www3';
my $API_ID      = $ENV{API_ID}      // 'API_ID';
my $EMAIL_NOTIFY = $ENV{EMAIL_NOTIFY} // '';
my $NS          = $ENV{NS}          // 'ns1.hofmeirmedia.net';

my $ZONE_URL    = "https://domainexpress.de:443/api/dns_zone/my_zone/$API_ID/";
my $IP_URL_V4   = 'http://v4.liveconfig-bei-domainexpress.eu/';
my $IP_URL_V6   = 'http://v6.liveconfig-bei-domainexpress.eu/';


# Abhängigkeit: dig für DNS-Abfragen (für TXT und autoritative A-Records).
# Wenn dig fehlt, wird auf den System-Resolver zurückgefallen.
my $HAVE_DIG = 1;
{
    my $ok = system('dig -v >/dev/null 2>&1');
    if ($ok != 0) {
        $HAVE_DIG = 0;
        warn "Hinweis: 'dig' nicht gefunden oder nicht ausführbar – verwende System-DNS.\n";
    }
}

my $http = HTTP::Tiny->new( timeout => 30 );

# --- Debug-Helfer ---

sub _dbg {
    return unless $DEBUG;
    my ($msg) = @_;
    chomp $msg;
    print STDERR "[DEBUG] $msg\n";
}

# IP und Typ (A vs AAAA): per --ip (mit Auto-Erkennung) oder extern abfragen
my ($ip, $rtype, $dig_type);
if (length $opt_ip) {
    $ip = $opt_ip;
    $ip =~ s/^\s+|\s+$//g;
    die "Fehler: Ungültige oder leere IP bei --ip.\n" if !length $ip;
    if ($ip =~ /:/) {
        $rtype    = 'aaaa';
        $dig_type = 'AAAA';
    } else {
        $rtype    = 'a';
        $dig_type = 'A';
    }
    _dbg("IP aus Option --ip: $ip ($rtype)");
} else {
    if ($opt_ipv6) {
        $ip = get_external_ip($IP_URL_V6);
        die "Fehler: Konnte externe IPv6 nicht ermitteln.\n" if !length $ip;
        $rtype    = 'aaaa';
        $dig_type = 'AAAA';
        _dbg("Externe IPv6: $ip");
    } else {
        $ip = get_external_ip($IP_URL_V4);
        die "Fehler: Konnte externe IPv4 nicht ermitteln.\n" if !length $ip;
        $rtype    = 'a';
        $dig_type = 'A';
        _dbg("Externe IP: $ip");
    }
}

# Aktuellen Eintrag aus DNS holen (A oder AAAA)
# Kein Eintrag = undef/leer → Update durchführen und Eintrag anlegen
my $ip_now = $rtype eq 'aaaa' ? dig_aaaa("$TARGETSUB.$ZONE") : dig_a("$TARGETSUB.$ZONE");
if (defined $ip_now) {
    $ip_now =~ s/\s+$//;
    $ip_now = undef if $ip_now eq '';
}
_dbg(defined $ip_now ? "Aktueller DNS-$dig_type-Record für $TARGETSUB.$ZONE: $ip_now" : "Kein $dig_type-Record für $TARGETSUB.$ZONE (wird angelegt)");

if (defined $ip_now && $ip_now eq $ip) {
    print "DNS bereits aktuell ($ip)\n";
    exit 0;
}

print "Starte Update auf $ip für $TARGETSUB.$ZONE\n";
_dbg("ZONE_URL: $ZONE_URL");

# Zone abrufen
my $zone_text = http_get($ZONE_URL);
die "Fehler: Zone konnte nicht geladen werden.\n" if !length $zone_text;
_dbg("Zone-Länge vor Update: " . length($zone_text) . " Bytes");

# Zone bearbeiten: A- oder AAAA-Record für TARGETSUB
$zone_text = update_zone_ip_record($zone_text, $rtype, $TARGETSUB, $ip);

# last_change TXT setzen/aktualisieren
my $last_change_value = strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
$zone_text = update_zone_txt_record($zone_text, 'last_change', $last_change_value);

# Optional: E-Mail-Benachrichtigung
if (length $EMAIL_NOTIFY && $zone_text !~ /^email::1\/notify:/m) {
    $zone_text .= "email::1/notify: $EMAIL_NOTIFY\n";
}

# Zone zurückschicken
my $res = http_post($ZONE_URL, $zone_text);
if (!$res->{success}) {
    print STDERR "Fehler beim POST: " . ($res->{content} // $res->{status}) . "\n";
    exit 1;
}
_dbg("Serverantwort Status: $res->{status}") if defined $res->{status};
print $res->{content} . "\n" if length($res->{content} // '');
print "Update abgeschlossen.\n";

# Optional: Verifikation per DNS
if ($VERIFY_LOOP && $HAVE_DIG) {
    my $max_wait = 300;
    my $interval = 15;
    my $waited   = 0;
    print "Prüfe, ob TXT last_change im DNS erscheint ...\n";
    while ($waited < $max_wait) {
        my $dns_txt = dig_txt("last_change.$ZONE");
        $dns_txt =~ s/^"|"$//g;
        if (defined $dns_txt && $dns_txt eq $last_change_value) {
            print "Änderung im DNS bestätigt: last_change=$dns_txt\n";
            exit 0;
        }
        sleep $interval;
        $waited += $interval;
    }
    print "Warnung: last_change-TXT wurde nach ${max_wait}s noch nicht im DNS gesehen.\n";
}
elsif ($VERIFY_LOOP && !$HAVE_DIG) {
    warn "Verifikation via TXT-Record ist aktiviert, aber 'dig' ist nicht verfügbar – überspringe Prüf-Schleife.\n";
}

# --- Hilfsfunktionen ---

sub get_external_ip {
    my ($url) = @_;
    $url //= $IP_URL_V4;
    my $r = $http->get($url);
    return '' unless $r->{success} && length($r->{content});
    my $ip = $r->{content};
    $ip =~ s/\s+$//;
    return $ip;
}

sub dig_a {
    my ($fqdn) = @_;

    if ($HAVE_DIG) {
        open my $fh, '-|', 'dig', '@' . $NS, '+short', 'A', $fqdn or return undef;
        my $line = <$fh>;
        close $fh;
        return defined $line ? $line : undef;
    }

    my ($name, $aliases, $addrtype, $length, @addrs) = gethostbyname($fqdn);
    return undef unless @addrs;
    return inet_ntoa($addrs[0]);
}

sub dig_aaaa {
    my ($fqdn) = @_;

    return undef unless $HAVE_DIG;
    open my $fh, '-|', 'dig', '@' . $NS, '+short', 'AAAA', $fqdn or return undef;
    my $line = <$fh>;
    close $fh;
    return defined $line ? $line : undef;
}

sub dig_txt {
    my ($fqdn) = @_;

    # TXT-Records können wir nur mit 'dig' sauber auflösen.
    return undef unless $HAVE_DIG;

    open my $fh, '-|', 'dig', '@' . $NS, '+short', 'TXT', $fqdn or return undef;
    my $line = <$fh>;
    close $fh;
    return defined $line ? $line : undef;
}

sub http_get {
    my ($url) = @_;
    my $r = $http->get($url);
    return '' unless $r->{success};
    return $r->{content} // '';
}

sub http_post {
    my ($url, $body) = @_;
    return $http->request('POST', $url, {
        content => $body,
        headers => { 'Content-Type' => 'application/octet-stream' },
    });
}

# $rtype = 'a' oder 'aaaa' (Zonenformat: a:: bzw. aaaa::)
sub update_zone_ip_record {
    my ($zone, $rtype, $source, $ip) = @_;
    my $prefix = $rtype;    # "a" oder "aaaa"
    my $label   = $rtype eq 'aaaa' ? 'AAAA' : 'A';
    my $record_id;
    if ($zone =~ /^\Q$prefix\E::(\d+)\/source: \Q$source\E\s*$/m) {
        $record_id = $1;
    }
    if (defined $record_id) {
        print "Gefundener bestehender $label-Record mit ID $record_id – aktualisiere target\n";
        $zone =~ s/^(\Q$prefix\E::\Q$record_id\E\/target: ).*/$1$ip/m;
        return $zone;
    }
    print "Kein bestehender $label-Record für $source gefunden – füge neuen Eintrag hinzu\n";
    my $max_id = 0;
    while ($zone =~ /^\Q$prefix\E::(\d+)\/source:/gm) { $max_id = $1 if $1 > $max_id; }
    my $new_id = $max_id + 1;
    $zone .= "${prefix}::${new_id}/source: $source\n${prefix}::${new_id}/target: $ip\n";
    return $zone;
}

sub update_zone_txt_record {
    my ($zone, $source, $target) = @_;
    my $record_id;
    if ($zone =~ /^txt::(\d+)\/source: \Q$source\E\s*$/m) {
        $record_id = $1;
    }
    if (defined $record_id) {
        print "Aktualisiere TXT-Record $source (ID $record_id) auf $target\n";
        $zone =~ s/^(txt::\Q$record_id\E\/target: ).*/$1$target/m;
        return $zone;
    }
    print "Füge neuen TXT-Record $source mit Wert $target hinzu\n";
    my $max_id = 0;
    while ($zone =~ /^txt::(\d+)\/source:/gm) { $max_id = $1 if $1 > $max_id; }
    my $new_id = $max_id + 1;
    $zone .= "txt::${new_id}/source: $source\ntxt::${new_id}/target: $target\n";
    return $zone;
}