diff --git a/Package.swift b/Package.swift index 06a2998..1c81e85 100644 --- a/Package.swift +++ b/Package.swift @@ -30,7 +30,8 @@ let package = Package( .package(url: "https://github.com/apple/swift-distributed-tracing.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-certificates.git", from: "1.16.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), + // TODO: Update `branch` once NIOAsyncTestingChannel patch (https://github.com/apple/swift-nio/pull/3464) is released. + .package(url: "https://github.com/apple/swift-nio.git", branch: "main"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"), .package(url: "https://github.com/apple/swift-nio-extras.git", from: "1.30.0"), .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.0.0"), diff --git a/Sources/HTTPServer/NIOHTTPServer+HTTP1_1.swift b/Sources/HTTPServer/NIOHTTPServer+HTTP1_1.swift new file mode 100644 index 0000000..5cc0022 --- /dev/null +++ b/Sources/HTTPServer/NIOHTTPServer+HTTP1_1.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import NIOPosix + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer { + func serveInsecureHTTP1_1( + bindTarget: NIOHTTPServerConfiguration.BindTarget, + handler: some HTTPServerRequestHandler, + asyncChannelConfiguration: NIOAsyncChannel.Configuration + ) async throws { + let serverChannel = try await self.setupHTTP1_1ServerChannel( + bindTarget: bindTarget, + asyncChannelConfiguration: asyncChannelConfiguration + ) + + try await _serveInsecureHTTP1_1(serverChannel: serverChannel, handler: handler) + } + + private func setupHTTP1_1ServerChannel( + bindTarget: NIOHTTPServerConfiguration.BindTarget, + asyncChannelConfiguration: NIOAsyncChannel.Configuration + ) async throws -> NIOAsyncChannel, Never> { + switch bindTarget.backing { + case .hostAndPort(let host, let port): + let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .bind(host: host, port: port) { channel in + self.setupHTTP1_1ConnectionChildChannel( + channel: channel, + asyncChannelConfiguration: asyncChannelConfiguration + ) + } + + try self.addressBound(serverChannel.channel.localAddress) + + return serverChannel + } + } + + func setupHTTP1_1ConnectionChildChannel( + channel: any Channel, + asyncChannelConfiguration: NIOAsyncChannel.Configuration + ) -> EventLoopFuture> { + channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { + try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: false)) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: asyncChannelConfiguration + ) + } + } + + private func _serveInsecureHTTP1_1( + serverChannel: NIOAsyncChannel, Never>, + handler: some HTTPServerRequestHandler + ) async throws { + try await withThrowingDiscardingTaskGroup { group in + try await serverChannel.executeThenClose { inbound in + for try await http1Channel in inbound { + group.addTask { + try await self.handleRequestChannel( + channel: http1Channel, + handler: handler + ) + } + } + } + } + } + + func serveInsecureHTTP1_1WithTestChannel( + testChannel: NIOAsyncTestingChannel, + handler: some HTTPServerRequestHandler + ) async throws { + // The server requires a NIOAsyncChannel, so we create one from the test channel + let serverTestAsyncChannel = try await testChannel.eventLoop.submit { + return try NIOAsyncChannel, Never>( + wrappingChannelSynchronously: testChannel, + configuration: .init() + ) + }.get() + + // Trick the server into thinking it's been bound to an address so that we don't leak the listening address + // promise. In reality, the server hasn't been bound to any address: we will manually feed in requests and + // observe responses. + try self.addressBound(.init(ipAddress: "127.0.0.1", port: 8000)) + _ = try await self.listeningAddress + + try await _serveInsecureHTTP1_1(serverChannel: serverTestAsyncChannel, handler: handler) + } +} diff --git a/Sources/HTTPServer/NIOHTTPServer+SecureUpgrade.swift b/Sources/HTTPServer/NIOHTTPServer+SecureUpgrade.swift new file mode 100644 index 0000000..1d7bff9 --- /dev/null +++ b/Sources/HTTPServer/NIOHTTPServer+SecureUpgrade.swift @@ -0,0 +1,229 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import NIOCore +import NIOEmbedded +import NIOHTTP2 +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import NIOHTTPTypesHTTP2 +import NIOPosix +import NIOSSL +import X509 + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer { + func serveSecureUpgrade( + bindTarget: NIOHTTPServerConfiguration.BindTarget, + tlsConfiguration: TLSConfiguration, + handler: some HTTPServerRequestHandler, + asyncChannelConfiguration: NIOAsyncChannel.Configuration, + http2Configuration: NIOHTTP2Handler.Configuration, + verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil + ) async throws { + let serverChannel = try await self.setupSecureUpgradeServerChannel( + bindTarget: bindTarget, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: http2Configuration, + verificationCallback: verificationCallback + ) + + try await self._serveSecureUpgrade(serverChannel: serverChannel, handler: handler) + } + + typealias NegotiatedChannel = NIONegotiatedHTTPVersion< + NIOAsyncChannel, + (any Channel, NIOHTTP2Handler.AsyncStreamMultiplexer>) + > + + private func setupSecureUpgradeServerChannel( + bindTarget: NIOHTTPServerConfiguration.BindTarget, + tlsConfiguration: TLSConfiguration, + asyncChannelConfiguration: NIOAsyncChannel.Configuration, + http2Configuration: NIOHTTP2Handler.Configuration, + verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + ) async throws -> NIOAsyncChannel, Never> { + switch bindTarget.backing { + case .hostAndPort(let host, let port): + let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .bind(host: host, port: port) { channel in + self.setupSecureUpgradeConnectionChildChannel( + channel: channel, + tlsConfiguration: tlsConfiguration, + asyncChannelConfiguration: asyncChannelConfiguration, + http2Configuration: http2Configuration, + verificationCallback: verificationCallback + ) + } + + try self.addressBound(serverChannel.channel.localAddress) + + return serverChannel + } + } + + func setupSecureUpgradeConnectionChildChannel( + channel: any Channel, + tlsConfiguration: TLSConfiguration, + asyncChannelConfiguration: NIOAsyncChannel.Configuration, + http2Configuration: NIOHTTP2Handler.Configuration, + verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + ) -> EventLoopFuture> { + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + self.makeSSLServerHandler(tlsConfiguration, verificationCallback) + ) + }.flatMap { + channel.configureAsyncHTTPServerPipeline( + http2Configuration: http2Configuration, + http1ConnectionInitializer: { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: asyncChannelConfiguration + ) + } + }, + http2ConnectionInitializer: { channel in channel.eventLoop.makeCompletedFuture(.success(channel)) }, + http2StreamInitializer: { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations + .addHandler( + HTTP2FramePayloadToHTTPServerCodec() + ) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: asyncChannelConfiguration + ) + } + } + ) + } + } + + private func _serveSecureUpgrade( + serverChannel: NIOAsyncChannel, Never>, + handler: some HTTPServerRequestHandler + ) async throws { + try await withThrowingDiscardingTaskGroup { group in + try await serverChannel.executeThenClose { inbound in + for try await upgradeResult in inbound { + group.addTask { + do { + try await withThrowingDiscardingTaskGroup { connectionGroup in + switch try await upgradeResult.get() { + case .http1_1(let http1Channel): + let chainFuture = http1Channel.channel.nioSSL_peerValidatedCertificateChain() + Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http1Channel, + handler: handler + ) + } + } + case .http2((let http2Connection, let http2Multiplexer)): + do { + let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() + try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { + for try await http2StreamChannel in http2Multiplexer.inbound { + connectionGroup.addTask { + try await self.handleRequestChannel( + channel: http2StreamChannel, + handler: handler + ) + } + } + } + } catch { + self.logger.debug("HTTP2 connection closed: \(error)") + } + } + } + } catch { + self.logger.debug("Negotiating ALPN failed: \(error)") + } + } + } + } + } + } + + func serveSecureUpgradeWithTestChannel( + testChannel: NIOAsyncTestingChannel, + handler: some HTTPServerRequestHandler + ) async throws { + // The server requires a NIOAsyncChannel, so we create one from the test channel + let testAsyncChannel = try await testChannel.eventLoop.submit { + return try NIOAsyncChannel, Never>( + wrappingChannelSynchronously: testChannel, + configuration: .init() + ) + }.get() + + // Trick the server into thinking it's been bound to an address so that we don't leak the listening address + // promise. In reality, the server hasn't been bound to any address: we will manually feed in requests and + // observe responses. + try self.addressBound(.init(ipAddress: "127.0.0.1", port: 8000)) + _ = try await self.listeningAddress + + try await self._serveSecureUpgrade(serverChannel: testAsyncChannel, handler: handler) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServer { + func makeSSLServerHandler( + _ tlsConfiguration: TLSConfiguration, + _ customVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? + ) throws -> NIOSSLServerHandler { + if let customVerificationCallback { + return try NIOSSLServerHandler( + context: .init(configuration: tlsConfiguration), + customVerificationCallbackWithMetadata: { certificates, promise in + promise.completeWithTask { + // Convert input [NIOSSLCertificate] to [X509.Certificate] + let x509Certs = try certificates.map { try Certificate($0) } + + let callbackResult = try await customVerificationCallback(x509Certs) + + switch callbackResult { + case .certificateVerified(let verificationMetadata): + guard let peerChain = verificationMetadata.validatedCertificateChain else { + return .certificateVerified(.init(nil)) + } + + // Convert the result into [NIOSSLCertificate] + let nioSSLCerts = try peerChain.map { try NIOSSLCertificate($0) } + return .certificateVerified(.init(.init(nioSSLCerts))) + + case .failed(let error): + self.logger.error("Custom certificate verification failed", metadata: [ + "failure-reason": .string(error.reason) + ]) + return .failed + } + } + } + ) + } else { + return try NIOSSLServerHandler(context: .init(configuration: tlsConfiguration)) + } + } +} diff --git a/Sources/HTTPServer/NIOHTTPServer.swift b/Sources/HTTPServer/NIOHTTPServer.swift index 5175ee9..a667a91 100644 --- a/Sources/HTTPServer/NIOHTTPServer.swift +++ b/Sources/HTTPServer/NIOHTTPServer.swift @@ -80,7 +80,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { public typealias RequestReader = HTTPRequestConcludingAsyncReader public typealias ResponseWriter = HTTPResponseConcludingAsyncWriter - private let logger: Logger + let logger: Logger private let configuration: NIOHTTPServerConfiguration var listeningAddressState: NIOLockedValueBox @@ -186,7 +186,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( - bindTarget: configuration.bindTarget, + bindTarget: self.configuration.bindTarget, tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, @@ -204,7 +204,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( - bindTarget: configuration.bindTarget, + bindTarget: self.configuration.bindTarget, tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, @@ -229,7 +229,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( - bindTarget: configuration.bindTarget, + bindTarget: self.configuration.bindTarget, tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, @@ -252,7 +252,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { tlsConfiguration.applicationProtocols = ["h2", "http/1.1"] try await self.serveSecureUpgrade( - bindTarget: configuration.bindTarget, + bindTarget: self.configuration.bindTarget, tlsConfiguration: tlsConfiguration, handler: handler, asyncChannelConfiguration: asyncChannelConfiguration, @@ -262,134 +262,7 @@ public struct NIOHTTPServer: HTTPServerProtocol { } } - private func serveInsecureHTTP1_1( - bindTarget: NIOHTTPServerConfiguration.BindTarget, - handler: some HTTPServerRequestHandler, - asyncChannelConfiguration: NIOAsyncChannel.Configuration - ) async throws { - switch bindTarget.backing { - case .hostAndPort(let host, let port): - let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - .bind(host: host, port: port) { channel in - channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { - try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: false)) - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: asyncChannelConfiguration - ) - } - } - - try self.addressBound(serverChannel.channel.localAddress) - - try await withThrowingDiscardingTaskGroup { group in - try await serverChannel.executeThenClose { inbound in - for try await http1Channel in inbound { - group.addTask { - try await self.handleRequestChannel( - channel: http1Channel, - handler: handler - ) - } - } - } - } - } - } - - private func serveSecureUpgrade( - bindTarget: NIOHTTPServerConfiguration.BindTarget, - tlsConfiguration: TLSConfiguration, - handler: some HTTPServerRequestHandler, - asyncChannelConfiguration: NIOAsyncChannel.Configuration, - http2Configuration: NIOHTTP2Handler.Configuration, - verificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? = nil - ) async throws { - switch bindTarget.backing { - case .hostAndPort(let host, let port): - let serverChannel = try await ServerBootstrap(group: .singletonMultiThreadedEventLoopGroup) - .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) - .bind(host: host, port: port) { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations - .addHandler(self.makeSSLServerHandler(tlsConfiguration, verificationCallback)) - }.flatMap { - channel.configureAsyncHTTPServerPipeline(http2Configuration: http2Configuration) { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPServerCodec(secure: true)) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: asyncChannelConfiguration - ) - } - } http2ConnectionInitializer: { channel in - channel.eventLoop.makeCompletedFuture(.success(channel)) - } http2StreamInitializer: { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations - .addHandler( - HTTP2FramePayloadToHTTPServerCodec() - ) - - return try NIOAsyncChannel( - wrappingChannelSynchronously: channel, - configuration: asyncChannelConfiguration - ) - } - } - } - } - - try self.addressBound(serverChannel.channel.localAddress) - - try await withThrowingDiscardingTaskGroup { group in - try await serverChannel.executeThenClose { inbound in - for try await upgradeResult in inbound { - group.addTask { - do { - try await withThrowingDiscardingTaskGroup { connectionGroup in - switch try await upgradeResult.get() { - case .http1_1(let http1Channel): - let chainFuture = http1Channel.channel.nioSSL_peerValidatedCertificateChain() - Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http1Channel, - handler: handler - ) - } - } - case .http2((let http2Connection, let http2Multiplexer)): - do { - let chainFuture = http2Connection.nioSSL_peerValidatedCertificateChain() - try await Self.$connectionContext.withValue(ConnectionContext(chainFuture)) { - for try await http2StreamChannel in http2Multiplexer.inbound { - connectionGroup.addTask { - try await self.handleRequestChannel( - channel: http2StreamChannel, - handler: handler - ) - } - } - } - } catch { - self.logger.debug("HTTP2 connection closed: \(error)") - } - } - } - } catch { - self.logger.debug("Negotiating ALPN failed: \(error)") - } - } - } - } - } - } - } - - private func handleRequestChannel( + func handleRequestChannel( channel: NIOAsyncChannel, handler: some HTTPServerRequestHandler ) async throws { @@ -470,47 +343,6 @@ public struct NIOHTTPServer: HTTPServerProtocol { } } -@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -extension NIOHTTPServer { - fileprivate func makeSSLServerHandler( - _ tlsConfiguration: TLSConfiguration, - _ customVerificationCallback: (@Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult)? - ) throws -> NIOSSLServerHandler { - if let customVerificationCallback { - return try NIOSSLServerHandler( - context: .init(configuration: tlsConfiguration), - customVerificationCallbackWithMetadata: { certificates, promise in - promise.completeWithTask { - // Convert input [NIOSSLCertificate] to [X509.Certificate] - let x509Certs = try certificates.map { try Certificate($0) } - - let callbackResult = try await customVerificationCallback(x509Certs) - - switch callbackResult { - case .certificateVerified(let verificationMetadata): - guard let peerChain = verificationMetadata.validatedCertificateChain else { - return .certificateVerified(.init(nil)) - } - - // Convert the result into [NIOSSLCertificate] - let nioSSLCerts = try peerChain.map { try NIOSSLCertificate($0) } - return .certificateVerified(.init(.init(nioSSLCerts))) - - case .failed(let error): - self.logger.error("Custom certificate verification failed", metadata: [ - "failure-reason": .string(error.reason) - ]) - return .failed - } - } - } - ) - } else { - return try NIOSSLServerHandler(context: .init(configuration: tlsConfiguration)) - } - } -} - @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) extension NIOHTTP2Handler.Configuration { init(httpServerHTTP2Configuration http2Config: NIOHTTPServerConfiguration.HTTP2) { diff --git a/Tests/HTTPServerTests/NIOHTTPServerEndToEndTests.swift b/Tests/HTTPServerTests/NIOHTTPServerEndToEndTests.swift new file mode 100644 index 0000000..f35680d --- /dev/null +++ b/Tests/HTTPServerTests/NIOHTTPServerEndToEndTests.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import NIOHTTP2 +import NIOHTTPTypes +import NIOHTTPTypesHTTP2 +import NIOSSL +import Testing +import X509 + +@testable import HTTPServer + +@Suite +struct NIOHTTPServerEndToEndTests { + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test("HTTP/1.1 request and response") + func testHTTP1_1() async throws { + try await HTTP1ClientServerProvider.withProvider( + handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in + let sender = try await resSender.send(.init(status: .ok)) + + try await sender.produceAndConclude { writer in + var writer = writer + try await writer.write([1, 2].span) + return [.serverTiming: "test"] + } + } + ) { clientServerProvider in + try await clientServerProvider.withConnectedClient { client in + try await client.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + + outerLoop: for try await response in inbound { + switch response { + case .head(let response): + #expect(response.status == 200) + #expect(response.headerFields == [.transferEncoding: "chunked"]) + case .body(let body): + #expect(body == .init([1, 2])) + case .end(let trailers): + #expect(trailers == [.serverTiming: "test"]) + break outerLoop + } + } + } + } + } + } + + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + @Test("HTTP/2 negotiation") + func testSecureUpgradeNegotiation() async throws { + let serverChain = try TestCA.makeSelfSignedChain() + var serverTLSConfig = TLSConfiguration.makeServerConfiguration( + certificateChain: [try .init(serverChain.leaf)], + privateKey: try .init(serverChain.privateKey) + ) + serverTLSConfig.applicationProtocols = ["h2", "http/1.1"] + + var clientTLSConfig = TLSConfiguration.makeClientConfiguration() + clientTLSConfig.trustRoots = try .init(treatingNilAsSystemTrustRoots: [serverChain.ca]) + clientTLSConfig.certificateVerification = .noHostnameVerification + clientTLSConfig.applicationProtocols = ["h2"] + + try await HTTPSecureUpgradeClientServerProvider.withProvider( + tlsConfiguration: serverTLSConfig, + handler: HTTPServerClosureRequestHandler { request, reqContext, reqReader, resSender in + let sender = try await resSender.send(.init(status: .ok)) + + try await sender.produceAndConclude { writer in + var writer = writer + try await writer.write([1, 2].span) + return [.serverTiming: "test"] + } + } + ) { clientServerProvider in + try await clientServerProvider.withConnectedClient(clientTLSConfiguration: clientTLSConfig) { negotiatedConnection in + switch negotiatedConnection { + case .http1(_): + Issue.record("Failed to negotiate HTTP/2 despite the client requiring HTTP/2.") + case .http2(let http2StreamManager): + let http2AsyncChannel = try await http2StreamManager.openStream() + + try await http2AsyncChannel.executeThenClose { inbound, outbound in + try await outbound.write(.head(.init(method: .get, scheme: "", authority: "", path: "/"))) + try await outbound.write(.end(nil)) + + outerLoop: for try await response in inbound { + switch response { + case .head(let response): + #expect(response.status == 200) + case .body(let body): + #expect(body == .init([1, 2])) + case .end(let trailers): + #expect(trailers == [.serverTiming: "test"]) + break outerLoop + } + } + } + } + } + } + } +} diff --git a/Tests/HTTPServerTests/Utilities/Client.swift b/Tests/HTTPServerTests/Utilities/Client.swift index 60a2522..78971de 100644 --- a/Tests/HTTPServerTests/Utilities/Client.swift +++ b/Tests/HTTPServerTests/Utilities/Client.swift @@ -21,10 +21,8 @@ import X509 @testable import HTTPServer -typealias ClientChannel = NIOAsyncChannel - @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) -func setUpClient(host: String, port: Int) async throws -> ClientChannel { +func setUpClient(host: String, port: Int) async throws -> NIOAsyncChannel { try await ClientBootstrap(group: .singletonMultiThreadedEventLoopGroup) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .connect(to: try .init(ipAddress: host, port: port)) { channel in @@ -32,7 +30,7 @@ func setUpClient(host: String, port: Int) async throws -> ClientChannel { try channel.pipeline.syncOperations.addHTTPClientHandlers() try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec()) - return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + return try NIOAsyncChannel(wrappingChannelSynchronously: channel, configuration: .init()) } } } @@ -43,7 +41,7 @@ func setUpClientWithMTLS( chain: ChainPrivateKeyPair, trustRoots: [Certificate], applicationProtocol: String, -) async throws -> ClientChannel { +) async throws -> NIOAsyncChannel { var clientTLSConfig = TLSConfiguration.makeClientConfiguration() clientTLSConfig.certificateChain = [try NIOSSLCertificateSource(chain.leaf)] clientTLSConfig.privateKey = .privateKey(try .init(chain.privateKey)) @@ -66,7 +64,10 @@ func setUpClientWithMTLS( try channel.pipeline.syncOperations.addHTTPClientHandlers() try channel.pipeline.syncOperations.addHandlers(HTTP1ToHTTPClientCodec()) - return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init() + ) } }, http2ConnectionInitializer: { channel in @@ -86,7 +87,7 @@ func setUpClientWithMTLS( return try await http2Channel.openStream { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) - return try ClientChannel(wrappingChannelSynchronously: channel, configuration: .init()) + return try NIOAsyncChannel(wrappingChannelSynchronously: channel, configuration: .init()) } } } diff --git a/Tests/HTTPServerTests/Utilities/HTTP1ClientServerProvider.swift b/Tests/HTTPServerTests/Utilities/HTTP1ClientServerProvider.swift new file mode 100644 index 0000000..82b7779 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/HTTP1ClientServerProvider.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOEmbedded +import NIOHTTP1 +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import X509 + +@testable import HTTPServer + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +struct HTTP1ClientServerProvider { + let server: NIOHTTPServer + let serverTestChannel: NIOAsyncTestingChannel + + static func withProvider( + handler: some HTTPServerRequestHandler, + body: (HTTP1ClientServerProvider) async throws -> Void + ) async throws { + let server = NIOHTTPServer( + logger: .init(label: "test"), + // The server won't actually be bound to this host and port; we just have to pass this argument + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) + ) + // Create a test channel. We will run the server on this channel. + let serverTestChannel = NIOAsyncTestingChannel() + + try await withThrowingTaskGroup { group in + // We are ready now. Start the server with the test channel. + group.addTask { + try await server.serveInsecureHTTP1_1WithTestChannel(testChannel: serverTestChannel, handler: handler) + } + + // Execute the provided closure with a `HTTP1ClientServerProvider` instance created from the server + // instance and the test channel instance + try await body( + HTTP1ClientServerProvider(server: server, serverTestChannel: serverTestChannel) + ) + + group.cancelAll() + } + } + + /// Starts a new connection with the server and executes the provided `body` closure. + /// - Parameter body: A closure that should send a request using the provided client instance and validate + /// the received response. + func withConnectedClient(body: (NIOAsyncChannel) async throws -> Void) async throws { + // Create a test connection channel + let serverTestConnectionChannel = NIOAsyncTestingChannel() + + let connectionPromise = serverTestConnectionChannel.eventLoop.makePromise(of: Void.self) + serverTestConnectionChannel.connect(to: try .init(ipAddress: "127.0.0.1", port: 8000), promise: connectionPromise) + try await connectionPromise.futureResult.get() + + // Set up the required channel handlers on `serverTestConnectionChannel` + let serverAsyncConnectionChannel = try await self.server.setupHTTP1_1ConnectionChildChannel( + channel: serverTestConnectionChannel, + asyncChannelConfiguration: .init() + ).get() + + // Write the connection channel to the server channel to simulate an incoming connection + try await self.serverTestChannel.writeInbound(serverAsyncConnectionChannel) + + // Now, we could write requests directly to `serverAsyncConnectionChannel`, but it expects `ByteBuffer` inputs. + // This is cumbersome to work with in tests. + // So, we create a client channel, and use it to send requests and observe responses in terms of HTTP types. + let (clientTestChannel, clientAsyncChannel) = try await self.setUpClientConnection() + + try await withThrowingTaskGroup { group in + // We must forward all client outbound writes to the server and vice-versa. + group.addTask { try await clientTestChannel.glueTo(serverTestConnectionChannel) } + + try await body(clientAsyncChannel) + + try await serverTestConnectionChannel.close() + try await group.next() + } + } + + private func setUpClientConnection() async throws -> (NIOAsyncTestingChannel, NIOAsyncChannel) { + let clientTestChannel = try await NIOAsyncTestingChannel { channel in + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandler(HTTP1ToHTTPClientCodec()) + } + + // Wrap the client channel in a NIOAsyncChannel for convenience + let clientAsyncChannel = try await clientTestChannel.eventLoop.submit { + try NIOAsyncChannel( + wrappingChannelSynchronously: clientTestChannel, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + }.get() + + return (clientTestChannel, clientAsyncChannel) + } +} + +extension NIOAsyncTestingChannel { + /// Forwards all of our outbound writes to `other` and vice-versa. + func glueTo(_ other: NIOAsyncTestingChannel) async throws { + await withThrowingTaskGroup { group in + // 1. Forward all `self` writes to `other` + group.addTask { + while !Task.isCancelled { + let ourPart = try await self.waitForOutboundWrite(as: ByteBuffer.self) + try await other.writeInbound(ourPart) + } + } + + // 2. Forward all `other` writes to `self` + group.addTask { + while !Task.isCancelled { + let otherPart = try await other.waitForOutboundWrite(as: ByteBuffer.self) + try await self.writeInbound(otherPart) + } + } + } + } +} diff --git a/Tests/HTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift b/Tests/HTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift new file mode 100644 index 0000000..36e4020 --- /dev/null +++ b/Tests/HTTPServerTests/Utilities/HTTPSecureUpgradeClientServerProvider.swift @@ -0,0 +1,186 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import NIOCore +import NIOEmbedded +import NIOHTTP2 +import NIOHTTPTypes +import NIOHTTPTypesHTTP1 +import NIOHTTPTypesHTTP2 +import NIOSSL +import X509 + +@testable import HTTPServer + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +struct HTTPSecureUpgradeClientServerProvider { + let server: NIOHTTPServer + let serverTestChannel: NIOAsyncTestingChannel + + let serverTLSConfiguration: TLSConfiguration + let verificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? + + let http2Configuration: NIOHTTP2Handler.Configuration + + static func withProvider( + tlsConfiguration: TLSConfiguration, + tlsVerificationCallback: (@Sendable ([Certificate]) async throws -> CertificateVerificationResult)? = nil, + http2Configuration: NIOHTTP2Handler.Configuration = .init(), + handler: some HTTPServerRequestHandler, + body: (HTTPSecureUpgradeClientServerProvider) async throws -> Void + ) async throws { + let server = NIOHTTPServer( + logger: .init(label: "test"), + // The server won't actually be bound to this host and port; we just have to pass this argument + configuration: .init(bindTarget: .hostAndPort(host: "127.0.0.1", port: 8000)) + ) + + // Create a test channel. We will run the server on this channel. + let serverTestChannel = NIOAsyncTestingChannel() + + try await withThrowingTaskGroup { group in + // We are ready now. Start the server with the test channel. + group.addTask { + try await server.serveSecureUpgradeWithTestChannel(testChannel: serverTestChannel, handler: handler) + } + + // Execute the provided closure with a `HTTPSecureUpgradeClientServerProvider` instance + try await body( + HTTPSecureUpgradeClientServerProvider( + server: server, + serverTestChannel: serverTestChannel, + serverTLSConfiguration: tlsConfiguration, + verificationCallback: tlsVerificationCallback, + http2Configuration: http2Configuration + ) + ) + + group.cancelAll() + } + } + + func withConnectedClient( + clientTLSConfiguration: TLSConfiguration, + body: (NegotiatedConnection) async throws -> Void + ) async throws { + // Create a test connection channel + let serverTestConnectionChannel = NIOAsyncTestingChannel() + + let connectionPromise = serverTestConnectionChannel.eventLoop.makePromise(of: Void.self) + serverTestConnectionChannel.connect(to: try .init(ipAddress: "127.0.0.1", port: 8000), promise: connectionPromise) + try await connectionPromise.futureResult.get() + + // Set up the required channel handlers on `serverTestConnectionChannel` + let negotiatedServerConnectionFuture = try await serverTestConnectionChannel.eventLoop.flatSubmit { + self.server.setupSecureUpgradeConnectionChildChannel( + channel: serverTestConnectionChannel, + tlsConfiguration: self.serverTLSConfiguration, + asyncChannelConfiguration: .init(), + http2Configuration: self.http2Configuration, + verificationCallback: self.verificationCallback + ) + }.get() + + // Write the connection channel to the server channel to simulate an incoming connection + try await self.serverTestChannel.writeInbound(negotiatedServerConnectionFuture) + + // Now we could write requests directly to the server channle, but it expects `ByteBuffer` inputs. This is + // cumbersome to work with in tests. + // So, we create a client channel, and use it to send requests and observe responses in terms of HTTP types. + let (clientTestChannel, clientNegotiatedConnectionFuture) = try await self.setUpClientConnection( + tlsConfiguration: clientTLSConfiguration + ) + + try await withThrowingTaskGroup { group in + // We must forward all client outbound writes to the server and vice-versa. + group.addTask { try await clientTestChannel.glueTo(serverTestConnectionChannel) } + + try await body(.init(negotiationResult: try await clientNegotiatedConnectionFuture.get())) + + try await serverTestConnectionChannel.close() + try await group.next() + } + } + + private func setUpClientConnection(tlsConfiguration: TLSConfiguration) async throws -> ( + NIOAsyncTestingChannel, + EventLoopFuture, NIOHTTP2Handler.AsyncStreamMultiplexer>> + ) { + let clientTestChannel = try await NIOAsyncTestingChannel { channel in + _ = channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + try NIOSSLClientHandler(context: .init(configuration: tlsConfiguration), serverHostname: nil) + ) + } + } + + let clientNegotiatedConnection = try await clientTestChannel.eventLoop.flatSubmit { + clientTestChannel.configureHTTP2AsyncSecureUpgrade( + http1ConnectionInitializer: { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandlers(HTTP1ToHTTPClientCodec()) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + } + }, + http2ConnectionInitializer: { channel in + channel.configureAsyncHTTP2Pipeline(mode: .client) { $0.eventLoop.makeSucceededFuture($0) } + } + ) + }.get() + + let connectionPromise = clientTestChannel.eventLoop.makePromise(of: Void.self) + clientTestChannel.connect(to: try .init(ipAddress: "127.0.0.1", port: 8000), promise: connectionPromise) + try await connectionPromise.futureResult.get() + + return (clientTestChannel, clientNegotiatedConnection) + } +} + +enum NegotiatedConnection { + case http1(NIOAsyncChannel) + case http2(HTTP2StreamManager) + + init(negotiationResult: NIONegotiatedHTTPVersion, NIOHTTP2Handler.AsyncStreamMultiplexer>) async throws { + switch negotiationResult { + case .http1_1(let http1AsyncChannel): + self = .http1(http1AsyncChannel) + + case .http2(let http2StreamMultiplexer): + self = .http2(.init(http2StreamMultiplexer: http2StreamMultiplexer)) + } + } + + struct HTTP2StreamManager { + let http2StreamMultiplexer: NIOHTTP2Handler.AsyncStreamMultiplexer + + /// A wrapper over `NIOHTTP2Handler/AsyncStreamMultiplexer/openStream(_:)` that first initializes the stream + /// channel with the `HTTP2FramePayloadToHTTPClientCodec` channel handler, and wraps it in a `NIOAsyncChannel` + /// (with outbound half closure enabled). + func openStream() async throws -> NIOAsyncChannel { + try await self.http2StreamMultiplexer.openStream { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(HTTP2FramePayloadToHTTPClientCodec()) + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init(isOutboundHalfClosureEnabled: true) + ) + } + } + } + } +}