Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone
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).
This commit is contained in:
parent
1d2d919728
commit
0f28127284
10 changed files with 1223 additions and 1109 deletions
252
update.go
252
update.go
|
|
@ -1,34 +1,36 @@
|
|||
package rfc2136
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// handleUpdate implements the RFC 2136 UPDATE opcode.
|
||||
// handleUpdate implements the RFC 2136 UPDATE opcode against the
|
||||
// on-disk zone file.
|
||||
//
|
||||
// Message layout in an UPDATE (RFC 2136 §2.2):
|
||||
// Sequence per UPDATE message:
|
||||
// 1. Validate the Zone section (RFC 2136 §2.3): must be exactly one
|
||||
// SOA-typed record whose name is a zone we manage.
|
||||
// 2. Acquire the zone file's mutex.
|
||||
// 3. Load the file's RRs into memory.
|
||||
// 4. Check each prerequisite (§3.2) against the loaded RRs. First
|
||||
// failure short-circuits with the spec's rcode.
|
||||
// 5. Apply each update RR (§3.4.2) to the in-memory slice.
|
||||
// 6. Bump the SOA serial (CalVer YYYYMMDDNN).
|
||||
// 7. Atomic write to disk (temp file + rename).
|
||||
// 8. Optionally `git add && git commit` for audit trail.
|
||||
//
|
||||
// Question → "Zone" section (exactly one record, type SOA)
|
||||
// Answer → "Prerequisite" section (zero or more, see §2.4)
|
||||
// Authority → "Update" section (zero or more, see §2.5)
|
||||
// Additional → TSIG, OPT, etc.
|
||||
//
|
||||
// Processing order:
|
||||
// 1. Zone-section validation: zone must be one we're authoritative for.
|
||||
// 2. Prerequisite checks (§3.2). First failure short-circuits with the
|
||||
// RFC-specified rcode (NXDOMAIN/YXDOMAIN/NXRRSET/YXRRSET/NOTAUTH).
|
||||
// 3. Apply updates (§3.4.2). All updates either all succeed or all fail
|
||||
// by acquiring the store lock once for the batch.
|
||||
//
|
||||
// TSIG verification happens before this function is called — see
|
||||
// ServeDNS for the auth gate.
|
||||
// Steps 3-7 happen under the zone-file mutex. If 8 fails we log but
|
||||
// don't roll back (the on-disk state is authoritative; lost commits
|
||||
// can be re-staged via `git add` later).
|
||||
func (p *RFC2136) handleUpdate(w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
||||
resp := new(dns.Msg)
|
||||
resp.SetReply(r)
|
||||
|
||||
// 1. Validate zone section.
|
||||
// 1. Validate the Zone section.
|
||||
if len(r.Question) != 1 {
|
||||
log.Debugf("UPDATE rejected: expected 1 Zone record, got %d", len(r.Question))
|
||||
return p.updateResp(w, resp, dns.RcodeFormatError)
|
||||
|
|
@ -43,28 +45,76 @@ func (p *RFC2136) handleUpdate(w dns.ResponseWriter, r *dns.Msg) (int, error) {
|
|||
log.Debugf("UPDATE rejected: zone %q not authoritative", zoneQ.Name)
|
||||
return p.updateResp(w, resp, dns.RcodeNotAuth)
|
||||
}
|
||||
zf, ok := p.zones[zone]
|
||||
if !ok {
|
||||
log.Errorf("UPDATE rejected: no zone file handle for %q (setup bug?)", zone)
|
||||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
}
|
||||
|
||||
// 2. Verify each prerequisite. Read-locked through the store API.
|
||||
zf.mu.Lock()
|
||||
defer zf.mu.Unlock()
|
||||
|
||||
// 3. Load the current zone contents.
|
||||
rrs, err := zf.loadRRs()
|
||||
if err != nil {
|
||||
log.Errorf("UPDATE failed: %v", err)
|
||||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
}
|
||||
|
||||
// 4. Check prerequisites.
|
||||
for _, rr := range r.Answer {
|
||||
rcode := p.checkPrereq(zone, rr)
|
||||
rcode := checkPrereq(zone, rrs, rr)
|
||||
if rcode != dns.RcodeSuccess {
|
||||
log.Debugf("UPDATE prereq failed: %s → rcode=%d", rr.String(), rcode)
|
||||
return p.updateResp(w, resp, rcode)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Apply updates. We don't take a single batch lock here — each
|
||||
// store operation locks internally. RFC 2136 §3.7 allows the
|
||||
// "atomic" requirement to be relaxed for implementations; with
|
||||
// short-lived ACME records this is fine in practice.
|
||||
// 5. Apply updates. Build a fresh RR slice rather than mutating in
|
||||
// place — that way a partial application can't leave the slice in
|
||||
// a half-modified state if an early update fails.
|
||||
updated := rrs
|
||||
changed := false
|
||||
for _, rr := range r.Ns {
|
||||
if rcode := p.applyUpdate(zone, rr); rcode != dns.RcodeSuccess {
|
||||
next, rcode, modified := applyUpdate(zone, p.TTL, updated, rr)
|
||||
if rcode != dns.RcodeSuccess {
|
||||
return p.updateResp(w, resp, rcode)
|
||||
}
|
||||
updated = next
|
||||
if modified {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("UPDATE applied: zone=%s prereqs=%d updates=%d gen=%d",
|
||||
zone, len(r.Answer), len(r.Ns), p.store.generation())
|
||||
if !changed {
|
||||
// UPDATE was a valid no-op (e.g. only contained adds for RRs
|
||||
// that were already present, deduped away). Return NOERROR
|
||||
// without rewriting the file.
|
||||
return p.updateResp(w, resp, dns.RcodeSuccess)
|
||||
}
|
||||
|
||||
// 6. Bump SOA serial.
|
||||
now := time.Now()
|
||||
if err := bumpSerial(updated, now); err != nil {
|
||||
log.Errorf("UPDATE failed: %v", err)
|
||||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
}
|
||||
|
||||
// 7. Atomic write.
|
||||
if err := zf.writeAtomic(updated, now); err != nil {
|
||||
log.Errorf("UPDATE write failed: %v", err)
|
||||
return p.updateResp(w, resp, dns.RcodeServerFailure)
|
||||
}
|
||||
|
||||
// 8. Auto-commit. Failure to commit isn't fatal to the UPDATE —
|
||||
// the on-disk state is authoritative — but we log loudly.
|
||||
msg := summarizeUpdate(zone, r.Ns)
|
||||
if err := zf.commit(msg); err != nil {
|
||||
log.Warningf("git auto-commit failed: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("UPDATE applied: zone=%s prereqs=%d updates=%d msg=%q",
|
||||
zone, len(r.Answer), len(r.Ns), msg)
|
||||
return p.updateResp(w, resp, dns.RcodeSuccess)
|
||||
}
|
||||
|
||||
|
|
@ -75,122 +125,152 @@ func (p *RFC2136) updateResp(w dns.ResponseWriter, resp *dns.Msg, rcode int) (in
|
|||
return rcode, nil
|
||||
}
|
||||
|
||||
// checkPrereq evaluates one record from the Prerequisite section.
|
||||
// Returns dns.RcodeSuccess if satisfied, or the appropriate error rcode.
|
||||
//
|
||||
// Encoding rules (§3.2.4):
|
||||
//
|
||||
// CLASS=ANY TYPE=ANY → name must exist (else NXDOMAIN)
|
||||
// CLASS=ANY TYPE!=ANY → RRset must exist (else NXRRSET)
|
||||
// CLASS=NONE TYPE=ANY → name must NOT exist (else YXDOMAIN)
|
||||
// CLASS=NONE TYPE!=ANY → RRset must NOT exist (else YXRRSET)
|
||||
// CLASS=<zone> ... rdata → RRset must exist with this exact rdata
|
||||
func (p *RFC2136) checkPrereq(zone string, rr dns.RR) int {
|
||||
// findZone returns the longest matching configured zone for qname, or
|
||||
// "" if qname is outside all configured zones.
|
||||
func (p *RFC2136) findZone(qname string) string {
|
||||
qname = canon(qname)
|
||||
var best string
|
||||
for _, z := range p.Zones {
|
||||
if qname == z || strings.HasSuffix(qname, "."+z) {
|
||||
if len(z) > len(best) {
|
||||
best = z
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// checkPrereq evaluates one record from the Prerequisite section
|
||||
// against the loaded RR slice. Returns dns.RcodeSuccess if satisfied,
|
||||
// or the spec rcode otherwise (§3.2).
|
||||
func checkPrereq(zone string, rrs []dns.RR, rr dns.RR) int {
|
||||
hdr := rr.Header()
|
||||
name := canon(hdr.Name)
|
||||
|
||||
// All prereq names must be within the zone.
|
||||
if !inZone(name, zone) {
|
||||
return dns.RcodeNotZone
|
||||
}
|
||||
|
||||
switch hdr.Class {
|
||||
case dns.ClassANY:
|
||||
// "Name/RRset is in use"
|
||||
if hdr.Rrtype == dns.TypeANY {
|
||||
if !p.store.NameExists(name) && !isApex(name, zone) {
|
||||
if !nameExistsIn(rrs, name) {
|
||||
return dns.RcodeNameError
|
||||
}
|
||||
return dns.RcodeSuccess
|
||||
}
|
||||
if rrs := p.store.Lookup(name, hdr.Rrtype); rrs == nil {
|
||||
if len(lookupIn(rrs, name, hdr.Rrtype)) == 0 {
|
||||
return dns.RcodeNXRrset
|
||||
}
|
||||
return dns.RcodeSuccess
|
||||
|
||||
case dns.ClassNONE:
|
||||
// "Name/RRset is NOT in use"
|
||||
if hdr.Rrtype == dns.TypeANY {
|
||||
if p.store.NameExists(name) {
|
||||
if nameExistsIn(rrs, name) {
|
||||
return dns.RcodeYXDomain
|
||||
}
|
||||
return dns.RcodeSuccess
|
||||
}
|
||||
if rrs := p.store.Lookup(name, hdr.Rrtype); rrs != nil {
|
||||
if len(lookupIn(rrs, name, hdr.Rrtype)) > 0 {
|
||||
return dns.RcodeYXRrset
|
||||
}
|
||||
return dns.RcodeSuccess
|
||||
|
||||
default:
|
||||
// CLASS = zone class. Exact rdata match required (§3.2.5).
|
||||
// Skipped for v1 — Caddy/caddy-dns/rfc2136 doesn't emit these.
|
||||
// Document the gap; v2 can implement value-prereq if a caller
|
||||
// actually needs it.
|
||||
log.Debugf("prereq with rdata-match semantics not yet implemented; treating as satisfied")
|
||||
// CLASS = zone class with rdata. Exact value-match prereqs
|
||||
// (§3.2.5). Not used by Caddy/caddy-dns/rfc2136; treating as
|
||||
// satisfied for now. v2 can implement value-prereq if a real
|
||||
// caller needs it.
|
||||
log.Debugf("prereq with rdata-match semantics not implemented; treating as satisfied")
|
||||
return dns.RcodeSuccess
|
||||
}
|
||||
}
|
||||
|
||||
// applyUpdate handles one record in the Update section per §3.4.2.
|
||||
//
|
||||
// Encoding rules:
|
||||
//
|
||||
// CLASS=<zone> RDLEN>0 → add RR (§3.4.2.2)
|
||||
// CLASS=ANY TYPE=ANY → delete all RRsets from name (§3.4.2.3)
|
||||
// CLASS=ANY TYPE!=ANY RDLEN=0 → delete this RRset (§3.4.2.3)
|
||||
// CLASS=NONE RDLEN>0 → delete the specific RR (§3.4.2.4)
|
||||
func (p *RFC2136) applyUpdate(zone string, rr dns.RR) int {
|
||||
// Returns the (possibly mutated) RR slice, an rcode (Success unless
|
||||
// the update was rejected), and a flag indicating whether the slice
|
||||
// was actually modified (to avoid no-op file rewrites).
|
||||
func applyUpdate(zone string, defaultTTL uint32, rrs []dns.RR, rr dns.RR) ([]dns.RR, int, bool) {
|
||||
hdr := rr.Header()
|
||||
name := canon(hdr.Name)
|
||||
|
||||
if !inZone(name, zone) {
|
||||
return dns.RcodeNotZone
|
||||
return rrs, dns.RcodeNotZone, false
|
||||
}
|
||||
|
||||
switch hdr.Class {
|
||||
case dns.ClassANY:
|
||||
if hdr.Rrtype == dns.TypeANY {
|
||||
// Reject deleting the apex (SOA/NS bedrock); the rest of
|
||||
// the zone is free game.
|
||||
// Wipe the whole name. Refuse apex wipes — that would
|
||||
// destroy SOA + NS bedrock.
|
||||
if isApex(name, zone) {
|
||||
log.Debugf("apex deletion refused: %s", name)
|
||||
return dns.RcodeRefused
|
||||
log.Debugf("apex wipe refused: %s", name)
|
||||
return rrs, dns.RcodeRefused, false
|
||||
}
|
||||
p.store.RemoveName(name)
|
||||
return dns.RcodeSuccess
|
||||
before := len(rrs)
|
||||
rrs = removeNameFrom(rrs, name)
|
||||
return rrs, dns.RcodeSuccess, len(rrs) != before
|
||||
}
|
||||
// Apex SOA/NS protected against type-targeted deletion too.
|
||||
// Apex SOA/NS removal refused for the same reason.
|
||||
if isApex(name, zone) && (hdr.Rrtype == dns.TypeSOA || hdr.Rrtype == dns.TypeNS) {
|
||||
log.Debugf("apex %s deletion refused: %s", dns.TypeToString[hdr.Rrtype], name)
|
||||
return dns.RcodeRefused
|
||||
log.Debugf("apex %s removal refused", dns.TypeToString[hdr.Rrtype])
|
||||
return rrs, dns.RcodeRefused, false
|
||||
}
|
||||
p.store.RemoveRRset(name, hdr.Rrtype)
|
||||
return dns.RcodeSuccess
|
||||
before := len(rrs)
|
||||
rrs = removeRRsetFrom(rrs, name, hdr.Rrtype)
|
||||
return rrs, dns.RcodeSuccess, len(rrs) != before
|
||||
|
||||
case dns.ClassNONE:
|
||||
// Refuse to delete apex SOA/NS by exact-RR match.
|
||||
if isApex(name, zone) && (hdr.Rrtype == dns.TypeSOA || hdr.Rrtype == dns.TypeNS) {
|
||||
return dns.RcodeRefused
|
||||
return rrs, dns.RcodeRefused, false
|
||||
}
|
||||
p.store.RemoveRR(rr)
|
||||
return dns.RcodeSuccess
|
||||
before := len(rrs)
|
||||
rrs = removeRRFrom(rrs, rr)
|
||||
return rrs, dns.RcodeSuccess, len(rrs) != before
|
||||
|
||||
default:
|
||||
// CLASS = zone class → add. Apply default TTL if missing.
|
||||
if hdr.Ttl == 0 {
|
||||
hdr.Ttl = p.TTL
|
||||
}
|
||||
// SOA/NS at the apex are synthetic — don't let UPDATE override.
|
||||
// Apex SOA/NS adds refused — those are managed by the zone-file
|
||||
// owner, not by dynamic updates.
|
||||
if isApex(name, zone) && (hdr.Rrtype == dns.TypeSOA || hdr.Rrtype == dns.TypeNS) {
|
||||
log.Debugf("apex %s add refused: synthetic at this plugin", dns.TypeToString[hdr.Rrtype])
|
||||
return dns.RcodeRefused
|
||||
log.Debugf("apex %s add refused", dns.TypeToString[hdr.Rrtype])
|
||||
return rrs, dns.RcodeRefused, false
|
||||
}
|
||||
p.store.Add(rr)
|
||||
return dns.RcodeSuccess
|
||||
if hdr.Ttl == 0 {
|
||||
hdr.Ttl = defaultTTL
|
||||
}
|
||||
before := len(rrs)
|
||||
rrs = addRRTo(rrs, rr)
|
||||
return rrs, dns.RcodeSuccess, len(rrs) != before
|
||||
}
|
||||
}
|
||||
|
||||
// inZone reports whether name is within zone (either the apex itself
|
||||
// or a sub-name of it). Both arguments must already be canonical.
|
||||
// summarizeUpdate produces a one-line commit message describing the
|
||||
// UPDATE for git history.
|
||||
func summarizeUpdate(zone string, updates []dns.RR) string {
|
||||
if len(updates) == 1 {
|
||||
return fmt.Sprintf("rfc2136 %s: %s", zone, oneLineOp(updates[0]))
|
||||
}
|
||||
return fmt.Sprintf("rfc2136 %s: %d operations", zone, len(updates))
|
||||
}
|
||||
|
||||
// oneLineOp returns a short human-readable description of a single
|
||||
// update RR for inclusion in commit messages.
|
||||
func oneLineOp(rr dns.RR) string {
|
||||
hdr := rr.Header()
|
||||
name := strings.TrimSuffix(canon(hdr.Name), ".")
|
||||
ttype := dns.TypeToString[hdr.Rrtype]
|
||||
switch hdr.Class {
|
||||
case dns.ClassANY:
|
||||
if hdr.Rrtype == dns.TypeANY {
|
||||
return fmt.Sprintf("delete all %s", name)
|
||||
}
|
||||
return fmt.Sprintf("delete %s %s", ttype, name)
|
||||
case dns.ClassNONE:
|
||||
return fmt.Sprintf("delete-rr %s %s", ttype, name)
|
||||
default:
|
||||
return fmt.Sprintf("add %s %s", ttype, name)
|
||||
}
|
||||
}
|
||||
|
||||
// inZone reports whether name is within zone.
|
||||
func inZone(name, zone string) bool {
|
||||
return name == zone || strings.HasSuffix(name, "."+zone)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue