Telegraph: Secure Web Server for iOS, tvOS and macOS

Telegraph CI CocoaPods compatible License Platform

Telegraph 是一个用 Swift 编写的适用于 iOS、tvOS 和 macOS 的安全 Web 服务器。

特性

平台

安装

Swift Package Manager

Swift Package Manager 是一个用于自动化分发 Swift 代码的工具。

您可以通过选择 File - Swift Packages - Add Package Dependency 选项将 Telegraph 添加到您的项目中。使用如下指定的存储库 URL,并选择您想要使用的版本。

或者,您可以手动将 Package.swift 文件添加到您的项目中,内容为:

dependencies: [
    .package(url: "https://github.com/Building42/Telegraph.git")
]

CocoaPods

CocoaPods 是 Cocoa 项目的依赖项管理器,它使依赖项成为您工作区的一部分。

pod 'Telegraph'

有关更多信息,请参见 CocoaPods - 入门

构建

您可以使用以下步骤构建 Telegraph 框架和示例

  1. 克隆存储库
  2. 打开 Telegraph.xcworkspace
  3. 确保 Xcode 下载 Swift Package Dependencies
  4. 选择其中一个示例 scheme 并构建

只有在您想更改框架或尝试示例时才需要这样做。

使用

配置 App Transport Security

在 iOS 9 中,Apple 引入了 APS(App Transport Security),旨在通过要求应用程序使用 HTTPS 的安全网络连接来提高用户安全性和隐私。 这意味着,如果没有额外的配置,以 iOS 9 或更高版本为目标的应用程序中的不安全 HTTP 请求将失败。 不幸的是,在 iOS 9 中,APS 也为 LAN 连接激活。 Apple 在 iOS 10 中修复了此问题,添加了 NSAllowsLocalNetworking

即使我们正在使用 HTTPS,我们也必须考虑以下几点:

  1. 当我们 iPad 之间通信时,很可能我们会连接到 IP 地址,或者至少设备的 hostname 与证书的通用名称不匹配。
  2. 我们的服务器将使用由我们自己的证书颁发机构签名的证书,而不是由公认的根证书颁发机构签名的证书。

您可以通过将键 App Transport Security Settings 添加到您的 Info.plist 中来禁用 APS,其中的子键 Allow Arbitrary Loads 设置为 Yes。 有关更多信息,请参见 ATS 配置基础

准备证书

对于安全 Web 服务器,您需要两件事:

  1. 一个或多个 DER 格式的证书颁发机构证书。
  2. 一个包含私钥和证书(由 CA 签名)的 PKCS12 捆绑包。

Telegraph 包含一些类,可以更轻松地加载证书

let caCertificateURL = Bundle.main.url(forResource: "ca", withExtension: "der")!
let caCertificate = Certificate(derURL: caCertificateURL)!

let identityURL = Bundle.main.url(forResource: "localhost", withExtension: "p12")!
let identity = CertificateIdentity(p12URL: identityURL, passphrase: "test")!

注意:macOS 不接受没有密码的 P12 文件。

HTTP:服务器

您很可能希望通过传入证书来创建一个安全服务器

serverHTTPs = Server(identity: identity, caCertificates: [caCertificate])
try! server.start(port: 9000)

或者,对于快速测试,创建一个不安全的服务器

serverHTTP = Server()
try! server.start(port: 9000)

您可以通过在启动服务器时指定一个接口来将服务器限制为 localhost 连接

try! server.start(port: 9000, interface: "localhost")

HTTP:路由

路由由三个部分组成:HTTP 方法、路径和一个处理程序

server.route(.POST, "test", handleTest)
server.route(.GET, "hello/:name", handleGreeting)
server.route(.GET, "secret/*") { .forbidden }
server.route(.GET, "status") { (.ok, "Server is running") }

server.serveBundle(.main, "/")

// You can also serve custom urls, for example the Demo folder in your bundle
let demoBundleURL = Bundle.main.url(forResource: "Demo", withExtension: nil)!
server.serveDirectory(demoBundleURL, "/demo")

路径开头的斜杠是可选的。 路由不区分大小写。 您可以为更高级的路由匹配指定自定义正则表达式。 如果没有任何路由匹配,服务器将返回 404 未找到。

上面的示例中的第一个路由有一个路由参数 (name)。 当服务器将传入的请求与该路由匹配时,它会将该参数放置在请求的 params 数组中。

func handleGreeting(request: HTTPRequest) -> HTTPResponse {
  let name = request.params["name"] ?? "stranger"
  return HTTPResponse(content: "Hello \(name.capitalized)")
}

HTTP:中间件

当 HTTP 请求由服务器处理时,它会通过一系列消息处理程序。 如果您不更改默认配置,请求将首先传递到 HTTPWebSocketHandler,然后再传递到 HTTPRouteHandler

以下是一个消息处理程序的示例

public class HTTPGETOnlyHandler: HTTPRequestHandler {
  public func respond(to request: HTTPRequest, nextHandler: HTTPRequest.Handler) throws -> HTTPResponse? {
    // If this is a GET request, pass it to the next handler
    if request.method == .GET {
      return try nextHandler(request)
    }

    // Otherwise return 403 - Forbidden
    return HTTPResponse(.forbidden, content: "Only GET requests are allowed")
  }
}

