From f06a07657003b77b757d5a9654e7bff6fc60b4c1 Mon Sep 17 00:00:00 2001 From: Victor Debray Date: Thu, 30 Jul 2026 21:31:32 -0700 Subject: [PATCH 1/2] Add local port binding to HTTPClient.Configuration Adds a `localPort: Int` knob on `HTTPClient.Configuration` that pins the TCP source port the connection binds to before connecting, plus a per-request `HTTPClientRequest.localPort` override. `0` (the default) keeps the previous behaviour of letting the OS assign an ephemeral port. `localPort` is threaded through `RequestOptions` and `ConnectionPool.Key` alongside the existing `localAddress`, so connections with different `(localAddress, localPort)` pairs are pooled separately. Both the NIOTS (`requiredLocalEndpoint`) and NIOPosix (`bind(to:)`) bootstraps in the plain and TLS paths honour it. --- .../AsyncAwait/HTTPClient+execute.swift | 1 + .../HTTPClientRequest+Prepared.swift | 5 +++- .../AsyncAwait/HTTPClientRequest.swift | 9 +++++++ Sources/AsyncHTTPClient/ConnectionPool.swift | 24 +++++++++++++++---- .../HTTPConnectionPool+Factory.swift | 10 ++++---- .../ConnectionPool/RequestOptions.swift | 9 +++++-- Sources/AsyncHTTPClient/HTTPClient.swift | 17 ++++++++++++- Sources/AsyncHTTPClient/RequestBag.swift | 3 ++- 8 files changed, 64 insertions(+), 14 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift index a1047fa85..a769207d5 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift @@ -101,6 +101,7 @@ extension HTTPClient { currentRequest, dnsOverride: configuration.dnsOverride, localAddress: configuration.localAddress, + localPort: configuration.localPort, tracing: self.configuration.tracing ) let response = try await { diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift index 3e4482292..bee59953d 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift @@ -58,6 +58,7 @@ extension HTTPClientRequest.Prepared { _ request: HTTPClientRequest, dnsOverride: [String: String] = [:], localAddress: String? = nil, + localPort: Int = 0, tracing: HTTPClient.TracingConfiguration? = nil ) throws { guard !request.url.isEmpty, let url = URL(string: request.url) else { @@ -85,7 +86,8 @@ extension HTTPClientRequest.Prepared { url: deconstructedURL, tlsConfiguration: request.tlsConfiguration, dnsOverride: dnsOverride, - localAddress: request.localAddress ?? localAddress + localAddress: request.localAddress ?? localAddress, + localPort: request.localPort ?? localPort ), requestFramingMetadata: metadata, head: .init( @@ -162,6 +164,7 @@ extension HTTPClientRequest { newRequest.headers = headers newRequest.body = body newRequest.localAddress = self.localAddress + newRequest.localPort = self.localPort return newRequest } } diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift index 17563a122..d36fdb764 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift @@ -60,6 +60,14 @@ public struct HTTPClientRequest: Sendable { /// Defaults to `nil` (use client configuration default). public var localAddress: String? + /// The local TCP source port to bind this request's connection to. + /// + /// When set, overrides ``HTTPClient/Configuration/localPort`` for this + /// request. Only consulted when a local address (request-level or + /// configuration-level) is also set. Defaults to `nil` (use client + /// configuration default). + public var localPort: Int? + public init(url: String) { self.url = url self.method = .GET @@ -67,6 +75,7 @@ public struct HTTPClientRequest: Sendable { self.body = .none self.tlsConfiguration = nil self.localAddress = nil + self.localPort = nil } } diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index a659df27b..93b30dbfb 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -50,19 +50,22 @@ enum ConnectionPool { private var tlsConfiguration: BestEffortHashableTLSConfiguration? var serverNameIndicatorOverride: String? var localAddress: String? + var localPort: Int init( scheme: Scheme, connectionTarget: ConnectionTarget, tlsConfiguration: BestEffortHashableTLSConfiguration? = nil, serverNameIndicatorOverride: String?, - localAddress: String? = nil + localAddress: String? = nil, + localPort: Int = 0 ) { self.scheme = scheme self.connectionTarget = connectionTarget self.tlsConfiguration = tlsConfiguration self.serverNameIndicatorOverride = serverNameIndicatorOverride self.localAddress = localAddress + self.localPort = localPort } var description: String { @@ -82,6 +85,9 @@ enum ConnectionPool { "\(self.scheme)://\(hostDescription)\(self.serverNameIndicatorOverride.map { " SNI: \($0)" } ?? "") TLS-hash: \(hash)" if let addr = self.localAddress { result += " bind: \(addr)" + if self.localPort != 0 { + result += ":\(self.localPort)" + } } return result } @@ -108,7 +114,8 @@ extension ConnectionPool.Key { url: DeconstructedURL, tlsConfiguration: TLSConfiguration?, dnsOverride: [String: String], - localAddress: String? = nil + localAddress: String? = nil, + localPort: Int = 0 ) { let (connectionTarget, serverNameIndicatorOverride) = url.applyDNSOverride(dnsOverride) self.init( @@ -118,16 +125,23 @@ extension ConnectionPool.Key { BestEffortHashableTLSConfiguration(wrapping: $0) }, serverNameIndicatorOverride: serverNameIndicatorOverride, - localAddress: localAddress + localAddress: localAddress, + localPort: localPort ) } - init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:], localAddress: String? = nil) { + init( + _ request: HTTPClient.Request, + dnsOverride: [String: String] = [:], + localAddress: String? = nil, + localPort: Int = 0 + ) { self.init( url: request.deconstructedURL, tlsConfiguration: request.tlsConfiguration, dnsOverride: dnsOverride, - localAddress: localAddress + localAddress: localAddress, + localPort: localPort ) } } diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index f26fc8c08..3a56444b0 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -471,10 +471,11 @@ extension HTTPConnectionPool.ConnectionFactory { } } if let localAddress = self.key.localAddress { + let localPort = self.key.localPort bootstrap = bootstrap.configureNWParameters { params in params.requiredLocalEndpoint = NWEndpoint.hostPort( host: NWEndpoint.Host(localAddress), - port: .any + port: localPort == 0 ? .any : .init(integerLiteral: UInt16(localPort)) ) } } @@ -495,7 +496,7 @@ extension HTTPConnectionPool.ConnectionFactory { } if let localAddress = self.key.localAddress { do { - let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0) + let socketAddress = try SocketAddress(ipAddress: localAddress, port: self.key.localPort) bootstrap = bootstrap.bind(to: socketAddress) } catch { throw HTTPClientError.invalidLocalAddress @@ -589,6 +590,7 @@ extension HTTPConnectionPool.ConnectionFactory { if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), eventLoop is QoSEventLoop { // create NIOClientTCPBootstrap with NIOTS TLS provider let localAddr = self.key.localAddress + let localPort = self.key.localPort let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions( on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride @@ -625,7 +627,7 @@ extension HTTPConnectionPool.ConnectionFactory { bootstrap = bootstrap.configureNWParameters { params in params.requiredLocalEndpoint = NWEndpoint.hostPort( host: NWEndpoint.Host(localAddress), - port: .any + port: localPort == 0 ? .any : .init(integerLiteral: UInt16(localPort)) ) } } @@ -653,7 +655,7 @@ extension HTTPConnectionPool.ConnectionFactory { } if let localAddress = key.localAddress { do { - let socketAddress = try SocketAddress(ipAddress: localAddress, port: 0) + let socketAddress = try SocketAddress(ipAddress: localAddress, port: key.localPort) bootstrap = bootstrap.bind(to: socketAddress) } catch { throw HTTPClientError.invalidLocalAddress diff --git a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift index bf33a95bd..e077d5416 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift @@ -24,17 +24,21 @@ struct RequestOptions { /// The local IP address to bind outgoing connections to. This is typically used on multi-NIC /// systems where we want to control where traffic goes. var localAddress: String? + /// The local TCP source port to bind outgoing connections to. `0` means OS-assigned. + var localPort: Int init( idleReadTimeout: TimeAmount?, idleWriteTimeout: TimeAmount?, dnsOverride: [String: String], - localAddress: String? = nil + localAddress: String? = nil, + localPort: Int = 0 ) { self.idleReadTimeout = idleReadTimeout self.idleWriteTimeout = idleWriteTimeout self.dnsOverride = dnsOverride self.localAddress = localAddress + self.localPort = localPort } } @@ -44,7 +48,8 @@ extension RequestOptions { idleReadTimeout: configuration.timeout.read, idleWriteTimeout: configuration.timeout.write, dnsOverride: configuration.dnsOverride, - localAddress: configuration.localAddress + localAddress: configuration.localAddress, + localPort: configuration.localPort ) } } diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index cc8792497..cc14f71c3 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -923,7 +923,7 @@ public final class HTTPClient: Sendable { /// /// When set, all outgoing connections will bind to this address before connecting. /// The value should be an IP address string (e.g. `"192.168.1.10"` or `"::1"`). - /// Port 0 (OS-assigned ephemeral port) is always used. + /// The local port comes from ``localPort`` (default `0` = OS-assigned ephemeral). /// /// This is most commonly used on multi-NIC systems where you want traffic to take a /// specific network path which is not the choice the routing table would make by @@ -933,6 +933,21 @@ public final class HTTPClient: Sendable { /// Defaults to `nil` (OS default interface selection). public var localAddress: String? + /// The local TCP source port to bind outgoing connections to. + /// + /// `0` (the default) means the OS assigns an ephemeral port. Non-zero + /// values are useful for callers that need a specific source port — + /// for example, server-side trust signals that key off a privileged + /// (1–1023) source port. Bind failures (e.g. `EACCES` for + /// privileged ports without `CAP_NET_BIND_SERVICE`, or `EADDRINUSE` + /// for a port already held) surface as ``HTTPClientError`` / + /// channel errors at request time. + /// + /// Only consulted when ``localAddress`` is also set; otherwise the + /// kernel picks both interface and port. Connections with different + /// `(localAddress, localPort)` pairs are pooled separately. + public var localPort: Int = 0 + /// A method with access to the HTTP/1 connection channel that is called when creating the connection. public var http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture)? diff --git a/Sources/AsyncHTTPClient/RequestBag.swift b/Sources/AsyncHTTPClient/RequestBag.swift index 1122aa8e0..09f2ca370 100644 --- a/Sources/AsyncHTTPClient/RequestBag.swift +++ b/Sources/AsyncHTTPClient/RequestBag.swift @@ -99,7 +99,8 @@ final class RequestBag: Sendabl self.poolKey = .init( request, dnsOverride: requestOptions.dnsOverride, - localAddress: requestOptions.localAddress + localAddress: requestOptions.localAddress, + localPort: requestOptions.localPort ) self.eventLoopPreference = eventLoopPreference self.task = task From 61f46c22c21e8af5bf236e51d11f0b0b98b85b12 Mon Sep 17 00:00:00 2001 From: Victor Debray Date: Tue, 11 Aug 2026 14:00:46 -0700 Subject: [PATCH 2/2] Validate localPort range before connecting Reject localPort values outside 0...65535 with a new invalidLocalPort error instead of failing silently or misbehaving at bind time. --- .../AsyncAwait/HTTPClientRequest.swift | 5 +- .../HTTPConnectionPool+Factory.swift | 10 + Sources/AsyncHTTPClient/HTTPClient.swift | 9 + .../AsyncAwaitEndToEndTests.swift | 192 ++++++++++++++++++ .../HTTPClientTestUtils.swift | 20 ++ .../HTTPClientTests.swift | 81 ++++++++ .../LocalAddressOverrideTests.swift | 147 ++++++++++++++ .../RequestBagTests.swift | 41 +++- 8 files changed, 501 insertions(+), 4 deletions(-) diff --git a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift index d36fdb764..72e4793fe 100644 --- a/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift +++ b/Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift @@ -64,8 +64,9 @@ public struct HTTPClientRequest: Sendable { /// /// When set, overrides ``HTTPClient/Configuration/localPort`` for this /// request. Only consulted when a local address (request-level or - /// configuration-level) is also set. Defaults to `nil` (use client - /// configuration default). + /// configuration-level) is also set. Values outside of `0...65535` fail the + /// request with ``HTTPClientError/invalidLocalPort``. Defaults to `nil` + /// (use client configuration default). public var localPort: Int? public init(url: String) { diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift index 3a56444b0..e72aa1842 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -28,6 +28,10 @@ import NIOTransportServices extension HTTPConnectionPool { struct ConnectionFactory { + /// The range of port numbers that can be bound as a local (source) port. `0` lets the OS + /// pick an ephemeral port. + static let validPortRange = 0...Int(UInt16.max) + let key: ConnectionPool.Key let clientConfiguration: HTTPClient.Configuration let tlsConfiguration: TLSConfiguration @@ -443,6 +447,9 @@ extension HTTPConnectionPool.ConnectionFactory { if let localAddress = self.key.localAddress, !localAddress.isIPAddress { throw HTTPClientError.invalidLocalAddress } + guard Self.validPortRange.contains(self.key.localPort) else { + throw HTTPClientError.invalidLocalPort + } #if canImport(Network) if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), @@ -573,6 +580,9 @@ extension HTTPConnectionPool.ConnectionFactory { if let localAddress = self.key.localAddress, !localAddress.isIPAddress { return eventLoop.makeFailedFuture(HTTPClientError.invalidLocalAddress) } + guard Self.validPortRange.contains(self.key.localPort) else { + return eventLoop.makeFailedFuture(HTTPClientError.invalidLocalPort) + } var tlsConfig = self.tlsConfiguration switch self.clientConfiguration.httpVersion.configuration { diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index cc14f71c3..7c2207a46 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -946,6 +946,9 @@ public final class HTTPClient: Sendable { /// Only consulted when ``localAddress`` is also set; otherwise the /// kernel picks both interface and port. Connections with different /// `(localAddress, localPort)` pairs are pooled separately. + /// + /// Values outside of `0...65535` fail the request with + /// ``HTTPClientError/invalidLocalPort``. public var localPort: Int = 0 /// A method with access to the HTTP/1 connection channel that is called when creating the connection. @@ -1539,6 +1542,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case invalidHTTPVersionConfiguration case invalidDNSOverridesConfiguration case invalidLocalAddress + case invalidLocalPort case invalidProxyConfiguration case internalStateFailure(file: String, line: UInt) } @@ -1635,6 +1639,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { "The DNS overrides specified in the configuration are not valid. Please specify in the format hostname1:ip1,hostname2:ip2" case .invalidLocalAddress: return "Invalid local address" + case .invalidLocalPort: + return "Invalid local port. The local port must be in the range 0...65535" case .invalidProxyConfiguration: return "The proxy configuration is not valid" case .internalStateFailure(let file, let line): @@ -1743,6 +1749,9 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { /// The local address specified is not a valid IP address. public static let invalidLocalAddress = HTTPClientError(code: .invalidLocalAddress) + /// The local port specified is outside of the valid port range `0...65535`. + public static let invalidLocalPort = HTTPClientError(code: .invalidLocalPort) + /// The proxy configuration is not valid. public static let invalidProxyConfiguration = HTTPClientError(code: .invalidProxyConfiguration) diff --git a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift index 78dec4296..43b0c9ade 100644 --- a/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift +++ b/Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift @@ -1238,6 +1238,198 @@ final class AsyncAwaitEndToEndTests: XCTestCase { let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) XCTAssertEqual(requestInfo?.data, localAddress) } + + // MARK: - Integration tests: local port binding + + func testLocalPortBinding_configLevel() async throws { + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let localPort = try reserveEphemeralPort() + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + config.localPort = localPort + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-port") + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, "\(localPort)") + } + + func testLocalPortBinding_perRequest() async throws { + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let localPort = try reserveEphemeralPort() + + let config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-port") + request.localAddress = "127.0.0.1" + request.localPort = localPort + + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, "\(localPort)") + } + + func testLocalPortBinding_perRequestOverridesConfig() async throws { + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let configPort = try reserveEphemeralPort() + let requestPort = try reserveEphemeralPort() + XCTAssertNotEqual(configPort, requestPort) + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + config.localPort = configPort + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-port") + request.localPort = requestPort + + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, "\(requestPort)") + } + + func testLocalPortBinding_perRequestZeroOverridesConfigWithEphemeralPort() async throws { + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let configPort = try reserveEphemeralPort() + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + config.localPort = configPort + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + var request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-port") + request.localPort = 0 + + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + let boundPort = requestInfo.flatMap { Int($0.data) } + XCTAssertNotNil(boundPort) + XCTAssertNotEqual(boundPort, 0) + XCTAssertNotEqual(boundPort, configPort) + } + + func testLocalPortBinding_defaultsToEphemeralPort() async throws { + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-port") + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + let boundPort = requestInfo.flatMap { Int($0.data) } + XCTAssertNotNil(boundPort) + XCTAssertNotEqual(boundPort, 0) + } + + func testLocalPortBinding_withTLS() async throws { + let bin = HTTPBin(.http2(compress: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let localPort = try reserveEphemeralPort() + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.tlsConfiguration = .clientDefault + config.tlsConfiguration?.certificateVerification = .none + config.localAddress = "127.0.0.1" + config.localPort = localPort + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "https://127.0.0.1:\(bin.port)/echo-client-port") + let response = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTAssertEqual(response.status, .ok) + + var body = try await response.body.collect(upTo: 1024) + let requestInfo = try body.readJSONDecodable(RequestInfo.self, length: body.readableBytes) + XCTAssertEqual(requestInfo?.data, "\(localPort)") + } + + func testLocalPortBinding_portAlreadyInUseFailsRequest() async throws { + // Keep a listener on the port so that binding the client socket to it fails. + let blocker = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .bind(host: "127.0.0.1", port: 0) + .get() + defer { XCTAssertNoThrow(try blocker.close().wait()) } + let localPort = blocker.localAddress!.port! + + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + config.localPort = localPort + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://127.0.0.1:\(bin.port)/echo-client-port") + await XCTAssertThrowsError(try await client.execute(request, deadline: .now() + .seconds(10))) + } + + func testLocalPortBinding_outOfRangePort() async throws { + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + config.localPort = 100_000 + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let request = HTTPClientRequest(url: "http://127.0.0.1/ok") + do { + _ = try await client.execute(request, deadline: .now() + .seconds(10)) + XCTFail("Expected error to be thrown") + } catch { + XCTAssertEqual(error as? HTTPClientError, .invalidLocalPort) + } + } } struct AnySendableSequence: @unchecked Sendable { diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift index ea80cee6e..985472d44 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift @@ -832,6 +832,19 @@ internal struct RequestInfo: Codable, Equatable { var connectionNumber: Int } +/// Asks the OS for an ephemeral port on `host`, releases it again and returns the port number. +/// +/// Used by tests that need to bind a *specific* local port: the returned port was free a moment +/// ago, which makes it a much better candidate than a hard coded one. +internal func reserveEphemeralPort(host: String = "127.0.0.1") throws -> Int { + let channel = try ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .bind(host: host, port: 0) + .wait() + let port = channel.localAddress!.port! + try channel.close().wait() + return port +} + internal final class HTTPBinHandler: ChannelInboundHandler { typealias InboundIn = HTTPServerRequestPart typealias OutboundOut = HTTPServerResponsePart @@ -1058,6 +1071,13 @@ internal final class HTTPBinHandler: ChannelInboundHandler { builder.add(buf) self.resps.append(builder) return + case "/echo-client-port": + var builder = HTTPResponseBuilder(status: .ok) + let clientPort = context.channel.remoteAddress?.port.map { String($0) } ?? "unknown" + let buf = context.channel.allocator.buffer(string: clientPort) + builder.add(buf) + self.resps.append(builder) + return case "/echohostheader": var builder = HTTPResponseBuilder(status: .ok) let hostValue = req.headers["Host"].first ?? "" diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 75371d437..4f3df8463 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -4667,6 +4667,87 @@ final class HTTPClientTests: XCTestCaseHTTPClientTestsBaseClass { let data = try JSONDecoder().decode(RequestInfo.self, from: bytes!) XCTAssertEqual(data.data, localAddress) } + + func testLocalPortBinding_configLevel() throws { + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let localPort = try reserveEphemeralPort() + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + config.localPort = localPort + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let response = try client.get(url: "http://127.0.0.1:\(bin.port)/echo-client-port").wait() + XCTAssertEqual(response.status, .ok) + + let bytes = response.body.flatMap { $0.getData(at: 0, length: $0.readableBytes) } + let data = try JSONDecoder().decode(RequestInfo.self, from: bytes!) + XCTAssertEqual(data.data, "\(localPort)") + } + + func testLocalPortBinding_defaultsToEphemeralPort() throws { + let bin = HTTPBin(.http1_1(ssl: false)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let response = try client.get(url: "http://127.0.0.1:\(bin.port)/echo-client-port").wait() + XCTAssertEqual(response.status, .ok) + + let bytes = response.body.flatMap { $0.getData(at: 0, length: $0.readableBytes) } + let data = try JSONDecoder().decode(RequestInfo.self, from: bytes!) + let boundPort = Int(data.data) + XCTAssertNotNil(boundPort) + XCTAssertNotEqual(boundPort, 0) + } + + func testLocalPortBinding_withTLS() throws { + let bin = HTTPBin(.http1_1(ssl: true)) + defer { XCTAssertNoThrow(try bin.shutdown()) } + + let localPort = try reserveEphemeralPort() + + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.tlsConfiguration = .clientDefault + config.tlsConfiguration?.certificateVerification = .none + config.localAddress = "127.0.0.1" + config.localPort = localPort + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + let response = try client.get(url: "https://127.0.0.1:\(bin.port)/echo-client-port").wait() + XCTAssertEqual(response.status, .ok) + + let bytes = response.body.flatMap { $0.getData(at: 0, length: $0.readableBytes) } + let data = try JSONDecoder().decode(RequestInfo.self, from: bytes!) + XCTAssertEqual(data.data, "\(localPort)") + } + + func testLocalPortBinding_outOfRangePort() throws { + var config = HTTPClient.Configuration() + .enableFastFailureModeForTesting() + config.localAddress = "127.0.0.1" + config.localPort = -1 + + let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config) + defer { XCTAssertNoThrow(try client.syncShutdown()) } + + XCTAssertThrowsError(try client.get(url: "http://127.0.0.1/ok").wait()) { error in + XCTAssertEqual(error as? HTTPClientError, .invalidLocalPort) + } + } } final class CountingDebugInitializerUtil: Sendable { diff --git a/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift b/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift index caf7c56bf..48030dbf7 100644 --- a/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift +++ b/Tests/AsyncHTTPClientTests/LocalAddressOverrideTests.swift @@ -130,4 +130,151 @@ struct LocalAddressOverrideTests { #expect(redirected.localAddress == "192.168.1.10") } + + // MARK: - Pool Key with localPort + + @Test func poolKeysWithDifferentLocalPortsAreNotEqual() { + let key1 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10", + localPort: 12345 + ) + let key2 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10", + localPort: 12346 + ) + let keyEphemeral = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10" + ) + #expect(key1 != key2) + #expect(key1 != keyEphemeral) + #expect(key2 != keyEphemeral) + } + + @Test func poolKeysWithSameLocalPortAreEqual() { + let key1 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10", + localPort: 12345 + ) + let key2 = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10", + localPort: 12345 + ) + #expect(key1 == key2) + } + + @Test func poolKeyLocalPortDefaultsToEphemeral() { + let key = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10" + ) + #expect(key.localPort == 0) + } + + @Test func poolKeyDescriptionContainsBoundAddressAndPort() { + let withPort = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10", + localPort: 12345 + ) + let withoutPort = ConnectionPool.Key( + scheme: .https, + connectionTarget: .domain(name: "example.com", port: 443), + serverNameIndicatorOverride: nil, + localAddress: "192.168.1.10" + ) + #expect(withPort.description.contains("bind: 192.168.1.10:12345")) + #expect(withoutPort.description.contains("bind: 192.168.1.10")) + #expect(!withoutPort.description.contains("192.168.1.10:")) + } + + // MARK: - Per-request localPort override + + @Test func perRequestLocalPortOverridesConfig() throws { + var request = HTTPClientRequest(url: "https://example.com/get") + request.localPort = 12345 + + let prepared = try HTTPClientRequest.Prepared( + request, + localAddress: "192.168.1.10", + localPort: 54321 + ) + + #expect(prepared.poolKey.localPort == 12345) + #expect(prepared.poolKey.localAddress == "192.168.1.10") + } + + @Test func configLocalPortUsedWhenRequestHasNone() throws { + let request = HTTPClientRequest(url: "https://example.com/get") + + let prepared = try HTTPClientRequest.Prepared( + request, + localAddress: "192.168.1.10", + localPort: 54321 + ) + + #expect(prepared.poolKey.localPort == 54321) + } + + @Test func perRequestLocalPortZeroOverridesNonZeroConfig() throws { + var request = HTTPClientRequest(url: "https://example.com/get") + request.localPort = 0 + + let prepared = try HTTPClientRequest.Prepared( + request, + localAddress: "192.168.1.10", + localPort: 54321 + ) + + #expect(prepared.poolKey.localPort == 0) + } + + @Test func noLocalPortWhenNeitherSet() throws { + let request = HTTPClientRequest(url: "https://example.com/get") + + let prepared = try HTTPClientRequest.Prepared(request) + + #expect(prepared.poolKey.localPort == 0) + } + + // MARK: - Redirect preserves localPort + + @Test func redirectPreservesLocalPort() { + var request = HTTPClientRequest(url: "https://example.com/redirect/301") + request.localAddress = "192.168.1.10" + request.localPort = 12345 + + let redirected = request.followingRedirect( + from: URL(string: "https://example.com/redirect/301")!, + to: URL(string: "https://other.com/ok")!, + status: .movedPermanently, + config: .init( + max: 5, + allowCycles: false, + retainHTTPMethodAndBodyOn301: false, + retainHTTPMethodAndBodyOn302: false + ) + ) + + #expect(redirected.localAddress == "192.168.1.10") + #expect(redirected.localPort == 12345) + } } diff --git a/Tests/AsyncHTTPClientTests/RequestBagTests.swift b/Tests/AsyncHTTPClientTests/RequestBagTests.swift index 297e81704..3278c7858 100644 --- a/Tests/AsyncHTTPClientTests/RequestBagTests.swift +++ b/Tests/AsyncHTTPClientTests/RequestBagTests.swift @@ -1047,6 +1047,41 @@ final class RequestBagTests: XCTestCase { ) XCTAssertNil(requestBag.poolKey.localAddress) } + + func testRequestBagPassesLocalPortToPoolKey() throws { + let request = try HTTPClient.Request(url: "https://example.com/get") + let eventLoop = EmbeddedEventLoop() + defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) } + let task = HTTPClient.Task(eventLoop: eventLoop, logger: .init(label: "test")) + let requestBag = try RequestBag( + request: request, + eventLoopPreference: .indifferent, + task: task, + redirectHandler: nil, + connectionDeadline: .distantFuture, + requestOptions: .forTests(localAddress: "10.0.0.1", localPort: 12345), + delegate: ResponseAccumulator(request: request) + ) + XCTAssertEqual(requestBag.poolKey.localAddress, "10.0.0.1") + XCTAssertEqual(requestBag.poolKey.localPort, 12345) + } + + func testRequestBagPoolKeyEphemeralLocalPortByDefault() throws { + let request = try HTTPClient.Request(url: "https://example.com/get") + let eventLoop = EmbeddedEventLoop() + defer { XCTAssertNoThrow(try eventLoop.syncShutdownGracefully()) } + let task = HTTPClient.Task(eventLoop: eventLoop, logger: .init(label: "test")) + let requestBag = try RequestBag( + request: request, + eventLoopPreference: .indifferent, + task: task, + redirectHandler: nil, + connectionDeadline: .distantFuture, + requestOptions: .forTests(localAddress: "10.0.0.1"), + delegate: ResponseAccumulator(request: request) + ) + XCTAssertEqual(requestBag.poolKey.localPort, 0) + } } extension HTTPClient.Task { @@ -1172,13 +1207,15 @@ extension RequestOptions { idleReadTimeout: TimeAmount? = nil, idleWriteTimeout: TimeAmount? = nil, dnsOverride: [String: String] = [:], - localAddress: String? = nil + localAddress: String? = nil, + localPort: Int = 0 ) -> Self { RequestOptions( idleReadTimeout: idleReadTimeout, idleWriteTimeout: idleWriteTimeout, dnsOverride: dnsOverride, - localAddress: localAddress + localAddress: localAddress, + localPort: localPort ) } }