- 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
411 lines
19 KiB
Swift
411 lines
19 KiB
Swift
import Foundation
|
|
import NIOCore
|
|
import NIOPosix
|
|
import NIOSSL
|
|
import NIOHTTP1
|
|
|
|
final class MITMHandler: ChannelInboundHandler, RemovableChannelHandler {
|
|
typealias InboundIn = ByteBuffer
|
|
|
|
private let originalHost: String
|
|
private let upstreamHost: String
|
|
private let port: Int
|
|
private let trafficRepo: TrafficRepository
|
|
private let certManager: CertificateManager
|
|
private let runtimeStatusRepo = RuntimeStatusRepository()
|
|
|
|
init(
|
|
originalHost: String,
|
|
upstreamHost: String,
|
|
port: Int,
|
|
trafficRepo: TrafficRepository,
|
|
certManager: CertificateManager = .shared
|
|
) {
|
|
self.originalHost = originalHost
|
|
self.upstreamHost = upstreamHost
|
|
self.port = port
|
|
self.trafficRepo = trafficRepo
|
|
self.certManager = certManager
|
|
ProxyLogger.mitm.info("MITMHandler created original=\(originalHost) upstream=\(upstreamHost):\(port)")
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
var buffer = unwrapInboundIn(data)
|
|
let bufferSize = buffer.readableBytes
|
|
|
|
let sniDomain = extractSNI(from: buffer) ?? originalHost
|
|
ProxyLogger.mitm.info("MITM ClientHello: \(bufferSize) bytes, SNI=\(sniDomain) (fallback host=\(self.originalHost))")
|
|
|
|
context.pipeline.removeHandler(self, promise: nil)
|
|
|
|
let sslContext: NIOSSLContext
|
|
do {
|
|
sslContext = try certManager.tlsServerContext(for: sniDomain)
|
|
ProxyLogger.mitm.info("MITM TLS context created for \(sniDomain)")
|
|
} catch {
|
|
ProxyLogger.mitm.error("MITM TLS context FAILED for \(sniDomain): \(error.localizedDescription)")
|
|
runtimeStatusRepo.update {
|
|
$0.lastMITMError = "TLS context \(sniDomain): \(error.localizedDescription)"
|
|
}
|
|
context.close(promise: nil)
|
|
return
|
|
}
|
|
|
|
let sslServerHandler = NIOSSLServerHandler(context: sslContext)
|
|
let trafficRepo = self.trafficRepo
|
|
let originalHost = self.originalHost
|
|
let upstreamHost = self.upstreamHost
|
|
let port = self.port
|
|
let runtimeStatusRepo = self.runtimeStatusRepo
|
|
let tlsErrorHandler = TLSErrorLogger(label: "CLIENT-SIDE", domain: sniDomain, runtimeStatusRepo: runtimeStatusRepo)
|
|
|
|
context.channel.pipeline.addHandler(sslServerHandler, position: .first).flatMap {
|
|
// Add TLS error logger right after the SSL handler to catch handshake failures
|
|
context.channel.pipeline.addHandler(tlsErrorHandler)
|
|
}.flatMap {
|
|
context.channel.pipeline.addHandler(ByteToMessageHandler(HTTPRequestDecoder()))
|
|
}.flatMap {
|
|
context.channel.pipeline.addHandler(HTTPResponseEncoder())
|
|
}.flatMap {
|
|
context.channel.pipeline.addHandler(
|
|
MITMForwardHandler(
|
|
remoteHost: upstreamHost,
|
|
remotePort: port,
|
|
originalDomain: originalHost,
|
|
trafficRepo: trafficRepo
|
|
)
|
|
)
|
|
}.whenComplete { result in
|
|
switch result {
|
|
case .success:
|
|
ProxyLogger.mitm.info("MITM pipeline installed for \(sniDomain), re-firing ClientHello (\(bufferSize) bytes)")
|
|
context.channel.pipeline.fireChannelRead(NIOAny(buffer))
|
|
case .failure(let error):
|
|
ProxyLogger.mitm.error("MITM pipeline setup FAILED for \(sniDomain): \(error)")
|
|
runtimeStatusRepo.update {
|
|
$0.lastMITMError = "Pipeline setup \(sniDomain): \(error.localizedDescription)"
|
|
}
|
|
context.close(promise: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func extractSNI(from buffer: ByteBuffer) -> String? {
|
|
var buf = buffer
|
|
guard buf.readableBytes >= 43 else { return nil }
|
|
guard buf.readInteger(as: UInt8.self) == 0x16 else { return nil }
|
|
let _ = buf.readInteger(as: UInt16.self)
|
|
let _ = buf.readInteger(as: UInt16.self)
|
|
guard buf.readInteger(as: UInt8.self) == 0x01 else { return nil }
|
|
let _ = buf.readBytes(length: 3)
|
|
let _ = buf.readInteger(as: UInt16.self)
|
|
guard buf.readBytes(length: 32) != nil else { return nil }
|
|
guard let sessionIdLen = buf.readInteger(as: UInt8.self) else { return nil }
|
|
guard buf.readBytes(length: Int(sessionIdLen)) != nil else { return nil }
|
|
guard let cipherSuitesLen = buf.readInteger(as: UInt16.self) else { return nil }
|
|
guard buf.readBytes(length: Int(cipherSuitesLen)) != nil else { return nil }
|
|
guard let compMethodsLen = buf.readInteger(as: UInt8.self) else { return nil }
|
|
guard buf.readBytes(length: Int(compMethodsLen)) != nil else { return nil }
|
|
guard let extensionsLen = buf.readInteger(as: UInt16.self) else { return nil }
|
|
var extensionsRemaining = Int(extensionsLen)
|
|
|
|
while extensionsRemaining > 4 {
|
|
guard let extType = buf.readInteger(as: UInt16.self),
|
|
let extLen = buf.readInteger(as: UInt16.self) else { return nil }
|
|
extensionsRemaining -= 4 + Int(extLen)
|
|
|
|
if extType == 0x0000 {
|
|
guard let _ = buf.readInteger(as: UInt16.self),
|
|
let nameType = buf.readInteger(as: UInt8.self),
|
|
nameType == 0x00,
|
|
let nameLen = buf.readInteger(as: UInt16.self),
|
|
let nameBytes = buf.readBytes(length: Int(nameLen)) else { return nil }
|
|
let name = String(bytes: nameBytes, encoding: .utf8)
|
|
ProxyLogger.mitm.debug("SNI extracted: \(name ?? "nil")")
|
|
return name
|
|
} else {
|
|
guard buf.readBytes(length: Int(extLen)) != nil else { return nil }
|
|
}
|
|
}
|
|
ProxyLogger.mitm.debug("SNI: not found in ClientHello")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - MITMForwardHandler
|
|
|
|
final class MITMForwardHandler: ChannelInboundHandler, RemovableChannelHandler {
|
|
typealias InboundIn = HTTPServerRequestPart
|
|
typealias OutboundOut = HTTPServerResponsePart
|
|
|
|
private let remoteHost: String
|
|
private let remotePort: Int
|
|
private let originalDomain: String
|
|
private let trafficRepo: TrafficRepository
|
|
private let runtimeStatusRepo = RuntimeStatusRepository()
|
|
private var remoteChannel: Channel?
|
|
private var pendingParts: [HTTPServerRequestPart] = []
|
|
private var isConnected = false
|
|
|
|
init(remoteHost: String, remotePort: Int, originalDomain: String, trafficRepo: TrafficRepository) {
|
|
self.remoteHost = remoteHost
|
|
self.remotePort = remotePort
|
|
self.originalDomain = originalDomain
|
|
self.trafficRepo = trafficRepo
|
|
}
|
|
|
|
func handlerAdded(context: ChannelHandlerContext) {
|
|
ProxyLogger.mitm.info("MITMForward: connecting to upstream \(self.remoteHost):\(self.remotePort)")
|
|
connectToRemote(context: context)
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
let part = unwrapInboundIn(data)
|
|
|
|
if isConnected, let remote = remoteChannel {
|
|
switch part {
|
|
case .head(let head):
|
|
ProxyLogger.mitm.info("MITMForward: decrypted request \(head.method.rawValue) \(head.uri)")
|
|
var clientHead = HTTPRequestHead(version: head.version, method: head.method, uri: head.uri, headers: head.headers)
|
|
if !clientHead.headers.contains(name: "Host") {
|
|
clientHead.headers.add(name: "Host", value: originalDomain)
|
|
}
|
|
runtimeStatusRepo.update {
|
|
$0.lastSuccessfulMITMDomain = self.originalDomain
|
|
$0.lastSuccessfulMITMAt = Date().timeIntervalSince1970
|
|
$0.lastMITMError = nil
|
|
}
|
|
remote.write(NIOAny(HTTPClientRequestPart.head(clientHead)), promise: nil)
|
|
case .body(let buffer):
|
|
remote.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(buffer))), promise: nil)
|
|
case .end(let trailers):
|
|
remote.writeAndFlush(NIOAny(HTTPClientRequestPart.end(trailers)), promise: nil)
|
|
}
|
|
} else {
|
|
ProxyLogger.mitm.debug("MITMForward: buffering request part (not connected yet)")
|
|
pendingParts.append(part)
|
|
}
|
|
}
|
|
|
|
func channelInactive(context: ChannelHandlerContext) {
|
|
ProxyLogger.mitm.debug("MITMForward: client channel inactive")
|
|
remoteChannel?.close(promise: nil)
|
|
}
|
|
|
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
|
ProxyLogger.mitm.error("MITMForward error: \(error.localizedDescription)")
|
|
runtimeStatusRepo.update {
|
|
$0.lastMITMError = "Forwarding \(self.originalDomain): \(error.localizedDescription)"
|
|
}
|
|
context.close(promise: nil)
|
|
remoteChannel?.close(promise: nil)
|
|
}
|
|
|
|
private func connectToRemote(context: ChannelHandlerContext) {
|
|
let captureHandler = HTTPCaptureHandler(trafficRepo: trafficRepo, domain: originalDomain, scheme: "https")
|
|
let clientContext = context
|
|
|
|
do {
|
|
let tlsConfig = TLSConfiguration.makeClientConfiguration()
|
|
let sslContext = try NIOSSLContext(configuration: tlsConfig)
|
|
|
|
ClientBootstrap(group: context.eventLoop)
|
|
.channelOption(.socketOption(.so_reuseaddr), value: 1)
|
|
.channelInitializer { channel in
|
|
let sniHandler: NIOSSLClientHandler
|
|
do {
|
|
sniHandler = try NIOSSLClientHandler(context: sslContext, serverHostname: self.originalDomain)
|
|
} catch {
|
|
ProxyLogger.mitm.error("NIOSSLClientHandler init FAILED: \(error.localizedDescription)")
|
|
self.runtimeStatusRepo.update {
|
|
$0.lastMITMError = "Client TLS handler \(self.originalDomain): \(error.localizedDescription)"
|
|
}
|
|
channel.close(promise: nil)
|
|
return channel.eventLoop.makeFailedFuture(error)
|
|
}
|
|
let upstreamTLSLogger = TLSErrorLogger(label: "UPSTREAM", domain: self.originalDomain, runtimeStatusRepo: self.runtimeStatusRepo)
|
|
return channel.pipeline.addHandler(sniHandler).flatMap {
|
|
channel.pipeline.addHandler(upstreamTLSLogger)
|
|
}.flatMap {
|
|
channel.pipeline.addHandler(HTTPRequestEncoder())
|
|
}.flatMap {
|
|
channel.pipeline.addHandler(ByteToMessageHandler(HTTPResponseDecoder()))
|
|
}.flatMap {
|
|
channel.pipeline.addHandler(captureHandler)
|
|
}.flatMap {
|
|
channel.pipeline.addHandler(MITMRelayHandler(clientContext: clientContext))
|
|
}
|
|
}
|
|
.connect(host: remoteHost, port: remotePort)
|
|
.whenComplete { result in
|
|
switch result {
|
|
case .success(let channel):
|
|
ProxyLogger.mitm.info("MITMForward: upstream connected to \(self.remoteHost):\(self.remotePort)")
|
|
self.remoteChannel = channel
|
|
self.isConnected = true
|
|
self.flushPending(remote: channel)
|
|
case .failure(let error):
|
|
ProxyLogger.mitm.error("MITMForward: upstream connect FAILED \(self.remoteHost):\(self.remotePort): \(error.localizedDescription)")
|
|
self.runtimeStatusRepo.update {
|
|
$0.lastMITMError = "Upstream \(self.originalDomain): \(error.localizedDescription)"
|
|
}
|
|
clientContext.close(promise: nil)
|
|
}
|
|
}
|
|
} catch {
|
|
ProxyLogger.mitm.error("MITMForward: TLS context creation FAILED: \(error.localizedDescription)")
|
|
runtimeStatusRepo.update {
|
|
$0.lastMITMError = "TLS configuration \(self.originalDomain): \(error.localizedDescription)"
|
|
}
|
|
context.close(promise: nil)
|
|
}
|
|
}
|
|
|
|
private func flushPending(remote: Channel) {
|
|
ProxyLogger.mitm.debug("MITMForward: flushing \(self.pendingParts.count) buffered parts")
|
|
for part in pendingParts {
|
|
switch part {
|
|
case .head(let head):
|
|
var clientHead = HTTPRequestHead(version: head.version, method: head.method, uri: head.uri, headers: head.headers)
|
|
if !clientHead.headers.contains(name: "Host") {
|
|
clientHead.headers.add(name: "Host", value: originalDomain)
|
|
}
|
|
remote.write(NIOAny(HTTPClientRequestPart.head(clientHead)), promise: nil)
|
|
case .body(let buffer):
|
|
remote.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(buffer))), promise: nil)
|
|
case .end(let trailers):
|
|
remote.writeAndFlush(NIOAny(HTTPClientRequestPart.end(trailers)), promise: nil)
|
|
}
|
|
}
|
|
pendingParts.removeAll()
|
|
}
|
|
}
|
|
|
|
// MARK: - MITMRelayHandler
|
|
|
|
final class MITMRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
|
|
typealias InboundIn = HTTPClientResponsePart
|
|
|
|
private let clientContext: ChannelHandlerContext
|
|
|
|
init(clientContext: ChannelHandlerContext) {
|
|
self.clientContext = clientContext
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
let part = unwrapInboundIn(data)
|
|
switch part {
|
|
case .head(let head):
|
|
ProxyLogger.mitm.debug("MITMRelay response: \(head.status.code)")
|
|
clientContext.write(NIOAny(HTTPServerResponsePart.head(HTTPResponseHead(version: head.version, status: head.status, headers: head.headers))), promise: nil)
|
|
case .body(let buffer):
|
|
clientContext.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil)
|
|
case .end(let trailers):
|
|
clientContext.writeAndFlush(NIOAny(HTTPServerResponsePart.end(trailers)), promise: nil)
|
|
}
|
|
}
|
|
|
|
func channelInactive(context: ChannelHandlerContext) {
|
|
ProxyLogger.mitm.debug("MITMRelay: remote inactive")
|
|
clientContext.close(promise: nil)
|
|
}
|
|
|
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
|
ProxyLogger.mitm.error("MITMRelay error: \(error.localizedDescription)")
|
|
RuntimeStatusRepository().update {
|
|
$0.lastMITMError = "Relay response: \(error.localizedDescription)"
|
|
}
|
|
context.close(promise: nil)
|
|
clientContext.close(promise: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - TLSErrorLogger
|
|
|
|
/// Catches and logs TLS handshake errors with detailed context.
|
|
/// Placed right after NIOSSLServerHandler/NIOSSLClientHandler in the pipeline.
|
|
final class TLSErrorLogger: ChannelInboundHandler, RemovableChannelHandler {
|
|
typealias InboundIn = NIOAny
|
|
|
|
private let label: String
|
|
private let domain: String
|
|
private let runtimeStatusRepo: RuntimeStatusRepository
|
|
|
|
init(label: String, domain: String, runtimeStatusRepo: RuntimeStatusRepository) {
|
|
self.label = label
|
|
self.domain = domain
|
|
self.runtimeStatusRepo = runtimeStatusRepo
|
|
}
|
|
|
|
func channelActive(context: ChannelHandlerContext) {
|
|
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): channel active (handshake starting)")
|
|
context.fireChannelActive()
|
|
}
|
|
|
|
func channelInactive(context: ChannelHandlerContext) {
|
|
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): channel inactive")
|
|
context.fireChannelInactive()
|
|
}
|
|
|
|
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
|
// TLS handshake completed if we're getting data through
|
|
context.fireChannelRead(data)
|
|
}
|
|
|
|
func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) {
|
|
if let tlsEvent = event as? NIOSSLVerificationCallback {
|
|
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): verification callback triggered")
|
|
}
|
|
// Check for handshake completion by string matching the event type
|
|
let eventDesc = String(describing: event)
|
|
if eventDesc.contains("handshakeCompleted") {
|
|
ProxyLogger.mitm.info("TLS[\(self.label)] \(self.domain): HANDSHAKE COMPLETED event=\(eventDesc)")
|
|
} else {
|
|
ProxyLogger.mitm.debug("TLS[\(self.label)] \(self.domain): user event=\(eventDesc)")
|
|
}
|
|
context.fireUserInboundEventTriggered(event)
|
|
}
|
|
|
|
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
|
let errorDesc = String(describing: error)
|
|
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): ERROR \(errorDesc)")
|
|
|
|
// Categorize and detect SSL pinning
|
|
let lowerError = errorDesc.lowercased()
|
|
var isPinningLikely = false
|
|
var category = "UNKNOWN"
|
|
|
|
if lowerError.contains("certificate") || lowerError.contains("trust") {
|
|
category = "CERTIFICATE_TRUST"
|
|
isPinningLikely = label == "CLIENT-SIDE"
|
|
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): CERTIFICATE TRUST ISSUE — client likely doesn't trust our CA")
|
|
} else if lowerError.contains("handshake") {
|
|
category = "HANDSHAKE_FAILURE"
|
|
isPinningLikely = label == "CLIENT-SIDE"
|
|
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): HANDSHAKE FAILURE — protocol mismatch or cert rejected")
|
|
} else if lowerError.contains("eof") || lowerError.contains("reset") || lowerError.contains("closed") || lowerError.contains("connection") {
|
|
category = "CONNECTION_RESET"
|
|
isPinningLikely = label == "CLIENT-SIDE"
|
|
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): CONNECTION RESET during handshake (SSL pinning suspected)")
|
|
} else if lowerError.contains("unrecognized") || lowerError.contains("alert") || lowerError.contains("fatal") {
|
|
category = "TLS_ALERT"
|
|
isPinningLikely = true
|
|
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): TLS ALERT — peer sent alert (unknown_ca / bad_certificate)")
|
|
}
|
|
|
|
// If this is a client-side error (the app rejected our cert), it's likely SSL pinning.
|
|
// Auto-record this domain as pinned so future connections use passthrough.
|
|
if isPinningLikely && label == "CLIENT-SIDE" {
|
|
let reason = "TLS \(category): \(String(errorDesc.prefix(200)))"
|
|
PinnedDomainRepository().markPinned(domain: domain, reason: reason)
|
|
ProxyLogger.mitm.error("TLS[\(self.label)] \(self.domain): AUTO-PINNED — future connections will use passthrough")
|
|
}
|
|
|
|
runtimeStatusRepo.update {
|
|
$0.lastMITMError = "TLS[\(self.label)] \(self.domain) [\(category)]: \(String(errorDesc.prefix(200)))"
|
|
}
|
|
|
|
context.fireErrorCaught(error)
|
|
}
|
|
}
|