From ba39656bd096ef83d36d46c7e1dc8e7e01a63bac Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 13 Sep 2021 23:50:31 -0700 Subject: [PATCH] Adds support for TLS & HTTP/2 (#69) By default, the server runs over HTTP/1.1. To enable running HTTP/1.1 over TLS, use useHTTPS. To enable HTTP/2 upgrades (will prefer HTTP/2 but still accept HTTP/1.1 over TLS), use useHTTP2. Note that the HTTP/2 protocol is only supported over TLS, so implies using it. Thus, there's no need to call both useHTTPS and useHTTP2; useHTTP2 sets up both TLS and HTTP/2 support. --- Docs/1_Configuration.md | 26 ++++++ Package.swift | 4 + .../Application+Configuration.swift | 56 +++++++++++++ .../Application/Application+Launch.swift | 2 +- .../Application/Application+Services.swift | 1 + Sources/Alchemy/Application/Application.swift | 2 +- Sources/Alchemy/Commands/HTTPHandler.swift | 34 ++++---- Sources/Alchemy/Commands/ServeCommand.swift | 83 +++++++++++++++---- Sources/Alchemy/HTTP/Response.swift | 11 +-- 9 files changed, 175 insertions(+), 44 deletions(-) create mode 100644 Sources/Alchemy/Application/Application+Configuration.swift diff --git a/Docs/1_Configuration.md b/Docs/1_Configuration.md index 46bbad2f..99c6a856 100644 --- a/Docs/1_Configuration.md +++ b/Docs/1_Configuration.md @@ -119,6 +119,32 @@ You can load your environment from another location by passing your app the `--e If you have separate environment variables for different server configurations (i.e. local dev, staging, production), you can pass your program a separate `--env` for each configuration so the right environment is loaded. +## Configuring Your Server + +There are a couple of options available for configuring how your server is running. By default, the server runs over `HTTP/1.1`. + +### Enable TLS + +You can enable running over TLS with `useHTTPS`. + +```swift +func boot() throws { + try useHTTPS(key: "/path/to/private-key.pem", cert: "/path/to/cert.pem") +} +``` + +### Enable HTTP/2 + +You may also configure your server with `HTTP/2` upgrades (will prefer `HTTP/2` but still accept `HTTP/1.1` over TLS). To do this use `useHTTP2`. + +```swift +func boot() throws { + try useHTTP2(key: "/path/to/private-key.pem", cert: "/path/to/cert.pem") +} +``` + +Note that the `HTTP/2` protocol is only supported over TLS, and so implies using it. Thus, there's no need to call both `useHTTPS` and `useHTTP2`; `useHTTP2` sets up both TLS and `HTTP/2` support. + ## Working with Xcode You can use Xcode to run your project to take advantage of all the great tools built into it; debugging, breakpoints, memory graphs, testing, etc. diff --git a/Package.swift b/Package.swift index f4322124..94552e9b 100644 --- a/Package.swift +++ b/Package.swift @@ -13,6 +13,8 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.6.0"), + .package(url: "https://github.com/apple/swift-nio-http2.git", from: "1.9.0"), .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMinor(from: "0.3.0")), .package(url: "https://github.com/vapor/postgres-nio.git", from: "1.1.0"), .package(url: "https://github.com/vapor/mysql-nio.git", from: "1.3.0"), @@ -40,6 +42,8 @@ let package = Package( .product(name: "MySQLNIO", package: "mysql-nio"), .product(name: "NIO", package: "swift-nio"), .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOHTTP2", package: "swift-nio-http2"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), .product(name: "Logging", package: "swift-log"), .product(name: "Plot", package: "Plot"), .product(name: "LifecycleNIOCompat", package: "swift-service-lifecycle"), diff --git a/Sources/Alchemy/Application/Application+Configuration.swift b/Sources/Alchemy/Application/Application+Configuration.swift new file mode 100644 index 00000000..a57cc4ca --- /dev/null +++ b/Sources/Alchemy/Application/Application+Configuration.swift @@ -0,0 +1,56 @@ +import NIOSSL + +/// Settings for how this server should talk to clients. +public final class ApplicationConfiguration: Service { + /// Any TLS configuration for serving over HTTPS. + public var tlsConfig: TLSConfiguration? + /// The HTTP protocol versions supported. Defaults to `HTTP/1.1`. + public var httpVersions: [HTTPVersion] = [.http1_1] +} + +extension Application { + /// Use HTTPS when serving. + /// + /// - Parameters: + /// - key: The path to the private key. + /// - cert: The path of the cert. + /// - Throws: Any errors encountered when accessing the certs. + public func useHTTPS(key: String, cert: String) throws { + let config = Container.resolve(ApplicationConfiguration.self) + config.tlsConfig = TLSConfiguration + .makeServerConfiguration( + certificateChain: try NIOSSLCertificate + .fromPEMFile(cert) + .map { NIOSSLCertificateSource.certificate($0) }, + privateKey: .file(key)) + } + + /// Use HTTPS when serving. + /// + /// - Parameter tlsConfig: A raw NIO `TLSConfiguration` to use. + public func useHTTPS(tlsConfig: TLSConfiguration) { + let config = Container.resolve(ApplicationConfiguration.self) + config.tlsConfig = tlsConfig + } + + /// Use HTTP/2 when serving, over TLS with the given key and cert. + /// + /// - Parameters: + /// - key: The path to the private key. + /// - cert: The path of the cert. + /// - Throws: Any errors encountered when accessing the certs. + public func useHTTP2(key: String, cert: String) throws { + let config = Container.resolve(ApplicationConfiguration.self) + config.httpVersions = [.http2, .http1_1] + try useHTTPS(key: key, cert: cert) + } + + /// Use HTTP/2 when serving, over TLS with the given tls config. + /// + /// - Parameter tlsConfig: A raw NIO `TLSConfiguration` to use. + public func useHTTP2(tlsConfig: TLSConfiguration) { + let config = Container.resolve(ApplicationConfiguration.self) + config.httpVersions = [.http2, .http1_1] + useHTTPS(tlsConfig: tlsConfig) + } +} diff --git a/Sources/Alchemy/Application/Application+Launch.swift b/Sources/Alchemy/Application/Application+Launch.swift index ddf52c3c..5a07dfa6 100644 --- a/Sources/Alchemy/Application/Application+Launch.swift +++ b/Sources/Alchemy/Application/Application+Launch.swift @@ -39,7 +39,7 @@ extension Application { bootServices() // Boot the app - boot() + try boot() // Register the runner runner.register(lifecycle: lifecycle) diff --git a/Sources/Alchemy/Application/Application+Services.swift b/Sources/Alchemy/Application/Application+Services.swift index 0ea7405e..b7372d5e 100644 --- a/Sources/Alchemy/Application/Application+Services.swift +++ b/Sources/Alchemy/Application/Application+Services.swift @@ -7,6 +7,7 @@ extension Application { Loop.config() // Register all services + ApplicationConfiguration.config(default: ApplicationConfiguration()) Router.config(default: Router()) Scheduler.config(default: Scheduler()) NIOThreadPool.config(default: NIOThreadPool(numberOfThreads: System.coreCount)) diff --git a/Sources/Alchemy/Application/Application.swift b/Sources/Alchemy/Application/Application.swift index cd3cb5b8..26eb0a95 100644 --- a/Sources/Alchemy/Application/Application.swift +++ b/Sources/Alchemy/Application/Application.swift @@ -18,7 +18,7 @@ public protocol Application { /// environment is loaded and the global `EventLoopGroup` is /// set. Called on an event loop, so `Loop.current` is /// available for use if needed. - func boot() + func boot() throws /// Required empty initializer. init() diff --git a/Sources/Alchemy/Commands/HTTPHandler.swift b/Sources/Alchemy/Commands/HTTPHandler.swift index b1276c4e..0c8f960a 100644 --- a/Sources/Alchemy/Commands/HTTPHandler.swift +++ b/Sources/Alchemy/Commands/HTTPHandler.swift @@ -19,7 +19,7 @@ final class HTTPHandler: ChannelInboundHandler { // Indicates that the TCP connection needs to be closed after a // response has been sent. - private var closeAfterResponse = true + private var keepAlive = true /// A temporary local Request that is used to accumulate data /// into. @@ -48,7 +48,7 @@ final class HTTPHandler: ChannelInboundHandler { switch part { case .head(let requestHead): // If the part is a `head`, a new Request is received - self.closeAfterResponse = !requestHead.isKeepAlive + keepAlive = requestHead.isKeepAlive let contentLength: Int @@ -86,7 +86,7 @@ final class HTTPHandler: ChannelInboundHandler { self.request = nil // Writes the response when done - self.writeResponse(response, to: context) + self.writeResponse(version: request.head.version, response: response, to: context) } } @@ -94,16 +94,17 @@ final class HTTPHandler: ChannelInboundHandler { /// `ChannelHandlerContext`. /// /// - Parameters: + /// - version: The HTTP version of the connection. /// - response: The reponse to write to the handler context. /// - context: The context to write to. /// - Returns: An future that completes when the response is /// written. @discardableResult - private func writeResponse(_ responseFuture: EventLoopFuture, to context: ChannelHandlerContext) -> EventLoopFuture { - return responseFuture.flatMap { response in - let responseWriter = HTTPResponseWriter(handler: self, context: context) + private func writeResponse(version: HTTPVersion, response: EventLoopFuture, to context: ChannelHandlerContext) -> EventLoopFuture { + return response.flatMap { response in + let responseWriter = HTTPResponseWriter(version: version, handler: self, context: context) responseWriter.completionPromise.futureResult.whenComplete { _ in - if self.closeAfterResponse { + if !self.keepAlive { context.close(promise: nil) } } @@ -124,11 +125,11 @@ final class HTTPHandler: ChannelInboundHandler { /// Used for writing a response to a remote peer with an /// `HTTPHandler`. private struct HTTPResponseWriter: ResponseWriter { - /// The HTTP version we're working with. - static private var httpVersion: HTTPVersion { HTTPVersion(major: 1, minor: 1) } - /// A promise to hook into for when the writing is finished. let completionPromise: EventLoopPromise + + /// The HTTP version we're working with. + private var version: HTTPVersion /// The handler in which this writer is writing. private let handler: HTTPHandler @@ -138,10 +139,12 @@ private struct HTTPResponseWriter: ResponseWriter { /// Initialize /// - Parameters: + /// - version: The HTTPVersion of this connection. /// - handler: The handler in which this response is writing /// inside. /// - context: The context to write responses to. - init(handler: HTTPHandler, context: ChannelHandlerContext) { + init(version: HTTPVersion, handler: HTTPHandler, context: ChannelHandlerContext) { + self.version = version self.handler = handler self.context = context self.completionPromise = context.eventLoop.makePromise() @@ -150,20 +153,15 @@ private struct HTTPResponseWriter: ResponseWriter { // MARK: ResponseWriter func writeHead(status: HTTPResponseStatus, _ headers: HTTPHeaders) { - let version = HTTPResponseWriter.httpVersion let head = HTTPResponseHead(version: version, status: status, headers: headers) context.write(handler.wrapOutboundOut(.head(head)), promise: nil) } func writeBody(_ body: ByteBuffer) { - context.writeAndFlush( - handler.wrapOutboundOut(.body(IOData.byteBuffer(body))), - promise: nil - ) + context.writeAndFlush(handler.wrapOutboundOut(.body(IOData.byteBuffer(body))), promise: nil) } func writeEnd() { - context.writeAndFlush(handler.wrapOutboundOut(.end(nil)), promise: nil) - completionPromise.succeed(()) + context.writeAndFlush(handler.wrapOutboundOut(.end(nil)), promise: completionPromise) } } diff --git a/Sources/Alchemy/Commands/ServeCommand.swift b/Sources/Alchemy/Commands/ServeCommand.swift index 15fe40d6..daeea4a1 100644 --- a/Sources/Alchemy/Commands/ServeCommand.swift +++ b/Sources/Alchemy/Commands/ServeCommand.swift @@ -1,5 +1,8 @@ import ArgumentParser import NIO +import NIOSSL +import NIOHTTP1 +import NIOHTTP2 /// Command to serve on launched. This is a subcommand of `Launch`. /// The app will route with the singleton `HTTPRouter`. @@ -68,7 +71,7 @@ extension ServeCommand: Runner { lifecycle.register( label: "Serve", - start: .eventLoopFuture { self.start().map { channel = $0 } }, + start: .eventLoopFuture { start().map { channel = $0 } }, shutdown: .eventLoopFuture { channel?.close() ?? .new() } ) @@ -80,25 +83,17 @@ extension ServeCommand: Runner { } private func start() -> EventLoopFuture { - // Much of this is courtesy of [apple/swift-nio-examples]( - // https://github.com/apple/swift-nio-examples/tree/main/http2-server/Sources/http2-server) - func childChannelInitializer(channel: Channel) -> EventLoopFuture { + func childChannelInitializer(_ channel: Channel) -> EventLoopFuture { channel.pipeline - .configureHTTPServerPipeline(withErrorHandling: true) - .flatMap { channel.pipeline.addHandler(HTTPHandler(router: Router.default)) } + .addAnyTLS() + .flatMap { channel.addHTTP() } } - + let serverBootstrap = ServerBootstrap(group: Loop.group) - // Specify backlog and enable SO_REUSEADDR for the server - // itself .serverChannelOption(ChannelOptions.backlog, value: 256) .serverChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) - - // Set the handlers that are applied to the accepted - // `Channel`s - .childChannelInitializer(childChannelInitializer(channel:)) - - // Enable SO_REUSEADDR for the accepted `Channel`s + .childChannelInitializer(childChannelInitializer) + .childChannelOption(ChannelOptions.socket(IPPROTO_TCP, TCP_NODELAY), value: 1) .childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) .childChannelOption(ChannelOptions.maxMessagesPerRead, value: 1) @@ -140,3 +135,61 @@ extension SocketAddress { } } } + +extension ChannelPipeline { + /// Configures this pipeline with any TLS config in the + /// `ApplicationConfiguration`. + /// + /// - Returns: A future that completes when the config completes. + fileprivate func addAnyTLS() -> EventLoopFuture { + let config = Container.resolve(ApplicationConfiguration.self) + if var tls = config.tlsConfig { + if config.httpVersions.contains(.http2) { + tls.applicationProtocols.append("h2") + } + if config.httpVersions.contains(.http1_1) { + tls.applicationProtocols.append("http/1.1") + } + let sslContext = try! NIOSSLContext(configuration: tls) + let sslHandler = NIOSSLServerHandler(context: sslContext) + return addHandler(sslHandler) + } else { + return .new() + } + } +} + +extension Channel { + /// Configures this channel to handle whatever HTTP versions the + /// server should be speaking over. + /// + /// - Returns: A future that completes when the config completes. + fileprivate func addHTTP() -> EventLoopFuture { + let config = Container.resolve(ApplicationConfiguration.self) + if config.httpVersions.contains(.http2) { + return configureHTTP2SecureUpgrade( + h2ChannelConfigurator: { h2Channel in + h2Channel.configureHTTP2Pipeline( + mode: .server, + inboundStreamInitializer: { channel in + channel.pipeline + .addHandlers([ + HTTP2FramePayloadToHTTP1ServerCodec(), + HTTPHandler(router: Router.default) + ]) + }) + .voided() + }, + http1ChannelConfigurator: { http1Channel in + http1Channel.pipeline + .configureHTTPServerPipeline(withErrorHandling: true) + .flatMap { self.pipeline.addHandler(HTTPHandler(router: Router.default)) } + } + ) + } else { + return pipeline + .configureHTTPServerPipeline(withErrorHandling: true) + .flatMap { self.pipeline.addHandler(HTTPHandler(router: Router.default)) } + } + } +} diff --git a/Sources/Alchemy/HTTP/Response.swift b/Sources/Alchemy/HTTP/Response.swift index ddba46b2..775da719 100644 --- a/Sources/Alchemy/HTTP/Response.swift +++ b/Sources/Alchemy/HTTP/Response.swift @@ -88,8 +88,8 @@ public final class Response { /// - Parameter writer: An abstraction around writing data to a /// remote peer. private func defaultWriterClosure(writer: ResponseWriter) { - writer.writeHead(status: self.status, self.headers) - if let body = self.body { + writer.writeHead(status: status, headers) + if let body = body { writer.writeBody(body.buffer) } writer.writeEnd() @@ -121,10 +121,3 @@ public protocol ResponseWriter { /// response, when all data has been written. func writeEnd() } - -extension ResponseWriter { - // Convenience default parameters for `writeHead`. - public func writeHead(status: HTTPResponseStatus = .ok, _ headers: HTTPHeaders = HTTPHeaders()) { - self.writeHead(status: status, headers) - } -}