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.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# Built artifacts — anchored so they do not accidentally match cmd/dns-updater/.
|
||||
/dns-updater
|
||||
/*.rpm
|
||||
/dist/
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -1,3 +1,80 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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():
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+55
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+34
@@ -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
|
||||
Reference in New Issue
Block a user