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;