mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
Adds TCP and UDP port forwarders. (#338)
This commit is contained in:
@@ -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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user