Browse code

Refactor 'resolv.conf' generation.

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

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

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

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

Signed-off-by: Rob Murray <rob.murray@docker.com>

Rob Murray authored on 2024/01/03 18:10:51
Showing 50 changed files
... ...
@@ -419,6 +419,7 @@ func serviceDiscoveryOnDefaultNetwork() bool {
419 419
 
420 420
 func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Config, sboxOptions *[]libnetwork.SandboxOption) error {
421 421
 	var err error
422
+	var originResolvConfPath string
422 423
 
423 424
 	// Set the correct paths for /etc/hosts and /etc/resolv.conf, based on the
424 425
 	// networking-mode of the container. Note that containers with "container"
... ...
@@ -432,8 +433,8 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con
432 432
 		*sboxOptions = append(
433 433
 			*sboxOptions,
434 434
 			libnetwork.OptionOriginHostsPath("/etc/hosts"),
435
-			libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"),
436 435
 		)
436
+		originResolvConfPath = "/etc/resolv.conf"
437 437
 	case container.HostConfig.NetworkMode.IsUserDefined():
438 438
 		// The container uses a user-defined network. We use the embedded DNS
439 439
 		// server for container name resolution and to act as a DNS forwarder
... ...
@@ -446,10 +447,7 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con
446 446
 		// If systemd-resolvd is used, the "upstream" DNS servers can be found in
447 447
 		// /run/systemd/resolve/resolv.conf. We do not query those DNS servers
448 448
 		// directly, as they can be dynamically reconfigured.
449
-		*sboxOptions = append(
450
-			*sboxOptions,
451
-			libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"),
452
-		)
449
+		originResolvConfPath = "/etc/resolv.conf"
453 450
 	default:
454 451
 		// For other situations, such as the default bridge network, container
455 452
 		// discovery / name resolution is handled through /etc/hosts, and no
... ...
@@ -462,11 +460,15 @@ func setupPathsAndSandboxOptions(container *container.Container, cfg *config.Con
462 462
 		// DNS servers on the host can be dynamically updated.
463 463
 		//
464 464
 		// Copy the host's resolv.conf for the container (/run/systemd/resolve/resolv.conf or /etc/resolv.conf)
465
-		*sboxOptions = append(
466
-			*sboxOptions,
467
-			libnetwork.OptionOriginResolvConfPath(cfg.GetResolvConf()),
468
-		)
465
+		originResolvConfPath = cfg.GetResolvConf()
466
+	}
467
+
468
+	// Allow tests to point at their own resolv.conf file.
469
+	if envPath := os.Getenv("DOCKER_TEST_RESOLV_CONF_PATH"); envPath != "" {
470
+		log.G(context.TODO()).Infof("Using OriginResolvConfPath from env: %s", envPath)
471
+		originResolvConfPath = envPath
469 472
 	}
473
+	*sboxOptions = append(*sboxOptions, libnetwork.OptionOriginResolvConfPath(originResolvConfPath))
470 474
 
471 475
 	container.HostsPath, err = container.GetRootResourcePath("hosts")
472 476
 	if err != nil {
... ...
@@ -1275,10 +1275,11 @@ func (s *DockerCLIRunSuite) TestRunDNSDefaultOptions(c *testing.T) {
1275 1275
 	}
1276 1276
 
1277 1277
 	actual := cli.DockerCmd(c, "run", "busybox", "cat", "/etc/resolv.conf").Combined()
1278
-	// check that the actual defaults are appended to the commented out
1279
-	// localhost resolver (which should be preserved)
1278
+	actual = regexp.MustCompile("(?m)^#.*$").ReplaceAllString(actual, "")
1279
+	actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ")
1280 1280
 	// NOTE: if we ever change the defaults from google dns, this will break
1281
-	expected := "#nameserver 127.0.2.1\n\nnameserver 8.8.8.8\nnameserver 8.8.4.4\n"
1281
+	expected := "nameserver 8.8.8.8 nameserver 8.8.4.4"
1282
+
1282 1283
 	if actual != expected {
1283 1284
 		c.Fatalf("expected resolv.conf be: %q, but was: %q", expected, actual)
1284 1285
 	}
... ...
@@ -1295,14 +1296,16 @@ func (s *DockerCLIRunSuite) TestRunDNSOptions(c *testing.T) {
1295 1295
 		c.Fatalf("Expected warning on stderr about localhost resolver, but got %q", result.Stderr())
1296 1296
 	}
1297 1297
 
1298
-	actual := strings.ReplaceAll(strings.Trim(result.Stdout(), "\r\n"), "\n", " ")
1299
-	if actual != "search mydomain nameserver 127.0.0.1 options ndots:9" {
1300
-		c.Fatalf("expected 'search mydomain nameserver 127.0.0.1 options ndots:9', but says: %q", actual)
1298
+	actual := regexp.MustCompile("(?m)^#.*$").ReplaceAllString(result.Stdout(), "")
1299
+	actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ")
1300
+	if actual != "nameserver 127.0.0.1 search mydomain options ndots:9" {
1301
+		c.Fatalf("nameserver 127.0.0.1 expected 'search mydomain options ndots:9', but says: %q", actual)
1301 1302
 	}
1302 1303
 
1303 1304
 	out := cli.DockerCmd(c, "run", "--dns=1.1.1.1", "--dns-search=.", "--dns-opt=ndots:3", "busybox", "cat", "/etc/resolv.conf").Combined()
1304 1305
 
1305
-	actual = strings.ReplaceAll(strings.Trim(strings.Trim(out, "\r\n"), " "), "\n", " ")
1306
+	actual = regexp.MustCompile("(?m)^#.*$").ReplaceAllString(out, "")
1307
+	actual = strings.ReplaceAll(strings.Trim(strings.Trim(actual, "\r\n"), " "), "\n", " ")
1306 1308
 	if actual != "nameserver 1.1.1.1 options ndots:3" {
1307 1309
 		c.Fatalf("expected 'nameserver 1.1.1.1 options ndots:3', but says: %q", actual)
1308 1310
 	}
... ...
@@ -1312,9 +1315,10 @@ func (s *DockerCLIRunSuite) TestRunDNSRepeatOptions(c *testing.T) {
1312 1312
 	testRequires(c, DaemonIsLinux)
1313 1313
 	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()
1314 1314
 
1315
-	actual := strings.ReplaceAll(strings.Trim(out, "\r\n"), "\n", " ")
1316
-	if actual != "search mydomain mydomain2 nameserver 1.1.1.1 nameserver 2.2.2.2 options ndots:9 timeout:3" {
1317
-		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)
1315
+	actual := regexp.MustCompile("(?m)^#.*$").ReplaceAllString(out, "")
1316
+	actual = strings.ReplaceAll(strings.Trim(actual, "\r\n"), "\n", " ")
1317
+	if actual != "nameserver 1.1.1.1 nameserver 2.2.2.2 search mydomain mydomain2 options ndots:9 timeout:3" {
1318
+		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)
1318 1319
 	}
1319 1320
 }
1320 1321
 
1321 1322
new file mode 100644
... ...
@@ -0,0 +1,72 @@
0
+package networking
1
+
2
+import (
3
+	"os"
4
+	"strings"
5
+	"testing"
6
+
7
+	containertypes "github.com/docker/docker/api/types/container"
8
+	"github.com/docker/docker/integration/internal/container"
9
+	"github.com/docker/docker/integration/internal/network"
10
+	"github.com/docker/docker/testutil/daemon"
11
+	"gotest.tools/v3/assert"
12
+	is "gotest.tools/v3/assert/cmp"
13
+	"gotest.tools/v3/skip"
14
+)
15
+
16
+// Regression test for https://github.com/moby/moby/issues/46968
17
+func TestResolvConfLocalhostIPv6(t *testing.T) {
18
+	// No "/etc/resolv.conf" on Windows.
19
+	skip.If(t, testEnv.DaemonInfo.OSType == "windows")
20
+
21
+	ctx := setupTest(t)
22
+
23
+	// Write a resolv.conf that only contains a loopback address.
24
+	// Not using t.TempDir() here because in rootless mode, while the temporary
25
+	// directory gets mode 0777, it's a subdir of an 0700 directory owned by root.
26
+	// So, it's not accessible by the daemon.
27
+	f, err := os.CreateTemp("", "resolv.conf")
28
+	assert.NilError(t, err)
29
+	defer os.Remove(f.Name())
30
+	err = f.Chmod(0644)
31
+	assert.NilError(t, err)
32
+	f.Write([]byte("nameserver 127.0.0.53\n"))
33
+
34
+	d := daemon.New(t, daemon.WithEnvVars("DOCKER_TEST_RESOLV_CONF_PATH="+f.Name()))
35
+	d.StartWithBusybox(ctx, t, "--experimental", "--ip6tables")
36
+	defer d.Stop(t)
37
+
38
+	c := d.NewClientT(t)
39
+	defer c.Close()
40
+
41
+	netName := "nnn"
42
+	network.CreateNoError(ctx, t, c, netName,
43
+		network.WithDriver("bridge"),
44
+		network.WithIPv6(),
45
+		network.WithIPAM("fd49:b5ef:36d9::/64", "fd49:b5ef:36d9::1"),
46
+	)
47
+	defer network.RemoveNoError(ctx, t, c, netName)
48
+
49
+	result := container.RunAttach(ctx, t, c,
50
+		container.WithImage("busybox:latest"),
51
+		container.WithNetworkMode(netName),
52
+		container.WithCmd("cat", "/etc/resolv.conf"),
53
+	)
54
+	defer c.ContainerRemove(ctx, result.ContainerID, containertypes.RemoveOptions{
55
+		Force: true,
56
+	})
57
+
58
+	output := strings.ReplaceAll(result.Stdout.String(), f.Name(), "RESOLV.CONF")
59
+	assert.Check(t, is.Equal(output, `# Generated by Docker Engine.
60
+# This file can be edited; Docker Engine will not make further changes once it
61
+# has been modified.
62
+
63
+nameserver 127.0.0.11
64
+options ndots:0
65
+
66
+# Based on host file: 'RESOLV.CONF' (internal resolver)
67
+# ExtServers: [host(127.0.0.53)]
68
+# Overrides: []
69
+# Option ndots from: internal
70
+`))
71
+}
0 72
new file mode 100644
... ...
@@ -0,0 +1,510 @@
0
+// Package resolvconf is used to generate a container's /etc/resolv.conf file.
1
+//
2
+// Constructor Load and Parse read a resolv.conf file from the filesystem or
3
+// a reader respectively, and return a ResolvConf object.
4
+//
5
+// The ResolvConf object can then be updated with overrides for nameserver,
6
+// search domains, and DNS options.
7
+//
8
+// ResolvConf can then be transformed to make it suitable for legacy networking,
9
+// a network with an internal nameserver, or used as-is for host networking.
10
+//
11
+// This package includes methods to write the file for the container, along with
12
+// a hash that can be used to detect modifications made by the user to avoid
13
+// overwriting those updates.
14
+package resolvconf
15
+
16
+import (
17
+	"bufio"
18
+	"bytes"
19
+	"context"
20
+	_ "embed"
21
+	"fmt"
22
+	"io"
23
+	"io/fs"
24
+	"net/netip"
25
+	"os"
26
+	"slices"
27
+	"strconv"
28
+	"strings"
29
+	"text/template"
30
+
31
+	"github.com/containerd/log"
32
+	"github.com/docker/docker/errdefs"
33
+	"github.com/docker/docker/pkg/ioutils"
34
+	"github.com/opencontainers/go-digest"
35
+	"github.com/pkg/errors"
36
+)
37
+
38
+// Fallback nameservers, to use if none can be obtained from the host or command
39
+// line options.
40
+var (
41
+	defaultIPv4NSs = []netip.Addr{
42
+		netip.MustParseAddr("8.8.8.8"),
43
+		netip.MustParseAddr("8.8.4.4"),
44
+	}
45
+	defaultIPv6NSs = []netip.Addr{
46
+		netip.MustParseAddr("2001:4860:4860::8888"),
47
+		netip.MustParseAddr("2001:4860:4860::8844"),
48
+	}
49
+)
50
+
51
+// ResolvConf represents a resolv.conf file. It can be constructed by
52
+// reading a resolv.conf file, using method Parse().
53
+type ResolvConf struct {
54
+	nameServers []netip.Addr
55
+	search      []string
56
+	options     []string
57
+	other       []string // Unrecognised directives from the host's file, if any.
58
+
59
+	md metadata
60
+}
61
+
62
+// ExtDNSEntry represents a nameserver address that was removed from the
63
+// container's resolv.conf when it was transformed by TransformForIntNS(). These
64
+// are addresses read from the host's file, or applied via an override ('--dns').
65
+type ExtDNSEntry struct {
66
+	Addr         netip.Addr
67
+	HostLoopback bool // The address is loopback, in the host's namespace.
68
+}
69
+
70
+func (ed ExtDNSEntry) String() string {
71
+	if ed.HostLoopback {
72
+		return fmt.Sprintf("host(%s)", ed.Addr)
73
+	}
74
+	return ed.Addr.String()
75
+}
76
+
77
+// metadata is used to track where components of the generated file have come
78
+// from, in order to generate comments in the file for debug/info. Struct members
79
+// are exported for use by 'text/template'.
80
+type metadata struct {
81
+	SourcePath      string
82
+	Header          string
83
+	NSOverride      bool
84
+	SearchOverride  bool
85
+	OptionsOverride bool
86
+	NDotsFrom       string
87
+	UsedDefaultNS   bool
88
+	Transform       string
89
+	InvalidNSs      []string
90
+	ExtNameServers  []ExtDNSEntry
91
+}
92
+
93
+// Load opens a file at path and parses it as a resolv.conf file.
94
+// On error, the returned ResolvConf will be zero-valued.
95
+func Load(path string) (ResolvConf, error) {
96
+	f, err := os.Open(path)
97
+	if err != nil {
98
+		return ResolvConf{}, err
99
+	}
100
+	defer f.Close()
101
+	return Parse(f, path)
102
+}
103
+
104
+// Parse parses a resolv.conf file from reader.
105
+// path is optional if reader is an *os.File.
106
+// On error, the returned ResolvConf will be zero-valued.
107
+func Parse(reader io.Reader, path string) (ResolvConf, error) {
108
+	var rc ResolvConf
109
+	rc.md.SourcePath = path
110
+	if path == "" {
111
+		if namer, ok := reader.(interface{ Name() string }); ok {
112
+			rc.md.SourcePath = namer.Name()
113
+		}
114
+	}
115
+
116
+	scanner := bufio.NewScanner(reader)
117
+	for scanner.Scan() {
118
+		rc.processLine(scanner.Text())
119
+	}
120
+	if err := scanner.Err(); err != nil {
121
+		return ResolvConf{}, errdefs.System(err)
122
+	}
123
+	if _, ok := rc.Option("ndots"); ok {
124
+		rc.md.NDotsFrom = "host"
125
+	}
126
+	return rc, nil
127
+}
128
+
129
+// SetHeader sets the content to be included verbatim at the top of the
130
+// generated resolv.conf file. No formatting or checking is done on the
131
+// string. It must be valid resolv.conf syntax. (Comments must have '#'
132
+// or ';' in the first column of each line).
133
+//
134
+// For example:
135
+//
136
+//	SetHeader("# My resolv.conf\n# This file was generated.")
137
+func (rc *ResolvConf) SetHeader(c string) {
138
+	rc.md.Header = c
139
+}
140
+
141
+// NameServers returns addresses used in nameserver directives.
142
+func (rc *ResolvConf) NameServers() []netip.Addr {
143
+	return slices.Clone(rc.nameServers)
144
+}
145
+
146
+// OverrideNameServers replaces the current set of nameservers.
147
+func (rc *ResolvConf) OverrideNameServers(nameServers []netip.Addr) {
148
+	rc.nameServers = nameServers
149
+	rc.md.NSOverride = true
150
+}
151
+
152
+// Search returns the current DNS search domains.
153
+func (rc *ResolvConf) Search() []string {
154
+	return slices.Clone(rc.search)
155
+}
156
+
157
+// OverrideSearch replaces the current DNS search domains.
158
+func (rc *ResolvConf) OverrideSearch(search []string) {
159
+	var filtered []string
160
+	for _, s := range search {
161
+		if s != "." {
162
+			filtered = append(filtered, s)
163
+		}
164
+	}
165
+	rc.search = filtered
166
+	rc.md.SearchOverride = true
167
+}
168
+
169
+// Options returns the current options.
170
+func (rc *ResolvConf) Options() []string {
171
+	return slices.Clone(rc.options)
172
+}
173
+
174
+// Option finds the last option named search, and returns (value, true) if
175
+// found, else ("", false). Options are treated as "name:value", where the
176
+// ":value" may be omitted.
177
+//
178
+// For example, for "ndots:1 edns0":
179
+//
180
+//	Option("ndots") -> ("1", true)
181
+//	Option("edns0") -> ("", true)
182
+func (rc *ResolvConf) Option(search string) (string, bool) {
183
+	for i := len(rc.options) - 1; i >= 0; i -= 1 {
184
+		k, v, _ := strings.Cut(rc.options[i], ":")
185
+		if k == search {
186
+			return v, true
187
+		}
188
+	}
189
+	return "", false
190
+}
191
+
192
+// OverrideOptions replaces the current DNS options.
193
+func (rc *ResolvConf) OverrideOptions(options []string) {
194
+	rc.options = slices.Clone(options)
195
+	rc.md.NDotsFrom = ""
196
+	if _, exists := rc.Option("ndots"); exists {
197
+		rc.md.NDotsFrom = "override"
198
+	}
199
+	rc.md.OptionsOverride = true
200
+}
201
+
202
+// AddOption adds a single DNS option.
203
+func (rc *ResolvConf) AddOption(option string) {
204
+	if len(option) > 6 && option[:6] == "ndots:" {
205
+		rc.md.NDotsFrom = "internal"
206
+	}
207
+	rc.options = append(rc.options, option)
208
+}
209
+
210
+// TransformForLegacyNw makes sure the resolv.conf file will be suitable for
211
+// use in a legacy network (one that has no internal resolver).
212
+//   - Remove loopback addresses inherited from the host's resolv.conf, because
213
+//     they'll only work in the host's namespace.
214
+//   - Remove IPv6 addresses if !ipv6.
215
+//   - Add default nameservers if there are no addresses left.
216
+func (rc *ResolvConf) TransformForLegacyNw(ipv6 bool) {
217
+	rc.md.Transform = "legacy"
218
+	if rc.md.NSOverride {
219
+		return
220
+	}
221
+	var filtered []netip.Addr
222
+	for _, addr := range rc.nameServers {
223
+		if !addr.IsLoopback() && (!addr.Is6() || ipv6) {
224
+			filtered = append(filtered, addr)
225
+		}
226
+	}
227
+	rc.nameServers = filtered
228
+	if len(rc.nameServers) == 0 {
229
+		log.G(context.TODO()).Info("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers")
230
+		rc.nameServers = defaultNSAddrs(ipv6)
231
+		rc.md.UsedDefaultNS = true
232
+	}
233
+}
234
+
235
+// TransformForIntNS makes sure the resolv.conf file will be suitable for
236
+// use in a network sandbox that has an internal DNS resolver.
237
+//   - Add internalNS as a nameserver.
238
+//   - Remove other nameservers, stashing them as ExtNameServers for the
239
+//     internal resolver to use. (Apart from IPv6 nameservers, if keepIPv6.)
240
+//   - Mark ExtNameServers that must be used in the host namespace.
241
+//   - If no ExtNameServer addresses are found, use the defaults.
242
+//   - Return an error if an "ndots" option inherited from the host's config, or
243
+//     supplied in an override is not valid.
244
+//   - Ensure there's an 'options' value for each entry in reqdOptions. If the
245
+//     option includes a ':', and an option with a matching prefix exists, it
246
+//     is not modified.
247
+func (rc *ResolvConf) TransformForIntNS(
248
+	keepIPv6 bool,
249
+	internalNS netip.Addr,
250
+	reqdOptions []string,
251
+) ([]ExtDNSEntry, error) {
252
+	// The transformed config must list the internal nameserver.
253
+	newNSs := []netip.Addr{internalNS}
254
+	// Filter out other nameservers, keeping them for use as upstream nameservers by the
255
+	// internal nameserver.
256
+	rc.md.ExtNameServers = nil
257
+	for _, addr := range rc.nameServers {
258
+		// The internal resolver only uses IPv4 addresses so, keep IPv6 nameservers in
259
+		// the container's file if keepIPv6, else drop them.
260
+		if addr.Is6() {
261
+			if keepIPv6 {
262
+				newNSs = append(newNSs, addr)
263
+			}
264
+		} else {
265
+			// Extract this NS. Mark loopback addresses that did not come from an override as
266
+			// 'HostLoopback'. Upstream requests for these servers will be made in the host's
267
+			// network namespace. (So, '--dns 127.0.0.53' means use a nameserver listening on
268
+			// the container's loopback interface. But, if the host's resolv.conf contains
269
+			// 'nameserver 127.0.0.53', the host's resolver will be used.)
270
+			//
271
+			//  TODO(robmry) - why only loopback addresses?
272
+			//   Addresses from the host's resolv.conf must be usable in the host's namespace,
273
+			//   and a lookup from the container's namespace is more expensive? And, for
274
+			//   example, if the host has a nameserver with an IPv6 LL address with a zone-id,
275
+			//   it won't work from the container's namespace (now, while the address is left in
276
+			//   the container's resolv.conf, or in future for the internal resolver).
277
+			rc.md.ExtNameServers = append(rc.md.ExtNameServers, ExtDNSEntry{
278
+				Addr:         addr,
279
+				HostLoopback: addr.IsLoopback() && !rc.md.NSOverride,
280
+			})
281
+		}
282
+	}
283
+	rc.nameServers = newNSs
284
+
285
+	// If there are no external nameservers, and the only nameserver left is the
286
+	// internal resolver, use the defaults as ext nameservers.
287
+	if len(rc.md.ExtNameServers) == 0 && len(rc.nameServers) == 1 {
288
+		log.G(context.TODO()).Info("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers")
289
+		for _, addr := range defaultNSAddrs(keepIPv6) {
290
+			rc.md.ExtNameServers = append(rc.md.ExtNameServers, ExtDNSEntry{Addr: addr})
291
+		}
292
+		rc.md.UsedDefaultNS = true
293
+	}
294
+
295
+	// Validate the ndots option from host config or overrides, if present.
296
+	// TODO(robmry) - pre-existing behaviour, but ...
297
+	//   Validating ndots from an override is good, but not-liking something in the
298
+	//   host's resolv.conf isn't a reason to fail - just remove? (And it'll be
299
+	//   replaced by the value in reqdOptions, if given.)
300
+	if ndots, exists := rc.Option("ndots"); exists {
301
+		if n, err := strconv.Atoi(ndots); err != nil || n < 0 {
302
+			return nil, errdefs.InvalidParameter(
303
+				fmt.Errorf("invalid number for ndots option: %v", ndots))
304
+		}
305
+	}
306
+	// For each option required by the nameserver, add it if not already
307
+	// present (if the option already has a value don't change it).
308
+	for _, opt := range reqdOptions {
309
+		optName, _, _ := strings.Cut(opt, ":")
310
+		if _, exists := rc.Option(optName); !exists {
311
+			rc.AddOption(opt)
312
+		}
313
+	}
314
+
315
+	rc.md.Transform = "internal resolver"
316
+	return slices.Clone(rc.md.ExtNameServers), nil
317
+}
318
+
319
+// Generate returns content suitable for writing to a resolv.conf file. If comments
320
+// is true, the file will include header information if supplied, and a trailing
321
+// comment that describes how the file was constructed and lists external resolvers.
322
+func (rc *ResolvConf) Generate(comments bool) ([]byte, error) {
323
+	s := struct {
324
+		Md          *metadata
325
+		NameServers []netip.Addr
326
+		Search      []string
327
+		Options     []string
328
+		Other       []string
329
+		Overrides   []string
330
+		Comments    bool
331
+	}{
332
+		Md:          &rc.md,
333
+		NameServers: rc.nameServers,
334
+		Search:      rc.search,
335
+		Options:     rc.options,
336
+		Other:       rc.other,
337
+		Comments:    comments,
338
+	}
339
+	if rc.md.NSOverride {
340
+		s.Overrides = append(s.Overrides, "nameservers")
341
+	}
342
+	if rc.md.SearchOverride {
343
+		s.Overrides = append(s.Overrides, "search")
344
+	}
345
+	if rc.md.OptionsOverride {
346
+		s.Overrides = append(s.Overrides, "options")
347
+	}
348
+
349
+	const templateText = `{{if .Comments}}{{with .Md.Header}}{{.}}
350
+
351
+{{end}}{{end}}{{range .NameServers -}}
352
+nameserver {{.}}
353
+{{end}}{{with .Search -}}
354
+search {{join . " "}}
355
+{{end}}{{with .Options -}}
356
+options {{join . " "}}
357
+{{end}}{{with .Other -}}
358
+{{join . "\n"}}
359
+{{end}}{{if .Comments}}
360
+# Based on host file: '{{.Md.SourcePath}}'{{with .Md.Transform}} ({{.}}){{end}}
361
+{{if .Md.UsedDefaultNS -}}
362
+# Used default nameservers.
363
+{{end -}}
364
+{{with .Md.ExtNameServers -}}
365
+# ExtServers: {{.}}
366
+{{end -}}
367
+{{with .Md.InvalidNSs -}}
368
+# Invalid nameservers: {{.}}
369
+{{end -}}
370
+# Overrides: {{.Overrides}}
371
+{{with .Md.NDotsFrom -}}
372
+# Option ndots from: {{.}}
373
+{{end -}}
374
+{{end -}}
375
+`
376
+
377
+	funcs := template.FuncMap{"join": strings.Join}
378
+	var buf bytes.Buffer
379
+	templ, err := template.New("summary").Funcs(funcs).Parse(templateText)
380
+	if err != nil {
381
+		return nil, errdefs.System(err)
382
+	}
383
+	if err := templ.Execute(&buf, s); err != nil {
384
+		return nil, errdefs.System(err)
385
+	}
386
+	return buf.Bytes(), nil
387
+}
388
+
389
+// WriteFile generates content and writes it to path. If hashPath is non-zero, it
390
+// also writes a file containing a hash of the content, to enable UserModified()
391
+// to determine whether the file has been modified.
392
+func (rc *ResolvConf) WriteFile(path, hashPath string, perm os.FileMode) error {
393
+	content, err := rc.Generate(true)
394
+	if err != nil {
395
+		return err
396
+	}
397
+
398
+	// Write the resolv.conf file - it's bind-mounted into the container, so can't
399
+	// move a temp file into place, just have to truncate and write it.
400
+	if err := os.WriteFile(path, content, perm); err != nil {
401
+		return errdefs.System(err)
402
+	}
403
+
404
+	// Write the hash file.
405
+	if hashPath != "" {
406
+		hashFile, err := ioutils.NewAtomicFileWriter(hashPath, perm)
407
+		if err != nil {
408
+			return errdefs.System(err)
409
+		}
410
+		defer hashFile.Close()
411
+
412
+		digest := digest.FromBytes(content)
413
+		if _, err = hashFile.Write([]byte(digest)); err != nil {
414
+			return err
415
+		}
416
+	}
417
+
418
+	return nil
419
+}
420
+
421
+// UserModified can be used to determine whether the resolv.conf file has been
422
+// modified since it was generated. It returns false with no error if the file
423
+// matches the hash, true with no error if the file no longer matches the hash,
424
+// and false with an error if the result cannot be determined.
425
+func UserModified(rcPath, rcHashPath string) (bool, error) {
426
+	currRCHash, err := os.ReadFile(rcHashPath)
427
+	if err != nil {
428
+		// If the hash file doesn't exist, can only assume it hasn't been written
429
+		// yet (so, the user hasn't modified the file it hashes).
430
+		if errors.Is(err, fs.ErrNotExist) {
431
+			return false, nil
432
+		}
433
+		return false, errors.Wrapf(err, "failed to read hash file %s", rcHashPath)
434
+	}
435
+	expected, err := digest.Parse(string(currRCHash))
436
+	if err != nil {
437
+		return false, errors.Wrapf(err, "failed to parse hash file %s", rcHashPath)
438
+	}
439
+	v := expected.Verifier()
440
+	currRC, err := os.Open(rcPath)
441
+	if err != nil {
442
+		return false, errors.Wrapf(err, "failed to open %s to check for modifications", rcPath)
443
+	}
444
+	defer currRC.Close()
445
+	if _, err := io.Copy(v, currRC); err != nil {
446
+		return false, errors.Wrapf(err, "failed to hash %s to check for modifications", rcPath)
447
+	}
448
+	return !v.Verified(), nil
449
+}
450
+
451
+func (rc *ResolvConf) processLine(line string) {
452
+	fields := strings.Fields(line)
453
+
454
+	// Strip comments.
455
+	// TODO(robmry) - ignore comment chars except in column 0.
456
+	//   This preserves old behaviour, but it's wrong. For example, resolvers
457
+	//   will honour the option in line "options # ndots:0" (and ignore the
458
+	//   "#" as an unknown option).
459
+	for i, s := range fields {
460
+		if s[0] == '#' || s[0] == ';' {
461
+			fields = fields[:i]
462
+			break
463
+		}
464
+	}
465
+	if len(fields) == 0 {
466
+		return
467
+	}
468
+
469
+	switch fields[0] {
470
+	case "nameserver":
471
+		if len(fields) < 2 {
472
+			return
473
+		}
474
+		if addr, err := netip.ParseAddr(fields[1]); err != nil {
475
+			rc.md.InvalidNSs = append(rc.md.InvalidNSs, fields[1])
476
+		} else {
477
+			rc.nameServers = append(rc.nameServers, addr)
478
+		}
479
+	case "domain":
480
+		// 'domain' is an obsolete name for 'search'.
481
+		fallthrough
482
+	case "search":
483
+		if len(fields) < 2 {
484
+			return
485
+		}
486
+		// Only the last 'search' directive is used.
487
+		rc.search = fields[1:]
488
+	case "options":
489
+		if len(fields) < 2 {
490
+			return
491
+		}
492
+		// Replace options from earlier directives.
493
+		// TODO(robmry) - preserving incorrect behaviour, options should accumulate.
494
+		//     rc.options = append(rc.options, fields[1:]...)
495
+		rc.options = fields[1:]
496
+	default:
497
+		// Copy anything that's not a recognised directive.
498
+		rc.other = append(rc.other, line)
499
+	}
500
+}
501
+
502
+func defaultNSAddrs(ipv6 bool) []netip.Addr {
503
+	var addrs []netip.Addr
504
+	addrs = append(addrs, defaultIPv4NSs...)
505
+	if ipv6 {
506
+		addrs = append(addrs, defaultIPv6NSs...)
507
+	}
508
+	return addrs
509
+}
0 510
new file mode 100644
... ...
@@ -0,0 +1,56 @@
0
+package resolvconf
1
+
2
+import (
3
+	"context"
4
+	"net/netip"
5
+	"sync"
6
+
7
+	"github.com/containerd/log"
8
+)
9
+
10
+const (
11
+	// defaultPath is the default path to the resolv.conf that contains information to resolve DNS. See Path().
12
+	defaultPath = "/etc/resolv.conf"
13
+	// alternatePath is a path different from defaultPath, that may be used to resolve DNS. See Path().
14
+	alternatePath = "/run/systemd/resolve/resolv.conf"
15
+)
16
+
17
+// For Path to detect systemd (only needed for legacy networking).
18
+var (
19
+	detectSystemdResolvConfOnce sync.Once
20
+	pathAfterSystemdDetection   = defaultPath
21
+)
22
+
23
+// Path returns the path to the resolv.conf file that libnetwork should use.
24
+//
25
+// When /etc/resolv.conf contains 127.0.0.53 as the only nameserver, then
26
+// it is assumed systemd-resolved manages DNS. Because inside the container 127.0.0.53
27
+// is not a valid DNS server, Path() returns /run/systemd/resolve/resolv.conf
28
+// which is the resolv.conf that systemd-resolved generates and manages.
29
+// Otherwise Path() returns /etc/resolv.conf.
30
+//
31
+// Errors are silenced as they will inevitably resurface at future open/read calls.
32
+//
33
+// More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf
34
+//
35
+// TODO(robmry) - alternatePath is only needed for legacy networking ...
36
+//
37
+//	Host networking can use the host's resolv.conf as-is, and with an internal
38
+//	resolver it's also possible to use nameservers on the host's loopback
39
+//	interface. Once legacy networking is removed, this can always return
40
+//	defaultPath.
41
+func Path() string {
42
+	detectSystemdResolvConfOnce.Do(func() {
43
+		rc, err := Load(defaultPath)
44
+		if err != nil {
45
+			// silencing error as it will resurface at next calls trying to read defaultPath
46
+			return
47
+		}
48
+		ns := rc.nameServers
49
+		if len(ns) == 1 && ns[0] == netip.MustParseAddr("127.0.0.53") {
50
+			pathAfterSystemdDetection = alternatePath
51
+			log.G(context.TODO()).Infof("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath)
52
+		}
53
+	})
54
+	return pathAfterSystemdDetection
55
+}
0 56
new file mode 100644
... ...
@@ -0,0 +1,577 @@
0
+package resolvconf
1
+
2
+import (
3
+	"bytes"
4
+	"io/fs"
5
+	"net/netip"
6
+	"os"
7
+	"path/filepath"
8
+	"runtime"
9
+	"strings"
10
+	"testing"
11
+
12
+	"github.com/docker/docker/internal/sliceutil"
13
+	"github.com/google/go-cmp/cmp/cmpopts"
14
+	"gotest.tools/v3/assert"
15
+	is "gotest.tools/v3/assert/cmp"
16
+	"gotest.tools/v3/golden"
17
+)
18
+
19
+func TestRCOption(t *testing.T) {
20
+	testcases := []struct {
21
+		name     string
22
+		options  string
23
+		search   string
24
+		expFound bool
25
+		expValue string
26
+	}{
27
+		{
28
+			name:    "Empty options",
29
+			options: "",
30
+			search:  "ndots",
31
+		},
32
+		{
33
+			name:    "Not found",
34
+			options: "ndots:0 edns0",
35
+			search:  "trust-ad",
36
+		},
37
+		{
38
+			name:     "Found with value",
39
+			options:  "ndots:0 edns0",
40
+			search:   "ndots",
41
+			expFound: true,
42
+			expValue: "0",
43
+		},
44
+		{
45
+			name:     "Found without value",
46
+			options:  "ndots:0 edns0",
47
+			search:   "edns0",
48
+			expFound: true,
49
+			expValue: "",
50
+		},
51
+		{
52
+			name:     "Found last value",
53
+			options:  "ndots:0 edns0 ndots:1",
54
+			search:   "ndots",
55
+			expFound: true,
56
+			expValue: "1",
57
+		},
58
+	}
59
+
60
+	for _, tc := range testcases {
61
+		t.Run(tc.name, func(t *testing.T) {
62
+			rc, err := Parse(bytes.NewBuffer([]byte("options "+tc.options)), "")
63
+			assert.NilError(t, err)
64
+			value, found := rc.Option(tc.search)
65
+			assert.Check(t, is.Equal(found, tc.expFound))
66
+			assert.Check(t, is.Equal(value, tc.expValue))
67
+		})
68
+	}
69
+}
70
+
71
+func TestRCWrite(t *testing.T) {
72
+	testcases := []struct {
73
+		name            string
74
+		fileName        string
75
+		perm            os.FileMode
76
+		hashFileName    string
77
+		modify          bool
78
+		expUserModified bool
79
+	}{
80
+		{
81
+			name:         "Write with hash",
82
+			fileName:     "testfile",
83
+			hashFileName: "testfile.hash",
84
+		},
85
+		{
86
+			name:            "Write with hash and modify",
87
+			fileName:        "testfile",
88
+			hashFileName:    "testfile.hash",
89
+			modify:          true,
90
+			expUserModified: true,
91
+		},
92
+		{
93
+			name:            "Write without hash and modify",
94
+			fileName:        "testfile",
95
+			modify:          true,
96
+			expUserModified: false,
97
+		},
98
+		{
99
+			name:     "Write perm",
100
+			fileName: "testfile",
101
+			perm:     0640,
102
+		},
103
+	}
104
+
105
+	rc, err := Parse(bytes.NewBuffer([]byte("nameserver 1.2.3.4")), "")
106
+	assert.NilError(t, err)
107
+
108
+	for _, tc := range testcases {
109
+		t.Run(tc.name, func(t *testing.T) {
110
+			tc := tc
111
+			d := t.TempDir()
112
+			path := filepath.Join(d, tc.fileName)
113
+			var hashPath string
114
+			if tc.hashFileName != "" {
115
+				hashPath = filepath.Join(d, tc.hashFileName)
116
+			}
117
+			if tc.perm == 0 {
118
+				tc.perm = 0644
119
+			}
120
+			err := rc.WriteFile(path, hashPath, tc.perm)
121
+			assert.NilError(t, err)
122
+
123
+			fi, err := os.Stat(path)
124
+			assert.NilError(t, err)
125
+			// Windows files won't have the expected perms.
126
+			if runtime.GOOS != "windows" {
127
+				assert.Check(t, is.Equal(fi.Mode(), tc.perm))
128
+			}
129
+
130
+			if tc.modify {
131
+				err := os.WriteFile(path, []byte("modified"), 0644)
132
+				assert.NilError(t, err)
133
+			}
134
+
135
+			um, err := UserModified(path, hashPath)
136
+			assert.NilError(t, err)
137
+			assert.Check(t, is.Equal(um, tc.expUserModified))
138
+		})
139
+	}
140
+}
141
+
142
+var a2s = sliceutil.Mapper(netip.Addr.String)
143
+var s2a = sliceutil.Mapper(netip.MustParseAddr)
144
+
145
+// Test that a resolv.conf file can be modified using OverrideXXX() methods
146
+// to modify nameservers/search/options directives, and tha options can be
147
+// added via AddOption().
148
+func TestRCModify(t *testing.T) {
149
+	testcases := []struct {
150
+		name            string
151
+		inputNS         []string
152
+		inputSearch     []string
153
+		inputOptions    []string
154
+		noOverrides     bool // Whether to apply overrides (empty lists are valid overrides).
155
+		overrideNS      []string
156
+		overrideSearch  []string
157
+		overrideOptions []string
158
+		addOption       string
159
+	}{
160
+		{
161
+			name:    "No content no overrides",
162
+			inputNS: []string{},
163
+		},
164
+		{
165
+			name:         "No overrides",
166
+			noOverrides:  true,
167
+			inputNS:      []string{"1.2.3.4"},
168
+			inputSearch:  []string{"invalid"},
169
+			inputOptions: []string{"ndots:0"},
170
+		},
171
+		{
172
+			name:         "Empty overrides",
173
+			inputNS:      []string{"1.2.3.4"},
174
+			inputSearch:  []string{"invalid"},
175
+			inputOptions: []string{"ndots:0"},
176
+		},
177
+		{
178
+			name:            "Overrides",
179
+			inputNS:         []string{"1.2.3.4"},
180
+			inputSearch:     []string{"invalid"},
181
+			inputOptions:    []string{"ndots:0"},
182
+			overrideNS:      []string{"2.3.4.5", "fdba:acdd:587c::53"},
183
+			overrideSearch:  []string{"com", "invalid", "example"},
184
+			overrideOptions: []string{"ndots:1", "edns0", "trust-ad"},
185
+		},
186
+		{
187
+			name:         "Add option no overrides",
188
+			noOverrides:  true,
189
+			inputNS:      []string{"1.2.3.4"},
190
+			inputSearch:  []string{"invalid"},
191
+			inputOptions: []string{"ndots:0"},
192
+			addOption:    "attempts:3",
193
+		},
194
+	}
195
+
196
+	for _, tc := range testcases {
197
+		t.Run(tc.name, func(t *testing.T) {
198
+			tc := tc
199
+			var input string
200
+			if len(tc.inputNS) != 0 {
201
+				for _, ns := range tc.inputNS {
202
+					input += "nameserver " + ns + "\n"
203
+				}
204
+			}
205
+			if len(tc.inputSearch) != 0 {
206
+				input += "search " + strings.Join(tc.inputSearch, " ") + "\n"
207
+			}
208
+			if len(tc.inputOptions) != 0 {
209
+				input += "options " + strings.Join(tc.inputOptions, " ") + "\n"
210
+			}
211
+			rc, err := Parse(bytes.NewBuffer([]byte(input)), "")
212
+			assert.NilError(t, err)
213
+			assert.Check(t, is.DeepEqual(a2s(rc.NameServers()), tc.inputNS))
214
+			assert.Check(t, is.DeepEqual(rc.Search(), tc.inputSearch))
215
+			assert.Check(t, is.DeepEqual(rc.Options(), tc.inputOptions))
216
+
217
+			if !tc.noOverrides {
218
+				overrideNS := s2a(tc.overrideNS)
219
+				rc.OverrideNameServers(overrideNS)
220
+				rc.OverrideSearch(tc.overrideSearch)
221
+				rc.OverrideOptions(tc.overrideOptions)
222
+
223
+				assert.Check(t, is.DeepEqual(rc.NameServers(), overrideNS, cmpopts.EquateComparable(netip.Addr{})))
224
+				assert.Check(t, is.DeepEqual(rc.Search(), tc.overrideSearch))
225
+				assert.Check(t, is.DeepEqual(rc.Options(), tc.overrideOptions))
226
+			}
227
+
228
+			if tc.addOption != "" {
229
+				options := rc.Options()
230
+				rc.AddOption(tc.addOption)
231
+				assert.Check(t, is.DeepEqual(rc.Options(), append(options, tc.addOption)))
232
+			}
233
+
234
+			d := t.TempDir()
235
+			path := filepath.Join(d, "resolv.conf")
236
+			err = rc.WriteFile(path, "", 0644)
237
+			assert.NilError(t, err)
238
+
239
+			content, err := os.ReadFile(path)
240
+			assert.NilError(t, err)
241
+			assert.Check(t, golden.String(string(content), t.Name()+".golden"))
242
+		})
243
+	}
244
+}
245
+
246
+func TestRCTransformForLegacyNw(t *testing.T) {
247
+	testcases := []struct {
248
+		name       string
249
+		input      string
250
+		ipv6       bool
251
+		overrideNS []string
252
+	}{
253
+		{
254
+			name:  "Routable IPv4 only",
255
+			input: "nameserver 10.0.0.1",
256
+		},
257
+		{
258
+			name:  "Routable IPv4 and IPv6, ipv6 enabled",
259
+			input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
260
+			ipv6:  true,
261
+		},
262
+		{
263
+			name:  "Routable IPv4 and IPv6, ipv6 disabled",
264
+			input: "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
265
+			ipv6:  false,
266
+		},
267
+		{
268
+			name:  "IPv4 localhost, ipv6 disabled",
269
+			input: "nameserver 127.0.0.53",
270
+			ipv6:  false,
271
+		},
272
+		{
273
+			name:  "IPv4 localhost, ipv6 enabled",
274
+			input: "nameserver 127.0.0.53",
275
+			ipv6:  true,
276
+		},
277
+		{
278
+			name:  "IPv4 and IPv6 localhost, ipv6 disabled",
279
+			input: "nameserver 127.0.0.53\nnameserver ::1",
280
+			ipv6:  false,
281
+		},
282
+		{
283
+			name:  "IPv4 and IPv6 localhost, ipv6 enabled",
284
+			input: "nameserver 127.0.0.53\nnameserver ::1",
285
+			ipv6:  true,
286
+		},
287
+		{
288
+			name:  "IPv4 localhost, IPv6 routeable, ipv6 enabled",
289
+			input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
290
+			ipv6:  true,
291
+		},
292
+		{
293
+			name:  "IPv4 localhost, IPv6 routeable, ipv6 disabled",
294
+			input: "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
295
+			ipv6:  false,
296
+		},
297
+		{
298
+			name:       "Override nameservers",
299
+			input:      "nameserver 127.0.0.53",
300
+			overrideNS: []string{"127.0.0.1", "::1"},
301
+			ipv6:       false,
302
+		},
303
+	}
304
+
305
+	for _, tc := range testcases {
306
+		t.Run(tc.name, func(t *testing.T) {
307
+			tc := tc
308
+			rc, err := Parse(bytes.NewBuffer([]byte(tc.input)), "/etc/resolv.conf")
309
+			assert.NilError(t, err)
310
+			if tc.overrideNS != nil {
311
+				rc.OverrideNameServers(s2a(tc.overrideNS))
312
+			}
313
+
314
+			rc.TransformForLegacyNw(tc.ipv6)
315
+
316
+			d := t.TempDir()
317
+			path := filepath.Join(d, "resolv.conf")
318
+			err = rc.WriteFile(path, "", 0644)
319
+			assert.NilError(t, err)
320
+
321
+			content, err := os.ReadFile(path)
322
+			assert.NilError(t, err)
323
+			assert.Check(t, golden.String(string(content), t.Name()+".golden"))
324
+		})
325
+	}
326
+}
327
+
328
+func TestRCTransformForIntNS(t *testing.T) {
329
+	mke := func(addr string, hostLoopback bool) ExtDNSEntry {
330
+		return ExtDNSEntry{
331
+			Addr:         netip.MustParseAddr(addr),
332
+			HostLoopback: hostLoopback,
333
+		}
334
+	}
335
+
336
+	testcases := []struct {
337
+		name            string
338
+		input           string
339
+		intNameServer   string
340
+		ipv6            bool
341
+		overrideNS      []string
342
+		overrideOptions []string
343
+		reqdOptions     []string
344
+		expExtServers   []ExtDNSEntry
345
+		expErr          string
346
+	}{
347
+		{
348
+			name:          "IPv4 only",
349
+			input:         "nameserver 10.0.0.1",
350
+			expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)},
351
+		},
352
+		{
353
+			name:          "IPv4 and IPv6, ipv6 enabled",
354
+			input:         "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
355
+			ipv6:          true,
356
+			expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)},
357
+		},
358
+		{
359
+			name:          "IPv4 and IPv6, ipv6 disabled",
360
+			input:         "nameserver 10.0.0.1\nnameserver fdb6:b8fe:b528::1",
361
+			ipv6:          false,
362
+			expExtServers: []ExtDNSEntry{mke("10.0.0.1", false)},
363
+		},
364
+		{
365
+			name:          "IPv4 localhost",
366
+			input:         "nameserver 127.0.0.53",
367
+			ipv6:          false,
368
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
369
+		},
370
+		{
371
+			// Overriding the nameserver with a localhost address means use the container's
372
+			// loopback interface, not the host's.
373
+			name:          "IPv4 localhost override",
374
+			input:         "nameserver 10.0.0.1",
375
+			ipv6:          false,
376
+			overrideNS:    []string{"127.0.0.53"},
377
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", false)},
378
+		},
379
+		{
380
+			name:          "IPv4 localhost, ipv6 enabled",
381
+			input:         "nameserver 127.0.0.53",
382
+			ipv6:          true,
383
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
384
+		},
385
+		{
386
+			name:  "IPv6 addr, IPv6 enabled",
387
+			input: "nameserver fd14:6e0e:f855::1",
388
+			ipv6:  true,
389
+			// Note that there are no ext servers in this case, the internal resolver
390
+			// will only look up container names. The default nameservers aren't added
391
+			// because the host's IPv6 nameserver remains in the container's resolv.conf,
392
+			// (because only IPv4 ext servers are currently allowed).
393
+		},
394
+		{
395
+			name:          "IPv4 and IPv6 localhost, IPv6 disabled",
396
+			input:         "nameserver 127.0.0.53\nnameserver ::1",
397
+			ipv6:          false,
398
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
399
+		},
400
+		{
401
+			name:          "IPv4 and IPv6 localhost, ipv6 enabled",
402
+			input:         "nameserver 127.0.0.53\nnameserver ::1",
403
+			ipv6:          true,
404
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
405
+		},
406
+		{
407
+			name:          "IPv4 localhost, IPv6 private, IPv6 enabled",
408
+			input:         "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
409
+			ipv6:          true,
410
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
411
+		},
412
+		{
413
+			name:          "IPv4 localhost, IPv6 private, IPv6 disabled",
414
+			input:         "nameserver 127.0.0.53\nnameserver fd3e:2d1a:1f5a::1",
415
+			ipv6:          false,
416
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
417
+		},
418
+		{
419
+			name:  "No host nameserver, no iv6",
420
+			input: "",
421
+			ipv6:  false,
422
+			expExtServers: []ExtDNSEntry{
423
+				mke("8.8.8.8", false),
424
+				mke("8.8.4.4", false),
425
+			},
426
+		},
427
+		{
428
+			name:  "No host nameserver, iv6",
429
+			input: "",
430
+			ipv6:  true,
431
+			expExtServers: []ExtDNSEntry{
432
+				mke("8.8.8.8", false),
433
+				mke("8.8.4.4", false),
434
+				mke("2001:4860:4860::8888", false),
435
+				mke("2001:4860:4860::8844", false),
436
+			},
437
+		},
438
+		{
439
+			name:          "ndots present and required",
440
+			input:         "nameserver 127.0.0.53\noptions ndots:1",
441
+			reqdOptions:   []string{"ndots:0"},
442
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
443
+		},
444
+		{
445
+			name:          "ndots missing but required",
446
+			input:         "nameserver 127.0.0.53",
447
+			reqdOptions:   []string{"ndots:0"},
448
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
449
+		},
450
+		{
451
+			name:            "ndots host, override and required",
452
+			input:           "nameserver 127.0.0.53",
453
+			reqdOptions:     []string{"ndots:0"},
454
+			overrideOptions: []string{"ndots:2"},
455
+			expExtServers:   []ExtDNSEntry{mke("127.0.0.53", true)},
456
+		},
457
+		{
458
+			name:          "Extra required options",
459
+			input:         "nameserver 127.0.0.53\noptions trust-ad",
460
+			reqdOptions:   []string{"ndots:0", "attempts:3", "edns0", "trust-ad"},
461
+			expExtServers: []ExtDNSEntry{mke("127.0.0.53", true)},
462
+		},
463
+	}
464
+
465
+	for _, tc := range testcases {
466
+		t.Run(tc.name, func(t *testing.T) {
467
+			tc := tc
468
+			rc, err := Parse(bytes.NewBuffer([]byte(tc.input)), "/etc/resolv.conf")
469
+			assert.NilError(t, err)
470
+
471
+			if tc.intNameServer == "" {
472
+				tc.intNameServer = "127.0.0.11"
473
+			}
474
+			if len(tc.overrideNS) > 0 {
475
+				rc.OverrideNameServers(s2a(tc.overrideNS))
476
+			}
477
+			if len(tc.overrideOptions) > 0 {
478
+				rc.OverrideOptions(tc.overrideOptions)
479
+			}
480
+			intNS := netip.MustParseAddr(tc.intNameServer)
481
+			extNameServers, err := rc.TransformForIntNS(tc.ipv6, intNS, tc.reqdOptions)
482
+			if tc.expErr != "" {
483
+				assert.Check(t, is.ErrorContains(err, tc.expErr))
484
+				return
485
+			}
486
+			assert.NilError(t, err)
487
+
488
+			d := t.TempDir()
489
+			path := filepath.Join(d, "resolv.conf")
490
+			err = rc.WriteFile(path, "", 0644)
491
+			assert.NilError(t, err)
492
+
493
+			content, err := os.ReadFile(path)
494
+			assert.NilError(t, err)
495
+			assert.Check(t, golden.String(string(content), t.Name()+".golden"))
496
+			assert.Check(t, is.DeepEqual(extNameServers, tc.expExtServers,
497
+				cmpopts.EquateComparable(netip.Addr{})))
498
+		})
499
+	}
500
+}
501
+
502
+func TestRCRead(t *testing.T) {
503
+	d := t.TempDir()
504
+	path := filepath.Join(d, "resolv.conf")
505
+
506
+	// Try to read a nonexistent file, equivalent to an empty file.
507
+	_, err := Load(path)
508
+	assert.Check(t, is.ErrorIs(err, fs.ErrNotExist))
509
+
510
+	err = os.WriteFile(path, []byte("options edns0"), 0644)
511
+	assert.NilError(t, err)
512
+
513
+	// Read that file in the constructor.
514
+	rc, err := Load(path)
515
+	assert.NilError(t, err)
516
+	assert.Check(t, is.DeepEqual(rc.Options(), []string{"edns0"}))
517
+
518
+	// Pass in an os.File, check the path is extracted.
519
+	file, err := os.Open(path)
520
+	assert.NilError(t, err)
521
+	defer file.Close()
522
+	rc, err = Parse(file, "")
523
+	assert.NilError(t, err)
524
+	assert.Check(t, is.Equal(rc.md.SourcePath, path))
525
+}
526
+
527
+func TestRCInvalidNS(t *testing.T) {
528
+	d := t.TempDir()
529
+
530
+	// A resolv.conf with an invalid nameserver address.
531
+	rc, err := Parse(bytes.NewBuffer([]byte("nameserver 1.2.3.4.5")), "")
532
+	assert.NilError(t, err)
533
+
534
+	path := filepath.Join(d, "resolv.conf")
535
+	err = rc.WriteFile(path, "", 0644)
536
+	assert.NilError(t, err)
537
+
538
+	content, err := os.ReadFile(path)
539
+	assert.NilError(t, err)
540
+	assert.Check(t, golden.String(string(content), t.Name()+".golden"))
541
+}
542
+
543
+func TestRCSetHeader(t *testing.T) {
544
+	rc, err := Parse(bytes.NewBuffer([]byte("nameserver 127.0.0.53")), "/etc/resolv.conf")
545
+	assert.NilError(t, err)
546
+
547
+	rc.SetHeader("# This is a comment.")
548
+	d := t.TempDir()
549
+	path := filepath.Join(d, "resolv.conf")
550
+	err = rc.WriteFile(path, "", 0644)
551
+	assert.NilError(t, err)
552
+
553
+	content, err := os.ReadFile(path)
554
+	assert.NilError(t, err)
555
+	assert.Check(t, golden.String(string(content), t.Name()+".golden"))
556
+}
557
+
558
+func TestRCUnknownDirectives(t *testing.T) {
559
+	const input = `
560
+something unexpected
561
+nameserver 127.0.0.53
562
+options ndots:1
563
+unrecognised thing
564
+`
565
+	rc, err := Parse(bytes.NewBuffer([]byte(input)), "/etc/resolv.conf")
566
+	assert.NilError(t, err)
567
+
568
+	d := t.TempDir()
569
+	path := filepath.Join(d, "resolv.conf")
570
+	err = rc.WriteFile(path, "", 0644)
571
+	assert.NilError(t, err)
572
+
573
+	content, err := os.ReadFile(path)
574
+	assert.NilError(t, err)
575
+	assert.Check(t, golden.String(string(content), t.Name()+".golden"))
576
+}
0 577
new file mode 100644
... ...
@@ -0,0 +1 @@
0
+* text=auto eol=lf
0 1
new file mode 100644
... ...
@@ -0,0 +1,4 @@
0
+
1
+# Based on host file: ''
2
+# Invalid nameservers: [1.2.3.4.5]
3
+# Overrides: []
0 4
new file mode 100644
... ...
@@ -0,0 +1,7 @@
0
+nameserver 1.2.3.4
1
+search invalid
2
+options ndots:0 attempts:3
3
+
4
+# Based on host file: ''
5
+# Overrides: []
6
+# Option ndots from: host
0 7
new file mode 100644
... ...
@@ -0,0 +1,3 @@
0
+
1
+# Based on host file: ''
2
+# Overrides: [nameservers search options]
0 3
new file mode 100644
... ...
@@ -0,0 +1,3 @@
0
+
1
+# Based on host file: ''
2
+# Overrides: [nameservers search options]
0 3
new file mode 100644
... ...
@@ -0,0 +1,7 @@
0
+nameserver 1.2.3.4
1
+search invalid
2
+options ndots:0
3
+
4
+# Based on host file: ''
5
+# Overrides: []
6
+# Option ndots from: host
0 7
new file mode 100644
... ...
@@ -0,0 +1,8 @@
0
+nameserver 2.3.4.5
1
+nameserver fdba:acdd:587c::53
2
+search com invalid example
3
+options ndots:1 edns0 trust-ad
4
+
5
+# Based on host file: ''
6
+# Overrides: [nameservers search options]
7
+# Option ndots from: override
0 8
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+# This is a comment.
1
+
2
+nameserver 127.0.0.53
3
+
4
+# Based on host file: '/etc/resolv.conf'
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,7 @@
0
+nameserver 127.0.0.11
1
+options trust-ad ndots:0 attempts:3 edns0
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# ExtServers: [host(127.0.0.53)]
5
+# Overrides: []
6
+# Option ndots from: internal
0 7
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# ExtServers: [10.0.0.1]
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 127.0.0.11
1
+nameserver fdb6:b8fe:b528::1
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# ExtServers: [10.0.0.1]
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# ExtServers: [host(127.0.0.53)]
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 127.0.0.11
1
+nameserver ::1
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# ExtServers: [host(127.0.0.53)]
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# ExtServers: [host(127.0.0.53)]
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 127.0.0.11
1
+nameserver fd3e:2d1a:1f5a::1
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# ExtServers: [host(127.0.0.53)]
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# ExtServers: [host(127.0.0.53)]
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# ExtServers: [host(127.0.0.53)]
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# ExtServers: [127.0.0.53]
4
+# Overrides: [nameservers]
0 5
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# ExtServers: [10.0.0.1]
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.11
1
+nameserver fd14:6e0e:f855::1
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# Used default nameservers.
4
+# ExtServers: [8.8.8.8 8.8.4.4 2001:4860:4860::8888 2001:4860:4860::8844]
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 127.0.0.11
1
+
2
+# Based on host file: '/etc/resolv.conf' (internal resolver)
3
+# Used default nameservers.
4
+# ExtServers: [8.8.8.8 8.8.4.4]
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,7 @@
0
+nameserver 127.0.0.11
1
+options ndots:2
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# ExtServers: [host(127.0.0.53)]
5
+# Overrides: [options]
6
+# Option ndots from: override
0 7
new file mode 100644
... ...
@@ -0,0 +1,7 @@
0
+nameserver 127.0.0.11
1
+options ndots:0
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# ExtServers: [host(127.0.0.53)]
5
+# Overrides: []
6
+# Option ndots from: internal
0 7
new file mode 100644
... ...
@@ -0,0 +1,7 @@
0
+nameserver 127.0.0.11
1
+options ndots:1
2
+
3
+# Based on host file: '/etc/resolv.conf' (internal resolver)
4
+# ExtServers: [host(127.0.0.53)]
5
+# Overrides: []
6
+# Option ndots from: host
0 7
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 8.8.8.8
1
+nameserver 8.8.4.4
2
+
3
+# Based on host file: '/etc/resolv.conf' (legacy)
4
+# Used default nameservers.
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,8 @@
0
+nameserver 8.8.8.8
1
+nameserver 8.8.4.4
2
+nameserver 2001:4860:4860::8888
3
+nameserver 2001:4860:4860::8844
4
+
5
+# Based on host file: '/etc/resolv.conf' (legacy)
6
+# Used default nameservers.
7
+# Overrides: []
0 8
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 8.8.8.8
1
+nameserver 8.8.4.4
2
+
3
+# Based on host file: '/etc/resolv.conf' (legacy)
4
+# Used default nameservers.
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,4 @@
0
+nameserver fd3e:2d1a:1f5a::1
1
+
2
+# Based on host file: '/etc/resolv.conf' (legacy)
3
+# Overrides: []
0 4
new file mode 100644
... ...
@@ -0,0 +1,6 @@
0
+nameserver 8.8.8.8
1
+nameserver 8.8.4.4
2
+
3
+# Based on host file: '/etc/resolv.conf' (legacy)
4
+# Used default nameservers.
5
+# Overrides: []
0 6
new file mode 100644
... ...
@@ -0,0 +1,8 @@
0
+nameserver 8.8.8.8
1
+nameserver 8.8.4.4
2
+nameserver 2001:4860:4860::8888
3
+nameserver 2001:4860:4860::8844
4
+
5
+# Based on host file: '/etc/resolv.conf' (legacy)
6
+# Used default nameservers.
7
+# Overrides: []
0 8
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 127.0.0.1
1
+nameserver ::1
2
+
3
+# Based on host file: '/etc/resolv.conf' (legacy)
4
+# Overrides: [nameservers]
0 5
new file mode 100644
... ...
@@ -0,0 +1,4 @@
0
+nameserver 10.0.0.1
1
+
2
+# Based on host file: '/etc/resolv.conf' (legacy)
3
+# Overrides: []
0 4
new file mode 100644
... ...
@@ -0,0 +1,5 @@
0
+nameserver 10.0.0.1
1
+nameserver fdb6:b8fe:b528::1
2
+
3
+# Based on host file: '/etc/resolv.conf' (legacy)
4
+# Overrides: []
0 5
new file mode 100644
... ...
@@ -0,0 +1,4 @@
0
+nameserver 10.0.0.1
1
+
2
+# Based on host file: '/etc/resolv.conf' (legacy)
3
+# Overrides: []
0 4
new file mode 100644
... ...
@@ -0,0 +1,8 @@
0
+nameserver 127.0.0.53
1
+options ndots:1
2
+something unexpected
3
+unrecognised thing
4
+
5
+# Based on host file: '/etc/resolv.conf'
6
+# Overrides: []
7
+# Option ndots from: host
... ...
@@ -11,6 +11,7 @@ import (
11 11
 	"os"
12 12
 	"os/exec"
13 13
 	"path/filepath"
14
+	"regexp"
14 15
 	"strings"
15 16
 	"sync"
16 17
 	"testing"
... ...
@@ -32,6 +33,8 @@ import (
32 32
 	"github.com/vishvananda/netlink"
33 33
 	"github.com/vishvananda/netns"
34 34
 	"golang.org/x/sync/errgroup"
35
+	"gotest.tools/v3/assert"
36
+	is "gotest.tools/v3/assert/cmp"
35 37
 )
36 38
 
37 39
 const (
... ...
@@ -1278,6 +1281,28 @@ func makeTesthostNetwork(t *testing.T, c *libnetwork.Controller) *libnetwork.Net
1278 1278
 	return n
1279 1279
 }
1280 1280
 
1281
+func makeTestIPv6Network(t *testing.T, c *libnetwork.Controller) *libnetwork.Network {
1282
+	t.Helper()
1283
+	netOptions := options.Generic{
1284
+		netlabel.EnableIPv6: true,
1285
+		netlabel.GenericData: options.Generic{
1286
+			"BridgeName": "testnetwork",
1287
+		},
1288
+	}
1289
+	ipamV6ConfList := []*libnetwork.IpamConf{
1290
+		{PreferredPool: "fd81:fb6e:38ba:abcd::/64", Gateway: "fd81:fb6e:38ba:abcd::9"},
1291
+	}
1292
+	n, err := createTestNetwork(c,
1293
+		"bridge",
1294
+		"testnetwork",
1295
+		netOptions,
1296
+		nil,
1297
+		ipamV6ConfList,
1298
+	)
1299
+	assert.NilError(t, err)
1300
+	return n
1301
+}
1302
+
1281 1303
 func TestHost(t *testing.T) {
1282 1304
 	defer netnsutils.SetupTestOSContext(t)()
1283 1305
 	controller := newController(t)
... ...
@@ -1790,295 +1815,92 @@ func reexecSetKey(key string, containerID string, controllerID string) error {
1790 1790
 	return cmd.Run()
1791 1791
 }
1792 1792
 
1793
-func TestEnableIPv6(t *testing.T) {
1794
-	defer netnsutils.SetupTestOSContext(t)()
1795
-	controller := newController(t)
1796
-
1797
-	tmpResolvConf := []byte("search pommesfrites.fr\nnameserver 12.34.56.78\nnameserver 2001:4860:4860::8888\n")
1798
-	expectedResolvConf := []byte("search pommesfrites.fr\nnameserver 127.0.0.11\nnameserver 2001:4860:4860::8888\noptions ndots:0\n")
1799
-	// take a copy of resolv.conf for restoring after test completes
1800
-	resolvConfSystem, err := os.ReadFile("/etc/resolv.conf")
1801
-	if err != nil {
1802
-		t.Fatal(err)
1803
-	}
1804
-	// cleanup
1805
-	defer func() {
1806
-		if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil {
1807
-			t.Fatal(err)
1808
-		}
1809
-	}()
1810
-
1811
-	netOption := options.Generic{
1812
-		netlabel.EnableIPv6: true,
1813
-		netlabel.GenericData: options.Generic{
1814
-			"BridgeName": "testnetwork",
1815
-		},
1816
-	}
1817
-	ipamV6ConfList := []*libnetwork.IpamConf{{PreferredPool: "fe99::/64", Gateway: "fe99::9"}}
1818
-
1819
-	n, err := createTestNetwork(controller, "bridge", "testnetwork", netOption, nil, ipamV6ConfList)
1820
-	if err != nil {
1821
-		t.Fatal(err)
1822
-	}
1823
-	defer func() {
1824
-		if err := n.Delete(); err != nil {
1825
-			t.Fatal(err)
1826
-		}
1827
-	}()
1828
-
1829
-	ep1, err := n.CreateEndpoint("ep1")
1830
-	if err != nil {
1831
-		t.Fatal(err)
1832
-	}
1833
-
1834
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf, 0o644); err != nil {
1835
-		t.Fatal(err)
1836
-	}
1837
-
1838
-	resolvConfPath := "/tmp/libnetwork_test/resolv.conf"
1839
-	defer os.Remove(resolvConfPath)
1840
-
1841
-	sb, err := controller.NewSandbox(containerID, libnetwork.OptionResolvConfPath(resolvConfPath))
1842
-	if err != nil {
1843
-		t.Fatal(err)
1844
-	}
1845
-	defer func() {
1846
-		if err := sb.Delete(); err != nil {
1847
-			t.Fatal(err)
1848
-		}
1849
-	}()
1850
-
1851
-	err = ep1.Join(sb)
1852
-	if err != nil {
1853
-		t.Fatal(err)
1854
-	}
1855
-
1856
-	content, err := os.ReadFile(resolvConfPath)
1857
-	if err != nil {
1858
-		t.Fatal(err)
1859
-	}
1860
-
1861
-	if !bytes.Equal(content, expectedResolvConf) {
1862
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(expectedResolvConf), string(content))
1863
-	}
1864
-
1865
-	if err != nil {
1866
-		t.Fatal(err)
1867
-	}
1868
-}
1869
-
1870
-func TestResolvConfHost(t *testing.T) {
1871
-	defer netnsutils.SetupTestOSContext(t)()
1872
-	controller := newController(t)
1873
-
1874
-	tmpResolvConf := []byte("search localhost.net\nnameserver 127.0.0.1\nnameserver 2001:4860:4860::8888\n")
1875
-
1876
-	// take a copy of resolv.conf for restoring after test completes
1877
-	resolvConfSystem, err := os.ReadFile("/etc/resolv.conf")
1878
-	if err != nil {
1879
-		t.Fatal(err)
1880
-	}
1881
-	// cleanup
1882
-	defer func() {
1883
-		if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil {
1884
-			t.Fatal(err)
1885
-		}
1886
-	}()
1887
-
1888
-	n := makeTesthostNetwork(t, controller)
1889
-	ep1, err := n.CreateEndpoint("ep1", libnetwork.CreateOptionDisableResolution())
1890
-	if err != nil {
1891
-		t.Fatal(err)
1892
-	}
1893
-
1894
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf, 0o644); err != nil {
1895
-		t.Fatal(err)
1896
-	}
1897
-
1898
-	resolvConfPath := "/tmp/libnetwork_test/resolv.conf"
1899
-	defer os.Remove(resolvConfPath)
1900
-
1901
-	sb, err := controller.NewSandbox(containerID,
1902
-		libnetwork.OptionUseDefaultSandbox(),
1903
-		libnetwork.OptionResolvConfPath(resolvConfPath),
1904
-		libnetwork.OptionOriginResolvConfPath("/etc/resolv.conf"))
1905
-	if err != nil {
1906
-		t.Fatal(err)
1907
-	}
1908
-	defer func() {
1909
-		if err := sb.Delete(); err != nil {
1910
-			t.Fatal(err)
1911
-		}
1912
-	}()
1913
-
1914
-	err = ep1.Join(sb)
1915
-	if err != nil {
1916
-		t.Fatal(err)
1917
-	}
1918
-	defer func() {
1919
-		err = ep1.Leave(sb)
1920
-		if err != nil {
1921
-			t.Fatal(err)
1922
-		}
1923
-	}()
1924
-
1925
-	finfo, err := os.Stat(resolvConfPath)
1926
-	if err != nil {
1927
-		t.Fatal(err)
1928
-	}
1929
-
1930
-	fmode := (os.FileMode)(0o644)
1931
-	if finfo.Mode() != fmode {
1932
-		t.Fatalf("Expected file mode %s, got %s", fmode.String(), finfo.Mode().String())
1933
-	}
1934
-
1935
-	content, err := os.ReadFile(resolvConfPath)
1936
-	if err != nil {
1937
-		t.Fatal(err)
1938
-	}
1939
-
1940
-	if !bytes.Equal(content, tmpResolvConf) {
1941
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(tmpResolvConf), string(content))
1942
-	}
1943
-}
1944
-
1945 1793
 func TestResolvConf(t *testing.T) {
1946
-	defer netnsutils.SetupTestOSContext(t)()
1947
-	controller := newController(t)
1948
-
1949
-	tmpResolvConf1 := []byte("search pommesfrites.fr\nnameserver 12.34.56.78\nnameserver 2001:4860:4860::8888\n")
1950
-	tmpResolvConf2 := []byte("search pommesfrites.fr\nnameserver 112.34.56.78\nnameserver 2001:4860:4860::8888\n")
1951
-	expectedResolvConf1 := []byte("search pommesfrites.fr\nnameserver 127.0.0.11\noptions ndots:0\n")
1952
-	tmpResolvConf3 := []byte("search pommesfrites.fr\nnameserver 113.34.56.78\n")
1953
-
1954
-	// take a copy of resolv.conf for restoring after test completes
1955
-	resolvConfSystem, err := os.ReadFile("/etc/resolv.conf")
1956
-	if err != nil {
1957
-		t.Fatal(err)
1958
-	}
1959
-	// cleanup
1960
-	defer func() {
1961
-		if err := os.WriteFile("/etc/resolv.conf", resolvConfSystem, 0o644); err != nil {
1962
-			t.Fatal(err)
1963
-		}
1964
-	}()
1965
-
1966
-	netOption := options.Generic{
1967
-		netlabel.GenericData: options.Generic{
1968
-			"BridgeName": "testnetwork",
1794
+	tmpDir := t.TempDir()
1795
+	originResolvConfPath := filepath.Join(tmpDir, "origin_resolv.conf")
1796
+	resolvConfPath := filepath.Join(tmpDir, "resolv.conf")
1797
+
1798
+	// Strip comments that end in a newline (a comment with no newline at the end
1799
+	// of the file will not be stripped).
1800
+	stripCommentsRE := regexp.MustCompile(`(?m)^#.*\n`)
1801
+
1802
+	testcases := []struct {
1803
+		name             string
1804
+		makeNet          func(t *testing.T, c *libnetwork.Controller) *libnetwork.Network
1805
+		delNet           bool
1806
+		epOpts           []libnetwork.EndpointOption
1807
+		sbOpts           []libnetwork.SandboxOption
1808
+		originResolvConf string
1809
+		expResolvConf    string
1810
+	}{
1811
+		{
1812
+			name:             "IPv6 network",
1813
+			makeNet:          makeTestIPv6Network,
1814
+			delNet:           true,
1815
+			originResolvConf: "search pommesfrites.fr\nnameserver 12.34.56.78\nnameserver 2001:4860:4860::8888\n",
1816
+			expResolvConf:    "nameserver 127.0.0.11\nnameserver 2001:4860:4860::8888\nsearch pommesfrites.fr\noptions ndots:0",
1817
+		},
1818
+		{
1819
+			name:             "host network",
1820
+			makeNet:          makeTesthostNetwork,
1821
+			epOpts:           []libnetwork.EndpointOption{libnetwork.CreateOptionDisableResolution()},
1822
+			sbOpts:           []libnetwork.SandboxOption{libnetwork.OptionUseDefaultSandbox()},
1823
+			originResolvConf: "search localhost.net\nnameserver 127.0.0.1\nnameserver 2001:4860:4860::8888\n",
1824
+			expResolvConf:    "nameserver 127.0.0.1\nnameserver 2001:4860:4860::8888\nsearch localhost.net",
1969 1825
 		},
1970
-	}
1971
-	n, err := createTestNetwork(controller, "bridge", "testnetwork", netOption, nil, nil)
1972
-	if err != nil {
1973
-		t.Fatal(err)
1974
-	}
1975
-	defer func() {
1976
-		if err := n.Delete(); err != nil {
1977
-			t.Fatal(err)
1978
-		}
1979
-	}()
1980
-
1981
-	ep, err := n.CreateEndpoint("ep")
1982
-	if err != nil {
1983
-		t.Fatal(err)
1984
-	}
1985
-
1986
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf1, 0o644); err != nil {
1987
-		t.Fatal(err)
1988
-	}
1989
-
1990
-	resolvConfPath := "/tmp/libnetwork_test/resolv.conf"
1991
-	defer os.Remove(resolvConfPath)
1992
-
1993
-	sb1, err := controller.NewSandbox(containerID, libnetwork.OptionResolvConfPath(resolvConfPath))
1994
-	if err != nil {
1995
-		t.Fatal(err)
1996
-	}
1997
-	defer func() {
1998
-		if err := sb1.Delete(); err != nil {
1999
-			t.Fatal(err)
2000
-		}
2001
-	}()
2002
-
2003
-	err = ep.Join(sb1)
2004
-	if err != nil {
2005
-		t.Fatal(err)
2006
-	}
2007
-
2008
-	finfo, err := os.Stat(resolvConfPath)
2009
-	if err != nil {
2010
-		t.Fatal(err)
2011
-	}
2012
-
2013
-	fmode := (os.FileMode)(0o644)
2014
-	if finfo.Mode() != fmode {
2015
-		t.Fatalf("Expected file mode %s, got %s", fmode.String(), finfo.Mode().String())
2016
-	}
2017
-
2018
-	content, err := os.ReadFile(resolvConfPath)
2019
-	if err != nil {
2020
-		t.Fatal(err)
2021
-	}
2022
-
2023
-	if !bytes.Equal(content, expectedResolvConf1) {
2024
-		fmt.Printf("\n%v\n%v\n", expectedResolvConf1, content)
2025
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(expectedResolvConf1), string(content))
2026
-	}
2027
-
2028
-	err = ep.Leave(sb1)
2029
-	if err != nil {
2030
-		t.Fatal(err)
2031
-	}
2032
-
2033
-	if err := os.WriteFile("/etc/resolv.conf", tmpResolvConf2, 0o644); err != nil {
2034
-		t.Fatal(err)
2035
-	}
2036
-
2037
-	sb2, err := controller.NewSandbox(containerID+"_2", libnetwork.OptionResolvConfPath(resolvConfPath))
2038
-	if err != nil {
2039
-		t.Fatal(err)
2040
-	}
2041
-	defer func() {
2042
-		if err := sb2.Delete(); err != nil {
2043
-			t.Fatal(err)
2044
-		}
2045
-	}()
2046
-
2047
-	err = ep.Join(sb2)
2048
-	if err != nil {
2049
-		t.Fatal(err)
2050
-	}
2051
-
2052
-	content, err = os.ReadFile(resolvConfPath)
2053
-	if err != nil {
2054
-		t.Fatal(err)
2055
-	}
2056
-
2057
-	if !bytes.Equal(content, expectedResolvConf1) {
2058
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(expectedResolvConf1), string(content))
2059
-	}
2060
-
2061
-	if err := os.WriteFile(resolvConfPath, tmpResolvConf3, 0o644); err != nil {
2062
-		t.Fatal(err)
2063 1826
 	}
