Browse code

libnet/rlkclient: don't collapse loopback host IPs to 127.0.0.1

In rootless mode, ChildHostIP maps every IPv4 host address to 127.0.0.1
in the child network namespace. Port bindings on the same port but
distinct loopback addresses (e.g. 127.0.1.2:80 and 127.0.1.3:80) were
therefore both reserved as 127.0.0.1:80 by the port allocator in the
child namespace, and the second binding failed with "Bind for
127.0.0.1:8080 failed: port is already allocated" even though the
requested addresses do not conflict.

Preserve IPv4 loopback host addresses as the child host IP instead. The
child namespace's lo interface covers all of 127.0.0.0/8, so the
addresses are bindable as-is, and RootlessKit's builtin port driver
both listens on the requested parent address and dials the requested
child address verbatim. Port drivers that disallow loopback child IPs
(slirp4netns) are unaffected: their forced non-loopback childIP is
selected before the loopback fallback.

Signed-off-by: Andrew Liu <andrewjliu22@gmail.com>

Andrew Liu authored on 2026/06/10 10:59:57
Showing 3 changed files
... ...
@@ -110,6 +110,13 @@ func (c *PortDriverClient) ChildHostIP(proto string, hostIP netip.Addr) netip.Ad
110 110
 	if c.childIP.IsValid() {
111 111
 		return c.childIP
112 112
 	}
113
+	// Preserve IPv4 loopback addresses, the child namespace's lo covers all
114
+	// of 127.0.0.0/8. Mapping them all to 127.0.0.1 makes bindings on the
115
+	// same port but distinct loopback addresses collide in the child
116
+	// namespace.
117
+	if hostIP.Is4() && hostIP.IsLoopback() {
118
+		return hostIP
119
+	}
113 120
 	if hostIP.Is6() {
114 121
 		return netip.IPv6Loopback()
115 122
 	}
116 123
new file mode 100644
... ...
@@ -0,0 +1,95 @@
0
+package rlkclient
1
+
2
+import (
3
+	"net/netip"
4
+	"testing"
5
+
6
+	"gotest.tools/v3/assert"
7
+	is "gotest.tools/v3/assert/cmp"
8
+)
9
+
10
+func TestChildHostIP(t *testing.T) {
11
+	builtin := &PortDriverClient{
12
+		portDriverName: "builtin",
13
+		protos:         map[string]struct{}{"tcp4": {}, "tcp6": {}, "udp4": {}, "udp6": {}},
14
+	}
15
+	slirp4netns := &PortDriverClient{
16
+		portDriverName: "slirp4netns",
17
+		protos:         map[string]struct{}{"tcp4": {}, "udp4": {}},
18
+		childIP:        netip.MustParseAddr("10.0.2.100"),
19
+	}
20
+
21
+	testcases := []struct {
22
+		name   string
23
+		pdc    *PortDriverClient
24
+		proto  string
25
+		hostIP netip.Addr
26
+		want   netip.Addr
27
+	}{
28
+		{
29
+			name:   "nil client",
30
+			proto:  "tcp",
31
+			hostIP: netip.MustParseAddr("127.0.1.2"),
32
+			want:   netip.MustParseAddr("127.0.1.2"),
33
+		},
34
+		{
35
+			name:   "unsupported proto",
36
+			pdc:    slirp4netns,
37
+			proto:  "tcp",
38
+			hostIP: netip.MustParseAddr("::1"),
39
+		},
40
+		{
41
+			name:   "forced child IP",
42
+			pdc:    slirp4netns,
43
+			proto:  "tcp",
44
+			hostIP: netip.MustParseAddr("127.0.1.2"),
45
+			want:   netip.MustParseAddr("10.0.2.100"),
46
+		},
47
+		{
48
+			name:   "v4 unspecified",
49
+			pdc:    builtin,
50
+			proto:  "tcp",
51
+			hostIP: netip.MustParseAddr("0.0.0.0"),
52
+			want:   netip.MustParseAddr("127.0.0.1"),
53
+		},
54
+		{
55
+			name:   "v4 non-loopback",
56
+			pdc:    builtin,
57
+			proto:  "tcp",
58
+			hostIP: netip.MustParseAddr("192.0.2.1"),
59
+			want:   netip.MustParseAddr("127.0.0.1"),
60
+		},
61
+		{
62
+			name:   "v4 default loopback",
63
+			pdc:    builtin,
64
+			proto:  "tcp",
65
+			hostIP: netip.MustParseAddr("127.0.0.1"),
66
+			want:   netip.MustParseAddr("127.0.0.1"),
67
+		},
68
+		{
69
+			// Distinct loopback addresses must not be collapsed to
70
+			// 127.0.0.1, or bindings on the same port collide in the
71
+			// child namespace.
72
+			// Regression test for https://github.com/moby/moby/issues/52783
73
+			name:   "v4 non-default loopback",
74
+			pdc:    builtin,
75
+			proto:  "tcp",
76
+			hostIP: netip.MustParseAddr("127.0.1.2"),
77
+			want:   netip.MustParseAddr("127.0.1.2"),
78
+		},
79
+		{
80
+			name:   "v6 non-loopback",
81
+			pdc:    builtin,
82
+			proto:  "tcp",
83
+			hostIP: netip.MustParseAddr("2001:db8::1"),
84
+			want:   netip.IPv6Loopback(),
85
+		},
86
+	}
87
+
88
+	for _, tc := range testcases {
89
+		t.Run(tc.name, func(t *testing.T) {
90
+			got := tc.pdc.ChildHostIP(tc.proto, tc.hostIP)
91
+			assert.Check(t, is.Equal(got, tc.want))
92
+		})
93
+	}
94
+}
... ...
@@ -4,6 +4,7 @@ import (
4 4
 	"bytes"
5 5
 	"context"
6 6
 	"fmt"
7
+	"io"
7 8
 	"net"
8 9
 	"net/http"
9 10
 	"net/netip"
... ...
@@ -1601,3 +1602,63 @@ func TestMixAnyWithSpecificHostAddrs(t *testing.T) {
1601 1601
 		})
