// Package rfc2136 is a CoreDNS plugin that accepts dynamic DNS updates // per RFC 2136 (UPDATE opcode), authenticated via TSIG, and applies // them to on-disk zone files. This is the right shape for stacks where // the operator wants to keep zones in flat files (perhaps under git, // with HE pulling AXFR), but also wants programmatic updates from // clients like Caddy's caddy-dns/rfc2136 module. // // The plugin does NOT serve any queries — that's the job of the // `auto`/`file` plugin running alongside it. This plugin's only // responsibility is the UPDATE opcode path: verify TSIG, dissect the // UPDATE, write the zone file, bump the SOA serial, optionally // auto-commit to git. CoreDNS's auto plugin notices the mtime change // and re-serves the zone within its reload interval. // // See the plan at // ~/.claude/plans/dood-does-coredns-offer-enumerated-piglet.md // for the architectural rationale. package rfc2136 import ( "context" "github.com/coredns/coredns/plugin" "github.com/miekg/dns" ) // DefaultTTL is applied to dynamically-added records whose UPDATE // messages carry TTL=0. 60s matches the short-lived nature of ACME // challenge records and keeps stale answers from lingering in // resolver caches. const DefaultTTL uint32 = 60 // RFC2136 is the plugin handler. One instance per Corefile server block. type RFC2136 struct { // Next is the downstream plugin in the chain — queries always // pass through; only UPDATE opcode is intercepted. Next plugin.Handler // Zones is the set of canonical (dot-terminated, lowercase) zone // names this instance accepts UPDATEs for. UPDATEs for any other // zone are rejected with NOTAUTH. Zones []string // TSIGKeys is keyed by canonical key name (lowercased, trailing // dot). Empty means TSIG is disabled — UPDATEs are refused // unconditionally as a safety default. TSIGKeys map[string]tsigKey // TTL is applied to dynamically-injected records that don't carry // an explicit TTL in the UPDATE message. TTL uint32 // ZonesDir is the directory where .zone files live (matching // the mount path inside the CoreDNS container). The plugin reads // and writes files at /.zone. ZonesDir string // AutoCommit governs whether the plugin auto-commits zone-file // changes to git after every successful UPDATE. AutoCommit bool // zones holds per-zone file handlers, keyed by canonical zone name. // Populated in setup; mutexes live inside each zoneFile. zones map[string]*zoneFile } // Name implements plugin.Handler. func (p *RFC2136) Name() string { return "rfc2136" } // ServeDNS implements plugin.Handler. // // Dispatch: // // UPDATE opcode → verify TSIG, then apply via the UPDATE handler. // Anything else → pass through to Next (the auto plugin handles // queries against the zone files we maintain). func (p *RFC2136) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) { if r.Opcode == dns.OpcodeUpdate { if err := p.checkTSIG(w, r); err != nil { log.Warningf("UPDATE rejected: %v", err) resp := new(dns.Msg) resp.SetRcode(r, dns.RcodeRefused) // Best-effort: if the request had TSIG and we recognize // the key, the framework will sign the rejection so the // client can authenticate "yes, server rejected this." // Unknown keys silently skip signing — correct, since we // can't prove identity to a peer we don't share a key with. signResponseIfSigned(resp, r) _ = w.WriteMsg(resp) return dns.RcodeRefused, nil } return p.handleUpdate(w, r) } return plugin.NextOrFailure(p.Name(), p.Next, ctx, w, r) }