Phase 1.2: wire parser → typed config + 13 unit tests

The Corefile parser now fully populates typed fields on RFC2136 instead
of just recognising directives. Validation happens at parse-time so
configuration errors fail loud at CoreDNS startup rather than silent at
request time.

Added:
- config.go: tsigKey type, TSIG algorithm allowlist (rejects HMAC-MD5
  deliberately), base64 secret decoder with 8-byte minimum length check,
  canonical-key-name normalisation (lowercase + trailing dot).
- plugin.go: RFC2136 struct now carries TSIGKeys map, TTL uint32,
  PersistPath string. DefaultTTL=60.
- setup.go: parse() validates and stores tsig-key/ttl/persist directives.
  Duplicate key names rejected. Multiple TSIG keys allowed (for rotation).
  At-least-one-zone is enforced.
- setup_test.go: 13 table-driven cases (5 happy + 8 error paths) using
  caddy.NewTestController. All pass.

ServeDNS still passes through — UPDATE handling lands in Phase 1.4.

Module path: git.supported.systems/rsp2k/coredns-rfc2136
This commit is contained in:
Ryan Malloy 2026-05-21 10:31:22 -06:00
parent e9d37f483c
commit eba6313ec0
6 changed files with 364 additions and 29 deletions

View File

@ -66,10 +66,10 @@ This plugin is consumed by a custom CoreDNS build via `plugin.cfg`:
```
# In CoreDNS source's plugin.cfg, BEFORE the `cache` plugin:
rfc2136:git.supportedsystems.net/rpm/coredns-rfc2136
rfc2136:git.supported.systems/rsp2k/coredns-rfc2136
```
Then `go get git.supportedsystems.net/rpm/coredns-rfc2136 && make`.
Then `go get git.supported.systems/rsp2k/coredns-rfc2136 && make`.
## License

78
config.go Normal file
View File

@ -0,0 +1,78 @@
package rfc2136
import (
"encoding/base64"
"fmt"
"strings"
"github.com/miekg/dns"
)
// tsigKey is a single pre-shared TSIG credential. The plugin holds one
// per declared `tsig-key` directive, keyed in RFC2136.TSIGKeys by the
// canonical (lowercased, trailing-dot) key name.
//
// Phase 1.2 stores the algorithm and decoded secret. Phase 1.4 will
// use them for actual signature verification via dns.TsigSecret +
// dns.Msg.IsTsig() / dns.TsigVerify().
type tsigKey struct {
// Algorithm is the canonical miekg/dns algorithm identifier, e.g.
// dns.HmacSHA256 ("hmac-sha256."). Stored in dns-library form so
// it's directly usable when wiring TSIG verification later.
Algorithm string
// Secret is the base64-decoded raw bytes of the shared secret.
// Decoding at parse time means a malformed secret fails Corefile
// load (loud), not mid-query (silent).
Secret []byte
}
// supportedTSIGAlgorithms maps user-facing algorithm names (as written
// in Corefile) to the canonical names recognised by miekg/dns. Only
// SHA-family entries are included — HMAC-MD5 is deprecated and not
// supported here on principle.
var supportedTSIGAlgorithms = map[string]string{
"hmac-sha1": dns.HmacSHA1,
"hmac-sha224": dns.HmacSHA224,
"hmac-sha256": dns.HmacSHA256,
"hmac-sha384": dns.HmacSHA384,
"hmac-sha512": dns.HmacSHA512,
}
// parseTSIGAlgorithm validates and canonicalises the algorithm name
// from a `tsig-key` directive. Returns the miekg/dns canonical form
// (which has a trailing dot per DNS-name convention).
func parseTSIGAlgorithm(s string) (string, error) {
canon, ok := supportedTSIGAlgorithms[strings.ToLower(s)]
if !ok {
return "", fmt.Errorf("unsupported TSIG algorithm %q (supported: hmac-sha1, hmac-sha224, hmac-sha256, hmac-sha384, hmac-sha512)", s)
}
return canon, nil
}
// decodeTSIGSecret validates the base64-encoded TSIG secret from the
// Corefile and returns the raw bytes. Rejects empty secrets and any
// secret shorter than 8 bytes (well under the HMAC algorithm's block
// size for any supported algorithm — anything that short is almost
// certainly a typo, not deliberate).
func decodeTSIGSecret(s string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("invalid base64 in TSIG secret: %w", err)
}
if len(raw) < 8 {
return nil, fmt.Errorf("TSIG secret too short (%d bytes after base64-decode; need >= 8)", len(raw))
}
return raw, nil
}
// canonicalKeyName normalises a TSIG key name to lowercase + trailing
// dot. miekg/dns expects the dot-terminated FQDN form internally, so
// we coerce here once at parse-time rather than every lookup.
func canonicalKeyName(name string) string {
name = strings.ToLower(name)
if !strings.HasSuffix(name, ".") {
name += "."
}
return name
}

