From f2027c1ad23b9c05658d4f161f86056a550d69f3 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 13 Sep 2021 15:26:58 -0700 Subject: [PATCH 1/6] Add HTTP2 and TLS handlers --- Package.swift | 4 + .../Application/Application+Launch.swift | 2 +- Sources/Alchemy/Application/Application.swift | 2 +- Sources/Alchemy/Commands/ServeCommand.swift | 101 +++++++++++++++--- 4 files changed, 94 insertions(+), 15 deletions(-) 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+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.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/ServeCommand.swift b/Sources/Alchemy/Commands/ServeCommand.swift index 15fe40d6..2a68577a 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,23 +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 { - channel.pipeline - .configureHTTPServerPipeline(withErrorHandling: true) - .flatMap { channel.pipeline.addHandler(HTTPHandler(router: Router.default)) } - } - 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:)) + + .childChannelInitializer { channel in + return channel.pipeline + .addAnyTLS() + .flatMap { channel.addHTTP() } + } // Enable SO_REUSEADDR for the accepted `Channel`s .childChannelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1) @@ -140,3 +137,81 @@ extension SocketAddress { } } } + +extension ChannelPipeline { + func addAnyTLS() -> EventLoopFuture { + let config = Container.resolve(ServerConfiguration.self) + if let tls = config.tlsConfig { + let sslContext = try! NIOSSLContext(configuration: tls) + let sslHandler = NIOSSLServerHandler(context: sslContext) + return addHandler(sslHandler) + } else { + return .new() + } + } +} + +extension Channel { + func addHTTP() -> EventLoopFuture { + let config = Container.resolve(ServerConfiguration.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)) } + } + } +} + +extension Application { + public func useHTTPS(key: String, cert: String) throws { + let config = Container.resolve(ServerConfiguration.self) + config.tlsConfig = TLSConfiguration + .makeServerConfiguration( + certificateChain: try NIOSSLCertificate + .fromPEMFile(cert) + .map { NIOSSLCertificateSource.certificate($0) }, + privateKey: .file(key)) + } + + public func useHTTPS(tlsConfig: TLSConfiguration) { + let config = Container.resolve(ServerConfiguration.self) + config.tlsConfig = tlsConfig + } + + public func useHTTP2(key: String, cert: String) throws { + let config = Container.resolve(ServerConfiguration.self) + config.httpVersions = [.http2, .http1_1] + try useHTTPS(key: key, cert: cert) + } + + public func useHTTP2(tlsConfig: TLSConfiguration) { + let config = Container.resolve(ServerConfiguration.self) + config.httpVersions = [.http2, .http1_1] + useHTTPS(tlsConfig: tlsConfig) + } +} + +public final class ServerConfiguration { + public var tlsConfig: TLSConfiguration? + public var httpVersions: [HTTPVersion] = [.http1_1] +} From ccd5a6b31a13555cafc5925d26fe2e86df565696 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 13 Sep 2021 21:12:33 -0700 Subject: [PATCH 2/6] Docs and cleanup --- .../Application+Configuration.swift | 56 +++++++++++++++ .../Application/Application+Services.swift | 1 + Sources/Alchemy/Commands/ServeCommand.swift | 68 ++++++------------- 3 files changed, 77 insertions(+), 48 deletions(-) create mode 100644 Sources/Alchemy/Application/Application+Configuration.swift 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+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/Commands/ServeCommand.swift b/Sources/Alchemy/Commands/ServeCommand.swift index 2a68577a..72f66c98 100644 --- a/Sources/Alchemy/Commands/ServeCommand.swift +++ b/Sources/Alchemy/Commands/ServeCommand.swift @@ -83,19 +83,17 @@ extension ServeCommand: Runner { } private func start() -> EventLoopFuture { + func childChannelInitializer(_ channel: Channel) -> EventLoopFuture { + channel.pipeline + .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) - - .childChannelInitializer { channel in - return channel.pipeline - .addAnyTLS() - .flatMap { channel.addHTTP() } - } - - // 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) @@ -139,8 +137,12 @@ extension SocketAddress { } extension ChannelPipeline { - func addAnyTLS() -> EventLoopFuture { - let config = Container.resolve(ServerConfiguration.self) + /// 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 let tls = config.tlsConfig { let sslContext = try! NIOSSLContext(configuration: tls) let sslHandler = NIOSSLServerHandler(context: sslContext) @@ -152,8 +154,12 @@ extension ChannelPipeline { } extension Channel { - func addHTTP() -> EventLoopFuture { - let config = Container.resolve(ServerConfiguration.self) + /// 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 @@ -181,37 +187,3 @@ extension Channel { } } } - -extension Application { - public func useHTTPS(key: String, cert: String) throws { - let config = Container.resolve(ServerConfiguration.self) - config.tlsConfig = TLSConfiguration - .makeServerConfiguration( - certificateChain: try NIOSSLCertificate - .fromPEMFile(cert) - .map { NIOSSLCertificateSource.certificate($0) }, - privateKey: .file(key)) - } - - public func useHTTPS(tlsConfig: TLSConfiguration) { - let config = Container.resolve(ServerConfiguration.self) - config.tlsConfig = tlsConfig - } - - public func useHTTP2(key: String, cert: String) throws { - let config = Container.resolve(ServerConfiguration.self) - config.httpVersions = [.http2, .http1_1] - try useHTTPS(key: key, cert: cert) - } - - public func useHTTP2(tlsConfig: TLSConfiguration) { - let config = Container.resolve(ServerConfiguration.self) - config.httpVersions = [.http2, .http1_1] - useHTTPS(tlsConfig: tlsConfig) - } -} - -public final class ServerConfiguration { - public var tlsConfig: TLSConfiguration? - public var httpVersions: [HTTPVersion] = [.http1_1] -} From 0509e1ed096193147ad832b6926e4efcd7c83f72 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 13 Sep 2021 21:21:03 -0700 Subject: [PATCH 3/6] Add protocols for TLS --- Sources/Alchemy/Commands/ServeCommand.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Alchemy/Commands/ServeCommand.swift b/Sources/Alchemy/Commands/ServeCommand.swift index 72f66c98..daeea4a1 100644 --- a/Sources/Alchemy/Commands/ServeCommand.swift +++ b/Sources/Alchemy/Commands/ServeCommand.swift @@ -143,7 +143,13 @@ extension ChannelPipeline { /// - Returns: A future that completes when the config completes. fileprivate func addAnyTLS() -> EventLoopFuture { let config = Container.resolve(ApplicationConfiguration.self) - if let tls = config.tlsConfig { + 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) From 190f70b9374e3621bd569b70b6979b74e35ad93e Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 13 Sep 2021 22:55:05 -0700 Subject: [PATCH 4/6] Fix keep alive and succeeding end promise too early --- Sources/Alchemy/Commands/HTTPHandler.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Sources/Alchemy/Commands/HTTPHandler.swift b/Sources/Alchemy/Commands/HTTPHandler.swift index b1276c4e..b071c97f 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 @@ -103,7 +103,7 @@ final class HTTPHandler: ChannelInboundHandler { return responseFuture.flatMap { response in let responseWriter = HTTPResponseWriter(handler: self, context: context) responseWriter.completionPromise.futureResult.whenComplete { _ in - if self.closeAfterResponse { + if !self.keepAlive { context.close(promise: nil) } } @@ -156,14 +156,10 @@ private struct HTTPResponseWriter: ResponseWriter { } 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) } } From 319468c41fe91ca4a2b9ffcc1a39b0ec235e3546 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 13 Sep 2021 23:24:12 -0700 Subject: [PATCH 5/6] Properly pass through HTTP version to response --- Sources/Alchemy/Commands/HTTPHandler.swift | 20 +++++++++++--------- Sources/Alchemy/HTTP/Response.swift | 11 ++--------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/Sources/Alchemy/Commands/HTTPHandler.swift b/Sources/Alchemy/Commands/HTTPHandler.swift index b071c97f..0c8f960a 100644 --- a/Sources/Alchemy/Commands/HTTPHandler.swift +++ b/Sources/Alchemy/Commands/HTTPHandler.swift @@ -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,14 +94,15 @@ 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.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,7 +153,6 @@ 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) } 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) - } -} From acb2ed7efd1380b6830dc1a284549f07a907548f Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 13 Sep 2021 23:37:06 -0700 Subject: [PATCH 6/6] Add docs --- Docs/1_Configuration.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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.