Skip to content

Commit 98b45ed

Browse files
authored
Allow DNS override (#675)
Sometimes it can be useful to connect to one host e.g. `x.example.com` but request and validate the certificate chain as if we would connect to `y.example.com`. This is what this PR adds support for by adding a `dnsOverride` configuration to `HTTPClient.Configuration`. This is similar to curls `—resolve-to` option but only allows overriding host and not ports for now.
1 parent 423fd0b commit 98b45ed

18 files changed

+232
-67
lines changed

Package.swift

+2
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ let package = Package(
6868
resources: [
6969
.copy("Resources/self_signed_cert.pem"),
7070
.copy("Resources/self_signed_key.pem"),
71+
.copy("Resources/example.com.cert.pem"),
72+
.copy("Resources/example.com.private-key.pem"),
7173
]
7274
),
7375
]

[email protected]

+2
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ let package = Package(
6767
resources: [
6868
.copy("Resources/self_signed_cert.pem"),
6969
.copy("Resources/self_signed_key.pem"),
70+
.copy("Resources/example.com.cert.pem"),
71+
.copy("Resources/example.com.private-key.pem"),
7072
]
7173
),
7274
]

Sources/AsyncHTTPClient/AsyncAwait/HTTPClient+execute.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ extension HTTPClient {
7777

7878
// this loop is there to follow potential redirects
7979
while true {
80-
let preparedRequest = try HTTPClientRequest.Prepared(currentRequest)
80+
let preparedRequest = try HTTPClientRequest.Prepared(currentRequest, dnsOverride: configuration.dnsOverride)
8181
let response = try await executeCancellable(preparedRequest, deadline: deadline, logger: logger)
8282

8383
guard var redirectState = currentRedirectState else {
@@ -131,7 +131,7 @@ extension HTTPClient {
131131
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<HTTPClientResponse, Swift.Error>) -> Void in
132132
let transaction = Transaction(
133133
request: request,
134-
requestOptions: .init(idleReadTimeout: nil),
134+
requestOptions: .fromClientConfiguration(self.configuration),
135135
logger: logger,
136136
connectionDeadline: .now() + (self.configuration.timeout.connectionCreationTimeout),
137137
preferredEventLoop: eventLoop,

Sources/AsyncHTTPClient/AsyncAwait/HTTPClientRequest+Prepared.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ extension HTTPClientRequest {
4242

4343
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
4444
extension HTTPClientRequest.Prepared {
45-
init(_ request: HTTPClientRequest) throws {
45+
init(_ request: HTTPClientRequest, dnsOverride: [String: String] = [:]) throws {
4646
guard let url = URL(string: request.url) else {
4747
throw HTTPClientError.invalidURL
4848
}
@@ -58,7 +58,7 @@ extension HTTPClientRequest.Prepared {
5858

5959
self.init(
6060
url: url,
61-
poolKey: .init(url: deconstructedURL, tlsConfiguration: nil),
61+
poolKey: .init(url: deconstructedURL, tlsConfiguration: nil, dnsOverride: dnsOverride),
6262
requestFramingMetadata: metadata,
6363
head: .init(
6464
version: .http1_1,

Sources/AsyncHTTPClient/ConnectionPool.swift

+47-7
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@
1414

1515
import NIOSSL
1616

17+
#if canImport(Darwin)
18+
import Darwin.C
19+
#elseif os(Linux) || os(FreeBSD) || os(Android)
20+
import Glibc
21+
#else
22+
#error("unsupported target operating system")
23+
#endif
24+
25+
extension String {
26+
var isIPAddress: Bool {
27+
var ipv4Address = in_addr()
28+
var ipv6Address = in6_addr()
29+
return self.withCString { host in
30+
inet_pton(AF_INET, host, &ipv4Address) == 1 ||
31+
inet_pton(AF_INET6, host, &ipv6Address) == 1
32+
}
33+
}
34+
}
35+
1736
enum ConnectionPool {
1837
/// Used by the `ConnectionPool` to index its `HTTP1ConnectionProvider`s
1938
///
@@ -24,15 +43,18 @@ enum ConnectionPool {
2443
var scheme: Scheme
2544
var connectionTarget: ConnectionTarget
2645
private var tlsConfiguration: BestEffortHashableTLSConfiguration?
46+
var serverNameIndicatorOverride: String?
2747

2848
init(
2949
scheme: Scheme,
3050
connectionTarget: ConnectionTarget,
31-
tlsConfiguration: BestEffortHashableTLSConfiguration? = nil
51+
tlsConfiguration: BestEffortHashableTLSConfiguration? = nil,
52+
serverNameIndicatorOverride: String?
3253
) {
3354
self.scheme = scheme
3455
self.connectionTarget = connectionTarget
3556
self.tlsConfiguration = tlsConfiguration
57+
self.serverNameIndicatorOverride = serverNameIndicatorOverride
3658
}
3759

3860
var description: String {
@@ -48,26 +70,44 @@ enum ConnectionPool {
4870
case .unixSocket(let socketPath):
4971
hostDescription = socketPath
5072
}
51-
return "\(self.scheme)://\(hostDescription) TLS-hash: \(hash)"
73+
return "\(self.scheme)://\(hostDescription)\(self.serverNameIndicatorOverride.map { " SNI: \($0)" } ?? "") TLS-hash: \(hash) "
5274
}
5375
}
5476
}
5577

78+
extension DeconstructedURL {
79+
func applyDNSOverride(_ dnsOverride: [String: String]) -> (ConnectionTarget, serverNameIndicatorOverride: String?) {
80+
guard
81+
let originalHost = self.connectionTarget.host,
82+
let hostOverride = dnsOverride[originalHost]
83+
else {
84+
return (self.connectionTarget, nil)
85+
}
86+
return (
87+
.init(remoteHost: hostOverride, port: self.connectionTarget.port ?? self.scheme.defaultPort),
88+
serverNameIndicatorOverride: originalHost.isIPAddress ? nil : originalHost
89+
)
90+
}
91+
}
92+
5693
extension ConnectionPool.Key {
57-
init(url: DeconstructedURL, tlsConfiguration: TLSConfiguration?) {
94+
init(url: DeconstructedURL, tlsConfiguration: TLSConfiguration?, dnsOverride: [String: String]) {
95+
let (connectionTarget, serverNameIndicatorOverride) = url.applyDNSOverride(dnsOverride)
5896
self.init(
5997
scheme: url.scheme,
60-
connectionTarget: url.connectionTarget,
98+
connectionTarget: connectionTarget,
6199
tlsConfiguration: tlsConfiguration.map {
62100
BestEffortHashableTLSConfiguration(wrapping: $0)
63-
}
101+
},
102+
serverNameIndicatorOverride: serverNameIndicatorOverride
64103
)
65104
}
66105

67-
init(_ request: HTTPClient.Request) {
106+
init(_ request: HTTPClient.Request, dnsOverride: [String: String] = [:]) {
68107
self.init(
69108
url: request.deconstructedURL,
70-
tlsConfiguration: request.tlsConfiguration
109+
tlsConfiguration: request.tlsConfiguration,
110+
dnsOverride: dnsOverride
71111
)
72112
}
73113
}

Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift

+9-4
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ extension HTTPConnectionPool.ConnectionFactory {
281281
}
282282
let tlsEventHandler = TLSEventsHandler(deadline: deadline)
283283

284-
let sslServerHostname = self.key.connectionTarget.sslServerHostname
284+
let sslServerHostname = self.key.serverNameIndicator
285285
let sslContextFuture = self.sslContextCache.sslContext(
286286
tlsConfiguration: tlsConfig,
287287
eventLoop: channel.eventLoop,
@@ -409,7 +409,7 @@ extension HTTPConnectionPool.ConnectionFactory {
409409
#if canImport(Network)
410410
if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop) {
411411
// create NIOClientTCPBootstrap with NIOTS TLS provider
412-
let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions(on: eventLoop).map {
412+
let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions(on: eventLoop, serverNameIndicatorOverride: key.serverNameIndicatorOverride).map {
413413
options -> NIOClientTCPBootstrapProtocol in
414414

415415
tsBootstrap
@@ -434,7 +434,6 @@ extension HTTPConnectionPool.ConnectionFactory {
434434
}
435435
#endif
436436

437-
let sslServerHostname = self.key.connectionTarget.sslServerHostname
438437
let sslContextFuture = sslContextCache.sslContext(
439438
tlsConfiguration: tlsConfig,
440439
eventLoop: eventLoop,
@@ -449,7 +448,7 @@ extension HTTPConnectionPool.ConnectionFactory {
449448
let sync = channel.pipeline.syncOperations
450449
let sslHandler = try NIOSSLClientHandler(
451450
context: sslContext,
452-
serverHostname: sslServerHostname
451+
serverHostname: self.key.serverNameIndicator
453452
)
454453
let tlsEventHandler = TLSEventsHandler(deadline: deadline)
455454

@@ -488,6 +487,12 @@ extension Scheme {
488487
}
489488
}
490489

490+
extension ConnectionPool.Key {
491+
var serverNameIndicator: String? {
492+
serverNameIndicatorOverride ?? connectionTarget.sslServerHostname
493+
}
494+
}
495+
491496
extension ConnectionTarget {
492497
fileprivate var sslServerHostname: String? {
493498
switch self {

Sources/AsyncHTTPClient/ConnectionPool/RequestOptions.swift

+6-2
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@ struct RequestOptions {
1818
/// The maximal `TimeAmount` that is allowed to pass between `channelRead`s from the Channel.
1919
var idleReadTimeout: TimeAmount?
2020

21-
init(idleReadTimeout: TimeAmount?) {
21+
var dnsOverride: [String: String]
22+
23+
init(idleReadTimeout: TimeAmount?, dnsOverride: [String: String]) {
2224
self.idleReadTimeout = idleReadTimeout
25+
self.dnsOverride = dnsOverride
2326
}
2427
}
2528

2629
extension RequestOptions {
2730
static func fromClientConfiguration(_ configuration: HTTPClient.Configuration) -> Self {
2831
RequestOptions(
29-
idleReadTimeout: configuration.timeout.read
32+
idleReadTimeout: configuration.timeout.read,
33+
dnsOverride: configuration.dnsOverride
3034
)
3135
}
3236
}

Sources/AsyncHTTPClient/HTTPClient.swift

+11
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,17 @@ public class HTTPClient {
711711
public struct Configuration {
712712
/// TLS configuration, defaults to `TLSConfiguration.makeClientConfiguration()`.
713713
public var tlsConfiguration: Optional<TLSConfiguration>
714+
715+
/// Sometimes it can be useful to connect to one host e.g. `x.example.com` but
716+
/// request and validate the certificate chain as if we would connect to `y.example.com`.
717+
/// ``dnsOverride`` allows to do just that by mapping host names which we will request and validate the certificate chain, to a different
718+
/// host name which will be used to actually connect to.
719+
///
720+
/// **Example:** if ``dnsOverride`` is set to `["example.com": "localhost"]` and we execute a request with a
721+
/// `url` of `https://example.com/`, the ``HTTPClient`` will actually open a connection to `localhost` instead of `example.com`.
722+
/// ``HTTPClient`` will still request certificates from the server for `example.com` and validate them as if we would connect to `example.com`.
723+
public var dnsOverride: [String: String] = [:]
724+
714725
/// Enables following 3xx redirects automatically.
715726
///
716727
/// Following redirects are supported:

Sources/AsyncHTTPClient/NIOTransportServices/TLSConfiguration.swift

+9-3
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ extension TLSConfiguration {
6666
///
6767
/// - Parameter eventLoop: EventLoop to wait for creation of options on
6868
/// - Returns: Future holding NWProtocolTLS Options
69-
func getNWProtocolTLSOptions(on eventLoop: EventLoop) -> EventLoopFuture<NWProtocolTLS.Options> {
69+
func getNWProtocolTLSOptions(on eventLoop: EventLoop, serverNameIndicatorOverride: String?) -> EventLoopFuture<NWProtocolTLS.Options> {
7070
let promise = eventLoop.makePromise(of: NWProtocolTLS.Options.self)
7171
Self.tlsDispatchQueue.async {
7272
do {
73-
let options = try self.getNWProtocolTLSOptions()
73+
let options = try self.getNWProtocolTLSOptions(serverNameIndicatorOverride: serverNameIndicatorOverride)
7474
promise.succeed(options)
7575
} catch {
7676
promise.fail(error)
@@ -82,7 +82,7 @@ extension TLSConfiguration {
8282
/// create NWProtocolTLS.Options for use with NIOTransportServices from the NIOSSL TLSConfiguration
8383
///
8484
/// - Returns: Equivalent NWProtocolTLS Options
85-
func getNWProtocolTLSOptions() throws -> NWProtocolTLS.Options {
85+
func getNWProtocolTLSOptions(serverNameIndicatorOverride: String?) throws -> NWProtocolTLS.Options {
8686
let options = NWProtocolTLS.Options()
8787

8888
let useMTELGExplainer = """
@@ -92,6 +92,12 @@ extension TLSConfiguration {
9292
platform networking stack).
9393
"""
9494

95+
if let serverNameIndicatorOverride = serverNameIndicatorOverride {
96+
serverNameIndicatorOverride.withCString { serverNameIndicatorOverride in
97+
sec_protocol_options_set_tls_server_name(options.securityProtocolOptions, serverNameIndicatorOverride)
98+
}
99+
}
100+
95101
// minimum TLS protocol
96102
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) {
97103
sec_protocol_options_set_min_tls_protocol_version(options.securityProtocolOptions, self.minimumTLSVersion.nwTLSProtocolVersion)

Sources/AsyncHTTPClient/RequestBag.swift

+3-4
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ final class RequestBag<Delegate: HTTPClientResponseDelegate> {
2727
50
2828
}
2929

30+
let poolKey: ConnectionPool.Key
31+
3032
let task: HTTPClient.Task<Delegate.Response>
3133
var eventLoop: EventLoop {
3234
self.task.eventLoop
@@ -63,6 +65,7 @@ final class RequestBag<Delegate: HTTPClientResponseDelegate> {
6365
connectionDeadline: NIODeadline,
6466
requestOptions: RequestOptions,
6567
delegate: Delegate) throws {
68+
self.poolKey = .init(request, dnsOverride: requestOptions.dnsOverride)
6669
self.eventLoopPreference = eventLoopPreference
6770
self.task = task
6871
self.state = .init(redirectHandler: redirectHandler)
@@ -392,10 +395,6 @@ final class RequestBag<Delegate: HTTPClientResponseDelegate> {
392395
}
393396

394397
extension RequestBag: HTTPSchedulableRequest {
395-
var poolKey: ConnectionPool.Key {
396-
ConnectionPool.Key(self.request)
397-
}
398-
399398
var tlsConfiguration: TLSConfiguration? {
400399
self.request.tlsConfiguration
401400
}

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests+XCTest.swift

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ extension AsyncAwaitEndToEndTests {
4040
("testImmediateDeadline", testImmediateDeadline),
4141
("testConnectTimeout", testConnectTimeout),
4242
("testSelfSignedCertificateIsRejectedWithCorrectErrorIfRequestDeadlineIsExceeded", testSelfSignedCertificateIsRejectedWithCorrectErrorIfRequestDeadlineIsExceeded),
43+
("testDnsOverride", testDnsOverride),
4344
("testInvalidURL", testInvalidURL),
4445
("testRedirectChangesHostHeader", testRedirectChangesHostHeader),
4546
("testShutdown", testShutdown),

Tests/AsyncHTTPClientTests/AsyncAwaitEndToEndTests.swift

+58
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,64 @@ final class AsyncAwaitEndToEndTests: XCTestCase {
492492
}
493493
}
494494

495+
func testDnsOverride() {
496+
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
497+
XCTAsyncTest(timeout: 5) {
498+
/// key + cert was created with the following code (depends on swift-certificates)
499+
/// ```
500+
/// let privateKey = P384.Signing.PrivateKey()
501+
/// let name = try DistinguishedName {
502+
/// OrganizationName("Self Signed")
503+
/// CommonName("localhost")
504+
/// }
505+
/// let certificate = try Certificate(
506+
/// version: .v3,
507+
/// serialNumber: .init(),
508+
/// publicKey: .init(privateKey.publicKey),
509+
/// notValidBefore: Date(),
510+
/// notValidAfter: Date() + .days(365),
511+
/// issuer: name,
512+
/// subject: name,
513+
/// signatureAlgorithm: .ecdsaWithSHA384,
514+
/// extensions: try .init {
515+
/// SubjectAlternativeNames([.dnsName("example.com")])
516+
/// ExtendedKeyUsage([.serverAuth])
517+
/// },
518+
/// issuerPrivateKey: .init(privateKey)
519+
/// )
520+
/// ```
521+
let certPath = Bundle.module.path(forResource: "example.com.cert", ofType: "pem")!
522+
let keyPath = Bundle.module.path(forResource: "example.com.private-key", ofType: "pem")!
523+
let localhostCert = try NIOSSLCertificate.fromPEMFile(certPath)
524+
let configuration = TLSConfiguration.makeServerConfiguration(
525+
certificateChain: localhostCert.map { .certificate($0) },
526+
privateKey: .file(keyPath)
527+
)
528+
let bin = HTTPBin(.http2(tlsConfiguration: configuration))
529+
defer { XCTAssertNoThrow(try bin.shutdown()) }
530+
531+
var config = HTTPClient.Configuration()
532+
.enableFastFailureModeForTesting()
533+
var tlsConfig = TLSConfiguration.makeClientConfiguration()
534+
535+
tlsConfig.trustRoots = .certificates(localhostCert)
536+
config.tlsConfiguration = tlsConfig
537+
// this is the actual configuration under test
538+
config.dnsOverride = ["example.com": "localhost"]
539+
540+
let localClient = HTTPClient(eventLoopGroupProvider: .createNew, configuration: config)
541+
defer { XCTAssertNoThrow(try localClient.syncShutdown()) }
542+
let request = HTTPClientRequest(url: "https://example.com:\(bin.port)/echohostheader")
543+
let response = await XCTAssertNoThrowWithResult(try await localClient.execute(request, deadline: .now() + .seconds(2)))
544+
XCTAssertEqual(response?.status, .ok)
545+
XCTAssertEqual(response?.version, .http2)
546+
var body = try await response?.body.collect(upTo: 1024)
547+
let readableBytes = body?.readableBytes ?? 0
548+
let responseInfo = try body?.readJSONDecodable(RequestInfo.self, length: readableBytes)
549+
XCTAssertEqual(responseInfo?.data, "example.com\(bin.port == 443 ? "" : ":\(bin.port)")")
550+
}
551+
}
552+
495553
func testInvalidURL() {
496554
guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return }
497555
XCTAsyncTest(timeout: 5) {

Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ class HTTPClientNIOTSTests: XCTestCase {
165165
var tlsConfig = TLSConfiguration.makeClientConfiguration()
166166
tlsConfig.trustRoots = .file("not/a/certificate")
167167

168-
XCTAssertThrowsError(try tlsConfig.getNWProtocolTLSOptions()) { error in
168+
XCTAssertThrowsError(try tlsConfig.getNWProtocolTLSOptions(serverNameIndicatorOverride: nil)) { error in
169169
switch error {
170170
case let error as NIOSSL.NIOSSLError where error == .failedToLoadCertificate:
171171
break

0 commit comments

Comments
 (0)