From 5ff8cc59a6a35dd0eb468f875fa5877b18601041 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers <104022490+rnro@users.noreply.github.com> Date: Thu, 12 Jan 2023 13:56:04 +0000 Subject: [PATCH] Migrate http1 proxy connect handler (#185) Motivation: Moving the HTTP1ProxyConnectHandler into swift-nio-extras will make the code which is generally useful when dealing with HTTP1 proxies available more easily to a wider audience. Modifications: The code and tests are copied over from https://github.com/swift-server/async-http-client/blob/0b5bec741bfcf941e208d937de2ec29affe750a7/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/HTTP1ProxyConnectHandler.swift. Result: HTTP1ProxyConnectHandler will be surfaced via the NIOExtras library --- NOTICE.txt | 11 + Package.swift | 1 + Package@swift-5.5.swift | 1 + .../NIOExtras/HTTP1ProxyConnectHandler.swift | 396 ++++++++++++++++++ ...edCircularBuffer+PopFirstCheckMarked.swift | 23 + Tests/LinuxMain.swift | 1 + ...HTTP1ProxyConnectHandlerTests+XCTest.swift | 40 ++ .../HTTP1ProxyConnectHandlerTests.swift | 363 ++++++++++++++++ scripts/soundness.sh | 2 +- 9 files changed, 837 insertions(+), 1 deletion(-) create mode 100644 Sources/NIOExtras/HTTP1ProxyConnectHandler.swift create mode 100644 Sources/NIOExtras/MarkedCircularBuffer+PopFirstCheckMarked.swift create mode 100644 Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests+XCTest.swift create mode 100644 Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests.swift diff --git a/NOTICE.txt b/NOTICE.txt index cf22b8e7..7640185f 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -32,3 +32,14 @@ This product contains a derivation of the Tony Stone's 'process_test_files.rb'. * https://www.apache.org/licenses/LICENSE-2.0 * HOMEPAGE: * https://codegists.com/snippet/ruby/generate_xctest_linux_runnerrb_tonystone_ruby + +--- + +This product contains a derivation of "HTTP1ProxyConnectHandler.swift" and accompanying tests from AsyncHTTPClient. + + * LICENSE (Apache License 2.0): + * https://www.apache.org/licenses/LICENSE-2.0 + * HOMEPAGE: + * https://github.com/swift-server/async-http-client + +--- diff --git a/Package.swift b/Package.swift index edf6a3f4..b25b6036 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ var targets: [PackageDescription.Target] = [ dependencies: [ .product(name: "NIO", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), ]), .target( name: "NIOHTTPCompression", diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift index 57538037..d042065a 100644 --- a/Package@swift-5.5.swift +++ b/Package@swift-5.5.swift @@ -21,6 +21,7 @@ var targets: [PackageDescription.Target] = [ dependencies: [ .product(name: "NIO", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio") ]), .target( name: "NIOHTTPCompression", diff --git a/Sources/NIOExtras/HTTP1ProxyConnectHandler.swift b/Sources/NIOExtras/HTTP1ProxyConnectHandler.swift new file mode 100644 index 00000000..7663bdd8 --- /dev/null +++ b/Sources/NIOExtras/HTTP1ProxyConnectHandler.swift @@ -0,0 +1,396 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTP1 + +public final class NIOHTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHandler { + public typealias OutboundIn = Never + public typealias OutboundOut = HTTPClientRequestPart + public typealias InboundIn = HTTPClientResponsePart + + /// Whether we've already seen the first request. + private var seenFirstRequest = false + private var bufferedWrittenMessages: MarkedCircularBuffer + + struct BufferedWrite { + var data: NIOAny + var promise: EventLoopPromise? + } + + private 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 headers: HTTPHeaders + private let deadline: NIODeadline + private let promise: EventLoopPromise? + + /// Creates a new ``NIOHTTP1ProxyConnectHandler`` that issues a CONNECT request to a proxy server + /// and instructs the server to connect to `targetHost`. + /// - Parameters: + /// - targetHost: The desired end point host + /// - targetPort: The port to be used when connecting to `targetHost` + /// - headers: Headers to supply to the proxy server as part of the CONNECT request + /// - deadline: Deadline for the CONNECT request + /// - promise: Promise with which the result of the connect operation is communicated + public init(targetHost: String, + targetPort: Int, + headers: HTTPHeaders, + deadline: NIODeadline, + promise: EventLoopPromise?) { + self.targetHost = targetHost + self.targetPort = targetPort + self.headers = headers + self.deadline = deadline + self.promise = promise + + self.bufferedWrittenMessages = MarkedCircularBuffer(initialCapacity: 16) // matches CircularBuffer default + } + + public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + switch self.state { + case .initialized, .connectSent, .headReceived, .completed: + self.bufferedWrittenMessages.append(BufferedWrite(data: data, promise: promise)) + case .failed(let error): + promise?.fail(error) + } + } + + public func flush(context: ChannelHandlerContext) { + self.bufferedWrittenMessages.mark() + } + + public func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { + // We have been formally removed from the pipeline. We should send any buffered data we have. + switch self.state { + case .initialized, .connectSent, .headReceived, .failed: + self.failWithError(.noResult(), context: context) + + case .completed: + while let (bufferedPart, isMarked) = self.bufferedWrittenMessages.popFirstCheckMarked() { + context.write(bufferedPart.data, promise: bufferedPart.promise) + if isMarked { + context.flush() + } + } + + } + + context.leavePipeline(removalToken: removalToken) + } + + public func handlerAdded(context: ChannelHandlerContext) { + if context.channel.isActive { + self.sendConnect(context: context) + } + } + + public func handlerRemoved(context: ChannelHandlerContext) { + switch self.state { + case .failed, .completed: + guard self.bufferedWrittenMessages.isEmpty else { + self.failWithError(Error.droppedWrites(), context: context) + return + } + break + + case .initialized, .connectSent, .headReceived: + self.failWithError(Error.noResult(), context: context) + } + } + + public func channelActive(context: ChannelHandlerContext) { + self.sendConnect(context: context) + context.fireChannelActive() + } + + public func channelInactive(context: ChannelHandlerContext) { + switch self.state { + case .initialized: + self.failWithError(Error.channelUnexpectedlyInactive(), context: context, closeConnection: false) + case .connectSent(let timeout), .headReceived(let timeout): + timeout.cancel() + self.failWithError(Error.remoteConnectionClosed(), context: context, closeConnection: false) + + case .failed, .completed: + break + } + context.fireChannelInactive() + } + + public 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(Error.httpProxyHandshakeTimeout(), context: context) + + case .failed, .completed: + break + } + } + + self.state = .connectSent(timeout) + + let head = HTTPRequestHead( + version: .init(major: 1, minor: 1), + method: .CONNECT, + uri: "\(self.targetHost):\(self.targetPort)", + headers: self.headers + ) + + 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) { + switch self.state { + case .connectSent(let scheduled): + 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(Error.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(Error.invalidProxyResponseHead(head), context: context) + } + case .failed: + break + case .initialized, .headReceived, .completed: + preconditionFailure("Invalid state: \(self.state)") + } + } + + private func handleHTTPBodyReceived(context: ChannelHandlerContext) { + switch self.state { + case .headReceived(let timeout): + timeout.cancel() + // we don't expect a body + self.failWithError(Error.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 + case .failed: + // ran into an error before... ignore this one + return + case .initialized, .connectSent, .completed: + preconditionFailure("Invalid state: \(self.state)") + } + + // Ok, we've set up the proxy connection. We can now remove ourselves, which should happen synchronously. + context.pipeline.removeHandler(context: context, promise: nil) + + self.promise?.succeed(()) + } + + private func failWithError(_ error: Error, context: ChannelHandlerContext, closeConnection: Bool = true) { + switch self.state { + case .failed: + return + case .initialized, .connectSent, .headReceived, .completed: + self.state = .failed(error) + self.promise?.fail(error) + context.fireErrorCaught(error) + if closeConnection { + context.close(mode: .all, promise: nil) + } + while let bufferedWrite = self.bufferedWrittenMessages.popFirst() { + bufferedWrite.promise?.fail(error) + } + } + } + + /// Error types for ``HTTP1ProxyConnectHandler`` + public struct Error: Swift.Error { + fileprivate enum Details { + case proxyAuthenticationRequired + case invalidProxyResponseHead(head: HTTPResponseHead) + case invalidProxyResponse + case remoteConnectionClosed + case httpProxyHandshakeTimeout + case noResult + case channelUnexpectedlyInactive + case droppedWrites + } + + final class Storage: Sendable { + fileprivate let details: Details + public let file: String + public let line: UInt + + fileprivate init(error details: Details, file: String, line: UInt) { + self.details = details + self.file = file + self.line = line + } + } + + fileprivate let store: Storage + + fileprivate init(error: Details, file: String, line: UInt) { + self.store = Storage(error: error, file: file, line: line) + } + + /// Proxy response status `407` indicates that authentication is required + public static func proxyAuthenticationRequired(file: String = #file, line: UInt = #line) -> Error { + Error(error: .proxyAuthenticationRequired, file: file, line: line) + } + + /// Proxy response contains unexpected status + public static func invalidProxyResponseHead(_ head: HTTPResponseHead, file: String = #file, line: UInt = #line) -> Error { + Error(error: .invalidProxyResponseHead(head: head), file: file, line: line) + } + + /// Proxy response contains unexpected body + public static func invalidProxyResponse(file: String = #file, line: UInt = #line) -> Error { + Error(error: .invalidProxyResponse, file: file, line: line) + } + + /// Connection has been closed for ongoing request + public static func remoteConnectionClosed(file: String = #file, line: UInt = #line) -> Error { + Error(error: .remoteConnectionClosed, file: file, line: line) + } + + /// Proxy connection handshake has timed out + public static func httpProxyHandshakeTimeout(file: String = #file, line: UInt = #line) -> Error { + Error(error: .httpProxyHandshakeTimeout, file: file, line: line) + } + + /// Handler was removed before we received a result for the request + public static func noResult(file: String = #file, line: UInt = #line) -> Error { + Error(error: .noResult, file: file, line: line) + } + + /// Handler became unexpectedly inactive before a connection was made + public static func channelUnexpectedlyInactive(file: String = #file, line: UInt = #line) -> Error { + Error(error: .channelUnexpectedlyInactive, file: file, line: line) + } + + public static func droppedWrites(file: String = #file, line: UInt = #line) -> Error { + Error(error: .droppedWrites, file: file, line: line) + } + + fileprivate var errorCode: Int { + switch self.store.details { + case .proxyAuthenticationRequired: + return 0 + case .invalidProxyResponseHead: + return 1 + case .invalidProxyResponse: + return 2 + case .remoteConnectionClosed: + return 3 + case .httpProxyHandshakeTimeout: + return 4 + case .noResult: + return 5 + case .channelUnexpectedlyInactive: + return 6 + case .droppedWrites: + return 7 + } + } + } + +} + +extension NIOHTTP1ProxyConnectHandler.Error: Hashable { + // compare only the kind of error, not the associated response head + public static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.errorCode == rhs.errorCode + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(self.errorCode) + } +} + + +extension NIOHTTP1ProxyConnectHandler.Error: CustomStringConvertible { + public var description: String { + "\(self.store.details.description) (\(self.store.file): \(self.store.line))" + } +} + +extension NIOHTTP1ProxyConnectHandler.Error.Details: CustomStringConvertible { + public var description: String { + switch self { + case .proxyAuthenticationRequired: + return "Proxy Authentication Required" + case .invalidProxyResponseHead(let head): + return "Invalid Proxy Response Head: \(head)" + case .invalidProxyResponse: + return "Invalid Proxy Response" + case .remoteConnectionClosed: + return "Remote Connection Closed" + case .httpProxyHandshakeTimeout: + return "HTTP Proxy Handshake Timeout" + case .noResult: + return "No Result" + case .channelUnexpectedlyInactive: + return "Channel Unexpectedly Inactive" + case .droppedWrites: + return "Handler Was Removed with Writes Left in the Buffer" + } + } +} diff --git a/Sources/NIOExtras/MarkedCircularBuffer+PopFirstCheckMarked.swift b/Sources/NIOExtras/MarkedCircularBuffer+PopFirstCheckMarked.swift new file mode 100644 index 00000000..03e1825b --- /dev/null +++ b/Sources/NIOExtras/MarkedCircularBuffer+PopFirstCheckMarked.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore + +extension MarkedCircularBuffer { + @inlinable + internal mutating func popFirstCheckMarked() -> (Element, Bool)? { + let marked = self.markedElementIndex == self.startIndex + return self.popFirst().map { ($0, marked) } + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 4bfb3d66..740628cc 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -40,6 +40,7 @@ class LinuxMainRunner { testCase(DebugInboundEventsHandlerTest.allTests), testCase(DebugOutboundEventsHandlerTest.allTests), testCase(FixedLengthFrameDecoderTest.allTests), + testCase(HTTP1ProxyConnectHandlerTests.allTests), testCase(HTTPRequestCompressorTest.allTests), testCase(HTTPRequestDecompressorTest.allTests), testCase(HTTPResponseCompressorTest.allTests), diff --git a/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests+XCTest.swift b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests+XCTest.swift new file mode 100644 index 00000000..faa75ff5 --- /dev/null +++ b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests+XCTest.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2018-2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO 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 { + + @available(*, deprecated, message: "not actually deprecated. Just deprecated to allow deprecated tests (which test deprecated functionality) without warnings") + static var allTests : [(String, (HTTP1ProxyConnectHandlerTests) -> () throws -> Void)] { + return [ + ("testProxyConnectWithoutAuthorizationSuccess", testProxyConnectWithoutAuthorizationSuccess), + ("testProxyConnectWithAuthorization", testProxyConnectWithAuthorization), + ("testProxyConnectWithoutAuthorizationFailure500", testProxyConnectWithoutAuthorizationFailure500), + ("testProxyConnectWithoutAuthorizationButAuthorizationNeeded", testProxyConnectWithoutAuthorizationButAuthorizationNeeded), + ("testProxyConnectReceivesBody", testProxyConnectReceivesBody), + ("testProxyConnectWithoutAuthorizationBufferedWrites", testProxyConnectWithoutAuthorizationBufferedWrites), + ("testProxyConnectFailsBufferedWritesAreFailed", testProxyConnectFailsBufferedWritesAreFailed), + ] + } +} + diff --git a/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests.swift b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests.swift new file mode 100644 index 00000000..f2457d18 --- /dev/null +++ b/Tests/NIOExtrasTests/HTTP1ProxyConnectHandlerTests.swift @@ -0,0 +1,363 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2022 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import NIOExtras +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import XCTest + +class HTTP1ProxyConnectHandlerTests: XCTestCase { + func testProxyConnectWithoutAuthorizationSuccess() throws { + 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 promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + 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 promise.futureResult.wait()) + } + + func testProxyConnectWithAuthorization() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: ["proxy-authorization" : "Basic abc123"], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + 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 promise.futureResult.wait()) + } + + func testProxyConnectWithoutAuthorizationFailure500() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + 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? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try promise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + } + + func testProxyConnectWithoutAuthorizationButAuthorizationNeeded() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + let head = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + 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? NIOHTTP1ProxyConnectHandler.Error, .proxyAuthenticationRequired()) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try promise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .proxyAuthenticationRequired()) + } + } + + func testProxyConnectReceivesBody() { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let promise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: promise + ) + + 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? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponse()) + } + XCTAssertEqual(embedded.isActive, false) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try promise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponse()) + } + } + + func testProxyConnectWithoutAuthorizationBufferedWrites() throws { + 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 proxyConnectPromise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: proxyConnectPromise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + // write a request to be buffered inside the ProxyConnectHandler + // it will be unbuffered when the handler completes and removes itself + let requestHead = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1), method: .GET, uri: "http://apple.com") + var promises: [EventLoopPromise] = [] + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.head(requestHead)), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(ByteBuffer(string: "Test")))), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.end(nil)), promise: promises.last) + embedded.pipeline.flush() + + // read the connect header back + let connectHead = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + + XCTAssertEqual(connectHead.method, .CONNECT) + XCTAssertEqual(connectHead.uri, "swift.org:443") + XCTAssertNil(connectHead.headers["proxy-authorization"].first) + + let connectTrailers = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertEnd() + XCTAssertNil(connectTrailers) + + // ensure that nothing has been unbuffered by mistake + XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .ok) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertNoThrow(try proxyConnectPromise.futureResult.wait()) + + // read the buffered write back + let bufferedHead = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + + XCTAssertEqual(bufferedHead.method, .GET) + XCTAssertEqual(bufferedHead.uri, "http://apple.com") + XCTAssertNil(bufferedHead.headers["proxy-authorization"].first) + + let bufferedBody = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertBody() + XCTAssertEqual(bufferedBody, ByteBuffer(string: "Test")) + + let bufferedTrailers = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertEnd() + XCTAssertNil(bufferedTrailers) + + let resultFutures = promises.map { $0.futureResult } + XCTAssertNoThrow(_ = try EventLoopFuture.whenAllComplete(resultFutures, on: embedded.eventLoop).wait()) + } + + func testProxyConnectFailsBufferedWritesAreFailed() throws { + let embedded = EmbeddedChannel() + + let socketAddress = try! SocketAddress.makeAddressResolvingHost("localhost", port: 0) + XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait()) + + let proxyConnectPromise: EventLoopPromise = embedded.eventLoop.makePromise() + let proxyConnectHandler = NIOHTTP1ProxyConnectHandler( + targetHost: "swift.org", + targetPort: 443, + headers: [:], + deadline: .now() + .seconds(10), + promise: proxyConnectPromise + ) + + XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler)) + + // write a request to be buffered inside the ProxyConnectHandler + // it will be unbuffered when the handler completes and removes itself + let requestHead = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1), method: .GET, uri: "apple.com") + var promises: [EventLoopPromise] = [] + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.head(requestHead)), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(ByteBuffer(string: "Test")))), promise: promises.last) + + promises.append(embedded.eventLoop.makePromise()) + embedded.pipeline.write(NIOAny(HTTPClientRequestPart.end(nil)), promise: promises.last) + embedded.pipeline.flush() + + // read the connect header back + let connectHead = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertHead() + + XCTAssertEqual(connectHead.method, .CONNECT) + XCTAssertEqual(connectHead.uri, "swift.org:443") + XCTAssertNil(connectHead.headers["proxy-authorization"].first) + + let connectTrailers = try XCTUnwrap(try embedded.readOutbound(as: HTTPClientRequestPart.self)).assertEnd() + XCTAssertNil(connectTrailers) + + // ensure that nothing has been unbuffered by mistake + XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self)) + + let responseHead = HTTPResponseHead(version: .http1_1, status: .internalServerError) + XCTAssertThrowsError(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead))) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + XCTAssertFalse(embedded.isActive, "Channel should be closed in response to the error") + XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil))) + + XCTAssertThrowsError(try proxyConnectPromise.futureResult.wait()) { + XCTAssertEqual($0 as? NIOHTTP1ProxyConnectHandler.Error, .invalidProxyResponseHead(responseHead)) + } + + // buffered writes are dropped + XCTAssertNil(try embedded.readOutbound(as: HTTPClientRequestPart.self)) + + // all outstanding buffered write promises should be completed + let resultFutures = promises.map { $0.futureResult } + XCTAssertNoThrow(_ = try EventLoopFuture.whenAllComplete(resultFutures, on: embedded.eventLoop).wait()) + } +} + +struct HTTPRequestPartMismatch: Error {} + +extension HTTPClientRequestPart { + @discardableResult + func assertHead(file: StaticString = #file, line: UInt = #line) throws -> HTTPRequestHead { + switch self { + case .head(let head): + return head + default: + XCTFail("Expected .head but got \(self)", file: file, line: line) + throw HTTPRequestPartMismatch() + } + } + + @discardableResult + func assertBody(file: StaticString = #file, line: UInt = #line) throws -> ByteBuffer { + switch self { + case .body(.byteBuffer(let body)): + return body + default: + XCTFail("Expected .body but got \(self)", file: file, line: line) + throw HTTPRequestPartMismatch() + } + } + + @discardableResult + func assertEnd(file: StaticString = #file, line: UInt = #line) throws -> HTTPHeaders? { + switch self { + case .end(let trailers): + return trailers + default: + XCTFail("Expected .end but got \(self)", file: file, line: line) + throw HTTPRequestPartMismatch() + } + } +} diff --git a/scripts/soundness.sh b/scripts/soundness.sh index 574d3bac..59dc5ea5 100755 --- a/scripts/soundness.sh +++ b/scripts/soundness.sh @@ -18,7 +18,7 @@ here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" function replace_acceptable_years() { # this needs to replace all acceptable forms with 'YEARS' - sed -e 's/20[12][789012]-20[12][789012]/YEARS/' -e 's/20[12][89012]/YEARS/' + sed -e 's/20[12][7890123]-20[12][7890123]/YEARS/' -e 's/20[12][890123]/YEARS/' } printf "=> Checking linux tests... "