mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-30 03:21:29 +02:00
1544 lines
48 KiB
Go
1544 lines
48 KiB
Go
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"
|
|
)
|
|
|
|
// TestHandlerChain_ServeDNS_Priorities tests that handlers are executed in priority order
|
|
func TestHandlerChain_ServeDNS_Priorities(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
|
|
// Create mock handlers for different priorities
|
|
defaultHandler := &nbdns.MockHandler{}
|
|
matchDomainHandler := &nbdns.MockHandler{}
|
|
dnsRouteHandler := &nbdns.MockHandler{}
|
|
|
|
// Setup handlers with different priorities
|
|
chain.AddHandler("example.com.", defaultHandler, nbdns.PriorityDefault)
|
|
chain.AddHandler("example.com.", matchDomainHandler, nbdns.PriorityUpstream)
|
|
chain.AddHandler("example.com.", dnsRouteHandler, nbdns.PriorityDNSRoute)
|
|
|
|
// Create test request
|
|
r := new(dns.Msg)
|
|
r.SetQuestion("example.com.", dns.TypeA)
|
|
|
|
// Create test writer
|
|
w := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
|
|
// Setup expectations - only highest priority handler should be called
|
|
dnsRouteHandler.On("ServeDNS", mock.Anything, r).Once()
|
|
matchDomainHandler.On("ServeDNS", mock.Anything, r).Maybe()
|
|
defaultHandler.On("ServeDNS", mock.Anything, r).Maybe()
|
|
|
|
// Execute
|
|
chain.ServeDNS(w, r)
|
|
|
|
// Verify all expectations were met
|
|
dnsRouteHandler.AssertExpectations(t)
|
|
matchDomainHandler.AssertExpectations(t)
|
|
defaultHandler.AssertExpectations(t)
|
|
}
|
|
|
|
// TestHandlerChain_ServeDNS_DomainMatching tests various domain matching scenarios
|
|
func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
handlerDomain string
|
|
queryDomain string
|
|
isWildcard bool
|
|
matchSubdomains bool
|
|
shouldMatch bool
|
|
}{
|
|
{
|
|
name: "exact match",
|
|
handlerDomain: "example.com.",
|
|
queryDomain: "example.com.",
|
|
isWildcard: false,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "subdomain with non-wildcard and MatchSubdomains true",
|
|
handlerDomain: "example.com.",
|
|
queryDomain: "sub.example.com.",
|
|
isWildcard: false,
|
|
matchSubdomains: true,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "subdomain with non-wildcard and MatchSubdomains false",
|
|
handlerDomain: "example.com.",
|
|
queryDomain: "sub.example.com.",
|
|
isWildcard: false,
|
|
matchSubdomains: false,
|
|
shouldMatch: false,
|
|
},
|
|
{
|
|
name: "wildcard match",
|
|
handlerDomain: "*.example.com.",
|
|
queryDomain: "sub.example.com.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "wildcard no match on apex",
|
|
handlerDomain: "*.example.com.",
|
|
queryDomain: "example.com.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: false,
|
|
},
|
|
{
|
|
name: "root zone match",
|
|
handlerDomain: ".",
|
|
queryDomain: "anything.com.",
|
|
isWildcard: false,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "no match different domain",
|
|
handlerDomain: "example.com.",
|
|
queryDomain: "example.org.",
|
|
isWildcard: false,
|
|
matchSubdomains: false,
|
|
shouldMatch: false,
|
|
},
|
|
{
|
|
name: "single letter TLD exact match",
|
|
handlerDomain: "example.x.",
|
|
queryDomain: "example.x.",
|
|
isWildcard: false,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "single letter TLD subdomain match",
|
|
handlerDomain: "example.x.",
|
|
queryDomain: "sub.example.x.",
|
|
isWildcard: false,
|
|
matchSubdomains: true,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "single letter TLD wildcard match",
|
|
handlerDomain: "*.example.x.",
|
|
queryDomain: "sub.example.x.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "two letter domain labels",
|
|
handlerDomain: "a.b.",
|
|
queryDomain: "a.b.",
|
|
isWildcard: false,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "single character domain",
|
|
handlerDomain: "x.",
|
|
queryDomain: "x.",
|
|
isWildcard: false,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "single character domain with subdomain match",
|
|
handlerDomain: "x.",
|
|
queryDomain: "sub.x.",
|
|
isWildcard: false,
|
|
matchSubdomains: true,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "wildcard label-boundary mismatch (suffix overlap)",
|
|
handlerDomain: "*.b.test.",
|
|
queryDomain: "x.ab.test.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: false,
|
|
},
|
|
{
|
|
name: "wildcard label-boundary match",
|
|
handlerDomain: "*.b.test.",
|
|
queryDomain: "x.b.test.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "wildcard multi-label match",
|
|
handlerDomain: "*.b.test.",
|
|
queryDomain: "x.y.b.test.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
{
|
|
name: "wildcard no match on multi-label apex",
|
|
handlerDomain: "*.b.test.",
|
|
queryDomain: "b.test.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: false,
|
|
},
|
|
{
|
|
name: "wildcard no match on unrelated suffix containment",
|
|
handlerDomain: "*.example.com.",
|
|
queryDomain: "notexample.com.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: false,
|
|
},
|
|
{
|
|
name: "wildcard accepts pattern registered without trailing dot",
|
|
handlerDomain: "*.b.test",
|
|
queryDomain: "x.b.test.",
|
|
isWildcard: true,
|
|
matchSubdomains: false,
|
|
shouldMatch: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
var handler dns.Handler
|
|
|
|
if tt.matchSubdomains {
|
|
mockSubHandler := &nbdns.MockSubdomainHandler{Subdomains: true}
|
|
handler = mockSubHandler
|
|
if tt.shouldMatch {
|
|
mockSubHandler.On("ServeDNS", mock.Anything, mock.Anything).Once()
|
|
}
|
|
} else {
|
|
mockHandler := &nbdns.MockHandler{}
|
|
handler = mockHandler
|
|
if tt.shouldMatch {
|
|
mockHandler.On("ServeDNS", mock.Anything, mock.Anything).Once()
|
|
}
|
|
}
|
|
|
|
pattern := tt.handlerDomain
|
|
if tt.isWildcard {
|
|
pattern = "*." + tt.handlerDomain[2:]
|
|
}
|
|
|
|
chain.AddHandler(pattern, handler, nbdns.PriorityDefault)
|
|
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(tt.queryDomain, dns.TypeA)
|
|
w := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
|
|
chain.ServeDNS(w, r)
|
|
|
|
if h, ok := handler.(*nbdns.MockHandler); ok {
|
|
h.AssertExpectations(t)
|
|
} else if h, ok := handler.(*nbdns.MockSubdomainHandler); ok {
|
|
h.AssertExpectations(t)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandlerChain_ServeDNS_OverlappingDomains tests behavior with overlapping domain patterns
|
|
func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
handlers []struct {
|
|
pattern string
|
|
priority int
|
|
}
|
|
queryDomain string
|
|
expectedCalls int
|
|
expectedHandler int // index of the handler that should be called
|
|
}{
|
|
{
|
|
name: "wildcard and exact same priority - exact should win",
|
|
handlers: []struct {
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{pattern: "*.example.com.", priority: nbdns.PriorityDefault},
|
|
{pattern: "example.com.", priority: nbdns.PriorityDefault},
|
|
},
|
|
queryDomain: "example.com.",
|
|
expectedCalls: 1,
|
|
expectedHandler: 1, // exact match handler should be called
|
|
},
|
|
{
|
|
name: "higher priority wildcard over lower priority exact",
|
|
handlers: []struct {
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{pattern: "example.com.", priority: nbdns.PriorityDefault},
|
|
{pattern: "*.example.com.", priority: nbdns.PriorityDNSRoute},
|
|
},
|
|
queryDomain: "test.example.com.",
|
|
expectedCalls: 1,
|
|
expectedHandler: 1, // higher priority wildcard handler should be called
|
|
},
|
|
{
|
|
name: "multiple wildcards different priorities",
|
|
handlers: []struct {
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{pattern: "*.example.com.", priority: nbdns.PriorityDefault},
|
|
{pattern: "*.example.com.", priority: nbdns.PriorityUpstream},
|
|
{pattern: "*.example.com.", priority: nbdns.PriorityDNSRoute},
|
|
},
|
|
queryDomain: "test.example.com.",
|
|
expectedCalls: 1,
|
|
expectedHandler: 2, // highest priority handler should be called
|
|
},
|
|
{
|
|
name: "subdomain with mix of patterns",
|
|
handlers: []struct {
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{pattern: "*.example.com.", priority: nbdns.PriorityDefault},
|
|
{pattern: "test.example.com.", priority: nbdns.PriorityUpstream},
|
|
{pattern: "*.test.example.com.", priority: nbdns.PriorityDNSRoute},
|
|
},
|
|
queryDomain: "sub.test.example.com.",
|
|
expectedCalls: 1,
|
|
expectedHandler: 2, // highest priority matching handler should be called
|
|
},
|
|
{
|
|
name: "overlapping wildcard suffixes route to correct handler",
|
|
handlers: []struct {
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
|
|
{pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
|
|
},
|
|
queryDomain: "app.ab.test.",
|
|
expectedCalls: 1,
|
|
expectedHandler: 1,
|
|
},
|
|
{
|
|
name: "root zone with specific domain",
|
|
handlers: []struct {
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{pattern: ".", priority: nbdns.PriorityDefault},
|
|
{pattern: "example.com.", priority: nbdns.PriorityDNSRoute},
|
|
},
|
|
queryDomain: "example.com.",
|
|
expectedCalls: 1,
|
|
expectedHandler: 1, // higher priority specific domain should win over root
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
var handlers []*nbdns.MockHandler
|
|
|
|
// Setup handlers and expectations
|
|
for i := range tt.handlers {
|
|
handler := &nbdns.MockHandler{}
|
|
handlers = append(handlers, handler)
|
|
|
|
// Set expectation based on whether this handler should be called
|
|
if i == tt.expectedHandler {
|
|
handler.On("ServeDNS", mock.Anything, mock.Anything).Once()
|
|
} else {
|
|
handler.On("ServeDNS", mock.Anything, mock.Anything).Maybe()
|
|
}
|
|
|
|
chain.AddHandler(tt.handlers[i].pattern, handler, tt.handlers[i].priority)
|
|
}
|
|
|
|
// Create and execute request
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(tt.queryDomain, dns.TypeA)
|
|
w := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
chain.ServeDNS(w, r)
|
|
|
|
// Verify expectations
|
|
for _, handler := range handlers {
|
|
handler.AssertExpectations(t)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandlerChain_ServeDNS_ChainContinuation tests the chain continuation functionality
|
|
func TestHandlerChain_ServeDNS_ChainContinuation(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
|
|
// Create handlers
|
|
handler1 := &nbdns.MockHandler{}
|
|
handler2 := &nbdns.MockHandler{}
|
|
handler3 := &nbdns.MockHandler{}
|
|
|
|
// Add handlers in priority order
|
|
chain.AddHandler("example.com.", handler1, nbdns.PriorityDNSRoute)
|
|
chain.AddHandler("example.com.", handler2, nbdns.PriorityUpstream)
|
|
chain.AddHandler("example.com.", handler3, nbdns.PriorityDefault)
|
|
|
|
// Create test request
|
|
r := new(dns.Msg)
|
|
r.SetQuestion("example.com.", dns.TypeA)
|
|
|
|
// Setup mock responses to simulate chain continuation
|
|
handler1.On("ServeDNS", mock.Anything, r).Run(func(args mock.Arguments) {
|
|
// First handler signals continue
|
|
w := args.Get(0).(*nbdns.ResponseWriterChain)
|
|
resp := new(dns.Msg)
|
|
resp.SetRcode(r, dns.RcodeNameError)
|
|
resp.MsgHdr.Zero = true // Signal to continue
|
|
assert.NoError(t, w.WriteMsg(resp))
|
|
}).Once()
|
|
|
|
handler2.On("ServeDNS", mock.Anything, r).Run(func(args mock.Arguments) {
|
|
// Second handler signals continue
|
|
w := args.Get(0).(*nbdns.ResponseWriterChain)
|
|
resp := new(dns.Msg)
|
|
resp.SetRcode(r, dns.RcodeNameError)
|
|
resp.MsgHdr.Zero = true
|
|
assert.NoError(t, w.WriteMsg(resp))
|
|
}).Once()
|
|
|
|
handler3.On("ServeDNS", mock.Anything, r).Run(func(args mock.Arguments) {
|
|
// Last handler responds normally
|
|
w := args.Get(0).(*nbdns.ResponseWriterChain)
|
|
resp := new(dns.Msg)
|
|
resp.SetRcode(r, dns.RcodeSuccess)
|
|
assert.NoError(t, w.WriteMsg(resp))
|
|
}).Once()
|
|
|
|
// Execute
|
|
w := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
chain.ServeDNS(w, r)
|
|
|
|
// Verify all handlers were called in order
|
|
handler1.AssertExpectations(t)
|
|
handler2.AssertExpectations(t)
|
|
handler3.AssertExpectations(t)
|
|
}
|
|
|
|
func TestHandlerChain_PriorityDeregistration(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ops []struct {
|
|
action string // "add" or "remove"
|
|
pattern string
|
|
priority int
|
|
}
|
|
query string
|
|
expectedCalls map[int]bool // map[priority]shouldBeCalled
|
|
}{
|
|
{
|
|
name: "remove high priority keeps lower priority handler",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{"add", "example.com.", nbdns.PriorityDNSRoute},
|
|
{"add", "example.com.", nbdns.PriorityUpstream},
|
|
{"remove", "example.com.", nbdns.PriorityDNSRoute},
|
|
},
|
|
query: "example.com.",
|
|
expectedCalls: map[int]bool{
|
|
nbdns.PriorityDNSRoute: false,
|
|
nbdns.PriorityUpstream: true,
|
|
},
|
|
},
|
|
{
|
|
name: "remove lower priority keeps high priority handler",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{"add", "example.com.", nbdns.PriorityDNSRoute},
|
|
{"add", "example.com.", nbdns.PriorityUpstream},
|
|
{"remove", "example.com.", nbdns.PriorityUpstream},
|
|
},
|
|
query: "example.com.",
|
|
expectedCalls: map[int]bool{
|
|
nbdns.PriorityDNSRoute: true,
|
|
nbdns.PriorityUpstream: false,
|
|
},
|
|
},
|
|
{
|
|
name: "remove all handlers in order",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
}{
|
|
{"add", "example.com.", nbdns.PriorityDNSRoute},
|
|
{"add", "example.com.", nbdns.PriorityUpstream},
|
|
{"add", "example.com.", nbdns.PriorityDefault},
|
|
{"remove", "example.com.", nbdns.PriorityDNSRoute},
|
|
{"remove", "example.com.", nbdns.PriorityUpstream},
|
|
},
|
|
query: "example.com.",
|
|
expectedCalls: map[int]bool{
|
|
nbdns.PriorityDNSRoute: false,
|
|
nbdns.PriorityUpstream: false,
|
|
nbdns.PriorityDefault: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
handlers := make(map[int]*nbdns.MockHandler)
|
|
|
|
// Execute operations
|
|
for _, op := range tt.ops {
|
|
if op.action == "add" {
|
|
handler := &nbdns.MockHandler{}
|
|
handlers[op.priority] = handler
|
|
chain.AddHandler(op.pattern, handler, op.priority)
|
|
} else {
|
|
chain.RemoveHandler(op.pattern, op.priority)
|
|
}
|
|
}
|
|
|
|
// Create test request
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(tt.query, dns.TypeA)
|
|
w := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
|
|
// Setup expectations
|
|
for priority, handler := range handlers {
|
|
if shouldCall, exists := tt.expectedCalls[priority]; exists && shouldCall {
|
|
handler.On("ServeDNS", mock.Anything, r).Once()
|
|
} else {
|
|
handler.On("ServeDNS", mock.Anything, r).Maybe()
|
|
}
|
|
}
|
|
|
|
// Execute request
|
|
chain.ServeDNS(w, r)
|
|
|
|
// Verify expectations
|
|
for _, handler := range handlers {
|
|
handler.AssertExpectations(t)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerChain_MultiPriorityHandling(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
|
|
testDomain := "example.com."
|
|
testQuery := "test.example.com."
|
|
|
|
// Create handlers with MatchSubdomains enabled
|
|
routeHandler := &nbdns.MockSubdomainHandler{Subdomains: true}
|
|
matchHandler := &nbdns.MockSubdomainHandler{Subdomains: true}
|
|
defaultHandler := &nbdns.MockSubdomainHandler{Subdomains: true}
|
|
|
|
// Create test request that will be reused
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(testQuery, dns.TypeA)
|
|
|
|
// Keep track of mocks for the final assertion in Step 4
|
|
mocks := []*nbdns.MockSubdomainHandler{routeHandler, matchHandler, defaultHandler}
|
|
|
|
// Add handlers in mixed order
|
|
chain.AddHandler(testDomain, defaultHandler, nbdns.PriorityDefault)
|
|
chain.AddHandler(testDomain, routeHandler, nbdns.PriorityDNSRoute)
|
|
chain.AddHandler(testDomain, matchHandler, nbdns.PriorityUpstream)
|
|
|
|
// Test 1: Initial state
|
|
w1 := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
// Highest priority handler (routeHandler) should be called
|
|
routeHandler.On("ServeDNS", mock.Anything, r).Return().Once()
|
|
matchHandler.On("ServeDNS", mock.Anything, r).Maybe() // Ensure others are not expected yet
|
|
defaultHandler.On("ServeDNS", mock.Anything, r).Maybe() // Ensure others are not expected yet
|
|
|
|
chain.ServeDNS(w1, r)
|
|
routeHandler.AssertExpectations(t)
|
|
|
|
routeHandler.ExpectedCalls = nil
|
|
routeHandler.Calls = nil
|
|
matchHandler.ExpectedCalls = nil
|
|
matchHandler.Calls = nil
|
|
defaultHandler.ExpectedCalls = nil
|
|
defaultHandler.Calls = nil
|
|
|
|
// Test 2: Remove highest priority handler
|
|
chain.RemoveHandler(testDomain, nbdns.PriorityDNSRoute)
|
|
|
|
w2 := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
// Now middle priority handler (matchHandler) should be called
|
|
matchHandler.On("ServeDNS", mock.Anything, r).Return().Once()
|
|
defaultHandler.On("ServeDNS", mock.Anything, r).Maybe() // Ensure default is not expected yet
|
|
|
|
chain.ServeDNS(w2, r)
|
|
matchHandler.AssertExpectations(t)
|
|
|
|
matchHandler.ExpectedCalls = nil
|
|
matchHandler.Calls = nil
|
|
defaultHandler.ExpectedCalls = nil
|
|
defaultHandler.Calls = nil
|
|
|
|
// Test 3: Remove middle priority handler
|
|
chain.RemoveHandler(testDomain, nbdns.PriorityUpstream)
|
|
|
|
w3 := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
// Now lowest priority handler (defaultHandler) should be called
|
|
defaultHandler.On("ServeDNS", mock.Anything, r).Return().Once()
|
|
|
|
chain.ServeDNS(w3, r)
|
|
defaultHandler.AssertExpectations(t)
|
|
|
|
defaultHandler.ExpectedCalls = nil
|
|
defaultHandler.Calls = nil
|
|
|
|
// Test 4: Remove last handler
|
|
chain.RemoveHandler(testDomain, nbdns.PriorityDefault)
|
|
|
|
w4 := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
chain.ServeDNS(w4, r) // Call ServeDNS on the now empty chain for this domain
|
|
|
|
for _, m := range mocks {
|
|
m.AssertNumberOfCalls(t, "ServeDNS", 0)
|
|
}
|
|
}
|
|
|
|
func TestHandlerChain_CaseSensitivity(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
scenario string
|
|
addHandlers []struct {
|
|
pattern string
|
|
priority int
|
|
subdomains bool
|
|
shouldMatch bool
|
|
}
|
|
query string
|
|
expectedCalls int
|
|
}{
|
|
{
|
|
name: "case insensitive exact match",
|
|
scenario: "handler registered lowercase, query uppercase",
|
|
addHandlers: []struct {
|
|
pattern string
|
|
priority int
|
|
subdomains bool
|
|
shouldMatch bool
|
|
}{
|
|
{"example.com.", nbdns.PriorityDefault, false, true},
|
|
},
|
|
query: "EXAMPLE.COM.",
|
|
expectedCalls: 1,
|
|
},
|
|
{
|
|
name: "case insensitive wildcard match",
|
|
scenario: "handler registered mixed case wildcard, query different case",
|
|
addHandlers: []struct {
|
|
pattern string
|
|
priority int
|
|
subdomains bool
|
|
shouldMatch bool
|
|
}{
|
|
{"*.Example.Com.", nbdns.PriorityDefault, false, true},
|
|
},
|
|
query: "sub.EXAMPLE.COM.",
|
|
expectedCalls: 1,
|
|
},
|
|
{
|
|
name: "multiple handlers different case same domain",
|
|
scenario: "second handler should replace first despite case difference",
|
|
addHandlers: []struct {
|
|
pattern string
|
|
priority int
|
|
subdomains bool
|
|
shouldMatch bool
|
|
}{
|
|
{"EXAMPLE.COM.", nbdns.PriorityDefault, false, false},
|
|
{"example.com.", nbdns.PriorityDefault, false, true},
|
|
},
|
|
query: "ExAmPlE.cOm.",
|
|
expectedCalls: 1,
|
|
},
|
|
{
|
|
name: "subdomain matching case insensitive",
|
|
scenario: "handler with MatchSubdomains true should match regardless of case",
|
|
addHandlers: []struct {
|
|
pattern string
|
|
priority int
|
|
subdomains bool
|
|
shouldMatch bool
|
|
}{
|
|
{"example.com.", nbdns.PriorityDefault, true, true},
|
|
},
|
|
query: "SUB.EXAMPLE.COM.",
|
|
expectedCalls: 1,
|
|
},
|
|
{
|
|
name: "root zone case insensitive",
|
|
scenario: "root zone handler should match regardless of case",
|
|
addHandlers: []struct {
|
|
pattern string
|
|
priority int
|
|
subdomains bool
|
|
shouldMatch bool
|
|
}{
|
|
{".", nbdns.PriorityDefault, false, true},
|
|
},
|
|
query: "EXAMPLE.COM.",
|
|
expectedCalls: 1,
|
|
},
|
|
{
|
|
name: "multiple handlers different priority",
|
|
scenario: "should call higher priority handler despite case differences",
|
|
addHandlers: []struct {
|
|
pattern string
|
|
priority int
|
|
subdomains bool
|
|
shouldMatch bool
|
|
}{
|
|
{"EXAMPLE.COM.", nbdns.PriorityDefault, false, false},
|
|
{"example.com.", nbdns.PriorityUpstream, false, false},
|
|
{"Example.Com.", nbdns.PriorityDNSRoute, false, true},
|
|
},
|
|
query: "example.com.",
|
|
expectedCalls: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
handlerCalls := make(map[string]bool) // track which patterns were called
|
|
|
|
// Add handlers according to test case
|
|
for _, h := range tt.addHandlers {
|
|
var handler dns.Handler
|
|
pattern := h.pattern // capture pattern for closure
|
|
|
|
if h.subdomains {
|
|
subHandler := &nbdns.MockSubdomainHandler{
|
|
Subdomains: true,
|
|
}
|
|
if h.shouldMatch {
|
|
subHandler.On("ServeDNS", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
|
handlerCalls[pattern] = true
|
|
w := args.Get(0).(dns.ResponseWriter)
|
|
r := args.Get(1).(*dns.Msg)
|
|
resp := new(dns.Msg)
|
|
resp.SetRcode(r, dns.RcodeSuccess)
|
|
assert.NoError(t, w.WriteMsg(resp))
|
|
}).Once()
|
|
}
|
|
handler = subHandler
|
|
} else {
|
|
mockHandler := &nbdns.MockHandler{}
|
|
if h.shouldMatch {
|
|
mockHandler.On("ServeDNS", mock.Anything, mock.Anything).Run(func(args mock.Arguments) {
|
|
handlerCalls[pattern] = true
|
|
w := args.Get(0).(dns.ResponseWriter)
|
|
r := args.Get(1).(*dns.Msg)
|
|
resp := new(dns.Msg)
|
|
resp.SetRcode(r, dns.RcodeSuccess)
|
|
assert.NoError(t, w.WriteMsg(resp))
|
|
}).Once()
|
|
}
|
|
handler = mockHandler
|
|
}
|
|
|
|
chain.AddHandler(pattern, handler, h.priority)
|
|
}
|
|
|
|
// Execute request
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(tt.query, dns.TypeA)
|
|
chain.ServeDNS(&test.MockResponseWriter{}, r)
|
|
|
|
// Verify each handler was called exactly as expected
|
|
for _, h := range tt.addHandlers {
|
|
wasCalled := handlerCalls[h.pattern]
|
|
assert.Equal(t, h.shouldMatch, wasCalled,
|
|
"Handler for pattern %q was %s when it should%s have been",
|
|
h.pattern,
|
|
map[bool]string{true: "called", false: "not called"}[wasCalled],
|
|
map[bool]string{true: "", false: " not"}[wasCalled == h.shouldMatch])
|
|
}
|
|
|
|
// Verify total number of calls
|
|
assert.Equal(t, tt.expectedCalls, len(handlerCalls),
|
|
"Wrong number of total handler calls")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerChain_DomainSpecificityOrdering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
scenario string
|
|
ops []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
subdomain bool
|
|
}
|
|
query string
|
|
expectedMatch string
|
|
}{
|
|
{
|
|
name: "more specific domain matches first",
|
|
scenario: "sub.example.com should match before example.com",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
subdomain bool
|
|
}{
|
|
{"add", "example.com.", nbdns.PriorityUpstream, true},
|
|
{"add", "sub.example.com.", nbdns.PriorityUpstream, false},
|
|
},
|
|
query: "sub.example.com.",
|
|
expectedMatch: "sub.example.com.",
|
|
},
|
|
{
|
|
name: "more specific domain matches first, both match subdomains",
|
|
scenario: "sub.example.com should match before example.com",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
subdomain bool
|
|
}{
|
|
{"add", "example.com.", nbdns.PriorityUpstream, true},
|
|
{"add", "sub.example.com.", nbdns.PriorityUpstream, true},
|
|
},
|
|
query: "sub.example.com.",
|
|
expectedMatch: "sub.example.com.",
|
|
},
|
|
{
|
|
name: "maintain specificity order after removal",
|
|
scenario: "after removing most specific, should fall back to less specific",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
subdomain bool
|
|
}{
|
|
{"add", "example.com.", nbdns.PriorityUpstream, true},
|
|
{"add", "sub.example.com.", nbdns.PriorityUpstream, true},
|
|
{"add", "test.sub.example.com.", nbdns.PriorityUpstream, false},
|
|
{"remove", "test.sub.example.com.", nbdns.PriorityUpstream, false},
|
|
},
|
|
query: "test.sub.example.com.",
|
|
expectedMatch: "sub.example.com.",
|
|
},
|
|
{
|
|
name: "priority overrides specificity",
|
|
scenario: "less specific domain with higher priority should match first",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
subdomain bool
|
|
}{
|
|
{"add", "sub.example.com.", nbdns.PriorityUpstream, false},
|
|
{"add", "example.com.", nbdns.PriorityDNSRoute, true},
|
|
},
|
|
query: "sub.example.com.",
|
|
expectedMatch: "example.com.",
|
|
},
|
|
{
|
|
name: "equal priority respects specificity",
|
|
scenario: "with equal priority, more specific domain should match",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
subdomain bool
|
|
}{
|
|
{"add", "example.com.", nbdns.PriorityUpstream, true},
|
|
{"add", "other.example.com.", nbdns.PriorityUpstream, true},
|
|
{"add", "sub.example.com.", nbdns.PriorityUpstream, false},
|
|
},
|
|
query: "sub.example.com.",
|
|
expectedMatch: "sub.example.com.",
|
|
},
|
|
{
|
|
name: "specific matches before wildcard",
|
|
scenario: "specific domain should match before wildcard at same priority",
|
|
ops: []struct {
|
|
action string
|
|
pattern string
|
|
priority int
|
|
subdomain bool
|
|
}{
|
|
{"add", "*.example.com.", nbdns.PriorityDNSRoute, false},
|
|
{"add", "sub.example.com.", nbdns.PriorityDNSRoute, false},
|
|
},
|
|
query: "sub.example.com.",
|
|
expectedMatch: "sub.example.com.",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
handlers := make(map[string]*nbdns.MockSubdomainHandler)
|
|
|
|
for _, op := range tt.ops {
|
|
if op.action == "add" {
|
|
handler := &nbdns.MockSubdomainHandler{Subdomains: op.subdomain}
|
|
handlers[op.pattern] = handler
|
|
chain.AddHandler(op.pattern, handler, op.priority)
|
|
} else {
|
|
chain.RemoveHandler(op.pattern, op.priority)
|
|
}
|
|
}
|
|
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(tt.query, dns.TypeA)
|
|
w := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
|
|
// Setup handler expectations
|
|
for pattern, handler := range handlers {
|
|
if pattern == tt.expectedMatch {
|
|
handler.On("ServeDNS", mock.Anything, r).Run(func(args mock.Arguments) {
|
|
w := args.Get(0).(dns.ResponseWriter)
|
|
r := args.Get(1).(*dns.Msg)
|
|
resp := new(dns.Msg)
|
|
resp.SetReply(r)
|
|
assert.NoError(t, w.WriteMsg(resp))
|
|
}).Once()
|
|
}
|
|
}
|
|
|
|
chain.ServeDNS(w, r)
|
|
|
|
for pattern, handler := range handlers {
|
|
if pattern == tt.expectedMatch {
|
|
handler.AssertNumberOfCalls(t, "ServeDNS", 1)
|
|
} else {
|
|
handler.AssertNumberOfCalls(t, "ServeDNS", 0)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerChain_AddRemoveRoundtrip(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
addPattern string
|
|
removePattern string
|
|
queryPattern string
|
|
shouldBeRemoved bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "exact same pattern",
|
|
addPattern: "example.com.",
|
|
removePattern: "example.com.",
|
|
queryPattern: "example.com.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding and removing with identical patterns",
|
|
},
|
|
{
|
|
name: "case difference",
|
|
addPattern: "Example.Com.",
|
|
removePattern: "EXAMPLE.COM.",
|
|
queryPattern: "example.com.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding with mixed case, removing with uppercase",
|
|
},
|
|
{
|
|
name: "reversed case difference",
|
|
addPattern: "EXAMPLE.ORG.",
|
|
removePattern: "example.org.",
|
|
queryPattern: "example.org.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding with uppercase, removing with lowercase",
|
|
},
|
|
{
|
|
name: "add wildcard, remove wildcard",
|
|
addPattern: "*.example.com.",
|
|
removePattern: "*.example.com.",
|
|
queryPattern: "sub.example.com.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding and removing with identical wildcard patterns",
|
|
},
|
|
{
|
|
name: "add wildcard, remove transformed pattern",
|
|
addPattern: "*.example.net.",
|
|
removePattern: "example.net.",
|
|
queryPattern: "sub.example.net.",
|
|
shouldBeRemoved: false,
|
|
description: "Adding with wildcard, removing with non-wildcard pattern",
|
|
},
|
|
{
|
|
name: "add transformed pattern, remove wildcard",
|
|
addPattern: "example.io.",
|
|
removePattern: "*.example.io.",
|
|
queryPattern: "example.io.",
|
|
shouldBeRemoved: false,
|
|
description: "Adding with non-wildcard pattern, removing with wildcard pattern",
|
|
},
|
|
{
|
|
name: "trailing dot difference",
|
|
addPattern: "example.dev",
|
|
removePattern: "example.dev.",
|
|
queryPattern: "example.dev.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding without trailing dot, removing with trailing dot",
|
|
},
|
|
{
|
|
name: "reversed trailing dot difference",
|
|
addPattern: "example.app.",
|
|
removePattern: "example.app",
|
|
queryPattern: "example.app.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding with trailing dot, removing without trailing dot",
|
|
},
|
|
{
|
|
name: "mixed case and wildcard",
|
|
addPattern: "*.Example.Site.",
|
|
removePattern: "*.EXAMPLE.SITE.",
|
|
queryPattern: "sub.example.site.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding mixed case wildcard, removing uppercase wildcard",
|
|
},
|
|
{
|
|
name: "root zone",
|
|
addPattern: ".",
|
|
removePattern: ".",
|
|
queryPattern: "random.domain.",
|
|
shouldBeRemoved: true,
|
|
description: "Adding and removing root zone",
|
|
},
|
|
{
|
|
name: "wrong domain",
|
|
addPattern: "example.com.",
|
|
removePattern: "different.com.",
|
|
queryPattern: "example.com.",
|
|
shouldBeRemoved: false,
|
|
description: "Adding one domain, trying to remove a different domain",
|
|
},
|
|
{
|
|
name: "subdomain mismatch",
|
|
addPattern: "sub.example.com.",
|
|
removePattern: "example.com.",
|
|
queryPattern: "sub.example.com.",
|
|
shouldBeRemoved: false,
|
|
description: "Adding subdomain, trying to remove parent domain",
|
|
},
|
|
{
|
|
name: "parent domain mismatch",
|
|
addPattern: "example.com.",
|
|
removePattern: "sub.example.com.",
|
|
queryPattern: "example.com.",
|
|
shouldBeRemoved: false,
|
|
description: "Adding parent domain, trying to remove subdomain",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
|
|
handler := &nbdns.MockHandler{}
|
|
r := new(dns.Msg)
|
|
r.SetQuestion(tt.queryPattern, dns.TypeA)
|
|
w := &nbdns.ResponseWriterChain{ResponseWriter: &test.MockResponseWriter{}}
|
|
|
|
// First verify no handler is called before adding any
|
|
chain.ServeDNS(w, r)
|
|
handler.AssertNotCalled(t, "ServeDNS")
|
|
|
|
// Add handler
|
|
chain.AddHandler(tt.addPattern, handler, nbdns.PriorityDefault)
|
|
|
|
// Verify handler is called after adding
|
|
handler.On("ServeDNS", mock.Anything, r).Once()
|
|
chain.ServeDNS(w, r)
|
|
handler.AssertExpectations(t)
|
|
|
|
// Reset mock for the next test
|
|
handler.ExpectedCalls = nil
|
|
|
|
// Remove handler
|
|
chain.RemoveHandler(tt.removePattern, nbdns.PriorityDefault)
|
|
|
|
// Set up expectations based on whether removal should succeed
|
|
if !tt.shouldBeRemoved {
|
|
handler.On("ServeDNS", mock.Anything, r).Once()
|
|
}
|
|
|
|
// Test if handler is still called after removal attempt
|
|
chain.ServeDNS(w, r)
|
|
|
|
if tt.shouldBeRemoved {
|
|
handler.AssertNotCalled(t, "ServeDNS",
|
|
"Handler should not be called after successful removal with pattern %q",
|
|
tt.removePattern)
|
|
} else {
|
|
handler.AssertExpectations(t)
|
|
handler.ExpectedCalls = nil
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// answeringHandler writes a fixed A record to ack the query. Used to verify
|
|
// which handler ResolveInternal dispatches to.
|
|
type answeringHandler struct {
|
|
name string
|
|
ip string
|
|
}
|
|
|
|
func (h *answeringHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|
resp := &dns.Msg{}
|
|
resp.SetReply(r)
|
|
resp.Answer = []dns.RR{&dns.A{
|
|
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
|
A: net.ParseIP(h.ip).To4(),
|
|
}}
|
|
_ = w.WriteMsg(resp)
|
|
}
|
|
|
|
func (h *answeringHandler) String() string { return h.name }
|
|
|
|
func TestHandlerChain_ResolveInternal_SkipsAboveMaxPriority(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
|
|
high := &answeringHandler{name: "high", ip: "10.0.0.1"}
|
|
low := &answeringHandler{name: "low", ip: "10.0.0.2"}
|
|
|
|
chain.AddHandler("example.com.", high, nbdns.PriorityMgmtCache)
|
|
chain.AddHandler("example.com.", low, nbdns.PriorityUpstream)
|
|
|
|
r := new(dns.Msg)
|
|
r.SetQuestion("example.com.", dns.TypeA)
|
|
|
|
resp, err := chain.ResolveInternal(context.Background(), r, nbdns.PriorityUpstream)
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, resp)
|
|
assert.Equal(t, 1, len(resp.Answer))
|
|
a, ok := resp.Answer[0].(*dns.A)
|
|
assert.True(t, ok)
|
|
assert.Equal(t, "10.0.0.2", a.A.String(), "should skip mgmtCache handler and resolve via upstream")
|
|
}
|
|
|
|
func TestHandlerChain_ResolveInternal_ErrorWhenNoMatch(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
high := &answeringHandler{name: "high", ip: "10.0.0.1"}
|
|
chain.AddHandler("example.com.", high, nbdns.PriorityMgmtCache)
|
|
|
|
r := new(dns.Msg)
|
|
r.SetQuestion("example.com.", dns.TypeA)
|
|
|
|
_, err := chain.ResolveInternal(context.Background(), r, nbdns.PriorityUpstream)
|
|
assert.Error(t, err, "no handler at or below maxPriority should error")
|
|
}
|
|
|
|
// rawWriteHandler packs a response and calls ResponseWriter.Write directly
|
|
// (instead of WriteMsg), exercising the internalResponseWriter.Write path.
|
|
type rawWriteHandler struct {
|
|
ip string
|
|
}
|
|
|
|
func (h *rawWriteHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|
resp := &dns.Msg{}
|
|
resp.SetReply(r)
|
|
resp.Answer = []dns.RR{&dns.A{
|
|
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
|
A: net.ParseIP(h.ip).To4(),
|
|
}}
|
|
packed, err := resp.Pack()
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, _ = w.Write(packed)
|
|
}
|
|
|
|
func TestHandlerChain_ResolveInternal_CapturesRawWrite(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
chain.AddHandler("example.com.", &rawWriteHandler{ip: "10.0.0.3"}, nbdns.PriorityUpstream)
|
|
|
|
r := new(dns.Msg)
|
|
r.SetQuestion("example.com.", dns.TypeA)
|
|
|
|
resp, err := chain.ResolveInternal(context.Background(), r, nbdns.PriorityUpstream)
|
|
assert.NoError(t, err)
|
|
require.NotNil(t, resp)
|
|
require.Len(t, resp.Answer, 1)
|
|
a, ok := resp.Answer[0].(*dns.A)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "10.0.0.3", a.A.String(), "handlers calling Write(packed) must still surface their answer")
|
|
}
|
|
|
|
func TestHandlerChain_ResolveInternal_EmptyQuestion(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
_, err := chain.ResolveInternal(context.Background(), new(dns.Msg), nbdns.PriorityUpstream)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// hangingHandler blocks indefinitely until closed, simulating a wedged upstream.
|
|
type hangingHandler struct {
|
|
block chan struct{}
|
|
}
|
|
|
|
func (h *hangingHandler) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
|
<-h.block
|
|
resp := &dns.Msg{}
|
|
resp.SetReply(r)
|
|
_ = w.WriteMsg(resp)
|
|
}
|
|
|
|
func (h *hangingHandler) String() string { return "hangingHandler" }
|
|
|
|
func TestHandlerChain_ResolveInternal_HonorsContextTimeout(t *testing.T) {
|
|
chain := nbdns.NewHandlerChain()
|
|
h := &hangingHandler{block: make(chan struct{})}
|
|
defer close(h.block)
|
|
|
|
chain.AddHandler("example.com.", h, nbdns.PriorityUpstream)
|
|
|
|
r := new(dns.Msg)
|
|
r.SetQuestion("example.com.", dns.TypeA)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
_, err := chain.ResolveInternal(ctx, r, nbdns.PriorityUpstream)
|
|
elapsed := time.Since(start)
|
|
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, context.DeadlineExceeded)
|
|
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) {
|
|
// One hook for the whole test: logtest installs it on the standard logger
|
|
// and logrus has no way to take it off again, so a hook per subtest would
|
|
// leave several behind buffering every later line in the package.
|
|
hook := logtest.NewGlobal()
|
|
t.Cleanup(hook.Reset)
|
|
|
|
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."
|
|
|
|
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.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"}
|
|
|
|
assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "empty chain")
|
|
|
|
chain.AddHandler("example.com.", h, nbdns.PriorityUpstream)
|
|
assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "non-root handler does not count")
|
|
|
|
chain.AddHandler(".", h, nbdns.PriorityMgmtCache)
|
|
assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root handler above threshold excluded")
|
|
|
|
chain.AddHandler(".", h, nbdns.PriorityDefault)
|
|
assert.True(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root handler at PriorityDefault included")
|
|
|
|
chain.RemoveHandler(".", nbdns.PriorityDefault)
|
|
assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream))
|
|
|
|
// Primary nsgroup case: root handler lands at PriorityUpstream.
|
|
chain.AddHandler(".", h, nbdns.PriorityUpstream)
|
|
assert.True(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root at PriorityUpstream included")
|
|
chain.RemoveHandler(".", nbdns.PriorityUpstream)
|
|
|
|
// Fallback case: original /etc/resolv.conf entries land at PriorityFallback.
|
|
chain.AddHandler(".", h, nbdns.PriorityFallback)
|
|
assert.True(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream), "root at PriorityFallback included")
|
|
chain.RemoveHandler(".", nbdns.PriorityFallback)
|
|
assert.False(t, chain.HasRootHandlerAtOrBelow(nbdns.PriorityUpstream))
|
|
}
|