diff --git a/daemon/libnetwork/sandbox_dns_unix.go b/daemon/libnetwork/sandbox_dns_unix.go index ca18a75919..a28181b896 100644 --- a/daemon/libnetwork/sandbox_dns_unix.go +++ b/daemon/libnetwork/sandbox_dns_unix.go @@ -264,8 +264,17 @@ func (sb *Sandbox) loadResolvConf(path string) (*resolvconf.ResolvConf, error) { // be a copy of the host's file, with overrides for nameservers, options and search // domains applied. func (sb *Sandbox) setupDNS() error { - // Make sure the directory exists. sb.restoreResolvConfPath() + + // When the container is restarted, a new Sandbox is created but the same resolv.conf is re-used. If it was + // user-modified, do not attempt to overwrite it. + if !sb.config.useDefaultSandBox { + if mod, err := resolvconf.UserModified(sb.config.resolvConfPath, sb.config.resolvConfHashFile); err != nil || mod { + return err + } + } + + // Make sure the directory exists. dir, _ := filepath.Split(sb.config.resolvConfPath) if err := createBasePath(dir); err != nil { return err diff --git a/daemon/libnetwork/sandbox_dns_unix_test.go b/daemon/libnetwork/sandbox_dns_unix_test.go index 3bb64cf5ce..49439d3571 100644 --- a/daemon/libnetwork/sandbox_dns_unix_test.go +++ b/daemon/libnetwork/sandbox_dns_unix_test.go @@ -14,12 +14,17 @@ import ( is "gotest.tools/v3/assert/cmp" ) -func getResolvConfOptions(t *testing.T, rcPath string) []string { +func getResolvConf(t *testing.T, rcPath string) resolvconf.ResolvConf { t.Helper() resolv, err := os.ReadFile(rcPath) assert.NilError(t, err) rc, err := resolvconf.Parse(bytes.NewBuffer(resolv), "") assert.NilError(t, err) + return rc +} + +func getResolvConfOptions(t *testing.T, rcPath string) []string { + rc := getResolvConf(t, rcPath) return rc.Options() } @@ -90,3 +95,60 @@ func TestDNSOptions(t *testing.T) { dnsOptionsList = getResolvConfOptions(t, sb2.config.resolvConfPath) assert.Check(t, is.DeepEqual([]string{"ndots:0"}, dnsOptionsList)) } + +func TestNonHostNetDNSRestart(t *testing.T) { + c, err := New(context.Background(), config.OptionDataDir(t.TempDir())) + assert.NilError(t, err) + + // Step 1: Create initial sandbox (simulating first container start) + sb, err := c.NewSandbox(context.Background(), "cnt1") + assert.NilError(t, err) + + defer func() { + _ = sb.Delete(context.Background()) + }() + + sb.startResolver(false) + + err = sb.setupDNS() + assert.NilError(t, err) + err = sb.rebuildDNS() + assert.NilError(t, err) + + // Step 2: Simulate user manually overwriting the container's resolv.conf + resolvConfPath := sb.config.resolvConfPath + modifiedContent := []byte(`nameserver 1.1.1.1`) + err = os.WriteFile(resolvConfPath, modifiedContent, 0644) + assert.NilError(t, err) + + // Step 3: Delete the sandbox (simulating container stop) + err = sb.Delete(context.Background()) + assert.NilError(t, err) + + // Step 4: Create a new sandbox (simulating container restart) + sbRestart, err := c.NewSandbox(context.Background(), "cnt1", + OptionResolvConfPath(resolvConfPath), + ) + assert.NilError(t, err) + defer func() { + if err := sbRestart.Delete(context.Background()); err != nil { + t.Error(err) + } + }() + + sbRestart.startResolver(false) + + // Step 5: Call setupDNS on restart - should preserve user modifications + err = sbRestart.setupDNS() + assert.NilError(t, err) + + rc := getResolvConf(t, sbRestart.config.resolvConfPath) + assert.Check(t, is.Equal("1.1.1.1", rc.NameServers()[0].String())) + + // Step 6: Call rebuildDNS on restart - should preserve user modifications + err = sbRestart.rebuildDNS() + assert.NilError(t, err) + + rc = getResolvConf(t, sbRestart.config.resolvConfPath) + assert.Check(t, is.Equal("1.1.1.1", rc.NameServers()[0].String())) +} diff --git a/integration/networking/resolvconf_test.go b/integration/networking/resolvconf_test.go index c0119bd650..7f5caa3a8f 100644 --- a/integration/networking/resolvconf_test.go +++ b/integration/networking/resolvconf_test.go @@ -212,3 +212,41 @@ func TestNslookupWindows(t *testing.T) { // can only be changed in daemon.json using feature flag "windows-dns-proxy". assert.Check(t, is.Contains(res.Stdout.String(), "Addresses:")) } + +// TestResolvConfPreservedOnRestart verifies that external modifications to +// /etc/resolv.conf are preserved when a non-host network container is restarted. +// Regression test for https://github.com/moby/moby/issues/51490 +func TestResolvConfPreservedOnRestart(t *testing.T) { + skip.If(t, testEnv.DaemonInfo.OSType == "windows", "No /etc/resolv.conf on Windows") + + ctx := setupTest(t) + + d := daemon.New(t, daemon.WithResolvConf(network.GenResolvConf("8.8.8.8"))) + d.StartWithBusybox(ctx, t) + defer d.Stop(t) + + c := d.NewClientT(t) + defer c.Close() + + const ctrName = "test-resolvconf-preserved-on-restart" + id := container.Run(ctx, t, c, container.WithName(ctrName)) + defer c.ContainerRemove(ctx, id, client.ContainerRemoveOptions{Force: true}) + + appendContent := `# hello` + res, err := container.Exec(ctx, c, ctrName, []string{ + "sh", "-c", + "echo '" + appendContent + "' >> /etc/resolv.conf", + }) + assert.NilError(t, err) + assert.Check(t, is.Equal(res.ExitCode, 0)) + + // Restart the container. + _, err = c.ContainerRestart(ctx, ctrName, client.ContainerRestartOptions{}) + assert.Assert(t, is.Nil(err)) + + // Verify the modification was preserved + res, err = container.Exec(ctx, c, ctrName, []string{"tail", "-n", "1", "/etc/resolv.conf"}) + assert.NilError(t, err) + assert.Check(t, is.Equal(res.ExitCode, 0)) + assert.Check(t, is.Contains(res.Stdout(), appendContent)) +}