Skip to content

Commit

Permalink
Allow HTTPServer's configuration to be dynamically updatable (#3132)
Browse files Browse the repository at this point in the history
* Updated HTTPServer's configuration to be dynamically updatable

Fixes #3130

* Added a check for a specific thrown error in updating-config tests

* Updated HTTPServer comments to be uniform
  • Loading branch information
dimitribouniol committed Feb 20, 2024
1 parent 9da9d14 commit 3a7da19
Show file tree
Hide file tree
Showing 3 changed files with 160 additions and 33 deletions.
18 changes: 16 additions & 2 deletions Sources/Vapor/HTTP/Server/Application+HTTP+Server.swift
Expand Up @@ -33,15 +33,29 @@ extension Application.HTTP {
typealias Value = HTTPServer
}

/// The configuration for the HTTP server.
///
/// Although the configuration can be changed after the server has started, a warning will be logged
/// and the configuration will be discarded if an option will no longer be considered.
///
/// These include the following properties, which are only read once when the server starts:
/// - ``HTTPServer/Configuration-swift.struct/address``
/// - ``HTTPServer/Configuration-swift.struct/hostname``
/// - ``HTTPServer/Configuration-swift.struct/port``
/// - ``HTTPServer/Configuration-swift.struct/backlog``
/// - ``HTTPServer/Configuration-swift.struct/reuseAddress``
/// - ``HTTPServer/Configuration-swift.struct/tcpNoDelay``
public var configuration: HTTPServer.Configuration {
get {
self.application.storage[ConfigurationKey.self] ?? .init(
logger: self.application.logger
)
}
nonmutating set {
if self.application.storage.contains(Key.self) {
self.application.logger.warning("Cannot modify server configuration after server has been used.")
/// If a server is available, configure it directly, otherwise cache a configuration instance
/// here to be used until the server is instantiated.
if let server = self.application.storage[Key.self] {
server.configuration = newValue
} else {
self.application.storage[ConfigurationKey.self] = newValue
}
Expand Down
89 changes: 59 additions & 30 deletions Sources/Vapor/HTTP/Server/HTTPServer.swift
Expand Up @@ -237,11 +237,32 @@ public final class HTTPServer: Server, Sendable {
return connection.channel.closeFuture
}

/// The configuration for the HTTP server.
///
/// Many properties of the configuration may be changed both before and after the server has been started.
///
/// However, a warning will be logged and the configuration will be discarded if an option could not be
/// changed after the server has started. These include the following properties, which are only read
/// once when the server starts:
/// - ``Configuration-swift.struct/address``
/// - ``Configuration-swift.struct/hostname``
/// - ``Configuration-swift.struct/port``
/// - ``Configuration-swift.struct/backlog``
/// - ``Configuration-swift.struct/reuseAddress``
/// - ``Configuration-swift.struct/tcpNoDelay``
public var configuration: Configuration {
get { _configuration.withLockedValue { $0 } }
set {
guard !didStart.withLockedValue({ $0 }) else {
_configuration.withLockedValue({ $0 }).logger.warning("Cannot modify server configuration after server has been started.")
let oldValue = _configuration.withLockedValue { $0 }

let canBeUpdatedDynamically =
oldValue.address == newValue.address
&& oldValue.backlog == newValue.backlog
&& oldValue.reuseAddress == newValue.reuseAddress
&& oldValue.tcpNoDelay == newValue.tcpNoDelay

guard canBeUpdatedDynamically || !didStart.withLockedValue({ $0 }) else {
oldValue.logger.warning("Cannot modify server configuration after server has been started.")
return
}
self.application.storage[Application.HTTP.Server.ConfigurationKey.self] = newValue
Expand Down Expand Up @@ -276,15 +297,18 @@ public final class HTTPServer: Server, Sendable {
var configuration = self.configuration

switch address {
case .none: // use the configuration as is
case .none:
/// Use the configuration as is.
break
case .hostname(let hostname, let port): // override the hostname, port, neither, or both
case .hostname(let hostname, let port):
/// Override the hostname, port, neither, or both.
configuration.address = .hostname(hostname ?? configuration.hostname, port: port ?? configuration.port)
case .unixDomainSocket: // override the socket path
case .unixDomainSocket:
/// Override the socket path.
configuration.address = address!
}

// print starting message
/// Print starting message.
let scheme = configuration.tlsConfiguration == nil ? "http" : "https"
let addressDescription: String
switch configuration.address {
Expand All @@ -296,10 +320,11 @@ public final class HTTPServer: Server, Sendable {

self.configuration.logger.notice("Server starting on \(addressDescription)")

// start the actual HTTPServer
/// Start the actual `HTTPServer`.
try self.connection.withLockedValue {
$0 = try HTTPServerConnection.start(
application: self.application,
server: self,
responder: self.responder,
configuration: configuration,
on: self.eventLoopGroup
Expand Down Expand Up @@ -341,26 +366,30 @@ private final class HTTPServerConnection: Sendable {

static func start(
application: Application,
server: HTTPServer,
responder: Responder,
configuration: HTTPServer.Configuration,
on eventLoopGroup: EventLoopGroup
) -> EventLoopFuture<HTTPServerConnection> {
let quiesce = ServerQuiescingHelper(group: eventLoopGroup)
let bootstrap = ServerBootstrap(group: eventLoopGroup)
// Specify backlog and enable SO_REUSEADDR for the server itself
/// Specify backlog and enable `SO_REUSEADDR` for the server itself.
.serverChannelOption(ChannelOptions.backlog, value: Int32(configuration.backlog))
.serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: configuration.reuseAddress ? SocketOptionValue(1) : SocketOptionValue(0))

// Set handlers that are applied to the Server's channel
/// Set handlers that are applied to the Server's channel.
.serverChannelInitializer { channel in
channel.pipeline.addHandler(quiesce.makeServerChannelHandler(channel: channel))
}

// Set the handlers that are applied to the accepted Channels
.childChannelInitializer { [unowned application] channel in
// add TLS handlers if configured
/// Set the handlers that are applied to the accepted Channels.
.childChannelInitializer { [unowned application, unowned server] channel in
/// Copy the most up-to-date configuration.
let configuration = server.configuration

/// Add TLS handlers if configured.
if var tlsConfiguration = configuration.tlsConfiguration {
// prioritize http/2
/// Prioritize http/2 if supported.
if configuration.supportVersions.contains(.two) {
tlsConfiguration.applicationProtocols.append("h2")
}
Expand Down Expand Up @@ -408,7 +437,7 @@ private final class HTTPServerConnection: Sendable {
}
}

// Enable TCP_NODELAY and SO_REUSEADDR for the accepted Channels
/// Enable `TCP_NODELAY` and `SO_REUSEADDR` for the accepted Channels.
.childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: configuration.tcpNoDelay ? SocketOptionValue(1) : SocketOptionValue(0))
.childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: configuration.reuseAddress ? SocketOptionValue(1) : SocketOptionValue(0))
.childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1)
Expand Down Expand Up @@ -468,31 +497,31 @@ extension ChannelPipeline {
responder: Responder,
configuration: HTTPServer.Configuration
) -> EventLoopFuture<Void> {
// create server pipeline array
/// Create server pipeline array.
var handlers: [ChannelHandler] = []

let http2 = HTTP2FramePayloadToHTTP1ServerCodec()
handlers.append(http2)

// add NIO -> HTTP request decoder
/// Add NIO HTTP request decoder.
let serverReqDecoder = HTTPServerRequestDecoder(
application: application
)
handlers.append(serverReqDecoder)

// add NIO -> HTTP response encoder
/// Add NIO HTTP response encoder.
let serverResEncoder = HTTPServerResponseEncoder(
serverHeader: configuration.serverName,
dateCache: .eventLoop(self.eventLoop)
)
handlers.append(serverResEncoder)

// add server request -> response delegate
/// Add server request response delegate.
let handler = HTTPServerHandler(responder: responder, logger: application.logger)
handlers.append(handler)

return self.addHandlers(handlers).flatMap {
// close the connection in case of any errors
/// Close the connection in case of any errors.
self.addHandler(NIOCloseOnErrorHandler())
}
}
Expand All @@ -502,24 +531,24 @@ extension ChannelPipeline {
responder: Responder,
configuration: HTTPServer.Configuration
) -> EventLoopFuture<Void> {
// create server pipeline array
/// Create server pipeline array.
var handlers: [RemovableChannelHandler] = []

// configure HTTP/1
// add http parsing and serializing
/// Configure HTTP/1:
/// Add http parsing and serializing.
let httpResEncoder = HTTPResponseEncoder()
let httpReqDecoder = ByteToMessageHandler(HTTPRequestDecoder(
leftOverBytesStrategy: .forwardBytes
))
handlers += [httpResEncoder, httpReqDecoder]

// add pipelining support if configured
/// Add pipelining support if configured.
if configuration.supportPipelining {
let pipelineHandler = HTTPServerPipelineHandler()
handlers.append(pipelineHandler)
}

// add response compressor if configured
/// Add response compressor if configured.
switch configuration.responseCompression.storage {
case .enabled(let initialByteBufferCapacity):
let responseCompressionHandler = HTTPResponseCompressor(
Expand All @@ -530,7 +559,7 @@ extension ChannelPipeline {
break
}

// add request decompressor if configured
/// Add request decompressor if configured.
switch configuration.requestDecompression.storage {
case .enabled(let limit):
let requestDecompressionHandler = NIOHTTPRequestDecompressor(
Expand All @@ -541,22 +570,22 @@ extension ChannelPipeline {
break
}

// add NIO -> HTTP response encoder
/// Add NIO HTTP response encoder.
let serverResEncoder = HTTPServerResponseEncoder(
serverHeader: configuration.serverName,
dateCache: .eventLoop(self.eventLoop)
)
handlers.append(serverResEncoder)

// add NIO -> HTTP request decoder
/// Add NIO HTTP request decoder.
let serverReqDecoder = HTTPServerRequestDecoder(
application: application
)
handlers.append(serverReqDecoder)
// add server request -> response delegate
/// Add server request response delegate.
let handler = HTTPServerHandler(responder: responder, logger: application.logger)

// add HTTP upgrade handler
/// Add HTTP upgrade handler.
let upgrader = HTTPServerUpgradeHandler(
httpRequestDecoder: httpReqDecoder,
httpHandlers: handlers + [handler]
Expand All @@ -566,7 +595,7 @@ extension ChannelPipeline {
handlers.append(handler)

return self.addHandlers(handlers).flatMap {
// close the connection in case of any errors
/// Close the connection in case of any errors.
self.addHandler(NIOCloseOnErrorHandler())
}
}
Expand Down
86 changes: 85 additions & 1 deletion Tests/VaporTests/ServerTests.swift
Expand Up @@ -931,7 +931,6 @@ final class ServerTests: XCTestCase {
// This lies and accepts the above cert, which has actually expired.
XCTAssertEqual(peerCerts, [cert])
successPromise.succeed(.certificateVerified)

}

// We need to disable verification on the client, because the cert we're using has expired, and we want to
Expand Down Expand Up @@ -967,6 +966,91 @@ final class ServerTests: XCTestCase {
XCTAssertEqual(a.body, ByteBuffer(string: "world"))
}

func testCanChangeConfigurationDynamically() throws {
guard let clientCertPath = Bundle.module.url(forResource: "expired", withExtension: "crt"),
let clientKeyPath = Bundle.module.url(forResource: "expired", withExtension: "key") else {
XCTFail("Cannot load expired cert and associated key")
return
}

let cert = try NIOSSLCertificate(file: clientCertPath.path, format: .pem)
let key = try NIOSSLPrivateKey(file: clientKeyPath.path, format: .pem)

let app = Application(.testing)

app.http.server.configuration.hostname = "127.0.0.1"
app.http.server.configuration.port = 0
app.http.server.configuration.serverName = "Old"

/// We need to disable verification on the client, because the cert we're using has expired
var clientConfig = TLSConfiguration.makeClientConfiguration()
clientConfig.certificateVerification = .none
clientConfig.certificateChain = [.certificate(cert)]
clientConfig.privateKey = .privateKey(key)
app.http.client.configuration.tlsConfiguration = clientConfig
app.http.client.configuration.maximumUsesPerConnection = 1

app.environment.arguments = ["serve"]

app.get("hello") { req in
"world"
}

defer { app.shutdown() }
try app.start()

XCTAssertNotNil(app.http.server.shared.localAddress)
guard let localAddress = app.http.server.shared.localAddress,
let ip = localAddress.ipAddress,
let port = localAddress.port else {
XCTFail("couldn't get ip/port from \(app.http.server.shared.localAddress.debugDescription)")
return
}

/// Make a regular request
let a = try app.http.client.shared.execute(
request: try HTTPClient.Request(
url: "http://\(ip):\(port)/hello",
method: .GET
)
).wait()
XCTAssertEqual(a.headers[.server], ["Old"])
XCTAssertEqual(a.body, ByteBuffer(string: "world"))

/// Configure server name without stopping the server
app.http.server.configuration.serverName = "New"
/// Configure TLS without stopping the server
var serverConfig = TLSConfiguration.makeServerConfiguration(certificateChain: [.certificate(cert)], privateKey: .privateKey(key))
serverConfig.certificateVerification = .noHostnameVerification

app.http.server.configuration.tlsConfiguration = serverConfig
app.http.server.configuration.customCertificateVerifyCallback = { peerCerts, successPromise in
/// This lies and accepts the above cert, which has actually expired.
XCTAssertEqual(peerCerts, [cert])
successPromise.succeed(.certificateVerified)
}

/// Make a TLS request this time around
let b = try app.http.client.shared.execute(
request: try HTTPClient.Request(
url: "https://\(ip):\(port)/hello",
method: .GET
)
).wait()
XCTAssertEqual(b.headers[.server], ["New"])
XCTAssertEqual(b.body, ByteBuffer(string: "world"))

/// Non-TLS request should now fail
XCTAssertThrowsError(try app.http.client.shared.execute(
request: try HTTPClient.Request(
url: "http://\(ip):\(port)/hello",
method: .GET
)
).wait()) { error in
XCTAssertEqual(error as? HTTPClientError, HTTPClientError.remoteConnectionClosed)
}
}

override class func setUp() {
XCTAssertTrue(isLoggingConfigured)
}
Expand Down

0 comments on commit 3a7da19

Please sign in to comment.