Capture HTTP, HTTPS, Websocket from iOS with Atlantis by Proxyman

Version Platform Twitter License 加入我们的 Discord 频道

Atlantis 由 Proxyman 团队开发

特性

Atlantis: Capture HTTP/HTTPS traffic from iOS app without Proxy and Certificate with Proxyman

⚠️注意

要求

👉 如何使用

1. 安装 Atlantis framework

Swift Packages Manager(推荐)

CocoaPod

pod 'atlantis-proxyman'

2. 将所需的配置添加到 Info.plist (iOS 14 或更高版本)

  1. 打开您的 iOS 项目 -> 打开 Info.plist 文件并添加以下键和值
<key>NSLocalNetworkUsageDescription</key>
<string>Atlantis would use Bonjour Service to discover Proxyman app from your local network.</string>
<key>NSBonjourServices</key>
<array>
    <string>_Proxyman._tcp</string>
</array>

3. 开始调试

  1. 如果您只有 一台 打开了 Proxyman 的 macOS 机器。 让我们使用简单版本
#if DEBUG
import Atlantis
#endif

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    // Auto connect to a current Macbook
    // Add to the end of `application(_:didFinishLaunchingWithOptions:)` in AppDelegate.swift or SceneDelegate.swift
    #if DEBUG
        Atlantis.start()
    #endif

    return true
}
#if DEBUG
import Atlantis
#endif

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    // Auto connect to a current Macbook
    // Add to the end of `application(_:didFinishLaunchingWithOptions:)` in AppDelegate.swift or SceneDelegate.swift
    #if DEBUG
        Atlantis.start(hostName: "Your_host_name")
    #endif

    return true
}

您可以获取 hostName: 打开 Proxyman macOS -> 证书菜单 -> 为 iOS 安装 -> Atlantis -> 如何启动 Atlantis -> 并复制 HostName

Proxyman screenshot

#import "Atlantis-Swift.h"

// Or import Atlantis as a module, you can use:
@import Atlantis;

// Add to the end of `application(_:didFinishLaunchingWithOptions:)` in AppDelegate.m file
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // Override point for customization after application launch.
    [Atlantis startWithHostName:nil shouldCaptureWebSocketTraffic:YES];
    return YES;
}
  1. 确保您的 iOS 设备/模拟器和 macOS Proxyman 位于同一 Wi-Fi 网络中,或者通过 USB 数据线将您的 iOS 设备连接到您的 Mac
  2. 通过 Xcode 启动您的 iOS 应用程序。 适用于 iOS 模拟器或 iOS 设备。
  3. Proxyman 现在捕获来自您的 iOS 应用程序的所有 HTTP/HTTPS、Websocket
  4. 享受调试的乐趣 ❤️

Websocket 流量

Proxyman capture websocket from iOS

示例应用

Atlantis 提供了一个简单的 iOS 应用程序,可以演示如何集成和使用 Atlantis 和 Proxyman。 请按照以下步骤操作

  1. 打开 macOS 的 Proxyman
  2. ./Example/Atlantis-Example-App.xcodeproj 打开 iOS 项目
  3. 使用任何 iPhone/iPad 模拟器启动项目
  4. 单击主屏幕上的按钮
  5. 返回 Proxyman 应用程序并检查您的 HTTPS 请求/响应。

Atlantis: Capture HTTP/HTTPS traffic from iOS app without Proxy and Certificate with Proxyman

高级用法

默认情况下,如果您的 iOS 应用程序使用 Apple 的网络类(例如 URLSession)或使用流行的网络库(例如 Alamofire 和 AFNetworking)发出 HTTP 请求,Atlantis 将 开箱即用

但是,如果您的应用程序未使用其中任何一个,则 Atlantis 无法自动捕获网络流量。

为了解决这个问题,Atlantis 提供了一些函数来帮助您 手动* 添加您的 Request 和 Response,这些 Request 和 Response 将像往常一样在 Proxyman 应用程序上显示。

1. 我的应用程序使用 C++ 网络库,不使用 URLSession, NSURLSession 或任何 iOS 网络库

