Initial support for port mapping in CLI#14225
Initial support for port mapping in CLI#14225AmelBawa-msft wants to merge 5 commits intofeature/wsl-for-appsfrom
Conversation
|
I have a meta comment before we go too far down the argument validation path. We have two types of arguments in the CLI:
@OneBlue recommended that our general approach for argument validation for arguments to the service is that we should pass the argument along to the service with as little modification as possible, and if it's bad input the service will return error messages that we can surface to the user. I completely agree with this approach and believe it should apply to everything not just the service. I would prefer not to have rigid validation of arguments in the CLI unless it's a type 1 argument specific to the CLI or some minimum parsing needs to be done in order to put the data into a form the service can consume (converting a string to two integers, for example). The port validation is something I would expect the service to handle and I am generally concerned about the CLI parsing the port data argument differently from the service. I am strongly in favor of only validating the bare minimum needed to make it valid input to the service and let the service tell us if its inputs were invalid. So the general guidance for argument validation should be: "Is something else already validating or going to validate this?" If the answer is yes, then the CLI should only validate it enough to get it to that validator and then rely on that validator to do its job correctly. |
|
@dkbennett great observation and feedback, thank you! I have a couple of questions before marking this PR as ready, one of which you touched on, and another related to what we currently support. This PR contains the necessary pieces to light up the feature, but I’ve held off on marking it ready to avoid duplicating service logic and to clarify how certain parts of the syntax should be handled. Some aspects may require follow-up work with the runtime team. I will sync up today with the team for suggestions on the next step. Excited to see this feature finally available 🚀🙌 |
There was a problem hiding this comment.
Pull request overview
This PR adds initial support for port mapping in the WSLC CLI, implementing the -p/--publish flag for the wslc container run and wslc container create commands. The implementation includes a comprehensive port mapping parser that supports various formats including IPv4/IPv6 addresses, port ranges, and protocol specifications (TCP/UDP).
Changes:
- Adds
PublishPortparser with support for formats like[host-ip:][host-port:]container-port[/protocol], including IPv6 addresses in brackets and port ranges - Integrates port mapping into CLI commands with
-p/--publishargument - Implements ephemeral port allocation using socket binding when host port is not specified
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| src/windows/wslc/ContainerModel.h | Adds PublishPort class with nested PortRange and IPAddress structs for parsing port mapping specifications |
| src/windows/wslc/ContainerModel.cpp | Implements parsing logic for port mappings, IP addresses, and port ranges with validation |
| src/windows/wslc/ContainerService.cpp | Adds ResolveOrAllocatePort function for ephemeral port allocation and integrates port mapping into container creation |
| src/windows/wslc/ContainerCommand.h | Adds -p/--publish flag to container run and create commands with help text |
| src/windows/wslc/CMakeLists.txt | Adds ContainerModel.cpp to build configuration |
| sockaddr_in addr{}; | ||
| addr.sin_family = AF_INET; | ||
| addr.sin_addr.s_addr = htonl(loopback ? INADDR_LOOPBACK : INADDR_ANY); | ||
| addr.sin_port = htons(0); |
There was a problem hiding this comment.
The sockaddr_in structure is only suitable for IPv4 addresses. When handling IPv6 addresses (as determined by the host IP), a sockaddr_in6 structure should be used instead, with the appropriate IPv6 address fields (sin6_family, sin6_addr, sin6_port).
| auto socket = ::socket(AF_INET, sockType, ipProto); | ||
| THROW_LAST_ERROR_IF(socket == INVALID_SOCKET); | ||
|
|
||
| // Bind to port 0 to ask Windows for an ephemeral port | ||
| auto loopback = port.HostIP().has_value() && port.HostIP()->IsLoopback(); | ||
| sockaddr_in addr{}; | ||
| addr.sin_family = AF_INET; | ||
| addr.sin_addr.s_addr = htonl(loopback ? INADDR_LOOPBACK : INADDR_ANY); | ||
| addr.sin_port = htons(0); | ||
| if (::bind(socket, reinterpret_cast<const sockaddr*>(&addr), sizeof(addr)) == SOCKET_ERROR) | ||
| { | ||
| ::closesocket(socket); | ||
| THROW_LAST_ERROR(); | ||
| } | ||
|
|
||
| // Read the chosen port back | ||
| sockaddr_in bound{}; | ||
| int len = sizeof(bound); | ||
| if (::getsockname(socket, reinterpret_cast<sockaddr*>(&bound), &len) == SOCKET_ERROR) | ||
| { | ||
| ::closesocket(socket); | ||
| THROW_LAST_ERROR(); | ||
| } | ||
|
|
||
| auto chosen = ntohs(bound.sin_port); | ||
| ::closesocket(socket); |
There was a problem hiding this comment.
This code uses a raw socket handle and manually calls closesocket. Following the established pattern in this codebase (as seen in files like hvsocket.cpp, NatNetworking.cpp, etc.), sockets should be managed using wil::unique_socket for automatic cleanup and to avoid resource leaks if exceptions are thrown before closesocket is called.
| for (unsigned int i = 0; i < containerPort.Count(); ++i) | ||
| { | ||
| int family = portMapping.HostIP().has_value() && portMapping.HostIP()->IsIPv6() ? AF_INET6 : AF_INET; | ||
| auto currentContainerPort = containerPort.Start() + i; | ||
| auto currentHostPort = ResolveOrAllocatePort(portMapping, i); |
There was a problem hiding this comment.
The ephemeral port allocation allocates a port for each iteration in the loop, but immediately closes the socket after reading the port number. This creates a race condition where the allocated port could be reused by another process before AddPort is called. For port ranges, this could lead to port conflicts. Consider keeping the socket open until after AddPort is called, or using a different mechanism to ensure ports remain reserved.
| PublishPort::PortRange result{}; | ||
| result.m_start = startPort; | ||
| result.m_end = endPort; | ||
| return result; | ||
| } | ||
| else | ||
| { | ||
| // Single port specified | ||
| int port = parsePort(portPart, "Invalid port specified in port mapping."); | ||
|
|
||
| PublishPort::PortRange result{}; | ||
| result.m_start = port; | ||
| result.m_end = port; | ||
| return result; |
There was a problem hiding this comment.
The PortRange struct's m_start and m_end members are private, but ParsePortPart attempts to directly assign to them. This will cause a compilation error. Consider making these members public, or adding a private constructor that takes start and end values.
| PublishPort::PortRange result{}; | |
| result.m_start = startPort; | |
| result.m_end = endPort; | |
| return result; | |
| } | |
| else | |
| { | |
| // Single port specified | |
| int port = parsePort(portPart, "Invalid port specified in port mapping."); | |
| PublishPort::PortRange result{}; | |
| result.m_start = port; | |
| result.m_end = port; | |
| return result; | |
| return PublishPort::PortRange(startPort, endPort); | |
| } | |
| else | |
| { | |
| // Single port specified | |
| int port = parsePort(portPart, "Invalid port specified in port mapping."); | |
| return PublishPort::PortRange(port, port); |
| "arguments (pos. 1..): Arguments to pass to container's init process", | ||
| "-t, --tty: Open a TTY with the container process", | ||
| "-i, --interactive: Keep stdin open", | ||
| "-p, --publish: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])", |
There was a problem hiding this comment.
The help text format description '[host-ip:]host-port:container-port[/protocol]' is misleading because it suggests host-port is always required. However, the parser supports the format 'container-port' (without host-port) for ephemeral port allocation. The format should be '[host-ip:][host-port:]container-port[/protocol]' to indicate that host-port is also optional.
| // Create a socket matching the protocol. | ||
| const int sockType = (port.PortProtocol() == PublishPort::Protocol::TCP) ? SOCK_STREAM : SOCK_DGRAM; | ||
| const int ipProto = (port.PortProtocol() == PublishPort::Protocol::TCP) ? IPPROTO_TCP : IPPROTO_UDP; | ||
| auto socket = ::socket(AF_INET, sockType, ipProto); |
There was a problem hiding this comment.
The ResolveOrAllocatePort function always creates an AF_INET (IPv4) socket regardless of whether the port mapping specifies an IPv6 address. When the user specifies an IPv6 host IP (e.g., '[::1]:8080:80'), this function should create an AF_INET6 socket instead. The socket family should be determined based on whether port.HostIP() is IPv6, similar to how the family is determined in line 130.
| PublishPort::PortRange result{}; | ||
| result.m_start = startPort; | ||
| result.m_end = endPort; |
There was a problem hiding this comment.
The PortRange struct's m_start and m_end members are private, but ParsePortPart (a static member function) attempts to directly assign to them. This will cause a compilation error because static member functions cannot access private non-static members without an object, and these assignments are trying to access private members of 'result'. Consider making these members public, or adding a private constructor that takes start and end values.
| "-t, --tty: Open a TTY with the container process", | ||
| "-i, --interactive: Keep stdin open", | ||
| "-d, --detach: Run container in background", | ||
| "-p, --publish: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])", |
There was a problem hiding this comment.
The help text format description '[host-ip:]host-port:container-port[/protocol]' is misleading because it suggests host-port is always required. However, the parser supports the format 'container-port' (without host-port) for ephemeral port allocation. The format should be '[host-ip:][host-port:]container-port[/protocol]' to indicate that host-port is also optional.
| "-p, --publish: Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])", | |
| "-p, --publish: Publish a port from container to host (format: [host-ip:][host-port:]container-port[/protocol])", |
| static auto parsePort = [](const std::string& value, const char* errorMessage) -> int { | ||
| try | ||
| { | ||
| size_t idx = 0; | ||
| int port = std::stoi(value, &idx, 10); | ||
| if (idx != value.size()) | ||
| { | ||
| THROW_HR_WITH_USER_ERROR(E_INVALIDARG, errorMessage); | ||
| } | ||
| return port; | ||
| } | ||
| catch (const std::exception&) | ||
| { | ||
| THROW_HR_WITH_USER_ERROR(E_INVALIDARG, errorMessage); | ||
| } | ||
| }; |
There was a problem hiding this comment.
The static lambda 'parsePort' is defined inside ParsePortPart. While this is thread-safe in C++11 and later (static local variables are initialized once in a thread-safe manner), the function could potentially be called recursively or concurrently. Consider making this a file-level static function instead for better clarity and to avoid potential confusion about initialization overhead on every call.
| int family = portMapping.HostIP().has_value() && portMapping.HostIP()->IsIPv6() ? AF_INET6 : AF_INET; | ||
| auto currentContainerPort = containerPort.Start() + i; | ||
| auto currentHostPort = ResolveOrAllocatePort(portMapping, i); | ||
| containerLauncher.AddPortW(currentHostPort, currentContainerPort, family); |
There was a problem hiding this comment.
The method call uses 'AddPortW' but the actual method name in WSLAContainerLauncher is 'AddPort'. This will cause a compilation error. The method should be called as 'AddPort' without the 'W' suffix.
| containerLauncher.AddPortW(currentHostPort, currentContainerPort, family); | |
| containerLauncher.AddPort(currentHostPort, currentContainerPort, family); |
Summary of the Pull Request
🦞 Port mapping parser
Parser support examples:
✅ Valid
❌ Invalid
🦐 Full example
PR Checklist
Detailed Description of the Pull Request / Additional comments
Validation Steps Performed