Repository: netty/netty
Issue: AnnotatedConnectException: Connection refused: localhost/127.0.1.1:37655

When there are more than one entries for localhost in `/etc/hosts`, connection is refused.
This happens only when using `DnsAddressResolverGroup`. If `DefaultAddressResolverGroup` is used there is no problem with connection establishment.

```
# /etc/hosts
127.0.1.1	localhost
127.0.0.1	localhost
```

If `127.0.0.1` is first then there is no problem

```
# /etc/hosts
127.0.0.1	localhost
127.0.1.1	localhost
```

Original issue https://github.com/reactor/reactor-netty/issues/1405

### Expected behavior
Connection to be established regardless of the `AddressResolverGroup` that is used.

### Actual behavior
Exception is thrown with `DnsAddressResolverGroup`:
`AnnotatedConnectException: Connection refused: localhost/127.0.1.1:37655`

### Steps to reproduce
A reproducible example and steps are described in the issue https://github.com/reactor/reactor-netty/issues/1405

I added additional test to the existing test cases here https://github.com/hisener/playground/blob/master/spring-webclient-connection-failure-test/src/test/java/com/github/hisener/spring/webclient/WebClientTest.java

```
    @RepeatedTest(10)
    void nettyClient() throws Exception {
        NioEventLoopGroup group = new NioEventLoopGroup();

        try {
            Bootstrap bootstrap = new Bootstrap();

            bootstrap.group(group)
                     .channel(NioSocketChannel.class)
                     // There is no issue with DefaultAddressResolverGroup
                     //.resolver(DefaultAddressResolverGroup.INSTANCE)
                     .resolver(new DnsAddressResolverGroup(new DnsNameResolverBuilder(group.next()).channelType(NioDatagramChannel.class)))
                     .handler(new ChannelInitializer<SocketChannel>() {
                         @Override
                         protected void initChannel(SocketChannel socketChannel) {
                             socketChannel.pipeline()
                                          .addLast(new HttpClientCodec());
                         }
                     });

            Channel ch = bootstrap.connect(baseUrl.getHost(), baseUrl.getPort())
                                  .sync()
                                  .channel();

            ch.close();
        }
        finally {
            group.shutdownGracefully();
        }
    }
```

### Netty version
4.1.55.Final-SNAPSHOT

### JVM version (e.g. `java -version`)
openjdk version "11.0.9.1" 2020-11-04
OpenJDK Runtime Environment 18.9 (build 11.0.9.1+1)
OpenJDK 64-Bit Server VM 18.9 (build 11.0.9.1+1, mixed mode)

### OS version (e.g. `uname -a`)
Linux b732417723fd 5.4.39-linuxkit #1 SMP Fri May 8 23:03:06 UTC 2020 x86_64 GNU/Linux

Issue comments:
That seems none of Netty`s business, cause just connect 127.0.0.1:37655 successfully but not 127.0.1.1:37655
@violetagg can you connect to the service via telnet on `127.0.1.1` ?
> @violetagg can you connect to the service via telnet on `127.0.1.1` ?

```
# python -m SimpleHTTPServer 8000 &> /dev/null & pid=$!
[1] 1143
# telnet 127.0.1.1 8000
Trying 127.0.1.1...
Connected to 127.0.1.1.
Escape character is '^]'.
^C
Connection closed by foreign host.
# telnet 127.0.0.1 8000
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
^C
Connection closed by foreign host.
# telnet localhost 8000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
^C
Connection closed by foreign host.
```
Thanks, @violetagg. I've also added the test case to [the repo](https://github.com/hisener/playground/blob/master/spring-webclient-connection-failure-test/src/test/java/com/github/hisener/spring/webclient/WebClientTest.java#L83). Do you have some time to have a look at this issue? @normanmaurer 

```
[ERROR] Failures: 
[ERROR]   WebClientTest.reactorNetty expectation "assertNext" failed (expected: onNext(); actual: onError(io.netty.channel.AbstractChannel$AnnotatedConnectException: finishConnect(..) failed: Connection refused: localhost/127.0.1.1:42891))
[ERROR]   WebClientTest.webClient expectation "expectComplete" failed (expected: onComplete(); actual: onError(org.springframework.web.reactive.function.client.WebClientRequestException: finishConnect(..) failed: Connection refused: localhost/127.0.1.1:42891; nested exception is io.netty.channel.AbstractChannel$AnnotatedConnectException: finishConnect(..) failed: Connection refused: localhost/127.0.1.1:42891))
[ERROR] Errors: 
[ERROR]   WebClientTest.nettyClient » AnnotatedConnect Connection refused: localhost/127...
[INFO] 
[ERROR] Tests run: 4, Failures: 2, Errors: 1, Skipped: 0
```
https://travis-ci.com/github/hisener/playground/builds/209949385#L1163-L1169
@normanmaurer The current Netty implementations returns always only one address for `localhost` when `resolveAll` is invoked. If Netty is able to return all resolved addresses for `localhost`, Reactor Netty will be able to iterate over the result.

https://github.com/netty/netty/blob/6724786dcc9fa38ba516dab97a04e2bbc17e81d9/resolver/src/main/java/io/netty/resolver/DefaultHostsFileEntriesResolver.java#L46-L63

https://github.com/netty/netty/blob/6724786dcc9fa38ba516dab97a04e2bbc17e81d9/resolver-dns/src/main/java/io/netty/resolver/dns/DnsNameResolver.java#L1023

https://github.com/netty/netty/blob/6724786dcc9fa38ba516dab97a04e2bbc17e81d9/resolver-dns/src/main/java/io/netty/resolver/dns/DnsNameResolver.java#L684-L697

Also JDK's `HostsFileNameService` returns all entries IPv4/IPv6 for `localhost`, while Netty's `HostsFileParser` always returns the first for IPv4/IPv6.
https://github.com/openjdk/jdk/blob/e8eda655bb27ab1b8d806a64622ea7ee9719a85d/src/java.base/share/classes/java/net/InetAddress.java#L1047-L1054

https://github.com/netty/netty/blob/6724786dcc9fa38ba516dab97a04e2bbc17e81d9/resolver/src/main/java/io/netty/resolver/HostsFileParser.java#L198-L209


Here is a simple test and its results:

```
@Test
void test() throws Exception {
    InetAddress[] addresses = InetAddress.getAllByName("localhost");
    System.out.println("JDK " + Arrays.asList(addresses));


    NioEventLoopGroup group = new NioEventLoopGroup();
    try {
        DefaultAddressResolverGroup.INSTANCE
                .getResolver(group.next())
                .resolveAll(InetSocketAddress.createUnresolved("localhost", 80))
                .addListener(future -> System.out.println("Netty DefaultAddressResolverGroup " + future.get()));

        DnsNameResolverBuilder builder = new DnsNameResolverBuilder(group.next())
                .channelType(NioDatagramChannel.class);
        DnsNameResolver resolver = builder.build();
        resolver.resolveAll("localhost")
                .addListener(future -> System.out.println("Netty DnsNameResolver " + future.get()));
    }
    finally {
        group.shutdownGracefully();
    }
}
```

```
JDK [localhost/127.0.0.1, localhost/127.0.1.1]
Netty DefaultAddressResolverGroup [localhost/127.0.0.1:80, localhost/127.0.1.1:80]
Netty DnsNameResolver [localhost/127.0.1.1]
```

Also I'm seeing that when I use JDK's `PlatformNameService`, the result is sorted, while when I force the usage of `HostsFileNameService`, they are not.

So I need a method that returns all entries for `localhost` like in JDK's implementation (preferably sorted). If it is not possible to modify the current interface (io.netty.resolver.HostsFileEntriesResolver), is it possible to introduce a new one?  May be even `completeOncePreferredResolved` can be used.
@violetagg I think we don't want to break the API ... So maybe add a "sup-interface" and implement it in our implementation ? We could expose a method like `List<InetAddress> addressess(...)` or something like this 
or we could just add an extra method to DefaultHostsFileEntriesResolver and do an instanceof check and call the extra method. 
> or we could just add an extra method to DefaultHostsFileEntriesResolver and do an instanceof check and call the extra method.

Even with `sup-interface` we will need `instance of` so may be let's add this functionality to `DefaultHostsFileEntriesResolver`, if later somebody wants also such functionality, we can always add `sup-interface`
I see similar weird behavior when using Redisson 3.15.3 on top of Netty. Netty says:
```
1376 [main] [DEBUG] Loopback interface: lo1 (lo1, 127.0.1.1)
```
while `ifconfig` says:
```
$ ifconfig lo0
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> metric 0 mtu 16384
        options=680003<RXCSUM,TXCSUM,LINKSTATE,RXCSUM_IPV6,TXCSUM_IPV6>
        inet6 ::1 prefixlen 128
        inet6 fe80::1%lo0 prefixlen 64 scopeid 0x5
        inet 127.0.0.1 netmask 0xff000000
        groups: lo
        nd6 options=21<PERFORMNUD,AUTO_LINKLOCAL>
