Files
dns-updater/cmd/dns-updater/main.go
T
unkinben 194080511c
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
records: add domain/IP-range allow+deny filtering
Adds a Filter (allow/deny CIDRs + allow/deny domain suffixes) applied to the
desired set before reconcile. Range rules match A/AAAA by value and PTR by the
address encoded in the reverse-DNS owner, so both the junk A record and its
reverse PTR are dropped together. Configured via -deny-ranges/-allow-ranges/
-deny-domains/-allow-domains (comma-separated) or DNS_UPDATER_* env.

Purpose: keep k8s pod/service CIDRs, LB VIP ranges and other internal addresses
out of the authoritative zones, and stop NOTAUTH updates for reverse zones the
server does not host (10.42.x, 198.18.200.x, etc.).
2026-07-18 01:59:29 +10:00

276 lines
7.4 KiB
Go

// 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, "."))
if !cfg.Filter.Empty() {
log.Info("record filter active",
"deny_ranges", len(cfg.Filter.DenyRanges), "allow_ranges", len(cfg.Filter.AllowRanges),
"deny_domains", len(cfg.Filter.DenyDomains), "allow_domains", len(cfg.Filter.AllowDomains))
}
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)
}
if !d.cfg.Filter.Empty() {
var dropped int
desired, dropped = d.cfg.Filter.Apply(desired)
if dropped > 0 {
d.log.Debug("filtered records", "trigger", trigger, "dropped", dropped, "kept", desired.Len())
}
}
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
}
}