diff --git a/Package.swift b/Package.swift index a78325e..a6646d8 100644 --- a/Package.swift +++ b/Package.swift @@ -24,10 +24,10 @@ let package = Package( .package(url: "https://github.com/apple/swift-collections.git", from: "1.0.4"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-certificates.git", from: "1.0.4"), + .package(url: "https://github.com/apple/swift-certificates.git", from: "1.16.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), - .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.0.0"), ], diff --git a/Sources/HTTPServer/HTTPServerConfiguration.swift b/Sources/HTTPServer/HTTPServerConfiguration.swift deleted file mode 100644 index f2fad3f..0000000 --- a/Sources/HTTPServer/HTTPServerConfiguration.swift +++ /dev/null @@ -1,205 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift HTTP Server open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -public import X509 -public import NIOCertificateReloading -import NIOSSL - -/// Configuration settings for the HTTP server. -/// -/// This structure contains all the necessary configuration options for setting up -/// and running an HTTP server, including network binding and TLS settings. -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -public struct HTTPServerConfiguration: Sendable { - /// Specifies where the server should bind and listen for incoming connections. - /// - /// Currently supports binding to a specific host and port combination. - /// Additional binding targets may be added in the future. - public struct BindTarget: Sendable { - enum Backing { - case hostAndPort(host: String, port: Int) - } - - let backing: Backing - - /// Creates a bind target for a specific host and port. - /// - /// - Parameters: - /// - host: The hostname or IP address to bind to (e.g., "localhost", "0.0.0.0") - /// - port: The port number to listen on (e.g., 8080, 443) - /// - Returns: A configured `BindTarget` instance - /// - /// ## Example - /// ```swift - /// let target = BindTarget.hostAndPort(host: "localhost", port: 8080) - /// ``` - public static func hostAndPort(host: String, port: Int) -> Self { - Self(backing: .hostAndPort(host: host, port: port)) - } - } - - /// Configuration for transport security settings. - /// - /// Provides options for running the server with or without TLS encryption. - /// When using TLS, you must either provide a certificate chain and private key, or a `CertificateReloader`. - public struct TransportSecurity: Sendable { - enum Backing { - case plaintext - case tls( - certificateChain: [Certificate], - privateKey: Certificate.PrivateKey - ) - case reloadingTLS(certificateReloader: any CertificateReloader) - case mTLS( - certificateChain: [Certificate], - privateKey: Certificate.PrivateKey, - trustRoots: [Certificate]? - ) - case reloadingMTLS( - certificateReloader: any CertificateReloader, - trustRoots: [Certificate]? - ) - } - - let backing: Backing - - public static let plaintext: Self = Self(backing: .plaintext) - - public static func tls( - certificateChain: [Certificate], - privateKey: Certificate.PrivateKey - ) -> Self { - Self( - backing: .tls( - certificateChain: certificateChain, - privateKey: privateKey - ) - ) - } - - public static func tls(certificateReloader: any CertificateReloader) throws -> Self { - Self(backing: .reloadingTLS(certificateReloader: certificateReloader)) - } - - public static func mTLS( - certificateChain: [Certificate], - privateKey: Certificate.PrivateKey, - trustRoots: [Certificate]? - ) -> Self { - Self( - backing: .mTLS( - certificateChain: certificateChain, - privateKey: privateKey, - trustRoots: trustRoots - ) - ) - } - - public static func mTLS( - certificateReloader: any CertificateReloader, - trustRoots: [Certificate]? - ) throws -> Self { - Self(backing: .reloadingMTLS( - certificateReloader: certificateReloader, - trustRoots: trustRoots - )) - } - } - - /// HTTP/2 specific configuration. - public struct HTTP2: Sendable, Hashable { - /// The maximum frame size to be used in an HTTP/2 connection. - public var maxFrameSize: Int - - /// The target window size for this connection. - /// - /// - Note: This will also be set as the initial window size for the connection. - public var targetWindowSize: Int - - /// The number of concurrent streams on the HTTP/2 connection. - public var maxConcurrentStreams: Int? - - public init( - maxFrameSize: Int, - targetWindowSize: Int, - maxConcurrentStreams: Int? - ) { - self.maxFrameSize = maxFrameSize - self.targetWindowSize = targetWindowSize - self.maxConcurrentStreams = maxConcurrentStreams - } - - /// Default values. The max frame size defaults to 2^14, the target window size defaults to 2^16-1, and - /// the max concurrent streams default to infinite. - public static var defaults: Self { - Self( - maxFrameSize: 1 << 14, - targetWindowSize: (1 << 16) - 1, - maxConcurrentStreams: nil - ) - } - } - - /// Configuration for the backpressure strategy to use when reading requests and writing back responses. - public struct BackPressureStrategy: Sendable { - enum Backing { - case watermark(low: Int, high: Int) - } - - internal let backing: Backing - - private init(backing: Backing) { - self.backing = backing - } - - /// A low/high watermark will be applied when reading requests and writing responses. - /// - Parameters: - /// - low: The threshold below which the consumer will ask the producer to produce more elements. - /// - high: The threshold above which the producer will stop producing elements. - /// - Returns: A low/high watermark strategy with the configured thresholds. - public static func watermark(low: Int, high: Int) -> Self { - .init(backing: .watermark(low: low, high: high)) - } - } - - /// Network binding configuration - public var bindTarget: BindTarget - - /// TLS configuration for the server. - public var transportSecurity: TransportSecurity - - /// Backpressure strategy to use in the server. - public var backpressureStrategy: BackPressureStrategy - - /// Backpressure strategy to use in the server. - public var http2: HTTP2 - - /// Create a new configuration. - /// - Parameters: - /// - bindTarget: A ``BindTarget``. - /// - tlsConfiguration: A ``TLSConfiguration``. Defaults to ``TLSConfiguration/insecure``. - /// - backpressureStrategy: A ``BackPressureStrategy``. - /// Defaults to ``BackPressureStrategy/watermark(low:high:)`` with a low watermark of 2 and a high of 10. - /// - http2: A ``HTTP2``. Defaults to ``HTTP2/defaults``. - public init( - bindTarget: BindTarget, - transportSecurity: TransportSecurity = .plaintext, - backpressureStrategy: BackPressureStrategy = .watermark(low: 2, high: 10), - http2: HTTP2 = .defaults - ) { - self.bindTarget = bindTarget - self.transportSecurity = transportSecurity - self.backpressureStrategy = backpressureStrategy - self.http2 = http2 - } -} diff --git a/Sources/HTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/HTTPServer/NIOHTTPServer+ConnectionContext.swift new file mode 100644 index 0000000..79a698e --- /dev/null +++ b/Sources/HTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -0,0 +1,29 @@ +import NIOCore +import NIOSSL +public import X509 + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer { + /// Connection-specific information available during request handling. + /// + /// Provides access to data such as the peer's validated certificate chain. + public struct ConnectionContext: Sendable { + var peerCertificateChainFuture: EventLoopFuture? + + init(_ peerCertificateChainFuture: EventLoopFuture? = nil) { + self.peerCertificateChainFuture = peerCertificateChainFuture + } + + /// The peer's validated certificate chain. This returns `nil` if a custom verification callback was not set + /// when configuring mTLS in the server configuration, or if the custom verification callback did not return the + /// derived validated chain. + public var peerCertificateChain: X509.ValidatedCertificateChain? { + get async throws { + if let certs = try await self.peerCertificateChainFuture?.get() { + return .init(uncheckedCertificateChain: try certs.map { try Certificate($0) }) + } + return nil + } + } + } +} diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index 43f3f6e..5175ee9 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -40,7 +40,7 @@ import X509 /// ## Usage /// /// ```swift -/// let configuration = HTTPServerConfiguration( +/// let configuration = NIOHTTPServerConfiguration( /// bindTarget: .hostAndPort(host: "localhost", port: 8080), /// tlsConfiguration: .insecure() /// ) @@ -81,17 +81,22 @@ public struct NIOHTTPServer: HTTPServerProtocol { public typealias ResponseWriter = HTTPResponseConcludingAsyncWriter private let logger: Logger - private let configuration: HTTPServerConfiguration + private let configuration: NIOHTTPServerConfiguration var listeningAddressState: NIOLockedValueBox + /// Task-local storage for connection-specific information accessible from request handlers. + /// + /// - SeeAlso: ``ConnectionContext``. + @TaskLocal public static var connectionContext = ConnectionContext() + /// Create a new ``HTTPServer`` implemented over `SwiftNIO`. /// - Parameters: /// - logger: A logger instance for recording server events and debugging information. /// - configuration: The server configuration including bind target and TLS settings. public init( logger: Logger, - configuration: HTTPServerConfiguration, + configuration: NIOHTTPServerConfiguration, ) { self.logger = logger self.configuration = configuration @@ -171,20 +176,8 @@ public struct NIOHTTPServer: HTTPServerProtocol { httpServerHTTP2Configuration: self.configuration.http2 ) - let certificateChain = try certificateChain - .map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - .map { NIOSSLCertificateSource.certificate($0) } - let privateKey = NIOSSLPrivateKeySource.privateKey( - try NIOSSLPrivateKey( - bytes: privateKey.serializeAsPEM().derBytes, - format: .der - ) - ) + let certificateChain = try certificateChain.map { try NIOSSLCertificateSource($0) } + let privateKey = try NIOSSLPrivateKeySource(privateKey) var tlsConfiguration: TLSConfiguration = .makeServerConfiguration( certificateChain: certificateChain, @@ -218,45 +211,21 @@ public struct NIOHTTPServer: HTTPServerProtocol { http2Configuration: http2Config ) - case .mTLS(let certificateChain, let privateKey, let trustRoots): + case .mTLS(let certificateChain, let privateKey, let trustRoots, let verificationMode, let verificationCallback): let http2Config = NIOHTTP2Handler.Configuration( httpServerHTTP2Configuration: configuration.http2 ) - let certificateChain = try certificateChain - .map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - .map { NIOSSLCertificateSource.certificate($0) } - let privateKey = NIOSSLPrivateKeySource.privateKey( - try NIOSSLPrivateKey( - bytes: privateKey.serializeAsPEM().derBytes, - format: .der - ) - ) - - let nioTrustRoots: NIOSSLTrustRoots - if let trustRoots { - nioTrustRoots = .certificates( - try trustRoots.map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - ) - } else { - nioTrustRoots = .default - } + let certificateChain = try certificateChain.map { try NIOSSLCertificateSource($0) } + let privateKey = try NIOSSLPrivateKeySource(privateKey) + let nioTrustRoots = try NIOSSLTrustRoots(treatingNilAsSystemTrustRoots: trustRoots) var tlsConfiguration: TLSConfiguration = .makeServerConfigurationWithMTLS( certificateChain: certificateChain, privateKey: privateKey, trustRoots: nioTrustRoots ) + tlsConfiguration.certificateVerification = .init(verificationMode) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( @@ -264,32 +233,22 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + http2Configuration: http2Config, + verificationCallback: verificationCallback ) - case .reloadingMTLS(let certificateReloader, let trustRoots): + case .reloadingMTLS(let certificateReloader, let trustRoots, let verificationMode, let verificationCallback): let http2Config = NIOHTTP2Handler.Configuration( httpServerHTTP2Configuration: configuration.http2 ) - let nioTrustRoots: NIOSSLTrustRoots - if let trustRoots { - nioTrustRoots = .certificates( - try trustRoots.map { - try NIOSSLCertificate( - bytes: $0.serializeAsPEM().derBytes, - format: .der - ) - } - ) - } else { - nioTrustRoots = .default - } + let nioTrustRoots = try NIOSSLTrustRoots(treatingNilAsSystemTrustRoots: trustRoots) var tlsConfiguration: TLSConfiguration = try .makeServerConfigurationWithMTLS( certificateReloader: certificateReloader, trustRoots: nioTrustRoots ) + tlsConfiguration.certificateVerification = .init(verificationMode) tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( @@ -297,13 +256,14 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Config + http2Configuration: http2Config, + verificationCallback: verificationCallback ) } } private func serveInsecureHTTP1_1( - bindTarget: HTTPServerConfiguration.BindTarget, + bindTarget: NIOHTTPServerConfiguration.BindTarget, handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration ) async throws { @@ -339,11 +299,12 @@ public struct NIOHTTPServer: HTTPServerProtocol { } private func serveSecureUpgrade( - bindTarget: HTTPServerConfiguration.BindTarget, + bindTarget: NIOHTTPServerConfiguration.BindTarget, tlsConfiguration: TLSConfiguration, handler: some HTTPServerRequestHandler, asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration + http2Configuration: NIOHTTP2Handler.Configuration, + verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil ) async throws { switch bindTarget.backing { case .hostAndPort(let host, let port): @@ -352,11 +313,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { .bind(host: host, port: port) { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations - .addHandler( - NIOSSLServerHandler( - context: .init(configuration: tlsConfiguration) - ) - ) + .addHandler(self.makeSSLServerHandler(tlsConfiguration, verificationCallback)) }.flatMap { channel.configureAsyncHTTPServerPipeline(http2Configuration: http2Configuration) { channel in channel.eventLoop.makeCompletedFuture { @@ -368,7 +325,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { ) } } http2ConnectionInitializer: { channel in - channel.eventLoop.makeSucceededVoidFuture() + channel.eventLoop.makeCompletedFuture(.success(channel)) } http2StreamInitializer: { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations @@ -395,20 +352,26 @@ public struct NIOHTTPServer: HTTPServerProtocol { try await withThrowingDiscardingTaskGroup { connectionGroup in switch try await upgradeResult.get() { case .http1_1(let http1Channel): - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http1Channel, - handler: handler - ) + let chainFuture = http1Channel.channel.nioSSL_peerValidatedCertificateChain() + Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http1Channel, + handler: handler + ) + } } - case .http2((_, let http2Multiplexer)): + case .http2((let http2Connection, let http2Multiplexer)): do { - for try await http2StreamChannel in http2Multiplexer.inbound { - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http2StreamChannel, - handler: handler - ) + let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() + try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + for try await http2StreamChannel in http2Multiplexer.inbound { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http2StreamChannel, + handler: handler + ) + } } } } catch { @@ -507,9 +470,50 @@ public struct NIOHTTPServer: HTTPServerProtocol { } } +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer { + fileprivate func makeSSLServerHandler( + _ tlsConfiguration: TLSConfiguration, + _ customVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + ) throws -> NIOSSLServerHandler { + if let customVerificationCallback { + return try NIOSSLServerHandler( + context: .init(configuration: tlsConfiguration), + customVerificationCallbackWithMetadata: { certificates, promise in + promise.completeWithTask { + // Convert input [NIOSSLCertificate] to [X509.Certificate] + let x509Certs = try certificates.map { try Certificate($0) } + + let callbackResult = try await customVerificationCallback(x509Certs) + + switch callbackResult { + case .certificateVerified(let verificationMetadata): + guard let peerChain = verificationMetadata.validatedCertificateChain else { + return .certificateVerified(.init(nil)) + } + + // Convert the result into [NIOSSLCertificate] + let nioSSLCerts = try peerChain.map { try NIOSSLCertificate($0) } + return .certificateVerified(.init(.init(nioSSLCerts))) + + case .failed(let error): + self.logger.error("Custom certificate verification failed", metadata: [ + "failure-reason": .string(error.reason) + ]) + return .failed + } + } + } + ) + } else { + return try NIOSSLServerHandler(context: .init(configuration: tlsConfiguration)) + } + } +} + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOHTTP2Handler.Configuration { - init(httpServerHTTP2Configuration http2Config: HTTPServerConfiguration.HTTP2) { + init(httpServerHTTP2Configuration http2Config: NIOHTTPServerConfiguration.HTTP2) { let clampedTargetWindowSize = Self.clampTargetWindowSize(http2Config.targetWindowSize) let clampedMaxFrameSize = Self.clampMaxFrameSize(http2Config.maxFrameSize) diff --git a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift new file mode 100644 index 0000000..c41b109 --- /dev/null +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -0,0 +1,353 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import NIOCertificateReloading +import NIOCore +import NIOSSL +public import X509 + +/// Configuration settings for ``NIOHTTPServer``. +/// +/// This structure contains all the necessary configuration options for setting up +/// and running ``NIOHTTPServer``, including network binding and TLS settings. +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +public struct NIOHTTPServerConfiguration: Sendable { + /// Specifies where the server should bind and listen for incoming connections. + /// + /// Currently supports binding to a specific host and port combination. + /// Additional binding targets may be added in the future. + public struct BindTarget: Sendable { + enum Backing { + case hostAndPort(host: String, port: Int) + } + + let backing: Backing + + /// Creates a bind target for a specific host and port. + /// + /// - Parameters: + /// - host: The hostname or IP address to bind to (e.g., "localhost", "0.0.0.0") + /// - port: The port number to listen on (e.g., 8080, 443) + /// - Returns: A configured `BindTarget` instance + /// + /// ## Example + /// ```swift + /// let target = BindTarget.hostAndPort(host: "localhost", port: 8080) + /// ``` + public static func hostAndPort(host: String, port: Int) -> Self { + Self(backing: .hostAndPort(host: host, port: port)) + } + } + + /// Configuration for transport security settings. + /// + /// Provides options for running the server with or without TLS encryption. + /// When using TLS, you must either provide a certificate chain and private key, or a `CertificateReloader`. + public struct TransportSecurity: Sendable { + enum Backing { + case plaintext + case tls( + certificateChain: [Certificate], + privateKey: Certificate.PrivateKey + ) + case reloadingTLS(certificateReloader: any CertificateReloader) + case mTLS( + certificateChain: [Certificate], + privateKey: Certificate.PrivateKey, + trustRoots: [Certificate]?, + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil + ) + case reloadingMTLS( + certificateReloader: any CertificateReloader, + trustRoots: [Certificate]?, + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil + ) + } + + let backing: Backing + + /// Configures the server for plaintext HTTP without TLS encryption. + public static let plaintext: Self = Self(backing: .plaintext) + + /// Configures the server for TLS with the provided certificate chain and private key. + /// - Parameters: + /// - certificateChain: The certificate chain to present during negotiation. + /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. + public static func tls( + certificateChain: [Certificate], + privateKey: Certificate.PrivateKey + ) -> Self { + Self( + backing: .tls( + certificateChain: certificateChain, + privateKey: privateKey + ) + ) + } + + /// Configures the server for TLS with automatic certificate reloading. + /// - Parameters: + /// - certificateReloader: The certificate reloader instance. + public static func tls(certificateReloader: any CertificateReloader) throws -> Self { + Self(backing: .reloadingTLS(certificateReloader: certificateReloader)) + } + + /// Configures the server for mTLS with support for customizing client certificate verification logic. + /// + /// - Parameters: + /// - certificateChain: The certificate chain to present during negotiation. + /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. + /// - trustRoots: The root certificates to trust when verifying client certificates. + /// - certificateVerification: Configures the client certificate validation behaviour. Defaults to + /// ``CertificateVerificationMode/noHostnameVerification``. + /// - customCertificateVerificationCallback: If specified, this callback *overrides* the default NIOSSL client + /// certificate verification logic. The callback receives the certificates presented by the peer. Within the + /// callback, you must validate these certificates against your trust roots and derive a validated chain of + /// trust per [RFC 4158](https://datatracker.ietf.org/doc/html/rfc4158). Return + /// ``CertificateVerificationResult/certificateVerified(_:)`` from the callback if verification succeeds, + /// optionally including the validated certificate chain you derived. Returning the validated certificate + /// chain allows ``NIOHTTPServer`` to provide access to it in the request handler through + /// ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, accessed via the task-local + /// ``NIOHTTPServer/connectionContext`` property. Otherwise, return + /// ``CertificateVerificationResult/failed(_:)`` if verification fails. + /// + /// - Warning: If `customCertificateVerificationCallback` is set, it will **override** NIOSSL's default + /// certificate verification logic. + public static func mTLS( + certificateChain: [Certificate], + privateKey: Certificate.PrivateKey, + trustRoots: [Certificate]?, + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: ( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + )? = nil + ) -> Self { + Self( + backing: .mTLS( + certificateChain: certificateChain, + privateKey: privateKey, + trustRoots: trustRoots, + customCertificateVerificationCallback: customCertificateVerificationCallback + ) + ) + } + + /// Configures the server for mTLS with automatic certificate reloading and support for customizing client + /// certificate verification logic. + /// + /// - Parameters: + /// - certificateReloader: The certificate reloader instance. + /// - trustRoots: The root certificates to trust when verifying client certificates. + /// - certificateVerification: Configures the client certificate validation behaviour. Defaults to + /// ``CertificateVerification/noHostnameVerification``. + /// - customCertificateVerificationCallback: If specified, this callback *overrides* the default NIOSSL client + /// certificate verification logic. The callback receives the certificates presented by the peer. Within the + /// callback, you must validate these certificates against your trust roots and derive a validated chain of + /// trust per [RFC 4158](https://datatracker.ietf.org/doc/html/rfc4158). Return + /// ``CertificateVerificationResult/certificateVerified(_:)`` from the callback if verification succeeds, + /// optionally including the validated certificate chain you derived. Returning the validated certificate + /// chain allows ``NIOHTTPServer`` to provide access to it in the request handler through + /// ``NIOHTTPServer/ConnectionContext/peerCertificateChain``, accessed via the task-local + /// ``NIOHTTPServer/connectionContext`` property. Otherwise, return + /// ``CertificateVerificationResult/failed(_:)`` if verification fails. + /// + /// - Warning: If `customCertificateVerificationCallback` is set, it will **override** NIOSSL's default + /// certificate verification logic. + public static func mTLS( + certificateReloader: any CertificateReloader, + trustRoots: [Certificate]?, + certificateVerification: CertificateVerificationMode = .noHostnameVerification, + customCertificateVerificationCallback: ( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + )? = nil + ) throws -> Self { + Self( + backing: .reloadingMTLS( + certificateReloader: certificateReloader, + trustRoots: trustRoots, + customCertificateVerificationCallback: customCertificateVerificationCallback + ) + ) + } + } + + /// HTTP/2 specific configuration. + public struct HTTP2: Sendable, Hashable { + /// The maximum frame size to be used in an HTTP/2 connection. + public var maxFrameSize: Int + + /// The target window size for this connection. + /// + /// - Note: This will also be set as the initial window size for the connection. + public var targetWindowSize: Int + + /// The number of concurrent streams on the HTTP/2 connection. + public var maxConcurrentStreams: Int? + + public init( + maxFrameSize: Int, + targetWindowSize: Int, + maxConcurrentStreams: Int? + ) { + self.maxFrameSize = maxFrameSize + self.targetWindowSize = targetWindowSize + self.maxConcurrentStreams = maxConcurrentStreams + } + + /// Default values. The max frame size defaults to 2^14, the target window size defaults to 2^16-1, and + /// the max concurrent streams default to infinite. + public static var defaults: Self { + Self( + maxFrameSize: 1 << 14, + targetWindowSize: (1 << 16) - 1, + maxConcurrentStreams: nil + ) + } + } + + /// Configuration for the backpressure strategy to use when reading requests and writing back responses. + public struct BackPressureStrategy: Sendable { + enum Backing { + case watermark(low: Int, high: Int) + } + + internal let backing: Backing + + private init(backing: Backing) { + self.backing = backing + } + + /// A low/high watermark will be applied when reading requests and writing responses. + /// - Parameters: + /// - low: The threshold below which the consumer will ask the producer to produce more elements. + /// - high: The threshold above which the producer will stop producing elements. + /// - Returns: A low/high watermark strategy with the configured thresholds. + public static func watermark(low: Int, high: Int) -> Self { + .init(backing: .watermark(low: low, high: high)) + } + } + + /// Network binding configuration + public var bindTarget: BindTarget + + /// TLS configuration for the server. + public var transportSecurity: TransportSecurity + + /// Backpressure strategy to use in the server. + public var backpressureStrategy: BackPressureStrategy + + /// Backpressure strategy to use in the server. + public var http2: HTTP2 + + /// Create a new configuration. + /// - Parameters: + /// - bindTarget: A ``BindTarget``. + /// - transportSecurity: A ``TransportSecurity``. Defaults to ``TransportSecurity/plaintext``. + /// - backpressureStrategy: A ``BackPressureStrategy``. + /// Defaults to ``BackPressureStrategy/watermark(low:high:)`` with a low watermark of 2 and a high of 10. + /// - http2: A ``HTTP2``. Defaults to ``HTTP2/defaults``. + public init( + bindTarget: BindTarget, + transportSecurity: TransportSecurity = .plaintext, + backpressureStrategy: BackPressureStrategy = .watermark(low: 2, high: 10), + http2: HTTP2 = .defaults + ) { + self.bindTarget = bindTarget + self.transportSecurity = transportSecurity + self.backpressureStrategy = backpressureStrategy + self.http2 = http2 + } +} + +/// Represents the outcome of certificate verification. +/// +/// Indicates whether certificate verification succeeded or failed, and provides associated metadata when verification +/// is successful. +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +public enum CertificateVerificationResult: Sendable, Hashable { + /// Metadata resulting from successful certificate verification. + public struct VerificationMetadata: Sendable, Hashable { + /// A container for the validated certificate chain: an array of certificates forming a verified and ordered + /// chain of trust, starting from the peer's leaf certificate to a trusted root certificate. + public var validatedCertificateChain: X509.ValidatedCertificateChain? + + /// Creates an instance with the peer's *validated* certificate chain. + /// + /// - Parameter validatedCertificateChain: An optional *validated* certificate chain. If provided, it must + /// **only** contain the **validated** chain of trust that was built and verified from the certificates + /// presented by the peer. + public init(_ validatedCertificateChain: X509.ValidatedCertificateChain?) { + self.validatedCertificateChain = validatedCertificateChain + } + } + + /// An error representing certificate verification failure. + public struct VerificationError: Swift.Error, Hashable { + public let reason: String + + /// Creates a verification error with the reason why verification failed. + /// - Parameter reason: The reason of why certificate verification failed. + public init(reason: String) { + self.reason = reason + } + } + + /// Certificate verification succeeded. + /// + /// The associated metadata contains information captured during verification. + case certificateVerified(VerificationMetadata) + + /// Certificate verification failed. + case failed(VerificationError) +} + +/// Represents the certificate verification behaviour. +public struct CertificateVerificationMode: Sendable { + enum VerificationMode { + case optionalVerification + case noHostnameVerification + } + + let mode: VerificationMode + + /// Allows peers to connect without presenting any certificates. However, if the peer *does* present + /// certificates, they are validated like normal (exactly like with ``noHostnameVerification``), and the TLS + /// handshake will fail if verification fails. + /// + /// - Warning: With this mode, a peer can successfully connect even without presenting any certificates. As such, + /// this mode must be used with great caution. + public static var optionalVerification: Self { + Self(mode: .optionalVerification) + } + + /// Validates the certificates presented by the peer but skips hostname verification as it cannot succeed in + /// a server context. + public static var noHostnameVerification: Self { + Self(mode: .noHostnameVerification) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOSSL.CertificateVerification { + /// Maps ``CertificateVerificationMode`` to the NIOSSL representation. + init(_ verificationMode: CertificateVerificationMode) { + switch verificationMode.mode { + case .noHostnameVerification: + self = .noHostnameVerification + case .optionalVerification: + self = .optionalVerification + } + } +} diff --git a/Sources/HTTPServer/NIOSSL+X509.swift b/Sources/HTTPServer/NIOSSL+X509.swift new file mode 100644 index 0000000..863b6db --- /dev/null +++ b/Sources/HTTPServer/NIOSSL+X509.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOSSL +import SwiftASN1 +import X509 + +/// Some convenience helpers for converting between NIOSSL and X509 certificate and private key types. + +// MARK: X509 to NIOSSL + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) +extension NIOSSLCertificate { + convenience init(_ certificate: Certificate) throws { + var serializer = DER.Serializer() + try certificate.serialize(into: &serializer) + try self.init(bytes: serializer.serializedBytes, format: .der) + } +} + +@available(macOS 11.0, iOS 14, tvOS 14, watchOS 7, macCatalyst 14, visionOS 1.0, *) +extension NIOSSLPrivateKey { + convenience init(_ privateKey: Certificate.PrivateKey) throws { + try self.init(bytes: try privateKey.serializeAsPEM().derBytes, format: .der) + } +} + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) +extension NIOSSLCertificateSource { + init(_ certificate: Certificate) throws { + self = .certificate(try NIOSSLCertificate(certificate)) + } +} + +@available(macOS 11.0, iOS 14, tvOS 14, watchOS 7, macCatalyst 14, visionOS 1.0, *) +extension NIOSSLPrivateKeySource { + init(_ privateKey: Certificate.PrivateKey) throws { + self = .privateKey(try NIOSSLPrivateKey(privateKey)) + } +} + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) +extension NIOSSLTrustRoots { + init(treatingNilAsSystemTrustRoots certificates: [Certificate]?) throws { + if let certificates { + self = .certificates(try certificates.map { try NIOSSLCertificate($0) }) + } else { + self = .default + } + } +} + +// MARK: NIOSSL to X509 + +@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, visionOS 1.0, *) +extension Certificate { + init(_ certificate: NIOSSLCertificate) throws { + try self.init(derEncoded: certificate.toDERBytes()) + } +} diff --git a/Sources/HTTPServer/SocketAddress.swift b/Sources/HTTPServer/SocketAddress.swift index 218df15..3b56809 100644 --- a/Sources/HTTPServer/SocketAddress.swift +++ b/Sources/HTTPServer/SocketAddress.swift @@ -11,8 +11,6 @@ // //===----------------------------------------------------------------------===// -import NIOCore - /// Represents an IPv4 address. public struct IPv4: Hashable, Sendable { /// The resolved host address. diff --git a/Tests/HTTPServerTests/HTTPServerTests.swift b/Tests/HTTPServerTests/HTTPServerTests.swift index dc210d1..17393af 100644 --- a/Tests/HTTPServerTests/HTTPServerTests.swift +++ b/Tests/HTTPServerTests/HTTPServerTests.swift @@ -25,32 +25,41 @@ struct HTTPServerTests { logger: Logger(label: "Test"), configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) ) - try await server.serve { request, context, requestBodyAndTrailers, responseSender in - _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. -// _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } - - let responseConcludingWriter = try await responseSender.send(HTTPResponse(status: .ok)) - // Uncommenting this would cause a "responseSender consumed more than once" error. -// let responseConcludingWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) - - // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. -// _ = try await requestBodyAndTrailers.consumeAndConclude { reader in -// var reader = reader -// try await reader.read { elem in } -// } - - try await responseConcludingWriter.produceAndConclude { writer in - var writer = writer - try await writer.write([1,2].span) - return nil + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, context, requestBodyAndTrailers, responseSender in + _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } + // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. + // _ = try await requestBodyAndTrailers.collect(upTo: 100) { _ in } + + let responseConcludingWriter = try await responseSender.send(HTTPResponse(status: .ok)) + // Uncommenting this would cause a "responseSender consumed more than once" error. + // let responseConcludingWriter2 = try await responseSender.send(HTTPResponse(status: .ok)) + + // Uncommenting this would cause a "requestBodyAndTrailers consumed more than once" error. + // _ = try await requestBodyAndTrailers.consumeAndConclude { reader in + // var reader = reader + // try await reader.read { elem in } + // } + + try await responseConcludingWriter.produceAndConclude { writer in + var writer = writer + try await writer.write([1, 2].span) + return nil + } + + // Uncommenting this would cause a "responseConcludingWriter consumed more than once" error. + // try await responseConcludingWriter.writeAndConclude( + // element: [1, 2].span, + // finalElement: HTTPFields(dictionaryLiteral: (.acceptEncoding, "Encoding")) + // ) + } } - // Uncommenting this would cause a "responseConcludingWriter consumed more than once" error. -// try await responseConcludingWriter.writeAndConclude( -// element: [1, 2].span, -// finalElement: HTTPFields(dictionaryLiteral: (.acceptEncoding, "Encoding")) -// ) + _ = try await server.listeningAddress + + group.cancelAll() } } } diff --git a/Tests/HTTPServerTests/NIOHTTPServerTests.swift b/Tests/HTTPServerTests/NIOHTTPServerTests.swift index 155ec1a..683e0f8 100644 --- a/Tests/HTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/HTTPServerTests/NIOHTTPServerTests.swift @@ -14,10 +14,9 @@ import HTTPTypes import Logging import NIOCore -import NIOHTTP1 import NIOHTTPTypes -import NIOPosix import Testing +import X509 @testable import HTTPServer @@ -27,6 +26,28 @@ import Dispatch @Suite struct NIOHTTPServerTests { + static let reqHead = HTTPRequestPart.head(.init(method: .post, scheme: "http", authority: "", path: "/")) + static let bodyData = ByteBuffer(repeating: 5, count: 100) + static let reqBody = HTTPRequestPart.body(Self.bodyData) + static let trailer: HTTPFields = [.trailer: "test_trailer"] + static let reqEnd = HTTPRequestPart.end(trailer) + + static func clientResponseHandler( + _ response: HTTPResponsePart, + expectedStatus: HTTPResponse.Status, + expectedBody: ByteBuffer, + expectedTrailers: HTTPFields? = nil + ) async throws { + switch response { + case .head(let head): + try #require(head.status == expectedStatus) + case .body(let body): + try #require(body == expectedBody) + case .end(let trailers): + try #require(trailers == expectedTrailers) + } + } + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) @Test("Obtain the listening address correctly") func testListeningAddress() async throws { @@ -54,4 +75,142 @@ struct NIOHTTPServerTests { try await server.listeningAddress } } + + @Test("Plaintext request-response") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testPlaintext() async throws { + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, requestContext, reader, responseWriter in + #expect(request.method == .post) + #expect(request.path == "/") + + var buffer = ByteBuffer() + let finalElement = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + return try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in + buffer.writeBytes(body.bytes) + } + } + #expect(buffer == Self.bodyData) + #expect(finalElement == Self.trailer) + + let responseBodySender = try await responseWriter.send(.init(status: .ok)) + try await responseBodySender.produceAndConclude { responseBodyWriter in + var responseBodyWriter = responseBodyWriter + try await responseBodyWriter.write([1, 2].span) + return Self.trailer + } + } + } + + let serverAddress = try await server.listeningAddress + + let client = try await setUpClient(host: serverAddress.host, port: serverAddress.port) + try await client.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + + for try await response in inbound { + try await Self.clientResponseHandler( + response, + expectedStatus: .ok, + expectedBody: .init([1, 2]), + expectedTrailers: Self.trailer + ) + } + } + + group.cancelAll() + } + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test("mTLS request-response with custom verification callback returning peer certificates", .serialized, arguments: ["http/1.1", "h2"]) + func testMTLS(applicationProtocol: String) async throws { + let serverChain = try TestCA.makeSelfSignedChain() + let clientChain = try TestCA.makeSelfSignedChain() + + let server = NIOHTTPServer( + logger: Logger(label: "Test"), + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + transportSecurity: .mTLS( + certificateChain: [serverChain.leaf], + privateKey: serverChain.privateKey, + trustRoots: [clientChain.ca], + customCertificateVerificationCallback: { certificates in + // Return the peer's certificate chain; this must then be accessible in the request handler + .certificateVerified(.init(.init(uncheckedCertificateChain: certificates))) + } + ) + ) + ) + + try await withThrowingTaskGroup { group in + group.addTask { + try await server.serve { request, requestContext, reader, responseWriter in + #expect(request.method == .post) + #expect(request.path == "/") + + do { + let peerChain = try #require(try await NIOHTTPServer.connectionContext.peerCertificateChain) + #expect(Array(peerChain) == [clientChain.leaf]) + } catch { + Issue.record("Could not obtain the peer's certificate chain: \(error)") + } + + let (buffer, finalElement) = try await reader.consumeAndConclude { bodyReader in + var bodyReader = bodyReader + var buffer = ByteBuffer() + _ = try await bodyReader.collect(upTo: Self.bodyData.readableBytes + 1) { body in + buffer.writeBytes(body.bytes) + } + return buffer + } + #expect(buffer == Self.bodyData) + #expect(finalElement == Self.trailer) + + let sender = try await responseWriter.send(.init(status: .ok)) + try await sender.produceAndConclude { bodyWriter in + var bodyWriter = bodyWriter + try await bodyWriter.write([1, 2].span) + return Self.trailer + } + } + } + + let serverAddress = try await server.listeningAddress + + let clientChannel = try await setUpClientWithMTLS( + at: serverAddress, + chain: clientChain, + trustRoots: [serverChain.ca], + applicationProtocol: applicationProtocol + ) + + try await clientChannel.executeThenClose { inbound, outbound in + try await outbound.write(Self.reqHead) + try await outbound.write(Self.reqBody) + try await outbound.write(Self.reqEnd) + + for try await response in inbound { + try await Self.clientResponseHandler( + response, + expectedStatus: .ok, + expectedBody: .init([1, 2]), + expectedTrailers: Self.trailer + ) + } + } + // Cancel the server and client task once we know the client has received the response + group.cancelAll() + } + } } diff --git a/Tests/HTTPServerTests/Utilities/Certificates.swift b/Tests/HTTPServerTests/Utilities/Certificates.swift new file mode 100644 index 0000000..e6afb1a --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/Certificates.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Crypto +import Foundation +import X509 + +struct ChainPrivateKeyPair { + let leaf: Certificate + let ca: Certificate + let privateKey: Certificate.PrivateKey +} + +struct TestCA { + static func makeSelfSignedChain() throws -> ChainPrivateKeyPair { + let caKey = P384.Signing.PrivateKey() + let caName = try DistinguishedName { OrganizationName("Test CA") } + let ca = try makeCA(name: caName, privateKey: caKey) + + let leafKey = P384.Signing.PrivateKey() + let leafName = try DistinguishedName { OrganizationName("Test") } + + let leaf = try makeCertificate( + issuerName: caName, + issuerKey: .init(caKey), + publicKey: .init(leafKey.publicKey), + subject: leafName, + extensions: .init() + ) + + return ChainPrivateKeyPair(leaf: leaf, ca: ca, privateKey: .init(leafKey)) + } + + static func makeCA(name: DistinguishedName, privateKey: P384.Signing.PrivateKey) throws -> Certificate { + try makeCertificate( + issuerName: name, + issuerKey: .init(privateKey), + publicKey: .init(privateKey.publicKey), + subject: name, + extensions: try .init { + BasicConstraints.isCertificateAuthority(maxPathLength: nil) + } + ) + } + + static func makeCertificate( + issuerName: DistinguishedName, + issuerKey: Certificate.PrivateKey, + publicKey: Certificate.PublicKey, + subject: DistinguishedName, + extensions: Certificate.Extensions + ) throws -> Certificate { + try Certificate( + version: .v3, + serialNumber: .init(), + publicKey: publicKey, + notValidBefore: .now - 60, + notValidAfter: .now + 60, + issuer: issuerName, + subject: subject, + signatureAlgorithm: .ecdsaWithSHA384, + extensions: extensions, + issuerPrivateKey: issuerKey + ) + } +} diff --git a/Tests/HTTPServerTests/Utilities/Client.swift b/Tests/HTTPServerTests/Utilities/Client.swift new file mode 100644 index 0000000..60a2522 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/Client.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import NIOHTTPTypesHTTP2 +import NIOPosix +import NIOSSL +import X509 + +@testable import HTTPServer + +typealias ClientChannel = NIOAsyncChannel + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +func setUpClient(host: String, port: Int) async throws -> ClientChannel { + try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .connect(to: try .init(ipAddress: host, port: port)) { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec()) + + return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + } + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +func setUpClientWithMTLS( + at address: HTTPServer.SocketAddress, + chain: ChainPrivateKeyPair, + trustRoots: [Certificate], + applicationProtocol: String, +) async throws -> ClientChannel { + var clientTLSConfig = TLSConfiguration.makeClientConfiguration() + clientTLSConfig.certificateChain = [try NIOSSLCertificateSource(chain.leaf)] + clientTLSConfig.privateKey = .privateKey(try .init(chain.privateKey)) + clientTLSConfig.trustRoots = .certificates(try trustRoots.map { try NIOSSLCertificate($0) }) + clientTLSConfig.certificateVerification = .noHostnameVerification + clientTLSConfig.applicationProtocols = [applicationProtocol] + + let sslContext = try NIOSSLContext(configuration: clientTLSConfig) + + let clientNegotiatedChannel = try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .connect(to: try .init(ipAddress: address.host, port: address.port)) { channel in + channel.eventLoop.makeCompletedFuture { + let sslHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: nil) + try channel.pipeline.syncOperations.addHandler(sslHandler) + }.flatMap { + channel.configureHTTP2AsyncSecureUpgrade( + http1ConnectionInitializer: { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandlers(HTTP1ToHTTPClientCodec()) + + return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + } + }, + http2ConnectionInitializer: { channel in + channel.configureAsyncHTTP2Pipeline(mode: .client) { $0.eventLoop.makeSucceededFuture($0) } + } + ) + } + }.get() + + switch clientNegotiatedChannel { + case .http1_1(let http1Channel): + precondition(applicationProtocol == "http/1.1", "Unexpectedly established a HTTP 1.1 channel") + return http1Channel + + case .http2(let http2Channel): + precondition(applicationProtocol == "h2", "Unexpectedly established a HTTP 2 channel") + return try await http2Channel.openStream { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) + return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + } + } + } +} diff --git a/Tests/HTTPServerTests/Utilities/SocketAddress.swift b/Tests/HTTPServerTests/Utilities/SocketAddress.swift new file mode 100644 index 0000000..a6f5e98 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/SocketAddress.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import HTTPServer + +extension HTTPServer.SocketAddress { + var host: String { + switch self.base { + case .ipv4(let ipv4): + return ipv4.host + case .ipv6(let ipv6): + return ipv6.host + } + } + + var port: Int { + switch self.base { + case .ipv4(let ipv4): + return ipv4.port + case .ipv6(let ipv6): + return ipv6.port + } + } +}