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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
24
ProxyCore/Sources/DataLayer/Models/PinnedDomain.swift
Normal file
24
ProxyCore/Sources/DataLayer/Models/PinnedDomain.swift
Normal 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
|
||||
}
|
||||
}
|
||||
35
ProxyCore/Sources/DataLayer/Models/ProxyConfiguration.swift
Normal file
35
ProxyCore/Sources/DataLayer/Models/ProxyConfiguration.swift
Normal 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
|
||||
}
|
||||
}
|
||||
61
ProxyCore/Sources/DataLayer/Models/ProxyRuntimeStatus.swift
Normal file
61
ProxyCore/Sources/DataLayer/Models/ProxyRuntimeStatus.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user