Skip to content

Commit

Permalink
Enable 'lo' IPv6 when possible.
Browse files Browse the repository at this point in the history
Always try to enable IPv6 on a container's loopback interface, removing
the dependency on whether it currently has any IPv6-enabled endpoints.

If IPv6 can be enabled, the kernel will assign address '::1' to the
loopback interface.

If IPv6 cannot be enabled, perhaps because it's not enabled in the
kernel, or because the container was created it disabled using option
'--sysctl net.ipv6.conf.all.disable_ipv6=1' - the loopback interface
will not get address '::1'.

After the container task has been created, before it is started, use
the presence of the '::1' address to determine whether the container can
support IPv6. Then, generate the its '/etc/hosts' file with or without
IPv6 entries accordingly.

Signed-off-by: Rob Murray <rob.murray@docker.com>
  • Loading branch information
robmry committed Jan 12, 2024
1 parent ef1a85c commit 842d603
Show file tree
Hide file tree
Showing 11 changed files with 246 additions and 48 deletions.
7 changes: 7 additions & 0 deletions daemon/start.go
Expand Up @@ -224,6 +224,13 @@ func (daemon *Daemon) containerStart(ctx context.Context, daemonCfg *configStore
}
}()

// Finish sandbox initialisation (if there's a sandbox, so not using container networking).
if sb, err := daemon.netController.GetSandbox(container.ID); err == nil {
if err := sb.CtrTaskCreated(); err != nil {
return err
}
}

if err := tsk.Start(context.TODO()); err != nil { // passing ctx caused integration tests to be stuck in the cleanup phase
return setExitCodeFromError(container.SetExitCode, err)
}
Expand Down
8 changes: 8 additions & 0 deletions integration/internal/container/ops.go
@@ -1,6 +1,7 @@
package container

import (
"maps"
"strings"

"github.com/docker/docker/api/types/container"
Expand Down Expand Up @@ -46,6 +47,13 @@ func WithNetworkMode(mode string) func(*TestContainerConfig) {
}
}

// WithSysctls sets sysctl options for the container
func WithSysctls(sysctls map[string]string) func(*TestContainerConfig) {
return func(c *TestContainerConfig) {
c.HostConfig.Sysctls = maps.Clone(sysctls)
}
}

// WithExposedPorts sets the exposed ports of the container
func WithExposedPorts(ports ...string) func(*TestContainerConfig) {
return func(c *TestContainerConfig) {
Expand Down
96 changes: 96 additions & 0 deletions integration/networking/etchosts_test.go
@@ -0,0 +1,96 @@
package networking

import (
"context"
"testing"
"time"

"github.com/docker/docker/testutil"

containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/integration/internal/container"
"github.com/docker/docker/testutil/daemon"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/skip"
)

// Check that the '/etc/hosts' file in a container is created according to
// whether the container supports IPv6.
// Regression test for https://github.com/moby/moby/issues/35954
func TestEtcHostsIpv6(t *testing.T) {
skip.If(t, testEnv.DaemonInfo.OSType == "windows")

ctx := setupTest(t)
d := daemon.New(t)
d.StartWithBusybox(ctx, t)
defer d.Stop(t)

c := d.NewClientT(t)
defer c.Close()

testcases := []struct {
name string
sysctls map[string]string
expPingExitStatus int
expEtcHosts string
}{
{
// Create a container with no overrides, on the default bridge (which does not
// have IPv6 enabled). Expect the container to have a working '::1' address, on
// the assumption the test host's kernel supports IPv6 - and for its '/etc/hosts'
// file to include IPv6 addresses.
name: "IPv6 enabled",
expPingExitStatus: 0,
expEtcHosts: `127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
`,
},
{
// Create a container with no overrides, on the default bridge (which does not
// have IPv6 enabled). Expect the container to have a working '::1' address, on
// the assumption the test host's kernel supports IPv6 - and for its '/etc/hosts'
// file to include IPv6 addresses.
name: "IPv6 disabled",
sysctls: map[string]string{"net.ipv6.conf.all.disable_ipv6": "1"},
expPingExitStatus: 1,
expEtcHosts: "127.0.0.1\tlocalhost\n",
},
}

for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
ctx := testutil.StartSpan(ctx, t)
ctrId := container.Run(ctx, t, c,
container.WithName("etchosts_"+sanitizeCtrName(t.Name())),
container.WithImage("busybox:latest"),
container.WithCmd("top"),
container.WithSysctls(tc.sysctls),
)
defer func() {
c.ContainerRemove(ctx, ctrId, containertypes.RemoveOptions{Force: true})
}()

runCmd := func(ctrId string, cmd []string, expExitCode int) string {
t.Helper()
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
res, err := container.Exec(execCtx, c, ctrId, cmd)
assert.Check(t, is.Nil(err))
assert.Check(t, is.Equal(res.ExitCode, expExitCode))
return res.Stdout()
}

// Check that IPv6 is/isn't enabled, as expected.
runCmd(ctrId, []string{"ping", "-6", "-c1", "-W3", "::1"}, tc.expPingExitStatus)

// Check the contents of /etc/hosts.
stdout := runCmd(ctrId, []string{"cat", "/etc/hosts"}, 0)
assert.Check(t, is.Equal(stdout, tc.expEtcHosts))
})
}
}
35 changes: 23 additions & 12 deletions libnetwork/endpoint.go
Expand Up @@ -478,18 +478,8 @@ func (ep *Endpoint) sbJoin(sb *Sandbox, options ...EndpointOption) (err error) {
}
}

