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).
204 lines
5.4 KiB
Go
204 lines
5.4 KiB
Go
package rfc2136
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"github.com/coredns/caddy"
|
|
"github.com/coredns/coredns/core/dnsserver"
|
|
"github.com/coredns/coredns/plugin"
|
|
clog "github.com/coredns/coredns/plugin/pkg/log"
|
|
)
|
|
|
|
// log is the package logger, scoped so messages are prefixed `[rfc2136]`.
|
|
var log = clog.NewWithPlugin("rfc2136")
|
|
|
|
func init() {
|
|
plugin.Register("rfc2136", setup)
|
|
}
|
|
|
|
// setup is invoked by the CoreDNS plugin registry once per Corefile
|
|
// `rfc2136` directive. It parses the directive, validates that each
|
|
// declared zone has a corresponding file in zones-dir, registers
|
|
// TSIG keys with the underlying dns.Server, and links the handler
|
|
// into the plugin chain.
|
|
func setup(c *caddy.Controller) error {
|
|
p, err := parse(c)
|
|
if err != nil {
|
|
return plugin.Error("rfc2136", err)
|
|
}
|
|
if err := p.validateZoneFiles(); err != nil {
|
|
return plugin.Error("rfc2136", err)
|
|
}
|
|
|
|
cfg := dnsserver.GetConfig(c)
|
|
|
|
// Register TSIG keys with the underlying dns.Server so miekg/dns
|
|
// auto-verifies incoming signatures. We then just inspect the
|
|
// result via dns.ResponseWriter.TsigStatus() in our UPDATE handler.
|
|
if len(p.TSIGKeys) > 0 {
|
|
if cfg.TsigSecret == nil {
|
|
cfg.TsigSecret = make(map[string]string)
|
|
}
|
|
for name, key := range p.TSIGKeys {
|
|
cfg.TsigSecret[name] = base64.StdEncoding.EncodeToString(key.Secret)
|
|
}
|
|
}
|
|
|
|
cfg.AddPlugin(func(next plugin.Handler) plugin.Handler {
|
|
p.Next = next
|
|
return p
|
|
})
|
|
|
|
log.Infof("ready: zones=%v keys=%d ttl=%d dir=%q auto-commit=%t",
|
|
p.Zones, len(p.TSIGKeys), p.TTL, p.ZonesDir, p.AutoCommit)
|
|
return nil
|
|
}
|
|
|
|
// parse reads a single `rfc2136 <zone> [<zone>...] { ... }` block.
|
|
//
|
|
// Grammar:
|
|
//
|
|
// rfc2136 <zone> [<zone>...] {
|
|
// zones-dir <path> ; required
|
|
// tsig-key <name> <algorithm> <base64-secret> ; may repeat
|
|
// ttl <seconds> ; default 60
|
|
// auto-commit <true|false> ; default true
|
|
// git-author <name> <email> ; optional
|
|
// }
|
|
func parse(c *caddy.Controller) (*RFC2136, error) {
|
|
p := &RFC2136{
|
|
TSIGKeys: make(map[string]tsigKey),
|
|
TTL: DefaultTTL,
|
|
AutoCommit: true,
|
|
}
|
|
|
|
// Per-zone git author overrides. Defaults are applied later.
|
|
var gitAuthorName, gitAuthorEmail string
|
|
|
|
for c.Next() {
|
|
args := c.RemainingArgs()
|
|
if len(args) < 1 {
|
|
return nil, c.ArgErr()
|
|
}
|
|
for _, z := range args {
|
|
p.Zones = append(p.Zones, plugin.Host(z).NormalizeExact()...)
|
|
}
|
|
|
|
for c.NextBlock() {
|
|
switch c.Val() {
|
|
|
|
case "zones-dir":
|
|
dArgs := c.RemainingArgs()
|
|
if len(dArgs) != 1 {
|
|
return nil, c.ArgErr()
|
|
}
|
|
p.ZonesDir = dArgs[0]
|
|
|
|
case "tsig-key":
|
|
kArgs := c.RemainingArgs()
|
|
if len(kArgs) != 3 {
|
|
return nil, c.Errf("tsig-key requires 3 args (name algorithm secret), got %d", len(kArgs))
|
|
}
|
|
keyName := canonicalKeyName(kArgs[0])
|
|
algo, err := parseTSIGAlgorithm(kArgs[1])
|
|
if err != nil {
|
|
return nil, c.Err(err.Error())
|
|
}
|
|
secret, err := decodeTSIGSecret(kArgs[2])
|
|
if err != nil {
|
|
return nil, c.Errf("tsig-key %q: %v", keyName, err)
|
|
}
|
|
if _, exists := p.TSIGKeys[keyName]; exists {
|
|
return nil, c.Errf("duplicate tsig-key %q", keyName)
|
|
}
|
|
p.TSIGKeys[keyName] = tsigKey{Algorithm: algo, Secret: secret}
|
|
|
|
case "ttl":
|
|
tArgs := c.RemainingArgs()
|
|
if len(tArgs) != 1 {
|
|
return nil, c.ArgErr()
|
|
}
|
|
ttl, err := strconv.ParseUint(tArgs[0], 10, 32)
|
|
if err != nil {
|
|
return nil, c.Errf("ttl must be a non-negative integer: %v", err)
|
|
}
|
|
p.TTL = uint32(ttl)
|
|
|
|
case "auto-commit":
|
|
aArgs := c.RemainingArgs()
|
|
if len(aArgs) != 1 {
|
|
return nil, c.ArgErr()
|
|
}
|
|
switch aArgs[0] {
|
|
case "true", "yes", "on":
|
|
p.AutoCommit = true
|
|
case "false", "no", "off":
|
|
p.AutoCommit = false
|
|
default:
|
|
return nil, c.Errf("auto-commit must be true|false, got %q", aArgs[0])
|
|
}
|
|
|
|
case "git-author":
|
|
gArgs := c.RemainingArgs()
|
|
if len(gArgs) != 2 {
|
|
return nil, c.Errf("git-author requires 2 args (name email), got %d", len(gArgs))
|
|
}
|
|
gitAuthorName = gArgs[0]
|
|
gitAuthorEmail = gArgs[1]
|
|
|
|
default:
|
|
return nil, c.Errf("unknown directive: %s", c.Val())
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(p.Zones) == 0 {
|
|
return nil, c.Err("at least one zone must be specified")
|
|
}
|
|
if p.ZonesDir == "" {
|
|
return nil, c.Err("zones-dir is required")
|
|
}
|
|
|
|
// Build zoneFile handles for each declared zone.
|
|
p.zones = make(map[string]*zoneFile, len(p.Zones))
|
|
for _, z := range p.Zones {
|
|
// Trailing dot → filename. supported.systems. → supported.systems.zone
|
|
stem := z
|
|
if l := len(stem); l > 0 && stem[l-1] == '.' {
|
|
stem = stem[:l-1]
|
|
}
|
|
path := filepath.Join(p.ZonesDir, stem+".zone")
|
|
zf := openZoneFile(path, z)
|
|
zf.AutoCommit = p.AutoCommit
|
|
if gitAuthorName != "" {
|
|
zf.GitAuthorName = gitAuthorName
|
|
}
|
|
if gitAuthorEmail != "" {
|
|
zf.GitAuthorEmail = gitAuthorEmail
|
|
}
|
|
p.zones[z] = zf
|
|
}
|
|
|
|
return p, nil
|
|
}
|
|
|
|
// validateZoneFiles ensures every configured zone has an accessible
|
|
// file on disk at the expected path. Catches typos at CoreDNS startup
|
|
// rather than the first UPDATE.
|
|
func (p *RFC2136) validateZoneFiles() error {
|
|
for zone, zf := range p.zones {
|
|
st, err := os.Stat(zf.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("zone %q: file not accessible at %s: %w", zone, zf.Path, err)
|
|
}
|
|
if st.IsDir() {
|
|
return fmt.Errorf("zone %q: %s is a directory, expected a regular file", zone, zf.Path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|