From 8466f0878009ad8465b824b59d35062bfc2ddf09 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Fri, 22 May 2026 11:51:45 -0600 Subject: [PATCH] Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- zonefile.go | 78 ++++++++++++++++++++++++++++++++++++++---------- zonefile_test.go | 77 +++++++++++++++++++++++++++++++++++++---------- 2 files changed, 124 insertions(+), 31 deletions(-) diff --git a/zonefile.go b/zonefile.go index cd35aed..dca4e5e 100644 --- a/zonefile.go +++ b/zonefile.go @@ -179,12 +179,30 @@ func addRRTo(rrs []dns.RR, rr dns.RR) []dns.RR { return append(rrs, rr) } -// bumpSerial advances the SOA's serial in CalVer (YYYYMMDDNN) form. +// serialCounterMul is the multiplier between the date prefix and the +// counter in our SOA-serial encoding. The format is YYMMDD*10000 + NNNN, +// giving 10000 bumps/day (NNNN ∈ [0001, 9999]). The 2-digit year keeps +// the maximum within uint32 (the RFC 1035 ceiling for SOA serials): for +// 2026-05-22, max serial 2,605,229,999 is well below 2^32-1=4,294,967,295. +// A 4-digit year (e.g., 20260522*10000) would overflow uint32. +const serialCounterMul = 10000 + +// bumpSerial advances the SOA's serial in CalVer YYMMDD*10000+NNNN form. +// // Behaviour: -// - If today is later than the existing serial's date, jump to -// today with NN=01. -// - Otherwise (same day, or serial-date is in the future), bump NN. -// - Caps at NN=99; returns an error if exceeded. +// - If cur encodes today (or a future-encoded date from a prior NNNN +// rollover), increment NNNN. On NNNN=9999, roll forward to the next +// encoded day with NNNN=0001. The encoded date drifts ahead of wall +// time during heavy churn and catches back up on quiet days; serial +// numbers stay strictly monotonic, which is the only DNS hard +// requirement. +// - Otherwise (older serial; including legacy YYYYMMDDNN-format serials +// left over from before this format change), jump to today*10000+1. +// Legacy serials migrate automatically here: a value like 2026052299 +// (~2.026B) is numerically smaller than today's new-format minimum +// 2605220001 (~2.605B), so it falls to this branch and gets rewritten +// in-place. The new value is strictly greater, so AXFR receivers (HE +// et al.) treat it as a normal forward bump and pull cleanly. // // The SOA is found by type (there should be exactly one); mutated in // place. The returned slice is the same slice with the SOA's serial @@ -201,24 +219,54 @@ func bumpSerial(rrs []dns.RR, now time.Time) error { return fmt.Errorf("zone has no SOA record") } - today := now.UTC().Format("20060102") - cur := fmt.Sprintf("%d", soa.Serial) + today := now.UTC().Format("060102") // YYMMDD + cur := fmt.Sprintf("%010d", soa.Serial) - if len(cur) == 10 && cur[:8] == today { - nn := atoi(cur[8:10]) - if nn >= 99 { - return fmt.Errorf("serial counter exhausted for %s (NN=99)", today) + // Try the new-format read: cur[:6] is YYMMDD, cur[6:10] is NNNN. + // We only honour this when the encoded date is today or later — an + // older encoded date means a normal new-day reset (or a legacy + // serial that happens to look like a valid YYMMDD prefix but is in + // the past, which is the same handling: jump to today). + if curDate := cur[:6]; isValidYYMMDD(curDate) && curDate >= today { + nnnn := atoi(cur[6:10]) + if nnnn < 9999 { + soa.Serial = uint32(parseUint(curDate)*serialCounterMul + uint64(nnnn+1)) + return nil } - soa.Serial = uint32(parseUint(today)*100 + uint64(nn+1)) + // NNNN=9999: roll to next encoded day, NNNN=0001. + d, err := time.Parse("060102", curDate) + if err != nil { + return fmt.Errorf("serial date %q unparseable: %w", curDate, err) + } + next := d.AddDate(0, 0, 1).Format("060102") + soa.Serial = uint32(parseUint(next)*serialCounterMul + 1) return nil } - // Either the date is older, or the format isn't CalVer at all — - // either way, today + 01 is the next valid serial. - soa.Serial = uint32(parseUint(today)*100 + 1) + // Older or unparseable: jump to today*10000+1. Migration path for + // legacy YYYYMMDDNN serials lives here. + candidate := uint32(parseUint(today)*serialCounterMul + 1) + if candidate <= soa.Serial { + // Defensive: don't regress. If something has somehow + // provisioned a serial >= today's new-format candidate (e.g., + // far-future serial from a hand-edit), just +1 to advance. + soa.Serial++ + return nil + } + soa.Serial = candidate return nil } +// isValidYYMMDD reports whether s is a 6-character YYMMDD string with a +// valid month and day. Year is any 2-digit value (00-99). +func isValidYYMMDD(s string) bool { + if len(s) != 6 { + return false + } + _, err := time.Parse("060102", s) + return err == nil +} + // atoi is a tiny helper that ignores errors — only called on a // substring we already validated is two digits. func atoi(s string) int { diff --git a/zonefile_test.go b/zonefile_test.go index 22a12af..e576b30 100644 --- a/zonefile_test.go +++ b/zonefile_test.go @@ -121,57 +121,102 @@ func TestAddRRTo_Dedupes(t *testing.T) { } } -func TestBumpSerial_SameDay_NNIncrement(t *testing.T) { +// 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: 2026052105}, + Serial: 2605210105}, // today, NNNN=0105 } - // Use a 'now' that matches today's serial date. 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 != 2026052106 { - t.Errorf("Serial = %d, want 2026052106", got) + if got := rrs[0].(*dns.SOA).Serial; got != 2605210106 { + t.Errorf("Serial = %d, want 2605210106", got) } } -func TestBumpSerial_NewDay_DateAdvancesNN01(t *testing.T) { +func TestBumpSerial_NewDay_DateAdvancesNNNN0001(t *testing.T) { rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, - Serial: 2026052099}, // exhausted yesterday + 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 != 2026052101 { - t.Errorf("Serial = %d, want 2026052101 (today, NN=01)", got) + if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 { + t.Errorf("Serial = %d, want 2605210001 (today, NNNN=0001)", got) } } -func TestBumpSerial_NNExhausted_ReturnsError(t *testing.T) { +func TestBumpSerial_NNNN9999_RollsToNextEncodedDay(t *testing.T) { rrs := []dns.RR{ &dns.SOA{Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA}, - Serial: 2026052199}, // today, NN=99 + 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.Errorf("expected error on NN=99, got nil") + 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 unix-ish serial, not CalVer + 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 != 2026052101 { - t.Errorf("Serial = %d, want 2026052101", got) + if got := rrs[0].(*dns.SOA).Serial; got != 2605210001 { + t.Errorf("Serial = %d, want 2605210001", got) } }