Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds support for TLS & HTTP/2 #69

Merged
merged 6 commits into from
Sep 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Docs/1_Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
56 changes: 56 additions & 0 deletions Sources/Alchemy/Application/Application+Configuration.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion Sources/Alchemy/Application/Application+Launch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ extension Application {
bootServices()

// Boot the app
boot()
try boot()

// Register the runner
runner.register(lifecycle: lifecycle)
Expand Down
1 change: 1 addition & 0 deletions Sources/Alchemy/Application/Application+Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion Sources/Alchemy/Application/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
34 changes: 16 additions & 18 deletions Sources/Alchemy/Commands/HTTPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -86,24 +86,25 @@ 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)
}
}

/// Writes the `Responder`'s `Response` to a
/// `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<Response>, to context: ChannelHandlerContext) -> EventLoopFuture<Void> {
return responseFuture.flatMap { response in
let responseWriter = HTTPResponseWriter(handler: self, context: context)
private func writeResponse(version: HTTPVersion, response: EventLoopFuture<Response>, to context: ChannelHandlerContext) -> EventLoopFuture<Void> {
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)
}
}
Expand All @@ -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<Void>

/// The HTTP version we're working with.
private var version: HTTPVersion

/// The handler in which this writer is writing.
private let handler: HTTPHandler
Expand All @@ -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()
Expand All @@ -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)
}
}
83 changes: 68 additions & 15 deletions Sources/Alchemy/Commands/ServeCommand.swift
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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() }
)

Expand All @@ -80,25 +83,17 @@ extension ServeCommand: Runner {
}

private func start() -> EventLoopFuture<Channel> {
// 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<Void> {
func childChannelInitializer(_ channel: Channel) -> EventLoopFuture<Void> {
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)

Expand Down Expand Up @@ -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<Void> {
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<Void> {
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)) }
}
}
}
11 changes: 2 additions & 9 deletions Sources/Alchemy/HTTP/Response.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}