2064 1827
 
2065
-	err = ep.Leave(sb2)
2066
-	if err != nil {
2067
-		t.Fatal(err)
2068
-	}
1828
+	for _, tc := range testcases {
1829
+		t.Run(tc.name, func(t *testing.T) {
1830
+			defer netnsutils.SetupTestOSContext(t)()
1831
+			c := newController(t)
2069 1832
 
2070
-	err = ep.Join(sb2)
2071
-	if err != nil {
2072
-		t.Fatal(err)
2073
-	}
1833
+			err := os.WriteFile(originResolvConfPath, []byte(tc.originResolvConf), 0o644)
1834
+			assert.NilError(t, err)
2074 1835
 
2075
-	content, err = os.ReadFile(resolvConfPath)
2076
-	if err != nil {
2077
-		t.Fatal(err)
2078
-	}
1836
+			n := tc.makeNet(t, c)
1837
+			if tc.delNet {
1838
+				defer func() {
1839
+					err := n.Delete()
1840
+					assert.Check(t, err)
1841
+				}()
1842
+			}
2079 1843
 
2080
-	if !bytes.Equal(content, tmpResolvConf3) {
2081
-		t.Fatalf("Expected:\n%s\nGot:\n%s", string(tmpResolvConf3), string(content))
1844
+			sbOpts := append(tc.sbOpts,
1845
+				libnetwork.OptionResolvConfPath(resolvConfPath),
1846
+				libnetwork.OptionOriginResolvConfPath(originResolvConfPath),
1847
+			)
1848
+			sb, err := c.NewSandbox(containerID, sbOpts...)
1849
+			assert.NilError(t, err)
1850
+			defer func() {
1851
+				err := sb.Delete()
1852
+				assert.Check(t, err)
1853
+			}()
1854
+
1855
+			ep, err := n.CreateEndpoint("ep", tc.epOpts...)
1856
+			assert.NilError(t, err)
1857
+			defer func() {
1858
+				err := ep.Delete(false)
1859
+				assert.Check(t, err)
1860
+			}()
1861
+
1862
+			err = ep.Join(sb)
1863
+			assert.NilError(t, err)
1864
+			defer func() {
1865
+				err := ep.Leave(sb)
1866
+				assert.Check(t, err)
1867
+			}()
1868
+
1869
+			finfo, err := os.Stat(resolvConfPath)
1870
+			assert.NilError(t, err)
1871
+			expFMode := (os.FileMode)(0o644)
1872
+			assert.Check(t, is.Equal(finfo.Mode().String(), expFMode.String()))
1873
+			content, err := os.ReadFile(resolvConfPath)
1874
+			assert.NilError(t, err)
1875
+			actual := stripCommentsRE.ReplaceAllString(string(content), "")
1876
+			actual = strings.TrimSpace(actual)
1877
+			assert.Check(t, is.Equal(actual, tc.expResolvConf))
1878
+		})
2082 1879
 	}
2083 1880
 }
