CoreDNS plugin: accept RFC 2136 dynamic DNS updates with TSIG auth. Targets self-hosted ACME DNS-01 cert automation.
Find a file
Ryan Malloy d9dad01798 H3/H4/H5: zone-integrity invariants
H3+H4 — Zone SOA invariant. After parsing, loadRRs enforces:
exactly one SOA, owned by the zone apex. Catches three failure modes
with a single guard:

  - Missing SOA (H4): a malformed line earlier in the file may have
    tripped miekg/dns's ZoneParser into dropping records without
    reporting an error via parser.Err(). If the SOA went missing, we
    refuse rather than treat the partial parse as authoritative.

  - Multiple SOAs (H3): zone files with accidental duplicate SOA
    records produce inconsistent zone state visible to AXFR clients.
    The old code's first-match SOA-bump would silently propagate the
    inconsistency. Now we refuse.

  - Non-apex SOA (H3): an SOA whose owner doesn't match the zone
    origin is either a parse error or a hand-edit mistake; bumping
    it would leave the real apex unchanged. Now we refuse.

assertSingleApexSOA returns a descriptive error so the failure mode
is actionable from logs alone.

H5 — MaxUint32 guard in bumpSerial. The old "+1 defensive advance"
branch would wrap to 0 if soa.Serial == MaxUint32, and downstream
secondaries per RFC 1982 §3.2 treat 0-after-MaxUint32 as "older"
(they refuse to AXFR and the zone goes dark). Now we explicitly check
and refuse with a loud message; operator must reset the serial
manually. Practical reach is zero for our deployment (10000 bumps/day
× 117 years would still fit uint32) but the defensive ceiling matters
for fuzz, hand-edit, or future code-path errors.

The full RFC 1982 wraparound-aware comparison was prototyped but
removed: it broke the legacy-format migration case where a tiny
non-CalVer serial (e.g., 12345) is "more than 2^31 distant" from a
new-format serial (~2.6B), which RFC 1982 reads as "going backwards"
and would block migration. Naive `>` is correct in practice; the
MaxUint32 case is the only real failure mode worth guarding.

New tests:
- TestBumpSerial_MaxUint32_RefusesWrap
- TestLoadRRs_NoSOA_Refused
- TestLoadRRs_MultipleSOAs_Refused
- TestLoadRRs_NonApexSOA_Refused
2026-05-22 21:25:35 -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 C1/C2/M9: tighten security boundary at handleUpdate 2026-05-22 21:18:47 -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 Sign responses to TSIG-signed UPDATEs (RFC 8945 §5.4.2) 2026-05-22 09:24:12 -06:00
update.go H1/H2/M1: atomicity at the file boundary 2026-05-22 21:22:11 -06:00
update_test.go H1/H2/M1: atomicity at the file boundary 2026-05-22 21:22:11 -06:00
zonefile.go H3/H4/H5: zone-integrity invariants 2026-05-22 21:25:35 -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).