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

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

Replaces the puppet dns-update shell script; keeps the same records-file and
TSIG-key contract.
This commit is contained in:
2026-07-17 23:24:49 +10:00
parent 9d38e67b35
commit 02e3e0315d
28 changed files with 2038 additions and 1 deletions
+30
View File
@@ -0,0 +1,30 @@
[Unit]
Description=DNS record updater (RFC2136 dynamic DNS from a records file)
Documentation=https://git.unkin.net/unkin/dns-updater
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
EnvironmentFile=-/etc/dns-updater/env
ExecStart=/usr/bin/dns-updater
Restart=on-failure
RestartSec=5
WatchdogSec=0
# Hardening. Runs as root to read the root-owned TSIG key; lock the rest down.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectControlGroups=true
ProtectKernelModules=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
RestrictNamespaces=true
ReadWritePaths=/var/lib/dns-updater /run/dns-updater
RuntimeDirectory=dns-updater
StateDirectory=dns-updater
[Install]
WantedBy=multi-user.target
+24
View File
@@ -0,0 +1,24 @@
# dns-updater configuration (systemd EnvironmentFile).
# The authoritative write endpoint (BIND primary). Required.
DNS_UPDATER_SERVER=198.18.200.9
# BIND-style TSIG key file (algorithm + secret). Puppet manages this.
DNS_UPDATER_KEY_FILE=/etc/dns-updater/key
# Desired records, one per line: zone|name|type|ttl|value. Puppet writes this.
DNS_UPDATER_RECORDS_FILE=/var/lib/dns-updater/records
# Last-applied state (managed by the daemon).
DNS_UPDATER_STATE_FILE=/var/lib/dns-updater/applied
# Reconcile on interface address changes (DHCP renew, link reconfig).
DNS_UPDATER_WATCH_INTERFACES=true
# Periodic safety-net resync (0 disables).
DNS_UPDATER_RESYNC=10m
# Local status API (unix socket path or host:port; empty disables).
DNS_UPDATER_API=/run/dns-updater/api.sock
# debug|info|warn|error
DNS_UPDATER_LOG_LEVEL=info
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Facter external fact: report dns-updater's status by querying its local API
# socket. Emits flat key=value facts (compatible across facter versions).
# Puppet already knows the DESIRED records (it writes the records file); these
# facts report what the daemon actually achieved on the server.
set -u
SOCK="${DNS_UPDATER_API_SOCK:-/run/dns-updater/api.sock}"
emit() { printf 'dns_updater_%s=%s\n' "$1" "$2"; }
if [ ! -S "$SOCK" ]; then
emit running false
emit healthy false
exit 0
fi
json="$(curl -s --max-time 3 --unix-socket "$SOCK" http://local/status 2>/dev/null)"
if [ -z "$json" ]; then
emit running true
emit healthy false
emit last_error "api_unreachable"
exit 0
fi
emit running true
python3 - "$json" <<'PY'
import json, sys
try:
d = json.loads(sys.argv[1])
except Exception:
print("dns_updater_healthy=false")
print("dns_updater_last_error=bad_json")
sys.exit(0)
def emit(k, v):
print(f"dns_updater_{k}={v}")
emit("healthy", str(d.get("healthy", False)).lower())
emit("managed_records", d.get("managed_records", 0))
if d.get("last_reconcile"):
emit("last_reconcile", d["last_reconcile"])
if d.get("last_change"):
emit("last_change", d["last_change"])
if d.get("last_error"):
emit("last_error", d["last_error"].replace("\n", " ")[:200])
zones = d.get("zones") or []
failed = [z for z in zones if (z.get("error") or z.get("rcode", 0) != 0)]
emit("zones_total", len(zones))
emit("zones_failed", len(failed))
if failed:
emit("failed_zones", ",".join(f"{z['zone']}:{z.get('rcode_text','?')}" for z in failed))
PY
+45
View File
@@ -0,0 +1,45 @@
# nfpm packaging for dns-updater. Rendered by scripts/build-rpm.sh (envsubst)
# after `make build` produces dist/dns-updater.
name: ${PACKAGE_NAME}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
section: net
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
description: "${PACKAGE_DESCRIPTION}"
provides:
- dns-updater
contents:
- src: ./dist/dns-updater
dst: /usr/bin/dns-updater
file_info:
mode: 0755
- src: ./packaging/dns-updater.service
dst: /usr/lib/systemd/system/dns-updater.service
file_info:
mode: 0644
- src: ./packaging/facts.d/dns_updater.sh
dst: /opt/puppetlabs/facter/facts.d/dns_updater.sh
file_info:
mode: 0755
- src: ./packaging/env.sample
dst: /etc/dns-updater/env
type: config|noreplace
file_info:
mode: 0644
scripts:
postinstall: ./packaging/scripts/postinstall.sh
preremove: ./packaging/scripts/preremove.sh
overrides:
rpm:
depends:
- systemd
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
systemctl daemon-reload >/dev/null 2>&1 || true
# Restart if already enabled/running; otherwise leave it for puppet to enable
# once the TSIG key and records file are in place.
if systemctl is-enabled dns-updater.service >/dev/null 2>&1; then
systemctl try-restart dns-updater.service >/dev/null 2>&1 || true
fi
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
set -e
# $1 is 0 on final removal (rpm), "remove" on purge (deb).
if [ "$1" = "0" ] || [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
systemctl disable --now dns-updater.service >/dev/null 2>&1 || true
fi