Adds TCP and UDP port forwarders. (#338)

This commit is contained in:
J Logan
2025-07-18 12:08:52 -07:00
committed by GitHub
parent 2e1bee1bda
commit 1de3036f9a
16 changed files with 1142 additions and 1 deletions
+16
View File
@@ -38,10 +38,12 @@ let package = Package(
.library(name: "ContainerPersistence", targets: ["ContainerPersistence"]),
.library(name: "ContainerPlugin", targets: ["ContainerPlugin"]),
.library(name: "ContainerXPC", targets: ["ContainerXPC"]),
.library(name: "SocketForwarder", targets: ["SocketForwarder"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"),
.package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"),
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"),
@@ -116,6 +118,7 @@ let package = Package(
"ContainerNetworkService",
"ContainerClient",
"ContainerXPC",
"SocketForwarder",
],
path: "Sources/Services/ContainerSandboxService"
),
@@ -283,6 +286,19 @@ let package = Package(
"DNSServer",
]
),
.target(
name: "SocketForwarder",
dependencies: [
.product(name: "Collections", package: "swift-collections"),
.product(name: "Logging", package: "swift-log"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOFoundationCompat", package: "swift-nio"),
]
),
.testTarget(
name: "SocketForwarderTests",
dependencies: ["SocketForwarder"]
),
.testTarget(
name: "CLITests",
dependencies: [
-1
View File
@@ -1,4 +1,3 @@
# `container`
`container` is a tool that you can use to create and run Linux containers as lightweight virtual machines on your Mac. It's written in Swift, and optimized for Apple silicon.
@@ -0,0 +1,106 @@
//===----------------------------------------------------------------------===//
// 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 Logging
import NIOCore
import NIOPosix
final class ConnectHandler {
private var pendingBytes: [NIOAny]
private let serverAddress: SocketAddress
private var log: Logger? = nil
init(serverAddress: SocketAddress, log: Logger?) {
self.pendingBytes = []
self.serverAddress = serverAddress
self.log = log
}
}
extension ConnectHandler: ChannelInboundHandler {
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
if self.pendingBytes.isEmpty {
self.connectToServer(context: context)
}
self.pendingBytes.append(data)
}
func handlerAdded(context: ChannelHandlerContext) {
// Add logger metadata.
self.log?[metadataKey: "proxy"] = "\(context.channel.localAddress?.description ?? "none")"
self.log?[metadataKey: "server"] = "\(context.channel.remoteAddress?.description ?? "none")"
}
}
extension ConnectHandler: RemovableChannelHandler {
func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) {
var didRead = false
// We are being removed, and need to deliver any pending bytes we may have if we're upgrading.
while self.pendingBytes.count > 0 {
let data = self.pendingBytes.removeFirst()
context.fireChannelRead(data)
didRead = true
}
if didRead {
context.fireChannelReadComplete()
}
self.log?.trace("backend - removing connect handler from pipeline")
context.leavePipeline(removalToken: removalToken)
}
}
extension ConnectHandler {
private func connectToServer(context: ChannelHandlerContext) {
self.log?.trace("backend - connecting")
ClientBootstrap(group: context.eventLoop)
.connect(to: serverAddress)
.assumeIsolatedUnsafeUnchecked()
.whenComplete { result in
switch result {
case .success(let channel):
self.log?.trace("backend - connected")
self.glue(channel, context: context)
case .failure(let error):
self.log?.error("backend - connect failed: \(error)")
context.close(promise: nil)
context.fireErrorCaught(error)
}
}
}
private func glue(_ peerChannel: Channel, context: ChannelHandlerContext) {
self.log?.trace("backend - gluing channels")
// Now we need to glue our channel and the peer channel together.
let (localGlue, peerGlue) = GlueHandler.matchedPair()
do {
try context.channel.pipeline.syncOperations.addHandler(localGlue)
try peerChannel.pipeline.syncOperations.addHandler(peerGlue)
context.pipeline.syncOperations.removeHandler(self, promise: nil)
} catch {
// Close connected peer channel before closing our channel.
peerChannel.close(mode: .all, promise: nil)
context.close(promise: nil)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
//===----------------------------------------------------------------------===//
// 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 NIOCore
final class GlueHandler {
private var partner: GlueHandler?
private var context: ChannelHandlerContext?
private var pendingRead: Bool = false
private init() {}
}
extension GlueHandler {
static func matchedPair() -> (GlueHandler, GlueHandler) {
let first = GlueHandler()
let second = GlueHandler()
first.partner = second
second.partner = first
return (first, second)
}
}
extension GlueHandler {
private func partnerWrite(_ data: NIOAny) {
self.context?.write(data, promise: nil)
}
private func partnerFlush() {
self.context?.flush()
}
private func partnerWriteEOF() {
self.context?.close(mode: .output, promise: nil)
}
private func partnerCloseFull() {
self.context?.close(promise: nil)
}
private func partnerBecameWritable() {
if self.pendingRead {
self.pendingRead = false
self.context?.read()
}
}
private var partnerWritable: Bool {
self.context?.channel.isWritable ?? false
}
}
extension GlueHandler: ChannelDuplexHandler {
typealias InboundIn = NIOAny
typealias OutboundIn = NIOAny
typealias OutboundOut = NIOAny
func handlerAdded(context: ChannelHandlerContext) {
self.context = context
}
func handlerRemoved(context: ChannelHandlerContext) {
self.context = nil
self.partner = nil
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
self.partner?.partnerWrite(data)
}
func channelReadComplete(context: ChannelHandlerContext) {
self.partner?.partnerFlush()
}
func channelInactive(context: ChannelHandlerContext) {
self.partner?.partnerCloseFull()
}
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
if let event = event as? ChannelEvent, case .inputClosed = event {
// We have read EOF.
self.partner?.partnerWriteEOF()
}
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
self.partner?.partnerCloseFull()
}
func channelWritabilityChanged(context: ChannelHandlerContext) {
if context.channel.isWritable {
self.partner?.partnerBecameWritable()
}
}
func read(context: ChannelHandlerContext) {
if let partner = self.partner, partner.partnerWritable {
context.read()
} else {
self.pendingRead = true
}
}
}
+118
View File
@@ -0,0 +1,118 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
struct KeyExistsError: Error {}
class LRUCache<K: Hashable, V> {
private class Node {
fileprivate var prev: Node?
fileprivate var next: Node?
fileprivate let key: K
fileprivate let value: V
init(key: K, value: V) {
self.prev = nil
self.next = nil
self.key = key
self.value = value
}
}
private let size: UInt
private var head: Node?
private var tail: Node?
private var members: [K: Node]
init(size: UInt) {
self.size = size
self.head = nil
self.tail = nil
self.members = [:]
}
var count: Int { members.count }
func get(_ key: K) -> V? {
guard let node = members[key] else {
return nil
}
listRemove(node: node)
listInsert(node: node, after: tail)
return node.value
}
func put(key: K, value: V) -> (K, V)? {
let node = Node(key: key, value: value)
var evicted: (K, V)? = nil
if let existingNode = members[key] {
// evict the replaced node
listRemove(node: existingNode)
evicted = (existingNode.key, existingNode.value)
} else if self.count >= self.size {
// evict the least recently used node
evicted = evict()
}
// insert the new node and return any evicted node
members[key] = node
listInsert(node: node, after: tail)
return evicted
}
private func evict() -> (K, V)? {
guard let head else {
return nil
}
let ret = (head.key, head.value)
listRemove(node: head)
members.removeValue(forKey: head.key)
return ret
}
private func listRemove(node: Node) {
if let prev = node.prev {
prev.next = node.next
} else {
head = node.next
}
if let next = node.next {
next.prev = node.prev
} else {
tail = node.prev
}
}
private func listInsert(node: Node, after: Node?) {
let before: Node?
if let after {
before = after.next
after.next = node
} else {
before = head
head = node
}
if let before {
before.prev = node
} else {
tail = node
}
node.prev = after
node.next = before
}
}
@@ -0,0 +1,21 @@
//===----------------------------------------------------------------------===//
// 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 NIO
public protocol SocketForwarder: Sendable {
func run() throws -> EventLoopFuture<SocketForwarderResult>
}
@@ -0,0 +1,37 @@
//===----------------------------------------------------------------------===//
// 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 NIO
public struct SocketForwarderResult: Sendable {
private let channel: any Channel
public init(channel: Channel) {
self.channel = channel
}
public var proxyAddress: SocketAddress? { self.channel.localAddress }
public func close() {
self.channel.eventLoop.execute {
_ = channel.close()
}
}
public func wait() async throws {
try await self.channel.closeFuture.get()
}
}
@@ -0,0 +1,62 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import Logging
import NIO
import NIOFoundationCompat
public struct TCPForwarder: SocketForwarder {
private let proxyAddress: SocketAddress
private let serverAddress: SocketAddress
private let eventLoopGroup: any EventLoopGroup
private let log: Logger?
public init(
proxyAddress: SocketAddress,
serverAddress: SocketAddress,
eventLoopGroup: any EventLoopGroup,
log: Logger? = nil
) throws {
self.proxyAddress = proxyAddress
self.serverAddress = serverAddress
self.eventLoopGroup = eventLoopGroup
self.log = log
}
public func run() throws -> EventLoopFuture<SocketForwarderResult> {
self.log?.trace("frontend - creating listener")
let bootstrap = ServerBootstrap(group: self.eventLoopGroup)
.serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
.childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
.childChannelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(
ConnectHandler(serverAddress: self.serverAddress, log: log)
)
}
}
return
bootstrap
.bind(to: self.proxyAddress)
.flatMap { $0.eventLoop.makeSucceededFuture(SocketForwarderResult(channel: $0)) }
}
}
+211
View File
@@ -0,0 +1,211 @@
//===----------------------------------------------------------------------===//
// 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 Collections
import Foundation
import Logging
import NIO
import NIOFoundationCompat
import Synchronization
// Proxy backend for a single client address (clientIP, clientPort).
private final class UDPProxyBackend: ChannelInboundHandler, Sendable {
typealias InboundIn = AddressedEnvelope<ByteBuffer>
typealias OutboundOut = AddressedEnvelope<ByteBuffer>
private struct State {
var queuedPayloads: Deque<ByteBuffer>
var channel: (any Channel)?
}
private let clientAddress: SocketAddress
private let serverAddress: SocketAddress
private let frontendChannel: any Channel
private let log: Logger?
private let state: Mutex<State>
init(clientAddress: SocketAddress, serverAddress: SocketAddress, frontendChannel: any Channel, log: Logger? = nil) {
self.clientAddress = clientAddress
self.serverAddress = serverAddress
self.frontendChannel = frontendChannel
self.log = log
let state = State(queuedPayloads: Deque(), channel: nil)
self.state = Mutex(state)
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
// relay data from server to client.
let inbound = self.unwrapInboundIn(data)
let outbound = OutboundOut(remoteAddress: self.clientAddress, data: inbound.data)
self.log?.trace("backend - writing datagram to client")
_ = self.frontendChannel.writeAndFlush(outbound)
}
func channelActive(context: ChannelHandlerContext) {
state.withLock {
if !$0.queuedPayloads.isEmpty {
self.log?.trace("backend - writing \($0.queuedPayloads.count) queued datagrams to server")
while let queuedData = $0.queuedPayloads.popFirst() {
let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: queuedData)
_ = context.channel.writeAndFlush(outbound)
}
}
$0.channel = context.channel
}
}
func write(data: ByteBuffer) {
// change package remote address from proxy server to real server
state.withLock {
if let channel = $0.channel {
// channel has been initialized, so relay any queued packets, along with this one to outbound
self.log?.trace("backend - writing datagram to server")
let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: data)
_ = channel.writeAndFlush(outbound)
} else {
// channel is initializing, queue
self.log?.trace("backend - queuing datagram")
$0.queuedPayloads.append(data)
}
}
}
func close() {
state.withLock {
guard let channel = $0.channel else {
self.log?.warning("backend - close on inactive channel")
return
}
_ = channel.close()
}
}
}
private struct ProxyContext {
public let proxy: UDPProxyBackend
public let closeFuture: EventLoopFuture<Void>
}
private final class UDPProxyFrontend: ChannelInboundHandler, Sendable {
typealias InboundIn = AddressedEnvelope<ByteBuffer>
typealias OutboundOut = AddressedEnvelope<ByteBuffer>
private let maxProxies = UInt(256)
private let proxyAddress: SocketAddress
private let serverAddress: SocketAddress
private let eventLoopGroup: any EventLoopGroup
private let log: Logger?
private let proxies: Mutex<LRUCache<String, ProxyContext>>
init(proxyAddress: SocketAddress, serverAddress: SocketAddress, eventLoopGroup: any EventLoopGroup, log: Logger? = nil) {
self.proxyAddress = proxyAddress
self.serverAddress = serverAddress
self.eventLoopGroup = eventLoopGroup
self.proxies = Mutex(LRUCache(size: maxProxies))
self.log = log
}
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let inbound = self.unwrapInboundIn(data)
guard let clientIP = inbound.remoteAddress.ipAddress else {
log?.error("frontend - no client IP address in inbound payload")
return
}
guard let clientPort = inbound.remoteAddress.port else {
log?.error("frontend - no client port in inbound payload")
return
}
let key = "\(clientIP):\(clientPort)"
do {
try proxies.withLock {
if let context = $0.get(key) {
context.proxy.write(data: inbound.data)
} else {
self.log?.trace("frontend - creating backend")
let proxy = UDPProxyBackend(
clientAddress: inbound.remoteAddress,
serverAddress: self.serverAddress,
frontendChannel: context.channel,
log: log
)
let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0)
let proxyToServerFuture = DatagramBootstrap(group: self.eventLoopGroup)
.channelInitializer {
self.log?.trace("frontend - initializing backend")
return $0.pipeline.addHandler(proxy)
}
.bind(to: proxyAddress)
.flatMap { $0.closeFuture }
let context = ProxyContext(proxy: proxy, closeFuture: proxyToServerFuture)
if let (_, evictedContext) = $0.put(key: key, value: context) {
self.log?.trace("frontend - closing evicted backend")
evictedContext.proxy.close()
}
proxy.write(data: inbound.data)
}
}
} catch {
log?.error("server handler - backend channel creation failed with error: \(error)")
return
}
}
}
public struct UDPForwarder: SocketForwarder {
private let proxyAddress: SocketAddress
private let serverAddress: SocketAddress
private let eventLoopGroup: any EventLoopGroup
private let log: Logger?
public init(
proxyAddress: SocketAddress,
serverAddress: SocketAddress,
eventLoopGroup: any EventLoopGroup,
log: Logger? = nil
) throws {
self.proxyAddress = proxyAddress
self.serverAddress = serverAddress
self.eventLoopGroup = eventLoopGroup
self.log = log
}
public func run() throws -> EventLoopFuture<SocketForwarderResult> {
self.log?.trace("frontend - creating channel")
let proxyToServerHandler = UDPProxyFrontend(
proxyAddress: proxyAddress,
serverAddress: serverAddress,
eventLoopGroup: self.eventLoopGroup,
log: log
)
let bootstrap = DatagramBootstrap(group: self.eventLoopGroup)
.channelInitializer { serverChannel in
self.log?.trace("frontend - initializing channel")
return serverChannel.pipeline.addHandler(proxyToServerHandler)
}
return
bootstrap
.bind(to: proxyAddress)
.flatMap { $0.eventLoop.makeSucceededFuture(SocketForwarderResult(channel: $0)) }
}
}
@@ -0,0 +1,54 @@
//===----------------------------------------------------------------------===//
// 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 Testing
@testable import SocketForwarder
struct LRUCacheTest {
@Test
func testLRUCache() throws {
let cache = LRUCache<String, String>(size: 3)
#expect(cache.count == 0)
#expect(cache.put(key: "foo", value: "1") == nil)
#expect(cache.count == 1)
#expect(cache.put(key: "bar", value: "2") == nil)
#expect(cache.count == 2)
#expect(cache.put(key: "baz", value: "3") == nil)
#expect(cache.count == 3)
let replaced = try #require(cache.put(key: "bar", value: "4"))
#expect(replaced == ("bar", "2"))
#expect(cache.count == 3)
let firstEvicted = try #require(cache.put(key: "qux", value: "5"))
#expect(firstEvicted == ("foo", "1"))
#expect(cache.count == 3)
let secondEvicted = try #require(cache.put(key: "quux", value: "6"))
#expect(secondEvicted == ("baz", "3"))
#expect(cache.count == 3)
#expect(cache.get("foo") == nil)
#expect(cache.get("bar") == "4")
#expect(cache.get("baz") == nil)
#expect(cache.get("qux") == "5")
#expect(cache.get("quux") == "6")
}
}
@@ -0,0 +1,31 @@
//===----------------------------------------------------------------------===//
// 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 NIO
final class TCPEchoHandler: ChannelInboundHandler {
typealias InboundIn = ByteBuffer
typealias OutboundOut = ByteBuffer
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
context.writeAndFlush(data, promise: nil)
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
context.close(promise: nil)
}
}
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// 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 NIO
struct TCPEchoServer: Sendable {
private let serverAddress: SocketAddress
private let eventLoopGroup: MultiThreadedEventLoopGroup
public init(serverAddress: SocketAddress, eventLoopGroup: MultiThreadedEventLoopGroup) {
self.serverAddress = serverAddress
self.eventLoopGroup = eventLoopGroup
}
public func run() throws -> EventLoopFuture<any Channel> {
let bootstrap = ServerBootstrap(group: self.eventLoopGroup)
.serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
.childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1)
.childChannelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(
BackPressureHandler()
)
try channel.pipeline.syncOperations.addHandler(
TCPEchoHandler()
)
}
}
return bootstrap.bind(to: self.serverAddress)
}
}
@@ -0,0 +1,138 @@
//===----------------------------------------------------------------------===//
// 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 NIO
import Testing
@testable import SocketForwarder
struct TCPForwarderTest {
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
@Test
func testTCPForwarder() async throws {
let requestCount = 100
var responses: [String] = []
// bring up server on ephemeral port and get address
let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0)
let server = TCPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup)
let serverChannel = try await server.run().get()
let actualServerAddress = try #require(serverChannel.localAddress)
// bring up proxy on ephemeral port and get address
let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0)
let forwarder = try TCPForwarder(
proxyAddress: proxyAddress,
serverAddress: actualServerAddress,
eventLoopGroup: eventLoopGroup
)
let forwarderResult = try await forwarder.run().get()
let actualProxyAddress = try #require(forwarderResult.proxyAddress)
// send a bunch of messages and collect them
try await withThrowingTaskGroup(of: String.self) { group in
for i in 0..<requestCount {
group.addTask {
var response: String = "\(i): error"
let channel = try await ClientBootstrap(group: self.eventLoopGroup)
.connectTimeout(.seconds(2))
.connect(to: actualProxyAddress) { channel in
channel.eventLoop.makeCompletedFuture {
// We are using two simple handlers here to frame our messages with "\n"
try channel.pipeline.syncOperations.addHandler(ByteToMessageHandler(NewlineDelimiterCoder()))
try channel.pipeline.syncOperations.addHandler(MessageToByteHandler(NewlineDelimiterCoder()))
return try NIOAsyncChannel(
wrappingChannelSynchronously: channel,
configuration: NIOAsyncChannel.Configuration(
inboundType: String.self,
outboundType: String.self
)
)
}
}
try await channel.executeThenClose { inbound, outbound in
try await outbound.write("\(i): success-tcp")
for try await inboundData in inbound {
response = "\(inboundData)"
break
}
}
return response
}
}
for try await response in group {
responses.append(response)
}
}
// close everything down
print("testTCPForwarder: close server")
serverChannel.eventLoop.execute { _ = serverChannel.close() }
try await serverChannel.closeFuture.get()
print("testTCPForwarder: close forwarder")
forwarderResult.close()
try await forwarderResult.wait()
// verify all expected messages
print("testTCPForwarder: validate responses")
let sortedResponses = try responses.sorted { (a, b) in
let aParts = a.split(separator: ":")
let bParts = b.split(separator: ":")
#expect(aParts.count > 1)
#expect(bParts.count > 1)
let aIndex = try #require(Int(aParts[0]))
let bIndex = try #require(Int(bParts[0]))
return aIndex < bIndex
}
#expect(sortedResponses.count == requestCount)
for i in 0..<requestCount {
#expect(sortedResponses[i] == "\(i): success-tcp")
}
}
}
private final class NewlineDelimiterCoder: ByteToMessageDecoder, MessageToByteEncoder {
typealias InboundIn = ByteBuffer
typealias InboundOut = String
private let newLine = UInt8(ascii: "\n")
init() {}
func decode(context: ChannelHandlerContext, buffer: inout ByteBuffer) throws -> DecodingState {
let readableBytes = buffer.readableBytesView
guard let firstLine = readableBytes.firstIndex(of: self.newLine).map({ readableBytes[..<$0] }) else {
return .needMoreData
}
buffer.moveReaderIndex(forwardBy: firstLine.count + 1)
// Fire a read without a newline
let data = Self.wrapInboundOut(String(buffer: ByteBuffer(firstLine)))
context.fireChannelRead(data)
return .continue
}
func encode(data: String, out: inout ByteBuffer) throws {
out.writeString(data)
out.writeInteger(self.newLine)
}
}
@@ -0,0 +1,30 @@
//===----------------------------------------------------------------------===//
// 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 NIO
final class UDPEchoHandler: ChannelInboundHandler {
typealias InboundIn = AddressedEnvelope<ByteBuffer>
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
context.writeAndFlush(data, promise: nil)
}
func errorCaught(context: ChannelHandlerContext, error: Error) {
context.close(promise: nil)
}
}
@@ -0,0 +1,41 @@
//===----------------------------------------------------------------------===//
// 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 NIO
struct UDPEchoServer: Sendable {
private let serverAddress: SocketAddress
private let eventLoopGroup: MultiThreadedEventLoopGroup
public init(serverAddress: SocketAddress, eventLoopGroup: MultiThreadedEventLoopGroup) {
self.serverAddress = serverAddress
self.eventLoopGroup = eventLoopGroup
}
public func run() throws -> EventLoopFuture<any Channel> {
let bootstrap = DatagramBootstrap(group: self.eventLoopGroup)
.channelInitializer { channel in
channel.eventLoop.makeCompletedFuture {
try channel.pipeline.syncOperations.addHandler(
UDPEchoHandler()
)
}
}
return bootstrap.bind(to: self.serverAddress)
}
}
@@ -0,0 +1,110 @@
//===----------------------------------------------------------------------===//
// 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 Logging
import NIO
import Testing
@testable import SocketForwarder
struct UDPForwarderTest {
let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
@Test
func testUDPForwarder() async throws {
let requestCount = 100
var responses: [String] = []
// bring up server on ephemeral port and get address
let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0)
let server = UDPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup)
let serverChannel = try await server.run().get()
let actualServerAddress = try #require(serverChannel.localAddress)
// bring up proxy on ephemeral port and get address
let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0)
let forwarder = try UDPForwarder(
proxyAddress: proxyAddress,
serverAddress: actualServerAddress,
eventLoopGroup: eventLoopGroup
)
let forwarderResult = try await forwarder.run().get()
let actualProxyAddress = try #require(forwarderResult.proxyAddress)
// send a bunch of messages and collect them
print("testUDPForwarder: send messages")
try await withThrowingTaskGroup(of: String.self) { group in
for i in 0..<requestCount {
group.addTask {
var response: String = "\(i): error"
let channel = try await DatagramBootstrap(group: self.eventLoopGroup)
.connect(to: actualProxyAddress) { channel in
channel.eventLoop.makeCompletedFuture {
try NIOAsyncChannel(
wrappingChannelSynchronously: channel,
configuration: NIOAsyncChannel.Configuration(
inboundType: AddressedEnvelope<ByteBuffer>.self,
outboundType: AddressedEnvelope<ByteBuffer>.self
)
)
}
}
try await channel.executeThenClose { inbound, outbound in
let remoteAddress = try #require(channel.channel.remoteAddress)
let data = ByteBufferAllocator().buffer(string: "\(i): success-udp")
try await outbound.write(AddressedEnvelope<ByteBuffer>(remoteAddress: remoteAddress, data: data))
for try await inboundData in inbound {
response = String(buffer: inboundData.data)
break
}
}
return response
}
}
for try await response in group {
responses.append(response)
}
}
// close everything down
print("testUDPForwarder: close server")
serverChannel.eventLoop.execute { _ = serverChannel.close() }
try await serverChannel.closeFuture.get()
print("testUDPForwarder: close forwarder")
forwarderResult.close()
try await forwarderResult.wait()
// verify all expected messages
print("testUDPForwarder: validate responses")
let sortedResponses = try responses.sorted { (a, b) in
let aParts = a.split(separator: ":")
let bParts = b.split(separator: ":")
#expect(aParts.count > 1)
#expect(bParts.count > 1)
let aIndex = try #require(Int(aParts[0]))
let bIndex = try #require(Int(bParts[0]))
return aIndex < bIndex
}
#expect(sortedResponses.count == requestCount)
for i in 0..<requestCount {
#expect(sortedResponses[i] == "\(i): success-udp")
}
}
}