Rewrite in Go, IPv4-Support und Release-Pipeline #1

Merged
nexus merged 6 commits from feature/go-rewrite into main 2026-08-04 18:08:57 +02:00
Owner

Macht aus dem privaten Shell-Skript ein Projekt, das jeder gegen eigene Domains laufen lassen kann.

Was neu ist

  • Domain-agnostisch: DDNS_RECORDS=zone:name[:typ], keine Domain mehr im Quelltext
  • A-Records (IPv4) ueber externe HTTPS-Echo-Dienste mit Fallback-Kette, weil hinter NAT nur der Router die oeffentliche IPv4 kennt
  • Fehlende RRsets anlegenset_records scheitert an einem RRset, das es noch nicht gibt
  • TTL-Abgleich, weil eine zu hohe TTL dynamisches DNS wirkungslos macht
  • Bogon-Filter: RFC 1918, CGNAT, Loopback, Link-Local, ULA, Multicast werden verworfen, statt als oeffentlicher Record zu landen
  • Unterkommandos check, healthcheck, version; Health-Endpunkte; DDNS_ONESHOT fuer cron
  • Ausfuehrliche deutsche README inklusive der Schritte in der Hetzner Console

Behobene Fehler des Shell-Skripts

Problem Fix
curl -sf verwarf den Response-Body bei HTTP >= 400 — die Fehlerausgabe war ausgerechnet im Fehlerfall leer error.code/error.message werden geparst und um einen Hinweis ergaenzt
/tmp/last_ip ohne Volume: jeder Neustart schrieb alle Records neu Ist-Zustand kommt aus der API; Container laeuft read_only ohne Volume
Keine Wiederholung bei transienten Fehlern Backoff mit Jitter fuer 5xx/429/Netzwerk, Retry-After beachtet, 4xx nie
stderr landete in der Variablen, aus der der HTTP-Status gelesen wurde Typisierter HTTP-Client
ZONES in der Compose-Datei war tote, abweichende Konfiguration Eine Quelle: DDNS_RECORDS
grep -v temporary als Heuristik fuer IPv6-Privacy-Adressen Kernel-Flags aus /proc/net/if_inet6; kein iproute2 mehr noetig
SIGTERM wurde im sleep ignoriert Context-basiertes Herunterfahren
Token konnte in Logs erscheinen Redaktion aller Werte, auch in verschachtelten Fehlerobjekten

Sicherheit

  • Laufzeit-Image gcr.io/distroless/static:nonroot: keine Shell, kein Paketmanager, Nicht-Root
  • Keine Abhaengigkeiten ausserhalb der Go-Standardbibliothek — kein go.sum
  • scripts/check-domains.sh erzwingt, dass nur RFC-2606-Platzhalter im Baum und in der Historie vorkommen. Positivliste, faengt also auch Domains, an die heute niemand denkt. Erster Schritt der Pipeline.

Pipeline

Uebernimmt die Anordnung, die auf dieser Instanz nachweislich funktioniert: schlichtes docker build ohne BuildKit ueber den Podman-Socket-Proxy, Actions per voller URL, jeder Push durch Zurueckziehen des Tags verifiziert. Gebaut wird auf jedem Branch, gepusht nur von main und nur :latest.

Noch zu tun (ausserhalb dieses PRs)

  • Repo-Secret REGISTRY_TOKEN setzen (Forgejo-Token mit write:package). Ohne ihn baut die Pipeline nur und pusht nicht — sie bleibt also gruen.
  • Das alte Hetzner-Token zurueckziehen und ein neues erzeugen.

Getestet

  • make lint test-race gruen, 0 Lint-Issues
  • check gegen example.com: IPv4 stimmte mit einem unabhaengigen Echo-Dienst ueberein, IPv6 mit der globalen Adresse auf eth0, das 401 kam mit lesbarer Fehlermeldung und Hinweis
  • Domain-Schranke gegen eine absichtlich eingeschleuste Fremddomain geprueft
