CoreDNS plugin: accept RFC 2136 dynamic DNS updates with TSIG auth. Targets self-hosted ACME DNS-01 cert automation.
Find a file
Ryan Malloy 6ab2b6af6d H6/H7/M3/M4/M7: hardening + behavior documentation
H6 — TSIG replay-window test. New TestCheckTSIG_BadStatus_Refused
verifies that when miekg/dns reports a TSIG verification failure via
ResponseWriter.TsigStatus (the channel for fudge-window violations,
bad MACs, expired timestamps), our plugin refuses. The fudge tolerance
itself is miekg/dns's default (300s); documented in tsig.go so
operators know the dependency.

H7 — No-op UPDATE policy: documented explicitly in update.go. We do
NOT bump the SOA on a no-op (deduped) UPDATE — forcing downstream
secondaries to AXFR identical content wastes bandwidth and contradicts
RFC 2136's intent. Callers wanting to force a serial bump can send a
throwaway add+delete pair (touch-UPDATE pattern).

M3 — Delete-by-exact-match ignores TTL and class per RFC 2136 §2.5.4.
The previous rr.String() comparison included TTL, so an UPDATE with
CLASS=NONE TTL=0 (the protocol-required encoding for a delete) failed
to match stored RRs at CLASS=IN with non-zero TTL. Now we normalize
both sides (TTL=0, class=IN) before invoking dns.IsDuplicate.

M4 — validateZoneFiles now actually parses each zone at startup
(loadRRs invocation). Previously it only stat()'d the file; corrupt
zone content sailed through startup and produced SERVFAIL on the first
UPDATE with no startup-time signal. Combined with H3+H4's invariant
checks, this turns silent zone corruption into immediate startup
failure.

M7 — Commit-message sanitization. RR names are attacker-controlled
(TSIG only authenticates the sender; the payload is hostile by
default). Control characters in commit messages could inject newlines
into git log or ANSI sequences into downstream log renderers. New
sanitizeForCommitMessage escapes \n, \r, \t, and other C0 controls.

New tests:
- TestCheckTSIG_BadStatus_Refused (H6)
- TestUpdate_DeleteRR_IgnoresTTL (M3)
- TestSanitizeForCommitMessage (M7)
2026-05-22 21:29:13 -06:00
.gitignore Initial commit: plugin skeleton, compiles against CoreDNS 1.14.3 2026-05-20 18:25:36 -06:00
config.go Phase 1.2: wire parser → typed config + 13 unit tests 2026-05-21 10:31:22 -06:00
go.mod Phase 1.2: wire parser → typed config + 13 unit tests 2026-05-21 10:31:22 -06:00
go.sum Initial commit: plugin skeleton, compiles against CoreDNS 1.14.3 2026-05-20 18:25:36 -06:00
plugin.go C1/C2/M9: tighten security boundary at handleUpdate 2026-05-22 21:18:47 -06:00
README.md Phase 1.2: wire parser → typed config + 13 unit tests 2026-05-21 10:31:22 -06:00
setup.go H6/H7/M3/M4/M7: hardening + behavior documentation 2026-05-22 21:29:13 -06:00
setup_test.go Phase 2b: refactor to file-backed storage; UPDATE writes zones/*.zone 2026-05-21 11:26:50 -06:00
tsig.go H6/H7/M3/M4/M7: hardening + behavior documentation 2026-05-22 21:29:13 -06:00
update.go H6/H7/M3/M4/M7: hardening + behavior documentation 2026-05-22 21:29:13 -06:00
update_test.go H6/H7/M3/M4/M7: hardening + behavior documentation 2026-05-22 21:29:13 -06:00
zonefile.go H6/H7/M3/M4/M7: hardening + behavior documentation 2026-05-22 21:29:13 -06:00
zonefile_test.go H3/H4/H5: zone-integrity invariants 2026-05-22 21:25:35 -06:00

coredns-rfc2136

A CoreDNS plugin that accepts RFC 2136 dynamic DNS updates (TSIG-authenticated), filling a gap in the official plugin set.

CoreDNS as-shipped has no plugin for accepting dynamic updates — its plugin model treats authoritative data as read-only (loaded from auto, file, secondary, etc.). This plugin adds the missing piece.

Primary use case: self-hosted ACME DNS-01

The motivating problem: automate Let's Encrypt cert issuance for many domains without depending on registrar APIs (Vultr/Route53/Cloudflare). The architecture:

_acme-challenge.example.com  CNAME  <uuid>.auth.supported.systems
                                      │
                                      │ delegated NS to your CoreDNS host
                                      ▼
                              CoreDNS + rfc2136 plugin
                                      │
                                      │ accepts TSIG UPDATEs from Caddy
                                      │ (caddy-dns/rfc2136) or any other
                                      │ ACME client
                                      ▼
                                  Let's Encrypt validates

One-time per protected domain: add a CNAME glue line in your static zones. After that, all cert issuance + renewal happens via UPDATE messages — zero static zone-file churn.

Status

Phase 1 (skeleton): compiles, registers with CoreDNS, parses the Corefile directive. Does not yet handle UPDATE messages or serve any records. ServeDNS is a pass-through. See phases.md for the roadmap.

Configuration

rfc2136 <zone> [<zone>...] {
    tsig-key <key-name> <algorithm> <base64-secret>
    ttl <seconds>
    persist <path>
}

Example:

.:53 auth.example.com {
    rfc2136 auth.example.com {
        tsig-key acme-key. hmac-sha256 BASE64SECRET==
        ttl 60
    }
    errors
    log
}

Building

This plugin is consumed by a custom CoreDNS build via plugin.cfg:

# In CoreDNS source's plugin.cfg, BEFORE the `cache` plugin:
rfc2136:git.supported.systems/rsp2k/coredns-rfc2136

Then go get git.supported.systems/rsp2k/coredns-rfc2136 && make.

License

MIT (TODO: add LICENSE file).