diff --git a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift index c9493c8..c9db61f 100644 --- a/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/HTTP3/HTTP3+QUICConfiguration.swift @@ -279,4 +279,40 @@ extension NIOQUIC.QUICConfiguration { ) } } + +@available(anyAppleOS 26.0, *) +extension NIOQUIC.Authenticator { + /// Creates an `Authenticator` instance from X.509 TLS credentials. + /// + /// 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. + /// + /// - Throws: + /// - ``NIOHTTPServerConfigurationError/incompatibleTransportSecurity`` if `transportSecurity` is `.plaintext`. + /// - ``NIOHTTPServerConfigurationError/inMemoryOrReloadingTLSCredentialsNotSupportedOverHTTP3`` if the X.509 + /// credentials are provided as in-memory `X509.Certificate`/`X509.Certificate.PrivateKey` objects or as a + /// `CertificateReloader` instance. + /// - 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 .reloading: + throw NIOHTTPServerConfigurationError.onlyPEMFileCredentialsCurrentlySupportedOverHTTP3 + + case .pemFile(let certificateChainPath, let privateKeyPath): + try self.init(certificateFilePath: certificateChainPath, privateKeyFilePath: privateKeyPath) + + case .inMemory(let certificateChain, let privateKey): + try self.init(certificates: certificateChain, privateKey: privateKey) + } + } + } +} #endif // HTTP3 diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift index f2c8e0a..cdc425a 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+Connection.swift @@ -16,6 +16,11 @@ import NIOCore import NIOHTTP2 import NIOHTTPTypes +#if HTTP3 +@_spi(HTTP3AsyncInterface) import NIOHTTP3 +import NIOQUIC +#endif + @available(anyAppleOS 26.0, *) extension NIOHTTPServer { /// An active HTTP server connection. @@ -32,15 +37,26 @@ extension NIOHTTPServer { /// owns the channel and drives `executeThenClose`, so the writer is finished cleanly even if the connection /// handler returns without calling ``handleRequests(handler:)``). /// - `http2` carries the connection channel and stream multiplexer. + /// - `http3` carries an ``HTTP3ServerConnection``. enum HTTPProtocol: Sendable { case http1_1( inbound: NIOAsyncChannelInboundStream, outbound: NIOAsyncChannelOutboundWriter ) + case http2( connectionChannel: any Channel, multiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer> ) + + #if HTTP3 + case http3( + connection: HTTP3ServerConnection< + NIOAsyncChannel, + NIOQUIC.QUICStreamCreator + > + ) + #endif } let server: NIOHTTPServer @@ -76,6 +92,7 @@ extension NIOHTTPServer { handler: handler, context: context ) + case .http2(let connectionChannel, let multiplexer): await server.handleHTTP2Connection( connectionChannel: connectionChannel, @@ -83,6 +100,11 @@ extension NIOHTTPServer { handler: handler, context: context ) + + #if HTTP3 + case .http3(let connection): + await server.handleHTTP3Connection(connection: connection, handler: handler, context: context) + #endif } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift index d3be12a..1758109 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ConnectionContext.swift @@ -20,8 +20,12 @@ public import X509 extension NIOHTTPServer { /// The application-level HTTP version negotiated for a connection. public enum HTTPVersion: String, Sendable, Hashable { - case http1_1 = "http/1.1" - case http2 = "http/2" + case plaintextHTTP1_1 = "Plaintext HTTP/1.1" + case http1_1 = "HTTP/1.1" + case http2 = "HTTP/2" + #if HTTP3 + case http3 = "HTTP/3" + #endif } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift index 494f140..57a98b2 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -79,8 +79,10 @@ extension NIOHTTPServer { ) async { do { try await requestChannel.executeThenClose { inbound, outbound in - let context = NIOHTTPServer.makeHTTP1ConnectionContext( - requestChannel: requestChannel, + let context = ConnectionContext( + httpVersion: .plaintextHTTP1_1, + remoteAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.remoteAddress), + localAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.localAddress), peerCertificateChainFuture: nil ) let connection = Connection( @@ -158,8 +160,6 @@ extension NIOHTTPServer { throw error } - try self.addressesBound(serverChannels.map { (serverChannel, _) in serverChannel.channel.localAddress }) - return serverChannels } @@ -172,10 +172,7 @@ extension NIOHTTPServer { channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: isSecure)) try channel.pipeline.syncOperations.addHandler(HTTPKeepAliveHandler()) - try channel - .pipeline - .syncOperations - .addTimeoutHandlers(self.configuration.connectionTimeouts) + try channel.pipeline.syncOperations.addTimeoutHandlers(self.configuration.connectionTimeouts) return try NIOAsyncChannel( wrappingChannelSynchronously: channel, @@ -184,19 +181,6 @@ extension NIOHTTPServer { } } - /// Builds a ``ConnectionContext`` for an HTTP/1.1 request channel. - static func makeHTTP1ConnectionContext( - requestChannel: NIOAsyncChannel, - peerCertificateChainFuture: EventLoopFuture? - ) -> ConnectionContext { - ConnectionContext( - httpVersion: .http1_1, - remoteAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.remoteAddress), - localAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.localAddress), - peerCertificateChainFuture: peerCertificateChainFuture - ) - } - /// Drives the request loop on an HTTP/1.1 connection that may carry /// multiple serial requests (keep-alive). Invoked from /// ``NIOHTTPServer/Connection/handleRequests(handler:)`` for the diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift new file mode 100644 index 0000000..b531fc8 --- /dev/null +++ b/Sources/NIOHTTPServer/NIOHTTPServer+HTTP3.swift @@ -0,0 +1,276 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +#if HTTP3 +import HTTP3 +import Logging +import NIOCore +import NIOEmbedded +@_spi(HTTP3AsyncInterface) import NIOHTTP3 +import NIOHTTPTypes +import NIOPosix +import NIOQUIC +import NIOQUICHelpers +import NIOSSL +import X509 + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServer { + func serveHTTP3( + connectionMultiplexer: HTTP3ServerConnectionMultiplexer< + NIOAsyncChannel, + NIOQUIC.QUICStreamCreator + >, + connectionHandler: Handler + ) async { + // We don't use a `withThrowingDiscardingTaskGroup` here because an error thrown from the body or a child task + // would immediately propagate upwards, cancelling all child tasks and bringing down the entire server. We + // instead use a non-throwing discarding task group so that errors in the body must be caught and handled + // directly. + await withDiscardingTaskGroup { connectionGroup in + for await connection in connectionMultiplexer.inboundConnections { + connectionGroup.addTask { + await self.dispatchHTTP3Connection(connection, handler: connectionHandler) + } + } + } + } + + /// Builds the per-connection ``Connection`` and ``ConnectionContext`` for a HTTP/3 connection channel and + /// dispatches the connection to the connection handler. Errors from the connection handler are logged. + func dispatchHTTP3Connection( + _ http3Connection: HTTP3ServerConnection< + NIOAsyncChannel, + NIOQUIC.QUICStreamCreator + >, + handler: Handler + ) async { + let context = ConnectionContext( + httpVersion: .http3, + remoteAddress: nil, + localAddress: nil, + peerCertificateChainFuture: nil + ) + + let connection = Connection( + server: self, + context: context, + httpProtocol: .http3(connection: http3Connection) + ) + + do { + try await handler.handleConnection(connection: connection, context: context) + } catch { + self.logger.debug("Error thrown by connection handler", metadata: ["error": "\(error)"]) + } + } + + /// Drives the request loop on a HTTP/3 connection by iterating the stream channels and handling each stream + /// concurrently. + /// + /// - Note: Stream iteration errors are logged but do not propagate to the caller. + func handleHTTP3Connection( + connection: HTTP3ServerConnection< + NIOAsyncChannel, + NIOQUIC.QUICStreamCreator + >, + handler: Handler, + context: ConnectionContext + ) async + where + Handler.RequestContext == RequestContext, + Handler.Reader == Reader, + Handler.ResponseSender == ResponseSender + { + await withDiscardingTaskGroup { streamGroup in + for await streamChannel in connection.inboundStreams { + streamGroup.addTask { + await self.handleStreamChannel(channel: streamChannel, handler: handler, context: context) + } + } + } + } + + /// Creates and binds a QUIC channel for each of the provided bind targets, and returns every bound channel + /// alongside the associated HTTP/3 connection multiplexer. + func setupHTTP3ServerChannels( + bindTargets: [NIOHTTPServerConfiguration.BindTarget], + http3Configuration: NIOHTTPServerConfiguration.HTTP3 + ) async throws -> [( + quicChannel: any Channel, + connectionMultiplexer: HTTP3ServerConnectionMultiplexer< + NIOAsyncChannel, + 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) + + var serverChannels = [ + ( + any Channel, + HTTP3ServerConnectionMultiplexer< + NIOAsyncChannel, NIOQUIC.QUICStreamCreator + > + ) + ]() + do { + for bindTarget in bindTargets { + switch bindTarget.backing { + 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 + ) + } + } + + serverChannels.append((quicChannel, multiplexer)) + } + } + } catch { + // A later bind failed: close any channels that are already bound to avoid leaking sockets. + for (serverChannel, _) in serverChannels { + try? await serverChannel.close() + } + throw error + } + + return serverChannels + } + + /// Installs the QUIC handler on a bound datagram channel and returns the channel alongside the connection + /// multiplexer. + func setupQUICChannel( + channel: any Channel, + quicConfiguration: NIOQUIC.QUICConfiguration, + http3Configuration: NIOHTTPServerConfiguration.HTTP3 + ) throws -> ( + quicChannel: any Channel, + connectionMultiplexer: HTTP3ServerConnectionMultiplexer< + NIOAsyncChannel, NIOQUIC.QUICStreamCreator + > + ) { + let connectionMultiplexer = HTTP3ServerConnectionMultiplexer< + NIOAsyncChannel, + NIOQUIC.QUICStreamCreator + >() + + let quicHandler = QUICHandler( + channel: channel, + quicConfiguration: quicConfiguration, + // 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), + logger: self.logger, + inboundConnectionInitializer: { connectionChannel, streamCreator in + connectionChannel.eventLoop.makeCompletedFuture { + let connection = try self.setupHTTP3Connection( + http3Configuration: http3Configuration, + connectionChannel: connectionChannel, + streamCreator: streamCreator + ) + connectionMultiplexer.yield(connection: connection) + } + }, + inboundStreamInitializer: { streamChannel in + streamChannel.parent!.pipeline.handler(type: HTTP3ConnectionHandler.self) + .flatMap { http3Handler in + http3Handler.inboundStreamReceived(streamChannel) + } + }, + noMoreConnections: { + connectionMultiplexer.finish() + } + ) + + try channel.pipeline.syncOperations.addHandler(quicHandler) + + return (channel, connectionMultiplexer) + } + + /// Sets up an `HTTP3ConnectionHandler` and adds it to the connection channel pipeline. + func setupHTTP3Connection( + http3Configuration: NIOHTTPServerConfiguration.HTTP3, + connectionChannel: any Channel, + streamCreator: NIOQUIC.QUICStreamCreator, + ) throws -> HTTP3ServerConnection< + NIOAsyncChannel, + NIOQUIC.QUICStreamCreator + > { + let loopBoundHandler = NIOLoopBoundBox?>( + nil, + eventLoop: connectionChannel.eventLoop + ) + + let connection = HTTP3ServerConnection(connectionHandler: loopBoundHandler) { streamInitializerParameters in + let streamChannel = streamInitializerParameters.channel + + return streamChannel.eventLoop.makeCompletedFuture { + try self.setupHTTP3Stream(streamChannel: streamChannel) + } + } + + var h3ServerConfig = HTTP3ServerConfiguration(http3Configuration) + h3ServerConfig.rttProvider = { + guard let syncOptions = connectionChannel.syncOptions else { + // We should never reach this case; connection channels are `ChildChannel`s and + // `ChildChannel` implements `syncOptions`. + preconditionFailure("The connection channel does not have syncOptions set.") + } + + guard let rtt = try? syncOptions.getOption(.rttEstimate) else { + // Use the fallback RTT if there is an error obtaining the RTT estimate channel option. + return NIOHTTPServerConfiguration.HTTP3.fallbackConnectionRTT + } + + return rtt + } + + let http3Handler = HTTP3ConnectionHandler.server( + eventLoop: connectionChannel.eventLoop, + configuration: h3ServerConfig, + settings: .init(http3Configuration.connectionSettings), + streamCreator: streamCreator, + logger: self.logger, + connection: connection + ) + loopBoundHandler.value = http3Handler + try connectionChannel.pipeline.syncOperations.addHandler(http3Handler) + + return connection + } + + /// Configures the pipeline for an inbound HTTP/3 stream channel and wraps it in a `NIOAsyncChannel`. + func setupHTTP3Stream(streamChannel: any Channel) throws -> NIOAsyncChannel { + try streamChannel.pipeline.syncOperations.addReadTimeoutHandlers(self.configuration.connectionTimeouts) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: streamChannel, + configuration: .init( + backPressureStrategy: .init(self.configuration.backpressureStrategy), + isOutboundHalfClosureEnabled: true + ) + ) + } +} +#endif // HTTP3 diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift index b96de84..c57cc5b 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+ListeningAddress.swift @@ -141,8 +141,30 @@ extension NIOHTTPServer.SocketAddress { switch address { case .v4(let ipv4Address): self.init(base: .ipv4(.init(host: ipv4Address.host, port: port))) + case .v6(let ipv6Address): self.init(base: .ipv6(.init(host: ipv6Address.host, port: port))) + + case .unixDomainSocket: + throw ListeningAddressError.unsupportedAddressType + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.BindTarget { + init(_ address: NIOCore.SocketAddress?) throws(ListeningAddressError) { + guard let address, let port = address.port else { + throw ListeningAddressError.addressOrPortNotAvailable + } + + switch address { + case .v4(let ipv4Address): + self.init(backing: .hostAndPort(host: ipv4Address.host, port: port)) + + case .v6(let ipv6Address): + self.init(backing: .hostAndPort(host: ipv6Address.host, port: port)) + case .unixDomainSocket: throw ListeningAddressError.unsupportedAddressType } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift index f5cf9e5..5a5bbc4 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -98,8 +98,10 @@ extension NIOHTTPServer { do { try await requestChannel.executeThenClose { inbound, outbound in let chainFuture = requestChannel.channel.nioSSL_peerValidatedCertificateChain() - let context = NIOHTTPServer.makeHTTP1ConnectionContext( - requestChannel: requestChannel, + let context = ConnectionContext( + httpVersion: .http1_1, + remoteAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.remoteAddress), + localAddress: try? NIOHTTPServer.SocketAddress(requestChannel.channel.localAddress), peerCertificateChainFuture: chainFuture ) let connection = Connection( @@ -183,11 +185,7 @@ extension NIOHTTPServer { do { for try await streamChannel in multiplexer.inbound { streamGroup.addTask { - await self.handleHTTP2StreamChannel( - channel: streamChannel, - handler: handler, - context: context - ) + await self.handleStreamChannel(channel: streamChannel, handler: handler, context: context) } } } catch { @@ -261,8 +259,6 @@ extension NIOHTTPServer { throw error } - try self.addressesBound(serverChannels.map { (serverChannel, _) in serverChannel.channel.localAddress }) - return serverChannels } @@ -294,10 +290,9 @@ extension NIOHTTPServer { ) // Add read header and body timeouts per-stream for HTTP/2 - try http2StreamChannel - .pipeline - .syncOperations - .addReadTimeoutHandlers(self.configuration.connectionTimeouts) + try http2StreamChannel.pipeline.syncOperations.addReadTimeoutHandlers( + self.configuration.connectionTimeouts + ) return try NIOAsyncChannel( wrappingChannelSynchronously: http2StreamChannel, @@ -375,8 +370,8 @@ extension NIOHTTPServer { } } - /// Handles an HTTP/2 stream channel, which carries exactly one request per stream. - func handleHTTP2StreamChannel( + /// Handles a stream channel, which carries exactly one request per stream. + func handleStreamChannel( channel: NIOAsyncChannel, handler: Handler, context: ConnectionContext @@ -387,36 +382,38 @@ extension NIOHTTPServer { Handler.ResponseSender == ResponseSender { do { - try await channel - .executeThenClose { inbound, outbound in - var iterator = inbound.makeAsyncIterator() + try await channel.executeThenClose { inbound, outbound in + var iterator = inbound.makeAsyncIterator() - guard let httpRequest = try await self.nextRequestHead(from: &iterator) else { - outbound.finish() - return - } + guard let httpRequest = try await self.nextRequestHead(from: &iterator) else { + outbound.finish() + return + } - _ = try await self.invokeHandler( - request: httpRequest, - iterator: iterator, - outbound: outbound, - handler: handler, - context: context - ) + _ = try await self.invokeHandler( + request: httpRequest, + iterator: iterator, + outbound: outbound, + handler: handler, + context: context + ) - // TODO: handle other state scenarios. - // For example, if we didn't finish reading but we wrote back a response, we - // should send a RST_STREAM with NO_ERROR set. If we finished reading but we - // didn't write back a response, then RST_STREAM is also likely appropriate but - // unclear about the error. + // TODO: handle other state scenarios. + // For example, if we didn't finish reading but we wrote back a response, we + // should send a RST_STREAM with NO_ERROR set. If we finished reading but we + // didn't write back a response, then RST_STREAM is also likely appropriate but + // unclear about the error. - // Finish the outbound and wait on the close future to make sure all pending - // writes are actually written. - outbound.finish() - try await channel.channel.closeFuture.get() - } + // Finish the outbound and wait on the close future to make sure all pending + // writes are actually written. + outbound.finish() + try await channel.channel.closeFuture.get() + } } catch { - self.logger.debug("Error thrown while handling HTTP/2 stream: \(error)") + self.logger.debug( + "Error thrown while handling stream", + metadata: ["error": "\(error)", "protocol": "\(context.httpVersion)"] + ) try? await channel.channel.close() } } diff --git a/Sources/NIOHTTPServer/NIOHTTPServer.swift b/Sources/NIOHTTPServer/NIOHTTPServer.swift index 63ba1b5..37c0bf2 100644 --- a/Sources/NIOHTTPServer/NIOHTTPServer.swift +++ b/Sources/NIOHTTPServer/NIOHTTPServer.swift @@ -205,25 +205,60 @@ public struct NIOHTTPServer: HTTPServer { /// Creates and returns server channels based on the configured transport security. private func makeServerChannels() async throws -> [ServerChannel] { - switch self.configuration.transportSecurity.backing { - case .plaintext: - return try await self.setupHTTP1_1ServerChannels(bindTargets: self.configuration.bindTargets) - .map { channel, quiescingHelper in - .plaintextHTTP1_1(channel: channel, quiescingHelper: quiescingHelper) - } + // 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) + 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 - case .tls, .mTLS: - return try await self.setupSecureUpgradeServerChannels( + #if HTTP3 + if let http3Config = self.configuration.supportedHTTPVersions.http3ConfigIfSupported { + let http3Channels = try await self.setupHTTP3ServerChannels( bindTargets: self.configuration.bindTargets, - supportedHTTPVersions: self.configuration.supportedHTTPVersions, - sslContext: .makeServerContext( - transportSecurity: self.configuration.transportSecurity, - alpnIdentifiers: self.configuration.supportedHTTPVersions.alpnIdentifiers - ), - ).map { channel, quiescingHelper in - .secureUpgrade(channel: channel, quiescingHelper: quiescingHelper) + http3Configuration: http3Config + ) + serverChannels.append( + contentsOf: http3Channels.map { (quicChannel, mux) in + .http3(quicChannel: quicChannel, connectionMultiplexer: mux) + } + ) + + 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) } } + #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) + } + ) + + return serverChannels } private func _serve( @@ -245,6 +280,14 @@ public struct NIOHTTPServer: HTTPServer { serverChannel: secureUpgradeChannel, connectionHandler: connectionHandler ) + + #if HTTP3 + case .http3(_, let connectionMultiplexer): + await self.serveHTTP3( + connectionMultiplexer: connectionMultiplexer, + connectionHandler: connectionHandler + ) + #endif } } } @@ -344,15 +387,31 @@ public struct NIOHTTPServer: HTTPServer { } } - /// Initiates a graceful shutdown, allowing existing connections to drain before closing. + /// Initiates a graceful shutdown, allowing existing connections to drain before closing. How graceful shutdown is + /// signalled depends on the protocol: + /// + /// For HTTP/1.1 and HTTP/2, `ServerQuiescingHelper` is added to the server channel pipeline. For each accepted + /// connection, `ServerQuiescingHelper` stores the associated connection child channel. When `initiateShutdown` is + /// called, `ServerQuiescingHelper` closes the server's socket to stop accepting any new connections, then fires + /// `ChannelShouldQuiesceEvent` on each stored child channel. + /// + /// For HTTP/3, `ServerQuiescingHelper` cannot be used as QUIC connections are multiplexed internally by + /// `QUICHandler`. We instead fire `ChannelShouldQuiesceEvent` directly on the QUIC channel. `QUICHandler` reacts to + /// it by propagating the event to each QUIC connection channel. This eventually reaches `HTTP3ConnectionHandler`, + /// which performs the two-phase GOAWAY shutdown sequence. private func beginGracefulShutdown(serverChannels: [ServerChannel]) { self.finishListeningAddressPromise() for serverChannel in serverChannels { switch serverChannel { - case .plaintextHTTP1_1(_, let quiescingHelper), - .secureUpgrade(_, let quiescingHelper): + case .plaintextHTTP1_1(_, let quiescingHelper), .secureUpgrade(_, let quiescingHelper): quiescingHelper.initiateShutdown(promise: nil) + + #if HTTP3 + case .http3(let quicChannel, _): + // Fire ChannelShouldQuiesceEvent directly on the QUIC channel. + quicChannel.pipeline.fireUserInboundEventTriggered(ChannelShouldQuiesceEvent()) + #endif } } } @@ -368,25 +427,29 @@ public struct NIOHTTPServer: HTTPServer { case .secureUpgrade(let secureUpgradeChannel, _): secureUpgradeChannel.channel.close(promise: nil) + + #if HTTP3 + case .http3(let quicChannel, _): + quicChannel.close(promise: nil) + #endif } } } - } @available(anyAppleOS 26.0, *) extension ChannelPipeline.SynchronousOperations { /// Adds timeout handlers (idle, read header, read body) to the channel pipeline. /// - /// Only handlers for non-nil timeouts are installed. Called for HTTP/1.1 connection channels. + /// Only handlers for non-nil timeouts are installed. func addTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { try self.addIdleTimeoutHandlers(timeouts) try self.addReadTimeoutHandlers(timeouts) } - /// Adds the connection idle timeout handler to the channel. Used by HTTP/1.1 connection - /// channels. (HTTP/2 delegates idle handling to `NIOHTTP2ServerConnectionManagementHandler`'s - /// `maxIdleTime`, which is stream-aware.) + /// Adds the connection idle timeout handler to the channel. Used by HTTP/1.1 connection channels. HTTP/2 delegates + /// idle handling to `NIOHTTP2ServerConnectionManagementHandler`'s `maxIdleTime`. Idle timeout is not currently + /// supported over HTTP/3. func addIdleTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { if let idle = timeouts.idle { try self.addHandler( @@ -395,8 +458,7 @@ extension ChannelPipeline.SynchronousOperations { } } - /// Adds only read header and body timeout handlers to the channel. Used for HTTP/1.1 - /// connection channels and HTTP/2 per-stream channels. + /// Adds header and body read timeout handlers to the channel. func addReadTimeoutHandlers(_ timeouts: NIOHTTPServerConfiguration.ConnectionTimeouts) throws { let readHeader = timeouts.readHeader.map { TimeAmount($0) } let readBody = timeouts.readBody.map { TimeAmount($0) } diff --git a/Sources/NIOHTTPServer/ServerChannel.swift b/Sources/NIOHTTPServer/ServerChannel.swift index ae65104..c856e50 100644 --- a/Sources/NIOHTTPServer/ServerChannel.swift +++ b/Sources/NIOHTTPServer/ServerChannel.swift @@ -16,10 +16,14 @@ import NIOCore import NIOExtras import NIOHTTPTypes +#if HTTP3 +@_spi(HTTP3AsyncInterface) import NIOHTTP3 +import NIOQUIC +#endif + @available(anyAppleOS 26.0, *) extension NIOHTTPServer { - /// Abstracts over the two types of server channels ``NIOHTTPServer`` can create: plaintext HTTP/1.1 and Secure - /// Upgrade. + /// Abstracts over the types of server channels ``NIOHTTPServer`` can serve. enum ServerChannel { case plaintextHTTP1_1( channel: NIOAsyncChannel, Never>, @@ -30,5 +34,15 @@ extension NIOHTTPServer { channel: NIOAsyncChannel, Never>, quiescingHelper: ServerQuiescingHelper ) + + #if HTTP3 + case http3( + quicChannel: any Channel, + connectionMultiplexer: HTTP3ServerConnectionMultiplexer< + NIOAsyncChannel, + QUICStreamCreator + > + ) + #endif } } diff --git a/Sources/NIOHTTPServer/TimeoutHandlers.swift b/Sources/NIOHTTPServer/TimeoutHandlers.swift index c51208f..0d2c93d 100644 --- a/Sources/NIOHTTPServer/TimeoutHandlers.swift +++ b/Sources/NIOHTTPServer/TimeoutHandlers.swift @@ -15,17 +15,11 @@ import NIOCore import NIOHTTPTypes -/// A channel handler that closes an HTTP/1.1 connection after a period in which no request is in -/// flight. +/// A channel handler that closes a connection after a period in which no request is in flight. /// -/// The timer runs only between requests: it is scheduled when the channel becomes active and -/// after each response `.end` is written. It is cancelled when an inbound request `.head` is -/// observed. While a request is being processed, request-level timeouts (see -/// ``RequestTimeoutHandler``) are responsible for protecting the server. -/// -/// This handler is used on the per-connection channel for HTTP/1.1 only. For HTTP/2, idle -/// behaviour is delegated to `NIOHTTP2ServerConnectionManagementHandler`'s `maxIdleTime`, which -/// already understands stream lifecycle. +/// The timer runs only between requests: it is scheduled when the channel becomes active and after each response `.end` +/// is written. It is cancelled when an inbound request `.head` is observed. While a request is being processed, +/// request-level timeouts (see ``RequestTimeoutHandler``) are responsible for protecting the server. final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannelHandler { typealias InboundIn = HTTPRequestPart typealias InboundOut = HTTPRequestPart @@ -90,12 +84,10 @@ final class ConnectionIdleTimeoutHandler: ChannelDuplexHandler, RemovableChannel /// /// State machine: /// - On channel active, a header timeout is scheduled (if configured). -/// - When `.head` is received, the header timeout is cancelled and a body timeout is scheduled -/// (if configured). -/// - When `.end` is received, the body timeout is cancelled and the header timeout is rescheduled -/// so that the next request on a keep-alive connection is also protected. (For HTTP/2 streams -/// this is a no-op in practice: each stream sees only one request and is closed shortly after -/// `.end`.) +/// - When `.head` is received, the header timeout is cancelled and a body timeout is scheduled (if configured). +/// - When `.end` is received, the body timeout is cancelled and the header timeout is rescheduled so that the next +/// request on a keep-alive connection is also protected. (For HTTP/2 and HTTP/3 streams this is a no-op in practice: +/// each stream sees only one request and is closed shortly after `.end`.) /// /// If either timeout fires, the connection is closed. final class RequestTimeoutHandler: ChannelInboundHandler, RemovableChannelHandler { diff --git a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift index 777207a..7c8739d 100644 --- a/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift +++ b/Tests/NIOHTTPServerTests/ConnectionLifecycleTests.swift @@ -458,7 +458,7 @@ struct ConnectionLifecycleTests { } } - #expect(observed.withLockedValue { $0 } == .http1_1) + #expect(observed.withLockedValue { $0 } == .plaintextHTTP1_1) } @available(anyAppleOS 26.0, *)