Skip to content
Closed
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 @@ -434,6 +434,53 @@ extension HTTPConnectionPool.ConnectionFactory {
}
}

#if canImport(Network)
/// Applies every `HTTPClient.Configuration` field that maps onto `NWParameters` in one place.
/// `NIOTSConnectionBootstrap.configureNWParameters(_:)` stores only the most recently passed
/// closure rather than composing across calls, so every concern that needs `NWParameters` must
/// be folded into a single call to this method instead of separate `configureNWParameters { }`
/// invocations. Callers already run under `#available(OSX 10.14, iOS 12.0, tvOS 12.0,
/// watchOS 6.0, *)`, the minimum for `NWParameters` itself; fields with a higher minimum
/// (`prohibitConstrainedPaths`, `allowUltraConstrainedPaths`) are individually re-checked below
/// instead of raising that requirement for the whole method.
@available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *)
private func configureNWParameters(_ params: NWParameters, localAddress: String?) {
if let localAddress {
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(localAddress),
port: .any
)
}
if !self.clientConfiguration.prohibitedInterfaceTypes.isEmpty {
params.prohibitedInterfaceTypes = self.clientConfiguration.prohibitedInterfaceTypes.map {
switch $0.backing {
case .other: return .other
case .wifi: return .wifi
case .cellular: return .cellular
case .wiredEthernet: return .wiredEthernet
case .loopback: return .loopback
}
}
}
if let required = self.clientConfiguration.requiredInterfaceType {
switch required.backing {
case .other: params.requiredInterfaceType = .other
case .wifi: params.requiredInterfaceType = .wifi
case .cellular: params.requiredInterfaceType = .cellular
case .wiredEthernet: params.requiredInterfaceType = .wiredEthernet
case .loopback: params.requiredInterfaceType = .loopback
}
}
params.prohibitExpensivePaths = !self.clientConfiguration.allowsExpensiveNetworkAccess
if #available(OSX 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) {
params.prohibitConstrainedPaths = !self.clientConfiguration.allowsConstrainedNetworkAccess
}
if #available(OSX 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) {
params.allowUltraConstrainedPaths = self.clientConfiguration.allowsUltraConstrainedPaths
}
}
#endif

private func makePlainBootstrap<Requester: HTTPConnectionRequester>(
requester: Requester,
connectionID: HTTPConnectionPool.Connection.ID,
Expand Down Expand Up @@ -470,13 +517,8 @@ extension HTTPConnectionPool.ConnectionFactory {
return channel.eventLoop.makeFailedFuture(error)
}
}
if let localAddress = self.key.localAddress {
bootstrap = bootstrap.configureNWParameters { params in
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(localAddress),
port: .any
)
}
bootstrap = bootstrap.configureNWParameters { params in
self.configureNWParameters(params, localAddress: self.key.localAddress)
}
return bootstrap
}
Expand Down Expand Up @@ -621,13 +663,8 @@ extension HTTPConnectionPool.ConnectionFactory {
return channel.eventLoop.makeFailedFuture(error)
}
}
if let localAddress = localAddr {
bootstrap = bootstrap.configureNWParameters { params in
params.requiredLocalEndpoint = NWEndpoint.hostPort(
host: NWEndpoint.Host(localAddress),
port: .any
)
}
bootstrap = bootstrap.configureNWParameters { params in
self.configureNWParameters(params, localAddress: localAddr)
}
return bootstrap as NIOClientTCPBootstrapProtocol
}
Expand Down
63 changes: 63 additions & 0 deletions Sources/AsyncHTTPClient/HTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -903,6 +903,37 @@ public final class HTTPClient: Sendable {
/// which is the recommended setting. Only set this to `false` when attempting to trigger a particular error path.
public var networkFrameworkWaitForConnectivity: Bool

/// Interface types that connections must not use (e.g. `[.cellular]` to forbid cellular).
/// Defaults to empty — no restriction. Mirrors `NWParameters.prohibitedInterfaceTypes`.
/// Only applies when Network.framework is used as the transport (Darwin platforms);
/// ignored otherwise.
public var prohibitedInterfaceTypes: Set<NetworkInterfaceType> = []

/// The single interface type connections are restricted to (e.g. `.wifi` to forbid
/// everything but Wi-Fi). Defaults to `nil` — no restriction. Mirrors
/// `NWParameters.requiredInterfaceType`, but as an `Optional` instead of that property's
/// non-optional `.other`-means-"unrestricted" sentinel. Only applies when Network.framework
/// is used as the transport; ignored otherwise.
public var requiredInterfaceType: NetworkInterfaceType?

/// Whether the connection may use an expensive network path (e.g. cellular, personal
/// hotspot). Defaults to `true`. Mirrors `NWParameters.prohibitExpensivePaths` (inverted).
/// Only applies when Network.framework is used as the transport; ignored otherwise.
public var allowsExpensiveNetworkAccess: Bool = true

/// Whether the connection may use a constrained network path (e.g. Low Data Mode).
/// Defaults to `true`. Mirrors `NWParameters.prohibitConstrainedPaths` (inverted).
/// Only applies when Network.framework is used as the transport; ignored otherwise.
public var allowsConstrainedNetworkAccess: Bool = true

/// Whether the connection may use an ultra-constrained network path. Defaults to `false`.
/// Mirrors `NWParameters.allowUltraConstrainedPaths` directly (same polarity, unlike
/// ``allowsExpensiveNetworkAccess`` / ``allowsConstrainedNetworkAccess`` above). Only takes
/// effect on OS versions where `NWParameters.allowUltraConstrainedPaths` exists (macOS 26.0 /
/// iOS 26.0 / watchOS 26.0 / tvOS 26.0 / visionOS 26.0 and newer); a no-op everywhere else,
/// including older Network.framework-capable OS versions, not just non-Darwin platforms.
public var allowsUltraConstrainedPaths: Bool = false

/// The maximum number of times each connection can be used before it is replaced with a new one. Use `nil` (the default)
/// if no limit should be applied to each connection.
///
Expand Down Expand Up @@ -1479,6 +1510,38 @@ extension HTTPClient.Configuration {

var configuration: Configuration
}

/// A network interface category, mirroring `NWInterface.InterfaceType` (`Network.framework`)
/// without requiring `Network` to be imported at this declaration site, so it can be declared
/// unconditionally like ``HTTPClient/Configuration-swift.struct/networkFrameworkWaitForConnectivity``.
/// Only meaningful when Network.framework is used as the transport (Darwin platforms); ignored
/// otherwise.
public struct NetworkInterfaceType: Sendable, Hashable {
enum Backing: Sendable, Hashable {
case other
case wifi
case cellular
case wiredEthernet
case loopback
}

let backing: Backing

private init(backing: Backing) {
self.backing = backing
}

/// Mirrors `NWInterface.InterfaceType.other`.
public static let other: Self = .init(backing: .other)
/// Mirrors `NWInterface.InterfaceType.wifi`.
public static let wifi: Self = .init(backing: .wifi)
/// Mirrors `NWInterface.InterfaceType.cellular`.
public static let cellular: Self = .init(backing: .cellular)
/// Mirrors `NWInterface.InterfaceType.wiredEthernet`.
public static let wiredEthernet: Self = .init(backing: .wiredEthernet)
/// Mirrors `NWInterface.InterfaceType.loopback`.
public static let loopback: Self = .init(backing: .loopback)
}
}

