diff --git a/Package.swift b/Package.swift index cf3616f..8f38fa5 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,10 @@ let package = Package( targets: ["HTTPServer"] ) ], + traits: [ + .trait(name: "SwiftConfiguration"), + .default(enabledTraits: ["SwiftConfiguration"]), + ], dependencies: [ .package( url: "https://github.com/FranzBusch/swift-collections.git", @@ -48,6 +52,7 @@ let package = Package( .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"), + .package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"), ], targets: [ .executableTarget( @@ -80,6 +85,11 @@ let package = Package( .product(name: "NIOHTTPTypesHTTP1", package: "swift-nio-extras"), .product(name: "NIOHTTPTypesHTTP2", package: "swift-nio-extras"), .product(name: "NIOCertificateReloading", package: "swift-nio-extras"), + .product( + name: "Configuration", + package: "swift-configuration", + condition: .when(traits: ["SwiftConfiguration"]) + ), ], swiftSettings: extraSettings ), diff --git a/Sources/HTTPServer/NIOHTTPServer+SwiftConfiguration.swift b/Sources/HTTPServer/NIOHTTPServer+SwiftConfiguration.swift new file mode 100644 index 0000000..95b1c61 --- /dev/null +++ b/Sources/HTTPServer/NIOHTTPServer+SwiftConfiguration.swift @@ -0,0 +1,346 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftConfiguration +public import Configuration +import NIOCertificateReloading +import SwiftASN1 +public import X509 + +enum NIOHTTPServerConfigurationError: Error, CustomStringConvertible { + case customVerificationCallbackProvidedWhenNotUsingMTLS + + var description: String { + switch self { + case .customVerificationCallbackProvidedWhenNotUsingMTLS: + "Invalid configuration. A custom certificate verification callback was provided despite the server not being configured for mTLS." + } + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration { + /// Initialize the server configuration from a config reader. + /// + /// ## Configuration keys: + /// + /// ``NIOHTTPServerConfiguration`` is comprised of four types. Provide configuration for each type under the + /// specified key: + /// - ``BindTarget`` - Provide under key `"bindTarget"` (keys listed in ``BindTarget/init(config:)``). + /// - ``TransportSecurity`` - Provide under key `"transportSecurity"` (keys listed in + /// ``TransportSecurity/init(config:customCertificateVerificationCallback:)``). + /// - ``BackPressureStrategy`` - Provide under key `"backpressureStrategy"` (keys listed in + /// ``BackPressureStrategy/init(config:)``). + /// - ``HTTP2`` - Provide under key `"http2"` (keys listed in ``HTTP2/init(config:)``). + /// + /// - Parameters: + /// - config: The configuration reader to read configuration values from. + /// - customCertificateVerificationCallback: An optional client certificate verification callback to use when + /// mTLS is configured (i.e., when `"transportSecurity.security"` is `"mTLS"` or `"reloadingMTLS"`). If provided + /// when mTLS is *not* configured, this initializer throws + /// ``NIOHTTPServerConfigurationError/customVerificationCallbackProvidedWhenNotUsingMTLS``. If set to `nil` when + /// mTLS *is* configured, the default client certificate verification logic of the underlying SSL implementation + /// is used. + public init( + config: ConfigReader, + customCertificateVerificationCallback: ( + @Sendable ([Certificate]) async throws -> CertificateVerificationResult + )? = nil + ) throws { + let snapshot = config.snapshot() + + self.init( + bindTarget: try .init(config: snapshot.scoped(to: "bindTarget")), + transportSecurity: try .init( + config: snapshot.scoped(to: "transportSecurity"), + customCertificateVerificationCallback: customCertificateVerificationCallback + ), + backpressureStrategy: .init(config: snapshot.scoped(to: "backpressureStrategy")), + http2: .init(config: snapshot.scoped(to: "http2")) + ) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.BindTarget { + /// Initialize a bind target configuration from a config reader. + /// + /// ## Configuration keys: + /// - `host` (string, required): The hostname or IP address the server will bind to (e.g., "localhost", "0.0.0.0"). + /// - `port` (int, required): The port number the server will listen on (e.g., 8080, 443). + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) throws { + self.init( + backing: .hostAndPort( + host: try config.requiredString(forKey: "host"), + port: try config.requiredInt(forKey: "port") + ) + ) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.TransportSecurity { + /// Initialize a transport security configuration from a config reader. + /// + /// ## Configuration keys: + /// - `security` (string, required): The transport security for the server (permitted values: `"plaintext"`, + /// `"tls"`, `"reloadingTLS"`, `"mTLS"`, `"reloadingMTLS"`). + /// + /// ### Configuration keys for `"tls"`: + /// - `certificateChainPEMString` (string, required): PEM-formatted certificate chain content. + /// - `privateKeyPEMString` (string, required, secret): PEM-formatted private key content. + /// + /// ### Configuration keys for `"reloadingTLS"`: + /// - `refreshInterval` (int, optional, default: 30): The interval (in seconds) at which the certificate chain and + /// private key will be reloaded. + /// - `certificateChainPEMPath` (string, required): Path to the certificate chain PEM file. + /// - `privateKeyPEMPath` (string, required): Path to the private key PEM file. + /// + /// ### Configuration keys for `"mTLS"`: + /// - `certificateChainPEMString` (string, required): PEM-formatted certificate chain content. + /// - `privateKeyPEMString` (string, required, secret): PEM-formatted private key content. + /// - `trustRoots` (string array, optional, default: system trust roots): The root certificates to trust when + /// verifying client certificates. + /// - `certificateVerificationMode` (string, required): The client certificate validation behavior (permitted + /// values: "optionalVerification" or "noHostnameVerification"). + /// + /// ### Configuration keys for `"reloadingMTLS"`: + /// - `refreshInterval` (int, optional, default: 30): The interval (in seconds) at which the certificate chain and + /// private key will be reloaded. + /// - `certificateChainPEMPath` (string, required): Path to the certificate chain PEM file. + /// - `privateKeyPEMPath` (string, required): Path to the private key PEM file. + /// - `trustRoots` (string array, optional, default: system trust roots): The root certificates to trust when + /// verifying client certificates. + /// - `certificateVerificationMode` (string, required): The client certificate validation behavior (permitted + /// values: "optionalVerification" or "noHostnameVerification"). + /// + /// - Parameters: + /// - config: The configuration reader. + /// - customCertificateVerificationCallback: An optional client certificate verification callback to use when + /// mTLS is configured (i.e., when `"transportSecurity.security"` is `"mTLS"` or `"reloadingMTLS"`). If provided + /// when mTLS is *not* configured, this initializer throws + /// ``NIOHTTPServerConfigurationError/customVerificationCallbackProvidedWhenNotUsingMTLS``. If set to `nil` when + /// mTLS *is* configured, the default client certificate verification logic of the underlying SSL implementation + /// is used. + public init( + config: ConfigSnapshotReader, + customCertificateVerificationCallback: ( + @Sendable ([Certificate]) async throws -> CertificateVerificationResult + )? = nil + ) throws { + let security = try config.requiredString(forKey: "security", as: TransportSecurityKind.self) + + // A custom verification callback can only be used when the server is configured for mTLS. + if let _ = customCertificateVerificationCallback, !security.isMTLS() { + throw NIOHTTPServerConfigurationError.customVerificationCallbackProvidedWhenNotUsingMTLS + } + + switch security { + case .plaintext: + self = .plaintext + + case .tls: + self = try .tls(config: config) + + case .reloadingTLS: + self = try .reloadingTLS(config: config) + + case .mTLS: + self = try .mTLS( + config: config, + customCertificateVerificationCallback: customCertificateVerificationCallback + ) + + case .reloadingMTLS: + self = try .reloadingMTLS( + config: config, + customCertificateVerificationCallback: customCertificateVerificationCallback + ) + } + } + + private static func tls(config: ConfigSnapshotReader) throws -> Self { + let certificateChainPEMString = try config.requiredString(forKey: "certificateChainPEMString") + let privateKeyPEMString = try config.requiredString(forKey: "privateKeyPEMString", isSecret: true) + + return Self.tls( + certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) + .map { try Certificate(pemEncoded: $0.pemString) }, + privateKey: try .init(pemEncoded: privateKeyPEMString) + ) + } + + private static func reloadingTLS(config: ConfigSnapshotReader) throws -> Self { + let refreshInterval = config.int(forKey: "refreshInterval", default: 30) + let certificateChainPEMPath = try config.requiredString(forKey: "certificateChainPEMPath") + let privateKeyPEMPath = try config.requiredString(forKey: "privateKeyPEMPath") + + return try Self.tls( + certificateReloader: TimedCertificateReloader( + refreshInterval: .seconds(refreshInterval), + certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), + privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + ) + ) + } + + private static func mTLS( + config: ConfigSnapshotReader, + customCertificateVerificationCallback: ( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + )? = nil + ) throws -> Self { + let certificateChainPEMString = try config.requiredString(forKey: "certificateChainPEMString") + let privateKeyPEMString = try config.requiredString(forKey: "privateKeyPEMString", isSecret: true) + let trustRoots = config.stringArray(forKey: "trustRoots") + let verificationMode = try config.requiredString( + forKey: "certificateVerificationMode", + as: VerificationMode.self + ) + + return Self.mTLS( + certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) + .map { try Certificate(pemEncoded: $0.pemString) }, + privateKey: try .init(pemEncoded: privateKeyPEMString), + trustRoots: try trustRoots?.map { try Certificate(pemEncoded: $0) }, + certificateVerification: .init(verificationMode), + customCertificateVerificationCallback: customCertificateVerificationCallback + ) + } + + private static func reloadingMTLS( + config: ConfigSnapshotReader, + customCertificateVerificationCallback: ( + @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult + )? = nil + ) throws -> Self { + let refreshInterval = config.int(forKey: "refreshInterval", default: 30) + let certificateChainPEMPath = try config.requiredString(forKey: "certificateChainPEMPath") + let privateKeyPEMPath = try config.requiredString(forKey: "privateKeyPEMPath") + let trustRoots = config.stringArray(forKey: "trustRoots") + let verificationMode = try config.requiredString( + forKey: "certificateVerificationMode", + as: VerificationMode.self + ) + + return try Self.mTLS( + certificateReloader: TimedCertificateReloader( + refreshInterval: .seconds(refreshInterval), + certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), + privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + ), + trustRoots: try trustRoots?.map { try Certificate(pemEncoded: $0) }, + certificateVerification: .init(verificationMode), + customCertificateVerificationCallback: customCertificateVerificationCallback + ) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.BackPressureStrategy { + /// Initialize the backpressure strategy configuration from a config reader. + /// + /// ## Configuration keys: + /// - `low` (int, optional, default: 2): The threshold below which the consumer will ask the producer to produce + /// more elements. + /// - `high` (int, optional, default: 10): The threshold above which the producer will stop producing elements. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) { + self.init( + backing: .watermark( + low: config.int( + forKey: "low", + default: NIOHTTPServerConfiguration.BackPressureStrategy.defaultWatermarkLow + ), + high: config.int( + forKey: "high", + default: NIOHTTPServerConfiguration.BackPressureStrategy.defaultWatermarkHigh + ) + ) + ) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP2 { + /// Initialize a HTTP/2 configuration from a config reader. + /// + /// ## Configuration keys: + /// - `maxFrameSize` (int, optional, default: 2^14): The maximum frame size to be used in an HTTP/2 connection. + /// - `targetWindowSize` (int, optional, default: 2^16 - 1): The target window size to be used in an HTTP/2 + /// connection. + /// - `maxConcurrentStreams` (int, optional, default: 100): The maximum number of concurrent streams in an HTTP/2 + /// connection. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) { + self.init( + maxFrameSize: config.int( + forKey: "maxFrameSize", + default: NIOHTTPServerConfiguration.HTTP2.defaultMaxFrameSize + ), + targetWindowSize: config.int( + forKey: "targetWindowSize", + default: NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize + ), + /// The default value, ``NIOHTTPServerConfiguration.HTTP2.DEFAULT_TARGET_WINDOW_SIZE``, is `nil`. However, + /// we can only specify a non-nil `default` argument to `config.int(...)`. But `config.int(...)` already + /// defaults to `nil` if it can't find the `"maxConcurrentStreams"` key, so that works for us. + maxConcurrentStreams: config.int(forKey: "maxConcurrentStreams") + ) + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension NIOHTTPServerConfiguration.TransportSecurity { + fileprivate enum TransportSecurityKind: String { + case plaintext + case tls + case reloadingTLS + case mTLS + case reloadingMTLS + + func isMTLS() -> Bool { + switch self { + case .mTLS, .reloadingMTLS: + return true + + default: + return false + } + } + } + + /// A wrapper over ``CertificateVerificationMode``. + fileprivate enum VerificationMode: String { + case optionalVerification + case noHostnameVerification + } +} + +@available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) +extension CertificateVerificationMode { + fileprivate init(_ mode: NIOHTTPServerConfiguration.TransportSecurity.VerificationMode) { + switch mode { + case .optionalVerification: + self.init(mode: .optionalVerification) + case .noHostnameVerification: + self.init(mode: .noHostnameVerification) + } + } +} +#endif // SwiftConfiguration diff --git a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift index 864b539..43d5274 100644 --- a/Sources/HTTPServer/NIOHTTPServerConfiguration.swift +++ b/Sources/HTTPServer/NIOHTTPServerConfiguration.swift @@ -213,13 +213,22 @@ public struct NIOHTTPServerConfiguration: Sendable { self.maxConcurrentStreams = maxConcurrentStreams } + @inlinable + static var defaultMaxFrameSize: Int { 1 << 14 } + + @inlinable + static var defaultTargetWindowSize: Int { (1 << 16) - 1 } + + @inlinable + static var defaultMaxConcurrentStreams: Int? { nil } + /// Default values. The max frame size defaults to 2^14, the target window size defaults to 2^16-1, and /// the max concurrent streams default to infinite. public static var defaults: Self { Self( - maxFrameSize: 1 << 14, - targetWindowSize: (1 << 16) - 1, - maxConcurrentStreams: nil + maxFrameSize: Self.defaultMaxFrameSize, + targetWindowSize: Self.defaultTargetWindowSize, + maxConcurrentStreams: Self.defaultMaxConcurrentStreams ) } } @@ -232,7 +241,7 @@ public struct NIOHTTPServerConfiguration: Sendable { internal let backing: Backing - private init(backing: Backing) { + init(backing: Backing) { self.backing = backing } @@ -244,6 +253,22 @@ public struct NIOHTTPServerConfiguration: Sendable { public static func watermark(low: Int, high: Int) -> Self { .init(backing: .watermark(low: low, high: high)) } + + @inlinable + static var defaultWatermarkLow: Int { 2 } + + @inlinable + static var defaultWatermarkHigh: Int { 10 } + + /// Default values. The watermark low value defaults to 2, and the watermark high value default to 10. + public static var defaults: Self { + Self.init( + backing: .watermark( + low: Self.defaultWatermarkLow, + high: Self.defaultWatermarkHigh + ) + ) + } } /// Network binding configuration @@ -268,7 +293,7 @@ public struct NIOHTTPServerConfiguration: Sendable { public init( bindTarget: BindTarget, transportSecurity: TransportSecurity = .plaintext, - backpressureStrategy: BackPressureStrategy = .watermark(low: 2, high: 10), + backpressureStrategy: BackPressureStrategy = .defaults, http2: HTTP2 = .defaults ) { self.bindTarget = bindTarget diff --git a/Tests/HTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/HTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift new file mode 100644 index 0000000..08346a8 --- /dev/null +++ b/Tests/HTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -0,0 +1,486 @@ +//===----------------------------------------------------------------------===// +// +// 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 SwiftConfiguration +import Configuration +import Crypto +import Foundation +import NIOCertificateReloading +import SwiftASN1 +import Testing +import X509 + +@testable import HTTPServer + +@Suite +struct NIOHTTPServerSwiftConfigurationTests { + @Suite("BindTarget") + struct BindTargetTests { + @Test("Valid host and port") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testValidConfig() throws { + let provider = InMemoryProvider(values: ["host": "localhost", "port": 8080]) + + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let bindTarget = try NIOHTTPServerConfiguration.BindTarget(config: snapshot) + + switch bindTarget.backing { + case .hostAndPort(let host, let port): + #expect(host == "localhost") + #expect(port == 8080) + } + } + + @Test("Init fails with missing host") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testMissingHost() throws { + let provider = InMemoryProvider(values: ["port": 8080]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let error = #expect(throws: Error.self) { + try NIOHTTPServerConfiguration.BindTarget(config: snapshot) + } + let configError = try #require(error) + + #expect("Missing required config value for key: host." == "\(configError)") + } + + @Test("Init fails with missing port") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testMissingPort() throws { + let provider = InMemoryProvider(values: ["host": "localhost"]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let error = #expect(throws: Error.self) { + try NIOHTTPServerConfiguration.BindTarget(config: snapshot) + } + let configError = try #require(error) + + #expect("Missing required config value for key: port." == "\(configError)") + } + } + + @Suite("BackPressureStrategy") + struct BackPressureStrategyTests { + @Test("Default values") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testDefaultValues() throws { + // Don't provide anything. All values have defaults. + let provider = InMemoryProvider(values: [:]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let strategy = NIOHTTPServerConfiguration.BackPressureStrategy(config: snapshot) + + switch strategy.backing { + case .watermark(let low, let high): + #expect(low == NIOHTTPServerConfiguration.BackPressureStrategy.defaultWatermarkLow) + #expect(high == NIOHTTPServerConfiguration.BackPressureStrategy.defaultWatermarkHigh) + } + } + + @Test("Custom values") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testCustomValues() throws { + let provider = InMemoryProvider(values: ["low": 5, "high": 20]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let strategy = NIOHTTPServerConfiguration.BackPressureStrategy(config: snapshot) + + switch strategy.backing { + case .watermark(let low, let high): + #expect(low == 5) + #expect(high == 20) + } + } + + @Test("Partial custom values") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testPartialCustomValues() throws { + let provider = InMemoryProvider(values: ["low": 3]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let strategy = NIOHTTPServerConfiguration.BackPressureStrategy(config: snapshot) + + switch strategy.backing { + case .watermark(let low, let high): + #expect(low == 3) + #expect(high == NIOHTTPServerConfiguration.BackPressureStrategy.defaultWatermarkHigh) + } + } + } + + @Suite("HTTP2") + struct HTTP2Tests { + @Test("Default values") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testDefaultValues() throws { + let provider = InMemoryProvider(values: [:]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let http2 = NIOHTTPServerConfiguration.HTTP2(config: snapshot) + + #expect(http2.maxFrameSize == NIOHTTPServerConfiguration.HTTP2.defaultMaxFrameSize) + #expect(http2.targetWindowSize == NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize) + #expect(http2.maxConcurrentStreams == nil) + } + + @Test("Custom values") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testCustomValues() throws { + let provider = InMemoryProvider(values: [ + "maxFrameSize": 1, "targetWindowSize": 2, "maxConcurrentStreams": 3, + ]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let http2 = NIOHTTPServerConfiguration.HTTP2(config: snapshot) + + #expect(http2.maxFrameSize == 1) + #expect(http2.targetWindowSize == 2) + #expect(http2.maxConcurrentStreams == 3) + } + + @Test("Partial custom values") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testPartialCustomValues() throws { + let provider = InMemoryProvider(values: ["maxFrameSize": 5]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let http2 = NIOHTTPServerConfiguration.HTTP2(config: snapshot) + + #expect(http2.maxFrameSize == 5) + #expect(http2.targetWindowSize == NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize) + #expect(http2.maxConcurrentStreams == nil) + } + } + + @Suite("TransportSecurity") + struct TransportSecurityTests { + @Test("Invalid security type") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testInvalidSecurityType() throws { + let provider = InMemoryProvider(values: ["security": ""]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let error = #expect(throws: Error.self) { + try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + } + let configError = try #require(error) + + #expect("Config value for key 'security' failed to cast to type TransportSecurityKind." == "\(configError)") + } + + @Test("Custom verification callback without mTLS being enabled") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testCannotInitializeWithCustomCallbackWhenMTLSNotEnabled() throws { + let provider = InMemoryProvider(values: ["security": "tls"]) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let error = #expect(throws: Error.self) { + // The custom verification callback will not be used when mTLS is not enabled. This is therefore an invalid + // config, and we should expect an error. + try NIOHTTPServerConfiguration.TransportSecurity( + config: snapshot, + customCertificateVerificationCallback: { peerCertificates in + .failed(.init(reason: "test")) + } + ) + } + + #expect(error as? NIOHTTPServerConfigurationError == .customVerificationCallbackProvidedWhenNotUsingMTLS) + } + + @Suite + struct TLS { + @Test("Valid config") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testValidConfig() throws { + let chain = try TestCA.makeSelfSignedChain() + let certsPEM = try chain.chainPEMString + let keyPEM = try chain.privateKey.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "security": "tls", + "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + switch transportSecurity.backing { + case .tls(let certificateChain, let privateKey): + #expect(certificateChain == chain.chain) + #expect(privateKey == chain.privateKey) + default: + Issue.record("Expected TLS backing, got different type") + } + } + + @Test("Init fails with missing certificate") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testMissingCertificate() throws { + let chain = try TestCA.makeSelfSignedChain() + let keyPEM = try chain.privateKey.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "security": "tls", + "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let error = #expect(throws: Error.self) { + try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + } + let configError = try #require(error) + + #expect("Missing required config value for key: certificateChainPEMString." == "\(configError)") + } + + @Test("Init fails with missing private key") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testMissingPrivateKey() throws { + let chain = try TestCA.makeSelfSignedChain() + let certsPEM = try chain.chainPEMString + + let provider = InMemoryProvider( + values: [ + "security": "tls", + "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let error = #expect(throws: Error.self) { + try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + } + let configError = try #require(error) + + #expect("Missing required config value for key: privateKeyPEMString." == "\(configError)") + } + } + + @Suite + struct ReloadingTLS { + @Test("Valid config") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testValidConfig() async throws { + let provider = InMemoryProvider( + values: [ + "security": "reloadingTLS", + "certificateChainPEMPath": .init(.string("cert.pem"), isSecret: false), + "privateKeyPEMPath": .init(.string("key.pem"), isSecret: false), + "refreshInterval": 60, + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + guard case .reloadingTLS = transportSecurity.backing else { + Issue.record("Expected reloadingTLS backing, got \(transportSecurity.backing)") + return + } + } + } + + @Suite + struct MTLS { + @Test("Custom verification callback") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testValidConfigWithCustomVerificationCallback() throws { + let serverChain = try TestCA.makeSelfSignedChain() + let clientChain = try TestCA.makeSelfSignedChain() + + let certsPEM = try serverChain.chainPEMString + let keyPEM = try serverChain.privateKey.serializeAsPEM().pemString + let trustRootPEM = try clientChain.ca.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "security": "mTLS", + "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "trustRoots": .init(.stringArray([trustRootPEM]), isSecret: false), + "certificateVerificationMode": "noHostnameVerification", + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + // Initialize with a custom verification callback + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity( + config: snapshot, + customCertificateVerificationCallback: { peerCerts in + .certificateVerified(.init(.init(uncheckedCertificateChain: peerCerts))) + } + ) + + switch transportSecurity.backing { + case .mTLS(let certificateChain, let privateKey, let trustRoots, let verification, let callback): + #expect(certificateChain == [serverChain.leaf, serverChain.ca]) + #expect(privateKey == serverChain.privateKey) + #expect(trustRoots == [clientChain.ca]) + #expect(verification.mode == .noHostnameVerification) + #expect(callback != nil) + default: + Issue.record("Expected mTLS backing, got \(transportSecurity.backing)") + } + } + + @Test("Optional verification mode") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testOptionalVerification() throws { + let serverChain = try TestCA.makeSelfSignedChain() + let certsPEM = try serverChain.chainPEMString + let keyPEM = try serverChain.privateKey.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "security": "mTLS", + "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "certificateVerificationMode": "optionalVerification", + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + switch transportSecurity.backing { + case .mTLS(let certificateChain, let privateKey, _, let verification, _): + #expect(certificateChain == [serverChain.leaf, serverChain.ca]) + #expect(privateKey == serverChain.privateKey) + #expect(verification.mode == .optionalVerification) + default: + Issue.record("Expected mTLS backing, got \(transportSecurity.backing)") + } + } + + @Test("Invalid verification mode") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testInvalidVerificationMode() throws { + let serverChain = try TestCA.makeSelfSignedChain() + + let certsPEM = try serverChain.chainPEMString + let keyPEM = try serverChain.privateKey.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "security": "mTLS", + "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "certificateVerificationMode": "", + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let error = #expect(throws: Error.self) { + try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + } + let configError = try #require(error) + + #expect( + "Config value for key 'certificateVerificationMode' failed to cast to type VerificationMode." + == "\(configError)" + ) + } + + @Test("Default trust roots") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testDefaultTrustRoots() throws { + let serverChain = try TestCA.makeSelfSignedChain() + + let certsPEM = try serverChain.chainPEMString + let keyPEM = try serverChain.privateKey.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "security": "mTLS", + "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "certificateVerificationMode": "noHostnameVerification", + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + switch transportSecurity.backing { + case .mTLS(_, _, let trustRoots, _, _): + // trustRoots should be nil + #expect(trustRoots == nil) + default: + Issue.record("Expected mTLS backing, got \(transportSecurity.backing)") + } + } + + } + + @Suite + struct ReloadingMTLS { + @Test("Valid config") + @available(macOS 26.0, iOS 26.0, watchOS 26.0, tvOS 26.0, visionOS 26.0, *) + func testValidConfig() async throws { + let chain = try TestCA.makeSelfSignedChain() + let trustRootPEM = try chain.ca.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "security": "reloadingMTLS", + "certificateChainPEMPath": .init(.string("certs.pem"), isSecret: false), + "privateKeyPEMPath": .init(.string("key.pem"), isSecret: false), + "trustRoots": .init(.stringArray([trustRootPEM]), isSecret: false), + "certificateVerificationMode": "noHostnameVerification", + "refreshInterval": 45, + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + switch transportSecurity.backing { + case .reloadingMTLS(_, let trustRoots, _, _): + #expect(trustRoots == [chain.ca]) + default: + Issue.record("Expected reloadingMTLS backing, got different type") + } + } + } + } +} +#endif // SwiftConfiguration diff --git a/Tests/HTTPServerTests/Utilities/Certificates.swift b/Tests/HTTPServerTests/Utilities/Certificates.swift index 20c80c4..aca219c 100644 --- a/Tests/HTTPServerTests/Utilities/Certificates.swift +++ b/Tests/HTTPServerTests/Utilities/Certificates.swift @@ -20,6 +20,17 @@ struct ChainPrivateKeyPair { let leaf: Certificate let ca: Certificate let privateKey: Certificate.PrivateKey + + var chain: [Certificate] { + [self.leaf, self.ca] + } + + var chainPEMString: String { + get throws { + let certs = [try self.leaf.serializeAsPEM().pemString, try self.ca.serializeAsPEM().pemString] + return certs.joined(separator: "\n") + } + } } struct TestCA {