From 69572684a946a1344fe22769426b988bcc2e0f81 Mon Sep 17 00:00:00 2001 From: cnkwocha Date: Mon, 23 Sep 2024 16:58:18 +0100 Subject: [PATCH 1/3] Implement DNS resolver Motivation: The GRPCNIOTransportCore module needs a DNS resolver, as currently, IP addresses have to be passed in manually. Modifications: - Add a new type `DNSResolver` with a method `resolve(host:port:)` that calls the `getaddrinfo` `libc` function to resolve a hostname and port number to a list of socket addresses. `resolve(host:port:)` is non-blocking and asynchronous. Result: The GRPCHTTP2Core module will have a DNS resolver. --- .../Client/Resolver/DNSResolver.swift | 255 ++++++++++++++++++ .../Client/Resolver/DNSResolverTests.swift | 37 +++ 2 files changed, 292 insertions(+) create mode 100644 Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift create mode 100644 Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift diff --git a/Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift b/Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift new file mode 100644 index 0000000..f3f1316 --- /dev/null +++ b/Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift @@ -0,0 +1,255 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +private import Dispatch + +#if canImport(Darwin) +package import Darwin +#elseif canImport(Glibc) +package import Glibc +#elseif canImport(Musl) +package import Musl +#else +#error("The GRPCNIOTransportCore module was unable to identify your C library.") +#endif + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +/// An asynchronous non-blocking DNS resolver built on top of the libc `getaddrinfo` function. +package enum DNSResolver { + private static let dispatchQueue = DispatchQueue( + label: "io.grpc.DNSResolver" + ) + + /// Resolves a hostname and port number to a list of socket addresses. This method is non-blocking. + package static func resolve(host: String, port: Int) async throws -> [SocketAddress] { + try Task.checkCancellation() + + return try await withCheckedThrowingContinuation { continuation in + Self.dispatchQueue.async { + do { + let result = try Self.resolveBlocking(host: host, port: port) + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// Resolves a hostname and port number to a list of socket addresses. + /// + /// Calls to `getaddrinfo` are blocking and this method calls `getaddrinfo` directly. Hence, this method is also blocking. + private static func resolveBlocking(host: String, port: Int) throws -> [SocketAddress] { + var result: UnsafeMutablePointer? + defer { + if let result { + // Release memory allocated by a successful call to getaddrinfo + freeaddrinfo(result) + } + } + + var hints = addrinfo() + hints.ai_socktype = SOCK_STREAM + hints.ai_protocol = IPPROTO_TCP + + let errorCode = getaddrinfo(host, String(port), &hints, &result) + + guard errorCode == 0, let result else { + throw Self.GetAddrInfoError(code: errorCode) + } + + return try Self.parseResult(result) + } + + /// Parses the linked list of DNS results (`addrinfo`), returning an array of socket addresses. + private static func parseResult( + _ result: UnsafeMutablePointer + ) throws -> [SocketAddress] { + var result = result + var socketAddresses = [SocketAddress]() + + while true { + let addressBytes: UnsafeRawPointer = UnsafeRawPointer(result.pointee.ai_addr) + + switch result.pointee.ai_family { + case AF_INET: // IPv4 address + let ipv4AddressStructure = addressBytes.load(as: sockaddr_in.self) + try socketAddresses.append(.ipv4(.init(ipv4AddressStructure))) + case AF_INET6: // IPv6 address + let ipv6AddressStructure = addressBytes.load(as: sockaddr_in6.self) + try socketAddresses.append(.ipv6(.init(ipv6AddressStructure))) + default: + () + } + + guard let nextResult = result.pointee.ai_next else { break } + result = nextResult + } + + return socketAddresses + } + + /// Converts an address from a network format to a presentation format using `inet_ntop`. + fileprivate static func convertAddressFromNetworkToPresentationFormat( + addressPtr: UnsafeRawPointer, + family: CInt, + length: CInt + ) throws -> String { + var presentationAddressBytes = [CChar](repeating: 0, count: Int(length)) + + return try presentationAddressBytes.withUnsafeMutableBufferPointer { + (presentationAddressBytesPtr: inout UnsafeMutableBufferPointer) throws -> String in + + // Convert + let presentationAddressStringPtr = inet_ntop( + family, + addressPtr, + presentationAddressBytesPtr.baseAddress!, + socklen_t(length) + ) + + if let presentationAddressStringPtr { + return String(cString: presentationAddressStringPtr) + } else { + throw Self.InetNetworkToPresentationError.systemError(errno: errno) + } + } + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension DNSResolver { + /// `Error` that may be thrown based on the error code returned by `getaddrinfo`. + package enum GetAddrInfoError: Error, Hashable { + /// Address family for nodename not supported. + case addressFamilyForNodenameNotSupported + + /// Temporary failure in name resolution. + case temporaryFailure + + /// Invalid value for `ai_flags`. + case invalidAIFlags + + /// Invalid value for `hints`. + case invalidHints + + /// Non-recoverable failure in name resolution. + case nonRecoverableFailure + + /// `ai_family` not supported. + case aiFamilyNotSupported + + /// Memory allocation failure. + case memoryAllocationFailure + + /// No address associated with nodename. + case noAddressAssociatedWithNodename + + /// `hostname` or `servname` not provided, or not known. + case hostnameOrServnameNotProvidedOrNotKnown + + /// Argument buffer overflow. + case argumentBufferOverflow + + /// Resolved protocol is unknown. + case resolvedProtocolIsUnknown + + /// `servname` not supported for `ai_socktype`. + case servnameNotSupportedForSocktype + + /// `ai_socktype` not supported. + case socktypeNotSupported + + /// System error returned in `errno`. + case systemError + + /// Unknown error. + case unknown + + package init(code: CInt) { + switch code { + case EAI_ADDRFAMILY: + self = .addressFamilyForNodenameNotSupported + case EAI_AGAIN: + self = .temporaryFailure + case EAI_BADFLAGS: + self = .invalidAIFlags + case EAI_BADHINTS: + self = .invalidHints + case EAI_FAIL: + self = .nonRecoverableFailure + case EAI_FAMILY: + self = .aiFamilyNotSupported + case EAI_MEMORY: + self = .memoryAllocationFailure + case EAI_NODATA: + self = .noAddressAssociatedWithNodename + case EAI_NONAME: + self = .hostnameOrServnameNotProvidedOrNotKnown + case EAI_OVERFLOW: + self = .argumentBufferOverflow + case EAI_PROTOCOL: + self = .resolvedProtocolIsUnknown + case EAI_SERVICE: + self = .servnameNotSupportedForSocktype + case EAI_SOCKTYPE: + self = .socktypeNotSupported + case EAI_SYSTEM: + self = .systemError + default: + self = .unknown + } + } + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension DNSResolver { + /// `Error` that may be thrown based on the system error encountered by `inet_ntop`. + package enum InetNetworkToPresentationError: Error, Hashable { + case systemError(errno: errno_t) + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension SocketAddress.IPv4 { + fileprivate init(_ address: sockaddr_in) throws { + let presentationAddress = try withUnsafePointer(to: address.sin_addr) { addressPtr in + return try DNSResolver.convertAddressFromNetworkToPresentationFormat( + addressPtr: addressPtr, + family: AF_INET, + length: INET_ADDRSTRLEN + ) + } + + self = .init(host: presentationAddress, port: Int(in_port_t(bigEndian: address.sin_port))) + } +} + +@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) +extension SocketAddress.IPv6 { + fileprivate init(_ address: sockaddr_in6) throws { + let presentationAddress = try withUnsafePointer(to: address.sin6_addr) { addressPtr in + return try DNSResolver.convertAddressFromNetworkToPresentationFormat( + addressPtr: addressPtr, + family: AF_INET6, + length: INET6_ADDRSTRLEN + ) + } + + self = .init(host: presentationAddress, port: Int(in_port_t(bigEndian: address.sin6_port))) + } +} diff --git a/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift b/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift new file mode 100644 index 0000000..308076f --- /dev/null +++ b/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift @@ -0,0 +1,37 @@ +/* + * Copyright 2024, gRPC Authors All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import GRPCNIOTransportCore +import Testing + +@Suite("DNSResolver") +struct DNSResolverTests { + @Test("Resolve hostname") + func resolve() async throws { + // Note: This test checks the IPv4 and IPv6 addresses separately (instead of + // `DNSResolver.resolve(host: "localhost", port: 80)`) because the ordering of the resulting + // list containing both IP address versions can be different. + + let expectedIPv4Result: [SocketAddress] = [.ipv4(host: "127.0.0.1", port: 80)] + let expectedIPv6Result: [SocketAddress] = [.ipv6(host: "::1", port: 80)] + + let ipv4Result = try await DNSResolver.resolve(host: "127.0.0.1", port: 80) + let ipv6Result = try await DNSResolver.resolve(host: "::1", port: 80) + + #expect(ipv4Result == expectedIPv4Result) + #expect(ipv6Result == expectedIPv6Result) + } +} From ec6dbac45b8b7e861157cb7d325e8d49f93516ed Mon Sep 17 00:00:00 2001 From: cnkwocha Date: Mon, 23 Sep 2024 19:07:24 +0100 Subject: [PATCH 2/3] Implement feedback --- .../Client/Resolver/DNSResolver.swift | 104 ++++-------------- .../Client/Resolver/DNSResolverTests.swift | 20 ++-- 2 files changed, 32 insertions(+), 92 deletions(-) diff --git a/Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift b/Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift index f3f1316..e27238f 100644 --- a/Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift +++ b/Sources/GRPCNIOTransportCore/Client/Resolver/DNSResolver.swift @@ -17,11 +17,11 @@ private import Dispatch #if canImport(Darwin) -package import Darwin +private import Darwin #elseif canImport(Glibc) -package import Glibc +private import Glibc #elseif canImport(Musl) -package import Musl +private import Musl #else #error("The GRPCNIOTransportCore module was unable to identify your C library.") #endif @@ -62,8 +62,12 @@ package enum DNSResolver { } var hints = addrinfo() + #if os(Linux) + hints.ai_socktype = CInt(SOCK_STREAM.rawValue) + #else hints.ai_socktype = SOCK_STREAM - hints.ai_protocol = IPPROTO_TCP + #endif + hints.ai_protocol = CInt(IPPROTO_TCP) let errorCode = getaddrinfo(host, String(port), &hints, &result) @@ -124,7 +128,7 @@ package enum DNSResolver { if let presentationAddressStringPtr { return String(cString: presentationAddressStringPtr) } else { - throw Self.InetNetworkToPresentationError.systemError(errno: errno) + throw Self.InetNetworkToPresentationError(errno: errno) } } } @@ -133,84 +137,14 @@ package enum DNSResolver { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension DNSResolver { /// `Error` that may be thrown based on the error code returned by `getaddrinfo`. - package enum GetAddrInfoError: Error, Hashable { - /// Address family for nodename not supported. - case addressFamilyForNodenameNotSupported - - /// Temporary failure in name resolution. - case temporaryFailure - - /// Invalid value for `ai_flags`. - case invalidAIFlags - - /// Invalid value for `hints`. - case invalidHints - - /// Non-recoverable failure in name resolution. - case nonRecoverableFailure - - /// `ai_family` not supported. - case aiFamilyNotSupported - - /// Memory allocation failure. - case memoryAllocationFailure - - /// No address associated with nodename. - case noAddressAssociatedWithNodename - - /// `hostname` or `servname` not provided, or not known. - case hostnameOrServnameNotProvidedOrNotKnown - - /// Argument buffer overflow. - case argumentBufferOverflow - - /// Resolved protocol is unknown. - case resolvedProtocolIsUnknown - - /// `servname` not supported for `ai_socktype`. - case servnameNotSupportedForSocktype - - /// `ai_socktype` not supported. - case socktypeNotSupported - - /// System error returned in `errno`. - case systemError - - /// Unknown error. - case unknown + package struct GetAddrInfoError: Error, Hashable, CustomStringConvertible { + package let description: String package init(code: CInt) { - switch code { - case EAI_ADDRFAMILY: - self = .addressFamilyForNodenameNotSupported - case EAI_AGAIN: - self = .temporaryFailure - case EAI_BADFLAGS: - self = .invalidAIFlags - case EAI_BADHINTS: - self = .invalidHints - case EAI_FAIL: - self = .nonRecoverableFailure - case EAI_FAMILY: - self = .aiFamilyNotSupported - case EAI_MEMORY: - self = .memoryAllocationFailure - case EAI_NODATA: - self = .noAddressAssociatedWithNodename - case EAI_NONAME: - self = .hostnameOrServnameNotProvidedOrNotKnown - case EAI_OVERFLOW: - self = .argumentBufferOverflow - case EAI_PROTOCOL: - self = .resolvedProtocolIsUnknown - case EAI_SERVICE: - self = .servnameNotSupportedForSocktype - case EAI_SOCKTYPE: - self = .socktypeNotSupported - case EAI_SYSTEM: - self = .systemError - default: - self = .unknown + if let errorMessage = gai_strerror(code) { + self.description = String(cString: errorMessage) + } else { + self.description = "Unknown error: \(code)" } } } @@ -219,8 +153,12 @@ extension DNSResolver { @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) extension DNSResolver { /// `Error` that may be thrown based on the system error encountered by `inet_ntop`. - package enum InetNetworkToPresentationError: Error, Hashable { - case systemError(errno: errno_t) + package struct InetNetworkToPresentationError: Error, Hashable { + package let errno: CInt + + package init(errno: CInt) { + self.errno = errno + } } } diff --git a/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift b/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift index 308076f..1fd6b24 100644 --- a/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift +++ b/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift @@ -19,19 +19,21 @@ import Testing @Suite("DNSResolver") struct DNSResolverTests { - @Test("Resolve hostname") - func resolve() async throws { + @Test( + "Resolve hostname", + arguments: [ + ("127.0.0.1", .ipv4(host: "127.0.0.1", port: 80)), + ("::1", .ipv6(host: "::1", port: 80)), + ] as [(String, SocketAddress)] + ) + func resolve(host: String, expected: SocketAddress) async throws { // Note: This test checks the IPv4 and IPv6 addresses separately (instead of // `DNSResolver.resolve(host: "localhost", port: 80)`) because the ordering of the resulting // list containing both IP address versions can be different. - let expectedIPv4Result: [SocketAddress] = [.ipv4(host: "127.0.0.1", port: 80)] - let expectedIPv6Result: [SocketAddress] = [.ipv6(host: "::1", port: 80)] + let result = try await DNSResolver.resolve(host: host, port: 80) - let ipv4Result = try await DNSResolver.resolve(host: "127.0.0.1", port: 80) - let ipv6Result = try await DNSResolver.resolve(host: "::1", port: 80) - - #expect(ipv4Result == expectedIPv4Result) - #expect(ipv6Result == expectedIPv6Result) + #expect(result.count == 1) + #expect(result[0] == expected) } } From df49dbd1be3a874d0eac692ec79fa14c33dbf40a Mon Sep 17 00:00:00 2001 From: George Barnett Date: Mon, 23 Sep 2024 20:09:32 +0100 Subject: [PATCH 3/3] Update Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift --- .../Client/Resolver/DNSResolverTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift b/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift index 1fd6b24..66550d9 100644 --- a/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift +++ b/Tests/GRPCNIOTransportCoreTests/Client/Resolver/DNSResolverTests.swift @@ -33,7 +33,6 @@ struct DNSResolverTests { let result = try await DNSResolver.resolve(host: host, port: 80) - #expect(result.count == 1) - #expect(result[0] == expected) + #expect(result == [expected]) } }