Skip to content

Commit

Permalink
Adds support for TLS & HTTP/2 (#69)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
joshuawright11 authored Sep 14, 2021
1 parent efe370b commit ba39656
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 44 deletions.
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)
}
}

0 comments on commit ba39656

Please sign in to comment.