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

@@ -1,14 +1,23 @@
import Foundation
import GRDB
import os
private let log = Logger(subsystem: "com.treyt.proxyapp", category: "db")
public final class DatabaseManager: Sendable {
public let dbPool: DatabasePool
public static let shared: DatabaseManager = {
let url = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: "group.com.treyt.proxyapp")!
.appendingPathComponent("proxy.sqlite")
return try! DatabaseManager(path: url.path)
let url = AppGroupPaths.containerURL.appendingPathComponent("proxy.sqlite")
log.info("DB path: \(url.path)")
do {
let mgr = try DatabaseManager(path: url.path)
log.info("DatabaseManager initialized OK")
return mgr
} catch {
log.fault("DatabaseManager FATAL: \(error.localizedDescription)")
fatalError("[DatabaseManager] Failed to initialize database at \(url.path): \(error)")
}
}()
public init(path: String) throws {
@@ -129,6 +138,65 @@ public final class DatabaseManager: Sendable {
}
}
migrator.registerMigration("v2_create_proxy_configuration") { db in
try db.create(table: "proxy_configuration") { t in
t.column("id", .integer).primaryKey(onConflict: .replace)
t.column("sslProxyingEnabled", .boolean).notNull().defaults(to: false)
t.column("blockListEnabled", .boolean).notNull().defaults(to: false)
t.column("breakpointEnabled", .boolean).notNull().defaults(to: false)
t.column("noCachingEnabled", .boolean).notNull().defaults(to: false)
t.column("dnsSpoofingEnabled", .boolean).notNull().defaults(to: false)
t.column("hideSystemTraffic", .boolean).notNull().defaults(to: false)
t.column("updatedAt", .double).notNull()
}
try db.execute(
sql: """
INSERT INTO proxy_configuration (
id, sslProxyingEnabled, blockListEnabled, breakpointEnabled,
noCachingEnabled, dnsSpoofingEnabled, hideSystemTraffic, updatedAt
) VALUES (1, 0, 0, 0, 0, 0, 0, ?)
""",
arguments: [Date().timeIntervalSince1970]
)
}
migrator.registerMigration("v3_create_proxy_runtime_status") { db in
try db.create(table: "proxy_runtime_status") { t in
t.column("id", .integer).primaryKey(onConflict: .replace)
t.column("tunnelState", .text).notNull().defaults(to: ProxyRuntimeState.stopped.rawValue)
t.column("proxyHost", .text)
t.column("proxyPort", .integer)
t.column("caFingerprint", .text)
t.column("lastProxyError", .text)
t.column("lastMITMError", .text)
t.column("lastConnectError", .text)
t.column("lastSuccessfulMITMDomain", .text)
t.column("lastSuccessfulMITMAt", .double)
t.column("lastExtensionStartAt", .double)
t.column("lastExtensionStopAt", .double)
t.column("updatedAt", .double).notNull()
}
try db.execute(
sql: """
INSERT INTO proxy_runtime_status (
id, tunnelState, updatedAt
) VALUES (1, ?, ?)
""",
arguments: [ProxyRuntimeState.stopped.rawValue, Date().timeIntervalSince1970]
)
}
migrator.registerMigration("v4_create_pinned_domains") { db in
try db.create(table: "pinned_domains") { t in
t.autoIncrementedPrimaryKey("id")
t.column("domain", .text).notNull().unique()
t.column("reason", .text)
t.column("detectedAt", .double).notNull()
}
}
try migrator.migrate(dbPool)
}
}

View File

