Browse code

libnet/pms/nat: don't bind IPv6 ports if not supported by port driver

In rootless mode, the Engine needs to call the rootless port driver to
know which IP address it should bind to inside of its network namespace.

The slirp4netns port drivers doesn't support binding to IPv6 address, so
we need to detect that before listening on the port.

Before commit 201968cc0, this wasn't a problem because the Engine was
binding the port, then calling rootless port driver to learn whether the
proto/IP family was supported, and listen on the port if so.

Starting with that commit, the Engine does bind + listen in one go, and
then calls the port driver — this is too late. Fix the bug by checking
if the port driver supports the PortBindingReq, and only allocate the
port if so.

Signed-off-by: Albin Kerouanton <albin.kerouanton@docker.com>

Albin Kerouanton authored on 2025/11/28 20:36:20
Showing 3 changed files
... ...
@@ -220,7 +220,7 @@ func TestAddPortMappings(t *testing.T) {
220 220
 		enableProxy  bool
221 221
 		hairpin      bool
222 222
 		busyPortIPv4 int
223
-		rootless     bool
223
+		newPDC       func() nat.PortDriverClient
224 224
 		hostAddrs    []string
225 225
 		noProxy6To4  bool
226 226
 
... ...
@@ -667,7 +667,7 @@ func TestAddPortMappings(t *testing.T) {
667 667
 				{PortBinding: types.PortBinding{Proto: types.TCP, Port: 80}},
668 668
 			},
669 669
 			enableProxy: true,
670
-			rootless:    true,
670
+			newPDC:      func() nat.PortDriverClient { return newMockPortDriverClient(true) },
671 671
 			expPBs: []types.PortBinding{
672 672
 				{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: net.IPv4zero, HostPort: firstEphemPort},
673 673
 				{Proto: types.TCP, IP: ctrIP6.IP, Port: 22, HostIP: net.IPv6zero, HostPort: firstEphemPort},
... ...
@@ -676,6 +676,21 @@ func TestAddPortMappings(t *testing.T) {
676 676
 			},
677 677
 		},
678 678
 		{
679
+			name:     "rootless, ipv6 not supported",
680
+			epAddrV4: ctrIP4,
681
+			epAddrV6: ctrIP6,
682
+			cfg: []portmapperapi.PortBindingReq{
683
+				{PortBinding: types.PortBinding{Proto: types.TCP, Port: 22}},
684
+				{PortBinding: types.PortBinding{Proto: types.TCP, Port: 80}},
685
+			},
686
+			enableProxy: true,
687
+			newPDC:      func() nat.PortDriverClient { return newMockPortDriverClient(false) },
688
+			expPBs: []types.PortBinding{
689
+				{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: net.IPv4zero, HostPort: firstEphemPort},
690
+				{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: firstEphemPort + 1},
691
+			},
692
+		},
693
+		{
679 694
 			name:     "rootless without proxy",
680 695
 			epAddrV4: ctrIP4,
681 696
 			epAddrV6: ctrIP6,
... ...
@@ -683,8 +698,8 @@ func TestAddPortMappings(t *testing.T) {
683 683
 				{PortBinding: types.PortBinding{Proto: types.TCP, Port: 22}},
684 684
 				{PortBinding: types.PortBinding{Proto: types.TCP, Port: 80}},
685 685
 			},
686
-			rootless: true,
687
-			hairpin:  true,
686
+			newPDC:  func() nat.PortDriverClient { return newMockPortDriverClient(true) },
687
+			hairpin: true,
688 688
 			expPBs: []types.PortBinding{
689 689
 				{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: net.IPv4zero, HostPort: firstEphemPort},
690 690
 				{Proto: types.TCP, IP: ctrIP6.IP, Port: 22, HostIP: net.IPv6zero, HostPort: firstEphemPort},
... ...
@@ -745,8 +760,8 @@ func TestAddPortMappings(t *testing.T) {
745 745
 			}
746 746
 
747 747
 			var pdc nat.PortDriverClient
748
-			if tc.rootless {
749
-				pdc = newMockPortDriverClient()
748
+			if tc.newPDC != nil {
749
+				pdc = tc.newPDC()
750 750
 			}
751 751
 
752 752
 			pms := &drvregistry.PortMappers{}
... ...
@@ -780,7 +795,7 @@ func TestAddPortMappings(t *testing.T) {
780 780
 			n.firewallerNetwork = fwn
781 781
 
782 782
 			expChildIP := func(hostIP net.IP) net.IP {
783
-				if !tc.rootless {
783
+				if pdc == nil {
784 784
 					return hostIP
785 785
 				}
786 786
 				if hostIP.To4() == nil {
... ...
@@ -938,16 +953,21 @@ func (p mockPortDriverPort) String() string {
938 938
 
939 939
 type mockPortDriverClient struct {
940 940
 	openPorts map[mockPortDriverPort]bool
941
+	supportV6 bool
941 942
 }
942 943
 
943
-func newMockPortDriverClient() *mockPortDriverClient {
944
+func newMockPortDriverClient(supportV6 bool) *mockPortDriverClient {
944 945
 	return &mockPortDriverClient{
945 946
 		openPorts: map[mockPortDriverPort]bool{},
947
+		supportV6: supportV6,
946 948
 	}
947 949
 }
948 950
 
949
-func (c *mockPortDriverClient) ChildHostIP(hostIP netip.Addr) netip.Addr {
951
+func (c *mockPortDriverClient) ChildHostIP(proto string, hostIP netip.Addr) netip.Addr {
950 952
 	if hostIP.Is6() {
953
+		if !c.supportV6 {
954
+			return netip.Addr{}
955
+		}
951 956
 		return netip.IPv6Loopback()
952 957
 	}
953 958
 	return netip.MustParseAddr("127.0.0.1")
... ...
@@ -75,14 +75,38 @@ func NewPortDriverClient(ctx context.Context) (*PortDriverClient, error) {
75 75
 	return pdc, nil
76 76
 }
77 77
 
78
+// proto normalizes the protocol to match what the rootlesskit API expects.
79
+func (c *PortDriverClient) proto(proto string, hostIP netip.Addr) string {
80
+	// proto is like "tcp", but we need to convert it to "tcp4" or "tcp6" explicitly
81
+	// for libnetwork >= 20201216
82
+	//
83
+	// See https://github.com/moby/libnetwork/pull/2604/files#diff-8fa48beed55dd033bf8e4f8c40b31cf69d0b2cc5d4bb53cde8594670ea6c938aR20
84
+	// See also https://github.com/rootless-containers/rootlesskit/issues/231
85
+	apiProto := proto
86
+	if !strings.HasSuffix(apiProto, "4") && !strings.HasSuffix(apiProto, "6") {
87
+		if hostIP.Is6() {
88
+			apiProto += "6"
89
+		} else {
90
+			apiProto += "4"
91
+		}
92
+	}
93
+	return apiProto
94
+}
95
+
78 96
 // ChildHostIP returns the address that must be used in the child network
79 97
 // namespace in place of hostIP, a host IP address. In particular, port
80 98
 // mappings from host IP addresses, and DNAT rules, must use this child
81
-// address in place of the real host address.
82
-func (c *PortDriverClient) ChildHostIP(hostIP netip.Addr) netip.Addr {
99
+// address in place of the real host address. It may return an invalid
100
+// netip.Addr if the proto and IP family aren't supported.
101
+func (c *PortDriverClient) ChildHostIP(proto string, hostIP netip.Addr) netip.Addr {
83 102
 	if c == nil {
84 103
 		return hostIP
85 104
 	}
105
+	if _, ok := c.protos[c.proto(proto, hostIP)]; !ok {
106
+		// This happens when apiProto="tcp6", portDriverName="slirp4netns",
107
+		// because "slirp4netns" port driver does not support listening on IPv6 yet.
108
+		return netip.Addr{}
109
+	}
86 110
 	if c.childIP.IsValid() {
87 111
 		return c.childIP
88 112
 	}
... ...
@@ -117,20 +141,8 @@ func (c *PortDriverClient) AddPort(
117 117
 	if c == nil {
118 118
 		return func() error { return nil }, nil
119 119
 	}
120
-	// proto is like "tcp", but we need to convert it to "tcp4" or "tcp6" explicitly
121
-	// for libnetwork >= 20201216
122
-	//
123
-	// See https://github.com/moby/libnetwork/pull/2604/files#diff-8fa48beed55dd033bf8e4f8c40b31cf69d0b2cc5d4bb53cde8594670ea6c938aR20
124
-	// See also https://github.com/rootless-containers/rootlesskit/issues/231
125
-	apiProto := proto
126
-	if !strings.HasSuffix(apiProto, "4") && !strings.HasSuffix(apiProto, "6") {
127
-		if hostIP.Is6() {
128
-			apiProto += "6"
129
-		} else {
130
-			apiProto += "4"
131
-		}
132
-	}
133 120
 
121
+	apiProto := c.proto(proto, hostIP)
134 122
 	if _, ok := c.protos[apiProto]; !ok {
135 123
 		// This happens when apiProto="tcp6", portDriverName="slirp4netns",
136 124
 		// because "slirp4netns" port driver does not support listening on IPv6 yet.
... ...
@@ -6,6 +6,7 @@ import (
6 6
 	"fmt"
7 7
 	"net"
8 8
 	"net/netip"
9
+	"slices"
9 10
 	"strconv"
10 11
 
11 12
 	"github.com/containerd/log"
... ...
@@ -13,12 +14,13 @@ import (
13 13
 	"github.com/moby/moby/v2/daemon/libnetwork/portallocator"
14 14
 	"github.com/moby/moby/v2/daemon/libnetwork/portmapperapi"
15 15
 	"github.com/moby/moby/v2/daemon/libnetwork/types"
16
+	"github.com/moby/moby/v2/internal/sliceutil"
16 17
 )
17 18
 
18 19
 const driverName = "nat"
19 20
 
20 21
 type PortDriverClient interface {
21
-	ChildHostIP(hostIP netip.Addr) netip.Addr
22
+	ChildHostIP(proto string, hostIP netip.Addr) netip.Addr
22 23
 	AddPort(ctx context.Context, proto string, hostIP, childIP netip.Addr, hostPort int) (func() error, error)
23 24
 }
24 25
 
... ...
@@ -73,12 +75,18 @@ func (pm PortMapper) MapPorts(ctx context.Context, cfg []portmapperapi.PortBindi
73 73
 		}
74 74
 	}()
75 75
 
76
-	addrs := make([]net.IP, 0, len(cfg))
77
-	for i := range cfg {
78
-		cfg[i] = setChildHostIP(pm.pdc, cfg[i])
79
-		addrs = append(addrs, cfg[i].ChildHostIP)
76
+	for i := len(cfg) - 1; i >= 0; i-- {
77
+		var supported bool
78
+		if cfg[i], supported = setChildHostIP(pm.pdc, cfg[i]); !supported {
79
+			cfg = slices.Delete(cfg, i, i+1)
80
+			continue
81
+		}
80 82
 	}
81 83
 
84
+	addrs := sliceutil.Map(cfg, func(req portmapperapi.PortBindingReq) net.IP {
85
+		return req.ChildHostIP
86
+	})
87
+
82 88
 	pa := portallocator.NewOSAllocator()
83 89
 	allocatedPort, socks, err := pa.RequestPortsInRange(addrs, proto, int(hostPort), int(hostPortEnd))
84 90
 	if err != nil {
... ...
@@ -127,14 +135,21 @@ func (pm PortMapper) UnmapPorts(ctx context.Context, pbs []portmapperapi.PortBin
127 127
 	return errors.Join(errs...)
128 128
 }
129 129
 
130
-func setChildHostIP(pdc PortDriverClient, req portmapperapi.PortBindingReq) portmapperapi.PortBindingReq {
130
+// setChildHostIP returns a modified PortBindingReq that contains the IP
131
+// address that should be used for port allocation, firewall rules, etc. It
132
+// returns false when the PortBindingReq isn't supported by the PortDriverClient.
133
+func setChildHostIP(pdc PortDriverClient, req portmapperapi.PortBindingReq) (portmapperapi.PortBindingReq, bool) {
131 134
 	if pdc == nil {
132 135
 		req.ChildHostIP = req.HostIP
133
-		return req
136
+		return req, true
134 137
 	}
135 138
 	hip, _ := netip.AddrFromSlice(req.HostIP)
136
-	req.ChildHostIP = pdc.ChildHostIP(hip.Unmap()).AsSlice()
137
-	return req
139
+	chip := pdc.ChildHostIP(req.Proto.String(), hip.Unmap())
140
+	if !chip.IsValid() {
141
+		return req, false
142
+	}
143
+	req.ChildHostIP = chip.AsSlice()
144
+	return req, true
138 145
 }
139 146
 
140 147
 // configPortDriver passes the port binding's details to rootlesskit, and updates the