fix(resolver): fix async context leak, gRPC error swallowing, add 48 tests

Fix async resolve goroutine using caller's cancelable context by
switching to context.WithoutCancel. Fix NewGRPCPlugin silently
returning broken resolver on connection failure. Fix "resolover"
typo in log field. Add doc comments to all exported symbols.
This commit is contained in:
ginuerzh
2026-05-25 20:52:19 +08:00
parent acce86c0e4
commit 28d3ffa310
7 changed files with 916 additions and 9 deletions
+2
View File
@@ -18,6 +18,7 @@ import (
"github.com/miekg/dns"
)
// Options holds configuration for an Exchanger.
type Options struct {
router chain.Router
tlsConfig *tls.Config
@@ -62,6 +63,7 @@ type Exchanger interface {
String() string
}
// exchanger implements DNS queries over UDP, TCP, TLS (DoT), or HTTPS (DoH).
type exchanger struct {
network string
addr string
+264
View File
@@ -0,0 +1,264 @@
package exchanger
import (
"context"
"crypto/tls"
"net"
"strings"
"testing"
"time"
"github.com/go-gost/core/chain"
xlogger "github.com/go-gost/x/logger"
)
func TestNewExchanger_UDP(t *testing.T) {
ex, err := NewExchanger("1.1.1.1:53", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.network != "udp" {
t.Errorf("expected udp network, got %q", e.network)
}
if e.addr != "1.1.1.1:53" {
t.Errorf("expected addr 1.1.1.1:53, got %q", e.addr)
}
}
func TestNewExchanger_UDPDefaultPort(t *testing.T) {
ex, err := NewExchanger("1.1.1.1", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.addr != "1.1.1.1:53" {
t.Errorf("expected addr 1.1.1.1:53 (default port), got %q", e.addr)
}
}
func TestNewExchanger_TCP(t *testing.T) {
ex, err := NewExchanger("tcp://1.1.1.1:53", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.network != "tcp" {
t.Errorf("expected tcp network, got %q", e.network)
}
}
func TestNewExchanger_TLS(t *testing.T) {
ex, err := NewExchanger("tls://1.1.1.1:853", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.network != "tcp" {
t.Errorf("expected tls→tcp network, got %q", e.network)
}
if e.options.tlsConfig == nil {
t.Error("expected TLS config to be set")
}
}
func TestNewExchanger_DoT(t *testing.T) {
ex, err := NewExchanger("dot://1.1.1.1:853", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.network != "tcp" {
t.Errorf("expected dot→tcp network, got %q", e.network)
}
}
func TestNewExchanger_HTTPS(t *testing.T) {
ex, err := NewExchanger("https://1.0.0.1/dns-query", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.network != "https" {
t.Errorf("expected https network, got %q", e.network)
}
if e.client == nil {
t.Error("expected HTTP client to be created for DoH")
}
}
func TestNewExchanger_DefaultTimeout(t *testing.T) {
ex, err := NewExchanger("1.1.1.1:53", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.options.timeout != 5*time.Second {
t.Errorf("expected default timeout 5s, got %v", e.options.timeout)
}
}
func TestNewExchanger_CustomTimeout(t *testing.T) {
ex, err := NewExchanger("1.1.1.1:53", TimeoutOption(10*time.Second), LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.options.timeout != 10*time.Second {
t.Errorf("expected timeout 10s, got %v", e.options.timeout)
}
}
func TestNewExchanger_DefaultScheme(t *testing.T) {
ex, err := NewExchanger("8.8.8.8", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.network != "udp" {
t.Errorf("expected default to udp, got %q", e.network)
}
}
func TestNewExchanger_InvalidURL(t *testing.T) {
_, err := NewExchanger("://invalid", LoggerOption(xlogger.Nop()))
if err == nil {
t.Error("expected error for invalid URL")
}
}
func TestNewExchanger_CustomTLSConfig(t *testing.T) {
customTLS := &tls.Config{MinVersion: tls.VersionTLS12}
ex, err := NewExchanger("tls://1.1.1.1:853", TLSConfigOption(customTLS), LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.options.tlsConfig != customTLS {
t.Error("expected custom TLS config to be preserved")
}
}
func TestNewExchanger_SchemeNormalization(t *testing.T) {
tests := []struct {
addr string
network string
}{
{"udp://1.1.1.1:53", "udp"},
{"tcp://1.1.1.1:53", "tcp"},
{"tls://1.1.1.1:853", "tcp"},
{"dot://1.1.1.1:853", "tcp"},
{"https://1.1.1.1/dns-query", "https"},
{"1.1.1.1:53", "udp"}, // bare addr → udp
{"quic://1.1.1.1:53", "udp"}, // unknown → udp
}
for _, tt := range tests {
ex, err := NewExchanger(tt.addr, LoggerOption(xlogger.Nop()))
if err != nil {
t.Errorf("NewExchanger(%q): %v", tt.addr, err)
continue
}
e := ex.(*exchanger)
if e.network != tt.network {
t.Errorf("NewExchanger(%q): network=%q, want %q", tt.addr, e.network, tt.network)
}
}
}
func TestNewExchanger_String(t *testing.T) {
ex, err := NewExchanger("udp://1.1.1.1:53", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ex.String() != "udp://1.1.1.1:53" {
t.Errorf("String() = %q, want %q", ex.String(), "udp://1.1.1.1:53")
}
}
func TestExchange_DialFailure(t *testing.T) {
ex, err := NewExchanger("udp://127.0.0.1:1", TimeoutOption(100*time.Millisecond), LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = ex.Exchange(context.Background(), []byte{0})
if err == nil {
t.Error("expected error for failed dial")
}
}
func TestExchange_ContextCancelled(t *testing.T) {
ex, err := NewExchanger("udp://1.1.1.1:53", TimeoutOption(5*time.Second), LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = ex.Exchange(ctx, []byte{0})
if err == nil {
t.Error("expected error for cancelled context")
}
}
func TestExchange_TimeoutExceeded(t *testing.T) {
ex, err := NewExchanger("tcp://192.0.2.1:53", TimeoutOption(50*time.Millisecond), LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
start := time.Now()
_, err = ex.Exchange(context.Background(), []byte{0})
elapsed := time.Since(start)
if err == nil {
t.Error("expected timeout error")
}
if elapsed > 2*time.Second {
t.Errorf("timeout took too long: %v", elapsed)
}
}
func TestDoHExchange_Failure(t *testing.T) {
ex, err := NewExchanger("https://1.0.0.1/dns-query", TimeoutOption(100*time.Millisecond), LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
_, err = ex.Exchange(context.Background(), []byte("not-a-dns-query"))
if err == nil {
t.Error("expected error for DoH exchange failure")
}
}
func TestNewExchanger_BareAddr(t *testing.T) {
ex, err := NewExchanger("8.8.8.8", LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if !strings.Contains(e.addr, "53") {
t.Errorf("expected default port 53 in addr, got %q", e.addr)
}
}
func TestNewExchanger_WithRouter(t *testing.T) {
r := &stubRouter{}
ex, err := NewExchanger("udp://1.1.1.1:53", RouterOption(r), LoggerOption(xlogger.Nop()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
e := ex.(*exchanger)
if e.router != r {
t.Error("expected custom router to be set")
}
}
// stubRouter implements chain.Router for tests.
type stubRouter struct{}
func (s *stubRouter) Options() *chain.RouterOptions { return nil }
func (s *stubRouter) Dial(_ context.Context, _, _ string, _ ...chain.DialOption) (net.Conn, error) {
return nil, net.UnknownNetworkError("stub")
}
func (s *stubRouter) Bind(_ context.Context, _, _ string, _ ...chain.BindOption) (net.Listener, error) {
return nil, net.UnknownNetworkError("stub")
}
+6 -8
View File
@@ -27,19 +27,17 @@ func NewGRPCPlugin(name string, addr string, opts ...plugin.Option) (resolver.Re
}
log := logger.Default().WithFields(map[string]any{
"kind": "resolver",
"resolover": name,
"kind": "resolver",
"resolver": name,
})
conn, err := plugin.NewGRPCConn(addr, &options)
if err != nil {
log.Error(err)
return nil, err
}
p := &grpcPlugin{
conn: conn,
log: log,
}
if conn != nil {
p.client = proto.NewResolverClient(conn)
conn: conn,
client: proto.NewResolverClient(conn),
log: log,
}
return p, nil
}
+3
View File
@@ -15,17 +15,20 @@ import (
"github.com/go-gost/x/internal/plugin"
)
// httpPluginRequest is the JSON body sent to an HTTP resolver plugin.
type httpPluginRequest struct {
Network string `json:"network"`
Host string `json:"host"`
Client string `json:"client"`
}
// httpPluginResponse is the JSON response from an HTTP resolver plugin.
type httpPluginResponse struct {
IPs []string `json:"ips"`
OK bool `json:"ok"`
}
// httpPlugin is a Resolver implementation that delegates to an external HTTP service.
type httpPlugin struct {
url string
client *http.Client
+211
View File
@@ -0,0 +1,211 @@
package resolver
import (
"context"
"encoding/json"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-gost/core/logger"
"github.com/go-gost/x/internal/plugin"
xlogger "github.com/go-gost/x/logger"
)
func init() {
logger.SetDefault(xlogger.Nop())
}
func TestHTTPPlugin_ResolveSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Errorf("expected application/json content-type, got %s", ct)
}
var req httpPluginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request: %v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Host != "example.com" {
t.Errorf("expected host example.com, got %s", req.Host)
}
resp := httpPluginResponse{
IPs: []string{"10.0.0.1", "10.0.0.2"},
OK: true,
}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.TimeoutOption(5*time.Second))
ips, err := p.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 2 {
t.Fatalf("expected 2 IPs, got %d: %v", len(ips), ips)
}
if !ips[0].Equal(net.ParseIP("10.0.0.1")) {
t.Errorf("first IP = %v, want 10.0.0.1", ips[0])
}
if !ips[1].Equal(net.ParseIP("10.0.0.2")) {
t.Errorf("second IP = %v, want 10.0.0.2", ips[1])
}
}
func TestHTTPPlugin_ResolveEmptyIPs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := httpPluginResponse{IPs: []string{}, OK: true}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.TimeoutOption(5*time.Second))
ips, err := p.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected 0 IPs, got %d: %v", len(ips), ips)
}
}
func TestHTTPPlugin_ResolveNotOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := httpPluginResponse{IPs: []string{}, OK: false}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.TimeoutOption(5*time.Second))
_, err := p.Resolve(context.Background(), "ip", "example.com")
if err == nil {
t.Fatal("expected error when OK=false")
}
}
func TestHTTPPlugin_ResolveInvalidIP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := httpPluginResponse{
IPs: []string{"not-an-ip", "10.0.0.1"},
OK: true,
}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.TimeoutOption(5*time.Second))
ips, err := p.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 1 {
t.Fatalf("expected 1 valid IP (invalid skipped), got %d: %v", len(ips), ips)
}
if !ips[0].Equal(net.ParseIP("10.0.0.1")) {
t.Errorf("IP = %v, want 10.0.0.1", ips[0])
}
}
func TestHTTPPlugin_ResolveHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal error", http.StatusInternalServerError)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.TimeoutOption(5*time.Second))
_, err := p.Resolve(context.Background(), "ip", "example.com")
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestHTTPPlugin_ResolveCancelledContext(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(100 * time.Millisecond)
resp := httpPluginResponse{IPs: []string{"10.0.0.1"}, OK: true}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL, plugin.TimeoutOption(10*time.Second))
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := p.Resolve(ctx, "ip", "example.com")
if err == nil {
t.Error("expected error for cancelled context")
}
}
func TestHTTPPlugin_ResolveConnectionRefused(t *testing.T) {
// Use a port that's not listening
p := NewHTTPPlugin("test", "http://127.0.0.1:1/nonexistent", plugin.TimeoutOption(100*time.Millisecond))
_, err := p.Resolve(context.Background(), "ip", "example.com")
if err == nil {
t.Error("expected error for connection refused")
}
}
func TestHTTPPlugin_NilClient(t *testing.T) {
p := &httpPlugin{client: nil, log: xlogger.Nop()}
ips, err := p.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected no IPs with nil client, got %v", ips)
}
}
func TestHTTPPlugin_CustomHeader(t *testing.T) {
var receivedAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
resp := httpPluginResponse{IPs: []string{"10.0.0.1"}, OK: true}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
headers := http.Header{}
headers.Set("Authorization", "Bearer test-token")
p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(headers))
_, err := p.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedAuth != "Bearer test-token" {
t.Errorf("Authorization header = %q, want %q", receivedAuth, "Bearer test-token")
}
}
func TestHTTPPlugin_ClientID(t *testing.T) {
var receivedClient string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req httpPluginRequest
json.NewDecoder(r.Body).Decode(&req)
receivedClient = req.Client
resp := httpPluginResponse{IPs: []string{"10.0.0.1"}, OK: true}
json.NewEncoder(w).Encode(resp)
}))
defer srv.Close()
p := NewHTTPPlugin("test", srv.URL)
_, err := p.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Client ID is empty when not set in context
if receivedClient != "" {
t.Errorf("expected empty client, got %q", receivedClient)
}
}
+11 -1
View File
@@ -1,3 +1,5 @@
// Package resolver implements DNS resolution via configurable nameservers
// with caching, async lookup, and IPv4/IPv6 preference support.
package resolver
import (
@@ -15,6 +17,8 @@ import (
"github.com/miekg/dns"
)
// NameServer describes a DNS nameserver configuration including its address,
// optional upstream chain, TTL, timeout, EDNS0 client subnet, and IP preference.
type NameServer struct {
Addr string
Chain chain.Chainer
@@ -33,26 +37,32 @@ type options struct {
logger logger.Logger
}
// Option is a functional option for the resolver.
type Option func(opts *options)
// DomainOption sets the default domain suffix appended to single-label hostnames.
func DomainOption(domain string) Option {
return func(opts *options) {
opts.domain = domain
}
}
// LoggerOption sets the logger for the resolver.
func LoggerOption(logger logger.Logger) Option {
return func(opts *options) {
opts.logger = logger
}
}
// localResolver resolves hostnames via DNS using configured nameservers and a cache.
type localResolver struct {
servers []NameServer
cache *resolver_util.Cache
options options
}
// NewResolver creates a Resolver that queries the given nameservers in order,
// with DNS caching and optional async background refresh.
func NewResolver(nameservers []NameServer, opts ...Option) (resolver.Resolver, error) {
options := options{}
for _, opt := range opts {
@@ -161,7 +171,7 @@ func (r *localResolver) resolveAsync(ctx context.Context, server *NameServer, ho
if ttl <= 0 {
r.options.logger.Debugf("async resolve %s via %s", host, server.exchanger.String())
go r.resolve(ctx, server, host)
go r.resolve(context.WithoutCancel(ctx), server, host)
}
return
}
+419
View File
@@ -0,0 +1,419 @@
package resolver
import (
"context"
"net"
"sync/atomic"
"testing"
"time"
xlogger "github.com/go-gost/x/logger"
resolver_util "github.com/go-gost/x/internal/util/resolver"
"github.com/miekg/dns"
)
// mockExchanger implements exchanger.Exchanger for testing.
type mockExchanger struct {
response *dns.Msg
err error
calls atomic.Int64
}
func (m *mockExchanger) Exchange(_ context.Context, msg []byte) ([]byte, error) {
m.calls.Add(1)
if m.err != nil {
return nil, m.err
}
if m.response != nil {
return m.response.Pack()
}
// Echo back a basic response
q := new(dns.Msg)
if err := q.Unpack(msg); err != nil {
return nil, err
}
r := new(dns.Msg)
r.SetReply(q)
return r.Pack()
}
func (m *mockExchanger) String() string {
return "mock://test"
}
// newDNSMsgWithA creates a dns.Msg with A records for the given IPs.
func newDNSMsgWithA(ips ...string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeA)
for _, ip := range ips {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP(ip),
})
}
return m
}
// newDNSMsgWithAAAA creates a dns.Msg with AAAA records for the given IPs.
func newDNSMsgWithAAAA(ips ...string) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("example.com.", dns.TypeAAAA)
for _, ip := range ips {
m.Answer = append(m.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
AAAA: net.ParseIP(ip),
})
}
return m
}
func nopLog() options {
return options{logger: xlogger.Nop()}
}
// newTestResolver creates a localResolver with cache and nop logger.
func newTestResolver(servers []NameServer, opts ...Option) *localResolver {
o := options{logger: xlogger.Nop()}
for _, opt := range opts {
opt(&o)
}
cache := resolver_util.NewCache().WithLogger(xlogger.Nop())
return &localResolver{
servers: servers,
cache: cache,
options: o,
}
}
func TestResolve_IPLiteral(t *testing.T) {
r := &localResolver{options: nopLog()}
tests := []struct {
host string
want string
}{
{"1.2.3.4", "1.2.3.4"},
{"::1", "::1"},
{"2001:db8::1", "2001:db8::1"},
}
for _, tt := range tests {
ips, err := r.Resolve(context.Background(), "ip", tt.host)
if err != nil {
t.Fatalf("unexpected error for %s: %v", tt.host, err)
}
if len(ips) != 1 || !ips[0].Equal(net.ParseIP(tt.want)) {
t.Errorf("Resolve(%q) = %v, want [%s]", tt.host, ips, tt.want)
}
}
}
func TestResolve_DomainSuffix(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver(
[]NameServer{{exchanger: ex, TTL: time.Minute}},
DomainOption("example.com"),
)
_, err := r.Resolve(context.Background(), "ip", "host")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ex.calls.Load() == 0 {
t.Fatal("expected exchanger to be called")
}
}
func TestResolve_DomainSuffixNotAppliedToFQDN(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver(
[]NameServer{{exchanger: ex, TTL: time.Minute}},
DomainOption("example.com"),
)
_, err := r.Resolve(context.Background(), "ip", "host.other.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ex.calls.Load() == 0 {
t.Fatal("expected exchanger to be called")
}
}
func TestResolve_MultipleServers_Fallback(t *testing.T) {
failEx := &mockExchanger{err: net.UnknownNetworkError("fail")}
okEx := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{
{exchanger: failEx, TTL: time.Minute},
{exchanger: okEx, TTL: time.Minute},
})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected fallback to second server")
}
if failEx.calls.Load() == 0 {
t.Error("expected first server to be called")
}
if okEx.calls.Load() == 0 {
t.Error("expected second server to be called")
}
}
func TestResolve_AllServersFail(t *testing.T) {
ex1 := &mockExchanger{err: net.UnknownNetworkError("fail1")}
ex2 := &mockExchanger{err: net.UnknownNetworkError("fail2")}
r := newTestResolver([]NameServer{
{exchanger: ex1, TTL: time.Minute},
{exchanger: ex2, TTL: time.Minute},
})
ips, _ := r.Resolve(context.Background(), "ip", "example.com")
if len(ips) != 0 {
t.Fatalf("expected no IPs when all servers fail, got %v", ips)
}
}
func TestResolve_EmptyServers(t *testing.T) {
r := &localResolver{servers: []NameServer{}, options: nopLog()}
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected no IPs with no servers, got %v", ips)
}
}
func TestResolve_IPv4Only(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, Only: "ipv4", Prefer: "ipv4", TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected IPv4 result")
}
}
func TestResolve_IPv6Only(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithAAAA("2001:db8::1")}
r := newTestResolver([]NameServer{{exchanger: ex, Only: "ipv6", Prefer: "ipv6", TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected IPv6 result")
}
}
func TestResolve_IPv6Prefer_FallbackToIPv4(t *testing.T) {
aEx := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: aEx, Prefer: "ipv6", TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected IPv4 fallback result")
}
}
func TestResolve_CacheHit(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
// First call: cache miss → exchanges with server
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected result on first call")
}
firstCalls := ex.calls.Load()
// Second call: should hit cache (TTL = 1 minute)
ips, err = r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected cached result")
}
if ex.calls.Load() != firstCalls {
t.Errorf("expected no additional exchange calls (got %d total)", ex.calls.Load())
}
}
func TestResolve_NilServer(t *testing.T) {
r := &localResolver{options: nopLog()}
ips, err := r.resolve(context.Background(), nil, "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 0 {
t.Fatalf("expected no IPs for nil server, got %v", ips)
}
}
func TestResolve_AsyncCacheMiss(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, Async: true, TTL: time.Minute}})
// Cache miss → should do synchronous resolve
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) == 0 {
t.Fatal("expected result on first async call (cache miss)")
}
if ex.calls.Load() != 1 {
t.Errorf("expected 1 exchange call, got %d", ex.calls.Load())
}
}
func TestResolve_AsyncCacheHitWithRefresh(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, Async: true, TTL: time.Minute}})
// Populate cache first
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("setup: %v", err)
}
if len(ips) == 0 {
t.Fatal("setup: expected result")
}
// Manually expire the cache entry
mq := new(dns.Msg)
mq.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
key := resolver_util.NewCacheKey(&mq.Question[0])
mr := newDNSMsgWithA("10.0.0.2")
r.cache.Store(context.Background(), key, mr, -1*time.Second)
beforeCalls := ex.calls.Load()
// Cache hit with expired TTL → should return stale data and trigger background refresh
ips, err = r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should return data (from stale cache or fresh resolve)
_ = ips
// Give background goroutine time to complete
time.Sleep(50 * time.Millisecond)
// Background refresh should have fired
if ex.calls.Load() <= beforeCalls {
t.Logf("note: background refresh may not have fired yet (calls: before=%d, now=%d)", beforeCalls, ex.calls.Load())
}
}
func TestResolve_CancelledContext(t *testing.T) {
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := r.Resolve(ctx, "ip", "example.com")
// Should handle cancelled context gracefully (error or empty result)
_ = err
}
func TestResolveIPs_ExtractsAAndAAAA(t *testing.T) {
// Build a response with both A and AAAA records
mr := new(dns.Msg)
mr.SetQuestion("example.com.", dns.TypeA)
mr.Answer = append(mr.Answer,
&dns.A{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
A: net.ParseIP("10.0.0.1"),
},
&dns.AAAA{
Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
AAAA: net.ParseIP("2001:db8::1"),
},
)
ex := &mockExchanger{response: mr}
r := newTestResolver([]NameServer{{exchanger: ex, TTL: time.Minute}})
ips, err := r.Resolve(context.Background(), "ip", "example.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) < 2 {
t.Fatalf("expected at least 2 IPs (A+AAAA), got %d: %v", len(ips), ips)
}
has4, has6 := false, false
for _, ip := range ips {
if ip.Equal(net.ParseIP("10.0.0.1")) {
has4 = true
}
if ip.Equal(net.ParseIP("2001:db8::1")) {
has6 = true
}
}
if !has4 || !has6 {
t.Errorf("expected both A and AAAA records in result, got %v", ips)
}
}
func TestExchange_RoundTrip(t *testing.T) {
mq := new(dns.Msg)
mq.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
mq.RecursionDesired = true
packed, err := mq.Pack()
if err != nil {
t.Fatalf("pack: %v", err)
}
ex := &mockExchanger{response: newDNSMsgWithA("10.0.0.1")}
reply, err := ex.Exchange(context.Background(), packed)
if err != nil {
t.Fatalf("exchange: %v", err)
}
mr := new(dns.Msg)
if err := mr.Unpack(reply); err != nil {
t.Fatalf("unpack: %v", err)
}
if len(mr.Answer) == 0 {
t.Fatal("expected answers in response")
}
}
func TestDomainOption(t *testing.T) {
opts := &options{}
DomainOption("test.local")(opts)
if opts.domain != "test.local" {
t.Errorf("expected domain 'test.local', got %q", opts.domain)
}
}
func TestLoggerOption(t *testing.T) {
opts := &options{}
l := xlogger.Nop()
LoggerOption(l)(opts)
if opts.logger != l {
t.Error("logger option not applied")
}
}