From 043c39d5fdc0e2043d4cf240c1a69b78df3217d8 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 3 Aug 2026 16:49:49 +0100 Subject: [PATCH 01/16] Validate configuration at initialization --- .../HTTP3/HTTP3+QUICConfiguration.swift | 86 ++-- ...IOHTTPServerConfiguration+Validation.swift | 110 +++++ .../NIOHTTPServerConfiguration.swift | 46 ++- .../NIOHTTPServerConfigurationError.swift | 4 +- .../NIOHTTPServer/NIOHTTPServer+HTTP3.swift | 25 +- .../NIOHTTPServer+SecureUpgrade.swift | 37 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 101 ++--- .../HTTP3ConfigurationTests.swift | 27 +- .../NIOHTTPServerConfigurationTests.swift | 388 ++++++++++++++++++ ...NIOHTTPServerSwiftConfigurationTests.swift | 5 +- .../Utilities/Certificates.swift | 28 ++ .../Utilities/Helpers.swift | 1 + .../TestingChannelServer+SecureUpgrade.swift | 11 +- 13 files changed, 683 insertions(+), 186 deletions(-) create mode 100644 Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift create mode 100644 Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift diff --git a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift index c2e7d14..3d9f931 100644 --- a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift @@ -228,33 +228,24 @@ extension NIOQUIC.KeyExchangeGroup { @available(anyAppleOS 26.0, *) extension NIOQUIC.AuthenticationConfiguration { - init(_ transportSecurity: NIOHTTPServerConfiguration.TransportSecurity) throws { - switch transportSecurity.backing { - case .plaintext: - throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity - - case .mTLS: - throw NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3 - - case .tls(let tlsCredentials): - switch tlsCredentials.backing { - case .x509(let x509Credentials): - switch x509Credentials.backing { - case .serialized(.file(let certificateChain, let privateKey, format: .pem)): - self = .x509Certificates(certificateChainFilePath: certificateChain, privateKeyFilePath: privateKey) - - case .certificates, .reloading, .serialized(.file(_, _, .der)), .serialized(.bytes): - throw NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 - } - - case .rawPublicKey(let rawPublicKeyCredentials): - switch rawPublicKeyCredentials.backing { - case .file(let publicKey, let privateKey, .der): - self = .rawPublicKeys(publicKeyFilePath: publicKey, privateKeyFilePath: privateKey) - - case .file(_, _, .pem): - throw NIOHTTPServerConfigurationError.pemRawPublicKeysNotCurrentlySupported - } + init(_ tlsCredentials: NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials) throws { + switch tlsCredentials.backing { + case .x509(let x509Credentials): + switch x509Credentials.backing { + case .serialized(.file(let certificateChain, let privateKey, format: .pem)): + self = .x509Certificates(certificateChainFilePath: certificateChain, privateKeyFilePath: privateKey) + + case .certificates, .reloading, .serialized(.file(_, _, .der)), .serialized(.bytes): + throw NIOHTTPServerConfigurationError.onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3 + } + + case .rawPublicKey(let rawPublicKeyCredentials): + switch rawPublicKeyCredentials.backing { + case .file(let publicKey, let privateKey, .der): + self = .rawPublicKeys(publicKeyFilePath: publicKey, privateKeyFilePath: privateKey) + + case .file(_, _, .pem): + throw NIOHTTPServerConfigurationError.pemRawPublicKeysNotCurrentlySupported } } } @@ -296,34 +287,27 @@ extension NIOQUIC.Authenticator { /// Returns `nil` for raw public key credentials, because NIOQUIC reads the public/private key paths directly from /// `QUICConfiguration.authenticationConfiguration` (no `Authenticator` instance is required in that case). /// - /// - Parameter transportSecurity: The server's transport security configuration. + /// - Parameter tlsCredentials: The server's TLS credentials. /// /// - Throws: - /// - ``NIOHTTPServerConfigurationError/incompatibleTransportSecurity`` if `transportSecurity` is `.plaintext`. - /// - ``NIOHTTPServerConfigurationError/http3RequiresPEMFileCertificates`` if the X.509 credentials are not - /// provided as a PEM-encoded certificate chain and private key on disk. + /// - ``NIOHTTPServerConfigurationError/onlyPEMFileCredentialsCurrentlySupportedOverHTTP3`` if X.509 credentials + /// are not provided as a PEM-encoded certificate chain and private key on disk. /// - An underlying error from `Authenticator`'s initializer if the certificate chain or private key cannot be /// loaded. - convenience init?(_ transportSecurity: NIOHTTPServerConfiguration.TransportSecurity) throws { - switch transportSecurity.backing { - case .plaintext: - throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity - - case .tls(let tlsCredentials), .mTLS(let tlsCredentials, _): - switch tlsCredentials.backing { - case .rawPublicKey: - // Public/private key paths are read directly from `QUICConfiguration.authenticationConfiguration`, so - // we return `nil` here. - return nil - - case .x509(let x509Credentials): - switch x509Credentials.backing { - case .reloading, .serialized(.bytes), .serialized(.file(_, _, .der)), .certificates: - throw NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 - - case .serialized(.file(let certificateChain, let privateKey, .pem)): - try self.init(certificateFilePath: certificateChain, privateKeyFilePath: privateKey) - } + convenience init?(_ tlsCredentials: NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials) throws { + switch tlsCredentials.backing { + case .rawPublicKey: + // Public/private key paths are read directly from `QUICConfiguration.authenticationConfiguration`, so we + // return `nil` here. + return nil + + case .x509(let x509Credentials): + switch x509Credentials.backing { + case .reloading, .serialized(.bytes), .serialized(.file(_, _, .der)), .certificates: + throw NIOHTTPServerConfigurationError.onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3 + + case .serialized(.file(let certificateChain, let privateKey, .pem)): + try self.init(certificateFilePath: certificateChain, privateKeyFilePath: privateKey) } } } diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift new file mode 100644 index 0000000..c29a32b --- /dev/null +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// +// 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 NIOSSL + +#if HTTP3 +import NIOQUIC +#endif + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration { + /// The context required to serve a secure upgrade channel. + struct SecureUpgradeContext { + let http2Configuration: NIOHTTPServerConfiguration.HTTP2? + let sslContext: NIOSSLContext + } + + /// Validates the server configuration and creates the TLS contexts and configurations required to set up the server + /// channels. + static func makeValidatedSecureUpgradeConfiguration( + supportedHTTPVersions: Set, + transportSecurity: TransportSecurity + ) throws -> SecureUpgradeContext? { + #if HTTP3 + if supportedHTTPVersions.http3ConfigIfSupported != nil, supportedHTTPVersions.count == 1 { + // Only HTTP/3 was specified. As such, we do not create a secure upgrade channel. + return nil + } + #endif + + switch transportSecurity.backing { + case .plaintext: + // Only HTTP/1.1 can be served over plaintext. To serve HTTP/2, `transportSecurity` must be set to `.tls` or + // `.mTLS`. + guard supportedHTTPVersions == [.http1_1] else { + throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity + } + return nil + + case .tls, .mTLS: + return SecureUpgradeContext( + http2Configuration: supportedHTTPVersions.http2ConfigIfSupported, + sslContext: try .makeServerContext( + transportSecurity: transportSecurity, + alpnIdentifiers: supportedHTTPVersions.alpnIdentifiers + ) + ) + } + } + + #if HTTP3 + /// The context required to serve an HTTP/3 channel. + struct HTTP3Context { + let configuration: NIOHTTPServerConfiguration.HTTP3 + let quicAuthConfiguration: NIOQUIC.AuthenticationConfiguration + let quicAuthenticator: NIOQUIC.Authenticator? + } + + /// Validates the server configuration and creates the TLS contexts and configurations required to set up the server + /// channels. + static func makeValidatedHTTP3Configuration( + supportedHTTPVersions: Set, + transportSecurity: TransportSecurity + ) throws -> HTTP3Context? { + guard let http3Config = supportedHTTPVersions.http3ConfigIfSupported else { return nil } + + switch transportSecurity.backing { + case .plaintext: + // Only HTTP/1.1 can be served over plaintext. To serve HTTP/3, `transportSecurity` must be set to `.tls`. + guard supportedHTTPVersions == [.http1_1] else { + throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity + } + return nil + + case .tls(let tlsCredentials): + // We unfortunately need to pass forward both an `AuthenticationConfiguration` and an `Authenticator`: + // + // - RPK credentials are read from `AuthenticationConfiguration`; + // - X509 certificates (in-memory or PEM files on disk) are read from `Authenticator`. + // + // The problem is that `QUICConfiguration` requires the `AuthenticationConfiguration` argument, *even* + // when the TLS credentials are X509 certificates. Moreover, `AuthenticationConfiguration` can only be + // created with *PEM-file backed X509 credentials* (or RPKs), *even though* `Authenticator` supports + // `swift-certificates` objects as the source. + let authConfig = try NIOQUIC.AuthenticationConfiguration(tlsCredentials) + let authenticator = try NIOQUIC.Authenticator(tlsCredentials) + + return HTTP3Context( + configuration: http3Config, + quicAuthConfiguration: authConfig, + quicAuthenticator: authenticator + ) + + case .mTLS: + throw NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3 + } + } + #endif // HTTP3 +} diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index 3a74de3..cb3218f 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -285,21 +285,23 @@ public struct NIOHTTPServerConfiguration: Sendable { } /// Network binding configuration specifying all addresses where the server should listen. - public var bindTargets: [BindTarget] + public let bindTargets: [BindTarget] /// TLS configuration for the server. - public var transportSecurity: TransportSecurity + public let transportSecurity: TransportSecurity /// The HTTP protocol versions the server advertises and accepts connections for. - public var supportedHTTPVersions: Set + public let supportedHTTPVersions: Set /// Backpressure strategy to use in the server. public var backpressureStrategy: BackPressureStrategy /// The maximum number of concurrent connections the server will accept. /// - /// When this limit is reached, the server stops accepting new connections - /// until existing ones close. `nil` means unlimited (the default). + /// When this limit is reached, the server stops accepting new connections until existing ones close. `nil` means + /// unlimited (the default). + /// + /// - Note: Connection limits are not currently supported over HTTP/3. /// /// - Precondition: Must be greater than 0 if non-`nil`. public var maxConnections: Int? { @@ -313,6 +315,15 @@ public struct NIOHTTPServerConfiguration: Sendable { /// Configuration for connection timeouts. public var connectionTimeouts: ConnectionTimeouts + /// The context required to set up secure upgrade channels. If nil, it means the configuration did not specify + /// HTTP/1.1 or HTTP/2 over TLS. + let secureUpgradeContext: SecureUpgradeContext? + + #if HTTP3 + /// The context required to set up HTTP/3 channels. If nil, it means the configuration did not specify HTTP/3. + let http3Context: HTTP3Context? + #endif + /// Create a new configuration with multiple bind targets. /// /// Other configuration properties (``backpressureStrategy``, ``maxConnections``, @@ -328,22 +339,27 @@ public struct NIOHTTPServerConfiguration: Sendable { supportedHTTPVersions: Set, transportSecurity: TransportSecurity ) throws { - if bindTargets.isEmpty { + // Validate the configuration. + guard !bindTargets.isEmpty else { throw NIOHTTPServerConfigurationError.noBindTargetsSpecified } - // 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 { - guard supportedHTTPVersions == [.http1_1] else { - throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity - } - } - - if supportedHTTPVersions.isEmpty { + guard !supportedHTTPVersions.isEmpty else { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } + #if HTTP3 + self.http3Context = try Self.makeValidatedHTTP3Configuration( + supportedHTTPVersions: supportedHTTPVersions, + transportSecurity: transportSecurity + ) + #endif + + self.secureUpgradeContext = try Self.makeValidatedSecureUpgradeConfiguration( + supportedHTTPVersions: supportedHTTPVersions, + transportSecurity: transportSecurity + ) + self.bindTargets = bindTargets self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift index 086e93a..855d442 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfigurationError.swift @@ -17,7 +17,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case noSupportedHTTPVersionsSpecified case incompatibleTransportSecurity case noBindTargetsSpecified - case onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 + case onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3 case rawPublicKeyTLSCredentialsNotCurrentlySupportedOverHTTP1OrHTTP2 case pemRawPublicKeysNotCurrentlySupported // swift-nio-quic doesn't currently support mTLS. See https://github.com/apple/swift-nio-quic/issues/5. @@ -34,7 +34,7 @@ enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { case .noBindTargetsSpecified: "Invalid configuration: at least one bind target must be specified." - case .onlyPEMFileCredentialsCurrentlySupportedOverHTTP3: + case .onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3: "Invalid configuration: only PEM-file X.509 credentials are supported over HTTP/3. DER-encoded, in-memory, reloading, and PEM/DER bytes credential sources are not currently supported." case .rawPublicKeyTLSCredentialsNotCurrentlySupportedOverHTTP1OrHTTP2: diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift index b531fc8..16abc0b 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -106,7 +106,7 @@ extension NIOHTTPServer { /// alongside the associated HTTP/3 connection multiplexer. func setupHTTP3ServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget], - http3Configuration: NIOHTTPServerConfiguration.HTTP3 + context: NIOHTTPServerConfiguration.HTTP3Context ) async throws -> [( quicChannel: any Channel, connectionMultiplexer: HTTP3ServerConnectionMultiplexer< @@ -114,11 +114,6 @@ extension NIOHTTPServer { NIOQUIC.QUICStreamCreator > )] { - let quicConfiguration = try NIOQUIC.QUICConfiguration.init( - http3Configuration.quicConfiguration, - authenticationConfiguration: .init(self.configuration.transportSecurity) - ) - let bootstrap = DatagramBootstrap(group: .singletonMultiThreadedEventLoopGroup) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) @@ -136,11 +131,7 @@ extension NIOHTTPServer { case .hostAndPort(let host, let port): let (quicChannel, multiplexer) = try await bootstrap.bind(host: host, port: port) { channel in channel.eventLoop.makeCompletedFuture { - try self.setupQUICChannel( - channel: channel, - quicConfiguration: quicConfiguration, - http3Configuration: http3Configuration - ) + try self.setupQUICChannel(channel: channel, http3Context: context) } } @@ -162,8 +153,7 @@ extension NIOHTTPServer { /// multiplexer. func setupQUICChannel( channel: any Channel, - quicConfiguration: NIOQUIC.QUICConfiguration, - http3Configuration: NIOHTTPServerConfiguration.HTTP3 + http3Context: NIOHTTPServerConfiguration.HTTP3Context ) throws -> ( quicChannel: any Channel, connectionMultiplexer: HTTP3ServerConnectionMultiplexer< @@ -177,15 +167,18 @@ extension NIOHTTPServer { let quicHandler = QUICHandler( channel: channel, - quicConfiguration: quicConfiguration, + quicConfiguration: .init( + http3Context.configuration.quicConfiguration, + authenticationConfiguration: http3Context.quicAuthConfiguration + ), // TODO: mTLS is not yet supported by NIOQUIC so we don't specify a value for `asyncVerifier`. asyncVerifier: nil, - authenticator: try .init(self.configuration.transportSecurity), + authenticator: http3Context.quicAuthenticator, logger: self.logger, inboundConnectionInitializer: { connectionChannel, streamCreator in connectionChannel.eventLoop.makeCompletedFuture { let connection = try self.setupHTTP3Connection( - http3Configuration: http3Configuration, + http3Configuration: http3Context.configuration, connectionChannel: connectionChannel, streamCreator: streamCreator ) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 5a5bbc4..a78be36 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -214,8 +214,7 @@ extension NIOHTTPServer { func setupSecureUpgradeServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget], - supportedHTTPVersions: Set, - sslContext: NIOSSLContext + context: NIOHTTPServerConfiguration.SecureUpgradeContext ) async throws -> [(NIOAsyncChannel, Never>, ServerQuiescingHelper)] { let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) @@ -242,8 +241,8 @@ extension NIOHTTPServer { }.bind(host: host, port: port) { channel in self.setupSecureUpgradeConnectionChildChannel( channel: channel, - supportedHTTPVersions: supportedHTTPVersions, - sslContext: sslContext + http2Configuration: context.http2Configuration, + sslContext: context.sslContext ) } serverChannels.append((serverChannel, serverQuiescingHelper)) @@ -312,31 +311,23 @@ extension NIOHTTPServer { func setupSecureUpgradeConnectionChildChannel( channel: any Channel, - supportedHTTPVersions: Set, + http2Configuration: NIOHTTPServerConfiguration.HTTP2?, sslContext: NIOSSLContext ) -> EventLoopFuture> { channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler( - self.makeSSLServerHandler( - sslContext, - self.configuration.transportSecurity.customVerificationCallback - ) + let sslHandler = self.makeSSLServerHandler( + sslContext, + self.configuration.transportSecurity.customVerificationCallback ) - }.flatMap { - channel.eventLoop.makeCompletedFuture { - let alpnHandler = self.makeALPNHandler( - channel: channel, - http2Config: supportedHTTPVersions.http2ConfigIfSupported - ) + let alpnHandler = self.makeALPNHandler(channel: channel, http2Config: http2Configuration) - do { - try channel.pipeline.syncOperations.addHandler(alpnHandler) - } catch { - return channel.eventLoop.makeFailedFuture(error) - } - - return alpnHandler.protocolNegotiationResult + do { + try channel.pipeline.syncOperations.addHandlers([sslHandler, alpnHandler]) + } catch { + return channel.eventLoop.makeFailedFuture(error) } + + return alpnHandler.protocolNegotiationResult } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 37c0bf2..876728b 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -203,63 +203,70 @@ public struct NIOHTTPServer: HTTPServer { ) } + #if HTTP3 /// Creates and returns server channels based on the configured transport security. private func makeServerChannels() async throws -> [ServerChannel] { - // If transport security is `plaintext`, we can only create an HTTP/1.1 channel. - if case .plaintext = self.configuration.transportSecurity.backing { - let http1Channels = try await self.setupHTTP1_1ServerChannels(bindTargets: self.configuration.bindTargets) + let bindTargets = self.configuration.bindTargets + let secureUpgradeContext = self.configuration.secureUpgradeContext + let http3Context = self.configuration.http3Context + + switch (secureUpgradeContext, http3Context) { + case (.none, .none): + // Set up plaintext HTTP/1.1 channel(s). + let http1Channels = try await self.setupHTTP1_1ServerChannels(bindTargets: bindTargets) try self.addressesBound(http1Channels.map { (channel, _) in channel.channel.localAddress }) - return http1Channels.map { (channel, quiescingHelper) in - .plaintextHTTP1_1(channel: channel, quiescingHelper: quiescingHelper) - } - } - - var serverChannels = [ServerChannel]() - var secureUpgradeBindTargets = self.configuration.bindTargets + return http1Channels.map { .plaintextHTTP1_1(channel: $0, quiescingHelper: $1) } - #if HTTP3 - if let http3Config = self.configuration.supportedHTTPVersions.http3ConfigIfSupported { - let http3Channels = try await self.setupHTTP3ServerChannels( - bindTargets: self.configuration.bindTargets, - http3Configuration: http3Config + case (.some(let secureUpgradeContext), .none): + // Set up secure upgrade channel(s). + let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( + bindTargets: bindTargets, + context: secureUpgradeContext ) - serverChannels.append( - contentsOf: http3Channels.map { (quicChannel, mux) in - .http3(quicChannel: quicChannel, connectionMultiplexer: mux) - } + try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) + return secureUpgradeChannels.map { .secureUpgrade(channel: $0, quiescingHelper: $1) } + + case (.none, .some(let http3Context)): + let http3Channels = try await self.setupHTTP3ServerChannels(bindTargets: bindTargets, context: http3Context) + try self.addressesBound(http3Channels.map { (channel, _) in channel.localAddress }) + return http3Channels.map { .http3(quicChannel: $0, connectionMultiplexer: $1) } + + case (.some(let secureUpgradeContext), .some(let http3Context)): + // Set up HTTP/3 and secure upgrade channel(s) on the same port. + let http3Channels = try await self.setupHTTP3ServerChannels(bindTargets: bindTargets, context: http3Context) + + let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( + // We must bind the secure-upgrade channel(s) to the same port(s) as the HTTP/3 channel(s). + bindTargets: try http3Channels.map { (http3Channel, _) in try .init(http3Channel.localAddress) }, + context: secureUpgradeContext ) + try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) - guard self.configuration.supportedHTTPVersions.count > 1 else { - // `supportedHTTPVersions == [.http3]` here. We therefore just return HTTP/3 channel(s). - try self.addressesBound(http3Channels.map { (channel, _) in channel.localAddress }) - return serverChannels - } - - // We also need to set up secure upgrade channel(s) on the same port. - secureUpgradeBindTargets = try http3Channels.map { (http3Channel, _) in - try NIOHTTPServerConfiguration.BindTarget(http3Channel.localAddress) - } + return http3Channels.map { .http3(quicChannel: $0, connectionMultiplexer: $1) } + + secureUpgradeChannels.map { .secureUpgrade(channel: $0, quiescingHelper: $1) } } - #endif // HTTP3 - - let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( - bindTargets: secureUpgradeBindTargets, - supportedHTTPVersions: self.configuration.supportedHTTPVersions, - sslContext: .makeServerContext( - transportSecurity: self.configuration.transportSecurity, - alpnIdentifiers: self.configuration.supportedHTTPVersions.alpnIdentifiers - ) - ) - try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) - - serverChannels.append( - contentsOf: secureUpgradeChannels.map { (channel, quiescingHelper) in - .secureUpgrade(channel: channel, quiescingHelper: quiescingHelper) - } - ) + } + #else + /// Creates and returns server channels based on the configured transport security. + private func makeServerChannels() async throws -> [ServerChannel] { + let bindTargets = self.configuration.bindTargets + let secureUpgradeContext = self.configuration.secureUpgradeContext - return serverChannels + if let secureUpgradeContext { + let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( + bindTargets: bindTargets, + context: secureUpgradeContext + ) + try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) + return secureUpgradeChannels.map { .secureUpgrade(channel: $0, quiescingHelper: $1) } + } else { + // Set up plaintext HTTP/1.1 channel(s). + let http1Channels = try await self.setupHTTP1_1ServerChannels(bindTargets: bindTargets) + try self.addressesBound(http1Channels.map { (channel, _) in channel.channel.localAddress }) + return http1Channels.map { .plaintextHTTP1_1(channel: $0, quiescingHelper: $1) } + } } + #endif // HTTP3 private func _serve( serverChannels: [ServerChannel], diff --git a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift index 0d01747..65a3a28 100644 --- a/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/HTTP3ConfigurationTests.swift @@ -56,34 +56,13 @@ struct HTTP3ConfigurationTests { @Suite struct AuthenticationConfigurationTests { - @Test("Plaintext transport security is rejected") - @available(anyAppleOS 26.0, *) - func plaintextRejected() { - #expect(throws: NIOHTTPServerConfigurationError.incompatibleTransportSecurity) { - _ = try NIOQUIC.AuthenticationConfiguration(.plaintext) - } - } - - @Test("mTLS transport security is rejected") - @available(anyAppleOS 26.0, *) - func mTLSRejected() { - #expect(throws: NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3) { - _ = try NIOQUIC.AuthenticationConfiguration( - .mTLS( - credentials: .x509(.pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem")), - trustConfiguration: .init(.pemFile(trustRootsPath: "/roots.pem")) - ) - ) - } - } - @Test("In-memory TLS credentials are rejected") @available(anyAppleOS 26.0, *) func inMemoryCredentialsRejected() throws { let chain = try TestCA.makeSelfSignedChain() - #expect(throws: NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3) { + #expect(throws: NIOHTTPServerConfigurationError.onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3) { _ = try NIOQUIC.AuthenticationConfiguration( - .tls(credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey))) + .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)) ) } } @@ -93,7 +72,7 @@ struct HTTP3ConfigurationTests { func pemFileCredentialsAccepted() { #expect(throws: Never.self) { _ = try NIOQUIC.AuthenticationConfiguration( - .tls(credentials: .x509(.pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem"))) + .x509(.pemFile(certificateChainPath: "/cert.pem", privateKeyPath: "/key.pem")) ) } } diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift new file mode 100644 index 0000000..995e7fb --- /dev/null +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift @@ -0,0 +1,388 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import NIOCertificateReloading +import NIOSSL +import Testing +import X509 + +@testable import NIOHTTPServer + +@Suite +struct NIOHTTPServerConfigurationTests { + @Suite + struct BindTarget { + @available(anyAppleOS 26.0, *) + @Test("Empty bindTargets throws error") + func emptyBindTargetsThrows() throws { + #expect(throws: NIOHTTPServerConfigurationError.noBindTargetsSpecified) { + try NIOHTTPServerConfiguration( + bindTargets: [], + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + } + } + } + + @Suite + struct SupportedHTTPVersions { + @available(anyAppleOS 26.0, *) + @Test("Empty supportedHTTPVersions throws error") + func emptySupportedHTTPVersionsThrows() { + #expect(throws: NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [], + transportSecurity: .plaintext + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test("transport: plaintext, versions: {HTTP/1.1} -> valid") + func plaintextTransportAndHTTP1_1IsValid() { + #expect(throws: Never.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1], + transportSecurity: .plaintext + ) + } + } + + #if HTTP3 + @available(anyAppleOS 26.0, *) + @Test( + "transport: plaintext, versions: HTTP/2 and/or HTTP/3 -> invalid", + arguments: [ + [NIOHTTPServerConfiguration.HTTPVersion.http2], + [.http3], + [.http2, .http3], + // Even when HTTP/1.1 is specified, the presence of HTTP/2 and/or HTTP/3 should make the config invalid. + [.http1_1, .http2], + [.http1_1, .http3], + [.http1_1, .http2, .http3], + ] + ) + func plaintextNotSupportedForHTTP2OrHTTP3(supportedHTTPVersions: Set) { + #expect(throws: NIOHTTPServerConfigurationError.incompatibleTransportSecurity) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: supportedHTTPVersions, + transportSecurity: .plaintext + ) + } + } + #endif // HTTP3 + } + + @Suite + struct TransportSecurity { + @available(anyAppleOS 26.0, *) + @Test( + "All X.509 credential sources produce a valid configuration", + arguments: [TestX509CredentialSource]([.inMemory, .reloading, .pemFile, .derFile, .pemBytes, .derBytes]) + ) + func x509CredentialSourceProducesValidConfiguration(source: TestX509CredentialSource) throws { + let chain = try TestCA.makeSelfSignedChain() + let credentials = try source.makeCredentials(from: chain) + + #expect(throws: Never.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1, .http2], + transportSecurity: .tls(credentials: .x509(credentials)) + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test( + "All mTLS trust root sources produce a valid configuration", + arguments: [MTLSTrustSource]([ + .systemDefaults, .inMemory, .pemFile, .pemBytes, .derFile, .derBytes, .customCallback, + ]) + ) + func mTLSTrustRootSourceProducesValidConfiguration(source: MTLSTrustSource) throws { + let chain = try TestCA.makeSelfSignedChain() + let trustConfiguration = try source.makeTrustConfiguration(from: chain) + + #expect(throws: Never.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1, .http2], + transportSecurity: .mTLS( + credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)), + trustConfiguration: .init(trustConfiguration) + ) + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test( + "A non-existent X.509 certificate file path is rejected", + arguments: [NIOHTTPServerConfiguration.TransportSecurity.X509Credentials]([ + .pemFile(certificateChainPath: "/does/not/exist.pem", privateKeyPath: "/does/not/exist.key"), + .derFile(certificatePath: "/does/not/exist.der", privateKeyPath: "/does/not/exist.der"), + ]) + ) + func nonExistentX509FilePathRejected( + credentials: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials + ) throws { + #expect(throws: Error.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1, .http2], + transportSecurity: .tls(credentials: .x509(credentials)) + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test( + "Malformed X.509 credential bytes are rejected", + arguments: [NIOHTTPServerConfiguration.TransportSecurity.X509Credentials]([ + .pemBytes( + certificateChain: Array("not a valid PEM document".utf8), + privateKey: Array("not a valid PEM document".utf8) + ), + .derBytes(certificate: [0x00, 0x01, 0x02], privateKey: [0x03, 0x04, 0x05]), + ]) + ) + func malformedX509BytesRejected( + credentials: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials + ) throws { + #expect(throws: NIOSSLError.failedToLoadCertificate) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1, .http2], + transportSecurity: .tls(credentials: .x509(credentials)) + ) + } + } + + #if HTTP3 + @available(anyAppleOS 26.0, *) + @Test("PEM-file X.509 credentials over HTTP/3 produces a valid configuration") + func pemFileX509ProducesValidHTTP3Configuration() throws { + let chain = try TestCA.makeSelfSignedChain() + let (leafPath, _, keyPath) = try chain.writeToDisk() + + #expect(throws: Never.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http3], + transportSecurity: .tls( + credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)) + ) + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test( + "Non-PEM-file X.509 credentials are rejected over HTTP/3", + arguments: [TestX509CredentialSource]([.inMemory, .reloading, .pemBytes, .derFile, .derBytes]) + ) + func nonPEMFileX509RejectedOverHTTP3(source: TestX509CredentialSource) throws { + let chain = try TestCA.makeSelfSignedChain() + let credentials = try source.makeCredentials(from: chain) + + #expect(throws: NIOHTTPServerConfigurationError.onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http3], + transportSecurity: .tls(credentials: .x509(credentials)) + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test("DER-file RPK credentials produces a valid TLS configuration") + func derFileRPKProducesValidConfiguration() throws { + let chain = try TestCA.makeSelfSignedChain() + + #expect(throws: Never.self) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http3], + transportSecurity: .tls(credentials: .rawPublicKey(.makeTestCredentials(from: chain))) + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test("Raw public key credentials are rejected over HTTP/1.1 and HTTP/2") + func rawPublicKeyRejectedOverHTTP1AndHTTP2() throws { + #expect( + throws: NIOHTTPServerConfigurationError.rawPublicKeyTLSCredentialsNotCurrentlySupportedOverHTTP1OrHTTP2 + ) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http1_1, .http2], + transportSecurity: .tls( + credentials: .rawPublicKey(.derFile(publicKeyPath: "public.der", privateKeyPath: "private.der")) + ) + ) + } + } + + @available(anyAppleOS 26.0, *) + @Test("mTLS is rejected over HTTP/3") + func mTLSRejectedOverHTTP3() throws { + let chain = try TestCA.makeSelfSignedChain() + let (leafPath, _, keyPath) = try chain.writeToDisk() + + #expect(throws: NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3) { + try NIOHTTPServerConfiguration( + bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), + supportedHTTPVersions: [.http3], + transportSecurity: .mTLS( + credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)), + trustConfiguration: .init(.systemDefaults) + ) + ) + } + } + #endif // HTTP3 + } +} + +@available(anyAppleOS 26.0, *) +enum TestX509CredentialSource: Sendable { + case inMemory + case reloading + case pemFile + case pemBytes + case derFile + case derBytes + + /// Builds ``X509Credentials`` from `chain`. + func makeCredentials( + from chain: ChainPrivateKeyPair + ) throws -> NIOHTTPServerConfiguration.TransportSecurity.X509Credentials { + switch self { + case .inMemory: + return .certificates(chain: chain.chain, privateKey: chain.privateKey) + + case .reloading: + let (leafPath, _, keyPath) = try chain.writeToDisk() + + let reloader = try TimedCertificateReloader.makeReloaderValidatingSources( + configuration: .init( + refreshInterval: .seconds(60), + certificateSource: .init(location: .file(path: leafPath), format: .pem), + privateKeySource: .init(location: .file(path: keyPath), format: .pem) + ) + ) + + return .reloading(reloader) + + case .pemFile: + let (leafPath, _, keyPath) = try chain.writeToDisk() + return .pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath) + + case .pemBytes: + return .pemBytes( + certificateChain: Array(try chain.chainPEMString.utf8), + privateKey: Array(try chain.privateKey.serializeAsPEM().pemString.utf8) + ) + + case .derFile: + let (leafPath, _, keyPath) = try chain.writeToDisk(encoding: .der) + return .derFile(certificatePath: leafPath, privateKeyPath: keyPath) + + case .derBytes: + return .derBytes( + certificate: try chain.leaf.serializeAsPEM().derBytes, + privateKey: try chain.privateKey.serializeAsPEM().derBytes + ) + } + } +} + +#if HTTP3 +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.TransportSecurity.RawPublicKeyCredentials { + /// Builds ``RawPublicKeyCredentials`` from `chain`. + static func makeTestCredentials( + from chain: ChainPrivateKeyPair + ) throws -> Self { + let publicKey = chain.leaf.publicKey + let privateKey = chain.privateKey + + let uuid = UUID().uuidString + let publicKeyPath = FileManager.default.temporaryDirectory.appendingPathComponent("leaf-\(uuid)") + let privateKeyPath = FileManager.default.temporaryDirectory.appendingPathComponent("key-\(uuid)") + + try Data(try publicKey.serializeAsPEM().derBytes).write(to: publicKeyPath) + try Data(try privateKey.serializeAsPEM().derBytes).write(to: privateKeyPath) + + return .derFile(publicKeyPath: publicKeyPath.path, privateKeyPath: privateKeyPath.path) + } +} +#endif // HTTP3 + +@available(anyAppleOS 26.0, *) +enum MTLSTrustSource: Sendable { + case systemDefaults + case inMemory + case pemFile + case pemBytes + case derFile + case derBytes + case customCallback + + /// Builds ``MTLSTrustConfiguration`` from `chain`. + func makeTrustConfiguration( + from chain: ChainPrivateKeyPair + ) throws -> NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration.TrustSource { + switch self { + case .systemDefaults: + return .systemDefaults + + case .inMemory: + return .certificates(trustRoots: [chain.ca]) + + case .pemFile: + let (_, caPath, _) = try chain.writeToDisk() + return .pemFile(trustRootsPath: caPath) + + case .pemBytes: + return .pemBytes(trustRoots: Array(try chain.ca.serializeAsPEM().pemString.utf8)) + + case .derFile: + let (_, caPath, _) = try chain.writeToDisk(encoding: .der) + return .derFile(trustRootPath: caPath) + + case .derBytes: + return .derBytes(trustRoot: try chain.ca.serializeAsPEM().derBytes) + + case .customCallback: + return .customCertificateVerificationCallback { _ in .certificateVerified(.init(nil)) } + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTPVersion { + static let http2 = Self.http2(config: .defaults) + + #if HTTP3 + static let http3 = Self.http3(config: .defaults) + #endif +} diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 515c448..5bf4cfb 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -556,6 +556,7 @@ struct NIOHTTPServerSwiftConfigurationTests { @Test("End-to-end HTTP/3 configuration over TLS") @available(anyAppleOS 26.0, *) func testEndToEnd() throws { + let (leafPath, _, keyPath) = try TestCA.makeSelfSignedChain().writeToDisk() let provider = InMemoryProvider(values: [ "bindTarget.host": "127.0.0.1", "bindTarget.port": 8000, @@ -566,8 +567,8 @@ struct NIOHTTPServerSwiftConfigurationTests { "http.http3.quicConfiguration.sendRetry": true, "transportSecurity.mode": "tls", "transportSecurity.credentialSource": "file", - "transportSecurity.certificateChainPEMPath": .init(.string("cert.pem"), isSecret: false), - "transportSecurity.privateKeyPEMPath": .init(.string("key.pem"), isSecret: true), + "transportSecurity.certificateChainPEMPath": .init(.string(leafPath), isSecret: false), + "transportSecurity.privateKeyPEMPath": .init(.string(keyPath), isSecret: true), ]) let config = ConfigReader(provider: provider) diff --git a/Tests/NIOHTTPServerTests/Utilities/Certificates.swift b/Tests/NIOHTTPServerTests/Utilities/Certificates.swift index aca219c..14ab72c 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Certificates.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Certificates.swift @@ -14,8 +14,12 @@ import Crypto import Foundation +import SwiftASN1 import X509 +@testable import NIOHTTPServer + +@available(anyAppleOS 26.0, *) struct ChainPrivateKeyPair { let leaf: Certificate let ca: Certificate @@ -31,8 +35,32 @@ struct ChainPrivateKeyPair { return certs.joined(separator: "\n") } } + + func writeToDisk( + encoding: NIOHTTPServerConfiguration.TransportSecurity.Encoding = .pem + ) throws -> (leafPath: String, caPath: String, keyPath: String) { + let uuid = UUID().uuidString + let leafPath = FileManager.default.temporaryDirectory.appendingPathComponent("leaf-\(uuid)") + let caPath = FileManager.default.temporaryDirectory.appendingPathComponent("ca-\(uuid)") + let keyPath = FileManager.default.temporaryDirectory.appendingPathComponent("key-\(uuid)") + + switch encoding { + case .pem: + try Data(self.leaf.serializeAsPEM().pemString.utf8).write(to: leafPath) + try Data(self.ca.serializeAsPEM().pemString.utf8).write(to: caPath) + try Data(self.privateKey.serializeAsPEM().pemString.utf8).write(to: keyPath) + + case .der: + try Data(self.leaf.serializeAsPEM().derBytes).write(to: leafPath) + try Data(self.ca.serializeAsPEM().derBytes).write(to: caPath) + try Data(self.privateKey.serializeAsPEM().derBytes).write(to: keyPath) + } + + return (leafPath.path, caPath.path, keyPath.path) + } } +@available(anyAppleOS 26.0, *) struct TestCA { static func makeSelfSignedChain() throws -> ChainPrivateKeyPair { let caKey = P384.Signing.PrivateKey() diff --git a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift index d558378..7936c9c 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -93,6 +93,7 @@ extension TLSConfiguration { } /// Like ``makeTestClientConfiguration``, but with mTLS. + @available(anyAppleOS 26.0, *) static func makeTestClientMTLSConfiguration( trustRoots: NIOSSLTrustRoots, clientCredentials: ChainPrivateKeyPair, diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift index 19b1d33..3546cf7 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift @@ -77,17 +77,16 @@ 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 sslContext = try NIOSSLContext.makeServerContext( - transportSecurity: self.server.configuration.transportSecurity, - alpnIdentifiers: self.server.configuration.supportedHTTPVersions.alpnIdentifiers - ) + guard let secureUpgradeContext = self.server.configuration.secureUpgradeContext else { + throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity + } // Set up the required channel handlers on `serverTestConnectionChannel` let negotiatedServerConnectionFuture = try await serverTestConnectionChannel.eventLoop.flatSubmit { self.server.setupSecureUpgradeConnectionChildChannel( channel: serverTestConnectionChannel, - supportedHTTPVersions: self.server.configuration.supportedHTTPVersions, - sslContext: sslContext + http2Configuration: secureUpgradeContext.http2Configuration, + sslContext: secureUpgradeContext.sslContext ) }.get() From f933d14699ade2bef4409822840db24f7de74ee0 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 3 Aug 2026 16:56:28 +0100 Subject: [PATCH 02/16] Refactor --- .../Configuration/NIOHTTPServerConfiguration.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index cb3218f..e00b706 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -340,11 +340,11 @@ public struct NIOHTTPServerConfiguration: Sendable { transportSecurity: TransportSecurity ) throws { // Validate the configuration. - guard !bindTargets.isEmpty else { + if bindTargets.isEmpty { throw NIOHTTPServerConfigurationError.noBindTargetsSpecified } - guard !supportedHTTPVersions.isEmpty else { + if supportedHTTPVersions.isEmpty { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } From f6b994d132dcc6769b3e2ba36aa16ab33a8d3eea Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 3 Aug 2026 16:58:15 +0100 Subject: [PATCH 03/16] Refactor --- Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index a78be36..1e66a17 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -321,11 +321,7 @@ extension NIOHTTPServer { ) let alpnHandler = self.makeALPNHandler(channel: channel, http2Config: http2Configuration) - do { - try channel.pipeline.syncOperations.addHandlers([sslHandler, alpnHandler]) - } catch { - return channel.eventLoop.makeFailedFuture(error) - } + try channel.pipeline.syncOperations.addHandlers([sslHandler, alpnHandler]) return alpnHandler.protocolNegotiationResult } From 8da01f8193938f74f1ec52f7165d465bedecad6e Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 5 Aug 2026 14:23:27 +0100 Subject: [PATCH 04/16] Format argument declaration --- .../NIOHTTPServerConfigurationTests.swift | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift index 995e7fb..219d188 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift @@ -94,7 +94,7 @@ struct NIOHTTPServerConfigurationTests { @available(anyAppleOS 26.0, *) @Test( "All X.509 credential sources produce a valid configuration", - arguments: [TestX509CredentialSource]([.inMemory, .reloading, .pemFile, .derFile, .pemBytes, .derBytes]) + arguments: [TestX509CredentialSource.inMemory, .reloading, .pemFile, .derFile, .pemBytes, .derBytes] ) func x509CredentialSourceProducesValidConfiguration(source: TestX509CredentialSource) throws { let chain = try TestCA.makeSelfSignedChain() @@ -112,9 +112,9 @@ struct NIOHTTPServerConfigurationTests { @available(anyAppleOS 26.0, *) @Test( "All mTLS trust root sources produce a valid configuration", - arguments: [MTLSTrustSource]([ - .systemDefaults, .inMemory, .pemFile, .pemBytes, .derFile, .derBytes, .customCallback, - ]) + arguments: [ + MTLSTrustSource.systemDefaults, .inMemory, .pemFile, .pemBytes, .derFile, .derBytes, .customCallback + ] ) func mTLSTrustRootSourceProducesValidConfiguration(source: MTLSTrustSource) throws { let chain = try TestCA.makeSelfSignedChain() @@ -135,10 +135,13 @@ struct NIOHTTPServerConfigurationTests { @available(anyAppleOS 26.0, *) @Test( "A non-existent X.509 certificate file path is rejected", - arguments: [NIOHTTPServerConfiguration.TransportSecurity.X509Credentials]([ - .pemFile(certificateChainPath: "/does/not/exist.pem", privateKeyPath: "/does/not/exist.key"), + arguments: [ + NIOHTTPServerConfiguration.TransportSecurity.X509Credentials.pemFile( + certificateChainPath: "/does/not/exist.pem", + privateKeyPath: "/does/not/exist.key" + ), .derFile(certificatePath: "/does/not/exist.der", privateKeyPath: "/does/not/exist.der"), - ]) + ] ) func nonExistentX509FilePathRejected( credentials: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials @@ -155,13 +158,13 @@ struct NIOHTTPServerConfigurationTests { @available(anyAppleOS 26.0, *) @Test( "Malformed X.509 credential bytes are rejected", - arguments: [NIOHTTPServerConfiguration.TransportSecurity.X509Credentials]([ - .pemBytes( + arguments: [ + NIOHTTPServerConfiguration.TransportSecurity.X509Credentials.pemBytes( certificateChain: Array("not a valid PEM document".utf8), privateKey: Array("not a valid PEM document".utf8) ), .derBytes(certificate: [0x00, 0x01, 0x02], privateKey: [0x03, 0x04, 0x05]), - ]) + ] ) func malformedX509BytesRejected( credentials: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials @@ -196,7 +199,7 @@ struct NIOHTTPServerConfigurationTests { @available(anyAppleOS 26.0, *) @Test( "Non-PEM-file X.509 credentials are rejected over HTTP/3", - arguments: [TestX509CredentialSource]([.inMemory, .reloading, .pemBytes, .derFile, .derBytes]) + arguments: [TestX509CredentialSource.inMemory, .reloading, .pemBytes, .derFile, .derBytes] ) func nonPEMFileX509RejectedOverHTTP3(source: TestX509CredentialSource) throws { let chain = try TestCA.makeSelfSignedChain() From 6605bc2ca30f0206746c799b9f8a0492de6a7918 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 5 Aug 2026 14:24:10 +0100 Subject: [PATCH 05/16] Set default value for HTTP/2 and HTTP/3 configuration --- .../NIOHTTPServerConfiguration.swift | 4 +- .../NIOHTTPServerConfigurationTests.swift | 41 ++++++++----------- .../NIOHTTPServerEndToEndTests.swift | 2 +- .../NIOHTTPServerTests.swift | 2 +- 4 files changed, 20 insertions(+), 29 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index e00b706..61babc3 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -523,7 +523,7 @@ extension NIOHTTPServerConfiguration { /// The HTTP/2 protocol version. /// /// - Parameter config: The configuration to use for HTTP/2. - public static func http2(config: HTTP2) -> Self { + public static func http2(config: HTTP2 = .defaults) -> Self { Self(version: .http2(config: config)) } @@ -531,7 +531,7 @@ extension NIOHTTPServerConfiguration { /// The HTTP/3 protocol version. /// /// - Parameter config: The configuration to use for HTTP/3. - public static func http3(config: HTTP3) -> Self { + public static func http3(config: HTTP3 = .defaults) -> Self { Self(version: .http3(config: config)) } #endif diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift index 219d188..cda8368 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift @@ -68,13 +68,13 @@ struct NIOHTTPServerConfigurationTests { @Test( "transport: plaintext, versions: HTTP/2 and/or HTTP/3 -> invalid", arguments: [ - [NIOHTTPServerConfiguration.HTTPVersion.http2], - [.http3], - [.http2, .http3], + [NIOHTTPServerConfiguration.HTTPVersion.http2()], + [.http3()], + [.http2(), .http3()], // Even when HTTP/1.1 is specified, the presence of HTTP/2 and/or HTTP/3 should make the config invalid. - [.http1_1, .http2], - [.http1_1, .http3], - [.http1_1, .http2, .http3], + [.http1_1, .http2()], + [.http1_1, .http3()], + [.http1_1, .http2(), .http3()], ] ) func plaintextNotSupportedForHTTP2OrHTTP3(supportedHTTPVersions: Set) { @@ -103,7 +103,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2], + supportedHTTPVersions: [.http1_1, .http2()], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -123,7 +123,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2], + supportedHTTPVersions: [.http1_1, .http2()], transportSecurity: .mTLS( credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)), trustConfiguration: .init(trustConfiguration) @@ -145,11 +145,11 @@ struct NIOHTTPServerConfigurationTests { ) func nonExistentX509FilePathRejected( credentials: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials - ) throws { + ) { #expect(throws: Error.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2], + supportedHTTPVersions: [.http1_1, .http2()], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -172,7 +172,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: NIOSSLError.failedToLoadCertificate) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2], + supportedHTTPVersions: [.http1_1, .http2()], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -188,7 +188,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3], + supportedHTTPVersions: [.http3()], transportSecurity: .tls( credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)) ) @@ -208,7 +208,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: NIOHTTPServerConfigurationError.onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3], + supportedHTTPVersions: [.http3()], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -222,7 +222,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3], + supportedHTTPVersions: [.http3()], transportSecurity: .tls(credentials: .rawPublicKey(.makeTestCredentials(from: chain))) ) } @@ -236,7 +236,7 @@ struct NIOHTTPServerConfigurationTests { ) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2], + supportedHTTPVersions: [.http1_1, .http2()], transportSecurity: .tls( credentials: .rawPublicKey(.derFile(publicKeyPath: "public.der", privateKeyPath: "private.der")) ) @@ -253,7 +253,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3], + supportedHTTPVersions: [.http3()], transportSecurity: .mTLS( credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)), trustConfiguration: .init(.systemDefaults) @@ -380,12 +380,3 @@ enum MTLSTrustSource: Sendable { } } } - -@available(anyAppleOS 26.0, *) -extension NIOHTTPServerConfiguration.HTTPVersion { - static let http2 = Self.http2(config: .defaults) - - #if HTTP3 - static let http3 = Self.http3(config: .defaults) - #endif -} diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift index d05c7d4..e06579b 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -78,7 +78,7 @@ struct NIOHTTPServerEndToEndTests { transportSecurity: .tls( credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) ), - supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], + supportedHTTPVersions: [.http1_1, .http2()], handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in var buffer = UniqueArray(copying: [1, 2]) try await resSender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: [.serverTiming: "test"]) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift index de89986..3336974 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerTests.swift @@ -862,7 +862,7 @@ extension NIOHTTPServerTests { logger: logger, configuration: try .init( bindTargets: bindTargets, - supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], + supportedHTTPVersions: [.http1_1, .http2()], transportSecurity: .tls( credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) ) From bd7757dd2b12ef4bd16cb0c370699a6259ffdb5e Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 5 Aug 2026 15:39:35 +0100 Subject: [PATCH 06/16] Use `CaseIterable` --- .../NIOHTTPServerConfigurationTests.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift index cda8368..0df769c 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift @@ -94,7 +94,7 @@ struct NIOHTTPServerConfigurationTests { @available(anyAppleOS 26.0, *) @Test( "All X.509 credential sources produce a valid configuration", - arguments: [TestX509CredentialSource.inMemory, .reloading, .pemFile, .derFile, .pemBytes, .derBytes] + arguments: TestX509CredentialSource.allCases ) func x509CredentialSourceProducesValidConfiguration(source: TestX509CredentialSource) throws { let chain = try TestCA.makeSelfSignedChain() @@ -110,12 +110,7 @@ struct NIOHTTPServerConfigurationTests { } @available(anyAppleOS 26.0, *) - @Test( - "All mTLS trust root sources produce a valid configuration", - arguments: [ - MTLSTrustSource.systemDefaults, .inMemory, .pemFile, .pemBytes, .derFile, .derBytes, .customCallback - ] - ) + @Test("All mTLS trust root sources produce a valid configuration", arguments: MTLSTrustSource.allCases) func mTLSTrustRootSourceProducesValidConfiguration(source: MTLSTrustSource) throws { let chain = try TestCA.makeSelfSignedChain() let trustConfiguration = try source.makeTrustConfiguration(from: chain) @@ -266,7 +261,7 @@ struct NIOHTTPServerConfigurationTests { } @available(anyAppleOS 26.0, *) -enum TestX509CredentialSource: Sendable { +enum TestX509CredentialSource: Sendable, CaseIterable { case inMemory case reloading case pemFile @@ -341,7 +336,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity.RawPublicKeyCredentials { #endif // HTTP3 @available(anyAppleOS 26.0, *) -enum MTLSTrustSource: Sendable { +enum MTLSTrustSource: Sendable, CaseIterable { case systemDefaults case inMemory case pemFile From f20d89fa8519a38fd29ec78cc19af64d8d7e1258 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Wed, 5 Aug 2026 15:43:22 +0100 Subject: [PATCH 07/16] Add `Validated` prefix to context types --- .../NIOHTTPServerConfiguration+Validation.swift | 12 ++++++------ .../Configuration/NIOHTTPServerConfiguration.swift | 4 ++-- Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift | 4 ++-- .../NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift index c29a32b..cde7386 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift @@ -21,7 +21,7 @@ import NIOQUIC @available(anyAppleOS 26.0, *) extension NIOHTTPServerConfiguration { /// The context required to serve a secure upgrade channel. - struct SecureUpgradeContext { + struct ValidatedSecureUpgradeContext { let http2Configuration: NIOHTTPServerConfiguration.HTTP2? let sslContext: NIOSSLContext } @@ -31,7 +31,7 @@ extension NIOHTTPServerConfiguration { static func makeValidatedSecureUpgradeConfiguration( supportedHTTPVersions: Set, transportSecurity: TransportSecurity - ) throws -> SecureUpgradeContext? { + ) throws -> ValidatedSecureUpgradeContext? { #if HTTP3 if supportedHTTPVersions.http3ConfigIfSupported != nil, supportedHTTPVersions.count == 1 { // Only HTTP/3 was specified. As such, we do not create a secure upgrade channel. @@ -49,7 +49,7 @@ extension NIOHTTPServerConfiguration { return nil case .tls, .mTLS: - return SecureUpgradeContext( + return ValidatedSecureUpgradeContext( http2Configuration: supportedHTTPVersions.http2ConfigIfSupported, sslContext: try .makeServerContext( transportSecurity: transportSecurity, @@ -61,7 +61,7 @@ extension NIOHTTPServerConfiguration { #if HTTP3 /// The context required to serve an HTTP/3 channel. - struct HTTP3Context { + struct ValidatedHTTP3Context { let configuration: NIOHTTPServerConfiguration.HTTP3 let quicAuthConfiguration: NIOQUIC.AuthenticationConfiguration let quicAuthenticator: NIOQUIC.Authenticator? @@ -72,7 +72,7 @@ extension NIOHTTPServerConfiguration { static func makeValidatedHTTP3Configuration( supportedHTTPVersions: Set, transportSecurity: TransportSecurity - ) throws -> HTTP3Context? { + ) throws -> ValidatedHTTP3Context? { guard let http3Config = supportedHTTPVersions.http3ConfigIfSupported else { return nil } switch transportSecurity.backing { @@ -96,7 +96,7 @@ extension NIOHTTPServerConfiguration { let authConfig = try NIOQUIC.AuthenticationConfiguration(tlsCredentials) let authenticator = try NIOQUIC.Authenticator(tlsCredentials) - return HTTP3Context( + return ValidatedHTTP3Context( configuration: http3Config, quicAuthConfiguration: authConfig, quicAuthenticator: authenticator diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index 61babc3..c35ef2b 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -317,11 +317,11 @@ public struct NIOHTTPServerConfiguration: Sendable { /// The context required to set up secure upgrade channels. If nil, it means the configuration did not specify /// HTTP/1.1 or HTTP/2 over TLS. - let secureUpgradeContext: SecureUpgradeContext? + let secureUpgradeContext: ValidatedSecureUpgradeContext? #if HTTP3 /// The context required to set up HTTP/3 channels. If nil, it means the configuration did not specify HTTP/3. - let http3Context: HTTP3Context? + let http3Context: ValidatedHTTP3Context? #endif /// Create a new configuration with multiple bind targets. diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift index 5509b5b..f964dd8 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -106,7 +106,7 @@ extension NIOHTTPServer { /// alongside the associated HTTP/3 connection multiplexer. func setupHTTP3ServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget], - context: NIOHTTPServerConfiguration.HTTP3Context + context: NIOHTTPServerConfiguration.ValidatedHTTP3Context ) async throws -> [( quicChannel: any Channel, connectionMultiplexer: HTTP3ServerConnectionMultiplexer< @@ -153,7 +153,7 @@ extension NIOHTTPServer { /// multiplexer. func setupQUICChannel( channel: any Channel, - http3Context: NIOHTTPServerConfiguration.HTTP3Context + http3Context: NIOHTTPServerConfiguration.ValidatedHTTP3Context ) throws -> ( quicChannel: any Channel, connectionMultiplexer: HTTP3ServerConnectionMultiplexer< diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 0c9df23..42634ba 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -214,7 +214,7 @@ extension NIOHTTPServer { func setupSecureUpgradeServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget], - context: NIOHTTPServerConfiguration.SecureUpgradeContext + context: NIOHTTPServerConfiguration.ValidatedSecureUpgradeContext ) async throws -> [(NIOAsyncChannel, Never>, ServerQuiescingHelper)] { let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) From b32144dbe66b9edeb2eb561cc738d60a7adfcdc3 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 6 Aug 2026 17:17:14 +0100 Subject: [PATCH 08/16] Add static var for default `http2` and `http3` configurations --- .../NIOHTTPServerConfiguration.swift | 14 +++++++++ .../NIOHTTPServer+ServiceLifecycleTests.swift | 2 +- .../NIOHTTPServerConfigurationTests.swift | 30 +++++++++---------- .../NIOHTTPServerEndToEndTests.swift | 2 +- .../Utilities/HTTPVersion.swift | 4 +-- .../Utilities/Helpers.swift | 2 +- 6 files changed, 34 insertions(+), 20 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index c35ef2b..defbd23 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -527,6 +527,13 @@ extension NIOHTTPServerConfiguration { Self(version: .http2(config: config)) } + /// The HTTP/2 protocol version with default configuration values. + /// + /// - Note: Use ``http2(config:)`` to specify custom configuration values. + public static var http2: Self { + .http2(config: .defaults) + } + #if HTTP3 /// The HTTP/3 protocol version. /// @@ -534,6 +541,13 @@ extension NIOHTTPServerConfiguration { public static func http3(config: HTTP3 = .defaults) -> Self { Self(version: .http3(config: config)) } + + /// The HTTP/3 protocol version with default configuration values. + /// + /// - Note: Use ``http3(config:)`` to specify custom configuration values. + public static var http3: Self { + .http3(config: .defaults) + } #endif /// Two values are equal if they represent the same protocol version, regardless of any differences in HTTP/2 diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift index 3ef94d7..5509333 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServer+ServiceLifecycleTests.swift @@ -379,7 +379,7 @@ struct NIOHTTPServiceLifecycleTests { ) async throws { // Configure two listeners. We want to test whether graceful shutdown works independently on each listener. let (serverConfiguration, trustRootsPEMPath) = try TestHelpers.makeSecureUpgradeServerConfiguration( - supportedHTTPVersions: [.http1_1, .http2()], + supportedHTTPVersions: [.http1_1, .http2], concurrentListeners: 2 ) let server = NIOHTTPServer(logger: self.serverLogger, configuration: serverConfiguration) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift index 0df769c..71484bf 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerConfigurationTests.swift @@ -68,13 +68,13 @@ struct NIOHTTPServerConfigurationTests { @Test( "transport: plaintext, versions: HTTP/2 and/or HTTP/3 -> invalid", arguments: [ - [NIOHTTPServerConfiguration.HTTPVersion.http2()], - [.http3()], - [.http2(), .http3()], + [NIOHTTPServerConfiguration.HTTPVersion.http2], + [.http3], + [.http2, .http3], // Even when HTTP/1.1 is specified, the presence of HTTP/2 and/or HTTP/3 should make the config invalid. - [.http1_1, .http2()], - [.http1_1, .http3()], - [.http1_1, .http2(), .http3()], + [.http1_1, .http2], + [.http1_1, .http3], + [.http1_1, .http2, .http3], ] ) func plaintextNotSupportedForHTTP2OrHTTP3(supportedHTTPVersions: Set) { @@ -103,7 +103,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2()], + supportedHTTPVersions: [.http1_1, .http2], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -118,7 +118,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2()], + supportedHTTPVersions: [.http1_1, .http2], transportSecurity: .mTLS( credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)), trustConfiguration: .init(trustConfiguration) @@ -144,7 +144,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Error.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2()], + supportedHTTPVersions: [.http1_1, .http2], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -167,7 +167,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: NIOSSLError.failedToLoadCertificate) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2()], + supportedHTTPVersions: [.http1_1, .http2], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -183,7 +183,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3()], + supportedHTTPVersions: [.http3], transportSecurity: .tls( credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)) ) @@ -203,7 +203,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: NIOHTTPServerConfigurationError.onlyPEMFileX509CredentialsCurrentlySupportedOverHTTP3) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3()], + supportedHTTPVersions: [.http3], transportSecurity: .tls(credentials: .x509(credentials)) ) } @@ -217,7 +217,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: Never.self) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3()], + supportedHTTPVersions: [.http3], transportSecurity: .tls(credentials: .rawPublicKey(.makeTestCredentials(from: chain))) ) } @@ -231,7 +231,7 @@ struct NIOHTTPServerConfigurationTests { ) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2()], + supportedHTTPVersions: [.http1_1, .http2], transportSecurity: .tls( credentials: .rawPublicKey(.derFile(publicKeyPath: "public.der", privateKeyPath: "private.der")) ) @@ -248,7 +248,7 @@ struct NIOHTTPServerConfigurationTests { #expect(throws: NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3) { try NIOHTTPServerConfiguration( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3()], + supportedHTTPVersions: [.http3], transportSecurity: .mTLS( credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)), trustConfiguration: .init(.systemDefaults) diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift index 5e58963..42953a9 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -62,7 +62,7 @@ struct NIOHTTPServerEndToEndTests { transportSecurity: .tls( credentials: .x509(.certificates(chain: serverChain.chain, privateKey: serverChain.privateKey)) ), - supportedHTTPVersions: [.http1_1, .http2()], + supportedHTTPVersions: [.http1_1, .http2], handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in var buffer = UniqueArray(copying: [1, 2]) try await resSender.sendAndFinish(.init(status: .ok), buffer: &buffer, trailer: [.serverTiming: "test"]) diff --git a/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift b/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift index 2f13c18..116f709 100644 --- a/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift +++ b/Tests/NIOHTTPServerTests/Utilities/HTTPVersion.swift @@ -46,11 +46,11 @@ extension NIOHTTPServerConfiguration.HTTPVersion { self = .http1_1 case .http2: - self = .http2() + self = .http2 #if HTTP3 case .http3: - self = .http3() + self = .http3 #endif } } diff --git a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift index 3939620..77eddab 100644 --- a/Tests/NIOHTTPServerTests/Utilities/Helpers.swift +++ b/Tests/NIOHTTPServerTests/Utilities/Helpers.swift @@ -367,7 +367,7 @@ struct TestHelpers { @available(anyAppleOS 26.0, *) extension TestHelpers { static func makeSecureUpgradeServerConfiguration( - supportedHTTPVersions: Set = [.http1_1, .http2()], + supportedHTTPVersions: Set = [.http1_1, .http2], concurrentListeners: Int = 1 ) throws -> (NIOHTTPServerConfiguration, String) { let (leafPath, caPath, privateKeyPath) = try TestCA.makeSelfSignedChainWithSAN().writeToDisk() From 54b635760aacf786279b263993d42817e6656b90 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 6 Aug 2026 17:35:40 +0100 Subject: [PATCH 09/16] Remove validated types; store validated contexts inline --- ...IOHTTPServerConfiguration+Validation.swift | 76 ++++++--------- .../NIOHTTPServerConfiguration.swift | 79 +++++++++++---- .../NIOHTTPServer/NIOHTTPServer+HTTP3.swift | 23 +++-- .../NIOHTTPServer+SecureUpgrade.swift | 7 +- Sources/NIOHTTPServer/NIOHTTPServer.swift | 96 +++++++++---------- .../TestingChannelServer+SecureUpgrade.swift | 6 +- 6 files changed, 154 insertions(+), 133 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift index cde7386..d5acd22 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift @@ -20,68 +20,57 @@ import NIOQUIC @available(anyAppleOS 26.0, *) extension NIOHTTPServerConfiguration { - /// The context required to serve a secure upgrade channel. - struct ValidatedSecureUpgradeContext { - let http2Configuration: NIOHTTPServerConfiguration.HTTP2? - let sslContext: NIOSSLContext + /// Validates the configuration and derives the TLS resources required to set up the server channels. + mutating func validateHTTPVersionAndTLSCredentialCompatibility() throws { + #if HTTP3 + (self.quicAuthenticationConfiguration, self.quicAuthenticator) = try self.makeQUICAuthentication() + #endif + + self.sslContext = try self.makeSSLContext() } - /// Validates the server configuration and creates the TLS contexts and configurations required to set up the server - /// channels. - static func makeValidatedSecureUpgradeConfiguration( - supportedHTTPVersions: Set, - transportSecurity: TransportSecurity - ) throws -> ValidatedSecureUpgradeContext? { + /// Creates the `NIOSSLContext` used by the secure upgrade channel(s), or `nil` if the configuration doesn't call for + /// a secure upgrade channel. + private func makeSSLContext() throws -> NIOSSLContext? { #if HTTP3 - if supportedHTTPVersions.http3ConfigIfSupported != nil, supportedHTTPVersions.count == 1 { - // Only HTTP/3 was specified. As such, we do not create a secure upgrade channel. + if self.supportedHTTPVersions.http3ConfigIfSupported != nil, self.supportedHTTPVersions.count == 1 { + // Only HTTP/3 was specified. As such, `NIOSSLContext` is not needed because a secure upgrade channel won't + // be set up. We can just return `nil` here. return nil } #endif - switch transportSecurity.backing { + switch self.transportSecurity.backing { case .plaintext: // Only HTTP/1.1 can be served over plaintext. To serve HTTP/2, `transportSecurity` must be set to `.tls` or // `.mTLS`. - guard supportedHTTPVersions == [.http1_1] else { + guard self.supportedHTTPVersions == [.http1_1] else { throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity } return nil case .tls, .mTLS: - return ValidatedSecureUpgradeContext( - http2Configuration: supportedHTTPVersions.http2ConfigIfSupported, - sslContext: try .makeServerContext( - transportSecurity: transportSecurity, - alpnIdentifiers: supportedHTTPVersions.alpnIdentifiers - ) + return try .makeServerContext( + transportSecurity: self.transportSecurity, + alpnIdentifiers: self.supportedHTTPVersions.alpnIdentifiers ) } } #if HTTP3 - /// The context required to serve an HTTP/3 channel. - struct ValidatedHTTP3Context { - let configuration: NIOHTTPServerConfiguration.HTTP3 - let quicAuthConfiguration: NIOQUIC.AuthenticationConfiguration - let quicAuthenticator: NIOQUIC.Authenticator? - } - - /// Validates the server configuration and creates the TLS contexts and configurations required to set up the server - /// channels. - static func makeValidatedHTTP3Configuration( - supportedHTTPVersions: Set, - transportSecurity: TransportSecurity - ) throws -> ValidatedHTTP3Context? { - guard let http3Config = supportedHTTPVersions.http3ConfigIfSupported else { return nil } + /// Creates the QUIC authentication resources used by the HTTP/3 channel(s). + /// + /// Both are `nil` if HTTP/3 is not among ``supportedHTTPVersions``. + private func makeQUICAuthentication() throws -> ( + configuration: NIOQUIC.AuthenticationConfiguration?, + authenticator: NIOQUIC.Authenticator? + ) { + guard self.supportedHTTPVersions.http3ConfigIfSupported != nil else { return (nil, nil) } - switch transportSecurity.backing { + switch self.transportSecurity.backing { case .plaintext: // Only HTTP/1.1 can be served over plaintext. To serve HTTP/3, `transportSecurity` must be set to `.tls`. - guard supportedHTTPVersions == [.http1_1] else { - throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity - } - return nil + throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity case .tls(let tlsCredentials): // We unfortunately need to pass forward both an `AuthenticationConfiguration` and an `Authenticator`: @@ -93,14 +82,7 @@ extension NIOHTTPServerConfiguration { // when the TLS credentials are X509 certificates. Moreover, `AuthenticationConfiguration` can only be // created with *PEM-file backed X509 credentials* (or RPKs), *even though* `Authenticator` supports // `swift-certificates` objects as the source. - let authConfig = try NIOQUIC.AuthenticationConfiguration(tlsCredentials) - let authenticator = try NIOQUIC.Authenticator(tlsCredentials) - - return ValidatedHTTP3Context( - configuration: http3Config, - quicAuthConfiguration: authConfig, - quicAuthenticator: authenticator - ) + return (configuration: try .init(tlsCredentials), authenticator: try .init(tlsCredentials)) case .mTLS: throw NIOHTTPServerConfigurationError.mTLSNotCurrentlySupportedOverHTTP3 diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index defbd23..c0e3047 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -16,6 +16,10 @@ import NIOCore import NIOSSL public import X509 +#if HTTP3 +import NIOQUIC +#endif + /// Configuration settings for ``NIOHTTPServer``. /// /// This structure contains all the necessary configuration options for setting up @@ -285,13 +289,47 @@ public struct NIOHTTPServerConfiguration: Sendable { } /// Network binding configuration specifying all addresses where the server should listen. - public let bindTargets: [BindTarget] + /// + /// - Precondition: Must not be empty. + public var bindTargets: [BindTarget] { + didSet { + if self.bindTargets.isEmpty { + preconditionFailure(NIOHTTPServerConfigurationError.noBindTargetsSpecified.description) + } + } + } /// TLS configuration for the server. - public let transportSecurity: TransportSecurity + /// + /// - Precondition: Must be compatible with ``supportedHTTPVersions``: + /// - `transportSecurity == .mTLS` is not supported when `supportedHTTPVersions` contains `.http3`. + /// - Raw Public Key credentials are only supported when `supportedHTTPVersions == [.http3]`. + /// - When `supportedHTTPVersions` contains `.http2` and `.http3`, TLS credentials must be provided as PEM files + /// on disk. Other credential sources are not supported. + /// - `transportSecurity` can only be set to `.plaintext` when `supportedHTTPVersions == [.http1_1]`. + public var transportSecurity: TransportSecurity { + didSet { + try! self.validateHTTPVersionAndTLSCredentialCompatibility() + } + } /// The HTTP protocol versions the server advertises and accepts connections for. - public let supportedHTTPVersions: Set + /// + /// - Precondition: Must not be empty, and must be compatible with ``transportSecurity``: + /// - `transportSecurity == .mTLS` is not supported when `supportedHTTPVersions` contains `.http3`. + /// - Raw Public Key credentials are only supported when `supportedHTTPVersions == [.http3]`. + /// - When `supportedHTTPVersions` contains `.http2` and `.http3`, TLS credentials must be provided as PEM files + /// on disk. Other credential sources are not supported. + /// - `transportSecurity` can only be set to `.plaintext` when `supportedHTTPVersions == [.http1_1]`. + public var supportedHTTPVersions: Set { + didSet { + if self.supportedHTTPVersions.isEmpty { + preconditionFailure(NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified.description) + } + + try! self.validateHTTPVersionAndTLSCredentialCompatibility() + } + } /// Backpressure strategy to use in the server. public var backpressureStrategy: BackPressureStrategy @@ -315,13 +353,22 @@ public struct NIOHTTPServerConfiguration: Sendable { /// Configuration for connection timeouts. public var connectionTimeouts: ConnectionTimeouts - /// The context required to set up secure upgrade channels. If nil, it means the configuration did not specify - /// HTTP/1.1 or HTTP/2 over TLS. - let secureUpgradeContext: ValidatedSecureUpgradeContext? + /// The `NIOSSLContext` used by the secure upgrade channel(s), derived when the configuration is validated. + /// + /// `nil` when the configuration doesn't call for a secure upgrade channel, i.e. plaintext HTTP/1.1 or HTTP/3 only. + var sslContext: NIOSSLContext? #if HTTP3 - /// The context required to set up HTTP/3 channels. If nil, it means the configuration did not specify HTTP/3. - let http3Context: ValidatedHTTP3Context? + /// The QUIC authentication configuration used by the HTTP/3 channel(s). + /// + /// `nil` when HTTP/3 is not among ``supportedHTTPVersions``. + var quicAuthenticationConfiguration: NIOQUIC.AuthenticationConfiguration? + + /// The QUIC authenticator used by the HTTP/3 channel(s), derived when the configuration is validated. + /// + /// `nil` when HTTP/3 is not among ``supportedHTTPVersions``, and also when the TLS credentials are raw public keys; + /// NIOQUIC reads those directly from ``quicAuthenticationConfiguration``. + var quicAuthenticator: NIOQUIC.Authenticator? #endif /// Create a new configuration with multiple bind targets. @@ -339,7 +386,6 @@ public struct NIOHTTPServerConfiguration: Sendable { supportedHTTPVersions: Set, transportSecurity: TransportSecurity ) throws { - // Validate the configuration. if bindTargets.isEmpty { throw NIOHTTPServerConfigurationError.noBindTargetsSpecified } @@ -348,24 +394,15 @@ public struct NIOHTTPServerConfiguration: Sendable { throw NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified } - #if HTTP3 - self.http3Context = try Self.makeValidatedHTTP3Configuration( - supportedHTTPVersions: supportedHTTPVersions, - transportSecurity: transportSecurity - ) - #endif - - self.secureUpgradeContext = try Self.makeValidatedSecureUpgradeConfiguration( - supportedHTTPVersions: supportedHTTPVersions, - transportSecurity: transportSecurity - ) - self.bindTargets = bindTargets self.supportedHTTPVersions = supportedHTTPVersions self.transportSecurity = transportSecurity self.backpressureStrategy = .defaults self.maxConnections = nil self.connectionTimeouts = .defaults + + // Validate the configuration and derive the TLS resources needed to set up the server channels. + try self.validateHTTPVersionAndTLSCredentialCompatibility() } /// Create a new configuration with a single bind target. diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift index f964dd8..558a7ec 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -106,7 +106,9 @@ extension NIOHTTPServer { /// alongside the associated HTTP/3 connection multiplexer. func setupHTTP3ServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget], - context: NIOHTTPServerConfiguration.ValidatedHTTP3Context + http3Configuration: NIOHTTPServerConfiguration.HTTP3, + authenticationConfiguration: NIOQUIC.AuthenticationConfiguration, + authenticator: NIOQUIC.Authenticator? ) async throws -> [( quicChannel: any Channel, connectionMultiplexer: HTTP3ServerConnectionMultiplexer< @@ -131,7 +133,12 @@ extension NIOHTTPServer { case .hostAndPort(let host, let port): let (quicChannel, multiplexer) = try await bootstrap.bind(host: host, port: port) { channel in channel.eventLoop.makeCompletedFuture { - try self.setupQUICChannel(channel: channel, http3Context: context) + try self.setupQUICChannel( + channel: channel, + http3Configuration: http3Configuration, + authenticationConfiguration: authenticationConfiguration, + authenticator: authenticator + ) } } @@ -153,7 +160,9 @@ extension NIOHTTPServer { /// multiplexer. func setupQUICChannel( channel: any Channel, - http3Context: NIOHTTPServerConfiguration.ValidatedHTTP3Context + http3Configuration: NIOHTTPServerConfiguration.HTTP3, + authenticationConfiguration: NIOQUIC.AuthenticationConfiguration, + authenticator: NIOQUIC.Authenticator? ) throws -> ( quicChannel: any Channel, connectionMultiplexer: HTTP3ServerConnectionMultiplexer< @@ -168,17 +177,17 @@ extension NIOHTTPServer { let quicHandler = QUICHandler( channel: channel, quicConfiguration: .init( - http3Context.configuration.quicConfiguration, - authenticationConfiguration: http3Context.quicAuthConfiguration + http3Configuration.quicConfiguration, + authenticationConfiguration: authenticationConfiguration ), // TODO: mTLS is not yet supported by NIOQUIC so we don't specify a value for `asyncVerifier`. asyncVerifier: nil, - authenticator: http3Context.quicAuthenticator, + authenticator: authenticator, logger: self.logger, inboundConnectionInitializer: { connectionChannel, streamCreator in connectionChannel.eventLoop.makeCompletedFuture { let connection = try self.setupHTTP3Connection( - http3Configuration: http3Context.configuration, + http3Configuration: http3Configuration, connectionChannel: connectionChannel, streamCreator: streamCreator ) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index 42634ba..eb88fd7 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -214,7 +214,8 @@ extension NIOHTTPServer { func setupSecureUpgradeServerChannels( bindTargets: [NIOHTTPServerConfiguration.BindTarget], - context: NIOHTTPServerConfiguration.ValidatedSecureUpgradeContext + http2Configuration: NIOHTTPServerConfiguration.HTTP2?, + sslContext: NIOSSLContext ) async throws -> [(NIOAsyncChannel, Never>, ServerQuiescingHelper)] { let bootstrap = ServerBootstrap(group: self.eventLoopGroup) .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) @@ -241,8 +242,8 @@ extension NIOHTTPServer { }.bind(host: host, port: port) { channel in self.setupSecureUpgradeConnectionChildChannel( channel: channel, - http2Configuration: context.http2Configuration, - sslContext: context.sslContext + http2Configuration: http2Configuration, + sslContext: sslContext ) } serverChannels.append((serverChannel, serverQuiescingHelper)) diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 3e856b0..c1335c4 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -203,70 +203,62 @@ public struct NIOHTTPServer: HTTPServer { ) } - #if HTTP3 /// Creates and returns server channels based on the configured transport security. func makeServerChannels() async throws -> [ServerChannel] { - let bindTargets = self.configuration.bindTargets - let secureUpgradeContext = self.configuration.secureUpgradeContext - let http3Context = self.configuration.http3Context - - switch (secureUpgradeContext, http3Context) { - case (.none, .none): - // Set up plaintext HTTP/1.1 channel(s). - let http1Channels = try await self.setupHTTP1_1ServerChannels(bindTargets: bindTargets) - try self.addressesBound(http1Channels.map { (channel, _) in channel.channel.localAddress }) - return http1Channels.map { .plaintextHTTP1_1(channel: $0, quiescingHelper: $1) } - - case (.some(let secureUpgradeContext), .none): - // Set up secure upgrade channel(s). - let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( - bindTargets: bindTargets, - context: secureUpgradeContext + var serverChannels = [ServerChannel]() + var secureUpgradeBindTargets = self.configuration.bindTargets + + #if HTTP3 + if let http3Configuration = self.configuration.supportedHTTPVersions.http3ConfigIfSupported, + let authenticationConfiguration = self.configuration.quicAuthenticationConfiguration + { + let http3Channels = try await self.setupHTTP3ServerChannels( + bindTargets: self.configuration.bindTargets, + http3Configuration: http3Configuration, + authenticationConfiguration: authenticationConfiguration, + authenticator: self.configuration.quicAuthenticator ) - try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) - return secureUpgradeChannels.map { .secureUpgrade(channel: $0, quiescingHelper: $1) } - - case (.none, .some(let http3Context)): - let http3Channels = try await self.setupHTTP3ServerChannels(bindTargets: bindTargets, context: http3Context) - try self.addressesBound(http3Channels.map { (channel, _) in channel.localAddress }) - return http3Channels.map { .http3(quicChannel: $0, connectionMultiplexer: $1) } - - case (.some(let secureUpgradeContext), .some(let http3Context)): - // Set up HTTP/3 and secure upgrade channel(s) on the same port. - let http3Channels = try await self.setupHTTP3ServerChannels(bindTargets: bindTargets, context: http3Context) - - let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( - // We must bind the secure-upgrade channel(s) to the same port(s) as the HTTP/3 channel(s). - bindTargets: try http3Channels.map { (http3Channel, _) in try .init(http3Channel.localAddress) }, - context: secureUpgradeContext + serverChannels.append( + contentsOf: http3Channels.map { (quicChannel, mux) in + .http3(quicChannel: quicChannel, connectionMultiplexer: mux) + } ) - try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) - return http3Channels.map { .http3(quicChannel: $0, connectionMultiplexer: $1) } - + secureUpgradeChannels.map { .secureUpgrade(channel: $0, quiescingHelper: $1) } + if self.configuration.sslContext == nil { + // `supportedHTTPVersions == [.http3]` here. We therefore just return HTTP/3 channel(s). + try self.addressesBound(http3Channels.map { (channel, _) in channel.localAddress }) + return serverChannels + } + + // We also need to set up secure upgrade channel(s) on the same port. + secureUpgradeBindTargets = try http3Channels.map { (http3Channel, _) in + try NIOHTTPServerConfiguration.BindTarget(http3Channel.localAddress) + } } - } - #else - /// Creates and returns server channels based on the configured transport security. - func makeServerChannels() async throws -> [ServerChannel] { - let bindTargets = self.configuration.bindTargets - let secureUpgradeContext = self.configuration.secureUpgradeContext + #endif // HTTP3 - if let secureUpgradeContext { - let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( - bindTargets: bindTargets, - context: secureUpgradeContext - ) - try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) - return secureUpgradeChannels.map { .secureUpgrade(channel: $0, quiescingHelper: $1) } - } else { + guard let sslContext = self.configuration.sslContext else { // Set up plaintext HTTP/1.1 channel(s). - let http1Channels = try await self.setupHTTP1_1ServerChannels(bindTargets: bindTargets) + let http1Channels = try await self.setupHTTP1_1ServerChannels(bindTargets: secureUpgradeBindTargets) try self.addressesBound(http1Channels.map { (channel, _) in channel.channel.localAddress }) return http1Channels.map { .plaintextHTTP1_1(channel: $0, quiescingHelper: $1) } } + + let secureUpgradeChannels = try await self.setupSecureUpgradeServerChannels( + bindTargets: secureUpgradeBindTargets, + http2Configuration: self.configuration.supportedHTTPVersions.http2ConfigIfSupported, + sslContext: sslContext + ) + try self.addressesBound(secureUpgradeChannels.map { (channel, _) in channel.channel.localAddress }) + + serverChannels.append( + contentsOf: secureUpgradeChannels.map { (channel, quiescingHelper) in + .secureUpgrade(channel: channel, quiescingHelper: quiescingHelper) + } + ) + + return serverChannels } - #endif // HTTP3 private func _serve( serverChannels: [ServerChannel], diff --git a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift index 1c9f352..be659d2 100644 --- a/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift +++ b/Tests/NIOHTTPServerTests/Utilities/TestingChannelClientServer/TestingChannelServer+SecureUpgrade.swift @@ -79,7 +79,7 @@ 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() - guard let secureUpgradeContext = self.server.configuration.secureUpgradeContext else { + guard let sslContext = self.server.configuration.sslContext else { throw NIOHTTPServerConfigurationError.incompatibleTransportSecurity } @@ -87,8 +87,8 @@ struct TestingChannelSecureUpgradeServer { let negotiatedServerConnectionFuture = try await serverTestConnectionChannel.eventLoop.flatSubmit { self.server.setupSecureUpgradeConnectionChildChannel( channel: serverTestConnectionChannel, - http2Configuration: secureUpgradeContext.http2Configuration, - sslContext: secureUpgradeContext.sslContext + http2Configuration: self.server.configuration.supportedHTTPVersions.http2ConfigIfSupported, + sslContext: sslContext ) }.get() From 539385ffafbc00eace6f1929a0940523c54680ec Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 6 Aug 2026 17:35:46 +0100 Subject: [PATCH 10/16] Fix DocC references --- .../Configuration/TransportSecurity+TLSCredentials.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift index 131a929..31b3c7a 100644 --- a/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift +++ b/Sources/NIOHTTPServer/Configuration/TransportSecurity+TLSCredentials.swift @@ -61,7 +61,7 @@ extension NIOHTTPServerConfiguration.TransportSecurity { /// /// The credentials can be provided in any of the following ways: /// - As in-memory `X509.Certificate` and `X509.Certificate.PrivateKey` objects (``certificates(chain:privateKey:)``); - /// - From files (``pemFile(certificateChain:privateKey:)``, ``derFile(certificate:privateKey:)``) or bytes + /// - From files (``pemFile(certificateChainPath:privateKeyPath:)``, ``derFile(certificatePath:privateKeyPath:)``) or bytes /// (``pemBytes(certificateChain:privateKey:)``, ``derBytes(certificate:privateKey:)``), or; /// - Through a `CertificateReloader` instance that periodically reloads the credentials (``reloading(_:)``). public struct X509Credentials: Sendable { From a1658a96552ff3cd9cd82cf0fea1d8da22b62c7c Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 6 Aug 2026 17:50:07 +0100 Subject: [PATCH 11/16] Refactor --- .../NIOHTTPServerConfiguration+Validation.swift | 9 +++++---- .../Configuration/NIOHTTPServerConfiguration.swift | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift index d5acd22..6ae2780 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration+Validation.swift @@ -20,8 +20,9 @@ import NIOQUIC @available(anyAppleOS 26.0, *) extension NIOHTTPServerConfiguration { - /// Validates the configuration and derives the TLS resources required to set up the server channels. - mutating func validateHTTPVersionAndTLSCredentialCompatibility() throws { + /// Validates the compatibility of the `supportedHTTPVersions` and `transportSecurity` configurations, and stores + /// the TLS resources required to set up the server channels. + mutating func validateTransportConfiguration() throws { #if HTTP3 (self.quicAuthenticationConfiguration, self.quicAuthenticator) = try self.makeQUICAuthentication() #endif @@ -29,8 +30,8 @@ extension NIOHTTPServerConfiguration { self.sslContext = try self.makeSSLContext() } - /// Creates the `NIOSSLContext` used by the secure upgrade channel(s), or `nil` if the configuration doesn't call for - /// a secure upgrade channel. + /// Creates the `NIOSSLContext` used by the secure upgrade channel(s), or `nil` if the configuration does not + /// specify a secure upgrade channel. private func makeSSLContext() throws -> NIOSSLContext? { #if HTTP3 if self.supportedHTTPVersions.http3ConfigIfSupported != nil, self.supportedHTTPVersions.count == 1 { diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index c0e3047..f8c56d0 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -309,7 +309,7 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - `transportSecurity` can only be set to `.plaintext` when `supportedHTTPVersions == [.http1_1]`. public var transportSecurity: TransportSecurity { didSet { - try! self.validateHTTPVersionAndTLSCredentialCompatibility() + try! self.validateTransportConfiguration() } } @@ -327,7 +327,7 @@ public struct NIOHTTPServerConfiguration: Sendable { preconditionFailure(NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified.description) } - try! self.validateHTTPVersionAndTLSCredentialCompatibility() + try! self.validateTransportConfiguration() } } @@ -401,8 +401,8 @@ public struct NIOHTTPServerConfiguration: Sendable { self.maxConnections = nil self.connectionTimeouts = .defaults - // Validate the configuration and derive the TLS resources needed to set up the server channels. - try self.validateHTTPVersionAndTLSCredentialCompatibility() + // Validate the compatibility of `supportedHTTPVersions` and `transportSecurity`. + try self.validateTransportConfiguration() } /// Create a new configuration with a single bind target. From d970498ffb9b30128a1295ca7caf339dc0dd3ccb Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 6 Aug 2026 18:10:36 +0100 Subject: [PATCH 12/16] Reduce duplication in tests --- .../ServerChannelTests.swift | 80 ++++--------------- 1 file changed, 16 insertions(+), 64 deletions(-) diff --git a/Tests/NIOHTTPServerTests/ServerChannelTests.swift b/Tests/NIOHTTPServerTests/ServerChannelTests.swift index 130bffb..2eb6f37 100644 --- a/Tests/NIOHTTPServerTests/ServerChannelTests.swift +++ b/Tests/NIOHTTPServerTests/ServerChannelTests.swift @@ -42,38 +42,24 @@ struct ServerChannelTests { } @available(anyAppleOS 26.0, *) - @Test("transport: TLS, versions: {HTTP/1.1} -> secure upgrade channel") - func tlsHTTP1_1() async throws { - let chain = try TestCA.makeSelfSignedChain() - - let server = NIOHTTPServer( - logger: self.logger, - configuration: try .init( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1], - transportSecurity: .tls( - credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)) - ) - ) - ) - - let channels = try await server.makeServerChannels() - defer { server.close(serverChannels: channels) } - - #expect(channels.count == 1) - #expect(channels[0].isSecureUpgrade) - } - - @available(anyAppleOS 26.0, *) - @Test("transport: TLS, versions: {HTTP/1.1, HTTP/2} -> secure upgrade channel") - func tlsHTTP1_1AndHTTP2() async throws { + @Test( + "transport: TLS, versions: {HTTP/1.1 and/or HTTP/2} -> secure upgrade channel", + arguments: [ + [NIOHTTPServerConfiguration.HTTPVersion.http1_1], + [.http2], + [.http1_1, .http2], + ] + ) + func tlsHTTP1_1AndOrHTTP2( + supportedHTTPVersions: Set + ) async throws { let chain = try TestCA.makeSelfSignedChain() let server = NIOHTTPServer( logger: self.logger, configuration: try .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http1_1, .http2(config: .defaults)], + supportedHTTPVersions: supportedHTTPVersions, transportSecurity: .tls( credentials: .x509(.certificates(chain: chain.chain, privateKey: chain.privateKey)) ) @@ -98,7 +84,7 @@ struct ServerChannelTests { logger: self.logger, configuration: try .init( bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: [.http3(config: .defaults)], + supportedHTTPVersions: [.http3], transportSecurity: .tls( credentials: .x509(.pemFile(certificateChainPath: leafPath, privateKeyPath: keyPath)) ) @@ -116,9 +102,9 @@ struct ServerChannelTests { @Test( "transport: TLS, versions: {HTTP/1.1 and/or HTTP/2} + {HTTP/3} -> HTTP/3 and secure upgrade channels", arguments: [ - [Self.http1_1, Self.http3], - [Self.http2, Self.http3], - [Self.http1_1, Self.http2, Self.http3], + [NIOHTTPServerConfiguration.HTTPVersion.http1_1, .http3], + [.http2, .http3], + [.http1_1, .http2, .http3], ] ) func tlsCombinationOfSecureUpgradeAndHTTP3( @@ -150,43 +136,9 @@ struct ServerChannelTests { let secureUpgradeAddress = try #require(channels[1].localAddress) #expect(http3Address.port == secureUpgradeAddress.port) } - - @available(anyAppleOS 26.0, *) - @Test( - "transport: plaintext, versions: HTTP/2 and/or HTTP/3 -> rejected", - arguments: [ - [Self.http2], - [Self.http3], - [Self.http2, Self.http3], - // Even when HTTP/1.1 is specified, the presence of HTTP/2 and/or HTTP/3 should make the config invalid - [Self.http1_1, Self.http2], - [Self.http1_1, Self.http3], - [Self.http1_1, Self.http2, Self.http3], - ] - ) - func plaintextNotSupportedForHTTP2OrHTTP3(supportedHTTPVersions: Set) { - #expect(throws: NIOHTTPServerConfigurationError.incompatibleTransportSecurity) { - try NIOHTTPServerConfiguration( - bindTarget: .hostAndPort(host: "127.0.0.1", port: 0), - supportedHTTPVersions: supportedHTTPVersions, - transportSecurity: .plaintext - ) - } - } #endif // HTTP3 } -@available(anyAppleOS 26.0, *) -extension ServerChannelTests { - private static let http1_1 = NIOHTTPServerConfiguration.HTTPVersion.http1_1 - - private static let http2 = NIOHTTPServerConfiguration.HTTPVersion.http2(config: .defaults) - - #if HTTP3 - private static let http3 = NIOHTTPServerConfiguration.HTTPVersion.http3(config: .defaults) - #endif -} - @available(anyAppleOS 26.0, *) extension NIOHTTPServer.ServerChannel { var isPlaintextHTTP1_1: Bool { From 0ece72022cf1f9cdfd2519e956d11f05b991a0a9 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 7 Aug 2026 11:03:33 +0100 Subject: [PATCH 13/16] Empty commit to re-trigger CI checks From a8abebb3d5a7b115a51fa559b3ee8b7cad4b2df6 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Tue, 11 Aug 2026 09:47:47 +0100 Subject: [PATCH 14/16] Empty commit to re-trigger CI checks From 5c29ae184f3e877ece7895f10ac1ef1d5adeb692 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Tue, 11 Aug 2026 16:03:33 +0100 Subject: [PATCH 15/16] Replace try! with preconditionFailure --- .../Configuration/NIOHTTPServerConfiguration.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift index f8c56d0..33c39f3 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerConfiguration.swift @@ -309,7 +309,11 @@ public struct NIOHTTPServerConfiguration: Sendable { /// - `transportSecurity` can only be set to `.plaintext` when `supportedHTTPVersions == [.http1_1]`. public var transportSecurity: TransportSecurity { didSet { - try! self.validateTransportConfiguration() + do { + try self.validateTransportConfiguration() + } catch { + preconditionFailure("\(error)") + } } } @@ -327,7 +331,11 @@ public struct NIOHTTPServerConfiguration: Sendable { preconditionFailure(NIOHTTPServerConfigurationError.noSupportedHTTPVersionsSpecified.description) } - try! self.validateTransportConfiguration() + do { + try self.validateTransportConfiguration() + } catch { + preconditionFailure("\(error)") + } } } From 8d224c05cb85fb5ba15e9f24ed11a6d47ec7c327 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Thu, 13 Aug 2026 16:32:18 +0100 Subject: [PATCH 16/16] Empty commit to re-trigger CI checks