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
130 lines
3.6 KiB
Go
130 lines
3.6 KiB
Go
package rfc2136
|
|
|
|
import (
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
)
|
|
|
|
// testNotifyListener spins up a UDP DNS-protocol listener on an
|
|
// ephemeral port that captures any messages it receives. Returns the
|
|
// host:port string for use as a NOTIFY target, plus a getter for the
|
|
// last-captured message.
|
|
func testNotifyListener(t *testing.T) (addr string, getLast func() *dns.Msg) {
|
|
t.Helper()
|
|
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
|
|
if err != nil {
|
|
t.Fatalf("ListenUDP: %v", err)
|
|
}
|
|
|
|
var mu sync.Mutex
|
|
var last *dns.Msg
|
|
done := make(chan struct{})
|
|
|
|
go func() {
|
|
buf := make([]byte, 512)
|
|
for {
|
|
conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
|
n, _, err := conn.ReadFromUDP(buf)
|
|
if err != nil {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
msg := new(dns.Msg)
|
|
if unpackErr := msg.Unpack(buf[:n]); unpackErr == nil {
|
|
mu.Lock()
|
|
last = msg
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
|
|
t.Cleanup(func() {
|
|
close(done)
|
|
conn.Close()
|
|
})
|
|
|
|
return conn.LocalAddr().String(), func() *dns.Msg {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return last
|
|
}
|
|
}
|
|
|
|
func TestSendNotify_DeliversToTarget(t *testing.T) {
|
|
addr, getLast := testNotifyListener(t)
|
|
|
|
sendNotify("auth.example.com", []string{addr})
|
|
|
|
// Wait up to 1s for the packet to arrive (test listener polls on
|
|
// 500ms deadline). The send goroutine writes immediately; the
|
|
// listener loop just needs one read cycle to pick it up.
|
|
deadline := time.Now().Add(1 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if msg := getLast(); msg != nil {
|
|
if msg.Opcode != dns.OpcodeNotify {
|
|
t.Errorf("Opcode = %d, want OpcodeNotify (%d)", msg.Opcode, dns.OpcodeNotify)
|
|
}
|
|
if len(msg.Question) != 1 || msg.Question[0].Name != "auth.example.com." {
|
|
t.Errorf("Question = %+v, want one entry with name auth.example.com.", msg.Question)
|
|
}
|
|
if !msg.Authoritative {
|
|
t.Errorf("AA flag not set on NOTIFY")
|
|
}
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatal("NOTIFY never arrived at target within 1s")
|
|
}
|
|
|
|
func TestSendNotify_NoTargets_NoCrash(t *testing.T) {
|
|
// Empty target list must short-circuit without launching goroutines
|
|
// or panicking.
|
|
sendNotify("auth.example.com", nil)
|
|
sendNotify("auth.example.com", []string{})
|
|
// No assertions — survival is the test.
|
|
}
|
|
|
|
func TestSendNotify_BadTarget_LogsButDoesNotBlock(t *testing.T) {
|
|
// Target a port we know nothing listens on. The fire-and-forget
|
|
// send must return immediately; the goroutine eventually times out.
|
|
start := time.Now()
|
|
sendNotify("auth.example.com", []string{"127.0.0.1:1"})
|
|
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
|
t.Errorf("sendNotify blocked %v on unreachable target; expected fire-and-forget", elapsed)
|
|
}
|
|
}
|
|
|
|
func TestNotifyOne_AppendsDefaultPort(t *testing.T) {
|
|
// Spin up a listener on 127.0.0.1:<random>, then call notifyOne
|
|
// with both forms (bare host + host:port) and verify both deliver.
|
|
addr, getLast := testNotifyListener(t)
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
t.Fatalf("split: %v", err)
|
|
}
|
|
_ = host
|
|
|
|
// Form 1: host:port (the normal case).
|
|
notifyOne("first.example.com", addr)
|
|
time.Sleep(100 * time.Millisecond)
|
|
if m := getLast(); m == nil || len(m.Question) == 0 || m.Question[0].Name != "first.example.com." {
|
|
t.Errorf("host:port form did not deliver: %+v", m)
|
|
}
|
|
|
|
// We can't easily test the bare-host case because port 53 is the
|
|
// default and we can't bind there without root. Verifying the
|
|
// defaulting branch directly is sufficient.
|
|
if port == "" {
|
|
t.Fatal("unreachable: SplitHostPort returned empty port")
|
|
}
|
|
}
|