Procházet zdrojové kódy

Refactor 'resolv.conf' generation.

Replace regex matching/replacement and re-reading of generated files
with a simple parser, and struct to remember and manipulate the file
content.

Annotate the generated file with a header comment saying the file is
generated, but can be modified, and a trailing comment describing how
the file was generated and listing external nameservers.

Always start with the host's resolv.conf file, whether generating config
for host networking, or with/without an internal resolver - rather than
editing a file previously generated for a different use-case.

Resolves an issue where rewrites of the generated file resulted in
default IPv6 nameservers being unnecessarily added to the config.

Signed-off-by: Rob Murray <rob.murray@docker.com>
Rob Murray před 1 rokem
rodič
revize
beb97f7fdf
50 změnil soubory, kde provedl 1811 přidání a 781 odebrání
  1. 11 9
      daemon/container_operations_unix.go
  2. 14 10
      integration-cli/docker_cli_run_test.go
  3. 72 0
      integration/networking/resolvconf_test.go
  4. 510 0
      libnetwork/internal/resolvconf/resolvconf.go
  5. 56 0
      libnetwork/internal/resolvconf/resolvconf_path.go
  6. 577 0
      libnetwork/internal/resolvconf/resolvconf_test.go
  7. 1 0
      libnetwork/internal/resolvconf/testdata/.gitattributes
  8. 4 0
      libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden
  9. 7 0
      libnetwork/internal/resolvconf/testdata/TestRCModify/Add_option_no_overrides.golden
  10. 3 0
      libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden
  11. 3 0
      libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden
  12. 7 0
      libnetwork/internal/resolvconf/testdata/TestRCModify/No_overrides.golden
  13. 8 0
      libnetwork/internal/resolvconf/testdata/TestRCModify/Overrides.golden
  14. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCSetHeader.golden
  15. 7 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/Extra_required_options.golden
  16. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_disabled.golden
  17. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_enabled.golden
  18. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_IPv6_disabled.golden
  19. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_ipv6_enabled.golden
  20. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_disabled.golden
  21. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_enabled.golden
  22. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_ipv6_enabled.golden
  23. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost.golden
  24. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost_override.golden
  25. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_only.golden
  26. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv6_addr,_IPv6_enabled.golden
  27. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_iv6.golden
  28. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_no_iv6.golden
  29. 7 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_host,_override_and_required.golden
  30. 7 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_missing_but_required.golden
  31. 7 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_present_and_required.golden
  32. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_disabled.golden
  33. 8 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_enabled.golden
  34. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_disabled.golden
  35. 4 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_enabled.golden
  36. 6 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_disabled.golden
  37. 8 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_enabled.golden
  38. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Override_nameservers.golden
  39. 4 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_disabled.golden
  40. 5 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_enabled.golden
  41. 4 0
      libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_only.golden
  42. 8 0
      libnetwork/internal/resolvconf/testdata/TestRCUnknownDirectives.golden
  43. 104 282
      libnetwork/libnetwork_linux_test.go
  44. 50 139
      libnetwork/resolvconf/resolvconf.go
  45. 91 83
      libnetwork/resolvconf/resolvconf_unix_test.go
  46. 0 14
      libnetwork/resolvconf/utils.go
  47. 0 21
      libnetwork/resolvconf/utils_test.go
  48. 120 221
      libnetwork/sandbox_dns_unix.go
  49. 3 1
      libnetwork/sandbox_dns_windows.go
  50. 2 1
      libnetwork/sandbox_store.go

+ 11 - 9
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 {

+ 14 - 10
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)
 	}
 }
 

+ 72 - 0
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
+`))
+}

+ 510 - 0
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
+}

+ 56 - 0
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
+}

+ 577 - 0
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"))
+}

+ 1 - 0
libnetwork/internal/resolvconf/testdata/.gitattributes

@@ -0,0 +1 @@
+* text=auto eol=lf

+ 4 - 0
libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden

@@ -0,0 +1,4 @@
+
+# Based on host file: ''
+# Invalid nameservers: [1.2.3.4.5]
+# Overrides: []

+ 7 - 0
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

+ 3 - 0
libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden

@@ -0,0 +1,3 @@
+
+# Based on host file: ''
+# Overrides: [nameservers search options]

+ 3 - 0
libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden

@@ -0,0 +1,3 @@
+
+# Based on host file: ''
+# Overrides: [nameservers search options]

+ 7 - 0
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

+ 8 - 0
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

+ 6 - 0
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: []

+ 7 - 0
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

+ 5 - 0
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: []

+ 6 - 0
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: []

+ 5 - 0
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: []

+ 6 - 0
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: []

+ 5 - 0
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: []

+ 6 - 0
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: []

+ 5 - 0
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: []

+ 5 - 0
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: []

+ 5 - 0
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]

+ 5 - 0
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: []

+ 5 - 0
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: []

+ 6 - 0
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: []

+ 6 - 0
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: []

+ 7 - 0
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

+ 7 - 0
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

+ 7 - 0
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

+ 6 - 0
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: []

+ 8 - 0
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: []

+ 6 - 0
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: []

+ 4 - 0
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: []

+ 6 - 0
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: []

+ 8 - 0
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: []

+ 5 - 0
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]

+ 4 - 0
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: []

+ 5 - 0
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: []

+ 4 - 0
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: []

+ 8 - 0
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

+ 104 - 282
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))
+		})
 	}
 }
 

+ 50 - 139
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
 }

+ 91 - 83
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))
+		})
 	}
 }

+ 0 - 14
libnetwork/resolvconf/utils.go

@@ -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...)
-}

+ 0 - 21
libnetwork/resolvconf/utils_test.go

@@ -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)
-	}
-}

+ 120 - 221
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 {

+ 3 - 1
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

+ 2 - 1
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)