1602 1602
 	}
1603 1603
 }
1604
+
1605
+// TestPortMappingOnDistinctLoopbackAddrs checks that two containers can
1606
+// publish the same port on distinct host loopback addresses, and that each
1607
+// address is routed to its own container. In rootless mode, loopback host
1608
+// addresses used to be collapsed to 127.0.0.1 in the daemon's network
1609
+// namespace, making the second binding fail with "port is already allocated".
1610
+// Regression test for https://github.com/moby/moby/issues/52783
1611
+func TestPortMappingOnDistinctLoopbackAddrs(t *testing.T) {
1612
+	ctx := setupTest(t)
1613
+
1614
+	d := daemon.New(t)
1615
+	d.StartWithBusybox(ctx, t)
1616
+	defer d.Stop(t)
1617
+
1618
+	c := d.NewClientT(t)
1619
+	defer c.Close()
1620
+
1621
+	const hostPort = "1716"
1622
+	hostAddrs := []string{"127.0.1.2", "127.0.1.3"}
1623
+	for i, hostAddr := range hostAddrs {
1624
+		ctrID := container.Run(ctx, t, c,
1625
+			container.WithName(sanitizeCtrName(fmt.Sprintf("%s-server%d", t.Name(), i))),
1626
+			container.WithExposedPorts("80/tcp"),
1627
+			container.WithPortMap(networktypes.PortMap{
1628
+				networktypes.MustParsePort("80/tcp"): {{
1629
+					HostIP:   netip.MustParseAddr(hostAddr),
1630
+					HostPort: hostPort,
1631
+				}},
1632
+			}),
1633
+			// Serve the index of the host address the container is published
1634
+			// on, so the response identifies which container handled it.
1635
+			container.WithCmd("sh", "-c",
1636
+				fmt.Sprintf("mkdir -p /www && echo -n %d > /www/index.html && exec httpd -f -h /www", i)),
1637
+		)
1638
+		defer c.ContainerRemove(ctx, ctrID, client.ContainerRemoveOptions{Force: true})
1639
+	}
1640
+
1641
+	for i, hostAddr := range hostAddrs {
1642
+		httpClient := &http.Client{Timeout: 3 * time.Second}
1643
+		url := "http://" + net.JoinHostPort(hostAddr, hostPort)
1644
+		var resp *http.Response
1645
+		var err error
1646
+		// The userland proxy or RootlessKit forwarder may need a moment to
1647
+		// start accepting connections after container start.
1648
+		for attempt := 0; attempt < 5; attempt++ {
1649
+			resp, err = httpClient.Get(url)
1650
+			if err == nil {
1651
+				break
1652
+			}
1653
+			time.Sleep(500 * time.Millisecond)
1654
+		}
1655
+		assert.NilError(t, err)
1656
+		defer resp.Body.Close()
1657
+		assert.Check(t, is.Equal(resp.StatusCode, http.StatusOK))
1658
+		body, err := io.ReadAll(resp.Body)
1659
+		assert.NilError(t, err)
1660
+		assert.Check(t, is.Equal(string(body), strconv.Itoa(i)),
1661
+			"%s answered by the wrong container", url)
1662
+	}
1663
+}