coredns-rfc2136/notify.go
Ryan Malloy 7367401734 Send DNS NOTIFY to secondaries after every UPDATE
Per RFC 1996, a master that mutates a zone SHOULD notify its
secondaries so they can immediately AXFR rather than wait for their
next SOA-refresh poll. Without this, propagation lag from UPDATE to
public DNS is bounded by the secondary's refresh interval (300s for
us) — which is borderline for ACME validation timing.

New Corefile directive:
    notify <host[:port]> [<host[:port]>...]

Targets accept bare hostnames (port 53 default), host:port, or
[ipv6]:port. The same list applies to every zone in the rfc2136
block.

Implementation: fire-and-forget UDP per target, each in its own
goroutine, capped by a 2s timeout. The UPDATE response to the client
is never held pending NOTIFY acks (RFC 1996 §4 explicitly decouples
them). Failures log at DEBUG only — a briefly-unreachable secondary
is normal and would otherwise spam logs.

Retires the external scripts/notify-secondaries.py workflow for any
deployment that wires the directive: secondaries now hear about
changes within seconds of the UPDATE landing, no cron or manual
invocation needed.

New tests:
- TestSendNotify_DeliversToTarget — packet arrives, opcode + zone correct
- TestSendNotify_NoTargets_NoCrash — empty list short-circuits
- TestSendNotify_BadTarget_LogsButDoesNotBlock — fire-and-forget timing
- TestNotifyOne_AppendsDefaultPort — host vs host:port normalization
2026-05-23 00:54:45 -06:00

63 lines
2.0 KiB
Go

package rfc2136
import (
"net"
"time"
"github.com/miekg/dns"
)
// notifyTimeout caps how long any single NOTIFY send can block before
// we give up. RFC 1996 §4 says the master MUST NOT block UPDATE
// acknowledgement on NOTIFY delivery — the secondaries will fall back
// to their own SOA refresh polling if NOTIFY is missed. 2s is plenty
// for a healthy secondary to ack via UDP; a slow/blackholed target
// just times out.
const notifyTimeout = 2 * time.Second
// defaultNotifyPort is appended to any target that doesn't already
// specify host:port. NOTIFY is always-over-port-53 in practice.
const defaultNotifyPort = "53"
// sendNotify dispatches fire-and-forget DNS NOTIFY messages (RFC 1996)
// to every configured secondary for the given zone. Each target gets
// its own goroutine so a slow/blackholed secondary can't slow
// propagation to its siblings.
//
// We do NOT wait for goroutines to finish — the UPDATE response goes
// back to the client immediately. Whether secondaries ack or not, the
// master's job is done; secondaries that miss the NOTIFY pick up the
// new serial on their next refresh poll.
//
// Failures are logged at Debug level. NOTIFY is best-effort; logging
// at Warning would flood the operator on every transient packet drop
// for secondaries that are intermittently reachable.
func sendNotify(zone string, targets []string) {
if len(targets) == 0 {
return
}
for _, t := range targets {
go notifyOne(zone, t)
}
}
// notifyOne sends one NOTIFY packet to `target` for `zone`. Target
// can be "host" (default port 53), "host:port", or "[ipv6]:port".
func notifyOne(zone, target string) {
addr := target
if _, _, err := net.SplitHostPort(addr); err != nil {
addr = net.JoinHostPort(addr, defaultNotifyPort)
}
msg := new(dns.Msg)
msg.SetNotify(dns.Fqdn(zone))
c := &dns.Client{Net: "udp", Timeout: notifyTimeout}
_, _, err := c.Exchange(msg, addr)
if err != nil {
log.Debugf("NOTIFY %s → %s failed: %v", zone, addr, err)
return
}
log.Debugf("NOTIFY %s → %s ok", zone, addr)
}