Files
dns-updater/internal/records/records.go
T
unkinben 02e3e0315d
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-07-17 23:24:49 +10:00

211 lines
5.7 KiB
Go

// 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
}