Stub resolvers in languages

8. Stub resolvers in languages #

Let’s now take a look at other popular languages and understand the capabilities, features and options they provide in the context of resolvers.

8.1 Python #

We are going to talk about cpython 3.12.

8.1.1 Stub resolvers #

The Python standard library provides socket.getaddrinfo():

socket.getaddrinfo(host, port, family=0, type=0, proto=0, flags=0)

which internally calls libc getaddrinfo():

/* Python interface to getaddrinfo(host, port). */

/*ARGSUSED*/
static PyObject *
socket_getaddrinfo(PyObject *self, PyObject *args, PyObject* kwargs)
{
    ...
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = family;
    hints.ai_socktype = socktype;
    hints.ai_protocol = protocol;
    hints.ai_flags = flags;
    Py_BEGIN_ALLOW_THREADS
    error = getaddrinfo(hptr, pptr, &hints, &res0);
    ...
}

There are also socket.gethostbyname() and socket.gethostbyname_ex(), but they both don’t support IPv6.

The latest significant paradigm shift in Python was the adoption of async functions. The async standard framework includes loop.getaddrinfo(), which provides asynchronous DNS resolution capabilities:

import asyncio
import socket

async def resolve_hostname(hostname):
    loop = asyncio.get_running_loop()
    addresses = await loop.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP)
    return addresses

addresses = asyncio.run(resolve_hostname('microsoft.com'))

which internally shows the same behavior as getaddrinfo():

strace -f -s0 python3 ./main.py

Open config files:

[pid 49937] openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 6
[pid 49937] openat(AT_FDCWD, "/etc/host.conf", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid 49937] openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 6
[pid 49937] openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 6
[pid 49937] openat(AT_FDCWD, "/etc/gai.conf", O_RDONLY|O_CLOEXEC) = 6

Obtaining source addresses:

[pid 49955] connect(6, {sa_family=AF_UNSPEC, sa_data="\0\0\0\0\0\0\0\0\0\0\0\0\0\0"}, 16) = 0
[pid 49955] connect(6, {sa_family=AF_INET6, sin6_port=htons(0), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1030:20e:3::23c", &sin6_addr), sin6_scope_id=0}, 28) = 0
[pid 49955] getsockname(6, {sa_family=AF_INET6, sin6_port=htons(33582), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:db8:123:456:6af2:68fe:ff7c:e25c", &sin6_addr), sin6_scope_id=0}, [
28]) = 0

So we can guess that under the hood it still runs socket.getaddrinfo():

async def getaddrinfo(self, host, port, *, family=0, type=0, proto=0, flags=0):
    if self._debug:
        getaddr_func = self._getaddrinfo_debug
    else:
        getaddr_func = socket.getaddrinfo

    return await self.run_in_executor(
        None, getaddr_func, host, port, family, type, proto, flags)

The socket.getaddrinfo() is running in a thread execution pool.

Such a solution could show performance issues under high load, that’s why there are plenty of third party libraries. But I’d like to show the aiodns. Internally it uses pycares which is a Python interface for c-ares.

import aiodns
import asyncio
import socket

async def resolve_hostname(hostname):
    resolver = aiodns.DNSResolver()
    result = await resolver.gethostbyname(hostname, socket.AF_UNSPEC)
    return result

addresses = asyncio.run(resolve_hostname('microsoft.com'))
print(addresses.addresses)

Opening system files:

$ strace -e openat python3 ./dns.py
openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY) = 6
openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY) = 6
openat(AT_FDCWD, "/etc/host.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/svc.conf", O_RDONLY) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/hosts", O_RDONLY) = 6

Gather source addresses:

