Skip to content

Support request specific TLS configuration #358

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
May 7, 2021
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
32 changes: 32 additions & 0 deletions Sources/AsyncHTTPClient/BestEffortHashableTLSConfiguration.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
15 changes: 14 additions & 1 deletion Sources/AsyncHTTPClient/ConnectionPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -139,12 +139,16 @@ final class ConnectionPool {
self.port = request.port
self.host = request.host
self.unixPath = request.socketPath
if let tls = request.tlsConfiguration {
Copy link
Contributor

Choose a reason for hiding this comment

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

@artemredkin should we add a test case that targets the pool directly here? Ie no actual connections?

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
Expand All @@ -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
}
}
}

Expand Down
40 changes: 39 additions & 1 deletion Sources/AsyncHTTPClient/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand All @@ -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
}
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ extension HTTPClientTests {
("testFileDownloadChunked", testFileDownloadChunked),
("testCloseWhileBackpressureIsExertedIsFine", testCloseWhileBackpressureIsExertedIsFine),
("testErrorAfterCloseWhileBackpressureExerted", testErrorAfterCloseWhileBackpressureExerted),
("testRequestSpecificTLS", testRequestSpecificTLS),
]
}
}
48 changes: 48 additions & 0 deletions Tests/AsyncHTTPClientTests/HTTPClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
}
}