Skip to content

Commit de74219

Browse files
authored
fix UDS without a baseURL (#165)
Previously, UNIX Domain Sockets would only work if the URL also had a "base URL". If it didn't have a base URL, it would try to connect to the empty string which would fail :). Now, we support both cases: - URLs with a baseURL (the path to the UDS) and a path (actual path) - URLs that just have an actual path (path to the UDS) where we'll just use "/" as the URL's path
1 parent e2636a4 commit de74219

File tree

6 files changed

+187
-10
lines changed

6 files changed

+187
-10
lines changed

Sources/AsyncHTTPClient/HTTPClient.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,11 @@ public class HTTPClient {
272272
return channel.eventLoop.makeSucceededFuture(())
273273
}
274274
}.flatMap {
275-
let taskHandler = TaskHandler(task: task, delegate: delegate, redirectHandler: redirectHandler, ignoreUncleanSSLShutdown: self.configuration.ignoreUncleanSSLShutdown)
275+
let taskHandler = TaskHandler(task: task,
276+
kind: request.kind,
277+
delegate: delegate,
278+
redirectHandler: redirectHandler,
279+
ignoreUncleanSSLShutdown: self.configuration.ignoreUncleanSSLShutdown)
276280
return channel.pipeline.addHandler(taskHandler)
277281
}
278282
}
@@ -282,9 +286,11 @@ public class HTTPClient {
282286
}
283287

