Repository: moby/moby (language: go)

Issue:
Erroneously filtering out non-local IPv6 DNS servers when using user-defined networks
### Description

I'm having an issue with docker and IPv6 nameservers when using user-defined networks. As is [well-known](https://docs.docker.com/network/#nameservers-with-ipv6-addresses), when using both IPv4 and IPv6 nameservers, musl-based containers can have issues looking up hostnames due to a race looking up hostnames using both nameservers and using the first response. This manifests on my set-up where I'm trying to resolve a local domain name, which fails when done using the default Google DNS servers as set-up by docker.

[The documentation states](https://gdevillele.github.io/engine/userguide/networking/configure-dns/) that Google DNS servers are only used with user-defined networks when the host's `resolve.conf` does not provide any non-local nameservers. My `resolve.conf` does, however, and I think that docker is erroneously filtering out my IPv6 nameserver:

```
❯ resolvectl | cat
Global
       Protocols: +LLMNR +mDNS -DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: stub

Link 2 (enp89s0)
    Current Scopes: DNS LLMNR/IPv4 LLMNR/IPv6
         Protocols: +DefaultRoute +LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 192.168.0.2
       DNS Servers: 192.168.0.2 fe80::ba27:ebff:fe58:5993
```

Note how I'm using systemd-resolved, however, that doesn't seem to be an issue for Docker when using the default network:

```
❯ docker run debian cat /etc/resolv.conf
nameserver 192.168.0.2
nameserver fe80::ba27:ebff:fe58:5993%2
```

When using docker with a user-define network, however, things go wrong and my `fe80` nameserver is replaced by Google DNS servers:

```
❯ docker run --network web debian cat /etc/resolv.conf
nameserver 127.0.0.11
nameserver 2001:4860:4860::8888
nameserver 2001:4860:4860::8844
options edns0 trust-ad ndots:0
```

Because of the Google DNS servers there, resolving `*.localdomain` hostnames often fails on musl.

### Reproduce

Included in description.

### Expected behavior

_No response_

### docker version

```bash
Client: Docker Engine - Community
 Version:           24.0.7
 API version:       1.43
 Go version:        go1.20.10
 Git commit:        afdd53b
 Built:             Thu Oct 26 09:08:02 2023
 OS/Arch:           linux/amd64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          24.0.7
  API version:      1.43 (minimum version 1.12)
  Go version:       go1.20.10
  Git commit:       311b9ff
  Built:            Thu Oct 26 09:08:02 2023
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.26
  GitCommit:        3dd1e886e55dd695541fdcd67420c2888645a495
 runc:
  Version:          1.1.10
  GitCommit:        v1.1.10-0-g18a0cb0
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
```


### docker info

```bash
Client: Docker Engine - Community
 Version:    24.0.7
 Context:    default
 Debug Mode: false

Server:
 Containers: 27
  Running: 27
  Paused: 0
  Stopped: 0
 Images: 109
 Server Version: 24.0.7
 Storage Driver: btrfs
  Btrfs:
 Logging Driver: syslog
 Cgroup Driver: systemd
 Cgroup Version: 2
 Plugins:
  Volume: local
  Network: bridge host ipvlan macvlan null overlay
  Log: awslogs fluentd gcplogs gelf journald json-file local logentries splunk syslog
 Swarm: inactive
 Runtimes: io.containerd.runc.v2 runc
 Default Runtime: runc
 Init Binary: docker-init
 containerd version: 3dd1e886e55dd695541fdcd67420c2888645a495
 runc version: v1.1.10-0-g18a0cb0
 init version: de40ad0
 Security Options:
  apparmor
  seccomp
   Profile: builtin
  cgroupns
 Kernel Version: 6.1.0-16-amd64
 Operating System: Debian GNU/Linux 12 (bookworm)
 OSType: linux
 Architecture: x86_64
 CPUs: 8
 Total Memory: 30.85GiB
 Name: sagittarius
 ID: SYST:ZGOX:264T:44KN:XQGE:BJIQ:3FGI:ZVOF:VRG3:GCYP:QEIY:EP5Q
 Docker Root Dir: /var/lib/docker
 Debug Mode: false
 Experimental: false
 Insecure Registries:
  127.0.0.0/8
 Live Restore Enabled: false
```


### Additional Info

_No response_


Discussion / hints:
Hi @maleadt - thank you for raising this ...

As a (rather surprising) workaround, please could you try adding `--dns-search=.` to your `docker run` command.

That should result in the container's `resolv.conf` only listing Docker's internal nameserver `127.0.0.11`. That internal resolver will use systemd's `127.0.0.53`, which I think is what you need?

Please let me know how you get on ... I'm planning to look at improving the way DNS is configured in the new year.

Fix patch:
diff --git a/daemon/container_operations_unix.go b/daemon/container_operations_unix.go
index 120069e631597..fbc75945854a8 100644
--- a/daemon/container_operations_unix.go
+++ b/daemon/container_operations_unix.go
@@ -419,6 +419,7 @@ func serviceDiscoveryOnDefaultNetwork() bool {
 
 func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Config, sboxOptions *[]libnetwork.SandboxOption) error {
 	var err error
+	var originResolvConfPath string
 
 	// Set the correct paths for /etc/hosts and /etc/resolv.conf, based on the
 	// networking-mode of the container. Note that containers with "container"
@@ -432,8 +433,8 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con
 		*sboxOptions = append(
 			*sboxOptions,
 			libnetwork.OptionOriginHostsPath("/etc/hosts"),
-			libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"),
 		)
+		originResolvConfPath = "/etc/resolv.conf"
 	case container.HostConfig.NetworkMode.IsUserDefined():
 		// The container uses a user-defined network. We use the embedded DNS
 		// server for container name resolution and to act as a DNS forwarder
@@ -446,10 +447,7 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con
 		// If systemd-resolvd is used, the "upstream" DNS servers can be found in
 		// /run/systemd/resolve/resolv.conf. We do not query those DNS servers
 		// directly, as they can be dynamically reconfigured.
-		*sboxOptions = append(
-			*sboxOptions,
-			libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"),
-		)
+		originResolvConfPath = "/etc/resolv.conf"
 	default:
 		// For other situations, such as the default bridge network, container
 		// discovery / name resolution is handled through /etc/hosts, and no
@@ -462,11 +460,15 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con
 		// DNS servers on the host can be dynamically updated.
 		//
 		// Copy the host's resolv.conf for the container (/run/systemd/resolve/resolv.conf or /etc/resolv.conf)
-		*sboxOptions = append(
-			*sboxOptions,
-			libnetwork.OptionOriginResolvConfPath(cfg.GetResolvConf()),
-		)
+		originResolvConfPath = cfg.GetResolvConf()
+	}
+
+	// Allow tests to point at their own resolv.conf file.
+	if envPath := os.Getenv("DOCKER_TEST_RESOLV_CONF_PATH"); envPath != "" {
+		log.G(context.TODO()).Infof("Using OriginResolvConfPath from env: %s", envPath)
+		originResolvConfPath = envPath
 	}
+	*sboxOptions = append(*sboxOptions, libnetwork.OptionOriginResolvConfPath(originResolvConfPath))
 
 	container.HostsPath, err = container.GetRootResourcePath("hosts")
 	if err != nil {
diff --git a/libnetwork/internal/resolvconf/resolvconf.go b/libnetwork/internal/resolvconf/resolvconf.go
new file mode 100644
index 0000000000000..6e49c37927fd0
--- /dev/null
+++ b/libnetwork/internal/resolvconf/resolvconf.go
@@ -0,0 +1,510 @@
+// Package resolvconf is used to generate a container's /etc/resolv.conf file.
+//
+// Constructor Load and Parse read a resolv.conf file from the filesystem or
+// a reader respectively, and return a ResolvConf object.
+//
+// The ResolvConf object can then be updated with overrides for nameserver,
+// search domains, and DNS options.
+//
+// ResolvConf can then be transformed to make it suitable for legacy networking,
+// a network with an internal nameserver, or used as-is for host networking.
+//
+// This package includes methods to write the file for the container, along with
+// a hash that can be used to detect modifications made by the user to avoid
+// overwriting those updates.
+package resolvconf
+
+import (
+	"bufio"
+	"bytes"
+	"context"
+	_ "embed"
+	"fmt"
+	"io"
+	"io/fs"
+	"net/netip"
+	"os"
+	"slices"
+	"strconv"
+	"strings"
+	"text/template"
+
+	"github.com/containerd/log"
+	"github.com/docker/docker/errdefs"
+	"github.com/docker/docker/pkg/ioutils"
+	"github.com/opencontainers/go-digest"
+	"github.com/pkg/errors"
+)
+
+// Fallback nameservers, to use if none can be obtained from the host or command
+// line options.
+var (
+	defaultIPv4NSs = []netip.Addr{
+		netip.MustParseAddr("8.8.8.8"),
+		netip.MustParseAddr("8.8.4.4"),
+	}
+	defaultIPv6NSs = []netip.Addr{
+		netip.MustParseAddr("2001:4860:4860::8888"),
+		netip.MustParseAddr("2001:4860:4860::8844"),
+	}
+)
+
+// ResolvConf represents a resolv.conf file. It can be constructed by
+// reading a resolv.conf file, using method Parse().
+type ResolvConf struct {
+	nameServers []netip.Addr
+	search      []string
+	options     []string
+	other       []string // Unrecognised directives from the host's file, if any.
+
+	md metadata
+}
+
+// ExtDNSEntry represents a nameserver address that was removed from the
+// container's resolv.conf when it was transformed by TransformForIntNS(). These
+// are addresses read from the host's file, or applied via an override ('--dns').
+type ExtDNSEntry struct {
+	Addr         netip.Addr
+	HostLoopback bool // The address is loopback, in the host's namespace.
+}
+
+func (ed ExtDNSEntry) String() string {
+	if ed.HostLoopback {
+		return fmt.Sprintf("host(%s)", ed.Addr)
+	}
+	return ed.Addr.String()
+}
+
+// metadata is used to track where components of the generated file have come
+// from, in order to generate comments in the file for debug/info. Struct members
+// are exported for use by 'text/template'.
+type metadata struct {
+	SourcePath      string
+	Header          string
+	NSOverride      bool
+	SearchOverride  bool
+	OptionsOverride bool
+	NDotsFrom       string
+	UsedDefaultNS   bool
+	Transform       string
+	InvalidNSs      []string
+	ExtNameServers  []ExtDNSEntry
+}
+
+// Load opens a file at path and parses it as a resolv.conf file.
+// On error, the returned ResolvConf will be zero-valued.
+func Load(path string) (ResolvConf, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return ResolvConf{}, err
+	}
+	defer f.Close()
+	return Parse(f, path)
+}
+
+// Parse parses a resolv.conf file from reader.
+// path is optional if reader is an *os.File.
+// On error, the returned ResolvConf will be zero-valued.
+func Parse(reader io.Reader, path string) (ResolvConf, error) {
+	var rc ResolvConf
+	rc.md.SourcePath = path
+	if path == "" {
+		if namer, ok := reader.(interface{ Name() string }); ok {
+			rc.md.SourcePath = namer.Name()
+		}
+	}
+
+	scanner := bufio.NewScanner(reader)
+	for scanner.Scan() {
+		rc.processLine(scanner.Text())
+	}
+	if err := scanner.Err(); err != nil {
+		return ResolvConf{}, errdefs.System(err)
+	}
+	if _, ok := rc.Option("ndots"); ok {
+		rc.md.NDotsFrom = "host"
+	}
+	return rc, nil
+}
+
+// SetHeader sets the content to be included verbatim at the top of the
+// generated resolv.conf file. No formatting or checking is done on the
+// string. It must be valid resolv.conf syntax. (Comments must have '#'
+// or ';' in the first column of each line).
+//
+// For example:
+//
+//	SetHeader("# My resolv.conf\n# This file was generated.")
+func (rc *ResolvConf) SetHeader(c string) {
+	rc.md.Header = c
+}
+
+// NameServers returns addresses used in nameserver directives.
+func (rc *ResolvConf) NameServers() []netip.Addr {
+	return slices.Clone(rc.nameServers)
+}
+
+// OverrideNameServers replaces the current set of nameservers.
+func (rc *ResolvConf) OverrideNameServers(nameServers []netip.Addr) {
+	rc.nameServers = nameServers
+	rc.md.NSOverride = true
+}
+
+// Search returns the current DNS search domains.
+func (rc *ResolvConf) Search() []string {
+	return slices.Clone(rc.search)
+}
+
+// OverrideSearch replaces the current DNS search domains.
+func (rc *ResolvConf) OverrideSearch(search []string) {
+	var filtered []string
+	for _, s := range search {
+		if s != "." {
+			filtered = append(filtered, s)
+		}
+	}
+	rc.search = filtered
+	rc.md.SearchOverride = true
+}
+
+// Options returns the current options.
+func (rc *ResolvConf) Options() []string {
+	return slices.Clone(rc.options)
+}
+
+// Option finds the last option named search, and returns (value, true) if
+// found, else ("", false). Options are treated as "name:value", where the
+// ":value" may be omitted.
+//
+// For example, for "ndots:1 edns0":
+//
+//	Option("ndots") -> ("1", true)
+//	Option("edns0") -> ("", true)
+func (rc *ResolvConf) Option(search string) (string, bool) {
+	for i := len(rc.options) - 1; i >= 0; i -= 1 {
+		k, v, _ := strings.Cut(rc.options[i], ":")
+		if k == search {
+			return v, true
+		}
+	}
+	return "", false
+}
+
+// OverrideOptions replaces the current DNS options.
+func (rc *ResolvConf) OverrideOptions(options []string) {
+	rc.options = slices.Clone(options)
+	rc.md.NDotsFrom = ""
+	if _, exists := rc.Option("ndots"); exists {
+		rc.md.NDotsFrom = "override"
+	}
+	rc.md.OptionsOverride = true
+}
+
+// AddOption adds a single DNS option.
+func (rc *ResolvConf) AddOption(option string) {
+	if len(option) > 6 && option[:6] == "ndots:" {
+		rc.md.NDotsFrom = "internal"
+	}
+	rc.options = append(rc.options, option)
+}
+
+// TransformForLegacyNw makes sure the resolv.conf file will be suitable for
+// use in a legacy network (one that has no internal resolver).
+//   - Remove loopback addresses inherited from the host's resolv.conf, because
+//     they'll only work in the host's namespace.
+//   - Remove IPv6 addresses if !ipv6.
+//   - Add default nameservers if there are no addresses left.
+func (rc *ResolvConf) TransformForLegacyNw(ipv6 bool) {
+	rc.md.Transform = "legacy"
+	if rc.md.NSOverride {
+		return
+	}
+	var filtered []netip.Addr
+	for _, addr := range rc.nameServers {
+		if !addr.IsLoopback() && (!addr.Is6() || ipv6) {
+			filtered = append(filtered, addr)
+		}
+	}
+	rc.nameServers = filtered
+	if len(rc.nameServers) == 0 {
+		log.G(context.TODO()).Info("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers")
+		rc.nameServers = defaultNSAddrs(ipv6)
+		rc.md.UsedDefaultNS = true
+	}
+}
+
+// TransformForIntNS makes sure the resolv.conf file will be suitable for
+// use in a network sandbox that has an internal DNS resolver.
+//   - Add internalNS as a nameserver.
+//   - Remove other nameservers, stashing them as ExtNameServers for the
+//     internal resolver to use. (Apart from IPv6 nameservers, if keepIPv6.)
+//   - Mark ExtNameServers that must be used in the host namespace.
+//   - If no ExtNameServer addresses are found, use the defaults.
+//   - Return an error if an "ndots" option inherited from the host's config, or
+//     supplied in an override is not valid.
+//   - Ensure there's an 'options' value for each entry in reqdOptions. If the
+//     option includes a ':', and an option with a matching prefix exists, it
+//     is not modified.
+func (rc *ResolvConf) TransformForIntNS(
+	keepIPv6 bool,
+	internalNS netip.Addr,
+	reqdOptions []string,
+) ([]ExtDNSEntry, error) {
+	// The transformed config must list the internal nameserver.
+	newNSs := []netip.Addr{internalNS}
+	// Filter out other nameservers, keeping them for use as upstream nameservers by the
+	// internal nameserver.
+	rc.md.ExtNameServers = nil
+	for _, addr := range rc.nameServers {
+		// The internal resolver only uses IPv4 addresses so, keep IPv6 nameservers in
+		// the container's file if keepIPv6, else drop them.
+		if addr.Is6() {
+			if keepIPv6 {
+				newNSs = append(newNSs, addr)
+			}
+		} else {
+			// Extract this NS. Mark loopback addresses that did not come from an override as
+			// 'HostLoopback'. Upstream requests for these servers will be made in the host's
+			// network namespace. (So, '--dns 127.0.0.53' means use a nameserver listening on
+			// the container's loopback interface. But, if the host's resolv.conf contains
+			// 'nameserver 127.0.0.53', the host's resolver will be used.)
+			//
+			//  TODO(robmry) - why only loopback addresses?
+			//   Addresses from the host's resolv.conf must be usable in the host's namespace,
+			//   and a lookup from the container's namespace is more expensive? And, for
+			//   example, if the host has a nameserver with an IPv6 LL address with a zone-id,
+			//   it won't work from the container's namespace (now, while the address is left in
+			//   the container's resolv.conf, or in future for the internal resolver).
+			rc.md.ExtNameServers = append(rc.md.ExtNameServers, ExtDNSEntry{
+				Addr:         addr,
+				HostLoopback: addr.IsLoopback() && !rc.md.NSOverride,
+			})
+		}
+	}
+	rc.nameServers = newNSs
+
+	// If there are no external nameservers, and the only nameserver left is the
+	// internal resolver, use the defaults as ext nameservers.
+	if len(rc.md.ExtNameServers) == 0 && len(rc.nameServers) == 1 {
+		log.G(context.TODO()).Info("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers")
+		for _, addr := range defaultNSAddrs(keepIPv6) {
+			rc.md.ExtNameServers = append(rc.md.ExtNameServers, ExtDNSEntry{Addr: addr})
+		}
+		rc.md.UsedDefaultNS = true
+	}
+
+	// Validate the ndots option from host config or overrides, if present.
+	// TODO(robmry) - pre-existing behaviour, but ...
+	//   Validating ndots from an override is good, but not-liking something in the
+	//   host's resolv.conf isn't a reason to fail - just remove? (And it'll be
+	//   replaced by the value in reqdOptions, if given.)
+	if ndots, exists := rc.Option("ndots"); exists {
+		if n, err := strconv.Atoi(ndots); err != nil || n < 0 {
+			return nil, errdefs.InvalidParameter(
+				fmt.Errorf("invalid number for ndots option: %v", ndots))
+		}
+	}
+	// For each option required by the nameserver, add it if not already
+	// present (if the option already has a value don't change it).
+	for _, opt := range reqdOptions {
+		optName, _, _ := strings.Cut(opt, ":")
+		if _, exists := rc.Option(optName); !exists {
+			rc.AddOption(opt)
+		}
+	}
+
+	rc.md.Transform = "internal resolver"
+	return slices.Clone(rc.md.ExtNameServers), nil
+}
+
+// Generate returns content suitable for writing to a resolv.conf file. If comments
+// is true, the file will include header information if supplied, and a trailing
+// comment that describes how the file was constructed and lists external resolvers.
+func (rc *ResolvConf) Generate(comments bool) ([]byte, error) {
+	s := struct {
+		Md          *metadata
+		NameServers []netip.Addr
+		Search      []string
+		Options     []string
+		Other       []string
+		Overrides   []string
+		Comments    bool
+	}{
+		Md:          &rc.md,
+		NameServers: rc.nameServers,
+		Search:      rc.search,
+		Options:     rc.options,
+		Other:       rc.other,
+		Comments:    comments,
+	}
+	if rc.md.NSOverride {
+		s.Overrides = append(s.Overrides, "nameservers")
+	}
+	if rc.md.SearchOverride {
+		s.Overrides = append(s.Overrides, "search")
+	}
+	if rc.md.OptionsOverride {
+		s.Overrides = append(s.Overrides, "options")
+	}
+
+	const templateText = `{{if .Comments}}{{with .Md.Header}}{{.}}
+
+{{end}}{{end}}{{range .NameServers -}}
+nameserver {{.}}
+{{end}}{{with .Search -}}
+search {{join . " "}}
+{{end}}{{with .Options -}}
+options {{join . " "}}
+{{end}}{{with .Other -}}
+{{join . "\n"}}
+{{end}}{{if .Comments}}
+# Based on host file: '{{.Md.SourcePath}}'{{with .Md.Transform}} ({{.}}){{end}}
+{{if .Md.UsedDefaultNS -}}
+# Used default nameservers.
+{{end -}}
+{{with .Md.ExtNameServers -}}
+# ExtServers: {{.}}
+{{end -}}
+{{with .Md.InvalidNSs -}}
+# Invalid nameservers: {{.}}
+{{end -}}
+# Overrides: {{.Overrides}}
+{{with .Md.NDotsFrom -}}
+# Option ndots from: {{.}}
+{{end -}}
+{{end -}}
+`
+
+	funcs := template.FuncMap{"join": strings.Join}
+	var buf bytes.Buffer
+	templ, err := template.New("summary").Funcs(funcs).Parse(templateText)
+	if err != nil {
+		return nil, errdefs.System(err)
+	}
+	if err := templ.Execute(&buf, s); err != nil {
+		return nil, errdefs.System(err)
+	}
+	return buf.Bytes(), nil
+}
+
+// WriteFile generates content and writes it to path. If hashPath is non-zero, it
+// also writes a file containing a hash of the content, to enable UserModified()
+// to determine whether the file has been modified.
+func (rc *ResolvConf) WriteFile(path, hashPath string, perm os.FileMode) error {
+	content, err := rc.Generate(true)
+	if err != nil {
+		return err
+	}
+
+	// Write the resolv.conf file - it's bind-mounted into the container, so can't
+	// move a temp file into place, just have to truncate and write it.
+	if err := os.WriteFile(path, content, perm); err != nil {
+		return errdefs.System(err)
+	}
+
+	// Write the hash file.
+	if hashPath != "" {
+		hashFile, err := ioutils.NewAtomicFileWriter(hashPath, perm)
+		if err != nil {
+			return errdefs.System(err)
+		}
+		defer hashFile.Close()
+
+		digest := digest.FromBytes(content)
+		if _, err = hashFile.Write([]byte(digest)); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// UserModified can be used to determine whether the resolv.conf file has been
+// modified since it was generated. It returns false with no error if the file
+// matches the hash, true with no error if the file no longer matches the hash,
+// and false with an error if the result cannot be determined.
+func UserModified(rcPath, rcHashPath string) (bool, error) {
+	currRCHash, err := os.ReadFile(rcHashPath)
+	if err != nil {
+		// If the hash file doesn't exist, can only assume it hasn't been written
+		// yet (so, the user hasn't modified the file it hashes).
+		if errors.Is(err, fs.ErrNotExist) {
+			return false, nil
+		}
+		return false, errors.Wrapf(err, "failed to read hash file %s", rcHashPath)
+	}
+	expected, err := digest.Parse(string(currRCHash))
+	if err != nil {
+		return false, errors.Wrapf(err, "failed to parse hash file %s", rcHashPath)
+	}
+	v := expected.Verifier()
+	currRC, err := os.Open(rcPath)
+	if err != nil {
+		return false, errors.Wrapf(err, "failed to open %s to check for modifications", rcPath)
+	}
+	defer currRC.Close()
+	if _, err := io.Copy(v, currRC); err != nil {
+		return false, errors.Wrapf(err, "failed to hash %s to check for modifications", rcPath)
+	}
+	return !v.Verified(), nil
+}
+
+func (rc *ResolvConf) processLine(line string) {
+	fields := strings.Fields(line)
+
+	// Strip comments.
+	// TODO(robmry) - ignore comment chars except in column 0.
+	//   This preserves old behaviour, but it's wrong. For example, resolvers
+	//   will honour the option in line "options # ndots:0" (and ignore the
+	//   "#" as an unknown option).
+	for i, s := range fields {
+		if s[0] == '#' || s[0] == ';' {
+			fields = fields[:i]
+			break
+		}
+	}
+	if len(fields) == 0 {
+		return
+	}
+
+	switch fields[0] {
+	case "nameserver":
+		if len(fields) < 2 {
+			return
+		}
+		if addr, err := netip.ParseAddr(fields[1]); err != nil {
+			rc.md.InvalidNSs = append(rc.md.InvalidNSs, fields[1])
+		} else {
+			rc.nameServers = append(rc.nameServers, addr)
+		}
+	case "domain":
+		// 'domain' is an obsolete name for 'search'.
+		fallthrough
+	case "search":
+		if len(fields) < 2 {
+			return
+		}
+		// Only the last 'search' directive is used.
+		rc.search = fields[1:]
+	case "options":
+		if len(fields) < 2 {
+			return
+		}
+		// Replace options from earlier directives.
+		// TODO(robmry) - preserving incorrect behaviour, options should accumulate.
+		//     rc.options = append(rc.options, fields[1:]...)
+		rc.options = fields[1:]
+	default:
+		// Copy anything that's not a recognised directive.
+		rc.other = append(rc.other, line)
+	}
+}
+
+func defaultNSAddrs(ipv6 bool) []netip.Addr {
+	var addrs []netip.Addr
+	addrs = append(addrs, defaultIPv4NSs...)
+	if ipv6 {
+		addrs = append(addrs, defaultIPv6NSs...)
+	}
+	return addrs
+}
diff --git a/libnetwork/internal/resolvconf/resolvconf_path.go b/libnetwork/internal/resolvconf/resolvconf_path.go
new file mode 100644
index 0000000000000..65d0fe14098db
--- /dev/null
+++ b/libnetwork/internal/resolvconf/resolvconf_path.go
@@ -0,0 +1,56 @@
+package resolvconf
+
+import (
+	"context"
+	"net/netip"
+	"sync"
+
+	"github.com/containerd/log"
+)
+
+const (
+	// defaultPath is the default path to the resolv.conf that contains information to resolve DNS. See Path().
+	defaultPath = "/etc/resolv.conf"
+	// alternatePath is a path different from defaultPath, that may be used to resolve DNS. See Path().
+	alternatePath = "/run/systemd/resolve/resolv.conf"
+)
+
+// For Path to detect systemd (only needed for legacy networking).
+var (
+	detectSystemdResolvConfOnce sync.Once
+	pathAfterSystemdDetection   = defaultPath
+)
+
+// Path returns the path to the resolv.conf file that libnetwork should use.
+//
+// When /etc/resolv.conf contains 127.0.0.53 as the only nameserver, then
+// it is assumed systemd-resolved manages DNS. Because inside the container 127.0.0.53
+// is not a valid DNS server, Path() returns /run/systemd/resolve/resolv.conf
+// which is the resolv.conf that systemd-resolved generates and manages.
+// Otherwise Path() returns /etc/resolv.conf.
+//
+// Errors are silenced as they will inevitably resurface at future open/read calls.
+//
+// More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf
+//
+// TODO(robmry) - alternatePath is only needed for legacy networking ...
+//
+//	Host networking can use the host's resolv.conf as-is, and with an internal
+//	resolver it's also possible to use nameservers on the host's loopback
+//	interface. Once legacy networking is removed, this can always return
+//	defaultPath.
+func Path() string {
+	detectSystemdResolvConfOnce.Do(func() {
+		rc, err := Load(defaultPath)
+		if err != nil {
+			// silencing error as it will resurface at next calls trying to read defaultPath
+			return
+		}
+		ns := rc.nameServers
+		if len(ns) == 1 && ns[0] == netip.MustParseAddr("127.0.0.53") {
+			pathAfterSystemdDetection = alternatePath
+			log.G(context.TODO()).Infof("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath)
+		}
+	})
+	return pathAfterSystemdDetection
+}
diff --git a/libnetwork/resolvconf/resolvconf.go b/libnetwork/resolvconf/resolvconf.go
index da20e1c031827..c3473872b1725 100644
--- a/libnetwork/resolvconf/resolvconf.go
+++ b/libnetwork/resolvconf/resolvconf.go
@@ -3,20 +3,12 @@ package resolvconf
 
 import (
 	"bytes"
-	"context"
+	"fmt"
 	"os"
-	"regexp"
 	"strings"
-	"sync"
 
-	"github.com/containerd/log"
-)
-
-const (
-	// defaultPath is the default path to the resolv.conf that contains information to resolve DNS. See Path().
-	defaultPath = "/etc/resolv.conf"
-	// alternatePath is a path different from defaultPath, that may be used to resolve DNS. See Path().
-	alternatePath = "/run/systemd/resolve/resolv.conf"
+	"github.com/docker/docker/libnetwork/internal/resolvconf"
+	"github.com/opencontainers/go-digest"
 )
 
 // constants for the IP address type
@@ -26,72 +18,16 @@ const (
 	IPv6
 )
 
-var (
-	detectSystemdResolvConfOnce sync.Once
-	pathAfterSystemdDetection   = defaultPath
-)
-
-// Path returns the path to the resolv.conf file that libnetwork should use.
-//
-// When /etc/resolv.conf contains 127.0.0.53 as the only nameserver, then
-// it is assumed systemd-resolved manages DNS. Because inside the container 127.0.0.53
-// is not a valid DNS server, Path() returns /run/systemd/resolve/resolv.conf
-// which is the resolv.conf that systemd-resolved generates and manages.
-// Otherwise Path() returns /etc/resolv.conf.
-//
-// Errors are silenced as they will inevitably resurface at future open/read calls.
-//
-// More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf
-func Path() string {
-	detectSystemdResolvConfOnce.Do(func() {
-		candidateResolvConf, err := os.ReadFile(defaultPath)
-		if err != nil {
-			// silencing error as it will resurface at next calls trying to read defaultPath
-			return
-		}
-		ns := GetNameservers(candidateResolvConf, IP)
-		if len(ns) == 1 && ns[0] == "127.0.0.53" {
-			pathAfterSystemdDetection = alternatePath
-			log.G(context.TODO()).Infof("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath)
-		}
-	})
-	return pathAfterSystemdDetection
-}
-
-const (
-	// ipLocalhost is a regex pattern for IPv4 or IPv6 loopback range.
-	ipLocalhost  = `((127\.([0-9]{1,3}\.){2}[0-9]{1,3})|(::1)$)`
-	ipv4NumBlock = `(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)`
-	ipv4Address  = `(` + ipv4NumBlock + `\.){3}` + ipv4NumBlock
-
-	// This is not an IPv6 address verifier as it will accept a super-set of IPv6, and also
-	// will *not match* IPv4-Embedded IPv6 Addresses (RFC6052), but that and other variants
-	// -- e.g. other link-local types -- either won't work in containers or are unnecessary.
-	// For readability and sufficiency for Docker purposes this seemed more reasonable than a
-	// 1000+ character regexp with exact and complete IPv6 validation
-	ipv6Address = `([0-9A-Fa-f]{0,4}:){2,7}([0-9A-Fa-f]{0,4})(%\w+)?`
-)
-
-var (
-	// Note: the default IPv4 & IPv6 resolvers are set to Google's Public DNS
-	defaultIPv4Dns = []string{"nameserver 8.8.8.8", "nameserver 8.8.4.4"}
-	defaultIPv6Dns = []string{"nameserver 2001:4860:4860::8888", "nameserver 2001:4860:4860::8844"}
-
-	localhostNSRegexp = regexp.MustCompile(`(?m)^nameserver\s+` + ipLocalhost + `\s*\n*`)
-	nsIPv6Regexp      = regexp.MustCompile(`(?m)^nameserver\s+` + ipv6Address + `\s*\n*`)
-	nsRegexp          = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `)|(` + ipv6Address + `))\s*$`)
-	nsIPv6Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv6Address + `))\s*$`)
-	nsIPv4Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `))\s*$`)
-	searchRegexp      = regexp.MustCompile(`^\s*search\s*(([^\s]+\s*)*)$`)
-	optionsRegexp     = regexp.MustCompile(`^\s*options\s*(([^\s]+\s*)*)$`)
-)
-
 // File contains the resolv.conf content and its hash
 type File struct {
 	Content []byte
 	Hash    []byte
 }
 
+func Path() string {
+	return resolvconf.Path()
+}
+
 // Get returns the contents of /etc/resolv.conf and its hash
 func Get() (*File, error) {
 	return GetSpecific(Path())
@@ -103,7 +39,8 @@ func GetSpecific(path string) (*File, error) {
 	if err != nil {
 		return nil, err
 	}
-	return &File{Content: resolv, Hash: hashData(resolv)}, nil
+	hash := digest.FromBytes(resolv)
+	return &File{Content: resolv, Hash: []byte(hash)}, nil
 }
 
 // FilterResolvDNS cleans up the config in resolvConf.  It has two main jobs:
@@ -113,54 +50,34 @@ func GetSpecific(path string) (*File, error) {
 //  2. Given the caller provides the enable/disable state of IPv6, the filter
 //     code will remove all IPv6 nameservers if it is not enabled for containers
 func FilterResolvDNS(resolvConf []byte, ipv6Enabled bool) (*File, error) {
-	cleanedResolvConf := localhostNSRegexp.ReplaceAll(resolvConf, []byte{})
-	// if IPv6 is not enabled, also clean out any IPv6 address nameserver
-	if !ipv6Enabled {
-		cleanedResolvConf = nsIPv6Regexp.ReplaceAll(cleanedResolvConf, []byte{})
-	}
-	// if the resulting resolvConf has no more nameservers defined, add appropriate
-	// default DNS servers for IPv4 and (optionally) IPv6
-	if len(GetNameservers(cleanedResolvConf, IP)) == 0 {
-		log.G(context.TODO()).Infof("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers: %v", defaultIPv4Dns)
-		dns := defaultIPv4Dns
-		if ipv6Enabled {
-			log.G(context.TODO()).Infof("IPv6 enabled; Adding default IPv6 external servers: %v", defaultIPv6Dns)
-			dns = append(dns, defaultIPv6Dns...)
-		}
-		cleanedResolvConf = append(cleanedResolvConf, []byte("\n"+strings.Join(dns, "\n"))...)
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
+	if err != nil {
+		return nil, err
 	}
-	return &File{Content: cleanedResolvConf, Hash: hashData(cleanedResolvConf)}, nil
-}
-
-// getLines parses input into lines and strips away comments.
-func getLines(input []byte, commentMarker []byte) [][]byte {
-	lines := bytes.Split(input, []byte("\n"))
-	var output [][]byte
-	for _, currentLine := range lines {
-		commentIndex := bytes.Index(currentLine, commentMarker)
-		if commentIndex == -1 {
-			output = append(output, currentLine)
-		} else {
-			output = append(output, currentLine[:commentIndex])
-		}
+	rc.TransformForLegacyNw(ipv6Enabled)
+	content, err := rc.Generate(false)
+	if err != nil {
+		return nil, err
 	}
-	return output
+	hash := digest.FromBytes(content)
+	return &File{Content: content, Hash: []byte(hash)}, nil
 }
 
 // GetNameservers returns nameservers (if any) listed in /etc/resolv.conf
 func GetNameservers(resolvConf []byte, kind int) []string {
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
+	if err != nil {
+		return nil
+	}
+	nsAddrs := rc.NameServers()
 	var nameservers []string
-	for _, line := range getLines(resolvConf, []byte("#")) {
-		var ns [][]byte
+	for _, addr := range nsAddrs {
 		if kind == IP {
-			ns = nsRegexp.FindSubmatch(line)
-		} else if kind == IPv4 {
-			ns = nsIPv4Regexpmatch.FindSubmatch(line)
-		} else if kind == IPv6 {
-			ns = nsIPv6Regexpmatch.FindSubmatch(line)
-		}
-		if len(ns) > 0 {
-			nameservers = append(nameservers, string(ns[1]))
+			nameservers = append(nameservers, addr.String())
+		} else if kind == IPv4 && addr.Is4() {
+			nameservers = append(nameservers, addr.String())
+		} else if kind == IPv6 && addr.Is6() {
+			nameservers = append(nameservers, addr.String())
 		}
 	}
 	return nameservers
@@ -170,16 +87,15 @@ func GetNameservers(resolvConf []byte, kind int) []string {
 // /etc/resolv.conf as CIDR blocks (e.g., "1.2.3.4/32")
 // This function's output is intended for net.ParseCIDR
 func GetNameserversAsCIDR(resolvConf []byte) []string {
-	var nameservers []string
-	for _, nameserver := range GetNameservers(resolvConf, IP) {
-		var address string
-		// If IPv6, strip zone if present
-		if strings.Contains(nameserver, ":") {
-			address = strings.Split(nameserver, "%")[0] + "/128"
-		} else {
-			address = nameserver + "/32"
-		}
-		nameservers = append(nameservers, address)
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
+	if err != nil {
+		return nil
+	}
+	nsAddrs := rc.NameServers()
+	nameservers := make([]string, 0, len(nsAddrs))
+	for _, addr := range nsAddrs {
+		str := fmt.Sprintf("%s/%d", addr.WithZone("").String(), addr.BitLen())
+		nameservers = append(nameservers, str)
 	}
 	return nameservers
 }
@@ -188,36 +104,30 @@ func GetNameserversAsCIDR(resolvConf []byte) []string {
 // If more than one search line is encountered, only the contents of the last
 // one is returned.
 func GetSearchDomains(resolvConf []byte) []string {
-	var domains []string
-	for _, line := range getLines(resolvConf, []byte("#")) {
-		match := searchRegexp.FindSubmatch(line)
-		if match == nil {
-			continue
-		}
-		domains = strings.Fields(string(match[1]))
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
+	if err != nil {
+		return nil
 	}
-	return domains
+	return rc.Search()
 }
 
 // GetOptions returns options (if any) listed in /etc/resolv.conf
 // If more than one options line is encountered, only the contents of the last
 // one is returned.
 func GetOptions(resolvConf []byte) []string {
-	var options []string
-	for _, line := range getLines(resolvConf, []byte("#")) {
-		match := optionsRegexp.FindSubmatch(line)
-		if match == nil {
-			continue
-		}
-		options = strings.Fields(string(match[1]))
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
+	if err != nil {
+		return nil
 	}
-	return options
+	return rc.Options()
 }
 
 // Build generates and writes a configuration file to path containing a nameserver
 // entry for every element in nameservers, a "search" entry for every element in
 // dnsSearch, and an "options" entry for every element in dnsOptions. It returns
 // a File containing the generated content and its (sha256) hash.
+//
+// Note that the resolv.conf file is written, but the hash file is not.
 func Build(path string, nameservers, dnsSearch, dnsOptions []string) (*File, error) {
 	content := bytes.NewBuffer(nil)
 	if len(dnsSearch) > 0 {
@@ -244,5 +154,6 @@ func Build(path string, nameservers, dnsSearch, dnsOptions []string) (*File, err
 		return nil, err
 	}
 
-	return &File{Content: content.Bytes(), Hash: hashData(content.Bytes())}, nil
+	hash := digest.FromBytes(content.Bytes())
+	return &File{Content: content.Bytes(), Hash: []byte(hash)}, nil
 }
diff --git a/libnetwork/resolvconf/utils.go b/libnetwork/resolvconf/utils.go
deleted file mode 100644
index 8e005e2a1922c..0000000000000
--- a/libnetwork/resolvconf/utils.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package resolvconf
-
-import (
-	"crypto/sha256"
-	"encoding/hex"
-)
-
-// hashData returns the sha256 sum of data.
-func hashData(data []byte) []byte {
-	f := sha256.Sum256(data)
-	out := make([]byte, 2*sha256.Size)
-	hex.Encode(out, f[:])
-	return append([]byte("sha256:"), out...)
-}
diff --git a/libnetwork/sandbox_dns_unix.go b/libnetwork/sandbox_dns_unix.go
index 40f1a7b46f7e7..a7c857d5820a6 100644
--- a/libnetwork/sandbox_dns_unix.go
+++ b/libnetwork/sandbox_dns_unix.go
@@ -3,22 +3,19 @@
 package libnetwork
 
 import (
-	"bytes"
 	"context"
-	"fmt"
-	"net"
+	"io/fs"
 	"net/netip"
 	"os"
-	"path"
 	"path/filepath"
-	"strconv"
 	"strings"
 
 	"github.com/containerd/log"
 	"github.com/docker/docker/errdefs"
 	"github.com/docker/docker/libnetwork/etchosts"
-	"github.com/docker/docker/libnetwork/resolvconf"
+	"github.com/docker/docker/libnetwork/internal/resolvconf"
 	"github.com/docker/docker/libnetwork/types"
+	"github.com/pkg/errors"
 )
 
 const (
@@ -100,6 +97,13 @@ func (sb *Sandbox) setupResolutionFiles() error {
 }
 
 func (sb *Sandbox) buildHostsFile() error {
+	sb.restoreHostsPath()
+
+	dir, _ := filepath.Split(sb.config.hostsPath)
+	if err := createBasePath(dir); err != nil {
+		return err
+	}
+
 	// This is for the host mode networking
 	if sb.config.useDefaultSandBox && len(sb.config.extraHosts) == 0 {
 		// We are working under the assumption that the origin file option had been properly expressed by the upper layer
@@ -208,276 +212,171 @@ func (sb *Sandbox) updateParentHosts() error {
 	return nil
 }
 
-func (sb *Sandbox) restorePath() {
+func (sb *Sandbox) restoreResolvConfPath() {
 	if sb.config.resolvConfPath == "" {
 		sb.config.resolvConfPath = defaultPrefix + "/" + sb.id + "/resolv.conf"
 	}
 	sb.config.resolvConfHashFile = sb.config.resolvConfPath + ".hash"
+}
+
+func (sb *Sandbox) restoreHostsPath() {
 	if sb.config.hostsPath == "" {
 		sb.config.hostsPath = defaultPrefix + "/" + sb.id + "/hosts"
 	}
 }
 
-func (sb *Sandbox) setExternalResolvers(content []byte, addrType int, checkLoopback bool) {
-	servers := resolvconf.GetNameservers(content, addrType)
-	for _, ip := range servers {
-		hostLoopback := false
-		if checkLoopback && isIPv4Loopback(ip) {
-			hostLoopback = true
-		}
+func (sb *Sandbox) setExternalResolvers(entries []resolvconf.ExtDNSEntry) {
+	sb.extDNS = make([]extDNSEntry, 0, len(entries))
+	for _, entry := range entries {
 		sb.extDNS = append(sb.extDNS, extDNSEntry{
-			IPStr:        ip,
-			HostLoopback: hostLoopback,
+			IPStr:        entry.Addr.String(),
+			HostLoopback: entry.HostLoopback,
 		})
 	}
 }
 
-// isIPv4Loopback checks if the given IP address is an IPv4 loopback address.
-// It's based on the logic in Go's net.IP.IsLoopback(), but only the IPv4 part:
-// https://github.com/golang/go/blob/go1.16.6/src/net/ip.go#L120-L126
-func isIPv4Loopback(ipAddress string) bool {
-	if ip := net.ParseIP(ipAddress); ip != nil {
-		if ip4 := ip.To4(); ip4 != nil {
-			return ip4[0] == 127
-		}
+func (c *containerConfig) getOriginResolvConfPath() string {
+	if c.originResolvConfPath != "" {
+		return c.originResolvConfPath
 	}
-	return false
+	// Fallback if not specified.
+	return resolvconf.Path()
 }
 
-func (sb *Sandbox) setupDNS() error {
-	if sb.config.resolvConfPath == "" {
-		sb.config.resolvConfPath = defaultPrefix + "/" + sb.id + "/resolv.conf"
+// loadResolvConf reads the resolv.conf file at path, and merges in overrides for
+// nameservers, options, and search domains.
+func (sb *Sandbox) loadResolvConf(path string) (*resolvconf.ResolvConf, error) {
+	rc, err := resolvconf.Load(path)
+	if err != nil && !errors.Is(err, fs.ErrNotExist) {
+		return nil, err
+	}
+	// Proceed with rc, which might be zero-valued if path does not exist.
+
+	rc.SetHeader(`# Generated by Docker Engine.
+# This file can be edited; Docker Engine will not make further changes once it
+# has been modified.`)
+	if len(sb.config.dnsList) > 0 {
+		var dnsAddrs []netip.Addr
+		for _, ns := range sb.config.dnsList {
+			addr, err := netip.ParseAddr(ns)
+			if err != nil {
+				return nil, errors.Wrapf(err, "bad nameserver address %s", ns)
+			}
+			dnsAddrs = append(dnsAddrs, addr)
+		}
+		rc.OverrideNameServers(dnsAddrs)
 	}
+	if len(sb.config.dnsSearchList) > 0 {
+		rc.OverrideSearch(sb.config.dnsSearchList)
+	}
+	if len(sb.config.dnsOptionsList) > 0 {
+		rc.OverrideOptions(sb.config.dnsOptionsList)
+	}
+	return &rc, nil
+}
 
-	sb.config.resolvConfHashFile = sb.config.resolvConfPath + ".hash"
-
+// For a new sandbox, write an initial version of the container's resolv.conf. It'll
+// be a copy of the host's file, with overrides for nameservers, options and search
+// domains applied.
+func (sb *Sandbox) setupDNS() error {
+	// Make sure the directory exists.
+	sb.restoreResolvConfPath()
 	dir, _ := filepath.Split(sb.config.resolvConfPath)
 	if err := createBasePath(dir); err != nil {
 		return err
 	}
 
-	// When the user specify a conainter in the host namespace and do no have any dns option specified
-	// we just copy the host resolv.conf from the host itself
-	if sb.config.useDefaultSandBox && len(sb.config.dnsList) == 0 && len(sb.config.dnsSearchList) == 0 && len(sb.config.dnsOptionsList) == 0 {
-		// We are working under the assumption that the origin file option had been properly expressed by the upper layer
-		// if not here we are going to error out
-		if err := copyFile(sb.config.originResolvConfPath, sb.config.resolvConfPath); err != nil {
-			if !os.IsNotExist(err) {
-				return fmt.Errorf("could not copy source resolv.conf file %s to %s: %v", sb.config.originResolvConfPath, sb.config.resolvConfPath, err)
-			}
-			log.G(context.TODO()).Infof("%s does not exist, we create an empty resolv.conf for container", sb.config.originResolvConfPath)
-			if err := createFile(sb.config.resolvConfPath); err != nil {
-				return err
-			}
-		}
-		return nil
-	}
-
-	originResolvConfPath := sb.config.originResolvConfPath
-	if originResolvConfPath == "" {
-		// fallback if not specified
-		originResolvConfPath = resolvconf.Path()
-	}
-	currRC, err := os.ReadFile(originResolvConfPath)
+	rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath())
 	if err != nil {
-		if !os.IsNotExist(err) {
-			return err
-		}
-		// No /etc/resolv.conf found: we'll use the default resolvers (Google's Public DNS).
-		log.G(context.TODO()).WithField("path", originResolvConfPath).Infof("no resolv.conf found, falling back to defaults")
-	}
-
-	var newRC *resolvconf.File
-	if len(sb.config.dnsList) > 0 || len(sb.config.dnsSearchList) > 0 || len(sb.config.dnsOptionsList) > 0 {
-		var (
-			dnsList        = sb.config.dnsList
-			dnsSearchList  = sb.config.dnsSearchList
-			dnsOptionsList = sb.config.dnsOptionsList
-		)
-		if len(sb.config.dnsList) == 0 {
-			dnsList = resolvconf.GetNameservers(currRC, resolvconf.IP)
-		}
-		if len(sb.config.dnsSearchList) == 0 {
-			dnsSearchList = resolvconf.GetSearchDomains(currRC)
-		}
-		if len(sb.config.dnsOptionsList) == 0 {
-			dnsOptionsList = resolvconf.GetOptions(currRC)
-		}
-		newRC, err = resolvconf.Build(sb.config.resolvConfPath, dnsList, dnsSearchList, dnsOptionsList)
-		if err != nil {
-			return err
-		}
-		// After building the resolv.conf from the user config save the
-		// external resolvers in the sandbox. Note that --dns 127.0.0.x
-		// config refers to the loopback in the container namespace
-		sb.setExternalResolvers(newRC.Content, resolvconf.IPv4, len(sb.config.dnsList) == 0)
-	} else {
-		// If the host resolv.conf file has 127.0.0.x container should
-		// use the host resolver for queries. This is supported by the
-		// docker embedded DNS server. Hence save the external resolvers
-		// before filtering it out.
-		sb.setExternalResolvers(currRC, resolvconf.IPv4, true)
-
-		// Replace any localhost/127.* (at this point we have no info about ipv6, pass it as true)
-		newRC, err = resolvconf.FilterResolvDNS(currRC, true)
-		if err != nil {
-			return err
-		}
-		// No contention on container resolv.conf file at sandbox creation
-		err = os.WriteFile(sb.config.resolvConfPath, newRC.Content, filePerm)
-		if err != nil {
-			return types.InternalErrorf("failed to write unhaltered resolv.conf file content when setting up dns for sandbox %s: %v", sb.ID(), err)
-		}
-	}
-
-	// Write hash
-	err = os.WriteFile(sb.config.resolvConfHashFile, newRC.Hash, filePerm)
-	if err != nil {
-		return types.InternalErrorf("failed to write resolv.conf hash file when setting up dns for sandbox %s: %v", sb.ID(), err)
+		return err
 	}
-
-	return nil
+	return rc.WriteFile(sb.config.resolvConfPath, sb.config.resolvConfHashFile, filePerm)
 }
 
+// Called when an endpoint has joined the sandbox.
 func (sb *Sandbox) updateDNS(ipv6Enabled bool) error {
-	// This is for the host mode networking
-	if sb.config.useDefaultSandBox {
-		return nil
-	}
-
-	if len(sb.config.dnsList) > 0 || len(sb.config.dnsSearchList) > 0 || len(sb.config.dnsOptionsList) > 0 {
-		return nil
+	if mod, err := resolvconf.UserModified(sb.config.resolvConfPath, sb.config.resolvConfHashFile); err != nil || mod {
+		return err
 	}
 
-	var currHash []byte
-	currRC, err := resolvconf.GetSpecific(sb.config.resolvConfPath)
+	// Load the host's resolv.conf as a starting point.
+	rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath())
 	if err != nil {
-		if !os.IsNotExist(err) {
-			return err
-		}
-	} else {
-		currHash, err = os.ReadFile(sb.config.resolvConfHashFile)
-		if err != nil && !os.IsNotExist(err) {
-			return err
-		}
+		return err
 	}
-
-	if len(currHash) > 0 && !bytes.Equal(currHash, currRC.Hash) {
-		// Seems the user has changed the container resolv.conf since the last time
-		// we checked so return without doing anything.
-		// log.G(ctx).Infof("Skipping update of resolv.conf file with ipv6Enabled: %t because file was touched by user", ipv6Enabled)
-		return nil
+	// For host-networking, no further change is needed.
+	if !sb.config.useDefaultSandBox {
+		// The legacy bridge network has no internal nameserver. So, strip localhost
+		// nameservers from the host's config, then add default nameservers if there
+		// are none remaining.
+		rc.TransformForLegacyNw(ipv6Enabled)
 	}
+	return rc.WriteFile(sb.config.resolvConfPath, sb.config.resolvConfHashFile, filePerm)
+}
 
-	// replace any localhost/127.* and remove IPv6 nameservers if IPv6 disabled.
-	newRC, err := resolvconf.FilterResolvDNS(currRC.Content, ipv6Enabled)
-	if err != nil {
-		return err
-	}
-	err = os.WriteFile(sb.config.resolvConfPath, newRC.Content, filePerm)
-	if err != nil {
+// Embedded DNS server has to be enabled for this sandbox. Rebuild the container's resolv.conf.
+func (sb *Sandbox) rebuildDNS() error {
+	// Don't touch the file if the user has modified it.
+	if mod, err := resolvconf.UserModified(sb.config.resolvConfPath, sb.config.resolvConfHashFile); err != nil || mod {
 		return err
 	}
 
-	// write the new hash in a temp file and rename it to make the update atomic
-	dir := path.Dir(sb.config.resolvConfPath)
-	tmpHashFile, err := os.CreateTemp(dir, "hash")
+	// Load the host's resolv.conf as a starting point.
+	rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath())
 	if err != nil {
 		return err
 	}
-	if err = tmpHashFile.Chmod(filePerm); err != nil {
-		tmpHashFile.Close()
-		return err
-	}
-	_, err = tmpHashFile.Write(newRC.Hash)
-	if err1 := tmpHashFile.Close(); err == nil {
-		err = err1
+
+	// Check for IPv6 endpoints in this sandbox. If there are any, IPv6 nameservers
+	// will be left in the container's 'resolv.conf'.
+	// TODO(robmry) - preserving old behaviour, but ...
+	//   IPv6 nameservers should be treated like IPv4 ones, and used as upstream
+	//   servers for the internal resolver (if it has IPv6 connectivity). This
+	//   doesn't need to depend on whether there are currently any IPv6 endpoints.
+	//   Removing IPv6 nameservers from the container's resolv.conf will avoid the
+	//   problem that musl-libc's resolver tries all nameservers in parallel, so an
+	//   external IPv6 resolver can return NXDOMAIN before the internal resolver
+	//   returns the address of a container.
+	ipv6 := false
+	for _, ep := range sb.endpoints {
+		if ep.network.enableIPv6 {
+			ipv6 = true
+			break
+		}
 	}
+
+	intNS, err := netip.ParseAddr(sb.resolver.NameServer())
 	if err != nil {
 		return err
 	}
-	return os.Rename(tmpHashFile.Name(), sb.config.resolvConfHashFile)
-}
 
-// Embedded DNS server has to be enabled for this sandbox. Rebuild the container's
-// resolv.conf by doing the following
-// - Add only the embedded server's IP to container's resolv.conf
-// - If the embedded server needs any resolv.conf options add it to the current list
-func (sb *Sandbox) rebuildDNS() error {
-	currRC, err := os.ReadFile(sb.config.resolvConfPath)
+	// Work out whether ndots has been set from host config or overrides.
+	_, sb.ndotsSet = rc.Option("ndots")
+	// Swap nameservers for the internal one, and make sure the required options are set.
+	var extNameServers []resolvconf.ExtDNSEntry
+	extNameServers, err = rc.TransformForIntNS(ipv6, intNS, sb.resolver.ResolverOptions())
 	if err != nil {
 		return err
 	}
-
-	// If the user config and embedded DNS server both have ndots option set,
-	// remember the user's config so that unqualified names not in the docker
-	// domain can be dropped.
-	resOptions := sb.resolver.ResolverOptions()
-	dnsOptionsList := resolvconf.GetOptions(currRC)
-
-dnsOpt:
-	for _, resOpt := range resOptions {
-		if strings.Contains(resOpt, "ndots") {
-			for _, option := range dnsOptionsList {
-				if strings.Contains(option, "ndots") {
-					parts := strings.Split(option, ":")
-					if len(parts) != 2 {
-						return fmt.Errorf("invalid ndots option %v", option)
-					}
-					if num, err := strconv.Atoi(parts[1]); err != nil {
-						return fmt.Errorf("invalid number for ndots option: %v", parts[1])
-					} else if num >= 0 {
-						// if the user sets ndots, use the user setting
-						sb.ndotsSet = true
-						break dnsOpt
-					} else {
-						return fmt.Errorf("invalid number for ndots option: %v", num)
-					}
-				}
-			}
-		}
-	}
-
-	if !sb.ndotsSet {
-		// if the user did not set the ndots, set it to 0 to prioritize the service name resolution
-		// Ref: https://linux.die.net/man/5/resolv.conf
-		dnsOptionsList = append(dnsOptionsList, resOptions...)
-	}
-	if len(sb.extDNS) == 0 {
-		sb.setExternalResolvers(currRC, resolvconf.IPv4, false)
-	}
-
-	var (
-		// external v6 DNS servers have to be listed in resolv.conf
-		dnsList       = append([]string{sb.resolver.NameServer()}, resolvconf.GetNameservers(currRC, resolvconf.IPv6)...)
-		dnsSearchList = resolvconf.GetSearchDomains(currRC)
-	)
-
-	_, err = resolvconf.Build(sb.config.resolvConfPath, dnsList, dnsSearchList, dnsOptionsList)
-	return err
+	// Extract the list of nameservers that just got swapped out, and store them as
+	// upstream nameservers.
+	sb.setExternalResolvers(extNameServers)
+
+	// Write the file for the container - preserving old behaviour, not updating the
+	// hash file (so, no further updates will be made).
+	// TODO(robmry) - I think that's probably accidental, I can't find a reason for it,
+	//  and the old resolvconf.Build() function wrote the file but not the hash, which
+	//  is surprising. But, before fixing it, a guard/flag needs to be added to
+	//  sb.updateDNS() to make sure that when an endpoint joins a sandbox that already
+	//  has an internal resolver, the container's resolv.conf is still (re)configured
+	//  for an internal resolver.
+	return rc.WriteFile(sb.config.resolvConfPath, "", filePerm)
 }
 
 func createBasePath(dir string) error {
 	return os.MkdirAll(dir, dirPerm)
 }
 
-func createFile(path string) error {
-	var f *os.File
-
-	dir, _ := filepath.Split(path)
-	err := createBasePath(dir)
-	if err != nil {
-		return err
-	}
-
-	f, err = os.Create(path)
-	if err == nil {
-		f.Close()
-	}
-
-	return err
-}
-
 func copyFile(src, dst string) error {
 	sBytes, err := os.ReadFile(src)
 	if err != nil {
diff --git a/libnetwork/sandbox_dns_windows.go b/libnetwork/sandbox_dns_windows.go
index 923316ae1dfac..a8fbbca9a1d3d 100644
--- a/libnetwork/sandbox_dns_windows.go
+++ b/libnetwork/sandbox_dns_windows.go
@@ -12,7 +12,9 @@ func (sb *Sandbox) setupResolutionFiles() error {
 	return nil
 }
 
-func (sb *Sandbox) restorePath() {}
+func (sb *Sandbox) restoreHostsPath() {}
+
+func (sb *Sandbox) restoreResolvConfPath() {}
 
 func (sb *Sandbox) updateHostsFile(ifaceIP []string) error {
 	return nil
diff --git a/libnetwork/sandbox_store.go b/libnetwork/sandbox_store.go
index 4993481c3d8bf..4500eb34c351e 100644
--- a/libnetwork/sandbox_store.go
+++ b/libnetwork/sandbox_store.go
@@ -206,7 +206,8 @@ func (c *Controller) sandboxCleanup(activeSandboxes map[string]interface{}) erro
 			isRestore = true
 			opts := val.([]SandboxOption)
 			sb.processOptions(opts...)
-			sb.restorePath()
+			sb.restoreHostsPath()
+			sb.restoreResolvConfPath()
 			create = !sb.config.useDefaultSandBox
 		}
 		sb.osSbox, err = osl.NewSandbox(sb.Key(), create, isRestore)


Test patch:
diff --git a/integration-cli/docker_cli_run_test.go b/integration-cli/docker_cli_run_test.go
index 166d2d4ed78eb..11276ffc1fcd4 100644
--- a/integration-cli/docker_cli_run_test.go
+++ b/integration-cli/docker_cli_run_test.go
@@ -1275,10 +1275,11 @@ func (s *DockerCLIRunSuite) TestRunDNSDefaultOptions(c *testing.T) {
 	}
 
 	actual := cli.DockerCmd(c, "run", "busybox", "cat", "/etc/resolv.conf").Combined()
-	// check that the actual defaults are appended to the commented out
-	// localhost resolver (which should be preserved)
+	actual = regexp.MustCompile("(?m)^#.*$").ReplaceAllString(actual, "")
+	actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ")
 	// NOTE: if we ever change the defaults from google dns, this will break
-	expected := "#nameserver 127.0.2.1\n\nnameserver 8.8.8.8\nnameserver 8.8.4.4\n"
+	expected := "nameserver 8.8.8.8 nameserver 8.8.4.4"
+
 	if actual != expected {
 		c.Fatalf("expected resolv.conf be: %q, but was: %q", expected, actual)
 	}
@@ -1295,14 +1296,16 @@ func (s *DockerCLIRunSuite) TestRunDNSOptions(c *testing.T) {
 		c.Fatalf("Expected warning on stderr about localhost resolver, but got %q", result.Stderr())
 	}
 
-	actual := strings.ReplaceAll(strings.Trim(result.Stdout(), "\r\n"), "\n", " ")
-	if actual != "search mydomain nameserver 127.0.0.1 options ndots:9" {
-		c.Fatalf("expected 'search mydomain nameserver 127.0.0.1 options ndots:9', but says: %q", actual)
+	actual := regexp.MustCompile("(?m)^#.*$").ReplaceAllString(result.Stdout(), "")
+	actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ")
+	if actual != "nameserver 127.0.0.1 search mydomain options ndots:9" {
+		c.Fatalf("nameserver 127.0.0.1 expected 'search mydomain options ndots:9', but says: %q", actual)
 	}
 
 	out := cli.DockerCmd(c, "run", "--dns=1.1.1.1", "--dns-search=.", "--dns-opt=ndots:3", "busybox", "cat", "/etc/resolv.conf").Combined()
 
-	actual = strings.ReplaceAll(strings.Trim(strings.Trim(out, "\r\n"), " "), "\n", " ")
+	actual = regexp.MustCompile("(?m)^#.*$").ReplaceAllString(out, "")
+	actual = strings.ReplaceAll(strings.Trim(strings.Trim(actual, "\r\n"), " "), "\n", " ")
 	if actual != "nameserver 1.1.1.1 options ndots:3" {
 		c.Fatalf("expected 'nameserver 1.1.1.1 options ndots:3', but says: %q", actual)
 	}
@@ -1312,9 +1315,10 @@ func (s *DockerCLIRunSuite) TestRunDNSRepeatOptions(c *testing.T) {
 	testRequires(c, DaemonIsLinux)
 	out := cli.DockerCmd(c, "run", "--dns=1.1.1.1", "--dns=2.2.2.2", "--dns-search=mydomain", "--dns-search=mydomain2", "--dns-opt=ndots:9", "--dns-opt=timeout:3", "busybox", "cat", "/etc/resolv.conf").Stdout()
 
-	actual := strings.ReplaceAll(strings.Trim(out, "\r\n"), "\n", " ")
-	if actual != "search mydomain mydomain2 nameserver 1.1.1.1 nameserver 2.2.2.2 options ndots:9 timeout:3" {
-		c.Fatalf("expected 'search mydomain mydomain2 nameserver 1.1.1.1 nameserver 2.2.2.2 options ndots:9 timeout:3', but says: %q", actual)
+	actual := regexp.MustCompile("(?m)^#.*$").ReplaceAllString(out, "")
+	actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ")
+	if actual != "nameserver 1.1.1.1 nameserver 2.2.2.2 search mydomain mydomain2 options ndots:9 timeout:3" {
+		c.Fatalf("expected 'nameserver 1.1.1.1 nameserver 2.2.2.2 search mydomain mydomain2 options ndots:9 timeout:3', but says: %q", actual)
 	}
 }
 
diff --git a/integration/networking/resolvconf_test.go b/integration/networking/resolvconf_test.go
new file mode 100644
index 0000000000000..a10e7646aa8a8
--- /dev/null
+++ b/integration/networking/resolvconf_test.go
@@ -0,0 +1,72 @@
+package networking
+
+import (
+	"os"
+	"strings"
+	"testing"
+
+	containertypes "github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/integration/internal/container"
+	"github.com/docker/docker/integration/internal/network"
+	"github.com/docker/docker/testutil/daemon"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
+	"gotest.tools/v3/skip"
+)
+
+// Regression test for https://github.com/moby/moby/issues/46968
+func TestResolvConfLocalhostIPv6(t *testing.T) {
+	// No "/etc/resolv.conf" on Windows.
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows")
+
+	ctx := setupTest(t)
+
+	// Write a resolv.conf that only contains a loopback address.
+	// Not using t.TempDir() here because in rootless mode, while the temporary
+	// directory gets mode 0777, it's a subdir of an 0700 directory owned by root.
+	// So, it's not accessible by the daemon.
+	f, err := os.CreateTemp("", "resolv.conf")
+	assert.NilError(t, err)
+	defer os.Remove(f.Name())
+	err = f.Chmod(0644)
+	assert.NilError(t, err)
+	f.Write([]byte("nameserver 127.0.0.53\n"))
+
+	d := daemon.New(t, daemon.WithEnvVars("DOCKER_TEST_RESOLV_CONF_PATH="+f.Name()))
+	d.StartWithBusybox(ctx, t, "--experimental", "--ip6tables")
+	defer d.Stop(t)
+
+	c := d.NewClientT(t)
+	defer c.Close()
+
+	netName := "nnn"
+	network.CreateNoError(ctx, t, c, netName,
+		network.WithDriver("bridge"),
+		network.WithIPv6(),
+		network.WithIPAM("fd49:b5ef:36d9::/64", "fd49:b5ef:36d9::1"),
+	)
+	defer network.RemoveNoError(ctx, t, c, netName)
+
+	result := container.RunAttach(ctx, t, c,
+		container.WithImage("busybox:latest"),
+		container.WithNetworkMode(netName),
+		container.WithCmd("cat", "/etc/resolv.conf"),
+	)
+	defer c.ContainerRemove(ctx, result.ContainerID, containertypes.RemoveOptions{
+		Force: true,
+	})
+
+	output := strings.ReplaceAll(result.Stdout.String(), f.Name(), "RESOLV.CONF")
+	assert.Check(t, is.Equal(output, `# Generated by Docker Engine.
+# This file can be edited; Docker Engine will not make further changes once it
+# has been modified.
+
+nameserver 127.0.0.11
+options ndots:0
+
+# Based on host file: 'RESOLV.CONF' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
+# Option ndots from: internal
+`))
+}
diff --git a/libnetwork/internal/resolvconf/resolvconf_test.go b/libnetwork/internal/resolvconf/resolvconf_test.go
new file mode 100644
index 0000000000000..21605178df8cb
--- /dev/null
+++ b/libnetwork/internal/resolvconf/resolvconf_test.go
@@ -0,0 +1,577 @@
+package resolvconf
+
+import (
+	"bytes"
+	"io/fs"
+	"net/netip"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/docker/docker/internal/sliceutil"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
+	"gotest.tools/v3/golden"
+)
+
+func TestRCOption(t *testing.T) {
+	testcases := []struct {
+		name     string
+		options  string
+		search   string
+		expFound bool
+		expValue string
+	}{
+		{
+			name:    "Empty options",
+			options: "",
+			search:  "ndots",
+		},
+		{
+			name:    "Not found",
+			options: "ndots:0 edns0",
+			search:  "trust-ad",
+		},
+		{
+			name:     "Found with value",
+			options:  "ndots:0 edns0",
+			search:   "ndots",
+			expFound: true,
+			expValue: "0",
+		},
+		{
+			name:     "Found without value",
+			options:  "ndots:0 edns0",
+			search:   "edns0",
+			expFound: true,
+			expValue: "",
+		},
+		{
+			name:     "Found last value",
+			options:  "ndots:0 edns0 ndots:1",
+			search:   "ndots",
+			expFound: true,
+			expValue: "1",
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			rc, err := Parse(bytes.NewBuffer([]byte("options "+tc.options)), "")
+			assert.NilError(t, err)
+			value, found := rc.Option(tc.search)
+			assert.Check(t, is.Equal(found, tc.expFound))
+			assert.Check(t, is.Equal(value, tc.expValue))
+		})
+	}
+}
+
+func TestRCWrite(t *testing.T) {
+	testcases := []struct {
+		name            string
+		fileName        string
+		perm            os.FileMode
+		hashFileName    string
+		modify          bool
+		expUserModified bool
+	}{
+		{
+			name:         "Write with hash",
+			fileName:     "testfile",
+			hashFileName: "testfile.hash",
+		},
+		{
+			name:            "Write with hash and modify",
+			fileName:        "testfile",
+			hashFileName:    "testfile.hash",
+			modify:          true,
+			expUserModified: true,
+		},
+		{
+			name:            "Write without hash and modify",
+			fileName:        "testfile",
+			modify:          true,
+			expUserModified: false,
+		},
+		{
+			name:     "Write perm",
+			fileName: "testfile",
+			perm:     0640,
+		},
+	}
+
+	rc, err := Parse(bytes.NewBuffer([]byte("nameserver 1.2.3.4")), "")
+	assert.NilError(t, err)
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc := tc
+			d := t.TempDir()
+			path := filepath.Join(d, tc.fileName)
+			var hashPath string
+			if tc.hashFileName != "" {
+				hashPath = filepath.Join(d, tc.hashFileName)
+			}
+			if tc.perm == 0 {
+				tc.perm = 0644
+			}
+			err := rc.WriteFile(path, hashPath, tc.perm)
+			assert.NilError(t, err)
+
+			fi, err := os.Stat(path)
+			assert.NilError(t, err)
+			// Windows files won't have the expected perms.
+			if runtime.GOOS != "windows" {
+				assert.Check(t, is.Equal(fi.Mode(), tc.perm))
+			}
+
+			if tc.modify {
+				err := os.WriteFile(path, []byte("modified"), 0644)
+				assert.NilError(t, err)
+			}
+
+			um, err := UserModified(path, hashPath)
+			assert.NilError(t, err)
+			assert.Check(t, is.Equal(um, tc.expUserModified))
+		})
+	}
+}
+
+var a2s = sliceutil.Mapper(netip.Addr.String)
+var s2a = sliceutil.Mapper(netip.MustParseAddr)
+
+// Test that a resolv.conf file can be modified using OverrideXXX() methods
+// to modify nameservers/search/options directives, and tha options can be
+// added via AddOption().
+func TestRCModify(t *testing.T) {
+	testcases := []struct {
+		name            string
+		inputNS         []string
+		inputSearch     []string
+		inputOptions    []string
+		noOverrides     bool // Whether to apply overrides (empty lists are valid overrides).
+		overrideNS      []string
+		overrideSearch  []string
+		overrideOptions []string
+		addOption       string
+	}{
+		{
+			name:    "No content no overrides",
+			inputNS: []string{},
+		},
+		{
+			name:         "No overrides",
+			noOverrides:  true,
+			inputNS:      []string{"1.2.3.4"},
+			inputSearch:  []string{"invalid"},
+			inputOptions: []string{"ndots:0"},
+		},
+		{
+			name:         "Empty overrides",
+			inputNS:      []string{"1.2.3.4"},
+			inputSearch:  []string{"invalid"},
+			inputOptions: []string{"ndots:0"},
+		},
+		{
+			name:            "Overrides",
+			inputNS:         []string{"1.2.3.4"},
+			inputSearch:     []string{"invalid"},
+			inputOptions:    []string{"ndots:0"},
+			overrideNS:      []string{"2.3.4.5", "fdba:acdd:587c::53"},
+			overrideSearch:  []string{"com", "invalid", "example"},
+			overrideOptions: []string{"ndots:1", "edns0", "trust-ad"},
+		},
+		{
+			name:         "Add option no overrides",
+			noOverrides:  true,
+			inputNS:      []string{"1.2.3.4"},
+			inputSearch:  []string{"invalid"},
+			inputOptions: []string{"ndots:0"},
+			addOption:    "attempts:3",
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc := tc
+			var input string
+			if len(tc.inputNS) != 0 {
+				for _, ns := range tc.inputNS {
+					input += "nameserver " + ns + "\n"
+				}
+			}
+			if len(tc.inputSearch) != 0 {
+				input += "search " + strings.Join(tc.inputSearch, " ") + "\n"
+			}
+			if len(tc.inputOptions) != 0 {
+				input += "options " + strings.Join(tc.inputOptions, " ") + "\n"
+			}
+			rc, err := Parse(bytes.NewBuffer([]byte(input)), "")
+			assert.NilError(t, err)
+			assert.Check(t, is.DeepEqual(a2s(rc.NameServers()), tc.inputNS))
+			assert.Check(t, is.DeepEqual(rc.Search(), tc.inputSearch))
+			assert.Check(t, is.DeepEqual(rc.Options(), tc.inputOptions))
+
+			if !tc.noOverrides {
+				overrideNS := s2a(tc.overrideNS)
+				rc.OverrideNameServers(overrideNS)
+				rc.OverrideSearch(tc.overrideSearch)
+				rc.OverrideOptions(tc.overrideOptions)
+
+				assert.Check(t, is.DeepEqual(rc.NameServers(), overrideNS, cmpopts.EquateComparable(netip.Addr{})))
+				assert.Check(t, is.DeepEqual(rc.Search(), tc.overrideSearch))
+				assert.Check(t, is.DeepEqual(rc.Options(), tc.overrideOptions))
+			}
+
+			if tc.addOption != "" {
+				options := rc.Options()
+				rc.AddOption(tc.addOption)
+				assert.Check(t, is.DeepEqual(rc.Options(), append(options, tc.addOption)))
+			}
+
+			d := t.TempDir()
+			path := filepath.Join(d, "resolv.conf")
+			err = rc.WriteFile(path, "", 0644)
+			assert.NilError(t, err)
+
+			content, err := os.ReadFile(path)
+			assert.NilError(t, err)
+			assert.Check(t, golden.String(string(content), t.Name()+".golden"))
+		})
+	}
+}
+
+func TestRCTransformForLegacyNw(t *testing.T) {
+	testcases := []struct {
+		name       string
+		input      string
+		ipv6       bool
+		overrideNS []string
+	}{
+		{
+			name:  "Routable IPv4 only",
+			input: "nameserver 10.0.0.1",
+		},
+		{
+			name:  "Routable IPv4 and IPv6, ipv6 enabled",
+			input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
+			ipv6:  true,
+		},
+		{
+			name:  "Routable IPv4 and IPv6, ipv6 disabled",
+			input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
+			ipv6:  false,
+		},
+		{
+			name:  "IPv4 localhost, ipv6 disabled",
+			input: "nameserver 127.0.0.53",
+			ipv6:  false,
+		},
+		{
+			name:  "IPv4 localhost, ipv6 enabled",
+			input: "nameserver 127.0.0.53",
+			ipv6:  true,
+		},
+		{
+			name:  "IPv4 and IPv6 localhost, ipv6 disabled",
+			input: "nameserver 127.0.0.53\nnameserver ::1",
+			ipv6:  false,
+		},
+		{
+			name:  "IPv4 and IPv6 localhost, ipv6 enabled",
+			input: "nameserver 127.0.0.53\nnameserver ::1",
+			ipv6:  true,
+		},
+		{
+			name:  "IPv4 localhost, IPv6 routeable, ipv6 enabled",
+			input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
+			ipv6:  true,
+		},
+		{
+			name:  "IPv4 localhost, IPv6 routeable, ipv6 disabled",
+			input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
+			ipv6:  false,
+		},
+		{
+			name:       "Override nameservers",
+			input:      "nameserver 127.0.0.53",
+			overrideNS: []string{"127.0.0.1", "::1"},
+			ipv6:       false,
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc := tc
+			rc, err := Parse(bytes.NewBuffer([]byte(tc.input)), "/etc/resolv.conf")
+			assert.NilError(t, err)
+			if tc.overrideNS != nil {
+				rc.OverrideNameServers(s2a(tc.overrideNS))
+			}
+
+			rc.TransformForLegacyNw(tc.ipv6)
+
+			d := t.TempDir()
+			path := filepath.Join(d, "resolv.conf")
+			err = rc.WriteFile(path, "", 0644)
+			assert.NilError(t, err)
+
+			content, err := os.ReadFile(path)
+			assert.NilError(t, err)
+			assert.Check(t, golden.String(string(content), t.Name()+".golden"))
+		})
+	}
+}
+
+func TestRCTransformForIntNS(t *testing.T) {
+	mke := func(addr string, hostLoopback bool) ExtDNSEntry {
+		return ExtDNSEntry{
+			Addr:         netip.MustParseAddr(addr),
+			HostLoopback: hostLoopback,
+		}
+	}
+
+	testcases := []struct {
+		name            string
+		input           string
+		intNameServer   string
+		ipv6            bool
+		overrideNS      []string
+		overrideOptions []string
+		reqdOptions     []string
+		expExtServers   []ExtDNSEntry
+		expErr          string
+	}{
+		{
+			name:          "IPv4 only",
+			input:         "nameserver 10.0.0.1",
+			expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)},
+		},
+		{
+			name:          "IPv4 and IPv6, ipv6 enabled",
+			input:         "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
+			ipv6:          true,
+			expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)},
+		},
+		{
+			name:          "IPv4 and IPv6, ipv6 disabled",
+			input:         "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
+			ipv6:          false,
+			expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)},
+		},
+		{
+			name:          "IPv4 localhost",
+			input:         "nameserver 127.0.0.53",
+			ipv6:          false,
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			// Overriding the nameserver with a localhost address means use the container's
+			// loopback interface, not the host's.
+			name:          "IPv4 localhost override",
+			input:         "nameserver 10.0.0.1",
+			ipv6:          false,
+			overrideNS:    []string{"127.0.0.53"},
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", false)},
+		},
+		{
+			name:          "IPv4 localhost, ipv6 enabled",
+			input:         "nameserver 127.0.0.53",
+			ipv6:          true,
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:  "IPv6 addr, IPv6 enabled",
+			input: "nameserver fd14:6e0e:f855::1",
+			ipv6:  true,
+			// Note that there are no ext servers in this case, the internal resolver
+			// will only look up container names. The default nameservers aren't added
+			// because the host's IPv6 nameserver remains in the container's resolv.conf,
+			// (because only IPv4 ext servers are currently allowed).
+		},
+		{
+			name:          "IPv4 and IPv6 localhost, IPv6 disabled",
+			input:         "nameserver 127.0.0.53\nnameserver ::1",
+			ipv6:          false,
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:          "IPv4 and IPv6 localhost, ipv6 enabled",
+			input:         "nameserver 127.0.0.53\nnameserver ::1",
+			ipv6:          true,
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:          "IPv4 localhost, IPv6 private, IPv6 enabled",
+			input:         "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
+			ipv6:          true,
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:          "IPv4 localhost, IPv6 private, IPv6 disabled",
+			input:         "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
+			ipv6:          false,
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:  "No host nameserver, no iv6",
+			input: "",
+			ipv6:  false,
+			expExtServers: []ExtDNSEntry{
+				mke("8.8.8.8", false),
+				mke("8.8.4.4", false),
+			},
+		},
+		{
+			name:  "No host nameserver, iv6",
+			input: "",
+			ipv6:  true,
+			expExtServers: []ExtDNSEntry{
+				mke("8.8.8.8", false),
+				mke("8.8.4.4", false),
+				mke("2001:4860:4860::8888", false),
+				mke("2001:4860:4860::8844", false),
+			},
+		},
+		{
+			name:          "ndots present and required",
+			input:         "nameserver 127.0.0.53\noptions ndots:1",
+			reqdOptions:   []string{"ndots:0"},
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:          "ndots missing but required",
+			input:         "nameserver 127.0.0.53",
+			reqdOptions:   []string{"ndots:0"},
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:            "ndots host, override and required",
+			input:           "nameserver 127.0.0.53",
+			reqdOptions:     []string{"ndots:0"},
+			overrideOptions: []string{"ndots:2"},
+			expExtServers:   []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+		{
+			name:          "Extra required options",
+			input:         "nameserver 127.0.0.53\noptions trust-ad",
+			reqdOptions:   []string{"ndots:0", "attempts:3", "edns0", "trust-ad"},
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			tc := tc
+			rc, err := Parse(bytes.NewBuffer([]byte(tc.input)), "/etc/resolv.conf")
+			assert.NilError(t, err)
+
+			if tc.intNameServer == "" {
+				tc.intNameServer = "127.0.0.11"
+			}
+			if len(tc.overrideNS) > 0 {
+				rc.OverrideNameServers(s2a(tc.overrideNS))
+			}
+			if len(tc.overrideOptions) > 0 {
+				rc.OverrideOptions(tc.overrideOptions)
+			}
+			intNS := netip.MustParseAddr(tc.intNameServer)
+			extNameServers, err := rc.TransformForIntNS(tc.ipv6, intNS, tc.reqdOptions)
+			if tc.expErr != "" {
+				assert.Check(t, is.ErrorContains(err, tc.expErr))
+				return
+			}
+			assert.NilError(t, err)
+
+			d := t.TempDir()
+			path := filepath.Join(d, "resolv.conf")
+			err = rc.WriteFile(path, "", 0644)
+			assert.NilError(t, err)
+
+			content, err := os.ReadFile(path)
+			assert.NilError(t, err)
+			assert.Check(t, golden.String(string(content), t.Name()+".golden"))
+			assert.Check(t, is.DeepEqual(extNameServers, tc.expExtServers,
+				cmpopts.EquateComparable(netip.Addr{})))
+		})
+	}
+}
+
+func TestRCRead(t *testing.T) {
+	d := t.TempDir()
+	path := filepath.Join(d, "resolv.conf")
+
+	// Try to read a nonexistent file, equivalent to an empty file.
+	_, err := Load(path)
+	assert.Check(t, is.ErrorIs(err, fs.ErrNotExist))
+
+	err = os.WriteFile(path, []byte("options edns0"), 0644)
+	assert.NilError(t, err)
+
+	// Read that file in the constructor.
+	rc, err := Load(path)
+	assert.NilError(t, err)
+	assert.Check(t, is.DeepEqual(rc.Options(), []string{"edns0"}))
+
+	// Pass in an os.File, check the path is extracted.
+	file, err := os.Open(path)
+	assert.NilError(t, err)
+	defer file.Close()
+	rc, err = Parse(file, "")
+	assert.NilError(t, err)
+	assert.Check(t, is.Equal(rc.md.SourcePath, path))
+}
+
+func TestRCInvalidNS(t *testing.T) {
+	d := t.TempDir()
+
+	// A resolv.conf with an invalid nameserver address.
+	rc, err := Parse(bytes.NewBuffer([]byte("nameserver 1.2.3.4.5")), "")
+	assert.NilError(t, err)
+
+	path := filepath.Join(d, "resolv.conf")
+	err = rc.WriteFile(path, "", 0644)
+	assert.NilError(t, err)
+
+	content, err := os.ReadFile(path)
+	assert.NilError(t, err)
+	assert.Check(t, golden.String(string(content), t.Name()+".golden"))
+}
+
+func TestRCSetHeader(t *testing.T) {
+	rc, err := Parse(bytes.NewBuffer([]byte("nameserver 127.0.0.53")), "/etc/resolv.conf")
+	assert.NilError(t, err)
+
+	rc.SetHeader("# This is a comment.")
+	d := t.TempDir()
+	path := filepath.Join(d, "resolv.conf")
+	err = rc.WriteFile(path, "", 0644)
+	assert.NilError(t, err)
+
+	content, err := os.ReadFile(path)
+	assert.NilError(t, err)
+	assert.Check(t, golden.String(string(content), t.Name()+".golden"))
+}
+
+func TestRCUnknownDirectives(t *testing.T) {
+	const input = `
+something unexpected
+nameserver 127.0.0.53
+options ndots:1
+unrecognised thing
+`
+	rc, err := Parse(bytes.NewBuffer([]byte(input)), "/etc/resolv.conf")
+	assert.NilError(t, err)
+
+	d := t.TempDir()
+	path := filepath.Join(d, "resolv.conf")
+	err = rc.WriteFile(path, "", 0644)
+	assert.NilError(t, err)
+
+	content, err := os.ReadFile(path)
+	assert.NilError(t, err)
+	assert.Check(t, golden.String(string(content), t.Name()+".golden"))
+}
diff --git a/libnetwork/internal/resolvconf/testdata/.gitattributes b/libnetwork/internal/resolvconf/testdata/.gitattributes
new file mode 100644
index 0000000000000..6313b56c57848
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/.gitattributes
@@ -0,0 +1,1 @@
+* text=auto eol=lf
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden b/libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden
new file mode 100644
index 0000000000000..34c9172b32eee
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden
@@ -0,0 +1,4 @@
+
+# Based on host file: ''
+# Invalid nameservers: [1.2.3.4.5]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/Add_option_no_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/Add_option_no_overrides.golden
new file mode 100644
index 0000000000000..e4e5b287d841e
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/Add_option_no_overrides.golden
@@ -0,0 +1,7 @@
+nameserver 1.2.3.4
+search invalid
+options ndots:0 attempts:3
+
+# Based on host file: ''
+# Overrides: []
+# Option ndots from: host
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden
new file mode 100644
index 0000000000000..75a0159f2c660
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden
@@ -0,0 +1,3 @@
+
+# Based on host file: ''
+# Overrides: [nameservers search options]
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden
new file mode 100644
index 0000000000000..75a0159f2c660
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden
@@ -0,0 +1,3 @@
+
+# Based on host file: ''
+# Overrides: [nameservers search options]
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/No_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_overrides.golden
new file mode 100644
index 0000000000000..6ce29386fcc0e
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_overrides.golden
@@ -0,0 +1,7 @@
+nameserver 1.2.3.4
+search invalid
+options ndots:0
+
+# Based on host file: ''
+# Overrides: []
+# Option ndots from: host
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/Overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/Overrides.golden
new file mode 100644
index 0000000000000..62d6ef61dbd63
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/Overrides.golden
@@ -0,0 +1,8 @@
+nameserver 2.3.4.5
+nameserver fdba:acdd:587c::53
+search com invalid example
+options ndots:1 edns0 trust-ad
+
+# Based on host file: ''
+# Overrides: [nameservers search options]
+# Option ndots from: override
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCSetHeader.golden b/libnetwork/internal/resolvconf/testdata/TestRCSetHeader.golden
new file mode 100644
index 0000000000000..beed1a765806a
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCSetHeader.golden
@@ -0,0 +1,6 @@
+# This is a comment.
+
+nameserver 127.0.0.53
+
+# Based on host file: '/etc/resolv.conf'
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/Extra_required_options.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/Extra_required_options.golden
new file mode 100644
index 0000000000000..68fd467207479
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/Extra_required_options.golden
@@ -0,0 +1,7 @@
+nameserver 127.0.0.11
+options trust-ad ndots:0 attempts:3 edns0
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
+# Option ndots from: internal
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_disabled.golden
new file mode 100644
index 0000000000000..4479c5936980c
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_disabled.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [10.0.0.1]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_enabled.golden
new file mode 100644
index 0000000000000..578b5babbe113
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_enabled.golden
@@ -0,0 +1,6 @@
+nameserver 127.0.0.11
+nameserver fdb6:b8fe:b528::1
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [10.0.0.1]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_IPv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_IPv6_disabled.golden
new file mode 100644
index 0000000000000..926d44d49a12d
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_IPv6_disabled.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_ipv6_enabled.golden
new file mode 100644
index 0000000000000..b64148d8450d6
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_ipv6_enabled.golden
@@ -0,0 +1,6 @@
+nameserver 127.0.0.11
+nameserver ::1
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_disabled.golden
new file mode 100644
index 0000000000000..926d44d49a12d
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_disabled.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_enabled.golden
new file mode 100644
index 0000000000000..6c1c311c1b4c9
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_enabled.golden
@@ -0,0 +1,6 @@
+nameserver 127.0.0.11
+nameserver fd3e:2d1a:1f5a::1
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_ipv6_enabled.golden
new file mode 100644
index 0000000000000..926d44d49a12d
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_ipv6_enabled.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost.golden
new file mode 100644
index 0000000000000..926d44d49a12d
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost_override.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost_override.golden
new file mode 100644
index 0000000000000..da55aad0caf37
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost_override.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [127.0.0.53]
+# Overrides: [nameservers]
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_only.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_only.golden
new file mode 100644
index 0000000000000..4479c5936980c
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_only.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [10.0.0.1]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv6_addr,_IPv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv6_addr,_IPv6_enabled.golden
new file mode 100644
index 0000000000000..0a412defbf6fc
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv6_addr,_IPv6_enabled.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.11
+nameserver fd14:6e0e:f855::1
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_iv6.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_iv6.golden
new file mode 100644
index 0000000000000..cde7c90bd9852
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_iv6.golden
@@ -0,0 +1,6 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# Used default nameservers.
+# ExtServers: [8.8.8.8 8.8.4.4 2001:4860:4860::8888 2001:4860:4860::8844]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_no_iv6.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_no_iv6.golden
new file mode 100644
index 0000000000000..c620d3442d1e2
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_no_iv6.golden
@@ -0,0 +1,6 @@
+nameserver 127.0.0.11
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# Used default nameservers.
+# ExtServers: [8.8.8.8 8.8.4.4]
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_host,_override_and_required.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_host,_override_and_required.golden
new file mode 100644
index 0000000000000..dc469d58cda0b
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_host,_override_and_required.golden
@@ -0,0 +1,7 @@
+nameserver 127.0.0.11
+options ndots:2
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: [options]
+# Option ndots from: override
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_missing_but_required.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_missing_but_required.golden
new file mode 100644
index 0000000000000..827ecd295cb20
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_missing_but_required.golden
@@ -0,0 +1,7 @@
+nameserver 127.0.0.11
+options ndots:0
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
+# Option ndots from: internal
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_present_and_required.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_present_and_required.golden
new file mode 100644
index 0000000000000..d005fa38b6b3f
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_present_and_required.golden
@@ -0,0 +1,7 @@
+nameserver 127.0.0.11
+options ndots:1
+
+# Based on host file: '/etc/resolv.conf' (internal resolver)
+# ExtServers: [host(127.0.0.53)]
+# Overrides: []
+# Option ndots from: host
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_disabled.golden
new file mode 100644
index 0000000000000..165eafe282b9f
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_disabled.golden
@@ -0,0 +1,6 @@
+nameserver 8.8.8.8
+nameserver 8.8.4.4
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Used default nameservers.
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_enabled.golden
new file mode 100644
index 0000000000000..fd66dad64e482
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_enabled.golden
@@ -0,0 +1,8 @@
+nameserver 8.8.8.8
+nameserver 8.8.4.4
+nameserver 2001:4860:4860::8888
+nameserver 2001:4860:4860::8844
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Used default nameservers.
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_disabled.golden
new file mode 100644
index 0000000000000..165eafe282b9f
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_disabled.golden
@@ -0,0 +1,6 @@
+nameserver 8.8.8.8
+nameserver 8.8.4.4
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Used default nameservers.
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_enabled.golden
new file mode 100644
index 0000000000000..c188830fcd881
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_enabled.golden
@@ -0,0 +1,4 @@
+nameserver fd3e:2d1a:1f5a::1
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_disabled.golden
new file mode 100644
index 0000000000000..165eafe282b9f
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_disabled.golden
@@ -0,0 +1,6 @@
+nameserver 8.8.8.8
+nameserver 8.8.4.4
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Used default nameservers.
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_enabled.golden
new file mode 100644
index 0000000000000..fd66dad64e482
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_enabled.golden
@@ -0,0 +1,8 @@
+nameserver 8.8.8.8
+nameserver 8.8.4.4
+nameserver 2001:4860:4860::8888
+nameserver 2001:4860:4860::8844
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Used default nameservers.
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Override_nameservers.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Override_nameservers.golden
new file mode 100644
index 0000000000000..74f7abf968731
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Override_nameservers.golden
@@ -0,0 +1,5 @@
+nameserver 127.0.0.1
+nameserver ::1
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Overrides: [nameservers]
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_disabled.golden
new file mode 100644
index 0000000000000..cdeaadf88cd11
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_disabled.golden
@@ -0,0 +1,4 @@
+nameserver 10.0.0.1
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_enabled.golden
new file mode 100644
index 0000000000000..e00cad9333555
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_enabled.golden
@@ -0,0 +1,5 @@
+nameserver 10.0.0.1
+nameserver fdb6:b8fe:b528::1
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_only.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_only.golden
new file mode 100644
index 0000000000000..cdeaadf88cd11
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_only.golden
@@ -0,0 +1,4 @@
+nameserver 10.0.0.1
+
+# Based on host file: '/etc/resolv.conf' (legacy)
+# Overrides: []
diff --git a/libnetwork/internal/resolvconf/testdata/TestRCUnknownDirectives.golden b/libnetwork/internal/resolvconf/testdata/TestRCUnknownDirectives.golden
new file mode 100644
index 0000000000000..4fc961c558b44
--- /dev/null
+++ b/libnetwork/internal/resolvconf/testdata/TestRCUnknownDirectives.golden
@@ -0,0 +1,8 @@
+nameserver 127.0.0.53
+options ndots:1
+something unexpected
+unrecognised thing
+
+# Based on host file: '/etc/resolv.conf'
+# Overrides: []
+# Option ndots from: host
diff --git a/libnetwork/libnetwork_linux_test.go b/libnetwork/libnetwork_linux_test.go
index a228a657c7713..5b96962e4edd0 100644
--- a/libnetwork/libnetwork_linux_test.go
+++ b/libnetwork/libnetwork_linux_test.go
@@ -11,6 +11,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"regexp"
 	"strings"
 	"sync"
 	"testing"
@@ -32,6 +33,8 @@ import (
 	"github.com/vishvananda/netlink"
 	"github.com/vishvananda/netns"
 	"golang.org/x/sync/errgroup"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
 )
 
 const (
@@ -1278,6 +1281,28 @@ func makeTesthostNetwork(t *testing.T, c *libnetwork.Controller) *libnetwork.Net
 	return n
 }
 
+func makeTestIPv6Network(t *testing.T, c *libnetwork.Controller) *libnetwork.Network {
+	t.Helper()
+	netOptions := options.Generic{
+		netlabel.EnableIPv6: true,
+		netlabel.GenericData: options.Generic{
+			"BridgeName": "testnetwork",
+		},
+	}
+	ipamV6ConfList := []*libnetwork.IpamConf{
+		{PreferredPool: "fd81:fb6e:38ba:abcd::/64", Gateway: "fd81:fb6e:38ba:abcd::9"},
+	}
+	n, err := createTestNetwork(c,
+		"bridge",
+		"testnetwork",
+		netOptions,
+		nil,
+		ipamV6ConfList,
+	)
+	assert.NilError(t, err)
+	return n
+}
+
 func TestHost(t *testing.T) {
 	defer netnsutils.SetupTestOSContext(t)()
 	controller := newController(t)
@@ -1790,295 +1815,92 @@ func reexecSetKey(key string, containerID string, controllerID string) error {
 	return cmd.Run()
 }
 
-func TestEnableIPv6(t *testing.T) {
-	defer netnsutils.SetupTestOSContext(t)()
-	controller := newController(t)
-
-	tmpResolvConf := []byte("search pommesfrites.fr\nnameserver 12.34.56.78\nnameserver 2001:4860:4860::8888\n")
-	expectedResolvConf := []byte("search pommesfrites.fr\nnameserver 127.0.0.11\nnameserver 2001:4860:4860::8888\noptions ndots:0\n")
-	// take a copy of resolv.conf for restoring after test completes
-	resolvConfSystem, err := os.ReadFile("/etc/resolv.conf")
-	if err != nil {
-		t.Fatal(err)
-	}
-	// cleanup
-	defer func() {
-		if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	netOption := options.Generic{
-		netlabel.EnableIPv6: true,
-		netlabel.GenericData: options.Generic{
-			"BridgeName": "testnetwork",
-		},
-	}
-	ipamV6ConfList := []*libnetwork.IpamConf{{PreferredPool: "fe99::/64", Gateway: "fe99::9"}}
-
-	n, err := createTestNetwork(controller, "bridge", "testnetwork", netOption, nil, ipamV6ConfList)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() {
-		if err := n.Delete(); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	ep1, err := n.CreateEndpoint("ep1")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf, 0o644); err != nil {
-		t.Fatal(err)
-	}
-
-	resolvConfPath := "/tmp/libnetwork_test/resolv.conf"
-	defer os.Remove(resolvConfPath)
-
-	sb, err := controller.NewSandbox(containerID, libnetwork.OptionResolvConfPath(resolvConfPath))
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() {
-		if err := sb.Delete(); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	err = ep1.Join(sb)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	content, err := os.ReadFile(resolvConfPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if !bytes.Equal(content, expectedResolvConf) {
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(expectedResolvConf), string(content))
-	}
-
-	if err != nil {
-		t.Fatal(err)
-	}
-}
-
-func TestResolvConfHost(t *testing.T) {
-	defer netnsutils.SetupTestOSContext(t)()
-	controller := newController(t)
-
-	tmpResolvConf := []byte("search localhost.net\nnameserver 127.0.0.1\nnameserver 2001:4860:4860::8888\n")
-
-	// take a copy of resolv.conf for restoring after test completes
-	resolvConfSystem, err := os.ReadFile("/etc/resolv.conf")
-	if err != nil {
-		t.Fatal(err)
-	}
-	// cleanup
-	defer func() {
-		if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	n := makeTesthostNetwork(t, controller)
-	ep1, err := n.CreateEndpoint("ep1", libnetwork.CreateOptionDisableResolution())
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf, 0o644); err != nil {
-		t.Fatal(err)
-	}
-
-	resolvConfPath := "/tmp/libnetwork_test/resolv.conf"
-	defer os.Remove(resolvConfPath)
-
-	sb, err := controller.NewSandbox(containerID,
-		libnetwork.OptionUseDefaultSandbox(),
-		libnetwork.OptionResolvConfPath(resolvConfPath),
-		libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"))
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() {
-		if err := sb.Delete(); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	err = ep1.Join(sb)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() {
-		err = ep1.Leave(sb)
-		if err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	finfo, err := os.Stat(resolvConfPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	fmode := (os.FileMode)(0o644)
-	if finfo.Mode() != fmode {
-		t.Fatalf("Expected file mode %s, got %s", fmode.String(), finfo.Mode().String())
-	}
-
-	content, err := os.ReadFile(resolvConfPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if !bytes.Equal(content, tmpResolvConf) {
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(tmpResolvConf), string(content))
-	}
-}
-
 func TestResolvConf(t *testing.T) {
-	defer netnsutils.SetupTestOSContext(t)()
-	controller := newController(t)
-
-	tmpResolvConf1 := []byte("search pommesfrites.fr\nnameserver 12.34.56.78\nnameserver 2001:4860:4860::8888\n")
-	tmpResolvConf2 := []byte("search pommesfrites.fr\nnameserver 112.34.56.78\nnameserver 2001:4860:4860::8888\n")
-	expectedResolvConf1 := []byte("search pommesfrites.fr\nnameserver 127.0.0.11\noptions ndots:0\n")
-	tmpResolvConf3 := []byte("search pommesfrites.fr\nnameserver 113.34.56.78\n")
-
-	// take a copy of resolv.conf for restoring after test completes
-	resolvConfSystem, err := os.ReadFile("/etc/resolv.conf")
-	if err != nil {
-		t.Fatal(err)
-	}
-	// cleanup
-	defer func() {
-		if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	netOption := options.Generic{
-		netlabel.GenericData: options.Generic{
-			"BridgeName": "testnetwork",
+	tmpDir := t.TempDir()
+	originResolvConfPath := filepath.Join(tmpDir, "origin_resolv.conf")
+	resolvConfPath := filepath.Join(tmpDir, "resolv.conf")
+
+	// Strip comments that end in a newline (a comment with no newline at the end
+	// of the file will not be stripped).
+	stripCommentsRE := regexp.MustCompile(`(?m)^#.*\n`)
+
+	testcases := []struct {
+		name             string
+		makeNet          func(t *testing.T, c *libnetwork.Controller) *libnetwork.Network
+		delNet           bool
+		epOpts           []libnetwork.EndpointOption
+		sbOpts           []libnetwork.SandboxOption
+		originResolvConf string
+		expResolvConf    string
+	}{
+		{
+			name:             "IPv6 network",
+			makeNet:          makeTestIPv6Network,
+			delNet:           true,
+			originResolvConf: "search pommesfrites.fr\nnameserver 12.34.56.78\nnameserver 2001:4860:4860::8888\n",
+			expResolvConf:    "nameserver 127.0.0.11\nnameserver 2001:4860:4860::8888\nsearch pommesfrites.fr\noptions ndots:0",
+		},
+		{
+			name:             "host network",
+			makeNet:          makeTesthostNetwork,
+			epOpts:           []libnetwork.EndpointOption{libnetwork.CreateOptionDisableResolution()},
+			sbOpts:           []libnetwork.SandboxOption{libnetwork.OptionUseDefaultSandbox()},
+			originResolvConf: "search localhost.net\nnameserver 127.0.0.1\nnameserver 2001:4860:4860::8888\n",
+			expResolvConf:    "nameserver 127.0.0.1\nnameserver 2001:4860:4860::8888\nsearch localhost.net",
 		},
-	}
-	n, err := createTestNetwork(controller, "bridge", "testnetwork", netOption, nil, nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() {
-		if err := n.Delete(); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	ep, err := n.CreateEndpoint("ep")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf1, 0o644); err != nil {
-		t.Fatal(err)
-	}
-
-	resolvConfPath := "/tmp/libnetwork_test/resolv.conf"
-	defer os.Remove(resolvConfPath)
-
-	sb1, err := controller.NewSandbox(containerID, libnetwork.OptionResolvConfPath(resolvConfPath))
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() {
-		if err := sb1.Delete(); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	err = ep.Join(sb1)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	finfo, err := os.Stat(resolvConfPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	fmode := (os.FileMode)(0o644)
-	if finfo.Mode() != fmode {
-		t.Fatalf("Expected file mode %s, got %s", fmode.String(), finfo.Mode().String())
-	}
-
-	content, err := os.ReadFile(resolvConfPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if !bytes.Equal(content, expectedResolvConf1) {
-		fmt.Printf("\n%v\n%v\n", expectedResolvConf1, content)
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(expectedResolvConf1), string(content))
-	}
-
-	err = ep.Leave(sb1)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf2, 0o644); err != nil {
-		t.Fatal(err)
-	}
-
-	sb2, err := controller.NewSandbox(containerID+"_2", libnetwork.OptionResolvConfPath(resolvConfPath))
-	if err != nil {
-		t.Fatal(err)
-	}
-	defer func() {
-		if err := sb2.Delete(); err != nil {
-			t.Fatal(err)
-		}
-	}()
-
-	err = ep.Join(sb2)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	content, err = os.ReadFile(resolvConfPath)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	if !bytes.Equal(content, expectedResolvConf1) {
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(expectedResolvConf1), string(content))
-	}
-
-	if err := os.WriteFile(resolvConfPath, tmpResolvConf3, 0o644); err != nil {
-		t.Fatal(err)
 	}
 
-	err = ep.Leave(sb2)
-	if err != nil {
-		t.Fatal(err)
-	}
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			defer netnsutils.SetupTestOSContext(t)()
+			c := newController(t)
 
-	err = ep.Join(sb2)
-	if err != nil {
-		t.Fatal(err)
-	}
+			err := os.WriteFile(originResolvConfPath, []byte(tc.originResolvConf), 0o644)
+			assert.NilError(t, err)
 
-	content, err = os.ReadFile(resolvConfPath)
-	if err != nil {
-		t.Fatal(err)
-	}
+			n := tc.makeNet(t, c)
+			if tc.delNet {
+				defer func() {
+					err := n.Delete()
+					assert.Check(t, err)
+				}()
+			}
 
-	if !bytes.Equal(content, tmpResolvConf3) {
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(tmpResolvConf3), string(content))
+			sbOpts := append(tc.sbOpts,
+				libnetwork.OptionResolvConfPath(resolvConfPath),
+				libnetwork.OptionOriginResolvConfPath(originResolvConfPath),
+			)
+			sb, err := c.NewSandbox(containerID, sbOpts...)
+			assert.NilError(t, err)
+			defer func() {
+				err := sb.Delete()
+				assert.Check(t, err)
+			}()
+
+			ep, err := n.CreateEndpoint("ep", tc.epOpts...)
+			assert.NilError(t, err)
+			defer func() {
+				err := ep.Delete(false)
+				assert.Check(t, err)
+			}()
+
+			err = ep.Join(sb)
+			assert.NilError(t, err)
+			defer func() {
+				err := ep.Leave(sb)
+				assert.Check(t, err)
+			}()
+
+			finfo, err := os.Stat(resolvConfPath)
+			assert.NilError(t, err)
+			expFMode := (os.FileMode)(0o644)
+			assert.Check(t, is.Equal(finfo.Mode().String(), expFMode.String()))
+			content, err := os.ReadFile(resolvConfPath)
+			assert.NilError(t, err)
+			actual := stripCommentsRE.ReplaceAllString(string(content), "")
+			actual = strings.TrimSpace(actual)
+			assert.Check(t, is.Equal(actual, tc.expResolvConf))
+		})
 	}
 }
 
diff --git a/libnetwork/resolvconf/resolvconf_unix_test.go b/libnetwork/resolvconf/resolvconf_unix_test.go
index bd37de4833f6a..6fcbe34381381 100644
--- a/libnetwork/resolvconf/resolvconf_unix_test.go
+++ b/libnetwork/resolvconf/resolvconf_unix_test.go
@@ -5,7 +5,12 @@ package resolvconf
 import (
 	"bytes"
 	"os"
+	"strings"
 	"testing"
+
+	"github.com/opencontainers/go-digest"
+	"gotest.tools/v3/assert"
+	is "gotest.tools/v3/assert/cmp"
 )
 
 func TestGet(t *testing.T) {
@@ -20,7 +25,8 @@ func TestGet(t *testing.T) {
 	if !bytes.Equal(actual.Content, expected) {
 		t.Errorf("%s and GetResolvConf have different content.", Path())
 	}
-	if !bytes.Equal(actual.Hash, hashData(expected)) {
+	hash := digest.FromBytes(expected)
+	if !bytes.Equal(actual.Hash, []byte(hash)) {
 		t.Errorf("%s and GetResolvConf have different hashes.", Path())
 	}
 }
@@ -111,6 +117,14 @@ nameserver 1.2.3.4
 nameserver 1.2.3.4 # not 4.3.2.1`,
 			result: []string{"1.2.3.4/32"},
 		},
+		{
+			input:  `nameserver fd6f:c490:ec68::1`,
+			result: []string{"fd6f:c490:ec68::1/128"},
+		},
+		{
+			input:  `nameserver fe80::1234%eth0`,
+			result: []string{"fe80::1234/128"},
+		},
 	} {
 		test := GetNameserversAsCIDR([]byte(tc.input))
 		if !strSlicesEqual(test, tc.result) {
@@ -175,6 +189,10 @@ search foo.example.com example.com
 nameserver 4.30.20.100`,
 			result: []string{"foo.example.com", "example.com"},
 		},
+		{
+			input:  `domain an.example`,
+			result: []string{"an.example"},
+		},
 	} {
 		test := GetSearchDomains([]byte(tc.input))
 		if !strSlicesEqual(test, tc.result) {
@@ -338,89 +356,79 @@ func TestBuildWithNoOptions(t *testing.T) {
 }
 
 func TestFilterResolvDNS(t *testing.T) {
-	ns0 := "nameserver 10.16.60.14\nnameserver 10.16.60.21\n"
-
-	if result, _ := FilterResolvDNS([]byte(ns0), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed No Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	ns1 := "nameserver 10.16.60.14\nnameserver 10.16.60.21\nnameserver 127.0.0.1\n"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	ns1 = "nameserver 10.16.60.14\nnameserver 127.0.0.1\nnameserver 10.16.60.21\n"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	ns1 = "nameserver 127.0.1.1\nnameserver 10.16.60.14\nnameserver 10.16.60.21\n"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	ns1 = "nameserver ::1\nnameserver 10.16.60.14\nnameserver 127.0.2.1\nnameserver 10.16.60.21\n"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	ns1 = "nameserver 10.16.60.14\nnameserver ::1\nnameserver 10.16.60.21\nnameserver ::1"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	// with IPv6 disabled (false param), the IPv6 nameserver should be removed
-	ns1 = "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost+IPv6 off: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	// with IPv6 disabled (false param), the IPv6 link-local nameserver with zone ID should be removed
-	ns1 = "nameserver 10.16.60.14\nnameserver FE80::BB1%1\nnameserver FE80::BB1%eth0\nnameserver 10.16.60.21\n"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost+IPv6 off: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	// with IPv6 enabled, the IPv6 nameserver should be preserved
-	ns0 = "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\n"
-	ns1 = "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1"
-	if result, _ := FilterResolvDNS([]byte(ns1), true); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed Localhost+IPv6 on: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
-	}
-
-	// with IPv6 enabled, and no non-localhost servers, Google defaults (both IPv4+IPv6) should be added
-	ns0 = "\nnameserver 8.8.8.8\nnameserver 8.8.4.4\nnameserver 2001:4860:4860::8888\nnameserver 2001:4860:4860::8844"
-	ns1 = "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1"
-	if result, _ := FilterResolvDNS([]byte(ns1), true); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed no Localhost+IPv6 enabled: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
+	testcases := []struct {
+		name        string
+		input       string
+		ipv6Enabled bool
+		expOut      string
+	}{
+		{
+			name:   "No localhost",
+			input:  "nameserver 10.16.60.14\nnameserver 10.16.60.21\n",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:   "Localhost last",
+			input:  "nameserver 10.16.60.14\nnameserver 10.16.60.21\nnameserver 127.0.0.1\n",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:   "Localhost middle",
+			input:  "nameserver 10.16.60.14\nnameserver 127.0.0.1\nnameserver 10.16.60.21\n",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:   "Localhost first",
+			input:  "nameserver 127.0.1.1\nnameserver 10.16.60.14\nnameserver 10.16.60.21\n",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:   "IPv6 Localhost",
+			input:  "nameserver ::1\nnameserver 10.16.60.14\nnameserver 127.0.2.1\nnameserver 10.16.60.21\n",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:   "Two IPv6 Localhosts",
+			input:  "nameserver 10.16.60.14\nnameserver ::1\nnameserver 10.16.60.21\nnameserver ::1",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:   "IPv6 disabled",
+			input:  "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:   "IPv6 link-local disabled",
+			input:  "nameserver 10.16.60.14\nnameserver FE80::BB1%1\nnameserver FE80::BB1%eth0\nnameserver 10.16.60.21",
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
+		},
+		{
+			name:        "IPv6 enabled",
+			input:       "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1\n",
+			ipv6Enabled: true,
+			expOut:      "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21",
+		},
+		{
+			// with IPv6 enabled, and no non-localhost servers, Google defaults (both IPv4+IPv6) should be added
+			name:        "localhost only IPv6",
+			input:       "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1",
+			ipv6Enabled: true,
+			expOut:      "nameserver 8.8.8.8\nnameserver 8.8.4.4\nnameserver 2001:4860:4860::8888\nnameserver 2001:4860:4860::8844",
+		},
+		{
+			// with IPv6 disabled, and no non-localhost servers, Google defaults (only IPv4) should be added
+			name:   "localhost only no IPv6",
+			input:  "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1",
+			expOut: "nameserver 8.8.8.8\nnameserver 8.8.4.4",
+		},
 	}
 
-	// with IPv6 disabled, and no non-localhost servers, Google defaults (only IPv4) should be added
-	ns0 = "\nnameserver 8.8.8.8\nnameserver 8.8.4.4"
-	ns1 = "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1"
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
-		if ns0 != string(result.Content) {
-			t.Errorf("Failed no Localhost+IPv6 enabled: expected \n<%s> got \n<%s>", ns0, string(result.Content))
-		}
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			f, err := FilterResolvDNS([]byte(tc.input), tc.ipv6Enabled)
+			assert.Check(t, is.Nil(err))
+			out := strings.TrimSpace(string(f.Content))
+			assert.Check(t, is.Equal(out, tc.expOut))
+		})
 	}
 }
diff --git a/libnetwork/resolvconf/utils_test.go b/libnetwork/resolvconf/utils_test.go
deleted file mode 100644
index 852ae4c52e1dd..0000000000000
--- a/libnetwork/resolvconf/utils_test.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package resolvconf
-
-import (
-	"bytes"
-	"testing"
-)
-
-func TestHashData(t *testing.T) {
-	const expected = "sha256:4d11186aed035cc624d553e10db358492c84a7cd6b9670d92123c144930450aa"
-	if actual := hashData([]byte("hash-me")); !bytes.Equal(actual, []byte(expected)) {
-		t.Fatalf("Expecting %s, got %s", expected, string(actual))
-	}
-}
-
-func BenchmarkHashData(b *testing.B) {
-	b.ReportAllocs()
-	data := []byte("hash-me")
-	for i := 0; i < b.N; i++ {
-		_ = hashData(data)
-	}
-}