/// Possible client errors.
Expand Down
52 changes: 52 additions & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,56 @@ class HTTPClientNIOTSTests: XCTestCase {
}
#endif
}

func testNetworkPathRestrictionFieldsDontBreakRequests() {
guard isTestingNIOTS() else { return }
#if canImport(Network)
let httpBin = HTTPBin(.http1_1(ssl: false))
var config = HTTPClient.Configuration()
// Loopback isn't cellular/expensive/constrained, so none of these should block the request;
// this exercises the field plumbing (Configuration -> ConnectionFactory -> NWParameters)
// without needing a real cellular/constrained network path in CI.
config.prohibitedInterfaceTypes = [.cellular]
config.allowsExpensiveNetworkAccess = false
config.allowsConstrainedNetworkAccess = false
config.allowsUltraConstrainedPaths = false
// Also set localAddress, to cover the case that regressed `configureNWParameters`
// composition: local-address binding and interface restriction must both apply, since
// `NIOTSConnectionBootstrap.configureNWParameters(_:)` only keeps the most recent closure.
config.localAddress = "127.0.0.1"

let httpClient = HTTPClient(
eventLoopGroupProvider: .shared(self.clientGroup),
configuration: config
)
defer {
XCTAssertNoThrow(try httpClient.syncShutdown(requiresCleanClose: true))
XCTAssertNoThrow(try httpBin.shutdown())
}

XCTAssertNoThrow(try httpClient.get(url: "http://localhost:\(httpBin.port)/get").wait())
#endif
}

func testRequiredInterfaceTypeOtherDoesNotRestrictLoopback() {
guard isTestingNIOTS() else { return }
#if canImport(Network)
let httpBin = HTTPBin(.http1_1(ssl: false))
var config = HTTPClient.Configuration()
// `.other` mirrors NWParameters' own "unrestricted" default; setting it explicitly should
// behave the same as leaving `requiredInterfaceType` `nil`.
config.requiredInterfaceType = .other

let httpClient = HTTPClient(
eventLoopGroupProvider: .shared(self.clientGroup),
configuration: config
)
defer {
XCTAssertNoThrow(try httpClient.syncShutdown(requiresCleanClose: true))
XCTAssertNoThrow(try httpBin.shutdown())
}

XCTAssertNoThrow(try httpClient.get(url: "http://localhost:\(httpBin.port)/get").wait())
#endif
}
}