diff --git a/Sources/Example/Example.swift b/Sources/Example/Example.swift index f057092..6d82e46 100644 --- a/Sources/Example/Example.swift +++ b/Sources/Example/Example.swift @@ -40,22 +40,25 @@ struct Example { logger: logger, configuration: .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 12345), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], transportSecurity: .tls( - certificateChain: [ - try Certificate( - version: .v3, - serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), - publicKey: .init(privateKey.publicKey), - notValidBefore: Date.now.addingTimeInterval(-60), - notValidAfter: Date.now.addingTimeInterval(60 * 60), - issuer: DistinguishedName(), - subject: DistinguishedName(), - signatureAlgorithm: .ecdsaWithSHA256, - extensions: .init(), - issuerPrivateKey: Certificate.PrivateKey(privateKey) - ) - ], - privateKey: Certificate.PrivateKey(privateKey) + credentials: .inMemory( + certificateChain: [ + try Certificate( + version: .v3, + serialNumber: .init(bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]), + publicKey: .init(privateKey.publicKey), + notValidBefore: Date.now.addingTimeInterval(-60), + notValidAfter: Date.now.addingTimeInterval(60 * 60), + issuer: DistinguishedName(), + subject: DistinguishedName(), + signatureAlgorithm: .ecdsaWithSHA256, + extensions: .init(), + issuerPrivateKey: Certificate.PrivateKey(privateKey) + ) + ], + privateKey: Certificate.PrivateKey(privateKey) + ) ) ) ) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift similarity index 66% rename from Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift rename to Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 0bff668..46d61af 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -21,10 +21,14 @@ import SwiftASN1 public import X509 enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { + case customVerificationCallbackAndTrustRootsProvided case customVerificationCallbackProvidedWhenNotUsingMTLS var description: String { switch self { + case .customVerificationCallbackAndTrustRootsProvided: + "Invalid configuration. Both a custom certificate verification callback and a set of trust roots were provided. When a custom verification callback is provided, trust must be established directly within the callback." + case .customVerificationCallbackProvidedWhenNotUsingMTLS: "Invalid configuration. A custom certificate verification callback was provided despite the server not being configured for mTLS." } @@ -40,11 +44,15 @@ extension NIOHTTPServerConfiguration { /// ``NIOHTTPServerConfiguration`` is comprised of four types. Provide configuration for each type under the /// specified key: /// - ``BindTarget`` - Provide under key `"bindTarget"` (keys listed in ``BindTarget/init(config:)``). + /// + /// - ``SupportedHTTPVersions`` - Provide under key `"supportedHTTPVersions"` (keys listed in + /// ``SupportedHTTPVersions/init(config:)``). + /// /// - ``TransportSecurity`` - Provide under key `"transportSecurity"` (keys listed in /// ``TransportSecurity/init(config:customCertificateVerificationCallback:)``). + /// /// - ``BackPressureStrategy`` - Provide under key `"backpressureStrategy"` (keys listed in /// ``BackPressureStrategy/init(config:)``). - /// - ``HTTP2`` - Provide under key `"http2"` (keys listed in ``HTTP2/init(config:)``). /// /// - Parameters: /// - config: The configuration reader to read configuration values from. @@ -64,12 +72,12 @@ extension NIOHTTPServerConfiguration { self.init( bindTarget: try .init(config: snapshot.scoped(to: "bindTarget")), + supportedHTTPVersions: try .init(config: snapshot), transportSecurity: try .init( config: snapshot.scoped(to: "transportSecurity"), customCertificateVerificationCallback: customCertificateVerificationCallback ), - backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")), - http2: .init(config: snapshot.scoped(to: "http2")) + backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")) ) } } @@ -93,6 +101,49 @@ extension NIOHTTPServerConfiguration.BindTarget { } } +private enum HTTPVersionKind: String { + case http1_1 + case http2 +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Set where Element == NIOHTTPServerConfiguration.HTTPVersion { + /// Initialize a supported HTTP versions configuration from a config reader. + /// + /// ## Configuration keys: + /// - `supportedHTTPVersions` (string array, required): A set of HTTP versions the server should support (permitted + /// values: `"http1_1"`, `"http2"`). If `"http2"` is contained in this array, then HTTP/2 configuration can be + /// specified under the `"http2"` key. See ``NIOHTTPServerConfiguration/HTTP2/init(config:)`` for the supported + /// keys under `"http2"`. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) throws { + self = .init() + + let versions = Set( + try config.requiredStringArray( + forKey: "supportedHTTPVersions", + as: HTTPVersionKind.self + ) + ) + + if versions.isEmpty { + fatalError("Invalid configuration: at least one supported HTTP version must be specified.") + } + + for version in versions { + switch version { + case .http1_1: + self.insert(.http1_1) + + case .http2: + let h2Config = NIOHTTPServerConfiguration.HTTP2(config: config.scoped(to: "http2")) + self.insert(.http2(config: h2Config)) + } + } + } +} + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServerConfiguration.TransportSecurity { /// Initialize a transport security configuration from a config reader. @@ -114,7 +165,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// ### Configuration keys for `"mTLS"`: /// - `certificateChainPEMString` (string, required): PEM-formatted certificate chain content. /// - `privateKeyPEMString` (string, required, secret): PEM-formatted private key content. - /// - `trustRoots` (string array, optional, default: system trust roots): The root certificates to trust when + /// - `trustRootsPEMString` (string, optional, default: system trust roots): The root certificates to trust when /// verifying client certificates. /// - `certificateVerificationMode` (string, required): The client certificate validation behavior (permitted /// values: "optionalVerification" or "noHostnameVerification"). @@ -124,7 +175,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// private key will be reloaded. /// - `certificateChainPEMPath` (string, required): Path to the certificate chain PEM file. /// - `privateKeyPEMPath` (string, required): Path to the private key PEM file. - /// - `trustRoots` (string array, optional, default: system trust roots): The root certificates to trust when + /// - `trustRootsPEMString` (string, optional, default: system trust roots): The root certificates to trust when /// verifying client certificates. /// - `certificateVerificationMode` (string, required): The client certificate validation behavior (permitted /// values: "optionalVerification" or "noHostnameVerification"). @@ -179,9 +230,11 @@ extension NIOHTTPServerConfiguration.TransportSecurity { let privateKeyPEMString = try config.requiredString(forKey: "privateKeyPEMString", isSecret: true) return Self.tls( - certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) - .map { try Certificate(pemEncoded: $0.pemString) }, - privateKey: try .init(pemEncoded: privateKeyPEMString) + credentials: .inMemory( + certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) + .map { try Certificate(pemEncoded: $0.pemString) }, + privateKey: try .init(pemEncoded: privateKeyPEMString) + ) ) } @@ -190,11 +243,13 @@ extension NIOHTTPServerConfiguration.TransportSecurity { let certificateChainPEMPath = try config.requiredString(forKey: "certificateChainPEMPath") let privateKeyPEMPath = try config.requiredString(forKey: "privateKeyPEMPath") - return try Self.tls( - certificateReloader: TimedCertificateReloader( - refreshInterval: .seconds(refreshInterval), - certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), - privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + return Self.tls( + credentials: .reloading( + certificateReloader: TimedCertificateReloader( + refreshInterval: .seconds(refreshInterval), + certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), + privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + ) ) ) } @@ -207,19 +262,17 @@ extension NIOHTTPServerConfiguration.TransportSecurity { ) throws -> Self { let certificateChainPEMString = try config.requiredString(forKey: "certificateChainPEMString") let privateKeyPEMString = try config.requiredString(forKey: "privateKeyPEMString", isSecret: true) - let trustRoots = config.stringArray(forKey: "trustRoots") - let verificationMode = try config.requiredString( - forKey: "certificateVerificationMode", - as: VerificationMode.self - ) return Self.mTLS( - certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) - .map { try Certificate(pemEncoded: $0.pemString) }, - privateKey: try .init(pemEncoded: privateKeyPEMString), - trustRoots: try trustRoots?.map { try Certificate(pemEncoded: $0) }, - certificateVerification: .init(verificationMode), - customCertificateVerificationCallback: customCertificateVerificationCallback + credentials: .inMemory( + certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) + .map { try Certificate(pemEncoded: $0.pemString) }, + privateKey: try .init(pemEncoded: privateKeyPEMString) + ), + trustConfiguration: try .init( + config: config, + customCertificateVerificationCallback: customCertificateVerificationCallback + ) ) } @@ -232,25 +285,76 @@ extension NIOHTTPServerConfiguration.TransportSecurity { let refreshInterval = config.int(forKey: "refreshInterval", default: 30) let certificateChainPEMPath = try config.requiredString(forKey: "certificateChainPEMPath") let privateKeyPEMPath = try config.requiredString(forKey: "privateKeyPEMPath") - let trustRoots = config.stringArray(forKey: "trustRoots") - let verificationMode = try config.requiredString( - forKey: "certificateVerificationMode", - as: VerificationMode.self - ) return try Self.mTLS( - certificateReloader: TimedCertificateReloader( - refreshInterval: .seconds(refreshInterval), - certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), - privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + credentials: .reloading( + certificateReloader: TimedCertificateReloader( + refreshInterval: .seconds(refreshInterval), + certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), + privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + ) ), - trustRoots: try trustRoots?.map { try Certificate(pemEncoded: $0) }, - certificateVerification: .init(verificationMode), - customCertificateVerificationCallback: customCertificateVerificationCallback + trustConfiguration: .init( + config: config, + customCertificateVerificationCallback: customCertificateVerificationCallback + ) ) } } +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration { + /// Initialize an mTLS trust configuration from a config reader. + /// + /// ## Configuration keys: + /// - `trustRootsPEMString` (string, optional, default: system trust roots): The root certificates to trust when + /// verifying client certificates. + /// - `certificateVerificationMode` (string, required): The client certificate validation behavior (permitted + /// values: "optionalVerification" or "noHostnameVerification") + /// + /// - Parameters: + /// - config: The configuration reader. + /// - customCertificateVerificationCallback: An optional client certificate verification callback to use when + /// mTLS is configured (i.e., when `"transportSecurity.security"` is `"mTLS"` or `"reloadingMTLS"`). If set to + /// `nil`, the default client certificate verification logic of the underlying SSL implementation is used. + /// + /// - Note: It is invalid to pass both a custom verification callback and a set of trust roots. If using a custom + /// verification callback, trust must be established within the callback itself. Providing both will result in a + /// `NIOHTTPServerConfigurationError.customVerificationCallbackAndTrustRootsProvided` error. + public init( + config: ConfigSnapshotReader, + customCertificateVerificationCallback: ( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + )? + ) throws { + let trustRootsPEMString = config.string(forKey: "trustRootsPEMString") + let certificateVerificationMode = try config.requiredString( + forKey: "certificateVerificationMode", + as: VerificationMode.self + ) + + if trustRootsPEMString != nil, customCertificateVerificationCallback != nil { + // Throw if both `trustRoots` and `customCertificateVerificationCallback` are provided. + throw NIOHTTPServerConfigurationError.customVerificationCallbackAndTrustRootsProvided + } + + if let trustRootsPEMString { + self = .inMemory( + trustRoots: try PEMDocument.parseMultiple(pemString: trustRootsPEMString) + .map { try Certificate(pemEncoded: $0.pemString) }, + certificateVerification: .init(certificateVerificationMode) + ) + } else if let customCertificateVerificationCallback { + self = .customCertificateVerificationCallback( + customCertificateVerificationCallback, + certificateVerification: .init(certificateVerificationMode) + ) + } else { + self = .systemDefaults(certificateVerification: .init(certificateVerificationMode)) + } + } +} + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServerConfiguration.BackPressureStrategy { /// Initialize the backpressure strategy configuration from a config reader. @@ -343,7 +447,10 @@ extension NIOHTTPServerConfiguration.TransportSecurity { } } } +} +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration { /// A wrapper over ``CertificateVerificationMode``. fileprivate enum VerificationMode: String { case optionalVerification @@ -353,7 +460,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension CertificateVerificationMode { - fileprivate init(_ mode: NIOHTTPServerConfiguration.TransportSecurity.VerificationMode) { + fileprivate init(_ mode: NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration.VerificationMode) { switch mode { case .optionalVerification: self.init(mode: .optionalVerification) diff --git a/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift similarity index 60% rename from Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift rename to Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index 183a7f7..3d34ffb 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -2,7 +2,7 @@ // // 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 +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors // Licensed under Apache License v2.0 // // See LICENSE.txt for license information @@ -12,7 +12,6 @@ // //===----------------------------------------------------------------------===// -public import NIOCertificateReloading import NIOCore import NIOSSL public import X509 @@ -57,27 +56,10 @@ public struct NIOHTTPServerConfiguration: Sendable { public struct TransportSecurity: Sendable { enum Backing { case plaintext - case tls( - certificateChain: [Certificate], - privateKey: Certificate.PrivateKey - ) - case reloadingTLS(certificateReloader: any CertificateReloader) + case tls(credentials: TLSCredentials) 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 + credentials: TLSCredentials, + trustConfiguration: MTLSTrustConfiguration ) } @@ -86,107 +68,55 @@ public struct NIOHTTPServerConfiguration: Sendable { /// 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 TLS with the provided credentials. + /// + /// - Parameter credentials: The TLS credentials containing the certificate chain and private key + /// to present during the TLS handshake. + public static func tls(credentials: TLSCredentials) -> Self { + Self(backing: .tls(credentials: credentials)) } - /// Configures the server for mTLS with support for customizing client certificate verification logic. + /// Configures the server for mTLS with the provided credentials and trust configuration. /// /// - 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. + /// - credentials: The TLS credentials containing the certificate chain and private key + /// to present during the TLS handshake. + /// - trustConfiguration: The trust roots and certificate verification mode to use when + /// validating client certificates. public static func mTLS( - certificateChain: [Certificate], - privateKey: Certificate.PrivateKey, - trustRoots: [Certificate]?, - certificateVerification: CertificateVerificationMode = .noHostnameVerification, - customCertificateVerificationCallback: ( - @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult - )? = nil + credentials: TLSCredentials, + trustConfiguration: MTLSTrustConfiguration ) -> Self { Self( backing: .mTLS( - certificateChain: certificateChain, - privateKey: privateKey, - trustRoots: trustRoots, - certificateVerification: certificateVerification, - customCertificateVerificationCallback: customCertificateVerificationCallback + credentials: credentials, + trustConfiguration: trustConfiguration ) ) } - /// Configures the server for mTLS with automatic certificate reloading and support for customizing client - /// certificate verification logic. + /// The custom mTLS certificate verification callback, if one was configured. /// - /// - 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, - certificateVerification: certificateVerification, - customCertificateVerificationCallback: customCertificateVerificationCallback - ) - ) + /// Returns the callback when the transport security is configured for mTLS with a + /// ``MTLSTrustConfiguration/customCertificateVerificationCallback(_:certificateVerification:)``, + /// or `nil` otherwise. + var customVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + { + switch self.backing { + case .tls, .plaintext: + // A custom certificate verification callback is an mTLS concept (the callback verifies the certificates + // presented by the client); it doesn't apply for plaintext and TLS. + return nil + + case .mTLS(_, let trustRoots): + switch trustRoots.backing { + case .customCertificateVerificationCallback(let callback): + return callback + + case .systemDefaults, .inMemory, .pemFile: + return nil + } + } } } @@ -306,29 +236,41 @@ public struct NIOHTTPServerConfiguration: Sendable { /// TLS configuration for the server. public var transportSecurity: TransportSecurity - /// Backpressure strategy to use in the server. - public var backpressureStrategy: BackPressureStrategy + /// The HTTP protocol versions the server advertises and accepts connections for. + public var supportedHTTPVersions: Set /// Backpressure strategy to use in the server. - public var http2: HTTP2 + public var backpressureStrategy: BackPressureStrategy /// Create a new configuration. /// - Parameters: /// - bindTarget: A ``BindTarget``. - /// - transportSecurity: A ``TransportSecurity``. Defaults to ``TransportSecurity/plaintext``. + /// - supportedHTTPVersions: The HTTP protocol versions the server should support. + /// - transportSecurity: The transport security mode (plaintext, TLS, or mTLS). /// - 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 = .defaults, - http2: HTTP2 = .defaults + supportedHTTPVersions: Set, + transportSecurity: TransportSecurity, + backpressureStrategy: BackPressureStrategy = .defaults ) { + // If `transportSecurity`` is set to `.plaintext`, the server can only support HTTP/1.1. To support HTTP/2, + // `transportSecurity` must be set to `.tls` or `.mTLS`. + if case .plaintext = transportSecurity.backing, supportedHTTPVersions != [.http1_1] { + fatalError( + "Only HTTP/1.1 can be served over plaintext. transportSecurity must be set to (m)TLS for serving HTTP/2." + ) + } + + if supportedHTTPVersions.isEmpty { + fatalError("Invalid configuration: at least one supported HTTP version must be specified.") + } + self.bindTarget = bindTarget + self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity self.backpressureStrategy = backpressureStrategy - self.http2 = http2 } } @@ -374,7 +316,7 @@ public enum CertificateVerificationResult: Sendable, Hashable { case failed(VerificationError) } -/// Represents the certificate verification behaviour. +/// Represents the certificate verification behavior. public struct CertificateVerificationMode: Sendable { enum VerificationMode { case optionalVerification @@ -412,3 +354,96 @@ extension NIOSSL.CertificateVerification { } } } + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration { + /// Represents an HTTP version. + public struct HTTPVersion: Sendable, Hashable { + enum Version { + case http1_1 + case http2(config: HTTP2) + + /// The HTTP/2 configuration if this version is HTTP/2, or `nil` if it is HTTP/1.1. + var http2Config: HTTP2? { + switch self { + case .http1_1: + return nil + case .http2(let config): + return config + } + } + } + + let version: Version + + /// The HTTP/1.1 protocol version. + public static var http1_1: Self { + Self(version: .http1_1) + } + + /// The HTTP/2 protocol version. + /// + /// - Parameter config: The configuration to use for HTTP/2. + public static func http2(config: HTTP2) -> Self { + Self(version: .http2(config: config)) + } + + /// Two values are equal if they represent the same protocol version, regardless of any differences in HTTP/2 + /// configuration. + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs.version, rhs.version) { + case (.http1_1, .http1_1), (.http2, .http2): + return true + + default: + return false + } + } + + /// Hashes by protocol version only. Consistent with the ``Equatable`` conformance. + public func hash(into hasher: inout Hasher) { + switch self.version { + case .http1_1: + hasher.combine(1) + + case .http2: + hasher.combine(2) + } + } + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOAsyncSequenceProducerBackPressureStrategies.HighLowWatermark { + init(_ backpressureStrategy: NIOHTTPServerConfiguration.BackPressureStrategy) { + switch backpressureStrategy.backing { + case .watermark(let low, let high): + self.init(lowWatermark: low, highWatermark: high) + } + } +} + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Set where Element == NIOHTTPServerConfiguration.HTTPVersion { + /// The ALPN protocol identifiers to advertise during the TLS handshake, derived from the supported HTTP versions. + /// + /// Returns `"h2"` if HTTP/2 is supported, and `"http/1.1"` if HTTP/1.1 is supported, in that order of preference. + var alpnIdentifiers: [String] { + var identifiers = [String]() + + if self.http2ConfigIfSupported != nil { + identifiers.append("h2") + } + + if self.contains(.http1_1) { + identifiers.append("http/1.1") + } + + return identifiers + } + + /// The HTTP/2 configuration if HTTP/2 is among the supported versions, or `nil` if only HTTP/1.1 is supported. + var http2ConfigIfSupported: NIOHTTPServerConfiguration.HTTP2? { + self.compactMap({ $0.version.http2Config }).first + } +} diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift new file mode 100644 index 0000000..62ad9dc --- /dev/null +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+MTLSTrustConfiguration.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOSSL +public import X509 + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration.TransportSecurity { + /// Configures how the server verifies client certificates during mTLS. + public struct MTLSTrustConfiguration: Sendable { + enum Backing { + case systemDefaults + case inMemory(trustRoots: [Certificate]) + case pemFile(path: String) + case customCertificateVerificationCallback( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + ) + } + + let backing: Backing + let certificateVerification: CertificateVerificationMode + + /// Verifies client certificates against the operating system's default trust store. + /// + /// - Parameter certificateVerification: The client certificate verification behavior. Defaults to + /// ``CertificateVerificationMode/noHostnameVerification``. + public static func systemDefaults( + certificateVerification: CertificateVerificationMode = .noHostnameVerification + ) -> Self { + Self(backing: .systemDefaults, certificateVerification: certificateVerification) + } + + /// Verifies client certificates against the provided in-memory trust roots. + /// + /// - Parameters: + /// - trustRoots: The root certificates to trust when verifying client certificates. + /// - certificateVerification: The client certificate verification behavior. Defaults to + /// ``CertificateVerificationMode/noHostnameVerification``. + public static func inMemory( + trustRoots: [Certificate], + certificateVerification: CertificateVerificationMode = .noHostnameVerification + ) -> Self { + Self( + backing: .inMemory(trustRoots: trustRoots), + certificateVerification: certificateVerification + ) + } + + /// Verifies client certificates against trust roots loaded from a PEM-encoded file. + /// + /// - Parameters: + /// - path: The file path to the PEM-encoded trust root certificates. + /// - certificateVerification: The client certificate verification behavior. Defaults to + /// ``CertificateVerificationMode/noHostnameVerification``. + public static func pemFile( + path: String, + certificateVerification: CertificateVerificationMode = .noHostnameVerification + ) -> Self { + Self( + backing: .pemFile(path: path), + certificateVerification: certificateVerification + ) + } + + /// Uses a custom callback to verify client certificates, overriding the default NIOSSL verification logic. + /// + /// - Parameters: + /// - callback: 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. + /// - certificateVerification: The client certificate verification behavior. Defaults to + /// ``CertificateVerificationMode/noHostnameVerification``. + /// + /// - Warning: The provided `callback` will override NIOSSL's default certificate verification logic. + public static func customCertificateVerificationCallback( + _ callback: @escaping @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult, + certificateVerification: CertificateVerificationMode = .noHostnameVerification + ) -> Self { + Self( + backing: .customCertificateVerificationCallback(callback), + certificateVerification: certificateVerification + ) + } + } +} diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+NIOSSL.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+NIOSSL.swift new file mode 100644 index 0000000..50692f0 --- /dev/null +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+NIOSSL.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCertificateReloading +import NIOSSL +import X509 + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOSSL.TLSConfiguration { + /// Creates a `NIOSSL.TLSConfiguration` from the server's TLS credentials and mTLS trust configuration. + static func makeServerConfiguration( + tlsCredentials: NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials, + mTLSConfiguration: NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration? + ) throws -> Self { + var config: Self + + switch tlsCredentials.backing { + case .inMemory(let certificateChain, let privateKey): + config = .makeServerConfiguration( + certificateChain: try certificateChain.map { try NIOSSLCertificateSource($0) }, + privateKey: try NIOSSLPrivateKeySource(privateKey) + ) + + case .reloading(let certificateReloader): + config = try .makeServerConfiguration(certificateReloader: certificateReloader) + + case .pemFile(let certificateChainPath, let privateKeyPath): + config = try .makeServerConfiguration( + certificateChain: NIOSSLCertificate.fromPEMFile(certificateChainPath).map { .certificate($0) }, + privateKey: .privateKey(.init(file: privateKeyPath, format: .pem)) + ) + } + + if let mTLSConfiguration { + switch mTLSConfiguration.backing { + case .systemDefaults: + config.trustRoots = .default + + case .inMemory(let trustRoots): + config.trustRoots = .certificates(try trustRoots.map { try NIOSSLCertificate($0) }) + + case .pemFile(let path): + config.trustRoots = .file(path) + + case .customCertificateVerificationCallback: + // There are no trust roots when a custom certificate verification callback is specified: the callback + // itself is responsible for establishing trust. + () + } + + config.certificateVerification = .init(mTLSConfiguration.certificateVerification) + } + + return config + } +} diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift new file mode 100644 index 0000000..2928635 --- /dev/null +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public import NIOCertificateReloading +public import X509 + +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration.TransportSecurity { + /// Represents the server's TLS credentials: a certificate chain and its corresponding private key. + /// + /// Credentials can be provided as in-memory objects, loaded from PEM files on disk, or automatically reloaded at + /// runtime using a `CertificateReloader`. + public struct TLSCredentials: Sendable { + enum Backing { + case inMemory(certificateChain: [Certificate], privateKey: Certificate.PrivateKey) + case reloading(certificateReloader: any CertificateReloader) + case pemFile(certificateChainPath: String, privateKeyPath: String) + } + + let backing: Backing + + /// Credentials from in-memory certificate objects. + /// + /// - Parameters: + /// - certificateChain: The certificate chain to present during the TLS handshake. + /// - privateKey: The private key corresponding to the leaf certificate in `certificateChain`. + public static func inMemory(certificateChain: [Certificate], privateKey: Certificate.PrivateKey) -> Self { + Self(backing: .inMemory(certificateChain: certificateChain, privateKey: privateKey)) + } + + /// Credentials backed by a `CertificateReloader` that periodically refreshes the certificate chain and + /// private key. + /// + /// - Parameter certificateReloader: The reloader responsible for refreshing the credentials. + public static func reloading(certificateReloader: any CertificateReloader) -> Self { + Self(backing: .reloading(certificateReloader: certificateReloader)) + } + + /// Credentials loaded from PEM-encoded files on disk. + /// + /// - Parameters: + /// - certificateChainPath: The file path to the PEM-encoded certificate chain. + /// - privateKeyPath: The file path to the PEM-encoded private key, corresponding to the leaf certificate in + /// `certificateChainPath`. + public static func pemFile(certificateChainPath: String, privateKeyPath: String) -> Self { + Self(backing: .pemFile(certificateChainPath: certificateChainPath, privateKeyPath: privateKeyPath)) + } + } +} diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index d52cdc6..8cd4a0d 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -41,8 +41,7 @@ extension NIOHTTPServer { } func setupHTTP1_1ServerChannel( - bindTarget: NIOHTTPServerConfiguration.BindTarget, - asyncChannelConfiguration: NIOAsyncChannel.Configuration + bindTarget: NIOHTTPServerConfiguration.BindTarget ) async throws -> NIOAsyncChannel, Never> { switch bindTarget.backing { case .hostAndPort(let host, let port): @@ -58,7 +57,10 @@ extension NIOHTTPServer { .bind(host: host, port: port) { channel in self.setupHTTP1_1ConnectionChildChannel( channel: channel, - asyncChannelConfiguration: asyncChannelConfiguration + asyncChannelConfiguration: .init( + backPressureStrategy: .init(self.configuration.backpressureStrategy), + isOutboundHalfClosureEnabled: true + ) ) } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index a8fe91f..65657f7 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -14,6 +14,7 @@ import HTTPAPIs import Logging +import NIOCertificateReloading import NIOCore import NIOEmbedded import NIOExtras @@ -24,6 +25,7 @@ import NIOHTTPTypesHTTP1 import NIOHTTPTypesHTTP2 import NIOPosix import NIOSSL +import NIOTLS import X509 @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) @@ -83,10 +85,8 @@ extension NIOHTTPServer { func setupSecureUpgradeServerChannel( bindTarget: NIOHTTPServerConfiguration.BindTarget, - tlsConfiguration: TLSConfiguration, - asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTPServerConfiguration.HTTP2, - verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + supportedHTTPVersions: Set, + tlsConfiguration: TLSConfiguration ) async throws -> NIOAsyncChannel, Never> { switch bindTarget.backing { case .hostAndPort(let host, let port): @@ -102,10 +102,8 @@ extension NIOHTTPServer { .bind(host: host, port: port) { channel in self.setupSecureUpgradeConnectionChildChannel( channel: channel, - tlsConfiguration: tlsConfiguration, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: http2Configuration, - verificationCallback: verificationCallback + supportedHTTPVersions: supportedHTTPVersions, + tlsConfiguration: tlsConfiguration ) } @@ -115,63 +113,118 @@ extension NIOHTTPServer { } } + private func http1ConnectionInitializer( + channel: any Channel + ) -> EventLoopFuture> { + channel.pipeline.configureHTTPServerPipeline().flatMap { _ in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init( + backPressureStrategy: .init(self.configuration.backpressureStrategy), + isOutboundHalfClosureEnabled: true + ) + ) + } + } + } + + private func http2ConnectionInitializer( + channel: any Channel, + configuration: NIOHTTPServerConfiguration.HTTP2 + ) -> EventLoopFuture< + ( + any Channel, + NIOHTTP2Handler.AsyncStreamMultiplexer> + ) + > { + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( + mode: .server, + connectionManagerConfiguration: .init( + maxIdleTime: nil, + maxAge: nil, + maxGraceTime: configuration.gracefulShutdown.maximumGracefulShutdownDuration + .map { TimeAmount($0) }, + keepalive: nil + ), + http2HandlerConfiguration: .init(httpServerHTTP2Configuration: configuration), + streamInitializer: { http2StreamChannel in + http2StreamChannel.eventLoop.makeCompletedFuture { + try http2StreamChannel.pipeline.syncOperations + .addHandler( + HTTP2FramePayloadToHTTPServerCodec() + ) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: http2StreamChannel, + configuration: .init( + backPressureStrategy: .init(self.configuration.backpressureStrategy), + isOutboundHalfClosureEnabled: true + ) + ) + } + } + ) + } + .flatMap { multiplexer in + channel.eventLoop.makeCompletedFuture(.success((channel, multiplexer))) + } + } + func setupSecureUpgradeConnectionChildChannel( channel: any Channel, - tlsConfiguration: TLSConfiguration, - asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTPServerConfiguration.HTTP2, - verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + supportedHTTPVersions: Set, + tlsConfiguration: TLSConfiguration ) -> EventLoopFuture> { channel.eventLoop.makeCompletedFuture { + var tlsConfiguration = tlsConfiguration + // Set the application protocols to the appropriate value depending upon whether we want to serve HTTP/1.1, + // HTTP/2, or both. + tlsConfiguration.applicationProtocols = supportedHTTPVersions.alpnIdentifiers + try channel.pipeline.syncOperations.addHandler( - self.makeSSLServerHandler(tlsConfiguration, verificationCallback) + self.makeSSLServerHandler( + tlsConfiguration, + self.configuration.transportSecurity.customVerificationCallback + ) ) }.flatMap { - channel.configureHTTP2AsyncSecureUpgrade( - http1ConnectionInitializer: { http1Channel in - http1Channel.pipeline.configureHTTPServerPipeline().flatMap { _ in - http1Channel.eventLoop.makeCompletedFuture { - try http1Channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: http1Channel, - configuration: asyncChannelConfiguration - ) - } - } - }, - http2ConnectionInitializer: { http2Channel in - http2Channel.eventLoop.makeCompletedFuture { - try http2Channel.pipeline.syncOperations.configureAsyncHTTP2Pipeline( - mode: .server, - connectionManagerConfiguration: .init( - maxIdleTime: nil, - maxAge: nil, - maxGraceTime: http2Configuration.gracefulShutdown.maximumGracefulShutdownDuration - .map { TimeAmount($0) }, - keepalive: nil - ), - http2HandlerConfiguration: .init(httpServerHTTP2Configuration: http2Configuration), - streamInitializer: { http2StreamChannel in - http2StreamChannel.eventLoop.makeCompletedFuture { - try http2StreamChannel.pipeline.syncOperations - .addHandler( - HTTP2FramePayloadToHTTPServerCodec() - ) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: http2StreamChannel, - configuration: asyncChannelConfiguration - ) - } - } - ) - } - .flatMap { multiplexer in - http2Channel.eventLoop.makeCompletedFuture(.success((http2Channel, multiplexer))) - } + channel.eventLoop.makeCompletedFuture { + let alpnHandler = self.makeALPNHandler( + channel: channel, + http2Config: supportedHTTPVersions.http2ConfigIfSupported + ) + + do { + try channel.pipeline.syncOperations.addHandler(alpnHandler) + } catch { + return channel.eventLoop.makeFailedFuture(error) } - ) + + return alpnHandler.protocolNegotiationResult + } + } + } + + private func makeALPNHandler( + channel: any Channel, + http2Config: NIOHTTPServerConfiguration.HTTP2? + ) -> NIOTypedApplicationProtocolNegotiationHandler { + NIOTypedApplicationProtocolNegotiationHandler { result in + switch (result, http2Config) { + case (.negotiated("http/1.1"), _): + return self.http1ConnectionInitializer(channel: channel).map { .http1_1($0) } + + case (.negotiated("h2"), .some(let http2Config)): + return self.http2ConnectionInitializer(channel: channel, configuration: http2Config).map { .http2($0) } + + case (.negotiated, _), (.fallback, _): + // The negotiated result was an unsupported protocol, or ALPN negotiation failed / never took place. + return channel.close().flatMap { channel.eventLoop.makeFailedFuture(NIOHTTP2Errors.invalidALPNToken()) } + } } } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 1f3c974..6e7e5e0 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -85,7 +85,7 @@ public struct NIOHTTPServer: HTTPServer { public typealias ResponseConcludingWriter = HTTPResponseConcludingAsyncWriter let logger: Logger - private let configuration: NIOHTTPServerConfiguration + let configuration: NIOHTTPServerConfiguration let serverQuiescingHelper: ServerQuiescingHelper @@ -171,100 +171,30 @@ public struct NIOHTTPServer: HTTPServer { /// Creates and returns a server channel based on the configured transport security. private func makeServerChannel() async throws -> ServerChannel { - let asyncChannelConfiguration: NIOAsyncChannel.Configuration - switch self.configuration.backpressureStrategy.backing { - case .watermark(let low, let high): - asyncChannelConfiguration = .init( - backPressureStrategy: .init(lowWatermark: low, highWatermark: high), - isOutboundHalfClosureEnabled: true - ) - } - switch self.configuration.transportSecurity.backing { case .plaintext: - return .plaintextHTTP1( - try await self.setupHTTP1_1ServerChannel( - bindTarget: self.configuration.bindTarget, - asyncChannelConfiguration: asyncChannelConfiguration - ) - ) - - case .tls(let certificateChain, let privateKey): - let certificateChain = try certificateChain.map { try NIOSSLCertificateSource($0) } - let privateKey = try NIOSSLPrivateKeySource(privateKey) - - var tlsConfiguration: TLSConfiguration = .makeServerConfiguration( - certificateChain: certificateChain, - privateKey: privateKey - ) - tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] - - return .secureUpgrade( - try await self.setupSecureUpgradeServerChannel( - bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: self.configuration.http2, - verificationCallback: nil - ) + return .plaintextHTTP1_1( + try await self.setupHTTP1_1ServerChannel(bindTarget: self.configuration.bindTarget) ) - case .reloadingTLS(let certificateReloader): - var tlsConfiguration: TLSConfiguration = try .makeServerConfiguration( - certificateReloader: certificateReloader - ) - tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] - - return .secureUpgrade( - try await self.setupSecureUpgradeServerChannel( - bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: self.configuration.http2, - verificationCallback: nil - ) - ) - - case .mTLS(let certificateChain, let privateKey, let trustRoots, let verificationMode, let verificationCallback): - 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"] - + case .tls(let credentials): return .secureUpgrade( try await self.setupSecureUpgradeServerChannel( bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: self.configuration.http2, - verificationCallback: verificationCallback + supportedHTTPVersions: self.configuration.supportedHTTPVersions, + tlsConfiguration: try .makeServerConfiguration(tlsCredentials: credentials, mTLSConfiguration: nil) ) ) - case .reloadingMTLS(let certificateReloader, let trustRoots, let verificationMode, let verificationCallback): - 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"] - + case .mTLS(let credentials, let mTLSConfiguration): return .secureUpgrade( try await self.setupSecureUpgradeServerChannel( bindTarget: self.configuration.bindTarget, - tlsConfiguration: tlsConfiguration, - asyncChannelConfiguration: asyncChannelConfiguration, - http2Configuration: self.configuration.http2, - verificationCallback: verificationCallback + supportedHTTPVersions: self.configuration.supportedHTTPVersions, + tlsConfiguration: try .makeServerConfiguration( + tlsCredentials: credentials, + mTLSConfiguration: mTLSConfiguration + ) ) ) } @@ -275,7 +205,7 @@ public struct NIOHTTPServer: HTTPServer { handler: some HTTPServerRequestHandler ) async throws { switch serverChannel { - case .plaintextHTTP1(let http1Channel): + case .plaintextHTTP1_1(let http1Channel): try await self.serveInsecureHTTP1_1(serverChannel: http1Channel, handler: handler) case .secureUpgrade(let secureUpgradeChannel): @@ -385,7 +315,7 @@ public struct NIOHTTPServer: HTTPServer { self.finishListeningAddressPromise() switch serverChannel { - case .plaintextHTTP1(let http1Channel): + case .plaintextHTTP1_1(let http1Channel): http1Channel.channel.close(promise: nil) case .secureUpgrade(let secureUpgradeChannel): diff --git a/Sources/NIOHTTPServer/ServerChannel.swift b/Sources/NIOHTTPServer/ServerChannel.swift index 4fc7e04..3cb81ee 100644 --- a/Sources/NIOHTTPServer/ServerChannel.swift +++ b/Sources/NIOHTTPServer/ServerChannel.swift @@ -20,7 +20,7 @@ extension NIOHTTPServer { /// Abstracts over the two types of server channels ``NIOHTTPServer`` can create: plaintext HTTP/1.1 and Secure /// Upgrade. enum ServerChannel { - case plaintextHTTP1(NIOAsyncChannel, Never>) + case plaintextHTTP1_1(NIOAsyncChannel, Never>) case secureUpgrade(NIOAsyncChannel, Never>) } } diff --git a/Tests/NIOHTTPServerTests/HTTPServerTests.swift b/Tests/NIOHTTPServerTests/HTTPServerTests.swift index 0e5da7f..dfb882a 100644 --- a/Tests/NIOHTTPServerTests/HTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/HTTPServerTests.swift @@ -24,7 +24,11 @@ struct HTTPServerTests { func testConsumingServe() async throws { let server = NIOHTTPServer( logger: Logger(label: "Test"), - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) ) try await withThrowingTaskGroup { group in diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 25504ea..91d4211 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -40,7 +40,11 @@ struct NIOHTTPServiceLifecycleTests { func activeHTTP1ConnectionCanCompleteWhenGracefulShutdown() async throws { let server = NIOHTTPServer( logger: self.serverLogger, - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) ) // This promise will be fulfilled when the server receives the first part of the body. Once this happens, we can @@ -122,7 +126,11 @@ struct NIOHTTPServiceLifecycleTests { func activeHTTP1ConnectionForcefullyShutdownWhenServerTaskCancelled() async throws { let server = NIOHTTPServer( logger: self.serverLogger, - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) ) // This promise will be fulfilled when the server receives the first part of the request body. Once this @@ -196,8 +204,13 @@ struct NIOHTTPServiceLifecycleTests { logger: self.serverLogger, configuration: .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - transportSecurity: .tls(certificateChain: serverChain.chain, privateKey: serverChain.privateKey), - http2: .init(gracefulShutdown: .init(maximumGracefulShutdownDuration: .milliseconds(500))) + supportedHTTPVersions: [ + .http1_1, + .http2(config: .init(gracefulShutdown: .init(maximumGracefulShutdownDuration: .milliseconds(500)))), + ], + transportSecurity: .tls( + credentials: .inMemory(certificateChain: serverChain.chain, privateKey: serverChain.privateKey) + ) ) ) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift index 06c3859..8bf9f74 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -73,20 +73,20 @@ struct NIOHTTPServerEndToEndTests { @Test("HTTP/2 negotiation") func testHTTP2Negotiation() async throws { let serverChain = try TestCA.makeSelfSignedChain() - var serverTLSConfig = TLSConfiguration.makeServerConfiguration( - certificateChain: [try .init(serverChain.leaf)], - privateKey: try .init(serverChain.privateKey) - ) - serverTLSConfig.applicationProtocols = ["h2", "http/1.1"] - var clientTLSConfig = TLSConfiguration.makeClientConfiguration() clientTLSConfig.trustRoots = try .init(treatingNilAsSystemTrustRoots: [serverChain.ca]) clientTLSConfig.certificateVerification = .noHostnameVerification - clientTLSConfig.applicationProtocols = ["h2"] + clientTLSConfig.applicationProtocols = ["http/1.1", "h2"] try await TestingChannelSecureUpgradeServer.serve( logger: Logger(label: "NIOHTTPServerEndToEndTests"), - tlsConfiguration: serverTLSConfig, + transportSecurity: .tls( + credentials: .inMemory( + certificateChain: serverChain.chain, + privateKey: serverChain.privateKey + ) + ), + supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in let sender = try await resSender.send(.init(status: .ok)) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index f5635d8..776ba66 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -51,10 +51,9 @@ struct NIOHTTPServerSwiftConfigurationTests { let config = ConfigReader(provider: provider) let snapshot = config.snapshot() - let error = #expect(throws: Error.self) { + let configError = try #require(throws: Error.self) { try NIOHTTPServerConfiguration.BindTarget(config: snapshot) } - let configError = try #require(error) #expect("Missing required config value for key: host." == "\(configError)") } @@ -66,10 +65,9 @@ struct NIOHTTPServerSwiftConfigurationTests { let config = ConfigReader(provider: provider) let snapshot = config.snapshot() - let error = #expect(throws: Error.self) { + let configError = try #require(throws: Error.self) { try NIOHTTPServerConfiguration.BindTarget(config: snapshot) } - let configError = try #require(error) #expect("Missing required config value for key: port." == "\(configError)") } @@ -127,6 +125,57 @@ struct NIOHTTPServerSwiftConfigurationTests { } } + @Suite("SupportedHTTPVersions") + struct SupportedHTTPVersionsTests { + @Test("Empty supported version set is invalid") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testEmptySupportedHTTPVersionSetFails() async { + await #expect(processExitsWith: .failure) { + let provider = InMemoryProvider(values: [ + "supportedHTTPVersions": .init(.stringArray([]), isSecret: false) + ]) + + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + _ = try Set(config: snapshot) + } + } + + @Test("Unrecognized versions are ignored") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testUnrecognizedHTTPVersionIgnored() throws { + let provider = InMemoryProvider(values: [ + "supportedHTTPVersions": .init(.stringArray(["unrecognized_version"]), isSecret: false) + ]) + + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let configError = try #require(throws: Error.self) { + _ = try Set(config: snapshot) + } + + #expect( + "Config value for key 'supportedHTTPVersions' failed to cast to type HTTPVersionKind." + == "\(configError)" + ) + } + + @Test("Default HTTP/2 configuration used when not specified") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testDefaultHTTP2ConfigurationUsed() throws { + let provider = InMemoryProvider(values: [ + "supportedHTTPVersions": .init(.stringArray(["http1_1", "http2"]), isSecret: false) + ]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let supportedVersions = try Set(config: snapshot) + #expect(supportedVersions.contains(.http1_1)) + #expect(supportedVersions.http2ConfigIfSupported == .defaults) + } + } + @Suite("HTTP2") struct HTTP2Tests { @Test("Default values") @@ -189,10 +238,9 @@ struct NIOHTTPServerSwiftConfigurationTests { let config = ConfigReader(provider: provider) let snapshot = config.snapshot() - let error = #expect(throws: Error.self) { + let configError = try #require(throws: Error.self) { try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) } - let configError = try #require(error) #expect("Config value for key 'security' failed to cast to type TransportSecurityKind." == "\(configError)") } @@ -239,13 +287,18 @@ struct NIOHTTPServerSwiftConfigurationTests { let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) - switch transportSecurity.backing { - case .tls(let certificateChain, let privateKey): - #expect(certificateChain == chain.chain) - #expect(privateKey == chain.privateKey) - default: - Issue.record("Expected TLS backing, got different type") + guard case .tls(let credentials) = transportSecurity.backing else { + Issue.record("Expected TLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .inMemory(let certificateChain, let privateKey) = credentials.backing else { + Issue.record("Expected in-memory TLS credentials, got \(credentials.backing) instead.") + return } + + #expect(certificateChain == chain.chain) + #expect(privateKey == chain.privateKey) } @Test("Init fails with missing certificate") @@ -263,10 +316,9 @@ struct NIOHTTPServerSwiftConfigurationTests { let config = ConfigReader(provider: provider) let snapshot = config.snapshot() - let error = #expect(throws: Error.self) { + let configError = try #require(throws: Error.self) { try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) } - let configError = try #require(error) #expect("Missing required config value for key: certificateChainPEMString." == "\(configError)") } @@ -286,10 +338,9 @@ struct NIOHTTPServerSwiftConfigurationTests { let config = ConfigReader(provider: provider) let snapshot = config.snapshot() - let error = #expect(throws: Error.self) { + let configError = try #require(throws: Error.self) { try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) } - let configError = try #require(error) #expect("Missing required config value for key: privateKeyPEMString." == "\(configError)") } @@ -313,8 +364,13 @@ struct NIOHTTPServerSwiftConfigurationTests { let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) - guard case .reloadingTLS = transportSecurity.backing else { - Issue.record("Expected reloadingTLS backing, got \(transportSecurity.backing)") + guard case .tls(let credentials) = transportSecurity.backing else { + Issue.record("Expected TLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .reloading = credentials.backing else { + Issue.record("Expected reloading TLS credentials, got \(credentials.backing) instead.") return } } @@ -326,18 +382,15 @@ struct NIOHTTPServerSwiftConfigurationTests { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testValidConfigWithCustomVerificationCallback() throws { let serverChain = try TestCA.makeSelfSignedChain() - let clientChain = try TestCA.makeSelfSignedChain() let certsPEM = try serverChain.chainPEMString let keyPEM = try serverChain.privateKey.serializeAsPEM().pemString - let trustRootPEM = try clientChain.ca.serializeAsPEM().pemString let provider = InMemoryProvider( values: [ "security": "mTLS", "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), - "trustRoots": .init(.stringArray([trustRootPEM]), isSecret: false), "certificateVerificationMode": "noHostnameVerification", ] ) @@ -352,16 +405,27 @@ struct NIOHTTPServerSwiftConfigurationTests { } ) - switch transportSecurity.backing { - case .mTLS(let certificateChain, let privateKey, let trustRoots, let verification, let callback): - #expect(certificateChain == [serverChain.leaf, serverChain.ca]) - #expect(privateKey == serverChain.privateKey) - #expect(trustRoots == [clientChain.ca]) - #expect(verification.mode == .noHostnameVerification) - #expect(callback != nil) - default: - Issue.record("Expected mTLS backing, got \(transportSecurity.backing)") + guard case .mTLS(let tlsCredentials, let mTLSTrustConfiguration) = transportSecurity.backing else { + Issue.record("Expected mTLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") + return + } + + #expect(certificateChain == [serverChain.leaf, serverChain.ca]) + #expect(privateKey == serverChain.privateKey) + + guard case .customCertificateVerificationCallback = mTLSTrustConfiguration.backing else { + Issue.record( + "Expected a custom verification callback, got \(mTLSTrustConfiguration.backing) instead." + ) + return } + + #expect(mTLSTrustConfiguration.certificateVerification.mode == .noHostnameVerification) } @Test("Optional verification mode") @@ -384,14 +448,24 @@ struct NIOHTTPServerSwiftConfigurationTests { let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) - switch transportSecurity.backing { - case .mTLS(let certificateChain, let privateKey, _, let verification, _): - #expect(certificateChain == [serverChain.leaf, serverChain.ca]) - #expect(privateKey == serverChain.privateKey) - #expect(verification.mode == .optionalVerification) - default: - Issue.record("Expected mTLS backing, got \(transportSecurity.backing)") + guard case .mTLS(let tlsCredentials, let mTLSTrustConfiguration) = transportSecurity.backing else { + Issue.record("Expected mTLS transport security, got \(transportSecurity.backing) instead.") + return } + + guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") + return + } + + #expect(certificateChain == [serverChain.leaf, serverChain.ca]) + #expect(privateKey == serverChain.privateKey) + + guard case .systemDefaults = mTLSTrustConfiguration.backing else { + Issue.record("Expected system default trust roots, got \(mTLSTrustConfiguration.backing) instead.") + return + } + #expect(mTLSTrustConfiguration.certificateVerification.mode == .optionalVerification) } @Test("Invalid verification mode") @@ -413,10 +487,9 @@ struct NIOHTTPServerSwiftConfigurationTests { let config = ConfigReader(provider: provider) let snapshot = config.snapshot() - let error = #expect(throws: Error.self) { + let configError = try #require(throws: Error.self) { try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) } - let configError = try #require(error) #expect( "Config value for key 'certificateVerificationMode' failed to cast to type VerificationMode." @@ -445,15 +518,24 @@ struct NIOHTTPServerSwiftConfigurationTests { let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) - switch transportSecurity.backing { - case .mTLS(_, _, let trustRoots, _, _): - // trustRoots should be nil - #expect(trustRoots == nil) - default: - Issue.record("Expected mTLS backing, got \(transportSecurity.backing)") + guard case .mTLS(let tlsCredentials, let mTLSTrustConfiguration) = transportSecurity.backing else { + Issue.record("Expected mTLS transport security, got \(transportSecurity.backing) instead.") + return } - } + guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") + return + } + + #expect(certificateChain == [serverChain.leaf, serverChain.ca]) + #expect(privateKey == serverChain.privateKey) + + guard case .systemDefaults = mTLSTrustConfiguration.backing else { + Issue.record("Expected system default trust roots, got \(mTLSTrustConfiguration.backing) instead.") + return + } + } } @Suite @@ -469,7 +551,7 @@ struct NIOHTTPServerSwiftConfigurationTests { "security": "reloadingMTLS", "certificateChainPEMPath": .init(.string("certs.pem"), isSecret: false), "privateKeyPEMPath": .init(.string("key.pem"), isSecret: false), - "trustRoots": .init(.stringArray([trustRootPEM]), isSecret: false), + "trustRootsPEMString": .init(.string(trustRootPEM), isSecret: false), "certificateVerificationMode": "noHostnameVerification", "refreshInterval": 45, ] @@ -479,12 +561,107 @@ struct NIOHTTPServerSwiftConfigurationTests { let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) - switch transportSecurity.backing { - case .reloadingMTLS(_, let trustRoots, _, _): - #expect(trustRoots == [chain.ca]) - default: - Issue.record("Expected reloadingMTLS backing, got different type") + guard case .mTLS(let tlsCredentials, let mTLSTrustConfiguration) = transportSecurity.backing else { + Issue.record("Expected mTLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .reloading = tlsCredentials.backing else { + Issue.record("Expected reloading TLS credentials, got \(tlsCredentials.backing) instead.") + return + } + + guard case .inMemory(let trustRoots) = mTLSTrustConfiguration.backing else { + Issue.record("Expected in-memory trust roots, got \(mTLSTrustConfiguration.backing) instead.") + return } + #expect(trustRoots == [chain.ca]) + } + } + } + + @Suite("End-to-End") + struct EndToEndConfigurationTests { + @Test("Configure all possible values") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func fullConfiguration() throws { + let chain = try TestCA.makeSelfSignedChain() + let certsPEM = try chain.chainPEMString + let keyPEM = try chain.privateKey.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8000, + "supportedHTTPVersions": .init(.stringArray(["http1_1", "http2"]), isSecret: false), + "http2.maxFrameSize": 1, + "http2.targetWindowSize": 2, + "http2.maxConcurrentStreams": 3, + "http2.maximumGracefulShutdownDuration": 4, + "transportSecurity.security": .init(.string("mTLS"), isSecret: false), + "transportSecurity.certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + "transportSecurity.privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "transportSecurity.trustRootsPEMString": .init(.string(certsPEM), isSecret: false), + "transportSecurity.certificateVerificationMode": "optionalVerification", + ] + ) + let config = ConfigReader(provider: provider) + + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + guard case .hostAndPort(host: "127.0.0.1", port: 8000) = serverConfig.bindTarget.backing else { + Issue.record( + "Expected bind target to be 127.0.0.1:8000, got \(serverConfig.bindTarget.backing) instead." + ) + return + } + + #expect(serverConfig.supportedHTTPVersions.contains(.http1_1)) + #expect( + serverConfig.supportedHTTPVersions.http2ConfigIfSupported + == .init( + maxFrameSize: 1, + targetWindowSize: 2, + maxConcurrentStreams: 3, + gracefulShutdown: .init(maximumGracefulShutdownDuration: .seconds(4)) + ) + ) + + guard case .mTLS(let tlsCredentials, let trustConfig) = serverConfig.transportSecurity.backing else { + Issue.record("Expected mTLS transport security, got \(serverConfig.transportSecurity.backing) instead.") + return + } + + guard case .inMemory(let certificateChain, let privateKey) = tlsCredentials.backing else { + Issue.record("Expected in-memory TLS credentials, got \(tlsCredentials.backing) instead.") + return + } + + guard case .inMemory(let trustRoots) = trustConfig.backing else { + Issue.record("Expected in-memory trust roots, got \(trustConfig.backing) instead.") + return + } + + #expect(trustRoots == chain.chain) + #expect(certificateChain == chain.chain) + #expect(privateKey == chain.privateKey) + } + + @Test("Only HTTP/1.1 supported over plaintext") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func onlyHTTP1_1SupportedOverPlaintext() async { + await #expect(processExitsWith: .failure) { + let provider = InMemoryProvider( + values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8000, + "supportedHTTPVersions": .init(.stringArray(["http1_1", "http2"]), isSecret: false), + "transportSecurity.security": .init(.string("plaintext"), isSecret: false), + ] + ) + let config = ConfigReader(provider: provider) + + _ = try NIOHTTPServerConfiguration(config: config) } } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index a831133..b86f8d0 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -39,8 +39,12 @@ struct NIOHTTPServerTests { @Test("Obtain the listening address correctly") func testListeningAddress() async throws { let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 1234)) + logger: Logger(label: "NIOHTTPServerTests"), + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 1234), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) ) try await Self.withServer( @@ -63,8 +67,12 @@ struct NIOHTTPServerTests { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testPlaintext() async throws { let server = NIOHTTPServer( - logger: self.serverLogger, - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 0)) + logger: Logger(label: "NIOHTTPServerTests"), + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) ) try await Self.withServer( @@ -122,11 +130,13 @@ struct NIOHTTPServerTests { logger: self.serverLogger, configuration: .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1, .http2(config: .init())], transportSecurity: .mTLS( - certificateChain: [serverChain.leaf], - privateKey: serverChain.privateKey, - trustRoots: [clientChain.ca], - customCertificateVerificationCallback: { certificates in + credentials: .inMemory( + certificateChain: [serverChain.leaf], + privateKey: serverChain.privateKey, + ), + trustConfiguration: .customCertificateVerificationCallback { certificates in // Return the peer's certificate chain; this must then be accessible in the request handler .certificateVerified(.init(.init(uncheckedCertificateChain: certificates))) } @@ -515,7 +525,10 @@ extension NIOHTTPServerTests { logger: self.serverLogger, configuration: .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - transportSecurity: .tls(certificateChain: serverChain.chain, privateKey: serverChain.privateKey) + supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], + transportSecurity: .tls( + credentials: .inMemory(certificateChain: serverChain.chain, privateKey: serverChain.privateKey) + ) ) ) diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift index cfaba2f..e62e954 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+HTTP1.swift @@ -47,7 +47,11 @@ struct TestingChannelHTTP1Server { let server = NIOHTTPServer( logger: logger, // The server won't actually be bound to this host and port; we just have to pass this argument. - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) ) // Create a test channel. We will run the server on this channel. let serverTestChannel = NIOAsyncTestingChannel() diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift index a31fc1a..c494122 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift @@ -29,25 +29,24 @@ struct TestingChannelSecureUpgradeServer { let server: NIOHTTPServer let serverTestChannel: NIOAsyncTestingChannel - let tlsConfiguration: TLSConfiguration - let tlsVerificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? - let http2Configuration: NIOHTTPServerConfiguration.HTTP2 - /// Sets up the server with a testing channel and the provided request handler, starts the server, and provides /// `Self` to the `body` closure. Call `withConnection(clientTLSConfiguration:body:)` on the provided instance to /// simulate incoming connections. static func serve( logger: Logger, - tlsConfiguration: TLSConfiguration, - tlsVerificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? = nil, - http2Configuration: NIOHTTPServerConfiguration.HTTP2 = .init(), + transportSecurity: NIOHTTPServerConfiguration.TransportSecurity, + supportedHTTPVersions: Set, handler: some HTTPServerRequestHandler, body: (Self) async throws -> Void ) async throws { let server = NIOHTTPServer( logger: logger, // The server won't actually be bound to this host and port; we just have to pass this argument - configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) + configuration: .init( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000), + supportedHTTPVersions: supportedHTTPVersions, + transportSecurity: transportSecurity, + ) ) // Create a test channel. We will run the server on this channel. @@ -60,15 +59,7 @@ struct TestingChannelSecureUpgradeServer { } // Execute the provided closure. - try await body( - Self( - server: server, - serverTestChannel: serverTestChannel, - tlsConfiguration: tlsConfiguration, - tlsVerificationCallback: tlsVerificationCallback, - http2Configuration: http2Configuration - ) - ) + try await body(Self(server: server, serverTestChannel: serverTestChannel)) group.cancelAll() } @@ -83,14 +74,28 @@ struct TestingChannelSecureUpgradeServer { // Create a connection channel: we will write this to the server channel to simulate an incoming connection. let serverTestConnectionChannel = try await NIOAsyncTestingChannel.createActiveChannel() + let tlsConfiguration: TLSConfiguration + + switch self.server.configuration.transportSecurity.backing { + case .plaintext: + fatalError("Plaintext transport security is not supported for Secure Upgrade transport.") + + case .tls(let credentials): + tlsConfiguration = try .makeServerConfiguration(tlsCredentials: credentials, mTLSConfiguration: nil) + + case .mTLS(let credentials, let trustConfiguration): + tlsConfiguration = try .makeServerConfiguration( + tlsCredentials: credentials, + mTLSConfiguration: trustConfiguration + ) + } + // Set up the required channel handlers on `serverTestConnectionChannel` let negotiatedServerConnectionFuture = try await serverTestConnectionChannel.eventLoop.flatSubmit { self.server.setupSecureUpgradeConnectionChildChannel( channel: serverTestConnectionChannel, - tlsConfiguration: self.tlsConfiguration, - asyncChannelConfiguration: .init(), - http2Configuration: self.http2Configuration, - verificationCallback: self.tlsVerificationCallback + supportedHTTPVersions: self.server.configuration.supportedHTTPVersions, + tlsConfiguration: tlsConfiguration ) }.get()