diff --git a/Sources/GRPCHTTP2TransportNIOTransportServices/HTTP2ServerTransport+TransportServices.swift b/Sources/GRPCHTTP2TransportNIOTransportServices/HTTP2ServerTransport+TransportServices.swift index a3fb426d1..7b37bcc4d 100644 --- a/Sources/GRPCHTTP2TransportNIOTransportServices/HTTP2ServerTransport+TransportServices.swift +++ b/Sources/GRPCHTTP2TransportNIOTransportServices/HTTP2ServerTransport+TransportServices.swift @@ -19,9 +19,10 @@ public import GRPCCore public import NIOTransportServices // has to be public because of default argument value in init public import GRPCHTTP2Core -internal import NIOCore -internal import NIOExtras -internal import NIOHTTP2 +private import NIOCore +private import NIOExtras +private import NIOHTTP2 +private import Network private import Synchronization @@ -171,7 +172,25 @@ extension HTTP2ServerTransport { } } - let serverChannel = try await NIOTSListenerBootstrap(group: self.eventLoopGroup) + let bootstrap: NIOTSListenerBootstrap + + let requireALPN: Bool + let scheme: Scheme + switch self.config.transportSecurity.wrapped { + case .plaintext: + requireALPN = false + scheme = .http + bootstrap = NIOTSListenerBootstrap(group: self.eventLoopGroup) + + case .tls(let tlsConfig): + requireALPN = tlsConfig.requireALPN + scheme = .https + bootstrap = NIOTSListenerBootstrap(group: self.eventLoopGroup) + .tlsOptions(try NWProtocolTLS.Options(tlsConfig)) + } + + let serverChannel = + try await bootstrap .serverChannelOption( ChannelOptions.socketOption(.so_reuseaddr), value: 1 @@ -190,8 +209,8 @@ extension HTTP2ServerTransport { connectionConfig: self.config.connection, http2Config: self.config.http2, rpcConfig: self.config.rpc, - requireALPN: false, - scheme: .http + requireALPN: requireALPN, + scheme: scheme ) } } @@ -292,41 +311,55 @@ extension HTTP2ServerTransport.TransportServices { public struct Config: Sendable { /// Compression configuration. public var compression: HTTP2ServerTransport.Config.Compression + /// Connection configuration. public var connection: HTTP2ServerTransport.Config.Connection + /// HTTP2 configuration. public var http2: HTTP2ServerTransport.Config.HTTP2 + /// RPC configuration. public var rpc: HTTP2ServerTransport.Config.RPC + /// The transport's security. + public var transportSecurity: TransportSecurity + /// Construct a new `Config`. /// - Parameters: /// - compression: Compression configuration. /// - connection: Connection configuration. /// - http2: HTTP2 configuration. /// - rpc: RPC configuration. + /// - transportSecurity: The transport's security configuration. public init( compression: HTTP2ServerTransport.Config.Compression, connection: HTTP2ServerTransport.Config.Connection, http2: HTTP2ServerTransport.Config.HTTP2, - rpc: HTTP2ServerTransport.Config.RPC + rpc: HTTP2ServerTransport.Config.RPC, + transportSecurity: TransportSecurity ) { self.compression = compression self.connection = connection self.http2 = http2 self.rpc = rpc + self.transportSecurity = transportSecurity } /// Default values for the different configurations. /// - /// - Parameter configure: A closure which allows you to modify the defaults before - /// returning them. - public static func defaults(configure: (_ config: inout Self) -> Void = { _ in }) -> Self { + /// - Parameters: + /// - transportSecurity: The transport's security configuration. + /// - configure: A closure which allows you to modify the defaults before returning them. + public static func defaults( + transportSecurity: TransportSecurity, + configure: (_ config: inout Self) -> Void = { _ in } + ) -> Self { var config = Self( compression: .defaults, connection: .defaults, http2: .defaults, - rpc: .defaults + rpc: .defaults, + transportSecurity: transportSecurity ) configure(&config) return config @@ -396,4 +429,38 @@ extension ServerTransport where Self == HTTP2ServerTransport.TransportServices { ) } } + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension NWProtocolTLS.Options { + convenience init(_ tlsConfig: HTTP2ServerTransport.TransportServices.Config.TLS) throws { + self.init() + + guard let sec_identity = sec_identity_create(try tlsConfig.identityProvider()) else { + throw RuntimeError( + code: .transportError, + message: """ + There was an issue creating the SecIdentity required to set up TLS. \ + Please check your TLS configuration. + """ + ) + } + + sec_protocol_options_set_local_identity( + self.securityProtocolOptions, + sec_identity + ) + + sec_protocol_options_set_min_tls_protocol_version( + self.securityProtocolOptions, + .TLSv12 + ) + + for `protocol` in ["grpc-exp", "h2"] { + sec_protocol_options_add_tls_application_protocol( + self.securityProtocolOptions, + `protocol` + ) + } + } +} #endif diff --git a/Sources/GRPCHTTP2TransportNIOTransportServices/TLSConfig.swift b/Sources/GRPCHTTP2TransportNIOTransportServices/TLSConfig.swift new file mode 100644 index 000000000..05840a42d --- /dev/null +++ b/Sources/GRPCHTTP2TransportNIOTransportServices/TLSConfig.swift @@ -0,0 +1,63 @@ +/* + * 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. + */ + +#if canImport(Network) +public import Network + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension HTTP2ServerTransport.TransportServices.Config { + /// The security configuration for this connection. + public struct TransportSecurity: Sendable { + package enum Wrapped: Sendable { + case plaintext + case tls(TLS) + } + + package let wrapped: Wrapped + + /// This connection is plaintext: no encryption will take place. + public static let plaintext = Self(wrapped: .plaintext) + + /// This connection will use TLS. + public static func tls(_ tls: TLS) -> Self { + Self(wrapped: .tls(tls)) + } + } + + public struct TLS: Sendable { + /// A provider for the `SecIdentity` to be used when setting up TLS. + public var identityProvider: @Sendable () throws -> SecIdentity + + /// Whether ALPN is required. + /// + /// If this is set to `true` but the client does not support ALPN, then the connection will be rejected. + public var requireALPN: Bool + + /// Create a new HTTP2 NIO Transport Services transport TLS config, with some values defaulted: + /// - `requireALPN` equals `false` + /// + /// - Returns: A new HTTP2 NIO Transport Services transport TLS config. + public static func defaults( + identityProvider: @Sendable @escaping () throws -> SecIdentity + ) -> Self { + Self( + identityProvider: identityProvider, + requireALPN: false + ) + } + } +} +#endif diff --git a/Tests/GRPCHTTP2TransportTests/HTTP2TransportNIOTransportServicesTests.swift b/Tests/GRPCHTTP2TransportTests/HTTP2TransportNIOTransportServicesTests.swift index 8219540ec..8611bdb16 100644 --- a/Tests/GRPCHTTP2TransportTests/HTTP2TransportNIOTransportServicesTests.swift +++ b/Tests/GRPCHTTP2TransportTests/HTTP2TransportNIOTransportServicesTests.swift @@ -22,10 +22,59 @@ import XCTest @available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) final class HTTP2TransportNIOTransportServicesTests: XCTestCase { + private static let p12bundleURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // (this file) + .deletingLastPathComponent() // GRPCHTTP2TransportTests + .deletingLastPathComponent() // Tests + .appendingPathComponent("Sources") + .appendingPathComponent("GRPCSampleData") + .appendingPathComponent("bundle") + .appendingPathExtension("p12") + + @Sendable private static func loadIdentity() throws -> SecIdentity { + let data = try Data(contentsOf: Self.p12bundleURL) + + var externalFormat = SecExternalFormat.formatUnknown + var externalItemType = SecExternalItemType.itemTypeUnknown + let passphrase = "password" as CFTypeRef + var exportKeyParams = SecItemImportExportKeyParameters() + exportKeyParams.passphrase = Unmanaged.passUnretained(passphrase) + var items: CFArray? + + let status = SecItemImport( + data as CFData, + "bundle.p12" as CFString, + &externalFormat, + &externalItemType, + SecItemImportExportFlags(rawValue: 0), + &exportKeyParams, + nil, + &items + ) + + if status != errSecSuccess { + XCTFail( + """ + Unable to load identity from '\(Self.p12bundleURL)'. \ + SecItemImport failed with status \(status) + """ + ) + } else if items == nil { + XCTFail( + """ + Unable to load identity from '\(Self.p12bundleURL)'. \ + SecItemImport failed. + """ + ) + } + + return ((items! as NSArray)[0] as! SecIdentity) + } + func testGetListeningAddress_IPv4() async throws { let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices( address: .ipv4(host: "0.0.0.0", port: 0), - config: .defaults() + config: .defaults(transportSecurity: .plaintext) ) try await withThrowingDiscardingTaskGroup { group in @@ -45,7 +94,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase { func testGetListeningAddress_IPv6() async throws { let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices( address: .ipv6(host: "::1", port: 0), - config: .defaults() + config: .defaults(transportSecurity: .plaintext) ) try await withThrowingDiscardingTaskGroup { group in @@ -65,7 +114,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase { func testGetListeningAddress_UnixDomainSocket() async throws { let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices( address: .unixDomainSocket(path: "/tmp/niots-uds-test"), - config: .defaults() + config: .defaults(transportSecurity: .plaintext) ) defer { // NIOTS does not unlink the UDS on close. @@ -91,7 +140,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase { func testGetListeningAddress_InvalidAddress() async { let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices( address: .unixDomainSocket(path: "/this/should/be/an/invalid/path"), - config: .defaults() + config: .defaults(transportSecurity: .plaintext) ) try? await withThrowingDiscardingTaskGroup { group in @@ -120,7 +169,7 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase { func testGetListeningAddress_StoppedListening() async throws { let transport = GRPCHTTP2Core.HTTP2ServerTransport.TransportServices( address: .ipv4(host: "0.0.0.0", port: 0), - config: .defaults() + config: .defaults(transportSecurity: .plaintext) ) try? await withThrowingDiscardingTaskGroup { group in @@ -149,5 +198,14 @@ final class HTTP2TransportNIOTransportServicesTests: XCTestCase { } } } + + func testTLSConfig_Defaults() throws { + let identityProvider = Self.loadIdentity + let grpcTLSConfig = HTTP2ServerTransport.TransportServices.Config.TLS.defaults( + identityProvider: identityProvider + ) + XCTAssertEqual(try grpcTLSConfig.identityProvider(), try identityProvider()) + XCTAssertEqual(grpcTLSConfig.requireALPN, false) + } } #endif diff --git a/Tests/GRPCHTTP2TransportTests/HTTP2TransportTests.swift b/Tests/GRPCHTTP2TransportTests/HTTP2TransportTests.swift index a78751874..14a9d69c3 100644 --- a/Tests/GRPCHTTP2TransportTests/HTTP2TransportTests.swift +++ b/Tests/GRPCHTTP2TransportTests/HTTP2TransportTests.swift @@ -166,7 +166,7 @@ final class HTTP2TransportTests: XCTestCase { let server = GRPCServer( transport: .http2NIOTS( address: .ipv4(host: "127.0.0.1", port: 0), - config: .defaults { + config: .defaults(transportSecurity: .plaintext) { $0.compression.enabledAlgorithms = compression } ),