Skip to content

Commit 364d106

Browse files
Better support for UNIX Domain sockets (#228)
* Added tests for http+unix and https+unix url schemes Motivation: Using a base URL as the socket path only works when the URL object is maintained as long as possible through the stack. Additionally, it doesn't currently provide a way to use TLS over UNIX sockets. Modifications: Added two tests to test out the to-be supported URL schemes, http+unix, and https+unix, which encode the socket path as a %-escaped hostname, as some existing services already do. Result: Better UNIX domain socket support.
1 parent 070c1e5 commit 364d106

File tree

7 files changed

+113
-28
lines changed

7 files changed

+113
-28
lines changed

Diff for: Sources/AsyncHTTPClient/ConnectionPool.swift

+20-5
Original file line numberDiff line numberDiff line change
@@ -113,23 +113,38 @@ final class ConnectionPool {
113113
self.scheme = .https
114114
case "unix":
115115
self.scheme = .unix
116-
self.unixPath = request.url.baseURL?.path ?? request.url.path
116+
case "http+unix":
117+
self.scheme = .http_unix
118+
case "https+unix":
119+
self.scheme = .https_unix
117120
default:
118121
fatalError("HTTPClient.Request scheme should already be a valid one")
119122
}
120123
self.port = request.port
121124
self.host = request.host
125+
self.unixPath = request.socketPath
122126
}
123127

124128
var scheme: Scheme
125129
var host: String
126130
var port: Int
127-
var unixPath: String = ""
131+
var unixPath: String
128132