您可以通过在 HTTP 配置中设置消息处理程序来启用它

server.httpConfig.requestHandlers.insert(HTTPGETOnlyHandler(), at: 0)

请注意,请求处理程序的顺序非常重要。 您可能希望将 HTTPRouteHandler 作为最后一个请求处理程序,否则您的服务器将不处理任何路由请求。 HTTPRouteHandler 不调用任何处理程序,因此请不要在 HTTPRouteHandler 之后指定任何处理程序。

您还可以在处理程序中修改请求。 此处理程序将 QueryString 项复制到请求的 params 字典中

public class HTTPRequestParamsHandler: HTTPRequestHandler {
  public func respond(to request: HTTPRequest, nextHandler: HTTPRequest.Handler) throws -> HTTPResponse? {
    // Extract the query string items and put them in the HTTPRequest params
    request.uri.queryItems?.forEach { item in
      request.params[item.name] = item.value
    }

    // Continue with the rest of the handlers
    return try nextHandler(request)
  }
}

但是,如果您想向响应添加标头怎么办? 只需调用链并修改结果

public class HTTPAppDetailsHandler: HTTPRequestHandler {
  public func respond(to request: HTTPRequest, nextHandler: HTTPRequest.Handler) throws -> HTTPResponse? {
    //  Let the other handlers create a response
    let response = try nextHandler(request)

    // Add our own bit of magic
    response.headers["X-App-Version"] = "My App 1.0"
    return response
  }
}

HTTP:跨域资源共享 (CORS)

CORS 机制控制哪些站点有权访问您服务器的资源。 您可以通过向客户端发送 Access-Control-Allow-Origin 标头来设置 CORS。 为了开发目的,允许所有具有 * 值的站点可能很有用。

response.headers.accessControlAllowOrigin = "*"

如果您想让它更漂亮,可以创建一个处理程序

public class HTTPCORSHandler: HTTPRequestHandler {
  public func respond(to request: HTTPRequest, nextHandler: HTTPRequest.Handler) throws -> HTTPResponse? {
    let response = try nextHandler(request)

    // Add access control header for GET requests
    if request.method == .GET {
      response?.headers.accessControlAllowOrigin = "*"
    }

    return response
  }
}

为了提高安全性,您可以添加其他检查,深入研究请求,并为不同的客户端发回不同的 CORS 标头。

HTTP:客户端

对于客户端连接,我们将使用 Apple 的 URLSession 类。 Ray Wenderlich 有一个 优秀的教程。 我们将不得不手动验证 TLS 握手(需要禁用 App Transport Security)

let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
let tlsPolicy = TLSPolicy(commonName: "localhost", certificates: [caCertificate])

extension YourClass: URLSessionDelegate {
  func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
      completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
    // The TLSPolicy class will do most of the work for us
    let credential = tlsPolicy.evaluateSession(trust: challenge.protectionSpace.serverTrust)
    completionHandler(credential == nil ? .cancelAuthenticationChallenge : .useCredential, credential)
  }
}

TLSPolicy 中的通用名称应与服务器证书的通用名称(在 P12 存档中提供)匹配。 尽管我不建议这样做,您可以通过提供一个空字符串来禁用通用名称检查(如果您提供 nil,则通用名称将与设备的主机名进行比较)。

对于通用名称,您不限于设备的主机名或 IP 地址。 例如,您的后端可以生成一个通用名称与设备 UUID 匹配的证书。 如果客户端知道它连接到的设备的 UUID,您可以使其成为 TLSPolicy 检查的一部分。

WebSockets:服务器端

由于默认情况下 HTTP 请求处理程序列表中包含 HTTPWebSocketHandler,因此您的服务器将自动识别 WebSocket 请求。 设置 WebSocket 代理来处理传入的消息

server.webSocketDelegate = self

下一步是实现 ServerWebSocketDelegate 方法

func server(_ server: Server, webSocketDidConnect webSocket: WebSocket, handshake: HTTPRequest) {
  // A web socket connected, you can extract additional information from the handshake request
  webSocket.send(text: "Welcome!")
}

func server(_ server: Server, webSocketDidDisconnect webSocket: WebSocket, error: Error?) {
  // One of our web sockets disconnected
}

func server(_ server: Server, webSocket: WebSocket, didReceiveMessage message: WebSocketMessage) {
  // One of our web sockets sent us a message
}

func server(_ server: Server, webSocket: WebSocket, didSendMessage message: WebSocketMessage) {
  // We sent one of our web sockets a message (often you won't need to implement this one)
}

WebSockets:中间件

Web socket 也可以有自定义处理程序,但您只需指定一个类,而不是整个处理程序链。 WebSocketMessageDefaultHandler 将响应连接关闭消息并处理 ping 消息。

我建议通过从默认处理程序继承来创建自定义处理程序