$ strace -f -e trace=network python3 ./dns.py
socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP) = 7
connect(7, {sa_family=AF_INET6, sin6_port=htons(0), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1030:b:3::152", &sin6_addr), sin6_scope_id=0}, 28) = 0
getsockname(7, {sa_family=AF_INET6, sin6_port=htons(58701), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:db8:123:456:6af2:68fe:ff7c:e25c", &sin6_addr), sin6_scope_id=0}, [28]) = 0
socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP) = 7
connect(7, {sa_family=AF_INET6, sin6_port=htons(0), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1030:20e:3::23c", &sin6_addr), sin6_scope_id=0}, 28) = 0
getsockname(7, {sa_family=AF_INET6, sin6_port=htons(43168), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:db8:123:456:6af2:68fe:ff7c:e25c", &sin6_addr), sin6_scope_id=0}, [28]) = 0
socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP) = 7
connect(7, {sa_family=AF_INET6, sin6_port=htons(0), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1010:3:3::5b", &sin6_addr), sin6_scope_id=0}, 28) = 0
getsockname(7, {sa_family=AF_INET6, sin6_port=htons(36319), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:db8:123:456:6af2:68fe:ff7c:e25c", &sin6_addr), sin6_scope_id=0}, [28]) = 0
socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP) = 7
connect(7, {sa_family=AF_INET6, sin6_port=htons(0), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1020:201:10::10f", &sin6_addr), sin6_scope_id=0}, 28) = 0
getsockname(7, {sa_family=AF_INET6, sin6_port=htons(42078), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:db8:123:456:6af2:68fe:ff7c:e25c", &sin6_addr), sin6_scope_id=0}, [28]) = 0
socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP) = 7
connect(7, {sa_family=AF_INET6, sin6_port=htons(0), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1030:c02:8::14", &sin6_addr), sin6_scope_id=0}, 28) = 0
getsockname(7, {sa_family=AF_INET6, sin6_port=htons(43403), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:db8:123:456:6af2:68fe:ff7c:e25c", &sin6_addr), sin6_scope_id=0}, [28]) = 0

8.1.2 Happy Eyeballs #

The async loop.create_connection() allows to use the Happy Eyeballs algorithm from RFC 8305. The code is in in Lib/asyncio/base_events.py:

if happy_eyeballs_delay is None:
   # not using happy eyeballs
   
else:  # using happy eyeballs
   sock, _, _ = await staggered.staggered_race(
       (functools.partial(self._connect_sock,
               exceptions, addrinfo, laddr_infos)
        for addrinfo in infos),
        happy_eyeballs_delay, loop=self)

The params of loop.create_connection()are:

happy_eyeballs_delay, if given, enables Happy Eyeballs for this connection. It should be a floating-point number representing the amount of time in seconds to wait for a connection attempt to complete, before starting the next attempt in parallel. This is the “Connection Attempt Delay” as defined in RFC 8305. A sensible default value recommended by the RFC is 0.25 (250 milliseconds).

interleave – controls address reordering when a host name resolves to multiple IP addresses. If 0 or unspecified, no reordering is done, and addresses are tried in the order returned by getaddrinfo(). If a positive integer is specified, the addresses are interleaved by address family, and the given integer is interpreted as “First Address Family Count” as defined in RFC 8305. The default is 0 if happy_eyeballs_delay is not specified, and 1 if it is.

8.2 Go (golang) #

8.2.1 Resolver #

According to the documentation golang has two components to resolve domain names:

On Unix systems, the resolver has two options for resolving names.

It can use a pure Go resolver that sends DNS requests directly to the servers listed in /etc/resolv.conf, or it can use a cgo-based resolver that calls C library routines such as getaddrinfo and getnameinfo.

On Unix the pure Go resolver is preferred over the cgo resolver, because a blocked DNS request consumes only a goroutine, while a blocked C call consumes an operating system thread.

When cgo is available, the cgo-based resolver is used instead under a variety of conditions: on systems that do not let programs make direct DNS requests (OS X), when the LOCALDOMAIN environment variable is present (even if empty), when the RES_OPTIONS or HOSTALIASES environment variable is non-empty, when the ASR_CONFIG environment variable is non-empty (OpenBSD only), when /etc/resolv.conf or /etc/nsswitch.conf specify the use of features that the Go resolver does not implement.

On all systems (except Plan 9), when the cgo resolver is being used this package applies a concurrent cgo lookup limit to prevent the system from running out of system threads. Currently, it is limited to 500 concurrent lookups.

The resolver decision can be overridden by setting the netdns value of the GODEBUG environment variable (see package runtime) to go or cgo, as in:

export GODEBUG=netdns=go    # force pure Go resolver
export GODEBUG=netdns=cgo   # force native resolver (cgo, win32)

Let’s create an example client resolver:

package main

import (
	"fmt"
	"net"
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Println("Usage: go run main.go <domain>")
		return
	}

	domain := os.Args[1]

	ips, err := net.LookupIP(domain)
	if err != nil {
		fmt.Printf("Could not get IPs: %v\n", err)
		return
	}

	for _, ip := range ips {
		fmt.Println(ip.String())
	}
}

Build it with debug info:

$ go build -gcflags "all=-N -l" -o resolver ./main.go

Run:

$ ./resolver.go microsoft.com
2603:1020:201:10::10f
2603:1030:b:3::152
2603:1030:20e:3::23c
2603:1030:c02:8::14
2603:1010:3:3::5b
20.231.239.246
20.76.201.171
20.70.246.20
20.236.44.162
20.112.250.133

Run with strace:

$ sudo strace -e openat ./resolver microsoft.com
[pid 48568] openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 3
[pid 48568] openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3
[pid 48568] openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 3

It only reads the basic system configuration files. It also does source address retrieval:

$ sudo strace -f -e trace=network ./resolver microsoft.com 2>&1
[pid 48674] socket(AF_INET6, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3
[pid 48674] setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
[pid 48674] setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
[pid 48674] connect(3, {sa_family=AF_INET6, sin6_port=htons(9), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1030:c02:8::14", &sin6_addr), sin6_scope_id=0}, 28) = 0
[pid 48674] getsockname(3, {sa_family=AF_INET6, sin6_port=htons(58438), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2001:db8:123:456:6af2:68fe:ff7c:e25c", &sin6_addr), sin6_scope_id=0}, [112 => 28]) = 0
[pid 48674] getpeername(3, {sa_family=AF_INET6, sin6_port=htons(9), sin6_flowinfo=htonl(0), inet_pton(AF_INET6, "2603:1030:c02:8::14", &sin6_addr), sin6_scope_id=0}, [112 => 28]) = 0

Let make sure we don’t use libc getaddrinfo(). We use gdb debugger for that:

$ gdb ./resolver
(gdb) set args microsoft.com
(gdb) break getaddrinfo
Breakpoint 1 at 0x11aa90
(gdb) run
2603:1010:3:3::5b
2603:1030:b:3::152
2603:1020:201:10::10f
2603:1030:20e:3::23c
2603:1030:c02:8::14
20.112.250.133
20.231.239.246
20.76.201.171
20.236.44.162
20.70.246.20
[Inferior 1 (process 48729) exited normally]
(gdb) Quit

The break point was not hit, so there were no getaddrinfo() calls.

And if we recompile with cgo resolver:

$ export GODEBUG=netdns=cgo
$ go build -gcflags "all=-N -l"  -o resolver ./main.go
$ gdb ./resolver
(gdb) set args microsoft.com
(gdb) break getaddrinfo
Breakpoint 1 at 0x11aa90
(gdb) run

Thread 1 "resolver" hit Breakpoint 1, __GI_getaddrinfo (name=0x4000012120 "microsoft.com", service=0x0, hints=0x4000100540, pai=0x4000040048) at ./nss/getaddrinfo.c:2297
warning: 2297   ./nss/getaddrinfo.c: No such file or directory

The decision which resolver to use happens here and has a lot of logic to make a decision which stub resolver would be beneficial.

Go supports the destination address sorting (as we see earlier with obtaining source adresses) in its go resolver but doesn’t provide a way to change the sorting rules (doesn’t read /etc/gai.conf). The logic lives in https://github.com/golang/go/blob/master/src/net/addrselect.go.

8.2.2 Happy Eyeballs #

The standard golang library transparently supports RFC 6555 Happy Eyeballs: Success with Dual-Stack Hosts in net.Dialer:

type Dialer struct {
	
	// FallbackDelay specifies the length of time to wait before
	// spawning a RFC 6555 Fast Fallback connection. That is, this
	// is the amount of time to wait for IPv6 to succeed before
	// assuming that IPv6 is misconfigured and falling back to
	// IPv4.
	//
	// If zero, a default delay of 300ms is used.
	// A negative value disables Fast Fallback support.
	FallbackDelay time.Duration
	 
}

Internally net.Dialer in the DialContext() method resolves hostname into adresses, splits them into two groups by address families, and runs the sysDialer.dialParallel() function:

func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {
	...
	addrs, err := d.resolver().resolveAddrList(resolveCtx, "dial", network, address, d.LocalAddr)
	if err != nil {
		return nil, &OpError{Op: "dial", Net: network, Source: nil, Addr: nil, Err: err}
	}

	sd := &sysDialer{
		Dialer:  *d,
		network: network,
		address: address,
	}

	var primaries, fallbacks addrList
	if d.dualStack() && network == "tcp" {
		primaries, fallbacks = addrs.partition(isIPv4)
	} else {
		primaries = addrs
	}

	return sd.dialParallel(ctx, primaries, fallbacks)
}

where dialParallel() issues an IPv6 connection request first, waits for FallbackDelay or 300 ms by default in fallbackDelay().

// dialParallel races two copies of dialSerial, giving the first a
// head start. It returns the first established connection and
// closes the others. Otherwise it returns an error from the first
// primary address.
func (sd *sysDialer) dialParallel(ctx context.Context, primaries, fallbacks addrList) (Conn, error) {
	if len(fallbacks) == 0 {
		return sd.dialSerial(ctx, primaries)
	}

	returned := make(chan struct{})
	defer close(returned)

	type dialResult struct {
		Conn
		error
		primary bool
		done    bool
	}
	results := make(chan dialResult) // unbuffered

	startRacer := func(ctx context.Context, primary bool) {
		ras := primaries
		if !primary {
			ras = fallbacks
		}
		c, err := sd.dialSerial(ctx, ras)
		select {
		case results <- dialResult{Conn: c, error: err, primary: primary, done: true}:
		case <-returned:
			if c != nil {
				c.Close()
			}
		}
	}

	var primary, fallback dialResult

	// Start the main racer.
	primaryCtx, primaryCancel := context.WithCancel(ctx)
	defer primaryCancel()
	go startRacer(primaryCtx, true)

	// Start the timer for the fallback racer.
	fallbackTimer := time.NewTimer(sd.fallbackDelay())
	defer fallbackTimer.Stop()

	for {
		select {
		case <-fallbackTimer.C:
			fallbackCtx, fallbackCancel := context.WithCancel(ctx)
			defer fallbackCancel()
			go startRacer(fallbackCtx, false)

		case res := <-results:
			if res.error == nil {
				return res.Conn, nil
			}
			if res.primary {
				primary = res
			} else {
				fallback = res
			}
			if primary.done && fallback.done {
				return nil, primary.error
			}
			if res.primary && fallbackTimer.Stop() {
				// If we were able to stop the timer, that means it
				// was running (hadn't yet started the fallback), but
				// we just got an error on the primary path, so start
				// the fallback immediately (in 0 nanoseconds).
				fallbackTimer.Reset(0)
			}
		}
	}
}
func (d *Dialer) fallbackDelay() time.Duration {

	if d.FallbackDelay > 0 {

		return d.FallbackDelay

	} else {

		return 300 * time.Millisecond

	}

}

8.3 Rust #

We are talking about Rust 1.80.0.

8.3.1 Resolver #

Rust has a ToSocketAddrs trait:

pub trait ToSocketAddrs {
    type Iter: Iterator<Item = SocketAddr>;

    // Required method
    fn to_socket_addrs(&self) -> Result<Self::Iter>;
}

where SocketAddr:

pub enum SocketAddr {
    V4(SocketAddrV4),
    V6(SocketAddrV6),
}

The stdlib comes with the trait implementation for the(&str, u16):

#[stable(feature = "rust1", since = "1.0.0")]
impl ToSocketAddrs for (&str, u16) {
    type Iter = vec::IntoIter<SocketAddr>;
    fn to_socket_addrs(&self) -> io::Result<vec::IntoIter<SocketAddr>> {
        let (host, port) = *self;

        // try to parse the host as a regular IP address first
        if let Ok(addr) = host.parse::<Ipv4Addr>() {
            let addr = SocketAddrV4::new(addr, port);
            return Ok(vec![SocketAddr::V4(addr)].into_iter());
        }
        if let Ok(addr) = host.parse::<Ipv6Addr>() {
            let addr = SocketAddrV6::new(addr, port, 0, 0);
            return Ok(vec![SocketAddr::V6(addr)].into_iter());
        }

        resolve_socket_addr((host, port).try_into()?)
    }
}

where (host, port).try_into() uses the TryFrom trait which is reciprocal for TryInto:

impl<'a> TryFrom<(&'a str, u16)> for LookupHost {
    type Error = io::Error;

    fn try_from((host, port): (&'a str, u16)) -> io::Result<LookupHost> {
        init();

        run_with_cstr(host.as_bytes(), &|c_host| {
            let mut hints: c::addrinfo = unsafe { mem::zeroed() };
            hints.ai_socktype = c::SOCK_STREAM;
            let mut res = ptr::null_mut();
            unsafe {
                cvt_gai(c::getaddrinfo(c_host.as_ptr(), ptr::null(), &hints, &mut res))
                    .map(|_| LookupHost { original: res, cur: res, port })
            }
        })
    }
}

And here we can see here, it uses getaddrinfo() from libc.

If you need a non-boking resolver to run it with async rust feature, you can, for example, use trust-dns-resolver and tokio:

Dependency:

[dependencies]
tokio = { version = "1", features = ["full"] }
trust-dns-resolver = "0.20"

Code:

use trust_dns_resolver::TokioAsyncResolver;
use trust_dns_resolver::config::*;
use trust_dns_resolver::system_conf::read_system_conf;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create the resolver from system configuration
    let (resolver_config, mut resolver_opts) =read_system_conf()?; 

    // Specify the LookupIpStrategy you want
    resolver_opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6;

    // Create the resolver with the system configuration and modified options
    let resolver = TokioAsyncResolver::tokio(resolver_config, resolver_opts)?;

    let response = resolver.lookup_ip("microsoft.com").await?;

    // Print the IP addresses
    for ip in response.iter() {
        println!("IP address: {}", ip);
    }

    Ok(())
}

It doesn’t follow the RFC 6724 Default Address Selection for Internet Protocol Version 6 (IPv6) but it has a lot of other interesting features.

Run it:

$ cargo build --release && ./target/release/resolver
    Finished `release` profile [optimized] target(s) in 0.02s
IP address: 20.76.201.171
IP address: 20.236.44.162
IP address: 20.231.239.246
IP address: 20.70.246.20
IP address: 20.112.250.133
IP address: 2603:1010:3:3::5b
IP address: 2603:1030:c02:8::14
IP address: 2603:1030:b:3::152
IP address: 2603:1020:201:10::10f
IP address: 2603:1030:20e:3::23c

The strace() output regarding reading system config files:

openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 9
openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 9

8.3.2 Happy Eyeballs #

Although there is no support in the standard library, several third-party libraries provide such capabilities.

8.4 Java and Netty #

We will be talking about OpenJDK version 21.

8.4.1 Resolver #

The usual way to resolve a hostname is to run java.net.getAllByName(String host).

In order to change preferences of address families there are two system properties:

  1. java.net.preferIPv4Stack:

systemProperty java.net.preferIPv4Stack (default: false) If IPv6 is available on the operating system the underlying native socket will be, by default, an IPv6 socket which lets applications connect to, and accept connections from, both IPv4 and IPv6 hosts. However, in the case an application would rather use IPv4 only sockets, then this property can be set to true. The implication is that it will not be possible for the application to communicate with IPv6 only hosts.

  1. java.net.preferIPv6Addresse:

systemProperty java.net.preferIPv6Addresses (default: false) When dealing with a host which has both IPv4 and IPv6 addresses, and if IPv6 is available on the operating system, the default behavior is to prefer using IPv4 addresses over IPv6 ones. This is to ensure backward compatibility: for example, for applications that depend on the representation of an IPv4 address (e.g. 192.168.1.1). This property can be set to true to change that preference and use IPv6 addresses over IPv4 ones where possible, or system to preserve the order of the addresses as returned by the system-wide java.net.spi.InetAddressResolver resolver.

Internally the following static variables are initialized in java.net.InetAdress:

    static {
        // create the impl
        impl = isIPv6Supported() ?
                new Inet6AddressImpl() : new Inet4AddressImpl();

        // impl must be initialized before calling this method
        PLATFORM_LOOKUP_POLICY = initializePlatformLookupPolicy();

        // create built-in resolver
        BUILTIN_RESOLVER = createBuiltinInetAddressResolver();
    }

which ends up in the native code and calls our old friend getaddrinfo():

JNIEXPORT jobjectArray JNICALL
Java_java_net_Inet6AddressImpl_lookupAllHostAddr(JNIEnv *env, jobject this,
                                                 jstring host, 
                                                 jintcharacteristics) {
...
    memset(&hints, 0, sizeof(hints));
    hints.ai_flags = AI_CANONNAME;
    hints.ai_family = lookupCharacteristicsToAddressFamily(characteristics);
  
    error = getaddrinfo(hostname, NULL, &hints, &res);
...
}

The lookupCharacteristicsToAddressFamily() function is controlling preferences from the above system properties:

int lookupCharacteristicsToAddressFamily(int characteristics) {
    int ipv4 = characteristics & java_net_spi_InetAddressResolver_LookupPolicy_IPV4;
    int ipv6 = characteristics & java_net_spi_InetAddressResolver_LookupPolicy_IPV6;

    if (ipv4 != 0 && ipv6 == 0) {
        return AF_INET;
    }

    if (ipv4 == 0 && ipv6 != 0) {
        return AF_INET6;
    }
    return AF_UNSPEC;
}

We can make some conclusions now:

  • By default preference is given to IPv4 addresses, they are returned first.
  • You can reverse the logic by setting preferIPv6Addresses to true.
  • If you know that you are not going to use IPv6 at all, you can set preferIPv4Stack to true and the resolver stops querying for IPv6 AAAA records.
  • If you want to be dual stack ready and fully address agnostic with some drawback of the lack of Happy Eyeballs in Java, set preferIPv6Addresses to system to delegate all responsibility to getaddrinfo().

However, networking services are typically written using the Netty asynchronous event-driven network framework. Netty provides its own asynchronous non-blocking resolver to work natively with its event loop-based model.

Let’s review its features in version 4.1.

The resolver’s documentation is here and to resolve an address you need to call resolveAll(String inetHost) of SimpleNameResolver.

For example:

package com.example;

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.ChannelFactory;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.DatagramChannel;
import io.netty.channel.socket.nio.NioDatagramChannel;
import io.netty.resolver.ResolvedAddressTypes;
import io.netty.resolver.dns.DnsNameResolver;
import io.netty.resolver.dns.DnsNameResolverBuilder;
import io.netty.resolver.dns.DnsServerAddresses;
import io.netty.util.concurrent.Future;

import java.net.InetAddress;
import java.util.List;


public class App 
{
    public static void main( String[] args ) throws java.lang.InterruptedException
    {
        // Create an event loop group for handling DNS resolution
        NioEventLoopGroup group = new NioEventLoopGroup();

        try {
            // Create a DnsNameResolver
            DnsNameResolver resolver = new DnsNameResolverBuilder(group.next())
                    .channelFactory(new ChannelFactory<DatagramChannel>() {
                        @Override
                        public DatagramChannel newChannel() {
                            return new NioDatagramChannel();
                        }
                    })
                    .build();

            // Resolve the domain name asynchronously
            String domainName = "microsoft.com";
            Future<List<InetAddress>> resolveFuture = resolver.resolveAll(domainName);

            // Wait for the resolution to complete
            List<InetAddress> inetAddresses = resolveFuture.sync().getNow();

            // Print the resolved IP addresses
            for (InetAddress inetAddress : inetAddresses) {
                System.out.println("Resolved IP address: " + inetAddress);
            }
        } finally {
            // Shut down the event loop group to release resources
            group.shutdownGracefully();
        }
    }
}

The output:

Resolved IP address: microsoft.com/20.70.246.20
Resolved IP address: microsoft.com/20.231.239.246
Resolved IP address: microsoft.com/20.236.44.162
Resolved IP address: microsoft.com/20.112.250.133
Resolved IP address: microsoft.com/20.76.201.171
Resolved IP address: microsoft.com/2603:1010:3:3:0:0:0:5b
Resolved IP address: microsoft.com/2603:1030:20e:3:0:0:0:23c
Resolved IP address: microsoft.com/2603:1030:c02:8:0:0:0:14
Resolved IP address: microsoft.com/2603:1020:201:10:0:0:0:10f
Resolved IP address: microsoft.com/2603:1030:b:3:0:0:0:152

We can check that the resolver reads /etc/hosts in resolveHostsFileEntries():

private List<InetAddress> resolveHostsFileEntries(String hostname) {
        if (hostsFileEntriesResolver == null) {
            return null;
        }
        List<InetAddress> addresses;
        if (hostsFileEntriesResolver instanceof DefaultHostsFileEntriesResolver) {
            addresses = ((DefaultHostsFileEntriesResolver) hostsFileEntriesResolver)
                    .addresses(hostname, resolvedAddressTypes);
        } else {
            InetAddress address = hostsFileEntriesResolver.address(hostname, resolvedAddressTypes);
            addresses = address != null ? Collections.singletonList(address) : null;
        }
        return addresses == null && isLocalWindowsHost(hostname) ?
                Collections.singletonList(LOCALHOST_ADDRESS) : addresses;
    }

We can also specify the preference to resolve by settings DnsNameResolverBuilder():

DnsNameResolver resolver = new DnsNameResolverBuilder(group.next())
                    .channelFactory(new ChannelFactory<DatagramChannel>() {
                        @Override
                        public DatagramChannel newChannel() {
                            return new NioDatagramChannel();
                        }
                    }).resolvedAddressTypes(ResolvedAddressTypes.IPV6_PREFERRED)
                    .build();

Where ResolvedAddressTypes:

/**
 * Defined resolved address types.
 */
public enum ResolvedAddressTypes {
    /**
     * Only resolve IPv4 addresses
     */
    IPV4_ONLY,
    /**
     * Only resolve IPv6 addresses
     */
    IPV6_ONLY,
    /**
     * Prefer IPv4 addresses over IPv6 ones
     */
    IPV4_PREFERRED,
    /**
     * Prefer IPv6 addresses over IPv4 ones
     */
    IPV6_PREFERRED
}

Unfortunately, Netty does not support the system options for IPv6 preference like the standard socket resolver does. The decision what to prefer happens in NetUtil:

private static final boolean IPV4_PREFERRED = SystemPropertyUtil.getBoolean("java.net.preferIPv4Stack", false);
... 
static {
        String prefer = SystemPropertyUtil.get("java.net.preferIPv6Addresses", "false");
        if ("true".equalsIgnoreCase(prefer.trim())) {
            IPV6_ADDRESSES_PREFERRED = true;
        } else {
            // Let's just use false in this case as only true is "forcing" ipv6.
            IPV6_ADDRESSES_PREFERRED = false;
        }
        logger.debug("-Djava.net.preferIPv4Stack: {}", IPV4_PREFERRED);
        logger.debug("-Djava.net.preferIPv6Addresses: {}", prefer);
       ... 
}

If we set the properties:

-Djava.net.preferIPv6Addresses=true 

And get the return:

Resolved IP address: microsoft.com/2603:1030:20e:3:0:0:0:23c
Resolved IP address: microsoft.com/2603:1030:b:3:0:0:0:152
Resolved IP address: microsoft.com/2603:1030:c02:8:0:0:0:14
Resolved IP address: microsoft.com/2603:1010:3:3:0:0:0:5b
Resolved IP address: microsoft.com/2603:1020:201:10:0:0:0:10f
Resolved IP address: microsoft.com/20.231.239.246
Resolved IP address: microsoft.com/20.236.44.162
Resolved IP address: microsoft.com/20.112.250.133
Resolved IP address: microsoft.com/20.70.246.20
Resolved IP address: microsoft.com/20.76.201.171

If we force to return only IPv4 with preferIPv4Stack:

-Djava.net.preferIPv4Stack=true
Resolved IP address: microsoft.com/20.70.246.20
Resolved IP address: microsoft.com/20.231.239.246
Resolved IP address: microsoft.com/20.112.250.133
Resolved IP address: microsoft.com/20.76.201.171
Resolved IP address: microsoft.com/20.236.44.162

One more time there is no support of the system argument for -Djava.net.preferIPv6Addresses=system nor RFC 6724 with its section 6 Destination selection algorithm and Happy Eyeballs, so it’s completely on you to control you address preferences and the order in which they will appear in the result list from the resolver. This unfortunately could lead to a complexity with application configuration in the process of migration to IPv6 infrastructure.

The open issue https://github.com/netty/netty/issues/13400 has more info about the situation.

8.4.2 Java SecurityManager #

There is a well-known issue with Java regarding the caching of DNS resolutions.

If you have SecurityManager enabled, the default caching TTL is infinite. To change it, edit the $JAVA_HOME/jre/lib/security/java.security.

The defaults:

# The Java-level namelookup cache policy for successful lookups:
#
# any negative value: caching forever
# any positive value: the number of seconds to cache an address for
# zero: do not cache
#
# default value is forever (FOREVER). For security reasons, this
# caching is made forever when a security manager is set. When a security
# manager is not set, the default behavior in this implementation
# is to cache for 30 seconds.
#
# NOTE: setting this to anything other than the default value can have
#       serious security implications. Do not set it unless
#       you are sure you are not exposed to DNS spoofing attack.
#
#networkaddress.cache.ttl=-1
#
# The Java-level namelookup cache stale policy:
#
# any positive value: the number of seconds to use the stale names
# zero: do not use stale names
# negative values are ignored
#
# default value is 0 (NEVER).
#
#networkaddress.cache.stale.ttl=0
# The Java-level namelookup cache policy for failed lookups:
#
# any negative value: cache forever
# any positive value: the number of seconds to cache negative lookup results
# zero: do not cache
#
# In some Microsoft Windows networking environments that employ
# the WINS name service in addition to DNS, name service lookups
# that fail may take a noticeably long time to return (approx. 5 seconds).
# For this reason the default caching policy is to maintain these
# results for 10 seconds.
#
networkaddress.cache.negative.ttl=10

8.4.3 Happy eyeballs #

There is no support in standard libraries.

There is no support in 4.1 netty: https://github.com/netty/netty/issues/9540

The okhttp (doesn’t use Netty internally and implemented its own event loop) supports it since the 5.0 version.

8.5 Node.js #

8.5.1 Resoler #

The libuv is a C library originally written for Node.js to abstract non-blocking I/O operations.

It uses getaddrinfo() internally.

libuv provides asynchronous DNS resolution. For this it provides its own getaddrinfo replacement [3]. In the callback you can perform normal socket operations on the retrieved addresses. Let’s connect to Libera.chat to see an example of DNS resolution.

[3] - libuv use the system getaddrinfo in the libuv threadpool.

Code:

static void uv__getaddrinfo_work(struct uv__work* w) {
  uv_getaddrinfo_t* req;
  int err;

  req = container_of(w, uv_getaddrinfo_t, work_req);
  err = getaddrinfo(req->hostname, req->service, req->hints, &req->addrinfo);
  req->retcode = uv__getaddrinfo_translate_error(err);
}

8.5.2 Happy Eyeballs #

There is ongoing discussion about adding a basic support in https://github.com/nodejs/node/issues/48145.

Read next chapter →