From c7e1829714f2c543f5f49ca63d82143effd7ff53 Mon Sep 17 00:00:00 2001 From: John Chadwick Date: Wed, 24 Apr 2024 19:48:33 -0400 Subject: [PATCH 1/6] Implement address family affinity --- client.go | 2 +- resolver/resolver.go | 52 +++++++++- resolver/resolver_test.go | 197 +++++++++++++++++++++++++++++++++++++- 3 files changed, 248 insertions(+), 3 deletions(-) diff --git a/client.go b/client.go index 64cb905..32fbad1 100644 --- a/client.go +++ b/client.go @@ -37,7 +37,7 @@ var ( KeepAlive: 30 * time.Second, } defaultNameTTL = 5 * time.Minute - defaultResolver = resolver.NewDNSResolver(net.DefaultResolver, "ip", defaultNameTTL) + defaultResolver = resolver.NewDNSResolver(net.DefaultResolver, "ip", defaultNameTTL, resolver.PreferIPv4) ) // Client is an HTTP client that supports configurable client-side load diff --git a/resolver/resolver.go b/resolver/resolver.go index 0162e8b..951b23e 100644 --- a/resolver/resolver.go +++ b/resolver/resolver.go @@ -24,6 +24,27 @@ import ( "github.com/bufbuild/httplb/internal" ) +// AddressFamilyAffinity is an option that allows control over the preference +// for which addresses to consider when resolving, based on their address +// family. +type AddressFamilyAffinity int + +const ( + // AllFamilies will result in all addresses being used, regardless of + // their address family. + AllFamilies AddressFamilyAffinity = iota + + // PreferIPv4 will result in only IPv4 addresses being used, if any + // IPv4 addresses are present. If no IPv4 addresses are resolved, then + // all addresses will be used. + PreferIPv4 + + // PreferIPv6 will result in only IPv6 addresses being used, if any + // IPv6 addresses are present. If no IPv6 addresses are resolved, then + // all addresses will be used. + PreferIPv6 +) + // Resolver is an interface for continuous name resolution. type Resolver interface { // New creates a continuous resolver task for the given target name. When @@ -107,16 +128,20 @@ type Address struct { // parameter, and the resolver will return only IP addresses of the type // specified by network. The network must be one of "ip", "ip4" or "ip6". // Note that because net.Resolver does not expose the record TTL values, this -// resolver uses the fixed TTL provided in the ttl parameter. +// resolver uses the fixed TTL provided in the ttl parameter. The specified +// address family affinity value can be used to prefer using either IPv4 or +// IPv6 addresses only, in cases where there are both A and AAAA records. func NewDNSResolver( resolver *net.Resolver, network string, ttl time.Duration, + affinity AddressFamilyAffinity, ) Resolver { return NewPollingResolver( &dnsResolveProber{ resolver: resolver, network: network, + affinity: affinity, }, ttl, ) @@ -139,6 +164,7 @@ func NewPollingResolver( type dnsResolveProber struct { resolver *net.Resolver network string + affinity AddressFamilyAffinity } func (r *dnsResolveProber) ResolveOnce( @@ -161,6 +187,30 @@ func (r *dnsResolveProber) ResolveOnce( if err != nil { return nil, 0, err } + switch r.affinity { + case AllFamilies: + break + case PreferIPv4: + ip4Addresses := addresses[:0] + for _, address := range addresses { + if address.Is4() || address.Is4In6() { + ip4Addresses = append(ip4Addresses, address) + } + } + if len(ip4Addresses) > 0 { + addresses = ip4Addresses + } + case PreferIPv6: + ip6Addresses := addresses[:0] + for _, address := range addresses { + if address.Is6() { + ip6Addresses = append(ip6Addresses, address) + } + } + if len(ip6Addresses) > 0 { + addresses = ip6Addresses + } + } result := make([]Address, len(addresses)) for i, address := range addresses { result[i].HostPort = net.JoinHostPort(address.Unmap().String(), port) diff --git a/resolver/resolver_test.go b/resolver/resolver_test.go index 8868c5d..1c6ed6a 100644 --- a/resolver/resolver_test.go +++ b/resolver/resolver_test.go @@ -16,6 +16,8 @@ package resolver import ( "context" + "encoding/binary" + "io" "net" "testing" "time" @@ -23,6 +25,7 @@ import ( "github.com/bufbuild/httplb/internal/clocktest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/net/dns/dnsmessage" ) func TestResolverTTL(t *testing.T) { @@ -36,7 +39,7 @@ func TestResolverTTL(t *testing.T) { t.Cleanup(cancel) testClock := clocktest.NewFakeClock() - resolver := NewDNSResolver(net.DefaultResolver, "ip6", testTTL) + resolver := NewDNSResolver(net.DefaultResolver, "ip6", testTTL, AllFamilies) resolver.(*pollingResolver).clock = testClock signal := make(chan struct{}) @@ -85,6 +88,124 @@ func TestResolverTTL(t *testing.T) { assert.NoError(t, err) } +func TestAddressFamilyAffinity(t *testing.T) { + t.Parallel() + + ip4Header := dnsmessage.ResourceHeader{ + Name: dnsmessage.MustNewName("example.com."), + Type: dnsmessage.TypeA, + Class: dnsmessage.ClassINET, + } + ip6Header := dnsmessage.ResourceHeader{ + Name: dnsmessage.MustNewName("example.com."), + Type: dnsmessage.TypeAAAA, + Class: dnsmessage.ClassINET, + } + ip4Address1 := net.ParseIP("10.0.0.100") + ip4Address2 := net.ParseIP("10.0.0.101") + ip6Address1 := net.ParseIP("fe80::1") + ip6Address2 := net.ParseIP("fe80::2") + ip4Address1Resource := dnsmessage.Resource{ + Header: ip4Header, + Body: &dnsmessage.AResource{A: [4]byte(ip4Address1.To4())}, + } + ip4Address2Resource := dnsmessage.Resource{ + Header: ip4Header, + Body: &dnsmessage.AResource{A: [4]byte(ip4Address2.To4())}, + } + ip6Address1Resource := dnsmessage.Resource{ + Header: ip6Header, + Body: &dnsmessage.AAAAResource{AAAA: [16]byte(ip6Address1)}, + } + ip6Address2Resource := dnsmessage.Resource{ + Header: ip6Header, + Body: &dnsmessage.AAAAResource{AAAA: [16]byte(ip6Address2)}, + } + + // Mixed A/AAAA records + mixedDNSResolver := newFakeDNSResolver(t, []dnsmessage.Resource{ + ip4Address1Resource, + ip6Address1Resource, + ip4Address2Resource, + ip6Address2Resource, + }) + resolver := NewDNSResolver(mixedDNSResolver, "ip", 1, AllFamilies) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2, ip6Address1, ip6Address2}) + resolver = NewDNSResolver(mixedDNSResolver, "ip", 1, PreferIPv4) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + resolver = NewDNSResolver(mixedDNSResolver, "ip", 1, PreferIPv6) + testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + + // A records only + ip4DNSResolver := newFakeDNSResolver(t, []dnsmessage.Resource{ + ip4Address1Resource, + ip4Address2Resource, + }) + resolver = NewDNSResolver(ip4DNSResolver, "ip", 1, AllFamilies) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + resolver = NewDNSResolver(ip4DNSResolver, "ip", 1, PreferIPv4) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + resolver = NewDNSResolver(ip4DNSResolver, "ip", 1, PreferIPv6) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + + // AAAA records only + ip6DNSResolver := newFakeDNSResolver(t, []dnsmessage.Resource{ + ip6Address1Resource, + ip6Address2Resource, + }) + resolver = NewDNSResolver(ip6DNSResolver, "ip", 1, AllFamilies) + testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + resolver = NewDNSResolver(ip6DNSResolver, "ip", 1, PreferIPv4) + testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + resolver = NewDNSResolver(ip6DNSResolver, "ip", 1, PreferIPv6) + testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) +} + +func testResolveAddresses( + t *testing.T, + resolver Resolver, + expectedAddresses []net.IP, +) { + t.Helper() + + refreshCh := make(chan struct{}) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + t.Cleanup(cancel) + + testClock := clocktest.NewFakeClock() + resolver.(*pollingResolver).clock = testClock + + resolved := make(chan []Address) + task := resolver.New(ctx, "http", "example.com", testReceiver{ + onResolve: func(resolvedAddresses []Address) { + resolved <- resolvedAddresses + }, + onResolveError: func(err error) { + t.Errorf("unexpected resolution error: %v", err) + }, + }, refreshCh) + + t.Cleanup(func() { + close(resolved) + err := task.Close() + close(refreshCh) + require.NoError(t, err) + }) + + select { + case resolvedAddresses := <-resolved: + actualAddresses := make([]net.IP, len(resolvedAddresses)) + for i, address := range resolvedAddresses { + actualHost, _, err := net.SplitHostPort(address.HostPort) + require.NoError(t, err) + actualAddresses[i] = net.ParseIP(actualHost) + } + assert.ElementsMatch(t, expectedAddresses, actualAddresses) + case <-ctx.Done(): + t.Fatal("expected call to resolver") + } +} + type testReceiver struct { onResolve func([]Address) onResolveError func(error) @@ -97,3 +218,77 @@ func (r testReceiver) OnResolve(addresses []Address) { func (r testReceiver) OnResolveError(err error) { r.onResolveError(err) } + +type fakeDNSResolver struct { + t *testing.T + answers []dnsmessage.Resource +} + +func (r *fakeDNSResolver) Dial(context.Context, string, string) (net.Conn, error) { + clientConn, serverConn := net.Pipe() + go func() { + var requestLength uint16 + if err := binary.Read(serverConn, binary.BigEndian, &requestLength); err != nil { + r.t.Errorf("error reading dns request length: %v", err) + return + } + requestData := make([]byte, requestLength) + if _, err := io.ReadFull(serverConn, requestData); err != nil { + r.t.Errorf("error reading dns request: %v", err) + return + } + request := &dnsmessage.Message{} + if err := request.Unpack(requestData); err != nil { + r.t.Errorf("error unpacking dns request: %v", err) + return + } + answers := []dnsmessage.Resource{} + for _, answer := range r.answers { + if answer.Header.Type == request.Questions[0].Type { + answers = append(answers, answer) + } + } + response := &dnsmessage.Message{ + Header: dnsmessage.Header{ + ID: request.ID, + Response: true, + RCode: dnsmessage.RCodeSuccess, + Authoritative: true, + }, + Questions: request.Questions, + Answers: answers, + } + responseData, err := response.Pack() + if err != nil { + r.t.Errorf("error packing dns response: %v", err) + return + } + responseLength := uint16(len(responseData)) + if err := binary.Write(serverConn, binary.BigEndian, &responseLength); err != nil { + r.t.Errorf("error writing dns response length: %v", err) + return + } + if _, err := serverConn.Write(responseData); err != nil { + r.t.Errorf("error writing dns response: %v", err) + return + } + if err := serverConn.Close(); err != nil { + r.t.Errorf("error closing dns server connection: %v", err) + return + } + }() + return clientConn, nil +} + +func newFakeDNSResolver(t *testing.T, answers []dnsmessage.Resource) *net.Resolver { + t.Helper() + + dialer := fakeDNSResolver{ + t: t, + answers: answers, + } + return &net.Resolver{ + PreferGo: true, + Dial: dialer.Dial, + } +} From beb8b2076b8a49a0baccd48170ae63901e58f417 Mon Sep 17 00:00:00 2001 From: John Chadwick Date: Thu, 25 Apr 2024 10:19:27 -0400 Subject: [PATCH 2/6] Update doc comment regarding default resolver --- doc.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc.go b/doc.go index 3c4f742..561963b 100644 --- a/doc.go +++ b/doc.go @@ -38,8 +38,9 @@ // 1. The client will re-resolve addresses in DNS every 5 minutes. // The http.DefaultClient does not re-resolve predictably. // -// 2. The client will route requests in a round-robin fashion to all -// addresses returned by the DNS system (both A and AAAA records). +// 2. The client will route requests in a round-robin fashion to the +// addresses returned by the DNS system, preferring A records if +// present but using AAAA records if no A records are present, // even with HTTP/2. // // This differs from the http.DefaultClient, which will use only a From 02c79a43c8bc628b22a73ce75059284c4bbcb6cf Mon Sep 17 00:00:00 2001 From: John Chadwick Date: Thu, 25 Apr 2024 10:19:42 -0400 Subject: [PATCH 3/6] Split out address affinity routine --- resolver/resolver.go | 54 ++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/resolver/resolver.go b/resolver/resolver.go index 951b23e..09f3ef0 100644 --- a/resolver/resolver.go +++ b/resolver/resolver.go @@ -18,6 +18,7 @@ import ( "context" "io" "net" + "net/netip" "time" "github.com/bufbuild/httplb/attribute" @@ -187,30 +188,7 @@ func (r *dnsResolveProber) ResolveOnce( if err != nil { return nil, 0, err } - switch r.affinity { - case AllFamilies: - break - case PreferIPv4: - ip4Addresses := addresses[:0] - for _, address := range addresses { - if address.Is4() || address.Is4In6() { - ip4Addresses = append(ip4Addresses, address) - } - } - if len(ip4Addresses) > 0 { - addresses = ip4Addresses - } - case PreferIPv6: - ip6Addresses := addresses[:0] - for _, address := range addresses { - if address.Is6() { - ip6Addresses = append(ip6Addresses, address) - } - } - if len(ip6Addresses) > 0 { - addresses = ip6Addresses - } - } + addresses = applyAddressFamilyAffinity(addresses, r.affinity) result := make([]Address, len(addresses)) for i, address := range addresses { result[i].HostPort = net.JoinHostPort(address.Unmap().String(), port) @@ -298,3 +276,31 @@ func (task *pollingResolverTask) run(ctx context.Context, scheme, hostPort strin } } } + +func applyAddressFamilyAffinity(addresses []netip.Addr, affinity AddressFamilyAffinity) []netip.Addr { + switch affinity { + case AllFamilies: + break + case PreferIPv4: + ip4Addresses := addresses[:0] + for _, address := range addresses { + if address.Is4() || address.Is4In6() { + ip4Addresses = append(ip4Addresses, address) + } + } + if len(ip4Addresses) > 0 { + addresses = ip4Addresses + } + case PreferIPv6: + ip6Addresses := addresses[:0] + for _, address := range addresses { + if address.Is6() { + ip6Addresses = append(ip6Addresses, address) + } + } + if len(ip6Addresses) > 0 { + addresses = ip6Addresses + } + } + return addresses +} From 8d42db8407e2e1b15beafa6349fa0f607ca06f41 Mon Sep 17 00:00:00 2001 From: John Chadwick Date: Thu, 25 Apr 2024 12:02:53 -0400 Subject: [PATCH 4/6] Merge network and affinity options into policy --- client.go | 2 +- resolver/resolver.go | 102 +++++++++++++++++++++----------------- resolver/resolver_test.go | 44 +++++++++++----- 3 files changed, 89 insertions(+), 59 deletions(-) diff --git a/client.go b/client.go index 32fbad1..520f74e 100644 --- a/client.go +++ b/client.go @@ -37,7 +37,7 @@ var ( KeepAlive: 30 * time.Second, } defaultNameTTL = 5 * time.Minute - defaultResolver = resolver.NewDNSResolver(net.DefaultResolver, "ip", defaultNameTTL, resolver.PreferIPv4) + defaultResolver = resolver.NewDNSResolver(net.DefaultResolver, resolver.PreferIPv4, defaultNameTTL) ) // Client is an HTTP client that supports configurable client-side load diff --git a/resolver/resolver.go b/resolver/resolver.go index 09f3ef0..4b15e25 100644 --- a/resolver/resolver.go +++ b/resolver/resolver.go @@ -25,25 +25,34 @@ import ( "github.com/bufbuild/httplb/internal" ) -// AddressFamilyAffinity is an option that allows control over the preference +// AddressFamilyPolicy is an option that allows control over the preference // for which addresses to consider when resolving, based on their address // family. -type AddressFamilyAffinity int +type AddressFamilyPolicy int const ( - // AllFamilies will result in all addresses being used, regardless of - // their address family. - AllFamilies AddressFamilyAffinity = iota - // PreferIPv4 will result in only IPv4 addresses being used, if any // IPv4 addresses are present. If no IPv4 addresses are resolved, then // all addresses will be used. - PreferIPv4 + PreferIPv4 AddressFamilyPolicy = iota + + // RequireIPv4 will result in only IPv4 addresses being used. If no IPv4 + // addresses are present, no addresses will be resolved. + RequireIPv4 // PreferIPv6 will result in only IPv6 addresses being used, if any // IPv6 addresses are present. If no IPv6 addresses are resolved, then // all addresses will be used. PreferIPv6 + + // RequireIPv6 will result in only IPv6 addresses being used. If no IPv6 + // addresses are present, no addresses will be resolved. + RequireIPv6 + + // PreferIPv6 will result in only IPv6 addresses being used, if any + // UseBothIPv4AndIPv6 will result in all addresses being used, regardless of + // their address family. + UseBothIPv4AndIPv6 ) // Resolver is an interface for continuous name resolution. @@ -124,25 +133,19 @@ type Address struct { Attributes attribute.Values } -// NewDNSResolver creates a new resolver that resolves DNS names. -// You can specify which kind of network addresses to resolve with the network -// parameter, and the resolver will return only IP addresses of the type -// specified by network. The network must be one of "ip", "ip4" or "ip6". -// Note that because net.Resolver does not expose the record TTL values, this -// resolver uses the fixed TTL provided in the ttl parameter. The specified -// address family affinity value can be used to prefer using either IPv4 or -// IPv6 addresses only, in cases where there are both A and AAAA records. +// NewDNSResolver creates a new resolver that resolves DNS names. The specified +// address family policy value can be used to require or prefer either IPv4 or +// IPv6 addresses. Note that because net.Resolver does not expose the record +// TTL values, this resolver uses the fixed TTL provided in the ttl parameter. func NewDNSResolver( resolver *net.Resolver, - network string, + policy AddressFamilyPolicy, ttl time.Duration, - affinity AddressFamilyAffinity, ) Resolver { return NewPollingResolver( &dnsResolveProber{ resolver: resolver, - network: network, - affinity: affinity, + policy: policy, }, ttl, ) @@ -164,8 +167,7 @@ func NewPollingResolver( type dnsResolveProber struct { resolver *net.Resolver - network string - affinity AddressFamilyAffinity + policy AddressFamilyPolicy } func (r *dnsResolveProber) ResolveOnce( @@ -184,11 +186,12 @@ func (r *dnsResolveProber) ResolveOnce( port = "80" } } - addresses, err := r.resolver.LookupNetIP(ctx, r.network, host) + network := networkForAddressFamilyPolicy(r.policy) + addresses, err := r.resolver.LookupNetIP(ctx, network, host) if err != nil { return nil, 0, err } - addresses = applyAddressFamilyAffinity(addresses, r.affinity) + addresses = applyAddressFamilyPolicy(addresses, r.policy) result := make([]Address, len(addresses)) for i, address := range addresses { result[i].HostPort = net.JoinHostPort(address.Unmap().String(), port) @@ -277,30 +280,37 @@ func (task *pollingResolverTask) run(ctx context.Context, scheme, hostPort strin } } -func applyAddressFamilyAffinity(addresses []netip.Addr, affinity AddressFamilyAffinity) []netip.Addr { - switch affinity { - case AllFamilies: - break - case PreferIPv4: - ip4Addresses := addresses[:0] - for _, address := range addresses { - if address.Is4() || address.Is4In6() { - ip4Addresses = append(ip4Addresses, address) - } - } - if len(ip4Addresses) > 0 { - addresses = ip4Addresses - } - case PreferIPv6: - ip6Addresses := addresses[:0] - for _, address := range addresses { - if address.Is6() { - ip6Addresses = append(ip6Addresses, address) - } - } - if len(ip6Addresses) > 0 { - addresses = ip6Addresses +func networkForAddressFamilyPolicy(policy AddressFamilyPolicy) string { + switch policy { + case PreferIPv4, PreferIPv6, UseBothIPv4AndIPv6: + return "ip" + case RequireIPv4: + return "ip4" + case RequireIPv6: + return "ip6" + } + return "" +} + +func applyAddressFamilyPolicy(addresses []netip.Addr, policy AddressFamilyPolicy) []netip.Addr { + var check func(netip.Addr) bool + required := policy == RequireIPv4 || policy == RequireIPv6 + switch policy { + case PreferIPv4, RequireIPv4: + check = func(address netip.Addr) bool { return address.Is4() || address.Is4In6() } + case PreferIPv6, RequireIPv6: + check = func(address netip.Addr) bool { return address.Is6() && !address.Is4In6() } + case UseBothIPv4AndIPv6: + return addresses + } + matchingAddresses := addresses[:0] + for _, address := range addresses { + if check(address) { + matchingAddresses = append(matchingAddresses, address) } } + if required || len(matchingAddresses) > 0 { + addresses = matchingAddresses + } return addresses } diff --git a/resolver/resolver_test.go b/resolver/resolver_test.go index 1c6ed6a..df66b1a 100644 --- a/resolver/resolver_test.go +++ b/resolver/resolver_test.go @@ -39,7 +39,7 @@ func TestResolverTTL(t *testing.T) { t.Cleanup(cancel) testClock := clocktest.NewFakeClock() - resolver := NewDNSResolver(net.DefaultResolver, "ip6", testTTL, AllFamilies) + resolver := NewDNSResolver(net.DefaultResolver, RequireIPv6, testTTL) resolver.(*pollingResolver).clock = testClock signal := make(chan struct{}) @@ -129,23 +129,31 @@ func TestAddressFamilyAffinity(t *testing.T) { ip4Address2Resource, ip6Address2Resource, }) - resolver := NewDNSResolver(mixedDNSResolver, "ip", 1, AllFamilies) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2, ip6Address1, ip6Address2}) - resolver = NewDNSResolver(mixedDNSResolver, "ip", 1, PreferIPv4) + resolver := NewDNSResolver(mixedDNSResolver, PreferIPv4, 1) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + resolver = NewDNSResolver(mixedDNSResolver, RequireIPv4, 1) testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) - resolver = NewDNSResolver(mixedDNSResolver, "ip", 1, PreferIPv6) + resolver = NewDNSResolver(mixedDNSResolver, PreferIPv6, 1) testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + resolver = NewDNSResolver(mixedDNSResolver, RequireIPv6, 1) + testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + resolver = NewDNSResolver(mixedDNSResolver, UseBothIPv4AndIPv6, 1) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2, ip6Address1, ip6Address2}) // A records only ip4DNSResolver := newFakeDNSResolver(t, []dnsmessage.Resource{ ip4Address1Resource, ip4Address2Resource, }) - resolver = NewDNSResolver(ip4DNSResolver, "ip", 1, AllFamilies) + resolver = NewDNSResolver(ip4DNSResolver, PreferIPv4, 1) testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) - resolver = NewDNSResolver(ip4DNSResolver, "ip", 1, PreferIPv4) + resolver = NewDNSResolver(ip4DNSResolver, RequireIPv4, 1) testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) - resolver = NewDNSResolver(ip4DNSResolver, "ip", 1, PreferIPv6) + resolver = NewDNSResolver(ip4DNSResolver, PreferIPv6, 1) + testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + resolver = NewDNSResolver(ip4DNSResolver, RequireIPv6, 1) + testResolveAddresses(t, resolver, []net.IP{}) + resolver = NewDNSResolver(ip4DNSResolver, UseBothIPv4AndIPv6, 1) testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) // AAAA records only @@ -153,11 +161,15 @@ func TestAddressFamilyAffinity(t *testing.T) { ip6Address1Resource, ip6Address2Resource, }) - resolver = NewDNSResolver(ip6DNSResolver, "ip", 1, AllFamilies) + resolver = NewDNSResolver(ip6DNSResolver, PreferIPv4, 1) + testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + resolver = NewDNSResolver(ip6DNSResolver, RequireIPv4, 1) + testResolveAddresses(t, resolver, []net.IP{}) + resolver = NewDNSResolver(ip6DNSResolver, PreferIPv6, 1) testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) - resolver = NewDNSResolver(ip6DNSResolver, "ip", 1, PreferIPv4) + resolver = NewDNSResolver(ip6DNSResolver, RequireIPv6, 1) testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) - resolver = NewDNSResolver(ip6DNSResolver, "ip", 1, PreferIPv6) + resolver = NewDNSResolver(ip6DNSResolver, UseBothIPv4AndIPv6, 1) testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) } @@ -181,7 +193,15 @@ func testResolveAddresses( resolved <- resolvedAddresses }, onResolveError: func(err error) { - t.Errorf("unexpected resolution error: %v", err) + if len(expectedAddresses) > 0 { + t.Errorf("unexpected resolution error: %v", err) + } else { + dnsErr := &net.DNSError{} + if assert.ErrorAs(t, err, &dnsErr) { + assert.True(t, dnsErr.IsNotFound) + resolved <- []Address{} + } + } }, }, refreshCh) From f26e28aad7d98e33882ba8290e1b4bc00d4f7bf2 Mon Sep 17 00:00:00 2001 From: John Chadwick Date: Thu, 25 Apr 2024 14:22:26 -0400 Subject: [PATCH 5/6] Add test for IPv4 embedded in IPv6 address --- resolver/resolver_test.go | 44 ++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/resolver/resolver_test.go b/resolver/resolver_test.go index df66b1a..0a21d11 100644 --- a/resolver/resolver_test.go +++ b/resolver/resolver_test.go @@ -88,7 +88,7 @@ func TestResolverTTL(t *testing.T) { assert.NoError(t, err) } -func TestAddressFamilyAffinity(t *testing.T) { +func TestAddressFamilyPolicy(t *testing.T) { t.Parallel() ip4Header := dnsmessage.ResourceHeader{ @@ -130,15 +130,15 @@ func TestAddressFamilyAffinity(t *testing.T) { ip6Address2Resource, }) resolver := NewDNSResolver(mixedDNSResolver, PreferIPv4, 1) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip4Address1, ip4Address2}) resolver = NewDNSResolver(mixedDNSResolver, RequireIPv4, 1) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip4Address1, ip4Address2}) resolver = NewDNSResolver(mixedDNSResolver, PreferIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip6Address1, ip6Address2}) resolver = NewDNSResolver(mixedDNSResolver, RequireIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip6Address1, ip6Address2}) resolver = NewDNSResolver(mixedDNSResolver, UseBothIPv4AndIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2, ip6Address1, ip6Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip4Address1, ip4Address2, ip6Address1, ip6Address2}) // A records only ip4DNSResolver := newFakeDNSResolver(t, []dnsmessage.Resource{ @@ -146,15 +146,15 @@ func TestAddressFamilyAffinity(t *testing.T) { ip4Address2Resource, }) resolver = NewDNSResolver(ip4DNSResolver, PreferIPv4, 1) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip4Address1, ip4Address2}) resolver = NewDNSResolver(ip4DNSResolver, RequireIPv4, 1) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip4Address1, ip4Address2}) resolver = NewDNSResolver(ip4DNSResolver, PreferIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip4Address1, ip4Address2}) resolver = NewDNSResolver(ip4DNSResolver, RequireIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{}) + testResolveAddresses(t, resolver, "example.com", []net.IP{}) resolver = NewDNSResolver(ip4DNSResolver, UseBothIPv4AndIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip4Address1, ip4Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip4Address1, ip4Address2}) // AAAA records only ip6DNSResolver := newFakeDNSResolver(t, []dnsmessage.Resource{ @@ -162,20 +162,30 @@ func TestAddressFamilyAffinity(t *testing.T) { ip6Address2Resource, }) resolver = NewDNSResolver(ip6DNSResolver, PreferIPv4, 1) - testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip6Address1, ip6Address2}) resolver = NewDNSResolver(ip6DNSResolver, RequireIPv4, 1) - testResolveAddresses(t, resolver, []net.IP{}) + testResolveAddresses(t, resolver, "example.com", []net.IP{}) resolver = NewDNSResolver(ip6DNSResolver, PreferIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip6Address1, ip6Address2}) resolver = NewDNSResolver(ip6DNSResolver, RequireIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip6Address1, ip6Address2}) resolver = NewDNSResolver(ip6DNSResolver, UseBothIPv4AndIPv6, 1) - testResolveAddresses(t, resolver, []net.IP{ip6Address1, ip6Address2}) + testResolveAddresses(t, resolver, "example.com", []net.IP{ip6Address1, ip6Address2}) + + // IPv4 embedded in IPv6 + // This is needed because Go will do this for all IPv4 addresses that + // are passed into the resolver. Even if Go's behavior changes, we + // should behave consistently in the face of this quirk. + resolver = NewDNSResolver(net.DefaultResolver, RequireIPv4, 1) + loopback := net.ParseIP("127.0.0.1") + testResolveAddresses(t, resolver, "127.0.0.1", []net.IP{loopback}) + testResolveAddresses(t, resolver, "::ffff:127.0.0.1", []net.IP{loopback}) } func testResolveAddresses( t *testing.T, resolver Resolver, + target string, expectedAddresses []net.IP, ) { t.Helper() @@ -188,7 +198,7 @@ func testResolveAddresses( resolver.(*pollingResolver).clock = testClock resolved := make(chan []Address) - task := resolver.New(ctx, "http", "example.com", testReceiver{ + task := resolver.New(ctx, "http", target, testReceiver{ onResolve: func(resolvedAddresses []Address) { resolved <- resolvedAddresses }, From be0e6e4bdaa90371cc2155848b54ae41f2850457 Mon Sep 17 00:00:00 2001 From: John Chadwick Date: Thu, 25 Apr 2024 14:55:25 -0400 Subject: [PATCH 6/6] Add more documentation --- doc.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/doc.go b/doc.go index 561963b..2280e63 100644 --- a/doc.go +++ b/doc.go @@ -40,7 +40,7 @@ // // 2. The client will route requests in a round-robin fashion to the // addresses returned by the DNS system, preferring A records if -// present but using AAAA records if no A records are present, +// present (but using AAAA records if no A records are present), // even with HTTP/2. // // This differs from the http.DefaultClient, which will use only a @@ -61,6 +61,31 @@ // policies, via the [WithResolver] and [WithPicker] options. Active health // checking can be enabled via the [WithHealthChecks] option. // +// Note that the behavior regarding A and AAAA records differs from the +// http.DefaultClient. In http.DefaultClient, the underlying connections use +// net.Dial directly on the provided hostname from the URL, and net.Dial in +// turn implements an RFC 6555 fallback to ensure that connections can be +// established even in the face of broken IPv6 configurations. Meanwhile, +// [httplb.Client] defaults to resolving the name eagerly and treating the +// resolved addresses as individual targets instead. In order to ensure +// maximum compatibility out of the box, the default behavior is to prefer +// IPv4 addresses whenever they are available: if DNS resolution returns +// both IPv4 and IPv6 addresses, only the IPv4 addresses will be used. +// Meanwhile, if DNS resolution only returns IPv6 addresses, those will be +// used instead. To override this behavior, use [WithResolver] and instantiate +// the DNS resolver with a different [resolver.AddressFamilyPolicy] value. +// For example, to prefer IPv6 addresses instead, one could use: +// +// client := httplb.NewClient( +// httplb.WithResolver( +// resolver.NewDNSResolver( +// net.DefaultResolver, +// resolver.PreferIPv6, +// 5 * time.Minute, // TTL value +// ), +// ), +// ) +// // # Transport Architecture // // The clients created by this function use a transport implementation