- Adaptive iPhone/iPad layout with NavigationSplitView sidebar - Auto-detect SSL-pinned domains, fall back to passthrough - Certificate install via local HTTP server (Safari profile flow) - App Group-backed CA, per-domain leaf cert LRU cache - DB-backed config repository, Darwin notification throttling - Rules engine, breakpoint rules, pinned domain tracking - os.Logger instrumentation across tunnel/proxy/mitm/capture/cert/rules/db/ipc/ui - Fix dyld framework embed, race conditions, thread safety
243 lines
10 KiB
Swift
243 lines
10 KiB
Swift
import Foundation
|
|
import NIOCore
|
|
import NIOHTTP1
|
|
|
|
/// Captures HTTP request/response pairs and writes them to the traffic database.
|
|
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
|
|
|
|
private let hardcodedDebugDomain = "okcupid"
|
|
private let hardcodedDebugNeedle = "jill"
|
|
|
|
init(trafficRepo: TrafficRepository, domain: String, scheme: String = "https") {
|
|
self.trafficRepo = trafficRepo
|
|
self.domain = domain
|
|
self.scheme = scheme
|
|
ProxyLogger.capture.debug("HTTPCaptureHandler created for \(domain) (\(scheme))")
|
|
}
|
|
|
|
// MARK: - Outbound (Request)
|
|
|
|
func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise<Void>?) {
|
|
let part = unwrapOutboundIn(data)
|
|
|
|
switch part {
|
|
case .head(var head):
|
|
currentRequestId = UUID().uuidString
|
|
requestBody = Data()
|
|
requestStartTime = Date().timeIntervalSince1970
|
|
|
|
if RulesEngine.shouldStripCache() {
|
|
head.headers.remove(name: "If-Modified-Since")
|
|
head.headers.remove(name: "If-None-Match")
|
|
head.headers.replaceOrAdd(name: "Cache-Control", value: "no-cache")
|
|
head.headers.replaceOrAdd(name: "Pragma", value: "no-cache")
|
|
}
|
|
|
|
requestHead = head
|
|
ProxyLogger.capture.info("CAPTURE REQ \(head.method.rawValue) \(self.scheme)://\(self.domain)\(head.uri)")
|
|
context.write(self.wrapOutboundOut(.head(head)), promise: promise)
|
|
return
|
|
case .body(.byteBuffer(let buffer)):
|
|
if requestBody.count < ProxyConstants.maxBodySizeBytes {
|
|
requestBody.append(contentsOf: buffer.readableBytesView)
|
|
}
|
|
ProxyLogger.capture.debug("CAPTURE REQ body chunk: \(buffer.readableBytes) bytes (total: \(self.requestBody.count))")
|
|
case .end:
|
|
ProxyLogger.capture.debug("CAPTURE REQ end — saving to DB")
|
|
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(var head):
|
|
if RulesEngine.shouldStripCache() {
|
|
head.headers.remove(name: "Expires")
|
|
head.headers.remove(name: "Last-Modified")
|
|
head.headers.remove(name: "ETag")
|
|
head.headers.replaceOrAdd(name: "Expires", value: "0")
|
|
head.headers.replaceOrAdd(name: "Cache-Control", value: "no-cache")
|
|
}
|
|
|
|
responseHead = head
|
|
responseBody = Data()
|
|
ProxyLogger.capture.info("CAPTURE RESP \(head.status.code) for \(self.domain)")
|
|
context.fireChannelRead(NIOAny(HTTPClientResponsePart.head(head)))
|
|
return
|
|
case .body(let buffer):
|
|
if responseBody.count < ProxyConstants.maxBodySizeBytes {
|
|
responseBody.append(contentsOf: buffer.readableBytesView)
|
|
}
|
|
ProxyLogger.capture.debug("CAPTURE RESP body chunk: \(buffer.readableBytes) bytes (total: \(self.responseBody.count))")
|
|
case .end:
|
|
ProxyLogger.capture.debug("CAPTURE RESP end — saving to DB")
|
|
saveResponse()
|
|
}
|
|
|
|
context.fireChannelRead(data)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func saveRequest() {
|
|
guard let head = requestHead, let reqId = currentRequestId else {
|
|
ProxyLogger.capture.error("saveRequest: no head or requestId!")
|
|
return
|
|
}
|
|
|
|
let url = "\(scheme)://\(domain)\(head.uri)"
|
|
let headersJSON = encodeHeaders(head.headers)
|
|
let queryParams = extractQueryParams(from: head.uri)
|
|
let shouldHide =
|
|
(IPCManager.shared.hideSystemTraffic && SystemTrafficFilter.isSystemDomain(domain)) ||
|
|
RulesEngine.checkBlockList(url: url, method: head.method.rawValue) == .hideOnly
|
|
|
|
let headerCount = head.headers.count
|
|
let bodySize = requestBody.count
|
|
|
|
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",
|
|
isHidden: shouldHide
|
|
)
|
|
|
|
do {
|
|
try trafficRepo.insert(&traffic)
|
|
ProxyLogger.capture.info("DB INSERT OK: \(head.method.rawValue) \(self.domain) headers=\(headerCount) body=\(bodySize)B id=\(reqId)")
|
|
} catch {
|
|
ProxyLogger.capture.error("DB INSERT FAILED: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
private func saveResponse() {
|
|
guard let reqId = currentRequestId, let head = responseHead else {
|
|
ProxyLogger.capture.error("saveResponse: no requestId or responseHead!")
|
|
return
|
|
}
|
|
|
|
let now = Date().timeIntervalSince1970
|
|
let durationMs = Int((now - requestStartTime) * 1000)
|
|
let headerCount = head.headers.count
|
|
let bodySize = responseBody.count
|
|
|
|
do {
|
|
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
|
|
)
|
|
ProxyLogger.capture.info("DB UPDATE OK: \(head.status.code) \(self.domain) headers=\(headerCount) body=\(bodySize)B duration=\(durationMs)ms id=\(reqId)")
|
|
} catch {
|
|
ProxyLogger.capture.error("DB UPDATE FAILED for \(reqId): \(error.localizedDescription)")
|
|
}
|
|
|
|
logHardcodedBodyDebug(responseHead: head, requestId: reqId)
|
|
|
|
// Debounce — don't flood with notifications for every single response
|
|
NotificationThrottle.shared.throttle()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private func logHardcodedBodyDebug(responseHead: HTTPResponseHead, requestId: String) {
|
|
let responseHeaders = headerDictionary(from: responseHead.headers)
|
|
let decodedBody = HTTPBodyDecoder.decodedBodyData(from: responseBody, headers: responseHeaders)
|
|
let searchableBody = HTTPBodyDecoder.searchableText(from: responseBody, headers: responseHeaders) ?? ""
|
|
let preview = decodedBodyPreview(headers: responseHeaders)
|
|
|
|
guard domain.localizedCaseInsensitiveContains(hardcodedDebugDomain) ||
|
|
requestHead?.uri.localizedCaseInsensitiveContains(hardcodedDebugDomain) == true ||
|
|
preview.localizedCaseInsensitiveContains(hardcodedDebugNeedle) else {
|
|
return
|
|
}
|
|
|
|
let contentType = responseHead.headers.first(name: "Content-Type") ?? "nil"
|
|
let contentEncoding = responseHead.headers.first(name: "Content-Encoding") ?? "nil"
|
|
let containsNeedle = searchableBody.localizedCaseInsensitiveContains(hardcodedDebugNeedle)
|
|
let decodingHint = HTTPBodyDecoder.decodingHint(for: responseBody, headers: responseHeaders)
|
|
|
|
ProxyLogger.capture.info(
|
|
"""
|
|
HARDCODED DEBUG capture domain=\(self.domain) id=\(requestId) status=\(responseHead.status.code) \
|
|
contentType=\(contentType) contentEncoding=\(contentEncoding) bodyBytes=\(self.responseBody.count) \
|
|
decodedBytes=\(decodedBody?.count ?? 0) decoding=\(decodingHint) containsNeedle=\(containsNeedle)
|
|
"""
|
|
)
|
|
|
|
if containsNeedle {
|
|
ProxyLogger.capture.info("HARDCODED DEBUG MATCH needle=\(self.hardcodedDebugNeedle) preview=\(preview)")
|
|
} else {
|
|
ProxyLogger.capture.info("HARDCODED DEBUG NO_MATCH needle=\(self.hardcodedDebugNeedle) preview=\(preview)")
|
|
}
|
|
}
|
|
|
|
private func decodedSearchableBody(headers: [String: String]) -> String {
|
|
HTTPBodyDecoder.searchableText(from: responseBody, headers: headers) ?? ""
|
|
}
|
|
|
|
private func decodedBodyPreview(headers: [String: String]) -> String {
|
|
let raw = decodedSearchableBody(headers: headers)
|
|
.replacingOccurrences(of: "\n", with: " ")
|
|
.replacingOccurrences(of: "\r", with: " ")
|
|
return String(raw.prefix(240))
|
|
}
|
|
|
|
private func headerDictionary(from headers: HTTPHeaders) -> [String: String] {
|
|
var dictionary: [String: String] = [:]
|
|
for (name, value) in headers {
|
|
dictionary[name] = value
|
|
}
|
|
return dictionary
|
|
}
|
|
}
|