- 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
127 lines
5.1 KiB
Swift
127 lines
5.1 KiB
Swift
import NetworkExtension
|
|
import ProxyCore
|
|
import os
|
|
|
|
private let log = Logger(subsystem: "com.treyt.proxyapp", category: "tunnel")
|
|
|
|
class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|
private var proxyServer: ProxyServer?
|
|
private let runtimeStatusRepo = RuntimeStatusRepository()
|
|
|
|
override func startTunnel(options: [String: NSObject]? = nil) async throws {
|
|
log.info("========== TUNNEL STARTING ==========")
|
|
CertificateManager.shared.reloadSharedCA()
|
|
runtimeStatusRepo.update {
|
|
$0.tunnelState = ProxyRuntimeState.starting.rawValue
|
|
$0.proxyHost = nil
|
|
$0.proxyPort = nil
|
|
$0.caFingerprint = CertificateManager.shared.caFingerprint
|
|
$0.lastProxyError = nil
|
|
$0.lastConnectError = nil
|
|
$0.lastMITMError = nil
|
|
$0.lastExtensionStartAt = Date().timeIntervalSince1970
|
|
}
|
|
|
|
let server = ProxyServer()
|
|
do {
|
|
try await server.start()
|
|
proxyServer = server
|
|
log.info("ProxyServer started on \(ProxyConstants.proxyHost):\(ProxyConstants.proxyPort)")
|
|
} catch {
|
|
log.error("ProxyServer FAILED to start: \(error.localizedDescription)")
|
|
runtimeStatusRepo.update {
|
|
$0.tunnelState = ProxyRuntimeState.failed.rawValue
|
|
$0.lastProxyError = error.localizedDescription
|
|
$0.caFingerprint = CertificateManager.shared.caFingerprint
|
|
}
|
|
throw error
|
|
}
|
|
|
|
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "192.0.2.1")
|
|
|
|
// Assign a dummy IP to the tunnel interface.
|
|
// This is required for NEPacketTunnelProvider to function.
|
|
let ipv4 = NEIPv4Settings(addresses: ["198.51.100.1"], subnetMasks: ["255.255.255.0"])
|
|
// Do NOT add includedRoutes — we don't want to route IP packets through the tunnel.
|
|
// Only the proxy settings below handle traffic redirection for HTTP/HTTPS.
|
|
ipv4.includedRoutes = []
|
|
ipv4.excludedRoutes = [NEIPv4Route.default()]
|
|
settings.ipv4Settings = ipv4
|
|
|
|
// HTTP/HTTPS proxy — this is the actual interception mechanism.
|
|
// Apps using NSURLSession/WKWebView will route through our proxy.
|
|
let proxySettings = NEProxySettings()
|
|
proxySettings.httpServer = NEProxyServer(
|
|
address: ProxyConstants.proxyHost,
|
|
port: ProxyConstants.proxyPort
|
|
)
|
|
proxySettings.httpsServer = NEProxyServer(
|
|
address: ProxyConstants.proxyHost,
|
|
port: ProxyConstants.proxyPort
|
|
)
|
|
proxySettings.httpEnabled = true
|
|
proxySettings.httpsEnabled = true
|
|
proxySettings.matchDomains = [""]
|
|
proxySettings.excludeSimpleHostnames = true
|
|
settings.proxySettings = proxySettings
|
|
|
|
log.info("Applying tunnel settings")
|
|
do {
|
|
try await setTunnelNetworkSettings(settings)
|
|
} catch {
|
|
runtimeStatusRepo.update {
|
|
$0.tunnelState = ProxyRuntimeState.failed.rawValue
|
|
$0.lastProxyError = "Tunnel settings: \(error.localizedDescription)"
|
|
}
|
|
await proxyServer?.stop()
|
|
proxyServer = nil
|
|
throw error
|
|
}
|
|
log.info("Tunnel settings applied successfully")
|
|
runtimeStatusRepo.update {
|
|
$0.tunnelState = ProxyRuntimeState.running.rawValue
|
|
$0.proxyHost = ProxyConstants.proxyHost
|
|
$0.proxyPort = ProxyConstants.proxyPort
|
|
$0.caFingerprint = CertificateManager.shared.caFingerprint
|
|
}
|
|
|
|
// Start reading packets from the tunnel to prevent the queue from filling up.
|
|
// We don't need to process them — just drain them. All real traffic goes
|
|
// through the HTTP proxy, not the IP tunnel.
|
|
startPacketDrain()
|
|
|
|
IPCManager.shared.post(.extensionStarted)
|
|
log.info("========== TUNNEL STARTED ==========")
|
|
}
|
|
|
|
override func stopTunnel(with reason: NEProviderStopReason) async {
|
|
log.info("========== TUNNEL STOPPING (reason: \(String(describing: reason))) ==========")
|
|
await proxyServer?.stop()
|
|
proxyServer = nil
|
|
runtimeStatusRepo.update {
|
|
$0.tunnelState = ProxyRuntimeState.stopped.rawValue
|
|
$0.proxyHost = nil
|
|
$0.proxyPort = nil
|
|
$0.lastExtensionStopAt = Date().timeIntervalSince1970
|
|
}
|
|
IPCManager.shared.post(.extensionStopped)
|
|
log.info("========== TUNNEL STOPPED ==========")
|
|
}
|
|
|
|
override func handleAppMessage(_ messageData: Data) async -> Data? {
|
|
log.debug("Received app message: \(messageData.count) bytes")
|
|
return nil
|
|
}
|
|
|
|
/// Continuously drain packets from the tunnel interface.
|
|
/// Since we exclude all IP routes, very few packets should arrive here.
|
|
/// But we must read them to prevent the tunnel from backing up.
|
|
private func startPacketDrain() {
|
|
packetFlow.readPackets { [weak self] packets, protocols in
|
|
// Discard packets — all real traffic goes through the HTTP proxy
|
|
// Re-arm the read
|
|
self?.startPacketDrain()
|
|
}
|
|
}
|
|
}
|