mirror of
https://github.com/netbirdio/netbird.git
synced 2026-03-31 06:24:18 -04:00
1422 lines
45 KiB
Go
1422 lines
45 KiB
Go
package local
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/dns/test"
|
|
nbdns "github.com/netbirdio/netbird/dns"
|
|
)
|
|
|
|
// mockResolver implements resolver for testing
|
|
type mockResolver struct {
|
|
lookupFunc func(ctx context.Context, network, host string) ([]netip.Addr, error)
|
|
}
|
|
|
|
func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
|
if m.lookupFunc != nil {
|
|
return m.lookupFunc(ctx, network, host)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func TestLocalResolver_ServeDNS(t *testing.T) {
|
|
recordA := nbdns.SimpleRecord{
|
|
Name: "peera.netbird.cloud.",
|
|
Type: 1,
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "1.2.3.4",
|
|
}
|
|
|
|
recordCNAME := nbdns.SimpleRecord{
|
|
Name: "peerb.netbird.cloud.",
|
|
Type: 5,
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "www.netbird.io",
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
inputRecord nbdns.SimpleRecord
|
|
inputMSG *dns.Msg
|
|
responseShouldBeNil bool
|
|
}{
|
|
{
|
|
name: "Should Resolve A Record",
|
|
inputRecord: recordA,
|
|
inputMSG: new(dns.Msg).SetQuestion(recordA.Name, dns.TypeA),
|
|
},
|
|
{
|
|
name: "Should Resolve CNAME Record",
|
|
inputRecord: recordCNAME,
|
|
inputMSG: new(dns.Msg).SetQuestion(recordCNAME.Name, dns.TypeCNAME),
|
|
},
|
|
{
|
|
name: "Should Not Write When Not Found A Record",
|
|
inputRecord: recordA,
|
|
inputMSG: new(dns.Msg).SetQuestion("not.found.com", dns.TypeA),
|
|
responseShouldBeNil: true,
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
_ = resolver.RegisterRecord(testCase.inputRecord)
|
|
var responseMSG *dns.Msg
|
|
responseWriter := &test.MockResponseWriter{
|
|
WriteMsgFunc: func(m *dns.Msg) error {
|
|
responseMSG = m
|
|
return nil
|
|
},
|
|
}
|
|
|
|
resolver.ServeDNS(responseWriter, testCase.inputMSG)
|
|
|
|
if responseMSG == nil || len(responseMSG.Answer) == 0 {
|
|
if testCase.responseShouldBeNil {
|
|
return
|
|
}
|
|
t.Fatalf("should write a response message")
|
|
}
|
|
|
|
answerString := responseMSG.Answer[0].String()
|
|
if !strings.Contains(answerString, testCase.inputRecord.Name) {
|
|
t.Fatalf("answer doesn't contain the same domain name: \nWant: %s\nGot:%s", testCase.name, answerString)
|
|
}
|
|
if !strings.Contains(answerString, dns.Type(testCase.inputRecord.Type).String()) {
|
|
t.Fatalf("answer doesn't contain the correct type: \nWant: %s\nGot:%s", dns.Type(testCase.inputRecord.Type).String(), answerString)
|
|
}
|
|
if !strings.Contains(answerString, testCase.inputRecord.RData) {
|
|
t.Fatalf("answer doesn't contain the same address: \nWant: %s\nGot:%s", testCase.inputRecord.RData, answerString)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocalResolver_Update_StaleRecord verifies that updating
|
|
// a record correctly replaces the old one, preventing stale entries.
|
|
func TestLocalResolver_Update_StaleRecord(t *testing.T) {
|
|
recordName := "host.example.com."
|
|
recordType := dns.TypeA
|
|
recordClass := dns.ClassINET
|
|
|
|
record1 := nbdns.SimpleRecord{
|
|
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "1.1.1.1",
|
|
}
|
|
record2 := nbdns.SimpleRecord{
|
|
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "2.2.2.2",
|
|
}
|
|
|
|
recordKey := dns.Question{Name: recordName, Qtype: uint16(recordClass), Qclass: recordType}
|
|
|
|
resolver := NewResolver()
|
|
|
|
zone1 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1}}}
|
|
zone2 := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record2}}}
|
|
|
|
// Apply first update
|
|
resolver.Update(zone1)
|
|
|
|
// Verify first update
|
|
resolver.mu.RLock()
|
|
rrSlice1, found1 := resolver.records[recordKey]
|
|
resolver.mu.RUnlock()
|
|
|
|
require.True(t, found1, "Record key %s not found after first update", recordKey)
|
|
require.Len(t, rrSlice1, 1, "Should have exactly 1 record after first update")
|
|
assert.Contains(t, rrSlice1[0].String(), record1.RData, "Record after first update should be %s", record1.RData)
|
|
|
|
// Apply second update
|
|
resolver.Update(zone2)
|
|
|
|
// Verify second update
|
|
resolver.mu.RLock()
|
|
rrSlice2, found2 := resolver.records[recordKey]
|
|
resolver.mu.RUnlock()
|
|
|
|
require.True(t, found2, "Record key %s not found after second update", recordKey)
|
|
require.Len(t, rrSlice2, 1, "Should have exactly 1 record after update overwriting the key")
|
|
assert.Contains(t, rrSlice2[0].String(), record2.RData, "The single record should be the updated one (%s)", record2.RData)
|
|
assert.NotContains(t, rrSlice2[0].String(), record1.RData, "The stale record (%s) should not be present", record1.RData)
|
|
}
|
|
|
|
// TestLocalResolver_MultipleRecords_SameQuestion verifies that multiple records
|
|
// with the same question are stored properly
|
|
func TestLocalResolver_MultipleRecords_SameQuestion(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
recordName := "multi.example.com."
|
|
recordType := dns.TypeA
|
|
|
|
// Create two records with the same name and type but different IPs
|
|
record1 := nbdns.SimpleRecord{
|
|
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1",
|
|
}
|
|
record2 := nbdns.SimpleRecord{
|
|
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.2",
|
|
}
|
|
|
|
zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2}}}
|
|
|
|
// Apply update with both records
|
|
resolver.Update(zones)
|
|
|
|
// Create question that matches both records
|
|
question := dns.Question{
|
|
Name: recordName,
|
|
Qtype: recordType,
|
|
Qclass: dns.ClassINET,
|
|
}
|
|
|
|
// Verify both records are stored
|
|
resolver.mu.RLock()
|
|
records, found := resolver.records[question]
|
|
resolver.mu.RUnlock()
|
|
|
|
require.True(t, found, "Records for question %v not found", question)
|
|
require.Len(t, records, 2, "Should have exactly 2 records for the same question")
|
|
|
|
// Verify both record data values are present
|
|
recordStrings := []string{records[0].String(), records[1].String()}
|
|
assert.Contains(t, recordStrings[0]+recordStrings[1], record1.RData, "First record data should be present")
|
|
assert.Contains(t, recordStrings[0]+recordStrings[1], record2.RData, "Second record data should be present")
|
|
}
|
|
|
|
// TestLocalResolver_RecordRotation verifies that records are rotated in a round-robin fashion
|
|
func TestLocalResolver_RecordRotation(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
recordName := "rotation.example.com."
|
|
recordType := dns.TypeA
|
|
|
|
// Create three records with the same name and type but different IPs
|
|
record1 := nbdns.SimpleRecord{
|
|
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1",
|
|
}
|
|
record2 := nbdns.SimpleRecord{
|
|
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.2",
|
|
}
|
|
record3 := nbdns.SimpleRecord{
|
|
Name: recordName, Type: int(recordType), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.3",
|
|
}
|
|
|
|
zones := []nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{record1, record2, record3}}}
|
|
|
|
// Apply update with all three records
|
|
resolver.Update(zones)
|
|
|
|
msg := new(dns.Msg).SetQuestion(recordName, recordType)
|
|
|
|
// First lookup - should return the records in original order
|
|
var responses [3]*dns.Msg
|
|
|
|
// Perform three lookups to verify rotation
|
|
for i := 0; i < 3; i++ {
|
|
responseWriter := &test.MockResponseWriter{
|
|
WriteMsgFunc: func(m *dns.Msg) error {
|
|
responses[i] = m
|
|
return nil
|
|
},
|
|
}
|
|
|
|
resolver.ServeDNS(responseWriter, msg)
|
|
}
|
|
|
|
// Verify all three responses contain answers
|
|
for i, resp := range responses {
|
|
require.NotNil(t, resp, "Response %d should not be nil", i)
|
|
require.Len(t, resp.Answer, 3, "Response %d should have 3 answers", i)
|
|
}
|
|
|
|
// Verify the first record in each response is different due to rotation
|
|
firstRecordIPs := []string{
|
|
responses[0].Answer[0].String(),
|
|
responses[1].Answer[0].String(),
|
|
responses[2].Answer[0].String(),
|
|
}
|
|
|
|
// Each record should be different (rotated)
|
|
assert.NotEqual(t, firstRecordIPs[0], firstRecordIPs[1], "First lookup should differ from second lookup due to rotation")
|
|
assert.NotEqual(t, firstRecordIPs[1], firstRecordIPs[2], "Second lookup should differ from third lookup due to rotation")
|
|
assert.NotEqual(t, firstRecordIPs[0], firstRecordIPs[2], "First lookup should differ from third lookup due to rotation")
|
|
|
|
// After three rotations, we should have cycled through all records
|
|
assert.Contains(t, firstRecordIPs[0]+firstRecordIPs[1]+firstRecordIPs[2], record1.RData)
|
|
assert.Contains(t, firstRecordIPs[0]+firstRecordIPs[1]+firstRecordIPs[2], record2.RData)
|
|
assert.Contains(t, firstRecordIPs[0]+firstRecordIPs[1]+firstRecordIPs[2], record3.RData)
|
|
}
|
|
|
|
// TestLocalResolver_CaseInsensitiveMatching verifies that DNS record lookups are case-insensitive
|
|
func TestLocalResolver_CaseInsensitiveMatching(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
// Create record with lowercase name
|
|
lowerCaseRecord := nbdns.SimpleRecord{
|
|
Name: "lower.example.com.",
|
|
Type: int(dns.TypeA),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "10.10.10.10",
|
|
}
|
|
|
|
// Create record with mixed case name
|
|
mixedCaseRecord := nbdns.SimpleRecord{
|
|
Name: "MiXeD.ExAmPlE.CoM.",
|
|
Type: int(dns.TypeA),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "20.20.20.20",
|
|
}
|
|
|
|
// Update resolver with the records
|
|
resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{lowerCaseRecord, mixedCaseRecord}}})
|
|
|
|
testCases := []struct {
|
|
name string
|
|
queryName string
|
|
expectedRData string
|
|
shouldResolve bool
|
|
}{
|
|
{
|
|
name: "Query lowercase with lowercase record",
|
|
queryName: "lower.example.com.",
|
|
expectedRData: "10.10.10.10",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query uppercase with lowercase record",
|
|
queryName: "LOWER.EXAMPLE.COM.",
|
|
expectedRData: "10.10.10.10",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query mixed case with lowercase record",
|
|
queryName: "LoWeR.eXaMpLe.CoM.",
|
|
expectedRData: "10.10.10.10",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query lowercase with mixed case record",
|
|
queryName: "mixed.example.com.",
|
|
expectedRData: "20.20.20.20",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query uppercase with mixed case record",
|
|
queryName: "MIXED.EXAMPLE.COM.",
|
|
expectedRData: "20.20.20.20",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query with different casing pattern",
|
|
queryName: "mIxEd.ExaMpLe.cOm.",
|
|
expectedRData: "20.20.20.20",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query non-existent domain",
|
|
queryName: "nonexistent.example.com.",
|
|
shouldResolve: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var responseMSG *dns.Msg
|
|
|
|
// Create DNS query with the test case name
|
|
msg := new(dns.Msg).SetQuestion(tc.queryName, dns.TypeA)
|
|
|
|
// Create mock response writer to capture the response
|
|
responseWriter := &test.MockResponseWriter{
|
|
WriteMsgFunc: func(m *dns.Msg) error {
|
|
responseMSG = m
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// Perform DNS query
|
|
resolver.ServeDNS(responseWriter, msg)
|
|
|
|
// Check if we expect a successful resolution
|
|
if !tc.shouldResolve {
|
|
if responseMSG == nil || len(responseMSG.Answer) == 0 {
|
|
// Expected no answer, test passes
|
|
return
|
|
}
|
|
t.Fatalf("Expected no resolution for %s, but got answer: %v", tc.queryName, responseMSG.Answer)
|
|
}
|
|
|
|
// Verify we got a response
|
|
require.NotNil(t, responseMSG, "Should have received a response message")
|
|
require.Greater(t, len(responseMSG.Answer), 0, "Response should contain at least one answer")
|
|
|
|
// Verify the response contains the expected data
|
|
answerString := responseMSG.Answer[0].String()
|
|
assert.Contains(t, answerString, tc.expectedRData,
|
|
"Answer should contain the expected IP address %s, got: %s",
|
|
tc.expectedRData, answerString)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocalResolver_CNAMEFallback verifies that the resolver correctly falls back
|
|
// to checking for CNAME records when the requested record type isn't found
|
|
func TestLocalResolver_CNAMEFallback(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
// Create a CNAME record (but no A record for this name)
|
|
cnameRecord := nbdns.SimpleRecord{
|
|
Name: "alias.example.com.",
|
|
Type: int(dns.TypeCNAME),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "target.example.com.",
|
|
}
|
|
|
|
// Create an A record for the CNAME target
|
|
targetRecord := nbdns.SimpleRecord{
|
|
Name: "target.example.com.",
|
|
Type: int(dns.TypeA),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "192.168.100.100",
|
|
}
|
|
|
|
// Update resolver with both records
|
|
resolver.Update([]nbdns.CustomZone{{Domain: "example.com.", Records: []nbdns.SimpleRecord{cnameRecord, targetRecord}}})
|
|
|
|
testCases := []struct {
|
|
name string
|
|
queryName string
|
|
queryType uint16
|
|
expectedType string
|
|
expectedRData string
|
|
shouldResolve bool
|
|
}{
|
|
{
|
|
name: "Directly query CNAME record",
|
|
queryName: "alias.example.com.",
|
|
queryType: dns.TypeCNAME,
|
|
expectedType: "CNAME",
|
|
expectedRData: "target.example.com.",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query A record but get CNAME fallback",
|
|
queryName: "alias.example.com.",
|
|
queryType: dns.TypeA,
|
|
expectedType: "CNAME",
|
|
expectedRData: "target.example.com.",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query AAAA record but get CNAME fallback",
|
|
queryName: "alias.example.com.",
|
|
queryType: dns.TypeAAAA,
|
|
expectedType: "CNAME",
|
|
expectedRData: "target.example.com.",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query direct A record",
|
|
queryName: "target.example.com.",
|
|
queryType: dns.TypeA,
|
|
expectedType: "A",
|
|
expectedRData: "192.168.100.100",
|
|
shouldResolve: true,
|
|
},
|
|
{
|
|
name: "Query non-existent name",
|
|
queryName: "nonexistent.example.com.",
|
|
queryType: dns.TypeA,
|
|
shouldResolve: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var responseMSG *dns.Msg
|
|
|
|
// Create DNS query with the test case parameters
|
|
msg := new(dns.Msg).SetQuestion(tc.queryName, tc.queryType)
|
|
|
|
// Create mock response writer to capture the response
|
|
responseWriter := &test.MockResponseWriter{
|
|
WriteMsgFunc: func(m *dns.Msg) error {
|
|
responseMSG = m
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// Perform DNS query
|
|
resolver.ServeDNS(responseWriter, msg)
|
|
|
|
// Check if we expect a successful resolution
|
|
if !tc.shouldResolve {
|
|
if responseMSG == nil || len(responseMSG.Answer) == 0 || responseMSG.Rcode != dns.RcodeSuccess {
|
|
// Expected no resolution, test passes
|
|
return
|
|
}
|
|
t.Fatalf("Expected no resolution for %s, but got answer: %v", tc.queryName, responseMSG.Answer)
|
|
}
|
|
|
|
// Verify we got a successful response
|
|
require.NotNil(t, responseMSG, "Should have received a response message")
|
|
require.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "Response should have success status code")
|
|
require.Greater(t, len(responseMSG.Answer), 0, "Response should contain at least one answer")
|
|
|
|
// Verify the response contains the expected data
|
|
answerString := responseMSG.Answer[0].String()
|
|
assert.Contains(t, answerString, tc.expectedType,
|
|
"Answer should be of type %s, got: %s", tc.expectedType, answerString)
|
|
assert.Contains(t, answerString, tc.expectedRData,
|
|
"Answer should contain the expected data %s, got: %s", tc.expectedRData, answerString)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocalResolver_NoErrorWithDifferentRecordType verifies that querying for a record type
|
|
// that doesn't exist but where other record types exist for the same domain returns NOERROR
|
|
// with 0 records instead of NXDOMAIN
|
|
func TestLocalResolver_NoErrorWithDifferentRecordType(t *testing.T) {
|
|
resolver := NewResolver()
|
|
// Mock external resolver for CNAME target resolution
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if host == "target.example.com." {
|
|
if network == "ip4" {
|
|
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
|
}
|
|
if network == "ip6" {
|
|
return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil
|
|
}
|
|
}
|
|
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
|
},
|
|
}
|
|
|
|
recordA := nbdns.SimpleRecord{
|
|
Name: "example.netbird.cloud.",
|
|
Type: int(dns.TypeA),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "192.168.1.100",
|
|
}
|
|
|
|
recordCNAME := nbdns.SimpleRecord{
|
|
Name: "alias.netbird.cloud.",
|
|
Type: int(dns.TypeCNAME),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "target.example.com.",
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud.", Records: []nbdns.SimpleRecord{recordA, recordCNAME}}})
|
|
|
|
testCases := []struct {
|
|
name string
|
|
queryName string
|
|
queryType uint16
|
|
expectedRcode int
|
|
shouldHaveData bool
|
|
}{
|
|
{
|
|
name: "Query A record that exists",
|
|
queryName: "example.netbird.cloud.",
|
|
queryType: dns.TypeA,
|
|
expectedRcode: dns.RcodeSuccess,
|
|
shouldHaveData: true,
|
|
},
|
|
{
|
|
name: "Query AAAA for domain with only A record",
|
|
queryName: "example.netbird.cloud.",
|
|
queryType: dns.TypeAAAA,
|
|
expectedRcode: dns.RcodeSuccess,
|
|
shouldHaveData: false,
|
|
},
|
|
{
|
|
name: "Query other record with different case and non-fqdn",
|
|
queryName: "EXAMPLE.netbird.cloud",
|
|
queryType: dns.TypeAAAA,
|
|
expectedRcode: dns.RcodeSuccess,
|
|
shouldHaveData: false,
|
|
},
|
|
{
|
|
name: "Query TXT for domain with only A record",
|
|
queryName: "example.netbird.cloud.",
|
|
queryType: dns.TypeTXT,
|
|
expectedRcode: dns.RcodeSuccess,
|
|
shouldHaveData: false,
|
|
},
|
|
{
|
|
name: "Query A for domain with only CNAME record",
|
|
queryName: "alias.netbird.cloud.",
|
|
queryType: dns.TypeA,
|
|
expectedRcode: dns.RcodeSuccess,
|
|
shouldHaveData: true,
|
|
},
|
|
{
|
|
name: "Query AAAA for domain with only CNAME record",
|
|
queryName: "alias.netbird.cloud.",
|
|
queryType: dns.TypeAAAA,
|
|
expectedRcode: dns.RcodeSuccess,
|
|
shouldHaveData: true,
|
|
},
|
|
{
|
|
name: "Query for completely non-existent domain",
|
|
queryName: "nonexistent.netbird.cloud.",
|
|
queryType: dns.TypeA,
|
|
expectedRcode: dns.RcodeNameError,
|
|
shouldHaveData: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var responseMSG *dns.Msg
|
|
|
|
msg := new(dns.Msg).SetQuestion(tc.queryName, tc.queryType)
|
|
|
|
responseWriter := &test.MockResponseWriter{
|
|
WriteMsgFunc: func(m *dns.Msg) error {
|
|
responseMSG = m
|
|
return nil
|
|
},
|
|
}
|
|
|
|
resolver.ServeDNS(responseWriter, msg)
|
|
|
|
require.NotNil(t, responseMSG, "Should have received a response message")
|
|
|
|
assert.Equal(t, tc.expectedRcode, responseMSG.Rcode,
|
|
"Response code should be %d (%s)",
|
|
tc.expectedRcode, dns.RcodeToString[tc.expectedRcode])
|
|
|
|
if tc.shouldHaveData {
|
|
assert.Greater(t, len(responseMSG.Answer), 0, "Response should contain answers")
|
|
} else {
|
|
assert.Equal(t, 0, len(responseMSG.Answer), "Response should contain no answers")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocalResolver_CNAMEChainResolution tests comprehensive CNAME chain following
|
|
func TestLocalResolver_CNAMEChainResolution(t *testing.T) {
|
|
t.Run("simple internal CNAME chain", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "example.com.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.example.com.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.example.com."},
|
|
{Name: "target.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "192.168.1.1"},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.example.com.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
|
require.Len(t, resp.Answer, 2)
|
|
|
|
cname, ok := resp.Answer[0].(*dns.CNAME)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "target.example.com.", cname.Target)
|
|
|
|
a, ok := resp.Answer[1].(*dns.A)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "192.168.1.1", a.A.String())
|
|
})
|
|
|
|
t.Run("multi-hop CNAME chain", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "hop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop2.test."},
|
|
{Name: "hop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "hop3.test."},
|
|
{Name: "hop3.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
|
require.Len(t, resp.Answer, 3)
|
|
})
|
|
|
|
t.Run("CNAME to non-existent internal target returns only CNAME", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.test."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
require.Len(t, resp.Answer, 1)
|
|
_, ok := resp.Answer[0].(*dns.CNAME)
|
|
assert.True(t, ok)
|
|
})
|
|
}
|
|
|
|
// TestLocalResolver_CNAMEMaxDepth tests the maximum depth limit for CNAME chains
|
|
func TestLocalResolver_CNAMEMaxDepth(t *testing.T) {
|
|
t.Run("chain at max depth resolves", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
var records []nbdns.SimpleRecord
|
|
// Create chain of 7 CNAMEs (under max of 8)
|
|
for i := 1; i <= 7; i++ {
|
|
records = append(records, nbdns.SimpleRecord{
|
|
Name: fmt.Sprintf("hop%d.test.", i),
|
|
Type: int(dns.TypeCNAME),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: fmt.Sprintf("hop%d.test.", i+1),
|
|
})
|
|
}
|
|
records = append(records, nbdns.SimpleRecord{
|
|
Name: "hop8.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10",
|
|
})
|
|
|
|
resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("hop1.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
|
require.Len(t, resp.Answer, 8)
|
|
})
|
|
|
|
t.Run("chain exceeding max depth stops", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
var records []nbdns.SimpleRecord
|
|
// Create chain of 10 CNAMEs (exceeds max of 8)
|
|
for i := 1; i <= 10; i++ {
|
|
records = append(records, nbdns.SimpleRecord{
|
|
Name: fmt.Sprintf("deep%d.test.", i),
|
|
Type: int(dns.TypeCNAME),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: fmt.Sprintf("deep%d.test.", i+1),
|
|
})
|
|
}
|
|
records = append(records, nbdns.SimpleRecord{
|
|
Name: "deep11.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.10.10.10",
|
|
})
|
|
|
|
resolver.Update([]nbdns.CustomZone{{Domain: "test.", Records: records}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("deep1.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
// Should NOT have the final A record (chain too deep)
|
|
assert.LessOrEqual(t, len(resp.Answer), 8)
|
|
})
|
|
|
|
t.Run("circular CNAME is protected by max depth", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "loop1.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop2.test."},
|
|
{Name: "loop2.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "loop1.test."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("loop1.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.LessOrEqual(t, len(resp.Answer), 8)
|
|
})
|
|
}
|
|
|
|
// TestLocalResolver_ExternalCNAMEResolution tests CNAME resolution to external domains
|
|
func TestLocalResolver_ExternalCNAMEResolution(t *testing.T) {
|
|
t.Run("CNAME to external domain resolves via external resolver", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if host == "external.example.com." && network == "ip4" {
|
|
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
require.Len(t, resp.Answer, 2, "Should have CNAME + A record")
|
|
|
|
cname, ok := resp.Answer[0].(*dns.CNAME)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "external.example.com.", cname.Target)
|
|
|
|
a, ok := resp.Answer[1].(*dns.A)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "93.184.216.34", a.A.String())
|
|
})
|
|
|
|
t.Run("CNAME to external domain resolves IPv6", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if host == "external.example.com." && network == "ip6" {
|
|
return []netip.Addr{netip.MustParseAddr("2606:2800:220:1:248:1893:25c8:1946")}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
require.Len(t, resp.Answer, 2, "Should have CNAME + AAAA record")
|
|
|
|
cname, ok := resp.Answer[0].(*dns.CNAME)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "external.example.com.", cname.Target)
|
|
|
|
aaaa, ok := resp.Answer[1].(*dns.AAAA)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "2606:2800:220:1:248:1893:25c8:1946", aaaa.AAAA.String())
|
|
})
|
|
|
|
t.Run("concurrent external resolution", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if host == "external.example.com." && network == "ip4" {
|
|
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "concurrent.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
|
},
|
|
}})
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]*dns.Msg, 10)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func(idx int) {
|
|
defer wg.Done()
|
|
msg := new(dns.Msg).SetQuestion("concurrent.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
results[idx] = resp
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, resp := range results {
|
|
require.NotNil(t, resp, "Response %d should not be nil", i)
|
|
require.Len(t, resp.Answer, 2, "Response %d should have CNAME + A", i)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestLocalResolver_ZoneManagement tests zone-aware CNAME resolution
|
|
func TestLocalResolver_ZoneManagement(t *testing.T) {
|
|
t.Run("Update sets zones correctly", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
resolver.Update([]nbdns.CustomZone{
|
|
{Domain: "example.com.", Records: []nbdns.SimpleRecord{
|
|
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
|
}},
|
|
{Domain: "test.local."},
|
|
})
|
|
|
|
assert.True(t, resolver.isInManagedZone("host.example.com."))
|
|
assert.True(t, resolver.isInManagedZone("other.example.com."))
|
|
assert.True(t, resolver.isInManagedZone("sub.test.local."))
|
|
assert.False(t, resolver.isInManagedZone("external.com."))
|
|
})
|
|
|
|
t.Run("isInManagedZone case insensitive", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{Domain: "Example.COM."}})
|
|
|
|
assert.True(t, resolver.isInManagedZone("host.example.com."))
|
|
assert.True(t, resolver.isInManagedZone("HOST.EXAMPLE.COM."))
|
|
})
|
|
|
|
t.Run("Update clears zones", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{Domain: "example.com."}})
|
|
assert.True(t, resolver.isInManagedZone("host.example.com."))
|
|
|
|
resolver.Update(nil)
|
|
assert.False(t, resolver.isInManagedZone("host.example.com."))
|
|
})
|
|
}
|
|
|
|
// TestLocalResolver_CNAMEZoneAwareResolution tests CNAME resolution with zone awareness
|
|
func TestLocalResolver_CNAMEZoneAwareResolution(t *testing.T) {
|
|
t.Run("CNAME target in managed zone returns NXDOMAIN per RFC 6604", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "myzone.test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "nonexistent.myzone.test."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.Equal(t, dns.RcodeNameError, resp.Rcode, "Should return NXDOMAIN")
|
|
require.Len(t, resp.Answer, 1, "Should include CNAME in answer")
|
|
})
|
|
|
|
t.Run("CNAME to external domain skips zone check", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if host == "external.other.com." && network == "ip4" {
|
|
return []netip.Addr{netip.MustParseAddr("203.0.113.1")}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "myzone.test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.other.com."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
|
require.Len(t, resp.Answer, 2, "Should have CNAME + A from external resolution")
|
|
})
|
|
|
|
t.Run("CNAME target exists with different type returns NODATA not NXDOMAIN", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
// CNAME points to target that has A but no AAAA - query for AAAA should be NODATA
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "myzone.test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.myzone.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "target.myzone.test."},
|
|
{Name: "target.myzone.test.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.1.1.1"},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.myzone.test.", dns.TypeAAAA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN")
|
|
require.Len(t, resp.Answer, 1, "Should have only CNAME, no AAAA")
|
|
_, ok := resp.Answer[0].(*dns.CNAME)
|
|
assert.True(t, ok, "Answer should be CNAME record")
|
|
})
|
|
|
|
t.Run("external CNAME target exists but no AAAA records (NODATA)", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if host == "external.example.com." {
|
|
if network == "ip6" {
|
|
// No AAAA records
|
|
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
|
}
|
|
if network == "ip4" {
|
|
// But A records exist - domain exists
|
|
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
|
}
|
|
}
|
|
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
|
},
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeAAAA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "Should return NODATA (success), not NXDOMAIN")
|
|
require.Len(t, resp.Answer, 1, "Should have only CNAME")
|
|
_, ok := resp.Answer[0].(*dns.CNAME)
|
|
assert.True(t, ok, "Answer should be CNAME record")
|
|
})
|
|
|
|
// Table-driven test for all external resolution outcomes
|
|
externalCases := []struct {
|
|
name string
|
|
lookupFunc func(context.Context, string, string) ([]netip.Addr, error)
|
|
expectedRcode int
|
|
expectedAnswer int
|
|
}{
|
|
{
|
|
name: "external NXDOMAIN (both A and AAAA not found)",
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
|
},
|
|
expectedRcode: dns.RcodeNameError,
|
|
expectedAnswer: 1, // CNAME only
|
|
},
|
|
{
|
|
name: "external SERVFAIL (temporary error)",
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
return nil, &net.DNSError{IsTemporary: true, Name: host}
|
|
},
|
|
expectedRcode: dns.RcodeServerFailure,
|
|
expectedAnswer: 1, // CNAME only
|
|
},
|
|
{
|
|
name: "external SERVFAIL (timeout)",
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
return nil, &net.DNSError{IsTimeout: true, Name: host}
|
|
},
|
|
expectedRcode: dns.RcodeServerFailure,
|
|
expectedAnswer: 1, // CNAME only
|
|
},
|
|
{
|
|
name: "external SERVFAIL (generic error)",
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
return nil, fmt.Errorf("connection refused")
|
|
},
|
|
expectedRcode: dns.RcodeServerFailure,
|
|
expectedAnswer: 1, // CNAME only
|
|
},
|
|
{
|
|
name: "external success with IPs",
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if network == "ip4" {
|
|
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
|
}
|
|
return nil, &net.DNSError{IsNotFound: true, Name: host}
|
|
},
|
|
expectedRcode: dns.RcodeSuccess,
|
|
expectedAnswer: 2, // CNAME + A
|
|
},
|
|
}
|
|
|
|
for _, tc := range externalCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.resolver = &mockResolver{lookupFunc: tc.lookupFunc}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.Equal(t, tc.expectedRcode, resp.Rcode, "rcode mismatch")
|
|
assert.Len(t, resp.Answer, tc.expectedAnswer, "answer count mismatch")
|
|
if tc.expectedAnswer > 0 {
|
|
_, ok := resp.Answer[0].(*dns.CNAME)
|
|
assert.True(t, ok, "first answer should be CNAME")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocalResolver_Fallthrough verifies that non-authoritative zones
|
|
// trigger fallthrough (Zero bit set) when no records match
|
|
func TestLocalResolver_Fallthrough(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
record := nbdns.SimpleRecord{
|
|
Name: "existing.custom.zone.",
|
|
Type: int(dns.TypeA),
|
|
Class: nbdns.DefaultClass,
|
|
TTL: 300,
|
|
RData: "10.0.0.1",
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
zones []nbdns.CustomZone
|
|
queryName string
|
|
expectFallthrough bool
|
|
expectRecord bool
|
|
}{
|
|
{
|
|
name: "Authoritative zone returns NXDOMAIN without fallthrough",
|
|
zones: []nbdns.CustomZone{{
|
|
Domain: "custom.zone.",
|
|
Records: []nbdns.SimpleRecord{record},
|
|
}},
|
|
queryName: "nonexistent.custom.zone.",
|
|
expectFallthrough: false,
|
|
expectRecord: false,
|
|
},
|
|
{
|
|
name: "Non-authoritative zone triggers fallthrough",
|
|
zones: []nbdns.CustomZone{{
|
|
Domain: "custom.zone.",
|
|
Records: []nbdns.SimpleRecord{record},
|
|
NonAuthoritative: true,
|
|
}},
|
|
queryName: "nonexistent.custom.zone.",
|
|
expectFallthrough: true,
|
|
expectRecord: false,
|
|
},
|
|
{
|
|
name: "Record found in non-authoritative zone returns normally",
|
|
zones: []nbdns.CustomZone{{
|
|
Domain: "custom.zone.",
|
|
Records: []nbdns.SimpleRecord{record},
|
|
NonAuthoritative: true,
|
|
}},
|
|
queryName: "existing.custom.zone.",
|
|
expectFallthrough: false,
|
|
expectRecord: true,
|
|
},
|
|
{
|
|
name: "Record found in authoritative zone returns normally",
|
|
zones: []nbdns.CustomZone{{
|
|
Domain: "custom.zone.",
|
|
Records: []nbdns.SimpleRecord{record},
|
|
}},
|
|
queryName: "existing.custom.zone.",
|
|
expectFallthrough: false,
|
|
expectRecord: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
resolver.Update(tc.zones)
|
|
|
|
var responseMSG *dns.Msg
|
|
responseWriter := &test.MockResponseWriter{
|
|
WriteMsgFunc: func(m *dns.Msg) error {
|
|
responseMSG = m
|
|
return nil
|
|
},
|
|
}
|
|
|
|
msg := new(dns.Msg).SetQuestion(tc.queryName, dns.TypeA)
|
|
resolver.ServeDNS(responseWriter, msg)
|
|
|
|
require.NotNil(t, responseMSG, "Should have received a response")
|
|
|
|
if tc.expectFallthrough {
|
|
assert.True(t, responseMSG.MsgHdr.Zero, "Zero bit should be set for fallthrough")
|
|
assert.Equal(t, dns.RcodeNameError, responseMSG.Rcode, "Should return NXDOMAIN")
|
|
} else {
|
|
assert.False(t, responseMSG.MsgHdr.Zero, "Zero bit should not be set")
|
|
}
|
|
|
|
if tc.expectRecord {
|
|
assert.Greater(t, len(responseMSG.Answer), 0, "Should have answer records")
|
|
assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLocalResolver_AuthoritativeFlag tests the AA flag behavior
|
|
func TestLocalResolver_AuthoritativeFlag(t *testing.T) {
|
|
t.Run("direct record lookup is authoritative", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "example.com.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.True(t, resp.Authoritative)
|
|
})
|
|
|
|
t.Run("external resolution is not authoritative", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
|
if host == "external.example.com." && network == "ip4" {
|
|
return []netip.Addr{netip.MustParseAddr("93.184.216.34")}, nil
|
|
}
|
|
return nil, nil
|
|
},
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
|
},
|
|
}})
|
|
|
|
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
require.Len(t, resp.Answer, 2)
|
|
assert.False(t, resp.Authoritative)
|
|
})
|
|
}
|
|
|
|
// TestLocalResolver_Stop tests cleanup on Stop
|
|
func TestLocalResolver_Stop(t *testing.T) {
|
|
t.Run("Stop clears all state", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "example.com.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
|
},
|
|
}})
|
|
|
|
resolver.Stop()
|
|
|
|
msg := new(dns.Msg).SetQuestion("host.example.com.", dns.TypeA)
|
|
var resp *dns.Msg
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}, msg)
|
|
|
|
require.NotNil(t, resp)
|
|
assert.Len(t, resp.Answer, 0)
|
|
assert.False(t, resolver.isInManagedZone("host.example.com."))
|
|
})
|
|
|
|
t.Run("Stop is safe to call multiple times", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "example.com.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"},
|
|
},
|
|
}})
|
|
|
|
resolver.Stop()
|
|
resolver.Stop()
|
|
resolver.Stop()
|
|
})
|
|
|
|
t.Run("Stop cancels in-flight external resolution", func(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
lookupStarted := make(chan struct{})
|
|
lookupCtxCanceled := make(chan struct{})
|
|
|
|
resolver.resolver = &mockResolver{
|
|
lookupFunc: func(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
|
close(lookupStarted)
|
|
<-ctx.Done()
|
|
close(lookupCtxCanceled)
|
|
return nil, ctx.Err()
|
|
},
|
|
}
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "test.",
|
|
Records: []nbdns.SimpleRecord{
|
|
{Name: "alias.test.", Type: int(dns.TypeCNAME), Class: nbdns.DefaultClass, TTL: 300, RData: "external.example.com."},
|
|
},
|
|
}})
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
msg := new(dns.Msg).SetQuestion("alias.test.", dns.TypeA)
|
|
resolver.ServeDNS(&test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}, msg)
|
|
close(done)
|
|
}()
|
|
|
|
<-lookupStarted
|
|
resolver.Stop()
|
|
|
|
select {
|
|
case <-lookupCtxCanceled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("external lookup context was not canceled")
|
|
}
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("ServeDNS did not return after Stop")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestLocalResolver_FallthroughCaseInsensitive verifies case-insensitive domain matching for fallthrough
|
|
func TestLocalResolver_FallthroughCaseInsensitive(t *testing.T) {
|
|
resolver := NewResolver()
|
|
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "EXAMPLE.COM.",
|
|
Records: []nbdns.SimpleRecord{{Name: "host.example.com.", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "1.2.3.4"}},
|
|
NonAuthoritative: true,
|
|
}})
|
|
|
|
var responseMSG *dns.Msg
|
|
responseWriter := &test.MockResponseWriter{
|
|
WriteMsgFunc: func(m *dns.Msg) error {
|
|
responseMSG = m
|
|
return nil
|
|
},
|
|
}
|
|
|
|
msg := new(dns.Msg).SetQuestion("nonexistent.example.com.", dns.TypeA)
|
|
resolver.ServeDNS(responseWriter, msg)
|
|
|
|
require.NotNil(t, responseMSG)
|
|
assert.True(t, responseMSG.MsgHdr.Zero, "Should fallthrough for non-authoritative zone with case-insensitive match")
|
|
}
|
|
|
|
// BenchmarkFindZone_BestCase benchmarks zone lookup with immediate match (first label)
|
|
func BenchmarkFindZone_BestCase(b *testing.B) {
|
|
resolver := NewResolver()
|
|
|
|
// Single zone that matches immediately
|
|
resolver.Update([]nbdns.CustomZone{{
|
|
Domain: "example.com.",
|
|
NonAuthoritative: true,
|
|
}})
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
resolver.shouldFallthrough("example.com.")
|
|
}
|
|
}
|
|
|
|
// BenchmarkFindZone_WorstCase benchmarks zone lookup with many zones, no match, many labels
|
|
func BenchmarkFindZone_WorstCase(b *testing.B) {
|
|
resolver := NewResolver()
|
|
|
|
// 100 zones that won't match
|
|
var zones []nbdns.CustomZone
|
|
for i := 0; i < 100; i++ {
|
|
zones = append(zones, nbdns.CustomZone{
|
|
Domain: fmt.Sprintf("zone%d.internal.", i),
|
|
NonAuthoritative: true,
|
|
})
|
|
}
|
|
resolver.Update(zones)
|
|
|
|
// Query with many labels that won't match any zone
|
|
qname := "a.b.c.d.e.f.g.h.external.com."
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
resolver.shouldFallthrough(qname)
|
|
}
|
|
}
|
|
|
|
// BenchmarkFindZone_TypicalCase benchmarks typical usage: few zones, subdomain match
|
|
func BenchmarkFindZone_TypicalCase(b *testing.B) {
|
|
resolver := NewResolver()
|
|
|
|
// Typical setup: peer zone (authoritative) + one user zone (non-authoritative)
|
|
resolver.Update([]nbdns.CustomZone{
|
|
{Domain: "netbird.cloud.", NonAuthoritative: false},
|
|
{Domain: "custom.local.", NonAuthoritative: true},
|
|
})
|
|
|
|
// Query for subdomain of user zone
|
|
qname := "myhost.custom.local."
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
resolver.shouldFallthrough(qname)
|
|
}
|
|
}
|
|
|
|
// BenchmarkIsInManagedZone_ManyZones benchmarks isInManagedZone with 100 zones
|
|
func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
|
|
resolver := NewResolver()
|
|
|
|
var zones []nbdns.CustomZone
|
|
for i := 0; i < 100; i++ {
|
|
zones = append(zones, nbdns.CustomZone{
|
|
Domain: fmt.Sprintf("zone%d.internal.", i),
|
|
})
|
|
}
|
|
resolver.Update(zones)
|
|
|
|
// Query that matches zone50
|
|
qname := "host.zone50.internal."
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
resolver.isInManagedZone(qname)
|
|
}
|
|
}
|