From 52fae09ec0a348c41cc41a0318c84884b14b6a2c Mon Sep 17 00:00:00 2001 From: Albin Kerouanton Date: Fri, 28 Nov 2025 12:36:20 +0100 Subject: [PATCH] libnet/pms/nat: don't bind IPv6 ports if not supported by port driver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../drivers/bridge/port_mapping_linux_test.go | 38 +++++++++++++---- .../rlkclient/rootlesskit_client_linux.go | 42 ++++++++++++------- .../portmappers/nat/mapper_linux.go | 33 +++++++++++---- 3 files changed, 80 insertions(+), 33 deletions(-) diff --git a/daemon/libnetwork/drivers/bridge/port_mapping_linux_test.go b/daemon/libnetwork/drivers/bridge/port_mapping_linux_test.go index 381a5c20b2..cd8e2b063f 100644 --- a/daemon/libnetwork/drivers/bridge/port_mapping_linux_test.go +++ b/daemon/libnetwork/drivers/bridge/port_mapping_linux_test.go @@ -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") diff --git a/daemon/libnetwork/internal/rlkclient/rootlesskit_client_linux.go b/daemon/libnetwork/internal/rlkclient/rootlesskit_client_linux.go index d1a1087f8f..d603124131 100644 --- a/daemon/libnetwork/internal/rlkclient/rootlesskit_client_linux.go +++ b/daemon/libnetwork/internal/rlkclient/rootlesskit_client_linux.go @@ -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. diff --git a/daemon/libnetwork/portmappers/nat/mapper_linux.go b/daemon/libnetwork/portmappers/nat/mapper_linux.go index 82c35b79dc..1630c66aaa 100644 --- a/daemon/libnetwork/portmappers/nat/mapper_linux.go +++ b/daemon/libnetwork/portmappers/nat/mapper_linux.go @@ -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