Macht aus dem privaten Shell-Skript ein Projekt, das jeder gegen eigene Domains laufen lassen kann. ## Was neu ist - **Domain-agnostisch**: `DDNS_RECORDS=zone:name[:typ]`, keine Domain mehr im Quelltext - **A-Records (IPv4)** ueber externe HTTPS-Echo-Dienste mit Fallback-Kette, weil hinter NAT nur der Router die oeffentliche IPv4 kennt - **Fehlende RRsets anlegen** — `set_records` scheitert an einem RRset, das es noch nicht gibt - **TTL-Abgleich**, weil eine zu hohe TTL dynamisches DNS wirkungslos macht - **Bogon-Filter**: RFC 1918, CGNAT, Loopback, Link-Local, ULA, Multicast werden verworfen, statt als oeffentlicher Record zu landen - Unterkommandos `check`, `healthcheck`, `version`; Health-Endpunkte; `DDNS_ONESHOT` fuer cron - Ausfuehrliche deutsche README inklusive der Schritte in der Hetzner Console ## Behobene Fehler des Shell-Skripts | Problem | Fix | |---|---| | `curl -sf` verwarf den Response-Body bei HTTP >= 400 — die Fehlerausgabe war ausgerechnet im Fehlerfall leer | `error.code`/`error.message` werden geparst und um einen Hinweis ergaenzt | | `/tmp/last_ip` ohne Volume: jeder Neustart schrieb alle Records neu | Ist-Zustand kommt aus der API; Container laeuft `read_only` ohne Volume | | Keine Wiederholung bei transienten Fehlern | Backoff mit Jitter fuer 5xx/429/Netzwerk, `Retry-After` beachtet, 4xx nie | | `stderr` landete in der Variablen, aus der der HTTP-Status gelesen wurde | Typisierter HTTP-Client | | `ZONES` in der Compose-Datei war tote, abweichende Konfiguration | Eine Quelle: `DDNS_RECORDS` | | `grep -v temporary` als Heuristik fuer IPv6-Privacy-Adressen | Kernel-Flags aus `/proc/net/if_inet6`; kein `iproute2` mehr noetig | | `SIGTERM` wurde im `sleep` ignoriert | Context-basiertes Herunterfahren | | Token konnte in Logs erscheinen | Redaktion aller Werte, auch in verschachtelten Fehlerobjekten | ## Sicherheit - Laufzeit-Image `gcr.io/distroless/static:nonroot`: keine Shell, kein Paketmanager, Nicht-Root - Keine Abhaengigkeiten ausserhalb der Go-Standardbibliothek — kein `go.sum` - `scripts/check-domains.sh` erzwingt, dass nur RFC-2606-Platzhalter im Baum und in der Historie vorkommen. Positivliste, faengt also auch Domains, an die heute niemand denkt. Erster Schritt der Pipeline. ## Pipeline Uebernimmt die Anordnung, die auf dieser Instanz nachweislich funktioniert: schlichtes `docker build` ohne BuildKit ueber den Podman-Socket-Proxy, Actions per voller URL, jeder Push durch Zurueckziehen des Tags verifiziert. Gebaut wird auf jedem Branch, gepusht nur von `main` und nur `:latest`. ## Noch zu tun (ausserhalb dieses PRs) - Repo-Secret `REGISTRY_TOKEN` setzen (Forgejo-Token mit `write:package`). Ohne ihn baut die Pipeline nur und pusht nicht — sie bleibt also gruen. - Das alte Hetzner-Token zurueckziehen und ein neues erzeugen. ## Getestet - `make lint test-race` gruen, 0 Lint-Issues - `check` gegen `example.com`: IPv4 stimmte mit einem unabhaengigen Echo-Dienst ueberein, IPv6 mit der globalen Adresse auf `eth0`, das 401 kam mit lesbarer Fehlermeldung und Hinweis - Domain-Schranke gegen eine absichtlich eingeschleuste Fremddomain geprueft
Rewrite the DDNS updater in Go and add IPv4 support
All checks were successful
CI / test (push) Successful in 1m35s
CI / test (pull_request) Successful in 1m47s
CI / image (push) Successful in 53s
CI / image (pull_request) Successful in 39s
1455a77b26
The tool started as a shell script with the zones hard coded, AAAA records
only, and no tests. This turns it into something anyone can run against their
own domains.

Configuration is entirely environment driven: DDNS_RECORDS takes entries of
the form zone:name[:type], so no domain appears in the source. A records are
new; their address comes from external HTTPS echo services by default, because
a host behind NAT only ever sees a private address on its own interface.

Several defects of the shell version are fixed along the way. curl -sf
discarded the response body on any status >= 400, so the one log line that
mattered was always empty; the API error object is now parsed and paired with
a hint about what to do. The last known IP lived in /tmp without a volume, so
every restart rewrote every record; the current state is read from the API
instead, which also lets the container run read-only with no volume at all.
There were no retries; there is now backoff with jitter for 5xx, 429 and
network errors, honouring Retry-After, and never for other 4xx. set_records
fails on an RRset that does not exist yet, so missing ones are created. The
IPv6 selection reads the kernel flags from /proc/net/if_inet6 rather than
grepping the output of ip, which makes the exclusion of temporary, deprecated
and tentative addresses reliable and drops the iproute2 dependency.

