The previous YYYYMMDDNN encoding capped at NN=99 (100 bumps/day) and hard-failed UPDATEs once the day's counter was exhausted — confirmed in production on 2026-05-22 when ACME activity across the supported. systems zone hit the cap and SERVFAILed every subsequent UPDATE. New format: YYMMDD*10000+NNNN. With 4-digit NNNN we get 10000/day, and dropping the century keeps a 2026-dated serial (2,605,229,999 max) under uint32's 4,294,967,295 ceiling. A 4-digit year (e.g., 20260522*10000) would overflow uint32 — RFC 1035's SOA serial type bounds this. Three behavior changes: 1. On NNNN=9999, roll forward to the next encoded day with NNNN=0001 rather than erroring. The encoded date drifts ahead of wall time on heavy churn days and catches up on quiet days; monotonic ordering (the only DNS requirement) holds. 2. Future-encoded serials (from a prior rollover) are honoured — the previous "older date" branch downgraded them back to today*100+1, producing a backwards serial. This bug also tripped a manual workaround on the same day. Now: future encoded dates bump their own NNNN. 3. Legacy YYYYMMDDNN serials migrate automatically on first bump. A value like 2026052299 (~2.026B) is numerically smaller than today's new-format minimum 2605220001 (~2.605B), so the older-or-unparseable branch fires and rewrites in place. New > old, so AXFR receivers treat it as a clean forward bump. Tests cover same-day, rollover, future-encoded no-regress, legacy migration, non-CalVer reset, and no-SOA error.
307 lines
9.2 KiB
Go
307 lines
9.2 KiB
Go
package rfc2136
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// mustRR is a test helper that parses an RR string or fails the test.
|
|
// Used widely in zonefile + update tests.
|
|
func mustRR(t *testing.T, s string) dns.RR {
|
|
t.Helper()
|
|
rr, err := dns.NewRR(s)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse RR %q: %v", s, err)
|
|
}
|
|
return rr
|
|
}
|
|
|
|
func TestZoneFile_LoadRRs(t *testing.T) {
|
|
dir := withTempZonesDir(t, "auth.example.com")
|
|
zf := openZoneFile(filepath.Join(dir, "auth.example.com.zone"), "auth.example.com.")
|
|
|
|
rrs, err := zf.loadRRs()
|
|
if err != nil {
|
|
t.Fatalf("loadRRs: %v", err)
|
|
}
|
|
if len(rrs) < 2 {
|
|
t.Fatalf("expected >=2 RRs (SOA + NS), got %d", len(rrs))
|
|
}
|
|
// First RR should be the SOA.
|
|
if _, ok := rrs[0].(*dns.SOA); !ok {
|
|
t.Errorf("first RR is %T, want SOA", rrs[0])
|
|
}
|
|
}
|
|
|
|
func TestZoneFile_LoadRRs_MissingFile(t *testing.T) {
|
|
zf := openZoneFile("/nope/missing.zone", "missing.")
|
|
if _, err := zf.loadRRs(); err == nil {
|
|
t.Errorf("expected error loading missing file, got nil")
|
|
}
|
|
}
|
|
|
|
func TestLookupIn_CaseInsensitive(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
mustRR(t, `FOO.example.com. 60 IN TXT "hello"`),
|
|
}
|
|
if got := lookupIn(rrs, "foo.EXAMPLE.com.", dns.TypeTXT); len(got) != 1 {
|
|
t.Errorf("case-insensitive lookup failed: %d results", len(got))
|
|
}
|
|
}
|
|
|
|
func TestLookupIn_WrongTypeReturnsEmpty(t *testing.T) {
|
|
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)}
|
|
if got := lookupIn(rrs, "foo.example.com.", dns.TypeTXT); len(got) != 0 {
|
|
t.Errorf("wrong-type lookup returned %d results, want 0", len(got))
|
|
}
|
|
}
|
|
|
|
func TestNameExistsIn(t *testing.T) {
|
|
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)}
|
|
if !nameExistsIn(rrs, "foo.example.com.") {
|
|
t.Errorf("nameExistsIn returned false for present name")
|
|
}
|
|
if nameExistsIn(rrs, "bar.example.com.") {
|
|
t.Errorf("nameExistsIn returned true for absent name")
|
|
}
|
|
}
|
|
|
|
func TestRemoveRRsetFrom(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
mustRR(t, `foo.example.com. 60 IN TXT "a"`),
|
|
mustRR(t, `foo.example.com. 60 IN TXT "b"`),
|
|
mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`),
|
|
mustRR(t, `bar.example.com. 60 IN A 192.0.2.2`),
|
|
}
|
|
out := removeRRsetFrom(rrs, "foo.example.com.", dns.TypeTXT)
|
|
if len(out) != 2 {
|
|
t.Errorf("after removing TXT rrset, got %d records, want 2", len(out))
|
|
}
|
|
// The remaining records: foo's A + bar's A
|
|
for _, rr := range out {
|
|
if rr.Header().Rrtype == dns.TypeTXT {
|
|
t.Errorf("a TXT slipped through removal: %s", rr.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoveRRFrom_ExactMatchOnly(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
mustRR(t, `foo.example.com. 60 IN TXT "keep"`),
|
|
mustRR(t, `foo.example.com. 60 IN TXT "drop"`),
|
|
}
|
|
out := removeRRFrom(rrs, mustRR(t, `foo.example.com. 60 IN TXT "drop"`))
|
|
if len(out) != 1 || out[0].(*dns.TXT).Txt[0] != "keep" {
|
|
t.Errorf("removeRRFrom wrong result: %v", out)
|
|
}
|
|
}
|
|
|
|
func TestRemoveNameFrom(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
mustRR(t, `foo.example.com. 60 IN TXT "x"`),
|
|
mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`),
|
|
mustRR(t, `bar.example.com. 60 IN A 192.0.2.2`),
|
|
}
|
|
out := removeNameFrom(rrs, "foo.example.com.")
|
|
if len(out) != 1 || out[0].Header().Name != "bar.example.com." {
|
|
t.Errorf("removeNameFrom left %v, want only bar", out)
|
|
}
|
|
}
|
|
|
|
func TestAddRRTo_Dedupes(t *testing.T) {
|
|
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN TXT "same"`)}
|
|
out := addRRTo(rrs, mustRR(t, `foo.example.com. 60 IN TXT "same"`))
|
|
if len(out) != 1 {
|
|
t.Errorf("identical RR was duplicated: %v", out)
|
|
}
|
|
}
|
|
|
|
// bumpSerial tests below pin the YYMMDD*10000+NNNN encoding. For
|
|
// 2026-05-21 (used as `now` in most cases): YYMMDD=260521, baseline
|
|
// "today, NNNN=0001" = 2,605,210,001.
|
|
|
|
func TestBumpSerial_SameDay_NNNNIncrement(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
|
|
Serial: 2605210105}, // today, NNNN=0105
|
|
}
|
|
now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
|
|
if err := bumpSerial(rrs, now); err != nil {
|
|
t.Fatalf("bumpSerial: %v", err)
|
|
}
|
|
if got := rrs[0].(*dns.SOA).Serial; got != 2605210106 {
|
|
t.Errorf("Serial = %d, want 2605210106", got)
|
|
}
|
|
}
|
|
|
|
func TestBumpSerial_NewDay_DateAdvancesNNNN0001(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
|
|
Serial: 2605209999}, // exhausted yesterday (in new format)
|
|
}
|
|
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
|
|
if err := bumpSerial(rrs, now); err != nil {
|
|
t.Fatalf("bumpSerial: %v", err)
|
|
}
|
|
if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 {
|
|
t.Errorf("Serial = %d, want 2605210001 (today, NNNN=0001)", got)
|
|
}
|
|
}
|
|
|
|
func TestBumpSerial_NNNN9999_RollsToNextEncodedDay(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
|
|
Serial: 2605219999}, // today, NNNN=9999 (the burst-day overflow case)
|
|
}
|
|
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
|
|
if err := bumpSerial(rrs, now); err != nil {
|
|
t.Fatalf("bumpSerial: %v", err)
|
|
}
|
|
if got := rrs[0].(*dns.SOA).Serial; got != 2605220001 {
|
|
t.Errorf("Serial = %d, want 2605220001 (next encoded day, NNNN=0001)", got)
|
|
}
|
|
}
|
|
|
|
func TestBumpSerial_FutureEncodedDate_DoesNotRegress(t *testing.T) {
|
|
// Reproduces the bug from 2026-05-22: an earlier bumpSerial would
|
|
// downgrade a future-encoded serial back to today's NNNN=0001. Now,
|
|
// future-encoded serials are honoured: their NNNN increments.
|
|
rrs := []dns.RR{
|
|
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
|
|
Serial: 2605220500}, // tomorrow (encoded), NNNN=0500
|
|
}
|
|
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
|
|
if err := bumpSerial(rrs, now); err != nil {
|
|
t.Fatalf("bumpSerial: %v", err)
|
|
}
|
|
if got := rrs[0].(*dns.SOA).Serial; got != 2605220501 {
|
|
t.Errorf("Serial = %d, want 2605220501 (future date NNNN+1, no regression)", got)
|
|
}
|
|
}
|
|
|
|
func TestBumpSerial_LegacyYYYYMMDDNNFormat_MigratesForward(t *testing.T) {
|
|
// The migration path: any production zone whose SOA serial is in the
|
|
// old 10-digit YYYYMMDDNN format (e.g., 2026052299) gets numerically
|
|
// crushed by today's YYMMDDNNNN minimum, so the "older/unparseable"
|
|
// branch fires and rewrites in place.
|
|
rrs := []dns.RR{
|
|
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
|
|
Serial: 2026052299}, // legacy YYYYMMDDNN, exhausted day
|
|
}
|
|
now := time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC) // 2026-05-22
|
|
if err := bumpSerial(rrs, now); err != nil {
|
|
t.Fatalf("bumpSerial: %v", err)
|
|
}
|
|
got := rrs[0].(*dns.SOA).Serial
|
|
if got != 2605220001 {
|
|
t.Errorf("Serial = %d, want 2605220001 (migrated to new format)", got)
|
|
}
|
|
if got <= 2026052299 {
|
|
t.Errorf("migration regressed serial: %d <= legacy %d", got, 2026052299)
|
|
}
|
|
}
|
|
|
|
func TestBumpSerial_NonCalVerFormat_ResetsToToday(t *testing.T) {
|
|
rrs := []dns.RR{
|
|
&dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA},
|
|
Serial: 12345}, // random small value, not CalVer
|
|
}
|
|
now := time.Date(2026, 5, 21, 1, 0, 0, 0, time.UTC)
|
|
if err := bumpSerial(rrs, now); err != nil {
|
|
t.Fatalf("bumpSerial: %v", err)
|
|
}
|
|
if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 {
|
|
t.Errorf("Serial = %d, want 2605210001", got)
|
|
}
|
|
}
|
|
|
|
func TestBumpSerial_NoSOA_ReturnsError(t *testing.T) {
|
|
rrs := []dns.RR{mustRR(t, `foo.example.com. 60 IN A 192.0.2.1`)}
|
|
now := time.Now()
|
|
if err := bumpSerial(rrs, now); err == nil {
|
|
t.Errorf("expected error when no SOA in zone, got nil")
|
|
}
|
|
}
|
|
|
|
func TestZoneFile_WriteAtomic_RoundTrip(t *testing.T) {
|
|
dir := withTempZonesDir(t, "auth.example.com")
|
|
path := filepath.Join(dir, "auth.example.com.zone")
|
|
zf := openZoneFile(path, "auth.example.com.")
|
|
zf.AutoCommit = false // not testing commit here
|
|
|
|
rrs, err := zf.loadRRs()
|
|
if err != nil {
|
|
t.Fatalf("initial load: %v", err)
|
|
}
|
|
originalCount := len(rrs)
|
|
|
|
// Add a record + write.
|
|
rrs = addRRTo(rrs, mustRR(t, `new.auth.example.com. 60 IN TXT "added"`))
|
|
if err := zf.writeAtomic(rrs, time.Now()); err != nil {
|
|
t.Fatalf("writeAtomic: %v", err)
|
|
}
|
|
|
|
// Re-load and verify.
|
|
after, err := zf.loadRRs()
|
|
if err != nil {
|
|
t.Fatalf("re-load: %v", err)
|
|
}
|
|
if len(after) != originalCount+1 {
|
|
t.Errorf("RR count = %d, want %d", len(after), originalCount+1)
|
|
}
|
|
found := false
|
|
for _, rr := range after {
|
|
if txt, ok := rr.(*dns.TXT); ok && txt.Hdr.Name == "new.auth.example.com." {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("added TXT not in reloaded zone")
|
|
}
|
|
}
|
|
|
|
func TestZoneFile_WriteAtomic_LeavesNoTempFile(t *testing.T) {
|
|
dir := withTempZonesDir(t, "auth.example.com")
|
|
path := filepath.Join(dir, "auth.example.com.zone")
|
|
zf := openZoneFile(path, "auth.example.com.")
|
|
zf.AutoCommit = false
|
|
|
|
rrs, _ := zf.loadRRs()
|
|
_ = zf.writeAtomic(rrs, time.Now())
|
|
|
|
// No .rfc2136-*.zone temp files should remain.
|
|
matches, _ := filepath.Glob(filepath.Join(dir, ".rfc2136-*.zone"))
|
|
if len(matches) != 0 {
|
|
t.Errorf("temp files leaked: %v", matches)
|
|
}
|
|
}
|
|
|
|
func TestZoneFile_WriteAtomic_FileEndsWithNewline(t *testing.T) {
|
|
dir := withTempZonesDir(t, "auth.example.com")
|
|
path := filepath.Join(dir, "auth.example.com.zone")
|
|
zf := openZoneFile(path, "auth.example.com.")
|
|
zf.AutoCommit = false
|
|
|
|
rrs, _ := zf.loadRRs()
|
|
_ = zf.writeAtomic(rrs, time.Now())
|
|
|
|
data, _ := os.ReadFile(path)
|
|
if !strings.HasSuffix(string(data), "\n") {
|
|
t.Errorf("written file should end with newline; tail: %q",
|
|
data[max(0, len(data)-20):])
|
|
}
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|