Skip to content

Commit a8f7c5e

Browse files
committed
Detect IPv6 support in containers.
Some configuration in a container depends on whether it has support for IPv6 (including default entries for '::1' etc in '/etc/hosts'). Before this change, the container's support for IPv6 was determined by whether it was connected to any IPv6-enabled networks. But, that can change over time, it isn't a property of the container itself. So, instead, detect IPv6 support by looking for '::1' on the container's loopback interface. It will not be present if the kernel does not have IPv6 support, or the user has disabled it in new namespaces by other means. Once IPv6 support has been determined for the container, its '/etc/hosts' is re-generated accordingly. The daemon no longer disables IPv6 on all interfaces during initialisation. It now disables IPv6 only for interfaces that have not been assigned an IPv6 address. (But, even if IPv6 is disabled for the container using the sysctl 'net.ipv6.conf.all.disable_ipv6=1', interfaces connected to IPv6 networks still get IPv6 addresses that appear in the internal DNS. There's more to-do!) Signed-off-by: Rob Murray <[email protected]>
1 parent 0046b16 commit a8f7c5e

11 files changed

Lines changed: 330 additions & 133 deletions

File tree

integration/internal/container/ops.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package container
22

33
import (
4+
"maps"
45
"strings"
56

67
"github.com/docker/docker/api/types/container"
@@ -46,6 +47,13 @@ func WithNetworkMode(mode string) func(*TestContainerConfig) {
4647
}
4748
}
4849

