Skip to content

Commit

Permalink
Implement address family affinity and prefer IPv4 by default (#66)
Browse files Browse the repository at this point in the history
Implements address family policy. When the policy is set to `PreferIPv4`
or `PreferIPv6`, any DNS result that has both IPv4 and IPv6 records will
be filtered to only the preferred records; in cases where only IPv4 or
IPv6 addresses are present, this has no impact. The default is set to
`PreferIPv4` to help lower the likelihood of problems due to the fact
that `httplb` lacks some kind of implementation of a "happy eyeballs"
algorithm to perform IPv6 fallback.

It winds up being a little tricky to test this, but it's probably
worthwhile; right now we can only test the DNS resolver in a fairly
limited way (by relying on the fact that resolving an IP address is a
no-op.) With this approach, we can test the DNS resolver more-or-less
end-to-end.
  • Loading branch information
jchadwick-buf committed Apr 26, 2024
1 parent 2635a48 commit b9ed61b
Show file tree
Hide file tree
Showing 4 changed files with 331 additions and 14 deletions.
2 changes: 1 addition & 1 deletion client.go
Expand Up @@ -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, resolver.PreferIPv4, defaultNameTTL)
)

// Client is an HTTP client that supports configurable client-side load
Expand Down
30 changes: 28 additions & 2 deletions doc.go
Expand Up @@ -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
Expand All @@ -60,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
Expand Down
86 changes: 76 additions & 10 deletions resolver/resolver.go
Expand Up @@ -18,12 +18,43 @@ import (
"context"
"io"
"net"
"net/netip"
"time"

"github.com/bufbuild/httplb/attribute"
"github.com/bufbuild/httplb/internal"
)

// AddressFamilyPolicy is an option that allows control over the preference
// for which addresses to consider when resolving, based on their address
// family.
type AddressFamilyPolicy int

const (
// 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 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.
type Resolver interface {
// New creates a continuous resolver task for the given target name. When
Expand Down Expand Up @@ -102,21 +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.
// 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,
) Resolver {
return NewPollingResolver(
&dnsResolveProber{
resolver: resolver,
network: network,
policy: policy,
},
ttl,
)
Expand All @@ -138,7 +167,7 @@ func NewPollingResolver(

type dnsResolveProber struct {
resolver *net.Resolver
network string
policy AddressFamilyPolicy
}

func (r *dnsResolveProber) ResolveOnce(
Expand All @@ -157,10 +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 = applyAddressFamilyPolicy(addresses, r.policy)
result := make([]Address, len(addresses))
for i, address := range addresses {
result[i].HostPort = net.JoinHostPort(address.Unmap().String(), port)
Expand Down Expand Up @@ -248,3 +279,38 @@ func (task *pollingResolverTask) run(ctx context.Context, scheme, hostPort strin
}
}
}

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
}

0 comments on commit b9ed61b

Please sign in to comment.