osipovmi@deblndw013x:~/var/Projekte/MRESOLVER-25
$ ifconfig lo1
lo1: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> metric 0 mtu 16384
        options=680003<RXCSUM,TXCSUM,LINKSTATE,RXCSUM_IPV6,TXCSUM_IPV6>
        inet 127.0.1.1 netmask 0xffffffff
        groups: lo
        nd6 options=29<PERFORMNUD,IFDISABLED,AUTO_LINKLOCAL>
```
Note that host `deblndw013x` uses `lo0` while `lo1` is a cloned loopback interface passed onto jails with a shared network stack as the jailhost.

Redis listens on:
```
$ sockstat | grep redis
menz_i   redis-serv 77878 6  tcp4   127.0.0.1:6379        *:*
```
@normanmaurer PTAL #11246 , I made some tricks to keep the backwards compatibility.

Pull request review comments:
nit: you can remove the else as we return in the if.
nit: should we use a different initial capacity ?
please add javadocs
imho it would be better to use `new ArrayList<InetAddress>(a.size() + (b == null ? 0 : b.size()));`. Otherwise you may need to expand the array after creation 
imho its a bit strange that you can modify the `ParserImpl` and `parse()` depends on the "state". Either make this a proper builder or change `parse(...)` to take arguments (which I think may be the best). 
I was thinking about that but it is not guaranteed that for every `InetAddress` from `hostsFileEntries` we will create a `DnsRecord`
fixed
fixed
fixed
fixed
@violetagg I think the two methods above should not exists but this should be just arguments to `parse`
Not really fixed... I think both methods above should also not exist.
So instead of configuration I should add the same signature as in `HostsFileParser`, is that correct?

```
parseSilently()
parseSilently(Charset... charsets)
parse()
parse(File file)
parse(File file, Charset... charsets)
```
yes... Or alternative just one which take all parameter and allow "null".
Please check the new commit
nit: either we should tell that this method returns `null` as well or return an empty List in this case. 
fixed

@violetagg question... should we use a `LinkedHashMap` here or doesn't it matter ?
I don't think that we have a use case for `LinkedHashMap` where we will need the insertion order. We always need the info for one particular `hostname`
nit: Since it is used more than once and has context in comments, should we extract this longish condition and comment in a method like `isLocalhost()`? 
let me do that
please check the new commit

Patch that resolved the issue:
diff --git a/common/src/main/java/io/netty/util/internal/Hidden.java b/common/src/main/java/io/netty/util/internal/Hidden.java
index abef31287cc..da64a8b1c21 100644
--- a/common/src/main/java/io/netty/util/internal/Hidden.java
+++ b/common/src/main/java/io/netty/util/internal/Hidden.java
@@ -128,7 +128,7 @@ public void applyTo(BlockHound.Builder builder) {
                     "parseEtcResolverOptions");
 
             builder.allowBlockingCallsInside(
-                    "io.netty.resolver.HostsFileParser",
+                    "io.netty.resolver.HostsFileEntriesProvider$ParserImpl",
                     "parse");
 
             builder.nonBlockingThreadPredicate(new Function<Predicate<Thread>, Predicate<Thread>>() {
diff --git a/resolver-dns/src/main/java/io/netty/resolver/dns/DnsNameResolver.java b/resolver-dns/src/main/java/io/netty/resolver/dns/DnsNameResolver.java
index a3a514f2039..76518f188c0 100644
--- a/resolver-dns/src/main/java/io/netty/resolver/dns/DnsNameResolver.java
+++ b/resolver-dns/src/main/java/io/netty/resolver/dns/DnsNameResolver.java
@@ -46,6 +46,7 @@
 import io.netty.handler.codec.dns.DnsResponse;
 import io.netty.handler.codec.dns.TcpDnsQueryEncoder;
 import io.netty.handler.codec.dns.TcpDnsResponseDecoder;
+import io.netty.resolver.DefaultHostsFileEntriesResolver;
 import io.netty.resolver.HostsFileEntries;
 import io.netty.resolver.HostsFileEntriesResolver;
 import io.netty.resolver.InetNameResolver;
@@ -693,20 +694,38 @@ protected EventLoop executor() {
     private InetAddress resolveHostsFileEntry(String hostname) {
         if (hostsFileEntriesResolver == null) {
             return null;
+        }
+        InetAddress address = hostsFileEntriesResolver.address(hostname, resolvedAddressTypes);
+        return address == null && isLocalWindowsHost(hostname) ? LOCALHOST_ADDRESS : address;
+    }
+
+    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);
-            if (address == null && PlatformDependent.isWindows() &&
-                    (LOCALHOST.equalsIgnoreCase(hostname) ||
-                            (WINDOWS_HOST_NAME != null && WINDOWS_HOST_NAME.equalsIgnoreCase(hostname)))) {
-                // If we tried to resolve localhost we need workaround that windows removed localhost from its
-                // hostfile in later versions.
-                // See https://github.com/netty/netty/issues/5386
-                // Need a workaround for resolving the host (computer) name in case it cannot be resolved from hostfile
-                // See https://github.com/netty/netty/issues/11142
-                return LOCALHOST_ADDRESS;
-            }
-            return address;
+            addresses = address != null ? Collections.singletonList(address) : null;
         }
+        return addresses == null && isLocalWindowsHost(hostname) ?
+                Collections.singletonList(LOCALHOST_ADDRESS) : addresses;
+    }
+
+    /**
+     * Checks whether the given hostname is the localhost/host (computer) name on Windows OS.
+     * Windows OS removed the localhost/host (computer) name information from the hosts file in the later versions
+     * and such hostname cannot be resolved from hosts file.
+     * See https://github.com/netty/netty/issues/5386
+     * See https://github.com/netty/netty/issues/11142
+     */
+    private static boolean isLocalWindowsHost(String hostname) {
+        return PlatformDependent.isWindows() &&
+                (LOCALHOST.equalsIgnoreCase(hostname) ||
+                        (WINDOWS_HOST_NAME != null && WINDOWS_HOST_NAME.equalsIgnoreCase(hostname)));
     }
 
     /**
@@ -840,24 +859,29 @@ private Future<List<DnsRecord>> resolveAll(DnsQuestion question, DnsRecord[] add
         final String hostname = question.name();
 
         if (type == DnsRecordType.A || type == DnsRecordType.AAAA) {
-            final InetAddress hostsFileEntry = resolveHostsFileEntry(hostname);
-            if (hostsFileEntry != null) {
-                ByteBuf content = null;
-                if (hostsFileEntry instanceof Inet4Address) {
-                    if (type == DnsRecordType.A) {
-                        content = Unpooled.wrappedBuffer(hostsFileEntry.getAddress());
+            final List<InetAddress> hostsFileEntries = resolveHostsFileEntries(hostname);
+            if (hostsFileEntries != null) {
+                List<DnsRecord> result = new ArrayList<DnsRecord>();
+                for (InetAddress hostsFileEntry : hostsFileEntries) {
+                    ByteBuf content = null;
+                    if (hostsFileEntry instanceof Inet4Address) {
+                        if (type == DnsRecordType.A) {
+                            content = Unpooled.wrappedBuffer(hostsFileEntry.getAddress());
+                        }
+                    } else if (hostsFileEntry instanceof Inet6Address) {
+                        if (type == DnsRecordType.AAAA) {
+                            content = Unpooled.wrappedBuffer(hostsFileEntry.getAddress());
+                        }
                     }
-                } else if (hostsFileEntry instanceof Inet6Address) {
-                    if (type == DnsRecordType.AAAA) {
-                        content = Unpooled.wrappedBuffer(hostsFileEntry.getAddress());
+                    if (content != null) {
+                        // Our current implementation does not support reloading the hosts file,
+                        // so use a fairly large TTL (1 day, i.e. 86400 seconds).
+                        result.add(new DefaultDnsRawRecord(hostname, type, 86400, content));
                     }
                 }
 
-                if (content != null) {
-                    // Our current implementation does not support reloading the hosts file,
-                    // so use a fairly large TTL (1 day, i.e. 86400 seconds).
-                    trySuccess(promise, Collections.<DnsRecord>singletonList(
-                            new DefaultDnsRawRecord(hostname, type, 86400, content)));
+                if (!result.isEmpty()) {
+                    trySuccess(promise, result);
                     return promise;
                 }
             }
@@ -1033,9 +1057,9 @@ protected void doResolveAll(String inetHost,
 
         final String hostname = hostname(inetHost);
 
-        InetAddress hostsFileEntry = resolveHostsFileEntry(hostname);
-        if (hostsFileEntry != null) {
-            promise.setSuccess(Collections.singletonList(hostsFileEntry));
+        List<InetAddress> hostsFileEntries = resolveHostsFileEntries(hostname);
+        if (hostsFileEntries != null) {
+            promise.setSuccess(hostsFileEntries);
             return;
         }
 
diff --git a/resolver/src/main/java/io/netty/resolver/DefaultHostsFileEntriesResolver.java b/resolver/src/main/java/io/netty/resolver/DefaultHostsFileEntriesResolver.java
index 5063c8006ed..378fd98d87e 100644
--- a/resolver/src/main/java/io/netty/resolver/DefaultHostsFileEntriesResolver.java
+++ b/resolver/src/main/java/io/netty/resolver/DefaultHostsFileEntriesResolver.java
@@ -18,10 +18,10 @@
 import io.netty.util.CharsetUtil;
 import io.netty.util.internal.PlatformDependent;
 
-import java.net.Inet4Address;
-import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 
@@ -30,21 +30,47 @@
  */
 public final class DefaultHostsFileEntriesResolver implements HostsFileEntriesResolver {
 
-    private final Map<String, Inet4Address> inet4Entries;
-    private final Map<String, Inet6Address> inet6Entries;
+    private final Map<String, List<InetAddress>> inet4Entries;
+    private final Map<String, List<InetAddress>> inet6Entries;
 
     public DefaultHostsFileEntriesResolver() {
         this(parseEntries());
     }
 
     // for testing purpose only
-    DefaultHostsFileEntriesResolver(HostsFileEntries entries) {
-        inet4Entries = entries.inet4Entries();
-        inet6Entries = entries.inet6Entries();
+    DefaultHostsFileEntriesResolver(HostsFileEntriesProvider entries) {
+        inet4Entries = entries.ipv4Entries();
+        inet6Entries = entries.ipv6Entries();
     }
 
     @Override
     public InetAddress address(String inetHost, ResolvedAddressTypes resolvedAddressTypes) {
+        String normalized = normalize(inetHost);
+        switch (resolvedAddressTypes) {
+            case IPV4_ONLY:
+                return firstAddress(inet4Entries.get(normalized));
+            case IPV6_ONLY:
+                return firstAddress(inet6Entries.get(normalized));
+            case IPV4_PREFERRED:
+                InetAddress inet4Address = firstAddress(inet4Entries.get(normalized));
+                return inet4Address != null ? inet4Address : firstAddress(inet6Entries.get(normalized));
+            case IPV6_PREFERRED:
+                InetAddress inet6Address = firstAddress(inet6Entries.get(normalized));
+                return inet6Address != null ? inet6Address : firstAddress(inet4Entries.get(normalized));
+            default:
+                throw new IllegalArgumentException("Unknown ResolvedAddressTypes " + resolvedAddressTypes);
+        }
+    }
+
+    /**
+     * Resolves all addresses of a hostname against the entries in a hosts file, depending on the specified
+     * {@link ResolvedAddressTypes}.
+     *
+     * @param inetHost the hostname to resolve
+     * @param resolvedAddressTypes the address types to resolve
+     * @return all matching addresses or {@code null} in case the hostname cannot be resolved
+     */
+    public List<InetAddress> addresses(String inetHost, ResolvedAddressTypes resolvedAddressTypes) {
         String normalized = normalize(inetHost);
         switch (resolvedAddressTypes) {
             case IPV4_ONLY:
@@ -52,11 +78,13 @@ public InetAddress address(String inetHost, ResolvedAddressTypes resolvedAddress
             case IPV6_ONLY:
                 return inet6Entries.get(normalized);
             case IPV4_PREFERRED:
-                Inet4Address inet4Address = inet4Entries.get(normalized);
-                return inet4Address != null? inet4Address : inet6Entries.get(normalized);
+                List<InetAddress> allInet4Addresses = inet4Entries.get(normalized);
+                return allInet4Addresses != null ? allAddresses(allInet4Addresses, inet6Entries.get(normalized)) :
+                        inet6Entries.get(normalized);
             case IPV6_PREFERRED:
-                Inet6Address inet6Address = inet6Entries.get(normalized);
-                return inet6Address != null? inet6Address : inet4Entries.get(normalized);
+                List<InetAddress> allInet6Addresses = inet6Entries.get(normalized);
+                return allInet6Addresses != null ? allAddresses(allInet6Addresses, inet4Entries.get(normalized)) :
+                        inet4Entries.get(normalized);
             default:
                 throw new IllegalArgumentException("Unknown ResolvedAddressTypes " + resolvedAddressTypes);
         }
@@ -67,13 +95,27 @@ String normalize(String inetHost) {
         return inetHost.toLowerCase(Locale.ENGLISH);
     }
 
-    private static HostsFileEntries parseEntries() {
+    private static List<InetAddress> allAddresses(List<InetAddress> a, List<InetAddress> b) {
+        List<InetAddress> result = new ArrayList<InetAddress>(a.size() + (b == null ? 0 : b.size()));
+        result.addAll(a);
+        if (b != null) {
+            result.addAll(b);
+        }
+        return result;
+    }
+
+    private static InetAddress firstAddress(List<InetAddress> addresses) {
+        return addresses != null && !addresses.isEmpty() ? addresses.get(0) : null;
+    }
+
+    private static HostsFileEntriesProvider parseEntries() {
         if (PlatformDependent.isWindows()) {
             // Ony windows there seems to be no standard for the encoding used for the hosts file, so let us
             // try multiple until we either were able to parse it or there is none left and so we return an
-            // empty intstance.
-            return HostsFileParser.parseSilently(Charset.defaultCharset(), CharsetUtil.UTF_16, CharsetUtil.UTF_8);
+            // empty instance.
+            return HostsFileEntriesProvider.parser()
+                    .parseSilently(Charset.defaultCharset(), CharsetUtil.UTF_16, CharsetUtil.UTF_8);
         }
-        return HostsFileParser.parseSilently();
+        return HostsFileEntriesProvider.parser().parseSilently();
     }
 }
diff --git a/resolver/src/main/java/io/netty/resolver/HostsFileEntries.java b/resolver/src/main/java/io/netty/resolver/HostsFileEntries.java
index dd47bd4c1e2..7c795225520 100644
--- a/resolver/src/main/java/io/netty/resolver/HostsFileEntries.java
+++ b/resolver/src/main/java/io/netty/resolver/HostsFileEntries.java
@@ -22,7 +22,9 @@
 import java.util.Map;
 
 /**
- * A container of hosts file entries
+ * A container of hosts file entries.
+ * The mappings contain only the first entry per hostname.
+ * Consider using {@link HostsFileEntriesProvider} when mappings with all entries per hostname are needed.
  */
 public final class HostsFileEntries {
 
diff --git a/resolver/src/main/java/io/netty/resolver/HostsFileEntriesProvider.java b/resolver/src/main/java/io/netty/resolver/HostsFileEntriesProvider.java
new file mode 100644
index 00000000000..8f0edb1fc6e
--- /dev/null
+++ b/resolver/src/main/java/io/netty/resolver/HostsFileEntriesProvider.java
@@ -0,0 +1,311 @@
+/*
+ * Copyright 2021 The Netty Project
+ *
+ * The Netty Project licenses this file to you under the Apache License,
+ * version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+package io.netty.resolver;
+
+import io.netty.util.NetUtil;
+import io.netty.util.internal.PlatformDependent;
+import io.netty.util.internal.logging.InternalLogger;
+import io.netty.util.internal.logging.InternalLoggerFactory;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import static io.netty.util.internal.ObjectUtil.checkNotNull;
+
+/**
+ * A container of hosts file entries
+ */
+public final class HostsFileEntriesProvider {
+
+    public interface Parser {
+
+        /**
+         * Parses the hosts file at standard OS location using the system default {@link Charset} for decoding.
+         *
+         * @return a new {@link HostsFileEntriesProvider}
+         * @throws IOException file could not be read
+         */
+        HostsFileEntriesProvider parse() throws IOException;
+
+        /**
+         * Parses the hosts file at standard OS location using the given {@link Charset}s one after another until
+         * parse something or none is left.
+         *
+         * @param charsets the {@link Charset}s to try as file encodings when parsing
+         * @return a new {@link HostsFileEntriesProvider}
+         * @throws IOException file could not be read
+         */
+        HostsFileEntriesProvider parse(Charset... charsets) throws IOException;
+
+        /**
+         * Parses the provided hosts file using the given {@link Charset}s one after another until
+         * parse something or none is left. In case {@link Charset}s are not provided,
+         * the system default {@link Charset} is used for decoding.
+         *
+         * @param file the file to be parsed
+         * @param charsets the {@link Charset}s to try as file encodings when parsing, in case {@link Charset}s
+         * are not provided, the system default {@link Charset} is used for decoding
+         * @return a new {@link HostsFileEntriesProvider}
+         * @throws IOException file could not be read
+         */
+        HostsFileEntriesProvider parse(File file, Charset... charsets) throws IOException;
+
+        /**
+         * Performs the parsing operation using the provided reader of hosts file format.
+         *
+         * @param reader the reader of hosts file format
+         * @return a new {@link HostsFileEntriesProvider}
+         */
+        HostsFileEntriesProvider parse(Reader reader) throws IOException;
+
+        /**
+         * Parses the hosts file at standard OS location using the system default {@link Charset} for decoding.
+         *
+         * @return a new {@link HostsFileEntriesProvider}
+         */
+        HostsFileEntriesProvider parseSilently();
+
+        /**
+         * Parses the hosts file at standard OS location using the given {@link Charset}s one after another until
+         * parse something or none is left.
+         *
+         * @param charsets the {@link Charset}s to try as file encodings when parsing
+         * @return a new {@link HostsFileEntriesProvider}
+         */
+        HostsFileEntriesProvider parseSilently(Charset... charsets);
+
+        /**
+         * Parses the provided hosts file using the given {@link Charset}s one after another until
+         * parse something or none is left. In case {@link Charset}s are not provided,
+         * the system default {@link Charset} is used for decoding.
+         *
+         * @param file the file to be parsed
+         * @param charsets the {@link Charset}s to try as file encodings when parsing, in case {@link Charset}s
+         * are not provided, the system default {@link Charset} is used for decoding
+         * @return a new {@link HostsFileEntriesProvider}
+         */
+        HostsFileEntriesProvider parseSilently(File file, Charset... charsets);
+    }
+
+    /**
+     * Creates a parser for {@link HostsFileEntriesProvider}.
+     *
+     * @return a new {@link HostsFileEntriesProvider.Parser}
+     */
+    public static Parser parser() {
+        return new ParserImpl();
+    }
+
+    static final HostsFileEntriesProvider EMPTY =
+            new HostsFileEntriesProvider(
+                    Collections.<String, List<InetAddress>>emptyMap(),
+                    Collections.<String, List<InetAddress>>emptyMap());
+
+    private final Map<String, List<InetAddress>> ipv4Entries;
+    private final Map<String, List<InetAddress>> ipv6Entries;
+
+    HostsFileEntriesProvider(Map<String, List<InetAddress>> ipv4Entries, Map<String, List<InetAddress>> ipv6Entries) {
+        this.ipv4Entries = Collections.unmodifiableMap(new HashMap<String, List<InetAddress>>(ipv4Entries));
+        this.ipv6Entries = Collections.unmodifiableMap(new HashMap<String, List<InetAddress>>(ipv6Entries));
+    }
+
+    /**
+     * The IPv4 entries.
+     *
+     * @return the IPv4 entries
+     */
+    public Map<String, List<InetAddress>> ipv4Entries() {
+        return ipv4Entries;
+    }
+
+    /**
+     * The IPv6 entries.
+     *
+     * @return the IPv6 entries
+     */
+    public Map<String, List<InetAddress>> ipv6Entries() {
+        return ipv6Entries;
+    }
+
+    private static final class ParserImpl implements Parser {
+
+        private static final String WINDOWS_DEFAULT_SYSTEM_ROOT = "C:\\Windows";
+        private static final String WINDOWS_HOSTS_FILE_RELATIVE_PATH = "\\system32\\drivers\\etc\\hosts";
+        private static final String X_PLATFORMS_HOSTS_FILE_PATH = "/etc/hosts";
+
+        private static final Pattern WHITESPACES = Pattern.compile("[ \t]+");
+
+        private static final InternalLogger logger = InternalLoggerFactory.getInstance(Parser.class);
+
+        @Override
+        public HostsFileEntriesProvider parse() throws IOException {
+            return parse(locateHostsFile(), Charset.defaultCharset());
+        }
+
+        @Override
+        public HostsFileEntriesProvider parse(Charset... charsets) throws IOException {
+            return parse(locateHostsFile(), charsets);
+        }
+
+        @Override
+        public HostsFileEntriesProvider parse(File file, Charset... charsets) throws IOException {
+            checkNotNull(file, "file");
+            checkNotNull(charsets, "charsets");
+            if (charsets.length == 0) {
+                charsets = new Charset[]{Charset.defaultCharset()};
+            }
+            if (file.exists() && file.isFile()) {
+                for (Charset charset : charsets) {
+                    BufferedReader reader = new BufferedReader(
+                            new InputStreamReader(new FileInputStream(file), charset));
+                    try {
+                        HostsFileEntriesProvider entries = parse(reader);
+                        if (entries != HostsFileEntriesProvider.EMPTY) {
+                            return entries;
+                        }
+                    } finally {
+                        reader.close();
+                    }
+                }
+            }
+            return HostsFileEntriesProvider.EMPTY;
+        }
+
+        @Override
+        public HostsFileEntriesProvider parse(Reader reader) throws IOException {
+            checkNotNull(reader, "reader");
+            BufferedReader buff = new BufferedReader(reader);
+            try {
+                Map<String, List<InetAddress>> ipv4Entries = new HashMap<String, List<InetAddress>>();
+                Map<String, List<InetAddress>> ipv6Entries = new HashMap<String, List<InetAddress>>();
+                String line;
+                while ((line = buff.readLine()) != null) {
+                    // remove comment
+                    int commentPosition = line.indexOf('#');
+                    if (commentPosition != -1) {
+                        line = line.substring(0, commentPosition);
+                    }
+                    // skip empty lines
+                    line = line.trim();
+                    if (line.isEmpty()) {
+                        continue;
+                    }
+
+                    // split
+                    List<String> lineParts = new ArrayList<String>();
+                    for (String s : WHITESPACES.split(line)) {
+                        if (!s.isEmpty()) {
+                            lineParts.add(s);
+                        }
+                    }
+
+                    // a valid line should be [IP, hostname, alias*]
+                    if (lineParts.size() < 2) {
+                        // skip invalid line
+                        continue;
+                    }
+
+                    byte[] ipBytes = NetUtil.createByteArrayFromIpAddressString(lineParts.get(0));
+
+                    if (ipBytes == null) {
+                        // skip invalid IP
+                        continue;
+                    }
+
+                    // loop over hostname and aliases
+                    for (int i = 1; i < lineParts.size(); i++) {
+                        String hostname = lineParts.get(i);
+                        String hostnameLower = hostname.toLowerCase(Locale.ENGLISH);
+                        InetAddress address = InetAddress.getByAddress(hostname, ipBytes);
+                        List<InetAddress> addresses;
+                        if (address instanceof Inet4Address) {
+                            addresses = ipv4Entries.get(hostnameLower);
+                            if (addresses == null) {
+                                addresses = new ArrayList<InetAddress>();
+                                ipv4Entries.put(hostnameLower, addresses);
+                            }
+                        } else {
+                            addresses = ipv6Entries.get(hostnameLower);
+                            if (addresses == null) {
+                                addresses = new ArrayList<InetAddress>();
+                                ipv6Entries.put(hostnameLower, addresses);
+                            }
+                        }
+                        addresses.add(address);
+                    }
+                }
+                return ipv4Entries.isEmpty() && ipv6Entries.isEmpty() ?
+                        HostsFileEntriesProvider.EMPTY :
+                        new HostsFileEntriesProvider(ipv4Entries, ipv6Entries);
+            } finally {
+                try {
+                    buff.close();
+                } catch (IOException e) {
+                    logger.warn("Failed to close a reader", e);
+                }
+            }
+        }
+
+        @Override
+        public HostsFileEntriesProvider parseSilently() {
+            return parseSilently(locateHostsFile(), Charset.defaultCharset());
+        }
+
+        @Override
+        public HostsFileEntriesProvider parseSilently(Charset... charsets) {
+            return parseSilently(locateHostsFile(), charsets);
+        }
+
+        @Override
+        public HostsFileEntriesProvider parseSilently(File file, Charset... charsets) {
+            try {
+                return parse(file, charsets);
+            } catch (IOException e) {
+                if (logger.isWarnEnabled()) {
+                    logger.warn("Failed to load and parse hosts file at " + file.getPath(), e);
+                }
+                return HostsFileEntriesProvider.EMPTY;
+            }
+        }
+
+        private static File locateHostsFile() {
+            File hostsFile;
+            if (PlatformDependent.isWindows()) {
+                hostsFile = new File(System.getenv("SystemRoot") + WINDOWS_HOSTS_FILE_RELATIVE_PATH);
+                if (!hostsFile.exists()) {
+                    hostsFile = new File(WINDOWS_DEFAULT_SYSTEM_ROOT + WINDOWS_HOSTS_FILE_RELATIVE_PATH);
+                }
+            } else {
+                hostsFile = new File(X_PLATFORMS_HOSTS_FILE_PATH);
+            }
+            return hostsFile;
+        }
+    }
+}
diff --git a/resolver/src/main/java/io/netty/resolver/HostsFileParser.java b/resolver/src/main/java/io/netty/resolver/HostsFileParser.java
index c73e3480298..af181f940c3 100644
--- a/resolver/src/main/java/io/netty/resolver/HostsFileParser.java
+++ b/resolver/src/main/java/io/netty/resolver/HostsFileParser.java
@@ -15,63 +15,31 @@
  */
 package io.netty.resolver;
 
-import io.netty.util.NetUtil;
-import io.netty.util.internal.PlatformDependent;
-import io.netty.util.internal.logging.InternalLogger;
-import io.netty.util.internal.logging.InternalLoggerFactory;
-
-import java.io.BufferedReader;
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.IOException;
-import java.io.InputStreamReader;
 import java.io.Reader;
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
 import java.nio.charset.Charset;
-import java.util.ArrayList;
 import java.util.List;
-import java.util.Locale;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.regex.Pattern;
-
-import static io.netty.util.internal.ObjectUtil.*;
 
 /**
  * A parser for hosts files.
+ * The produced mappings contain only the first entry per hostname.
+ * Consider using {@link HostsFileEntriesProvider} when mappings with all entries per hostname are needed.
  */
 public final class HostsFileParser {
 
-    private static final String WINDOWS_DEFAULT_SYSTEM_ROOT = "C:\\Windows";
-    private static final String WINDOWS_HOSTS_FILE_RELATIVE_PATH = "\\system32\\drivers\\etc\\hosts";
-    private static final String X_PLATFORMS_HOSTS_FILE_PATH = "/etc/hosts";
-
-    private static final Pattern WHITESPACES = Pattern.compile("[ \t]+");
-
-    private static final InternalLogger logger = InternalLoggerFactory.getInstance(HostsFileParser.class);
-
-    private static File locateHostsFile() {
-        File hostsFile;
-        if (PlatformDependent.isWindows()) {
-            hostsFile = new File(System.getenv("SystemRoot") + WINDOWS_HOSTS_FILE_RELATIVE_PATH);
-            if (!hostsFile.exists()) {
-                hostsFile = new File(WINDOWS_DEFAULT_SYSTEM_ROOT + WINDOWS_HOSTS_FILE_RELATIVE_PATH);
-            }
-        } else {
-            hostsFile = new File(X_PLATFORMS_HOSTS_FILE_PATH);
-        }
-        return hostsFile;
-    }
-
     /**
      * Parse hosts file at standard OS location using the systems default {@link Charset} for decoding.
      *
      * @return a {@link HostsFileEntries}
      */
     public static HostsFileEntries parseSilently() {
-        return parseSilently(Charset.defaultCharset());
+        return hostsFileEntries(HostsFileEntriesProvider.parser().parseSilently());
     }
 
     /**
@@ -82,15 +50,7 @@ public static HostsFileEntries parseSilently() {
      * @return a {@link HostsFileEntries}
      */
     public static HostsFileEntries parseSilently(Charset... charsets) {
-        File hostsFile = locateHostsFile();
-        try {
-            return parse(hostsFile, charsets);
-        } catch (IOException e) {
-            if (logger.isWarnEnabled()) {
-                logger.warn("Failed to load and parse hosts file at " + hostsFile.getPath(), e);
-            }
-            return HostsFileEntries.EMPTY;
-        }
+        return hostsFileEntries(HostsFileEntriesProvider.parser().parseSilently(charsets));
     }
 
     /**
@@ -100,7 +60,7 @@ public static HostsFileEntries parseSilently(Charset... charsets) {
      * @throws IOException file could not be read
      */
     public static HostsFileEntries parse() throws IOException {
-        return parse(locateHostsFile());
+        return hostsFileEntries(HostsFileEntriesProvider.parser().parse());
     }
 
     /**
@@ -111,7 +71,7 @@ public static HostsFileEntries parse() throws IOException {
      * @throws IOException file could not be read
      */
     public static HostsFileEntries parse(File file) throws IOException {
-        return parse(file, Charset.defaultCharset());
+        return hostsFileEntries(HostsFileEntriesProvider.parser().parse(file));
     }
 
     /**
@@ -123,23 +83,7 @@ public static HostsFileEntries parse(File file) throws IOException {
      * @throws IOException file could not be read
      */
     public static HostsFileEntries parse(File file, Charset... charsets) throws IOException {
-        checkNotNull(file, "file");
-        checkNotNull(charsets, "charsets");
-        if (file.exists() && file.isFile()) {
-            for (Charset charset: charsets) {
-                BufferedReader reader = new BufferedReader(
-                        new InputStreamReader(new FileInputStream(file), charset));
-                try {
-                    HostsFileEntries entries = parse(reader);
-                    if (entries != HostsFileEntries.EMPTY) {
-                        return entries;
-                    }
-                } finally {
-                    reader.close();
-                }
-            }
-        }
-        return HostsFileEntries.EMPTY;
+        return hostsFileEntries(HostsFileEntriesProvider.parser().parse(file, charsets));
     }
 
     /**
@@ -150,75 +94,7 @@ public static HostsFileEntries parse(File file, Charset... charsets) throws IOEx
      * @throws IOException file could not be read
      */
     public static HostsFileEntries parse(Reader reader) throws IOException {
-        checkNotNull(reader, "reader");
-        BufferedReader buff = new BufferedReader(reader);
-        try {
-            Map<String, Inet4Address> ipv4Entries = new HashMap<String, Inet4Address>();
-            Map<String, Inet6Address> ipv6Entries = new HashMap<String, Inet6Address>();
-            String line;
-            while ((line = buff.readLine()) != null) {
-                // remove comment
-                int commentPosition = line.indexOf('#');
-                if (commentPosition != -1) {
-                    line = line.substring(0, commentPosition);
-                }
-                // skip empty lines
-                line = line.trim();
-                if (line.isEmpty()) {
-                    continue;
-                }
-
-                // split
-                List<String> lineParts = new ArrayList<String>();
-                for (String s: WHITESPACES.split(line)) {
-                    if (!s.isEmpty()) {
-                        lineParts.add(s);
-                    }
-                }
-
-                // a valid line should be [IP, hostname, alias*]
-                if (lineParts.size() < 2) {
-                    // skip invalid line
-                    continue;
-                }
-
-                byte[] ipBytes = NetUtil.createByteArrayFromIpAddressString(lineParts.get(0));
-
-                if (ipBytes == null) {
-                    // skip invalid IP
-                    continue;
-                }
-
-                // loop over hostname and aliases
-                for (int i = 1; i < lineParts.size(); i ++) {
-                    String hostname = lineParts.get(i);
-                    String hostnameLower = hostname.toLowerCase(Locale.ENGLISH);
-                    InetAddress address = InetAddress.getByAddress(hostname, ipBytes);
-                    if (address instanceof Inet4Address) {
-                        Inet4Address previous = ipv4Entries.put(hostnameLower, (Inet4Address) address);
-                        if (previous != null) {
-                            // restore, we want to keep the first entry
-                            ipv4Entries.put(hostnameLower, previous);
-                        }
-                    } else {
-                        Inet6Address previous = ipv6Entries.put(hostnameLower, (Inet6Address) address);
-                        if (previous != null) {
-                            // restore, we want to keep the first entry
-                            ipv6Entries.put(hostnameLower, previous);
-                        }
-                    }
-                }
-            }
-            return ipv4Entries.isEmpty() && ipv6Entries.isEmpty() ?
-                    HostsFileEntries.EMPTY :
-                    new HostsFileEntries(ipv4Entries, ipv6Entries);
-        } finally {
-            try {
-                buff.close();
-            } catch (IOException e) {
-                logger.warn("Failed to close a reader", e);
-            }
-        }
+        return hostsFileEntries(HostsFileEntriesProvider.parser().parse(reader));
     }
 
     /**
@@ -226,4 +102,22 @@ public static HostsFileEntries parse(Reader reader) throws IOException {
      */
     private HostsFileParser() {
     }
+
+    @SuppressWarnings("unchecked")
+    private static HostsFileEntries hostsFileEntries(HostsFileEntriesProvider provider) {
+        return provider == HostsFileEntriesProvider.EMPTY ? HostsFileEntries.EMPTY :
+                new HostsFileEntries((Map<String, Inet4Address>) toMapWithSingleValue(provider.ipv4Entries()),
+                        (Map<String, Inet6Address>) toMapWithSingleValue(provider.ipv6Entries()));
+    }
+
+    private static Map<String, ?> toMapWithSingleValue(Map<String, List<InetAddress>> fromMapWithListValue) {
+        Map<String, InetAddress> result = new HashMap<String, InetAddress>();
+        for (Map.Entry<String, List<InetAddress>> entry : fromMapWithListValue.entrySet()) {
+            List<InetAddress> value = entry.getValue();
+            if (!value.isEmpty()) {
+                result.put(entry.getKey(), value.get(0));
+            }
+        }
+        return result;
+    }
 }


Test patch:
diff --git a/resolver/src/test/java/io/netty/resolver/DefaultHostsFileEntriesResolverTest.java b/resolver/src/test/java/io/netty/resolver/DefaultHostsFileEntriesResolverTest.java
index b3485f5978d..4a79cd2cdce 100644
--- a/resolver/src/test/java/io/netty/resolver/DefaultHostsFileEntriesResolverTest.java
+++ b/resolver/src/test/java/io/netty/resolver/DefaultHostsFileEntriesResolverTest.java
@@ -22,9 +22,16 @@
 import java.net.Inet4Address;
 import java.net.Inet6Address;
 import java.net.InetAddress;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
 public class DefaultHostsFileEntriesResolverTest {
 
     /**
@@ -32,53 +39,103 @@ public class DefaultHostsFileEntriesResolverTest {
      * HostsFileParser tries to resolve hostnames as case-sensitive
      */
     @Test
-    public void testCaseInsensitivity() throws Exception {
+    public void testCaseInsensitivity() {
         DefaultHostsFileEntriesResolver resolver = new DefaultHostsFileEntriesResolver();
         //normalized somehow
-        Assert.assertEquals(resolver.normalize("localhost"), resolver.normalize("LOCALHOST"));
+        assertEquals(resolver.normalize("localhost"), resolver.normalize("LOCALHOST"));
     }
 
     @Test
     public void shouldntFindWhenAddressTypeDoesntMatch() {
-        Map<String, Inet4Address> inet4Entries = new HashMap<String, Inet4Address>();
-        Map<String, Inet6Address> inet6Entries = new HashMap<String, Inet6Address>();
+        Map<String, List<InetAddress>> inet4Entries = new HashMap<String, List<InetAddress>>();
+        Map<String, List<InetAddress>> inet6Entries = new HashMap<String, List<InetAddress>>();
 
-        inet4Entries.put("localhost", NetUtil.LOCALHOST4);
+        inet4Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST4));
 
         DefaultHostsFileEntriesResolver resolver =
-                new DefaultHostsFileEntriesResolver(new HostsFileEntries(inet4Entries, inet6Entries));
+                new DefaultHostsFileEntriesResolver(new HostsFileEntriesProvider(inet4Entries, inet6Entries));
 
         InetAddress address = resolver.address("localhost", ResolvedAddressTypes.IPV6_ONLY);
-        Assert.assertNull("Should pick an IPv6 address", address);
+        assertNull("Should pick an IPv6 address", address);
     }
 
     @Test
     public void shouldPickIpv4WhenBothAreDefinedButIpv4IsPreferred() {
-        Map<String, Inet4Address> inet4Entries = new HashMap<String, Inet4Address>();
-        Map<String, Inet6Address> inet6Entries = new HashMap<String, Inet6Address>();
+        Map<String, List<InetAddress>> inet4Entries = new HashMap<String, List<InetAddress>>();
+        Map<String, List<InetAddress>> inet6Entries = new HashMap<String, List<InetAddress>>();
 
-        inet4Entries.put("localhost", NetUtil.LOCALHOST4);
-        inet6Entries.put("localhost", NetUtil.LOCALHOST6);
+        inet4Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST4));
+        inet6Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST6));
 
         DefaultHostsFileEntriesResolver resolver =
-                new DefaultHostsFileEntriesResolver(new HostsFileEntries(inet4Entries, inet6Entries));
+                new DefaultHostsFileEntriesResolver(new HostsFileEntriesProvider(inet4Entries, inet6Entries));
 
         InetAddress address = resolver.address("localhost", ResolvedAddressTypes.IPV4_PREFERRED);
-        Assert.assertTrue("Should pick an IPv4 address", address instanceof Inet4Address);
+        assertTrue("Should pick an IPv4 address", address instanceof Inet4Address);
     }
 
     @Test
     public void shouldPickIpv6WhenBothAreDefinedButIpv6IsPreferred() {
-        Map<String, Inet4Address> inet4Entries = new HashMap<String, Inet4Address>();
-        Map<String, Inet6Address> inet6Entries = new HashMap<String, Inet6Address>();
+        Map<String, List<InetAddress>> inet4Entries = new HashMap<String, List<InetAddress>>();
+        Map<String, List<InetAddress>> inet6Entries = new HashMap<String, List<InetAddress>>();
 
-        inet4Entries.put("localhost", NetUtil.LOCALHOST4);
-        inet6Entries.put("localhost", NetUtil.LOCALHOST6);
+        inet4Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST4));
+        inet6Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST6));
 
         DefaultHostsFileEntriesResolver resolver =
-                new DefaultHostsFileEntriesResolver(new HostsFileEntries(inet4Entries, inet6Entries));
+                new DefaultHostsFileEntriesResolver(new HostsFileEntriesProvider(inet4Entries, inet6Entries));
 
         InetAddress address = resolver.address("localhost", ResolvedAddressTypes.IPV6_PREFERRED);
-        Assert.assertTrue("Should pick an IPv6 address", address instanceof Inet6Address);
+        assertTrue("Should pick an IPv6 address", address instanceof Inet6Address);
+    }
+
+    @Test
+    public void shouldntFindWhenAddressesTypeDoesntMatch() {
+        Map<String, List<InetAddress>> inet4Entries = new HashMap<String, List<InetAddress>>();
+        Map<String, List<InetAddress>> inet6Entries = new HashMap<String, List<InetAddress>>();
+
+        inet4Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST4));
+
+        DefaultHostsFileEntriesResolver resolver =
+                new DefaultHostsFileEntriesResolver(new HostsFileEntriesProvider(inet4Entries, inet6Entries));
+
+        List<InetAddress> addresses = resolver.addresses("localhost", ResolvedAddressTypes.IPV6_ONLY);
+        assertNull("Should pick an IPv6 address", addresses);
+    }
+
+    @Test
+    public void shouldPickIpv4FirstWhenBothAreDefinedButIpv4IsPreferred() {
+        Map<String, List<InetAddress>> inet4Entries = new HashMap<String, List<InetAddress>>();
+        Map<String, List<InetAddress>> inet6Entries = new HashMap<String, List<InetAddress>>();
+
+        inet4Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST4));
+        inet6Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST6));
+
+        DefaultHostsFileEntriesResolver resolver =
+                new DefaultHostsFileEntriesResolver(new HostsFileEntriesProvider(inet4Entries, inet6Entries));
+
+        List<InetAddress> addresses = resolver.addresses("localhost", ResolvedAddressTypes.IPV4_PREFERRED);
+        assertNotNull(addresses);
+        assertEquals(2, addresses.size());
+        assertTrue("Should pick an IPv4 address", addresses.get(0) instanceof Inet4Address);
+        assertTrue("Should pick an IPv6 address", addresses.get(1) instanceof Inet6Address);
+    }
+
+    @Test
+    public void shouldPickIpv6FirstWhenBothAreDefinedButIpv6IsPreferred() {
+        Map<String, List<InetAddress>> inet4Entries = new HashMap<String, List<InetAddress>>();
+        Map<String, List<InetAddress>> inet6Entries = new HashMap<String, List<InetAddress>>();
+
+        inet4Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST4));
+        inet6Entries.put("localhost", Collections.<InetAddress>singletonList(NetUtil.LOCALHOST6));
+
+        DefaultHostsFileEntriesResolver resolver =
+                new DefaultHostsFileEntriesResolver(new HostsFileEntriesProvider(inet4Entries, inet6Entries));
+
+        List<InetAddress> addresses = resolver.addresses("localhost", ResolvedAddressTypes.IPV6_PREFERRED);
+        assertNotNull(addresses);
+        assertEquals(2, addresses.size());
+        assertTrue("Should pick an IPv6 address", addresses.get(0) instanceof Inet6Address);
+        assertTrue("Should pick an IPv4 address", addresses.get(1) instanceof Inet4Address);
     }
 }
diff --git a/resolver/src/test/java/io/netty/resolver/HostsFileEntriesProviderTest.java b/resolver/src/test/java/io/netty/resolver/HostsFileEntriesProviderTest.java
new file mode 100644
index 00000000000..2b5c5414f79
--- /dev/null
+++ b/resolver/src/test/java/io/netty/resolver/HostsFileEntriesProviderTest.java
@@ -0,0 +1,146 @@
+/*
+ * Copyright 2021 The Netty Project
+ *
+ * The Netty Project licenses this file to you under the Apache License,
+ * version 2.0 (the "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations
+ * under the License.
+ */
+package io.netty.resolver;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.function.Executable;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.net.InetAddress;
+import java.nio.charset.Charset;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class HostsFileEntriesProviderTest {
+
+    @Test
+    void testParse() throws IOException {
+        String hostsString = new StringBuilder()
+                .append("127.0.0.1 host1").append("\n") // single hostname, separated with blanks
+                .append("::1 host1").append("\n") // same as above, but IPv6
+                .append("\n") // empty line
+                .append("192.168.0.1\thost2").append("\n") // single hostname, separated with tabs
+                .append("#comment").append("\n") // comment at the beginning of the line
+                .append(" #comment  ").append("\n") // comment in the middle of the line
+                .append("192.168.0.2  host3  #comment").append("\n") // comment after hostname
+                .append("192.168.0.3  host4  host5 host6").append("\n") // multiple aliases
+                .append("192.168.0.4  host4").append("\n") // host mapped to a second address, must be considered
+                .append("192.168.0.5  HOST7").append("\n") // uppercase host, should match lowercase host
+                .append("192.168.0.6  host7").append("\n") // must be considered
+                .toString();
+
+        HostsFileEntriesProvider entries = HostsFileEntriesProvider.parser()
+                .parse(new BufferedReader(new StringReader(hostsString)));
+        Map<String, List<InetAddress>> inet4Entries = entries.ipv4Entries();
+        Map<String, List<InetAddress>> inet6Entries = entries.ipv6Entries();
+
+        assertEquals(7, inet4Entries.size(), "Expected 7 IPv4 entries");
+        assertEquals(1, inet6Entries.size(), "Expected 1 IPv6 entries");
+
+        assertEquals(1, inet4Entries.get("host1").size());
+        assertEquals("127.0.0.1", inet4Entries.get("host1").get(0).getHostAddress());
+
+        assertEquals(1, inet4Entries.get("host2").size());
+        assertEquals("192.168.0.1", inet4Entries.get("host2").get(0).getHostAddress());
+
+        assertEquals(1, inet4Entries.get("host3").size());
+        assertEquals("192.168.0.2", inet4Entries.get("host3").get(0).getHostAddress());
+
+        assertEquals(2, inet4Entries.get("host4").size());
+        assertEquals("192.168.0.3", inet4Entries.get("host4").get(0).getHostAddress());
+        assertEquals("192.168.0.4", inet4Entries.get("host4").get(1).getHostAddress());
+
+        assertEquals(1, inet4Entries.get("host5").size());
+        assertEquals("192.168.0.3", inet4Entries.get("host5").get(0).getHostAddress());
+
+        assertEquals(1, inet4Entries.get("host6").size());
+        assertEquals("192.168.0.3", inet4Entries.get("host6").get(0).getHostAddress());
+
+        assertNotNull(inet4Entries.get("host7"), "Uppercase host doesn't resolve");
+        assertEquals(2, inet4Entries.get("host7").size());
+        assertEquals("192.168.0.5", inet4Entries.get("host7").get(0).getHostAddress());
+        assertEquals("192.168.0.6", inet4Entries.get("host7").get(1).getHostAddress());
+
+        assertEquals(1, inet6Entries.get("host1").size());
+        assertEquals("0:0:0:0:0:0:0:1", inet6Entries.get("host1").get(0).getHostAddress());
+    }
+
+    @Test
+    void testCharsetInputValidation() {
+        assertThrows(NullPointerException.class, new Executable() {
+            @Override
+            public void execute() throws IOException {
+                HostsFileEntriesProvider.parser().parse((Charset[]) null);
+            }
+        });
+
+        assertThrows(NullPointerException.class, new Executable() {
+            @Override
+            public void execute() throws IOException {
+                HostsFileEntriesProvider.parser().parse(new File(""), (Charset[]) null);
+            }
+        });
+
+        assertThrows(NullPointerException.class, new Executable() {
+            @Override
+            public void execute() {
+                HostsFileEntriesProvider.parser().parseSilently((Charset[]) null);
+            }
+        });
+
+        assertThrows(NullPointerException.class, new Executable() {
+            @Override
+            public void execute() {
+                HostsFileEntriesProvider.parser().parseSilently(new File(""), (Charset[]) null);
+            }
+        });
+    }
+
+    @Test
+    void testFileInputValidation() {
+        assertThrows(NullPointerException.class, new Executable() {
+            @Override
+            public void execute() throws IOException {
+                HostsFileEntriesProvider.parser().parse((File) null);
+            }
+        });
+
+        assertThrows(NullPointerException.class, new Executable() {
+            @Override
+            public void execute() {
+                HostsFileEntriesProvider.parser().parseSilently((File) null);
+            }
+        });
+    }
+
+    @Test
+    void testReaderInputValidation() {
+        assertThrows(NullPointerException.class, new Executable() {
+            @Override
+            public void execute() throws IOException {
+                HostsFileEntriesProvider.parser().parse((Reader) null);
+            }
+        });
+    }
+}