284288
let eventLoopChannel: EventLoopFuture<Channel>
285-
if request.kind == .unixSocket, let baseURL = request.url.baseURL {
286-
eventLoopChannel = bootstrap.connect(unixDomainSocketPath: baseURL.path)
287-
} else {
289+
switch request.kind {
290+
case .unixSocket:
291+
let socketPath = request.url.baseURL?.path ?? request.url.path
292+
eventLoopChannel = bootstrap.connect(unixDomainSocketPath: socketPath)
293+
case .host:
288294
let address = self.resolveAddress(request: request, proxy: self.configuration.proxy)
289295
eventLoopChannel = bootstrap.connect(host: address.host, port: address.port)
290296
}

Sources/AsyncHTTPClient/HTTPHandler.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,12 +559,18 @@ internal class TaskHandler<Delegate: HTTPClientResponseDelegate> {
559559
var state: State = .idle
560560
var pendingRead = false
561561
var mayRead = true
562+
let kind: HTTPClient.Request.Kind
562563

563-
init(task: HTTPClient.Task<Delegate.Response>, delegate: Delegate, redirectHandler: RedirectHandler<Delegate.Response>?, ignoreUncleanSSLShutdown: Bool) {
564+
init(task: HTTPClient.Task<Delegate.Response>,
565+
kind: HTTPClient.Request.Kind,
566+
delegate: Delegate,
567+
redirectHandler: RedirectHandler<Delegate.Response>?,
568+
ignoreUncleanSSLShutdown: Bool) {
564569
self.task = task
565570
self.delegate = delegate
566571
self.redirectHandler = redirectHandler
567572
self.ignoreUncleanSSLShutdown = ignoreUncleanSSLShutdown
573+
self.kind = kind
568574
}
569575
}
570576

@@ -653,7 +659,19 @@ extension TaskHandler: ChannelDuplexHandler {
653659
self.state = .idle
654660
let request = unwrapOutboundIn(data)
655661

656-
var head = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1), method: request.method, uri: request.url.uri)
662+
let uri: String
663+
switch (self.kind, request.url.baseURL) {
664+
case (.host, _):
665+
uri = request.url.uri
666+
case (.unixSocket, .none):
667+
uri = "/" // we don't have a real path, the path we have is the path of the UNIX Domain Socket.
668+
case (.unixSocket, .some(_)):
669+
uri = request.url.uri
670+
}
671+
672+
var head = HTTPRequestHead(version: HTTPVersion(major: 1, minor: 1),
673+
method: request.method,
674+
uri: uri)
657675
var headers = request.headers
658676

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

Tests/AsyncHTTPClientTests/HTTPClientInternalTests.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ class HTTPClientInternalTests: XCTestCase {
2828
let task = Task<Void>(eventLoop: channel.eventLoop)
2929

3030
try channel.pipeline.addHandler(recorder).wait()
31-
try channel.pipeline.addHandler(TaskHandler(task: task, delegate: TestHTTPDelegate(), redirectHandler: nil, ignoreUncleanSSLShutdown: false)).wait()
31+
try channel.pipeline.addHandler(TaskHandler(task: task,
32+
kind: .host,
33+
delegate: TestHTTPDelegate(),
34+
redirectHandler: nil,
35+
ignoreUncleanSSLShutdown: false)).wait()
3236

3337
var request = try Request(url: "http://localhost/get")
3438
request.headers.add(name: "X-Test-Header", value: "X-Test-Value")
@@ -56,6 +60,7 @@ class HTTPClientInternalTests: XCTestCase {
5660

5761
XCTAssertNoThrow(try channel.pipeline.addHandler(recorder).wait())
5862
XCTAssertNoThrow(try channel.pipeline.addHandler(TaskHandler(task: task,
63+
kind: .host,
5964
delegate: TestHTTPDelegate(),
6065
redirectHandler: nil,
6166
ignoreUncleanSSLShutdown: false)).wait())
@@ -74,7 +79,11 @@ class HTTPClientInternalTests: XCTestCase {
7479
let channel = EmbeddedChannel()
7580
let delegate = TestHTTPDelegate()
7681
let task = Task<Void>(eventLoop: channel.eventLoop)
77-
let handler = TaskHandler(task: task, delegate: delegate, redirectHandler: nil, ignoreUncleanSSLShutdown: false)
82+
let handler = TaskHandler(task: task,
83+
kind: .host,
84+
delegate: delegate,
85+
redirectHandler: nil,
86+
ignoreUncleanSSLShutdown: false)
7887

7988
try channel.pipeline.addHandler(handler).wait()
8089

Tests/AsyncHTTPClientTests/HTTPClientTestUtils.swift

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,87 @@ internal final class RecordingHandler<Input, Output>: ChannelDuplexHandler {
9292
}
9393
}
9494

95+
enum TemporaryFileHelpers {
96+
private static var temporaryDirectory: String {
97+
#if targetEnvironment(simulator)
98+
// Simulator temp directories are so long (and contain the user name) that they're not usable
99+
// for UNIX Domain Socket paths (which are limited to 103 bytes).
100+
return "/tmp"
101+
#else
102+
#if os(Android)
103+
return "/data/local/tmp"
104+
#elseif os(Linux)
105+
return "/tmp"
106+
#else
107+
if #available(macOS 10.12, iOS 10, tvOS 10, watchOS 3, *) {
108+
return FileManager.default.temporaryDirectory.path
109+
} else {
110+
return "/tmp"
111+
}
112+
#endif // os
113+
#endif // targetEnvironment
114+
}
115+
116+
private static func openTemporaryFile() -> (CInt, String) {
117+
let template = "\(temporaryDirectory)/ahc_XXXXXX"
118+
var templateBytes = template.utf8 + [0]
119+
let templateBytesCount = templateBytes.count
120+
let fd = templateBytes.withUnsafeMutableBufferPointer { ptr in
121+
ptr.baseAddress!.withMemoryRebound(to: Int8.self, capacity: templateBytesCount) { ptr in
122+
mkstemp(ptr)
123+
}
124+
}
125+
templateBytes.removeLast()
126+
return (fd, String(decoding: templateBytes, as: Unicode.UTF8.self))
127+
}
128+
129+
/// This function creates a filename that can be used for a temporary UNIX domain socket path.
130+
///
131+
/// If the temporary directory is too long to store a UNIX domain socket path, it will `chdir` into the temporary
132+
/// directory and return a short-enough path. The iOS simulator is known to have too long paths.
133+
internal static func withTemporaryUnixDomainSocketPathName<T>(directory: String = temporaryDirectory,
134+
_ body: (String) throws -> T) throws -> T {
135+
// this is racy but we're trying to create the shortest possible path so we can't add a directory...
136+
let (fd, path) = self.openTemporaryFile()
137+
close(fd)
138+
try! FileManager.default.removeItem(atPath: path)
139+
140+
let saveCurrentDirectory = FileManager.default.currentDirectoryPath
141+
let restoreSavedCWD: Bool
142+
let shortEnoughPath: String
143+
do {
144+
_ = try SocketAddress(unixDomainSocketPath: path)
145+
// this seems to be short enough for a UDS
146+
shortEnoughPath = path
147+
restoreSavedCWD = false
148+
} catch SocketAddressError.unixDomainSocketPathTooLong {
149+
FileManager.default.changeCurrentDirectoryPath(URL(fileURLWithPath: path).deletingLastPathComponent().absoluteString)
150+
shortEnoughPath = URL(fileURLWithPath: path).lastPathComponent
151+
restoreSavedCWD = true
152+
print("WARNING: Path '\(path)' could not be used as UNIX domain socket path, using chdir & '\(shortEnoughPath)'")
153+
}
154+
defer {
155+
if FileManager.default.fileExists(atPath: path) {
156+
try? FileManager.default.removeItem(atPath: path)
157+
}
158+
if restoreSavedCWD {
159+
FileManager.default.changeCurrentDirectoryPath(saveCurrentDirectory)
160+
}
161+
}
162+
return try body(shortEnoughPath)
163+
}
164+
}
165+
95166
internal final class HTTPBin {
96167
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
97168
let serverChannel: Channel
98169
let isShutdown: NIOAtomic<Bool> = .makeAtomic(value: false)
99170

171+
enum BindTarget {
172+
case unixDomainSocket(String)
173+
case localhostIPv4RandomPort
174+
}
175+
100176
var port: Int {
101177
return Int(self.serverChannel.localAddress!.port!)
102178
}
@@ -112,7 +188,18 @@ internal final class HTTPBin {
112188
return channel.pipeline.addHandler(try! NIOSSLServerHandler(context: context), position: .first)
113189
}
114190

115-
init(ssl: Bool = false, compress: Bool = false, simulateProxy: HTTPProxySimulator.Option? = nil, channelPromise: EventLoopPromise<Channel>? = nil) {
191+
init(ssl: Bool = false,
192+
compress: Bool = false,
193+
bindTarget: BindTarget = .localhostIPv4RandomPort,
194+
simulateProxy: HTTPProxySimulator.Option? = nil,
195+
channelPromise: EventLoopPromise<Channel>? = nil) {
196+
let socketAddress: SocketAddress
197+
switch bindTarget {
198+
case .localhostIPv4RandomPort:
199+
socketAddress = try! SocketAddress(ipAddress: "127.0.0.1", port: 0)
200+
case .unixDomainSocket(let path):
201+
socketAddress = try! SocketAddress(unixDomainSocketPath: path)
202+
}
116203
self.serverChannel = try! ServerBootstrap(group: self.group)
117204
.serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
118205
.childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1)
@@ -145,7 +232,7 @@ internal final class HTTPBin {
145232
}
146233
}
147234
}
148-
.bind(host: "127.0.0.1", port: 0).wait()
235+
.bind(to: socketAddress).wait()
149236
}
150237

151238
func shutdown() throws {
@@ -250,6 +337,16 @@ internal final class HttpBinHandler: ChannelInboundHandler {
250337
case .head(let req):
251338
let url = URL(string: req.uri)!
252339
switch url.path {
340+
case "/":
341+
var headers = HTTPHeaders()
342+
headers.add(name: "X-Is-This-Slash", value: "Yes")
343+
self.resps.append(HTTPResponseBuilder(status: .ok, headers: headers))
344+
return
345+
case "/echo-uri":
346+
var headers = HTTPHeaders()
347+
headers.add(name: "X-Calling-URI", value: req.uri)
348+
self.resps.append(HTTPResponseBuilder(status: .ok, headers: headers))
349+
return
253350
case "/ok":
254351
self.resps.append(HTTPResponseBuilder(status: .ok))
255352
return

Tests/AsyncHTTPClientTests/HTTPClientTests+XCTest.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ extension HTTPClientTests {
7070
("testSubsequentRequestsWorkWithServerAlternatingBetweenKeepAliveAndClose", testSubsequentRequestsWorkWithServerAlternatingBetweenKeepAliveAndClose),
7171
("testManyConcurrentRequestsWork", testManyConcurrentRequestsWork),
7272
("testRepeatedRequestsWorkWhenServerAlwaysCloses", testRepeatedRequestsWorkWhenServerAlwaysCloses),
73+
("testUDSBasic", testUDSBasic),
74+
("testUDSSocketAndPath", testUDSSocketAndPath),
7375
]
7476
}
7577
}

Tests/AsyncHTTPClientTests/HTTPClientTests.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,4 +1003,49 @@ class HTTPClientTests: XCTestCase {
10031003
XCTAssertNil(response?.body)
10041004
}
10051005
}
1006+
1007+
func testUDSBasic() {
1008+
// This tests just connecting to a URL where the whole URL is the UNIX domain socket path like
1009+
// unix:///this/is/my/socket.sock
1010+
// We don't really have a path component, so we'll have to use "/"
1011+
XCTAssertNoThrow(try TemporaryFileHelpers.withTemporaryUnixDomainSocketPathName { path in
1012+
let httpBin = HTTPBin(bindTarget: .unixDomainSocket(path))
1013+
defer {
1014+
XCTAssertNoThrow(try httpBin.shutdown())
1015+
}
1016+
let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
1017+
defer {
1018+
XCTAssertNoThrow(try httpClient.syncShutdown())
1019+
}
1020+
1021+
let target = "unix://\(path)"
1022+
XCTAssertNoThrow(XCTAssertEqual(["Yes"[...]],
1023+
try httpClient.get(url: target).wait().headers[canonicalForm: "X-Is-This-Slash"]))
1024+
})
1025+
}
1026+
1027+
func testUDSSocketAndPath() {
1028+
// Here, we're testing a URL that's encoding two different paths:
1029+
//
1030+
// 1. a "base path" which is the path to the UNIX domain socket
1031+
// 2. an actual path which is the normal path in a regular URL like https://example.com/this/is/the/path
1032+
XCTAssertNoThrow(try TemporaryFileHelpers.withTemporaryUnixDomainSocketPathName { path in
1033+
let httpBin = HTTPBin(bindTarget: .unixDomainSocket(path))
1034+
defer {
1035+
XCTAssertNoThrow(try httpBin.shutdown())
1036+
}
1037+
let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
1038+
defer {
1039+
XCTAssertNoThrow(try httpClient.syncShutdown())
1040+
}
1041+
1042+
guard let target = URL(string: "/echo-uri", relativeTo: URL(string: "unix://\(path)")),
1043+
let request = try? Request(url: target) else {
1044+
XCTFail("couldn't build URL for request")
1045+
return
1046+
}
1047+
XCTAssertNoThrow(XCTAssertEqual(["/echo-uri"[...]],
1048+
try httpClient.execute(request: request).wait().headers[canonicalForm: "X-Calling-URI"]))
1049+
})
1050+
}
10061051
}

0 commit comments

Comments
 (0)