@samitouri / QOSAMI-WSL / commits / da3969e6

WSLC: Add UDP and IPv6 support for exposed image ports (#41124)

* Add UDP and IPv6 support for exposed image ports

David Bennett committed Jul 23, 2026 at 10:35 UTC da3969e6b60332ee0813ef353216e0393411e03c
5 files changed +200 -27
localization/strings/en-US/Resources.resw
+3
@@ -3530,6 +3530,9 @@ On first run, creates the file with all settings commented out at their defaults
3530 <data name="MessageWslcSwapInitFailed" xml:space="preserve">
3531 <value>Failed to initialize swap</value>
3532 </data>
3533 + <data name="MessageWslcPublishAllUdpNotSupported" xml:space="preserve">
3534 + <value>UDP ports exposed by the image were not published because UDP port forwarding is not supported in the current networking mode</value>
3535 + </data>
3536 <data name="MessageWslcContainerTimestampRecoveryFailed" xml:space="preserve">
3537 <value>Failed to restore timestamp for container '{}'</value>
3538 <comment>{FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated</comment>
src/windows/wslcsession/WSLCContainer.cpp
+29 -9
@@ -366,7 +366,10 @@ std::vector<ContainerPortMapping> BuildPortMappings(std::vector<_WSLCPortMapping
366 const bool allocateVmPorts = NetworkModeAllocatesVmPorts(primary);
367 for (auto& e : requestedPorts)
368 {
369 - if (e.HostPort == WSLC_EPHEMERAL_PORT && vm.NetworkingMode() == WSLCNetworkingModeNAT)
369 + // Pre-allocate a concrete host port whenever the wslrelay relay path will be used: that path
370 + // maps the host port verbatim and has no ephemeral writeback (unlike the virtioNet path), so
371 + // an unresolved WSLC_EPHEMERAL_PORT (0) would otherwise be mapped as port 0 and fail.
372 + if (e.HostPort == WSLC_EPHEMERAL_PORT && vm.UseWslRelayPortForwarding())
373 {
374 e.HostPort = AllocateEphemeralPort(e.Family, e.BindingAddress);
375 }
@@ -1885,22 +1888,39 @@ std::shared_ptr<WSLCContainerImpl> WSLCContainerImpl::Create(
1888
1889 if (imageInfo.Config.has_value() && imageInfo.Config->ExposedPorts.has_value())
1890 {
1891 + // The userspace wslrelay port relay only forwards TCP. When it's active, adding a UDP
1892 + // mapping would fail the whole container (MapPort throws ERROR_NOT_SUPPORTED), so skip
1893 + // UDP exposed ports and warn once. UDP is published normally on the virtioNet path.
1894 + const bool relayForwarding = virtualMachine.UseWslRelayPortForwarding();
1895 + bool warnedUdpSkipped = false;
1896 +
1897 for (const auto& [portKey, _] : imageInfo.Config->ExposedPorts.value())
1898 {
1899 auto [port, protocol] = ParseExposedPortKey(portKey);
1900
1892 - // Only TCP localhost mappings are currently supported by the relay path.
1893 - if (protocol != IPPROTO_TCP)
1901 + if (relayForwarding && protocol == IPPROTO_UDP)
1902 {
1903 + if (!warnedUdpSkipped)
1904 + {
1905 + EMIT_USER_WARNING(Localization::MessageWslcPublishAllUdpNotSupported());
1906 + warnedUdpSkipped = true;
1907 + }
1908 +
1909 continue;
1910 }
1911
1898 - auto& createdPort = ports.emplace_back();
1899 - createdPort.HostPort = WSLC_EPHEMERAL_PORT;
1900 - createdPort.Family = AF_INET;
1901 - createdPort.ContainerPort = port;
1902 - createdPort.Protocol = protocol;
1903 - strcpy_s(createdPort.BindingAddress, "127.0.0.1");
1912 + // Exposed ports carry only a port and protocol (tcp/udp), never an address family.
1913 + // Mirror Docker's dual-stack default by publishing each exposed port on both the IPv4
1914 + // and IPv6 loopback, while keeping wslc's loopback-only default binding convention.
1915 + for (const auto& [family, address] : {std::pair{AF_INET, "127.0.0.1"}, std::pair{AF_INET6, "::1"}})
1916 + {
1917 + auto& createdPort = ports.emplace_back();
1918 + createdPort.HostPort = WSLC_EPHEMERAL_PORT;
1919 + createdPort.Family = family;
1920 + createdPort.ContainerPort = port;
1921 + createdPort.Protocol = protocol;
1922 + strcpy_s(createdPort.BindingAddress, address);
1923 + }
1924 }
1925 }
1926 }
src/windows/wslcsession/WSLCVirtualMachine.h
+4 -2
@@ -188,11 +188,13 @@ public:
188
189 WSLCNetworkingMode NetworkingMode() const;
190
191 + // True when port forwarding goes through the userspace wslrelay path (NAT mode, or Consomme with
192 + // the wslrelay feature flag). That relay only supports TCP localhost mappings.
193 + bool UseWslRelayPortForwarding() const;
194 +
195 private:
196 void MapRelayPort(_In_ int Family, _In_ unsigned short WindowsPort, _In_ unsigned short LinuxPort, _In_ bool Remove);
197
194 - bool UseWslRelayPortForwarding() const;
195 -
198 // Initial setup during Connect()
199 void ConfigureNetworking();
200
test/windows/WSLCTests.cpp
+42 -16
@@ -9078,22 +9078,48 @@ class WSLCTests
9078 VERIFY_IS_TRUE(inspectData.Ports.contains("8080/tcp"));
9079 VERIFY_IS_TRUE(inspectData.Ports.contains("9090/tcp"));
9080
9081 - // Verify we can connect to the 8080 exposed port from the host.
9082 - auto portBindings8080 = inspectData.Ports["8080/tcp"];
9083 - VERIFY_ARE_EQUAL(1u, portBindings8080.size());
9084 - auto hostPort8080 = std::stoi(portBindings8080[0].HostPort);
9085 - VERIFY_IS_TRUE(hostPort8080 > 0);
9086 -
9087 - ExpectHttpResponse(std::format(L"http://127.0.0.1:{}", hostPort8080).c_str(), 200);
9088 -
9089 - // Verify the second exposed port got a mapping too.
9090 - auto portBindings9090 = inspectData.Ports["9090/tcp"];
9091 - VERIFY_ARE_EQUAL(1u, portBindings9090.size());
9092 - auto hostPort9090 = std::stoi(portBindings9090[0].HostPort);
9093 - VERIFY_IS_TRUE(hostPort9090 > 0);
9094 -
9095 - // The two host ports must be different.
9096 - VERIFY_ARE_NOT_EQUAL(hostPort8080, hostPort9090);
9081 + // Each exposed port is published dual-stack: one IPv4 (127.0.0.1) and one IPv6 (::1)
9082 + // loopback binding. Verify both are present and return both host ports.
9083 + struct DualStackHostPorts
9084 + {
9085 + int ipv4 = 0;
9086 + int ipv6 = 0;
9087 + };
9088 +
9089 + auto getDualStackHostPorts = [](const auto& bindings) -> DualStackHostPorts {
9090 + VERIFY_ARE_EQUAL(2u, bindings.size());
9091 +
9092 + DualStackHostPorts ports;
9093 + for (const auto& binding : bindings)
9094 + {
9095 + auto hostPort = std::stoi(binding.HostPort);
9096 + VERIFY_IS_TRUE(hostPort > 0);
9097 + if (binding.HostIp == "127.0.0.1")
9098 + {
9099 + ports.ipv4 = hostPort;
9100 + }
9101 + else if (binding.HostIp == "::1")
9102 + {
9103 + ports.ipv6 = hostPort;
9104 + }
9105 + }
9106 +
9107 + VERIFY_IS_TRUE(ports.ipv4 > 0);
9108 + VERIFY_IS_TRUE(ports.ipv6 > 0);
9109 + return ports;
9110 + };
9111 +
9112 + // Verify we can reach the 8080 exposed port over both IPv4 and IPv6 loopback.
9113 + auto ports8080 = getDualStackHostPorts(inspectData.Ports["8080/tcp"]);
9114 + ExpectHttpResponse(std::format(L"http://127.0.0.1:{}", ports8080.ipv4).c_str(), 200);
9115 + ExpectHttpResponse(std::format(L"http://[::1]:{}", ports8080.ipv6).c_str(), 200);
9116 +
9117 + // Verify the second exposed port got a dual-stack mapping too.
9118 + auto ports9090 = getDualStackHostPorts(inspectData.Ports["9090/tcp"]);
9119 +
9120 + // Each exposed port must map to distinct host ports on both loopback families.
9121 + VERIFY_ARE_NOT_EQUAL(ports8080.ipv4, ports9090.ipv4);
9122 + VERIFY_ARE_NOT_EQUAL(ports8080.ipv6, ports9090.ipv6);
9123 }
9124 }
9125
test/windows/wslc/e2e/WSLCE2EContainerCreateTests.cpp
+122
@@ -1379,6 +1379,124 @@ class WSLCE2EContainerCreateTests
1379 VERIFY_ARE_EQUAL("127.0.0.1", bindings[0].HostIp);
1380 }
1381
1382 + WSLC_TEST_METHOD(WSLCE2E_Container_Create_PublishAll_DualStack)
1383 + {
1384 + // Remove the container and built image on exit. Both helpers are idempotent, so this is safe
1385 + // to arm before either resource exists.
1386 + auto cleanup = wil::scope_exit([&] {
1387 + EnsureContainerDoesNotExist(WslcContainerName);
1388 + EnsureImageIsDeleted(PublishAllImage);
1389 + });
1390 +
1391 + // Load the Python base image so the test image can be built offline.
1392 + EnsureImageIsLoaded(PythonImage);
1393 +
1394 + // Build an image that exposes a TCP and a UDP port and ships a server that listens on both,
1395 + // so publish-all can be exercised end to end.
1396 + auto testRoot = std::filesystem::current_path() / L"wslc-e2e-publish-all";
1397 + auto cleanupDir = SetupTestDirectory(testRoot);
1398 +
1399 + auto contextDir = testRoot / L"context";
1400 + std::error_code ec;
1401 + std::filesystem::create_directories(contextDir, ec);
1402 + THROW_HR_IF(E_FAIL, ec.value() != 0 || !std::filesystem::exists(contextDir));
1403 +
1404 + // Dual-stack HTTP server on 8080/tcp plus a UDP echo server on 9090/udp in one process. Both
1405 + // sockets bind before "SERVERS READY" is printed, so that marker signals both ports accept.
1406 + WriteTestFileContent(
1407 + contextDir / L"server.py",
1408 + R"PY(
1409 +import contextlib, socket, threading
1410 +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
1411 +
1412 +class DualStackHttp(ThreadingHTTPServer):
1413 + address_family = socket.AF_INET6
1414 +
1415 + def server_bind(self):
1416 + with contextlib.suppress(Exception):
1417 + self.socket.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
1418 + super().server_bind()
1419 +
1420 +http_server = DualStackHttp(('::', 8080), SimpleHTTPRequestHandler)
1421 +
1422 +udp = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
1423 +udp.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
1424 +udp.bind(('::', 9090))
1425 +
1426 +threading.Thread(target=http_server.serve_forever, daemon=True).start()
1427 +print('SERVERS READY', flush=True)
1428 +
1429 +while True:
1430 + data, address = udp.recvfrom(1024)
1431 + udp.sendto(data.upper(), address)
1432 +)PY");
1433 +
1434 + auto dockerfilePath = testRoot / L"Dockerfile";
1435 + WriteTestFileContent(
1436 + dockerfilePath,
1437 + std::format(
1438 + "FROM {}\n"
1439 + "COPY server.py /server.py\n"
1440 + "EXPOSE 8080/tcp\n"
1441 + "EXPOSE 9090/udp\n",
1442 + string::WideToMultiByte(PythonImage.NameAndTag())));
1443 +
1444 + auto buildResult = RunWslc(std::format(
1445 + L"build \"{}\" -f \"{}\" -t {}", contextDir.wstring(), dockerfilePath.wstring(), PublishAllImage.NameAndTag()));
1446 + buildResult.Verify({.Stdout = L"", .ExitCode = 0});
1447 +
1448 + // Port bindings only show up in inspect after start, so create then start before inspecting.
1449 + auto result = RunWslc(
1450 + std::format(L"container create --name {} -P {} python3 -u /server.py", WslcContainerName, PublishAllImage.NameAndTag()));
1451 + result.Verify({.Stderr = L"", .ExitCode = 0});
1452 +
1453 + result = RunWslc(std::format(L"container start {}", WslcContainerName));
1454 + result.Verify({.Stderr = L"", .ExitCode = 0});
1455 +
1456 + // Wait until both listeners are bound before probing the published ports.
1457 + WaitForContainerOutput(WslcContainerName, "SERVERS READY");
1458 +
1459 + // Each exposed port must be published dual-stack: one IPv4 (127.0.0.1) and one IPv6 (::1)
1460 + // loopback binding, each on an ephemeral host port. Return both host ports for the protocol.
1461 + const auto inspect = InspectContainer(WslcContainerName);
1462 + auto getDualStackHostPorts = [&](const std::string& portKey) -> std::pair<uint16_t, uint16_t> {
1463 + VERIFY_IS_TRUE(inspect.Ports.contains(portKey));
1464 +
1465 + const auto& bindings = inspect.Ports.at(portKey);
1466 + VERIFY_ARE_EQUAL(2u, bindings.size());
1467 +
1468 + uint16_t ipv4Port = 0;
1469 + uint16_t ipv6Port = 0;
1470 + for (const auto& binding : bindings)
1471 + {
1472 + const int hostPort = std::stoi(binding.HostPort);
1473 + VERIFY_IS_TRUE(hostPort > 0 && hostPort <= 65535);
1474 + if (binding.HostIp == "127.0.0.1")
1475 + {
1476 + ipv4Port = static_cast<uint16_t>(hostPort);
1477 + }
1478 + else if (binding.HostIp == "::1")
1479 + {
1480 + ipv6Port = static_cast<uint16_t>(hostPort);
1481 + }
1482 + }
1483 +
1484 + VERIFY_IS_TRUE(ipv4Port > 0);
1485 + VERIFY_IS_TRUE(ipv6Port > 0);
1486 + return {ipv4Port, ipv6Port};
1487 + };
1488 +
1489 + // TCP: the HTTP server must respond over both IPv4 and IPv6 loopback.
1490 + const auto [tcpIpv4, tcpIpv6] = getDualStackHostPorts("8080/tcp");
1491 + ExpectHttpResponse(std::format(L"http://127.0.0.1:{}", tcpIpv4).c_str(), HTTP_STATUS_OK, true);
1492 + ExpectHttpResponse(std::format(L"http://[::1]:{}", tcpIpv6).c_str(), HTTP_STATUS_OK, true);
1493 +
1494 + // UDP: the echo server must reply over both IPv4 and IPv6 loopback.
1495 + const auto [udpIpv4, udpIpv6] = getDualStackHostPorts("9090/udp");
1496 + SendUdpAndReceive(udpIpv4, "hello", "HELLO", AF_INET);
1497 + SendUdpAndReceive(udpIpv6, "hello", "HELLO", AF_INET6);
1498 + }
1499 +
1500 private:
1501 // Test container name
1502 const std::wstring WslcContainerName = L"wslc-test-container";
@@ -1400,8 +1518,12 @@ private:
1518 // Test images
1519 const TestImage& AlpineImage = AlpineTestImage();
1520 const TestImage& DebianImage = DebianTestImage();
1521 + const TestImage& PythonImage = PythonTestImage();
1522 const TestImage& InvalidImage = InvalidTestImage();
1523
1524 + // Image built at test time with exposed TCP and UDP ports for publish-all coverage.
1525 + const TestImage PublishAllImage{L"wslc-e2e-publish-all", L"latest", L""};
1526 +
1527 // Test ports
1528 const uint16_t ContainerTestPort = 8080;
1529 const uint16_t HostTestPort1 = 1234;