public class AwesomeWebSocketHandler: WebSocketMessageHandler {
  public func incoming(message: WebSocketMessage, from webSocket: WebSocket) throws {
    // Don't forget to call super (for ping-pong etc.)
    super.incoming(message: message, from: webSocket)

    // Echo incoming text messages
    switch message.payload {
    case let .text(text): webSocket.send(text: text)
    default: break
    }
  }
}

WebSockets:客户端

现在我们有了一个安全的 WebSocket 服务器,我们也可以通过传递 CA 证书来使用安全的 WebSocket 客户端。 请注意,如果证书不是 Apple 信任的根 CA,或者您想从 证书绑定 中受益,您才需要指定 CA 证书。

client = try! WebSocketClient("wss://:9000", certificates: [caCertificate])
client.delegate = self

// You can specify headers too
client.headers.authorization = "Bearer secret-token"

代理方法如下所示

func webSocketClient(_ client: WebSocketClient, didConnectToHost host: String) {
  // The connection starts off as a HTTP request and then is upgraded to a
  // web socket connection. This method is called if the handshake was succesful.
}

func webSocketClient(_ client: WebSocketClient, didDisconnectWithError error: Error?) {
  // We were disconnected from the server
}

func webSocketClient(_ client: WebSocketClient, didReceiveData data: Data) {
  // We received a binary message. Ping, pong and the other opcodes are handled for us.
}

func webSocketClient(_ client: WebSocketClient, didReceiveText text: String) {
  // We received a text message, let's send one back
  client.send(text: "Message received")
}

常见问题解答

为什么选择 Telegraph?

iOS 只有少数可用的 Web 服务器,其中许多服务器没有 SSL 支持。 我们使用 Telegraph 的主要目标是在 iPad 之间提供安全的 HTTP 和 Web Socket 流量。 这个名字是对电报的致敬,电报是第一种形式的电气电信。

我该如何创建证书?

基本上,你想要的是一个证书颁发机构和一个由该机构签名的设备证书。 你可以在这里找到一个不错的教程:https://jamielinux.com/docs/openssl-certificate-authority/

请查看 Tools 文件夹,其中包含一个可以为您创建自签名证书的脚本。 该脚本非常基础,您可能需要编辑 config-ca.cnfconfig-localhost.cnf 文件中的一些信息。 使用该脚本生成的证书仅用于开发目的。

为什么我的证书在 iOS 13 或 macOS 10.15(或更高版本)上无法正常工作?

Apple 在 iOS 13 和 macOS 10.15 中引入了新的安全要求。 有关更多信息,请参见:https://support.apple.com/en-us/HT210176

我的服务器无法正常工作

请检查以下内容:

  1. 是否禁用了 Application Transport Security?
  2. 您的证书是否有效?
  3. 您的路由是否有效?
  4. 您是否自定义了任何处理程序? 路由处理程序是否仍然包含?

请查看此存储库中的示例项目,以获取可用的起点。

我的浏览器不显示我的捆绑包中的图像

在构建项目的过程中,Apple 会尝试优化您的图像以减少捆绑包的大小。 这种优化过程有时会导致 Chrome 无法读取图像。 在 Safari 中测试您的图像 URL 以仔细检查是否是这种情况。

要解决此问题,您可以转到 Xcode 中的属性检查器,并将资源的类型从 Default - PNG image 更改为 Data。 之后,构建过程将不会优化您的文件。 我还在示例项目中使用 logo.png 完成了此操作。

如果您想减少图像的大小,我强烈建议使用 ImageOptim

为什么我不能使用端口 80 和 443?

前 1024 个端口号仅限于 root 访问,您的应用程序在设备上没有 root 访问权限。 如果您尝试在这些端口上打开一个服务器,则在启动服务器时会收到权限被拒绝错误。 有关更多信息,请阅读 为什么前 1024 个端口仅限于 root 用户

如果设备进入待机状态怎么办?

如果您的应用被发送到后台,或者设备进入待机状态,您通常有大约 3 分钟的时间来处理请求和关闭连接。 您可以使用 UIApplication.beginBackgroundTask 创建一个后台任务,以告知 iOS 您需要额外的时间来完成操作。 属性 UIApplication.shared.backgroundTimeRemaining 告诉您到强制终止您的应用程序之前剩余的时间。

HTTP/2 支持怎么样?

是否曾经想知道远程服务器如何知道您的浏览器与 HTTP/2 兼容? 在 TLS 协商期间,应用层协议协商 (ALPN) 扩展字段包含 "h2" 以表示将使用 HTTP/2。 Apple 在 Secure Transport 或 CFNetwork 中不提供任何(公共)方法来配置 ALPN 扩展。 因此,目前无法实现安全的 HTTP/2 iOS 实现。

我可以在我的 Objective-C 项目中使用它吗?

此库是用 Swift 编写的,并且出于性能原因,除非绝对必要(没有动态调度),否则我没有用 NSObject 修饰类。 如果您愿意将 Swift 代码添加到您的项目中,您可以通过添加从 NSObject 继承并具有 Telegraph Server 变量的 Swift 包装器类来集成服务器。

作者

这个库是由

代码和设计灵感来自

感谢各位贡献者,我们非常欢迎并感谢您的 Pull Request!

许可证

Telegraph 使用 MIT 许可证发布。详情请参阅 LICENSE