diff --git a/daemon/container_operations_unix.go b/daemon/container_operations_unix.go index 120069e631..fbc7594585 100644 --- a/daemon/container_operations_unix.go +++ b/daemon/container_operations_unix.go @@ -419,6 +419,7 @@ func serviceDiscoveryOnDefaultNetwork() bool { func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Config, sboxOptions *[]libnetwork.SandboxOption) error { var err error + var originResolvConfPath string // Set the correct paths for /etc/hosts and /etc/resolv.conf, based on the // networking-mode of the container. Note that containers with "container" @@ -432,8 +433,8 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con *sboxOptions = append( *sboxOptions, libnetwork.OptionOriginHostsPath("/etc/hosts"), - libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"), ) + originResolvConfPath = "/etc/resolv.conf" case container.HostConfig.NetworkMode.IsUserDefined(): // The container uses a user-defined network. We use the embedded DNS // server for container name resolution and to act as a DNS forwarder @@ -446,10 +447,7 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con // If systemd-resolvd is used, the "upstream" DNS servers can be found in // /run/systemd/resolve/resolv.conf. We do not query those DNS servers // directly, as they can be dynamically reconfigured. - *sboxOptions = append( - *sboxOptions, - libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"), - ) + originResolvConfPath = "/etc/resolv.conf" default: // For other situations, such as the default bridge network, container // discovery / name resolution is handled through /etc/hosts, and no @@ -462,12 +460,16 @@ 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 { return err diff --git a/integration-cli/docker_cli_run_test.go b/integration-cli/docker_cli_run_test.go index 166d2d4ed7..11276ffc1f 100644 --- a/integration-cli/docker_cli_run_test.go +++ b/integration-cli/docker_cli_run_test.go @@ -1275,10 +1275,11 @@ func (s *DockerCLIRunSuite) TestRunDNSDefaultOptions(c *testing.T) { } actual := cli.DockerCmd(c, "run", "busybox", "cat", "/etc/resolv.conf").Combined() - // check that the actual defaults are appended to the commented out - // localhost resolver (which should be preserved) + actual = regexp.MustCompile("(?m)^#.*$").ReplaceAllString(actual, "") + actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ") // NOTE: if we ever change the defaults from google dns, this will break - expected := "#nameserver 127.0.2.1\n\nnameserver 8.8.8.8\nnameserver 8.8.4.4\n" + expected := "nameserver 8.8.8.8 nameserver 8.8.4.4" + if actual != expected { c.Fatalf("expected resolv.conf be: %q, but was: %q", expected, actual) } @@ -1295,14 +1296,16 @@ func (s *DockerCLIRunSuite) TestRunDNSOptions(c *testing.T) { c.Fatalf("Expected warning on stderr about localhost resolver, but got %q", result.Stderr()) } - actual := strings.ReplaceAll(strings.Trim(result.Stdout(), "\r\n"), "\n", " ") - if actual != "search mydomain nameserver 127.0.0.1 options ndots:9" { - c.Fatalf("expected 'search mydomain nameserver 127.0.0.1 options ndots:9', but says: %q", actual) + actual := regexp.MustCompile("(?m)^#.*$").ReplaceAllString(result.Stdout(), "") + actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ") + if actual != "nameserver 127.0.0.1 search mydomain options ndots:9" { + c.Fatalf("nameserver 127.0.0.1 expected 'search mydomain options ndots:9', but says: %q", actual) } out := cli.DockerCmd(c, "run", "--dns=1.1.1.1", "--dns-search=.", "--dns-opt=ndots:3", "busybox", "cat", "/etc/resolv.conf").Combined() - actual = strings.ReplaceAll(strings.Trim(strings.Trim(out, "\r\n"), " "), "\n", " ") + actual = regexp.MustCompile("(?m)^#.*$").ReplaceAllString(out, "") + actual = strings.ReplaceAll(strings.Trim(strings.Trim(actual, "\r\n"), " "), "\n", " ") if actual != "nameserver 1.1.1.1 options ndots:3" { c.Fatalf("expected 'nameserver 1.1.1.1 options ndots:3', but says: %q", actual) } @@ -1312,9 +1315,10 @@ func (s *DockerCLIRunSuite) TestRunDNSRepeatOptions(c *testing.T) { testRequires(c, DaemonIsLinux) out := cli.DockerCmd(c, "run", "--dns=1.1.1.1", "--dns=2.2.2.2", "--dns-search=mydomain", "--dns-search=mydomain2", "--dns-opt=ndots:9", "--dns-opt=timeout:3", "busybox", "cat", "/etc/resolv.conf").Stdout() - actual := strings.ReplaceAll(strings.Trim(out, "\r\n"), "\n", " ") - if actual != "search mydomain mydomain2 nameserver 1.1.1.1 nameserver 2.2.2.2 options ndots:9 timeout:3" { - c.Fatalf("expected 'search mydomain mydomain2 nameserver 1.1.1.1 nameserver 2.2.2.2 options ndots:9 timeout:3', but says: %q", actual) + actual := regexp.MustCompile("(?m)^#.*$").ReplaceAllString(out, "") + actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ") + if actual != "nameserver 1.1.1.1 nameserver 2.2.2.2 search mydomain mydomain2 options ndots:9 timeout:3" { + c.Fatalf("expected 'nameserver 1.1.1.1 nameserver 2.2.2.2 search mydomain mydomain2 options ndots:9 timeout:3', but says: %q", actual) } } diff --git a/integration/networking/resolvconf_test.go b/integration/networking/resolvconf_test.go new file mode 100644 index 0000000000..a10e7646aa --- /dev/null +++ b/integration/networking/resolvconf_test.go @@ -0,0 +1,72 @@ +package networking + +import ( + "os" + "strings" + "testing" + + containertypes "github.com/docker/docker/api/types/container" + "github.com/docker/docker/integration/internal/container" + "github.com/docker/docker/integration/internal/network" + "github.com/docker/docker/testutil/daemon" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" + "gotest.tools/v3/skip" +) + +// Regression test for https://github.com/moby/moby/issues/46968 +func TestResolvConfLocalhostIPv6(t *testing.T) { + // No "/etc/resolv.conf" on Windows. + skip.If(t, testEnv.DaemonInfo.OSType == "windows") + + ctx := setupTest(t) + + // Write a resolv.conf that only contains a loopback address. + // Not using t.TempDir() here because in rootless mode, while the temporary + // directory gets mode 0777, it's a subdir of an 0700 directory owned by root. + // So, it's not accessible by the daemon. + f, err := os.CreateTemp("", "resolv.conf") + assert.NilError(t, err) + defer os.Remove(f.Name()) + err = f.Chmod(0644) + assert.NilError(t, err) + f.Write([]byte("nameserver 127.0.0.53\n")) + + d := daemon.New(t, daemon.WithEnvVars("DOCKER_TEST_RESOLV_CONF_PATH="+f.Name())) + d.StartWithBusybox(ctx, t, "--experimental", "--ip6tables") + defer d.Stop(t) + + c := d.NewClientT(t) + defer c.Close() + + netName := "nnn" + network.CreateNoError(ctx, t, c, netName, + network.WithDriver("bridge"), + network.WithIPv6(), + network.WithIPAM("fd49:b5ef:36d9::/64", "fd49:b5ef:36d9::1"), + ) + defer network.RemoveNoError(ctx, t, c, netName) + + result := container.RunAttach(ctx, t, c, + container.WithImage("busybox:latest"), + container.WithNetworkMode(netName), + container.WithCmd("cat", "/etc/resolv.conf"), + ) + defer c.ContainerRemove(ctx, result.ContainerID, containertypes.RemoveOptions{ + Force: true, + }) + + output := strings.ReplaceAll(result.Stdout.String(), f.Name(), "RESOLV.CONF") + assert.Check(t, is.Equal(output, `# Generated by Docker Engine. +# This file can be edited; Docker Engine will not make further changes once it +# has been modified. + +nameserver 127.0.0.11 +options ndots:0 + +# Based on host file: 'RESOLV.CONF' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] +# Option ndots from: internal +`)) +} diff --git a/libnetwork/internal/resolvconf/resolvconf.go b/libnetwork/internal/resolvconf/resolvconf.go new file mode 100644 index 0000000000..6e49c37927 --- /dev/null +++ b/libnetwork/internal/resolvconf/resolvconf.go @@ -0,0 +1,510 @@ +// Package resolvconf is used to generate a container's /etc/resolv.conf file. +// +// Constructor Load and Parse read a resolv.conf file from the filesystem or +// a reader respectively, and return a ResolvConf object. +// +// The ResolvConf object can then be updated with overrides for nameserver, +// search domains, and DNS options. +// +// ResolvConf can then be transformed to make it suitable for legacy networking, +// a network with an internal nameserver, or used as-is for host networking. +// +// This package includes methods to write the file for the container, along with +// a hash that can be used to detect modifications made by the user to avoid +// overwriting those updates. +package resolvconf + +import ( + "bufio" + "bytes" + "context" + _ "embed" + "fmt" + "io" + "io/fs" + "net/netip" + "os" + "slices" + "strconv" + "strings" + "text/template" + + "github.com/containerd/log" + "github.com/docker/docker/errdefs" + "github.com/docker/docker/pkg/ioutils" + "github.com/opencontainers/go-digest" + "github.com/pkg/errors" +) + +// Fallback nameservers, to use if none can be obtained from the host or command +// line options. +var ( + defaultIPv4NSs = []netip.Addr{ + netip.MustParseAddr("8.8.8.8"), + netip.MustParseAddr("8.8.4.4"), + } + defaultIPv6NSs = []netip.Addr{ + netip.MustParseAddr("2001:4860:4860::8888"), + netip.MustParseAddr("2001:4860:4860::8844"), + } +) + +// ResolvConf represents a resolv.conf file. It can be constructed by +// reading a resolv.conf file, using method Parse(). +type ResolvConf struct { + nameServers []netip.Addr + search []string + options []string + other []string // Unrecognised directives from the host's file, if any. + + md metadata +} + +// ExtDNSEntry represents a nameserver address that was removed from the +// container's resolv.conf when it was transformed by TransformForIntNS(). These +// are addresses read from the host's file, or applied via an override ('--dns'). +type ExtDNSEntry struct { + Addr netip.Addr + HostLoopback bool // The address is loopback, in the host's namespace. +} + +func (ed ExtDNSEntry) String() string { + if ed.HostLoopback { + return fmt.Sprintf("host(%s)", ed.Addr) + } + return ed.Addr.String() +} + +// metadata is used to track where components of the generated file have come +// from, in order to generate comments in the file for debug/info. Struct members +// are exported for use by 'text/template'. +type metadata struct { + SourcePath string + Header string + NSOverride bool + SearchOverride bool + OptionsOverride bool + NDotsFrom string + UsedDefaultNS bool + Transform string + InvalidNSs []string + ExtNameServers []ExtDNSEntry +} + +// Load opens a file at path and parses it as a resolv.conf file. +// On error, the returned ResolvConf will be zero-valued. +func Load(path string) (ResolvConf, error) { + f, err := os.Open(path) + if err != nil { + return ResolvConf{}, err + } + defer f.Close() + return Parse(f, path) +} + +// Parse parses a resolv.conf file from reader. +// path is optional if reader is an *os.File. +// On error, the returned ResolvConf will be zero-valued. +func Parse(reader io.Reader, path string) (ResolvConf, error) { + var rc ResolvConf + rc.md.SourcePath = path + if path == "" { + if namer, ok := reader.(interface{ Name() string }); ok { + rc.md.SourcePath = namer.Name() + } + } + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + rc.processLine(scanner.Text()) + } + if err := scanner.Err(); err != nil { + return ResolvConf{}, errdefs.System(err) + } + if _, ok := rc.Option("ndots"); ok { + rc.md.NDotsFrom = "host" + } + return rc, nil +} + +// SetHeader sets the content to be included verbatim at the top of the +// generated resolv.conf file. No formatting or checking is done on the +// string. It must be valid resolv.conf syntax. (Comments must have '#' +// or ';' in the first column of each line). +// +// For example: +// +// SetHeader("# My resolv.conf\n# This file was generated.") +func (rc *ResolvConf) SetHeader(c string) { + rc.md.Header = c +} + +// NameServers returns addresses used in nameserver directives. +func (rc *ResolvConf) NameServers() []netip.Addr { + return slices.Clone(rc.nameServers) +} + +// OverrideNameServers replaces the current set of nameservers. +func (rc *ResolvConf) OverrideNameServers(nameServers []netip.Addr) { + rc.nameServers = nameServers + rc.md.NSOverride = true +} + +// Search returns the current DNS search domains. +func (rc *ResolvConf) Search() []string { + return slices.Clone(rc.search) +} + +// OverrideSearch replaces the current DNS search domains. +func (rc *ResolvConf) OverrideSearch(search []string) { + var filtered []string + for _, s := range search { + if s != "." { + filtered = append(filtered, s) + } + } + rc.search = filtered + rc.md.SearchOverride = true +} + +// Options returns the current options. +func (rc *ResolvConf) Options() []string { + return slices.Clone(rc.options) +} + +// Option finds the last option named search, and returns (value, true) if +// found, else ("", false). Options are treated as "name:value", where the +// ":value" may be omitted. +// +// For example, for "ndots:1 edns0": +// +// Option("ndots") -> ("1", true) +// Option("edns0") -> ("", true) +func (rc *ResolvConf) Option(search string) (string, bool) { + for i := len(rc.options) - 1; i >= 0; i -= 1 { + k, v, _ := strings.Cut(rc.options[i], ":") + if k == search { + return v, true + } + } + return "", false +} + +// OverrideOptions replaces the current DNS options. +func (rc *ResolvConf) OverrideOptions(options []string) { + rc.options = slices.Clone(options) + rc.md.NDotsFrom = "" + if _, exists := rc.Option("ndots"); exists { + rc.md.NDotsFrom = "override" + } + rc.md.OptionsOverride = true +} + +// AddOption adds a single DNS option. +func (rc *ResolvConf) AddOption(option string) { + if len(option) > 6 && option[:6] == "ndots:" { + rc.md.NDotsFrom = "internal" + } + rc.options = append(rc.options, option) +} + +// TransformForLegacyNw makes sure the resolv.conf file will be suitable for +// use in a legacy network (one that has no internal resolver). +// - Remove loopback addresses inherited from the host's resolv.conf, because +// they'll only work in the host's namespace. +// - Remove IPv6 addresses if !ipv6. +// - Add default nameservers if there are no addresses left. +func (rc *ResolvConf) TransformForLegacyNw(ipv6 bool) { + rc.md.Transform = "legacy" + if rc.md.NSOverride { + return + } + var filtered []netip.Addr + for _, addr := range rc.nameServers { + if !addr.IsLoopback() && (!addr.Is6() || ipv6) { + filtered = append(filtered, addr) + } + } + rc.nameServers = filtered + if len(rc.nameServers) == 0 { + log.G(context.TODO()).Info("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers") + rc.nameServers = defaultNSAddrs(ipv6) + rc.md.UsedDefaultNS = true + } +} + +// TransformForIntNS makes sure the resolv.conf file will be suitable for +// use in a network sandbox that has an internal DNS resolver. +// - Add internalNS as a nameserver. +// - Remove other nameservers, stashing them as ExtNameServers for the +// internal resolver to use. (Apart from IPv6 nameservers, if keepIPv6.) +// - Mark ExtNameServers that must be used in the host namespace. +// - If no ExtNameServer addresses are found, use the defaults. +// - Return an error if an "ndots" option inherited from the host's config, or +// supplied in an override is not valid. +// - Ensure there's an 'options' value for each entry in reqdOptions. If the +// option includes a ':', and an option with a matching prefix exists, it +// is not modified. +func (rc *ResolvConf) TransformForIntNS( + keepIPv6 bool, + internalNS netip.Addr, + reqdOptions []string, +) ([]ExtDNSEntry, error) { + // The transformed config must list the internal nameserver. + newNSs := []netip.Addr{internalNS} + // Filter out other nameservers, keeping them for use as upstream nameservers by the + // internal nameserver. + rc.md.ExtNameServers = nil + for _, addr := range rc.nameServers { + // The internal resolver only uses IPv4 addresses so, keep IPv6 nameservers in + // the container's file if keepIPv6, else drop them. + if addr.Is6() { + if keepIPv6 { + newNSs = append(newNSs, addr) + } + } else { + // Extract this NS. Mark loopback addresses that did not come from an override as + // 'HostLoopback'. Upstream requests for these servers will be made in the host's + // network namespace. (So, '--dns 127.0.0.53' means use a nameserver listening on + // the container's loopback interface. But, if the host's resolv.conf contains + // 'nameserver 127.0.0.53', the host's resolver will be used.) + // + // TODO(robmry) - why only loopback addresses? + // Addresses from the host's resolv.conf must be usable in the host's namespace, + // and a lookup from the container's namespace is more expensive? And, for + // example, if the host has a nameserver with an IPv6 LL address with a zone-id, + // it won't work from the container's namespace (now, while the address is left in + // the container's resolv.conf, or in future for the internal resolver). + rc.md.ExtNameServers = append(rc.md.ExtNameServers, ExtDNSEntry{ + Addr: addr, + HostLoopback: addr.IsLoopback() && !rc.md.NSOverride, + }) + } + } + rc.nameServers = newNSs + + // If there are no external nameservers, and the only nameserver left is the + // internal resolver, use the defaults as ext nameservers. + if len(rc.md.ExtNameServers) == 0 && len(rc.nameServers) == 1 { + log.G(context.TODO()).Info("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers") + for _, addr := range defaultNSAddrs(keepIPv6) { + rc.md.ExtNameServers = append(rc.md.ExtNameServers, ExtDNSEntry{Addr: addr}) + } + rc.md.UsedDefaultNS = true + } + + // Validate the ndots option from host config or overrides, if present. + // TODO(robmry) - pre-existing behaviour, but ... + // Validating ndots from an override is good, but not-liking something in the + // host's resolv.conf isn't a reason to fail - just remove? (And it'll be + // replaced by the value in reqdOptions, if given.) + if ndots, exists := rc.Option("ndots"); exists { + if n, err := strconv.Atoi(ndots); err != nil || n < 0 { + return nil, errdefs.InvalidParameter( + fmt.Errorf("invalid number for ndots option: %v", ndots)) + } + } + // For each option required by the nameserver, add it if not already + // present (if the option already has a value don't change it). + for _, opt := range reqdOptions { + optName, _, _ := strings.Cut(opt, ":") + if _, exists := rc.Option(optName); !exists { + rc.AddOption(opt) + } + } + + rc.md.Transform = "internal resolver" + return slices.Clone(rc.md.ExtNameServers), nil +} + +// Generate returns content suitable for writing to a resolv.conf file. If comments +// is true, the file will include header information if supplied, and a trailing +// comment that describes how the file was constructed and lists external resolvers. +func (rc *ResolvConf) Generate(comments bool) ([]byte, error) { + s := struct { + Md *metadata + NameServers []netip.Addr + Search []string + Options []string + Other []string + Overrides []string + Comments bool + }{ + Md: &rc.md, + NameServers: rc.nameServers, + Search: rc.search, + Options: rc.options, + Other: rc.other, + Comments: comments, + } + if rc.md.NSOverride { + s.Overrides = append(s.Overrides, "nameservers") + } + if rc.md.SearchOverride { + s.Overrides = append(s.Overrides, "search") + } + if rc.md.OptionsOverride { + s.Overrides = append(s.Overrides, "options") + } + + const templateText = `{{if .Comments}}{{with .Md.Header}}{{.}} + +{{end}}{{end}}{{range .NameServers -}} +nameserver {{.}} +{{end}}{{with .Search -}} +search {{join . " "}} +{{end}}{{with .Options -}} +options {{join . " "}} +{{end}}{{with .Other -}} +{{join . "\n"}} +{{end}}{{if .Comments}} +# Based on host file: '{{.Md.SourcePath}}'{{with .Md.Transform}} ({{.}}){{end}} +{{if .Md.UsedDefaultNS -}} +# Used default nameservers. +{{end -}} +{{with .Md.ExtNameServers -}} +# ExtServers: {{.}} +{{end -}} +{{with .Md.InvalidNSs -}} +# Invalid nameservers: {{.}} +{{end -}} +# Overrides: {{.Overrides}} +{{with .Md.NDotsFrom -}} +# Option ndots from: {{.}} +{{end -}} +{{end -}} +` + + funcs := template.FuncMap{"join": strings.Join} + var buf bytes.Buffer + templ, err := template.New("summary").Funcs(funcs).Parse(templateText) + if err != nil { + return nil, errdefs.System(err) + } + if err := templ.Execute(&buf, s); err != nil { + return nil, errdefs.System(err) + } + return buf.Bytes(), nil +} + +// WriteFile generates content and writes it to path. If hashPath is non-zero, it +// also writes a file containing a hash of the content, to enable UserModified() +// to determine whether the file has been modified. +func (rc *ResolvConf) WriteFile(path, hashPath string, perm os.FileMode) error { + content, err := rc.Generate(true) + if err != nil { + return err + } + + // Write the resolv.conf file - it's bind-mounted into the container, so can't + // move a temp file into place, just have to truncate and write it. + if err := os.WriteFile(path, content, perm); err != nil { + return errdefs.System(err) + } + + // Write the hash file. + if hashPath != "" { + hashFile, err := ioutils.NewAtomicFileWriter(hashPath, perm) + if err != nil { + return errdefs.System(err) + } + defer hashFile.Close() + + digest := digest.FromBytes(content) + if _, err = hashFile.Write([]byte(digest)); err != nil { + return err + } + } + + return nil +} + +// UserModified can be used to determine whether the resolv.conf file has been +// modified since it was generated. It returns false with no error if the file +// matches the hash, true with no error if the file no longer matches the hash, +// and false with an error if the result cannot be determined. +func UserModified(rcPath, rcHashPath string) (bool, error) { + currRCHash, err := os.ReadFile(rcHashPath) + if err != nil { + // If the hash file doesn't exist, can only assume it hasn't been written + // yet (so, the user hasn't modified the file it hashes). + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, errors.Wrapf(err, "failed to read hash file %s", rcHashPath) + } + expected, err := digest.Parse(string(currRCHash)) + if err != nil { + return false, errors.Wrapf(err, "failed to parse hash file %s", rcHashPath) + } + v := expected.Verifier() + currRC, err := os.Open(rcPath) + if err != nil { + return false, errors.Wrapf(err, "failed to open %s to check for modifications", rcPath) + } + defer currRC.Close() + if _, err := io.Copy(v, currRC); err != nil { + return false, errors.Wrapf(err, "failed to hash %s to check for modifications", rcPath) + } + return !v.Verified(), nil +} + +func (rc *ResolvConf) processLine(line string) { + fields := strings.Fields(line) + + // Strip comments. + // TODO(robmry) - ignore comment chars except in column 0. + // This preserves old behaviour, but it's wrong. For example, resolvers + // will honour the option in line "options # ndots:0" (and ignore the + // "#" as an unknown option). + for i, s := range fields { + if s[0] == '#' || s[0] == ';' { + fields = fields[:i] + break + } + } + if len(fields) == 0 { + return + } + + switch fields[0] { + case "nameserver": + if len(fields) < 2 { + return + } + if addr, err := netip.ParseAddr(fields[1]); err != nil { + rc.md.InvalidNSs = append(rc.md.InvalidNSs, fields[1]) + } else { + rc.nameServers = append(rc.nameServers, addr) + } + case "domain": + // 'domain' is an obsolete name for 'search'. + fallthrough + case "search": + if len(fields) < 2 { + return + } + // Only the last 'search' directive is used. + rc.search = fields[1:] + case "options": + if len(fields) < 2 { + return + } + // Replace options from earlier directives. + // TODO(robmry) - preserving incorrect behaviour, options should accumulate. + // rc.options = append(rc.options, fields[1:]...) + rc.options = fields[1:] + default: + // Copy anything that's not a recognised directive. + rc.other = append(rc.other, line) + } +} + +func defaultNSAddrs(ipv6 bool) []netip.Addr { + var addrs []netip.Addr + addrs = append(addrs, defaultIPv4NSs...) + if ipv6 { + addrs = append(addrs, defaultIPv6NSs...) + } + return addrs +} diff --git a/libnetwork/internal/resolvconf/resolvconf_path.go b/libnetwork/internal/resolvconf/resolvconf_path.go new file mode 100644 index 0000000000..65d0fe1409 --- /dev/null +++ b/libnetwork/internal/resolvconf/resolvconf_path.go @@ -0,0 +1,56 @@ +package resolvconf + +import ( + "context" + "net/netip" + "sync" + + "github.com/containerd/log" +) + +const ( + // defaultPath is the default path to the resolv.conf that contains information to resolve DNS. See Path(). + defaultPath = "/etc/resolv.conf" + // alternatePath is a path different from defaultPath, that may be used to resolve DNS. See Path(). + alternatePath = "/run/systemd/resolve/resolv.conf" +) + +// For Path to detect systemd (only needed for legacy networking). +var ( + detectSystemdResolvConfOnce sync.Once + pathAfterSystemdDetection = defaultPath +) + +// Path returns the path to the resolv.conf file that libnetwork should use. +// +// When /etc/resolv.conf contains 127.0.0.53 as the only nameserver, then +// it is assumed systemd-resolved manages DNS. Because inside the container 127.0.0.53 +// is not a valid DNS server, Path() returns /run/systemd/resolve/resolv.conf +// which is the resolv.conf that systemd-resolved generates and manages. +// Otherwise Path() returns /etc/resolv.conf. +// +// Errors are silenced as they will inevitably resurface at future open/read calls. +// +// More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf +// +// TODO(robmry) - alternatePath is only needed for legacy networking ... +// +// Host networking can use the host's resolv.conf as-is, and with an internal +// resolver it's also possible to use nameservers on the host's loopback +// interface. Once legacy networking is removed, this can always return +// defaultPath. +func Path() string { + detectSystemdResolvConfOnce.Do(func() { + rc, err := Load(defaultPath) + if err != nil { + // silencing error as it will resurface at next calls trying to read defaultPath + return + } + ns := rc.nameServers + if len(ns) == 1 && ns[0] == netip.MustParseAddr("127.0.0.53") { + pathAfterSystemdDetection = alternatePath + log.G(context.TODO()).Infof("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath) + } + }) + return pathAfterSystemdDetection +} diff --git a/libnetwork/internal/resolvconf/resolvconf_test.go b/libnetwork/internal/resolvconf/resolvconf_test.go new file mode 100644 index 0000000000..21605178df --- /dev/null +++ b/libnetwork/internal/resolvconf/resolvconf_test.go @@ -0,0 +1,577 @@ +package resolvconf + +import ( + "bytes" + "io/fs" + "net/netip" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/docker/docker/internal/sliceutil" + "github.com/google/go-cmp/cmp/cmpopts" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" + "gotest.tools/v3/golden" +) + +func TestRCOption(t *testing.T) { + testcases := []struct { + name string + options string + search string + expFound bool + expValue string + }{ + { + name: "Empty options", + options: "", + search: "ndots", + }, + { + name: "Not found", + options: "ndots:0 edns0", + search: "trust-ad", + }, + { + name: "Found with value", + options: "ndots:0 edns0", + search: "ndots", + expFound: true, + expValue: "0", + }, + { + name: "Found without value", + options: "ndots:0 edns0", + search: "edns0", + expFound: true, + expValue: "", + }, + { + name: "Found last value", + options: "ndots:0 edns0 ndots:1", + search: "ndots", + expFound: true, + expValue: "1", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + rc, err := Parse(bytes.NewBuffer([]byte("options "+tc.options)), "") + assert.NilError(t, err) + value, found := rc.Option(tc.search) + assert.Check(t, is.Equal(found, tc.expFound)) + assert.Check(t, is.Equal(value, tc.expValue)) + }) + } +} + +func TestRCWrite(t *testing.T) { + testcases := []struct { + name string + fileName string + perm os.FileMode + hashFileName string + modify bool + expUserModified bool + }{ + { + name: "Write with hash", + fileName: "testfile", + hashFileName: "testfile.hash", + }, + { + name: "Write with hash and modify", + fileName: "testfile", + hashFileName: "testfile.hash", + modify: true, + expUserModified: true, + }, + { + name: "Write without hash and modify", + fileName: "testfile", + modify: true, + expUserModified: false, + }, + { + name: "Write perm", + fileName: "testfile", + perm: 0640, + }, + } + + rc, err := Parse(bytes.NewBuffer([]byte("nameserver 1.2.3.4")), "") + assert.NilError(t, err) + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc := tc + d := t.TempDir() + path := filepath.Join(d, tc.fileName) + var hashPath string + if tc.hashFileName != "" { + hashPath = filepath.Join(d, tc.hashFileName) + } + if tc.perm == 0 { + tc.perm = 0644 + } + err := rc.WriteFile(path, hashPath, tc.perm) + assert.NilError(t, err) + + fi, err := os.Stat(path) + assert.NilError(t, err) + // Windows files won't have the expected perms. + if runtime.GOOS != "windows" { + assert.Check(t, is.Equal(fi.Mode(), tc.perm)) + } + + if tc.modify { + err := os.WriteFile(path, []byte("modified"), 0644) + assert.NilError(t, err) + } + + um, err := UserModified(path, hashPath) + assert.NilError(t, err) + assert.Check(t, is.Equal(um, tc.expUserModified)) + }) + } +} + +var a2s = sliceutil.Mapper(netip.Addr.String) +var s2a = sliceutil.Mapper(netip.MustParseAddr) + +// Test that a resolv.conf file can be modified using OverrideXXX() methods +// to modify nameservers/search/options directives, and tha options can be +// added via AddOption(). +func TestRCModify(t *testing.T) { + testcases := []struct { + name string + inputNS []string + inputSearch []string + inputOptions []string + noOverrides bool // Whether to apply overrides (empty lists are valid overrides). + overrideNS []string + overrideSearch []string + overrideOptions []string + addOption string + }{ + { + name: "No content no overrides", + inputNS: []string{}, + }, + { + name: "No overrides", + noOverrides: true, + inputNS: []string{"1.2.3.4"}, + inputSearch: []string{"invalid"}, + inputOptions: []string{"ndots:0"}, + }, + { + name: "Empty overrides", + inputNS: []string{"1.2.3.4"}, + inputSearch: []string{"invalid"}, + inputOptions: []string{"ndots:0"}, + }, + { + name: "Overrides", + inputNS: []string{"1.2.3.4"}, + inputSearch: []string{"invalid"}, + inputOptions: []string{"ndots:0"}, + overrideNS: []string{"2.3.4.5", "fdba:acdd:587c::53"}, + overrideSearch: []string{"com", "invalid", "example"}, + overrideOptions: []string{"ndots:1", "edns0", "trust-ad"}, + }, + { + name: "Add option no overrides", + noOverrides: true, + inputNS: []string{"1.2.3.4"}, + inputSearch: []string{"invalid"}, + inputOptions: []string{"ndots:0"}, + addOption: "attempts:3", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc := tc + var input string + if len(tc.inputNS) != 0 { + for _, ns := range tc.inputNS { + input += "nameserver " + ns + "\n" + } + } + if len(tc.inputSearch) != 0 { + input += "search " + strings.Join(tc.inputSearch, " ") + "\n" + } + if len(tc.inputOptions) != 0 { + input += "options " + strings.Join(tc.inputOptions, " ") + "\n" + } + rc, err := Parse(bytes.NewBuffer([]byte(input)), "") + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(a2s(rc.NameServers()), tc.inputNS)) + assert.Check(t, is.DeepEqual(rc.Search(), tc.inputSearch)) + assert.Check(t, is.DeepEqual(rc.Options(), tc.inputOptions)) + + if !tc.noOverrides { + overrideNS := s2a(tc.overrideNS) + rc.OverrideNameServers(overrideNS) + rc.OverrideSearch(tc.overrideSearch) + rc.OverrideOptions(tc.overrideOptions) + + assert.Check(t, is.DeepEqual(rc.NameServers(), overrideNS, cmpopts.EquateComparable(netip.Addr{}))) + assert.Check(t, is.DeepEqual(rc.Search(), tc.overrideSearch)) + assert.Check(t, is.DeepEqual(rc.Options(), tc.overrideOptions)) + } + + if tc.addOption != "" { + options := rc.Options() + rc.AddOption(tc.addOption) + assert.Check(t, is.DeepEqual(rc.Options(), append(options, tc.addOption))) + } + + d := t.TempDir() + path := filepath.Join(d, "resolv.conf") + err = rc.WriteFile(path, "", 0644) + assert.NilError(t, err) + + content, err := os.ReadFile(path) + assert.NilError(t, err) + assert.Check(t, golden.String(string(content), t.Name()+".golden")) + }) + } +} + +func TestRCTransformForLegacyNw(t *testing.T) { + testcases := []struct { + name string + input string + ipv6 bool + overrideNS []string + }{ + { + name: "Routable IPv4 only", + input: "nameserver 10.0.0.1", + }, + { + name: "Routable IPv4 and IPv6, ipv6 enabled", + input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1", + ipv6: true, + }, + { + name: "Routable IPv4 and IPv6, ipv6 disabled", + input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1", + ipv6: false, + }, + { + name: "IPv4 localhost, ipv6 disabled", + input: "nameserver 127.0.0.53", + ipv6: false, + }, + { + name: "IPv4 localhost, ipv6 enabled", + input: "nameserver 127.0.0.53", + ipv6: true, + }, + { + name: "IPv4 and IPv6 localhost, ipv6 disabled", + input: "nameserver 127.0.0.53\nnameserver ::1", + ipv6: false, + }, + { + name: "IPv4 and IPv6 localhost, ipv6 enabled", + input: "nameserver 127.0.0.53\nnameserver ::1", + ipv6: true, + }, + { + name: "IPv4 localhost, IPv6 routeable, ipv6 enabled", + input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1", + ipv6: true, + }, + { + name: "IPv4 localhost, IPv6 routeable, ipv6 disabled", + input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1", + ipv6: false, + }, + { + name: "Override nameservers", + input: "nameserver 127.0.0.53", + overrideNS: []string{"127.0.0.1", "::1"}, + ipv6: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc := tc + rc, err := Parse(bytes.NewBuffer([]byte(tc.input)), "/etc/resolv.conf") + assert.NilError(t, err) + if tc.overrideNS != nil { + rc.OverrideNameServers(s2a(tc.overrideNS)) + } + + rc.TransformForLegacyNw(tc.ipv6) + + d := t.TempDir() + path := filepath.Join(d, "resolv.conf") + err = rc.WriteFile(path, "", 0644) + assert.NilError(t, err) + + content, err := os.ReadFile(path) + assert.NilError(t, err) + assert.Check(t, golden.String(string(content), t.Name()+".golden")) + }) + } +} + +func TestRCTransformForIntNS(t *testing.T) { + mke := func(addr string, hostLoopback bool) ExtDNSEntry { + return ExtDNSEntry{ + Addr: netip.MustParseAddr(addr), + HostLoopback: hostLoopback, + } + } + + testcases := []struct { + name string + input string + intNameServer string + ipv6 bool + overrideNS []string + overrideOptions []string + reqdOptions []string + expExtServers []ExtDNSEntry + expErr string + }{ + { + name: "IPv4 only", + input: "nameserver 10.0.0.1", + expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)}, + }, + { + name: "IPv4 and IPv6, ipv6 enabled", + input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1", + ipv6: true, + expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)}, + }, + { + name: "IPv4 and IPv6, ipv6 disabled", + input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1", + ipv6: false, + expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)}, + }, + { + name: "IPv4 localhost", + input: "nameserver 127.0.0.53", + ipv6: false, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + // Overriding the nameserver with a localhost address means use the container's + // loopback interface, not the host's. + name: "IPv4 localhost override", + input: "nameserver 10.0.0.1", + ipv6: false, + overrideNS: []string{"127.0.0.53"}, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", false)}, + }, + { + name: "IPv4 localhost, ipv6 enabled", + input: "nameserver 127.0.0.53", + ipv6: true, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "IPv6 addr, IPv6 enabled", + input: "nameserver fd14:6e0e:f855::1", + ipv6: true, + // Note that there are no ext servers in this case, the internal resolver + // will only look up container names. The default nameservers aren't added + // because the host's IPv6 nameserver remains in the container's resolv.conf, + // (because only IPv4 ext servers are currently allowed). + }, + { + name: "IPv4 and IPv6 localhost, IPv6 disabled", + input: "nameserver 127.0.0.53\nnameserver ::1", + ipv6: false, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "IPv4 and IPv6 localhost, ipv6 enabled", + input: "nameserver 127.0.0.53\nnameserver ::1", + ipv6: true, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "IPv4 localhost, IPv6 private, IPv6 enabled", + input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1", + ipv6: true, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "IPv4 localhost, IPv6 private, IPv6 disabled", + input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1", + ipv6: false, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "No host nameserver, no iv6", + input: "", + ipv6: false, + expExtServers: []ExtDNSEntry{ + mke("8.8.8.8", false), + mke("8.8.4.4", false), + }, + }, + { + name: "No host nameserver, iv6", + input: "", + ipv6: true, + expExtServers: []ExtDNSEntry{ + mke("8.8.8.8", false), + mke("8.8.4.4", false), + mke("2001:4860:4860::8888", false), + mke("2001:4860:4860::8844", false), + }, + }, + { + name: "ndots present and required", + input: "nameserver 127.0.0.53\noptions ndots:1", + reqdOptions: []string{"ndots:0"}, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "ndots missing but required", + input: "nameserver 127.0.0.53", + reqdOptions: []string{"ndots:0"}, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "ndots host, override and required", + input: "nameserver 127.0.0.53", + reqdOptions: []string{"ndots:0"}, + overrideOptions: []string{"ndots:2"}, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + { + name: "Extra required options", + input: "nameserver 127.0.0.53\noptions trust-ad", + reqdOptions: []string{"ndots:0", "attempts:3", "edns0", "trust-ad"}, + expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)}, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + tc := tc + rc, err := Parse(bytes.NewBuffer([]byte(tc.input)), "/etc/resolv.conf") + assert.NilError(t, err) + + if tc.intNameServer == "" { + tc.intNameServer = "127.0.0.11" + } + if len(tc.overrideNS) > 0 { + rc.OverrideNameServers(s2a(tc.overrideNS)) + } + if len(tc.overrideOptions) > 0 { + rc.OverrideOptions(tc.overrideOptions) + } + intNS := netip.MustParseAddr(tc.intNameServer) + extNameServers, err := rc.TransformForIntNS(tc.ipv6, intNS, tc.reqdOptions) + if tc.expErr != "" { + assert.Check(t, is.ErrorContains(err, tc.expErr)) + return + } + assert.NilError(t, err) + + d := t.TempDir() + path := filepath.Join(d, "resolv.conf") + err = rc.WriteFile(path, "", 0644) + assert.NilError(t, err) + + content, err := os.ReadFile(path) + assert.NilError(t, err) + assert.Check(t, golden.String(string(content), t.Name()+".golden")) + assert.Check(t, is.DeepEqual(extNameServers, tc.expExtServers, + cmpopts.EquateComparable(netip.Addr{}))) + }) + } +} + +func TestRCRead(t *testing.T) { + d := t.TempDir() + path := filepath.Join(d, "resolv.conf") + + // Try to read a nonexistent file, equivalent to an empty file. + _, err := Load(path) + assert.Check(t, is.ErrorIs(err, fs.ErrNotExist)) + + err = os.WriteFile(path, []byte("options edns0"), 0644) + assert.NilError(t, err) + + // Read that file in the constructor. + rc, err := Load(path) + assert.NilError(t, err) + assert.Check(t, is.DeepEqual(rc.Options(), []string{"edns0"})) + + // Pass in an os.File, check the path is extracted. + file, err := os.Open(path) + assert.NilError(t, err) + defer file.Close() + rc, err = Parse(file, "") + assert.NilError(t, err) + assert.Check(t, is.Equal(rc.md.SourcePath, path)) +} + +func TestRCInvalidNS(t *testing.T) { + d := t.TempDir() + + // A resolv.conf with an invalid nameserver address. + rc, err := Parse(bytes.NewBuffer([]byte("nameserver 1.2.3.4.5")), "") + assert.NilError(t, err) + + path := filepath.Join(d, "resolv.conf") + err = rc.WriteFile(path, "", 0644) + assert.NilError(t, err) + + content, err := os.ReadFile(path) + assert.NilError(t, err) + assert.Check(t, golden.String(string(content), t.Name()+".golden")) +} + +func TestRCSetHeader(t *testing.T) { + rc, err := Parse(bytes.NewBuffer([]byte("nameserver 127.0.0.53")), "/etc/resolv.conf") + assert.NilError(t, err) + + rc.SetHeader("# This is a comment.") + d := t.TempDir() + path := filepath.Join(d, "resolv.conf") + err = rc.WriteFile(path, "", 0644) + assert.NilError(t, err) + + content, err := os.ReadFile(path) + assert.NilError(t, err) + assert.Check(t, golden.String(string(content), t.Name()+".golden")) +} + +func TestRCUnknownDirectives(t *testing.T) { + const input = ` +something unexpected +nameserver 127.0.0.53 +options ndots:1 +unrecognised thing +` + rc, err := Parse(bytes.NewBuffer([]byte(input)), "/etc/resolv.conf") + assert.NilError(t, err) + + d := t.TempDir() + path := filepath.Join(d, "resolv.conf") + err = rc.WriteFile(path, "", 0644) + assert.NilError(t, err) + + content, err := os.ReadFile(path) + assert.NilError(t, err) + assert.Check(t, golden.String(string(content), t.Name()+".golden")) +} diff --git a/libnetwork/internal/resolvconf/testdata/.gitattributes b/libnetwork/internal/resolvconf/testdata/.gitattributes new file mode 100644 index 0000000000..6313b56c57 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden b/libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden new file mode 100644 index 0000000000..34c9172b32 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCInvalidNS.golden @@ -0,0 +1,4 @@ + +# Based on host file: '' +# Invalid nameservers: [1.2.3.4.5] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/Add_option_no_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/Add_option_no_overrides.golden new file mode 100644 index 0000000000..e4e5b287d8 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/Add_option_no_overrides.golden @@ -0,0 +1,7 @@ +nameserver 1.2.3.4 +search invalid +options ndots:0 attempts:3 + +# Based on host file: '' +# Overrides: [] +# Option ndots from: host diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden new file mode 100644 index 0000000000..75a0159f2c --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/Empty_overrides.golden @@ -0,0 +1,3 @@ + +# Based on host file: '' +# Overrides: [nameservers search options] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden new file mode 100644 index 0000000000..75a0159f2c --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_content_no_overrides.golden @@ -0,0 +1,3 @@ + +# Based on host file: '' +# Overrides: [nameservers search options] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/No_overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_overrides.golden new file mode 100644 index 0000000000..6ce29386fc --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/No_overrides.golden @@ -0,0 +1,7 @@ +nameserver 1.2.3.4 +search invalid +options ndots:0 + +# Based on host file: '' +# Overrides: [] +# Option ndots from: host diff --git a/libnetwork/internal/resolvconf/testdata/TestRCModify/Overrides.golden b/libnetwork/internal/resolvconf/testdata/TestRCModify/Overrides.golden new file mode 100644 index 0000000000..62d6ef61db --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCModify/Overrides.golden @@ -0,0 +1,8 @@ +nameserver 2.3.4.5 +nameserver fdba:acdd:587c::53 +search com invalid example +options ndots:1 edns0 trust-ad + +# Based on host file: '' +# Overrides: [nameservers search options] +# Option ndots from: override diff --git a/libnetwork/internal/resolvconf/testdata/TestRCSetHeader.golden b/libnetwork/internal/resolvconf/testdata/TestRCSetHeader.golden new file mode 100644 index 0000000000..beed1a7658 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCSetHeader.golden @@ -0,0 +1,6 @@ +# This is a comment. + +nameserver 127.0.0.53 + +# Based on host file: '/etc/resolv.conf' +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/Extra_required_options.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/Extra_required_options.golden new file mode 100644 index 0000000000..68fd467207 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/Extra_required_options.golden @@ -0,0 +1,7 @@ +nameserver 127.0.0.11 +options trust-ad ndots:0 attempts:3 edns0 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] +# Option ndots from: internal diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_disabled.golden new file mode 100644 index 0000000000..4479c59369 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_disabled.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [10.0.0.1] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_enabled.golden new file mode 100644 index 0000000000..578b5babbe --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6,_ipv6_enabled.golden @@ -0,0 +1,6 @@ +nameserver 127.0.0.11 +nameserver fdb6:b8fe:b528::1 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [10.0.0.1] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_IPv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_IPv6_disabled.golden new file mode 100644 index 0000000000..926d44d49a --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_IPv6_disabled.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_ipv6_enabled.golden new file mode 100644 index 0000000000..b64148d845 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_and_IPv6_localhost,_ipv6_enabled.golden @@ -0,0 +1,6 @@ +nameserver 127.0.0.11 +nameserver ::1 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_disabled.golden new file mode 100644 index 0000000000..926d44d49a --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_disabled.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_enabled.golden new file mode 100644 index 0000000000..6c1c311c1b --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_IPv6_private,_IPv6_enabled.golden @@ -0,0 +1,6 @@ +nameserver 127.0.0.11 +nameserver fd3e:2d1a:1f5a::1 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_ipv6_enabled.golden new file mode 100644 index 0000000000..926d44d49a --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost,_ipv6_enabled.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost.golden new file mode 100644 index 0000000000..926d44d49a --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost_override.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost_override.golden new file mode 100644 index 0000000000..da55aad0ca --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_localhost_override.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [127.0.0.53] +# Overrides: [nameservers] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_only.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_only.golden new file mode 100644 index 0000000000..4479c59369 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv4_only.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [10.0.0.1] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv6_addr,_IPv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv6_addr,_IPv6_enabled.golden new file mode 100644 index 0000000000..0a412defbf --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/IPv6_addr,_IPv6_enabled.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.11 +nameserver fd14:6e0e:f855::1 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_iv6.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_iv6.golden new file mode 100644 index 0000000000..cde7c90bd9 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_iv6.golden @@ -0,0 +1,6 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# Used default nameservers. +# ExtServers: [8.8.8.8 8.8.4.4 2001:4860:4860::8888 2001:4860:4860::8844] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_no_iv6.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_no_iv6.golden new file mode 100644 index 0000000000..c620d3442d --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/No_host_nameserver,_no_iv6.golden @@ -0,0 +1,6 @@ +nameserver 127.0.0.11 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# Used default nameservers. +# ExtServers: [8.8.8.8 8.8.4.4] +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_host,_override_and_required.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_host,_override_and_required.golden new file mode 100644 index 0000000000..dc469d58cd --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_host,_override_and_required.golden @@ -0,0 +1,7 @@ +nameserver 127.0.0.11 +options ndots:2 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [options] +# Option ndots from: override diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_missing_but_required.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_missing_but_required.golden new file mode 100644 index 0000000000..827ecd295c --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_missing_but_required.golden @@ -0,0 +1,7 @@ +nameserver 127.0.0.11 +options ndots:0 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] +# Option ndots from: internal diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_present_and_required.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_present_and_required.golden new file mode 100644 index 0000000000..d005fa38b6 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForIntNS/ndots_present_and_required.golden @@ -0,0 +1,7 @@ +nameserver 127.0.0.11 +options ndots:1 + +# Based on host file: '/etc/resolv.conf' (internal resolver) +# ExtServers: [host(127.0.0.53)] +# Overrides: [] +# Option ndots from: host diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_disabled.golden new file mode 100644 index 0000000000..165eafe282 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_disabled.golden @@ -0,0 +1,6 @@ +nameserver 8.8.8.8 +nameserver 8.8.4.4 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Used default nameservers. +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_enabled.golden new file mode 100644 index 0000000000..fd66dad64e --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_and_IPv6_localhost,_ipv6_enabled.golden @@ -0,0 +1,8 @@ +nameserver 8.8.8.8 +nameserver 8.8.4.4 +nameserver 2001:4860:4860::8888 +nameserver 2001:4860:4860::8844 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Used default nameservers. +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_disabled.golden new file mode 100644 index 0000000000..165eafe282 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_disabled.golden @@ -0,0 +1,6 @@ +nameserver 8.8.8.8 +nameserver 8.8.4.4 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Used default nameservers. +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_enabled.golden new file mode 100644 index 0000000000..c188830fcd --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_IPv6_routeable,_ipv6_enabled.golden @@ -0,0 +1,4 @@ +nameserver fd3e:2d1a:1f5a::1 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_disabled.golden new file mode 100644 index 0000000000..165eafe282 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_disabled.golden @@ -0,0 +1,6 @@ +nameserver 8.8.8.8 +nameserver 8.8.4.4 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Used default nameservers. +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_enabled.golden new file mode 100644 index 0000000000..fd66dad64e --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/IPv4_localhost,_ipv6_enabled.golden @@ -0,0 +1,8 @@ +nameserver 8.8.8.8 +nameserver 8.8.4.4 +nameserver 2001:4860:4860::8888 +nameserver 2001:4860:4860::8844 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Used default nameservers. +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Override_nameservers.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Override_nameservers.golden new file mode 100644 index 0000000000..74f7abf968 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Override_nameservers.golden @@ -0,0 +1,5 @@ +nameserver 127.0.0.1 +nameserver ::1 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Overrides: [nameservers] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_disabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_disabled.golden new file mode 100644 index 0000000000..cdeaadf88c --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_disabled.golden @@ -0,0 +1,4 @@ +nameserver 10.0.0.1 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_enabled.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_enabled.golden new file mode 100644 index 0000000000..e00cad9333 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_and_IPv6,_ipv6_enabled.golden @@ -0,0 +1,5 @@ +nameserver 10.0.0.1 +nameserver fdb6:b8fe:b528::1 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_only.golden b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_only.golden new file mode 100644 index 0000000000..cdeaadf88c --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCTransformForLegacyNw/Routable_IPv4_only.golden @@ -0,0 +1,4 @@ +nameserver 10.0.0.1 + +# Based on host file: '/etc/resolv.conf' (legacy) +# Overrides: [] diff --git a/libnetwork/internal/resolvconf/testdata/TestRCUnknownDirectives.golden b/libnetwork/internal/resolvconf/testdata/TestRCUnknownDirectives.golden new file mode 100644 index 0000000000..4fc961c558 --- /dev/null +++ b/libnetwork/internal/resolvconf/testdata/TestRCUnknownDirectives.golden @@ -0,0 +1,8 @@ +nameserver 127.0.0.53 +options ndots:1 +something unexpected +unrecognised thing + +# Based on host file: '/etc/resolv.conf' +# Overrides: [] +# Option ndots from: host diff --git a/libnetwork/libnetwork_linux_test.go b/libnetwork/libnetwork_linux_test.go index a228a657c7..5b96962e4e 100644 --- a/libnetwork/libnetwork_linux_test.go +++ b/libnetwork/libnetwork_linux_test.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "sync" "testing" @@ -32,6 +33,8 @@ import ( "github.com/vishvananda/netlink" "github.com/vishvananda/netns" "golang.org/x/sync/errgroup" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" ) const ( @@ -1278,6 +1281,28 @@ func makeTesthostNetwork(t *testing.T, c *libnetwork.Controller) *libnetwork.Net return n } +func makeTestIPv6Network(t *testing.T, c *libnetwork.Controller) *libnetwork.Network { + t.Helper() + netOptions := options.Generic{ + netlabel.EnableIPv6: true, + netlabel.GenericData: options.Generic{ + "BridgeName": "testnetwork", + }, + } + ipamV6ConfList := []*libnetwork.IpamConf{ + {PreferredPool: "fd81:fb6e:38ba:abcd::/64", Gateway: "fd81:fb6e:38ba:abcd::9"}, + } + n, err := createTestNetwork(c, + "bridge", + "testnetwork", + netOptions, + nil, + ipamV6ConfList, + ) + assert.NilError(t, err) + return n +} + func TestHost(t *testing.T) { defer netnsutils.SetupTestOSContext(t)() controller := newController(t) @@ -1790,295 +1815,92 @@ func reexecSetKey(key string, containerID string, controllerID string) error { return cmd.Run() } -func TestEnableIPv6(t *testing.T) { - defer netnsutils.SetupTestOSContext(t)() - controller := newController(t) - - tmpResolvConf := []byte("search pommesfrites.fr\nnameserver 12.34.56.78\nnameserver 2001:4860:4860::8888\n") - expectedResolvConf := []byte("search pommesfrites.fr\nnameserver 127.0.0.11\nnameserver 2001:4860:4860::8888\noptions ndots:0\n") - // take a copy of resolv.conf for restoring after test completes - resolvConfSystem, err := os.ReadFile("/etc/resolv.conf") - if err != nil { - t.Fatal(err) - } - // cleanup - defer func() { - if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil { - t.Fatal(err) - } - }() - - netOption := options.Generic{ - netlabel.EnableIPv6: true, - netlabel.GenericData: options.Generic{ - "BridgeName": "testnetwork", - }, - } - ipamV6ConfList := []*libnetwork.IpamConf{{PreferredPool: "fe99::/64", Gateway: "fe99::9"}} - - n, err := createTestNetwork(controller, "bridge", "testnetwork", netOption, nil, ipamV6ConfList) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := n.Delete(); err != nil { - t.Fatal(err) - } - }() - - ep1, err := n.CreateEndpoint("ep1") - if err != nil { - t.Fatal(err) - } - - if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf, 0o644); err != nil { - t.Fatal(err) - } - - resolvConfPath := "/tmp/libnetwork_test/resolv.conf" - defer os.Remove(resolvConfPath) - - sb, err := controller.NewSandbox(containerID, libnetwork.OptionResolvConfPath(resolvConfPath)) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := sb.Delete(); err != nil { - t.Fatal(err) - } - }() - - err = ep1.Join(sb) - if err != nil { - t.Fatal(err) - } - - content, err := os.ReadFile(resolvConfPath) - if err != nil { - t.Fatal(err) - } - - if !bytes.Equal(content, expectedResolvConf) { - t.Fatalf("Expected:\n%s\nGot:\n%s", string(expectedResolvConf), string(content)) - } - - if err != nil { - t.Fatal(err) - } -} - -func TestResolvConfHost(t *testing.T) { - defer netnsutils.SetupTestOSContext(t)() - controller := newController(t) - - tmpResolvConf := []byte("search localhost.net\nnameserver 127.0.0.1\nnameserver 2001:4860:4860::8888\n") - - // take a copy of resolv.conf for restoring after test completes - resolvConfSystem, err := os.ReadFile("/etc/resolv.conf") - if err != nil { - t.Fatal(err) - } - // cleanup - defer func() { - if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil { - t.Fatal(err) - } - }() - - n := makeTesthostNetwork(t, controller) - ep1, err := n.CreateEndpoint("ep1", libnetwork.CreateOptionDisableResolution()) - if err != nil { - t.Fatal(err) - } - - if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf, 0o644); err != nil { - t.Fatal(err) - } - - resolvConfPath := "/tmp/libnetwork_test/resolv.conf" - defer os.Remove(resolvConfPath) - - sb, err := controller.NewSandbox(containerID, - libnetwork.OptionUseDefaultSandbox(), - libnetwork.OptionResolvConfPath(resolvConfPath), - libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf")) - if err != nil { - t.Fatal(err) - } - defer func() { - if err := sb.Delete(); err != nil { - t.Fatal(err) - } - }() - - err = ep1.Join(sb) - if err != nil { - t.Fatal(err) - } - defer func() { - err = ep1.Leave(sb) - if err != nil { - t.Fatal(err) - } - }() - - finfo, err := os.Stat(resolvConfPath) - if err != nil { - t.Fatal(err) - } - - fmode := (os.FileMode)(0o644) - if finfo.Mode() != fmode { - t.Fatalf("Expected file mode %s, got %s", fmode.String(), finfo.Mode().String()) - } - - content, err := os.ReadFile(resolvConfPath) - if err != nil { - t.Fatal(err) - } - - if !bytes.Equal(content, tmpResolvConf) { - t.Fatalf("Expected:\n%s\nGot:\n%s", string(tmpResolvConf), string(content)) - } -} - func TestResolvConf(t *testing.T) { - defer netnsutils.SetupTestOSContext(t)() - controller := newController(t) + tmpDir := t.TempDir() + originResolvConfPath := filepath.Join(tmpDir, "origin_resolv.conf") + resolvConfPath := filepath.Join(tmpDir, "resolv.conf") - 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") + // 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`) - // 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", + 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) - } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + defer netnsutils.SetupTestOSContext(t)() + c := newController(t) - if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf1, 0o644); err != nil { - t.Fatal(err) - } + err := os.WriteFile(originResolvConfPath, []byte(tc.originResolvConf), 0o644) + assert.NilError(t, err) - resolvConfPath := "/tmp/libnetwork_test/resolv.conf" - defer os.Remove(resolvConfPath) + n := tc.makeNet(t, c) + if tc.delNet { + defer func() { + err := n.Delete() + assert.Check(t, err) + }() + } - 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) - } - }() + 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) + }() - err = ep.Join(sb1) - if err != nil { - t.Fatal(err) - } + ep, err := n.CreateEndpoint("ep", tc.epOpts...) + assert.NilError(t, err) + defer func() { + err := ep.Delete(false) + assert.Check(t, err) + }() - finfo, err := os.Stat(resolvConfPath) - if err != nil { - t.Fatal(err) - } + err = ep.Join(sb) + assert.NilError(t, err) + defer func() { + err := ep.Leave(sb) + assert.Check(t, 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) - } - - 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, tmpResolvConf3) { - t.Fatalf("Expected:\n%s\nGot:\n%s", string(tmpResolvConf3), string(content)) + finfo, err := os.Stat(resolvConfPath) + assert.NilError(t, err) + expFMode := (os.FileMode)(0o644) + assert.Check(t, is.Equal(finfo.Mode().String(), expFMode.String())) + content, err := os.ReadFile(resolvConfPath) + assert.NilError(t, err) + actual := stripCommentsRE.ReplaceAllString(string(content), "") + actual = strings.TrimSpace(actual) + assert.Check(t, is.Equal(actual, tc.expResolvConf)) + }) } } diff --git a/libnetwork/resolvconf/resolvconf.go b/libnetwork/resolvconf/resolvconf.go index da20e1c031..c3473872b1 100644 --- a/libnetwork/resolvconf/resolvconf.go +++ b/libnetwork/resolvconf/resolvconf.go @@ -3,20 +3,12 @@ package resolvconf import ( "bytes" - "context" + "fmt" "os" - "regexp" "strings" - "sync" - "github.com/containerd/log" -) - -const ( - // defaultPath is the default path to the resolv.conf that contains information to resolve DNS. See Path(). - defaultPath = "/etc/resolv.conf" - // alternatePath is a path different from defaultPath, that may be used to resolve DNS. See Path(). - alternatePath = "/run/systemd/resolve/resolv.conf" + "github.com/docker/docker/libnetwork/internal/resolvconf" + "github.com/opencontainers/go-digest" ) // constants for the IP address type @@ -26,72 +18,16 @@ const ( IPv6 ) -var ( - detectSystemdResolvConfOnce sync.Once - pathAfterSystemdDetection = defaultPath -) - -// Path returns the path to the resolv.conf file that libnetwork should use. -// -// When /etc/resolv.conf contains 127.0.0.53 as the only nameserver, then -// it is assumed systemd-resolved manages DNS. Because inside the container 127.0.0.53 -// is not a valid DNS server, Path() returns /run/systemd/resolve/resolv.conf -// which is the resolv.conf that systemd-resolved generates and manages. -// Otherwise Path() returns /etc/resolv.conf. -// -// Errors are silenced as they will inevitably resurface at future open/read calls. -// -// More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf -func Path() string { - detectSystemdResolvConfOnce.Do(func() { - candidateResolvConf, err := os.ReadFile(defaultPath) - if err != nil { - // silencing error as it will resurface at next calls trying to read defaultPath - return - } - ns := GetNameservers(candidateResolvConf, IP) - if len(ns) == 1 && ns[0] == "127.0.0.53" { - pathAfterSystemdDetection = alternatePath - log.G(context.TODO()).Infof("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath) - } - }) - return pathAfterSystemdDetection -} - -const ( - // ipLocalhost is a regex pattern for IPv4 or IPv6 loopback range. - ipLocalhost = `((127\.([0-9]{1,3}\.){2}[0-9]{1,3})|(::1)$)` - ipv4NumBlock = `(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)` - ipv4Address = `(` + ipv4NumBlock + `\.){3}` + ipv4NumBlock - - // This is not an IPv6 address verifier as it will accept a super-set of IPv6, and also - // will *not match* IPv4-Embedded IPv6 Addresses (RFC6052), but that and other variants - // -- e.g. other link-local types -- either won't work in containers or are unnecessary. - // For readability and sufficiency for Docker purposes this seemed more reasonable than a - // 1000+ character regexp with exact and complete IPv6 validation - ipv6Address = `([0-9A-Fa-f]{0,4}:){2,7}([0-9A-Fa-f]{0,4})(%\w+)?` -) - -var ( - // Note: the default IPv4 & IPv6 resolvers are set to Google's Public DNS - defaultIPv4Dns = []string{"nameserver 8.8.8.8", "nameserver 8.8.4.4"} - defaultIPv6Dns = []string{"nameserver 2001:4860:4860::8888", "nameserver 2001:4860:4860::8844"} - - localhostNSRegexp = regexp.MustCompile(`(?m)^nameserver\s+` + ipLocalhost + `\s*\n*`) - nsIPv6Regexp = regexp.MustCompile(`(?m)^nameserver\s+` + ipv6Address + `\s*\n*`) - nsRegexp = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `)|(` + ipv6Address + `))\s*$`) - nsIPv6Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv6Address + `))\s*$`) - nsIPv4Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `))\s*$`) - searchRegexp = regexp.MustCompile(`^\s*search\s*(([^\s]+\s*)*)$`) - optionsRegexp = regexp.MustCompile(`^\s*options\s*(([^\s]+\s*)*)$`) -) - // File contains the resolv.conf content and its hash type File struct { Content []byte Hash []byte } +func Path() string { + return resolvconf.Path() +} + // Get returns the contents of /etc/resolv.conf and its hash func Get() (*File, error) { return GetSpecific(Path()) @@ -103,7 +39,8 @@ func GetSpecific(path string) (*File, error) { if err != nil { return nil, err } - return &File{Content: resolv, Hash: hashData(resolv)}, nil + hash := digest.FromBytes(resolv) + return &File{Content: resolv, Hash: []byte(hash)}, nil } // FilterResolvDNS cleans up the config in resolvConf. It has two main jobs: @@ -113,54 +50,34 @@ func GetSpecific(path string) (*File, error) { // 2. Given the caller provides the enable/disable state of IPv6, the filter // code will remove all IPv6 nameservers if it is not enabled for containers func FilterResolvDNS(resolvConf []byte, ipv6Enabled bool) (*File, error) { - cleanedResolvConf := localhostNSRegexp.ReplaceAll(resolvConf, []byte{}) - // if IPv6 is not enabled, also clean out any IPv6 address nameserver - if !ipv6Enabled { - cleanedResolvConf = nsIPv6Regexp.ReplaceAll(cleanedResolvConf, []byte{}) + rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "") + if err != nil { + return nil, err } - // 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.TransformForLegacyNw(ipv6Enabled) + content, err := rc.Generate(false) + 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]) - } - } - return output + hash := digest.FromBytes(content) + return &File{Content: content, Hash: []byte(hash)}, nil } // GetNameservers returns nameservers (if any) listed in /etc/resolv.conf func GetNameservers(resolvConf []byte, kind int) []string { + rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "") + if err != nil { + return nil + } + nsAddrs := rc.NameServers() var nameservers []string - for _, line := range getLines(resolvConf, []byte("#")) { - var ns [][]byte + for _, addr := range nsAddrs { if kind == IP { - ns = nsRegexp.FindSubmatch(line) - } else if kind == IPv4 { - ns = nsIPv4Regexpmatch.FindSubmatch(line) - } else if kind == IPv6 { - ns = nsIPv6Regexpmatch.FindSubmatch(line) - } - if len(ns) > 0 { - nameservers = append(nameservers, string(ns[1])) + nameservers = append(nameservers, addr.String()) + } else if kind == IPv4 && addr.Is4() { + nameservers = append(nameservers, addr.String()) + } else if kind == IPv6 && addr.Is6() { + nameservers = append(nameservers, addr.String()) } } return nameservers @@ -170,16 +87,15 @@ func GetNameservers(resolvConf []byte, kind int) []string { // /etc/resolv.conf as CIDR blocks (e.g., "1.2.3.4/32") // This function's output is intended for net.ParseCIDR func GetNameserversAsCIDR(resolvConf []byte) []string { - var nameservers []string - for _, nameserver := range GetNameservers(resolvConf, IP) { - var address string - // If IPv6, strip zone if present - if strings.Contains(nameserver, ":") { - address = strings.Split(nameserver, "%")[0] + "/128" - } else { - address = nameserver + "/32" - } - nameservers = append(nameservers, address) + rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "") + if err != nil { + return nil + } + nsAddrs := rc.NameServers() + nameservers := make([]string, 0, len(nsAddrs)) + for _, addr := range nsAddrs { + str := fmt.Sprintf("%s/%d", addr.WithZone("").String(), addr.BitLen()) + nameservers = append(nameservers, str) } return nameservers } @@ -188,36 +104,30 @@ func GetNameserversAsCIDR(resolvConf []byte) []string { // If more than one search line is encountered, only the contents of the last // one is returned. func GetSearchDomains(resolvConf []byte) []string { - var domains []string - for _, line := range getLines(resolvConf, []byte("#")) { - match := searchRegexp.FindSubmatch(line) - if match == nil { - continue - } - domains = strings.Fields(string(match[1])) + rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "") + if err != nil { + return nil } - return domains + return rc.Search() } // GetOptions returns options (if any) listed in /etc/resolv.conf // If more than one options line is encountered, only the contents of the last // one is returned. func GetOptions(resolvConf []byte) []string { - var options []string - for _, line := range getLines(resolvConf, []byte("#")) { - match := optionsRegexp.FindSubmatch(line) - if match == nil { - continue - } - options = strings.Fields(string(match[1])) + rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "") + if err != nil { + return nil } - return options + return rc.Options() } // Build generates and writes a configuration file to path containing a nameserver // entry for every element in nameservers, a "search" entry for every element in // dnsSearch, and an "options" entry for every element in dnsOptions. It returns // a File containing the generated content and its (sha256) hash. +// +// Note that the resolv.conf file is written, but the hash file is not. func Build(path string, nameservers, dnsSearch, dnsOptions []string) (*File, error) { content := bytes.NewBuffer(nil) if len(dnsSearch) > 0 { @@ -244,5 +154,6 @@ func Build(path string, nameservers, dnsSearch, dnsOptions []string) (*File, err return nil, err } - return &File{Content: content.Bytes(), Hash: hashData(content.Bytes())}, nil + hash := digest.FromBytes(content.Bytes()) + return &File{Content: content.Bytes(), Hash: []byte(hash)}, nil } diff --git a/libnetwork/resolvconf/resolvconf_unix_test.go b/libnetwork/resolvconf/resolvconf_unix_test.go index bd37de4833..6fcbe34381 100644 --- a/libnetwork/resolvconf/resolvconf_unix_test.go +++ b/libnetwork/resolvconf/resolvconf_unix_test.go @@ -5,7 +5,12 @@ package resolvconf import ( "bytes" "os" + "strings" "testing" + + "github.com/opencontainers/go-digest" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" ) func TestGet(t *testing.T) { @@ -20,7 +25,8 @@ func TestGet(t *testing.T) { if !bytes.Equal(actual.Content, expected) { t.Errorf("%s and GetResolvConf have different content.", Path()) } - if !bytes.Equal(actual.Hash, hashData(expected)) { + hash := digest.FromBytes(expected) + if !bytes.Equal(actual.Hash, []byte(hash)) { t.Errorf("%s and GetResolvConf have different hashes.", Path()) } } @@ -111,6 +117,14 @@ nameserver 1.2.3.4 nameserver 1.2.3.4 # not 4.3.2.1`, result: []string{"1.2.3.4/32"}, }, + { + input: `nameserver fd6f:c490:ec68::1`, + result: []string{"fd6f:c490:ec68::1/128"}, + }, + { + input: `nameserver fe80::1234%eth0`, + result: []string{"fe80::1234/128"}, + }, } { test := GetNameserversAsCIDR([]byte(tc.input)) if !strSlicesEqual(test, tc.result) { @@ -175,6 +189,10 @@ search foo.example.com example.com nameserver 4.30.20.100`, result: []string{"foo.example.com", "example.com"}, }, + { + input: `domain an.example`, + result: []string{"an.example"}, + }, } { test := GetSearchDomains([]byte(tc.input)) if !strSlicesEqual(test, tc.result) { @@ -338,89 +356,79 @@ func TestBuildWithNoOptions(t *testing.T) { } func TestFilterResolvDNS(t *testing.T) { - ns0 := "nameserver 10.16.60.14\nnameserver 10.16.60.21\n" - - if result, _ := FilterResolvDNS([]byte(ns0), false); result != nil { - if ns0 != string(result.Content) { - t.Errorf("Failed No Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content)) - } + 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", + }, } - 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)) - } - } - - // with IPv6 disabled, and no non-localhost servers, Google defaults (only IPv4) should be added - ns0 = "\nnameserver 8.8.8.8\nnameserver 8.8.4.4" - ns1 = "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1" - if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil { - if ns0 != string(result.Content) { - t.Errorf("Failed no Localhost+IPv6 enabled: expected \n<%s> got \n<%s>", ns0, string(result.Content)) - } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + f, err := FilterResolvDNS([]byte(tc.input), tc.ipv6Enabled) + assert.Check(t, is.Nil(err)) + out := strings.TrimSpace(string(f.Content)) + assert.Check(t, is.Equal(out, tc.expOut)) + }) } } diff --git a/libnetwork/resolvconf/utils.go b/libnetwork/resolvconf/utils.go deleted file mode 100644 index 8e005e2a19..0000000000 --- a/libnetwork/resolvconf/utils.go +++ /dev/null @@ -1,14 +0,0 @@ -package resolvconf - -import ( - "crypto/sha256" - "encoding/hex" -) - -// hashData returns the sha256 sum of data. -func hashData(data []byte) []byte { - f := sha256.Sum256(data) - out := make([]byte, 2*sha256.Size) - hex.Encode(out, f[:]) - return append([]byte("sha256:"), out...) -} diff --git a/libnetwork/resolvconf/utils_test.go b/libnetwork/resolvconf/utils_test.go deleted file mode 100644 index 852ae4c52e..0000000000 --- a/libnetwork/resolvconf/utils_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package resolvconf - -import ( - "bytes" - "testing" -) - -func TestHashData(t *testing.T) { - const expected = "sha256:4d11186aed035cc624d553e10db358492c84a7cd6b9670d92123c144930450aa" - if actual := hashData([]byte("hash-me")); !bytes.Equal(actual, []byte(expected)) { - t.Fatalf("Expecting %s, got %s", expected, string(actual)) - } -} - -func BenchmarkHashData(b *testing.B) { - b.ReportAllocs() - data := []byte("hash-me") - for i := 0; i < b.N; i++ { - _ = hashData(data) - } -} diff --git a/libnetwork/sandbox_dns_unix.go b/libnetwork/sandbox_dns_unix.go index 40f1a7b46f..a7c857d582 100644 --- a/libnetwork/sandbox_dns_unix.go +++ b/libnetwork/sandbox_dns_unix.go @@ -3,22 +3,19 @@ package libnetwork import ( - "bytes" "context" - "fmt" - "net" + "io/fs" "net/netip" "os" - "path" "path/filepath" - "strconv" "strings" "github.com/containerd/log" "github.com/docker/docker/errdefs" "github.com/docker/docker/libnetwork/etchosts" - "github.com/docker/docker/libnetwork/resolvconf" + "github.com/docker/docker/libnetwork/internal/resolvconf" "github.com/docker/docker/libnetwork/types" + "github.com/pkg/errors" ) const ( @@ -100,6 +97,13 @@ func (sb *Sandbox) setupResolutionFiles() error { } func (sb *Sandbox) buildHostsFile() error { + sb.restoreHostsPath() + + dir, _ := filepath.Split(sb.config.hostsPath) + if err := createBasePath(dir); err != nil { + return err + } + // This is for the host mode networking if sb.config.useDefaultSandBox && len(sb.config.extraHosts) == 0 { // We are working under the assumption that the origin file option had been properly expressed by the upper layer @@ -208,276 +212,171 @@ func (sb *Sandbox) updateParentHosts() error { return nil } -func (sb *Sandbox) restorePath() { +func (sb *Sandbox) restoreResolvConfPath() { if sb.config.resolvConfPath == "" { sb.config.resolvConfPath = defaultPrefix + "/" + sb.id + "/resolv.conf" } sb.config.resolvConfHashFile = sb.config.resolvConfPath + ".hash" +} + +func (sb *Sandbox) restoreHostsPath() { if sb.config.hostsPath == "" { sb.config.hostsPath = defaultPrefix + "/" + sb.id + "/hosts" } } -func (sb *Sandbox) setExternalResolvers(content []byte, addrType int, checkLoopback bool) { - servers := resolvconf.GetNameservers(content, addrType) - for _, ip := range servers { - hostLoopback := false - if checkLoopback && isIPv4Loopback(ip) { - hostLoopback = true - } +func (sb *Sandbox) setExternalResolvers(entries []resolvconf.ExtDNSEntry) { + sb.extDNS = make([]extDNSEntry, 0, len(entries)) + for _, entry := range entries { sb.extDNS = append(sb.extDNS, extDNSEntry{ - IPStr: ip, - HostLoopback: hostLoopback, + IPStr: entry.Addr.String(), + HostLoopback: entry.HostLoopback, }) } } -// isIPv4Loopback checks if the given IP address is an IPv4 loopback address. -// It's based on the logic in Go's net.IP.IsLoopback(), but only the IPv4 part: -// https://github.com/golang/go/blob/go1.16.6/src/net/ip.go#L120-L126 -func isIPv4Loopback(ipAddress string) bool { - if ip := net.ParseIP(ipAddress); ip != nil { - if ip4 := ip.To4(); ip4 != nil { - return ip4[0] == 127 - } +func (c *containerConfig) getOriginResolvConfPath() string { + if c.originResolvConfPath != "" { + return c.originResolvConfPath } - return false + // Fallback if not specified. + return resolvconf.Path() } -func (sb *Sandbox) setupDNS() error { - if sb.config.resolvConfPath == "" { - sb.config.resolvConfPath = defaultPrefix + "/" + sb.id + "/resolv.conf" +// loadResolvConf reads the resolv.conf file at path, and merges in overrides for +// nameservers, options, and search domains. +func (sb *Sandbox) loadResolvConf(path string) (*resolvconf.ResolvConf, error) { + rc, err := resolvconf.Load(path) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err } + // Proceed with rc, which might be zero-valued if path does not exist. - sb.config.resolvConfHashFile = sb.config.resolvConfPath + ".hash" + 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 +} +// 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") + return err } - - 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 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 - } - - var currHash []byte - currRC, err := resolvconf.GetSpecific(sb.config.resolvConfPath) - 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 - } - } - - 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 - } - - // 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 { + 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 + // 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) } - _, err = tmpHashFile.Write(newRC.Hash) - if err1 := tmpHashFile.Close(); err == nil { - err = err1 - } - if err != nil { - return err - } - return os.Rename(tmpHashFile.Name(), sb.config.resolvConfHashFile) + return rc.WriteFile(sb.config.resolvConfPath, sb.config.resolvConfHashFile, filePerm) } -// 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 +// Embedded DNS server has to be enabled for this sandbox. Rebuild the container's resolv.conf. func (sb *Sandbox) rebuildDNS() error { - currRC, err := os.ReadFile(sb.config.resolvConfPath) + // 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 + } + + // Load the host's resolv.conf as a starting point. + rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath()) 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) - } - } - } + // 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 } } - 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) + intNS, err := netip.ParseAddr(sb.resolver.NameServer()) + if err != nil { + return err } - 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) - ) + // 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 + } + // Extract the list of nameservers that just got swapped out, and store them as + // upstream nameservers. + sb.setExternalResolvers(extNameServers) - _, err = resolvconf.Build(sb.config.resolvConfPath, dnsList, dnsSearchList, dnsOptionsList) - return err + // Write the file for the container - preserving old behaviour, not updating the + // hash file (so, no further updates will be made). + // TODO(robmry) - I think that's probably accidental, I can't find a reason for it, + // and the old resolvconf.Build() function wrote the file but not the hash, which + // is surprising. But, before fixing it, a guard/flag needs to be added to + // sb.updateDNS() to make sure that when an endpoint joins a sandbox that already + // has an internal resolver, the container's resolv.conf is still (re)configured + // for an internal resolver. + return rc.WriteFile(sb.config.resolvConfPath, "", filePerm) } func createBasePath(dir string) error { return os.MkdirAll(dir, dirPerm) } -func createFile(path string) error { - var f *os.File - - dir, _ := filepath.Split(path) - err := createBasePath(dir) - if err != nil { - return err - } - - f, err = os.Create(path) - if err == nil { - f.Close() - } - - return err -} - func copyFile(src, dst string) error { sBytes, err := os.ReadFile(src) if err != nil { diff --git a/libnetwork/sandbox_dns_windows.go b/libnetwork/sandbox_dns_windows.go index 923316ae1d..a8fbbca9a1 100644 --- a/libnetwork/sandbox_dns_windows.go +++ b/libnetwork/sandbox_dns_windows.go @@ -12,7 +12,9 @@ func (sb *Sandbox) setupResolutionFiles() error { return nil } -func (sb *Sandbox) restorePath() {} +func (sb *Sandbox) restoreHostsPath() {} + +func (sb *Sandbox) restoreResolvConfPath() {} func (sb *Sandbox) updateHostsFile(ifaceIP []string) error { return nil diff --git a/libnetwork/sandbox_store.go b/libnetwork/sandbox_store.go index 4993481c3d..4500eb34c3 100644 --- a/libnetwork/sandbox_store.go +++ b/libnetwork/sandbox_store.go @@ -206,7 +206,8 @@ func (c *Controller) sandboxCleanup(activeSandboxes map[string]interface{}) erro isRestore = true opts := val.([]SandboxOption) sb.processOptions(opts...) - sb.restorePath() + sb.restoreHostsPath() + sb.restoreResolvConfPath() create = !sb.config.useDefaultSandBox } sb.osSbox, err = osl.NewSandbox(sb.Key(), create, isRestore)