2084 1881
 
... ...
@@ -3,20 +3,12 @@ package resolvconf
3 3
 
4 4
 import (
5 5
 	"bytes"
6
-	"context"
6
+	"fmt"
7 7
 	"os"
8
-	"regexp"
9 8
 	"strings"
10
-	"sync"
11 9
 
12
-	"github.com/containerd/log"
13
-)
14
-
15
-const (
16
-	// defaultPath is the default path to the resolv.conf that contains information to resolve DNS. See Path().
17
-	defaultPath = "/etc/resolv.conf"
18
-	// alternatePath is a path different from defaultPath, that may be used to resolve DNS. See Path().
19
-	alternatePath = "/run/systemd/resolve/resolv.conf"
10
+	"github.com/docker/docker/libnetwork/internal/resolvconf"
11
+	"github.com/opencontainers/go-digest"
20 12
 )
21 13
 
22 14
 // constants for the IP address type
... ...
@@ -26,72 +18,16 @@ const (
26 26
 	IPv6
27 27
 )
28 28
 
29
-var (
30
-	detectSystemdResolvConfOnce sync.Once
31
-	pathAfterSystemdDetection   = defaultPath
32
-)
33
-
34
-// Path returns the path to the resolv.conf file that libnetwork should use.
35
-//
36
-// When /etc/resolv.conf contains 127.0.0.53 as the only nameserver, then
37
-// it is assumed systemd-resolved manages DNS. Because inside the container 127.0.0.53
38
-// is not a valid DNS server, Path() returns /run/systemd/resolve/resolv.conf
39
-// which is the resolv.conf that systemd-resolved generates and manages.
40
-// Otherwise Path() returns /etc/resolv.conf.
41
-//
42
-// Errors are silenced as they will inevitably resurface at future open/read calls.
43
-//
44
-// More information at https://www.freedesktop.org/software/systemd/man/systemd-resolved.service.html#/etc/resolv.conf
45
-func Path() string {
46
-	detectSystemdResolvConfOnce.Do(func() {
47
-		candidateResolvConf, err := os.ReadFile(defaultPath)
48
-		if err != nil {
49
-			// silencing error as it will resurface at next calls trying to read defaultPath
50
-			return
51
-		}
52
-		ns := GetNameservers(candidateResolvConf, IP)
53
-		if len(ns) == 1 && ns[0] == "127.0.0.53" {
54
-			pathAfterSystemdDetection = alternatePath
55
-			log.G(context.TODO()).Infof("detected 127.0.0.53 nameserver, assuming systemd-resolved, so using resolv.conf: %s", alternatePath)
56
-		}
57
-	})
58
-	return pathAfterSystemdDetection
59
-}
60
-
61
-const (
62
-	// ipLocalhost is a regex pattern for IPv4 or IPv6 loopback range.
63
-	ipLocalhost  = `((127\.([0-9]{1,3}\.){2}[0-9]{1,3})|(::1)$)`
64
-	ipv4NumBlock = `(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)`
65
-	ipv4Address  = `(` + ipv4NumBlock + `\.){3}` + ipv4NumBlock
66
-
67
-	// This is not an IPv6 address verifier as it will accept a super-set of IPv6, and also
68
-	// will *not match* IPv4-Embedded IPv6 Addresses (RFC6052), but that and other variants
69
-	// -- e.g. other link-local types -- either won't work in containers or are unnecessary.
70
-	// For readability and sufficiency for Docker purposes this seemed more reasonable than a
71
-	// 1000+ character regexp with exact and complete IPv6 validation
72
-	ipv6Address = `([0-9A-Fa-f]{0,4}:){2,7}([0-9A-Fa-f]{0,4})(%\w+)?`
73
-)
74
-
75
-var (
76
-	// Note: the default IPv4 & IPv6 resolvers are set to Google's Public DNS
77
-	defaultIPv4Dns = []string{"nameserver 8.8.8.8", "nameserver 8.8.4.4"}
78
-	defaultIPv6Dns = []string{"nameserver 2001:4860:4860::8888", "nameserver 2001:4860:4860::8844"}
79
-
80
-	localhostNSRegexp = regexp.MustCompile(`(?m)^nameserver\s+` + ipLocalhost + `\s*\n*`)
81
-	nsIPv6Regexp      = regexp.MustCompile(`(?m)^nameserver\s+` + ipv6Address + `\s*\n*`)
82
-	nsRegexp          = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `)|(` + ipv6Address + `))\s*$`)
83
-	nsIPv6Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv6Address + `))\s*$`)
84
-	nsIPv4Regexpmatch = regexp.MustCompile(`^\s*nameserver\s*((` + ipv4Address + `))\s*$`)
85
-	searchRegexp      = regexp.MustCompile(`^\s*search\s*(([^\s]+\s*)*)$`)
86
-	optionsRegexp     = regexp.MustCompile(`^\s*options\s*(([^\s]+\s*)*)$`)
87
-)
88
-
89 29
 // File contains the resolv.conf content and its hash