2
go.mod
View File

@ -1,4 +1,4 @@
module git.supportedsystems.net/rpm/coredns-rfc2136
module git.supported.systems/rsp2k/coredns-rfc2136
go 1.25.0

View File

@ -6,7 +6,7 @@
//
// Scope:
// - Handles UPDATE messages (OPCODE=5) for configured zones.
// - Verifies TSIG signatures (HMAC-SHA256 today; algorithm-pluggable).
// - Verifies TSIG signatures (HMAC-SHA family; algorithm-pluggable).
// - Stores records in memory; optional periodic snapshot to disk.
// - Serves queries (SOA, NS, A, AAAA, TXT) for the configured zone
// from the in-memory store plus a synthetic SOA/NS apex.
@ -15,10 +15,11 @@
// - General-purpose authoritative DNS (use `auto`/`file` for that).
// - DNSSEC signing (add later via the `dnssec` plugin in front).
//
// Phase 1 status: skeleton only. ServeDNS passes through to the next
// plugin; setup parses the Corefile but does not yet do anything with
// the parsed configuration. See plan at
// ~/.claude/plans/dood-does-coredns-offer-enumerated-piglet.md
// Phase 1.2 status: parser fully wires Corefile into typed config.
// ServeDNS still passes through to the next plugin — UPDATE handling
// and zone-serving land in Phase 1.3/1.4. See plan at
//
// ~/.claude/plans/dood-does-coredns-offer-enumerated-piglet.md
package rfc2136
import (
@ -28,6 +29,12 @@ import (
"github.com/miekg/dns"
)
// DefaultTTL is the TTL applied to dynamically-added records when the
// Corefile doesn't specify one. 60s matches the short-lived nature of
// ACME challenge TXT 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.
@ -38,18 +45,28 @@ type RFC2136 struct {
// zones pass through to Next.
Zones []string
// Phase 2 will add:
// - tsigKeys map[string]tsigKey
// - store *recordStore
// - ttl uint32
// TSIGKeys is keyed by canonical key name (lowercased, trailing
// dot). Empty means TSIG is disabled — UPDATEs without TSIG would
// be rejected unconditionally in Phase 1.4.
TSIGKeys map[string]tsigKey
// TTL is applied to dynamically-injected records that don't carry
// an explicit TTL in the UPDATE message.
TTL uint32
// PersistPath, when non-empty, names a file the plugin writes a
// JSON snapshot of its in-memory store to on a periodic schedule.
// Empty means in-memory only (acceptable for ACME challenges,
// which are seconds-to-minutes lived and re-issued on restart).
PersistPath string
}
// Name implements plugin.Handler.
func (p *RFC2136) Name() string { return "rfc2136" }
// ServeDNS implements plugin.Handler. Phase 1 is a pass-through so the
// plugin can register, parse config, and live in the chain without
// changing behavior. Phase 2 wires the UPDATE handler and zone serving.
// ServeDNS implements plugin.Handler. Phase 1.x is a pass-through so
// the plugin can register, parse config, and live in the chain without
// changing behavior. Phase 1.3 wires UPDATE handling + query serving.
func (p *RFC2136) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
return plugin.NextOrFailure(p.Name(), p.Next, ctx, w, r)
}

View File

