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:
Trey t
2026-04-11 12:52:18 -05:00
parent c77e506db5
commit 148bc3887c
77 changed files with 6710 additions and 847 deletions

View 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")
}
}

View 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
}
}

View File

@@ -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?()
}
}

View 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)
}
}
}

View 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")
}

View 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
}
}