50+
// WithSysctls sets sysctl options for the container
51+
func WithSysctls(sysctls map[string]string) func(*TestContainerConfig) {
52+
return func(c *TestContainerConfig) {
53+
c.HostConfig.Sysctls = maps.Clone(sysctls)
54+
}
55+
}
56+
4957
// WithExposedPorts sets the exposed ports of the container
5058
func WithExposedPorts(ports ...string) func(*TestContainerConfig) {
5159
return func(c *TestContainerConfig) {
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package networking
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
containertypes "github.com/docker/docker/api/types/container"
9+
"github.com/docker/docker/integration/internal/container"
10+
"github.com/docker/docker/testutil"
11+
"github.com/docker/docker/testutil/daemon"
12+
"gotest.tools/v3/assert"
13+
is "gotest.tools/v3/assert/cmp"
14+
"gotest.tools/v3/skip"
15+
)
16+
17+
// Check that the '/etc/hosts' file in a container is created according to
18+
// whether the container supports IPv6.
19+
// Regression test for https://github.com/moby/moby/issues/35954
20+
func TestEtcHostsIpv6(t *testing.T) {
21+
skip.If(t, testEnv.DaemonInfo.OSType == "windows")
22+
23+
ctx := setupTest(t)
24+
d := daemon.New(t)
25+
d.StartWithBusybox(ctx, t,
26+
"--ipv6",
27+
"--ip6tables",
28+
"--experimental",
29+
"--fixed-cidr-v6=fdc8:ffe2:d8d7:1234::/64")
30+
defer d.Stop(t)
31+
32+
c := d.NewClientT(t)
33+
defer c.Close()
34+
35+
testcases := []struct {
36+
name string
37+
sysctls map[string]string
38+
expIPv6Enabled bool
39+
expEtcHosts string
40+
}{
41+
{
42+
// Create a container with no overrides, on the IPv6-enabled default bridge.
43+
// Expect the container to have a working '::1' address, on the assumption
44+
// the test host's kernel supports IPv6 - and for its '/etc/hosts' file to
45+
// include IPv6 addresses.
46+
name: "IPv6 enabled",
47+
expIPv6Enabled: true,
48+
expEtcHosts: `127.0.0.1 localhost
49+
::1 localhost ip6-localhost ip6-loopback
50+
fe00::0 ip6-localnet
51+
ff00::0 ip6-mcastprefix
52+
ff02::1 ip6-allnodes
53+
ff02::2 ip6-allrouters
54+
`,
55+
},
56+
{
57+
// Create a container in the same network, with IPv6 disabled. Expect '::1'
58+
// not to be pingable, and no IPv6 addresses in its '/etc/hosts'.
59+
name: "IPv6 disabled",
60+
sysctls: map[string]string{"net.ipv6.conf.all.disable_ipv6": "1"},
61+
expIPv6Enabled: false,
62+
expEtcHosts: "127.0.0.1\tlocalhost\n",
63+
},
64+
}
65+
66+
for _, tc := range testcases {
67+
t.Run(tc.name, func(t *testing.T) {
68+
ctx := testutil.StartSpan(ctx, t)
69+
ctrId := container.Run(ctx, t, c,
70+
container.WithName("etchosts_"+sanitizeCtrName(t.Name())),
71+
container.WithImage("busybox:latest"),
72+
container.WithCmd("top"),
73+
container.WithSysctls(tc.sysctls),
74+
)
75+
defer func() {
76+
c.ContainerRemove(ctx, ctrId, containertypes.RemoveOptions{Force: true})
77+
}()
78+
79+
runCmd := func(ctrId string, cmd []string, expExitCode int) string {
80+
t.Helper()
81+
execCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
82+
defer cancel()
83+
res, err := container.Exec(execCtx, c, ctrId, cmd)
84+
assert.Check(t, is.Nil(err))
85+
assert.Check(t, is.Equal(res.ExitCode, expExitCode))
86+
return res.Stdout()
87+
}
88+
89+
// Check that IPv6 is/isn't enabled, as expected.
90+
var expPingExitStatus int
91+
if !tc.expIPv6Enabled {
92+
expPingExitStatus = 1
93+
}
94+
runCmd(ctrId, []string{"ping", "-6", "-c1", "-W3", "::1"}, expPingExitStatus)
95+
96+
// Check the contents of /etc/hosts.
97+
stdout := runCmd(ctrId, []string{"cat", "/etc/hosts"}, 0)
98+
// Append the container's own addresses/name to the expected hosts file content.
99+
inspect := container.Inspect(ctx, t, c, ctrId)
100+
exp := tc.expEtcHosts + inspect.NetworkSettings.IPAddress + "\t" + inspect.Config.Hostname + "\n"
101+
if tc.expIPv6Enabled {
102+
exp += inspect.NetworkSettings.GlobalIPv6Address + "\t" + inspect.Config.Hostname + "\n"
103+
}
104+
assert.Check(t, is.Equal(stdout, exp))
105+
})
106+
}
107+
}

libnetwork/drivers/bridge/port_mapping_linux.go

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66
"errors"
77
"fmt"
88
"net"
9-
"sync"
109

1110
"github.com/containerd/log"
11+
"github.com/docker/docker/libnetwork/netutils"
1212
"github.com/docker/docker/libnetwork/types"
1313
"github.com/ishidawataru/sctp"
1414
)
@@ -55,7 +55,7 @@ func (n *bridgeNetwork) allocatePortsInternal(bindings []types.PortBinding, cont
5555
// skip adding implicit v6 addr, when the kernel was booted with `ipv6.disable=1`
5656
// https://github.com/moby/moby/issues/42288
5757
isV6Binding := c.HostIP != nil && c.HostIP.To4() == nil
58-
if !isV6Binding && !IsV6Listenable() {
58+
if !isV6Binding && !netutils.IsV6Listenable() {
5959
continue
6060
}
6161

@@ -219,26 +219,3 @@ func (n *bridgeNetwork) releasePort(bnd types.PortBinding) error {
219219

220220
return portmapper.Unmap(host)
221221
}
222-
223-
var (
224-
v6ListenableCached bool
225-
v6ListenableOnce sync.Once
226-
)
227-
228-
// IsV6Listenable returns true when `[::1]:0` is listenable.
229-
// IsV6Listenable returns false mostly when the kernel was booted with `ipv6.disable=1` option.
230-
func IsV6Listenable() bool {
231-
v6ListenableOnce.Do(func() {
232-
ln, err := net.Listen("tcp6", "[::1]:0")
233-
if err != nil {
234-
// When the kernel was booted with `ipv6.disable=1`,
235-
// we get err "listen tcp6 [::1]:0: socket: address family not supported by protocol"
236-
// https://github.com/moby/moby/issues/42288
237-
log.G(context.TODO()).Debugf("port_mapping: v6Listenable=false (%v)", err)
238-
} else {
239-
v6ListenableCached = true
240-
ln.Close()
241-
}
242-
})
243-
return v6ListenableCached
244-
}

libnetwork/endpoint.go

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -478,18 +478,8 @@ func (ep *Endpoint) sbJoin(sb *Sandbox, options ...EndpointOption) (err error) {
478478
}
479479
}
480480

481-
// Do not update hosts file with internal networks endpoint IP
482-
if !n.ingress && n.Name() != libnGWNetwork {
483-
var addresses []string
484-
if ip := ep.getFirstInterfaceIPv4Address(); ip != nil {
485-
addresses = append(addresses, ip.String())
486-
}
487-
if ip := ep.getFirstInterfaceIPv6Address(); ip != nil {
488-
addresses = append(addresses, ip.String())
489-
}
490-
if err = sb.updateHostsFile(addresses); err != nil {
491-
return err
492-
}
481+
if err := sb.updateHostsFile(ep.getEtcHostsAddrs()); err != nil {
482+
return err
493483
}
494484
if err = sb.updateDNS(n.enableIPv6); err != nil {
495485
return err
@@ -860,26 +850,24 @@ func (ep *Endpoint) getSandbox() (*Sandbox, bool) {
860850
return ps, ok
861851
}
862852

863-
func (ep *Endpoint) getFirstInterfaceIPv4Address() net.IP {
853+
// Return a list of this endpoint's addresses to add to '/etc/hosts'.
854+
func (ep *Endpoint) getEtcHostsAddrs() []string {
864855
ep.mu.Lock()
865856
defer ep.mu.Unlock()
866857

867-
if ep.iface.addr != nil {
868-
return ep.iface.addr.IP
858+
// Do not update hosts file with internal network's endpoint IP
859+
if n := ep.network; n == nil || n.ingress || n.Name() == libnGWNetwork {
860+
return nil
869861
}
870862

871-
return nil
872-
}
873-
874-
func (ep *Endpoint) getFirstInterfaceIPv6Address() net.IP {
875-
ep.mu.Lock()
876-
defer ep.mu.Unlock()
877-
863+
var addresses []string
864+
if ep.iface.addr != nil {
865+
addresses = append(addresses, ep.iface.addr.IP.String())
866+
}
878867
if ep.iface.addrv6 != nil {
879-
return ep.iface.addrv6.IP
868+
addresses = append(addresses, ep.iface.addrv6.IP.String())
880869
}
881-
882-
return nil
870+
return addresses
883871
}
884872

885873
// EndpointOptionGeneric function returns an option setter for a Generic option defined

libnetwork/etchosts/etchosts.go

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"bytes"
66
"fmt"
77
"io"
8+
"net/netip"
89
"os"
910
"regexp"
1011
"strings"
@@ -25,8 +26,10 @@ func (r Record) WriteTo(w io.Writer) (int64, error) {
2526

2627
var (
2728
// Default hosts config records slice
28-
defaultContent = []Record{
29+
defaultContentIPv4 = []Record{
2930
{Hosts: "localhost", IP: "127.0.0.1"},
31+
}
32+
defaultContentIPv6 = []Record{
3033
{Hosts: "localhost ip6-localhost ip6-loopback", IP: "::1"},
3134
{Hosts: "ip6-localnet", IP: "fe00::0"},
3235
{Hosts: "ip6-mcastprefix", IP: "ff00::0"},
@@ -71,9 +74,34 @@ func Drop(path string) {
7174
// IP, hostname, and domainname set main record leave empty for no master record
7275
// extraContent is an array of extra host records.
7376
func Build(path, IP, hostname, domainname string, extraContent []Record) error {
77+
return build(path, IP, hostname, domainname, defaultContentIPv4, defaultContentIPv6, extraContent)
78+
}
79+
80+
// BuildNoIPv6 is the same as Build, but will not include IPv6 entries.
81+
func BuildNoIPv6(path, IP, hostname, domainname string, extraContent []Record) error {
82+
if isIPv6(IP) {
83+
IP = ""
84+
}
85+
86+
var ipv4ExtraContent []Record
87+
for _, rec := range extraContent {
88+
if !isIPv6(rec.IP) {
89+
ipv4ExtraContent = append(ipv4ExtraContent, rec)
90+
}
91+
}
92+
93+
return build(path, IP, hostname, domainname, defaultContentIPv4, ipv4ExtraContent)
94+
}
95+
96+
func isIPv6(s string) bool {
97+
addr, err := netip.ParseAddr(s)
98+
return err == nil && addr.Is6()
99+
}
100+
101+
func build(path, IP, hostname, domainname string, contents ...[]Record) error {
74102
defer pathLock(path)()
75103

76-
content := bytes.NewBuffer(nil)
104+
buf := bytes.NewBuffer(nil)
77105
if IP != "" {
78106
// set main record
79107
var mainRec Record
@@ -89,24 +117,21 @@ func Build(path, IP, hostname, domainname string, extraContent []Record) error {
89117
if hostName, _, ok := strings.Cut(fqdn, "."); ok {
90118
mainRec.Hosts += " " + hostName
91119
}
92-
if _, err := mainRec.WriteTo(content); err != nil {
93-
return err
94-
}
95-
}
96-
// Write defaultContent slice to buffer
97-
for _, r := range defaultContent {
98-
if _, err := r.WriteTo(content); err != nil {
120+
if _, err := mainRec.WriteTo(buf); err != nil {
99121
return err
100122
}
101123
}
102-
// Write extra content from function arguments
103-
for _, r := range extraContent {
104-
if _, err := r.WriteTo(content); err != nil {
105-
return err
124+
125+
// Write content from function arguments
126+
for _, content := range contents {
127+
for _, c := range content {
128+
if _, err := c.WriteTo(buf); err != nil {
129+
return err
130+
}
106131
}
107132
}
108133

109-
return os.WriteFile(path, content.Bytes(), 0o644)
134+
return os.WriteFile(path, buf.Bytes(), 0o644)
110135
}
111136

112137
// Add adds an arbitrary number of Records to an already existing /etc/hosts file

libnetwork/etchosts/etchosts_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@ import (
44
"bytes"
55
"fmt"
66
"os"
7+
"path/filepath"
78
"testing"
89

910
"golang.org/x/sync/errgroup"
11+
"gotest.tools/v3/assert"
12+
is "gotest.tools/v3/assert/cmp"
1013
)
1114

1215
func TestBuildDefault(t *testing.T) {
@@ -35,6 +38,26 @@ func TestBuildDefault(t *testing.T) {
3538
}
3639
}
3740

41+
func TestBuildNoIPv6(t *testing.T) {
42+
d := t.TempDir()
43+
filename := filepath.Join(d, "hosts")
44+
45+
err := BuildNoIPv6(filename, "fdbb:c59c:d015::2", "an.example", "", []Record{
46+
{
47+
Hosts: "another.example",
48+
IP: "fdbb:c59c:d015::3",
49+
},
50+
{
51+
Hosts: "another.example",
52+
IP: "10.11.12.13",
53+
},
54+
})
55+
assert.NilError(t, err)
56+
content, err := os.ReadFile(filename)
57+
assert.NilError(t, err)
58+
assert.Check(t, is.DeepEqual(string(content), "127.0.0.1\tlocalhost\n10.11.12.13\tanother.example\n"))
59+
}
60+
3861
func TestBuildHostnameDomainname(t *testing.T) {
3962
file, err := os.CreateTemp("", "")
4063
if err != nil {

0 commit comments

Comments
 (0)