diff options
| author | Christian Cleberg <[email protected]> | 2026-04-26 00:22:01 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-26 00:22:01 -0500 |
| commit | cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5 (patch) | |
| tree | 653eda7655bba21df43f4039915d452110591094 | |
| parent | 535bb0ff0f64d57be1074e33ceb42f598f80205b (diff) | |
| download | domain-dig-cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5.tar.gz domain-dig-cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5.tar.bz2 domain-dig-cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5.zip | |
DomainDig v4.2.0: Add a local-only HTTP API layer for DomainDig
Expose DomainDig as a programmable local domain intelligence engine over
localhost with explicit user opt-in and token-based authentication.
Highlights:
- add a localhost-only API server with safe start/stop lifecycle
- require a local token for every request and store it in Keychain
- add read endpoints for portfolio, domains, history, events, and monitoring
- add inspection endpoints that reuse the existing inspection engine
- add monitoring enable/disable control endpoints
- add structured JSON response envelopes with API versioning
- add lightweight capped request logging with clear/reset support
- add a Settings UI for Local API enablement, token management, status, and logs
- start the server on launch only when enabled
- include Local API secrets in local data reset cleanup
This is the first programmability release for DomainDig and establishes the
foundation for Shortcuts, scripts, and other local automation workflows.
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 20 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 121 | ||||
| -rw-r--r-- | DomainDig/DataResetService.swift | 3 | ||||
| -rw-r--r-- | DomainDig/DomainDigApp.swift | 4 | ||||
| -rw-r--r-- | LocalAPIModels.swift | 45 | ||||
| -rw-r--r-- | LocalAPIService.swift | 1029 |
6 files changed, 1217 insertions, 5 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 2de9125..02634ff 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -16,6 +16,10 @@ 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; + 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; + 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; + 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */; }; + 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */; }; 8BF9DA862F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; 8BF9DA872F9B13FB00EF41D5 /* DomainDataPortabilityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */; }; /* End PBXBuildFile section */ @@ -27,6 +31,8 @@ 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; + 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIModels.swift; sourceTree = "<group>"; }; + 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAPIService.swift; sourceTree = "<group>"; }; 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDataPortabilityService.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ @@ -99,6 +105,8 @@ 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */, 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */, 8BF9DA842F9B13FB00EF41D5 /* DomainDataPortabilityService.swift */, + 8BCA3CBC2F9C8D57004B742C /* LocalAPIModels.swift */, + 8BCA3CBD2F9C8D57004B742C /* LocalAPIService.swift */, ); sourceTree = "<group>"; }; @@ -212,6 +220,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 8BCA3CC02F9C8D57004B742C /* LocalAPIModels.swift in Sources */, + 8BCA3CC12F9C8D57004B742C /* LocalAPIService.swift in Sources */, 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, @@ -230,6 +240,8 @@ 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */, 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */, 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */, + 8BCA3CBE2F9C8D57004B742C /* LocalAPIModels.swift in Sources */, + 8BCA3CBF2F9C8D57004B742C /* LocalAPIService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -366,7 +378,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 34; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -383,7 +395,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -403,7 +415,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = DomainDig/DomainDig.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 33; + CURRENT_PROJECT_VERSION = 34; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -420,7 +432,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 4.1.0; + MARKETING_VERSION = 4.2.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index c5c8e50..3716f88 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -2560,6 +2560,10 @@ struct SettingsView: View { IntegrationsSettingsView() } + NavigationLink("Local API") { + LocalAPISettingsView() + } + NavigationLink("iCloud Sync") { CloudSyncSettingsView() } @@ -2749,6 +2753,123 @@ private struct CloudSyncSettingsView: View { } } +private struct LocalAPISettingsView: View { + @Environment(\.appDensity) private var appDensity + @State private var localAPIService = LocalAPIService.shared + @State private var portText = "" + + var body: some View { + Form { + Section("Local API") { + Toggle( + "Enable Local API", + isOn: Binding( + get: { localAPIService.config.isEnabled }, + set: { localAPIService.setEnabled($0) } + ) + ) + + TextField( + "Port", + text: Binding( + get: { portText }, + set: { newValue in + portText = newValue + if let port = Int(newValue) { + localAPIService.setPort(port) + } + } + ) + ) + .keyboardType(.numberPad) + + LabeledContent("Address", value: localAPIService.address) + LabeledContent("Status", value: localAPIService.isRunning ? "Running" : (localAPIService.config.isEnabled ? "Stopped" : "Disabled")) + LabeledContent("Token", value: localAPIService.maskedToken) + + if let statusMessage = localAPIService.statusMessage { + Text(statusMessage) + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + } + + Section("Authentication") { + Button("Copy Token") { + localAPIService.copyToken() + } + + Button("Rotate Token") { + localAPIService.rotateToken() + } + + Text("Every request requires either `Authorization: Bearer <token>` or `X-API-Token`. DomainDig stores the token in Keychain and only binds the server to localhost.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } + + Section("Request Logging") { + Toggle( + "Log Requests", + isOn: Binding( + get: { localAPIService.config.requestLoggingEnabled }, + set: { localAPIService.setRequestLoggingEnabled($0) } + ) + ) + + if localAPIService.requestLogs.isEmpty { + Text("No local API requests logged yet.") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(.secondary) + } else { + ForEach(localAPIService.requestLogs.prefix(25)) { log in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("\(log.method) \(log.path)") + .font(appDensity.font(.callout, design: .monospaced)) + Spacer() + Text("\(log.statusCode)") + .font(appDensity.font(.caption, design: .default)) + .foregroundStyle(log.statusCode >= 400 ? .red : .secondary) + } + + Text(log.timestamp.formatted(date: .abbreviated, time: .standard)) + .font(appDensity.font(.caption2, design: .default)) + .foregroundStyle(.secondary) + + Text("\(Int(log.duration * 1000)) ms") + .font(appDensity.font(.caption2, design: .default)) + .foregroundStyle(.secondary) + } + } + } + + Button("Clear Logs", role: .destructive) { + localAPIService.clearRequestLogs() + } + } + + Section("Control") { + Button("Restart Server") { + localAPIService.setEnabled(false) + localAPIService.setEnabled(true) + } + .disabled(!localAPIService.config.isEnabled) + + Button("Stop Server") { + localAPIService.stopServer() + } + .disabled(!localAPIService.isRunning) + } + } + .navigationTitle("Local API") + .onAppear { + portText = String(localAPIService.config.port) + localAPIService.refresh() + } + } +} + private struct MonitoringSettingsView: View { @Environment(\.appDensity) private var appDensity @Bindable var viewModel: DomainViewModel diff --git a/DomainDig/DataResetService.swift b/DomainDig/DataResetService.swift index e993eba..d4c5bfe 100644 --- a/DomainDig/DataResetService.swift +++ b/DomainDig/DataResetService.swift @@ -15,7 +15,7 @@ enum DataResetService { static func wipeAllLocalData(viewModel: DomainViewModel) async throws { let secretReferences = await MainActor.run { - IntegrationService.shared.localSecretReferences() + IntegrationService.shared.localSecretReferences() + LocalAPIService.shared.localSecretReferences() } try await Task.detached(priority: .userInitiated) { @@ -28,6 +28,7 @@ enum DataResetService { await MainActor.run { IntegrationService.shared.resetAfterLocalWipe() + LocalAPIService.shared.resetAfterLocalWipe() CloudSyncService.shared.resetLocalStateAfterWipe() PurchaseService.shared.resetCachedStateAfterLocalWipe() _ = DomainMonitoringScheduler.shared.syncSchedule() diff --git a/DomainDig/DomainDigApp.swift b/DomainDig/DomainDigApp.swift index 2071213..ff40caf 100644 --- a/DomainDig/DomainDigApp.swift +++ b/DomainDig/DomainDigApp.swift @@ -15,6 +15,7 @@ struct DomainDigApp: App { @State private var viewModel = DomainViewModel() @State private var purchaseService = PurchaseService.shared @State private var cloudSyncService = CloudSyncService.shared + @State private var localAPIService = LocalAPIService.shared init() { LocalNotificationService.shared.configureForegroundPresentation() @@ -28,11 +29,13 @@ struct DomainDigApp: App { .task { let _ = purchaseService.currentTier let _ = cloudSyncService.status + let _ = localAPIService.isRunning let _ = IntegrationService.shared.targets.count await purchaseService.refreshEntitlements() viewModel.refreshMonitoringState() await viewModel.refreshMonitoringAuthorizationStatus() await cloudSyncService.refreshAvailability() + localAPIService.refresh() cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() @@ -50,6 +53,7 @@ struct DomainDigApp: App { await viewModel.refreshMonitoringAuthorizationStatus() await cloudSyncService.refreshAvailability() } + localAPIService.refresh() cloudSyncService.scheduleSyncIfNeeded(trigger: .launch) viewModel.monitoringStatusMessage = DomainMonitoringScheduler.shared.syncSchedule() IntegrationService.shared.processQueueNow() diff --git a/LocalAPIModels.swift b/LocalAPIModels.swift new file mode 100644 index 0000000..5a18f11 --- /dev/null +++ b/LocalAPIModels.swift @@ -0,0 +1,45 @@ +import Foundation + +struct LocalAPIConfig: Codable, Equatable, Sendable { + var isEnabled: Bool + var port: Int + var token: String + var requestLoggingEnabled: Bool + + init( + isEnabled: Bool = false, + port: Int = 47821, + token: String = "", + requestLoggingEnabled: Bool = true + ) { + self.isEnabled = isEnabled + self.port = port + self.token = token + self.requestLoggingEnabled = requestLoggingEnabled + } +} + +struct APIRequestLog: Codable, Identifiable, Equatable, Sendable { + let id: UUID + let timestamp: Date + let method: String + let path: String + let statusCode: Int + let duration: TimeInterval + + init( + id: UUID = UUID(), + timestamp: Date, + method: String, + path: String, + statusCode: Int, + duration: TimeInterval + ) { + self.id = id + self.timestamp = timestamp + self.method = method + self.path = path + self.statusCode = statusCode + self.duration = duration + } +} diff --git a/LocalAPIService.swift b/LocalAPIService.swift new file mode 100644 index 0000000..ad1e07d --- /dev/null +++ b/LocalAPIService.swift @@ -0,0 +1,1029 @@ +import Foundation +import Network +import Observation +import Security + +private let localAPIVersion = "v1" + +private enum LocalAPIServerError: LocalizedError { + case missingSecret + case secretPersistenceFailed + case serverStartFailed(String) + + var errorDescription: String? { + switch self { + case .missingSecret: + return "Local API token is unavailable." + case .secretPersistenceFailed: + return "Could not store the Local API token in Keychain." + case .serverStartFailed(let message): + return message + } + } +} + +private final class ListenerResumeState: @unchecked Sendable { + private let lock = NSLock() + nonisolated(unsafe) private var hasResumed = false + + nonisolated func beginResume() -> Bool { + lock.lock() + defer { lock.unlock() } + + guard !hasResumed else { return false } + hasResumed = true + return true + } +} + +@MainActor +@Observable +final class LocalAPIService { + static let shared = LocalAPIService() + + private static let configDefaultsKey = "localAPI.config" + private static let logsDefaultsKey = "localAPI.requestLogs" + private static let maxRequestLogs = 120 + private(set) var config: LocalAPIConfig + private(set) var requestLogs: [APIRequestLog] + private(set) var isRunning = false + private(set) var boundPort: Int? + private(set) var statusMessage: String? + + private var server: LocalAPIServer? + + private init() { + config = Self.loadConfig() + requestLogs = Self.loadRequestLogs() + } + + var address: String { + "http://127.0.0.1:\(boundPort ?? config.port)" + } + + var maskedToken: String { + guard !config.token.isEmpty else { return "Unavailable" } + let suffix = String(config.token.suffix(8)) + return "••••••••\(suffix)" + } + + func refresh() { + config = Self.loadConfig() + requestLogs = Self.loadRequestLogs() + + Task { + if config.isEnabled { + await startServerIfNeeded() + } else { + stopServer() + } + } + } + + func setEnabled(_ isEnabled: Bool) { + config.isEnabled = isEnabled + persistConfig() + + Task { + if isEnabled { + await startServer(forceRestart: true) + } else { + stopServer() + } + } + } + + func setPort(_ port: Int) { + let sanitizedPort = Self.sanitizedPort(port) + guard config.port != sanitizedPort else { return } + config.port = sanitizedPort + persistConfig() + + Task { + guard config.isEnabled else { return } + await startServer(forceRestart: true) + } + } + + func setRequestLoggingEnabled(_ isEnabled: Bool) { + config.requestLoggingEnabled = isEnabled + persistConfig() + } + + func rotateToken() { + config.token = Self.generateToken() + persistConfig() + + Task { + guard config.isEnabled else { return } + await startServer(forceRestart: true) + } + } + + func stopServer() { + server?.stop() + server = nil + isRunning = false + boundPort = nil + if config.isEnabled { + statusMessage = "Stopped" + } else { + statusMessage = "Disabled" + } + } + + func clearRequestLogs() { + requestLogs.removeAll() + Self.saveRequestLogs([]) + } + + func copyToken() { + guard !config.token.isEmpty else { return } + AppClipboard.copy(config.token) + } + + func localSecretReferences() -> [String] { + [LocalAPISecretStore.reference] + } + + func resetAfterLocalWipe() { + stopServer() + config = LocalAPIConfig(token: Self.generateToken()) + requestLogs = [] + persistConfig() + clearRequestLogs() + } + + private func startServerIfNeeded() async { + guard config.isEnabled else { + stopServer() + return + } + guard server == nil else { return } + await startServer(forceRestart: false) + } + + private func startServer(forceRestart: Bool) async { + if forceRestart { + stopServer() + } + + guard config.isEnabled else { + stopServer() + return + } + + let token = config.token.isEmpty ? Self.generateToken() : config.token + if token != config.token { + config.token = token + persistConfig() + } + + let server = LocalAPIServer( + port: Self.sanitizedPort(config.port), + token: token, + requestLogger: { log in + Task { @MainActor [weak self] in + self?.record(log) + } + }, + stateLogger: { stateMessage in + Task { @MainActor [weak self] in + self?.statusMessage = stateMessage + } + } + ) + + do { + let activePort = try await server.start() + self.server = server + boundPort = activePort + isRunning = true + statusMessage = "Listening on localhost:\(activePort)" + } catch { + self.server = nil + boundPort = nil + isRunning = false + statusMessage = error.localizedDescription + } + } + + private func record(_ log: APIRequestLog) { + guard config.requestLoggingEnabled else { return } + requestLogs.insert(log, at: 0) + requestLogs = Array(requestLogs.prefix(Self.maxRequestLogs)) + Self.saveRequestLogs(requestLogs) + } + + private func persistConfig() { + config.port = Self.sanitizedPort(config.port) + if config.token.isEmpty { + config.token = Self.generateToken() + } + + Self.saveConfig(config) + } + + private static func loadConfig(defaults: UserDefaults = .standard) -> LocalAPIConfig { + let persisted: PersistedLocalAPIConfig + if let data = defaults.data(forKey: configDefaultsKey), + let decoded = try? JSONDecoder().decode(PersistedLocalAPIConfig.self, from: data) { + persisted = decoded + } else { + persisted = PersistedLocalAPIConfig() + } + + let token = (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) ?? generateToken() + if (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) == nil { + try? LocalAPISecretStore.save(secret: token, reference: LocalAPISecretStore.reference) + } + + return LocalAPIConfig( + isEnabled: persisted.isEnabled, + port: sanitizedPort(persisted.port), + token: token, + requestLoggingEnabled: persisted.requestLoggingEnabled + ) + } + + private static func saveConfig(_ config: LocalAPIConfig, defaults: UserDefaults = .standard) { + let persisted = PersistedLocalAPIConfig( + isEnabled: config.isEnabled, + port: sanitizedPort(config.port), + requestLoggingEnabled: config.requestLoggingEnabled + ) + + if let data = try? JSONEncoder().encode(persisted) { + defaults.set(data, forKey: configDefaultsKey) + } + + try? LocalAPISecretStore.save(secret: config.token, reference: LocalAPISecretStore.reference) + } + + private static func loadRequestLogs(defaults: UserDefaults = .standard) -> [APIRequestLog] { + guard let data = defaults.data(forKey: logsDefaultsKey), + let decoded = try? JSONDecoder().decode([APIRequestLog].self, from: data) else { + return [] + } + return decoded + } + + private static func saveRequestLogs(_ logs: [APIRequestLog], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(Array(logs.prefix(maxRequestLogs))) { + defaults.set(data, forKey: logsDefaultsKey) + } + } + + private static func sanitizedPort(_ port: Int) -> Int { + min(max(port, 1024), 65535) + } + + private static func generateToken() -> String { + let bytes = (0..<24).map { _ in UInt8.random(in: .min ... .max) } + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private struct PersistedLocalAPIConfig: Codable { + var isEnabled: Bool = false + var port: Int = 47821 + var requestLoggingEnabled: Bool = true + } + + private enum LocalAPISecretStore { + static let reference = "DomainDig.LocalAPI.Token" + + static func save(secret: String, reference: String) throws { + let data = Data(secret.utf8) + try? delete(reference: reference) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: reference, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw LocalAPIServerError.secretPersistenceFailed + } + } + + static func secret(reference: String) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: reference, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let secret = String(data: data, encoding: .utf8) else { + throw LocalAPIServerError.missingSecret + } + + return secret + } + + static func delete(reference: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: reference + ] + SecItemDelete(query as CFDictionary) + } + } + +} + +private final class LocalAPIServer: @unchecked Sendable { + private let port: Int + private let token: String + private let requestLogger: @Sendable (APIRequestLog) -> Void + private let stateLogger: @Sendable (String) -> Void + private let handler = LocalAPIRequestHandler() + private let queue = DispatchQueue(label: "DomainDig.LocalAPIServer") + + private var listener: NWListener? + + init( + port: Int, + token: String, + requestLogger: @escaping @Sendable (APIRequestLog) -> Void, + stateLogger: @escaping @Sendable (String) -> Void + ) { + self.port = port + self.token = token + self.requestLogger = requestLogger + self.stateLogger = stateLogger + } + + func start() async throws -> Int { + let parameters = NWParameters.tcp + parameters.acceptLocalOnly = true + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort( + host: .ipv4(IPv4Address.loopback), + port: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any + ) + + let listener: NWListener + do { + listener = try NWListener( + using: parameters, + on: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any + ) + } catch { + throw LocalAPIServerError.serverStartFailed("Could not start Local API on port \(port).") + } + + listener.newConnectionHandler = { [weak self] connection in + Task { + await self?.handle(connection: connection) + } + } + + return try await withCheckedThrowingContinuation { continuation in + let resumeState = ListenerResumeState() + + listener.stateUpdateHandler = { state in + switch state { + case .ready: + guard resumeState.beginResume() else { return } + continuation.resume(returning: Int(listener.port?.rawValue ?? UInt16(self.port))) + case .failed(let error): + self.stateLogger("Failed: \(error.localizedDescription)") + guard resumeState.beginResume() else { return } + continuation.resume(throwing: LocalAPIServerError.serverStartFailed(error.localizedDescription)) + case .cancelled: + self.stateLogger("Stopped") + default: + break + } + } + + self.listener = listener + listener.start(queue: self.queue) + } + } + + func stop() { + listener?.cancel() + listener = nil + } + + private func handle(connection: NWConnection) async { + connection.start(queue: queue) + let startedAt = Date() + + do { + let requestData = try await readCompleteRequest(from: connection) + let request = try LocalAPIHTTPParser.parseRequest(from: requestData) + let response = await handler.handle(request: request, expectedToken: token) + let log = APIRequestLog( + timestamp: startedAt, + method: request.method, + path: request.pathWithQuery, + statusCode: response.statusCode, + duration: Date().timeIntervalSince(startedAt) + ) + requestLogger(log) + try await send(response.serialized(), on: connection) + } catch let error as LocalAPIHTTPParser.ParserError { + let response = LocalAPIHTTPResponse.error(statusCode: 400, code: "bad_request", message: error.localizedDescription) + let log = APIRequestLog( + timestamp: startedAt, + method: "INVALID", + path: "/", + statusCode: response.statusCode, + duration: Date().timeIntervalSince(startedAt) + ) + requestLogger(log) + try? await send(response.serialized(), on: connection) + } catch { + let response = LocalAPIHTTPResponse.error(statusCode: 500, code: "internal_error", message: "The Local API request failed.") + let log = APIRequestLog( + timestamp: startedAt, + method: "ERROR", + path: "/", + statusCode: response.statusCode, + duration: Date().timeIntervalSince(startedAt) + ) + requestLogger(log) + try? await send(response.serialized(), on: connection) + } + + connection.cancel() + } + + private func readCompleteRequest(from connection: NWConnection, accumulated: Data = Data()) async throws -> Data { + let chunk = try await receiveChunk(from: connection) + let combined = accumulated + chunk.data + + if LocalAPIHTTPParser.isCompleteRequest(combined) || chunk.isComplete { + return combined + } + + return try await readCompleteRequest(from: connection, accumulated: combined) + } + + private func receiveChunk(from connection: NWConnection) async throws -> (data: Data, isComplete: Bool) { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in + if let error { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: (data ?? Data(), isComplete)) + } + } + } + + private func send(_ data: Data, on connection: NWConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + }) + } + } +} + +private struct LocalAPIRequestHandler { + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + private let inspectionService = DomainInspectionService() + private let reportBuilder = DomainReportBuilder() + + func handle(request: LocalAPIHTTPRequest, expectedToken: String) async -> LocalAPIHTTPResponse { + guard isAuthorized(request: request, expectedToken: expectedToken) else { + return .error(statusCode: 401, code: "unauthorized", message: "A valid local API token is required.") + } + + if request.method == "GET", request.pathComponents == ["portfolio"] { + return successResponse(PortfolioPayload(summary: buildPortfolioSummary())) + } + + if request.method == "GET", request.pathComponents == ["domains"] { + return successResponse(DomainListPayload(domains: DomainDataPortabilityService.loadTrackedDomains())) + } + + if request.method == "GET", + request.pathComponents.count == 2, + request.pathComponents.first == "domains" { + let domain = request.pathComponents[1] + return domainDetailsResponse(for: domain) + } + + if request.method == "GET", + request.pathComponents.count == 3, + request.pathComponents.first == "domains", + request.pathComponents.last == "history" { + let domain = request.pathComponents[1] + return successResponse(DomainHistoryPayload(domain: normalizedDomain(domain), history: historyEntries(for: domain))) + } + + if request.method == "GET", request.pathComponents == ["events"] { + return successResponse(RecentEventsPayload(events: recentEvents())) + } + + if request.method == "GET", request.pathComponents == ["monitoring"] { + return successResponse(monitoringPayload()) + } + + if request.method == "POST", request.pathComponents == ["inspect"] { + return await inspectBodyResponse(request.body) + } + + if request.method == "POST", + request.pathComponents.count == 2, + request.pathComponents.first == "inspect" { + let domain = request.pathComponents[1] + return await inspectResponse(for: domain) + } + + if request.method == "POST", + request.pathComponents.count == 3, + request.pathComponents.first == "monitoring", + request.pathComponents.last == "enable" { + let domain = request.pathComponents[1] + return await setMonitoring(enabled: true, for: domain) + } + + if request.method == "POST", + request.pathComponents.count == 3, + request.pathComponents.first == "monitoring", + request.pathComponents.last == "disable" { + let domain = request.pathComponents[1] + return await setMonitoring(enabled: false, for: domain) + } + + return .error(statusCode: 404, code: "not_found", message: "The requested Local API route does not exist.") + } + + private func isAuthorized(request: LocalAPIHTTPRequest, expectedToken: String) -> Bool { + guard !expectedToken.isEmpty else { return false } + + if request.headers["x-api-token"] == expectedToken { + return true + } + + if let authorization = request.headers["authorization"], + authorization == "Bearer \(expectedToken)" { + return true + } + + return false + } + + private func successResponse<Value: Encodable>(_ value: Value) -> LocalAPIHTTPResponse { + let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: localAPIVersion) + guard let body = try? encoder.encode(envelope) else { + return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.") + } + + return LocalAPIHTTPResponse(statusCode: 200, body: body) + } + + private func domainDetailsResponse(for domain: String) -> LocalAPIHTTPResponse { + let normalized = normalizedDomain(domain) + let trackedDomain = DomainDataPortabilityService.loadTrackedDomains().first { + $0.domain.caseInsensitiveCompare(normalized) == .orderedSame + } + let history = historyEntries(for: normalized) + let latestEntry = history.first + + guard trackedDomain != nil || latestEntry != nil else { + return .error(statusCode: 404, code: "domain_not_found", message: "No local data exists for \(normalized).") + } + + let latestReport = latestEntry.map { buildReport(for: $0, from: history) } + return successResponse( + DomainDetailPayload( + domain: normalized, + trackedDomain: trackedDomain, + latestReport: latestReport + ) + ) + } + + private func inspectBodyResponse(_ body: Data) async -> LocalAPIHTTPResponse { + guard let payload = try? decoder.decode(InspectRequestPayload.self, from: body) else { + return .error(statusCode: 400, code: "invalid_body", message: "Expected JSON body: {\"domain\":\"example.com\"}.") + } + + return await inspectResponse(for: payload.domain) + } + + private func inspectResponse(for domain: String) async -> LocalAPIHTTPResponse { + let normalized = normalizedDomain(domain) + guard !normalized.isEmpty else { + return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.") + } + + let previousSnapshot = latestHistoryEntry(for: normalized)?.snapshot + let report = await inspectionService.inspect(domain: normalized, previousSnapshot: previousSnapshot) + return successResponse(InspectResponsePayload(report: report)) + } + + private func setMonitoring(enabled: Bool, for domain: String) async -> LocalAPIHTTPResponse { + let normalized = normalizedDomain(domain) + guard !normalized.isEmpty else { + return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.") + } + + var trackedDomains = DomainDataPortabilityService.loadTrackedDomains() + guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame }) else { + return .error(statusCode: 404, code: "domain_not_found", message: "Tracked domain \(normalized) was not found.") + } + + trackedDomains[index].monitoringEnabled = enabled + trackedDomains[index].updatedAt = Date() + DomainDataPortabilityService.saveTrackedDomains(trackedDomains) + + let sanitizedSettings = MonitoringStorage.sanitizeSettings( + MonitoringStorage.loadSettings(), + trackedDomains: trackedDomains + ) + MonitoringStorage.saveSettings(sanitizedSettings) + + await MainActor.run { + _ = DomainMonitoringScheduler.shared.syncSchedule() + } + + return successResponse( + MonitoringMutationPayload( + domain: trackedDomains[index].domain, + monitoringEnabled: trackedDomains[index].monitoringEnabled + ) + ) + } + + private func buildPortfolioSummary() -> PortfolioSummary { + let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() + let history = DomainDataPortabilityService.loadHistoryEntries() + + var healthyCount = 0 + var warningCount = 0 + var criticalCount = 0 + var changedLast24h = 0 + var unreachableCount = 0 + + for trackedDomain in trackedDomains { + guard let latestEntry = latestHistoryEntry(for: trackedDomain.domain, history: history) else { continue } + let report = buildReport(for: latestEntry, from: history) + + switch report.health { + case .healthy: + healthyCount += 1 + case .warning: + warningCount += 1 + case .critical: + criticalCount += 1 + } + + if latestEntry.changeSummary?.hasChanges == true, + Date().timeIntervalSince(latestEntry.timestamp) <= 24 * 60 * 60 { + changedLast24h += 1 + } + + if report.network.reachabilityError != nil || report.lastMonitoringFailure != nil { + unreachableCount += 1 + } + } + + return PortfolioSummary( + totalDomains: trackedDomains.count, + healthyCount: healthyCount, + warningCount: warningCount, + criticalCount: criticalCount, + changedLast24h: changedLast24h, + expiringSoonCount: trackedDomains.filter { $0.certificateWarningLevel != .none }.count, + unreachableCount: unreachableCount + ) + } + + private func monitoringPayload() -> MonitoringPayload { + let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() + let settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains) + + return MonitoringPayload( + isEnabled: settings.isEnabled, + scope: settings.scope, + alertsEnabled: settings.alertsEnabled, + monitoredDomains: trackedDomains.map { + MonitoringDomainPayload( + domain: $0.domain, + monitoringEnabled: $0.monitoringEnabled, + lastMonitoredAt: $0.lastMonitoredAt, + lastAlertAt: $0.lastAlertAt, + certificateWarningLevel: $0.certificateWarningLevel + ) + } + ) + } + + private func recentEvents() -> [RecentEventPayload] { + MonitoringStorage.loadLogs() + .prefix(25) + .flatMap { log in + log.checkedDomains.map { result in + RecentEventPayload( + timestamp: result.checkedAt, + domain: result.domain, + summary: result.summaryMessage, + status: result.errorMessage == nil ? "ok" : "error", + severity: result.alertSeverity?.title ?? "Info" + ) + } + } + } + + private func historyEntries(for domain: String) -> [HistoryEntry] { + let normalized = normalizedDomain(domain) + return DomainDataPortabilityService.loadHistoryEntries().filter { + $0.domain.caseInsensitiveCompare(normalized) == .orderedSame + } + } + + private func latestHistoryEntry(for domain: String, history: [HistoryEntry]? = nil) -> HistoryEntry? { + let entries = history ?? DomainDataPortabilityService.loadHistoryEntries() + let normalized = normalizedDomain(domain) + return entries.first { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame } + } + + private func buildReport(for entry: HistoryEntry, from history: [HistoryEntry]) -> DomainReport { + let previousSnapshot = history + .dropFirst(history.firstIndex(where: { $0.id == entry.id }).map { $0 + 1 } ?? history.count) + .first { $0.domain.caseInsensitiveCompare(entry.domain) == .orderedSame }? + .snapshot + return reportBuilder.build(from: entry, previousSnapshot: previousSnapshot) + } + + private func normalizedDomain(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } +} + +private struct LocalAPIHTTPRequest { + let method: String + let path: String + let queryItems: [URLQueryItem] + let headers: [String: String] + let body: Data + + var pathComponents: [String] { + path.split(separator: "/").map(String.init) + } + + var pathWithQuery: String { + guard !queryItems.isEmpty else { return path } + let query = queryItems.compactMap { item in + guard let value = item.value else { return item.name } + return "\(item.name)=\(value)" + } + .joined(separator: "&") + return "\(path)?\(query)" + } +} + +private struct LocalAPIHTTPResponse { + let statusCode: Int + let body: Data + + init(statusCode: Int, body: Data) { + self.statusCode = statusCode + self.body = body + } + + static func error(statusCode: Int, code: String, message: String) -> LocalAPIHTTPResponse { + let payload = LocalAPIEnvelope<EmptyPayload>( + success: false, + data: nil, + error: LocalAPIErrorPayload(code: code, message: message), + version: localAPIVersion + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let body = (try? encoder.encode(payload)) ?? Data() + return LocalAPIHTTPResponse(statusCode: statusCode, body: body) + } + + func serialized() -> Data { + var response = Data() + response.append("HTTP/1.1 \(statusCode) \(Self.reasonPhrase(for: statusCode))\r\n".data(using: .utf8) ?? Data()) + response.append("Content-Type: application/json\r\n".data(using: .utf8) ?? Data()) + response.append("Content-Length: \(body.count)\r\n".data(using: .utf8) ?? Data()) + response.append("Connection: close\r\n\r\n".data(using: .utf8) ?? Data()) + response.append(body) + return response + } + + private static func reasonPhrase(for statusCode: Int) -> String { + switch statusCode { + case 200: + return "OK" + case 400: + return "Bad Request" + case 401: + return "Unauthorized" + case 404: + return "Not Found" + default: + return "Internal Server Error" + } + } +} + +private enum LocalAPIHTTPParser { + enum ParserError: LocalizedError { + case invalidRequestLine + case invalidPath + + var errorDescription: String? { + switch self { + case .invalidRequestLine: + return "The HTTP request line is invalid." + case .invalidPath: + return "The HTTP request path is invalid." + } + } + } + + static func isCompleteRequest(_ data: Data) -> Bool { + guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else { + return false + } + + let headerData = data[..<separatorRange.lowerBound] + guard let headerString = String(data: headerData, encoding: .utf8) else { + return false + } + + let contentLength = headerString + .split(separator: "\r\n") + .first { $0.lowercased().hasPrefix("content-length:") } + .flatMap { Int($0.split(separator: ":", maxSplits: 1).last?.trimmingCharacters(in: .whitespaces) ?? "") } + ?? 0 + + let bodyLength = data.count - separatorRange.upperBound + return bodyLength >= contentLength + } + + static func parseRequest(from data: Data) throws -> LocalAPIHTTPRequest { + guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else { + throw ParserError.invalidRequestLine + } + + let headerData = data[..<separatorRange.lowerBound] + let bodyData = data[separatorRange.upperBound...] + + guard let headerString = String(data: headerData, encoding: .utf8) else { + throw ParserError.invalidRequestLine + } + + let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false) + guard let requestLine = lines.first else { + throw ParserError.invalidRequestLine + } + + let requestParts = requestLine.split(separator: " ") + guard requestParts.count >= 2 else { + throw ParserError.invalidRequestLine + } + + let method = String(requestParts[0]).uppercased() + let rawPath = String(requestParts[1]) + guard let components = URLComponents(string: rawPath) else { + throw ParserError.invalidPath + } + + var headers: [String: String] = [:] + for line in lines.dropFirst() { + let parts = line.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { continue } + headers[String(parts[0]).lowercased()] = String(parts[1]).trimmingCharacters(in: .whitespaces) + } + + return LocalAPIHTTPRequest( + method: method, + path: components.path, + queryItems: components.queryItems ?? [], + headers: headers, + body: Data(bodyData) + ) + } +} + +private struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable { + let success: Bool + let data: DataPayload? + let error: LocalAPIErrorPayload? + let version: String +} + +private struct LocalAPIErrorPayload: Encodable { + let code: String + let message: String +} + +private struct EmptyPayload: Encodable {} + +private struct PortfolioPayload: Encodable { + let summary: PortfolioSummary +} + +private struct PortfolioSummary: Encodable { + let totalDomains: Int + let healthyCount: Int + let warningCount: Int + let criticalCount: Int + let changedLast24h: Int + let expiringSoonCount: Int + let unreachableCount: Int +} + +private struct DomainListPayload: Encodable { + let domains: [TrackedDomain] +} + +private struct DomainDetailPayload: Encodable { + let domain: String + let trackedDomain: TrackedDomain? + let latestReport: DomainReport? +} + +private struct DomainHistoryPayload: Encodable { + let domain: String + let history: [HistoryEntry] +} + +private struct RecentEventsPayload: Encodable { + let events: [RecentEventPayload] +} + +private struct RecentEventPayload: Encodable { + let timestamp: Date + let domain: String + let summary: String + let status: String + let severity: String +} + +private struct MonitoringPayload: Encodable { + let isEnabled: Bool + let scope: MonitoringScope + let alertsEnabled: Bool + let monitoredDomains: [MonitoringDomainPayload] +} + +private struct MonitoringDomainPayload: Encodable { + let domain: String + let monitoringEnabled: Bool + let lastMonitoredAt: Date? + let lastAlertAt: Date? + let certificateWarningLevel: CertificateWarningLevel +} + +private struct MonitoringMutationPayload: Encodable { + let domain: String + let monitoringEnabled: Bool +} + +private struct InspectRequestPayload: Decodable { + let domain: String +} + +private struct InspectResponsePayload: Encodable { + let report: DomainReport +} |
