Add iPad support, auto-pinning, and comprehensive logging
- 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
This commit is contained in:
@@ -3,7 +3,6 @@ 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
|
||||
@@ -21,10 +20,14 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
|
||||
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)
|
||||
@@ -33,16 +36,29 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
|
||||
let part = unwrapOutboundIn(data)
|
||||
|
||||
switch part {
|
||||
case .head(let head):
|
||||
case .head(var head):
|
||||
currentRequestId = UUID().uuidString
|
||||
requestHead = head
|
||||
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
|
||||
@@ -57,14 +73,27 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
|
||||
let part = unwrapInboundIn(data)
|
||||
|
||||
switch part {
|
||||
case .head(let head):
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -74,56 +103,79 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
|
||||
// MARK: - Persistence
|
||||
|
||||
private func saveRequest() {
|
||||
guard let head = requestHead, let reqId = currentRequestId else { return }
|
||||
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,
|
||||
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"
|
||||
isSslDecrypted: scheme == "https",
|
||||
isHidden: shouldHide
|
||||
)
|
||||
|
||||
try? trafficRepo.insert(&traffic)
|
||||
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 { return }
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
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)")
|
||||
}
|
||||
|
||||
IPCManager.shared.post(.newTrafficCaptured)
|
||||
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
|
||||
}
|
||||
for (name, value) in headers { dict[name] = value }
|
||||
guard let data = try? JSONEncoder().encode(dict) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
@@ -132,10 +184,59 @@ final class HTTPCaptureHandler: ChannelDuplexHandler {
|
||||
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 ?? ""
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user