Adds --publish flag for forwarding traffic to container ports. (#352)

This commit is contained in:
J Logan
2025-07-23 00:28:41 -07:00
committed by GitHub
parent c0d1f8fb11
commit eea8cb6709
11 changed files with 456 additions and 32 deletions
@@ -23,6 +23,8 @@ public struct ContainerConfiguration: Sendable, Codable {
public var image: ImageDescription
/// External mounts to add to the container.
public var mounts: [Filesystem] = []
/// Ports to publish from container to host.
public var publishedPorts: [PublishPort] = []
/// Sockets to publish from container to host.
public var publishedSockets: [PublishSocket] = []
/// Key/Value labels for the container.
@@ -50,6 +52,7 @@ public struct ContainerConfiguration: Sendable, Codable {
case id
case image
case mounts
case publishedPorts
case publishedSockets
case labels
case sysctls
@@ -71,6 +74,7 @@ public struct ContainerConfiguration: Sendable, Codable {
id = try container.decode(String.self, forKey: .id)
image = try container.decode(ImageDescription.self, forKey: .image)
mounts = try container.decodeIfPresent([Filesystem].self, forKey: .mounts) ?? []
publishedPorts = try container.decodeIfPresent([PublishPort].self, forKey: .publishedPorts) ?? []
publishedSockets = try container.decodeIfPresent([PublishSocket].self, forKey: .publishedSockets) ?? []
labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
sysctls = try container.decodeIfPresent([String: String].self, forKey: .sysctls) ?? [:]
@@ -0,0 +1,58 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
/// The network protocols available for port forwarding.
public enum PublishProtocol: String, Sendable, Codable {
case tcp = "tcp"
case udp = "udp"
/// Initialize a protocol with to default value, `.tcp`.
public init() {
self = .tcp
}
/// Initialize a protocol value from the provided string.
public init?(_ value: String) {
switch value.lowercased() {
case "tcp": self = .tcp
case "udp": self = .udp
default: return nil
}
}
}
/// 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
/// The port number of the proxy listener on the host
public let hostPort: Int
/// The port number of the container listener
public let containerPort: Int
/// The network protocol for the proxy
public let proto: PublishProtocol
/// Creates a new port forwarding specification.
public init(hostAddress: String, hostPort: Int, containerPort: Int, proto: PublishProtocol) {
self.hostAddress = hostAddress
self.hostPort = hostPort
self.containerPort = containerPort
self.proto = proto
}
}
+8 -5
View File
@@ -84,7 +84,7 @@ public struct Flags {
public struct Management: ParsableArguments {
public init() {}
@Flag(name: [.customLong("detach"), .customShort("d")], help: "Run the container and detach from the process")
@Flag(name: [.customLong("detach"), .short], help: "Run the container and detach from the process")
public var detach = false
@Option(name: .customLong("entrypoint"), help: "Override the entrypoint of the image")
@@ -93,6 +93,9 @@ public struct Flags {
@Option(name: .customLong("mount"), help: "Add a mount to the container (type=<>,source=<>,target=<>,readonly)")
public var mounts: [String] = []
@Option(name: [.customLong("publish"), .short], help: "Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])")
public var publishPorts: [String] = []
@Option(name: .customLong("publish-socket"), help: "Publish a socket from container to host (format: host_path:container_path)")
public var publishSockets: [String] = []
@@ -109,14 +112,14 @@ public struct Flags {
public var os = "linux"
@Option(
name: [.customLong("arch"), .customShort("a")], help: "Set arch if image can target multiple architectures")
name: [.customLong("arch"), .short], help: "Set arch if image can target multiple architectures")
public var arch: String = Arch.hostArchitecture().rawValue
@Option(name: [.customLong("volume"), .customShort("v")], help: "Bind mount a volume into the container")
@Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container")
public var volumes: [String] = []
@Option(
name: [.customLong("kernel"), .customShort("k")], help: "Set a custom kernel path", completion: .file(),
name: [.customLong("kernel"), .short], help: "Set a custom kernel path", completion: .file(),
transform: { str in
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
})
@@ -143,7 +146,7 @@ public struct Flags {
@Option(name: .customLong("dns-option"), help: "DNS options")
public var dnsOptions: [String] = []
@Option(name: [.customLong("label"), .customShort("l")], help: "Add a key=value label to the container")
@Option(name: [.customLong("label"), .short], help: "Add a key=value label to the container")
public var labels: [String] = []
}
+82 -11
View File
@@ -397,12 +397,84 @@ public struct Parser {
}
}
// Parse --publish-socket arguments into PublishSocket objects
// Format: "host_path:container_path" (e.g., "/tmp/docker.sock:/var/run/docker.sock")
//
// - Parameter rawPublishSockets: Array of socket specifications
// - Returns: Array of PublishSocket objects
// - Throws: ContainerizationError if parsing fails
/// Parse --publish-port arguments into PublishPort objects
/// The format of each argument is `[host-ip:]host-port:container-port[/protocol]`
/// (e.g., "127.0.0.1:8080:80/tcp")
///
/// - Parameter rawPublishPorts: Array of port arguments
/// - Returns: Array of PublishPort objects
/// - Throws: ContainerizationError if parsing fails
static func publishPorts(_ rawPublishPorts: [String]) throws -> [PublishPort] {
var sockets: [PublishPort] = []
// Process each raw port string
for socket in rawPublishPorts {
let parsedSocket = try Parser.publishPort(socket)
sockets.append(parsedSocket)
}
return sockets
}
// Parse a single `--publish-port` argument into a `PublishPort`.
private 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:
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])
default:
throw ContainerizationError(.invalidArgument, message: "invalid publish address: \(portText)")
}
guard let hostPort = Int(hostPortText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
}
guard let containerPort = Int(containerPortText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
}
return PublishPort(
hostAddress: hostAddress,
hostPort: hostPort,
containerPort: containerPort,
proto: proto
)
}
/// Parse --publish-socket arguments into PublishSocket objects
/// The format of each argument is `host_path:container_path`
/// (e.g., "/tmp/docker.sock:/var/run/docker.sock")
///
/// - Parameter rawPublishSockets: Array of socket arguments
/// - Returns: Array of PublishSocket objects
/// - Throws: ContainerizationError if parsing fails or a path is invalid
static func publishSockets(_ rawPublishSockets: [String]) throws -> [PublishSocket] {
var sockets: [PublishSocket] = []
@@ -414,11 +486,10 @@ public struct Parser {
return sockets
}
// Parse a single --publish-socket argument and validate paths
// Format: "host_path:container_path" -> PublishSocket
private static func publishSocket(_ socket: String) throws -> PublishSocket {
// Parse a single `--publish-socket`` argument into a `PublishSocket`.
private static func publishSocket(_ socketText: String) throws -> PublishSocket {
// Split by colon to two parts: [host_path, container_path]
let parts = socket.split(separator: ":")
let parts = socketText.split(separator: ":")
switch parts.count {
case 2:
@@ -483,7 +554,7 @@ public struct Parser {
throw ContainerizationError(
.invalidArgument,
message:
"invalid publish-socket format \(socket). Expected: host_path:container_path")
"invalid publish-socket format \(socketText). Expected: host_path:container_path")
}
}
}
+2
View File
@@ -187,6 +187,8 @@ public struct Utility {
config.labels = try Parser.labels(management.labels)
config.publishedPorts = try Parser.publishPorts(management.publishPorts)
// Parse --publish-socket arguments and add to container configuration
// to enable socket forwarding from container to host.
config.publishedSockets = try Parser.publishSockets(management.publishSockets)
@@ -26,6 +26,9 @@ import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import NIO
import NIOFoundationCompat
import SocketForwarder
import struct ContainerizationOCI.Mount
import struct ContainerizationOCI.Process
@@ -36,11 +39,13 @@ public actor SandboxService {
private let interfaceStrategy: InterfaceStrategy
private var container: ContainerInfo?
private let monitor: ExitMonitor
private let eventLoopGroup: MultiThreadedEventLoopGroup
private var waiters: [String: [CheckedContinuation<Int32, Never>]] = [:]
private let lock: AsyncLock = AsyncLock()
private let log: Logging.Logger
private var state: State = .created
private var processes: [String: ProcessInfo] = [:]
private var socketForwarders: [SocketForwarderResult] = []
/// Create an instance with a bundle that describes the container.
///
@@ -54,6 +59,7 @@ public actor SandboxService {
self.interfaceStrategy = interfaceStrategy
self.log = log
self.monitor = ExitMonitor(log: log)
self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
}
/// Start the VM and the guest agent process for a container.
@@ -142,6 +148,11 @@ public actor SandboxService {
do {
try await container.create()
try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit)
if !container.interfaces.isEmpty {
let firstCidr = try CIDRAddress(container.interfaces[0].address)
let ipAddress = firstCidr.address.description
try await self.startSocketForwarders(containerIpAddress: ipAddress, publishedPorts: config.publishedPorts)
}
await self.setState(.booted)
} catch {
do {
@@ -274,6 +285,57 @@ public actor SandboxService {
try await self.monitor.track(id: id, waitingOn: waitFunc)
}
private func startSocketForwarders(containerIpAddress: String, publishedPorts: [PublishPort]) async throws {
var forwarders: [SocketForwarderResult] = []
try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in
for publishedPort in publishedPorts {
let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress, port: Int(publishedPort.hostPort))
let serverAddress = try SocketAddress(ipAddress: containerIpAddress, port: Int(publishedPort.containerPort))
log.info(
"creating forwarder for",
metadata: [
"proxy": "\(proxyAddress)",
"server": "\(serverAddress)",
"protocol": "\(publishedPort.proto)",
])
group.addTask {
let forwarder: SocketForwarder
switch publishedPort.proto {
case .tcp:
forwarder = try TCPForwarder(
proxyAddress: proxyAddress,
serverAddress: serverAddress,
eventLoopGroup: self.eventLoopGroup,
log: self.log
)
case .udp:
forwarder = try UDPForwarder(
proxyAddress: proxyAddress,
serverAddress: serverAddress,
eventLoopGroup: self.eventLoopGroup,
log: self.log
)
}
return try await forwarder.run().get()
}
}
for try await result in group {
forwarders.append(result)
}
}
self.socketForwarders = forwarders
}
private func stopSocketForwarders() async {
log.info("closing forwarders")
for forwarder in self.socketForwarders {
forwarder.close()
try? await forwarder.wait()
}
log.info("closed forwarders")
}
/// Create a process inside the virtual machine for the container.
///
/// Use this procedure to run ad hoc processes in the virtual
@@ -776,6 +838,7 @@ public actor SandboxService {
private func cleanupContainer() async throws {
// Give back our lovely IP(s)
await self.stopSocketForwarders()
let containerInfo = try self.getContainer()
for attachment in containerInfo.attachments {
let client = NetworkClient(id: attachment.network)
@@ -80,20 +80,4 @@ class TestCLINetwork: CLITest {
return
}
}
private func getClient() -> HTTPClient {
var httpConfiguration = HTTPClient.Configuration()
let proxyConfig: HTTPClient.Configuration.Proxy? = {
let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"]
guard let proxyEnv else {
return nil
}
guard let url = URL(string: proxyEnv), let host = url.host(), let port = url.port else {
return nil
}
return .server(host: host, port: port)
}()
httpConfiguration.proxy = proxyConfig
return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
}
}
@@ -16,7 +16,9 @@
//
import AsyncHTTPClient
import ContainerClient
import ContainerizationExtras
import ContainerizationOS
import Foundation
import Testing
@@ -426,4 +428,48 @@ class TestCLIRunCommand: CLITest {
return
}
}
@Test func testForwardTCP() async throws {
let retries = 10
let retryDelaySeconds = Int64(3)
do {
let name = Test.current!.name.trimmingCharacters(in: ["(", ")"])
let proxyIp = "127.0.0.1"
let proxyPort = UInt16.random(in: 50000..<55000)
let serverPort = UInt16.random(in: 55000..<60000)
try doLongRun(
name: name,
image: "docker.io/library/python:alpine",
args: ["--publish", "\(proxyIp):\(proxyPort):\(serverPort)/tcp"],
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(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
} catch {
print("request to \(url) failed, error \(error)")
try await Task.sleep(for: .seconds(retryDelaySeconds))
}
retriesRemaining -= 1
}
#expect(success, "Request to \(url) failed after \(retries - retriesRemaining) retries")
try doStop(name: name)
} catch {
Issue.record("failed to run container \(error)")
return
}
}
}
+17
View File
@@ -16,6 +16,7 @@
//
import AsyncHTTPClient
import ContainerClient
import ContainerNetworkService
import Containerization
@@ -382,4 +383,20 @@ class CLITest {
throw CLIError.executionFailed("command failed: \(error)")
}
}
func getClient() -> HTTPClient {
var httpConfiguration = HTTPClient.Configuration()
let proxyConfig: HTTPClient.Configuration.Proxy? = {
let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"]
guard let proxyEnv else {
return nil
}
guard let url = URL(string: proxyEnv), let host = url.host(), let port = url.port else {
return nil
}
return .server(host: host, port: port)
}()
httpConfiguration.proxy = proxyConfig
return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
}
}
+125
View File
@@ -0,0 +1,125 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
//
import ContainerizationError
import Foundation
import Testing
@testable import ContainerClient
struct ParserTest {
@Test
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")
#expect(result[0].hostPort == UInt16(8080))
#expect(result[0].containerPort == UInt16(8000))
#expect(result[0].proto == .tcp)
}
@Test
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")
#expect(result[0].hostPort == UInt16(8000))
#expect(result[0].containerPort == UInt16(8080))
#expect(result[0].proto == .udp)
}
@Test
func testPublishPortNoHostAddress() throws {
let result = try Parser.publishPorts(["8080:8000/tcp"])
#expect(result.count == 1)
#expect(result[0].hostAddress == "0.0.0.0")
#expect(result[0].hostPort == UInt16(8080))
#expect(result[0].containerPort == UInt16(8000))
#expect(result[0].proto == .tcp)
}
@Test
func testPublishPortNoProtocol() throws {
let result = try Parser.publishPorts(["8080:8000"])
#expect(result.count == 1)
#expect(result[0].hostAddress == "0.0.0.0")
#expect(result[0].hostPort == UInt16(8080))
#expect(result[0].containerPort == UInt16(8000))
#expect(result[0].proto == .tcp)
}
@Test
func testPublishPortInvalidProtocol() throws {
#expect {
_ = try Parser.publishPorts(["8080:8000/sctp"])
} throws: { error in
guard let error = error as? ContainerizationError else {
return false
}
return error.description.contains("invalid publish protocol")
}
}
@Test
func testPublishPortInvalidValue() throws {
#expect {
_ = try Parser.publishPorts([""])
} throws: { error in
guard let error = error as? ContainerizationError else {
return false
}
return error.description.contains("invalid publish value")
}
}
@Test
func testPublishPortInvalidAddress() 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")
}
}
@Test
func testPublishPortInvalidHostPort() throws {
#expect {
_ = try Parser.publishPorts(["foo:1234"])
} throws: { error in
guard let error = error as? ContainerizationError else {
return false
}
return error.description.contains("invalid publish host port")
}
}
@Test
func testPublishPortInvalidContainerPort() throws {
#expect {
_ = try Parser.publishPorts(["1234:foo"])
} throws: { error in
guard let error = error as? ContainerizationError else {
return false
}
return error.description.contains("invalid publish container port")
}
}
}
+51
View File
@@ -145,6 +145,57 @@ Use the `list` command with the `--format` option to display information for all
]
</pre>
## Forward traffic from `localhost` to your container
Use the `--publish` option to forward TCP or UDP traffic from your loopback IP to the container you run. The option value has the form `[host-ip:]host-port:container-port[/protocol]`, where protocol may be `tcp` or `udp`, case insensitive.
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:
```bash
container run -d --rm -p 127.0.0.1:8080:8000 python:slim python3 -m http.server --bind 0.0.0.0 8000
```
A `curl` to `localhost:8000` outputs:
```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>
```
## Create and use a separate isolated network
> [!NOTE]