90 30
 type File struct {
91 31
 	Content []byte
92 32
 	Hash    []byte
93 33
 }
94 34
 
35
+func Path() string {
36
+	return resolvconf.Path()
37
+}
38
+
95 39
 // Get returns the contents of /etc/resolv.conf and its hash
96 40
 func Get() (*File, error) {
97 41
 	return GetSpecific(Path())
... ...
@@ -103,7 +39,8 @@ func GetSpecific(path string) (*File, error) {
103 103
 	if err != nil {
104 104
 		return nil, err
105 105
 	}
106
-	return &File{Content: resolv, Hash: hashData(resolv)}, nil
106
+	hash := digest.FromBytes(resolv)
107
+	return &File{Content: resolv, Hash: []byte(hash)}, nil
107 108
 }
108 109
 
109 110
 // FilterResolvDNS cleans up the config in resolvConf.  It has two main jobs:
... ...
@@ -113,54 +50,34 @@ func GetSpecific(path string) (*File, error) {
113 113
 //  2. Given the caller provides the enable/disable state of IPv6, the filter
114 114
 //     code will remove all IPv6 nameservers if it is not enabled for containers
115 115
 func FilterResolvDNS(resolvConf []byte, ipv6Enabled bool) (*File, error) {
116
-	cleanedResolvConf := localhostNSRegexp.ReplaceAll(resolvConf, []byte{})
117
-	// if IPv6 is not enabled, also clean out any IPv6 address nameserver
118
-	if !ipv6Enabled {
119
-		cleanedResolvConf = nsIPv6Regexp.ReplaceAll(cleanedResolvConf, []byte{})
120
-	}
121
-	// if the resulting resolvConf has no more nameservers defined, add appropriate
122
-	// default DNS servers for IPv4 and (optionally) IPv6
123
-	if len(GetNameservers(cleanedResolvConf, IP)) == 0 {
124
-		log.G(context.TODO()).Infof("No non-localhost DNS nameservers are left in resolv.conf. Using default external servers: %v", defaultIPv4Dns)
125
-		dns := defaultIPv4Dns
126
-		if ipv6Enabled {
127
-			log.G(context.TODO()).Infof("IPv6 enabled; Adding default IPv6 external servers: %v", defaultIPv6Dns)
128
-			dns = append(dns, defaultIPv6Dns...)
129
-		}
130
-		cleanedResolvConf = append(cleanedResolvConf, []byte("\n"+strings.Join(dns, "\n"))...)
116
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
117
+	if err != nil {
118
+		return nil, err
131 119
 	}
132
-	return &File{Content: cleanedResolvConf, Hash: hashData(cleanedResolvConf)}, nil
133
-}
134
-
135
-// getLines parses input into lines and strips away comments.
136
-func getLines(input []byte, commentMarker []byte) [][]byte {
137
-	lines := bytes.Split(input, []byte("\n"))
138
-	var output [][]byte
139
-	for _, currentLine := range lines {
140
-		commentIndex := bytes.Index(currentLine, commentMarker)
141
-		if commentIndex == -1 {
142
-			output = append(output, currentLine)
143
-		} else {
144
-			output = append(output, currentLine[:commentIndex])
145
-		}
120
+	rc.TransformForLegacyNw(ipv6Enabled)
121
+	content, err := rc.Generate(false)
122
+	if err != nil {
123
+		return nil, err
146 124
 	}
147
-	return output
125
+	hash := digest.FromBytes(content)
126
+	return &File{Content: content, Hash: []byte(hash)}, nil
148 127
 }
149 128
 
150 129
 // GetNameservers returns nameservers (if any) listed in /etc/resolv.conf
151 130
 func GetNameservers(resolvConf []byte, kind int) []string {
131
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
132
+	if err != nil {
133
+		return nil
134
+	}
135
+	nsAddrs := rc.NameServers()
152 136
 	var nameservers []string
153
-	for _, line := range getLines(resolvConf, []byte("#")) {
154
-		var ns [][]byte
137
+	for _, addr := range nsAddrs {
155 138
 		if kind == IP {
156
-			ns = nsRegexp.FindSubmatch(line)
157
-		} else if kind == IPv4 {
158
-			ns = nsIPv4Regexpmatch.FindSubmatch(line)
159
-		} else if kind == IPv6 {
160
-			ns = nsIPv6Regexpmatch.FindSubmatch(line)
161
-		}
162
-		if len(ns) > 0 {
163
-			nameservers = append(nameservers, string(ns[1]))
139
+			nameservers = append(nameservers, addr.String())
140
+		} else if kind == IPv4 && addr.Is4() {
141
+			nameservers = append(nameservers, addr.String())
142
+		} else if kind == IPv6 && addr.Is6() {
143
+			nameservers = append(nameservers, addr.String())
164 144
 		}
165 145
 	}
166 146
 	return nameservers
... ...
@@ -170,16 +87,15 @@ func GetNameservers(resolvConf []byte, kind int) []string {
170 170
 // /etc/resolv.conf as CIDR blocks (e.g., "1.2.3.4/32")
171 171
 // This function's output is intended for net.ParseCIDR
172 172
 func GetNameserversAsCIDR(resolvConf []byte) []string {
173
-	var nameservers []string
174
-	for _, nameserver := range GetNameservers(resolvConf, IP) {
175
-		var address string
176
-		// If IPv6, strip zone if present
177
-		if strings.Contains(nameserver, ":") {
178
-			address = strings.Split(nameserver, "%")[0] + "/128"
179
-		} else {
180
-			address = nameserver + "/32"
181
-		}
182
-		nameservers = append(nameservers, address)
173
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
174
+	if err != nil {
175
+		return nil
176
+	}
177
+	nsAddrs := rc.NameServers()
178
+	nameservers := make([]string, 0, len(nsAddrs))
179
+	for _, addr := range nsAddrs {
180
+		str := fmt.Sprintf("%s/%d", addr.WithZone("").String(), addr.BitLen())
181
+		nameservers = append(nameservers, str)
183 182
 	}
184 183
 	return nameservers
185 184
 }
... ...
@@ -188,36 +104,30 @@ func GetNameserversAsCIDR(resolvConf []byte) []string {
188 188
 // If more than one search line is encountered, only the contents of the last
189 189
 // one is returned.
190 190
 func GetSearchDomains(resolvConf []byte) []string {
191
-	var domains []string
192
-	for _, line := range getLines(resolvConf, []byte("#")) {
193
-		match := searchRegexp.FindSubmatch(line)
194
-		if match == nil {
195
-			continue
196
-		}
197
-		domains = strings.Fields(string(match[1]))
191
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
192
+	if err != nil {
193
+		return nil
198 194
 	}
199
-	return domains
195
+	return rc.Search()
200 196
 }
201 197
 
202 198
 // GetOptions returns options (if any) listed in /etc/resolv.conf
203 199
 // If more than one options line is encountered, only the contents of the last
204 200
 // one is returned.
205 201
 func GetOptions(resolvConf []byte) []string {
206
-	var options []string
207
-	for _, line := range getLines(resolvConf, []byte("#")) {
208
-		match := optionsRegexp.FindSubmatch(line)
209
-		if match == nil {
210
-			continue
211
-		}
212
-		options = strings.Fields(string(match[1]))
202
+	rc, err := resolvconf.Parse(bytes.NewBuffer(resolvConf), "")
203
+	if err != nil {
204
+		return nil
213 205
 	}
214
-	return options
206
+	return rc.Options()
215 207
 }
216 208
 
217 209
 // Build generates and writes a configuration file to path containing a nameserver
218 210
 // entry for every element in nameservers, a "search" entry for every element in
219 211
 // dnsSearch, and an "options" entry for every element in dnsOptions. It returns
220 212
 // a File containing the generated content and its (sha256) hash.
213
+//
214
+// Note that the resolv.conf file is written, but the hash file is not.
221 215
 func Build(path string, nameservers, dnsSearch, dnsOptions []string) (*File, error) {
222 216
 	content := bytes.NewBuffer(nil)
223 217
 	if len(dnsSearch) > 0 {
... ...
@@ -244,5 +154,6 @@ func Build(path string, nameservers, dnsSearch, dnsOptions []string) (*File, err
244 244
 		return nil, err
245 245
 	}
246 246
 
247
-	return &File{Content: content.Bytes(), Hash: hashData(content.Bytes())}, nil
247
+	hash := digest.FromBytes(content.Bytes())
248
+	return &File{Content: content.Bytes(), Hash: []byte(hash)}, nil
248 249
 }
... ...
@@ -5,7 +5,12 @@ package resolvconf
5 5
 import (
6 6
 	"bytes"
7 7
 	"os"
8
+	"strings"
8 9
 	"testing"
10
+
11
+	"github.com/opencontainers/go-digest"
12
+	"gotest.tools/v3/assert"
13
+	is "gotest.tools/v3/assert/cmp"
9 14
 )
10 15
 
11 16
 func TestGet(t *testing.T) {
... ...
@@ -20,7 +25,8 @@ func TestGet(t *testing.T) {
20 20
 	if !bytes.Equal(actual.Content, expected) {
21 21
 		t.Errorf("%s and GetResolvConf have different content.", Path())
22 22
 	}
23
-	if !bytes.Equal(actual.Hash, hashData(expected)) {
23
+	hash := digest.FromBytes(expected)
24
+	if !bytes.Equal(actual.Hash, []byte(hash)) {
24 25
 		t.Errorf("%s and GetResolvConf have different hashes.", Path())
25 26
 	}
26 27
 }
... ...
@@ -111,6 +117,14 @@ nameserver 1.2.3.4
111 111
 nameserver 1.2.3.4 # not 4.3.2.1`,
112 112
 			result: []string{"1.2.3.4/32"},
113 113
 		},
114
+		{
115
+			input:  `nameserver fd6f:c490:ec68::1`,
116
+			result: []string{"fd6f:c490:ec68::1/128"},
117
+		},
118
+		{
119
+			input:  `nameserver fe80::1234%eth0`,
120
+			result: []string{"fe80::1234/128"},
121
+		},
114 122
 	} {
115 123
 		test := GetNameserversAsCIDR([]byte(tc.input))
116 124
 		if !strSlicesEqual(test, tc.result) {
... ...
@@ -175,6 +189,10 @@ search foo.example.com example.com
175 175
 nameserver 4.30.20.100`,
176 176
 			result: []string{"foo.example.com", "example.com"},
177 177
 		},
178
+		{
179
+			input:  `domain an.example`,
180
+			result: []string{"an.example"},
181
+		},
178 182
 	} {
179 183
 		test := GetSearchDomains([]byte(tc.input))
180 184
 		if !strSlicesEqual(test, tc.result) {
... ...
@@ -338,89 +356,79 @@ func TestBuildWithNoOptions(t *testing.T) {
338 338
 }
339 339
 
340 340
 func TestFilterResolvDNS(t *testing.T) {
341
-	ns0 := "nameserver 10.16.60.14\nnameserver 10.16.60.21\n"
342
-
343
-	if result, _ := FilterResolvDNS([]byte(ns0), false); result != nil {
344
-		if ns0 != string(result.Content) {
345
-			t.Errorf("Failed No Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
346
-		}
347
-	}
348
-
349
-	ns1 := "nameserver 10.16.60.14\nnameserver 10.16.60.21\nnameserver 127.0.0.1\n"
350
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
351
-		if ns0 != string(result.Content) {
352
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
353
-		}
354
-	}
355
-
356
-	ns1 = "nameserver 10.16.60.14\nnameserver 127.0.0.1\nnameserver 10.16.60.21\n"
357
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
358
-		if ns0 != string(result.Content) {
359
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
360
-		}
361
-	}
362
-
363
-	ns1 = "nameserver 127.0.1.1\nnameserver 10.16.60.14\nnameserver 10.16.60.21\n"
364
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
365
-		if ns0 != string(result.Content) {
366
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
367
-		}
368
-	}
369
-
370
-	ns1 = "nameserver ::1\nnameserver 10.16.60.14\nnameserver 127.0.2.1\nnameserver 10.16.60.21\n"
371
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
372
-		if ns0 != string(result.Content) {
373
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
374
-		}
375
-	}
376
-
377
-	ns1 = "nameserver 10.16.60.14\nnameserver ::1\nnameserver 10.16.60.21\nnameserver ::1"
378
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
379
-		if ns0 != string(result.Content) {
380
-			t.Errorf("Failed Localhost: expected \n<%s> got \n<%s>", ns0, string(result.Content))
381
-		}
382
-	}
383
-
384
-	// with IPv6 disabled (false param), the IPv6 nameserver should be removed
385
-	ns1 = "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1"
386
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
387
-		if ns0 != string(result.Content) {
388
-			t.Errorf("Failed Localhost+IPv6 off: expected \n<%s> got \n<%s>", ns0, string(result.Content))
389
-		}
390
-	}
391
-
392
-	// with IPv6 disabled (false param), the IPv6 link-local nameserver with zone ID should be removed
393
-	ns1 = "nameserver 10.16.60.14\nnameserver FE80::BB1%1\nnameserver FE80::BB1%eth0\nnameserver 10.16.60.21\n"
394
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
395
-		if ns0 != string(result.Content) {
396
-			t.Errorf("Failed Localhost+IPv6 off: expected \n<%s> got \n<%s>", ns0, string(result.Content))
397
-		}
398
-	}
399
-
400
-	// with IPv6 enabled, the IPv6 nameserver should be preserved
401
-	ns0 = "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\n"
402
-	ns1 = "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1"
403
-	if result, _ := FilterResolvDNS([]byte(ns1), true); result != nil {
404
-		if ns0 != string(result.Content) {
405
-			t.Errorf("Failed Localhost+IPv6 on: expected \n<%s> got \n<%s>", ns0, string(result.Content))
406
-		}
407
-	}
408
-
409
-	// with IPv6 enabled, and no non-localhost servers, Google defaults (both IPv4+IPv6) should be added
410
-	ns0 = "\nnameserver 8.8.8.8\nnameserver 8.8.4.4\nnameserver 2001:4860:4860::8888\nnameserver 2001:4860:4860::8844"
411
-	ns1 = "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1"
412
-	if result, _ := FilterResolvDNS([]byte(ns1), true); result != nil {
413
-		if ns0 != string(result.Content) {
414
-			t.Errorf("Failed no Localhost+IPv6 enabled: expected \n<%s> got \n<%s>", ns0, string(result.Content))
415
-		}
341
+	testcases := []struct {
342
+		name        string
343
+		input       string
344
+		ipv6Enabled bool
345
+		expOut      string
346
+	}{
347
+		{
348
+			name:   "No localhost",
349
+			input:  "nameserver 10.16.60.14\nnameserver 10.16.60.21\n",
350
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
351
+		},
352
+		{
353
+			name:   "Localhost last",
354
+			input:  "nameserver 10.16.60.14\nnameserver 10.16.60.21\nnameserver 127.0.0.1\n",
355
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
356
+		},
357
+		{
358
+			name:   "Localhost middle",
359
+			input:  "nameserver 10.16.60.14\nnameserver 127.0.0.1\nnameserver 10.16.60.21\n",
360
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
361
+		},
362
+		{
363
+			name:   "Localhost first",
364
+			input:  "nameserver 127.0.1.1\nnameserver 10.16.60.14\nnameserver 10.16.60.21\n",
365
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
366
+		},
367
+		{
368
+			name:   "IPv6 Localhost",
369
+			input:  "nameserver ::1\nnameserver 10.16.60.14\nnameserver 127.0.2.1\nnameserver 10.16.60.21\n",
370
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
371
+		},
372
+		{
373
+			name:   "Two IPv6 Localhosts",
374
+			input:  "nameserver 10.16.60.14\nnameserver ::1\nnameserver 10.16.60.21\nnameserver ::1",
375
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
376
+		},
377
+		{
378
+			name:   "IPv6 disabled",
379
+			input:  "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1",
380
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
381
+		},
382
+		{
383
+			name:   "IPv6 link-local disabled",
384
+			input:  "nameserver 10.16.60.14\nnameserver FE80::BB1%1\nnameserver FE80::BB1%eth0\nnameserver 10.16.60.21",
385
+			expOut: "nameserver 10.16.60.14\nnameserver 10.16.60.21",
386
+		},
387
+		{
388
+			name:        "IPv6 enabled",
389
+			input:       "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21\nnameserver ::1\n",
390
+			ipv6Enabled: true,
391
+			expOut:      "nameserver 10.16.60.14\nnameserver 2002:dead:beef::1\nnameserver 10.16.60.21",
392
+		},
393
+		{
394
+			// with IPv6 enabled, and no non-localhost servers, Google defaults (both IPv4+IPv6) should be added
395
+			name:        "localhost only IPv6",
396
+			input:       "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1",
397
+			ipv6Enabled: true,
398
+			expOut:      "nameserver 8.8.8.8\nnameserver 8.8.4.4\nnameserver 2001:4860:4860::8888\nnameserver 2001:4860:4860::8844",
399
+		},
400
+		{
401
+			// with IPv6 disabled, and no non-localhost servers, Google defaults (only IPv4) should be added
402
+			name:   "localhost only no IPv6",
403
+			input:  "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1",
404
+			expOut: "nameserver 8.8.8.8\nnameserver 8.8.4.4",
405
+		},
416 406
 	}
417 407
 
418
-	// with IPv6 disabled, and no non-localhost servers, Google defaults (only IPv4) should be added
419
-	ns0 = "\nnameserver 8.8.8.8\nnameserver 8.8.4.4"
420
-	ns1 = "nameserver 127.0.0.1\nnameserver ::1\nnameserver 127.0.2.1"
421
-	if result, _ := FilterResolvDNS([]byte(ns1), false); result != nil {
422
-		if ns0 != string(result.Content) {
423
-			t.Errorf("Failed no Localhost+IPv6 enabled: expected \n<%s> got \n<%s>", ns0, string(result.Content))
424
-		}
408
+	for _, tc := range testcases {
409
+		t.Run(tc.name, func(t *testing.T) {
410
+			f, err := FilterResolvDNS([]byte(tc.input), tc.ipv6Enabled)
411
+			assert.Check(t, is.Nil(err))
412
+			out := strings.TrimSpace(string(f.Content))
413
+			assert.Check(t, is.Equal(out, tc.expOut))
414
+		})
425 415
 	}
426 416
 }
427 417
deleted file mode 100644
... ...
@@ -1,14 +0,0 @@
1
-package resolvconf
2
-
3
-import (
4
-	"crypto/sha256"
5
-	"encoding/hex"
6
-)
7
-
8
-// hashData returns the sha256 sum of data.
9
-func hashData(data []byte) []byte {
10
-	f := sha256.Sum256(data)
11
-	out := make([]byte, 2*sha256.Size)
12
-	hex.Encode(out, f[:])
13
-	return append([]byte("sha256:"), out...)
14
-}
15 1
deleted file mode 100644
... ...
@@ -1,21 +0,0 @@
1
-package resolvconf
2
-
3
-import (
4
-	"bytes"
5
-	"testing"
6
-)
7
-
8
-func TestHashData(t *testing.T) {
9
-	const expected = "sha256:4d11186aed035cc624d553e10db358492c84a7cd6b9670d92123c144930450aa"
10
-	if actual := hashData([]byte("hash-me")); !bytes.Equal(actual, []byte(expected)) {
11
-		t.Fatalf("Expecting %s, got %s", expected, string(actual))
12
-	}
13
-}
14
-
15
-func BenchmarkHashData(b *testing.B) {
16
-	b.ReportAllocs()
17
-	data := []byte("hash-me")
18
-	for i := 0; i < b.N; i++ {
19
-		_ = hashData(data)
20
-	}
21
-}
... ...
@@ -3,22 +3,19 @@
3 3
 package libnetwork
4 4
 
5 5
 import (
6
-	"bytes"
7 6
 	"context"
8
-	"fmt"
9
-	"net"
7
+	"io/fs"
10 8
 	"net/netip"
11 9
 	"os"
12
-	"path"
13 10
 	"path/filepath"
14
-	"strconv"
15 11
 	"strings"
16 12
 
17 13
 	"github.com/containerd/log"
18 14
 	"github.com/docker/docker/errdefs"
19 15
 	"github.com/docker/docker/libnetwork/etchosts"
20
-	"github.com/docker/docker/libnetwork/resolvconf"
16
+	"github.com/docker/docker/libnetwork/internal/resolvconf"
21 17
 	"github.com/docker/docker/libnetwork/types"
18
+	"github.com/pkg/errors"
22 19
 )
23 20
 
24 21
 const (
... ...
@@ -100,6 +97,13 @@ func (sb *Sandbox) setupResolutionFiles() error {
100 100
 }
101 101
 
102 102
 func (sb *Sandbox) buildHostsFile() error {
103
+	sb.restoreHostsPath()
104
+
105
+	dir, _ := filepath.Split(sb.config.hostsPath)
106
+	if err := createBasePath(dir); err != nil {
107
+		return err
108
+	}
109
+
103 110
 	// This is for the host mode networking
104 111
 	if sb.config.useDefaultSandBox && len(sb.config.extraHosts) == 0 {
105 112
 		// 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 {
208 208
 	return nil
209 209
 }
210 210
 
211
-func (sb *Sandbox) restorePath() {
211
+func (sb *Sandbox) restoreResolvConfPath() {
212 212
 	if sb.config.resolvConfPath == "" {
213 213
 		sb.config.resolvConfPath = defaultPrefix + "/" + sb.id + "/resolv.conf"
214 214
 	}
215 215
 	sb.config.resolvConfHashFile = sb.config.resolvConfPath + ".hash"
216
+}
217
+
218
+func (sb *Sandbox) restoreHostsPath() {
216 219
 	if sb.config.hostsPath == "" {
217 220
 		sb.config.hostsPath = defaultPrefix + "/" + sb.id + "/hosts"
218 221
 	}
219 222
 }
220 223
 
221
-func (sb *Sandbox) setExternalResolvers(content []byte, addrType int, checkLoopback bool) {
222
-	servers := resolvconf.GetNameservers(content, addrType)
223
-	for _, ip := range servers {
224
-		hostLoopback := false
225
-		if checkLoopback && isIPv4Loopback(ip) {
226
-			hostLoopback = true
227
-		}
224
+func (sb *Sandbox) setExternalResolvers(entries []resolvconf.ExtDNSEntry) {
225
+	sb.extDNS = make([]extDNSEntry, 0, len(entries))
226
+	for _, entry := range entries {
228 227
 		sb.extDNS = append(sb.extDNS, extDNSEntry{
229
-			IPStr:        ip,
230
-			HostLoopback: hostLoopback,
228
+			IPStr:        entry.Addr.String(),
229
+			HostLoopback: entry.HostLoopback,
231 230
 		})
232 231
 	}
233 232
 }
234 233
 
235
-// isIPv4Loopback checks if the given IP address is an IPv4 loopback address.
236
-// It's based on the logic in Go's net.IP.IsLoopback(), but only the IPv4 part:
237
-// https://github.com/golang/go/blob/go1.16.6/src/net/ip.go#L120-L126
238
-func isIPv4Loopback(ipAddress string) bool {
239
-	if ip := net.ParseIP(ipAddress); ip != nil {
240
-		if ip4 := ip.To4(); ip4 != nil {
241
-			return ip4[0] == 127
242
-		}
234
+func (c *containerConfig) getOriginResolvConfPath() string {
235
+	if c.originResolvConfPath != "" {
236
+		return c.originResolvConfPath
243 237
 	}
244
-	return false
238
+	// Fallback if not specified.
239
+	return resolvconf.Path()
245 240
 }
246 241
 
247
-func (sb *Sandbox) setupDNS() error {
248
-	if sb.config.resolvConfPath == "" {
249
-		sb.config.resolvConfPath = defaultPrefix + "/" + sb.id + "/resolv.conf"
242
+// loadResolvConf reads the resolv.conf file at path, and merges in overrides for
243
+// nameservers, options, and search domains.
244
+func (sb *Sandbox) loadResolvConf(path string) (*resolvconf.ResolvConf, error) {
245
+	rc, err := resolvconf.Load(path)
246
+	if err != nil && !errors.Is(err, fs.ErrNotExist) {
247
+		return nil, err
248
+	}
249
+	// Proceed with rc, which might be zero-valued if path does not exist.
250
+
251
+	rc.SetHeader(`# Generated by Docker Engine.
252
+# This file can be edited; Docker Engine will not make further changes once it
253
+# has been modified.`)
254
+	if len(sb.config.dnsList) > 0 {
255
+		var dnsAddrs []netip.Addr
256
+		for _, ns := range sb.config.dnsList {
257
+			addr, err := netip.ParseAddr(ns)
258
+			if err != nil {
259
+				return nil, errors.Wrapf(err, "bad nameserver address %s", ns)
260
+			}
261
+			dnsAddrs = append(dnsAddrs, addr)
262
+		}
263
+		rc.OverrideNameServers(dnsAddrs)
250 264
 	}
265
+	if len(sb.config.dnsSearchList) > 0 {
266
+		rc.OverrideSearch(sb.config.dnsSearchList)
267
+	}
268
+	if len(sb.config.dnsOptionsList) > 0 {
269
+		rc.OverrideOptions(sb.config.dnsOptionsList)
270
+	}
271
+	return &rc, nil
272
+}
251 273
 
252
-	sb.config.resolvConfHashFile = sb.config.resolvConfPath + ".hash"
253
-
274
+// For a new sandbox, write an initial version of the container's resolv.conf. It'll
275
+// be a copy of the host's file, with overrides for nameservers, options and search
276
+// domains applied.
277
+func (sb *Sandbox) setupDNS() error {
278
+	// Make sure the directory exists.
279
+	sb.restoreResolvConfPath()
254 280
 	dir, _ := filepath.Split(sb.config.resolvConfPath)
255 281
 	if err := createBasePath(dir); err != nil {
256 282
 		return err
257 283
 	}
258 284
 
259
-	// When the user specify a conainter in the host namespace and do no have any dns option specified
260
-	// we just copy the host resolv.conf from the host itself
261
-	if sb.config.useDefaultSandBox && len(sb.config.dnsList) == 0 && len(sb.config.dnsSearchList) == 0 && len(sb.config.dnsOptionsList) == 0 {
262
-		// We are working under the assumption that the origin file option had been properly expressed by the upper layer
263
-		// if not here we are going to error out
264
-		if err := copyFile(sb.config.originResolvConfPath, sb.config.resolvConfPath); err != nil {
265
-			if !os.IsNotExist(err) {
266
-				return fmt.Errorf("could not copy source resolv.conf file %s to %s: %v", sb.config.originResolvConfPath, sb.config.resolvConfPath, err)
267
-			}
268
-			log.G(context.TODO()).Infof("%s does not exist, we create an empty resolv.conf for container", sb.config.originResolvConfPath)
269
-			if err := createFile(sb.config.resolvConfPath); err != nil {
270
-				return err
271
-			}
272
-		}
273
-		return nil
274
-	}
275
-
276
-	originResolvConfPath := sb.config.originResolvConfPath
277
-	if originResolvConfPath == "" {
278
-		// fallback if not specified
279
-		originResolvConfPath = resolvconf.Path()
280
-	}
281
-	currRC, err := os.ReadFile(originResolvConfPath)
285
+	rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath())
282 286
 	if err != nil {
283
-		if !os.IsNotExist(err) {
284
-			return err
285
-		}
286
-		// No /etc/resolv.conf found: we'll use the default resolvers (Google's Public DNS).
287
-		log.G(context.TODO()).WithField("path", originResolvConfPath).Infof("no resolv.conf found, falling back to defaults")
288
-	}
289
-
290
-	var newRC *resolvconf.File
291
-	if len(sb.config.dnsList) > 0 || len(sb.config.dnsSearchList) > 0 || len(sb.config.dnsOptionsList) > 0 {
292
-		var (
293
-			dnsList        = sb.config.dnsList
294
-			dnsSearchList  = sb.config.dnsSearchList
295
-			dnsOptionsList = sb.config.dnsOptionsList
296
-		)
297
-		if len(sb.config.dnsList) == 0 {
298
-			dnsList = resolvconf.GetNameservers(currRC, resolvconf.IP)
299
-		}
300
-		if len(sb.config.dnsSearchList) == 0 {
301
-			dnsSearchList = resolvconf.GetSearchDomains(currRC)
302
-		}
303
-		if len(sb.config.dnsOptionsList) == 0 {
304
-			dnsOptionsList = resolvconf.GetOptions(currRC)
305
-		}
306
-		newRC, err = resolvconf.Build(sb.config.resolvConfPath, dnsList, dnsSearchList, dnsOptionsList)
307
-		if err != nil {
308
-			return err
309
-		}
310
-		// After building the resolv.conf from the user config save the
311
-		// external resolvers in the sandbox. Note that --dns 127.0.0.x
312
-		// config refers to the loopback in the container namespace
313
-		sb.setExternalResolvers(newRC.Content, resolvconf.IPv4, len(sb.config.dnsList) == 0)
314
-	} else {
315
-		// If the host resolv.conf file has 127.0.0.x container should
316
-		// use the host resolver for queries. This is supported by the
317
-		// docker embedded DNS server. Hence save the external resolvers
318
-		// before filtering it out.
319
-		sb.setExternalResolvers(currRC, resolvconf.IPv4, true)
320
-
321
-		// Replace any localhost/127.* (at this point we have no info about ipv6, pass it as true)
322
-		newRC, err = resolvconf.FilterResolvDNS(currRC, true)
323
-		if err != nil {
324
-			return err
325
-		}
326
-		// No contention on container resolv.conf file at sandbox creation
327
-		err = os.WriteFile(sb.config.resolvConfPath, newRC.Content, filePerm)
328
-		if err != nil {
329
-			return types.InternalErrorf("failed to write unhaltered resolv.conf file content when setting up dns for sandbox %s: %v", sb.ID(), err)
330
-		}
331
-	}
332
-
333
-	// Write hash
334
-	err = os.WriteFile(sb.config.resolvConfHashFile, newRC.Hash, filePerm)
335
-	if err != nil {
336
-		return types.InternalErrorf("failed to write resolv.conf hash file when setting up dns for sandbox %s: %v", sb.ID(), err)
287
+		return err
337 288
 	}
338
-
339
-	return nil
289
+	return rc.WriteFile(sb.config.resolvConfPath, sb.config.resolvConfHashFile, filePerm)
340 290
 }
341 291
 
292
+// Called when an endpoint has joined the sandbox.
342 293
 func (sb *Sandbox) updateDNS(ipv6Enabled bool) error {
343
-	// This is for the host mode networking
344
-	if sb.config.useDefaultSandBox {
345
-		return nil
346
-	}
347
-
348
-	if len(sb.config.dnsList) > 0 || len(sb.config.dnsSearchList) > 0 || len(sb.config.dnsOptionsList) > 0 {
349
-		return nil
294
+	if mod, err := resolvconf.UserModified(sb.config.resolvConfPath, sb.config.resolvConfHashFile); err != nil || mod {
295
+		return err
350 296
 	}
351 297
 
352
-	var currHash []byte
353
-	currRC, err := resolvconf.GetSpecific(sb.config.resolvConfPath)
298
+	// Load the host's resolv.conf as a starting point.
299
+	rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath())
354 300
 	if err != nil {
355
-		if !os.IsNotExist(err) {
356
-			return err
357
-		}
358
-	} else {
359
-		currHash, err = os.ReadFile(sb.config.resolvConfHashFile)
360
-		if err != nil && !os.IsNotExist(err) {
361
-			return err
362
-		}
301
+		return err
363 302
 	}
364
-
365
-	if len(currHash) > 0 && !bytes.Equal(currHash, currRC.Hash) {
366
-		// Seems the user has changed the container resolv.conf since the last time
367
-		// we checked so return without doing anything.
368
-		// log.G(ctx).Infof("Skipping update of resolv.conf file with ipv6Enabled: %t because file was touched by user", ipv6Enabled)
369
-		return nil
303
+	// For host-networking, no further change is needed.
304
+	if !sb.config.useDefaultSandBox {
305
+		// The legacy bridge network has no internal nameserver. So, strip localhost
306
+		// nameservers from the host's config, then add default nameservers if there
307
+		// are none remaining.
308
+		rc.TransformForLegacyNw(ipv6Enabled)
370 309
 	}
310
+	return rc.WriteFile(sb.config.resolvConfPath, sb.config.resolvConfHashFile, filePerm)
311
+}
371 312
 
372
-	// replace any localhost/127.* and remove IPv6 nameservers if IPv6 disabled.
373
-	newRC, err := resolvconf.FilterResolvDNS(currRC.Content, ipv6Enabled)
374
-	if err != nil {
375
-		return err
376
-	}
377
-	err = os.WriteFile(sb.config.resolvConfPath, newRC.Content, filePerm)
378
-	if err != nil {
313
+// Embedded DNS server has to be enabled for this sandbox. Rebuild the container's resolv.conf.
314
+func (sb *Sandbox) rebuildDNS() error {
315
+	// Don't touch the file if the user has modified it.
316
+	if mod, err := resolvconf.UserModified(sb.config.resolvConfPath, sb.config.resolvConfHashFile); err != nil || mod {
379 317
 		return err
380 318
 	}
381 319
 
382
-	// write the new hash in a temp file and rename it to make the update atomic
383
-	dir := path.Dir(sb.config.resolvConfPath)
384
-	tmpHashFile, err := os.CreateTemp(dir, "hash")
320
+	// Load the host's resolv.conf as a starting point.
321
+	rc, err := sb.loadResolvConf(sb.config.getOriginResolvConfPath())
385 322
 	if err != nil {
386 323
 		return err
387 324
 	}
388
-	if err = tmpHashFile.Chmod(filePerm); err != nil {
389
-		tmpHashFile.Close()
390
-		return err
391
-	}
392
-	_, err = tmpHashFile.Write(newRC.Hash)
393
-	if err1 := tmpHashFile.Close(); err == nil {
394
-		err = err1
325
+
326
+	// Check for IPv6 endpoints in this sandbox. If there are any, IPv6 nameservers
327
+	// will be left in the container's 'resolv.conf'.
328
+	// TODO(robmry) - preserving old behaviour, but ...
329
+	//   IPv6 nameservers should be treated like IPv4 ones, and used as upstream
330
+	//   servers for the internal resolver (if it has IPv6 connectivity). This
331
+	//   doesn't need to depend on whether there are currently any IPv6 endpoints.
332
+	//   Removing IPv6 nameservers from the container's resolv.conf will avoid the
333
+	//   problem that musl-libc's resolver tries all nameservers in parallel, so an
334
+	//   external IPv6 resolver can return NXDOMAIN before the internal resolver
335
+	//   returns the address of a container.
336
+	ipv6 := false
337
+	for _, ep := range sb.endpoints {
338
+		if ep.network.enableIPv6 {
339
+			ipv6 = true
340
+			break
341
+		}
395 342
 	}
343
+
344
+	intNS, err := netip.ParseAddr(sb.resolver.NameServer())
396 345
 	if err != nil {
397 346
 		return err
398 347
 	}
399
-	return os.Rename(tmpHashFile.Name(), sb.config.resolvConfHashFile)
400
-}
401 348
 
402
-// Embedded DNS server has to be enabled for this sandbox. Rebuild the container's
403
-// resolv.conf by doing the following
404
-// - Add only the embedded server's IP to container's resolv.conf
405
-// - If the embedded server needs any resolv.conf options add it to the current list
406
-func (sb *Sandbox) rebuildDNS() error {
407
-	currRC, err := os.ReadFile(sb.config.resolvConfPath)
349
+	// Work out whether ndots has been set from host config or overrides.
350
+	_, sb.ndotsSet = rc.Option("ndots")
351
+	// Swap nameservers for the internal one, and make sure the required options are set.
352
+	var extNameServers []resolvconf.ExtDNSEntry
353
+	extNameServers, err = rc.TransformForIntNS(ipv6, intNS, sb.resolver.ResolverOptions())
408 354
 	if err != nil {
409 355
 		return err
410 356
 	}
411
-
412
-	// If the user config and embedded DNS server both have ndots option set,
413
-	// remember the user's config so that unqualified names not in the docker
414
-	// domain can be dropped.
415
-	resOptions := sb.resolver.ResolverOptions()
416
-	dnsOptionsList := resolvconf.GetOptions(currRC)
417
-
418
-dnsOpt:
419
-	for _, resOpt := range resOptions {
420
-		if strings.Contains(resOpt, "ndots") {
421
-			for _, option := range dnsOptionsList {
422
-				if strings.Contains(option, "ndots") {
423
-					parts := strings.Split(option, ":")
424
-					if len(parts) != 2 {
425
-						return fmt.Errorf("invalid ndots option %v", option)
426
-					}
427
-					if num, err := strconv.Atoi(parts[1]); err != nil {
428
-						return fmt.Errorf("invalid number for ndots option: %v", parts[1])
429
-					} else if num >= 0 {
430
-						// if the user sets ndots, use the user setting
431
-						sb.ndotsSet = true
432
-						break dnsOpt
433
-					} else {
434
-						return fmt.Errorf("invalid number for ndots option: %v", num)
435
-					}
436
-				}
437
-			}
438
-		}
439
-	}
440
-
441
-	if !sb.ndotsSet {
442
-		// if the user did not set the ndots, set it to 0 to prioritize the service name resolution
443
-		// Ref: https://linux.die.net/man/5/resolv.conf
444
-		dnsOptionsList = append(dnsOptionsList, resOptions...)
445
-	}
446
-	if len(sb.extDNS) == 0 {
447
-		sb.setExternalResolvers(currRC, resolvconf.IPv4, false)
448
-	}
449
-
450
-	var (
451
-		// external v6 DNS servers have to be listed in resolv.conf
452
-		dnsList       = append([]string{sb.resolver.NameServer()}, resolvconf.GetNameservers(currRC, resolvconf.IPv6)...)
453
-		dnsSearchList = resolvconf.GetSearchDomains(currRC)
454
-	)
455
-
456
-	_, err = resolvconf.Build(sb.config.resolvConfPath, dnsList, dnsSearchList, dnsOptionsList)
457
-	return err
357
+	// Extract the list of nameservers that just got swapped out, and store them as
358
+	// upstream nameservers.
359
+	sb.setExternalResolvers(extNameServers)
360
+
361
+	// Write the file for the container - preserving old behaviour, not updating the
362
+	// hash file (so, no further updates will be made).
363
+	// TODO(robmry) - I think that's probably accidental, I can't find a reason for it,
364
+	//  and the old resolvconf.Build() function wrote the file but not the hash, which
365
+	//  is surprising. But, before fixing it, a guard/flag needs to be added to
366
+	//  sb.updateDNS() to make sure that when an endpoint joins a sandbox that already
367
+	//  has an internal resolver, the container's resolv.conf is still (re)configured
368
+	//  for an internal resolver.
369
+	return rc.WriteFile(sb.config.resolvConfPath, "", filePerm)
458 370
 }
459 371
 
460 372
 func createBasePath(dir string) error {
461 373
 	return os.MkdirAll(dir, dirPerm)
462 374
 }
463 375
 
464
-func createFile(path string) error {
465
-	var f *os.File
466
-
467
-	dir, _ := filepath.Split(path)
468
-	err := createBasePath(dir)
469
-	if err != nil {
470
-		return err
471
-	}
472
-
473
-	f, err = os.Create(path)
474
-	if err == nil {
475
-		f.Close()
476
-	}
477
-
478
-	return err
479
-}
480
-
481 376
 func copyFile(src, dst string) error {
482 377
 	sBytes, err := os.ReadFile(src)
483 378
 	if err != nil {
... ...
@@ -12,7 +12,9 @@ func (sb *Sandbox) setupResolutionFiles() error {
12 12
 	return nil
13 13
 }
14 14
 
15
-func (sb *Sandbox) restorePath() {}
15
+func (sb *Sandbox) restoreHostsPath() {}
16
+
17
+func (sb *Sandbox) restoreResolvConfPath() {}
16 18
 
17 19
 func (sb *Sandbox) updateHostsFile(ifaceIP []string) error {
18 20
 	return nil
... ...
@@ -206,7 +206,8 @@ func (c *Controller) sandboxCleanup(activeSandboxes map[string]interface{}) erro
206 206
 			isRestore = true
207 207
 			opts := val.([]SandboxOption)
208 208
 			sb.processOptions(opts...)
209
-			sb.restorePath()
209
+			sb.restoreHostsPath()
210
+			sb.restoreResolvConfPath()
210 211
 			create = !sb.config.useDefaultSandBox
211 212
 		}
212 213
 		sb.osSbox, err = osl.NewSandbox(sb.Key(), create, isRestore)