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>
This commit is contained in:
Albin Kerouanton
2025-11-28 12:36:20 +01:00
parent 56e8e43339
commit 52fae09ec0
3 changed files with 80 additions and 33 deletions

View File

@@ -220,7 +220,7 @@ func TestAddPortMappings(t *testing.T) {
enableProxy bool
hairpin bool
busyPortIPv4 int
rootless bool
newPDC func() nat.PortDriverClient
hostAddrs []string
noProxy6To4 bool
@@ -667,7 +667,7 @@ func TestAddPortMappings(t *testing.T) {
{PortBinding: types.PortBinding{Proto: types.TCP, Port: 80}},
},
enableProxy: true,
rootless: true,
newPDC: func() nat.PortDriverClient { return newMockPortDriverClient(true) },
expPBs: []types.PortBinding{
{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: net.IPv4zero, HostPort: firstEphemPort},
{Proto: types.TCP, IP: ctrIP6.IP, Port: 22, HostIP: net.IPv6zero, HostPort: firstEphemPort},
@@ -675,6 +675,21 @@ func TestAddPortMappings(t *testing.T) {
{Proto: types.TCP, IP: ctrIP6.IP, Port: 80, HostIP: net.IPv6zero, HostPort: firstEphemPort + 1},
},
},
{
name: "rootless, ipv6 not supported",
epAddrV4: ctrIP4,
epAddrV6: ctrIP6,
cfg: []portmapperapi.PortBindingReq{
{PortBinding: types.PortBinding{Proto: types.TCP, Port: 22}},
{PortBinding: types.PortBinding{Proto: types.TCP, Port: 80}},
},
enableProxy: true,
newPDC: func() nat.PortDriverClient { return newMockPortDriverClient(false) },
expPBs: []types.PortBinding{
{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: net.IPv4zero, HostPort: firstEphemPort},
{Proto: types.TCP, IP: ctrIP4.IP, Port: 80, HostIP: net.IPv4zero, HostPort: firstEphemPort + 1},
},
},
{
name: "rootless without proxy",
epAddrV4: ctrIP4,
@@ -683,8 +698,8 @@ func TestAddPortMappings(t *testing.T) {
{PortBinding: types.PortBinding{Proto: types.TCP, Port: 22}},
{PortBinding: types.PortBinding{Proto: types.TCP, Port: 80}},
},
rootless: true,
hairpin: true,
newPDC: func() nat.PortDriverClient { return newMockPortDriverClient(true) },
hairpin: true,
expPBs: []types.PortBinding{
{Proto: types.TCP, IP: ctrIP4.IP, Port: 22, HostIP: net.IPv4zero, HostPort: firstEphemPort},
{Proto: types.TCP, IP: ctrIP6.IP, Port: 22, HostIP: net.IPv6zero, HostPort: firstEphemPort},
@@ -745,8 +760,8 @@ func TestAddPortMappings(t *testing.T) {
}
var pdc nat.PortDriverClient
if tc.rootless {
pdc = newMockPortDriverClient()
if tc.newPDC != nil {
pdc = tc.newPDC()
}
pms := &drvregistry.PortMappers{}
@@ -780,7 +795,7 @@ func TestAddPortMappings(t *testing.T) {
n.firewallerNetwork = fwn
expChildIP := func(hostIP net.IP) net.IP {
if !tc.rootless {
if pdc == nil {
return hostIP
}
if hostIP.To4() == nil {
@@ -938,16 +953,21 @@ func (p mockPortDriverPort) String() string {
type mockPortDriverClient struct {
openPorts map[mockPortDriverPort]bool
supportV6 bool
}
func newMockPortDriverClient() *mockPortDriverClient {
func newMockPortDriverClient(supportV6 bool) *mockPortDriverClient {
return &mockPortDriverClient{
openPorts: map[mockPortDriverPort]bool{},
supportV6: supportV6,
}
}
func (c *mockPortDriverClient) ChildHostIP(hostIP netip.Addr) netip.Addr {
func (c *mockPortDriverClient) ChildHostIP(proto string, hostIP netip.Addr) netip.Addr {
if hostIP.Is6() {
if !c.supportV6 {
return netip.Addr{}
}
return netip.IPv6Loopback()
}
return netip.MustParseAddr("127.0.0.1")

View File

@@ -75,14 +75,38 @@ func NewPortDriverClient(ctx context.Context) (*PortDriverClient, error) {
return pdc, nil
}
// proto normalizes the protocol to match what the rootlesskit API expects.
func (c *PortDriverClient) proto(proto string, hostIP netip.Addr) string {
// proto is like "tcp", but we need to convert it to "tcp4" or "tcp6" explicitly
// for libnetwork >= 20201216
//
// See https://github.com/moby/libnetwork/pull/2604/files#diff-8fa48beed55dd033bf8e4f8c40b31cf69d0b2cc5d4bb53cde8594670ea6c938aR20
// See also https://github.com/rootless-containers/rootlesskit/issues/231
apiProto := proto
if !strings.HasSuffix(apiProto, "4") && !strings.HasSuffix(apiProto, "6") {
if hostIP.Is6() {
apiProto += "6"
} else {
apiProto += "4"
}
}
return apiProto
}
// ChildHostIP returns the address that must be used in the child network
// namespace in place of hostIP, a host IP address. In particular, port
// mappings from host IP addresses, and DNAT rules, must use this child
// address in place of the real host address.
func (c *PortDriverClient) ChildHostIP(hostIP netip.Addr) netip.Addr {
// address in place of the real host address. It may return an invalid
// netip.Addr if the proto and IP family aren't supported.
func (c *PortDriverClient) ChildHostIP(proto string, hostIP netip.Addr) netip.Addr {
if c == nil {
return hostIP
}
if _, ok := c.protos[c.proto(proto, hostIP)]; !ok {
// This happens when apiProto="tcp6", portDriverName="slirp4netns",
// because "slirp4netns" port driver does not support listening on IPv6 yet.
return netip.Addr{}
}
if c.childIP.IsValid() {
return c.childIP
}
@@ -117,20 +141,8 @@ func (c *PortDriverClient) AddPort(
if c == nil {
return func() error { return nil }, nil
}
// proto is like "tcp", but we need to convert it to "tcp4" or "tcp6" explicitly
// for libnetwork >= 20201216
//
// See https://github.com/moby/libnetwork/pull/2604/files#diff-8fa48beed55dd033bf8e4f8c40b31cf69d0b2cc5d4bb53cde8594670ea6c938aR20
// See also https://github.com/rootless-containers/rootlesskit/issues/231
apiProto := proto
if !strings.HasSuffix(apiProto, "4") && !strings.HasSuffix(apiProto, "6") {
if hostIP.Is6() {
apiProto += "6"
} else {
apiProto += "4"
}
}
apiProto := c.proto(proto, hostIP)
if _, ok := c.protos[apiProto]; !ok {
// This happens when apiProto="tcp6", portDriverName="slirp4netns",
// because "slirp4netns" port driver does not support listening on IPv6 yet.

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/netip"
"slices"
"strconv"
"github.com/containerd/log"
@@ -13,12 +14,13 @@ import (
"github.com/moby/moby/v2/daemon/libnetwork/portallocator"
"github.com/moby/moby/v2/daemon/libnetwork/portmapperapi"
"github.com/moby/moby/v2/daemon/libnetwork/types"
"github.com/moby/moby/v2/internal/sliceutil"
)
const driverName = "nat"
type PortDriverClient interface {
ChildHostIP(hostIP netip.Addr) netip.Addr
ChildHostIP(proto string, hostIP netip.Addr) netip.Addr
AddPort(ctx context.Context, proto string, hostIP, childIP netip.Addr, hostPort int) (func() error, error)
}
@@ -73,12 +75,18 @@ func (pm PortMapper) MapPorts(ctx context.Context, cfg []portmapperapi.PortBindi
}
}()
addrs := make([]net.IP, 0, len(cfg))
for i := range cfg {
cfg[i] = setChildHostIP(pm.pdc, cfg[i])
addrs = append(addrs, cfg[i].ChildHostIP)
for i := len(cfg) - 1; i >= 0; i-- {
var supported bool
if cfg[i], supported = setChildHostIP(pm.pdc, cfg[i]); !supported {
cfg = slices.Delete(cfg, i, i+1)
continue
}
}
addrs := sliceutil.Map(cfg, func(req portmapperapi.PortBindingReq) net.IP {
return req.ChildHostIP
})
pa := portallocator.NewOSAllocator()
allocatedPort, socks, err := pa.RequestPortsInRange(addrs, proto, int(hostPort), int(hostPortEnd))
if err != nil {
@@ -127,14 +135,21 @@ func (pm PortMapper) UnmapPorts(ctx context.Context, pbs []portmapperapi.PortBin
return errors.Join(errs...)
}
func setChildHostIP(pdc PortDriverClient, req portmapperapi.PortBindingReq) portmapperapi.PortBindingReq {
// setChildHostIP returns a modified PortBindingReq that contains the IP
// address that should be used for port allocation, firewall rules, etc. It
// returns false when the PortBindingReq isn't supported by the PortDriverClient.
func setChildHostIP(pdc PortDriverClient, req portmapperapi.PortBindingReq) (portmapperapi.PortBindingReq, bool) {
if pdc == nil {
req.ChildHostIP = req.HostIP
return req
return req, true
}
hip, _ := netip.AddrFromSlice(req.HostIP)
req.ChildHostIP = pdc.ChildHostIP(hip.Unmap()).AsSlice()
return req
chip := pdc.ChildHostIP(req.Proto.String(), hip.Unmap())
if !chip.IsValid() {
return req, false
}
req.ChildHostIP = chip.AsSlice()
return req, true
}
// configPortDriver passes the port binding's details to rootlesskit, and updates the