From a1a220d9f9dc1e753972ffbe990e66eb74d33598 Mon Sep 17 00:00:00 2001 From: Aryan Shah Date: Mon, 20 Jul 2026 15:05:04 +0100 Subject: [PATCH] Integrate swift-configuration for HTTP/3 --- .../NIOHTTPServer+SwiftConfiguration.swift | 83 ++---- .../HTTP2+SwiftConfiguration.swift | 63 +++++ .../HTTP3+SwiftConfiguration.swift | 201 +++++++++++++++ .../SwiftConfigurationIntegration.md | 83 +++--- ...NIOHTTPServerSwiftConfigurationTests.swift | 242 ++++++++++++++++++ 5 files changed, 582 insertions(+), 90 deletions(-) create mode 100644 Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP2+SwiftConfiguration.swift create mode 100644 Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP3+SwiftConfiguration.swift diff --git a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift index 807ef45..3d3f298 100644 --- a/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift +++ b/Sources/NIOHTTPServer/Configuration/NIOHTTPServer+SwiftConfiguration.swift @@ -36,9 +36,11 @@ extension NIOHTTPServerConfiguration { /// `bindTargets.hosts` and `bindTargets.ports`. Exactly one of `"bindTarget"` or `"bindTargets"` must be /// provided. /// - /// - **`"http"`**: Supported HTTP versions and protocol settings. Supported keys are `"versions"` - /// (a string array of `"http1_1"` and/or `"http2"`) and, when HTTP/2 is enabled, `"http2"` (see - /// ``HTTP2/init(config:)``). + /// - **`"http"`**: Supported HTTP versions and per-version settings: + /// - `"versions"` (required string array): the HTTP versions to support (permitted values: `"http1_1"`, + /// `"http2"`, `"http3"`). + /// - `"http2"`: HTTP/2 settings, read when `"http2"` is contained in `"versions"` (see ``HTTP2/init(config:)``). + /// - `"http3"`: HTTP/3 settings, read when `"http3"` is contained in `"versions"` (see ``HTTP3/init(config:)``). /// /// - **`"transportSecurity"`**: The transport security mode: plaintext, TLS, or mTLS (see /// ``TransportSecurity/init(config:customCertificateVerificationCallback:)``). @@ -131,23 +133,21 @@ extension NIOHTTPServerConfiguration.BindTarget { } } -private enum HTTPVersionKind: String { - case http1_1 - case http2 -} - @available(anyAppleOS 26.0, *) extension Set where Element == NIOHTTPServerConfiguration.HTTPVersion { /// Initialize a supported HTTP versions configuration from a config reader. /// /// ## Configuration keys: /// - `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"`. + /// `"http1_1"`, `"http2"`, `"http3"`). + /// - If `"http2"` and/or `"http3"` are contained in this array, the corresponding protocol configuration can be + /// specified under the `"http2"` or `"http3"` key respectively. See + /// ``NIOHTTPServerConfiguration/HTTP2/init(config:)`` and ``NIOHTTPServerConfiguration/HTTP3/init(config:)`` + /// for the supported keys. /// /// - 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() @@ -168,6 +168,12 @@ extension Set where Element == NIOHTTPServerConfiguration.HTTPVersion { case .http2: let h2Config = NIOHTTPServerConfiguration.HTTP2(config: config.scoped(to: "http2")) self.insert(.http2(config: h2Config)) + + #if HTTP3 + case .http3: + let h3Config = try NIOHTTPServerConfiguration.HTTP3(config: config.scoped(to: "http3")) + self.insert(.http3(config: h3Config)) + #endif } } } @@ -248,8 +254,9 @@ extension NIOHTTPServerConfiguration.TransportSecurity { 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. + /// - When `credentialSource` is `"inline"`, the certificate chain and private key are read as PEM strings. + /// - When `credentialSource` is `"file"`, the certificate chain and private key are loaded from disk, and + /// optionally reloaded at a configured interval. fileprivate init(config: ConfigSnapshotReader) throws { let credentialSource = try config.requiredString( forKey: "credentialSource", @@ -389,57 +396,14 @@ extension NIOHTTPServerConfiguration.BackPressureStrategy { } } -@available(anyAppleOS 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. - /// - `gracefulShutdown.maximumDuration` (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) { - self.init( - maxFrameSize: config.int( - forKey: "maxFrameSize", - default: NIOHTTPServerConfiguration.HTTP2.defaultMaxFrameSize - ), - targetWindowSize: config.int( - forKey: "targetWindowSize", - default: NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize - ), - maxConcurrentStreams: config.int(forKey: "maxConcurrentStreams", default: 100), - gracefulShutdown: .init(config: config.scoped(to: "gracefulShutdown")) - ) - } -} - -@available(anyAppleOS 26.0, *) -extension NIOHTTPServerConfiguration.HTTP2.GracefulShutdownConfiguration { - /// Initialize a HTTP/2 graceful shutdown configuration from a config reader. - /// - /// ## Configuration keys: - /// - `maximumDuration` (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) { - self.init( - maximumGracefulShutdownDuration: config.int(forKey: "maximumDuration").map { .seconds($0) } - ) - } -} - @available(anyAppleOS 26.0, *) extension Set where Element == NIOHTTPServerConfiguration.HTTPVersion { fileprivate enum HTTPVersionKind: String { case http1_1 case http2 + #if HTTP3 + case http3 + #endif } } @@ -485,6 +449,7 @@ extension CertificateVerificationMode { } } } + @available(anyAppleOS 26.0, *) extension NIOHTTPServerConfiguration.ConnectionTimeouts { /// Initialize connection timeouts configuration from a config reader. diff --git a/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP2+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP2+SwiftConfiguration.swift new file mode 100644 index 0000000..d9e651f --- /dev/null +++ b/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP2+SwiftConfiguration.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// 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 Configuration +public import Configuration + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP2 { + /// Initialize an 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. + /// - `gracefulShutdown.maximumDuration` (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) { + self.init( + maxFrameSize: config.int( + forKey: "maxFrameSize", + default: NIOHTTPServerConfiguration.HTTP2.defaultMaxFrameSize + ), + targetWindowSize: config.int( + forKey: "targetWindowSize", + default: NIOHTTPServerConfiguration.HTTP2.defaultTargetWindowSize + ), + maxConcurrentStreams: config.int(forKey: "maxConcurrentStreams", default: 100), + gracefulShutdown: .init(config: config.scoped(to: "gracefulShutdown")) + ) + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP2.GracefulShutdownConfiguration { + /// Initialize a HTTP/2 graceful shutdown configuration from a config reader. + /// + /// ## Configuration keys: + /// - `maximumDuration` (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) { + self.init( + maximumGracefulShutdownDuration: config.int(forKey: "maximumDuration").map { .seconds($0) } + ) + } +} +#endif // Configuration diff --git a/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP3+SwiftConfiguration.swift b/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP3+SwiftConfiguration.swift new file mode 100644 index 0000000..bae806d --- /dev/null +++ b/Sources/NIOHTTPServer/Configuration/SwiftConfig/HTTP3+SwiftConfiguration.swift @@ -0,0 +1,201 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift HTTP Server open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift HTTP Server project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift HTTP Server project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if HTTP3 && Configuration +public import Configuration + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP3 { + /// Initialize an HTTP/3 configuration from a config reader. + /// + /// ## Configuration keys: + /// HTTP/3 configuration contains three sub-scopes. All keys are optional and resolve to their default values if not + /// provided: + /// - ``NIOHTTPServerConfiguration/HTTP3/defaults`` + /// - ``NIOHTTPServerConfiguration/HTTP3/ConnectionSettings/defaults`` + /// - ``NIOHTTPServerConfiguration/HTTP3/QUICConfiguration/defaults``. + /// + /// - **`"protocolConfiguration"`**: HTTP/3 protocol-level settings (see ``ProtocolConfiguration/init(config:)``). + /// - **`"connectionSettings"`**: HTTP/3 connection settings exchanged with the client (see + /// ``ConnectionSettings/init(config:)``). + /// - **`"quicConfiguration"`**: QUIC transport configuration (see ``QUICConfiguration/init(config:)``). + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) throws { + self.init( + preferHuffmanEncoding: config.bool(forKey: "preferHuffmanEncoding", default: true), + quicConfiguration: try .init(config: config.scoped(to: "quicConfiguration")), + connectionSettings: .init(config: config.scoped(to: "connectionSettings")) + ) + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP3.QUICConfiguration { + /// Initialize a QUIC transport configuration from a config reader. + /// + /// ## Configuration keys: + /// - `keyExchangeGroup` (string, optional, default: "x25519"): The named group to use for the TLS 1.3 key exchange + /// (permitted values: `"secp256"`, `"secp384"`, `"x25519"`, `"x25519MLKEM768"`). + /// - `maxIdleTimeout` (int seconds, optional, default: 30): The idle timeout (in seconds) advertised to the client. + /// - `initialMaxData` (int bytes, optional, default: 1 MiB): The initial value for the maximum amount of data that + /// can be sent on the connection. + /// - `initialMaxStreamDataBidirectionalLocal` (int bytes, optional, default: 1 MiB): The initial flow control limit + /// for locally initiated bidirectional streams. + /// - `initialMaxStreamDataBidirectionalRemote` (int bytes, optional, default: 1 MiB): The initial flow control + /// limit for client-initiated bidirectional streams. + /// - `initialMaxStreamDataUnidirectional` (int bytes, optional, default: 1 MiB): The initial flow control limit for + /// unidirectional streams. + /// - `initialMaxStreamsBidirectional` (int, optional, default: 100): The initial maximum number of bidirectional + /// streams the server is permitted to initiate. + /// - `initialMaxStreamsUnidirectional` (int, optional, default: 100): The initial maximum number of unidirectional + /// streams the server is permitted to initiate. + /// - `keepAliveInterval` (int seconds, optional, default: nil): The interval (in seconds) at which the server sends + /// keep-alive PING frames. When omitted, no keep-alive PINGs are sent. + /// - `sendRetry` (bool, optional, default: false): Whether the server sends a Retry packet before accepting a new + /// connection. + /// - `keyLogPath` (string, optional, default: nil): The path to the file where TLS session keys are logged in NSS + /// Key Log format. When omitted, keys are not logged. + /// - `qlog` (optional, default: nil): qlog output configuration. When present, its `path`, `topic`, and + /// `description` are required; when omitted, qlog output is disabled. + /// + /// - Throws: If `keyExchangeGroup` is specified with an invalid value, or when `qlog` is partially specified. + /// + /// - SeeAlso: ``NIOHTTPServerConfiguration/HTTP3/QUICConfiguration``. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) throws { + let keyExchangeGroup: KeyExchangeGroup + if config.string(forKey: "keyExchangeGroup") == nil { + keyExchangeGroup = .x25519 + } else { + // If a `keyExchangeGroup` value *is* specified, it must be a permitted value. We use `requiredString` so + // that an unrecognised value results in an error. + keyExchangeGroup = KeyExchangeGroup( + try config.requiredString(forKey: "keyExchangeGroup", as: KeyExchangeGroupKind.self) + ) + } + + self.init( + serverName: "", + keyExchangeGroup: keyExchangeGroup, + maxIdleTimeout: .seconds(config.int(forKey: "maxIdleTimeout", default: 30)), + initialMaxData: config.int(forKey: "initialMaxData", default: 1024 * 1024), + initialMaxStreamDataBidirectionalLocal: config.int( + forKey: "initialMaxStreamDataBidirectionalLocal", + default: 1024 * 1024 + ), + initialMaxStreamDataBidirectionalRemote: config.int( + forKey: "initialMaxStreamDataBidirectionalRemote", + default: 1024 * 1024 + ), + initialMaxStreamDataUnidirectional: config.int( + forKey: "initialMaxStreamDataUnidirectional", + default: 1024 * 1024 + ), + initialMaxStreamsBidirectional: config.int(forKey: "initialMaxStreamsBidirectional", default: 100), + initialMaxStreamsUnidirectional: config.int(forKey: "initialMaxStreamsUnidirectional", default: 100), + keepAliveInterval: config.int(forKey: "keepAliveInterval").map { .seconds($0) }, + sendRetry: config.bool(forKey: "sendRetry", default: false), + keyLogPath: config.string(forKey: "keyLogPath"), + qLogConfiguration: try QLogConfiguration(config: config.scoped(to: "qlog")) + ) + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP3.QUICConfiguration.QLogConfiguration { + /// Initialize an optional qlog configuration from a config reader. + /// + /// ## Configuration keys: + /// - `path` (string): The directory to write qlog files to. + /// - `topic` (string): The title to use when logging. + /// - `description` (string): The description to use when logging. + /// + /// - Note: If none of `path`, `topic`, or `description` are present, `nil` is returned. If *any* of them is + /// present, all three are required. + /// + /// - Throws: If some, but not all, of `path`, `topic`, and `description` are specified. + /// + /// - Parameter config: The configuration reader, scoped to the `qlog` key. + fileprivate init?(config: ConfigSnapshotReader) throws { + let path = config.string(forKey: "path") + let topic = config.string(forKey: "topic") + let description = config.string(forKey: "description") + + if path == nil, topic == nil, description == nil { + return nil + } + + self.init( + path: try config.requiredString(forKey: "path"), + topic: try config.requiredString(forKey: "topic"), + description: try config.requiredString(forKey: "description") + ) + } +} + +/// The permitted string values for the `keyExchangeGroup` configuration key. +private enum KeyExchangeGroupKind: String { + case secp256 + case secp384 + case x25519 + case x25519MLKEM768 +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP3.QUICConfiguration.KeyExchangeGroup { + fileprivate init(_ kind: KeyExchangeGroupKind) { + switch kind { + case .secp256: + self = .secp256 + case .secp384: + self = .secp384 + case .x25519: + self = .x25519 + case .x25519MLKEM768: + self = .x25519MLKEM768 + } + } +} + +@available(anyAppleOS 26.0, *) +extension NIOHTTPServerConfiguration.HTTP3.ConnectionSettings { + /// Initialize HTTP/3 connection settings from a config reader. + /// + /// ## Configuration keys: + /// - `qpackMaximumTableCapacity` (int, optional, default: 0): The maximum capacity of the QPACK dynamic table. + /// - `qpackBlockedStreams` (int, optional, default: 0): The maximum number of streams which may be blocked on QPACK + /// at any one time. + /// - `maximumFieldSectionSize` (int, optional, default: nil): The maximum size of a field section. When omitted, + /// there is no field section size limit. + /// + /// - Note: Negative `qpackMaximumTableCapacity` and `qpackBlockedStreams` values are clamped to 0. A negative + /// `maximumFieldSectionSize` value is resolved to `nil`. + /// + /// - SeeAlso: ``NIOHTTPServerConfiguration/HTTP3/ConnectionSettings``. + /// + /// - Parameter config: The configuration reader. + public init(config: ConfigSnapshotReader) { + self.init( + qpackMaximumTableCapacity: UInt64(clamping: config.int(forKey: "qpackMaximumTableCapacity", default: 0)), + qpackBlockedStreams: UInt64(clamping: config.int(forKey: "qpackBlockedStreams", default: 0)), + maximumFieldSectionSize: config.int(forKey: "maximumFieldSectionSize").flatMap { value in + if value < 0 { return nil } + return UInt64(value) + } + ) + } +} +#endif // HTTP3 && Configuration diff --git a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md index 77be261..d1d5e87 100644 --- a/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md +++ b/Sources/NIOHTTPServer/Documentation.docc/SwiftConfigurationIntegration.md @@ -35,37 +35,58 @@ its respective key prefix. > Important: Exactly one of `bindTarget` (singular, for a single address) or `bindTargets` (plural, for multiple > addresses) must be provided. Providing both results in an error. -> 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 when binding to a single address (mutually exclusive with `bindTargets`) | - | -| | `port` | `int` | Required when binding to a single address (mutually exclusive with `bindTargets`) | - | -| `bindTargets` | `hosts` | `string array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `ports` | - | -| | `ports` | `int array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `hosts` | - | -| `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 | 100 | -| `http.http2.gracefulShutdown` | `maximumDuration` | `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 | -| | `maxConnections` | `int` | Optional | nil | -| `connectionTimeouts` | `idle` | `int` | Optional | nil | -| | `readHeader` | `int` | Optional | nil | -| | `readBody` | `int` | Optional | nil | +> Important: HTTP/2 and HTTP/3 cannot be served over plaintext. If `"http2"` or `"http3"` is included in +> `http.versions`, the transport security must be set to `"tls"` or `"mTLS"`. Additionally, HTTP/3 requires PEM file +> credentials (`credentialSource: "file"` without a `refreshInterval`) with `transportSecurity.mode` set to `"tls"`. +> Inline (in-memory) credentials, reloading credentials, and mTLS are currently not supported over HTTP/3. Note that the +> `HTTP3` trait must be enabled for HTTP/3 configuration to be parsed. + +| Prefix | Configuration Key | Type | Required/Optional | Default | +|-------------------------------------|-------------------------------------------|-----------------|-------------------------------------------------------------------------------------------------------------------------------|----------------| +| `bindTarget` | `host` | `string` | Required when binding to a single address (mutually exclusive with `bindTargets`) | - | +| | `port` | `int` | Required when binding to a single address (mutually exclusive with `bindTargets`) | - | +| `bindTargets` | `hosts` | `string array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `ports` | - | +| | `ports` | `int array` | Required when binding to multiple addresses (mutually exclusive with `bindTarget`); must match length of `hosts` | - | +| `http` | `versions` | `string array` | Required (permitted values: `"http1_1"`, `"http2"`, `"http3"`) | - | +| `http.http2` | `maxFrameSize` | `int` (bytes) | Optional | 2^14 | +| | `targetWindowSize` | `int` (bytes) | Optional | 2^16-1 | +| | `maxConcurrentStreams` | `int` | Optional | 100 | +| `http.http2.gracefulShutdown` | `maximumDuration` | `int` (seconds) | Optional | nil | +| `http.http3` | `preferHuffmanEncoding` | `bool` | Optional | true | +| `http.http3.connectionSettings` | `qpackMaximumTableCapacity` | `int` | Optional | 0 | +| | `qpackBlockedStreams` | `int` | Optional | 0 | +| | `maximumFieldSectionSize` | `int` | Optional | nil (no limit) | +| `http.http3.quicConfiguration` | `keyExchangeGroup` | `string` | Optional (permitted values: `"secp256"`, `"secp384"`, `"x25519"`, `"x25519MLKEM768"`) | x25519 | +| | `maxIdleTimeout` | `int` (seconds) | Optional | 30 | +| | `initialMaxData` | `int` (bytes) | Optional | 2^20 (1 MiB) | +| | `initialMaxStreamDataBidirectionalLocal` | `int` (bytes) | Optional | 2^20 (1 MiB) | +| | `initialMaxStreamDataBidirectionalRemote` | `int` (bytes) | Optional | 2^20 (1 MiB) | +| | `initialMaxStreamDataUnidirectional` | `int` (bytes) | Optional | 2^20 (1 MiB) | +| | `initialMaxStreamsBidirectional` | `int` | Optional | 100 | +| | `initialMaxStreamsUnidirectional` | `int` | Optional | 100 | +| | `keepAliveInterval` | `int` (seconds) | Optional | nil (disabled) | +| | `sendRetry` | `bool` | Optional | false | +| | `keyLogPath` | `string` | Optional | nil | +| `http.http3.quicConfiguration.qlog` | `path` | `string` | Optional | nil | +| | `topic` | `string` | Optional | nil | +| | `description` | `string` | 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 | +| | `maxConnections` | `int` | Optional | nil | +| `connectionTimeouts` | `idle` | `int` | Optional | nil | +| | `readHeader` | `int` | Optional | nil | +| | `readBody` | `int` | Optional | nil | The `credentialSource` determines how server credentials are provided: diff --git a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift index 904f3b1..033c441 100644 --- a/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift +++ b/Tests/NIOHTTPServerTests/NIOHTTPServerSwiftConfigurationTests.swift @@ -285,6 +285,19 @@ struct NIOHTTPServerSwiftConfigurationTests { #expect(supportedVersions.contains(.http1_1)) #expect(supportedVersions.http2ConfigIfSupported == .defaults) } + + #if HTTP3 + @Test("Default HTTP/3 configuration used when not specified") + @available(anyAppleOS 26.0, *) + func defaultHTTP3ConfigurationUsed() throws { + let versions = ConfigValue(.stringArray(["http1_1", "http3"]), isSecret: false) + let snapshot = ConfigReader(provider: InMemoryProvider(values: ["versions": versions])).snapshot() + + let supportedVersions = try Set(config: snapshot) + #expect(supportedVersions.contains(.http1_1)) + #expect(supportedVersions.http3ConfigIfSupported == .defaults) + } + #endif } @Suite("HTTP2") @@ -340,6 +353,235 @@ struct NIOHTTPServerSwiftConfigurationTests { } } + #if HTTP3 + @Suite("HTTP3") + struct HTTP3Tests { + @Test("Default values") + @available(anyAppleOS 26.0, *) + func defaultValues() throws { + let snapshot = ConfigReader(provider: InMemoryProvider(values: [:])).snapshot() + + let http3 = try NIOHTTPServerConfiguration.HTTP3(config: snapshot) + + #expect(http3 == .defaults) + } + + @Test("Custom values") + @available(anyAppleOS 26.0, *) + func customValues() throws { + let provider = InMemoryProvider(values: [ + "preferHuffmanEncoding": false, + "connectionSettings.qpackMaximumTableCapacity": 4096, + "connectionSettings.qpackBlockedStreams": 16, + "connectionSettings.maximumFieldSectionSize": 8192, + "quicConfiguration.keyExchangeGroup": "secp384", + "quicConfiguration.maxIdleTimeout": 10, + "quicConfiguration.keepAliveInterval": 20, + "quicConfiguration.initialMaxData": 2048, + "quicConfiguration.initialMaxStreamDataBidirectionalLocal": 50, + "quicConfiguration.initialMaxStreamDataBidirectionalRemote": 75, + "quicConfiguration.initialMaxStreamDataUnidirectional": 125, + "quicConfiguration.initialMaxStreamsBidirectional": 150, + "quicConfiguration.initialMaxStreamsUnidirectional": 175, + "quicConfiguration.sendRetry": true, + "quicConfiguration.keyLogPath": "/tmp/keylog", + "quicConfiguration.qlog.path": "/tmp/qlog", + "quicConfiguration.qlog.topic": "topic", + "quicConfiguration.qlog.description": "description", + ]) + let snapshot = ConfigReader(provider: provider).snapshot() + + let http3 = try NIOHTTPServerConfiguration.HTTP3(config: snapshot) + + #expect(http3.preferHuffmanEncoding == false) + + let connectionSettings = http3.connectionSettings + #expect(connectionSettings.qpackMaximumTableCapacity == 4096) + #expect(connectionSettings.qpackBlockedStreams == 16) + #expect(connectionSettings.maximumFieldSectionSize == 8192) + + let quic = http3.quicConfiguration + #expect(quic.keyExchangeGroup == .secp384) + #expect(quic.maxIdleTimeout == .seconds(10)) + #expect(quic.keepAliveInterval == .seconds(20)) + #expect(quic.initialMaxData == 2048) + #expect(quic.initialMaxStreamDataBidirectionalLocal == 50) + #expect(quic.initialMaxStreamDataBidirectionalRemote == 75) + #expect(quic.initialMaxStreamDataUnidirectional == 125) + #expect(quic.initialMaxStreamsBidirectional == 150) + #expect(quic.initialMaxStreamsUnidirectional == 175) + #expect(quic.sendRetry == true) + #expect(quic.keyLogPath == "/tmp/keylog") + #expect(quic.qLogConfiguration == .init(path: "/tmp/qlog", topic: "topic", description: "description")) + } + + @Suite("ConnectionSettings") + struct ConnectionSettingsTests { + @Test("Default values") + @available(anyAppleOS 26.0, *) + func defaultValues() { + let snapshot = ConfigReader(provider: InMemoryProvider(values: [:])).snapshot() + + let settings = NIOHTTPServerConfiguration.HTTP3.ConnectionSettings(config: snapshot) + + #expect(settings == .defaults) + #expect(settings.qpackMaximumTableCapacity == 0) + #expect(settings.qpackBlockedStreams == 0) + #expect(settings.maximumFieldSectionSize == nil) + } + + @Test("Custom values") + @available(anyAppleOS 26.0, *) + func customValues() { + let snapshot = ConfigReader( + provider: InMemoryProvider(values: [ + "qpackMaximumTableCapacity": 1024, + "qpackBlockedStreams": 8, + "maximumFieldSectionSize": 4096, + ]) + ).snapshot() + + let settings = NIOHTTPServerConfiguration.HTTP3.ConnectionSettings(config: snapshot) + + #expect(settings.qpackMaximumTableCapacity == 1024) + #expect(settings.qpackBlockedStreams == 8) + #expect(settings.maximumFieldSectionSize == 4096) + } + + @Test("Negative values resolve to valid values") + @available(anyAppleOS 26.0, *) + func negativeClampsResolveToValidValues() { + let snapshot = ConfigReader( + provider: InMemoryProvider(values: [ + "qpackMaximumTableCapacity": -5, + "qpackBlockedStreams": -7, + "maximumFieldSectionSize": -1, + ]) + ).snapshot() + + let settings = NIOHTTPServerConfiguration.HTTP3.ConnectionSettings(config: snapshot) + + #expect(settings.qpackMaximumTableCapacity == 0) + #expect(settings.qpackBlockedStreams == 0) + #expect(settings.maximumFieldSectionSize == nil) + } + } + + @Suite("QUICConfiguration") + struct QUICConfigurationTests { + @Test("Default values") + @available(anyAppleOS 26.0, *) + func defaultValues() throws { + let snapshot = ConfigReader(provider: InMemoryProvider(values: [:])).snapshot() + + let quic = try NIOHTTPServerConfiguration.HTTP3.QUICConfiguration(config: snapshot) + + #expect(quic == .defaults) + } + + @Test("Durations are extracted as seconds") + @available(anyAppleOS 26.0, *) + func durationsExtractedAsSeconds() throws { + let snapshot = ConfigReader( + provider: InMemoryProvider(values: ["maxIdleTimeout": 45, "keepAliveInterval": 5]) + ).snapshot() + + let quic = try NIOHTTPServerConfiguration.HTTP3.QUICConfiguration(config: snapshot) + + #expect(quic.maxIdleTimeout == .seconds(45)) + #expect(quic.keepAliveInterval == .seconds(5)) + } + + @Test("Invalid key exchange group throws") + @available(anyAppleOS 26.0, *) + func invalidKeyExchangeGroup() throws { + let snapshot = ConfigReader(provider: InMemoryProvider(values: ["keyExchangeGroup": ""])) + .snapshot() + + let configError = try #require(throws: Error.self) { + try NIOHTTPServerConfiguration.HTTP3.QUICConfiguration(config: snapshot) + } + + #expect( + "Config value for key 'keyExchangeGroup' failed to cast to type KeyExchangeGroupKind." + == "\(configError)" + ) + } + } + + @Suite("QLogConfiguration") + struct QLogConfigurationTests { + @Test("No qlog configuration specified") + @available(anyAppleOS 26.0, *) + func valuesNotSpecified() throws { + let snapshot = ConfigReader(provider: InMemoryProvider(values: [:])).snapshot() + + let quic = try NIOHTTPServerConfiguration.HTTP3.QUICConfiguration(config: snapshot) + + #expect(quic.qLogConfiguration == nil) + } + + @Test("Fully specified qlog configuration") + @available(anyAppleOS 26.0, *) + func allValuesSpecified() throws { + let snapshot = ConfigReader( + provider: InMemoryProvider(values: [ + "qlog.path": "/var/log/qlog", + "qlog.topic": "server", + "qlog.description": "server qlog", + ]) + ).snapshot() + + let quic = try NIOHTTPServerConfiguration.HTTP3.QUICConfiguration(config: snapshot) + + #expect( + quic.qLogConfiguration == .init(path: "/var/log/qlog", topic: "server", description: "server qlog") + ) + } + + @Test("Partial qlog configuration is invalid") + @available(anyAppleOS 26.0, *) + func partialConfigurationInvalid() throws { + let snapshot = ConfigReader(provider: InMemoryProvider(values: ["qlog.path": "/var/log/qlog"])) + .snapshot() + + let configError = try #require(throws: Error.self) { + try NIOHTTPServerConfiguration.HTTP3.QUICConfiguration(config: snapshot) + } + + #expect("Missing required config value for key: qlog.topic." == "\(configError)") + } + } + + @Test("End-to-end HTTP/3 configuration over TLS") + @available(anyAppleOS 26.0, *) + func testEndToEnd() throws { + let provider = InMemoryProvider(values: [ + "bindTarget.host": "127.0.0.1", + "bindTarget.port": 8000, + "http.versions": .init(.stringArray(["http3"]), isSecret: false), + "http.http3.preferHuffmanEncoding": false, + "http.http3.connectionSettings.qpackBlockedStreams": 7, + "http.http3.quicConfiguration.maxIdleTimeout": 60, + "http.http3.quicConfiguration.sendRetry": true, + "transportSecurity.mode": "tls", + "transportSecurity.credentialSource": "file", + "transportSecurity.certificateChainPEMPath": .init(.string("cert.pem"), isSecret: false), + "transportSecurity.privateKeyPEMPath": .init(.string("key.pem"), isSecret: true), + ]) + let config = ConfigReader(provider: provider) + + let serverConfig = try NIOHTTPServerConfiguration(config: config) + + let http3 = try #require(serverConfig.supportedHTTPVersions.http3ConfigIfSupported) + #expect(http3.preferHuffmanEncoding == false) + #expect(http3.connectionSettings.qpackBlockedStreams == 7) + #expect(http3.quicConfiguration.maxIdleTimeout == .seconds(60)) + #expect(http3.quicConfiguration.sendRetry == true) + } + } + #endif // HTTP3 + @Suite("TransportSecurity") struct TransportSecurityTests { @Test("Invalid security mode")