@@ -117,6 +117,26 @@ extension CapturedTraffic {
return dict
}
public func requestHeaderValue(named name: String) -> String? {
HTTPBodyDecoder.headerValue(named: name, in: decodedRequestHeaders)
}
public func responseHeaderValue(named name: String) -> String? {
HTTPBodyDecoder.headerValue(named: name, in: decodedResponseHeaders)
}
public var decodedResponseBodyData: Data? {
HTTPBodyDecoder.decodedBodyData(from: responseBody, headers: decodedResponseHeaders)
}
public var searchableResponseBodyText: String? {
HTTPBodyDecoder.searchableText(from: responseBody, headers: decodedResponseHeaders)
}
public var responseBodyDecodingHint: String {
HTTPBodyDecoder.decodingHint(for: responseBody, headers: decodedResponseHeaders)
}
public var decodedQueryParameters: [String: String] {
guard let data = queryParameters?.data(using: .utf8),
let dict = try? JSONDecoder().decode([String: String].self, from: data) else {

View File

@@ -0,0 +1,24 @@
import Foundation
import GRDB
/// A domain detected as using SSL pinning. MITM will automatically skip these
/// and fall back to passthrough mode.
public struct PinnedDomain: Codable, FetchableRecord, MutablePersistableRecord, Identifiable, Sendable {
public var id: Int64?
public var domain: String
public var reason: String?
public var detectedAt: Double
public static let databaseTableName = "pinned_domains"
public mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
}
public init(id: Int64? = nil, domain: String, reason: String? = nil, detectedAt: Double = Date().timeIntervalSince1970) {
self.id = id
self.domain = domain
self.reason = reason
self.detectedAt = detectedAt
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
import GRDB
public struct ProxyConfiguration: Codable, FetchableRecord, MutablePersistableRecord, Sendable {
public static let databaseTableName = "proxy_configuration"
public var id: Int64
public var sslProxyingEnabled: Bool
public var blockListEnabled: Bool
public var breakpointEnabled: Bool
public var noCachingEnabled: Bool
public var dnsSpoofingEnabled: Bool
public var hideSystemTraffic: Bool
public var updatedAt: Double
public init(
id: Int64 = 1,
sslProxyingEnabled: Bool = false,
blockListEnabled: Bool = false,
breakpointEnabled: Bool = false,
noCachingEnabled: Bool = false,
dnsSpoofingEnabled: Bool = false,
hideSystemTraffic: Bool = false,
updatedAt: Double = Date().timeIntervalSince1970
) {
self.id = id
self.sslProxyingEnabled = sslProxyingEnabled
self.blockListEnabled = blockListEnabled
self.breakpointEnabled = breakpointEnabled
self.noCachingEnabled = noCachingEnabled
self.dnsSpoofingEnabled = dnsSpoofingEnabled
self.hideSystemTraffic = hideSystemTraffic
self.updatedAt = updatedAt
}
}

View File

@@ -0,0 +1,61 @@
import Foundation
import GRDB
public enum ProxyRuntimeState: String, Codable, Sendable {
case stopped
case starting
case running
case failed
}
public struct ProxyRuntimeStatus: Codable, FetchableRecord, MutablePersistableRecord, Sendable {
public static let databaseTableName = "proxy_runtime_status"
public var id: Int64
public var tunnelState: String
public var proxyHost: String?
public var proxyPort: Int?
public var caFingerprint: String?
public var lastProxyError: String?
public var lastMITMError: String?
public var lastConnectError: String?
public var lastSuccessfulMITMDomain: String?
public var lastSuccessfulMITMAt: Double?
public var lastExtensionStartAt: Double?
public var lastExtensionStopAt: Double?
public var updatedAt: Double
public init(
id: Int64 = 1,
tunnelState: ProxyRuntimeState = .stopped,
proxyHost: String? = nil,
proxyPort: Int? = nil,
caFingerprint: String? = nil,
lastProxyError: String? = nil,
lastMITMError: String? = nil,
lastConnectError: String? = nil,
lastSuccessfulMITMDomain: String? = nil,
lastSuccessfulMITMAt: Double? = nil,
lastExtensionStartAt: Double? = nil,
lastExtensionStopAt: Double? = nil,
updatedAt: Double = Date().timeIntervalSince1970
) {
self.id = id
self.tunnelState = tunnelState.rawValue
self.proxyHost = proxyHost
self.proxyPort = proxyPort
self.caFingerprint = caFingerprint
self.lastProxyError = lastProxyError
self.lastMITMError = lastMITMError
self.lastConnectError = lastConnectError
self.lastSuccessfulMITMDomain = lastSuccessfulMITMDomain
self.lastSuccessfulMITMAt = lastSuccessfulMITMAt
self.lastExtensionStartAt = lastExtensionStartAt
self.lastExtensionStopAt = lastExtensionStopAt
self.updatedAt = updatedAt
}
public var state: ProxyRuntimeState {
ProxyRuntimeState(rawValue: tunnelState) ?? .stopped
}
}

View File

@@ -14,6 +14,10 @@ public final class ComposeRepository: Sendable {
}
}
public func fetch(id: Int64) throws -> ComposeRequest? {
try db.dbPool.read { db in try ComposeRequest.fetchOne(db, id: id) }
}
public func insert(_ request: inout ComposeRequest) throws {
try db.dbPool.write { db in try request.insert(db) }
}

View File

@@ -0,0 +1,41 @@
import Foundation
import GRDB
public final class ConfigurationRepository: Sendable {
private let db: DatabaseManager
public init(db: DatabaseManager = .shared) {
self.db = db
}
public func observeConfiguration() -> ValueObservation<ValueReducers.Fetch<ProxyConfiguration>> {
ValueObservation.tracking { db in
try ProxyConfiguration.fetchOne(db, key: 1) ?? ProxyConfiguration()
}
}
public func current() throws -> ProxyConfiguration {
try db.dbPool.read { db in
try ProxyConfiguration.fetchOne(db, key: 1) ?? ProxyConfiguration()
}
}
public func update(_ mutate: (inout ProxyConfiguration) -> Void) throws {
try db.dbPool.write { db in
var configuration = try fetchOrCreate(in: db)
mutate(&configuration)
configuration.updatedAt = Date().timeIntervalSince1970
try configuration.save(db)
}
}
private func fetchOrCreate(in db: Database) throws -> ProxyConfiguration {
if let configuration = try ProxyConfiguration.fetchOne(db, key: 1) {
return configuration
}
var configuration = ProxyConfiguration()
try configuration.insert(db)
return configuration
}
}

View File

@@ -0,0 +1,63 @@
import Foundation
import GRDB
public final class PinnedDomainRepository: Sendable {
private let db: DatabaseManager
public init(db: DatabaseManager = .shared) {
self.db = db
}
/// Check if a domain (or any parent wildcard) is pinned.
public func isPinned(domain: String) -> Bool {
do {
return try db.dbPool.read { db in
try PinnedDomain.filter(Column("domain") == domain).fetchCount(db) > 0
}
} catch {
return false
}
}
/// Record a domain as pinned after a TLS handshake failure.
public func markPinned(domain: String, reason: String) {
do {
try db.dbPool.write { db in
// Use INSERT OR IGNORE since domain has UNIQUE constraint
try db.execute(
sql: "INSERT OR IGNORE INTO pinned_domains (domain, reason, detectedAt) VALUES (?, ?, ?)",
arguments: [domain, reason, Date().timeIntervalSince1970]
)
}
ProxyLogger.mitm.info("Marked domain as PINNED: \(domain) reason=\(reason)")
} catch {
ProxyLogger.mitm.error("Failed to mark pinned domain: \(error.localizedDescription)")
}
}
/// Remove a domain from the pinned list (user override).
public func unpin(domain: String) throws {
try db.dbPool.write { db in
_ = try PinnedDomain.filter(Column("domain") == domain).deleteAll(db)
}
}
/// Get all pinned domains.
public func fetchAll() throws -> [PinnedDomain] {
try db.dbPool.read { db in
try PinnedDomain.order(Column("detectedAt").desc).fetchAll(db)
}
}
public func observeAll() -> ValueObservation<ValueReducers.Fetch<[PinnedDomain]>> {
ValueObservation.tracking { db in
try PinnedDomain.order(Column("detectedAt").desc).fetchAll(db)
}
}
public func deleteAll() throws {
try db.dbPool.write { db in
_ = try PinnedDomain.deleteAll(db)
}
}
}

View File

@@ -26,6 +26,10 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in try entry.insert(db) }
}
public func updateSSLEntry(_ entry: SSLProxyingEntry) throws {
try db.dbPool.write { db in try entry.update(db) }
}
public func deleteSSLEntry(id: Int64) throws {
try db.dbPool.write { db in _ = try SSLProxyingEntry.deleteOne(db, id: id) }
}
@@ -34,6 +38,12 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in _ = try SSLProxyingEntry.deleteAll(db) }
}
public func fetchEnabledBlockEntries() throws -> [BlockListEntry] {
try db.dbPool.read { db in
try BlockListEntry.filter(Column("isEnabled") == true).fetchAll(db)
}
}
// MARK: - Block List
public func observeBlockListEntries() -> ValueObservation<ValueReducers.Fetch<[BlockListEntry]>> {
@@ -82,6 +92,12 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in _ = try BreakpointRule.deleteAll(db) }
}
public func fetchEnabledMapLocalRules() throws -> [MapLocalRule] {
try db.dbPool.read { db in
try MapLocalRule.filter(Column("isEnabled") == true).fetchAll(db)
}
}
// MARK: - Map Local Rules
public func observeMapLocalRules() -> ValueObservation<ValueReducers.Fetch<[MapLocalRule]>> {
@@ -106,6 +122,12 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in _ = try MapLocalRule.deleteAll(db) }
}
public func fetchEnabledDNSSpoofRules() throws -> [DNSSpoofRule] {
try db.dbPool.read { db in
try DNSSpoofRule.filter(Column("isEnabled") == true).fetchAll(db)
}
}
// MARK: - DNS Spoof Rules
public func observeDNSSpoofRules() -> ValueObservation<ValueReducers.Fetch<[DNSSpoofRule]>> {
@@ -118,6 +140,10 @@ public final class RulesRepository: Sendable {
try db.dbPool.write { db in try rule.insert(db) }
}
public func updateDNSSpoofRule(_ rule: DNSSpoofRule) throws {
try db.dbPool.write { db in try rule.update(db) }
}
public func deleteDNSSpoofRule(id: Int64) throws {
try db.dbPool.write { db in _ = try DNSSpoofRule.deleteOne(db, id: id) }
}

View File

@@ -0,0 +1,45 @@
import Foundation
import GRDB
public final class RuntimeStatusRepository: Sendable {
private let db: DatabaseManager
public init(db: DatabaseManager = .shared) {
self.db = db
}
public func observeStatus() -> ValueObservation<ValueReducers.Fetch<ProxyRuntimeStatus>> {
ValueObservation.tracking { db in
try ProxyRuntimeStatus.fetchOne(db, key: 1) ?? ProxyRuntimeStatus()
}
}
public func current() throws -> ProxyRuntimeStatus {
try db.dbPool.read { db in
try ProxyRuntimeStatus.fetchOne(db, key: 1) ?? ProxyRuntimeStatus()
}
}
public func update(_ mutate: (inout ProxyRuntimeStatus) -> Void) {
do {
try db.dbPool.write { db in
var status = try fetchOrCreate(in: db)
mutate(&status)
status.updatedAt = Date().timeIntervalSince1970
try status.save(db)
}
} catch {
ProxyLogger.db.error("RuntimeStatusRepository update failed: \(error.localizedDescription)")
}
}
private func fetchOrCreate(in db: Database) throws -> ProxyRuntimeStatus {
if let status = try ProxyRuntimeStatus.fetchOne(db, key: 1) {
return status
}
var status = ProxyRuntimeStatus()
try status.insert(db)
return status
}
}