Major architectural pivot per the user's "RFC 2136 mechanism for the existing zonefiles, not a new in-memory thing" framing. The plugin no longer maintains its own in-memory state OR serves any queries -- both of those are now the auto plugin's job, reading the same zone files. The plugin's sole responsibility is now: receive TSIG-authed UPDATE messages, edit the matching zones/<zone>.zone file, bump the SOA serial in CalVer (YYYYMMDDNN) form, and optionally auto-commit to git. What changed: - DELETED: store.go (in-memory recordStore), store_test.go (12 tests), plugin_test.go (10 ServeDNS query tests), old update_test.go. - NEW: zonefile.go -- file-backed authority for one zone. loadRRs via miekg/dns zone parser; mutation helpers (lookupIn/nameExistsIn/ removeRRsetFrom/removeRRFrom/removeNameFrom/addRRTo) on []dns.RR slices; bumpSerial with CalVer semantics + NN exhaustion handling; writeAtomic via temp-file rename; commit shells to `git add && git commit` with configurable author. - NEW: zonefile_test.go -- 17 tests covering load/lookup/mutate/bump/ write paths. - REWRITTEN: plugin.go -- ServeDNS is now thin: UPDATE → TSIG → handler; everything else → Next. No synthetic SOA/NS, no query serving. - REWRITTEN: update.go -- handleUpdate now opens the zoneFile, loads, applies (with prereq checks against the loaded RRs), bumps serial, writes, commits. Detects no-op updates to avoid spurious file writes. - REWRITTEN: setup.go -- new directives: `zones-dir` (required), `auto-commit` (default true), `git-author <name> <email>`. Dropped `nameserver` and `persist`. Validates each declared zone has a file on disk via os.Stat before CoreDNS finishes starting. - REWRITTEN: setup_test.go -- 17 cases for the new grammar. - REWRITTEN: update_test.go -- 11 cases using real temp zone files via t.TempDir(). Total: 30 tests passing, 0 failures. Next: Phase 2c (custom CoreDNS image, deploy, smoke test with nsupdate).
326 lines
9.6 KiB
Go
326 lines
9.6 KiB
Go
package rfc2136
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// zoneFile is a file-backed authority for a single DNS zone. Replaces
|
|
// the Phase-1.3 in-memory recordStore.
|
|
//
|
|
// On every UPDATE, the file is read fully into memory as parsed RRs,
|
|
// the requested adds/deletes are applied to that slice, the SOA serial
|
|
// is bumped (CalVer YYYYMMDDNN style), and the file is rewritten via
|
|
// an atomic temp-file rename. CoreDNS's `auto` plugin notices the
|
|
// mtime change within its reload interval (~30s) and re-serves the
|
|
// zone. HE eventually pulls on its SOA refresh.
|
|
//
|
|
// Concurrency: per-zone mutex serializes RFC 2136 UPDATEs against
|
|
// each other and against the plugin's own reads. It does NOT protect
|
|
// against external editors (e.g. a human running an editor while the
|
|
// plugin is mid-write); that's the operator's responsibility, and
|
|
// the typical mitigation is to do manual edits when no UPDATEs are
|
|
// in flight (or just accept the rare race — the worst case is one
|
|
// lost manual edit, easily restored from git).
|
|
type zoneFile struct {
|
|
mu sync.Mutex
|
|
|
|
// Path is the absolute path to the zone file on disk.
|
|
Path string
|
|
|
|
// Origin is the canonical (lowercase, trailing dot) zone apex.
|
|
Origin string
|
|
|
|
// AutoCommit, when true, runs `git add <path> && git commit ...`
|
|
// after every successful write. Defaults to true (per the chosen
|
|
// architecture: every dynamic update should leave a git trail).
|
|
AutoCommit bool
|
|
|
|
// GitAuthorName and GitAuthorEmail are passed to `git commit`
|
|
// via -c user.name and -c user.email so the commits are
|
|
// attributable without depending on the system git config.
|
|
GitAuthorName string
|
|
GitAuthorEmail string
|
|
}
|
|
|
|
// canon normalises a DNS name to the store's internal form: lowercase
|
|
// with trailing dot. miekg/dns sometimes hands us names without the
|
|
// trailing dot; passing through this once at the boundary keeps every
|
|
// lookup, every comparison consistent.
|
|
func canon(name string) string {
|
|
return strings.ToLower(dns.Fqdn(name))
|
|
}
|
|
|
|
// openZoneFile prepares a zoneFile handle. Does NOT read or parse the
|
|
// file; that happens lazily in each operation (so the file's content
|
|
// is always fresh and we never serve a stale snapshot).
|
|
func openZoneFile(path, origin string) *zoneFile {
|
|
return &zoneFile{
|
|
Path: path,
|
|
Origin: canon(origin),
|
|
AutoCommit: true,
|
|
GitAuthorName: "coredns-rfc2136",
|
|
GitAuthorEmail: "rfc2136@coredns",
|
|
}
|
|
}
|
|
|
|
// loadRRs reads the zone file and parses it into an RR slice via
|
|
// miekg/dns's zone parser. The parser handles $ORIGIN, $TTL, multi-line
|
|
// SOA, comments, includes, etc.
|
|
func (z *zoneFile) loadRRs() ([]dns.RR, error) {
|
|
f, err := os.Open(z.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open %s: %w", z.Path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
parser := dns.NewZoneParser(f, z.Origin, z.Path)
|
|
parser.SetDefaultTTL(3600)
|
|
|
|
var rrs []dns.RR
|
|
for rr, ok := parser.Next(); ok; rr, ok = parser.Next() {
|
|
rrs = append(rrs, rr)
|
|
}
|
|
if err := parser.Err(); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", z.Path, err)
|
|
}
|
|
if len(rrs) == 0 {
|
|
return nil, fmt.Errorf("%s: zero RRs parsed", z.Path)
|
|
}
|
|
return rrs, nil
|
|
}
|
|
|
|
// Lookup returns RRs in `rrs` matching (name, rtype). Both name and
|
|
// the RR header names are canonicalised for the comparison. Pass-by-
|
|
// slice rather than holding state means we can let the caller batch
|
|
// multiple operations against one snapshot of the file.
|
|
func lookupIn(rrs []dns.RR, name string, rtype uint16) []dns.RR {
|
|
name = canon(name)
|
|
var out []dns.RR
|
|
for _, rr := range rrs {
|
|
hdr := rr.Header()
|
|
if canon(hdr.Name) == name && hdr.Rrtype == rtype {
|
|
out = append(out, rr)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nameExistsIn reports whether any RR's owner equals name (canonical).
|
|
func nameExistsIn(rrs []dns.RR, name string) bool {
|
|
name = canon(name)
|
|
for _, rr := range rrs {
|
|
if canon(rr.Header().Name) == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// removeRRsetFrom returns rrs minus every RR matching (name, rtype).
|
|
func removeRRsetFrom(rrs []dns.RR, name string, rtype uint16) []dns.RR {
|
|
name = canon(name)
|
|
out := rrs[:0:0]
|
|
for _, rr := range rrs {
|
|
hdr := rr.Header()
|
|
if canon(hdr.Name) == name && hdr.Rrtype == rtype {
|
|
continue
|
|
}
|
|
out = append(out, rr)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// removeNameFrom returns rrs minus every RR with the given owner name.
|
|
func removeNameFrom(rrs []dns.RR, name string) []dns.RR {
|
|
name = canon(name)
|
|
out := rrs[:0:0]
|
|
for _, rr := range rrs {
|
|
if canon(rr.Header().Name) == name {
|
|
continue
|
|
}
|
|
out = append(out, rr)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// removeRRFrom returns rrs minus the single RR matching the given one
|
|
// by owner + type + rdata. String() comparison covers rdata exactness.
|
|
func removeRRFrom(rrs []dns.RR, target dns.RR) []dns.RR {
|
|
targetStr := target.String()
|
|
out := rrs[:0:0]
|
|
matched := false
|
|
for _, rr := range rrs {
|
|
if !matched && rr.String() == targetStr {
|
|
matched = true
|
|
continue
|
|
}
|
|
out = append(out, rr)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// addRRTo appends rr to rrs unless an identical RR already exists
|
|
// (de-dupe semantics per RFC 2136 §3.4.2.2).
|
|
func addRRTo(rrs []dns.RR, rr dns.RR) []dns.RR {
|
|
target := rr.String()
|
|
for _, existing := range rrs {
|
|
if existing.String() == target {
|
|
return rrs
|
|
}
|
|
}
|
|
return append(rrs, rr)
|
|
}
|
|
|
|
// bumpSerial advances the SOA's serial in CalVer (YYYYMMDDNN) form.
|
|
// Behaviour:
|
|
// - If today is later than the existing serial's date, jump to
|
|
// today with NN=01.
|
|
// - Otherwise (same day, or serial-date is in the future), bump NN.
|
|
// - Caps at NN=99; returns an error if exceeded.
|
|
//
|
|
// The SOA is found by type (there should be exactly one); mutated in
|
|
// place. The returned slice is the same slice with the SOA's serial
|
|
// updated.
|
|
func bumpSerial(rrs []dns.RR, now time.Time) error {
|
|
var soa *dns.SOA
|
|
for _, rr := range rrs {
|
|
if s, ok := rr.(*dns.SOA); ok {
|
|
soa = s
|
|
break
|
|
}
|
|
}
|
|
if soa == nil {
|
|
return fmt.Errorf("zone has no SOA record")
|
|
}
|
|
|
|
today := now.UTC().Format("20060102")
|
|
cur := fmt.Sprintf("%d", soa.Serial)
|
|
|
|
if len(cur) == 10 && cur[:8] == today {
|
|
nn := atoi(cur[8:10])
|
|
if nn >= 99 {
|
|
return fmt.Errorf("serial counter exhausted for %s (NN=99)", today)
|
|
}
|
|
soa.Serial = uint32(parseUint(today)*100 + uint64(nn+1))
|
|
return nil
|
|
}
|
|
|
|
// Either the date is older, or the format isn't CalVer at all —
|
|
// either way, today + 01 is the next valid serial.
|
|
soa.Serial = uint32(parseUint(today)*100 + 1)
|
|
return nil
|
|
}
|
|
|
|
// atoi is a tiny helper that ignores errors — only called on a
|
|
// substring we already validated is two digits.
|
|
func atoi(s string) int {
|
|
n := 0
|
|
for _, c := range s {
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
return n
|
|
}
|
|
|
|
// parseUint parses an all-digits string into a uint64. Used because
|
|
// strconv.ParseUint adds error-handling overhead we don't need on
|
|
// internally-controlled inputs.
|
|
func parseUint(s string) uint64 {
|
|
var n uint64
|
|
for _, c := range s {
|
|
n = n*10 + uint64(c-'0')
|
|
}
|
|
return n
|
|
}
|
|
|
|
// writeAtomic serializes rrs to a temp file in the same directory as
|
|
// z.Path, then renames over the destination. POSIX guarantees atomic
|
|
// rename on local filesystems, so a partial write can never leave a
|
|
// corrupt zone file on disk.
|
|
//
|
|
// Format: one RR per line, tab-separated owner/TTL/class/type/rdata.
|
|
// Comments and multi-line SOA formatting from the original file are
|
|
// NOT preserved (v1 limitation; sophisticated comment preservation can
|
|
// land in v2). A short header line is emitted with the write timestamp
|
|
// and the plugin name, so it's obvious in `git log` what touched the
|
|
// file.
|
|
func (z *zoneFile) writeAtomic(rrs []dns.RR, now time.Time) error {
|
|
dir := filepath.Dir(z.Path)
|
|
tmp, err := os.CreateTemp(dir, ".rfc2136-*.zone")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
// Best-effort cleanup if we fail before the rename.
|
|
defer func() {
|
|
if tmpPath != "" {
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
header := fmt.Sprintf("; Auto-written by coredns-rfc2136 on %s\n; Zone: %s\n$ORIGIN %s\n",
|
|
now.UTC().Format(time.RFC3339), z.Origin, z.Origin)
|
|
if _, err := tmp.WriteString(header); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("write header: %w", err)
|
|
}
|
|
|
|
for _, rr := range rrs {
|
|
if _, err := tmp.WriteString(rr.String() + "\n"); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("write rr: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("sync: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("close: %w", err)
|
|
}
|
|
if err := os.Rename(tmpPath, z.Path); err != nil {
|
|
return fmt.Errorf("rename %s -> %s: %w", tmpPath, z.Path, err)
|
|
}
|
|
tmpPath = "" // suppress cleanup; rename consumed it
|
|
return nil
|
|
}
|
|
|
|
// commit stages and commits the zone file via git. Runs from the
|
|
// repository directory inferred from the zone file's parent. Returns
|
|
// nil silently if AutoCommit is false. Returns an error if the commit
|
|
// fails; the caller decides whether to roll back the file write.
|
|
func (z *zoneFile) commit(message string) error {
|
|
if !z.AutoCommit {
|
|
return nil
|
|
}
|
|
// We run git from the directory containing the zone file. git will
|
|
// walk upward to find the .git dir.
|
|
dir := filepath.Dir(z.Path)
|
|
// `git add` first; if file is already in the index, no harm done.
|
|
add := exec.Command("git",
|
|
"-C", dir,
|
|
"add", "--", z.Path,
|
|
)
|
|
if out, err := add.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("git add failed: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
commit := exec.Command("git",
|
|
"-C", dir,
|
|
"-c", "user.name="+z.GitAuthorName,
|
|
"-c", "user.email="+z.GitAuthorEmail,
|
|
"commit", "-q", "-m", message, "--", z.Path,
|
|
)
|
|
if out, err := commit.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("git commit failed: %w: %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
return nil
|
|
}
|