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
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).