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") + } +}