M8: per-key UPDATE rate limiting (token bucket)

Hamilton M8: a compromised TSIG key — or a misconfigured client
retrying forever — must not be able to drive unbounded UPDATE traffic.
Each UPDATE costs disk IOPS, a git commit, and a slot in the SOA
serial counter (now 9999/day per zone). Without a cap, a few hours of
runaway traffic could exhaust the SOA serial counter and brick the
zone for the day.

Implementation: per-key token bucket in ratelimit.go. Default 100
tokens / 60 seconds. New keys start full so legitimate clients see no
delay at boot. Refill is continuous, capped at the burst value.

Configurable in Corefile:
  rate-limit off                    # disable entirely
  rate-limit <burst> <period-secs>  # e.g., rate-limit 200 60

Enforcement runs in ServeDNS after TSIG verification — a request that
fails auth doesn't consume a token (and a forged TSIG can't be used to
deny service to a real key holder, since we never reached the rate
check).

100/min is well above ACME's needs: a worst-case full-renewal storm
across our ~84 zones emits maybe 200 UPDATEs total over several
minutes. Anything beyond is suspicious by definition.

New tests covering: first-call allowed, burst exhaustion, refill
behavior, per-key isolation, refill-cap (no idle-accumulation
overflow).
This commit is contained in:
Ryan Malloy 2026-05-22 21:31:17 -06:00
parent 6ab2b6af6d
commit 8d1477350a
4 changed files with 222 additions and 0 deletions

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strconv"
"time"
"github.com/coredns/caddy"
"github.com/coredns/coredns/core/dnsserver"
@ -164,6 +165,13 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
// Per-zone git author overrides. Defaults are applied later.
var gitAuthorName, gitAuthorEmail string
// Rate-limit config (Hamilton M8). Defaults are
// defaultRateBurst/defaultRatePeriod from ratelimit.go; an explicit
// `rate-limit <burst> <period-seconds>` directive overrides.
rateBurst := defaultRateBurst
ratePeriod := defaultRatePeriod
rateLimitEnabled := true
for c.Next() {
args := c.RemainingArgs()
if len(args) < 1 {
@ -235,6 +243,30 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
gitAuthorName = gArgs[0]
gitAuthorEmail = gArgs[1]
case "rate-limit":
rArgs := c.RemainingArgs()
switch len(rArgs) {
case 1:
if rArgs[0] == "off" || rArgs[0] == "false" || rArgs[0] == "no" {
rateLimitEnabled = false
break
}
return nil, c.Errf("rate-limit single-arg form must be 'off'; for limits use 'rate-limit <burst> <period-seconds>'")
case 2:
b, err := strconv.ParseUint(rArgs[0], 10, 31)
if err != nil || b < 1 {
return nil, c.Errf("rate-limit burst must be positive integer, got %q", rArgs[0])
}
pSec, err := strconv.ParseUint(rArgs[1], 10, 31)
if err != nil || pSec < 1 {
return nil, c.Errf("rate-limit period must be positive integer seconds, got %q", rArgs[1])
}
rateBurst = int(b)
ratePeriod = time.Duration(pSec) * time.Second
default:
return nil, c.Errf("rate-limit takes 'off' OR '<burst> <period-seconds>', got %d args", len(rArgs))
}
default:
return nil, c.Errf("unknown directive: %s", c.Val())
}
@ -248,6 +280,11 @@ func parse(c *caddy.Controller) (*RFC2136, error) {
return nil, c.Err("zones-dir is required")
}
// Construct rate limiter if enabled.
if rateLimitEnabled {
p.rateLimit = newRateLimiter(rateBurst, ratePeriod)
}
// Build zoneFile handles for each declared zone.
p.zones = make(map[string]*zoneFile, len(p.Zones))
for _, z := range p.Zones {