Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ extension HTTPClient {
currentRequest,
dnsOverride: configuration.dnsOverride,
localAddress: configuration.localAddress,
localPort: configuration.localPort,
tracing: self.configuration.tracing
)
let response = try await {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -162,6 +164,7 @@ extension HTTPClientRequest {
newRequest.headers = headers
newRequest.body = body
newRequest.localAddress = self.localAddress
newRequest.localPort = self.localPort
return newRequest
}
}
10 changes: 10 additions & 0 deletions Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,23 @@ 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. Values outside of `0...65535` fail the
Comment on lines +66 to +67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only consulted when a local address (request-level or configuration-level) is also set.

What does this mean? This is only consulted if a client-level setting is set? Is that true?

/// request with ``HTTPClientError/invalidLocalPort``. Defaults to `nil`
/// (use client configuration default).
public var localPort: Int?

public init(url: String) {
self.url = url
self.method = .GET
self.headers = .init()
self.body = .none
self.tlsConfiguration = nil
self.localAddress = nil
self.localPort = nil
}
}

Expand Down
24 changes: 19 additions & 5 deletions Sources/AsyncHTTPClient/ConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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(
Expand All @@ -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
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, *),
Expand Down Expand Up @@ -471,10 +478,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))
)
}
}
Expand All @@ -495,7 +503,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
Expand Down Expand Up @@ -572,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 {
Expand All @@ -589,6 +600,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
Expand Down Expand Up @@ -625,7 +637,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))
)
}
}
Expand Down Expand Up @@ -653,7 +665,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
Expand Down
9 changes: 7 additions & 2 deletions Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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
)
}
}
26 changes: 25 additions & 1 deletion Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -933,6 +933,24 @@ 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.
///
/// 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.
public var http1_1ConnectionDebugInitializer: (@Sendable (Channel) -> EventLoopFuture<Void>)?

Expand Down Expand Up @@ -1524,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)
}
Expand Down Expand Up @@ -1620,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):
Expand Down Expand Up @@ -1728,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)

Expand Down
3 changes: 2 additions & 1 deletion Sources/AsyncHTTPClient/RequestBag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ final class RequestBag<Delegate: HTTPClientResponseDelegate & Sendable>: Sendabl
self.poolKey = .init(
request,
dnsOverride: requestOptions.dnsOverride,
localAddress: requestOptions.localAddress
localAddress: requestOptions.localAddress,
localPort: requestOptions.localPort
)
self.eventLoopPreference = eventLoopPreference
self.task = task
Expand Down
Loading