129133
enum Scheme: Hashable {
130134
case http
131135
case https
132136
case unix
137+
case http_unix
138+
case https_unix
139+
140+
var requiresTLS: Bool {
141+
switch self {
142+
case .https, .https_unix:
143+
return true
144+
default:
145+
return false
146+
}
147+
}
133148
}
134149
}
135150
}
@@ -433,7 +448,7 @@ class HTTP1ConnectionProvider {
433448

434449
private func makeChannel(preference: HTTPClient.EventLoopPreference) -> EventLoopFuture<Channel> {
435450
let eventLoop = preference.bestEventLoop ?? self.eventLoop
436-
let requiresTLS = self.key.scheme == .https
451+
let requiresTLS = self.key.scheme.requiresTLS
437452
let bootstrap: NIOClientTCPBootstrap
438453
do {
439454
bootstrap = try NIOClientTCPBootstrap.makeHTTPClientBootstrapBase(on: eventLoop, host: self.key.host, port: self.key.port, requiresTLS: requiresTLS, configuration: self.configuration)
@@ -446,12 +461,12 @@ class HTTP1ConnectionProvider {
446461
case .http, .https:
447462
let address = HTTPClient.resolveAddress(host: self.key.host, port: self.key.port, proxy: self.configuration.proxy)
448463
channel = bootstrap.connect(host: address.host, port: address.port)
449-
case .unix:
464+
case .unix, .http_unix, .https_unix:
450465
channel = bootstrap.connect(unixDomainSocketPath: self.key.unixPath)
451466
}
452467

453468
return channel.flatMap { channel in
454-
let requiresSSLHandler = self.configuration.proxy != nil && self.key.scheme == .https
469+
let requiresSSLHandler = self.configuration.proxy != nil && self.key.scheme.requiresTLS
455470
let handshakePromise = channel.eventLoop.makePromise(of: Void.self)
456471

457472
channel.pipeline.addSSLHandlerIfNeeded(for: self.key, tlsConfiguration: self.configuration.tlsConfiguration, addSSLClient: requiresSSLHandler, handshakePromise: handshakePromise)

Diff for: Sources/AsyncHTTPClient/HTTPClient.swift

+5-2
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ extension ChannelPipeline {
654654
}
655655

656656
func addSSLHandlerIfNeeded(for key: ConnectionPool.Key, tlsConfiguration: TLSConfiguration?, addSSLClient: Bool, handshakePromise: EventLoopPromise<Void>) {
657-
guard key.scheme == .https else {
657+
guard key.scheme.requiresTLS else {
658658
handshakePromise.succeed(())
659659
return
660660
}
@@ -665,7 +665,7 @@ extension ChannelPipeline {
665665
let tlsConfiguration = tlsConfiguration ?? TLSConfiguration.forClient()
666666
let context = try NIOSSLContext(configuration: tlsConfiguration)
667667
handlers = [
668-
try NIOSSLClientHandler(context: context, serverHostname: key.host.isIPAddress ? nil : key.host),
668+
try NIOSSLClientHandler(context: context, serverHostname: (key.host.isIPAddress || key.host.isEmpty) ? nil : key.host),
669669
TLSEventsHandler(completionPromise: handshakePromise),
670670
]
671671
} else {
@@ -726,6 +726,7 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
726726
private enum Code: Equatable {
727727
case invalidURL
728728
case emptyHost
729+
case missingSocketPath
729730
case alreadyShutdown
730731
case emptyScheme
731732
case unsupportedScheme(String)
@@ -758,6 +759,8 @@ public struct HTTPClientError: Error, Equatable, CustomStringConvertible {
758759
public static let invalidURL = HTTPClientError(code: .invalidURL)
759760
/// URL does not contain host.
760761
public static let emptyHost = HTTPClientError(code: .emptyHost)
762+
/// URL does not contain a socketPath as a host for http(s)+unix shemes.
763+
public static let missingSocketPath = HTTPClientError(code: .missingSocketPath)
761764
/// Client is shutdown and cannot be used for new requests.
762765
public static let alreadyShutdown = HTTPClientError(code: .alreadyShutdown)
763766
/// URL does not contain scheme.

Diff for: Sources/AsyncHTTPClient/HTTPHandler.swift

+48-19
Original file line numberDiff line numberDiff line change
@@ -99,20 +99,27 @@ extension HTTPClient {
9999
public struct Request {
100100
/// Represent kind of Request
101101
enum Kind {
102+
enum UnixScheme {
103+
case baseURL
104+
case http_unix
105+
case https_unix
106+
}
107+
102108
/// Remote host request.
103109
case host
104110
/// UNIX Domain Socket HTTP request.
105-
case unixSocket
111+
case unixSocket(_ scheme: UnixScheme)
106112

107113
private static var hostSchemes = ["http", "https"]
108-
private static var unixSchemes = ["unix"]
114+
private static var unixSchemes = ["unix", "http+unix", "https+unix"]
109115

110116
init(forScheme scheme: String) throws {
111-
if Kind.host.supports(scheme: scheme) {
112-
self = .host
113-
} else if Kind.unixSocket.supports(scheme: scheme) {
114-
self = .unixSocket
115-
} else {
117+
switch scheme {
118+
case "http", "https": self = .host
119+
case "unix": self = .unixSocket(.baseURL)
120+
case "http+unix": self = .unixSocket(.http_unix)
121+
case "https+unix": self = .unixSocket(.https_unix)
122+
default:
116123
throw HTTPClientError.unsupportedScheme(scheme)
117124
}
118125
}
@@ -129,6 +136,31 @@ extension HTTPClient {
129136
}
130137
}
131138

139+
func socketPathFromURL(_ url: URL) throws -> String {
140+
switch self {
141+
case .unixSocket(.baseURL):
142+
return url.baseURL?.path ?? url.path
143+
case .unixSocket:
144+
guard let socketPath = url.host else {
145+
throw HTTPClientError.missingSocketPath
146+
}
147+
return socketPath
148+
case .host:
149+
return ""
150+
}
151+
}
152+
153+
func uriFromURL(_ url: URL) -> String {
154+
switch self {
155+
case .host:
156+
return url.uri
157+
case .unixSocket(.baseURL):
158+
return url.baseURL != nil ? url.uri : "/"
159+
case .unixSocket:
160+
return url.uri
161+
}
162+
}
163+
132164
func supports(scheme: String) -> Bool {
133165
switch self {
134166
case .host:
@@ -147,6 +179,10 @@ extension HTTPClient {
147179
public let scheme: String
148180
/// Remote host, resolved from `URL`.
149181
public let host: String
182+
/// Socket path, resolved from `URL`.
183+
let socketPath: String
184+
/// URI composed of the path and query, resolved from `URL`.
185+
let uri: String
150186
/// Request custom HTTP Headers, defaults to no headers.
151187
public var headers: HTTPHeaders
152188
/// Request body, defaults to no body.
@@ -192,13 +228,16 @@ extension HTTPClient {
192228
/// - `emptyScheme` if URL does not contain HTTP scheme.
193229
/// - `unsupportedScheme` if URL does contains unsupported HTTP scheme.
194230
/// - `emptyHost` if URL does not contains a host.
231+
/// - `missingSocketPath` if URL does not contains a socketPath as an encoded host.
195232
public init(url: URL, method: HTTPMethod = .GET, headers: HTTPHeaders = HTTPHeaders(), body: Body? = nil) throws {
196233
guard let scheme = url.scheme?.lowercased() else {
197234
throw HTTPClientError.emptyScheme
198235
}
199236

200237
self.kind = try Kind(forScheme: scheme)
201238
self.host = try self.kind.hostFromURL(url)
239+
self.socketPath = try self.kind.socketPathFromURL(url)
240+
self.uri = self.kind.uriFromURL(url)
202241

203242
self.redirectState = nil
204243
self.url = url
@@ -210,7 +249,7 @@ extension HTTPClient {
210249

211250
/// Whether request will be executed using secure socket.
212251
public var useTLS: Bool {
213-
return self.scheme == "https"
252+
return self.scheme == "https" || self.scheme == "https+unix"
214253
}
215254

216255
/// Resolved port.
@@ -712,19 +751,9 @@ extension TaskHandler: ChannelDuplexHandler {
712751
self.state = .idle
713752
let request = self.unwrapOutboundIn(data)
714753

715-
let uri: String
716-
switch (self.kind, request.url.baseURL) {
717-
case (.host, _):
718-
uri = request.url.uri
719-
case (.unixSocket, .none):
720-
uri = "/" // we don't have a real path, the path we have is the path of the UNIX Domain Socket.
721-
case (.unixSocket, .some(_)):
722-
uri = request.url.uri
723-
}
724-
725754
var head = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1),
726755
method: request.method,
727-
uri: uri)
756+
uri: request.uri)
728757
var headers = request.headers
729758

730759
if !request.headers.contains(name: "Host") {

Diff for: Sources/AsyncHTTPClient/Utils.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ extension ClientBootstrap {
6464
} else {
6565
let tlsConfiguration = configuration.tlsConfiguration ?? TLSConfiguration.forClient()
6666
let sslContext = try NIOSSLContext(configuration: tlsConfiguration)
67-
let hostname = (!requiresTLS || host.isIPAddress) ? nil : host
67+
let hostname = (!requiresTLS || host.isIPAddress || host.isEmpty) ? nil : host
6868
let tlsProvider = try NIOSSLClientTLSProvider<ClientBootstrap>(context: sslContext, serverHostname: hostname)
6969
return NIOClientTCPBootstrap(self, tls: tlsProvider)
7070
}

Diff for: Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift

-1
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,6 @@ internal final class HTTPBin {
234234

235235
self.serverChannel = try! ServerBootstrap(group: self.group)
236236
.serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
237-
.childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
238237
.serverChannelInitializer { channel in
239238
channel.pipeline.addHandler(activeConnCounterHandler)
240239
}.childChannelInitializer { channel in

Diff for: Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift

+2
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ extension HTTPClientTests {
9090
("testMakeSecondRequestWhilstFirstIsOngoing", testMakeSecondRequestWhilstFirstIsOngoing),
9191
("testUDSBasic", testUDSBasic),
9292
("testUDSSocketAndPath", testUDSSocketAndPath),
93+
("testHTTPPlusUNIX", testHTTPPlusUNIX),
94+
("testHTTPSPlusUNIX", testHTTPSPlusUNIX),
9395
("testUseExistingConnectionOnDifferentEL", testUseExistingConnectionOnDifferentEL),
9496
("testWeRecoverFromServerThatClosesTheConnectionOnUs", testWeRecoverFromServerThatClosesTheConnectionOnUs),
9597
("testPoolClosesIdleConnections", testPoolClosesIdleConnections),

Diff for: Tests/AsyncHTTPClientTests/HTTPClientTests.swift

+37
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,43 @@ class HTTPClientTests: XCTestCase {
13251325
})
13261326
}
13271327

1328+
func testHTTPPlusUNIX() {
1329+
// Here, we're testing a URL where the UNIX domain socket is encoded as the host name
1330+
XCTAssertNoThrow(try TemporaryFileHelpers.withTemporaryUnixDomainSocketPathName { path in
1331+
let localHTTPBin = HTTPBin(bindTarget: .unixDomainSocket(path))
1332+
defer {
1333+
XCTAssertNoThrow(try localHTTPBin.shutdown())
1334+
}
1335+
guard let target = URL(string: "http+unix://\(path.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)/echo-uri"),
1336+
let request = try? Request(url: target) else {
1337+
XCTFail("couldn't build URL for request")
1338+
return
1339+
}
1340+
XCTAssertNoThrow(XCTAssertEqual(["/echo-uri"[...]],
1341+
try self.defaultClient.execute(request: request).wait().headers[canonicalForm: "X-Calling-URI"]))
1342+
})
1343+
}
1344+
1345+
func testHTTPSPlusUNIX() {
1346+
// Here, we're testing a URL where the UNIX domain socket is encoded as the host name
1347+
XCTAssertNoThrow(try TemporaryFileHelpers.withTemporaryUnixDomainSocketPathName { path in
1348+
let localHTTPBin = HTTPBin(ssl: true, bindTarget: .unixDomainSocket(path))
1349+
let localClient = HTTPClient(eventLoopGroupProvider: .shared(self.clientGroup),
1350+
configuration: HTTPClient.Configuration(certificateVerification: .none))
1351+
defer {
1352+
XCTAssertNoThrow(try localClient.syncShutdown())
1353+
XCTAssertNoThrow(try localHTTPBin.shutdown())
1354+
}
1355+
guard let target = URL(string: "https+unix://\(path.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!)/echo-uri"),
1356+
let request = try? Request(url: target) else {
1357+
XCTFail("couldn't build URL for request")
1358+
return
1359+
}
1360+
XCTAssertNoThrow(XCTAssertEqual(["/echo-uri"[...]],
1361+
try localClient.execute(request: request).wait().headers[canonicalForm: "X-Calling-URI"]))
1362+
})
1363+
}
1364+
13281365
func testUseExistingConnectionOnDifferentEL() throws {
13291366
let threadCount = 16
13301367
let elg = getDefaultEventLoopGroup(numberOfThreads: threadCount)

0 commit comments

Comments
 (0)