您可以从以下 func 为 Atlantis 构建 Request 和 Response

    /// Handy func to manually add Atlantis' Request & Response, then sending to Proxyman for inspecting
    /// It's useful if your Request & Response are not URLRequest and URLResponse
    /// - Parameters:
    ///   - request: Atlantis' request model
    ///   - response: Atlantis' response model
    ///   - responseBody: The body data of the response
    public class func add(request: Request,
                          response: Response,
                          responseBody: Data?) {
@IBAction func getManualBtnOnClick(_ sender: Any) {
    // Init Request and Response
    let header = Header(key: "X-Data", value: "Atlantis")
    let jsonType = Header(key: "Content-Type", value: "application/json")
    let jsonObj: [String: Any] = ["country": "Singapore"]
    let data = try! JSONSerialization.data(withJSONObject: jsonObj, options: [])
    let request = Request(url: "https://proxyman.com/get/data", method: "GET", headers: [header, jsonType], body: data)
    let response = Response(statusCode: 200, headers: [Header(key: "X-Response", value: "Internal Error server"), jsonType])
    let responseObj: [String: Any] = ["error_response": "Not FOund"]
    let responseData = try! JSONSerialization.data(withJSONObject: responseObj, options: [])
    
    // Add to Atlantis and show it on Proxyman app
    Atlantis.add(request: request, response: response, responseBody: responseData)
}

2. 我的应用程序使用 GRPC

您可以通过 grpc-swift 提供的拦截器模式从 GRPC 模型构建一元 Request 和 Response,并利用它来获取您调用的完整日志。

这是一个 AtlantisInterceptor 的示例
        import Atlantis
        import Foundation
        import GRPC
        import NIO
        import NIOHPACK
        import SwiftProtobuf

        extension HPACKHeaders {
            var atlantisHeaders: [Header] { map { Header(key: $0.name, value: $0.value) } }
        }

        public class AtlantisInterceptor<Request: Message, Response: Message>: ClientInterceptor<Request, Response> {
            private struct LogEntry {
                let id = UUID()
                var path: String = ""
                var started: Date?
                var request: LogRequest = .init()
                var response: LogResponse = .init()
            }

            private struct LogRequest {
                var metadata: [Header] = []
                var messages: [String] = []
                var ended = false
            }

            private struct LogResponse {
                var metadata: [Header] = []
                var messages: [String] = []
                var end: (status: GRPCStatus, metadata: String)?
            }

            private var logEntry = LogEntry()

            override public func send(_ part: GRPCClientRequestPart<Request>,
                                      promise: EventLoopPromise<Void>?,
                                      context: ClientInterceptorContext<Request, Response>)
            {
                logEntry.path = context.path
                if logEntry.started == nil {
                    logEntry.started = Date()
                }
                switch context.type {
                case .clientStreaming, .serverStreaming, .bidirectionalStreaming:
                    streamingSend(part, type: context.type)
                case .unary:
                    unarySend(part)
                }
                super.send(part, promise: promise, context: context)
            }

            private func streamingSend(_ part: GRPCClientRequestPart<Request>, type: GRPCCallType) {
                switch part {
                case .metadata(let metadata):
                    logEntry.request.metadata = metadata.atlantisHeaders
                case .message(let messageRequest, _):
                    Atlantis.addGRPCStreaming(id: logEntry.id,
                                              path: logEntry.path,
                                              message: .data((try? messageRequest.jsonUTF8Data()) ?? Data()),
                                              success: true,
                                              statusCode: 0,
                                              statusMessage: nil,
                                              streamingType: type.streamingType,
                                              type: .send,
                                              startedAt: logEntry.started,
                                              endedAt: Date(),
                                              HPACKHeadersRequest: logEntry.request.metadata,
                                              HPACKHeadersResponse: logEntry.response.metadata)
                case .end:
                    logEntry.request.ended = true
                    switch type {
                    case .unary, .serverStreaming, .bidirectionalStreaming:
                        break
                    case .clientStreaming:
                        Atlantis.addGRPCStreaming(id: logEntry.id,
                                                  path: logEntry.path,
                                                  message: .string("end"),
                                                  success: true,
                                                  statusCode: 0,
                                                  statusMessage: nil,
                                                  streamingType: type.streamingType,
                                                  type: .send,
                                                  startedAt: logEntry.started,
                                                  endedAt: Date(),
                                                  HPACKHeadersRequest: logEntry.request.metadata,
                                                  HPACKHeadersResponse: logEntry.response.metadata)
                    }
                }
            }

            private func unarySend(_ part: GRPCClientRequestPart<Request>) {
                switch part {
                case .metadata(let metadata):
                    logEntry.request.metadata = metadata.atlantisHeaders
                case .message(let messageRequest, _):
                    logEntry.request.messages.append((try? messageRequest.jsonUTF8Data())?.prettyJson ?? "")
                case .end:
                    logEntry.request.ended = true
                }
            }

            override public func receive(_ part: GRPCClientResponsePart<Response>, context: ClientInterceptorContext<Request, Response>) {
                logEntry.path = context.path
                switch context.type {
                case .unary:
                    unaryReceive(part)
                case .bidirectionalStreaming, .serverStreaming, .clientStreaming:
                    streamingReceive(part, type: context.type)
                }
                super.receive(part, context: context)
            }

            private func streamingReceive(_ part: GRPCClientResponsePart<Response>, type: GRPCCallType) {
                switch part {
                case .metadata(let metadata):
                    logEntry.response.metadata = metadata.atlantisHeaders
                case .message(let messageResponse):
                    Atlantis.addGRPCStreaming(id: logEntry.id,
                                              path: logEntry.path,
                                              message: .data((try? messageResponse.jsonUTF8Data()) ?? Data()),
                                              success: true,
                                              statusCode: 0,
                                              statusMessage: nil,
                                              streamingType: type.streamingType,
                                              type: .receive,
                                              startedAt: logEntry.started,
                                              endedAt: Date(),
                                              HPACKHeadersRequest: logEntry.request.metadata,
                                              HPACKHeadersResponse: logEntry.response.metadata)
                case .end(let status, _):
                    Atlantis.addGRPCStreaming(id: logEntry.id,
                                              path: logEntry.path,
                                              message: .string("end"),
                                              success: status.isOk,
                                              statusCode: status.code.rawValue,
                                              statusMessage: status.message,
                                              streamingType: type.streamingType,
                                              type: .receive,
                                              startedAt: logEntry.started,
                                              endedAt: Date(),
                                              HPACKHeadersRequest: logEntry.request.metadata,
                                              HPACKHeadersResponse: logEntry.response.metadata)
                }
            }

            private func unaryReceive(_ part: GRPCClientResponsePart<Response>) {
                switch part {
                case .metadata(let metadata):
                    logEntry.response.metadata = metadata.atlantisHeaders
                case .message(let messageResponse):
                    logEntry.response.messages.append((try? messageResponse.jsonUTF8Data())?.prettyJson ?? "")
                case .end(let status, _):
                    Atlantis.addGRPCUnary(path: logEntry.path,
                                          requestObject: logEntry.request.messages.joined(separator: "\n").data(using: .utf8),
                                          responseObject: logEntry.response.messages.joined(separator: "\n").data(using: .utf8),
                                          success: status.isOk,
                                          statusCode: status.code.rawValue,
                                          statusMessage: status.message,
                                          startedAt: logEntry.started,
                                          endedAt: Date(),
                                          HPACKHeadersRequest: logEntry.request.metadata,
                                          HPACKHeadersResponse: logEntry.response.metadata)
                }
            }

            override public func errorCaught(_ error: Error, context: ClientInterceptorContext<Request, Response>) {
                logEntry.path = context.path
                switch context.type {
                case .unary, .bidirectionalStreaming, .serverStreaming, .clientStreaming:
                    Atlantis.addGRPCUnary(path: logEntry.path,
                                          requestObject: logEntry.request.messages.joined(separator: "\n").data(using: .utf8),
                                          responseObject: logEntry.response.messages.joined(separator: "\n").data(using: .utf8),
                                          success: false,
                                          statusCode: GRPCStatus(code: .unknown, message: "").code.rawValue,
                                          statusMessage: error.localizedDescription,
                                          startedAt: logEntry.started,
                                          endedAt: Date(),
                                          HPACKHeadersRequest: logEntry.request.metadata,
                                          HPACKHeadersResponse: logEntry.response.metadata)
                }

                super.errorCaught(error, context: context)
            }

            override public func cancel(promise: EventLoopPromise<Void>?, context: ClientInterceptorContext<Request, Response>) {
                logEntry.path = context.path
                switch context.type {
                case .unary, .bidirectionalStreaming, .serverStreaming, .clientStreaming:
                    Atlantis.addGRPCUnary(path: logEntry.path,
                                          requestObject: logEntry.request.messages.joined(separator: "\n").data(using: .utf8),
                                          responseObject: logEntry.response.messages.joined(separator: "\n").data(using: .utf8),
                                          success: false,
                                          statusCode: GRPCStatus(code: .cancelled, message: nil).code.rawValue,
                                          statusMessage: "canceled",
                                          startedAt: logEntry.started,
                                          endedAt: Date(),
                                          HPACKHeadersRequest: logEntry.request.metadata,
                                          HPACKHeadersResponse: logEntry.response.metadata)
                }
                super.cancel(promise: promise, context: context)
            }
        }

        extension GRPCCallType {
            var streamingType: Atlantis.GRPCStreamingType {
                switch self {
                case .clientStreaming:
                    return .client
                case .serverStreaming:
                    return .server
                case .bidirectionalStreaming:
                    return .server
                case .unary:
                    fatalError("Unary is not a streaming type")
                }
            }
        }

        private extension Data {
            var prettyJson: String? {
                guard let object = try? JSONSerialization.jsonObject(with: self),
                      let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
                      let prettyPrintedString = String(data: data, encoding: .utf8) else {
                          return nil
                      }
                return prettyPrintedString
            }
        }
    public class YourInterceptorFactory: YourClientInterceptorFactoryProtocol {
        func makeGetYourCallInterceptors() -> [ClientInterceptor<YourRequest, YourResponse>] {
            [AtlantisInterceptor()]
        }
    }

    // Your GRPC services that is generated from SwiftGRPC
    private let client = NoteServiceServiceClient.init(channel: connectionChannel, interceptors: YourInterceptorFactory())

3. 在 Swift Playground 上使用 Atlantis

Atlantis 能够捕获来自您的 Swift Playground 的 HTTP/HTTPS 和 WS/WSS 流量。

  1. 使用 Arena 生成一个带有 Atlantis 的新 Swift Playground。 如果您想将 Atlantis 添加到现有的 Swift Playground,请遵循 本教程
  2. 启用 Swift Playground 模式
Atlantis.setIsRunningOniOSPlayground(true)
Atlantis.start()
  1. 信任 Proxyman 自签名证书
  1. 发出 HTTP/HTTPS 或 WS/WSS 并在 Proxyman 应用程序上检查它。

❓ 常见问题解答

1. Atlantis 是如何工作的?

Atlantis 使用 Method Swizzling 技术来交换 NSURLSession 的某些功能,这使 Atlantis 能够动态捕获 HTTP/HTTPS 流量。

然后它通过本地 Bonjour 服务发送到 Proxyman app 以进行检查。

2. Atlantis 如何将数据流式传输到 Proxyman 应用程序?

一旦您的 iOS 应用程序(启用 Atlantis)和 Proxyman macOS 应用程序位于相同的本地网络中,Atlantis 就可以使用 Bonjour Service 发现 Proxyman 应用程序。 建立连接后,Atlantis 将通过 Socket 发送数据。

3. 将我的网络流量日志发送到 Proxyman 应用程序是否安全?

这是完全安全的,因为您的数据是在您的 iOS 应用程序和 Proxyman 应用程序之间本地传输的,不需要 Internet。 所有流量日志都会被捕获并发送到 Proxyman 应用程序以进行动态检查。

Atlantis 和 Proxyman 应用程序不会将您的任何数据存储在任何服务器上。

4. Atlantis 捕获什么样的数据?

以上所有数据均未存储在任何地方(除了内存中)。 一旦您关闭应用程序,它将被清除。

它们是按项目和设备名称对 Proxyman 应用程序上的流量进行分类所必需的。 因此,更容易知道请求/响应来自哪里。

故障排除

1. 我无法在 Proxyman 应用程序上看到来自 Atlantis 的任何请求?

由于某些原因,Bonjour 服务可能无法找到 Proxyman 应用程序。

=> 确保您的 iOS 设备和 Mac 位于同一 Wi-Fi 网络中,或者使用 USB 数据线连接到您的 Mac

=> 请使用 Atlantis.start(hostName: "_your_host_name") 版本来显式告诉 Atlantis 连接到您的 Mac。

2. 我无法在 Atlantis 的请求上使用调试工具。

Atlantis 旨在用于检查网络,而不是用于调试目的。 如果您想使用调试工具,请考虑使用普通的 HTTP 代理

鸣谢

许可

Atlantis 在 Apache-2.0 许可下发布。 有关详细信息,请参见 LICENSE。