@ -1,6 +1,8 @@
package rfc2136
import (
"strconv"
"github.com/coredns/caddy"
"github.com/coredns/coredns/core/dnsserver"
"github.com/coredns/coredns/plugin"
@ -28,22 +30,28 @@ func setup(c *caddy.Controller) error {
return p
})
log.Infof("registered for zones: %v", p.Zones)
log.Infof("registered for zones=%v keys=%d ttl=%d persist=%q",
p.Zones, len(p.TSIGKeys), p.TTL, p.PersistPath)
return nil
}
// parse reads a single `rfc2136 <zone> { ... }` block from the Corefile.
// parse reads a single `rfc2136 <zone> [<zone>...] { ... }` block from
// the Corefile and returns a fully-populated RFC2136 handler with all
// values validated at parse time (so configuration errors fail fast at
// CoreDNS startup, not later mid-request).
//
// Phase 1 grammar (only the surface is parsed; sub-directives are
// accepted but ignored — Phase 2 wires them):
// Grammar:
//
// rfc2136 <zone> {
// tsig-key <name> <algorithm> <secret>
// ttl <seconds>
// persist <path>
// rfc2136 <zone> [<zone>...] {
// tsig-key <name> <algorithm> <base64-secret> ; may repeat
// ttl <seconds> ; default 60
// persist <path> ; default off (in-memory only)
// }
func parse(c *caddy.Controller) (*RFC2136, error) {
p := &RFC2136{}
p := &RFC2136{
TSIGKeys: make(map[string]tsigKey),
TTL: DefaultTTL,
}
for c.Next() {
args := c.RemainingArgs()
@ -59,28 +67,47 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
for c.NextBlock() {
switch c.Val() {
case "tsig-key":
// tsig-key <name> <algorithm> <base64-secret>
kArgs := c.RemainingArgs()
if len(kArgs) != 3 {
return nil, c.Errf("tsig-key requires 3 args (name algorithm secret), got %d", len(kArgs))
}
// Phase 2: store in p.tsigKeys[name] = tsigKey{algo, secret}
log.Debugf("tsig-key parsed (storage NYI): name=%s alg=%s", kArgs[0], kArgs[1])
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()
}
// Phase 2: parse uint32, validate range, store in p.ttl
log.Debugf("ttl parsed (storage NYI): %s", tArgs[0])
ttl, err := strconv.ParseUint(tArgs[0], 10, 32)
if err != nil {
return nil, c.Errf("ttl must be a non-negative integer: %v", err)
}
// Anything over a week is almost certainly a mistake
// for ACME challenge records, but allow up to the
// uint32 max so we don't ship an arbitrary cap.
p.TTL = uint32(ttl)
case "persist":
pArgs := c.RemainingArgs()
if len(pArgs) != 1 {
return nil, c.ArgErr()
}
log.Debugf("persist parsed (storage NYI): %s", pArgs[0])
p.PersistPath = pArgs[0]
default:
return nil, c.Errf("unknown directive: %s", c.Val())
@ -88,5 +115,9 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
}
}
if len(p.Zones) == 0 {
return nil, c.Err("at least one zone must be specified")
}
return p, nil
}

209
setup_test.go Normal file
View File

