From 02e3e0315d27264abea48512ef054aa767ad6855 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Fri, 17 Jul 2026 23:24:49 +1000 Subject: [PATCH] Initial implementation: dns-updater daemon 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. --- .gitignore | 4 + .pre-commit-config.yaml | 34 ++++ .woodpecker/build.yaml | 18 +++ .woodpecker/pre-commit.yaml | 18 +++ .woodpecker/release.yaml | 125 +++++++++++++++ .woodpecker/test.yaml | 18 +++ Makefile | 54 +++++++ README.md | 79 +++++++++- cmd/dns-updater/main.go | 263 +++++++++++++++++++++++++++++++ go.mod | 18 +++ go.sum | 22 +++ internal/api/api.go | 126 +++++++++++++++ internal/config/config.go | 88 +++++++++++ internal/config/notify.go | 27 ++++ internal/records/records.go | 210 ++++++++++++++++++++++++ internal/records/records_test.go | 77 +++++++++ internal/tsig/tsig.go | 85 ++++++++++ internal/tsig/tsig_test.go | 39 +++++ internal/updater/updater.go | 239 ++++++++++++++++++++++++++++ internal/updater/updater_test.go | 178 +++++++++++++++++++++ internal/watch/watch.go | 115 ++++++++++++++ packaging/dns-updater.service | 30 ++++ packaging/env.sample | 24 +++ packaging/facts.d/dns_updater.sh | 55 +++++++ packaging/nfpm.yaml | 45 ++++++ packaging/scripts/postinstall.sh | 8 + packaging/scripts/preremove.sh | 6 + scripts/build-rpm.sh | 34 ++++ 28 files changed, 2038 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/build.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 .woodpecker/release.yaml create mode 100644 .woodpecker/test.yaml create mode 100644 Makefile create mode 100644 cmd/dns-updater/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api.go create mode 100644 internal/config/config.go create mode 100644 internal/config/notify.go create mode 100644 internal/records/records.go create mode 100644 internal/records/records_test.go create mode 100644 internal/tsig/tsig.go create mode 100644 internal/tsig/tsig_test.go create mode 100644 internal/updater/updater.go create mode 100644 internal/updater/updater_test.go create mode 100644 internal/watch/watch.go create mode 100644 packaging/dns-updater.service create mode 100644 packaging/env.sample create mode 100755 packaging/facts.d/dns_updater.sh create mode 100644 packaging/nfpm.yaml create mode 100644 packaging/scripts/postinstall.sh create mode 100644 packaging/scripts/preremove.sh create mode 100755 scripts/build-rpm.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5feacfe --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Built artifacts — anchored so they do not accidentally match cmd/dns-updater/. +/dns-updater +/*.rpm +/dist/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2906ecd --- /dev/null +++ b/.pre-commit-config.yaml @@ -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] diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml new file mode 100644 index 0000000..610ecc4 --- /dev/null +++ b/.woodpecker/release.yaml @@ -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 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..6357365 --- /dev/null +++ b/.woodpecker/test.yaml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f536237 --- /dev/null +++ b/Makefile @@ -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 < Woodpecker release +``` diff --git a/cmd/dns-updater/main.go b/cmd/dns-updater/main.go new file mode 100644 index 0000000..04fee02 --- /dev/null +++ b/cmd/dns-updater/main.go @@ -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 + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c09b4fa --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d810fb4 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..86c315a --- /dev/null +++ b/internal/api/api.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cf1e663 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/notify.go b/internal/config/notify.go new file mode 100644 index 0000000..d434629 --- /dev/null +++ b/internal/config/notify.go @@ -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)) +} diff --git a/internal/records/records.go b/internal/records/records.go new file mode 100644 index 0000000..d62ba9a --- /dev/null +++ b/internal/records/records.go @@ -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 +} diff --git a/internal/records/records_test.go b/internal/records/records_test.go new file mode 100644 index 0000000..d0ad971 --- /dev/null +++ b/internal/records/records_test.go @@ -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()) + } +} diff --git a/internal/tsig/tsig.go b/internal/tsig/tsig.go new file mode 100644 index 0000000..fda7888 --- /dev/null +++ b/internal/tsig/tsig.go @@ -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} +} diff --git a/internal/tsig/tsig_test.go b/internal/tsig/tsig_test.go new file mode 100644 index 0000000..12b1b65 --- /dev/null +++ b/internal/tsig/tsig_test.go @@ -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) + } + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 0000000..fe82a49 --- /dev/null +++ b/internal/updater/updater.go @@ -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() +} diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go new file mode 100644 index 0000000..1c7e97e --- /dev/null +++ b/internal/updater/updater_test.go @@ -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) + } +} diff --git a/internal/watch/watch.go b/internal/watch/watch.go new file mode 100644 index 0000000..71563c5 --- /dev/null +++ b/internal/watch/watch.go @@ -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(): + } +} diff --git a/packaging/dns-updater.service b/packaging/dns-updater.service new file mode 100644 index 0000000..9326c9f --- /dev/null +++ b/packaging/dns-updater.service @@ -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 diff --git a/packaging/env.sample b/packaging/env.sample new file mode 100644 index 0000000..c55d202 --- /dev/null +++ b/packaging/env.sample @@ -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 diff --git a/packaging/facts.d/dns_updater.sh b/packaging/facts.d/dns_updater.sh new file mode 100755 index 0000000..053f900 --- /dev/null +++ b/packaging/facts.d/dns_updater.sh @@ -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 diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..0435c2c --- /dev/null +++ b/packaging/nfpm.yaml @@ -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 diff --git a/packaging/scripts/postinstall.sh b/packaging/scripts/postinstall.sh new file mode 100644 index 0000000..6dd06b7 --- /dev/null +++ b/packaging/scripts/postinstall.sh @@ -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 diff --git a/packaging/scripts/preremove.sh b/packaging/scripts/preremove.sh new file mode 100644 index 0000000..74b3b74 --- /dev/null +++ b/packaging/scripts/preremove.sh @@ -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 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..e233387 --- /dev/null +++ b/scripts/build-rpm.sh @@ -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 " +export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/dns-updater" +export PACKAGE_LICENSE="MIT" + +envsubst "${DIST}/nfpm.yaml" +nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm + +echo "Built:" +ls -1 "${DIST}"/*.rpm