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>
| ... | ... |
@@ -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 |
+} |