@ -0,0 +1,209 @@
package rfc2136
import (
"strings"
"testing"
"github.com/coredns/caddy"
"github.com/miekg/dns"
)
// A 32-byte HMAC-SHA256 secret, base64-encoded. Generated once and
// re-used across tests so failures are reproducible. NEVER use this
// value in production — it's literally in this file in plaintext.
const testSecret = "xTgset4zj7kHqniSslYFn+OcdCf419olek9MNmOvlUM="
// TestParse exercises the Corefile parser across the matrix of valid
// and invalid configurations. Each row is fully self-contained.
func TestParse(t *testing.T) {
tests := []struct {
name string
input string
shouldErr bool
errMatch string // substring to look for in the error (when shouldErr)
check func(t *testing.T, p *RFC2136)
}{
{
name: "single zone, no block",
input: `rfc2136 auth.example.com.`,
check: func(t *testing.T, p *RFC2136) {
wantZone(t, p, "auth.example.com.")
if p.TTL != DefaultTTL {
t.Errorf("TTL = %d, want default %d", p.TTL, DefaultTTL)
}
if len(p.TSIGKeys) != 0 {
t.Errorf("expected 0 TSIG keys, got %d", len(p.TSIGKeys))
}
},
},
{
name: "multiple zones in one directive",
input: `rfc2136 auth.example.com. acme.example.org.`,
check: func(t *testing.T, p *RFC2136) {
if len(p.Zones) != 2 {
t.Fatalf("Zones = %v, want 2", p.Zones)
}
wantZone(t, p, "auth.example.com.")
wantZone(t, p, "acme.example.org.")
},
},
{
name: "full block with all directives",
input: `rfc2136 auth.example.com. {
tsig-key acme-key. hmac-sha256 ` + testSecret + `
ttl 120
persist /var/lib/coredns/rfc2136/auth.db
}`,
check: func(t *testing.T, p *RFC2136) {
wantZone(t, p, "auth.example.com.")
if p.TTL != 120 {
t.Errorf("TTL = %d, want 120", p.TTL)
}
if p.PersistPath != "/var/lib/coredns/rfc2136/auth.db" {
t.Errorf("PersistPath = %q", p.PersistPath)
}
k, ok := p.TSIGKeys["acme-key."]
if !ok {
t.Fatalf("acme-key. not in TSIGKeys; have keys=%v", keysOf(p.TSIGKeys))
}
if k.Algorithm != dns.HmacSHA256 {
t.Errorf("Algorithm = %q, want %q", k.Algorithm, dns.HmacSHA256)
}
if len(k.Secret) != 32 {
t.Errorf("Secret length = %d, want 32 (decoded SHA-256-sized)", len(k.Secret))
}
},
},
{
name: "tsig-key name normalised to trailing-dot lowercase",
input: `rfc2136 auth.example.com. {
tsig-key Acme-Key hmac-sha256 ` + testSecret + `
}`,
check: func(t *testing.T, p *RFC2136) {
if _, ok := p.TSIGKeys["acme-key."]; !ok {
t.Errorf("expected canonicalised key 'acme-key.', got keys=%v", keysOf(p.TSIGKeys))
}
},
},
{
name: "multiple tsig-keys allowed",
input: `rfc2136 auth.example.com. {
tsig-key key-a. hmac-sha256 ` + testSecret + `
tsig-key key-b. hmac-sha512 ` + testSecret + `
}`,
check: func(t *testing.T, p *RFC2136) {
if len(p.TSIGKeys) != 2 {
t.Errorf("expected 2 keys, got %d (%v)", len(p.TSIGKeys), keysOf(p.TSIGKeys))
}
if p.TSIGKeys["key-a."].Algorithm != dns.HmacSHA256 {
t.Errorf("key-a algorithm wrong")
}
if p.TSIGKeys["key-b."].Algorithm != dns.HmacSHA512 {
t.Errorf("key-b algorithm wrong")
}
},
},
// ─── error cases ──────────────────────────────────────────
{
name: "no zone",
input: `rfc2136`,
shouldErr: true,
errMatch: "Wrong argument count",
},
{
name: "unknown directive",
input: `rfc2136 auth.example.com. { bogus value }`,
shouldErr: true,
errMatch: "unknown directive",
},
{
name: "tsig-key with too few args",
input: `rfc2136 auth.example.com. {
tsig-key only-name
}`,
shouldErr: true,
errMatch: "tsig-key requires 3 args",
},
{
name: "unsupported TSIG algorithm",
input: `rfc2136 auth.example.com. {
tsig-key key. hmac-md5 ` + testSecret + `
}`,
shouldErr: true,
errMatch: "unsupported TSIG algorithm",
},
{
name: "malformed base64 secret",
input: `rfc2136 auth.example.com. {
tsig-key key. hmac-sha256 not_base64_at_all!!!
}`,
shouldErr: true,
errMatch: "invalid base64",
},
{
name: "secret too short after decode",
input: `rfc2136 auth.example.com. {
tsig-key key. hmac-sha256 c2hvcnQ=
}`, // "short" → 5 bytes < 8 min
shouldErr: true,
errMatch: "too short",
},
{
name: "duplicate tsig-key name",
input: `rfc2136 auth.example.com. {
tsig-key dup. hmac-sha256 ` + testSecret + `
tsig-key dup. hmac-sha512 ` + testSecret + `
}`,
shouldErr: true,
errMatch: "duplicate tsig-key",
},
{
name: "ttl non-numeric",
input: `rfc2136 auth.example.com. {
ttl not-a-number
}`,
shouldErr: true,
errMatch: "ttl must be a non-negative integer",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := caddy.NewTestController("dns", tt.input)
p, err := parse(c)
if (err != nil) != tt.shouldErr {
t.Fatalf("parse() err = %v, shouldErr = %v", err, tt.shouldErr)
}
if err != nil {
if tt.errMatch != "" && !strings.Contains(err.Error(), tt.errMatch) {
t.Errorf("err = %q, expected substring %q", err.Error(), tt.errMatch)
}
return
}
if tt.check != nil {
tt.check(t, p)
}
})
}
}
// wantZone asserts that p.Zones contains the expected canonical zone.
func wantZone(t *testing.T, p *RFC2136, want string) {
t.Helper()
for _, z := range p.Zones {
if z == want {
return
}
}
t.Errorf("zone %q not in p.Zones=%v", want, p.Zones)
}
// keysOf is a debug helper for failure messages.
func keysOf(m map[string]tsigKey) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}