From 53edcbc48ecc097f17d5a4b4e7e770b5253fb704 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Fri, 20 Mar 2026 11:09:46 +0000 Subject: [PATCH] Improve SwiftConfiguration integration --- .../NIOHTTPServer+SwiftConfiguration.swift | 309 +++++++++--------- ...NIOHTTPServerSwiftConfigurationError.swift | 4 + .../SwiftConfigurationIntegration.md | 147 +++++++++ ...NIOHTTPServerSwiftConfigurationTests.swift | 209 ++++++++---- 4 files changed, 444 insertions(+), 225 deletions(-) create mode 100644 Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 1d1b3ed..02f710f 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -30,7 +30,7 @@ extension NIOHTTPServerConfiguration { /// specified key: /// - ``BindTarget`` - Provide under key `"bindTarget"` (keys listed in ``BindTarget/init(config:)``). /// - /// - ``SupportedHTTPVersions`` - Provide under key `"supportedHTTPVersions"` (keys listed in + /// - ``SupportedHTTPVersions`` - Provide under key `"http.versions"` (supported values listed in /// ``SupportedHTTPVersions/init(config:)``). /// /// - ``TransportSecurity`` - Provide under key `"transportSecurity"` (keys listed in @@ -41,12 +41,14 @@ extension NIOHTTPServerConfiguration { /// /// - 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 - /// ``NIOHTTPServerSwiftConfigurationError/customVerificationCallbackProvidedWhenNotUsingMTLS``. If set to `nil` when - /// mTLS *is* configured, the default client certificate verification logic of the underlying SSL implementation - /// is used. + /// - customCertificateVerificationCallback: A custom client certificate verification callback. This must be + /// provided when `transportSecurity.trustRootsSource` is `"customCertificateVerificationCallback"`, and must be + /// `nil` otherwise. + /// - Throws ``NIOHTTPServerConfigurationError/customVerificationCallbackProvidedWhenNotUsingMTLS`` if provided + /// when `transportSecurity.mode` is not `"mTLS"`. + /// - Throws ``NIOHTTPServerSwiftConfigurationError/trustRootsSourceAndVerificationCallbackMismatch`` if there + /// is a mismatch between `transportSecurity.trustRootsSource` and whether a custom certificate verification + /// callback is provided. public init( config: ConfigReader, customCertificateVerificationCallback: ( @@ -57,7 +59,7 @@ extension NIOHTTPServerConfiguration { try self.init( bindTarget: try .init(config: snapshot.scoped(to: "bindTarget")), - supportedHTTPVersions: try .init(config: snapshot), + supportedHTTPVersions: try .init(config: snapshot.scoped(to: "http")), transportSecurity: try .init( config: snapshot.scoped(to: "transportSecurity"), customCertificateVerificationCallback: customCertificateVerificationCallback @@ -96,20 +98,19 @@ extension Set where Element == NIOHTTPServerConfiguration.HTTPVersion { /// Initialize a supported HTTP versions configuration from a config reader. /// /// ## Configuration keys: - /// - `supportedHTTPVersions` (string array, required): A set of HTTP versions the server should support (permitted - /// values: `"http1_1"`, `"http2"`). If `"http2"` is contained in this array, then HTTP/2 configuration can be - /// specified under the `"http2"` key. See ``NIOHTTPServerConfiguration/HTTP2/init(config:)`` for the supported - /// keys under `"http2"`. + /// - `versions` (string array, required): A set of HTTP versions the server should support (permitted values: + /// `"http1_1"`, `"http2"`). + /// - If `"http2"` is contained in this array, then HTTP/2 configuration can be specified under the `"http2"` + /// key. See ``NIOHTTPServerConfiguration/HTTP2/init(config:)`` for the supported keys under `"http2"`. /// + /// - Throws ``NIOHTTPServerConfigurationError/noSupportedHTTPVersionsSpecified`` if no supported HTTP versions are + /// specified under the "versions" key. /// - Parameter config: The configuration reader. public init(config: ConfigSnapshotReader) throws { self = .init() let versions = Set( - try config.requiredStringArray( - forKey: "supportedHTTPVersions", - as: HTTPVersionKind.self - ) + try config.requiredStringArray(forKey: "versions", as: HTTPVersionKind.self) ) if versions.isEmpty { @@ -134,156 +135,115 @@ 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"`). + /// - `mode` (string, required): The transport security mode for the server (permitted values: `"plaintext"`, + /// `"tls"`, `"mTLS"`). + /// - `credentialSource` (string, required for `"tls"` and `"mTLS"`): How TLS credentials are provided (permitted + /// values: `"inline"`, `"file"`). /// - /// ### Configuration keys for `"tls"`: + /// ### Configuration keys for `credentialSource: "inline"`: /// - `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. + /// ### Configuration keys for `credentialSource: "file"`: /// - `certificateChainPEMPath` (string, required): Path to the certificate chain PEM file. /// - `privateKeyPEMPath` (string, required): Path to the private key PEM file. + /// - `refreshInterval` (int, optional): The interval (in seconds) at which the certificate chain and private key + /// will be reloaded. If omitted, credentials are loaded from the file only once at startup. /// - /// ### Configuration keys for `"mTLS"`: - /// - `certificateChainPEMString` (string, required): PEM-formatted certificate chain content. - /// - `privateKeyPEMString` (string, required, secret): PEM-formatted private key content. - /// - `trustRootsPEMString` (string, optional, default: system trust roots): The root certificates to trust when - /// verifying client certificates. - /// - `certificateVerificationMode` (string, required): The client certificate validation behavior (permitted - /// values: "optionalVerification" or "noHostnameVerification"). - /// - /// ### 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. - /// - `trustRootsPEMString` (string, optional, default: system trust roots): The root certificates to trust when - /// verifying client certificates. + /// ### Configuration keys for `mode: "mTLS"`: + /// - `trustRootsSource` (string, required): How trust roots are provided (permitted values: `"inline"`, `"file"`, + /// `"systemDefaults"`, `"customCertificateVerificationCallback"`). + /// - `trustRootsPEMString` (string, required for `trustRootsSource: "inline"`): The root certificates as a + /// PEM-encoded string. + /// - `trustRootsPEMPath` (string, required for `trustRootsSource: "file"`): Path to a PEM file containing root + /// 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 - /// ``NIOHTTPServerSwiftConfigurationError/customVerificationCallbackProvidedWhenNotUsingMTLS``. If set to `nil` when - /// mTLS *is* configured, the default client certificate verification logic of the underlying SSL implementation - /// is used. + /// - customCertificateVerificationCallback: A custom client certificate verification callback. This argument must + /// be provided when `trustRootsSource` is `"customCertificateVerificationCallback"`, and must be `nil` + /// otherwise. + /// - Throws ``NIOHTTPServerConfigurationError/customVerificationCallbackProvidedWhenNotUsingMTLS`` if the + /// callback is provided when `mode` is not `"mTLS"`. + /// - Throws ``NIOHTTPServerConfigurationError/trustRootsSourceAndVerificationCallbackMismatch`` if there is a + /// mismatch between `trustRootsSource` and whether the callback is provided. public init( config: ConfigSnapshotReader, customCertificateVerificationCallback: ( @Sendable ([Certificate]) async throws -> CertificateVerificationResult )? = nil ) throws { - let security = try config.requiredString(forKey: "security", as: TransportSecurityKind.self) + let mode = try config.requiredString(forKey: "mode", as: TransportSecurityMode.self) // A custom verification callback can only be used when the server is configured for mTLS. - if let _ = customCertificateVerificationCallback, !security.isMTLS() { + if let _ = customCertificateVerificationCallback, mode != .mTLS { throw NIOHTTPServerSwiftConfigurationError.customVerificationCallbackProvidedWhenNotUsingMTLS } - switch security { + switch mode { case .plaintext: self = .plaintext case .tls: - self = try .tls(config: config) - - case .reloadingTLS: - self = try .reloadingTLS(config: config) + self = .tls(credentials: try .init(config: config)) case .mTLS: - self = try .mTLS( - config: config, - customCertificateVerificationCallback: customCertificateVerificationCallback - ) - - case .reloadingMTLS: - self = try .reloadingMTLS( - config: config, - customCertificateVerificationCallback: customCertificateVerificationCallback + self = .mTLS( + credentials: try .init(config: config), + trustConfiguration: try .init( + 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( - credentials: .inMemory( - 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 Self.tls( - credentials: .reloading( - certificateReloader: TimedCertificateReloader( - refreshInterval: .seconds(refreshInterval), - certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), - privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) - ) - ) +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension NIOHTTPServerConfiguration.TransportSecurity.TLSCredentials { + /// Initialize TLS credentials (certificate chain and private key) from a config reader. + /// + /// When `credentialSource` is `"inline"`, the certificate chain and private key are read as PEM strings from the + /// configuration. When `"file"`, they are loaded from disk, optionally reloading at a configured interval. + fileprivate init(config: ConfigSnapshotReader) throws { + let credentialSource = try config.requiredString( + forKey: "credentialSource", + as: NIOHTTPServerConfiguration.TransportSecurity.CredentialSource.self ) - } - 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) + switch credentialSource { + case .inline: + let certificateChainPEMString = try config.requiredString(forKey: "certificateChainPEMString") + let privateKeyPEMString = try config.requiredString(forKey: "privateKeyPEMString", isSecret: true) - return Self.mTLS( - credentials: .inMemory( + self = .inMemory( certificateChain: try PEMDocument.parseMultiple(pemString: certificateChainPEMString) .map { try Certificate(pemEncoded: $0.pemString) }, privateKey: try .init(pemEncoded: privateKeyPEMString) - ), - trustConfiguration: try .init( - config: config, - customCertificateVerificationCallback: customCertificateVerificationCallback ) - ) - } - 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") - - return try Self.mTLS( - credentials: .reloading( - certificateReloader: TimedCertificateReloader( - refreshInterval: .seconds(refreshInterval), - certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), - privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + case .file: + let certificateChainPEMPath = try config.requiredString(forKey: "certificateChainPEMPath") + let privateKeyPEMPath = try config.requiredString(forKey: "privateKeyPEMPath") + let refreshInterval = config.int(forKey: "refreshInterval") + + if let refreshInterval { + self = .reloading( + certificateReloader: TimedCertificateReloader( + refreshInterval: .seconds(refreshInterval), + certificateSource: .init(location: .file(path: certificateChainPEMPath), format: .pem), + privateKeySource: .init(location: .file(path: privateKeyPEMPath), format: .pem) + ) ) - ), - trustConfiguration: .init( - config: config, - customCertificateVerificationCallback: customCertificateVerificationCallback - ) - ) + } else { + self = .pemFile( + certificateChainPath: certificateChainPEMPath, + privateKeyPath: privateKeyPEMPath + ) + } + } } } @@ -292,50 +252,69 @@ extension NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration { /// Initialize an mTLS trust configuration from a config reader. /// /// ## Configuration keys: - /// - `trustRootsPEMString` (string, optional, default: system trust roots): The root certificates to trust when - /// verifying client certificates. + /// - `trustRootsSource` (string, required): How trust roots are provided (permitted values: `"inline"`, `"file"`, + /// `"systemDefaults"`, `"customCertificateVerificationCallback"`). + /// - `trustRootsPEMString` (string, required for `trustRootsSource: "inline"`): The trusted root certificates as a + /// PEM-encoded string. + /// - `trustRootsPEMPath` (string, required for `trustRootsSource: "file"`): Path to a PEM file containing trusted + /// root certificates. /// - `certificateVerificationMode` (string, required): The client certificate validation behavior (permitted /// values: "optionalVerification" or "noHostnameVerification") /// /// - Parameters: /// - config: The configuration reader. - /// - customCertificateVerificationCallback: An optional client certificate verification callback to use when - /// mTLS is configured (i.e., when `"transportSecurity.security"` is `"mTLS"` or `"reloadingMTLS"`). If set to - /// `nil`, the default client certificate verification logic of the underlying SSL implementation is used. + /// - customCertificateVerificationCallback: A client certificate verification callback. Must be provided when + /// `trustRootsSource` is `"customCertificateVerificationCallback"`, and must be `nil` otherwise. /// - /// - Note: It is invalid to pass both a custom verification callback and a set of trust roots. If using a custom - /// verification callback, trust must be established within the callback itself. Providing both will result in a - /// `NIOHTTPServerSwiftConfigurationError.customVerificationCallbackAndTrustRootsProvided` error. + /// - Throws: ``NIOHTTPServerSwiftConfigurationError/trustRootsSourceAndVerificationCallbackMismatch`` if: + /// - A verification callback is provided when `trustRootsSource != "customCertificateVerificationCallback"`, or; + /// - A verification callback is *not* provided when `trustRootsSource == "customCertificateVerificationCallback"`. public init( config: ConfigSnapshotReader, customCertificateVerificationCallback: ( @Sendable ([X509.Certificate]) async throws -> CertificateVerificationResult )? ) throws { - let trustRootsPEMString = config.string(forKey: "trustRootsPEMString") + let trustRootsSource = try config.requiredString(forKey: "trustRootsSource", as: TrustRootsSource.self) let certificateVerificationMode = try config.requiredString( forKey: "certificateVerificationMode", as: VerificationMode.self ) - if trustRootsPEMString != nil, customCertificateVerificationCallback != nil { - // Throw if both `trustRoots` and `customCertificateVerificationCallback` are provided. - throw NIOHTTPServerSwiftConfigurationError.customVerificationCallbackAndTrustRootsProvided + if let _ = customCertificateVerificationCallback, trustRootsSource != .customCertificateVerificationCallback { + throw NIOHTTPServerSwiftConfigurationError.trustRootsSourceAndVerificationCallbackMismatch } - if let trustRootsPEMString { + switch trustRootsSource { + case .inline: + let trustRootsPEMString = try config.requiredString(forKey: "trustRootsPEMString") self = .inMemory( trustRoots: try PEMDocument.parseMultiple(pemString: trustRootsPEMString) .map { try Certificate(pemEncoded: $0.pemString) }, certificateVerification: .init(certificateVerificationMode) ) - } else if let customCertificateVerificationCallback { + + case .file: + let trustRootsPEMPath = try config.requiredString(forKey: "trustRootsPEMPath") + self = .pemFile( + path: trustRootsPEMPath, + certificateVerification: .init(certificateVerificationMode) + ) + + case .systemDefaults: + self = .systemDefaults(certificateVerification: .init(certificateVerificationMode)) + + case .customCertificateVerificationCallback: + guard let customCertificateVerificationCallback else { + // No custom verification callback provided despite the "trustRootsSource" key being set to + // "customCertificateVerificationCallback". + throw NIOHTTPServerSwiftConfigurationError.trustRootsSourceAndVerificationCallbackMismatch + } + self = .customCertificateVerificationCallback( customCertificateVerificationCallback, certificateVerification: .init(certificateVerificationMode) ) - } else { - self = .systemDefaults(certificateVerification: .init(certificateVerificationMode)) } } } @@ -345,20 +324,21 @@ 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. + /// - `lowWatermark` (int, optional, default: 2): The threshold below which the consumer will ask the producer to + /// produce more elements. + /// - `highWatermark` (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", + forKey: "lowWatermark", default: NIOHTTPServerConfiguration.BackPressureStrategy.defaultWatermarkLow ), high: config.int( - forKey: "high", + forKey: "highWatermark", default: NIOHTTPServerConfiguration.BackPressureStrategy.defaultWatermarkHigh ) ) @@ -374,8 +354,10 @@ extension NIOHTTPServerConfiguration.HTTP2 { /// - `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 + /// - `maxConcurrentStreams` (int, optional, default: nil): The maximum number of concurrent streams in an HTTP/2 /// connection. + /// - `gracefulShutdown.maximumGracefulShutdownDuration` (int, optional, default: nil): The maximum amount of time + /// (in seconds) that the connection has to close gracefully. /// /// - Parameter config: The configuration reader. public init(config: ConfigSnapshotReader) { @@ -392,7 +374,7 @@ extension NIOHTTPServerConfiguration.HTTP2 { /// 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"), - gracefulShutdown: .init(config: config) + gracefulShutdown: .init(config: config.scoped(to: "gracefulShutdown")) ) } } @@ -413,29 +395,38 @@ extension NIOHTTPServerConfiguration.HTTP2.GracefulShutdownConfiguration { } } +@available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) +extension Set where Element == NIOHTTPServerConfiguration.HTTPVersion { + fileprivate enum HTTPVersionKind: String { + case http1_1 + case http2 + } +} + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServerConfiguration.TransportSecurity { - fileprivate enum TransportSecurityKind: String { + fileprivate enum TransportSecurityMode: String { case plaintext case tls - case reloadingTLS case mTLS - case reloadingMTLS - - func isMTLS() -> Bool { - switch self { - case .mTLS, .reloadingMTLS: - return true + } - default: - return false - } - } + fileprivate enum CredentialSource: String { + case inline + case file } } @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) extension NIOHTTPServerConfiguration.TransportSecurity.MTLSTrustConfiguration { + /// The supported sources for trust roots. + fileprivate enum TrustRootsSource: String { + case inline + case file + case systemDefaults + case customCertificateVerificationCallback + } + /// A wrapper over ``CertificateVerificationMode``. fileprivate enum VerificationMode: String { case optionalVerification diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift index 36387c2..9d73dc6 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServerSwiftConfigurationError.swift @@ -18,6 +18,7 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible { case customVerificationCallbackAndTrustRootsProvided case customVerificationCallbackProvidedWhenNotUsingMTLS + case trustRootsSourceAndVerificationCallbackMismatch var description: String { switch self { @@ -26,6 +27,9 @@ enum NIOHTTPServerSwiftConfigurationError: Error, CustomStringConvertible { case .customVerificationCallbackProvidedWhenNotUsingMTLS: "Invalid configuration: a custom certificate verification callback was provided despite the server not being configured for mTLS." + + case .trustRootsSourceAndVerificationCallbackMismatch: + "Invalid configuration: there is a mismatch between the trustRootsSource key and the provided customCertificateVerificationCallback." } } } diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md new file mode 100644 index 0000000..6ef8aef --- /dev/null +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -0,0 +1,147 @@ +# Configuring the server with swift-configuration + +Initialize ``NIOHTTPServerConfiguration`` from a configuration source using [`swift-configuration`](https://github.com/apple/swift-configuration). + +## Overview + +``NIOHTTPServerConfiguration`` can be initialized from a `ConfigReader` provided by +[`swift-configuration`](https://github.com/apple/swift-configuration). This lets you load server settings from +environment variables, JSON files, or other `swift-configuration` providers. + +This functionality requires the `Configuration` package trait, which is enabled by default. + +### Basic usage + +```swift +import Configuration +import NIOHTTPServer + +// Create a configuration reader from one or more providers. +let config = ConfigReader( + providers: [ + EnvironmentVariablesProvider(), + try FileProvider(format: .json, filePath: "config.json"), + ] +) + +let serverConfiguration = try NIOHTTPServerConfiguration(config: config) +``` + +### Configuration key reference + +``NIOHTTPServerConfiguration`` is comprised of four components. Provide the configuration for each component under its +respective key prefix. + +> Important: HTTP/2 cannot be served over plaintext. If `"http2"` is included in `http.versions`, the transport +> security must be set to `"tls"` or `"mTLS"`. + +| Prefix | Configuration Key | Type | Required/Optional | Default | +|-------------------------------|-----------------------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|---------| +| `bindTarget` | `host` | `string` | Required | - | +| | `port` | `int` | Required | - | +| `http` | `versions` | `string array` | Required (permitted values: `"http1_1"`, `"http2"`) | - | +| `http.http2` | `maxFrameSize` | `int` | Optional | 2^14 | +| | `targetWindowSize` | `int` | Optional | 2^16-1 | +| | `maxConcurrentStreams` | `int` | Optional | nil | +| `http.http2.gracefulShutdown` | `maximumGracefulShutdownDuration` | `int` | Optional | nil | +| `transportSecurity` | `mode` | `string` | Required (permitted values: `"plaintext"`, `"tls"`, `"mTLS"`) | - | +| | `credentialSource` | `string` | Required for `"tls"` and `"mTLS"` (permitted values: `"inline"`, `"file"`) | - | +| | `certificateChainPEMString` | `string` | Required for `credentialSource: "inline"` | - | +| | `privateKeyPEMString` | `string` | Required for `credentialSource: "inline"`, secret. | - | +| | `certificateChainPEMPath` | `string` | Required for `credentialSource: "file"` | - | +| | `privateKeyPEMPath` | `string` | Required for `credentialSource: "file"`, secret. | - | +| | `refreshInterval` | `int` | Optional for `credentialSource: "file"` | - | +| | `trustRootsSource` | `string` | Required for `"mTLS"` (permitted values: `"inline"`, `"file"`, `"systemDefaults"`, `"customCertificateVerificationCallback"`) | - | +| | `trustRootsPEMString` | `string` | Required for `trustRootsSource: "inline"` | - | +| | `trustRootsPEMPath` | `string` | Required for `trustRootsSource: "file"` | - | +| | `certificateVerificationMode` | `string` | Required for `"mTLS"`, permitted values: `"optionalVerification"`, `"noHostnameVerification"` | - | +| `backpressureStrategy` | `lowWatermark` | `int` | Optional | 2 | +| | `highWatermark` | `int` | Optional | 10 | + + +The `credentialSource` determines how server credentials are provided: +- `"inline"`: provide the PEM-encoded certificate chain and private key as string values, using + `certificateChainPEMString` and `privateKeyPEMString`. +- `"file"`: provide file paths to PEM-encoded certificate chain and private key files on disk, using + `certificateChainPEMPath` and `privateKeyPEMPath`. + - When `refreshInterval` is provided, credentials are reloaded periodically at the specified interval (in seconds). + Otherwise, credentials are loaded from disk once at startup. + +The `trustRootsSource` determines how mTLS trust roots are provided: +- `"inline"`: provide the root certificates as a PEM-encoded string, using `trustRootsPEMString`. +- `"file"`: provide a file path to a PEM file containing root certificates, using `trustRootsPEMPath`. +- `"systemDefaults"`: use the operating system's default trust store. +- `"customCertificateVerificationCallback"`: use a custom verification callback provided programmatically via the + `customCertificateVerificationCallback` parameter. + +### Example JSON configuration + +The following JSON file shows an example configuration. Comments indicate the default value that would be used if the +key were omitted. + +```json +{ + "bindTarget": { + "host": "0.0.0.0", + "port": 443 + }, + "http": { + "versions": ["http1_1", "http2"], + "http2": { + "maxFrameSize": 16384, // default: 2^14 (16384) + "targetWindowSize": 65535, // default: 2^16 - 1 (65535) + "maxConcurrentStreams": 100, // default: nil (no limit) + "gracefulShutdown": { + "maximumGracefulShutdownDuration": 30 // default: nil (no time limit) + } + } + }, + "transportSecurity": { + "mode": "mTLS", + "credentialSource": "inline", + "certificateChainPEMString": "-----BEGIN CERTIFICATE-----\n...", + "privateKeyPEMString": "-----BEGIN PRIVATE KEY-----\n...", + "trustRootsSource": "inline", + "trustRootsPEMString": "-----BEGIN CERTIFICATE-----\n...", + "certificateVerificationMode": "noHostnameVerification" + }, + "backpressureStrategy": { + "lowWatermark": 2, // default: 2 + "highWatermark": 10 // default: 10 + } +} +``` + +### Custom certificate verification + +When using mTLS, you can provide a custom certificate verification callback instead of relying on trust roots. To do +so, set `trustRootsSource` to `"customCertificateVerificationCallback"` in the configuration: + +```json +{ + "transportSecurity": { + "mode": "mTLS", + "credentialSource": "inline", + "certificateChainPEMString": "...", + "privateKeyPEMString": "...", + "trustRootsSource": "customCertificateVerificationCallback", + "certificateVerificationMode": "noHostnameVerification" + } +} +``` + +Then pass the callback when initializing the configuration: + +```swift +let serverConfiguration = try NIOHTTPServerConfiguration( + config: config, + customCertificateVerificationCallback: { certificates in + // Perform custom verification logic. + return .certificateVerified(.init(nil)) + } +) +``` + +Setting `trustRootsSource` to `"customCertificateVerificationCallback"` without providing a callback, or providing a +callback when `trustRootsSource` is set to something else, will result in a +``NIOHTTPServerSwiftConfigurationError/trustRootsSourceAndVerificationCallbackMismatch`` error. diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 579a8af..b9cca9d 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -95,7 +95,7 @@ struct NIOHTTPServerSwiftConfigurationTests { @Test("Custom values") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testCustomValues() throws { - let provider = InMemoryProvider(values: ["low": 5, "high": 20]) + let provider = InMemoryProvider(values: ["lowWatermark": 5, "highWatermark": 20]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() @@ -111,7 +111,7 @@ struct NIOHTTPServerSwiftConfigurationTests { @Test("Partial custom values") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testPartialCustomValues() throws { - let provider = InMemoryProvider(values: ["low": 3]) + let provider = InMemoryProvider(values: ["lowWatermark": 3]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() @@ -132,7 +132,7 @@ struct NIOHTTPServerSwiftConfigurationTests { func testEmptySupportedHTTPVersionSetFails() async { await #expect(processExitsWith: .failure) { let provider = InMemoryProvider(values: [ - "supportedHTTPVersions": .init(.stringArray([]), isSecret: false) + "versions": .init(.stringArray([]), isSecret: false) ]) let config = ConfigReader(provider: provider) @@ -145,7 +145,7 @@ struct NIOHTTPServerSwiftConfigurationTests { @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testUnrecognizedHTTPVersionIgnored() throws { let provider = InMemoryProvider(values: [ - "supportedHTTPVersions": .init(.stringArray(["unrecognized_version"]), isSecret: false) + "versions": .init(.stringArray(["unrecognized_version"]), isSecret: false) ]) let config = ConfigReader(provider: provider) @@ -155,17 +155,14 @@ struct NIOHTTPServerSwiftConfigurationTests { _ = try Set(config: snapshot) } - #expect( - "Config value for key 'supportedHTTPVersions' failed to cast to type HTTPVersionKind." - == "\(configError)" - ) + #expect("Config value for key 'versions' failed to cast to type HTTPVersionKind." == "\(configError)") } @Test("Default HTTP/2 configuration used when not specified") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testDefaultHTTP2ConfigurationUsed() throws { let provider = InMemoryProvider(values: [ - "supportedHTTPVersions": .init(.stringArray(["http1_1", "http2"]), isSecret: false) + "versions": .init(.stringArray(["http1_1", "http2"]), isSecret: false) ]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() @@ -200,7 +197,7 @@ struct NIOHTTPServerSwiftConfigurationTests { "maxFrameSize": 1, "targetWindowSize": 2, "maxConcurrentStreams": 3, - "maximumGracefulShutdownDuration": 4, + "gracefulShutdown.maximumGracefulShutdownDuration": 4, ]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() @@ -231,10 +228,10 @@ struct NIOHTTPServerSwiftConfigurationTests { @Suite("TransportSecurity") struct TransportSecurityTests { - @Test("Invalid security type") + @Test("Invalid security mode") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) - func testInvalidSecurityType() throws { - let provider = InMemoryProvider(values: ["security": ""]) + func testInvalidSecurityMode() throws { + let provider = InMemoryProvider(values: ["mode": ""]) let config = ConfigReader(provider: provider) let snapshot = config.snapshot() @@ -242,19 +239,19 @@ struct NIOHTTPServerSwiftConfigurationTests { try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) } - #expect("Config value for key 'security' failed to cast to type TransportSecurityKind." == "\(configError)") + #expect("Config value for key 'mode' failed to cast to type TransportSecurityMode." == "\(configError)") } @Test("Custom verification callback without mTLS being enabled") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testCannotInitializeWithCustomCallbackWhenMTLSNotEnabled() throws { - let provider = InMemoryProvider(values: ["security": "tls"]) + let provider = InMemoryProvider(values: ["mode": "tls", "credentialSource": "inline"]) 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. + // 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 @@ -270,16 +267,17 @@ struct NIOHTTPServerSwiftConfigurationTests { @Suite struct TLS { - @Test("Valid config") + @Test("Valid config using inline credentials") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) - func testValidConfig() throws { + func testValidConfigUsingInlineCredentials() throws { let chain = try TestCA.makeSelfSignedChain() let certsPEM = try chain.chainPEMString let keyPEM = try chain.privateKey.serializeAsPEM().pemString let provider = InMemoryProvider( values: [ - "security": "tls", + "mode": "tls", + "credentialSource": "inline", "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), ] @@ -303,6 +301,61 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(privateKey == chain.privateKey) } + @Test("Valid file-based credentials with reloading") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testValidFileConfigWithReloading() async throws { + let provider = InMemoryProvider( + values: [ + "mode": "tls", + "credentialSource": "file", + "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 .tls(let credentials) = transportSecurity.backing else { + Issue.record("Expected TLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .reloading = credentials.backing else { + Issue.record("Expected reloading TLS credentials, got \(credentials.backing) instead.") + return + } + } + + @Test("Valid file-based credentials without reloading") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testValidFileConfigWithoutReloading() throws { + let provider = InMemoryProvider( + values: [ + "mode": "tls", + "credentialSource": "file", + "certificateChainPEMPath": .init(.string("cert.pem"), isSecret: false), + "privateKeyPEMPath": .init(.string("key.pem"), isSecret: false), + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + guard case .tls(let credentials) = transportSecurity.backing else { + Issue.record("Expected TLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .pemFile = credentials.backing else { + Issue.record("Expected PEM file TLS credentials, got \(credentials.backing) instead.") + return + } + } + @Test("Init fails with missing certificate") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testMissingCertificate() throws { @@ -311,7 +364,8 @@ struct NIOHTTPServerSwiftConfigurationTests { let provider = InMemoryProvider( values: [ - "security": "tls", + "mode": "tls", + "credentialSource": "inline", "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), ] ) @@ -333,7 +387,8 @@ struct NIOHTTPServerSwiftConfigurationTests { let provider = InMemoryProvider( values: [ - "security": "tls", + "mode": "tls", + "credentialSource": "inline", "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), ] ) @@ -348,36 +403,6 @@ struct NIOHTTPServerSwiftConfigurationTests { } } - @Suite - struct ReloadingTLS { - @Test("Valid config") - @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) - 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 .tls(let credentials) = transportSecurity.backing else { - Issue.record("Expected TLS transport security, got \(transportSecurity.backing) instead.") - return - } - - guard case .reloading = credentials.backing else { - Issue.record("Expected reloading TLS credentials, got \(credentials.backing) instead.") - return - } - } - } - @Suite struct MTLS { @Test("Custom verification callback") @@ -390,9 +415,11 @@ struct NIOHTTPServerSwiftConfigurationTests { let provider = InMemoryProvider( values: [ - "security": "mTLS", + "mode": "mTLS", + "credentialSource": "inline", "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "trustRootsSource": "customCertificateVerificationCallback", "certificateVerificationMode": "noHostnameVerification", ] ) @@ -439,9 +466,11 @@ struct NIOHTTPServerSwiftConfigurationTests { let provider = InMemoryProvider( values: [ - "security": "mTLS", + "mode": "mTLS", + "credentialSource": "inline", "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "trustRootsSource": "systemDefaults", "certificateVerificationMode": "optionalVerification", ] ) @@ -480,9 +509,11 @@ struct NIOHTTPServerSwiftConfigurationTests { let provider = InMemoryProvider( values: [ - "security": "mTLS", + "mode": "mTLS", + "credentialSource": "inline", "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "trustRootsSource": "systemDefaults", "certificateVerificationMode": "", ] ) @@ -509,9 +540,11 @@ struct NIOHTTPServerSwiftConfigurationTests { let provider = InMemoryProvider( values: [ - "security": "mTLS", + "mode": "mTLS", + "credentialSource": "inline", "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "trustRootsSource": "systemDefaults", "certificateVerificationMode": "noHostnameVerification", ] ) @@ -538,11 +571,51 @@ struct NIOHTTPServerSwiftConfigurationTests { return } } + + @Test("Trust roots from PEM file path") + @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) + func testTrustRootsFromPEMFilePath() throws { + let serverChain = try TestCA.makeSelfSignedChain() + + let certsPEM = try serverChain.chainPEMString + let keyPEM = try serverChain.privateKey.serializeAsPEM().pemString + + let provider = InMemoryProvider( + values: [ + "mode": "mTLS", + "credentialSource": "inline", + "certificateChainPEMString": .init(.string(certsPEM), isSecret: false), + "privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "trustRootsSource": "file", + "trustRootsPEMPath": .init(.string("/path/to/trust-roots.pem"), isSecret: false), + "certificateVerificationMode": "noHostnameVerification", + ] + ) + let config = ConfigReader(provider: provider) + let snapshot = config.snapshot() + + let transportSecurity = try NIOHTTPServerConfiguration.TransportSecurity(config: snapshot) + + guard case .mTLS(_, let mTLSTrustConfiguration) = transportSecurity.backing else { + Issue.record("Expected mTLS transport security, got \(transportSecurity.backing) instead.") + return + } + + guard case .pemFile(let path) = mTLSTrustConfiguration.backing else { + Issue.record( + "Expected pemFile trust configuration, got \(mTLSTrustConfiguration.backing) instead." + ) + return + } + + #expect(path == "/path/to/trust-roots.pem") + } + } @Suite struct ReloadingMTLS { - @Test("Valid config") + @Test("Valid config with file credentials and reloading") @available(macOS 26.2, iOS 26.2, watchOS 26.2, tvOS 26.2, visionOS 26.2, *) func testValidConfig() async throws { let chain = try TestCA.makeSelfSignedChain() @@ -550,9 +623,11 @@ struct NIOHTTPServerSwiftConfigurationTests { let provider = InMemoryProvider( values: [ - "security": "reloadingMTLS", + "mode": "mTLS", + "credentialSource": "file", "certificateChainPEMPath": .init(.string("certs.pem"), isSecret: false), "privateKeyPEMPath": .init(.string("key.pem"), isSecret: false), + "trustRootsSource": "inline", "trustRootsPEMString": .init(.string(trustRootPEM), isSecret: false), "certificateVerificationMode": "noHostnameVerification", "refreshInterval": 45, @@ -595,14 +670,16 @@ struct NIOHTTPServerSwiftConfigurationTests { values: [ "bindTarget.host": "127.0.0.1", "bindTarget.port": 8000, - "supportedHTTPVersions": .init(.stringArray(["http1_1", "http2"]), isSecret: false), - "http2.maxFrameSize": 1, - "http2.targetWindowSize": 2, - "http2.maxConcurrentStreams": 3, - "http2.maximumGracefulShutdownDuration": 4, - "transportSecurity.security": .init(.string("mTLS"), isSecret: false), + "http.versions": .init(.stringArray(["http1_1", "http2"]), isSecret: false), + "http.http2.maxFrameSize": 1, + "http.http2.targetWindowSize": 2, + "http.http2.maxConcurrentStreams": 3, + "http.http2.gracefulShutdown.maximumGracefulShutdownDuration": 4, + "transportSecurity.mode": .init(.string("mTLS"), isSecret: false), + "transportSecurity.credentialSource": .init(.string("inline"), isSecret: false), "transportSecurity.certificateChainPEMString": .init(.string(certsPEM), isSecret: false), "transportSecurity.privateKeyPEMString": .init(.string(keyPEM), isSecret: true), + "transportSecurity.trustRootsSource": .init(.string("inline"), isSecret: false), "transportSecurity.trustRootsPEMString": .init(.string(certsPEM), isSecret: false), "transportSecurity.certificateVerificationMode": "optionalVerification", ] @@ -657,8 +734,8 @@ struct NIOHTTPServerSwiftConfigurationTests { values: [ "bindTarget.host": "127.0.0.1", "bindTarget.port": 8000, - "supportedHTTPVersions": .init(.stringArray(["http1_1", "http2"]), isSecret: false), - "transportSecurity.security": .init(.string("plaintext"), isSecret: false), + "http.versions": .init(.stringArray(["http1_1", "http2"]), isSecret: false), + "transportSecurity.mode": "plaintext", ] ) let config = ConfigReader(provider: provider)