Skip to content

Commit 3fd0658

Browse files
authored
Implement SOCKS proxy functionality (#375)
Add a new Proxy type to enable a HTTPClient to send requests via a SOCKSv5 Proxy server.
1 parent 210b54f commit 3fd0658

9 files changed

+362
-17
lines changed

Diff for: Package.swift

+4-4
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,22 @@ let package = Package(
2121
.library(name: "AsyncHTTPClient", targets: ["AsyncHTTPClient"]),
2222
],
2323
dependencies: [
24-
.package(url: "https://github.com/apple/swift-nio.git", from: "2.27.0"),
24+
.package(url: "https://github.com/apple/swift-nio.git", from: "2.29.0"),
2525
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.13.0"),
26-
.package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.3.0"),
26+
.package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.9.1"),
2727
.package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.5.1"),
2828
.package(url: "https://github.com/apple/swift-log.git", from: "1.4.0"),
2929
],
3030
targets: [
3131
.target(
3232
name: "AsyncHTTPClient",
3333
dependencies: ["NIO", "NIOHTTP1", "NIOSSL", "NIOConcurrencyHelpers", "NIOHTTPCompression",
34-
"NIOFoundationCompat", "NIOTransportServices", "Logging"]
34+
"NIOFoundationCompat", "NIOTransportServices", "Logging", "NIOSOCKS"]
3535
),
3636
.testTarget(
3737
name: "AsyncHTTPClientTests",
3838
dependencies: ["NIO", "NIOConcurrencyHelpers", "NIOSSL", "AsyncHTTPClient", "NIOFoundationCompat",
39-
"NIOTestUtils", "Logging"]
39+
"NIOTestUtils", "Logging", "NIOSOCKS"]
4040
),
4141
]
4242
)

Diff for: Sources/AsyncHTTPClient/HTTPClient.swift

+8-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import NIO
1818
import NIOConcurrencyHelpers
1919
import NIOHTTP1
2020
import NIOHTTPCompression
21+
import NIOSOCKS
2122
import NIOSSL
2223
import NIOTLS
2324
import NIOTransportServices
@@ -883,7 +884,7 @@ extension HTTPClient.Configuration {
883884
}
884885

