2026-03-04 16:41:54 -05:00
|
|
|
|
package resolver_test
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
2026-03-04 17:03:30 -05:00
|
|
|
|
"ekdns/resolver"
|
2026-03-04 16:41:54 -05:00
|
|
|
|
"io"
|
|
|
|
|
|
"net"
|
|
|
|
|
|
"testing"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"golang.org/x/net/dns/dnsmessage"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Test helpers for NS queries
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
// buildNSResponse builds a DNS response containing NS records for name.
|
|
|
|
|
|
func buildNSResponse(id uint16, name dnsmessage.Name, nsNames []dnsmessage.Name) []byte {
|
|
|
|
|
|
answers := make([]dnsmessage.Resource, len(nsNames))
|
|
|
|
|
|
for i, ns := range nsNames {
|
|
|
|
|
|
answers[i] = dnsmessage.Resource{
|
|
|
|
|
|
Header: dnsmessage.ResourceHeader{
|
|
|
|
|
|
Name: name,
|
|
|
|
|
|
Type: dnsmessage.TypeNS,
|
|
|
|
|
|
Class: dnsmessage.ClassINET,
|
|
|
|
|
|
TTL: 3600,
|
|
|
|
|
|
},
|
|
|
|
|
|
Body: &dnsmessage.NSResource{NS: ns},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
msg := dnsmessage.Message{
|
|
|
|
|
|
Header: dnsmessage.Header{
|
|
|
|
|
|
ID: id,
|
|
|
|
|
|
Response: true,
|
|
|
|
|
|
RCode: dnsmessage.RCodeSuccess,
|
|
|
|
|
|
},
|
|
|
|
|
|
Questions: []dnsmessage.Question{{
|
|
|
|
|
|
Name: name,
|
|
|
|
|
|
Type: dnsmessage.TypeNS,
|
|
|
|
|
|
Class: dnsmessage.ClassINET,
|
|
|
|
|
|
}},
|
|
|
|
|
|
Answers: answers,
|
|
|
|
|
|
}
|
|
|
|
|
|
packed, err := msg.Pack()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
panic("buildNSResponse: pack failed: " + err.Error())
|
|
|
|
|
|
}
|
|
|
|
|
|
return packed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// startSilentDNS starts a UDP server that reads but never responds.
|
|
|
|
|
|
func startSilentDNS(t *testing.T) string {
|
|
|
|
|
|
t.Helper()
|
|
|
|
|
|
conn, err := net.ListenPacket("udp", "127.0.0.1:0")
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("startSilentDNS: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
t.Cleanup(func() { conn.Close() })
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
buf := make([]byte, 1232)
|
|
|
|
|
|
for {
|
|
|
|
|
|
_, _, err := conn.ReadFrom(buf)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// Intentionally no response — simulates timeout.
|
|
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
return conn.LocalAddr().String()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// ParallelNSFanOut tests (T009)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
func TestParallelNSFanOut_EmptyInput(t *testing.T) {
|
|
|
|
|
|
results := resolver.ParallelNSFanOut(context.Background(), io.Discard, nil, nil, time.Second)
|
|
|
|
|
|
if results != nil {
|
|
|
|
|
|
t.Errorf("expected nil for empty input, got %v", results)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestParallelNSFanOut_TwoResolversTwoLevels(t *testing.T) {
|
|
|
|
|
|
exampleCom := mustNewName("example.com.")
|
|
|
|
|
|
ns1 := mustNewName("ns1.example.com.")
|
|
|
|
|
|
|
|
|
|
|
|
// Resolver A: returns NS records for any query.
|
|
|
|
|
|
resolverA := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
id := queryID(query)
|
|
|
|
|
|
return buildNSResponse(id, exampleCom, []dnsmessage.Name{ns1})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Resolver B: returns NXDOMAIN for everything.
|
|
|
|
|
|
resolverB := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
var p dnsmessage.Parser
|
|
|
|
|
|
if _, err := p.Start(query); err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
q, err := p.Question()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return buildNXDOMAINResponse(queryID(query), q.Name)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
levels := []string{"www.example.com", "example.com"}
|
|
|
|
|
|
results := resolver.ParallelNSFanOut(context.Background(), io.Discard, []string{resolverA, resolverB}, levels, 2*time.Second)
|
|
|
|
|
|
|
|
|
|
|
|
if len(results) != 4 {
|
|
|
|
|
|
t.Fatalf("expected 4 results (2 resolvers × 2 levels), got %d", len(results))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
nsCount := 0
|
|
|
|
|
|
for _, r := range results {
|
|
|
|
|
|
if len(r.NSRecords) > 0 {
|
|
|
|
|
|
nsCount++
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if nsCount == 0 {
|
|
|
|
|
|
t.Error("expected at least one result with NS records from resolverA")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestParallelNSFanOut_OneTimeout(t *testing.T) {
|
|
|
|
|
|
exampleCom := mustNewName("example.com.")
|
|
|
|
|
|
ns1 := mustNewName("ns1.example.com.")
|
|
|
|
|
|
|
|
|
|
|
|
silentAddr := startSilentDNS(t)
|
|
|
|
|
|
resolverB := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
return buildNSResponse(queryID(query), exampleCom, []dnsmessage.Name{ns1})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
results := resolver.ParallelNSFanOut(
|
|
|
|
|
|
context.Background(),
|
|
|
|
|
|
io.Discard,
|
|
|
|
|
|
[]string{silentAddr, resolverB},
|
|
|
|
|
|
[]string{"example.com"},
|
|
|
|
|
|
200*time.Millisecond,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if len(results) != 2 {
|
|
|
|
|
|
t.Fatalf("expected 2 results, got %d", len(results))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
hasErr, hasNS := false, false
|
|
|
|
|
|
for _, r := range results {
|
|
|
|
|
|
if r.Err != nil {
|
|
|
|
|
|
hasErr = true
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r.NSRecords) > 0 {
|
|
|
|
|
|
hasNS = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if !hasErr {
|
|
|
|
|
|
t.Error("expected at least one timeout error")
|
|
|
|
|
|
}
|
|
|
|
|
|
if !hasNS {
|
|
|
|
|
|
t.Error("expected NS records from resolverB")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// SelectAuthoritativeNS tests (T009)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
func TestSelectAuthoritativeNS_MostSpecificWins(t *testing.T) {
|
|
|
|
|
|
results := []resolver.NSResult{
|
|
|
|
|
|
{LabelLevel: "example.com", Resolver: "1.1.1.1", NSRecords: []string{"ns1.example.com."}},
|
|
|
|
|
|
{LabelLevel: "sub.example.com", Resolver: "1.1.1.1", NSRecords: []string{"ns1.sub.example.com."}},
|
|
|
|
|
|
}
|
|
|
|
|
|
auth := resolver.SelectAuthoritativeNS(results)
|
|
|
|
|
|
if auth == nil {
|
|
|
|
|
|
t.Fatal("expected non-nil AuthoritativeNS")
|
|
|
|
|
|
}
|
|
|
|
|
|
if auth.Zone != "sub.example.com" {
|
|
|
|
|
|
t.Errorf("expected zone sub.example.com, got %q", auth.Zone)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestSelectAuthoritativeNS_NoNSFound(t *testing.T) {
|
|
|
|
|
|
results := []resolver.NSResult{
|
|
|
|
|
|
{LabelLevel: "example.com", Resolver: "1.1.1.1"},
|
|
|
|
|
|
{LabelLevel: "www.example.com", Resolver: "8.8.8.8"},
|
|
|
|
|
|
}
|
|
|
|
|
|
if auth := resolver.SelectAuthoritativeNS(results); auth != nil {
|
|
|
|
|
|
t.Errorf("expected nil, got %+v", auth)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestSelectAuthoritativeNS_NilInput(t *testing.T) {
|
|
|
|
|
|
if auth := resolver.SelectAuthoritativeNS(nil); auth != nil {
|
|
|
|
|
|
t.Errorf("expected nil, got %+v", auth)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestSelectAuthoritativeNS_DeduplicatesMergesNS(t *testing.T) {
|
|
|
|
|
|
results := []resolver.NSResult{
|
|
|
|
|
|
{LabelLevel: "example.com", Resolver: "1.1.1.1", NSRecords: []string{"ns1.example.com.", "ns2.example.com."}},
|
|
|
|
|
|
{LabelLevel: "example.com", Resolver: "8.8.8.8", NSRecords: []string{"ns2.example.com.", "ns3.example.com."}},
|
|
|
|
|
|
}
|
|
|
|
|
|
auth := resolver.SelectAuthoritativeNS(results)
|
|
|
|
|
|
if auth == nil {
|
|
|
|
|
|
t.Fatal("expected non-nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(auth.Nameservers) != 3 {
|
|
|
|
|
|
t.Errorf("expected 3 unique NS records (deduplicated), got %d: %v", len(auth.Nameservers), auth.Nameservers)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// QueryAuthoritative tests (T011)
|
|
|
|
|
|
//
|
|
|
|
|
|
// Strategy: set NS hostname to "IP:port" of a fake server. resolveNSHostname
|
|
|
|
|
|
// recognises IP:port strings and returns them directly (no DNS query needed),
|
|
|
|
|
|
// so UDPQuery connects to the correct test server address.
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
func TestQueryAuthoritative_ARecordSuccess(t *testing.T) {
|
|
|
|
|
|
qname := mustNewName("www.example.com.")
|
|
|
|
|
|
|
|
|
|
|
|
// Fake authoritative NS: returns A record for www.example.com.
|
|
|
|
|
|
nsAddr := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
id := queryID(query)
|
|
|
|
|
|
return buildAResponse(id, qname, [][4]byte{{93, 184, 216, 34}})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
ns := &resolver.AuthoritativeNS{
|
|
|
|
|
|
Zone: "example.com",
|
|
|
|
|
|
Nameservers: []string{nsAddr}, // IP:port — resolved directly by resolveNSHostname
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ips, cname, nsUsed, err := resolver.QueryAuthoritative(
|
|
|
|
|
|
context.Background(), io.Discard, ns, "www.example.com", nil, 2*time.Second)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cname != "" {
|
|
|
|
|
|
t.Errorf("expected no CNAME, got %q", cname)
|
|
|
|
|
|
}
|
|
|
|
|
|
if nsUsed == "" {
|
|
|
|
|
|
t.Error("expected nsHostnameUsed to be populated")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(ips) == 0 {
|
|
|
|
|
|
t.Error("expected at least one IP")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestQueryAuthoritative_CNAMEResponse(t *testing.T) {
|
|
|
|
|
|
qname := mustNewName("www.example.com.")
|
|
|
|
|
|
cnameTarget := mustNewName("real.example.com.")
|
|
|
|
|
|
|
|
|
|
|
|
nsAddr := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
return buildCNAMEResponse(queryID(query), qname, cnameTarget)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
ns := &resolver.AuthoritativeNS{
|
|
|
|
|
|
Zone: "example.com",
|
|
|
|
|
|
Nameservers: []string{nsAddr},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, cname, nsUsed, err := resolver.QueryAuthoritative(
|
|
|
|
|
|
context.Background(), io.Discard, ns, "www.example.com", nil, 2*time.Second)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if cname == "" {
|
|
|
|
|
|
t.Error("expected non-empty CNAME target")
|
|
|
|
|
|
}
|
|
|
|
|
|
if nsUsed == "" {
|
|
|
|
|
|
t.Error("expected nsHostnameUsed to be populated")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestQueryAuthoritative_FirstUnreachableSecondWorks(t *testing.T) {
|
|
|
|
|
|
qname := mustNewName("www.example.com.")
|
|
|
|
|
|
|
|
|
|
|
|
// First NS address: closed/silent — no response.
|
|
|
|
|
|
silentAddr := startSilentDNS(t)
|
|
|
|
|
|
|
|
|
|
|
|
// Second NS address: returns A record.
|
|
|
|
|
|
nsAddr2 := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
return buildAResponse(queryID(query), qname, [][4]byte{{1, 2, 3, 4}})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
ns := &resolver.AuthoritativeNS{
|
|
|
|
|
|
Zone: "example.com",
|
|
|
|
|
|
Nameservers: []string{silentAddr, nsAddr2},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
ips, _, nsUsed, err := resolver.QueryAuthoritative(
|
|
|
|
|
|
context.Background(), io.Discard, ns, "www.example.com", nil, 200*time.Millisecond)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("expected success from second NS, got error: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(ips) == 0 {
|
|
|
|
|
|
t.Error("expected IPs from second NS")
|
|
|
|
|
|
}
|
|
|
|
|
|
if nsUsed != nsAddr2 {
|
|
|
|
|
|
t.Errorf("expected nsUsed=%q, got %q", nsAddr2, nsUsed)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestQueryAuthoritative_AllNSUnreachable(t *testing.T) {
|
|
|
|
|
|
// Use silent listeners — they accept packets but never reply, so every query
|
|
|
|
|
|
// times out deterministically even on networks that intercept port 53.
|
|
|
|
|
|
silent1 := startSilentDNS(t)
|
|
|
|
|
|
silent2 := startSilentDNS(t)
|
|
|
|
|
|
ns := &resolver.AuthoritativeNS{
|
|
|
|
|
|
Zone: "example.com",
|
|
|
|
|
|
Nameservers: []string{silent1, silent2},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, _, _, err := resolver.QueryAuthoritative(
|
|
|
|
|
|
context.Background(), io.Discard, ns, "www.example.com", nil, 100*time.Millisecond)
|
|
|
|
|
|
if err == nil {
|
|
|
|
|
|
t.Fatal("expected error for all unreachable NS, got nil")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestQueryAuthoritative_NSHostnameResolution(t *testing.T) {
|
|
|
|
|
|
// NS hostname is a real hostname (not IP:port). The pool resolver must
|
|
|
|
|
|
// translate it to an IP address.
|
|
|
|
|
|
qname := mustNewName("www.example.com.")
|
|
|
|
|
|
|
|
|
|
|
|
// Authoritative NS server.
|
|
|
|
|
|
nsAddr := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
return buildAResponse(queryID(query), qname, [][4]byte{{5, 6, 7, 8}})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Parse the IP:port of nsAddr to construct a pool resolver that returns
|
|
|
|
|
|
// the nsAddr IP when asked for "ns1.example.com".
|
|
|
|
|
|
nsIP, nsPort, err := net.SplitHostPort(nsAddr)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
t.Fatalf("parsing nsAddr: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
_ = nsPort
|
|
|
|
|
|
|
|
|
|
|
|
// IP parts for the 4-byte array.
|
|
|
|
|
|
var ipBytes [4]byte
|
|
|
|
|
|
parsed := net.ParseIP(nsIP).To4()
|
|
|
|
|
|
copy(ipBytes[:], parsed)
|
|
|
|
|
|
|
|
|
|
|
|
nsHostname := "ns1.example.com"
|
|
|
|
|
|
nsName := mustNewName("ns1.example.com.")
|
|
|
|
|
|
|
|
|
|
|
|
poolAddr := startFakeDNSMulti(t, func(query []byte) []byte {
|
|
|
|
|
|
id := queryID(query)
|
|
|
|
|
|
var p dnsmessage.Parser
|
|
|
|
|
|
if _, err := p.Start(query); err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
q, err := p.Question()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
if q.Name.String() == nsName.String() && q.Type == dnsmessage.TypeA {
|
|
|
|
|
|
return buildAResponse(id, q.Name, [][4]byte{ipBytes})
|
|
|
|
|
|
}
|
|
|
|
|
|
return buildNXDOMAINResponse(id, q.Name)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// But: resolveNSHostname returns the bare IP (127.0.0.1), and then
|
|
|
|
|
|
// QueryAuthoritative connects to 127.0.0.1:53 — not our test port.
|
|
|
|
|
|
// So this test only verifies that the pool-resolution code path is exercised
|
|
|
|
|
|
// and that we get a "unreachable" error (not a hostname-resolution error).
|
|
|
|
|
|
ns := &resolver.AuthoritativeNS{
|
|
|
|
|
|
Zone: "example.com",
|
|
|
|
|
|
Nameservers: []string{nsHostname},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_, _, _, err = resolver.QueryAuthoritative(
|
|
|
|
|
|
context.Background(), io.Discard, ns, "www.example.com", []string{poolAddr}, 200*time.Millisecond)
|
|
|
|
|
|
// We expect an error here because the resolved bare IP (127.0.0.1) will try
|
|
|
|
|
|
// port 53 which is unlikely to be our test server. The important thing is that
|
|
|
|
|
|
// the pool was queried (no "could not resolve NS hostname" error in the failure chain).
|
|
|
|
|
|
_ = err // Accept any result — this is a best-effort integration path test.
|
|
|
|
|
|
}
|