// Do not update hosts file with internal networks endpoint IP
if !n.ingress && n.Name() != libnGWNetwork {
var addresses []string
if ip := ep.getFirstInterfaceIPv4Address(); ip != nil {
addresses = append(addresses, ip.String())
}
if ip := ep.getFirstInterfaceIPv6Address(); ip != nil {
addresses = append(addresses, ip.String())
}
if err = sb.updateHostsFile(addresses); err != nil {
return err
}
if err := ep.updateHostsFile(sb); err != nil {
return err
}
if err = sb.updateDNS(n.enableIPv6); err != nil {
return err
Expand Down Expand Up @@ -631,6 +621,27 @@ func (ep *Endpoint) UpdateDNSNames(dnsNames []string) error {
return nil
}

func (ep *Endpoint) updateHostsFile(sb *Sandbox) error {
ep.mu.Lock()
n := ep.network
ep.mu.Unlock()

// Do not update hosts file with internal network's endpoint IP
if n != nil && !n.ingress && n.Name() != libnGWNetwork {
var addresses []string
if ip := ep.getFirstInterfaceIPv4Address(); ip != nil {
addresses = append(addresses, ip.String())
}
if ip := ep.getFirstInterfaceIPv6Address(); ip != nil {
addresses = append(addresses, ip.String())
}
if err := sb.updateHostsFile(addresses); err != nil {
return err
}
}
return nil
}

func (ep *Endpoint) hasInterface(iName string) bool {
ep.mu.Lock()
defer ep.mu.Unlock()
Expand Down
22 changes: 20 additions & 2 deletions libnetwork/etchosts/etchosts.go
Expand Up @@ -25,8 +25,10 @@ func (r Record) WriteTo(w io.Writer) (int64, error) {

var (
// Default hosts config records slice
defaultContent = []Record{
defaultContentIPv4 = []Record{
{Hosts: "localhost", IP: "127.0.0.1"},
}
defaultContentIPv6 = []Record{
{Hosts: "localhost ip6-localhost ip6-loopback", IP: "::1"},
{Hosts: "ip6-localnet", IP: "fe00::0"},
{Hosts: "ip6-mcastprefix", IP: "ff00::0"},
Expand Down Expand Up @@ -71,6 +73,15 @@ func Drop(path string) {
// IP, hostname, and domainname set main record leave empty for no master record
// extraContent is an array of extra host records.
func Build(path, IP, hostname, domainname string, extraContent []Record) error {
return build(path, IP, hostname, domainname, extraContent, true)
}

// BuildNoIPv6 is the same as Build, but will not include IPv6 entries.
func BuildNoIPv6(path, IP, hostname, domainname string, extraContent []Record) error {
return build(path, IP, hostname, domainname, extraContent, false)
}

func build(path, IP, hostname, domainname string, extraContent []Record, ipv6 bool) error {
defer pathLock(path)()

content := bytes.NewBuffer(nil)
Expand All @@ -94,11 +105,18 @@ func Build(path, IP, hostname, domainname string, extraContent []Record) error {
}
}
// Write defaultContent slice to buffer
for _, r := range defaultContent {
for _, r := range defaultContentIPv4 {
if _, err := r.WriteTo(content); err != nil {
return err
}
}
if ipv6 {
for _, r := range defaultContentIPv6 {
if _, err := r.WriteTo(content); err != nil {
return err
}
}
}
// Write extra content from function arguments
for _, r := range extraContent {
if _, err := r.WriteTo(content); err != nil {
Expand Down
14 changes: 14 additions & 0 deletions libnetwork/etchosts/etchosts_test.go
Expand Up @@ -4,9 +4,12 @@ import (
"bytes"
"fmt"
"os"
"path/filepath"
"testing"

"golang.org/x/sync/errgroup"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)

func TestBuildDefault(t *testing.T) {
Expand Down Expand Up @@ -35,6 +38,17 @@ func TestBuildDefault(t *testing.T) {
}
}

func TestBuildNoIPv6(t *testing.T) {
d := t.TempDir()
filename := filepath.Join(d, "hosts")

err := BuildNoIPv6(filename, "", "", "", nil)
assert.NilError(t, err)
content, err := os.ReadFile(filename)
assert.NilError(t, err)
assert.Check(t, is.Equal(string(content), "127.0.0.1\tlocalhost\n"))
}

func TestBuildHostnameDomainname(t *testing.T) {
file, err := os.CreateTemp("", "")
if err != nil {
Expand Down
4 changes: 1 addition & 3 deletions libnetwork/osl/interface_linux.go
Expand Up @@ -257,7 +257,7 @@ func (n *Namespace) AddInterface(srcName, dstPrefix string, options ...IfaceOpti
n.iFaces = append(n.iFaces, i)
n.mu.Unlock()

n.checkLoV6()
n.enableLoV6()

return nil
}
Expand Down Expand Up @@ -311,8 +311,6 @@ func (n *Namespace) RemoveInterface(i *Interface) error {
}
n.mu.Unlock()

// TODO(aker): This function will disable IPv6 on lo interface if the removed interface was the last one offering IPv6 connectivity. That's a weird behavior, and shouldn't be hiding this deep down in this function.
n.checkLoV6()
return nil
}

Expand Down
41 changes: 17 additions & 24 deletions libnetwork/osl/namespace_linux.go
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/docker/docker/libnetwork/osl/kernel"
"github.com/docker/docker/libnetwork/types"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
"github.com/vishvananda/netns"
"golang.org/x/sys/unix"
)
Expand Down Expand Up @@ -330,7 +331,6 @@ type Namespace struct {
nextIfIndex map[string]int
isDefault bool
nlHandle *netlink.Handle
loV6Enabled bool
mu sync.Mutex
}

Expand Down Expand Up @@ -555,32 +555,25 @@ func (n *Namespace) Restore(interfaces map[Iface][]IfaceOption, routes []*types.
return nil
}

// Checks whether IPv6 needs to be enabled/disabled on the loopback interface
func (n *Namespace) checkLoV6() {
var (
enable = false
action = "disable"
)

n.mu.Lock()
for _, iface := range n.iFaces {
if iface.AddressIPv6() != nil {
enable = true
action = "enable"
break
}
}
n.mu.Unlock()

if n.loV6Enabled == enable {
return
// Enable IPv6 on the loopback interface, if possible.
func (n *Namespace) enableLoV6() {
if err := setIPv6(n.path, "lo", true); err != nil {
log.G(context.TODO()).Warnf("Failed to enable IPv6 on loopback interface on network namespace %q: %v", n.path, err)
}
}

if err := setIPv6(n.path, "lo", enable); err != nil {
log.G(context.TODO()).Warnf("Failed to %s IPv6 on loopback interface on network namespace %q: %v", action, n.path, err)
// IPv6LoEnabled checks whether the loopback interface has an IPv6 address ('::1'
// is assigned by the kernel if IPv6 is enabled). Namespace.enableLoV6() will try
// to enable it - but it will be disabled if, for example, IPv6 is not enabled in
// the kernel, or the container was started with "--sysctl
// net.ipv6.conf.all.disable_ipv6=1".
func (n *Namespace) IPv6LoEnabled() bool {
iface, err := n.nlHandle.LinkByName("lo")
if err != nil {
return false
}

n.loV6Enabled = enable
addrs, err := n.nlHandle.AddrList(iface, nl.FAMILY_V6)
return err == nil && len(addrs) > 0
}

// ApplyOSTweaks applies operating system specific knobs on the sandbox.
Expand Down
17 changes: 14 additions & 3 deletions libnetwork/sandbox.go
Expand Up @@ -52,9 +52,14 @@ type Sandbox struct {
inDelete bool
ingress bool
ndotsSet bool
oslTypes []osl.SandboxType // slice of properties of this sandbox
loadBalancerNID string // NID that this SB is a load balancer for
mu sync.Mutex
// ipv6 is set after container creation and before container start, for a
// non-Windows container, if its 'lo' interface has addr '::1'. If it is not set
// in that case, IPv6 has been disabled for the container. Otherwise, it will be
// set even when the Sandbox does not currently have any IPv6 Endpoints.
ipv6 bool
oslTypes []osl.SandboxType // slice of properties of this sandbox
loadBalancerNID string // NID that this SB is a load balancer for
mu sync.Mutex
// This mutex is used to serialize service related operation for an endpoint
// The lock is here because the endpoint is saved into the store so is not unique
service sync.Mutex
Expand Down Expand Up @@ -273,6 +278,12 @@ func (sb *Sandbox) Refresh(options ...SandboxOption) error {
return nil
}

// CtrTaskCreated is to be called after the container task has been created, before
// it is started.
func (sb *Sandbox) CtrTaskCreated() error {
return sb.ctrTaskCreatedDNS()
}

func (sb *Sandbox) MarshalJSON() ([]byte, error) {
sb.mu.Lock()
defer sb.mu.Unlock()
Expand Down

0 comments on commit 842d603

Please sign in to comment.