1
0
mirror of https://github.com/apple/swift-nio-extras.git synced 2025-05-28 09:23:41 +08:00

Migrate HTTP1ProxyConnectHandler into nio-extras

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 0b5bec741b/Sources/AsyncHTTPClient/ConnectionPool/ChannelHandler/HTTP1ProxyConnectHandler.swift.

Result:

HTTP1ProxyConnectHandler will be surfaced via the NIOExtras library
This commit is contained in:
Rick Newton-Rogers 2022-11-29 16:54:28 +00:00
parent 54def83a52
commit b5efd662ef
6 changed files with 482 additions and 0 deletions

@ -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
---

@ -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",

@ -0,0 +1,226 @@
//===----------------------------------------------------------------------===//
//
// 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 HTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHandler {
public typealias OutboundIn = Never
public typealias OutboundOut = HTTPClientRequestPart
public typealias InboundIn = HTTPClientResponsePart
enum State {
// transitions to `.connectSent` or `.failed`
case initialized
// transitions to `.headReceived` or `.failed`
case connectSent(Scheduled<Void>)
// transitions to `.completed` or `.failed`
case headReceived(Scheduled<Void>)
// 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 var proxyEstablishedPromise: EventLoopPromise<Void>?
var proxyEstablishedFuture: EventLoopFuture<Void>? {
return self.proxyEstablishedPromise?.futureResult
}
init(targetHost: String,
targetPort: Int,
headers: HTTPHeaders,
deadline: NIODeadline) {
self.targetHost = targetHost
self.targetPort = targetPort
self.headers = headers
self.deadline = deadline
}
public func handlerAdded(context: ChannelHandlerContext) {
self.proxyEstablishedPromise = context.eventLoop.makePromise(of: Void.self)
self.sendConnect(context: context)
}
public func handlerRemoved(context: ChannelHandlerContext) {
switch self.state {
case .failed, .completed:
break
case .initialized, .connectSent, .headReceived:
self.state = .failed(Error.noResult)
self.proxyEstablishedPromise?.fail(Error.noResult)
}
}
public func channelActive(context: ChannelHandlerContext) {
self.sendConnect(context: context)
}
public 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(Error.remoteConnectionClosed, context: context, closeConnection: false)
case .failed, .completed:
break
}
}
public func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
preconditionFailure("We don't support outgoing traffic during HTTP Proxy update.")
}
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) {
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(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.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(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
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)
}
}
/// Error types for ``HTTP1ProxyConnectHandler``
public struct Error: Swift.Error, CustomStringConvertible, Equatable {
fileprivate enum ErrorEnum: String {
case proxyAuthenticationRequired
case invalidProxyResponse
case remoteConnectionClosed
case httpProxyHandshakeTimeout
case noResult
}
fileprivate let error: ErrorEnum
/// return as String
public var description: String { return error.rawValue }
/// Proxy response status `407` indicates that authentication is required
public static let proxyAuthenticationRequired = Error(error: .proxyAuthenticationRequired)
/// Proxy response contains unexpected status or body
public static let invalidProxyResponse = Error(error: .invalidProxyResponse)
/// Connection has been closed for ongoing request
public static let remoteConnectionClosed = Error(error: .remoteConnectionClosed)
/// Proxy connection handshake has timed out
public static let httpProxyHandshakeTimeout = Error(error: .httpProxyHandshakeTimeout)
/// Handler was removed before we received a result for the request
public static let noResult = Error(error: .noResult)
}}

@ -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),

@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//
//
// 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),
]
}
}

@ -0,0 +1,205 @@
//===----------------------------------------------------------------------===//
//
// 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() {
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,
headers: [:],
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,
headers: ["proxy-authorization" : "Basic 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,
headers: [:],
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? HTTP1ProxyConnectHandler.Error, .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? HTTP1ProxyConnectHandler.Error, .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,
headers: [:],
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? HTTP1ProxyConnectHandler.Error, .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? HTTP1ProxyConnectHandler.Error, .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,
headers: [:],
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? HTTP1ProxyConnectHandler.Error, .invalidProxyResponse)
}
XCTAssertEqual(embedded.isActive, false)
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil)))
XCTAssertThrowsError(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait()) {
XCTAssertEqual($0 as? HTTP1ProxyConnectHandler.Error, .invalidProxyResponse)
}
}
}