142 lines
4.7 KiB
Swift
142 lines
4.7 KiB
Swift
import Foundation
|
|
import NIOCore
|
|
import NIOHTTP1
|
|
|
|
/// Captures HTTP request/response pairs and writes them to the traffic database.
|
|
/// Inserted into the pipeline after TLS termination (MITM) or for plain HTTP.
|
|
final class HTTPCaptureHandler: ChannelDuplexHandler {
|
|
typealias InboundIn = HTTPClientResponsePart
|
|
typealias InboundOut = HTTPClientResponsePart
|
|
typealias OutboundIn = HTTPClientRequestPart
|
|
typealias OutboundOut = HTTPClientRequestPart
|
|
|
|
private let trafficRepo: TrafficRepository
|
|
private let domain: String
|
|
private let scheme: String
|
|
|
|
private var currentRequestId: String?
|
|
private var requestHead: HTTPRequestHead?
|
|
private var requestBody = Data()
|
|
private var responseHead: HTTPResponseHead?
|
|
private var responseBody = Data()
|
|
private var requestStartTime: Double = 0
|
|
|
|
init(trafficRepo: TrafficRepository, domain: String, scheme: String = "https") {
|
|
self.trafficRepo = trafficRepo
|
|
self.domain = domain
|
|
self.scheme = scheme
|
|
}
|
|
|
|
// MARK: - Outbound (Request)
|
|
|
|
func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
|
|
let part = unwrapOutboundIn(data)
|
|
|
|
switch part {
|
|
case .head(let head):
|
|
currentRequestId = UUID().uuidString
|
|
requestHead = head
|
|
requestBody = Data()
|
|
requestStartTime = Date().timeIntervalSince1970
|
|
case .body(.byteBuffer(let buffer)):
|
|
if requestBody.count < ProxyConstants.maxBodySizeBytes {
|
|
requestBody.append(contentsOf: buffer.readableBytesView)
|
|
}
|
|
case .end:
|
|
saveRequest()
|
|
default:
|
|
break
|
|
}
|
|
|
|
context.write(data, promise: promise)
|
|
}
|
|
|
|
// MARK: - Inbound (Response)
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
let part = unwrapInboundIn(data)
|
|
|
|
switch part {
|
|
case .head(let head):
|
|
responseHead = head
|
|
responseBody = Data()
|
|
case .body(let buffer):
|
|
if responseBody.count < ProxyConstants.maxBodySizeBytes {
|
|
responseBody.append(contentsOf: buffer.readableBytesView)
|
|
}
|
|
case .end:
|
|
saveResponse()
|
|
}
|
|
|
|
context.fireChannelRead(data)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func saveRequest() {
|
|
guard let head = requestHead, let reqId = currentRequestId else { return }
|
|
|
|
let url = "\(scheme)://\(domain)\(head.uri)"
|
|
let headersJSON = encodeHeaders(head.headers)
|
|
let queryParams = extractQueryParams(from: head.uri)
|
|
|
|
var traffic = CapturedTraffic(
|
|
requestId: reqId,
|
|
domain: domain,
|
|
url: url,
|
|
method: head.method.rawValue,
|
|
scheme: scheme,
|
|
requestHeaders: headersJSON,
|
|
requestBody: requestBody.isEmpty ? nil : requestBody,
|
|
requestBodySize: requestBody.count,
|
|
requestContentType: head.headers.first(name: "Content-Type"),
|
|
queryParameters: queryParams,
|
|
startedAt: requestStartTime,
|
|
isSslDecrypted: scheme == "https"
|
|
)
|
|
|
|
try? trafficRepo.insert(&traffic)
|
|
}
|
|
|
|
private func saveResponse() {
|
|
guard let reqId = currentRequestId, let head = responseHead else { return }
|
|
|
|
let now = Date().timeIntervalSince1970
|
|
let durationMs = Int((now - requestStartTime) * 1000)
|
|
|
|
try? trafficRepo.updateResponse(
|
|
requestId: reqId,
|
|
statusCode: Int(head.status.code),
|
|
statusText: head.status.reasonPhrase,
|
|
responseHeaders: encodeHeaders(head.headers),
|
|
responseBody: responseBody.isEmpty ? nil : responseBody,
|
|
responseBodySize: responseBody.count,
|
|
responseContentType: head.headers.first(name: "Content-Type"),
|
|
completedAt: now,
|
|
durationMs: durationMs
|
|
)
|
|
|
|
IPCManager.shared.post(.newTrafficCaptured)
|
|
}
|
|
|
|
private func encodeHeaders(_ headers: HTTPHeaders) -> String? {
|
|
var dict: [String: String] = [:]
|
|
for (name, value) in headers {
|
|
dict[name] = value
|
|
}
|
|
guard let data = try? JSONEncoder().encode(dict) else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private func extractQueryParams(from uri: String) -> String? {
|
|
guard let url = URLComponents(string: uri),
|
|
let items = url.queryItems, !items.isEmpty else { return nil }
|
|
var dict: [String: String] = [:]
|
|
for item in items {
|
|
dict[item.name] = item.value ?? ""
|
|
}
|
|
guard let data = try? JSONEncoder().encode(dict) else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
}
|