diff --git a/Package.swift b/Package.swift index a14c1db8b..f2e606a93 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-nio.git", from: "2.27.0"), - .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.12.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.13.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.5.1"), .package(url: "https://github.com/apple/swift-log.git", from: "1.4.0"), diff --git a/Sources/AsyncHTTPClient/BestEffortHashableTLSConfiguration.swift b/Sources/AsyncHTTPClient/BestEffortHashableTLSConfiguration.swift new file mode 100644 index 000000000..58169f645 --- /dev/null +++ b/Sources/AsyncHTTPClient/BestEffortHashableTLSConfiguration.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOSSL + +/// Wrapper around `TLSConfiguration` from NIOSSL to provide a best effort implementation of `Hashable` +struct BestEffortHashableTLSConfiguration: Hashable { + let base: TLSConfiguration + + init(wrapping base: TLSConfiguration) { + self.base = base + } + + func hash(into hasher: inout Hasher) { + self.base.bestEffortHash(into: &hasher) + } + + static func == (lhs: BestEffortHashableTLSConfiguration, rhs: BestEffortHashableTLSConfiguration) -> Bool { + return lhs.base.bestEffortEquals(rhs.base) + } +} diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index e7c68aba4..63be3aa37 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -82,7 +82,7 @@ final class ConnectionPool { } else { let provider = HTTP1ConnectionProvider(key: key, eventLoop: taskEventLoop, - configuration: self.configuration, + configuration: key.config(overriding: self.configuration), pool: self, backgroundActivityLogger: self.backgroundActivityLogger) let enqueued = provider.enqueue() @@ -139,12 +139,16 @@ final class ConnectionPool { self.port = request.port self.host = request.host self.unixPath = request.socketPath + if let tls = request.tlsConfiguration { + self.tlsConfiguration = BestEffortHashableTLSConfiguration(wrapping: tls) + } } var scheme: Scheme var host: String var port: Int var unixPath: String + var tlsConfiguration: BestEffortHashableTLSConfiguration? enum Scheme: Hashable { case http @@ -162,6 +166,15 @@ final class ConnectionPool { } } } + + /// Returns a key-specific `HTTPClient.Configuration` by overriding the properties of `base` + func config(overriding base: HTTPClient.Configuration) -> HTTPClient.Configuration { + var config = base + if let tlsConfiguration = self.tlsConfiguration { + config.tlsConfiguration = tlsConfiguration.base + } + return config + } } } diff --git a/Sources/AsyncHTTPClient/HTTPHandler.swift b/Sources/AsyncHTTPClient/HTTPHandler.swift index 3cdfeeee7..4850c51d8 100644 --- a/Sources/AsyncHTTPClient/HTTPHandler.swift +++ b/Sources/AsyncHTTPClient/HTTPHandler.swift @@ -186,6 +186,8 @@ extension HTTPClient { public var headers: HTTPHeaders /// Request body, defaults to no body. public var body: Body? + /// Request-specific TLS configuration, defaults to no request-specific TLS configuration. + public var tlsConfiguration: TLSConfiguration? struct RedirectState { var count: Int @@ -209,11 +211,29 @@ extension HTTPClient { /// - `unsupportedScheme` if URL does contains unsupported HTTP scheme. /// - `emptyHost` if URL does not contains a host. public init(url: String, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil) throws { + try self.init(url: url, method: method, headers: headers, body: body, tlsConfiguration: nil) + } + + /// Create HTTP request. + /// + /// - parameters: + /// - url: Remote `URL`. + /// - version: HTTP version. + /// - method: HTTP method. + /// - headers: Custom HTTP headers. + /// - body: Request body. + /// - tlsConfiguration: Request TLS configuration + /// - throws: + /// - `invalidURL` if URL cannot be parsed. + /// - `emptyScheme` if URL does not contain HTTP scheme. + /// - `unsupportedScheme` if URL does contains unsupported HTTP scheme. + /// - `emptyHost` if URL does not contains a host. + public init(url: String, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil, tlsConfiguration: TLSConfiguration?) throws { guard let url = URL(string: url) else { throw HTTPClientError.invalidURL } - try self.init(url: url, method: method, headers: headers, body: body) + try self.init(url: url, method: method, headers: headers, body: body, tlsConfiguration: tlsConfiguration) } /// Create an HTTP `Request`. @@ -229,6 +249,23 @@ extension HTTPClient { /// - `emptyHost` if URL does not contains a host. /// - `missingSocketPath` if URL does not contains a socketPath as an encoded host. public init(url: URL, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil) throws { + try self.init(url: url, method: method, headers: headers, body: body, tlsConfiguration: nil) + } + + /// Create an HTTP `Request`. + /// + /// - parameters: + /// - url: Remote `URL`. + /// - method: HTTP method. + /// - headers: Custom HTTP headers. + /// - body: Request body. + /// - tlsConfiguration: Request TLS configuration + /// - throws: + /// - `emptyScheme` if URL does not contain HTTP scheme. + /// - `unsupportedScheme` if URL does contains unsupported HTTP scheme. + /// - `emptyHost` if URL does not contains a host. + /// - `missingSocketPath` if URL does not contains a socketPath as an encoded host. + public init(url: URL, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil, tlsConfiguration: TLSConfiguration?) throws { guard let scheme = url.scheme?.lowercased() else { throw HTTPClientError.emptyScheme } @@ -244,6 +281,7 @@ extension HTTPClient { self.scheme = scheme self.headers = headers self.body = body + self.tlsConfiguration = tlsConfiguration } /// Whether request will be executed using secure socket. diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift index 4344fd72e..7e9e2b5d6 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift @@ -133,6 +133,7 @@ extension HTTPClientTests { ("testFileDownloadChunked", testFileDownloadChunked), ("testCloseWhileBackpressureIsExertedIsFine", testCloseWhileBackpressureIsExertedIsFine), ("testErrorAfterCloseWhileBackpressureExerted", testErrorAfterCloseWhileBackpressureExerted), + ("testRequestSpecificTLS", testRequestSpecificTLS), ] } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index 34e95924e..ed8c1acc5 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -2916,4 +2916,52 @@ class HTTPClientTests: XCTestCase { XCTAssertEqual(error as? ExpectedError, .expected) } } + + func testRequestSpecificTLS() throws { + let configuration = HTTPClient.Configuration(tlsConfiguration: nil, + timeout: .init(), + ignoreUncleanSSLShutdown: false, + decompression: .disabled) + let localHTTPBin = HTTPBin(ssl: true) + let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), + configuration: configuration) + let decoder = JSONDecoder() + + defer { + XCTAssertNoThrow(try localClient.syncShutdown()) + XCTAssertNoThrow(try localHTTPBin.shutdown()) + } + + // First two requests use identical TLS configurations. + let firstRequest = try HTTPClient.Request(url: "https://localhost:\(localHTTPBin.port)/get", method: .GET, tlsConfiguration: .forClient(certificateVerification: .none)) + let firstResponse = try localClient.execute(request: firstRequest).wait() + guard let firstBody = firstResponse.body else { + XCTFail("No request body found") + return + } + let firstConnectionNumber = try decoder.decode(RequestInfo.self, from: firstBody).connectionNumber + + let secondRequest = try HTTPClient.Request(url: "https://localhost:\(localHTTPBin.port)/get", method: .GET, tlsConfiguration: .forClient(certificateVerification: .none)) + let secondResponse = try localClient.execute(request: secondRequest).wait() + guard let secondBody = secondResponse.body else { + XCTFail("No request body found") + return + } + let secondConnectionNumber = try decoder.decode(RequestInfo.self, from: secondBody).connectionNumber + + // Uses a differrent TLS config. + let thirdRequest = try HTTPClient.Request(url: "https://localhost:\(localHTTPBin.port)/get", method: .GET, tlsConfiguration: .forClient(maximumTLSVersion: .tlsv1, certificateVerification: .none)) + let thirdResponse = try localClient.execute(request: thirdRequest).wait() + guard let thirdBody = thirdResponse.body else { + XCTFail("No request body found") + return + } + let thirdConnectionNumber = try decoder.decode(RequestInfo.self, from: thirdBody).connectionNumber + + XCTAssertEqual(firstResponse.status, .ok) + XCTAssertEqual(secondResponse.status, .ok) + XCTAssertEqual(thirdResponse.status, .ok) + XCTAssertEqual(firstConnectionNumber, secondConnectionNumber, "Identical TLS configurations did not use the same connection") + XCTAssertNotEqual(thirdConnectionNumber, firstConnectionNumber, "Different TLS configurations did not use different connections.") + } }