Fall back to the DNS handler chain for route query types a peer cannot resolve

This commit is contained in:
Viktor Liu
2026-07-27 11:31:36 +02:00
parent 1e5b0a5c89
commit 4546908ceb
9 changed files with 1070 additions and 76 deletions
+127 -13
View File
@@ -3,6 +3,7 @@ package dns
import (
"context"
"fmt"
"maps"
"math"
"net"
"slices"
@@ -31,6 +32,26 @@ type SubdomainMatcher interface {
MatchSubdomains() bool
}
// responseMeta holds the annotations handlers attach to a request to explain the
// response the chain ends up writing. It survives a deferral, so an answer that
// did not come from the handler that owns the name still says who stepped aside
// and why.
type responseMeta map[resutil.MetaKey]string
// format renders the annotations for the response log line. The order is stable
// so the same event reads the same way every time; a map's own order is not.
func (m responseMeta) format() string {
if len(m) == 0 {
return ""
}
var b strings.Builder
for _, k := range slices.Sorted(maps.Keys(m)) {
b.WriteString(" " + string(k) + "=" + m[k])
}
return b.String()
}
type HandlerEntry struct {
Handler dns.Handler
Priority int
@@ -52,8 +73,19 @@ type ResponseWriterChain struct {
origPattern string
requestID string
shouldContinue bool
response *dns.Msg
meta map[string]string
// softNegative suppresses a poisoning negative verdict for this request. A
// handler that owns the name but cannot answer this query type sets it
// before deferring, and it stays set for every handler that runs after.
softNegative bool
// clientHasEdns records whether the original query advertised EDNS0, taken
// before any handler ran: handlers add EDNS0 to the query they forward
// upstream, so the message itself no longer answers the question later.
clientHasEdns bool
response *dns.Msg
// meta is handed to the next handler when this one defers, so the same map
// outlives the writer that created it. Handlers must only set metadata from
// within ServeDNS, never from a goroutine that outlives the call.
meta responseMeta
}
// RequestID returns the request ID for tracing
@@ -62,26 +94,64 @@ func (w *ResponseWriterChain) RequestID() string {
}
// SetMeta sets a metadata key-value pair for logging
func (w *ResponseWriterChain) SetMeta(key, value string) {
func (w *ResponseWriterChain) SetMeta(key resutil.MetaKey, value string) {
if w.meta == nil {
w.meta = make(map[string]string)
w.meta = make(responseMeta)
}
w.meta[key] = value
}
// RequestSoftNegative marks the request so a downstream NXDOMAIN is turned into
// NODATA before it reaches the client. Set by a handler that defers a query for
// a name it owns but a type it cannot resolve.
func (w *ResponseWriterChain) RequestSoftNegative() {
w.softNegative = true
}
func (w *ResponseWriterChain) WriteMsg(m *dns.Msg) error {
// Check if this is a continue signal (NXDOMAIN with Zero bit set)
if m.Rcode == dns.RcodeNameError && m.MsgHdr.Zero {
w.shouldContinue = true
return nil
}
if w.softNegative && m.Rcode == dns.RcodeNameError {
m = softenNegative(m, w.clientHasEdns)
w.SetMeta(resutil.MetaKeySoftened, "nxdomain->nodata")
}
w.response = m
if m.MsgHdr.Truncated {
w.SetMeta("truncated", "true")
w.SetMeta(resutil.MetaKeyTruncated, "true")
}
return w.ResponseWriter.WriteMsg(m)
}
// softenNegative downgrades an NXDOMAIN to NODATA for a request a handler that
// owns the name deferred. NXDOMAIN is cached for the name and every type below
// it, so a resolver that has never heard of a routed name would take out the
// record types the route does serve; NODATA is cached for this name and type
// only. The authority section goes with it: the negative TTL of a zone we just
// overruled does not apply, and RFC 2308 keeps a negative answer that carries no
// SOA out of caches altogether, so the rewrite cannot outlive the route.
//
// The rewrite is ours, not the answering resolver's, so an EDNS0 client is told
// as much: the reply we hand back travels a path no capture on this host sees,
// and an empty answer is otherwise indistinguishable from a real one. Returns a
// copy so the answering handler keeps whatever it may hold on to.
func softenNegative(m *dns.Msg, clientHasEdns bool) *dns.Msg {
out := m.Copy()
out.Rcode = dns.RcodeSuccess
out.Ns = nil
if clientHasEdns {
resutil.AttachEDE(out, resutil.EDENetbirdSoftenedNegative,
"netbird: name is served locally, NXDOMAIN from the fallthrough resolver suppressed")
} else {
resutil.StripOPT(out)
}
return out
}
func NewHandlerChain() *HandlerChain {
return &HandlerChain{
handlers: make([]HandlerEntry, 0),
@@ -223,6 +293,19 @@ func (c *HandlerChain) dispatch(w dns.ResponseWriter, r *dns.Msg, maxPriority in
handlers := slices.Clone(c.handlers)
c.mu.RUnlock()
// Carried across deferrals: once a handler that owns the name defers, no
// handler after it may answer with a verdict that poisons the name. The
// metadata of a handler that stepped aside is carried too, so the response
// log line explains an answer that did not come from the handler that owns
// the name.
var softNegative bool
var carried responseMeta
// Taken before any handler runs: handlers advertise EDNS0 on the query they
// forward upstream, so afterwards the message no longer tells us whether the
// client did.
clientHasEdns := r.IsEdns0() != nil
// Try handlers in priority order
for _, entry := range handlers {
if entry.Priority > maxPriority {
@@ -245,11 +328,16 @@ func (c *HandlerChain) dispatch(w dns.ResponseWriter, r *dns.Msg, maxPriority in
ResponseWriter: w,
origPattern: entry.OrigPattern,
requestID: requestID,
softNegative: softNegative,
clientHasEdns: clientHasEdns,
meta: carried,
}
entry.Handler.ServeDNS(chainWriter, r)
// If handler wants to continue, try next handler
if chainWriter.shouldContinue {
softNegative = softNegative || chainWriter.softNegative
carried = chainWriter.meta
if entry.Priority != PriorityMgmtCache {
logger.Tracef("handler requested continue for domain=%s", qname)
}
@@ -265,30 +353,56 @@ func (c *HandlerChain) dispatch(w dns.ResponseWriter, r *dns.Msg, maxPriority in
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
resp := &dns.Msg{}
resp.SetRcode(r, dns.RcodeRefused)
// A handler that owns the name deferred and nothing below it could answer
// (a client with no primary nameserver group). The name exists as far as
// this client is concerned, since the route serves its addresses, so REFUSED
// would contradict the route: a stub that takes it as "not served here" and
// retries another resolver can come back with an NXDOMAIN that takes the
// whole name down. Answer "no records of this type" instead, without an SOA,
// so RFC 2308 keeps it out of negative caches, and tell an EDNS0 client the
// empty answer is ours rather than a resolver's.
if softNegative {
resp.Rcode = dns.RcodeSuccess
if clientHasEdns {
resutil.AttachEDE(resp, resutil.EDENetbirdSoftenedNegative,
"netbird: name is served locally, no fallthrough resolver for this query type")
}
logger.Tracef("no handler below the deferring one for domain=%s type=%s, answering NODATA",
qname, dns.TypeToString[question.Qtype])
}
if err := w.WriteMsg(resp); err != nil {
logger.Errorf("failed to write DNS response: %v", err)
}
}
func (c *HandlerChain) logResponse(logger *log.Entry, cw *ResponseWriterChain, qname string, startTime time.Time) {
// Runs for every query, and the arguments below are not free: Len() packs
// the message to measure it, and formatting the answers and the metadata
// allocates. None of it is worth doing when the line is discarded.
if !log.IsLevelEnabled(log.TraceLevel) {
return
}
if cw.response == nil {
return
}
var meta string
for k, v := range cw.meta {
meta += " " + k + "=" + v
}
logger.Tracef("response: domain=%s rcode=%s answers=%s size=%dB%s took=%s",
qname, dns.RcodeToString[cw.response.Rcode], resutil.FormatAnswers(cw.response.Answer),
cw.response.Len(), meta, time.Since(startTime))
cw.response.Len(), cw.meta.format(), time.Since(startTime))
}
// ResolveInternal runs an in-process DNS query against the chain, skipping any
// handler with priority > maxPriority. Used by internal callers (e.g. the mgmt
// cache refresher) that must bypass themselves to avoid loops. Honors ctx
// cancellation; on ctx.Done the dispatch goroutine is left to drain on its own
// cache refresher) that must bypass themselves to avoid loops.
//
// "Nothing answered" is read off RcodeRefused, which a request soft-negatived by
// a deferring handler never carries: it ends in an empty NOERROR instead, and
// would look resolved. No caller can reach that today, since every handler that
// defers sits above the maxPriority any caller passes. Lowering one below it
// means this check needs the soft-negative case too.
//
// Honors ctx cancellation; on ctx.Done the dispatch goroutine is left to drain on its own
// (bounded by the invoked handler's internal timeout).
func (c *HandlerChain) ResolveInternal(ctx context.Context, r *dns.Msg, maxPriority int) (*dns.Msg, error) {
if len(r.Question) == 0 {
+272
View File
@@ -3,15 +3,19 @@ package dns_test
import (
"context"
"net"
"strings"
"testing"
"time"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
logtest "github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/dns/resutil"
"github.com/netbirdio/netbird/client/internal/dns/test"
)
@@ -1238,6 +1242,274 @@ func TestHandlerChain_ResolveInternal_HonorsContextTimeout(t *testing.T) {
assert.Less(t, elapsed, 500*time.Millisecond, "ResolveInternal must return shortly after ctx deadline")
}
// requestSoftNegative asks the chain to soften a negative verdict produced by
// the handlers that run after the caller defers, reporting whether the chain
// supports the signal. Written as a type assertion so these tests compile
// against a chain that does not support it yet.
func requestSoftNegative(w dns.ResponseWriter) bool {
sn, ok := w.(interface{ RequestSoftNegative() })
if ok {
sn.RequestSoftNegative()
}
return ok
}
// deferringHandler defers to the next handler in the chain, optionally asking
// for the negative verdict of whatever answers instead to be softened. This is
// what a DNS route handler does for a record type its routing peer cannot
// resolve.
type deferringHandler struct {
softNegative bool
called bool
supported bool
}
func (h *deferringHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
h.called = true
if h.softNegative {
h.supported = requestSoftNegative(w)
// The real handler records why it stepped aside, for the response log
// line of whichever handler answers instead.
resutil.SetMeta(w, resutil.MetaKeyDeferredBy, "test handler")
}
resp := new(dns.Msg)
resp.SetRcode(r, dns.RcodeNameError)
resp.MsgHdr.Zero = true
_ = w.WriteMsg(resp)
}
// nxdomainHandler answers an authoritative NXDOMAIN with an SOA in the
// authority section, the way a public resolver answers for a name that only
// exists inside the routed network.
type nxdomainHandler struct{}
func (h *nxdomainHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
resp := new(dns.Msg)
resp.SetRcode(r, dns.RcodeNameError)
resp.Ns = []dns.RR{&dns.SOA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600},
Ns: "ns1.example.com.",
Mbox: "hostmaster.example.com.",
Minttl: 3600,
Expire: 604800,
Refresh: 7200,
Retry: 3600,
}}
_ = w.WriteMsg(resp)
}
// TestHandlerChain_SoftNegative_DowngradesDownstreamNXDOMAIN is the whole point
// of the soft-negative signal: a route handler may only defer a query to the
// public chain if the answer cannot poison the routed name. NXDOMAIN is cached
// for the name and every type under it (RFC 2308, RFC 8020), so it has to be
// rewritten to NODATA, which is cached per name and type only.
func TestHandlerChain_SoftNegative_DowngradesDownstreamNXDOMAIN(t *testing.T) {
chain := nbdns.NewHandlerChain()
route := &deferringHandler{softNegative: true}
chain.AddHandler("*.example.com.", route, nbdns.PriorityDNSRoute)
chain.AddHandler(".", &nxdomainHandler{}, nbdns.PriorityDefault)
r := new(dns.Msg)
r.SetQuestion("_mongodb._tcp.db.example.com.", dns.TypeSRV)
mw := &test.MockResponseWriter{}
chain.ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
resp := mw.GetLastResponse()
require.NotNil(t, resp, "a response must reach the client")
assert.True(t, route.called, "the route handler must run first")
require.True(t, route.supported, "the chain writer must accept a soft-negative request")
assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "NXDOMAIN must be softened to NODATA")
assert.Empty(t, resp.Answer, "a softened negative carries no answer")
assert.Empty(t, resp.Ns, "the downstream zone's SOA must not set the negative TTL for a name we overrode")
}
// responseLineFor returns the chain's response log line for one query. Raising
// the level to trace also unmutes whatever else is logging in this package, so
// the line has to be picked by the name it was asked about rather than by being
// the last one seen.
func responseLineFor(hook *logtest.Hook, qname string) string {
for _, e := range hook.AllEntries() {
if strings.HasPrefix(e.Message, "response:") && strings.Contains(e.Message, qname) {
return e.Message
}
}
return ""
}
// TestHandlerChain_SoftNegative_IsVisibleToTheClient covers observability of the
// rewrite. The reply we hand the application travels over loopback, which the
// bundle capture does not see, so a softened verdict has to say so on the wire:
// without it an empty answer is indistinguishable from a real "no such record"
// in a dig output or a capture taken next to the application.
func TestHandlerChain_SoftNegative_IsVisibleToTheClient(t *testing.T) {
newChain := func() *nbdns.HandlerChain {
chain := nbdns.NewHandlerChain()
chain.AddHandler("*.example.com.", &deferringHandler{softNegative: true}, nbdns.PriorityDNSRoute)
chain.AddHandler(".", &nxdomainHandler{}, nbdns.PriorityDefault)
return chain
}
t.Run("EDNS0 client gets an extended error", func(t *testing.T) {
r := new(dns.Msg)
r.SetQuestion("_mongodb._tcp.db.example.com.", dns.TypeSRV)
r.SetEdns0(dns.DefaultMsgSize, false)
mw := &test.MockResponseWriter{}
newChain().ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
resp := mw.GetLastResponse()
require.NotNil(t, resp)
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
ede, ok := resutil.ExtractEDE(resp)
require.True(t, ok, "a softened verdict must carry an extended DNS error")
assert.Equal(t, resutil.EDENetbirdSoftenedNegative, ede.InfoCode)
assert.Contains(t, ede.ExtraText, "netbird", "the text must name us as the source of the rewrite")
})
t.Run("plain client gets no OPT", func(t *testing.T) {
r := new(dns.Msg)
r.SetQuestion("_mongodb._tcp.db.example.com.", dns.TypeSRV)
mw := &test.MockResponseWriter{}
newChain().ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
resp := mw.GetLastResponse()
require.NotNil(t, resp)
assert.Nil(t, resp.IsEdns0(), "RFC 6891 forbids an OPT toward a client that did not advertise EDNS0")
})
// The response line is what support reads out of a debug bundle, and it now
// carries several annotations at once. Built from a map, their order would
// differ on every query, so the same event never looks the same twice.
t.Run("log fields keep a stable order", func(t *testing.T) {
const qname = "_mongodb._tcp.stable.example.com."
hook := logtest.NewGlobal()
t.Cleanup(hook.Reset)
prev := log.GetLevel()
log.SetLevel(log.TraceLevel)
t.Cleanup(func() { log.SetLevel(prev) })
lineFor := func() string {
hook.Reset()
r := new(dns.Msg)
r.SetQuestion(qname, dns.TypeSRV)
mw := &test.MockResponseWriter{}
newChain().ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
line := responseLineFor(hook, qname)
// The duration differs per query and is not what we compare.
line, _, _ = strings.Cut(line, " took=")
return line
}
first := lineFor()
require.NotEmpty(t, first, "the chain must log the response it wrote")
require.Contains(t, first, "deferred_by=", "the line must carry more than one annotation to be worth ordering")
require.Contains(t, first, "softened=")
for range 20 {
assert.Equal(t, first, lineFor(), "the same event must produce the same line")
}
})
t.Run("logged with the reason it was deferred", func(t *testing.T) {
const qname = "_mongodb._tcp.reason.example.com."
hook := logtest.NewGlobal()
t.Cleanup(hook.Reset)
prev := log.GetLevel()
log.SetLevel(log.TraceLevel)
t.Cleanup(func() { log.SetLevel(prev) })
r := new(dns.Msg)
r.SetQuestion(qname, dns.TypeSRV)
mw := &test.MockResponseWriter{}
newChain().ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
response := responseLineFor(hook, qname)
require.NotEmpty(t, response, "the chain must log the response it wrote")
assert.Contains(t, response, "softened=", "the log must show the verdict was rewritten")
assert.Contains(t, response, "deferred_by=", "the log must name the handler that deferred")
})
}
// TestHandlerChain_SoftNegative_NoHandlerBelow covers a client with no primary
// nameserver group: the deferred query reaches the end of the chain unanswered.
// REFUSED would say the name is not served here while the route serves its
// addresses, and a stub that acts on that by asking elsewhere can bring back an
// NXDOMAIN for the whole name. The answer must be an empty, uncacheable NODATA.
func TestHandlerChain_SoftNegative_NoHandlerBelow(t *testing.T) {
chain := nbdns.NewHandlerChain()
route := &deferringHandler{softNegative: true}
chain.AddHandler("*.example.com.", route, nbdns.PriorityDNSRoute)
r := new(dns.Msg)
r.SetQuestion("db.example.com.", dns.TypeHTTPS)
mw := &test.MockResponseWriter{}
chain.ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
resp := mw.GetLastResponse()
require.NotNil(t, resp, "a response must reach the client")
assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "an unanswered soft-negative query must be NODATA, not REFUSED")
assert.Empty(t, resp.Answer)
assert.Empty(t, resp.Ns,
"no SOA, so RFC 2308 keeps the empty answer out of negative caches and it cannot outlive the route")
}
// TestHandlerChain_SoftNegative_KeepsRealAnswers guards the other direction:
// softening applies to negative verdicts only. A real answer from a downstream
// handler must reach the client untouched.
func TestHandlerChain_SoftNegative_KeepsRealAnswers(t *testing.T) {
chain := nbdns.NewHandlerChain()
route := &deferringHandler{softNegative: true}
chain.AddHandler("*.example.com.", route, nbdns.PriorityDNSRoute)
chain.AddHandler(".", &answeringHandler{name: "public", ip: "203.0.113.10"}, nbdns.PriorityDefault)
r := new(dns.Msg)
r.SetQuestion("db.example.com.", dns.TypeA)
mw := &test.MockResponseWriter{}
chain.ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
resp := mw.GetLastResponse()
require.NotNil(t, resp, "a response must reach the client")
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
require.Len(t, resp.Answer, 1, "the downstream answer must pass through")
}
// TestHandlerChain_NXDOMAINPreservedWithoutSoftNegative makes sure the
// softening is opt-in: an ordinary chain continuation still yields NXDOMAIN, so
// genuine non-existence keeps being reported.
func TestHandlerChain_NXDOMAINPreservedWithoutSoftNegative(t *testing.T) {
chain := nbdns.NewHandlerChain()
route := &deferringHandler{}
chain.AddHandler("*.example.com.", route, nbdns.PriorityDNSRoute)
chain.AddHandler(".", &nxdomainHandler{}, nbdns.PriorityDefault)
r := new(dns.Msg)
r.SetQuestion("nope.example.com.", dns.TypeA)
mw := &test.MockResponseWriter{}
chain.ServeDNS(&nbdns.ResponseWriterChain{ResponseWriter: mw}, r)
resp := mw.GetLastResponse()
require.NotNil(t, resp, "a response must reach the client")
assert.Equal(t, dns.RcodeNameError, resp.Rcode, "without the signal a real NXDOMAIN must survive")
}
func TestHandlerChain_HasRootHandlerAtOrBelow(t *testing.T) {
chain := nbdns.NewHandlerChain()
h := &answeringHandler{name: "h", ip: "10.0.0.1"}
+96 -6
View File
@@ -19,6 +19,34 @@ import (
// uses when a resolved host has no addresses of the requested family.
const errNoSuitableAddress = "no suitable address found"
// Extended DNS Error info codes NetBird emits so a client can see why an answer
// looks the way it does without reading this peer's logs. They live in the RFC
// 8914 Private Use range (49152-65535) and are registered here, in one place,
// because nothing else guarantees two NetBird components pick distinct codes.
const (
// EDENetbirdUpstreamTimeout: a DNS forwarder's upstream did not answer.
EDENetbirdUpstreamTimeout uint16 = 49152
// EDENetbirdUpstreamFailure: a DNS forwarder's upstream failed.
EDENetbirdUpstreamFailure uint16 = 49153
// EDENetbirdSoftenedNegative: the empty answer is ours, not the answering
// resolver's. A handler that owns the name deferred this query type, and the
// negative verdict that came back was downgraded so it cannot poison the
// name.
EDENetbirdSoftenedNegative uint16 = 49154
)
// AttachEDE adds an Extended DNS Error (RFC 8914) option to a message, creating
// the OPT pseudo-record if it has none. Callers must only use it toward a client
// that advertised EDNS0: per RFC 6891 an OPT must not appear otherwise.
func AttachEDE(msg *dns.Msg, code uint16, text string) {
opt := msg.IsEdns0()
if opt == nil {
msg.SetEdns0(dns.DefaultMsgSize, false)
opt = msg.IsEdns0()
}
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: code, ExtraText: text})
}
// GenerateRequestID creates a random 8-character hex string for request tracing.
func GenerateRequestID() string {
bytes := make([]byte, 4)
@@ -78,10 +106,38 @@ type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}
// MetaKey names an annotation a handler attaches to a request to explain the
// response the chain ends up writing. The set is closed and rendered onto a
// single log line, so an unrecognized key is a typo inventing a field rather
// than a new fact.
type MetaKey string
const (
// MetaKeyProtocol: the transport a query arrived on.
MetaKeyProtocol MetaKey = "protocol"
// MetaKeyUpstream: the upstream that answered.
MetaKeyUpstream MetaKey = "upstream"
// MetaKeyUpstreamProtocol: the transport used toward that upstream.
MetaKeyUpstreamProtocol MetaKey = "upstream_protocol"
// MetaKeyPeer: the routing peer whose forwarder answered.
MetaKeyPeer MetaKey = "peer"
// MetaKeyEDE: an Extended DNS Error carried by the answer.
MetaKeyEDE MetaKey = "ede"
// MetaKeyTruncated: the answer did not fit and was truncated.
MetaKeyTruncated MetaKey = "truncated"
// MetaKeySoftened: a negative verdict was rewritten so it cannot poison a
// name served locally.
MetaKeySoftened MetaKey = "softened"
// MetaKeyDeferredBy: the handler that owned the name and stepped aside.
MetaKeyDeferredBy MetaKey = "deferred_by"
// MetaKeyDeferredReason: why it stepped aside.
MetaKeyDeferredReason MetaKey = "deferred_reason"
)
// chainedWriter is implemented by ResponseWriters that carry request metadata
type chainedWriter interface {
RequestID() string
SetMeta(key, value string)
SetMeta(key MetaKey, value string)
}
// GetRequestID extracts a request ID from the ResponseWriter if available,
@@ -96,12 +152,30 @@ func GetRequestID(w dns.ResponseWriter) string {
}
// SetMeta sets metadata on the ResponseWriter if it supports it.
func SetMeta(w dns.ResponseWriter, key, value string) {
func SetMeta(w dns.ResponseWriter, key MetaKey, value string) {
if cw, ok := w.(chainedWriter); ok {
cw.SetMeta(key, value)
}
}
// softNegativeRequester is implemented by chain writers that can soften the
// negative verdict of the handlers a deferring handler falls through to.
type softNegativeRequester interface {
RequestSoftNegative()
}
// RequestSoftNegative asks the handler chain to downgrade an NXDOMAIN from the
// handlers that run after the caller defers. A handler that owns a name but
// cannot answer one query type for it needs this: the resolvers it falls
// through to cannot prove the name absent, and an NXDOMAIN from them is cached
// for the name and every type under it (RFC 2308, RFC 8020), taking the
// addresses the handler does serve down with it.
func RequestSoftNegative(w dns.ResponseWriter) {
if sn, ok := w.(softNegativeRequester); ok {
sn.RequestSoftNegative()
}
}
// LookupResult contains the result of an external DNS lookup
type LookupResult struct {
IPs []netip.Addr
@@ -199,11 +273,27 @@ type RecordResolver interface {
LookupAddr(ctx context.Context, addr string) ([]string, error)
}
// SupportedRecordQtype reports whether LookupRecords can resolve qtype. The
// set is bounded by the net.Resolver API, which exposes no way to query an
// arbitrary record type, and going around it with a raw exchange would mean
// picking nameservers ourselves instead of resolving the way the host does.
//
// Both ends read this: a DNS forwarder answers these types for the domains it
// routes, and a client uses it to tell which types are worth forwarding to a
// peer at all.
func SupportedRecordQtype(qtype uint16) bool {
switch qtype {
case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR:
return true
default:
return false
}
}
// LookupRecords resolves a non-address DNS record type through the host
// resolver and returns the resource records and the DNS rcode. Types the host
// resolver cannot answer (anything not covered by the net.Resolver Lookup*
// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN
// for an unsupported type.
// resolver and returns the resource records and the DNS rcode. Types outside
// SupportedRecordQtype yield NODATA so that a routed name is never poisoned
// with NXDOMAIN for a type we cannot look up.
func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) {
fqdn := dns.Fqdn(name)
+5 -5
View File
@@ -295,7 +295,7 @@ func (u *upstreamResolverBase) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
if addr := w.RemoteAddr(); addr != nil {
network := addr.Network()
ctx = contextWithDNSProtocol(ctx, network)
resutil.SetMeta(w, "protocol", network)
resutil.SetMeta(w, resutil.MetaKeyProtocol, network)
}
ok, failures := u.tryUpstreamServers(ctx, w, r, logger)
@@ -331,7 +331,7 @@ func (u *upstreamResolverBase) tryOnlyRace(ctx context.Context, w dns.ResponseWr
return false, res.failures
}
if res.ede != "" {
resutil.SetMeta(w, "ede", res.ede)
resutil.SetMeta(w, resutil.MetaKeyEDE, res.ede)
}
u.writeSuccessResponse(w, res.msg, res.upstream, r.Question[0].Name, res.protocol, logger)
return true, res.failures
@@ -361,7 +361,7 @@ func (u *upstreamResolverBase) raceAll(ctx context.Context, w dns.ResponseWriter
failures = append(failures, res.failures...)
if res.msg != nil {
if res.ede != "" {
resutil.SetMeta(w, "ede", res.ede)
resutil.SetMeta(w, resutil.MetaKeyEDE, res.ede)
}
u.writeSuccessResponse(w, res.msg, res.upstream, r.Question[0].Name, res.protocol, logger)
return true, failures
@@ -550,9 +550,9 @@ func (u *upstreamResolverBase) debugUpstreamTimeout(upstream netip.AddrPort) str
}
func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, proto string, logger *log.Entry) {
resutil.SetMeta(w, "upstream", upstream.String())
resutil.SetMeta(w, resutil.MetaKeyUpstream, upstream.String())
if proto != "" {
resutil.SetMeta(w, "upstream_protocol", proto)
resutil.SetMeta(w, resutil.MetaKeyUpstreamProtocol, proto)
}
// Clear Zero bit from external responses to prevent upstream servers from