diff --git a/Package.swift b/Package.swift index c9897a1c..2d0bc86a 100644 --- a/Package.swift +++ b/Package.swift @@ -335,6 +335,7 @@ let package = Package( name: "ContainerResourceTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), "ContainerResource", ] ), diff --git a/Sources/ContainerResource/Container/PublishPort.swift b/Sources/ContainerResource/Container/PublishPort.swift index b0ab0b38..6895a924 100644 --- a/Sources/ContainerResource/Container/PublishPort.swift +++ b/Sources/ContainerResource/Container/PublishPort.swift @@ -14,6 +14,8 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras + /// The network protocols available for port forwarding. public enum PublishProtocol: String, Sendable, Codable { case tcp = "tcp" @@ -37,7 +39,7 @@ public enum PublishProtocol: String, Sendable, Codable { /// Specifies internet port forwarding from host to container. public struct PublishPort: Sendable, Codable { /// The IP address of the proxy listener on the host - public let hostAddress: String + public let hostAddress: IPAddress /// The port number of the proxy listener on the host public let hostPort: UInt16 @@ -52,7 +54,7 @@ public struct PublishPort: Sendable, Codable { public let count: UInt16 /// Creates a new port forwarding specification. - public init(hostAddress: String, hostPort: UInt16, containerPort: UInt16, proto: PublishProtocol, count: UInt16) { + public init(hostAddress: IPAddress, hostPort: UInt16, containerPort: UInt16, proto: PublishProtocol, count: UInt16) { self.hostAddress = hostAddress self.hostPort = hostPort self.containerPort = containerPort @@ -65,7 +67,7 @@ public struct PublishPort: Sendable, Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - hostAddress = try container.decode(String.self, forKey: .hostAddress) + hostAddress = try container.decode(IPAddress.self, forKey: .hostAddress) hostPort = try container.decode(UInt16.self, forKey: .hostPort) containerPort = try container.decode(UInt16.self, forKey: .containerPort) proto = try container.decode(PublishProtocol.self, forKey: .proto) diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index ded05f45..74319cd7 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -17,6 +17,7 @@ import ContainerResource import Containerization import ContainerizationError +import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation @@ -576,41 +577,39 @@ public struct Parser { // Parse a single `--publish-port` argument into a `PublishPort`. public static func publishPort(_ portText: String) throws -> PublishPort { - let protoSplit = portText.split(separator: "/") - let proto: PublishProtocol - let addressAndPortText: String - switch protoSplit.count { - case 1: - addressAndPortText = String(protoSplit[0]) - proto = .tcp - case 2: - addressAndPortText = String(protoSplit[0]) - let protoText = String(protoSplit[1]) - guard let parsedProto = PublishProtocol(protoText) else { - throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") - } - proto = parsedProto - default: + let publishPortRegex = #/((\[(?[^\]]*)\]|(?[^:].*)):)?(?[^:].*):(?[^:/]*)(/(?.*))?/# + guard let match = try publishPortRegex.wholeMatch(in: portText) else { throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)") } - let hostAddress: String - let hostPortText: String - let containerPortText: String - let parts = addressAndPortText.split(separator: ":") - switch parts.count { - case 2: - hostAddress = "0.0.0.0" - hostPortText = String(parts[0]) - containerPortText = String(parts[1]) - case 3: - hostAddress = String(parts[0]) - hostPortText = String(parts[1]) - containerPortText = String(parts[2]) + let proto: PublishProtocol + let protoText = match.proto?.lowercased() ?? "tcp" + switch protoText { + case "tcp": + proto = .tcp + case "udp": + proto = .udp default: - throw ContainerizationError(.invalidArgument, message: "invalid publish address: \(portText)") + throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") } + let hostAddress: IPAddress + if let ipv6 = match.ipv6, !ipv6.isEmpty { + guard let address = try? IPAddress(String(ipv6)), case .v6 = address else { + throw ContainerizationError(.invalidArgument, message: "invalid publish IPv6 address: \(portText)") + } + hostAddress = address + } else if let ipv4 = match.ipv4, !ipv4.isEmpty { + guard let address = try? IPAddress(String(ipv4)), case .v4 = address else { + throw ContainerizationError(.invalidArgument, message: "invalid publish IPv4 address: \(portText)") + } + hostAddress = address + } else { + hostAddress = try IPAddress("0.0.0.0") + } + + let hostPortText = match.hostPort + let containerPortText = match.containerPort let hostPortRangeStart: UInt16 let hostPortRangeEnd: UInt16 let containerPortRangeStart: UInt16 @@ -679,7 +678,7 @@ public struct Parser { let containerCount = containerPortRangeEnd - containerPortRangeStart + 1 guard hostCount == containerCount else { - throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(addressAndPortText)") + throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(hostPortText):\(containerPortText)") } return PublishPort( diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index d5d8a008..f51e23af 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -218,9 +218,7 @@ public actor SandboxService { try await container.create() try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit) if !container.interfaces.isEmpty { - let firstCidr = container.interfaces[0].ipv4Address - let ipAddress = firstCidr.address.description - try await self.startSocketForwarders(containerIpAddress: ipAddress, publishedPorts: config.publishedPorts) + try await self.startSocketForwarders(attachment: attachments[0], publishedPorts: config.publishedPorts) } await self.setState(.booted) } catch { @@ -704,7 +702,7 @@ public actor SandboxService { try await self.monitor.track(id: id, waitingOn: waitFunc) } - private func startSocketForwarders(containerIpAddress: String, publishedPorts: [PublishPort]) async throws { + private func startSocketForwarders(attachment: Attachment, publishedPorts: [PublishPort]) async throws { var forwarders: [SocketForwarderResult] = [] guard !publishedPorts.hasOverlaps() else { throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap") @@ -713,8 +711,18 @@ public actor SandboxService { try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in for publishedPort in publishedPorts { for index in 0.. 0 { + do { + let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds)) + try #require(response.status == .ok) + success = true + print("request to \(url) succeeded") + } catch { + print("request to \(url) failed, error \(error)") + try await Task.sleep(for: .seconds(retryDelaySeconds)) + } + retriesRemaining -= 1 + } + try #require(success, "Request to \(url) failed after \(retries - retriesRemaining) retries") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + @Test func testRunCommandEnvFileFromNamedPipe() throws { do { let name = getTestName() diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 9c9328e3..b88b4c5a 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerizationError +import ContainerizationExtras import Foundation import Testing @@ -25,7 +26,8 @@ struct ParserTest { func testPublishPortParserTcp() throws { let result = try Parser.publishPorts(["127.0.0.1:8080:8000/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "127.0.0.1") + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) @@ -36,7 +38,8 @@ struct ParserTest { func testPublishPortParserUdp() throws { let result = try Parser.publishPorts(["192.168.32.36:8000:8080/UDP"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "192.168.32.36") + let expectedAddress = try IPAddress("192.168.32.36") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8000)) #expect(result[0].containerPort == UInt16(8080)) #expect(result[0].proto == .udp) @@ -47,7 +50,8 @@ struct ParserTest { func testPublishPortRange() throws { let result = try Parser.publishPorts(["127.0.0.1:8080-8179:9000-9099/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "127.0.0.1") + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(9000)) #expect(result[0].proto == .tcp) @@ -58,7 +62,8 @@ struct ParserTest { func testPublishPortRangeSingle() throws { let result = try Parser.publishPorts(["127.0.0.1:8080-8080:9000-9000/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "127.0.0.1") + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(9000)) #expect(result[0].proto == .tcp) @@ -69,7 +74,8 @@ struct ParserTest { func testPublishPortNoHostAddress() throws { let result = try Parser.publishPorts(["8080:8000/tcp"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "0.0.0.0") + let expectedAddress = try IPAddress("0.0.0.0") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) @@ -80,7 +86,20 @@ struct ParserTest { func testPublishPortNoProtocol() throws { let result = try Parser.publishPorts(["8080:8000"]) #expect(result.count == 1) - #expect(result[0].hostAddress == "0.0.0.0") + let expectedAddress = try IPAddress("0.0.0.0") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(8000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortParserIPv6() throws { + let result = try Parser.publishPorts(["[fe80::36f3:5e50:ed71:1bb]:8080:8000/tcp"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("fe80::36f3:5e50:ed71:1bb") + #expect(result[0].hostAddress == expectedAddress) #expect(result[0].hostPort == UInt16(8080)) #expect(result[0].containerPort == UInt16(8000)) #expect(result[0].proto == .tcp) @@ -112,14 +131,43 @@ struct ParserTest { } @Test - func testPublishPortInvalidAddress() throws { + func testPublishPortMissingPort() throws { #expect { _ = try Parser.publishPorts(["1234"]) } throws: { error in guard let error = error as? ContainerizationError else { return false } - return error.description.contains("invalid publish address") + return error.description.contains("invalid publish value") + } + } + + @Test + func testPublishInvalidIPv4Address() throws { + #expect { + _ = try Parser.publishPorts(["1234:8080:8000"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish IPv4 address") + } + } + + @Test + func testPublishInvalidIPv6Address() throws { + #expect { + _ = try Parser.publishPorts([ + "[1234:5678]:8080:8000", + "[2001::db8::1]:8080:8080", + "[2001:db8:85a3::8a2e:370g:7334]:8080:8080", + "[2001:db8:85a3::][8a2e::7334]:8080:8080", + ]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish IPv6 address") } } diff --git a/Tests/ContainerAPIClientTests/UtilityTests.swift b/Tests/ContainerAPIClientTests/UtilityTests.swift index e9dfeda0..bc48df97 100644 --- a/Tests/ContainerAPIClientTests/UtilityTests.swift +++ b/Tests/ContainerAPIClientTests/UtilityTests.swift @@ -97,12 +97,12 @@ struct UtilityTests { "8080-8179:9000-9099/udp", ]) #expect(ports.count == 2) - #expect(ports[0].hostAddress == "127.0.0.1") + #expect(ports[0].hostAddress.description == "127.0.0.1") #expect(ports[0].hostPort == 8000) #expect(ports[0].containerPort == 9080) #expect(ports[0].proto == .tcp) #expect(ports[0].count == 1) - #expect(ports[1].hostAddress == "0.0.0.0") + #expect(ports[1].hostAddress.description == "0.0.0.0") #expect(ports[1].hostPort == 8080) #expect(ports[1].containerPort == 9000) #expect(ports[1].proto == .udp) diff --git a/Tests/ContainerResourceTests/PublishPortTests.swift b/Tests/ContainerResourceTests/PublishPortTests.swift index 69cc93d9..3727f9eb 100644 --- a/Tests/ContainerResourceTests/PublishPortTests.swift +++ b/Tests/ContainerResourceTests/PublishPortTests.swift @@ -14,17 +14,18 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationExtras import Foundation import Testing @testable import ContainerResource -struct PublshPortTests { +struct PublishPortTests { @Test func testPublishPortsNonOverlapping() throws { let ports = [ - PublishPort(hostAddress: "0.0.0.0", hostPort: 9000, containerPort: 8080, proto: .tcp, count: 100), - PublishPort(hostAddress: "0.0.0.0", hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 100), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), ] #expect(!ports.hasOverlaps()) } @@ -32,8 +33,8 @@ struct PublshPortTests { @Test func testPublishPortsOverlapping() throws { let ports = [ - PublishPort(hostAddress: "0.0.0.0", hostPort: 9000, containerPort: 8080, proto: .tcp, count: 101), - PublishPort(hostAddress: "0.0.0.0", hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 101), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), ] #expect(ports.hasOverlaps()) } @@ -41,10 +42,10 @@ struct PublshPortTests { @Test func testPublishPortsSamePortDifferentProtocols() throws { let ports = [ - PublishPort(hostAddress: "0.0.0.0", hostPort: 8080, containerPort: 8080, proto: .tcp, count: 1), - PublishPort(hostAddress: "0.0.0.0", hostPort: 8080, containerPort: 8080, proto: .udp, count: 1), - PublishPort(hostAddress: "0.0.0.0", hostPort: 1024, containerPort: 1024, proto: .tcp, count: 1025), - PublishPort(hostAddress: "0.0.0.0", hostPort: 1024, containerPort: 1024, proto: .udp, count: 1025), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .tcp, count: 1), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .udp, count: 1), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .tcp, count: 1025), + PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .udp, count: 1025), ] #expect(!ports.hasOverlaps()) } diff --git a/docs/how-to.md b/docs/how-to.md index 94f37233..d6957042 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -156,49 +156,46 @@ Use the `--publish` option to forward TCP or UDP traffic from your loopback IP t If your container attaches to multiple networks, the ports you publish forward to the IP address of the interface attached to the first network. -To forward requests from `localhost:8080` to a Python webserver on container port 8000, run: +To forward requests from port 8080 on the IPv4 loopback IP to a NodeJS webserver on container port 8000, run: ```bash -container run -d --rm -p 127.0.0.1:8080:8000 python:slim python3 -m http.server --bind 0.0.0.0 8000 +container run -d --rm -p 127.0.0.1:8080:8000 node:latest npx http-server -a :: -p 8000 ``` -A `curl` to `localhost:8000` outputs: +Test access using `curl`: ```console -% curl http://localhost:8080 - - - - -Directory listing for / - - -

Directory listing for /

-
- -
- - +% curl http://127.0.0.1:8080 + + + + + + Index of / +... +
Node.js v25.2.1/ http-server server running @ 127.0.0.1:8080
+ +``` + +To forward requests from port 8080 on the IPv6 loopback IP to a NodeJS webserver on container port 8000, run: + +```bash +container run -d --rm -p '[::1]:8080:8000' node:latest npx http-server -a :: -p 8000 +``` + +Test access using `curl`: + +```console +% curl -6 'http://[::1]:8080' + + + + + + Index of / +... +
Node.js v25.2.1/ http-server server running @ [::1]:8080
+ ``` ## Set a custom MAC address for your container