Initial implementation: dns-updater daemon
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

RFC2136 dynamic-DNS updater. Watches a records file (inotify) and new
interface addresses and pushes TSIG-signed updates to BIND per zone, sending
only the delta. Native miekg/dns (structured per-zone RCODEs), local status
API + facter fact, systemd unit, nfpm RPM, Woodpecker CI.

Replaces the puppet dns-update shell script; keeps the same records-file and
TSIG-key contract.
This commit is contained in:
2026-07-17 23:24:49 +10:00
parent 9d38e67b35
commit 02e3e0315d
28 changed files with 2038 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
# Built artifacts — anchored so they do not accidentally match cmd/dns-updater/.
/dns-updater
/*.rpm
/dist/
+34
View File
@@ -0,0 +1,34 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-merge-conflict
- id: mixed-line-ending
args: [--fix=lf]
# Local Go hooks. dnephin go-vet runs `go vet` at the repo root, which reports
# "no Go files" here (all code is under cmd/ and internal/), so vet ./...
# instead.
- repo: local
hooks:
- id: go-fmt
name: go fmt
entry: bash -c 'test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }'
language: system
pass_filenames: false
types: [go]
- id: go-vet
name: go vet
entry: go vet ./...
language: system
pass_filenames: false
types: [go]
- id: go-test
name: go test
entry: go test ./...
language: system
pass_filenames: false
types: [go]
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+125
View File
@@ -0,0 +1,125 @@
when:
- event: tag
steps:
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Build the release binary (into dist/, consumed by the RPM step) plus the
# linux binaries attached to the Gitea release.
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
- |
for arch in amd64 arm64; do
GOOS=linux GOARCH="$arch" CGO_ENABLED=0 \
go build -ldflags="-s -w -X main.version=${CI_COMMIT_TAG}" \
-o "dns-updater-linux-$arch" ./cmd/dns-updater
done
depends_on: [test]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Package the built binary into an RPM.
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
# repodata regenerates automatically).
- name: upload-rpm
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then
echo "$$FILE already exists in $$REPO (HTTP $$code); skipping upload"
continue
fi
echo "Uploading $$FILE to $$REPO (existence probe returned $$code)"
curl -f -X PUT \
"$$HOST/api/v2/remotes/$$REPO/files/$$FILE" \
-H "Content-Type: application/x-rpm" \
--data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
# Cut a Gitea release with the linux binaries attached.
- name: release
image: git.unkin.net/unkin/almalinux9-base:20260606
environment:
RELEASER_TOKEN:
from_secret: RELEASER_TOKEN
commands:
- |
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -n "$PREV_TAG" ]; then
NOTES=$(git log "${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
else
NOTES=$(git log --pretty=format:"- %s")
fi
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "${NOTES}" --login gitea --repo "${CI_REPO}"
tea releases assets create "${CI_COMMIT_TAG}" \
dns-updater-linux-amd64 \
dns-updater-linux-arm64 \
--login gitea --repo "${CI_REPO}"
depends_on: [upload-rpm]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 128Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 500m
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: test
image: golang:1.25
commands:
- go test -race ./...
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+54
View File
@@ -0,0 +1,54 @@
GO_VERSION := 1.25
BINARY := dns-updater
PKG := git.unkin.net/unkin/dns-updater
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo 0.0.0)
LDFLAGS := -X main.version=$(VERSION)
.PHONY: all build test vet fmt tidy lint rpm clean patch minor major
all: build
build:
mkdir -p dist
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o dist/$(BINARY) ./cmd/$(BINARY)
test:
go test ./...
vet:
go vet ./...
fmt:
gofmt -l -w .
tidy:
go mod tidy
lint: vet
@gofmt -l . | grep . && { echo "gofmt needed"; exit 1; } || echo "gofmt clean"
rpm: build
./scripts/build-rpm.sh $(VERSION)
clean:
rm -rf $(BINARY) dist *.rpm
# Version bump targets: tag vX.Y.Z and push so Woodpecker builds the release.
patch: ; @$(MAKE) bump PART=patch
minor: ; @$(MAKE) bump PART=minor
major: ; @$(MAKE) bump PART=major
bump:
@cur=$$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo 0.0.0); \
IFS=. read -r MA MI PA <<EOF
$$cur
EOF
case "$(PART)" in \
major) MA=$$((MA+1)); MI=0; PA=0;; \
minor) MI=$$((MI+1)); PA=0;; \
patch) PA=$$((PA+1));; \
esac; \
new="v$$MA.$$MI.$$PA"; \
echo "tagging $$new"; \
git tag -a "$$new" -m "release $$new"; \
git push origin "$$new"
+78 -1
View File
@@ -1,3 +1,80 @@
# dns-updater # dns-updater
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. 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
```
+263
View File
@@ -0,0 +1,263 @@
// Command dns-updater is a daemon that keeps a host's DNS records current on a
// BIND server. It reconciles a desired-records file to the server via
// TSIG-signed RFC2136 updates, re-running whenever the file changes, a network
// interface address changes, or a periodic timer fires.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/miekg/dns"
"git.unkin.net/unkin/dns-updater/internal/api"
"git.unkin.net/unkin/dns-updater/internal/config"
"git.unkin.net/unkin/dns-updater/internal/records"
"git.unkin.net/unkin/dns-updater/internal/tsig"
"git.unkin.net/unkin/dns-updater/internal/updater"
"git.unkin.net/unkin/dns-updater/internal/watch"
)
// version is set at build time via -ldflags "-X main.version=...".
var version = "dev"
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "dns-updater:", err)
os.Exit(1)
}
}
func run(args []string) error {
cfg, err := config.Parse(args)
if err != nil {
return err
}
log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel(cfg.LogLevel)}))
log.Info("starting", "version", version, "server", cfg.Server,
"records", cfg.RecordsFile, "watch_interfaces", cfg.WatchIface, "resync", cfg.ResyncEvery.String())
key, err := tsig.Load(cfg.KeyFile)
if err != nil {
return fmt.Errorf("load key: %w", err)
}
log.Info("loaded tsig key", "name", strings.TrimSuffix(key.Name, "."), "algorithm", strings.TrimSuffix(key.Algorithm, "."))
app := updater.New(cfg.Server, key, cfg.Timeout)
store := api.NewStore(version, cfg.Server, cfg.RecordsFile)
d := &daemon{cfg: cfg, app: app, log: log, store: store}
if cfg.Oneshot {
return d.reconcile("oneshot")
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if cfg.APIAddr != "" {
go func() {
if err := api.Serve(ctx, cfg.APIAddr, store); err != nil {
log.Warn("status api stopped", "err", err)
}
}()
log.Info("status api listening", "addr", cfg.APIAddr)
}
triggers := make(chan watch.Event, 32)
if err := watch.File(ctx, cfg.RecordsFile, triggers, log); err != nil {
return fmt.Errorf("watch file: %w", err)
}
if cfg.WatchIface {
if err := watch.Interfaces(ctx, triggers, log); err != nil {
return fmt.Errorf("watch interfaces: %w", err)
}
}
// First reconcile at startup; report readiness to systemd regardless so a
// transient server outage does not wedge the unit in "activating".
if err := d.reconcile("startup"); err != nil {
log.Warn("initial reconcile failed; will retry on next trigger", "err", err)
}
config.SdNotify("READY=1")
log.Info("ready")
installHUP(ctx, triggers)
d.loop(ctx, triggers)
log.Info("shutting down")
return nil
}
type daemon struct {
cfg *config.Config
app *updater.Applier
log *slog.Logger
store *api.Store
}
// loop coalesces triggers with a debounce and runs a periodic resync.
func (d *daemon) loop(ctx context.Context, triggers <-chan watch.Event) {
var resync <-chan time.Time
if d.cfg.ResyncEvery > 0 {
t := time.NewTicker(d.cfg.ResyncEvery)
defer t.Stop()
resync = t.C
}
var timer *time.Timer
var reason string
fire := make(chan struct{}, 1)
arm := func(src string) {
d.log.Debug("trigger", "source", src)
reason = src
if timer == nil {
timer = time.AfterFunc(d.cfg.Debounce, func() {
select {
case fire <- struct{}{}:
default:
}
})
} else {
timer.Reset(d.cfg.Debounce)
}
}
for {
select {
case <-ctx.Done():
return
case ev := <-triggers:
arm(ev.Source + ":" + ev.Detail)
case <-resync:
arm("resync")
case <-fire:
if reason == "" {
continue
}
r := reason
reason = ""
if err := d.reconcile(r); err != nil {
d.log.Warn("reconcile failed", "trigger", r, "err", err)
}
}
}
}
// reconcile loads desired + applied state, pushes the delta, persists the new
// applied state, and updates the status store. Logging is intentionally quiet:
// INFO only when something actually changes or fails; the steady-state
// "nothing to do" path stays at DEBUG so periodic resyncs and interface flaps
// do not spam the journal.
func (d *daemon) reconcile(trigger string) error {
desired, err := records.Load(d.cfg.RecordsFile)
if err != nil && desired.Len() == 0 {
d.setStatus(trigger, nil, desired.Len(), err)
return fmt.Errorf("load records: %w", err)
}
if err != nil {
d.log.Warn("some records skipped", "trigger", trigger, "err", err)
}
applied, aerr := records.LoadOrEmpty(d.cfg.StateFile)
if aerr != nil {
d.log.Warn("could not read applied state; assuming empty", "err", aerr)
applied = records.NewSet()
}
res := d.app.Reconcile(desired, applied)
changed := res.String() != "no changes"
if changed {
newApplied := res.Applied(desired, applied)
if err := records.Save(newApplied, d.cfg.StateFile); err != nil {
d.log.Warn("could not persist applied state", "err", err)
}
}
d.setStatus(trigger, &res, desired.Len(), nil)
switch {
case !res.OK():
d.log.Warn("reconcile partial", "trigger", trigger, "result", res.String())
for _, z := range res.Zones {
if !z.OK() {
d.log.Warn("zone update failed", "zone", strings.TrimSuffix(z.Zone, "."),
"rcode", dns.RcodeToString[z.Rcode], "err", z.Err)
}
}
return fmt.Errorf("one or more zones failed: %s", res.String())
case changed:
d.log.Info("applied", "trigger", trigger, "result", res.String(), "managed", desired.Len())
default:
d.log.Debug("no changes", "trigger", trigger, "managed", desired.Len())
}
return nil
}
// setStatus mirrors the reconcile outcome into the API store.
func (d *daemon) setStatus(trigger string, res *updater.Result, managed int, loadErr error) {
prev := d.store.Get()
s := api.Status{
Healthy: loadErr == nil && (res == nil || res.OK()),
ManagedRecords: managed,
LastReconcile: time.Now(),
LastChange: prev.LastChange,
}
if loadErr != nil {
s.LastError = loadErr.Error()
}
if res != nil {
if res.String() != "no changes" {
s.LastChange = time.Now()
}
for _, z := range res.Zones {
zs := api.ZoneStatus{
Zone: strings.TrimSuffix(z.Zone, "."), Adds: z.Adds, Deletes: z.Deletes,
Rcode: z.Rcode, RcodeText: dns.RcodeToString[z.Rcode],
}
if z.Err != nil {
zs.Error = z.Err.Error()
if s.LastError == "" {
s.LastError = z.Err.Error()
}
}
s.Zones = append(s.Zones, zs)
}
}
d.store.Set(s)
}
func installHUP(ctx context.Context, triggers chan<- watch.Event) {
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
for {
select {
case <-ctx.Done():
return
case <-hup:
select {
case triggers <- watch.Event{Source: "signal", Detail: "SIGHUP"}:
case <-ctx.Done():
return
}
}
}
}()
}
func logLevel(s string) slog.Level {
switch strings.ToLower(s) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
+18
View File
@@ -0,0 +1,18 @@
module git.unkin.net/unkin/dns-updater
go 1.25
require (
github.com/fsnotify/fsnotify v1.10.1
github.com/miekg/dns v1.1.72
github.com/vishvananda/netlink v1.3.1
)
require (
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)
+22
View File
@@ -0,0 +1,22 @@
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
+126
View File
@@ -0,0 +1,126 @@
// Package api exposes the daemon's last-reconcile status over a local HTTP
// endpoint (a unix socket by default) so puppet's facter — or a health check —
// can see whether records are actually live on the server, without re-querying
// DNS itself. It reports results and health, not the desired record set (puppet
// already owns that, it writes the records file).
package api
import (
"context"
"encoding/json"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// ZoneStatus is the outcome of the last update for one zone.
type ZoneStatus struct {
Zone string `json:"zone"`
Adds int `json:"adds"`
Deletes int `json:"deletes"`
Rcode int `json:"rcode"`
RcodeText string `json:"rcode_text"`
Error string `json:"error,omitempty"`
}
// Status is the daemon's current view, serialised to JSON.
type Status struct {
Version string `json:"version"`
Server string `json:"server"`
RecordsFile string `json:"records_file"`
Healthy bool `json:"healthy"`
ManagedRecords int `json:"managed_records"`
LastReconcile time.Time `json:"last_reconcile"`
LastChange time.Time `json:"last_change,omitempty"`
LastError string `json:"last_error,omitempty"`
Zones []ZoneStatus `json:"zones"`
}
// Store holds the latest Status behind a mutex.
type Store struct {
mu sync.RWMutex
s Status
}
// NewStore seeds a Store with static fields.
func NewStore(version, server, recordsFile string) *Store {
return &Store{s: Status{Version: version, Server: server, RecordsFile: recordsFile}}
}
// Set replaces the dynamic portion of the status.
func (st *Store) Set(s Status) {
st.mu.Lock()
defer st.mu.Unlock()
// preserve static identity fields
s.Version, s.Server, s.RecordsFile = st.s.Version, st.s.Server, st.s.RecordsFile
st.s = s
}
// Get returns a copy of the current status.
func (st *Store) Get() Status {
st.mu.RLock()
defer st.mu.RUnlock()
return st.s
}
// Serve starts an HTTP server on addr. If addr contains a '/', it is treated as
// a unix socket path; otherwise as a TCP address. It returns once ctx is done.
func Serve(ctx context.Context, addr string, store *Store) error {
mux := http.NewServeMux()
mux.HandleFunc("/status", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, store.Get())
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
s := store.Get()
code := http.StatusOK
if !s.Healthy {
code = http.StatusServiceUnavailable
}
writeJSON(w, code, map[string]any{"healthy": s.Healthy, "last_error": s.LastError})
})
ln, err := listen(addr)
if err != nil {
return err
}
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
shutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = srv.Shutdown(shutCtx)
}()
err = srv.Serve(ln)
if err == http.ErrServerClosed {
return nil
}
return err
}
func listen(addr string) (net.Listener, error) {
if strings.Contains(addr, "/") {
if err := os.MkdirAll(filepath.Dir(addr), 0o755); err != nil {
return nil, err
}
_ = os.Remove(addr) // clear a stale socket from an unclean exit
ln, err := net.Listen("unix", addr)
if err != nil {
return nil, err
}
_ = os.Chmod(addr, 0o660)
return ln, nil
}
return net.Listen("tcp", addr)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
_ = enc.Encode(v)
}
+88
View File
@@ -0,0 +1,88 @@
// Package config holds dns-updater's runtime configuration, sourced from flags
// with environment-variable fallbacks so it works as a systemd unit with an
// EnvironmentFile.
package config
import (
"flag"
"fmt"
"os"
"time"
)
// Config is the daemon configuration.
type Config struct {
Server string // authoritative write endpoint host[:port]
KeyFile string // BIND-style TSIG key file
RecordsFile string // desired records (zone|name|type|ttl|value)
StateFile string // last-applied state
ResyncEvery time.Duration // periodic reconcile safety net
Debounce time.Duration // coalesce bursts of watch events
Timeout time.Duration // per-update network timeout
WatchIface bool // subscribe to interface address changes
Oneshot bool // reconcile once and exit (no watching)
APIAddr string // status API address (unix socket path or host:port; empty disables)
LogLevel string // debug|info|warn|error
}
const defaultPort = "53"
// Parse builds a Config from args (typically os.Args[1:]).
func Parse(args []string) (*Config, error) {
fs := flag.NewFlagSet("dns-updater", flag.ContinueOnError)
c := &Config{}
fs.StringVar(&c.Server, "server", env("DNS_UPDATER_SERVER", ""), "authoritative write endpoint host[:port]")
fs.StringVar(&c.KeyFile, "key-file", env("DNS_UPDATER_KEY_FILE", "/etc/dns-updater/key"), "BIND-style TSIG key file")
fs.StringVar(&c.RecordsFile, "records-file", env("DNS_UPDATER_RECORDS_FILE", "/var/lib/dns-updater/records"), "desired records file")
fs.StringVar(&c.StateFile, "state-file", env("DNS_UPDATER_STATE_FILE", "/var/lib/dns-updater/applied"), "last-applied state file")
fs.DurationVar(&c.ResyncEvery, "resync", envDur("DNS_UPDATER_RESYNC", 10*time.Minute), "periodic reconcile interval (0 disables)")
fs.DurationVar(&c.Debounce, "debounce", envDur("DNS_UPDATER_DEBOUNCE", 2*time.Second), "coalesce watch events for this long")
fs.DurationVar(&c.Timeout, "timeout", envDur("DNS_UPDATER_TIMEOUT", 10*time.Second), "per-update network timeout")
fs.BoolVar(&c.WatchIface, "watch-interfaces", envBool("DNS_UPDATER_WATCH_INTERFACES", true), "reconcile on interface address changes")
fs.BoolVar(&c.Oneshot, "oneshot", envBool("DNS_UPDATER_ONESHOT", false), "reconcile once and exit")
fs.StringVar(&c.APIAddr, "api", env("DNS_UPDATER_API", "/run/dns-updater/api.sock"), "status API address (unix path or host:port; empty disables)")
fs.StringVar(&c.LogLevel, "log-level", env("DNS_UPDATER_LOG_LEVEL", "info"), "log level: debug|info|warn|error")
if err := fs.Parse(args); err != nil {
return nil, err
}
if c.Server == "" {
return nil, fmt.Errorf("server is required (-server or DNS_UPDATER_SERVER)")
}
c.Server = withPort(c.Server)
return c, nil
}
func withPort(s string) string {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == ':' {
return s // already has a port
}
if s[i] == ']' {
break // IPv6 literal without port
}
}
return s + ":" + defaultPort
}
func env(k, def string) string {
if v, ok := os.LookupEnv(k); ok {
return v
}
return def
}
func envDur(k string, def time.Duration) time.Duration {
if v, ok := os.LookupEnv(k); ok {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}
func envBool(k string, def bool) bool {
if v, ok := os.LookupEnv(k); ok {
return v == "1" || v == "true" || v == "yes"
}
return def
}
+27
View File
@@ -0,0 +1,27 @@
package config
import (
"net"
"os"
)
// SdNotify sends a state string to systemd via NOTIFY_SOCKET (Type=notify). It
// is a no-op when not run under systemd. Implemented inline to avoid a
// dependency for a few lines of socket writing.
func SdNotify(state string) {
sock := os.Getenv("NOTIFY_SOCKET")
if sock == "" {
return
}
addr := &net.UnixAddr{Name: sock, Net: "unixgram"}
// Abstract namespace sockets start with '@'.
if len(sock) > 0 && sock[0] == '@' {
addr.Name = "\x00" + sock[1:]
}
conn, err := net.DialUnix("unixgram", nil, addr)
if err != nil {
return
}
defer conn.Close()
_, _ = conn.Write([]byte(state))
}
+210
View File
@@ -0,0 +1,210 @@
// Package records parses the desired-records file and turns each line into a
// DNS resource record. The file format matches what puppet's
// profiles::dns::record emits, one record per line:
//
// zone|name|type|ttl|value
//
// name is relative to zone, "@"/empty for the apex, or already fully qualified
// (trailing dot). Blank lines and lines beginning with '#' are ignored.
package records
import (
"bufio"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
"github.com/miekg/dns"
)
// Record is one desired DNS record.
type Record struct {
Zone string
Name string // as written in the file (relative, "@", or FQDN)
Type string
TTL uint32
Value string
}
// FQDN returns the fully-qualified owner name for a record. A name that is
// already fully qualified (trailing dot) is used verbatim; "@"/empty means the
// zone apex; anything else is treated as relative to the zone. This is the Go
// equivalent of the fixed shell fqdn() and avoids the empty-label ("..") bug.
func FQDN(name, zone string) string {
switch {
case name == "" || name == "@":
return dns.Fqdn(zone)
case strings.HasSuffix(name, "."):
return name
default:
return name + "." + dns.Fqdn(zone)
}
}
// Owner is the FQDN this record is written under.
func (r Record) Owner() string { return FQDN(r.Name, r.Zone) }
// Key uniquely identifies the RRset+value this record represents, used to diff
// desired against applied state.
func (r Record) Key() string {
return strings.ToLower(fmt.Sprintf("%s|%s|%s|%s", dns.Fqdn(r.Zone), r.Owner(), strings.ToUpper(r.Type), r.Value))
}
// RR renders the record as a miekg/dns resource record. It returns an error for
// a malformed name/type/value (e.g. an empty label) rather than silently
// emitting broken wire data — the structured failure the shell version lacked.
func (r Record) RR() (dns.RR, error) {
line := fmt.Sprintf("%s %d IN %s %s", r.Owner(), r.TTL, strings.ToUpper(r.Type), r.Value)
rr, err := dns.NewRR(line)
if err != nil {
return nil, fmt.Errorf("record %q: %w", line, err)
}
if rr == nil {
return nil, fmt.Errorf("record %q: parsed to nil", line)
}
return rr, nil
}
// Set is a parsed collection of desired records keyed by Key().
type Set struct {
byKey map[string]Record
}
// NewSet builds an empty Set.
func NewSet() *Set { return &Set{byKey: map[string]Record{}} }
// Add inserts a record, validating that it renders to a well-formed RR.
func (s *Set) Add(r Record) error {
if _, err := r.RR(); err != nil {
return err
}
s.byKey[r.Key()] = r
return nil
}
// Records returns the records in a stable order (by zone then owner then type).
func (s *Set) Records() []Record {
out := make([]Record, 0, len(s.byKey))
for _, r := range s.byKey {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Zone != out[j].Zone {
return out[i].Zone < out[j].Zone
}
if out[i].Owner() != out[j].Owner() {
return out[i].Owner() < out[j].Owner()
}
return out[i].Key() < out[j].Key()
})
return out
}
// Zones returns the distinct zones present, sorted.
func (s *Set) Zones() []string {
seen := map[string]struct{}{}
for _, r := range s.byKey {
seen[dns.Fqdn(r.Zone)] = struct{}{}
}
zs := make([]string, 0, len(seen))
for z := range seen {
zs = append(zs, z)
}
sort.Strings(zs)
return zs
}
// Has reports whether the set contains a record with the given Key.
func (s *Set) Has(key string) bool { _, ok := s.byKey[key]; return ok }
// Len returns the number of records.
func (s *Set) Len() int { return len(s.byKey) }
// Save writes the set to path (atomically) in the canonical
// zone|name|type|ttl|value format, so it can be reloaded as applied state.
func Save(set *Set, path string) error {
var b strings.Builder
b.WriteString("# dns-updater applied state; do not edit\n")
for _, r := range set.Records() {
fmt.Fprintf(&b, "%s|%s|%s|%d|%s\n", r.Zone, r.Name, r.Type, r.TTL, r.Value)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// LoadOrEmpty parses path, returning an empty Set if the file does not exist.
func LoadOrEmpty(path string) (*Set, error) {
s, err := Load(path)
if err != nil {
if os.IsNotExist(err) {
return NewSet(), nil
}
return s, err
}
return s, nil
}
// Load parses the records file at path. Malformed lines are returned as an
// aggregated error but every well-formed record is still collected, so a single
// bad line does not block the rest.
func Load(path string) (*Set, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return parse(f)
}
func parse(r io.Reader) (*Set, error) {
set := NewSet()
var errs []string
sc := bufio.NewScanner(r)
ln := 0
for sc.Scan() {
ln++
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rec, err := parseLine(line)
if err != nil {
errs = append(errs, fmt.Sprintf("line %d: %v", ln, err))
continue
}
if err := set.Add(rec); err != nil {
errs = append(errs, fmt.Sprintf("line %d: %v", ln, err))
}
}
if err := sc.Err(); err != nil {
return set, err
}
if len(errs) > 0 {
return set, fmt.Errorf("%d bad record(s): %s", len(errs), strings.Join(errs, "; "))
}
return set, nil
}
func parseLine(line string) (Record, error) {
parts := strings.SplitN(line, "|", 5)
if len(parts) < 5 {
return Record{}, fmt.Errorf("want zone|name|type|ttl|value, got %q", line)
}
ttl, err := strconv.ParseUint(strings.TrimSpace(parts[3]), 10, 32)
if err != nil {
return Record{}, fmt.Errorf("bad ttl %q: %w", parts[3], err)
}
return Record{
Zone: strings.TrimSpace(parts[0]),
Name: strings.TrimSpace(parts[1]),
Type: strings.TrimSpace(parts[2]),
TTL: uint32(ttl),
Value: strings.TrimSpace(parts[4]),
}, nil
}
+77
View File
@@ -0,0 +1,77 @@
package records
import (
"strings"
"testing"
)
func TestFQDN(t *testing.T) {
cases := []struct {
name, zone, want string
}{
{"ausyd1nxvm2070", "main.unkin.net", "ausyd1nxvm2070.main.unkin.net."},
{"@", "main.unkin.net", "main.unkin.net."},
{"", "main.unkin.net", "main.unkin.net."},
// The regression: an already-qualified name must NOT get the zone
// appended again (that produced the ".." empty label in the shell bug).
{"au-syd1-pve.main.unkin.net.", "main.unkin.net", "au-syd1-pve.main.unkin.net."},
{"cobbler.main.unkin.net.", "main.unkin.net", "cobbler.main.unkin.net."},
{"18", "24.18.198.in-addr.arpa", "18.24.18.198.in-addr.arpa."},
}
for _, c := range cases {
if got := FQDN(c.name, c.zone); got != c.want {
t.Errorf("FQDN(%q,%q)=%q want %q", c.name, c.zone, got, c.want)
}
if strings.Contains(FQDN(c.name, c.zone), "..") {
t.Errorf("FQDN(%q,%q) produced an empty label", c.name, c.zone)
}
}
}
func TestRRValidatesName(t *testing.T) {
// A well-formed FQDN record renders cleanly.
good := Record{Zone: "main.unkin.net", Name: "au-syd1-pve.main.unkin.net.", Type: "CNAME", TTL: 300, Value: "au-syd1-prod-halb.main.unkin.net."}
if _, err := good.RR(); err != nil {
t.Fatalf("good record: unexpected error %v", err)
}
// An empty label must be rejected, not silently emitted.
bad := Record{Zone: "main.unkin.net", Name: "broken..name.", Type: "A", TTL: 300, Value: "198.18.24.18"}
if _, err := bad.RR(); err == nil {
t.Fatalf("bad record: expected error for empty label, got nil")
}
}
func TestParseAndSet(t *testing.T) {
in := `# comment
main.unkin.net|ausyd1nxvm2070|A|300|198.18.24.18
24.18.198.in-addr.arpa|18|PTR|300|ausyd1nxvm2070.main.unkin.net.
main.unkin.net|au-syd1-pve.main.unkin.net.|CNAME|300|au-syd1-prod-halb.main.unkin.net.`
set, err := parse(strings.NewReader(in))
if err != nil {
t.Fatalf("parse: %v", err)
}
if set.Len() != 3 {
t.Fatalf("len=%d want 3", set.Len())
}
zones := set.Zones()
if len(zones) != 2 {
t.Fatalf("zones=%v want 2", zones)
}
// zones are fully-qualified and sorted
if zones[0] != "24.18.198.in-addr.arpa." || zones[1] != "main.unkin.net." {
t.Errorf("zones=%v", zones)
}
}
func TestParseReportsBadLine(t *testing.T) {
in := "main.unkin.net|host|A|notanumber|1.2.3.4\nmain.unkin.net|ok|A|300|1.2.3.4"
set, err := parse(strings.NewReader(in))
if err == nil {
t.Fatal("expected error for bad ttl")
}
// the good record is still collected
if set.Len() != 1 {
t.Errorf("len=%d want 1 (good record kept)", set.Len())
}
}
+85
View File
@@ -0,0 +1,85 @@
// Package tsig loads a BIND-style TSIG key file for signing RFC2136 updates.
//
// The file looks like:
//
// key "client-update" {
// algorithm hmac-sha256;
// secret "base64secret==";
// };
package tsig
import (
"fmt"
"os"
"regexp"
"strings"
"github.com/miekg/dns"
)
// Key is a parsed TSIG key ready for use with miekg/dns.
type Key struct {
Name string // fully qualified (trailing dot), as miekg/dns wants
Algorithm string // e.g. dns.HmacSHA256 ("hmac-sha256.")
Secret string // base64
}
var (
reName = regexp.MustCompile(`(?s)key\s+"([^"]+)"\s*\{(.*?)\}`)
reAlgo = regexp.MustCompile(`algorithm\s+([A-Za-z0-9\-]+)\s*;`)
reSec = regexp.MustCompile(`secret\s+"([^"]+)"\s*;`)
)
// algorithms maps BIND algorithm names to the fully-qualified constants
// miekg/dns expects in SetTsig and the TsigSecret map.
var algorithms = map[string]string{
"hmac-md5": dns.HmacMD5,
"hmac-sha1": dns.HmacSHA1,
"hmac-sha224": dns.HmacSHA224,
"hmac-sha256": dns.HmacSHA256,
"hmac-sha384": dns.HmacSHA384,
"hmac-sha512": dns.HmacSHA512,
}
// Load reads and parses the first key definition in the file at path.
func Load(path string) (*Key, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return Parse(string(b))
}
// Parse extracts a Key from BIND key-file text.
func Parse(text string) (*Key, error) {
m := reName.FindStringSubmatch(text)
if m == nil {
return nil, fmt.Errorf("no key { ... } block found")
}
name, body := m[1], m[2]
algoM := reAlgo.FindStringSubmatch(body)
if algoM == nil {
return nil, fmt.Errorf("key %q: missing algorithm", name)
}
algo, ok := algorithms[strings.ToLower(algoM[1])]
if !ok {
return nil, fmt.Errorf("key %q: unsupported algorithm %q", name, algoM[1])
}
secM := reSec.FindStringSubmatch(body)
if secM == nil {
return nil, fmt.Errorf("key %q: missing secret", name)
}
return &Key{
Name: dns.Fqdn(name),
Algorithm: algo,
Secret: secM[1],
}, nil
}
// SecretMap returns the name→secret map for dns.Client.TsigSecret.
func (k *Key) SecretMap() map[string]string {
return map[string]string{k.Name: k.Secret}
}
+39
View File
@@ -0,0 +1,39 @@
package tsig
import "testing"
func TestParse(t *testing.T) {
in := `key "client-update" {
algorithm hmac-sha256;
secret "dGVzdHNlY3JldA==";
};`
k, err := Parse(in)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if k.Name != "client-update." {
t.Errorf("name=%q want client-update.", k.Name)
}
if k.Algorithm != "hmac-sha256." {
t.Errorf("algorithm=%q want hmac-sha256.", k.Algorithm)
}
if k.Secret != "dGVzdHNlY3JldA==" {
t.Errorf("secret=%q", k.Secret)
}
if m := k.SecretMap(); m["client-update."] != k.Secret {
t.Errorf("SecretMap=%v", m)
}
}
func TestParseErrors(t *testing.T) {
for _, in := range []string{
`nonsense`,
`key "x" { algorithm hmac-sha256; };`, // no secret
`key "x" { secret "abc"; };`, // no algorithm
`key "x" { algorithm hmac-whirlpool; secret "a"; };`, // unsupported algo
} {
if _, err := Parse(in); err == nil {
t.Errorf("expected error for %q", in)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
// Package updater pushes the desired records to a BIND server via RFC2136
// dynamic update (TSIG-signed), sending only the delta since the last applied
// state. Each zone is updated in its own message so one bad zone cannot abort
// the others, and every zone's result carries the server RCODE.
package updater
import (
"fmt"
"sort"
"strings"
"time"
"github.com/miekg/dns"
"git.unkin.net/unkin/dns-updater/internal/records"
"git.unkin.net/unkin/dns-updater/internal/tsig"
)
// Applier sends RFC2136 updates to a single server.
type Applier struct {
server string
key *tsig.Key
client *dns.Client
fudge uint16
timeout time.Duration
}
// New builds an Applier. server is host:port; key signs the updates.
func New(server string, key *tsig.Key, timeout time.Duration) *Applier {
c := &dns.Client{
Net: "tcp", // updates use TCP; also lets responses exceed 512 bytes
TsigSecret: key.SecretMap(),
DialTimeout: timeout,
ReadTimeout: timeout,
WriteTimeout: timeout,
}
return &Applier{server: server, key: key, client: c, fudge: 300, timeout: timeout}
}
// ZoneResult is the outcome of updating one zone.
type ZoneResult struct {
Zone string
Adds int
Deletes int
Rcode int
Err error
}
// OK reports whether the zone update succeeded.
func (z ZoneResult) OK() bool { return z.Err == nil && z.Rcode == dns.RcodeSuccess }
// Result aggregates per-zone outcomes for one reconcile.
type Result struct {
Zones []ZoneResult
}
// OK reports whether every zone update succeeded.
func (r Result) OK() bool {
for _, z := range r.Zones {
if !z.OK() {
return false
}
}
return true
}
// Applied returns the keys of records that are now live on the server: for a
// failed zone, its records keep their previous applied state (from prev) so we
// retry them next time; for a succeeded zone, desired wins.
func (r Result) Applied(desired, prev *records.Set) *records.Set {
failed := map[string]bool{}
for _, z := range r.Zones {
if !z.OK() {
failed[z.Zone] = true
}
}
out := records.NewSet()
// desired records in succeeded zones are now applied
for _, rec := range desired.Records() {
if !failed[dns.Fqdn(rec.Zone)] {
_ = out.Add(rec)
}
}
// records from failed zones retain their previous applied state
for _, rec := range prev.Records() {
if failed[dns.Fqdn(rec.Zone)] {
_ = out.Add(rec)
}
}
return out
}
// Reconcile computes the delta between desired and applied and pushes one update
// per zone. applied is the last-known server state (empty on first run).
func (a *Applier) Reconcile(desired, applied *records.Set) Result {
// Union of zones touched by either desired or applied records.
zoneSet := map[string]struct{}{}
for _, z := range desired.Zones() {
zoneSet[z] = struct{}{}
}
for _, z := range applied.Zones() {
zoneSet[z] = struct{}{}
}
zones := make([]string, 0, len(zoneSet))
for z := range zoneSet {
zones = append(zones, z)
}
sort.Strings(zones)
dz := keysByZone(desired)
az := keysByZone(applied)
var res Result
for _, zone := range zones {
// Skip zones whose desired record set already matches applied — this is
// what keeps steady-state resyncs and interface flaps from sending (and
// logging) anything.
if equalKeys(dz[zone], az[zone]) {
continue
}
res.Zones = append(res.Zones, a.reconcileZone(zone, desired, applied))
}
return res
}
// keysByZone maps each zone (fqdn) to the set of record keys it contains.
func keysByZone(s *records.Set) map[string]map[string]bool {
out := map[string]map[string]bool{}
for _, r := range s.Records() {
z := dns.Fqdn(r.Zone)
if out[z] == nil {
out[z] = map[string]bool{}
}
out[z][r.Key()] = true
}
return out
}
func equalKeys(a, b map[string]bool) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if !b[k] {
return false
}
}
return true
}
func (a *Applier) reconcileZone(zone string, desired, applied *records.Set) ZoneResult {
zr := ZoneResult{Zone: zone}
msg := new(dns.Msg)
msg.SetUpdate(zone)
// Additions/updates: replace the RRset for every desired record in the zone.
// Grouping by owner+type first lets us RemoveRRset once then Insert all
// values, so multi-value RRsets (round-robin A) are not clobbered.
type ot struct{ owner, typ string }
byRRset := map[ot][]dns.RR{}
for _, rec := range desired.Records() {
if dns.Fqdn(rec.Zone) != zone {
continue
}
rr, err := rec.RR()
if err != nil {
zr.Err = err // should not happen: Set.Add already validated
return zr
}
k := ot{rr.Header().Name, dns.TypeToString[rr.Header().Rrtype]}
byRRset[k] = append(byRRset[k], rr)
}
rrsetKeys := make([]ot, 0, len(byRRset))
for k := range byRRset {
rrsetKeys = append(rrsetKeys, k)
}
sort.Slice(rrsetKeys, func(i, j int) bool {
if rrsetKeys[i].owner != rrsetKeys[j].owner {
return rrsetKeys[i].owner < rrsetKeys[j].owner
}
return rrsetKeys[i].typ < rrsetKeys[j].typ
})
for _, k := range rrsetKeys {
rrs := byRRset[k]
msg.RemoveRRset(rrs)
msg.Insert(rrs)
zr.Adds += len(rrs)
}
// Deletions: records present last run but gone now.
for _, rec := range applied.Records() {
if dns.Fqdn(rec.Zone) != zone || desired.Has(rec.Key()) {
continue
}
rr, err := rec.RR()
if err != nil {
continue
}
msg.Remove([]dns.RR{rr})
zr.Deletes++
}
if zr.Adds == 0 && zr.Deletes == 0 {
return zr // nothing to do for this zone
}
msg.SetTsig(a.key.Name, a.key.Algorithm, a.fudge, time.Now().Unix())
resp, _, err := a.client.Exchange(msg, a.server)
if err != nil {
zr.Err = fmt.Errorf("exchange with %s: %w", a.server, err)
return zr
}
zr.Rcode = resp.Rcode
if resp.Rcode != dns.RcodeSuccess {
zr.Err = fmt.Errorf("zone %s: server rcode %s", zone, dns.RcodeToString[resp.Rcode])
}
return zr
}
// String renders a result for logging.
func (r Result) String() string {
var b strings.Builder
for i, z := range r.Zones {
if i > 0 {
b.WriteString(" ")
}
status := dns.RcodeToString[z.Rcode]
if z.Err != nil && z.Rcode == dns.RcodeSuccess {
status = "ERR"
}
fmt.Fprintf(&b, "%s(+%d-%d %s)", strings.TrimSuffix(z.Zone, "."), z.Adds, z.Deletes, status)
}
if b.Len() == 0 {
return "no changes"
}
return b.String()
}
+178
View File
@@ -0,0 +1,178 @@
package updater
import (
"net"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"git.unkin.net/unkin/dns-updater/internal/records"
"git.unkin.net/unkin/dns-updater/internal/tsig"
)
// fakeServer is an in-process TSIG-verifying DNS server that records the update
// messages it receives and replies with a per-zone rcode.
type fakeServer struct {
addr string
srv *dns.Server
key *tsig.Key
mu sync.Mutex
received []*dns.Msg
rcodes map[string]int // zone (fqdn) -> rcode
}
func newFakeServer(t *testing.T, key *tsig.Key) *fakeServer {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
fs := &fakeServer{addr: l.Addr().String(), key: key, rcodes: map[string]int{}}
fs.srv = &dns.Server{
Listener: l,
Net: "tcp",
TsigSecret: key.SecretMap(),
// The default accept func rejects the UPDATE opcode with NOTIMP; a real
// BIND update server accepts it, so opt in here.
MsgAcceptFunc: func(dns.Header) dns.MsgAcceptAction { return dns.MsgAccept },
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
fs.mu.Lock()
fs.received = append(fs.received, r)
rc := dns.RcodeSuccess
if len(r.Question) > 0 {
if v, ok := fs.rcodes[r.Question[0].Name]; ok {
rc = v
}
}
fs.mu.Unlock()
m := new(dns.Msg)
m.SetReply(r)
m.Rcode = rc
if r.IsTsig() != nil {
m.SetTsig(key.Name, key.Algorithm, 300, time.Now().Unix())
}
_ = w.WriteMsg(m)
}),
}
go func() { _ = fs.srv.ActivateAndServe() }()
// give the server a moment to start accepting
time.Sleep(50 * time.Millisecond)
t.Cleanup(func() { _ = fs.srv.Shutdown() })
return fs
}
func testKey() *tsig.Key {
return &tsig.Key{Name: "test-key.", Algorithm: dns.HmacSHA256, Secret: "dGVzdHNlY3JldA=="}
}
func mustSet(t *testing.T, recs ...records.Record) *records.Set {
t.Helper()
s := records.NewSet()
for _, r := range recs {
if err := s.Add(r); err != nil {
t.Fatalf("add %v: %v", r, err)
}
}
return s
}
func TestReconcileAddsAndSigns(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
app := New(fs.addr, key, 3*time.Second)
desired := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
records.Record{Zone: "main.unkin.net", Name: "au-syd1-pve.main.unkin.net.", Type: "CNAME", TTL: 300, Value: "host1.main.unkin.net."},
records.Record{Zone: "24.18.198.in-addr.arpa", Name: "18", Type: "PTR", TTL: 300, Value: "host1.main.unkin.net."},
)
res := app.Reconcile(desired, records.NewSet())
if !res.OK() {
t.Fatalf("reconcile not OK: %s", res.String())
}
// two zones, both applied
if len(res.Zones) != 2 {
t.Fatalf("zones=%d want 2 (%s)", len(res.Zones), res.String())
}
fs.mu.Lock()
got := len(fs.received)
fs.mu.Unlock()
if got != 2 {
t.Fatalf("server received %d messages want 2", got)
}
}
func TestReconcileDeletesRemoved(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
app := New(fs.addr, key, 3*time.Second)
applied := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
records.Record{Zone: "main.unkin.net", Name: "gone", Type: "A", TTL: 300, Value: "198.18.24.99"},
)
desired := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
)
res := app.Reconcile(desired, applied)
if !res.OK() {
t.Fatalf("not OK: %s", res.String())
}
if len(res.Zones) != 1 || res.Zones[0].Deletes != 1 {
t.Fatalf("want 1 delete, got %s", res.String())
}
}
func TestReconcileZoneRcodeFailure(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
fs.rcodes["ceph.unkin.net."] = dns.RcodeNotZone // simulate NOTZONE
app := New(fs.addr, key, 3*time.Second)
desired := mustSet(t,
records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"},
records.Record{Zone: "ceph.unkin.net", Name: "dashboard.ceph.unkin.net.", Type: "CNAME", TTL: 300, Value: "host1.main.unkin.net."},
)
res := app.Reconcile(desired, records.NewSet())
if res.OK() {
t.Fatal("expected failure due to NOTZONE")
}
// The good zone still applied; only ceph failed — the "one bad zone must not
// abort the others" property that the shell version lacked.
var mainOK, cephFailed bool
for _, z := range res.Zones {
if z.Zone == "main.unkin.net." && z.OK() {
mainOK = true
}
if z.Zone == "ceph.unkin.net." && !z.OK() {
cephFailed = true
}
}
if !mainOK || !cephFailed {
t.Fatalf("mainOK=%v cephFailed=%v (%s)", mainOK, cephFailed, res.String())
}
// Applied state keeps the good zone, drops the failed one for retry.
appliedNow := res.Applied(desired, records.NewSet())
if appliedNow.Len() != 1 {
t.Errorf("applied=%d want 1 (only main)", appliedNow.Len())
}
}
func TestReconcileNoChanges(t *testing.T) {
key := testKey()
fs := newFakeServer(t, key)
app := New(fs.addr, key, 3*time.Second)
same := mustSet(t, records.Record{Zone: "main.unkin.net", Name: "host1", Type: "A", TTL: 300, Value: "198.18.24.18"})
res := app.Reconcile(same, same)
if res.String() != "no changes" {
t.Fatalf("want no changes, got %s", res.String())
}
fs.mu.Lock()
got := len(fs.received)
fs.mu.Unlock()
if got != 0 {
t.Errorf("server got %d messages, want 0 (nothing to do)", got)
}
}
+115
View File
@@ -0,0 +1,115 @@
// Package watch turns records-file changes and network-interface address
// changes into reconcile triggers on a single channel.
package watch
import (
"context"
"log/slog"
"path/filepath"
"github.com/fsnotify/fsnotify"
"github.com/vishvananda/netlink"
)
// Event describes why a reconcile was triggered.
type Event struct {
Source string // "file", "iface", or "startup"
Detail string
}
// File watches the records file for changes. It watches the containing
// directory (not the file inode) so that atomic replace — the write-temp +
// rename pattern puppet/concat uses — is still detected. Events are debounced by
// the caller.
func File(ctx context.Context, path string, out chan<- Event, log *slog.Logger) error {
w, err := fsnotify.NewWatcher()
if err != nil {
return err
}
dir := filepath.Dir(path)
base := filepath.Base(path)
if err := w.Add(dir); err != nil {
w.Close()
return err
}
log.Info("watching records file", "path", path)
go func() {
defer w.Close()
for {
select {
case <-ctx.Done():
return
case ev, ok := <-w.Events:
if !ok {
return
}
if filepath.Base(ev.Name) != base {
continue
}
if ev.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
continue
}
send(ctx, out, Event{Source: "file", Detail: ev.Op.String()})
case err, ok := <-w.Errors:
if !ok {
return
}
log.Warn("file watcher error", "err", err)
}
}
}()
return nil
}
// Interfaces watches for new interface addresses and emits a trigger, so a DHCP
// assignment/renew re-asserts the host's records without waiting for the next
// file write.
//
// It deliberately reacts ONLY to address ADDITIONS, not removals: an interface
// going down (address removed) is usually transient and must not disturb
// records — we act only when a new IP appears (an actual change). Loopback and
// link-local addresses are ignored as noise.
func Interfaces(ctx context.Context, out chan<- Event, log *slog.Logger) error {
updates := make(chan netlink.AddrUpdate, 16)
done := make(chan struct{})
if err := netlink.AddrSubscribe(updates, done); err != nil {
return err
}
log.Info("watching network interface addresses")
go func() {
defer close(done)
for {
select {
case <-ctx.Done():
return
case u, ok := <-updates:
if !ok {
return
}
if !u.NewAddr {
continue // address removed / interface down — ignore
}
// Trigger only for real routable addresses. IsGlobalUnicast is
// true for normal v4/v6 addresses on ANY device type — including
// dummy/anycast interfaces (the address is classified by value,
// not by the device) — and false for loopback, link-local,
// multicast and unspecified, which are never record targets.
ip := u.LinkAddress.IP
if !ip.IsGlobalUnicast() {
continue
}
send(ctx, out, Event{Source: "iface", Detail: "new address " + ip.String()})
}
}
}()
return nil
}
func send(ctx context.Context, out chan<- Event, ev Event) {
select {
case out <- ev:
case <-ctx.Done():
}
}
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=DNS record updater (RFC2136 dynamic DNS from a records file)
Documentation=https://git.unkin.net/unkin/dns-updater
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
EnvironmentFile=-/etc/dns-updater/env
ExecStart=/usr/bin/dns-updater
Restart=on-failure
RestartSec=5
WatchdogSec=0
# Hardening. Runs as root to read the root-owned TSIG key; lock the rest down.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
ProtectKernelModules=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictNamespaces=true
ReadWritePaths=/var/lib/dns-updater /run/dns-updater
RuntimeDirectory=dns-updater
StateDirectory=dns-updater
[Install]
WantedBy=multi-user.target
+24
View File
@@ -0,0 +1,24 @@
# dns-updater configuration (systemd EnvironmentFile).
# The authoritative write endpoint (BIND primary). Required.
DNS_UPDATER_SERVER=198.18.200.9
# BIND-style TSIG key file (algorithm + secret). Puppet manages this.
DNS_UPDATER_KEY_FILE=/etc/dns-updater/key
# Desired records, one per line: zone|name|type|ttl|value. Puppet writes this.
DNS_UPDATER_RECORDS_FILE=/var/lib/dns-updater/records
# Last-applied state (managed by the daemon).
DNS_UPDATER_STATE_FILE=/var/lib/dns-updater/applied
# Reconcile on interface address changes (DHCP renew, link reconfig).
DNS_UPDATER_WATCH_INTERFACES=true
# Periodic safety-net resync (0 disables).
DNS_UPDATER_RESYNC=10m
# Local status API (unix socket path or host:port; empty disables).
DNS_UPDATER_API=/run/dns-updater/api.sock
# debug|info|warn|error
DNS_UPDATER_LOG_LEVEL=info
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Facter external fact: report dns-updater's status by querying its local API
# socket. Emits flat key=value facts (compatible across facter versions).
# Puppet already knows the DESIRED records (it writes the records file); these
# facts report what the daemon actually achieved on the server.
set -u
SOCK="${DNS_UPDATER_API_SOCK:-/run/dns-updater/api.sock}"
emit() { printf 'dns_updater_%s=%s\n' "$1" "$2"; }
if [ ! -S "$SOCK" ]; then
emit running false
emit healthy false
exit 0
fi
json="$(curl -s --max-time 3 --unix-socket "$SOCK" http://local/status 2>/dev/null)"
if [ -z "$json" ]; then
emit running true
emit healthy false
emit last_error "api_unreachable"
exit 0
fi
emit running true
python3 - "$json" <<'PY'
import json, sys
try:
d = json.loads(sys.argv[1])
except Exception:
print("dns_updater_healthy=false")
print("dns_updater_last_error=bad_json")
sys.exit(0)
def emit(k, v):
print(f"dns_updater_{k}={v}")
emit("healthy", str(d.get("healthy", False)).lower())
emit("managed_records", d.get("managed_records", 0))
if d.get("last_reconcile"):
emit("last_reconcile", d["last_reconcile"])
if d.get("last_change"):
emit("last_change", d["last_change"])
if d.get("last_error"):
emit("last_error", d["last_error"].replace("\n", " ")[:200])
zones = d.get("zones") or []
failed = [z for z in zones if (z.get("error") or z.get("rcode", 0) != 0)]
emit("zones_total", len(zones))
emit("zones_failed", len(failed))
if failed:
emit("failed_zones", ",".join(f"{z['zone']}:{z.get('rcode_text','?')}" for z in failed))
PY
+45
View File
@@ -0,0 +1,45 @@
# nfpm packaging for dns-updater. Rendered by scripts/build-rpm.sh (envsubst)
# after `make build` produces dist/dns-updater.
name: ${PACKAGE_NAME}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
section: net
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
description: "${PACKAGE_DESCRIPTION}"
provides:
- dns-updater
contents:
- src: ./dist/dns-updater
dst: /usr/bin/dns-updater
file_info:
mode: 0755
- src: ./packaging/dns-updater.service
dst: /usr/lib/systemd/system/dns-updater.service
file_info:
mode: 0644
- src: ./packaging/facts.d/dns_updater.sh
dst: /opt/puppetlabs/facter/facts.d/dns_updater.sh
file_info:
mode: 0755
- src: ./packaging/env.sample
dst: /etc/dns-updater/env
type: config|noreplace
file_info:
mode: 0644
scripts:
postinstall: ./packaging/scripts/postinstall.sh
preremove: ./packaging/scripts/preremove.sh
overrides:
rpm:
depends:
- systemd
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
systemctl daemon-reload >/dev/null 2>&1 || true
# Restart if already enabled/running; otherwise leave it for puppet to enable
# once the TSIG key and records file are in place.
if systemctl is-enabled dns-updater.service >/dev/null 2>&1; then
systemctl try-restart dns-updater.service >/dev/null 2>&1 || true
fi
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
# $1 is 0 on final removal (rpm), "remove" on purge (deb).
if [ "$1" = "0" ] || [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
systemctl disable --now dns-updater.service >/dev/null 2>&1 || true
fi
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# Package the (already built) dns-updater binary into an RPM with nfpm.
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}" # strip a leading v
DIST="dist"
if [ ! -f "${DIST}/dns-updater" ]; then
echo "ERROR: ${DIST}/dns-updater not found; run 'make build' first" >&2
exit 1
fi
export PACKAGE_NAME="dns-updater"
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_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."
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/dns-updater"
export PACKAGE_LICENSE="MIT"
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
echo "Built:"
ls -1 "${DIST}"/*.rpm