Widen SOA serial counter: YYMMDDNNNN, 10000 bumps/day

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.
This commit is contained in:
Ryan Malloy 2026-05-22 11:51:45 -06:00
parent 6268e6eafd
commit 8466f08780
2 changed files with 124 additions and 31 deletions

View File

@ -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,22 +219,52 @@ 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

View File

@ -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)
}
}