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:
23
ProxyCore/Sources/Shared/AppGroupPaths.swift
Normal file
23
ProxyCore/Sources/Shared/AppGroupPaths.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
public enum AppGroupPaths {
|
||||
public static var containerURL: URL {
|
||||
if let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: ProxyConstants.appGroupIdentifier) {
|
||||
return groupURL
|
||||
}
|
||||
|
||||
return FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
public static var certificatesDirectory: URL {
|
||||
containerURL.appendingPathComponent("Certificates", isDirectory: true)
|
||||
}
|
||||
|
||||
public static var caCertificateURL: URL {
|
||||
certificatesDirectory.appendingPathComponent("proxy_ca.der")
|
||||
}
|
||||
|
||||
public static var caPrivateKeyURL: URL {
|
||||
certificatesDirectory.appendingPathComponent("proxy_ca_privatekey.raw")
|
||||
}
|
||||
}
|
||||
203
ProxyCore/Sources/Shared/HTTPBodyDecoder.swift
Normal file
203
ProxyCore/Sources/Shared/HTTPBodyDecoder.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import Foundation
|
||||
import zlib
|
||||
|
||||
public enum HTTPBodyDecoder {
|
||||
private static let inflateChunkSize = 64 * 1024
|
||||
private static let maxDecodedBodyBytes = 8 * 1024 * 1024
|
||||
|
||||
public static func headerValue(named name: String, in headers: [String: String]) -> String? {
|
||||
headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value
|
||||
}
|
||||
|
||||
public static func decodingHint(for body: Data?, headers: [String: String]) -> String {
|
||||
guard let body, !body.isEmpty else { return "empty" }
|
||||
|
||||
let encodings = contentEncodings(in: headers)
|
||||
if encodings.isEmpty {
|
||||
return hasGzipMagic(body) ? "gzip-magic-no-header" : "identity"
|
||||
}
|
||||
|
||||
var current = body
|
||||
var applied: [String] = []
|
||||
|
||||
for encoding in encodings.reversed() {
|
||||
switch encoding {
|
||||
case "identity":
|
||||
continue
|
||||
case "gzip", "x-gzip":
|
||||
guard let decoded = inflatePayload(current, windowBits: 47) else {
|
||||
return "failed(\(encoding))"
|
||||
}
|
||||
current = decoded
|
||||
applied.append(encoding)
|
||||
case "deflate":
|
||||
guard let decoded = inflateDeflatePayload(current) else {
|
||||
return "failed(\(encoding))"
|
||||
}
|
||||
current = decoded
|
||||
applied.append(encoding)
|
||||
default:
|
||||
return "unsupported(\(encoding))"
|
||||
}
|
||||
}
|
||||
|
||||
if applied.isEmpty {
|
||||
return "identity"
|
||||
}
|
||||
|
||||
return "decoded(\(applied.joined(separator: ",")))"
|
||||
}
|
||||
|
||||
public static func decodedBodyData(from body: Data?, headers: [String: String]) -> Data? {
|
||||
guard let body, !body.isEmpty else { return nil }
|
||||
|
||||
let encodings = contentEncodings(in: headers)
|
||||
if encodings.isEmpty {
|
||||
return hasGzipMagic(body) ? inflatePayload(body, windowBits: 47) : body
|
||||
}
|
||||
|
||||
var current = body
|
||||
|
||||
for encoding in encodings.reversed() {
|
||||
switch encoding {
|
||||
case "identity":
|
||||
continue
|
||||
case "gzip", "x-gzip":
|
||||
guard let decoded = inflatePayload(current, windowBits: 47) else { return nil }
|
||||
current = decoded
|
||||
case "deflate":
|
||||
guard let decoded = inflateDeflatePayload(current) else { return nil }
|
||||
current = decoded
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
public static func searchableText(from body: Data?, headers: [String: String]) -> String? {
|
||||
guard let body, !body.isEmpty else { return nil }
|
||||
|
||||
let candidate = decodedBodyData(from: body, headers: headers) ?? body
|
||||
|
||||
if let jsonText = searchableJSONText(from: candidate) {
|
||||
return jsonText
|
||||
}
|
||||
|
||||
guard let string = String(data: candidate, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!string.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return string
|
||||
}
|
||||
|
||||
private static func contentEncodings(in headers: [String: String]) -> [String] {
|
||||
guard let value = headerValue(named: "Content-Encoding", in: headers)?
|
||||
.lowercased()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private static func searchableJSONText(from data: Data) -> String? {
|
||||
guard let jsonObject = try? JSONSerialization.jsonObject(with: data) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fragments = flattenedSearchFragments(from: jsonObject)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
guard !fragments.isEmpty else { return nil }
|
||||
return fragments.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private static func flattenedSearchFragments(from value: Any) -> [String] {
|
||||
switch value {
|
||||
case let dict as [String: Any]:
|
||||
return dict.sorted(by: { $0.key < $1.key }).flatMap { key, nestedValue in
|
||||
[key] + flattenedSearchFragments(from: nestedValue)
|
||||
}
|
||||
case let array as [Any]:
|
||||
return array.flatMap(flattenedSearchFragments(from:))
|
||||
case let string as String:
|
||||
return [string]
|
||||
case let number as NSNumber:
|
||||
return ["\(number)"]
|
||||
case _ as NSNull:
|
||||
return []
|
||||
default:
|
||||
return ["\(value)"]
|
||||
}
|
||||
}
|
||||
|
||||
private static func inflateDeflatePayload(_ data: Data) -> Data? {
|
||||
inflatePayload(data, windowBits: 47) ??
|
||||
inflatePayload(data, windowBits: 15) ??
|
||||
inflatePayload(data, windowBits: -15)
|
||||
}
|
||||
|
||||
private static func inflatePayload(_ data: Data, windowBits: Int32) -> Data? {
|
||||
guard !data.isEmpty else { return Data() }
|
||||
|
||||
return data.withUnsafeBytes { rawBuffer in
|
||||
guard let sourceBytes = rawBuffer.bindMemory(to: Bytef.self).baseAddress else {
|
||||
return Data()
|
||||
}
|
||||
|
||||
var stream = z_stream()
|
||||
stream.next_in = UnsafeMutablePointer(mutating: sourceBytes)
|
||||
stream.avail_in = uInt(data.count)
|
||||
|
||||
let status = inflateInit2_(&stream, windowBits, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
|
||||
guard status == Z_OK else { return nil }
|
||||
defer { inflateEnd(&stream) }
|
||||
|
||||
var output = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: inflateChunkSize)
|
||||
|
||||
while true {
|
||||
let result = buffer.withUnsafeMutableBufferPointer { outputBuffer -> Int32 in
|
||||
stream.next_out = outputBuffer.baseAddress
|
||||
stream.avail_out = uInt(outputBuffer.count)
|
||||
return zlib.inflate(&stream, Z_SYNC_FLUSH)
|
||||
}
|
||||
|
||||
let produced = inflateChunkSize - Int(stream.avail_out)
|
||||
if produced > 0 {
|
||||
buffer.withUnsafeBufferPointer { outputBuffer in
|
||||
guard let baseAddress = outputBuffer.baseAddress else { return }
|
||||
output.append(baseAddress, count: produced)
|
||||
}
|
||||
|
||||
if output.count >= maxDecodedBodyBytes {
|
||||
return Data(output.prefix(maxDecodedBodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
switch result {
|
||||
case Z_STREAM_END:
|
||||
return output
|
||||
case Z_OK:
|
||||
continue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasGzipMagic(_ data: Data) -> Bool {
|
||||
guard data.count >= 2 else { return false }
|
||||
return data[data.startIndex] == 0x1f && data[data.index(after: data.startIndex)] == 0x8b
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight IPC between the main app and the packet tunnel extension
|
||||
/// using Darwin notifications (fire-and-forget signals) and shared UserDefaults.
|
||||
public final class IPCManager: Sendable {
|
||||
/// using Darwin notifications and a shared database-backed configuration model.
|
||||
public final class IPCManager: @unchecked Sendable {
|
||||
public static let shared = IPCManager()
|
||||
|
||||
private let suiteName = "group.com.treyt.proxyapp"
|
||||
|
||||
public enum Notification: String, Sendable {
|
||||
case newTrafficCaptured = "com.treyt.proxyapp.newTraffic"
|
||||
case configurationChanged = "com.treyt.proxyapp.configChanged"
|
||||
@@ -14,11 +12,16 @@ public final class IPCManager: Sendable {
|
||||
case extensionStopped = "com.treyt.proxyapp.extensionStopped"
|
||||
}
|
||||
|
||||
private init() {}
|
||||
private let configurationRepo = ConfigurationRepository()
|
||||
|
||||
private init() {
|
||||
ProxyLogger.ipc.info("IPCManager using shared database-backed configuration")
|
||||
}
|
||||
|
||||
// MARK: - Darwin Notifications
|
||||
|
||||
public func post(_ notification: Notification) {
|
||||
ProxyLogger.ipc.debug("POST Darwin: \(notification.rawValue)")
|
||||
let name = CFNotificationName(notification.rawValue as CFString)
|
||||
CFNotificationCenterPostNotification(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
@@ -27,12 +30,16 @@ public final class IPCManager: Sendable {
|
||||
}
|
||||
|
||||
public func observe(_ notification: Notification, callback: @escaping @Sendable () -> Void) {
|
||||
let didInstall = DarwinCallbackStore.shared.register(name: notification.rawValue, callback: callback)
|
||||
if !didInstall {
|
||||
ProxyLogger.ipc.info("OBSERVE Darwin reuse: \(notification.rawValue)")
|
||||
return
|
||||
}
|
||||
|
||||
ProxyLogger.ipc.info("OBSERVE Darwin install: \(notification.rawValue)")
|
||||
let name = notification.rawValue as CFString
|
||||
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
|
||||
// Store callback in a static dictionary keyed by notification name
|
||||
DarwinCallbackStore.shared.register(name: notification.rawValue, callback: callback)
|
||||
|
||||
CFNotificationCenterAddObserver(
|
||||
center, nil,
|
||||
{ _, _, name, _, _ in
|
||||
@@ -44,40 +51,58 @@ public final class IPCManager: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Shared UserDefaults
|
||||
|
||||
public var sharedDefaults: UserDefaults? {
|
||||
UserDefaults(suiteName: suiteName)
|
||||
}
|
||||
// MARK: - Shared Config (file-based, reliable cross-process)
|
||||
|
||||
public var isSSLProxyingEnabled: Bool {
|
||||
get { sharedDefaults?.bool(forKey: "sslProxyingEnabled") ?? false }
|
||||
set { sharedDefaults?.set(newValue, forKey: "sslProxyingEnabled") }
|
||||
get { get(\.sslProxyingEnabled) }
|
||||
set { set(\.sslProxyingEnabled, newValue) }
|
||||
}
|
||||
|
||||
public var isBlockListEnabled: Bool {
|
||||
get { sharedDefaults?.bool(forKey: "blockListEnabled") ?? false }
|
||||
set { sharedDefaults?.set(newValue, forKey: "blockListEnabled") }
|
||||
get { get(\.blockListEnabled) }
|
||||
set { set(\.blockListEnabled, newValue) }
|
||||
}
|
||||
|
||||
public var isBreakpointEnabled: Bool {
|
||||
get { sharedDefaults?.bool(forKey: "breakpointEnabled") ?? false }
|
||||
set { sharedDefaults?.set(newValue, forKey: "breakpointEnabled") }
|
||||
get { get(\.breakpointEnabled) }
|
||||
set { set(\.breakpointEnabled, newValue) }
|
||||
}
|
||||
|
||||
public var isNoCachingEnabled: Bool {
|
||||
get { sharedDefaults?.bool(forKey: "noCachingEnabled") ?? false }
|
||||
set { sharedDefaults?.set(newValue, forKey: "noCachingEnabled") }
|
||||
get { get(\.noCachingEnabled) }
|
||||
set { set(\.noCachingEnabled, newValue) }
|
||||
}
|
||||
|
||||
public var isDNSSpoofingEnabled: Bool {
|
||||
get { sharedDefaults?.bool(forKey: "dnsSpoofingEnabled") ?? false }
|
||||
set { sharedDefaults?.set(newValue, forKey: "dnsSpoofingEnabled") }
|
||||
get { get(\.dnsSpoofingEnabled) }
|
||||
set { set(\.dnsSpoofingEnabled, newValue) }
|
||||
}
|
||||
|
||||
public var hideSystemTraffic: Bool {
|
||||
get { sharedDefaults?.bool(forKey: "hideSystemTraffic") ?? false }
|
||||
set { sharedDefaults?.set(newValue, forKey: "hideSystemTraffic") }
|
||||
get { get(\.hideSystemTraffic) }
|
||||
set { set(\.hideSystemTraffic, newValue) }
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
private func get(_ keyPath: KeyPath<ProxyConfiguration, Bool>) -> Bool {
|
||||
do {
|
||||
return try configurationRepo.current()[keyPath: keyPath]
|
||||
} catch {
|
||||
ProxyLogger.ipc.error("Config READ failed: \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func set(_ keyPath: WritableKeyPath<ProxyConfiguration, Bool>, _ value: Bool) {
|
||||
do {
|
||||
try configurationRepo.update {
|
||||
$0[keyPath: keyPath] = value
|
||||
}
|
||||
ProxyLogger.ipc.info("Config SET value=\(value)")
|
||||
} catch {
|
||||
ProxyLogger.ipc.error("Config WRITE failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,18 +111,25 @@ public final class IPCManager: Sendable {
|
||||
private final class DarwinCallbackStore: @unchecked Sendable {
|
||||
static let shared = DarwinCallbackStore()
|
||||
private var callbacks: [String: @Sendable () -> Void] = [:]
|
||||
private var installedObserverNames: Set<String> = []
|
||||
private var fireCounts: [String: Int] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
func register(name: String, callback: @escaping @Sendable () -> Void) {
|
||||
func register(name: String, callback: @escaping @Sendable () -> Void) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
callbacks[name] = callback
|
||||
lock.unlock()
|
||||
let isNew = installedObserverNames.insert(name).inserted
|
||||
return isNew
|
||||
}
|
||||
|
||||
func fire(name: String) {
|
||||
lock.lock()
|
||||
let cb = callbacks[name]
|
||||
fireCounts[name, default: 0] += 1
|
||||
let count = fireCounts[name] ?? 0
|
||||
lock.unlock()
|
||||
ProxyLogger.ipc.debug("FIRE Darwin callback: \(name) count=\(count)")
|
||||
cb?()
|
||||
}
|
||||
}
|
||||
|
||||
32
ProxyCore/Sources/Shared/NotificationThrottle.swift
Normal file
32
ProxyCore/Sources/Shared/NotificationThrottle.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
/// Throttles Darwin notification posting to at most once per 0.5 seconds.
|
||||
/// Prevents flooding the main app with hundreds of "new traffic" notifications.
|
||||
public final class NotificationThrottle: @unchecked Sendable {
|
||||
public static let shared = NotificationThrottle()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending = false
|
||||
private let interval: TimeInterval = 0.5
|
||||
|
||||
private init() {}
|
||||
|
||||
public func throttle() {
|
||||
lock.lock()
|
||||
if pending {
|
||||
lock.unlock()
|
||||
ProxyLogger.ipc.debug("NotificationThrottle: suppressed newTraffic while pending")
|
||||
return
|
||||
}
|
||||
pending = true
|
||||
lock.unlock()
|
||||
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + interval) { [weak self] in
|
||||
self?.lock.lock()
|
||||
self?.pending = false
|
||||
self?.lock.unlock()
|
||||
ProxyLogger.ipc.debug("NotificationThrottle: emitting throttled newTraffic")
|
||||
IPCManager.shared.post(.newTrafficCaptured)
|
||||
}
|
||||
}
|
||||
}
|
||||
18
ProxyCore/Sources/Shared/ProxyLogger.swift
Normal file
18
ProxyCore/Sources/Shared/ProxyLogger.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Centralized logging for the proxy app. Uses os.Logger so logs appear in
|
||||
/// Console.app, Xcode debug console, and `xclog` capture — even from the extension process.
|
||||
public enum ProxyLogger {
|
||||
public static let tunnel = Logger(subsystem: "com.treyt.proxyapp", category: "tunnel")
|
||||
public static let proxy = Logger(subsystem: "com.treyt.proxyapp", category: "proxy")
|
||||
public static let connect = Logger(subsystem: "com.treyt.proxyapp", category: "connect")
|
||||
public static let glue = Logger(subsystem: "com.treyt.proxyapp", category: "glue")
|
||||
public static let mitm = Logger(subsystem: "com.treyt.proxyapp", category: "mitm")
|
||||
public static let capture = Logger(subsystem: "com.treyt.proxyapp", category: "capture")
|
||||
public static let cert = Logger(subsystem: "com.treyt.proxyapp", category: "cert")
|
||||
public static let rules = Logger(subsystem: "com.treyt.proxyapp", category: "rules")
|
||||
public static let db = Logger(subsystem: "com.treyt.proxyapp", category: "db")
|
||||
public static let ipc = Logger(subsystem: "com.treyt.proxyapp", category: "ipc")
|
||||
public static let ui = Logger(subsystem: "com.treyt.proxyapp", category: "ui")
|
||||
}
|
||||
27
ProxyCore/Sources/Shared/SystemTrafficFilter.swift
Normal file
27
ProxyCore/Sources/Shared/SystemTrafficFilter.swift
Normal file
@@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
public enum SystemTrafficFilter {
|
||||
private static let systemDomains = [
|
||||
"*.apple.com", "*.icloud.com", "*.icloud-content.com",
|
||||
"*.mzstatic.com", "push.apple.com", "*.push.apple.com",
|
||||
"*.itunes.apple.com", "gsp-ssl.ls.apple.com",
|
||||
"mesu.apple.com", "xp.apple.com", "*.cdn-apple.com",
|
||||
"time.apple.com", "time-ios.apple.com",
|
||||
"*.applemusic.com", "*.apple-cloudkit.com",
|
||||
"configuration.apple.com", "gdmf.apple.com",
|
||||
"gspe1-ssl.ls.apple.com", "*.gc.apple.com",
|
||||
"*.fe.apple-dns.net", "*.aaplimg.com",
|
||||
"stocks.apple.com", "weather-data.apple.com",
|
||||
"news-events.apple.com", "bag.itunes.apple.com"
|
||||
]
|
||||
|
||||
public static func isSystemDomain(_ domain: String) -> Bool {
|
||||
let lowered = domain.lowercased()
|
||||
for pattern in systemDomains {
|
||||
if WildcardMatcher.matches(lowered, pattern: pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user