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:
@@ -4,19 +4,21 @@ import NIOPosix
|
||||
import NIOHTTP1
|
||||
|
||||
/// Handles incoming proxy requests:
|
||||
/// - HTTP CONNECT → establishes TCP tunnel (GlueHandler passthrough, or MITM in Phase 3)
|
||||
/// - Plain HTTP → connects upstream, forwards request, captures request+response
|
||||
/// - HTTP CONNECT -> TCP tunnel (GlueHandler passthrough or MITM)
|
||||
/// - Plain HTTP -> forward with capture
|
||||
final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||||
typealias InboundIn = HTTPServerRequestPart
|
||||
typealias OutboundOut = HTTPServerResponsePart
|
||||
|
||||
private let trafficRepo: TrafficRepository
|
||||
private let runtimeStatusRepo = RuntimeStatusRepository()
|
||||
|
||||
private var pendingConnectHead: HTTPRequestHead?
|
||||
private var pendingConnectBytes: [ByteBuffer] = []
|
||||
|
||||
// Buffer request parts until we've connected upstream
|
||||
private var pendingHead: HTTPRequestHead?
|
||||
private var pendingBody: [ByteBuffer] = []
|
||||
private var pendingEnd: HTTPHeaders?
|
||||
private var receivedEnd = false
|
||||
|
||||
init(trafficRepo: TrafficRepository) {
|
||||
self.trafficRepo = trafficRepo
|
||||
@@ -28,145 +30,400 @@ final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||||
switch part {
|
||||
case .head(let head):
|
||||
if head.method == .CONNECT {
|
||||
handleConnect(context: context, head: head)
|
||||
ProxyLogger.connect.info("CONNECT \(head.uri)")
|
||||
pendingConnectHead = head
|
||||
pendingConnectBytes.removeAll()
|
||||
} else {
|
||||
ProxyLogger.connect.info("HTTP \(head.method.rawValue) \(head.uri)")
|
||||
pendingHead = head
|
||||
pendingBody.removeAll()
|
||||
pendingEnd = nil
|
||||
}
|
||||
|
||||
case .body(let buffer):
|
||||
pendingBody.append(buffer)
|
||||
if pendingConnectHead != nil {
|
||||
pendingConnectBytes.append(buffer)
|
||||
} else {
|
||||
pendingBody.append(buffer)
|
||||
}
|
||||
|
||||
case .end(let trailers):
|
||||
if let connectHead = pendingConnectHead {
|
||||
let bufferedBytes = pendingConnectBytes
|
||||
pendingConnectHead = nil
|
||||
pendingConnectBytes.removeAll()
|
||||
handleConnect(context: context, head: connectHead, initialBuffers: bufferedBytes)
|
||||
return
|
||||
}
|
||||
|
||||
if pendingHead != nil {
|
||||
pendingEnd = trailers
|
||||
receivedEnd = true
|
||||
handleHTTPRequest(context: context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CONNECT (HTTPS tunnel)
|
||||
// MARK: - CONNECT
|
||||
|
||||
private func handleConnect(context: ChannelHandlerContext, head: HTTPRequestHead) {
|
||||
private func handleConnect(
|
||||
context: ChannelHandlerContext,
|
||||
head: HTTPRequestHead,
|
||||
initialBuffers: [ByteBuffer]
|
||||
) {
|
||||
let components = head.uri.split(separator: ":")
|
||||
let host = String(components[0])
|
||||
let originalHost = String(components[0])
|
||||
let port = components.count > 1 ? Int(components[1]) ?? 443 : 443
|
||||
let connectURL = "https://\(originalHost):\(port)"
|
||||
|
||||
// Check if this domain should be MITM'd (SSL Proxying enabled + domain in include list)
|
||||
let shouldMITM = shouldInterceptSSL(domain: host)
|
||||
|
||||
// Send 200 Connection Established
|
||||
let responseHead = HTTPResponseHead(version: .http1_1, status: .ok)
|
||||
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
|
||||
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
|
||||
|
||||
if shouldMITM {
|
||||
// MITM mode: strip HTTP handlers, install MITMHandler
|
||||
setupMITM(context: context, host: host, port: port)
|
||||
} else {
|
||||
// Passthrough mode: record domain-level entry, tunnel raw bytes
|
||||
recordConnectTraffic(host: host, port: port)
|
||||
|
||||
// We don't need to connect upstream ourselves — GlueHandler does raw forwarding
|
||||
// But GlueHandler pairs two channels, so we need the remote channel first
|
||||
ClientBootstrap(group: context.eventLoop)
|
||||
.channelOption(.socketOption(.so_reuseaddr), value: 1)
|
||||
.connect(host: host, port: port)
|
||||
.whenComplete { result in
|
||||
switch result {
|
||||
case .success(let remoteChannel):
|
||||
self.setupGlue(context: context, remoteChannel: remoteChannel)
|
||||
case .failure(let error):
|
||||
print("[Proxy] CONNECT passthrough failed to \(host):\(port): \(error)")
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldInterceptSSL(domain: String) -> Bool {
|
||||
guard IPCManager.shared.isSSLProxyingEnabled else { return false }
|
||||
guard CertificateManager.shared.hasCA else { return false }
|
||||
|
||||
// Check SSL proxying list from database
|
||||
let rulesRepo = RulesRepository()
|
||||
do {
|
||||
let entries = try rulesRepo.fetchAllSSLEntries()
|
||||
|
||||
// Check exclude list first
|
||||
for entry in entries where !entry.isInclude {
|
||||
if WildcardMatcher.matches(domain, pattern: entry.domainPattern) {
|
||||
return false
|
||||
if let blockAction = RulesEngine.checkBlockList(url: connectURL, method: "CONNECT"),
|
||||
blockAction != .hideOnly {
|
||||
ProxyLogger.connect.info("BLOCKED \(originalHost) action=\(blockAction.rawValue)")
|
||||
if blockAction == .blockAndDisplay {
|
||||
var traffic = CapturedTraffic(
|
||||
domain: originalHost, url: connectURL, method: "CONNECT", scheme: "https",
|
||||
statusCode: 403, statusText: "Blocked",
|
||||
startedAt: Date().timeIntervalSince1970,
|
||||
completedAt: Date().timeIntervalSince1970, durationMs: 0, isSslDecrypted: false
|
||||
)
|
||||
do {
|
||||
try trafficRepo.insert(&traffic)
|
||||
IPCManager.shared.post(.newTrafficCaptured)
|
||||
} catch {
|
||||
ProxyLogger.db.error("DB insert blocked traffic failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
// Check include list
|
||||
let responseHead = HTTPResponseHead(version: .http1_1, status: .forbidden)
|
||||
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
|
||||
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let upstreamHost = RulesEngine.checkDNSSpoof(domain: originalHost) ?? originalHost
|
||||
let shouldMITM = shouldInterceptSSL(domain: originalHost)
|
||||
let shouldHide = shouldHideConnect(url: connectURL, host: originalHost)
|
||||
ProxyLogger.connect.info("=== CONNECT original=\(originalHost) upstream=\(upstreamHost):\(port) mitm=\(shouldMITM) ===")
|
||||
|
||||
if shouldMITM {
|
||||
upgradeToMITM(
|
||||
context: context,
|
||||
originalHost: originalHost,
|
||||
upstreamHost: upstreamHost,
|
||||
port: port,
|
||||
initialBuffers: initialBuffers
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
ClientBootstrap(group: context.eventLoop)
|
||||
.channelOption(.socketOption(.so_reuseaddr), value: 1)
|
||||
.channelOption(.autoRead, value: false)
|
||||
.connect(host: upstreamHost, port: port)
|
||||
.whenComplete { result in
|
||||
switch result {
|
||||
case .success(let remoteChannel):
|
||||
ProxyLogger.connect.info("Upstream connected to \(upstreamHost):\(port), upgrading to passthrough")
|
||||
self.upgradeToPassthrough(
|
||||
context: context,
|
||||
remoteChannel: remoteChannel,
|
||||
originalHost: originalHost,
|
||||
upstreamHost: upstreamHost,
|
||||
port: port,
|
||||
initialBuffers: initialBuffers,
|
||||
isHidden: shouldHide
|
||||
)
|
||||
|
||||
case .failure(let error):
|
||||
ProxyLogger.connect.error("Upstream connect FAILED \(upstreamHost):\(port): \(error.localizedDescription)")
|
||||
self.runtimeStatusRepo.update {
|
||||
$0.lastConnectError = "CONNECT \(originalHost): \(error.localizedDescription)"
|
||||
}
|
||||
let responseHead = HTTPResponseHead(version: .http1_1, status: .badGateway)
|
||||
context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
|
||||
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
|
||||
context.close(promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldInterceptSSL(domain: String) -> Bool {
|
||||
let sslEnabled = IPCManager.shared.isSSLProxyingEnabled
|
||||
let hasCA = CertificateManager.shared.hasCA
|
||||
ProxyLogger.connect.info("shouldInterceptSSL(\(domain)): sslEnabled=\(sslEnabled) hasCA=\(hasCA)")
|
||||
|
||||
// Write diagnostic info so the app can display what the extension sees
|
||||
runtimeStatusRepo.update {
|
||||
$0.caFingerprint = CertificateManager.shared.caFingerprint
|
||||
$0.lastConnectError = "SSL check: domain=\(domain) sslEnabled=\(sslEnabled) hasCA=\(hasCA)"
|
||||
}
|
||||
|
||||
guard sslEnabled else {
|
||||
ProxyLogger.connect.info("SSL proxying DISABLED globally — skipping MITM")
|
||||
runtimeStatusRepo.update {
|
||||
$0.lastMITMError = "SSL proxying disabled (sslEnabled=false in DB)"
|
||||
}
|
||||
return false
|
||||
}
|
||||
guard hasCA else {
|
||||
ProxyLogger.connect.info("Shared CA unavailable in extension — skipping MITM")
|
||||
runtimeStatusRepo.update {
|
||||
$0.lastMITMError = "No CA in extension (hasCA=false)"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if domain was auto-detected as using SSL pinning
|
||||
if PinnedDomainRepository().isPinned(domain: domain) {
|
||||
ProxyLogger.connect.info("SSL PINNED (auto-detected): \(domain) — using passthrough")
|
||||
runtimeStatusRepo.update {
|
||||
$0.lastMITMError = "Pinned domain (auto-fallback): \(domain)"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let rulesRepo = RulesRepository()
|
||||
do {
|
||||
let entries = try rulesRepo.fetchAllSSLEntries()
|
||||
let includeCount = entries.filter(\.isInclude).count
|
||||
let excludeCount = entries.filter { !$0.isInclude }.count
|
||||
let patterns = entries.map { "\($0.isInclude ? "+" : "-")\($0.domainPattern)" }.joined(separator: ", ")
|
||||
ProxyLogger.connect.info("SSL entries: \(entries.count) (include=\(includeCount) exclude=\(excludeCount)) patterns=[\(patterns)]")
|
||||
|
||||
runtimeStatusRepo.update {
|
||||
$0.lastConnectError = "SSL rules: \(entries.count) entries [\(patterns)] checking domain=\(domain)"
|
||||
}
|
||||
|
||||
for entry in entries where !entry.isInclude {
|
||||
if WildcardMatcher.matches(domain, pattern: entry.domainPattern) {
|
||||
ProxyLogger.connect.debug("SSL EXCLUDED by pattern: \(entry.domainPattern)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
for entry in entries where entry.isInclude {
|
||||
if WildcardMatcher.matches(domain, pattern: entry.domainPattern) {
|
||||
ProxyLogger.connect.info("SSL INCLUDED by pattern: \(entry.domainPattern) -> MITM ON")
|
||||
runtimeStatusRepo.update {
|
||||
$0.lastMITMError = nil
|
||||
$0.lastConnectError = "MITM enabled for \(domain) via pattern \(entry.domainPattern)"
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("[Proxy] Failed to check SSL proxying list: \(error)")
|
||||
ProxyLogger.connect.error("SSL list fetch failed: \(error.localizedDescription)")
|
||||
runtimeStatusRepo.update {
|
||||
$0.lastMITMError = "SSL list DB error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
ProxyLogger.connect.debug("SSL: no matching rule for \(domain)")
|
||||
return false
|
||||
}
|
||||
|
||||
private func setupMITM(context: ChannelHandlerContext, host: String, port: Int) {
|
||||
let mitmHandler = MITMHandler(host: host, port: port, trafficRepo: trafficRepo)
|
||||
private func shouldHideConnect(url: String, host: String) -> Bool {
|
||||
if let blockAction = RulesEngine.checkBlockList(url: url, method: "CONNECT"), blockAction == .hideOnly {
|
||||
return true
|
||||
}
|
||||
return IPCManager.shared.hideSystemTraffic && SystemTrafficFilter.isSystemDomain(host)
|
||||
}
|
||||
|
||||
// Remove HTTP handlers, keep raw bytes for MITMHandler
|
||||
context.channel.pipeline.handler(type: ByteToMessageHandler<HTTPRequestDecoder>.self)
|
||||
.whenSuccess { handler in
|
||||
context.channel.pipeline.removeHandler(handler, promise: nil)
|
||||
}
|
||||
private func upgradeToMITM(
|
||||
context: ChannelHandlerContext,
|
||||
originalHost: String,
|
||||
upstreamHost: String,
|
||||
port: Int,
|
||||
initialBuffers: [ByteBuffer]
|
||||
) {
|
||||
let channel = context.channel
|
||||
|
||||
context.pipeline.removeHandler(context: context).whenComplete { _ in
|
||||
context.channel.pipeline.addHandler(mitmHandler).whenFailure { error in
|
||||
print("[Proxy] Failed to install MITM handler: \(error)")
|
||||
context.close(promise: nil)
|
||||
channel.setOption(.autoRead, value: false).flatMap {
|
||||
self.upgradeClientChannelToRaw(channel)
|
||||
}.flatMap {
|
||||
channel.pipeline.addHandler(
|
||||
MITMHandler(
|
||||
originalHost: originalHost,
|
||||
upstreamHost: upstreamHost,
|
||||
port: port,
|
||||
trafficRepo: self.trafficRepo
|
||||
)
|
||||
)
|
||||
}.flatMap {
|
||||
self.sendConnectEstablished(on: channel)
|
||||
}.whenComplete { result in
|
||||
switch result {
|
||||
case .success:
|
||||
ProxyLogger.connect.info("MITM pipeline ready for \(originalHost):\(port)")
|
||||
self.runtimeStatusRepo.update {
|
||||
$0.lastConnectError = nil
|
||||
$0.lastMITMError = nil
|
||||
}
|
||||
channel.setOption(.autoRead, value: true).whenComplete { _ in
|
||||
channel.read()
|
||||
for buffer in initialBuffers {
|
||||
channel.pipeline.fireChannelRead(NIOAny(buffer))
|
||||
}
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
ProxyLogger.connect.error("MITM upgrade FAILED for \(originalHost):\(port): \(error.localizedDescription)")
|
||||
self.runtimeStatusRepo.update {
|
||||
$0.lastMITMError = "MITM setup \(originalHost): \(error.localizedDescription)"
|
||||
}
|
||||
channel.close(promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupGlue(context: ChannelHandlerContext, remoteChannel: Channel) {
|
||||
private func upgradeToPassthrough(
|
||||
context: ChannelHandlerContext,
|
||||
remoteChannel: Channel,
|
||||
originalHost: String,
|
||||
upstreamHost: String,
|
||||
port: Int,
|
||||
initialBuffers: [ByteBuffer],
|
||||
isHidden: Bool
|
||||
) {
|
||||
let channel = context.channel
|
||||
let localGlue = GlueHandler()
|
||||
let remoteGlue = GlueHandler()
|
||||
localGlue.partner = remoteGlue
|
||||
remoteGlue.partner = localGlue
|
||||
|
||||
// Remove all HTTP handlers from the client channel, leaving raw bytes
|
||||
context.channel.pipeline.handler(type: ByteToMessageHandler<HTTPRequestDecoder>.self)
|
||||
.whenSuccess { handler in
|
||||
context.channel.pipeline.removeHandler(handler, promise: nil)
|
||||
}
|
||||
|
||||
context.pipeline.removeHandler(context: context).whenComplete { _ in
|
||||
context.channel.pipeline.addHandler(localGlue).whenSuccess {
|
||||
remoteChannel.pipeline.addHandler(remoteGlue).whenFailure { _ in
|
||||
context.close(promise: nil)
|
||||
remoteChannel.close(promise: nil)
|
||||
channel.setOption(.autoRead, value: false).flatMap {
|
||||
self.upgradeClientChannelToRaw(channel)
|
||||
}.flatMap {
|
||||
remoteChannel.pipeline.addHandler(remoteGlue)
|
||||
}.flatMap {
|
||||
channel.pipeline.addHandler(localGlue)
|
||||
}.flatMap {
|
||||
self.sendConnectEstablished(on: channel)
|
||||
}.whenComplete { result in
|
||||
switch result {
|
||||
case .success:
|
||||
ProxyLogger.connect.info("Passthrough tunnel ready for \(originalHost):\(port) via \(upstreamHost)")
|
||||
self.runtimeStatusRepo.update {
|
||||
$0.lastConnectError = nil
|
||||
}
|
||||
self.recordConnectTraffic(host: originalHost, port: port, isHidden: isHidden)
|
||||
|
||||
for buffer in initialBuffers {
|
||||
remoteChannel.write(NIOAny(buffer), promise: nil)
|
||||
}
|
||||
remoteChannel.flush()
|
||||
|
||||
channel.setOption(.autoRead, value: true).whenComplete { _ in
|
||||
channel.read()
|
||||
}
|
||||
remoteChannel.setOption(.autoRead, value: true).whenComplete { _ in
|
||||
remoteChannel.read()
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
ProxyLogger.connect.error("Passthrough upgrade FAILED for \(originalHost):\(port): \(error.localizedDescription)")
|
||||
self.runtimeStatusRepo.update {
|
||||
$0.lastConnectError = "Passthrough \(originalHost): \(error.localizedDescription)"
|
||||
}
|
||||
channel.close(promise: nil)
|
||||
remoteChannel.close(promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Plain HTTP forwarding
|
||||
private func upgradeClientChannelToRaw(_ channel: Channel) -> EventLoopFuture<Void> {
|
||||
removeHandler(ByteToMessageHandler<HTTPRequestDecoder>.self, from: channel).flatMap { _ in
|
||||
self.removeHandler(HTTPResponseEncoder.self, from: channel)
|
||||
}.flatMap { _ in
|
||||
channel.pipeline.removeHandler(self)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendConnectEstablished(on channel: Channel) -> EventLoopFuture<Void> {
|
||||
var buffer = channel.allocator.buffer(capacity: 64)
|
||||
buffer.writeString("HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
return channel.writeAndFlush(NIOAny(buffer))
|
||||
}
|
||||
|
||||
private func removeHandler<H: RemovableChannelHandler>(_ type: H.Type, from channel: Channel) -> EventLoopFuture<Void> {
|
||||
channel.pipeline.handler(type: type).flatMap { handler in
|
||||
channel.pipeline.removeHandler(handler)
|
||||
}.recover { _ in () }
|
||||
}
|
||||
|
||||
// MARK: - Plain HTTP
|
||||
|
||||
private func handleHTTPRequest(context: ChannelHandlerContext) {
|
||||
guard let head = pendingHead else { return }
|
||||
|
||||
// Parse host and port from the absolute URI or Host header
|
||||
guard let (host, port, path) = parseHTTPTarget(head: head) else {
|
||||
ProxyLogger.connect.error("HTTP: failed to parse target from \(head.uri)")
|
||||
let responseHead = HTTPResponseHead(version: .http1_1, status: .badRequest)
|
||||
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
|
||||
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
|
||||
pendingHead = nil
|
||||
pendingBody.removeAll()
|
||||
pendingEnd = nil
|
||||
return
|
||||
}
|
||||
|
||||
let fullURL = "http://\(host)\(path)"
|
||||
let method = head.method.rawValue
|
||||
let upstreamHost = RulesEngine.checkDNSSpoof(domain: host) ?? host
|
||||
ProxyLogger.connect.info("HTTP FORWARD \(method) \(fullURL)")
|
||||
|
||||
if let blockAction = RulesEngine.checkBlockList(url: fullURL, method: method),
|
||||
blockAction != .hideOnly {
|
||||
ProxyLogger.connect.info("HTTP BLOCKED \(fullURL) action=\(blockAction.rawValue)")
|
||||
if blockAction == .blockAndDisplay {
|
||||
var traffic = CapturedTraffic(
|
||||
domain: host, url: fullURL, method: method, scheme: "http",
|
||||
statusCode: 403, statusText: "Blocked",
|
||||
startedAt: Date().timeIntervalSince1970,
|
||||
completedAt: Date().timeIntervalSince1970, durationMs: 0, isSslDecrypted: false
|
||||
)
|
||||
do {
|
||||
try trafficRepo.insert(&traffic)
|
||||
IPCManager.shared.post(.newTrafficCaptured)
|
||||
} catch {
|
||||
ProxyLogger.db.error("DB insert failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
let responseHead = HTTPResponseHead(version: .http1_1, status: .forbidden)
|
||||
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
|
||||
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
|
||||
pendingHead = nil
|
||||
pendingBody.removeAll()
|
||||
pendingEnd = nil
|
||||
return
|
||||
}
|
||||
|
||||
if let mapRule = RulesEngine.checkMapLocal(url: fullURL, method: method) {
|
||||
ProxyLogger.connect.info("MAP LOCAL match for \(fullURL) -> status \(mapRule.responseStatus)")
|
||||
let status = HTTPResponseStatus(statusCode: mapRule.responseStatus)
|
||||
var headers = decodeHeaders(mapRule.responseHeaders)
|
||||
if let ct = mapRule.responseContentType, !ct.isEmpty {
|
||||
headers.replaceOrAdd(name: "Content-Type", value: ct)
|
||||
}
|
||||
let bodyData = mapRule.responseBody
|
||||
if let bodyData, !bodyData.isEmpty {
|
||||
headers.replaceOrAdd(name: "Content-Length", value: "\(bodyData.count)")
|
||||
}
|
||||
let responseHead = HTTPResponseHead(version: .http1_1, status: status, headers: headers)
|
||||
context.write(wrapOutboundOut(.head(responseHead)), promise: nil)
|
||||
if let bodyData, !bodyData.isEmpty {
|
||||
var buffer = context.channel.allocator.buffer(capacity: bodyData.count)
|
||||
buffer.writeBytes(bodyData)
|
||||
context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil)
|
||||
}
|
||||
context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil)
|
||||
pendingHead = nil
|
||||
pendingBody.removeAll()
|
||||
pendingEnd = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Rewrite the request URI to relative path (upstream expects /path, not http://host/path)
|
||||
var upstreamHead = head
|
||||
upstreamHead.uri = path
|
||||
// Ensure Host header is set
|
||||
if !upstreamHead.headers.contains(name: "Host") {
|
||||
upstreamHead.headers.add(name: "Host", value: host)
|
||||
}
|
||||
@@ -176,7 +433,6 @@ final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||||
ClientBootstrap(group: context.eventLoop)
|
||||
.channelOption(.socketOption(.so_reuseaddr), value: 1)
|
||||
.channelInitializer { channel in
|
||||
// Remote channel: decode HTTP responses, encode HTTP requests
|
||||
channel.pipeline.addHandler(HTTPRequestEncoder()).flatMap {
|
||||
channel.pipeline.addHandler(ByteToMessageHandler(HTTPResponseDecoder(leftOverBytesStrategy: .forwardBytes)))
|
||||
}.flatMap {
|
||||
@@ -187,80 +443,90 @@ final class ConnectHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||||
)
|
||||
}
|
||||
}
|
||||
.connect(host: host, port: port)
|
||||
.connect(host: upstreamHost, port: port)
|
||||
.whenComplete { result in
|
||||
switch result {
|
||||
case .success(let remoteChannel):
|
||||
// Forward the buffered request to upstream
|
||||
ProxyLogger.connect.info("HTTP upstream connected to \(upstreamHost):\(port), forwarding request")
|
||||
remoteChannel.write(NIOAny(HTTPClientRequestPart.head(upstreamHead)), promise: nil)
|
||||
for bodyBuffer in self.pendingBody {
|
||||
remoteChannel.write(NIOAny(HTTPClientRequestPart.body(.byteBuffer(bodyBuffer))), promise: nil)
|
||||
}
|
||||
remoteChannel.writeAndFlush(NIOAny(HTTPClientRequestPart.end(self.pendingEnd)), promise: nil)
|
||||
|
||||
// Clear buffered data
|
||||
self.pendingHead = nil
|
||||
self.pendingBody.removeAll()
|
||||
self.pendingEnd = nil
|
||||
|
||||
case .failure(let error):
|
||||
print("[Proxy] HTTP forward failed to \(host):\(port): \(error)")
|
||||
ProxyLogger.connect.error("HTTP upstream connect FAILED \(host):\(port): \(error.localizedDescription)")
|
||||
self.runtimeStatusRepo.update {
|
||||
$0.lastConnectError = "HTTP \(fullURL): \(error.localizedDescription)"
|
||||
}
|
||||
let responseHead = HTTPResponseHead(version: .http1_1, status: .badGateway)
|
||||
context.write(self.wrapOutboundOut(.head(responseHead)), promise: nil)
|
||||
context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil)
|
||||
self.pendingHead = nil
|
||||
self.pendingBody.removeAll()
|
||||
self.pendingEnd = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeHeaders(_ json: String?) -> HTTPHeaders {
|
||||
guard let json,
|
||||
let data = json.data(using: .utf8),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: data) else {
|
||||
return HTTPHeaders()
|
||||
}
|
||||
|
||||
var headers = HTTPHeaders()
|
||||
for (name, value) in dict {
|
||||
headers.add(name: name, value: value)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// MARK: - URL Parsing
|
||||
|
||||
private func parseHTTPTarget(head: HTTPRequestHead) -> (host: String, port: Int, path: String)? {
|
||||
// Absolute URI: "http://example.com:8080/path?query"
|
||||
if head.uri.hasPrefix("http://") || head.uri.hasPrefix("https://") {
|
||||
guard let url = URLComponents(string: head.uri) else { return nil }
|
||||
let host = url.host ?? ""
|
||||
let port = url.port ?? (head.uri.hasPrefix("https") ? 443 : 80)
|
||||
var path = url.path.isEmpty ? "/" : url.path
|
||||
if let query = url.query {
|
||||
path += "?\(query)"
|
||||
}
|
||||
if let query = url.query { path += "?\(query)" }
|
||||
return (host, port, path)
|
||||
}
|
||||
|
||||
// Relative URI with Host header
|
||||
if let hostHeader = head.headers.first(name: "Host") {
|
||||
let parts = hostHeader.split(separator: ":")
|
||||
let host = String(parts[0])
|
||||
let port = parts.count > 1 ? Int(parts[1]) ?? 80 : 80
|
||||
return (host, port, head.uri)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - CONNECT traffic recording
|
||||
|
||||
private func recordConnectTraffic(host: String, port: Int) {
|
||||
private func recordConnectTraffic(host: String, port: Int, isHidden: Bool) {
|
||||
var traffic = CapturedTraffic(
|
||||
domain: host,
|
||||
url: "https://\(host):\(port)",
|
||||
method: "CONNECT",
|
||||
scheme: "https",
|
||||
statusCode: 200,
|
||||
statusText: "Connection Established",
|
||||
startedAt: Date().timeIntervalSince1970,
|
||||
completedAt: Date().timeIntervalSince1970,
|
||||
durationMs: 0,
|
||||
isSslDecrypted: false
|
||||
domain: host, url: "https://\(host):\(port)", method: "CONNECT", scheme: "https",
|
||||
statusCode: 200, statusText: "Connection Established",
|
||||
startedAt: Date().timeIntervalSince1970, completedAt: Date().timeIntervalSince1970,
|
||||
durationMs: 0, isSslDecrypted: false, isHidden: isHidden
|
||||
)
|
||||
try? trafficRepo.insert(&traffic)
|
||||
IPCManager.shared.post(.newTrafficCaptured)
|
||||
do {
|
||||
try trafficRepo.insert(&traffic)
|
||||
ProxyLogger.db.debug("Recorded CONNECT \(host) (hidden=\(isHidden))")
|
||||
} catch {
|
||||
ProxyLogger.db.error("Failed to record CONNECT \(host): \(error.localizedDescription)")
|
||||
}
|
||||
NotificationThrottle.shared.throttle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HTTPRelayHandler
|
||||
|
||||
/// Relays HTTP responses from the upstream server back to the proxy client.
|
||||
final class HTTPRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||||
typealias InboundIn = HTTPClientResponsePart
|
||||
|
||||
@@ -274,11 +540,10 @@ final class HTTPRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
let part = unwrapInboundIn(data)
|
||||
|
||||
switch part {
|
||||
case .head(let head):
|
||||
let serverHead = HTTPResponseHead(version: head.version, status: head.status, headers: head.headers)
|
||||
clientContext.write(wrapResponse(.head(serverHead)), promise: nil)
|
||||
ProxyLogger.connect.debug("HTTPRelay response: \(head.status.code)")
|
||||
clientContext.write(wrapResponse(.head(HTTPResponseHead(version: head.version, status: head.status, headers: head.headers))), promise: nil)
|
||||
case .body(let buffer):
|
||||
clientContext.write(wrapResponse(.body(.byteBuffer(buffer))), promise: nil)
|
||||
case .end(let trailers):
|
||||
@@ -287,11 +552,12 @@ final class HTTPRelayHandler: ChannelInboundHandler, RemovableChannelHandler {
|
||||
}
|
||||
|
||||
func channelInactive(context: ChannelHandlerContext) {
|
||||
ProxyLogger.connect.debug("HTTPRelay: remote channel inactive")
|
||||
clientContext.close(promise: nil)
|
||||
}
|
||||
|
||||
func errorCaught(context: ChannelHandlerContext, error: Error) {
|
||||
print("[Proxy] Relay error: \(error)")
|
||||
ProxyLogger.connect.error("HTTPRelay error: \(error.localizedDescription)")
|
||||
context.close(promise: nil)
|
||||
clientContext.close(promise: nil)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user