Every resolved address is checked against the non-routable ranges before it is
published, so a private or CGNAT address cannot silently become an A record.

The runtime image is distroless/static:nonroot: no shell, no package manager,
non-root. The module has no dependencies outside the standard library.

The pipeline mirrors the arrangement that already works on this instance:
plain docker build without BuildKit through the Podman socket proxy, actions
by full URL, and each push verified by pulling the tag back.

scripts/check-domains.sh enforces that only RFC 2606 placeholder names appear
in the tree, in the working copy and in the history. It uses an allow list, so
it catches domains nobody has thought of yet.
Say plainly how a token would leak into a log
All checks were successful
CI / image (push) Successful in 51s
CI / image (pull_request) Successful in 38s
CI / test (push) Successful in 1m30s
CI / test (pull_request) Successful in 1m41s
07351305db
"Leckpfad" was an invention, and an opaque one. Name the mechanism instead:
a library that puts the request URL into its error text.
Note that a domain bought at Hetzner needs no registrar change
All checks were successful
CI / test (push) Successful in 1m29s
CI / test (pull_request) Successful in 1m34s
CI / image (push) Successful in 51s
CI / image (pull_request) Successful in 36s
5f42dc744e
Step 1.2 assumed a third party registrar. When the domain was registered at
Hetzner there is no registrar to visit, and the step is simply skipped.

The nameserver check stays worth doing either way, because Hetzner runs two
sets: a domain administered through konsoleH is delegated to ns1.your-server.de
and friends, which does not serve the DNS zone in the Cloud project. That
looks like a working delegation and fails in a way nothing else explains.
Send RRset names to the API unencoded
All checks were successful
CI / test (pull_request) Successful in 1m41s
CI / image (push) Successful in 57s
CI / test (push) Successful in 1m33s
CI / image (pull_request) Successful in 40s
bbbbb7ad98
url.PathEscape turns "*" into %2A, and the Hetzner API does not
percent-decode path segments. Verified against the live API: /rrsets/%2A/AAAA
answers 404 while /rrsets/*/AAAA answers 200, and the same holds for the apex
and %40.

Every wildcard record was therefore broken, and broken in the worst available
way: the 404 reads as "this RRset does not exist", so the updater would have
gone on to create one that was already there, once per cycle, forever. The
test that was supposed to cover this asserted the encoding instead of the
behaviour, so it confirmed the bug rather than catching it.

Names now travel literally, with anything outside the DNS character set
percent-encoded so a stray slash cannot leave the segment, and the config
rejects such names up front.

The README claim that ns1.your-server.de and friends do not serve a Cloud DNS
zone was wrong too. A zone can legitimately be delegated to that set; what the
Console shows for the zone is what counts.
Add a quick start and say how to fetch the files
All checks were successful
CI / test (push) Successful in 1m29s
CI / test (pull_request) Successful in 1m37s
CI / image (push) Successful in 49s
CI / image (pull_request) Successful in 35s
628ec96fdd
The README opened with the Hetzner setup, which is the right first step for a
new zone but a wall of text for someone who already has one. A quick start now
sits at the top: two files, an editor, a dry run, up.

It also never said where the files come from. The raw, clone and archive URLs
are all public and need no account; the note about the image being single
architecture belongs there too, since that is where someone would hit it.
State that the published image is amd64, and build the right arch locally
All checks were successful
CI / test (push) Successful in 1m25s
CI / test (pull_request) Successful in 1m29s
CI / image (push) Successful in 44s
CI / image (pull_request) Successful in 32s
2e0f8c42df
The architecture note sat next to the Podman material and read as if the two
were related. They are not: Docker and Podman are interchangeable here, while
amd64 against ARM decides whether the binary runs at all. The README now says
outright that the published image is x86-64, so most readers are done, and
sends only ARM users to a local build.

That local build was itself wrong for the case it exists to serve. The
Makefile left TARGETARCH to the builder, and podman build — or any docker
without BuildKit — does not set it, so the Dockerfile fell through to amd64.
Building on a Raspberry Pi produced an amd64 binary that could not run there.
It is now derived from go env GOARCH and passed explicitly.
nexus merged commit d9ce8305ea into main 2026-08-04 18:08:57 +02:00
nexus deleted branch feature/go-rewrite 2026-08-04 18:08:57 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
nexus/hetzner-ddns!1
No description provided.