885886
extension ChannelPipeline {
886-
func syncAddProxyHandler(host: String, port: Int, authorization: HTTPClient.Authorization?) throws {
887+
func syncAddHTTPProxyHandler(host: String, port: Int, authorization: HTTPClient.Authorization?) throws {
887888
let encoder = HTTPRequestEncoder()
888889
let decoder = ByteToMessageHandler(HTTPResponseDecoder(leftOverBytesStrategy: .forwardBytes))
889890
let handler = HTTPClientProxyHandler(host: host, port: port, authorization: authorization) { channel in
@@ -900,6 +901,12 @@ extension ChannelPipeline {
900901
try sync.addHandler(handler)
901902
}
902903

904+
func syncAddSOCKSProxyHandler(host: String, port: Int) throws {
905+
let handler = SOCKSClientHandler(targetAddress: .domain(host, port: port))
906+
let sync = self.syncOperations
907+
try sync.addHandler(handler)
908+
}
909+
903910
func syncAddLateSSLHandlerIfNeeded(for key: ConnectionPool.Key,
904911
sslContext: NIOSSLContext,
905912
handshakePromise: EventLoopPromise<Void>) {

Diff for: Sources/AsyncHTTPClient/HTTPClientProxyHandler.swift

+37-7
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414

1515
import NIO
1616
import NIOHTTP1
17+
import NIOSOCKS
1718

18-
public extension HTTPClient.Configuration {
19+
extension HTTPClient.Configuration {
1920
/// Proxy server configuration
2021
/// Specifies the remote address of an HTTP proxy.
2122
///
@@ -26,31 +27,60 @@ public extension HTTPClient.Configuration {
2627
/// If a `TLSConfiguration` is used in conjunction with `HTTPClient.Configuration.Proxy`,
2728
/// TLS will be established _after_ successful proxy, between your client
2829
/// and the destination server.
29-
struct Proxy {
30+
public struct Proxy {
31+
enum ProxyType: Hashable {
32+
case http(HTTPClient.Authorization?)
33+
case socks
34+
}
35+
3036
/// Specifies Proxy server host.
3137
public var host: String
3238
/// Specifies Proxy server port.
3339
public var port: Int
3440
/// Specifies Proxy server authorization.
35-
public var authorization: HTTPClient.Authorization?
41+
public var authorization: HTTPClient.Authorization? {
42+
set {
43+
precondition(self.type == .http(self.authorization), "SOCKS authorization support is not yet implemented.")
44+
self.type = .http(newValue)
45+
}
46+
47+
get {
48+
switch self.type {
49+
case .http(let authorization):
50+
return authorization
51+
case .socks:
52+
return nil
53+
}
54+
}
55+
}
56+
57+
var type: ProxyType
3658

37-
/// Create proxy.
59+
/// Create a HTTP proxy.
3860
///
3961
/// - parameters:
4062
/// - host: proxy server host.
4163
/// - port: proxy server port.
4264
public static func server(host: String, port: Int) -> Proxy {
43-
return .init(host: host, port: port, authorization: nil)
65+
return .init(host: host, port: port, type: .http(nil))
4466
}
4567

46-
/// Create proxy.
68+
/// Create a HTTP proxy.
4769
///
4870
/// - parameters:
4971
/// - host: proxy server host.
5072
/// - port: proxy server port.
5173
/// - authorization: proxy server authorization.
5274
public static func server(host: String, port: Int, authorization: HTTPClient.Authorization? = nil) -> Proxy {
53-
return .init(host: host, port: port, authorization: authorization)
75+
return .init(host: host, port: port, type: .http(authorization))
76+
}
77+
78+
/// Create a SOCKSv5 proxy.
79+
/// - parameter host: The SOCKSv5 proxy address.
80+
/// - parameter port: The SOCKSv5 proxy port, defaults to 1080.
81+
/// - returns: A new instance of `Proxy` configured to connect to a `SOCKSv5` server.
82+
public static func socksServer(host: String, port: Int = 1080) -> Proxy {
83+
return .init(host: host, port: port, type: .socks)
5484
}
5585
}
5686
}

Diff for: Sources/AsyncHTTPClient/HTTPHandler.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -342,8 +342,8 @@ extension HTTPClient {
342342
}
343343

344344
/// HTTP authentication
345-
public struct Authorization {
346-
private enum Scheme {
345+
public struct Authorization: Hashable {
346+
private enum Scheme: Hashable {
347347
case Basic(String)
348348
case Bearer(String)
349349
}

Diff for: Sources/AsyncHTTPClient/Utils.swift

+8-3
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,14 @@ extension NIOClientTCPBootstrap {
148148
return bootstrap.channelInitializer { channel in
149149
do {
150150
if let proxy = configuration.proxy {
151-
try channel.pipeline.syncAddProxyHandler(host: host,
152-
port: port,
153-
authorization: proxy.authorization)
151+
switch proxy.type {
152+
case .http:
153+
try channel.pipeline.syncAddHTTPProxyHandler(host: host,
154+
port: port,
155+
authorization: proxy.authorization)
156+
case .socks:
157+
try channel.pipeline.syncAddSOCKSProxyHandler(host: host, port: port)
158+
}
154159
} else if requiresTLS {
155160
// We only add the handshake verifier if we need TLS and we're not going through a proxy.
156161
// If we're going through a proxy we add it later (outside of this method).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2018-2019 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
//
15+
// HTTPClient+SOCKSTests+XCTest.swift
16+
//
17+
import XCTest
18+
19+
///
20+
/// NOTE: This file was generated by generate_linux_tests.rb
21+
///
22+
/// Do NOT edit this file directly as it will be regenerated automatically when needed.
23+
///
24+
25+
extension HTTPClientSOCKSTests {
26+
static var allTests: [(String, (HTTPClientSOCKSTests) -> () throws -> Void)] {
27+
return [
28+
("testProxySOCKS", testProxySOCKS),
29+
("testProxySOCKSBogusAddress", testProxySOCKSBogusAddress),
30+
("testProxySOCKSFailureNoServer", testProxySOCKSFailureNoServer),
31+
("testProxySOCKSFailureInvalidServer", testProxySOCKSFailureInvalidServer),
32+
("testProxySOCKSMisbehavingServer", testProxySOCKSMisbehavingServer),
33+
]
34+
}
35+
}
+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the AsyncHTTPClient open source project
4+
//
5+
// Copyright (c) 2021 Apple Inc. and the AsyncHTTPClient project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of AsyncHTTPClient project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/* NOT @testable */ import AsyncHTTPClient // Tests that need @testable go into HTTPClientInternalTests.swift
16+
import Logging
17+
import NIO
18+
import NIOSOCKS
19+
import XCTest
20+
21+
class HTTPClientSOCKSTests: XCTestCase {
22+
typealias Request = HTTPClient.Request
23+
24+
var clientGroup: EventLoopGroup!
25+
var serverGroup: EventLoopGroup!
26+
var defaultHTTPBin: HTTPBin!
27+
var defaultClient: HTTPClient!
28+
var backgroundLogStore: CollectEverythingLogHandler.LogStore!
29+
30+
var defaultHTTPBinURLPrefix: String {
31+
return "http://localhost:\(self.defaultHTTPBin.port)/"
32+
}
33+
34+
override func setUp() {
35+
XCTAssertNil(self.clientGroup)
36+
XCTAssertNil(self.serverGroup)
37+
XCTAssertNil(self.defaultHTTPBin)
38+
XCTAssertNil(self.defaultClient)
39+
XCTAssertNil(self.backgroundLogStore)
40+
41+
self.clientGroup = getDefaultEventLoopGroup(numberOfThreads: 1)
42+
self.serverGroup = MultiThreadedEventLoopGroup(numberOfThreads: 1)
43+
self.defaultHTTPBin = HTTPBin()
44+
self.backgroundLogStore = CollectEverythingLogHandler.LogStore()
45+
var backgroundLogger = Logger(label: "\(#function)", factory: { _ in
46+
CollectEverythingLogHandler(logStore: self.backgroundLogStore!)
47+
})
48+
backgroundLogger.logLevel = .trace
49+
self.defaultClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
50+
backgroundActivityLogger: backgroundLogger)
51+
}
52+
53+
override func tearDown() {
54+
if let defaultClient = self.defaultClient {
55+
XCTAssertNoThrow(try defaultClient.syncShutdown())
56+
self.defaultClient = nil
57+
}
58+
59+
XCTAssertNotNil(self.defaultHTTPBin)
60+
XCTAssertNoThrow(try self.defaultHTTPBin.shutdown())
61+
self.defaultHTTPBin = nil
62+
63+
XCTAssertNotNil(self.clientGroup)
64+
XCTAssertNoThrow(try self.clientGroup.syncShutdownGracefully())
65+
self.clientGroup = nil
66+
67+
XCTAssertNotNil(self.serverGroup)
68+
XCTAssertNoThrow(try self.serverGroup.syncShutdownGracefully())
69+
self.serverGroup = nil
70+
71+
XCTAssertNotNil(self.backgroundLogStore)
72+
self.backgroundLogStore = nil
73+
}
74+
75+
func testProxySOCKS() throws {
76+
let socksBin = try MockSOCKSServer(expectedURL: "/socks/test", expectedResponse: "it works!")
77+
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
78+
configuration: .init(proxy: .socksServer(host: "localhost")))
79+
80+
defer {
81+
XCTAssertNoThrow(try localClient.syncShutdown())
82+
XCTAssertNoThrow(try socksBin.shutdown())
83+
}
84+
85+
var response: HTTPClient.Response?
86+
XCTAssertNoThrow(response = try localClient.get(url: "http://localhost/socks/test").wait())
87+
XCTAssertEqual(.ok, response?.status)
88+
XCTAssertEqual(ByteBuffer(string: "it works!"), response?.body)
89+
}
90+
91+
func testProxySOCKSBogusAddress() throws {
92+
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
93+
configuration: .init(proxy: .socksServer(host: "127.0..")))
94+
95+
defer {
96+
XCTAssertNoThrow(try localClient.syncShutdown())
97+
}
98+
XCTAssertThrowsError(try localClient.get(url: "http://localhost/socks/test").wait())
99+
}
100+
101+
// there is no socks server, so we should fail
102+
func testProxySOCKSFailureNoServer() throws {
103+
let localHTTPBin = HTTPBin()
104+
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
105+
configuration: .init(proxy: .socksServer(host: "localhost", port: localHTTPBin.port)))
106+
defer {
107+
XCTAssertNoThrow(try localClient.syncShutdown())
108+
XCTAssertNoThrow(try localHTTPBin.shutdown())
109+
}
110+
XCTAssertThrowsError(try localClient.get(url: "http://localhost/socks/test").wait())
111+
}
112+
113+
// speak to a server that doesn't speak SOCKS
114+
func testProxySOCKSFailureInvalidServer() throws {
115+
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
116+
configuration: .init(proxy: .socksServer(host: "localhost")))
117+
defer {
118+
XCTAssertNoThrow(try localClient.syncShutdown())
119+
}
120+
XCTAssertThrowsError(try localClient.get(url: "http://localhost/socks/test").wait())
121+
}
122+
123+
// test a handshake failure with a misbehaving server
124+
func testProxySOCKSMisbehavingServer() throws {
125+
let socksBin = try MockSOCKSServer(expectedURL: "/socks/test", expectedResponse: "it works!", misbehave: true)
126+
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
127+
configuration: .init(proxy: .socksServer(host: "localhost")))
128+
129+
defer {
130+
XCTAssertNoThrow(try localClient.syncShutdown())
131+
XCTAssertNoThrow(try socksBin.shutdown())
132+
}
133+
134+
// the server will send a bogus message in response to the clients request
135+
XCTAssertThrowsError(try localClient.get(url: "http://localhost/socks/test").wait())
136+
}
137+
}

0 commit comments

Comments
 (0)