diff --git a/Sources/AsyncHTTPClient/ConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool.swift index 8845f9709..1dccffa14 100644 --- a/Sources/AsyncHTTPClient/ConnectionPool.swift +++ b/Sources/AsyncHTTPClient/ConnectionPool.swift @@ -17,7 +17,6 @@ import Logging import NIO import NIOConcurrencyHelpers import NIOHTTP1 -import NIOHTTPCompression import NIOSSL import NIOTLS import NIOTransportServices @@ -86,7 +85,9 @@ final class ConnectionPool { let provider = HTTP1ConnectionProvider(key: key, eventLoop: taskEventLoop, configuration: key.config(overriding: self.configuration), + tlsConfiguration: request.tlsConfiguration, pool: self, + sslContextCache: self.sslContextCache, backgroundActivityLogger: self.backgroundActivityLogger) let enqueued = provider.enqueue() assert(enqueued) @@ -213,6 +214,8 @@ class HTTP1ConnectionProvider { private let backgroundActivityLogger: Logger + private let factory: HTTPConnectionPool.ConnectionFactory + /// Creates a new `HTTP1ConnectionProvider` /// /// - parameters: @@ -225,7 +228,9 @@ class HTTP1ConnectionProvider { init(key: ConnectionPool.Key, eventLoop: EventLoop, configuration: HTTPClient.Configuration, + tlsConfiguration: TLSConfiguration?, pool: ConnectionPool, + sslContextCache: SSLContextCache, backgroundActivityLogger: Logger) { self.eventLoop = eventLoop self.configuration = configuration @@ -234,6 +239,13 @@ class HTTP1ConnectionProvider { self.closePromise = eventLoop.makePromise() self.state = .init(eventLoop: eventLoop) self.backgroundActivityLogger = backgroundActivityLogger + + self.factory = HTTPConnectionPool.ConnectionFactory( + key: self.key, + tlsConfiguration: tlsConfiguration, + clientConfiguration: self.configuration, + sslContextCache: sslContextCache + ) } deinit { @@ -440,12 +452,15 @@ class HTTP1ConnectionProvider { private func makeChannel(preference: HTTPClient.EventLoopPreference, logger: Logger) -> EventLoopFuture { - return NIOClientTCPBootstrap.makeHTTP1Channel(destination: self.key, - eventLoop: self.eventLoop, - configuration: self.configuration, - sslContextCache: self.pool.sslContextCache, - preference: preference, - logger: logger) + let connectionID = HTTPConnectionPool.Connection.ID.globalGenerator.next() + let eventLoop = preference.bestEventLoop ?? self.eventLoop + let deadline = .now() + self.configuration.timeout.connectionCreationTimeout + return self.factory.makeHTTP1Channel( + connectionID: connectionID, + deadline: deadline, + eventLoop: eventLoop, + logger: logger + ) } /// A `Waiter` represents a request that waits for a connection when none is diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/HTTP1ProxyConnectHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/HTTP1ProxyConnectHandler.swift new file mode 100644 index 000000000..9fadda485 --- /dev/null +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/HTTP1ProxyConnectHandler.swift @@ -0,0 +1,199 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIO +import NIOHTTP1 + +final class HTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHandler { + typealias OutboundIn = Never + typealias OutboundOut = HTTPClientRequestPart + typealias InboundIn = HTTPClientResponsePart + + enum State { + // transitions to `.connectSent` or `.failed` + case initialized + // transitions to `.headReceived` or `.failed` + case connectSent(Scheduled) + // transitions to `.completed` or `.failed` + case headReceived(Scheduled) + // final error state + case failed(Error) + // final success state + case completed + } + + private var state: State = .initialized + + private let targetHost: String + private let targetPort: Int + private let proxyAuthorization: HTTPClient.Authorization? + private let deadline: NIODeadline + + private var proxyEstablishedPromise: EventLoopPromise? + var proxyEstablishedFuture: EventLoopFuture? { + return self.proxyEstablishedPromise?.futureResult + } + + init(targetHost: String, + targetPort: Int, + proxyAuthorization: HTTPClient.Authorization?, + deadline: NIODeadline) { + self.targetHost = targetHost + self.targetPort = targetPort + self.proxyAuthorization = proxyAuthorization + self.deadline = deadline + } + + func handlerAdded(context: ChannelHandlerContext) { + self.proxyEstablishedPromise = context.eventLoop.makePromise(of: Void.self) + + self.sendConnect(context: context) + } + + func handlerRemoved(context: ChannelHandlerContext) { + switch self.state { + case .failed, .completed: + break + case .initialized, .connectSent, .headReceived: + struct NoResult: Error {} + self.state = .failed(NoResult()) + self.proxyEstablishedPromise?.fail(NoResult()) + } + } + + func channelActive(context: ChannelHandlerContext) { + self.sendConnect(context: context) + } + + func channelInactive(context: ChannelHandlerContext) { + switch self.state { + case .initialized: + preconditionFailure("How can we receive a channelInactive before a channelActive?") + case .connectSent(let timeout), .headReceived(let timeout): + timeout.cancel() + self.failWithError(HTTPClientError.remoteConnectionClosed, context: context, closeConnection: false) + + case .failed, .completed: + break + } + } + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + preconditionFailure("We don't support outgoing traffic during HTTP Proxy update.") + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch self.unwrapInboundIn(data) { + case .head(let head): + self.handleHTTPHeadReceived(head, context: context) + case .body: + self.handleHTTPBodyReceived(context: context) + case .end: + self.handleHTTPEndReceived(context: context) + } + } + + private func sendConnect(context: ChannelHandlerContext) { + guard case .initialized = self.state else { + // we might run into this handler twice, once in handlerAdded and once in channelActive. + return + } + + let timeout = context.eventLoop.scheduleTask(deadline: self.deadline) { + switch self.state { + case .initialized: + preconditionFailure("How can we have a scheduled timeout, if the connection is not even up?") + + case .connectSent, .headReceived: + self.failWithError(HTTPClientError.httpProxyHandshakeTimeout, context: context) + + case .failed, .completed: + break + } + } + + self.state = .connectSent(timeout) + + var head = HTTPRequestHead( + version: .init(major: 1, minor: 1), + method: .CONNECT, + uri: "\(self.targetHost):\(self.targetPort)" + ) + if let authorization = self.proxyAuthorization { + head.headers.replaceOrAdd(name: "proxy-authorization", value: authorization.headerValue) + } + context.write(self.wrapOutboundOut(.head(head)), promise: nil) + context.write(self.wrapOutboundOut(.end(nil)), promise: nil) + context.flush() + } + + private func handleHTTPHeadReceived(_ head: HTTPResponseHead, context: ChannelHandlerContext) { + guard case .connectSent(let scheduled) = self.state else { + preconditionFailure("HTTPDecoder should throw an error, if we have not send a request") + } + + switch head.status.code { + case 200..<300: + // Any 2xx (Successful) response indicates that the sender (and all + // inbound proxies) will switch to tunnel mode immediately after the + // blank line that concludes the successful response's header section + self.state = .headReceived(scheduled) + case 407: + self.failWithError(HTTPClientError.proxyAuthenticationRequired, context: context) + + default: + // Any response other than a successful response indicates that the tunnel + // has not yet been formed and that the connection remains governed by HTTP. + self.failWithError(HTTPClientError.invalidProxyResponse, context: context) + } + } + + private func handleHTTPBodyReceived(context: ChannelHandlerContext) { + switch self.state { + case .headReceived(let timeout): + timeout.cancel() + // we don't expect a body + self.failWithError(HTTPClientError.invalidProxyResponse, context: context) + case .failed: + // ran into an error before... ignore this one + break + case .completed, .connectSent, .initialized: + preconditionFailure("Invalid state: \(self.state)") + } + } + + private func handleHTTPEndReceived(context: ChannelHandlerContext) { + switch self.state { + case .headReceived(let timeout): + timeout.cancel() + self.state = .completed + self.proxyEstablishedPromise?.succeed(()) + + case .failed: + // ran into an error before... ignore this one + break + case .initialized, .connectSent, .completed: + preconditionFailure("Invalid state: \(self.state)") + } + } + + private func failWithError(_ error: Error, context: ChannelHandlerContext, closeConnection: Bool = true) { + self.state = .failed(error) + self.proxyEstablishedPromise?.fail(error) + context.fireErrorCaught(error) + if closeConnection { + context.close(mode: .all, promise: nil) + } + } +} diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SOCKSEventsHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SOCKSEventsHandler.swift new file mode 100644 index 000000000..b3164a49c --- /dev/null +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/SOCKSEventsHandler.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIO +import NIOSOCKS + +final class SOCKSEventsHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = NIOAny + + enum State { + // transitions to channelActive or failed + case initialized + // transitions to socksEstablished or failed + case channelActive(Scheduled) + // final success state + case socksEstablished + // final success state + case failed(Error) + } + + private var socksEstablishedPromise: EventLoopPromise? + var socksEstablishedFuture: EventLoopFuture? { + return self.socksEstablishedPromise?.futureResult + } + + private let deadline: NIODeadline + private var state: State = .initialized + + init(deadline: NIODeadline) { + self.deadline = deadline + } + + func handlerAdded(context: ChannelHandlerContext) { + self.socksEstablishedPromise = context.eventLoop.makePromise(of: Void.self) + + if context.channel.isActive { + self.connectionStarted(context: context) + } + } + + func handlerRemoved(context: ChannelHandlerContext) { + struct NoResult: Error {} + self.socksEstablishedPromise!.fail(NoResult()) + } + + func channelActive(context: ChannelHandlerContext) { + self.connectionStarted(context: context) + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + guard event is SOCKSProxyEstablishedEvent else { + return context.fireUserInboundEventTriggered(event) + } + + switch self.state { + case .initialized: + preconditionFailure("How can we establish a SOCKS connection, if we are not connected?") + case .socksEstablished: + preconditionFailure("`SOCKSProxyEstablishedEvent` must only be fired once.") + case .channelActive(let scheduled): + self.state = .socksEstablished + scheduled.cancel() + self.socksEstablishedPromise?.succeed(()) + context.fireUserInboundEventTriggered(event) + case .failed: + // potentially a race with the timeout... + break + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + switch self.state { + case .initialized: + self.state = .failed(error) + self.socksEstablishedPromise?.fail(error) + case .channelActive(let scheduled): + scheduled.cancel() + self.state = .failed(error) + self.socksEstablishedPromise?.fail(error) + case .socksEstablished, .failed: + break + } + context.fireErrorCaught(error) + } + + private func connectionStarted(context: ChannelHandlerContext) { + guard case .initialized = self.state else { + return + } + + let scheduled = context.eventLoop.scheduleTask(deadline: self.deadline) { + switch self.state { + case .initialized, .channelActive: + // close the connection, if the handshake timed out + context.close(mode: .all, promise: nil) + let error = HTTPClientError.socksHandshakeTimeout + self.state = .failed(error) + self.socksEstablishedPromise?.fail(error) + case .failed, .socksEstablished: + break + } + } + + self.state = .channelActive(scheduled) + } +} diff --git a/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/TLSEventsHandler.swift b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/TLSEventsHandler.swift new file mode 100644 index 000000000..3d909d33a --- /dev/null +++ b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/TLSEventsHandler.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIO +import NIOTLS + +final class TLSEventsHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = NIOAny + + enum State { + // transitions to channelActive or failed + case initialized + // transitions to tlsEstablished or failed + case channelActive(Scheduled?) + // final success state + case tlsEstablished + // final success state + case failed(Error) + } + + private var tlsEstablishedPromise: EventLoopPromise? + var tlsEstablishedFuture: EventLoopFuture? { + return self.tlsEstablishedPromise?.futureResult + } + + private let deadline: NIODeadline? + private var state: State = .initialized + + init(deadline: NIODeadline?) { + self.deadline = deadline + } + + func handlerAdded(context: ChannelHandlerContext) { + self.tlsEstablishedPromise = context.eventLoop.makePromise(of: String?.self) + + if context.channel.isActive { + self.connectionStarted(context: context) + } + } + + func handlerRemoved(context: ChannelHandlerContext) { + struct NoResult: Error {} + self.tlsEstablishedPromise!.fail(NoResult()) + } + + func channelActive(context: ChannelHandlerContext) { + self.connectionStarted(context: context) + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + guard let tlsEvent = event as? TLSUserEvent else { + return context.fireUserInboundEventTriggered(event) + } + + switch tlsEvent { + case .handshakeCompleted(negotiatedProtocol: let negotiated): + switch self.state { + case .initialized: + preconditionFailure("How can we establish a TLS connection, if we are not connected?") + case .channelActive(let scheduled): + self.state = .tlsEstablished + scheduled?.cancel() + self.tlsEstablishedPromise?.succeed(negotiated) + context.fireUserInboundEventTriggered(event) + case .tlsEstablished, .failed: + // potentially a race with the timeout... + break + } + case .shutdownCompleted: + break + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + switch self.state { + case .initialized: + self.state = .failed(error) + self.tlsEstablishedPromise?.fail(error) + case .channelActive(let scheduled): + scheduled?.cancel() + self.state = .failed(error) + self.tlsEstablishedPromise?.fail(error) + case .tlsEstablished, .failed: + break + } + context.fireErrorCaught(error) + } + + private func connectionStarted(context: ChannelHandlerContext) { + guard case .initialized = self.state else { + return + } + + var scheduled: Scheduled? + if let deadline = deadline { + scheduled = context.eventLoop.scheduleTask(deadline: deadline) { + switch self.state { + case .initialized, .channelActive: + // close the connection, if the handshake timed out + context.close(mode: .all, promise: nil) + let error = HTTPClientError.tlsHandshakeTimeout + self.state = .failed(error) + self.tlsEstablishedPromise?.fail(error) + case .failed, .tlsEstablished: + break + } + } + } + + self.state = .channelActive(scheduled) + } +} diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift new file mode 100644 index 000000000..42b1fb2c2 --- /dev/null +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Factory.swift @@ -0,0 +1,435 @@ +//===----------------------------------------------------------------------===// +// +// 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 Logging +import NIO +import NIOHTTP1 +import NIOHTTPCompression +import NIOSOCKS +import NIOSSL +import NIOTLS +#if canImport(Network) + import NIOTransportServices +#endif + +extension HTTPConnectionPool { + enum NegotiatedProtocol { + case http1_1(Channel) + case http2_0(Channel) + } + + struct ConnectionFactory { + let key: ConnectionPool.Key + let clientConfiguration: HTTPClient.Configuration + let tlsConfiguration: TLSConfiguration + let sslContextCache: SSLContextCache + + init(key: ConnectionPool.Key, + tlsConfiguration: TLSConfiguration?, + clientConfiguration: HTTPClient.Configuration, + sslContextCache: SSLContextCache) { + self.key = key + self.clientConfiguration = clientConfiguration + self.sslContextCache = sslContextCache + self.tlsConfiguration = tlsConfiguration ?? clientConfiguration.tlsConfiguration ?? .makeClientConfiguration() + } + } +} + +extension HTTPConnectionPool.ConnectionFactory { + func makeHTTP1Channel( + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop, + logger: Logger + ) -> EventLoopFuture { + self.makeChannel( + connectionID: connectionID, + deadline: deadline, + eventLoop: eventLoop, + logger: logger + ).flatMapThrowing { + (channel, _) -> Channel in + + // add the http1.1 channel handlers + let syncOperations = channel.pipeline.syncOperations + try syncOperations.addHTTPClientHandlers(leftOverBytesStrategy: .forwardBytes) + + switch self.clientConfiguration.decompression { + case .disabled: + () + case .enabled(let limit): + let decompressHandler = NIOHTTPResponseDecompressor(limit: limit) + try syncOperations.addHandler(decompressHandler) + } + + return channel + } + } + + func makeChannel( + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop, + logger: Logger + ) -> EventLoopFuture<(Channel, HTTPVersion)> { + let channelFuture: EventLoopFuture<(Channel, HTTPVersion)> + + if self.key.scheme.isProxyable, let proxy = self.clientConfiguration.proxy { + switch proxy.type { + case .socks: + channelFuture = self.makeSOCKSProxyChannel( + proxy, + connectionID: connectionID, + deadline: deadline, + eventLoop: eventLoop, + logger: logger + ) + case .http: + channelFuture = self.makeHTTPProxyChannel( + proxy, + connectionID: connectionID, + deadline: deadline, + eventLoop: eventLoop, + logger: logger + ) + } + } else { + channelFuture = self.makeNonProxiedChannel(deadline: deadline, eventLoop: eventLoop, logger: logger) + } + + // let's map `ChannelError.connectTimeout` into a `HTTPClientError.connectTimeout` + return channelFuture.flatMapErrorThrowing { error throws -> (Channel, HTTPVersion) in + switch error { + case ChannelError.connectTimeout: + throw HTTPClientError.connectTimeout + default: + throw error + } + } + } + + private func makeNonProxiedChannel( + deadline: NIODeadline, + eventLoop: EventLoop, + logger: Logger + ) -> EventLoopFuture<(Channel, HTTPVersion)> { + switch self.key.scheme { + case .http, .http_unix, .unix: + return self.makePlainChannel(deadline: deadline, eventLoop: eventLoop).map { ($0, .http1_1) } + case .https, .https_unix: + return self.makeTLSChannel(deadline: deadline, eventLoop: eventLoop, logger: logger).flatMapThrowing { + channel, negotiated in + + (channel, try self.matchALPNToHTTPVersion(negotiated)) + } + } + } + + private func makePlainChannel(deadline: NIODeadline, eventLoop: EventLoop) -> EventLoopFuture { + let bootstrap = self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop) + + switch self.key.scheme { + case .http: + return bootstrap.connect(host: self.key.host, port: self.key.port) + case .http_unix, .unix: + return bootstrap.connect(unixDomainSocketPath: self.key.unixPath) + case .https, .https_unix: + preconditionFailure("Unexpected scheme") + } + } + + private func makeHTTPProxyChannel( + _ proxy: HTTPClient.Configuration.Proxy, + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop, + logger: Logger + ) -> EventLoopFuture<(Channel, HTTPVersion)> { + // A proxy connection starts with a plain text connection to the proxy server. After + // the connection has been established with the proxy server, the connection might be + // upgraded to TLS before we send our first request. + let bootstrap = self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop) + return bootstrap.connect(host: proxy.host, port: proxy.port).flatMap { channel in + let encoder = HTTPRequestEncoder() + let decoder = ByteToMessageHandler(HTTPResponseDecoder(leftOverBytesStrategy: .dropBytes)) + let proxyHandler = HTTP1ProxyConnectHandler( + targetHost: self.key.host, + targetPort: self.key.port, + proxyAuthorization: proxy.authorization, + deadline: deadline + ) + + do { + try channel.pipeline.syncOperations.addHandler(encoder) + try channel.pipeline.syncOperations.addHandler(decoder) + try channel.pipeline.syncOperations.addHandler(proxyHandler) + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + + // The proxyEstablishedFuture is set as soon as the HTTP1ProxyConnectHandler is in a + // pipeline. It is created in HTTP1ProxyConnectHandler's handlerAdded method. + return proxyHandler.proxyEstablishedFuture!.flatMap { + channel.pipeline.removeHandler(proxyHandler).flatMap { + channel.pipeline.removeHandler(decoder).flatMap { + channel.pipeline.removeHandler(encoder) + } + } + }.flatMap { + self.setupTLSInProxyConnectionIfNeeded(channel, deadline: deadline, logger: logger) + } + } + } + + private func makeSOCKSProxyChannel( + _ proxy: HTTPClient.Configuration.Proxy, + connectionID: HTTPConnectionPool.Connection.ID, + deadline: NIODeadline, + eventLoop: EventLoop, + logger: Logger + ) -> EventLoopFuture<(Channel, HTTPVersion)> { + // A proxy connection starts with a plain text connection to the proxy server. After + // the connection has been established with the proxy server, the connection might be + // upgraded to TLS before we send our first request. + let bootstrap = self.makePlainBootstrap(deadline: deadline, eventLoop: eventLoop) + return bootstrap.connect(host: proxy.host, port: proxy.port).flatMap { channel in + let socksConnectHandler = SOCKSClientHandler(targetAddress: .domain(self.key.host, port: self.key.port)) + let socksEventHandler = SOCKSEventsHandler(deadline: deadline) + + do { + try channel.pipeline.syncOperations.addHandler(socksConnectHandler) + try channel.pipeline.syncOperations.addHandler(socksEventHandler) + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + + // The socksEstablishedFuture is set as soon as the SOCKSEventsHandler is in a + // pipeline. It is created in SOCKSEventsHandler's handlerAdded method. + return socksEventHandler.socksEstablishedFuture!.flatMap { + channel.pipeline.removeHandler(socksEventHandler).flatMap { + channel.pipeline.removeHandler(socksConnectHandler) + } + }.flatMap { + self.setupTLSInProxyConnectionIfNeeded(channel, deadline: deadline, logger: logger) + } + } + } + + private func setupTLSInProxyConnectionIfNeeded( + _ channel: Channel, + deadline: NIODeadline, + logger: Logger + ) -> EventLoopFuture<(Channel, HTTPVersion)> { + switch self.key.scheme { + case .unix, .http_unix, .https_unix: + preconditionFailure("Unexpected scheme. Not supported for proxy!") + case .http: + return channel.eventLoop.makeSucceededFuture((channel, .http1_1)) + case .https: + var tlsConfig = self.tlsConfiguration + // since we can support h2, we need to advertise this in alpn + tlsConfig.applicationProtocols = ["http/1.1" /* , "h2" */ ] + let tlsEventHandler = TLSEventsHandler(deadline: deadline) + + let sslContextFuture = self.sslContextCache.sslContext( + tlsConfiguration: tlsConfig, + eventLoop: channel.eventLoop, + logger: logger + ) + + return sslContextFuture.flatMap { sslContext -> EventLoopFuture in + do { + let sslHandler = try NIOSSLClientHandler( + context: sslContext, + serverHostname: self.key.host + ) + try channel.pipeline.syncOperations.addHandler(sslHandler) + try channel.pipeline.syncOperations.addHandler(tlsEventHandler) + + // The tlsEstablishedFuture is set as soon as the TLSEventsHandler is in a + // pipeline. It is created in TLSEventsHandler's handlerAdded method. + return tlsEventHandler.tlsEstablishedFuture! + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + }.flatMap { negotiated -> EventLoopFuture<(Channel, HTTPVersion)> in + channel.pipeline.removeHandler(tlsEventHandler).flatMapThrowing { + (channel, try self.matchALPNToHTTPVersion(negotiated)) + } + } + } + } + + private func makePlainBootstrap(deadline: NIODeadline, eventLoop: EventLoop) -> NIOClientTCPBootstrapProtocol { + #if canImport(Network) + if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop) { + return tsBootstrap + .connectTimeout(deadline - NIODeadline.now()) + .channelInitializer { channel in + do { + try channel.pipeline.syncOperations.addHandler(HTTPClient.NWErrorHandler()) + return channel.eventLoop.makeSucceededVoidFuture() + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } + } + #endif + + if let nioBootstrap = ClientBootstrap(validatingGroup: eventLoop) { + return nioBootstrap + .connectTimeout(deadline - NIODeadline.now()) + } + + preconditionFailure("No matching bootstrap found") + } + + private func makeTLSChannel(deadline: NIODeadline, eventLoop: EventLoop, logger: Logger) -> EventLoopFuture<(Channel, String?)> { + let bootstrapFuture = self.makeTLSBootstrap( + deadline: deadline, + eventLoop: eventLoop, + logger: logger + ) + + var channelFuture = bootstrapFuture.flatMap { bootstrap -> EventLoopFuture in + switch self.key.scheme { + case .https: + return bootstrap.connect(host: self.key.host, port: self.key.port) + case .https_unix: + return bootstrap.connect(unixDomainSocketPath: self.key.unixPath) + case .http, .http_unix, .unix: + preconditionFailure("Unexpected scheme") + } + }.flatMap { channel -> EventLoopFuture<(Channel, String?)> in + // It is save to use `try!` here, since we are sure, that a `TLSEventsHandler` exists + // within the pipeline. It is added in `makeTLSBootstrap`. + let tlsEventHandler = try! channel.pipeline.syncOperations.handler(type: TLSEventsHandler.self) + + // The tlsEstablishedFuture is set as soon as the TLSEventsHandler is in a + // pipeline. It is created in TLSEventsHandler's handlerAdded method. + return tlsEventHandler.tlsEstablishedFuture!.flatMap { negotiated in + channel.pipeline.removeHandler(tlsEventHandler).map { (channel, negotiated) } + } + } + + #if canImport(Network) + // If NIOTransportSecurity is used, we want to map NWErrors into NWPOsixErrors or NWTLSError. + channelFuture = channelFuture.flatMapErrorThrowing { error in + throw HTTPClient.NWErrorHandler.translateError(error) + } + #endif + + return channelFuture + } + + private func makeTLSBootstrap(deadline: NIODeadline, eventLoop: EventLoop, logger: Logger) + -> EventLoopFuture { + // since we can support h2, we need to advertise this in alpn + var tlsConfig = self.tlsConfiguration + tlsConfig.applicationProtocols = ["http/1.1" /* , "h2" */ ] + + #if canImport(Network) + if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop) { + // create NIOClientTCPBootstrap with NIOTS TLS provider + let bootstrapFuture = tlsConfig.getNWProtocolTLSOptions(on: eventLoop).map { + options -> NIOClientTCPBootstrapProtocol in + + tsBootstrap + .connectTimeout(deadline - NIODeadline.now()) + .tlsOptions(options) + .channelInitializer { channel in + do { + try channel.pipeline.syncOperations.addHandler(HTTPClient.NWErrorHandler()) + // we don't need to set a TLS deadline for NIOTS connections, since the + // TLS handshake is part of the TS connection bootstrap. If the TLS + // handshake times out the complete connection creation will be failed. + try channel.pipeline.syncOperations.addHandler(TLSEventsHandler(deadline: nil)) + return channel.eventLoop.makeSucceededVoidFuture() + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } as NIOClientTCPBootstrapProtocol + } + return bootstrapFuture + } + #endif + + let host = self.key.host + let hostname = (host.isIPAddress || host.isEmpty) ? nil : host + + let sslContextFuture = sslContextCache.sslContext( + tlsConfiguration: tlsConfig, + eventLoop: eventLoop, + logger: logger + ) + + let bootstrap = ClientBootstrap(group: eventLoop) + .connectTimeout(deadline - NIODeadline.now()) + .channelInitializer { channel in + sslContextFuture.flatMap { (sslContext) -> EventLoopFuture in + do { + let sync = channel.pipeline.syncOperations + let sslHandler = try NIOSSLClientHandler( + context: sslContext, + serverHostname: hostname + ) + let tlsEventHandler = TLSEventsHandler(deadline: deadline) + + try sync.addHandler(sslHandler) + try sync.addHandler(tlsEventHandler) + return channel.eventLoop.makeSucceededVoidFuture() + } catch { + return channel.eventLoop.makeFailedFuture(error) + } + } + } + + return eventLoop.makeSucceededFuture(bootstrap) + } + + private func matchALPNToHTTPVersion(_ negotiated: String?) throws -> HTTPVersion { + switch negotiated { + case .none, .some("http/1.1"): + return .http1_1 + case .some("h2"): + return .http2 + case .some(let unsupported): + throw HTTPClientError.serverOfferedUnsupportedApplicationProtocol(unsupported) + } + } +} + +extension ConnectionPool.Key.Scheme { + var isProxyable: Bool { + switch self { + case .http, .https: + return true + case .unix, .http_unix, .https_unix: + return false + } + } +} + +private extension String { + var isIPAddress: Bool { + var ipv4Addr = in_addr() + var ipv6Addr = in6_addr() + + return self.withCString { ptr in + inet_pton(AF_INET, ptr, &ipv4Addr) == 1 || + inet_pton(AF_INET6, ptr, &ipv6Addr) == 1 + } + } +} diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Manager.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Manager.swift new file mode 100644 index 000000000..582f97527 --- /dev/null +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool+Manager.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOConcurrencyHelpers + +extension HTTPConnectionPool.Connection.ID { + static var globalGenerator = Generator() + + struct Generator { + private let atomic: NIOAtomic + + init() { + self.atomic = .makeAtomic(value: 0) + } + + func next() -> Int { + return self.atomic.add(1) + } + } +} diff --git a/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift new file mode 100644 index 000000000..e040108e6 --- /dev/null +++ b/Sources/AsyncHTTPClient/ConnectionPool/HTTPConnectionPool.swift @@ -0,0 +1,19 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +enum HTTPConnectionPool { + struct Connection { + typealias ID = Int + } +} diff --git a/Sources/AsyncHTTPClient/HTTPClient+Proxy.swift b/Sources/AsyncHTTPClient/HTTPClient+Proxy.swift new file mode 100644 index 000000000..e2f7fea84 --- /dev/null +++ b/Sources/AsyncHTTPClient/HTTPClient+Proxy.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2019 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 NIO +import NIOHTTP1 + +extension HTTPClient.Configuration { + /// Proxy server configuration + /// Specifies the remote address of an HTTP proxy. + /// + /// Adding an `Proxy` to your client's `HTTPClient.Configuration` + /// will cause requests to be passed through the specified proxy using the + /// HTTP `CONNECT` method. + /// + /// If a `TLSConfiguration` is used in conjunction with `HTTPClient.Configuration.Proxy`, + /// TLS will be established _after_ successful proxy, between your client + /// and the destination server. + public struct Proxy { + enum ProxyType: Hashable { + case http(HTTPClient.Authorization?) + case socks + } + + /// Specifies Proxy server host. + public var host: String + /// Specifies Proxy server port. + public var port: Int + /// Specifies Proxy server authorization. + public var authorization: HTTPClient.Authorization? { + set { + precondition(self.type == .http(self.authorization), "SOCKS authorization support is not yet implemented.") + self.type = .http(newValue) + } + + get { + switch self.type { + case .http(let authorization): + return authorization + case .socks: + return nil + } + } + } + + var type: ProxyType + + /// Create a HTTP proxy. + /// + /// - parameters: + /// - host: proxy server host. + /// - port: proxy server port. + public static func server(host: String, port: Int) -> Proxy { + return .init(host: host, port: port, type: .http(nil)) + } + + /// Create a HTTP proxy. + /// + /// - parameters: + /// - host: proxy server host. + /// - port: proxy server port. + /// - authorization: proxy server authorization. + public static func server(host: String, port: Int, authorization: HTTPClient.Authorization? = nil) -> Proxy { + return .init(host: host, port: port, type: .http(authorization)) + } + + /// Create a SOCKSv5 proxy. + /// - parameter host: The SOCKSv5 proxy address. + /// - parameter port: The SOCKSv5 proxy port, defaults to 1080. + /// - returns: A new instance of `Proxy` configured to connect to a `SOCKSv5` server. + public static func socksServer(host: String, port: Int = 1080) -> Proxy { + return .init(host: host, port: port, type: .socks) + } + } +} diff --git a/Sources/AsyncHTTPClient/HTTPClient.swift b/Sources/AsyncHTTPClient/HTTPClient.swift index 850abc415..9400930b9 100644 --- a/Sources/AsyncHTTPClient/HTTPClient.swift +++ b/Sources/AsyncHTTPClient/HTTPClient.swift @@ -833,10 +833,16 @@ extension HTTPClient.Configuration { /// Specifies read timeout. public var read: TimeAmount? + /// internal connection creation timeout. Defaults the connect timeout to always contain a value. + var connectionCreationTimeout: TimeAmount { + self.connect ?? .seconds(10) + } + /// Create timeout. /// /// - parameters: - /// - connect: `connect` timeout. + /// - connect: `connect` timeout. Will default to 10 seconds, if no value is + /// provided. See `var connectionCreationTimeout` /// - read: `read` timeout. public init(connect: TimeAmount? = nil, read: TimeAmount? = nil) { self.connect = connect @@ -887,90 +893,6 @@ extension HTTPClient.Configuration { } } -extension ChannelPipeline { - func syncAddHTTPProxyHandler(host: String, port: Int, authorization: HTTPClient.Authorization?) throws { - let encoder = HTTPRequestEncoder() - let decoder = ByteToMessageHandler(HTTPResponseDecoder(leftOverBytesStrategy: .forwardBytes)) - let handler = HTTPClientProxyHandler(host: host, port: port, authorization: authorization) { channel in - let encoderRemovePromise = self.eventLoop.next().makePromise(of: Void.self) - channel.pipeline.removeHandler(encoder, promise: encoderRemovePromise) - return encoderRemovePromise.futureResult.flatMap { - channel.pipeline.removeHandler(decoder) - } - } - - let sync = self.syncOperations - try sync.addHandler(encoder) - try sync.addHandler(decoder) - try sync.addHandler(handler) - } - - func syncAddSOCKSProxyHandler(host: String, port: Int) throws { - let handler = SOCKSClientHandler(targetAddress: .domain(host, port: port)) - let sync = self.syncOperations - try sync.addHandler(handler) - } - - func syncAddLateSSLHandlerIfNeeded(for key: ConnectionPool.Key, - sslContext: NIOSSLContext, - handshakePromise: EventLoopPromise) { - precondition(key.scheme.requiresTLS) - - do { - let synchronousPipelineView = self.syncOperations - - // We add the TLSEventsHandler first so that it's always in the pipeline before any other TLS handler we add. - // If we're here, we must not have one in the channel already. - assert((try? synchronousPipelineView.context(name: TLSEventsHandler.handlerName)) == nil) - let eventsHandler = TLSEventsHandler(completionPromise: handshakePromise) - try synchronousPipelineView.addHandler(eventsHandler, name: TLSEventsHandler.handlerName) - - // Then we add the SSL handler. - try synchronousPipelineView.addHandler( - try NIOSSLClientHandler(context: sslContext, - serverHostname: (key.host.isIPAddress || key.host.isEmpty) ? nil : key.host), - position: .before(eventsHandler) - ) - } catch { - handshakePromise.fail(error) - } - } -} - -class TLSEventsHandler: ChannelInboundHandler, RemovableChannelHandler { - typealias InboundIn = NIOAny - - static let handlerName: String = "AsyncHTTPClient.HTTPClient.TLSEventsHandler" - - var completionPromise: EventLoopPromise - - init(completionPromise: EventLoopPromise) { - self.completionPromise = completionPromise - } - - func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { - if let tlsEvent = event as? TLSUserEvent { - switch tlsEvent { - case .handshakeCompleted: - self.completionPromise.succeed(()) - case .shutdownCompleted: - break - } - } - context.fireUserInboundEventTriggered(event) - } - - func errorCaught(context: ChannelHandlerContext, error: Error) { - self.completionPromise.fail(error) - context.fireErrorCaught(error) - } - - func handlerRemoved(context: ChannelHandlerContext) { - struct NoResult: Error {} - self.completionPromise.fail(NoResult()) - } -} - /// Possible client errors. public struct HTTPClientError: Error, Equatable, CustomStringConvertible { private enum Code: Equatable { @@ -996,6 +918,11 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { case bodyLengthMismatch case writeAfterRequestSent case incompatibleHeaders + case connectTimeout + case socksHandshakeTimeout + case httpProxyHandshakeTimeout + case tlsHandshakeTimeout + case serverOfferedUnsupportedApplicationProtocol(String) } private var code: Code @@ -1052,4 +979,16 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible { public static let writeAfterRequestSent = HTTPClientError(code: .writeAfterRequestSent) /// Incompatible headers specified, for example `Transfer-Encoding` and `Content-Length`. public static let incompatibleHeaders = HTTPClientError(code: .incompatibleHeaders) + /// Creating a new tcp connection timed out + public static let connectTimeout = HTTPClientError(code: .connectTimeout) + /// The socks handshake timed out. + public static let socksHandshakeTimeout = HTTPClientError(code: .socksHandshakeTimeout) + /// The http proxy connection creation timed out. + public static let httpProxyHandshakeTimeout = HTTPClientError(code: .httpProxyHandshakeTimeout) + /// The tls handshake timed out. + public static let tlsHandshakeTimeout = HTTPClientError(code: .tlsHandshakeTimeout) + /// The remote server only offered an unsupported application protocol + public static func serverOfferedUnsupportedApplicationProtocol(_ proto: String) -> HTTPClientError { + return HTTPClientError(code: .serverOfferedUnsupportedApplicationProtocol(proto)) + } } diff --git a/Sources/AsyncHTTPClient/HTTPClientProxyHandler.swift b/Sources/AsyncHTTPClient/HTTPClientProxyHandler.swift deleted file mode 100644 index e2b891c8b..000000000 --- a/Sources/AsyncHTTPClient/HTTPClientProxyHandler.swift +++ /dev/null @@ -1,210 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the AsyncHTTPClient open source project -// -// Copyright (c) 2018-2019 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 NIO -import NIOHTTP1 -import NIOSOCKS - -extension HTTPClient.Configuration { - /// Proxy server configuration - /// Specifies the remote address of an HTTP proxy. - /// - /// Adding an `Proxy` to your client's `HTTPClient.Configuration` - /// will cause requests to be passed through the specified proxy using the - /// HTTP `CONNECT` method. - /// - /// If a `TLSConfiguration` is used in conjunction with `HTTPClient.Configuration.Proxy`, - /// TLS will be established _after_ successful proxy, between your client - /// and the destination server. - public struct Proxy { - enum ProxyType: Hashable { - case http(HTTPClient.Authorization?) - case socks - } - - /// Specifies Proxy server host. - public var host: String - /// Specifies Proxy server port. - public var port: Int - /// Specifies Proxy server authorization. - public var authorization: HTTPClient.Authorization? { - set { - precondition(self.type == .http(self.authorization), "SOCKS authorization support is not yet implemented.") - self.type = .http(newValue) - } - - get { - switch self.type { - case .http(let authorization): - return authorization - case .socks: - return nil - } - } - } - - var type: ProxyType - - /// Create a HTTP proxy. - /// - /// - parameters: - /// - host: proxy server host. - /// - port: proxy server port. - public static func server(host: String, port: Int) -> Proxy { - return .init(host: host, port: port, type: .http(nil)) - } - - /// Create a HTTP proxy. - /// - /// - parameters: - /// - host: proxy server host. - /// - port: proxy server port. - /// - authorization: proxy server authorization. - public static func server(host: String, port: Int, authorization: HTTPClient.Authorization? = nil) -> Proxy { - return .init(host: host, port: port, type: .http(authorization)) - } - - /// Create a SOCKSv5 proxy. - /// - parameter host: The SOCKSv5 proxy address. - /// - parameter port: The SOCKSv5 proxy port, defaults to 1080. - /// - returns: A new instance of `Proxy` configured to connect to a `SOCKSv5` server. - public static func socksServer(host: String, port: Int = 1080) -> Proxy { - return .init(host: host, port: port, type: .socks) - } - } -} - -internal final class HTTPClientProxyHandler: ChannelDuplexHandler, RemovableChannelHandler { - typealias InboundIn = HTTPClientResponsePart - typealias OutboundIn = HTTPClientRequestPart - typealias OutboundOut = HTTPClientRequestPart - - enum WriteItem { - case write(NIOAny, EventLoopPromise?) - case flush - } - - enum ReadState { - case awaitingResponse - case connecting - case connected - case failed - } - - private let host: String - private let port: Int - private let authorization: HTTPClient.Authorization? - private var onConnect: (Channel) -> EventLoopFuture - private var writeBuffer: CircularBuffer - private var readBuffer: CircularBuffer - private var readState: ReadState - - init(host: String, port: Int, authorization: HTTPClient.Authorization?, onConnect: @escaping (Channel) -> EventLoopFuture) { - self.host = host - self.port = port - self.authorization = authorization - self.onConnect = onConnect - self.writeBuffer = .init() - self.readBuffer = .init() - self.readState = .awaitingResponse - } - - func channelRead(context: ChannelHandlerContext, data: NIOAny) { - switch self.readState { - case .awaitingResponse: - let res = self.unwrapInboundIn(data) - switch res { - case .head(let head): - switch head.status.code { - case 200..<300: - // Any 2xx (Successful) response indicates that the sender (and all - // inbound proxies) will switch to tunnel mode immediately after the - // blank line that concludes the successful response's header section - break - case 407: - self.readState = .failed - context.fireErrorCaught(HTTPClientError.proxyAuthenticationRequired) - default: - // Any response other than a successful response - // indicates that the tunnel has not yet been formed and that the - // connection remains governed by HTTP. - context.fireErrorCaught(HTTPClientError.invalidProxyResponse) - } - case .end: - self.readState = .connecting - _ = self.handleConnect(context: context) - case .body: - break - } - case .connecting: - self.readBuffer.append(data) - case .connected: - context.fireChannelRead(data) - case .failed: - break - } - } - - func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { - self.writeBuffer.append(.write(data, promise)) - } - - func flush(context: ChannelHandlerContext) { - self.writeBuffer.append(.flush) - } - - func channelActive(context: ChannelHandlerContext) { - self.sendConnect(context: context) - context.fireChannelActive() - } - - // MARK: Private - - private func handleConnect(context: ChannelHandlerContext) -> EventLoopFuture { - return self.onConnect(context.channel).flatMap { - self.readState = .connected - - // forward any buffered reads - while !self.readBuffer.isEmpty { - context.fireChannelRead(self.readBuffer.removeFirst()) - } - - // calls to context.write may be re-entrant - while !self.writeBuffer.isEmpty { - switch self.writeBuffer.removeFirst() { - case .flush: - context.flush() - case .write(let data, let promise): - context.write(data, promise: promise) - } - } - return context.pipeline.removeHandler(self) - } - } - - private func sendConnect(context: ChannelHandlerContext) { - var head = HTTPRequestHead( - version: .init(major: 1, minor: 1), - method: .CONNECT, - uri: "\(self.host):\(self.port)" - ) - head.headers.add(name: "proxy-connection", value: "keep-alive") - if let authorization = authorization { - head.headers.add(name: "proxy-authorization", value: authorization.headerValue) - } - context.write(self.wrapOutboundOut(.head(head)), promise: nil) - context.write(self.wrapOutboundOut(.end(nil)), promise: nil) - context.flush() - } -} diff --git a/Sources/AsyncHTTPClient/Utils.swift b/Sources/AsyncHTTPClient/Utils.swift index d2fc46532..9546b7f45 100644 --- a/Sources/AsyncHTTPClient/Utils.swift +++ b/Sources/AsyncHTTPClient/Utils.swift @@ -23,18 +23,6 @@ import NIOHTTPCompression import NIOSSL import NIOTransportServices -internal extension String { - var isIPAddress: Bool { - var ipv4Addr = in_addr() - var ipv6Addr = in6_addr() - - return self.withCString { ptr in - inet_pton(AF_INET, ptr, &ipv4Addr) == 1 || - inet_pton(AF_INET6, ptr, &ipv6Addr) == 1 - } - } -} - public final class HTTPClientCopyingDelegate: HTTPClientResponseDelegate { public typealias Response = Void @@ -53,217 +41,6 @@ public final class HTTPClientCopyingDelegate: HTTPClientResponseDelegate { } } -extension NIOClientTCPBootstrap { - static func makeHTTP1Channel(destination: ConnectionPool.Key, - eventLoop: EventLoop, - configuration: HTTPClient.Configuration, - sslContextCache: SSLContextCache, - preference: HTTPClient.EventLoopPreference, - logger: Logger) -> EventLoopFuture { - let channelEventLoop = preference.bestEventLoop ?? eventLoop - - let key = destination - let requiresTLS = key.scheme.requiresTLS - let sslContext: EventLoopFuture - if key.scheme.requiresTLS, configuration.proxy != nil { - // If we use a proxy & also require TLS, then we always use NIOSSL (and not Network.framework TLS because - // it can't be added later) and therefore require a `NIOSSLContext`. - // In this case, `makeAndConfigureBootstrap` will not create another `NIOSSLContext`. - // - // Note that TLS proxies are not supported at the moment. This means that we will always speak - // plaintext to the proxy but we do support sending HTTPS traffic through the proxy. - sslContext = sslContextCache.sslContext(tlsConfiguration: configuration.tlsConfiguration ?? .makeClientConfiguration(), - eventLoop: eventLoop, - logger: logger).map { $0 } - } else { - sslContext = eventLoop.makeSucceededFuture(nil) - } - - let bootstrap = NIOClientTCPBootstrap.makeAndConfigureBootstrap(on: channelEventLoop, - host: key.host, - port: key.port, - requiresTLS: requiresTLS, - configuration: configuration, - sslContextCache: sslContextCache, - logger: logger) - return bootstrap.flatMap { bootstrap -> EventLoopFuture in - let channel: EventLoopFuture - switch key.scheme { - case .http, .https: - let address = HTTPClient.resolveAddress(host: key.host, port: key.port, proxy: configuration.proxy) - channel = bootstrap.connect(host: address.host, port: address.port) - case .unix, .http_unix, .https_unix: - channel = bootstrap.connect(unixDomainSocketPath: key.unixPath) - } - - return channel.flatMap { channel -> EventLoopFuture<(Channel, NIOSSLContext?)> in - sslContext.map { sslContext -> (Channel, NIOSSLContext?) in - (channel, sslContext) - } - }.flatMap { channel, sslContext in - configureChannelPipeline(channel, - isNIOTS: bootstrap.isNIOTS, - sslContext: sslContext, - configuration: configuration, - key: key) - }.flatMapErrorThrowing { error in - if bootstrap.isNIOTS { - throw HTTPClient.NWErrorHandler.translateError(error) - } else { - throw error - } - } - } - } - - /// Creates and configures a bootstrap given the `eventLoop`, if TLS/a proxy is being used. - private static func makeAndConfigureBootstrap( - on eventLoop: EventLoop, - host: String, - port: Int, - requiresTLS: Bool, - configuration: HTTPClient.Configuration, - sslContextCache: SSLContextCache, - logger: Logger - ) -> EventLoopFuture { - return self.makeBestBootstrap(host: host, - eventLoop: eventLoop, - requiresTLS: requiresTLS, - sslContextCache: sslContextCache, - tlsConfiguration: configuration.tlsConfiguration ?? .makeClientConfiguration(), - useProxy: configuration.proxy != nil, - logger: logger) - .map { bootstrap -> NIOClientTCPBootstrap in - var bootstrap = bootstrap - - if let timeout = configuration.timeout.connect { - bootstrap = bootstrap.connectTimeout(timeout) - } - - // Don't enable TLS if we have a proxy, this will be enabled later on (outside of this method). - if requiresTLS, configuration.proxy == nil { - bootstrap = bootstrap.enableTLS() - } - - return bootstrap.channelInitializer { channel in - do { - if let proxy = configuration.proxy { - switch proxy.type { - case .http: - try channel.pipeline.syncAddHTTPProxyHandler(host: host, - port: port, - authorization: proxy.authorization) - case .socks: - try channel.pipeline.syncAddSOCKSProxyHandler(host: host, port: port) - } - } else if requiresTLS { - // We only add the handshake verifier if we need TLS and we're not going through a proxy. - // If we're going through a proxy we add it later (outside of this method). - let completionPromise = channel.eventLoop.makePromise(of: Void.self) - try channel.pipeline.syncOperations.addHandler(TLSEventsHandler(completionPromise: completionPromise), - name: TLSEventsHandler.handlerName) - } - return channel.eventLoop.makeSucceededVoidFuture() - } catch { - return channel.eventLoop.makeFailedFuture(error) - } - } - } - } - - /// Creates the best-suited bootstrap given an `EventLoop` and pairs it with an appropriate TLS provider. - private static func makeBestBootstrap( - host: String, - eventLoop: EventLoop, - requiresTLS: Bool, - sslContextCache: SSLContextCache, - tlsConfiguration: TLSConfiguration, - useProxy: Bool, - logger: Logger - ) -> EventLoopFuture { - #if canImport(Network) - // if eventLoop is compatible with NIOTransportServices create a NIOTSConnectionBootstrap - if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *), let tsBootstrap = NIOTSConnectionBootstrap(validatingGroup: eventLoop) { - // create NIOClientTCPBootstrap with NIOTS TLS provider - return tlsConfiguration.getNWProtocolTLSOptions(on: eventLoop) - .map { parameters in - let tlsProvider = NIOTSClientTLSProvider(tlsOptions: parameters) - return NIOClientTCPBootstrap(tsBootstrap, tls: tlsProvider) - } - } - #endif - - if let clientBootstrap = ClientBootstrap(validatingGroup: eventLoop) { - // If there is a proxy don't create TLS provider as it will be added at a later point. - if !requiresTLS || useProxy { - return eventLoop.makeSucceededFuture(NIOClientTCPBootstrap(clientBootstrap, - tls: NIOInsecureNoTLS())) - } else { - return sslContextCache.sslContext(tlsConfiguration: tlsConfiguration, - eventLoop: eventLoop, - logger: logger) - .flatMapThrowing { sslContext in - let hostname = (host.isIPAddress || host.isEmpty) ? nil : host - let tlsProvider = try NIOSSLClientTLSProvider(context: sslContext, serverHostname: hostname) - return NIOClientTCPBootstrap(clientBootstrap, tls: tlsProvider) - } - } - } - - preconditionFailure("Cannot create bootstrap for event loop \(eventLoop)") - } -} - -private func configureChannelPipeline(_ channel: Channel, - isNIOTS: Bool, - sslContext: NIOSSLContext?, - configuration: HTTPClient.Configuration, - key: ConnectionPool.Key) -> EventLoopFuture { - let requiresTLS = key.scheme.requiresTLS - let handshakeFuture: EventLoopFuture - - if requiresTLS, configuration.proxy != nil { - let handshakePromise = channel.eventLoop.makePromise(of: Void.self) - channel.pipeline.syncAddLateSSLHandlerIfNeeded(for: key, - sslContext: sslContext!, - handshakePromise: handshakePromise) - handshakeFuture = handshakePromise.futureResult - } else if requiresTLS { - do { - handshakeFuture = try channel.pipeline.syncOperations.handler(type: TLSEventsHandler.self).completionPromise.futureResult - } catch { - return channel.eventLoop.makeFailedFuture(error) - } - } else { - handshakeFuture = channel.eventLoop.makeSucceededVoidFuture() - } - - return handshakeFuture.flatMapThrowing { - let syncOperations = channel.pipeline.syncOperations - - // If we got here and we had a TLSEventsHandler in the pipeline, we can remove it ow. - if requiresTLS { - channel.pipeline.removeHandler(name: TLSEventsHandler.handlerName, promise: nil) - } - - try syncOperations.addHTTPClientHandlers(leftOverBytesStrategy: .forwardBytes) - - if isNIOTS { - try syncOperations.addHandler(HTTPClient.NWErrorHandler(), position: .first) - } - - switch configuration.decompression { - case .disabled: - () - case .enabled(let limit): - let decompressHandler = NIOHTTPResponseDecompressor(limit: limit) - try syncOperations.addHandler(decompressHandler) - } - - return channel - } -} - extension Connection { func removeHandler(_ type: Handler.Type) -> EventLoopFuture { return self.channel.pipeline.handler(type: type).flatMap { handler in @@ -271,17 +48,3 @@ extension Connection { }.recover { _ in } } } - -extension NIOClientTCPBootstrap { - var isNIOTS: Bool { - #if canImport(Network) - if #available(OSX 10.14, iOS 12.0, tvOS 12.0, watchOS 6.0, *) { - return self.underlyingBootstrap is NIOTSConnectionBootstrap - } else { - return false - } - #else - return false - #endif - } -} diff --git a/Tests/AsyncHTTPClientTests/ConnectionTests.swift b/Tests/AsyncHTTPClientTests/ConnectionTests.swift index c1191124c..d80609d5f 100644 --- a/Tests/AsyncHTTPClientTests/ConnectionTests.swift +++ b/Tests/AsyncHTTPClientTests/ConnectionTests.swift @@ -142,7 +142,9 @@ class ConnectionTests: XCTestCase { try HTTP1ConnectionProvider(key: .init(.init(url: "http://some.test")), eventLoop: self.eventLoop, configuration: .init(), + tlsConfiguration: nil, pool: self.pool, + sslContextCache: .init(), backgroundActivityLogger: HTTPClient.loggingDisabled)) } diff --git a/Tests/AsyncHTTPClientTests/HTTP1ProxyConnectHandlerTests+XCTest.swift b/Tests/AsyncHTTPClientTests/HTTP1ProxyConnectHandlerTests+XCTest.swift new file mode 100644 index 000000000..15c432037 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTP1ProxyConnectHandlerTests+XCTest.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2019 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 +// +//===----------------------------------------------------------------------===// +// +// HTTP1ProxyConnectHandlerTests+XCTest.swift +// +import XCTest + +/// +/// NOTE: This file was generated by generate_linux_tests.rb +/// +/// Do NOT edit this file directly as it will be regenerated automatically when needed. +/// + +extension HTTP1ProxyConnectHandlerTests { + static var allTests: [(String, (HTTP1ProxyConnectHandlerTests) -> () throws -> Void)] { + return [ + ("testProxyConnectWithoutAuthorizationSuccess", testProxyConnectWithoutAuthorizationSuccess), + ("testProxyConnectWithAuthorization", testProxyConnectWithAuthorization), + ("testProxyConnectWithoutAuthorizationFailure500", testProxyConnectWithoutAuthorizationFailure500), + ("testProxyConnectWithoutAuthorizationButAuthorizationNeeded", testProxyConnectWithoutAuthorizationButAuthorizationNeeded), + ("testProxyConnectReceivesBody", testProxyConnectReceivesBody), + ] + } +} diff --git a/Tests/AsyncHTTPClientTests/HTTP1ProxyConnectHandlerTests.swift b/Tests/AsyncHTTPClientTests/HTTP1ProxyConnectHandlerTests.swift new file mode 100644 index 000000000..a17b242fc --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTP1ProxyConnectHandlerTests.swift @@ -0,0 +1,204 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@testable import AsyncHTTPClient +import NIO +import NIOHTTP1 +import XCTest + +class HTTP1ProxyConnectHandlerTests: XCTestCase { + func testProxyConnectWithoutAuthorizationSuccess() { + let embedded = EmbeddedChannel() + defer { XCTAssertNoThrow(try embedded.finish(acceptAlreadyClosed: false)) } + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectHandler = HTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + proxyAuthorization: .none, + deadline: .now() + .seconds(10) + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + var maybeHead: HTTPClientRequestPart? + XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self)) + guard case .some(.head(let head)) = maybeHead else { + return XCTFail("Expected the proxy connect handler to first send a http head part") + } + + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertNil(head.headers["proxy-authorization"].first) + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertNoThrow(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait()) + } + + func testProxyConnectWithAuthorization() { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectHandler = HTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + proxyAuthorization: .basic(credentials: "abc123"), + deadline: .now() + .seconds(10) + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + var maybeHead: HTTPClientRequestPart? + XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self)) + guard case .some(.head(let head)) = maybeHead else { + return XCTFail("Expected the proxy connect handler to first send a http head part") + } + + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertEqual(head.headers["proxy-authorization"].first, "Basic abc123") + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertNoThrow(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait()) + } + + func testProxyConnectWithoutAuthorizationFailure500() { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectHandler = HTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + proxyAuthorization: .none, + deadline: .now() + .seconds(10) + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + var maybeHead: HTTPClientRequestPart? + XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self)) + guard case .some(.head(let head)) = maybeHead else { + return XCTFail("Expected the proxy connect handler to first send a http head part") + } + + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertNil(head.headers["proxy-authorization"].first) + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .internalServerError) + // answering with 500 should lead to a triggered error in pipeline + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) { + XCTAssertEqual($0 as? HTTPClientError, .invalidProxyResponse) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait()) { + XCTAssertEqual($0 as? HTTPClientError, .invalidProxyResponse) + } + } + + func testProxyConnectWithoutAuthorizationButAuthorizationNeeded() { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectHandler = HTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + proxyAuthorization: .none, + deadline: .now() + .seconds(10) + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + var maybeHead: HTTPClientRequestPart? + XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self)) + guard case .some(.head(let head)) = maybeHead else { + return XCTFail("Expected the proxy connect handler to first send a http head part") + } + + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertNil(head.headers["proxy-authorization"].first) + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .proxyAuthenticationRequired) + // answering with 500 should lead to a triggered error in pipeline + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) { + XCTAssertEqual($0 as? HTTPClientError, .proxyAuthenticationRequired) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait()) { + XCTAssertEqual($0 as? HTTPClientError, .proxyAuthenticationRequired) + } + } + + func testProxyConnectReceivesBody() { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectHandler = HTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + proxyAuthorization: .none, + deadline: .now() + .seconds(10) + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + var maybeHead: HTTPClientRequestPart? + XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self)) + guard case .some(.head(let head)) = maybeHead else { + return XCTFail("Expected the proxy connect handler to first send a http head part") + } + + XCTAssertEqual(head.method, .CONNECT) + XCTAssertEqual(head.uri, "swift.org:443") + XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + // answering with a body should lead to a triggered error in pipeline + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.body(ByteBuffer(bytes: [0, 1, 2, 3])))) { + XCTAssertEqual($0 as? HTTPClientError, .invalidProxyResponse) + } + XCTAssertEqual(embedded.isActive, false) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait()) { + XCTAssertEqual($0 as? HTTPClientError, .invalidProxyResponse) + } + } +} diff --git a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift index 47ecd7a40..af3ab78d9 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientNIOTSTests.swift @@ -88,8 +88,8 @@ class HTTPClientNIOTSTests: XCTestCase { let port = httpBin.port XCTAssertNoThrow(try httpBin.shutdown()) - XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(port)/get").wait()) { error in - XCTAssertEqual(.connectTimeout(.milliseconds(100)), error as? ChannelError) + XCTAssertThrowsError(try httpClient.get(url: "https://localhost:\(port)/get").wait()) { + XCTAssertEqual($0 as? HTTPClientError, .connectTimeout) } } diff --git a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift index e72fa3f67..490860b1a 100644 --- a/Tests/AsyncHTTPClientTests/HTTPClientTests.swift +++ b/Tests/AsyncHTTPClientTests/HTTPClientTests.swift @@ -587,8 +587,8 @@ class HTTPClientTests: XCTestCase { } // This must throw as 198.51.100.254 is reserved for documentation only - XCTAssertThrowsError(try httpClient.get(url: "http://198.51.100.254:65535/get").wait()) { error in - XCTAssertEqual(.connectTimeout(.milliseconds(100)), error as? ChannelError) + XCTAssertThrowsError(try httpClient.get(url: "http://198.51.100.254:65535/get").wait()) { + XCTAssertEqual($0 as? HTTPClientError, .connectTimeout) } } @@ -1841,15 +1841,9 @@ class HTTPClientTests: XCTestCase { XCTAssertThrowsError(try localClient.get(url: "http://localhost:\(port)").wait()) { error in if isTestingNIOTS() { - guard case ChannelError.connectTimeout = error else { - XCTFail("Unexpected error: \(error)") - return - } + XCTAssertEqual(error as? HTTPClientError, .connectTimeout) } else { - guard error is NIOConnectionError else { - XCTFail("Unexpected error: \(error)") - return - } + XCTAssert(error is NIOConnectionError, "Unexpected error: \(error)") } } } @@ -2505,9 +2499,9 @@ class HTTPClientTests: XCTestCase { let request = try HTTPClient.Request(url: "http://198.51.100.254:65535/get") let delegate = TestDelegate() - XCTAssertThrowsError(try httpClient.execute(request: request, delegate: delegate).wait()) { error in - XCTAssertEqual(.connectTimeout(.milliseconds(10)), error as? ChannelError) - XCTAssertEqual(.connectTimeout(.milliseconds(10)), delegate.error as? ChannelError) + XCTAssertThrowsError(try httpClient.execute(request: request, delegate: delegate).wait()) { + XCTAssertEqual(.connectTimeout, $0 as? HTTPClientError) + XCTAssertEqual(.connectTimeout, delegate.error as? HTTPClientError) } } @@ -2733,7 +2727,7 @@ class HTTPClientTests: XCTestCase { XCTAssertThrowsError(try task.wait()) { error in if isTestingNIOTS() { - XCTAssertEqual(error as? ChannelError, .connectTimeout(.milliseconds(100))) + XCTAssertEqual(error as? HTTPClientError, .connectTimeout) } else { switch error as? NIOSSLError { case .some(.handshakeFailed(.sslError(_))): break @@ -2767,9 +2761,14 @@ class HTTPClientTests: XCTestCase { XCTAssertNoThrow(try server.close().wait()) } - // We set the connect timeout down very low here because on NIOTS this manifests as a connect - // timeout. - let config = HTTPClient.Configuration(timeout: HTTPClient.Configuration.Timeout(connect: .milliseconds(200), read: nil)) + var timeout = HTTPClient.Configuration.Timeout(connect: .seconds(10)) + if isTestingNIOTS() { + // If we are using Network.framework, we set the connect timeout down very low here + // because on NIOTS a failing TLS handshake manifests as a connect timeout. + timeout.connect = .milliseconds(300) + } + + let config = HTTPClient.Configuration(timeout: timeout) let client = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup), configuration: config) defer { XCTAssertNoThrow(try client.syncShutdown()) @@ -2780,7 +2779,7 @@ class HTTPClientTests: XCTestCase { XCTAssertThrowsError(try task.wait()) { error in if isTestingNIOTS() { - XCTAssertEqual(error as? ChannelError, .connectTimeout(.milliseconds(200))) + XCTAssertEqual(error as? HTTPClientError, .connectTimeout) } else { switch error as? NIOSSLError { case .some(.handshakeFailed(.sslError(_))): break diff --git a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests+XCTest.swift b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests+XCTest.swift new file mode 100644 index 000000000..898b2b867 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests+XCTest.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2019 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 +// +//===----------------------------------------------------------------------===// +// +// HTTPConnectionPool+FactoryTests+XCTest.swift +// +import XCTest + +/// +/// NOTE: This file was generated by generate_linux_tests.rb +/// +/// Do NOT edit this file directly as it will be regenerated automatically when needed. +/// + +extension HTTPConnectionPool_FactoryTests { + static var allTests: [(String, (HTTPConnectionPool_FactoryTests) -> () throws -> Void)] { + return [ + ("testConnectionCreationTimesoutIfDeadlineIsInThePast", testConnectionCreationTimesoutIfDeadlineIsInThePast), + ("testSOCKSConnectionCreationTimesoutIfRemoteIsUnresponsive", testSOCKSConnectionCreationTimesoutIfRemoteIsUnresponsive), + ("testHTTPProxyConnectionCreationTimesoutIfRemoteIsUnresponsive", testHTTPProxyConnectionCreationTimesoutIfRemoteIsUnresponsive), + ("testTLSConnectionCreationTimesoutIfRemoteIsUnresponsive", testTLSConnectionCreationTimesoutIfRemoteIsUnresponsive), + ] + } +} diff --git a/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift new file mode 100644 index 000000000..7d442ec0e --- /dev/null +++ b/Tests/AsyncHTTPClientTests/HTTPConnectionPool+FactoryTests.swift @@ -0,0 +1,172 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@testable import AsyncHTTPClient +import Logging +import NIO +import NIOSOCKS +import NIOSSL +import XCTest + +class HTTPConnectionPool_FactoryTests: XCTestCase { + func testConnectionCreationTimesoutIfDeadlineIsInThePast() { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) } + + var server: Channel? + XCTAssertNoThrow(server = try ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.pipeline.addHandler(NeverrespondServerHandler()) + } + .bind(to: .init(ipAddress: "127.0.0.1", port: 0)) + .wait()) + defer { + XCTAssertNoThrow(try server?.close().wait()) + } + + let request = try! HTTPClient.Request(url: "https://apple.com") + + let factory = HTTPConnectionPool.ConnectionFactory( + key: .init(request), + tlsConfiguration: nil, + clientConfiguration: .init(proxy: .socksServer(host: "127.0.0.1", port: server!.localAddress!.port!)), + sslContextCache: .init() + ) + + XCTAssertThrowsError(try factory.makeChannel( + connectionID: 1, + deadline: .now() - .seconds(1), + eventLoop: group.next(), + logger: .init(label: "test") + ).wait() + ) { + XCTAssertEqual($0 as? HTTPClientError, .connectTimeout) + } + } + + func testSOCKSConnectionCreationTimesoutIfRemoteIsUnresponsive() { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) } + + var server: Channel? + XCTAssertNoThrow(server = try ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.pipeline.addHandler(NeverrespondServerHandler()) + } + .bind(to: .init(ipAddress: "127.0.0.1", port: 0)) + .wait()) + defer { + XCTAssertNoThrow(try server?.close().wait()) + } + + let request = try! HTTPClient.Request(url: "https://apple.com") + + let factory = HTTPConnectionPool.ConnectionFactory( + key: .init(request), + tlsConfiguration: nil, + clientConfiguration: .init(proxy: .socksServer(host: "127.0.0.1", port: server!.localAddress!.port!)), + sslContextCache: .init() + ) + + XCTAssertThrowsError(try factory.makeChannel( + connectionID: 1, + deadline: .now() + .seconds(1), + eventLoop: group.next(), + logger: .init(label: "test") + ).wait() + ) { + XCTAssertEqual($0 as? HTTPClientError, .socksHandshakeTimeout) + } + } + + func testHTTPProxyConnectionCreationTimesoutIfRemoteIsUnresponsive() { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) } + + var server: Channel? + XCTAssertNoThrow(server = try ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.pipeline.addHandler(NeverrespondServerHandler()) + } + .bind(to: .init(ipAddress: "127.0.0.1", port: 0)) + .wait()) + defer { + XCTAssertNoThrow(try server?.close().wait()) + } + + let request = try! HTTPClient.Request(url: "https://localhost:\(server!.localAddress!.port!)") + + let factory = HTTPConnectionPool.ConnectionFactory( + key: .init(request), + tlsConfiguration: nil, + clientConfiguration: .init(proxy: .server(host: "127.0.0.1", port: server!.localAddress!.port!)), + sslContextCache: .init() + ) + + XCTAssertThrowsError(try factory.makeChannel( + connectionID: 1, + deadline: .now() + .seconds(1), + eventLoop: group.next(), + logger: .init(label: "test") + ).wait() + ) { + XCTAssertEqual($0 as? HTTPClientError, .httpProxyHandshakeTimeout) + } + } + + func testTLSConnectionCreationTimesoutIfRemoteIsUnresponsive() { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { XCTAssertNoThrow(try group.syncShutdownGracefully()) } + + var server: Channel? + XCTAssertNoThrow(server = try ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.pipeline.addHandler(NeverrespondServerHandler()) + } + .bind(to: .init(ipAddress: "127.0.0.1", port: 0)) + .wait()) + defer { + XCTAssertNoThrow(try server?.close().wait()) + } + + let request = try! HTTPClient.Request(url: "https://localhost:\(server!.localAddress!.port!)") + + var tlsConfig = TLSConfiguration.makeClientConfiguration() + tlsConfig.certificateVerification = .none + let factory = HTTPConnectionPool.ConnectionFactory( + key: .init(request), + tlsConfiguration: nil, + clientConfiguration: .init(tlsConfiguration: tlsConfig), + sslContextCache: .init() + ) + + XCTAssertThrowsError(try factory.makeChannel( + connectionID: 1, + deadline: .now() + .seconds(1), + eventLoop: group.next(), + logger: .init(label: "test") + ).wait() + ) { + XCTAssertEqual($0 as? HTTPClientError, .tlsHandshakeTimeout) + } + } +} + +class NeverrespondServerHandler: ChannelInboundHandler { + typealias InboundIn = NIOAny + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + // do nothing + } +} diff --git a/Tests/AsyncHTTPClientTests/SOCKSEventsHandlerTests+XCTest.swift b/Tests/AsyncHTTPClientTests/SOCKSEventsHandlerTests+XCTest.swift new file mode 100644 index 000000000..0338adf3c --- /dev/null +++ b/Tests/AsyncHTTPClientTests/SOCKSEventsHandlerTests+XCTest.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2019 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 +// +//===----------------------------------------------------------------------===// +// +// SOCKSEventsHandlerTests+XCTest.swift +// +import XCTest + +/// +/// NOTE: This file was generated by generate_linux_tests.rb +/// +/// Do NOT edit this file directly as it will be regenerated automatically when needed. +/// + +extension SOCKSEventsHandlerTests { + static var allTests: [(String, (SOCKSEventsHandlerTests) -> () throws -> Void)] { + return [ + ("testHandlerHappyPath", testHandlerHappyPath), + ("testHandlerFailsFutureWhenRemovedWithoutEvent", testHandlerFailsFutureWhenRemovedWithoutEvent), + ("testHandlerFailsFutureWhenHandshakeFails", testHandlerFailsFutureWhenHandshakeFails), + ("testHandlerClosesConnectionIfHandshakeTimesout", testHandlerClosesConnectionIfHandshakeTimesout), + ("testHandlerWorksIfDeadlineIsInPast", testHandlerWorksIfDeadlineIsInPast), + ] + } +} diff --git a/Tests/AsyncHTTPClientTests/SOCKSEventsHandlerTests.swift b/Tests/AsyncHTTPClientTests/SOCKSEventsHandlerTests.swift new file mode 100644 index 000000000..5d0e0bc40 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/SOCKSEventsHandlerTests.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@testable import AsyncHTTPClient +import NIO +import NIOSOCKS +import XCTest + +class SOCKSEventsHandlerTests: XCTestCase { + func testHandlerHappyPath() { + let socksEventsHandler = SOCKSEventsHandler(deadline: .now() + .seconds(10)) + XCTAssertNil(socksEventsHandler.socksEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [socksEventsHandler]) + XCTAssertNotNil(socksEventsHandler.socksEstablishedFuture) + + XCTAssertNoThrow(try embedded.connect(to: .makeAddressResolvingHost("localhost", port: 0)).wait()) + + embedded.pipeline.fireUserInboundEventTriggered(SOCKSProxyEstablishedEvent()) + XCTAssertNoThrow(try XCTUnwrap(socksEventsHandler.socksEstablishedFuture).wait()) + } + + func testHandlerFailsFutureWhenRemovedWithoutEvent() { + let socksEventsHandler = SOCKSEventsHandler(deadline: .now() + .seconds(10)) + XCTAssertNil(socksEventsHandler.socksEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [socksEventsHandler]) + XCTAssertNotNil(socksEventsHandler.socksEstablishedFuture) + + XCTAssertNoThrow(try embedded.pipeline.removeHandler(socksEventsHandler).wait()) + XCTAssertThrowsError(try XCTUnwrap(socksEventsHandler.socksEstablishedFuture).wait()) + } + + func testHandlerFailsFutureWhenHandshakeFails() { + let socksEventsHandler = SOCKSEventsHandler(deadline: .now() + .seconds(10)) + XCTAssertNil(socksEventsHandler.socksEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [socksEventsHandler]) + XCTAssertNotNil(socksEventsHandler.socksEstablishedFuture) + + let error = SOCKSError.InvalidReservedByte(actual: 19) + embedded.pipeline.fireErrorCaught(error) + XCTAssertThrowsError(try XCTUnwrap(socksEventsHandler.socksEstablishedFuture).wait()) { + XCTAssertEqual($0 as? SOCKSError.InvalidReservedByte, error) + } + } + + func testHandlerClosesConnectionIfHandshakeTimesout() { + // .uptimeNanoseconds(0) => .now() for EmbeddedEventLoops + let socksEventsHandler = SOCKSEventsHandler(deadline: .uptimeNanoseconds(0) + .milliseconds(10)) + XCTAssertNil(socksEventsHandler.socksEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [socksEventsHandler]) + XCTAssertNotNil(socksEventsHandler.socksEstablishedFuture) + + XCTAssertNoThrow(try embedded.connect(to: .makeAddressResolvingHost("localhost", port: 0)).wait()) + + embedded.embeddedEventLoop.advanceTime(by: .milliseconds(20)) + + XCTAssertThrowsError(try XCTUnwrap(socksEventsHandler.socksEstablishedFuture).wait()) { + XCTAssertEqual($0 as? HTTPClientError, .socksHandshakeTimeout) + } + XCTAssertFalse(embedded.isActive, "The timeout shall close the connection") + } + + func testHandlerWorksIfDeadlineIsInPast() { + // .uptimeNanoseconds(0) => .now() for EmbeddedEventLoops + let socksEventsHandler = SOCKSEventsHandler(deadline: .uptimeNanoseconds(0)) + XCTAssertNil(socksEventsHandler.socksEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [socksEventsHandler]) + embedded.embeddedEventLoop.advanceTime(by: .milliseconds(10)) + + XCTAssertNoThrow(try embedded.connect(to: .makeAddressResolvingHost("localhost", port: 0)).wait()) + + // schedules execute only on the next tick + embedded.embeddedEventLoop.run() + XCTAssertThrowsError(try XCTUnwrap(socksEventsHandler.socksEstablishedFuture).wait()) { + XCTAssertEqual($0 as? HTTPClientError, .socksHandshakeTimeout) + } + XCTAssertFalse(embedded.isActive, "The timeout shall close the connection") + } +} diff --git a/Tests/AsyncHTTPClientTests/TLSEventsHandlerTests+XCTest.swift b/Tests/AsyncHTTPClientTests/TLSEventsHandlerTests+XCTest.swift new file mode 100644 index 000000000..062132f4e --- /dev/null +++ b/Tests/AsyncHTTPClientTests/TLSEventsHandlerTests+XCTest.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the AsyncHTTPClient open source project +// +// Copyright (c) 2018-2019 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 +// +//===----------------------------------------------------------------------===// +// +// TLSEventsHandlerTests+XCTest.swift +// +import XCTest + +/// +/// NOTE: This file was generated by generate_linux_tests.rb +/// +/// Do NOT edit this file directly as it will be regenerated automatically when needed. +/// + +extension TLSEventsHandlerTests { + static var allTests: [(String, (TLSEventsHandlerTests) -> () throws -> Void)] { + return [ + ("testHandlerHappyPath", testHandlerHappyPath), + ("testHandlerFailsFutureWhenRemovedWithoutEvent", testHandlerFailsFutureWhenRemovedWithoutEvent), + ("testHandlerFailsFutureWhenHandshakeFails", testHandlerFailsFutureWhenHandshakeFails), + ("testHandlerIgnoresShutdownCompletedEvent", testHandlerIgnoresShutdownCompletedEvent), + ] + } +} diff --git a/Tests/AsyncHTTPClientTests/TLSEventsHandlerTests.swift b/Tests/AsyncHTTPClientTests/TLSEventsHandlerTests.swift new file mode 100644 index 000000000..5611b8ff4 --- /dev/null +++ b/Tests/AsyncHTTPClientTests/TLSEventsHandlerTests.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +@testable import AsyncHTTPClient +import NIO +import NIOSSL +import NIOTLS +import XCTest + +class TLSEventsHandlerTests: XCTestCase { + func testHandlerHappyPath() { + let tlsEventsHandler = TLSEventsHandler(deadline: nil) + XCTAssertNil(tlsEventsHandler.tlsEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [tlsEventsHandler]) + XCTAssertNotNil(tlsEventsHandler.tlsEstablishedFuture) + + XCTAssertNoThrow(try embedded.connect(to: .makeAddressResolvingHost("localhost", port: 0)).wait()) + + embedded.pipeline.fireUserInboundEventTriggered(TLSUserEvent.handshakeCompleted(negotiatedProtocol: "abcd1234")) + XCTAssertEqual(try XCTUnwrap(tlsEventsHandler.tlsEstablishedFuture).wait(), "abcd1234") + } + + func testHandlerFailsFutureWhenRemovedWithoutEvent() { + let tlsEventsHandler = TLSEventsHandler(deadline: nil) + XCTAssertNil(tlsEventsHandler.tlsEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [tlsEventsHandler]) + XCTAssertNotNil(tlsEventsHandler.tlsEstablishedFuture) + + XCTAssertNoThrow(try embedded.pipeline.removeHandler(tlsEventsHandler).wait()) + XCTAssertThrowsError(try XCTUnwrap(tlsEventsHandler.tlsEstablishedFuture).wait()) + } + + func testHandlerFailsFutureWhenHandshakeFails() { + let tlsEventsHandler = TLSEventsHandler(deadline: nil) + XCTAssertNil(tlsEventsHandler.tlsEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [tlsEventsHandler]) + XCTAssertNotNil(tlsEventsHandler.tlsEstablishedFuture) + + embedded.pipeline.fireErrorCaught(NIOSSLError.handshakeFailed(BoringSSLError.wantConnect)) + XCTAssertThrowsError(try XCTUnwrap(tlsEventsHandler.tlsEstablishedFuture).wait()) { + XCTAssertEqual($0 as? NIOSSLError, .handshakeFailed(BoringSSLError.wantConnect)) + } + } + + func testHandlerIgnoresShutdownCompletedEvent() { + let tlsEventsHandler = TLSEventsHandler(deadline: nil) + XCTAssertNil(tlsEventsHandler.tlsEstablishedFuture) + let embedded = EmbeddedChannel(handlers: [tlsEventsHandler]) + XCTAssertNotNil(tlsEventsHandler.tlsEstablishedFuture) + + XCTAssertNoThrow(try embedded.connect(to: .makeAddressResolvingHost("localhost", port: 0)).wait()) + + // ignore event + embedded.pipeline.fireUserInboundEventTriggered(TLSUserEvent.shutdownCompleted) + + embedded.pipeline.fireUserInboundEventTriggered(TLSUserEvent.handshakeCompleted(negotiatedProtocol: "alpn")) + XCTAssertEqual(try XCTUnwrap(tlsEventsHandler.tlsEstablishedFuture).wait(), "alpn") + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 094b0ee2a..54a2c80e0 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -28,13 +28,17 @@ import XCTest XCTMain([ testCase(ConnectionPoolTests.allTests), testCase(ConnectionTests.allTests), + testCase(HTTP1ProxyConnectHandlerTests.allTests), testCase(HTTPClientCookieTests.allTests), testCase(HTTPClientInternalTests.allTests), testCase(HTTPClientNIOTSTests.allTests), testCase(HTTPClientSOCKSTests.allTests), testCase(HTTPClientTests.allTests), + testCase(HTTPConnectionPool_FactoryTests.allTests), testCase(LRUCacheTests.allTests), testCase(RequestValidationTests.allTests), + testCase(SOCKSEventsHandlerTests.allTests), testCase(SSLContextCacheTests.allTests), + testCase(TLSEventsHandlerTests.allTests), ]) #endif