unkinben fa6db89f0d
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
make: fix bump target version parsing
The heredoc-based read broke across make's per-line recipe handling. Parse the
current version with cut in a single backslash-continued shell instead, and
error on an unknown PART.
2026-07-17 23:30:36 +10:00
2026-07-17 23:30:36 +10:00

dns-updater

A small Go daemon that keeps a host's DNS records current on a BIND server via TSIG-signed RFC2136 dynamic updates. It replaces the puppet profiles::dns::updater shell script + dns-update.path/dns-update.service systemd pair with a single long-running service.

What it does

  • Reads a desired-records file (zone|name|type|ttl|value, the same format profiles::dns::record already emits).
  • Pushes the delta to the server — one TSIG-signed UPDATE message per zone, so one bad zone (e.g. a NOTZONE) cannot abort the others.
  • Re-reconciles when:
    • the records file changes (inotify on the directory, so atomic replace is caught),
    • a new interface address appears (DHCP assign/renew) — address removals (interface down) are ignored, so a transient link drop never disturbs records; loopback/link-local are ignored too,
    • a periodic safety-net timer fires,
    • it receives SIGHUP.
  • Exposes a local status API (unix socket) for facter and health checks.

Names are qualified correctly: a record name already ending in . is used verbatim, so there is no .. empty-label bug — and a malformed record is rejected with a clear error instead of being sent as broken wire data.

Why native RFC2136 (not nsupdate)

The daemon talks the update protocol directly (github.com/miekg/dns), so every zone update carries a structured server RCODE and error. That is the observability the shell version lacked — a bad name or a missing zone shows up immediately in the logs and the status API instead of an opaque nsupdate ... failed.

Configuration

Flags or env (see packaging/env.sample); env wins via the systemd EnvironmentFile:

flag env default
-server DNS_UPDATER_SERVER (required)
-key-file DNS_UPDATER_KEY_FILE /etc/dns-updater/key
-records-file DNS_UPDATER_RECORDS_FILE /var/lib/dns-updater/records
-state-file DNS_UPDATER_STATE_FILE /var/lib/dns-updater/applied
-watch-interfaces DNS_UPDATER_WATCH_INTERFACES true
-resync DNS_UPDATER_RESYNC 10m
-api DNS_UPDATER_API /run/dns-updater/api.sock
-log-level DNS_UPDATER_LOG_LEVEL info
-oneshot DNS_UPDATER_ONESHOT false

The TSIG key file is BIND format (key "name" { algorithm ...; secret "..."; };).

Status API

  • GET /status → JSON: health, managed-record count, last reconcile/change time, and per-zone {adds, deletes, rcode, error}.
  • GET /healthz → 200 when healthy, 503 otherwise.

The packaged facter fact (/opt/puppetlabs/facter/facts.d/dns_updater.sh) queries this and emits dns_updater_healthy, dns_updater_zones_failed, dns_updater_failed_zones, etc. Puppet already knows the desired records (it writes the file); these facts report what actually landed on the server.

Logging

INFO on real changes (applied) and failures (reconcile partial, zone update failed with zone + rcode); the steady-state "nothing to do" path stays at DEBUG, so periodic resyncs and interface flaps do not spam the journal. slog key=value output parses cleanly in VictoriaLogs.

Build / release

make build      # binary
make test       # unit + in-process TSIG server integration tests
make rpm        # RPM via nfpm (needs the binary)
make patch      # tag vX.Y.(Z+1) and push -> Woodpecker release
S
Description
RFC2136 dynamic-DNS updater daemon: watches a records file and network interfaces and pushes TSIG-signed updates to BIND, with a local status API for facter.
Readme 72 KiB
v0.1.1 Latest
2026-07-18 08:28:28 +10:00
Languages
Go 91.5%
Shell 6%
Makefile 2.5%