Adds IPv6 port forwarding. (#1029)

- Closes #1006.
This commit is contained in:
J Logan
2026-01-07 18:23:31 -08:00
committed by GitHub
parent 9d06475aaf
commit 98410fdb57
9 changed files with 197 additions and 95 deletions
+1
View File
@@ -335,6 +335,7 @@ let package = Package(
name: "ContainerResourceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
"ContainerResource",
]
),
@@ -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)
@@ -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 = #/((\[(?<ipv6>[^\]]*)\]|(?<ipv4>[^:].*)):)?(?<hostPort>[^:].*):(?<containerPort>[^:/]*)(/(?<proto>.*))?/#
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(
@@ -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..<publishedPort.count {
let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress, port: Int(publishedPort.hostPort + index))
let serverAddress = try SocketAddress(ipAddress: containerIpAddress, port: Int(publishedPort.containerPort + index))
let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress.description, port: Int(publishedPort.hostPort + index))
let containerIPAddress: String
switch publishedPort.hostAddress {
case .v4(_):
containerIPAddress = attachment.ipv4Address.address.description
case .v6(_):
guard let ipv6Address = attachment.ipv6Address else {
throw ContainerizationError(.invalidState, message: "cannot configure IPv6 port forwarding for container with unknown IPv6 address")
}
containerIPAddress = ipv6Address.address.description
}
let serverAddress = try SocketAddress(ipAddress: containerIPAddress, port: Int(publishedPort.containerPort + index))
log.info(
"creating forwarder for",
metadata: [
@@ -577,6 +577,52 @@ class TestCLIRunCommand: CLITest {
}
}
@available(macOS 26, *)
@Test func testForwardTCPv6() async throws {
let retries = 10
let retryDelaySeconds = Int64(3)
do {
let name = getLowercasedTestName()
let proxyIp = "[::1]"
let proxyPort = UInt16.random(in: 50000..<55000)
let serverPort = UInt16.random(in: 55000..<60000)
try doLongRun(
name: name,
image: "docker.io/library/node:alpine",
args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"],
containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"])
defer {
try? doStop(name: name)
}
let url = "http://\(proxyIp):\(proxyPort)"
var request = HTTPClientRequest(url: url)
request.method = .GET
let config = HTTPClient.Configuration(proxy: nil)
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
defer { _ = client.shutdown() }
var retriesRemaining = retries
var success = false
while !success && retriesRemaining > 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()
+56 -8
View File
@@ -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")
}
}
@@ -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)
@@ -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())
}
+34 -37
View File
@@ -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
<!DOCTYPE HTML>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Directory listing for /</title>
</head>
<body>
<h1>Directory listing for /</h1>
<hr>
<ul>
<li><a href="bin/">bin@</a></li>
<li><a href="boot/">boot/</a></li>
<li><a href="dev/">dev/</a></li>
<li><a href="etc/">etc/</a></li>
<li><a href="home/">home/</a></li>
<li><a href="lib/">lib@</a></li>
<li><a href="lost%2Bfound/">lost+found/</a></li>
<li><a href="media/">media/</a></li>
<li><a href="mnt/">mnt/</a></li>
<li><a href="opt/">opt/</a></li>
<li><a href="proc/">proc/</a></li>
<li><a href="root/">root/</a></li>
<li><a href="run/">run/</a></li>
<li><a href="sbin/">sbin@</a></li>
<li><a href="srv/">srv/</a></li>
<li><a href="sys/">sys/</a></li>
<li><a href="tmp/">tmp/</a></li>
<li><a href="usr/">usr/</a></li>
<li><a href="var/">var/</a></li>
</ul>
<hr>
</body>
</html>
% curl http://127.0.0.1:8080
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Index of /</title>
...
<br><address>Node.js v25.2.1/ <a href="https://github.com/http-party/http-server">http-server</a> server running @ 127.0.0.1:8080</address>
</body></html>
```
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'
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Index of /</title>
...
<br><address>Node.js v25.2.1/ <a href="https://github.com/http-party/http-server">http-server</a> server running @ [::1]:8080</address>
</body></html>
```
## Set a custom MAC address for your container