Validate configuration at initialization - #107
Conversation
| @available(anyAppleOS 26.0, *) | ||
| @Test( | ||
| "All X.509 credential sources produce a valid configuration", | ||
| arguments: [TestX509CredentialSource]([.inMemory, .reloading, .pemFile, .derFile, .pemBytes, .derBytes]) |
There was a problem hiding this comment.
Nit, but for consistency with other tests/files/packages:
| arguments: [TestX509CredentialSource]([.inMemory, .reloading, .pemFile, .derFile, .pemBytes, .derBytes]) | |
| arguments: [TestX509CredentialSource.inMemory, .reloading, .pemFile, .derFile, .pemBytes, .derBytes] |
This has to change in a few places.
| func nonExistentX509FilePathRejected( | ||
| credentials: NIOHTTPServerConfiguration.TransportSecurity.X509Credentials | ||
| ) throws { | ||
| #expect(throws: Error.self) { |
There was a problem hiding this comment.
We can't assert a more specific error type or message?
There was a problem hiding this comment.
Different errors are returned in both cases by swift-nio-ssl. The PEM case throws a NIOSSLError.failedToLoadCertificate error and the DER case throws a NIOCore.IOError.
I felt that explicitly catching these errors clutters the test case without adding much benefit.
There was a problem hiding this comment.
I only worry that expecting any error may mean that even though this behaviour changes in the future (e.g. a new error we should be handling different starts being thrown), the test will still pass.
| } | ||
|
|
||
| @available(anyAppleOS 26.0, *) | ||
| enum TestX509CredentialSource: Sendable { |
There was a problem hiding this comment.
We don't have a type like this in the actual configuration types? Feels a bit weird to have this parallel type in the tests instead of an extension on that type similarly to what we're doing below with NIOHTTPServerConfiguration.TransportSecurity.RawPublicKeyCredentials.
There was a problem hiding this comment.
The actual configuration types don't allow us to express the cases (e.g. inMemory, reloading, etc.) without providing associated values.
These parallel types allow us to parameterise over all cases quite easily. There is only one supported format for RPK credentials (DER files), which is why I didn't bother for that case.
| #endif // HTTP3 | ||
|
|
||
| @available(anyAppleOS 26.0, *) | ||
| enum MTLSTrustSource: Sendable { |
| extension NIOHTTPServerConfiguration.HTTPVersion { | ||
| static let http2 = Self.http2(config: .defaults) | ||
|
|
||
| #if HTTP3 | ||
| static let http3 = Self.http3(config: .defaults) | ||
| #endif | ||
| } |
There was a problem hiding this comment.
Perhaps we should default config to .defaults in the init instead of having this
|
|
||
| /// Network binding configuration specifying all addresses where the server should listen. | ||
| public var bindTargets: [BindTarget] | ||
| public let bindTargets: [BindTarget] |
There was a problem hiding this comment.
We generally keep properties in config structs as vars so they can be modified. This is common across all of our packages AFAIK. Did you make this change for a specific reason?
There was a problem hiding this comment.
Yes. If any of these three properties (bindTargets, transportSecurity, supportedHTTPVersions) are modified after initialization, then the two new contexts we store may become invalid.
There was a problem hiding this comment.
We can add a willSet to these properties and do the validation/update of the contexts there.
There was a problem hiding this comment.
We discussed offline and because we can't have throwing setters we will probably just precondition failure.
Thinking more about it though, because this wouldn't fail on release builds, I think it's better to avoid having the ValidatedX types and instead just do validations in the init/setters, without encapsulating in separate types.
There was a problem hiding this comment.
I've now updated the PR with this approach.
| /// | ||
| /// - Parameter config: The configuration to use for HTTP/2. | ||
| public static func http2(config: HTTP2) -> Self { | ||
| public static func http2(config: HTTP2 = .defaults) -> Self { |
There was a problem hiding this comment.
Nit, but I think additionally having a static var for h2 and h3 that defaults the config to .defaults may make the API slightly nicer since we can avoid the empty brackets (.http2 instead of .http2())
|
This is interesting. A potential side observation: This seems to boil down to differences in support transport security for H1/2/3. It would be great if this is a compile time guarantee rather than a runtime enforced. Have we considered making the transport security structs HTTP version specific? |
Yes. Although it is worth nothing that most of the differences today just arise from a mismatch in the credential sources supported by Once there is parity, the only difference that remains is It feels odd to have a |
| /// - `transportSecurity` can only be set to `.plaintext` when `supportedHTTPVersions == [.http1_1]`. | ||
| public var transportSecurity: TransportSecurity { | ||
| didSet { | ||
| try! self.validateTransportConfiguration() |
There was a problem hiding this comment.
I don't think we should try! here. I think it's okay to precondition failure because we won't crash at runtime on release builds, but with this we could. We should throw in throwing contexts and precondition failure where we can't throw.
There was a problem hiding this comment.
Note that preconditionFailure will also crash in release builds (assuming standard release build configs). It will only not crash when the package is built with -Ounchecked, which is a non-standard release build config: https://developer.apple.com/documentation/swift/preconditionfailure(_:file:line:)#discussion.
But I've removed the try! and replaced it with preconditionFailure for consistency in commit 5c29ae1.
I understand where the current differences come from. Just to explain this a bit more. What if a user wants to bootstrap a server that serves H/1 over plaintext and H2 over TLS? This is currently not configurable right? This might be fine just trying to provide another argument why separate H1/2/3/ properties might be beneficial. I agree though that they should use the same type. |
Motivation
The required TLS contexts/configurations required to create the server channels are only constructed when
NIOHTTPServer.serveis invoked. The problem with this is that these constructions can throw an error if the provided configuration was invalid.We should not allow an invalid
NIOHTTPServerConfigurationinstance to be created in the first place: validation should occur during the initialization ofNIOHTTPServerConfiguration.Modifications
NIOHTTPServerConfiguration's initializer now validates thatsupportedHTTPVersionsandtransportSecurityare compatible with each other, and stores the necessary TLS objects.NIOHTTPServer.serve .servecan therefore use these properties directly and not invoke anythrowing method relating to configuration.Result
Validation of
NIOHTTPServerConfigurationnow happens at initialization rather than duringNIOHTTPServer.serve.