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
63 lines
2.0 KiB
Go
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)
|
|
}
|