- Go 92.7%
- Shell 3.5%
- Makefile 2.5%
- Dockerfile 1.3%
|
|
||
|---|---|---|
| .forgejo | ||
| cmd/hetzner-ddns | ||
| internal | ||
| scripts | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| CHANGELOG.md | ||
| CODEOWNERS | ||
| CONTRIBUTING.md | ||
| docker-compose.yml | ||
| Dockerfile | ||
| go.mod | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
| SECURITY.md | ||
hetzner-ddns
Dynamisches DNS für die Hetzner Cloud API. Das Tool ermittelt die aktuelle
öffentliche IP-Adresse dieses Hosts und hält damit die A- und
AAAA-Records beliebiger Hetzner-DNS-Zonen aktuell — für den Zonen-Apex, für
Wildcards und für einzelne Namen.
Gedacht ist es für den klassischen Fall: ein NAS, ein Heimserver oder ein Raspberry Pi hinter einem Anschluss mit wechselnder IP, der trotzdem unter seinem eigenen Namen erreichbar bleiben soll.
- Vollständig über eine
.envkonfigurierbar — keine Domain ist im Code verdrahtet. - IPv4 und IPv6: A-Records über externe Echo-Dienste (funktioniert hinter NAT), AAAA-Records wahlweise direkt vom Netzwerk-Interface.
- Schreibt nur, wenn sich wirklich etwas geändert hat, und legt fehlende Records auf Wunsch selbst an.
- Läuft unter Docker und Podman, auch rootless.
- Container ohne Shell, ohne Paketmanager, ohne libc, als Nicht-Root — rund 8 MB.
- Health-Endpunkt, sauberes Herunterfahren bei
SIGTERM, Logs als JSON oder Text.
Cloud API, nicht DNS-Console. Hetzner betreibt zwei DNS-Schnittstellen. Dieses Tool nutzt die Cloud API (
api.hetzner.cloud, Anmeldung perAuthorization: Bearer). Ein Token der älteren DNS-Console (dns.hetzner.com, HeaderAuth-API-Token) funktioniert hier nicht und führt zu401. Das ist der mit Abstand häufigste Einstiegsfehler.
Inhalt
- Quick Start
- Dateien und Image beziehen
- Voraussetzungen
- Schritt 1 — Vorbereitung bei Hetzner
- Schritt 2 — Konfiguration
- Schritt 3 — Probelauf
- Schritt 4 — Dauerbetrieb
- IPv4 hinter NAT, IPv6 am Interface
- Alle Konfigurationsvariablen
- Betrieb, Health und Logs
- Fehlersuche
- Sicherheit
- Aus dem Quelltext bauen
Quick Start
Für alle, die schon eine DNS-Zone bei Hetzner und ein Cloud-API-Token haben. Wenn nicht: Schritt 1 beschreibt beides.
# 1. Verzeichnis anlegen und die beiden Dateien holen
mkdir -p ~/hetzner-ddns && cd ~/hetzner-ddns
BASE=https://gbjo.mazurczak.cloud/nexus/hetzner-ddns/raw/branch/main
curl -fsSLO "$BASE/docker-compose.yml"
curl -fsSL "$BASE/.env.example" -o .env
# 2. Token und Records eintragen
${EDITOR:-nano} .env
# 3. Probelauf — schreibt nichts, zeigt nur, was sich ändern würde
docker compose run --rm hetzner-ddns check
# 4. Wenn die Ausgabe stimmt: starten
docker compose up -d
docker compose logs -f
In .env sind nur zwei Zeilen wirklich nötig:
HETZNER_API_TOKEN=dein-cloud-api-token
DDNS_RECORDS=example.com:@,example.com:*
Zwei Wege für den Token. Für den Anfang ist
HETZNER_API_TOKENmit dem Wert direkt in der.envder kürzere, und für ein Gerät, auf das nur du Zugriff hast, ist er auch völlig in Ordnung.Alternativ liest das Programm den Token aus einer Datei und hält ihn damit aus
docker inspectheraus. Das lohnt sich, sobald mehrere Personen an den Host kommen. Dafür wird die Datei nach/run/secrets/hetzner_api_tokengemountet — mehr ist nicht zu tun. Der Ort steht fest, es gibt keine Variable dafür, und in der.envbleibt der Token dann einfach leer. Die Einzelheiten stehen unter Geheimnisse aus einer Datei.
Wer IPv6 vom Interface lesen lässt (Voreinstellung), trägt zusätzlich den
Namen des Interfaces auf dem Host ein — ip -6 addr zeigt ihn:
IPV6_INTERFACE=eno1
Mit Podman ist der Ablauf identisch, nur podman-compose statt
docker compose.
Aktualisieren:
docker compose pull && docker compose up -d
Dateien und Image beziehen
Alles liegt öffentlich auf gbjo.mazurczak.cloud/nexus/hetzner-ddns — ein Konto wird zum Lesen und zum Pullen nicht gebraucht.
Nur die beiden Dateien für den Betrieb
Mehr als docker-compose.yml und .env braucht es nicht:
BASE=https://gbjo.mazurczak.cloud/nexus/hetzner-ddns/raw/branch/main
curl -fsSLO "$BASE/docker-compose.yml"
curl -fsSL "$BASE/.env.example" -o .env
Das Muster der URL ist …/raw/branch/<branch>/<pfad>, es funktioniert also
für jede Datei des Repos.
Ganzes Repo
git clone https://gbjo.mazurczak.cloud/nexus/hetzner-ddns.git
Oder als Archiv, ohne Git:
curl -fsSL https://gbjo.mazurczak.cloud/nexus/hetzner-ddns/archive/main.tar.gz \
| tar xz
Nur das Image
docker pull gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest
Es gibt bewusst nur :latest. Das Image wird bei jedem Merge nach main
gebaut und enthält ausschließlich das Binary — kein Shell, kein Paketmanager.
Das veröffentlichte Image ist ein x86-64-Image (amd64). Auf einem gewöhnlichen PC, Server oder NAS mit Intel- oder AMD-Prozessor ist damit nichts weiter zu tun —
docker pullgenügt.Nur auf ARM-Geräten — Raspberry Pi, ARM-basiertes NAS, Apple Silicon — passt das Image nicht, denn es enthält ein Go-Binary für genau eine Architektur.
uname -msagt, worauf du bist:x86_64heißt amd64 und passt,aarch64oderarmv7lheißt ARM und passt nicht. In dem Fall einmal selbst bauen, das dauert etwa eine Minute:git clone https://gbjo.mazurczak.cloud/nexus/hetzner-ddns.git cd hetzner-ddns && make docker-buildDanach in
docker-compose.ymlstatt derimage:-Zeile den darunter auskommentiertenbuild:-Block verwenden. Das Makefile erkennt Docker und Podman selbst.
Voraussetzungen
- Eine Domain bei einem Registrar
- Ein Hetzner-Account mit einem Cloud-Projekt
- Docker oder Podman auf dem Gerät, das erreichbar bleiben soll
Schritt 1 — Vorbereitung bei Hetzner
1.1 DNS-Zone anlegen
- console.hetzner.com öffnen und ein Projekt auswählen — oder ein neues anlegen (siehe Kasten unten).
- Links DNS wählen und auf Zone hinzufügen klicken.
- Den Domainnamen ohne Punkt am Ende eintragen, also
example.com.
Empfehlung: ein eigenes Projekt nur für DNS. Cloud-API-Tokens gelten immer für ein ganzes Projekt — einen Scope „nur DNS" gibt es nicht. Liegen im selben Projekt auch Server, Volumes oder Firewalls, dürfte dieses Token sie ebenfalls verändern oder löschen. Ein separates Projekt, das ausschließlich die DNS-Zonen enthält, begrenzt den Schaden eines geleakten Tokens auf genau diese Zonen. Das ist die einzige wirksame Einschränkung, die die API anbietet.
1.2 Nameserver beim Registrar eintragen
Bei Hetzner gekaufte Domains: dieser Schritt entfällt. Wurde die Domain direkt über Hetzner registriert, ist sie bereits an Hetzner delegiert — es gibt keinen fremden Registrar, bei dem etwas einzutragen wäre. Weiter mit 1.3.
Damit die Zone überhaupt greift, muss die Domain zu Hetzner delegiert werden. Beim Registrar der Domain die Nameserver eintragen, die die Hetzner Console bei dieser Zone anzeigt. Üblich ist dieser Satz:
hydrogen.ns.hetzner.com
oxygen.ns.hetzner.com
helium.ns.hetzner.de
Es gibt mehr als einen Hetzner-Nameserversatz. Je nachdem, woher die Zone stammt, kann sie stattdessen an
ns1.your-server.de,ns.second-ns.comundns3.second-ns.dedelegiert sein. Das ist kein Fehler: Auch dieser Satz liefert eine DNS-Zone aus dem Cloud-Projekt ganz normal aus. Maßgeblich ist immer, was in der Console bei der Zone steht — nicht die Liste oben.
Eine Umstellung bei einem fremden Registrar kann bis zu 24 Stunden dauern. Ob die Delegierung steht, beantwortet eine Abfrage gegen einen externen Resolver:
dig +short NS example.com @1.1.1.1
Die Ausgabe muss zu den Nameservern passen, die die Console bei dieser Zone nennt — welcher der Sätze es ist, spielt keine Rolle. Erst dann beantwortet die Zone aus dem Cloud-Projekt Anfragen für diese Domain.
1.3 TTL niedrig setzen
Die TTL bestimmt, wie lange Resolver eine Antwort zwischenspeichern. Der Zonen-Standard liegt oft bei 86400 Sekunden — einem ganzen Tag. Damit wäre dynamisches DNS praktisch wirkungslos: Der Record wäre nach einem IP-Wechsel zwar sofort korrekt, aber die halbe Welt bekäme noch stundenlang die alte Adresse.
Sinnvoll sind 60 bis 300 Sekunden. hetzner-ddns setzt die TTL beim
Anlegen eines Records selbst (DDNS_TTL) und korrigiert sie auch später, wenn
sie abweicht — von Hand nachziehen muss man also nichts.
1.4 Records anlegen (optional)
Die Records können vorab in der Console angelegt werden. Muss man aber nicht:
mit DDNS_CREATE_MISSING=true (Standard) legt hetzner-ddns fehlende RRsets
beim ersten Durchlauf selbst an, mit der konfigurierten TTL.
1.5 API-Token erzeugen
- In der Console das Projekt öffnen, in dem die Zone liegt.
- Security → API Tokens → Token generieren.
- Berechtigung Read & Write wählen. Nur-Lesen genügt nicht.
- Den Wert sofort kopieren — er wird nur ein einziges Mal angezeigt.
Schritt 2 — Konfiguration
Ein Verzeichnis anlegen und darin eine Datei .env erstellen. Das Minimum sind
zwei Zeilen:
HETZNER_API_TOKEN=dein-cloud-api-token
DDNS_RECORDS=example.com:@,example.com:*
Die vollständige Vorlage mit allen Optionen und Erklärungen liegt in .env.example.
Die Record-Syntax
DDNS_RECORDS ist eine kommagetrennte Liste. Jeder Eintrag hat die Form:
zone:name[:typ[+typ]]
| Teil | Bedeutung |
|---|---|
zone |
Der Zonenname, wie er in der Hetzner Console steht — example.com |
name |
Der Name relativ zur Zone. @ ist der Apex, * der Wildcard |
typ |
Optional. Überschreibt DDNS_TYPES für genau diesen Eintrag |
Beispiele:
# Apex und Wildcard, mit den Typen aus DDNS_TYPES
DDNS_RECORDS=example.com:@,example.com:*
# nas.example.com — nur der Name, nicht der volle Domainname
DDNS_RECORDS=example.com:nas
# Nur AAAA für einen Eintrag, beide Typen für den anderen
DDNS_RECORDS=example.com:@,example.com:v6only:AAAA
# Beide Typen ausdrücklich für einen einzelnen Eintrag
DDNS_RECORDS=example.com:vpn:A+AAAA
# Mehrere Zonen — sie müssen im selben Projekt wie das Token liegen
DDNS_RECORDS=example.com:@,example.com:*,example.org:@
Häufiger Stolperstein: Der Name ist relativ. example.com:nas.example.com
würde nas.example.com.example.com erzeugen. Das Tool weist das beim Start ab
und nennt die richtige Schreibweise.
Geheimnisse aus einer Datei
Statt HETZNER_API_TOKEN in der .env kann der Token als Datei bereitgestellt
werden. Der Ort dafür steht fest:
/run/secrets/hetzner_api_token
Dieser Pfad ist nicht konfigurierbar, und das ist Absicht. Es gibt keine
Variable dafür — liegt dort eine Datei, wird sie benutzt. Damit ist der Mount
die gesamte Konfiguration; in der .env ist nichts einzutragen.
Zu beachten bleibt nur eines: Der Prozess läuft nicht als Root. Eine Datei,
die root mit 0600 gehört, ist für ihn nicht lesbar.
Der Mount
In docker-compose.yml ist die betreffende Zeile auskommentiert und muss
zunächst aktiviert werden — das # am Zeilenanfang entfernen:
volumes:
- /etc/localtime:/etc/localtime:ro
- ./hetzner_api_token:/run/secrets/hetzner_api_token:ro
Eine Mount-Zeile besteht aus zwei durch : getrennten Pfaden:
./hetzner_api_token : /run/secrets/hetzner_api_token :ro
└── wo die Datei └── wo sie im Container └── nur lesbar
auf dem Host liegt erscheint
Die linke Seite richtet sich danach, wo deine Token-Datei tatsächlich liegt:
| Ablageort der Datei | Linke Seite der Mount-Zeile |
|---|---|
Neben der docker-compose.yml |
./hetzner_api_token |
| In einem Unterverzeichnis daneben | ./secrets/hetzner_api_token |
| Irgendwo sonst auf dem Host | Der vollständige Pfad, z.B. /srv/container/hetzner-ddns/hetzner_api_token |
Die rechte Seite bleibt, wie sie ist. Sie ist der Ort, an dem das Programm
nachsieht, und sie ist nicht konfigurierbar. Damit ist die Sache erledigt: in
der .env ist nichts einzutragen — kein Pfad, keine Variable.
Der Ort ist derselbe, den Docker und Podman auch für ihre eigenen
secrets:-Blöcke benutzen. Ein solcher Block funktioniert deshalb ebenfalls
ohne weitere Konfiguration:
secrets:
- hetzner_api_token
secrets:
hetzner_api_token:
file: ./hetzner_api_token
Die Rechte — der bequeme Weg
Der Container muss die Datei lesen können. Es liegt nahe, sie ihm dafür zu übereignen — aber dann gehört sie einem Benutzer, der auf dem Host gar nicht existiert, und man kommt selbst nicht mehr bequem daran.
Umgekehrt ist es einfacher: den Container unter der UID laufen lassen, der
die Datei ohnehin gehört. Das Image legt sich auf keine bestimmte UID fest —
es schreibt nichts, schlägt keinen Benutzer in /etc/passwd nach, und das
Binary ist für alle ausführbar.
# docker-compose.yml
user: "1000:1000" # deine eigene uid:gid, siehe `id -u` und `id -g`
chmod 600 hetzner_api_token # gehoert weiterhin dir
Damit bleibt die Datei dein Eigentum, du bearbeitest sie wie jede andere, und
es braucht weder chown noch podman unshare. Funktioniert unter Docker wie
unter Podman, rootful wie rootless.
Warum
600und nicht400?400nimmt dem Eigentümer das Schreibrecht, ohne dass es etwas gewinnt: Gegen andere Benutzer des Hosts schützt bereits600, und die Datei ist ohnehin nur so gut geschützt wie das Verzeichnis, in dem sie liegt.400bringt nur dann etwas, wenn ein versehentliches Überschreiben durch den Eigentümer selbst das Problem ist — und dann istchmodeinen Moment lang zurückzusetzen die Lösung, kein Dauerzustand.
Die folgenden Varianten braucht nur, wer die Vorgabe-UID des Images (65532) beibehalten will — etwa weil eine Richtlinie eine feste, nicht-menschliche UID verlangt. Für einen selbst verwalteten NAS ist der Weg oben der richtige.
Bei UID 65532 bleiben, ohne User-Namespace
Das ist der Standardfall bei Docker. Die UID im Container ist dieselbe wie auf dem Host:
chown 65532:65532 hetzner_api_token
chmod 400 hetzner_api_token
Die Datei gehört danach niemandem auf dem Host. Zum Bearbeiten braucht es
sudo — oder man gibt der eigenen Gruppe Schreibrecht:
chown 65532:$(id -g) hetzner_api_token
chmod 460 hetzner_api_token
Bei UID 65532 bleiben, mit User-Namespace (rootless Podman)
Hier greift die Abbildung aus /etc/subuid: Container-UID 65532 entspricht
auf dem Host einer ganz anderen UID. Ein chown 65532 auf dem Host setzt
deshalb die falsche — die Zuordnung stellt podman unshare her, das in
denselben Namespace wechselt:
podman unshare chown 65532:0 hetzner_api_token
podman unshare chmod 460 hetzner_api_token
Die 0 ist Absicht. GIDs werden genauso abgebildet wie UIDs, und in der
üblichen Konfiguration entspricht Container-GID 0 der Gruppe deines
Host-Benutzers. Mit 460 liest der Container-Benutzer die Datei (4), und du
kannst sie auf dem Host weiterhin lesen und bearbeiten (6), ohne jedes Mal
podman unshare zu bemühen. Wer sie nur lesen können muss, nimmt 440.
Bei UID 65532 bleiben, mit User-Namespace unter Docker
Dieselbe Abbildung gibt es auch bei Docker — aber nur bei rootless Docker
oder wenn der Daemon mit --userns-remap läuft. Ein Gegenstück zu
podman unshare gibt es dort allerdings nicht.
Der portable Ausweg ist, das chown aus einem Wegwerf-Container heraus zu
machen; der steckt bereits im richtigen Namespace:
docker run --rm -v "$PWD/hetzner_api_token:/t" --user 0 \
docker.io/library/alpine chown 65532:65532 /t
Umständlich genug, dass in diesem Fall die user:-Zeile oben die bessere Wahl
ist.
In dieser Datei steht nur der Token selbst — kein KEY=VALUE, kein
Kommentar, keine zweite Zeile:
# richtig
printf '%s' 'dein-cloud-api-token' > secrets/hetzner_api_token
chmod 600 secrets/hetzner_api_token
# falsch — das Präfix würde Teil des Tokens und ergäbe ein 401
HETZNER_API_TOKEN=dein-cloud-api-token
Ein abschließender Zeilenumbruch stört nicht, er wird abgeschnitten. Beide Fehlerfälle erkennt das Programm beim Start und benennt sie.
Der Weg über die Datei ist die bessere Variante, sobald mehr als eine Person
Zugriff auf den Host hat: Umgebungsvariablen sind über docker inspect für
jeden lesbar, der an den Docker-Socket kommt — der Inhalt einer Secret-Datei
nicht. Ist beides vorhanden — die Variable gesetzt und eine Datei gemountet —,
gewinnt die Variable.
Schritt 3 — Probelauf
Vor dem ersten scharfen Lauf zeigt das Unterkommando check, was passieren
würde, ohne irgendetwas zu schreiben:
docker run --rm --env-file .env --network host \
gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest check
Mit Podman identisch, nur podman run.
Die Ausgabe nennt die ermittelten Adressen und für jeden Record, ob er bereits stimmt, geändert oder angelegt würde:
level=INFO msg="hetzner-ddns starting" mode="check (nothing will be written)" records=4 ttl=300
level=INFO msg="managing record" record="example.com A"
level=INFO msg="managing record" record="example.com AAAA"
level=INFO msg="current address" type=A address=203.0.114.7 source="HTTP echo (…)"
level=INFO msg="current address" type=AAAA address=2a01:4f8:1:2::1 source="interface \"eth0\""
level=INFO msg="would update record" record="example.com A" from=[203.0.114.1] to=203.0.114.7
level=INFO msg="would create record" record="nas.example.com AAAA" value=2a01:4f8:1:2::1 ttl=300
level=INFO msg="cycle finished" checked=4 changed=1 created=1 failed=0 skipped=0
Prüfenswert ist hier vor allem, ob die gemeldete IPv4 mit der übereinstimmt,
die auch curl -s https://api.ipify.org liefert — und ob die IPv6 die des
Geräts ist und keine private Adresse.
Schritt 4 — Dauerbetrieb
Mit docker run
docker run -d --name hetzner-ddns --restart unless-stopped \
--network host \
--read-only --cap-drop ALL --security-opt no-new-privileges \
--env-file .env \
gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest
Logs mitlesen mit docker logs -f hetzner-ddns.
Mit Compose
Die mitgelieferte docker-compose.yml läuft unverändert unter beiden Engines:
docker compose up -d # oder: podman-compose up -d
docker compose logs -f
Auf eine neuere Version wechseln:
docker compose pull && docker compose up -d
Podman-Besonderheiten
Alles Nötige ist bereits im Image und in der Compose-Datei berücksichtigt, aber es ist gut zu wissen, warum:
- Der Healthcheck steht doppelt. Podman schreibt Images im OCI-Format, und
das kennt kein
HEALTHCHECK-Feld — der Check aus dem Image geht dabei verloren.docker-compose.ymldeklariert ihn deshalb noch einmal. Wer das Image lokal mit Podman baut, brauchtpodman build --format docker(make docker-builderledigt das automatisch). podman-composeignoriertCOMPOSE_PROFILES. Diese Compose-Datei nutzt keine Profile, insofern spielt es hier keine Rolle.- Rootless funktioniert. Der Container schreibt nichts auf die Platte und
braucht kein beschreibbares Volume, deshalb entfällt die sonst übliche
Reibung mit Benutzer-Namespaces und Volume-Rechten.
network_mode: hostreicht auch rootless aus, um die Adressen des Hosts zu lesen. - Healthcheck von Hand prüfen:
podman healthcheck run hetzner-ddns.
Ohne Dauerläufer: cron oder systemd-Timer
Mit DDNS_ONESHOT=true läuft das Tool einen Durchlauf und beendet sich, mit
Exit-Code ungleich null, falls etwas fehlgeschlagen ist:
*/5 * * * * docker run --rm --network host --env-file /etc/hetzner-ddns/.env \
gbjo.mazurczak.cloud/nexus/hetzner-ddns:latest
In diesem Modus gilt die Untergrenze von 60 Sekunden für DDNS_INTERVAL
nicht, weil der Takt von außen kommt.
IPv4 hinter NAT, IPv6 am Interface
Die beiden Adressfamilien werden unterschiedlich ermittelt, und das hat einen Grund.
IPv4 (IPV4_SOURCE=http, Standard). Hinter NAT — also an praktisch jedem
Privatanschluss — trägt das Gerät nur eine private Adresse wie
192.168.178.11. Die öffentliche IPv4 kennt allein der Router. Deshalb wird
sie bei externen Echo-Diensten erfragt, die zurückmelden, unter welcher Adresse
die Anfrage bei ihnen ankam. Die Anbieter sind über IPV4_HTTP_ENDPOINTS frei
austauschbar; sie werden der Reihe nach probiert, der erste brauchbare Treffer
gewinnt.
Wer einen Root-Server betreibt, der seine öffentliche IPv4 selbst trägt, kann
auf IPV4_SOURCE=interface umstellen.
IPv6 (IPV6_SOURCE=interface, Standard). Bei IPv6 gibt es kein NAT: jedes
Gerät hat seine eigene öffentliche Adresse. Ein Echo-Dienst würde
möglicherweise die Adresse des Routers melden, nicht die des NAS. Deshalb wird
hier direkt das Interface gelesen.
Dabei werden gezielt aussortiert:
- Temporäre Adressen (RFC 4941, Privacy Extensions). Sie wechseln alle paar Stunden — ein Record darauf wäre sofort wieder falsch.
- Veraltete und vorläufige Adressen (
deprecated,tentative). - ULA-Adressen aus
fc00::/7und Link-Local ausfe80::/10. Beide werden im Internet nicht geroutet.
Bevorzugt wird die als permanent markierte globale Adresse.
network_mode: host wird nur für IPV6_SOURCE=interface gebraucht. Ohne
den Netzwerk-Namespace des Hosts sieht der Container nur seine eigenen
Interfaces. Wer das vermeiden will, stellt beide Familien auf http um und
kann dann ohne Host-Networking auskommen — vorausgesetzt, der Container hat
selbst IPv6-Konnektivität.
IPV6_INTERFACE ist der Name auf dem Host, nicht im Container. Welche es
gibt, zeigt ip -6 addr; typisch sind eno1, enp3s0 oder eth0.
Adressen, die nie veröffentlicht werden. Unabhängig von der Quelle prüft
das Tool jede ermittelte Adresse und verwirft alles, was im Internet nicht
erreichbar ist: RFC-1918-Bereiche, Carrier-Grade-NAT (100.64.0.0/10),
Loopback, Link-Local, Multicast, Dokumentationsbereiche, ULA. Damit landet
keine 192.168.x.x versehentlich als A-Record — ein Fehler, der sonst
unbemerkt bliebe, weil im eigenen LAN alles weiter funktioniert.
Alle Konfigurationsvariablen
Pflicht sind nur die ersten beiden.
| Variable | Vorgabe | Bedeutung |
|---|---|---|
HETZNER_API_TOKEN |
— | Pflicht. Cloud-API-Token mit Read & Write |
DDNS_RECORDS |
— | Pflicht. Liste zone:name[:typ], siehe oben |
HETZNER_API_URL |
https://api.hetzner.cloud/v1 |
Muss https:// sein |
HETZNER_API_TIMEOUT |
10s |
Zeitlimit je Anfrage |
HETZNER_API_RETRIES |
3 |
Wiederholungen bei 5xx, 429, Netzwerkfehlern |
DDNS_TYPES |
A,AAAA |
Standardtypen für Einträge ohne drittes Feld |
DDNS_TTL |
300 |
TTL in Sekunden, Minimum 60 |
DDNS_RECORD_COMMENT |
managed-by-hetzner-ddns |
Kommentar am geschriebenen Record |
DDNS_CREATE_MISSING |
true |
Fehlende RRsets selbst anlegen |
IPV4_ENABLED |
true |
A-Records pflegen |
IPV4_SOURCE |
http |
http, interface oder none |
IPV4_HTTP_ENDPOINTS |
ipify, icanhazip, checkip | Fallback-Kette, https:// erforderlich |
IPV4_INTERFACE |
— | Nur bei IPV4_SOURCE=interface |
IPV6_ENABLED |
true |
AAAA-Records pflegen |
IPV6_SOURCE |
interface |
interface, http oder none |
IPV6_INTERFACE |
eth0 |
Interface auf dem Host |
IPV6_HTTP_ENDPOINTS |
ipify, icanhazip | Nur bei IPV6_SOURCE=http |
DDNS_INTERVAL |
300s |
Abstand zwischen Durchläufen, Minimum 60s |
DDNS_ONESHOT |
false |
Einmal laufen und beenden |
DDNS_DRY_RUN |
false |
Nichts schreiben, nur protokollieren |
LOG_LEVEL |
info |
debug, info, warn, error |
LOG_FORMAT |
json |
json oder text |
HEALTH_ADDR |
:8080 |
Leer schaltet den Health-Server ab |
HEALTH_PORT |
8080 |
Nur für das Port-Mapping in Compose |
TZ |
— | Zeitzone für die Zeitstempel im Log |
Fehlerhafte Werte werden beim Start abgewiesen, und zwar alle auf einmal — eine kaputte Konfiguration muss man nicht Neustart für Neustart reparieren.
Betrieb, Health und Logs
Wann geschrieben wird
Jeder Durchlauf ermittelt die Adressen einmal pro Familie und vergleicht sie dann für jeden Record mit dem tatsächlichen Zustand in der Zone. Geschrieben wird nur bei einer Abweichung. Ein bestätigter Wert wird für die Laufzeit des Prozesses gemerkt, sodass ein unveränderter Record in späteren Durchläufen gar keine Anfrage mehr auslöst.
Der Prozess hält keinen Zustand auf der Platte. Nach einem Neustart liest
er den Ist-Zustand einmal aus der API und schreibt dann ebenfalls nichts, wenn
alles stimmt. Deshalb braucht der Container kein Volume und kann read_only
laufen.
Schlägt eine Familie fehl — etwa weil IPv6 gerade weg ist — bleibt die andere davon unberührt: Die A-Records werden weiter gepflegt.
Health-Endpunkte
| Pfad | Bedeutung |
|---|---|
GET /healthz |
Der Prozess läuft. Fragt bewusst nicht den Update-Zustand ab — ein Neustart behebt keinen API-Ausfall |
GET /readyz |
200, solange ein Durchlauf kürzlich erfolgreich war; sonst 503 mit einer Begründung im JSON |
Ein einzelner fehlgeschlagener Durchlauf macht den Container nicht sofort
ungesund — die Records sind ja weiterhin richtig. Erst wenn längere Zeit kein
Durchlauf mehr geklappt hat, wird /readyz rot.
Das Image bringt keine Shell und kein curl mit, deshalb dient das Binary
selbst als Healthcheck:
docker exec hetzner-ddns hetzner-ddns healthcheck # Exit 0 = bereit
Weitere Unterkommandos
hetzner-ddns run # Vorgabe: dauerhaft aktualisieren
hetzner-ddns check # anzeigen, was sich ändern würde
hetzner-ddns healthcheck # Bereitschaft einer laufenden Instanz prüfen
hetzner-ddns version # Version und Commit ausgeben
SIGTERM beendet den Prozess sofort, ohne das laufende Intervall abzuwarten —
docker stop dauert also keine zehn Sekunden.
Fehlersuche
LOG_LEVEL=debug protokolliert jede API-Anfrage und jede IP-Ermittlung. Das
Token wird dabei in jedem Fall unkenntlich gemacht, auch wenn es in einer
Fehlermeldung stecken sollte.
| Symptom | Ursache und Abhilfe |
|---|---|
HTTP 401 beim Start |
Falscher Token-Typ. Dieses Tool braucht ein Cloud-API-Token von console.hetzner.com, kein Token der alten DNS-Console. Neu erzeugen unter Security → API Tokens |
no API token, obwohl die Token-Datei existiert |
Sie ist nicht nach /run/secrets/hetzner_api_token gemountet. Die rechte Seite der Mount-Zeile muss exakt dieser Pfad sein — siehe Geheimnisse aus einer Datei |
/run/secrets/hetzner_api_token is not readable |
Die Datei gehört root mit 0600, der Prozess läuft als UID 65532. Entweder user: auf den Eigentümer setzen oder die Datei ihm zugänglich machen |
HTTP 403 |
Das Token hat nur Leserechte, oder die Zone liegt in einem anderen Projekt als das Token |
HTTP 404 und die Zone existiert |
Das Token gehört zu einem anderen Projekt. Cloud-Tokens sind projektgebunden |
record does not exist |
DDNS_CREATE_MISSING=false und der Record fehlt. Entweder in der Console anlegen oder die Option einschalten |
HTTP 429 |
Rate-Limit erreicht (3600 Anfragen pro Stunde und Projekt). DDNS_INTERVAL erhöhen oder die Zahl der Records verringern |
no usable address found für IPv6, Interface nicht gefunden |
Falscher Interface-Name, oder der Container läuft ohne network_mode: host. ip -6 addr auf dem Host zeigt die richtigen Namen |
has no publishable global IPv6 |
Auf dem Interface liegt nur eine temporäre, veraltete oder ULA-Adresse. Prüfen, ob der Anschluss überhaupt eine feste globale IPv6 bekommt |
interface … has no public IPv4 |
Das Gerät hängt hinter NAT. IPV4_SOURCE=http setzen (Standard) |
A-Record zeigt auf 192.168.x.x |
Kann mit diesem Tool nicht passieren — private Adressen werden verworfen. Steht so ein Wert in der Zone, stammt er aus einer anderen Quelle |
dig liefert eine andere Adresse als erwartet |
Der eigene Resolver schreibt um. Pi-hole, FritzBox oder ein Split-Horizon-Setup beantworten Anfragen für die eigene Domain oft mit der LAN-Adresse. Immer gegen einen externen Resolver prüfen: dig +short A example.com @1.1.1.1 |
| Änderung wirkt nicht | Die alte TTL läuft noch. Nach der ersten Umstellung auf 300s einmal so lange warten, wie die vorherige TTL war |
| Zone antwortet gar nicht | Die Delegierung fehlt oder ist noch nicht durch. dig +short NS example.com @1.1.1.1 muss die Hetzner-Nameserver zeigen |
Sicherheit
Das Laufzeit-Image basiert auf gcr.io/distroless/static:nonroot und enthält
außer dem Binary und dem CA-Bundle nichts: keine Shell, keinen Paketmanager,
keine libc. Es läuft als Nicht-Root (UID 65532) und kommt mit read_only,
cap_drop: ALL und no-new-privileges aus. Das Programm selbst hat keine
Abhängigkeiten außerhalb der Go-Standardbibliothek.
Empfehlungen für den Betrieb:
- Eigenes Projekt für die DNS-Zonen. Cloud-Tokens gelten projektweit; das ist die einzige wirksame Begrenzung des Schadensradius.
- Ein eigenes Token für dieses Tool, nicht dasselbe wie für eine ACME-DNS-Challenge oder ein anderes Werkzeug. Getrennte Tokens lassen sich einzeln zurückziehen.
- Den Token als Datei mounten statt ihn in die
.envzu schreiben, sobald mehrere Personen Zugriff auf den Host haben. - Wer die IP nicht an die voreingestellten Echo-Dienste geben möchte, trägt
unter
IPV4_HTTP_ENDPOINTSeigene ein — etwa einen selbst betriebenen.
Sicherheitslücken bitte nach SECURITY.md melden.
Aus dem Quelltext bauen
Vorausgesetzt wird Go in der Version aus go.mod.
make build # Binary nach bin/
make test # Tests
make test-race # Tests mit Race-Detector
make lint # gofmt, go vet, golangci-lint
make check # Probelauf gegen die lokale .env
make docker-build # Image bauen (erkennt Docker oder Podman selbst)
make help # alle Ziele auflisten
Beiträge sind willkommen — siehe CONTRIBUTING.md.
Lizenz
MIT — siehe LICENSE.