From 28d3ffa310dff474a78509c1bc00c84d49e32508 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Mon, 25 May 2026 20:52:19 +0800 Subject: [PATCH] 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. --- resolver/exchanger/exchanger.go | 2 + resolver/exchanger/exchanger_test.go | 264 +++++++++++++++++ resolver/plugin/grpc.go | 14 +- resolver/plugin/http.go | 3 + resolver/plugin/http_test.go | 211 ++++++++++++++ resolver/resolver.go | 12 +- resolver/resolver_test.go | 419 +++++++++++++++++++++++++++ 7 files changed, 916 insertions(+), 9 deletions(-) create mode 100644 resolver/exchanger/exchanger_test.go create mode 100644 resolver/plugin/http_test.go create mode 100644 resolver/resolver_test.go diff --git a/resolver/exchanger/exchanger.go b/resolver/exchanger/exchanger.go index ce9ccf0f..46cbe1f4 100644 --- a/resolver/exchanger/exchanger.go +++ b/resolver/exchanger/exchanger.go @@ -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 diff --git a/resolver/exchanger/exchanger_test.go b/resolver/exchanger/exchanger_test.go new file mode 100644 index 00000000..b27f0487 --- /dev/null +++ b/resolver/exchanger/exchanger_test.go @@ -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") +} diff --git a/resolver/plugin/grpc.go b/resolver/plugin/grpc.go index c6cbdac3..cff8316e 100644 --- a/resolver/plugin/grpc.go +++ b/resolver/plugin/grpc.go @@ -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 } diff --git a/resolver/plugin/http.go b/resolver/plugin/http.go index 2da06377..a0e36d09 100644 --- a/resolver/plugin/http.go +++ b/resolver/plugin/http.go @@ -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 diff --git a/resolver/plugin/http_test.go b/resolver/plugin/http_test.go new file mode 100644 index 00000000..9c85322d --- /dev/null +++ b/resolver/plugin/http_test.go @@ -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) + } +} diff --git a/resolver/resolver.go b/resolver/resolver.go index a6b84460..2cc4ab4a 100644 --- a/resolver/resolver.go +++ b/resolver/resolver.go @@ -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 } diff --git a/resolver/resolver_test.go b/resolver/resolver_test.go new file mode 100644 index 00000000..e1238b7d --- /dev/null +++ b/resolver/resolver_test.go @@ -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") + } +}