diff options
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 78 | ||||
| -rw-r--r-- | DomainDig/BatchResultsView.swift | 5 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 50 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 267 | ||||
| -rw-r--r-- | DomainDig/LookupRuntime.swift | 289 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 40 | ||||
| -rw-r--r-- | DomainDig/RDAPService.swift | 31 | ||||
| -rw-r--r-- | DomainDig/SubdomainDiscoveryService.swift | 45 | ||||
| -rw-r--r-- | DomainDigCLI.swift | 32 | ||||
| -rw-r--r-- | DomainInspectionService.swift | 285 | ||||
| -rw-r--r-- | DomainReportBuilder.swift | 2 | ||||
| -rw-r--r-- | DomainReportExporter.swift | 3 | ||||
| -rw-r--r-- | LookupSnapshot.swift | 12 |
13 files changed, 916 insertions, 223 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 40dadc9..fbaa03c 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -12,16 +12,20 @@ 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; + 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; }; 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; 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>"; }; + 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -33,11 +37,9 @@ ); target = 8B7800682F6090E300933221 /* DomainDig */; }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - Assets.xcassets, BatchResultsView.swift, BatchSweepSummaryView.swift, ContentView.swift, @@ -45,13 +47,13 @@ DomainViewModel.swift, ExportPresenter.swift, HistoryView.swift, - Info.plist, LocalNotificationService.swift, SavedDomainsView.swift, WatchlistView.swift, ); target = 8BF124FB2F70000100933221 /* DomainDigCLI */; }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 8B78006B2F6090E300933221 /* DomainDig */ = { @@ -132,7 +134,7 @@ }; 8BF124FB2F70000100933221 /* DomainDigCLI */ = { isa = PBXNativeTarget; - buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; + buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */; buildPhases = ( 8BF124FC2F70000100933221 /* Sources */, 8BF124FD2F70000100933221 /* Frameworks */, @@ -201,7 +203,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, @@ -209,6 +210,18 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 8BF124FC2F70000100933221 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */, + 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */, + 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */, + 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */, + 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -341,7 +354,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -358,7 +371,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.5.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -377,7 +390,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -394,7 +407,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.4.0; + MARKETING_VERSION = 2.5.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -407,44 +420,22 @@ }; name = Release; }; - 8BF125002F70000100933221 /* Debug */ = { + 8BBFF0272F987E8700E8E144 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGNING_ALLOWED = NO; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; - DEVELOPMENT_TEAM = ZCNAX3VL9D; - ENABLE_TESTABILITY = YES; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.4.0; - PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; + COPY_PHASE_STRIP = NO; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; PRODUCT_NAME = domaindig; - SDKROOT = macosx; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; }; name = Debug; }; - 8BF125012F70000100933221 /* Release */ = { + 8BBFF0282F987E8700E8E144 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - CODE_SIGNING_ALLOWED = NO; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 17; - DEVELOPMENT_TEAM = ZCNAX3VL9D; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.4.0; - PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI; + COPY_PHASE_STRIP = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; PRODUCT_NAME = domaindig; - SDKROOT = macosx; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.0; }; name = Release; }; @@ -469,6 +460,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 8BBFF0292F987EF700E8E144 /* Build configuration list */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8BBFF0272F987E8700E8E144 /* Debug */, + 8BBFF0282F987E8700E8E144 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 8B7800612F6090E300933221 /* Project object */; diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift index 6bf18c0..1e0333d 100644 --- a/DomainDig/BatchResultsView.swift +++ b/DomainDig/BatchResultsView.swift @@ -58,6 +58,9 @@ struct BatchResultRowView: View { .foregroundStyle(.primary) .lineLimit(1) Spacer(minLength: 8) + Text(result.resultSource.label.lowercased()) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) Text(result.quickStatus) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(quickStatusColor) @@ -84,7 +87,7 @@ struct BatchResultRowView: View { if let errorMessage = result.errorMessage { Text(errorMessage) .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.red) + .foregroundStyle(result.status == .failed ? .red : .secondary) } } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index c5fc1fa..7a3a42d 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -30,6 +30,10 @@ struct ContentView: View { } if viewModel.hasRun { actionButtons + if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) { + LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource) + .padding(.top, 8) + } SummaryView(fields: viewModel.summaryFields) .padding(.top, 8) if let changeSummary = viewModel.currentChangeSummary { @@ -494,6 +498,52 @@ struct SummaryView: View { } } +struct LookupStatusBannerView: View { + let message: String + let resultSource: LookupResultSource + + var body: some View { + HStack(spacing: 8) { + Image(systemName: iconName) + .font(.caption) + Text(message) + .font(.system(.caption, design: .monospaced)) + Spacer() + } + .foregroundStyle(color) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(color.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + private var color: Color { + switch resultSource { + case .live: + return .green + case .cached: + return .secondary + case .mixed: + return .yellow + case .snapshot: + return .orange + } + } + + private var iconName: String { + switch resultSource { + case .live: + return "bolt.horizontal" + case .cached: + return "clock.arrow.trianglehead.counterclockwise.rotate.90" + case .mixed: + return "arrow.triangle.branch" + case .snapshot: + return "archivebox" + } + } +} + struct DomainChangeSummaryView: View { let summary: DomainChangeSummary diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 71209dc..9fdf1fc 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -184,6 +184,12 @@ final class DomainViewModel { private var activeBatchDomains: [String] = [] private var lastBatchStartedAt: Date? private let reportBuilder = DomainReportBuilder() + private let inspectionService = DomainInspectionService() + private(set) var currentResultSource: LookupResultSource = .live + private(set) var currentCachedSections: [LookupSectionKind] = [] + private(set) var currentStatusMessage: String? + private(set) var currentSnapshotTimestamp = Date() + private(set) var currentHistoryEntryID: UUID? private static let recentSearchesKey = "recentSearches" private static let maxRecent = 20 @@ -355,9 +361,9 @@ final class DomainViewModel { var currentSnapshot: LookupSnapshot { LookupSnapshot( - historyEntryID: nil, + historyEntryID: currentHistoryEntryID, domain: searchedDomain, - timestamp: Date(), + timestamp: currentSnapshotTimestamp, trackedDomainID: currentTrackedDomain?.id, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, @@ -393,7 +399,9 @@ final class DomainViewModel { portScanResults: allPortScanResults, portScanError: combinedPortScanError, changeSummary: currentChangeSummary, - isLive: true + resultSource: currentResultSource, + cachedSections: currentCachedSections, + statusMessage: currentStatusMessage ) } @@ -746,51 +754,163 @@ final class DomainViewModel { } private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { - await withTaskGroup(of: Void.self) { group in - group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } - group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } - group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } - group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } - group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) } - group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) } - } + let previous = previousSnapshot( + for: domain, + trackedDomainID: currentTrackedDomain?.id, + replacingLatest: false + ) + let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } - await withTaskGroup(of: Void.self) { group in - group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } - group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) } - group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) } - group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) } + let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous) + applySnapshot(snapshot) + lastLookupDurationMs = snapshot.totalLookupDurationMs + refreshingTrackedDomainID = nil + + guard snapshot.statusMessage == nil else { + return history.first(where: { $0.id == snapshot.historyEntryID }) } - guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } + return saveHistoryEntry(replaceLatest: false) + } - let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] - let primaryIP = primaryIPAddress(from: dnsSections) + private func applySnapshot(_ snapshot: LookupSnapshot) { + currentHistoryEntryID = snapshot.historyEntryID + currentSnapshotTimestamp = snapshot.timestamp + currentResultSource = snapshot.resultSource + currentCachedSections = snapshot.cachedSections + currentStatusMessage = snapshot.statusMessage + currentChangeSummary = snapshot.changeSummary + currentDiffSections = [] + ownershipDiff = [] - await withTaskGroup(of: Void.self) { group in - group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) } - if let primaryIP { - group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) } - group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) } - } else { - group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) } - } - } + dnsSections = snapshot.dnsSections + dnsError = snapshot.dnsError + availabilityResult = snapshot.availabilityResult + suggestions = snapshot.suggestions + sslInfo = snapshot.sslInfo + sslError = snapshot.sslError + hstsPreloaded = snapshot.hstsPreloaded + httpHeaders = snapshot.httpHeaders + httpSecurityGrade = snapshot.httpSecurityGrade + httpStatusCode = snapshot.httpStatusCode + httpResponseTimeMs = snapshot.httpResponseTimeMs + httpProtocol = snapshot.httpProtocol + http3Advertised = snapshot.http3Advertised + httpHeadersError = snapshot.httpHeadersError + reachabilityResults = snapshot.reachabilityResults + reachabilityError = snapshot.reachabilityError + ipGeolocation = snapshot.ipGeolocation + ipGeolocationError = snapshot.ipGeolocationError + emailSecurity = snapshot.emailSecurity + emailSecurityError = snapshot.emailSecurityError + ownershipResult = snapshot.ownership + ownershipError = snapshot.ownershipError + ptrRecord = snapshot.ptrRecord + ptrError = snapshot.ptrError + redirectChain = snapshot.redirectChain + redirectChainError = snapshot.redirectChainError + subdomains = snapshot.subdomains + subdomainsError = snapshot.subdomainsError + portScanResults = snapshot.portScanResults.filter { $0.kind == .standard } + customPortResults = snapshot.portScanResults.filter { $0.kind == .custom } + portScanError = snapshot.portScanError + customPortScanError = nil - guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } + dnsLoading = false + availabilityLoading = false + suggestionsLoading = false + sslLoading = false + hstsLoading = false + httpHeadersLoading = false + reachabilityLoading = false + ipGeolocationLoading = false + emailSecurityLoading = false + ownershipLoading = false + ptrLoading = false + redirectChainLoading = false + subdomainsLoading = false + portScanLoading = false + customPortScanLoading = false + } - if availabilityResult?.status == .registered { - await runSuggestions(domain: domain, lookupID: lookupID) - } else { - suggestions = [] - suggestionsLoading = false + private static func resolvedSnapshotAfterFallback( + _ snapshot: LookupSnapshot, + previousSnapshot: LookupSnapshot? + ) -> LookupSnapshot { + guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else { + return snapshot + } + + return LookupSnapshot( + historyEntryID: previousSnapshot.historyEntryID, + domain: previousSnapshot.domain, + timestamp: previousSnapshot.timestamp, + trackedDomainID: previousSnapshot.trackedDomainID, + resolverDisplayName: previousSnapshot.resolverDisplayName, + resolverURLString: previousSnapshot.resolverURLString, + totalLookupDurationMs: previousSnapshot.totalLookupDurationMs, + dnsSections: previousSnapshot.dnsSections, + dnsError: previousSnapshot.dnsError, + availabilityResult: previousSnapshot.availabilityResult, + suggestions: previousSnapshot.suggestions, + sslInfo: previousSnapshot.sslInfo, + sslError: previousSnapshot.sslError, + hstsPreloaded: previousSnapshot.hstsPreloaded, + httpHeaders: previousSnapshot.httpHeaders, + httpSecurityGrade: previousSnapshot.httpSecurityGrade, + httpStatusCode: previousSnapshot.httpStatusCode, + httpResponseTimeMs: previousSnapshot.httpResponseTimeMs, + httpProtocol: previousSnapshot.httpProtocol, + http3Advertised: previousSnapshot.http3Advertised, + httpHeadersError: previousSnapshot.httpHeadersError, + reachabilityResults: previousSnapshot.reachabilityResults, + reachabilityError: previousSnapshot.reachabilityError, + ipGeolocation: previousSnapshot.ipGeolocation, + ipGeolocationError: previousSnapshot.ipGeolocationError, + emailSecurity: previousSnapshot.emailSecurity, + emailSecurityError: previousSnapshot.emailSecurityError, + ownership: previousSnapshot.ownership, + ownershipError: previousSnapshot.ownershipError, + ptrRecord: previousSnapshot.ptrRecord, + ptrError: previousSnapshot.ptrError, + redirectChain: previousSnapshot.redirectChain, + redirectChainError: previousSnapshot.redirectChainError, + subdomains: previousSnapshot.subdomains, + subdomainsError: previousSnapshot.subdomainsError, + portScanResults: previousSnapshot.portScanResults, + portScanError: previousSnapshot.portScanError, + changeSummary: previousSnapshot.changeSummary, + resultSource: .snapshot, + cachedSections: [], + statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))" + ) + } + + private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool { + let candidateMessages = [ + snapshot.dnsError, + snapshot.httpHeadersError, + snapshot.sslError, + snapshot.ownershipError, + snapshot.subdomainsError, + snapshot.redirectChainError, + snapshot.ipGeolocationError + ] + .compactMap { $0?.lowercased() } + + guard !candidateMessages.isEmpty else { return false } + let failedDueToConnectivity = candidateMessages.allSatisfy { message in + message.hasPrefix("network error:") || message.hasPrefix("timeout:") || message.hasPrefix("rate limit:") } - guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } - lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } - let entry = saveHistoryEntry(replaceLatest: false) - refreshingTrackedDomainID = nil - return entry + let hasMaterialData = !snapshot.dnsSections.isEmpty + || !snapshot.httpHeaders.isEmpty + || snapshot.sslInfo != nil + || snapshot.ownership != nil + || !snapshot.subdomains.isEmpty + + return failedDueToConnectivity && !hasMaterialData } private func runDNS(domain: String, lookupID: UUID) async { @@ -1044,11 +1164,12 @@ final class DomainViewModel { customPortScanLoading = false } - private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { + private static func performBatchLookup(domain: String, previousSnapshot: LookupSnapshot?) async -> BatchLookupPayload? { guard !Task.isCancelled else { return nil } - let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain) + let inspectionService = DomainInspectionService() + let snapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot) guard !Task.isCancelled else { return nil } - return BatchLookupPayload(snapshot: snapshot) + return BatchLookupPayload(snapshot: resolvedSnapshotAfterFallback(snapshot, previousSnapshot: previousSnapshot)) } private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] { @@ -1141,6 +1262,10 @@ final class DomainViewModel { portScanError: snapshot.portScanError ) + if updateCurrentState { + currentHistoryEntryID = entry.id + } + if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame { history[0] = entry } else { @@ -1318,6 +1443,11 @@ final class DomainViewModel { addRecentSearch(target) searchedDomain = target hasRun = true + currentHistoryEntryID = nil + currentSnapshotTimestamp = Date() + currentResultSource = .live + currentCachedSections = [] + currentStatusMessage = nil currentDiffSections = [] currentChangeSummary = nil ownershipDiff = [] @@ -1424,9 +1554,10 @@ final class DomainViewModel { refreshingTrackedDomainID = trackedDomain(for: domain)?.id } updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil) + let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false) - group.addTask { [domain] in - let payload = await Self.performBatchLookup(domain: domain) + group.addTask { [domain, previousSnapshot] in + let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot) return (domain, payload) } } @@ -1441,13 +1572,21 @@ final class DomainViewModel { status: .failed, quickStatus: "Failed", entry: nil, + resultSource: .live, errorMessage: "Lookup cancelled" ) batchCompletedCount += 1 return } - let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) + let entry: HistoryEntry? + if payload.snapshot.statusMessage == nil { + entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false) + } else { + entry = payload.snapshot.historyEntryID.flatMap { id in + history.first(where: { $0.id == id }) + } + } let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) let quickStatus: String if entry?.changeSummary?.hasChanges == true { @@ -1463,7 +1602,8 @@ final class DomainViewModel { status: .completed, quickStatus: quickStatus, entry: entry, - errorMessage: nil + resultSource: payload.snapshot.resultSource, + errorMessage: payload.snapshot.statusMessage ) batchCompletedCount += 1 } @@ -1503,6 +1643,7 @@ final class DomainViewModel { status: BatchLookupStatus, quickStatus: String, entry: HistoryEntry?, + resultSource: LookupResultSource = .live, errorMessage: String? ) { guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { @@ -1513,6 +1654,7 @@ final class DomainViewModel { id: batchResults[index].id, domain: domain, historyEntryID: entry?.id, + resultSource: resultSource, availability: entry?.availabilityResult?.status, primaryIP: entry?.primaryIP, quickStatus: quickStatus, @@ -1573,6 +1715,11 @@ final class DomainViewModel { customPortResults = [] customPortScanError = nil customPortScanLoading = false + currentHistoryEntryID = nil + currentSnapshotTimestamp = Date() + currentResultSource = .live + currentCachedSections = [] + currentStatusMessage = nil } private func setAllLoadingStates(_ loading: Bool) { @@ -1715,7 +1862,9 @@ final class DomainViewModel { portScanResults: [], portScanError: nil, changeSummary: trackedDomain.lastChangeSummary, - isLive: false + resultSource: .snapshot, + cachedSections: [], + statusMessage: nil ) } @@ -1801,7 +1950,8 @@ final class DomainViewModel { SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), - SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary) + SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), + SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)) ] } @@ -1809,7 +1959,7 @@ final class DomainViewModel { var rows = [ InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), - InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), + InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)), InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) ] rows.insert( @@ -2083,7 +2233,7 @@ final class DomainViewModel { "DomainDig Export", "Domain: \(snapshot.domain)", "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", - "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", + "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)", "Resolver: \(snapshot.resolverDisplayName)", "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))", "Tracked: \(trackedDomain == nil ? "No" : "Yes")" @@ -2417,6 +2567,23 @@ final class DomainViewModel { } } + private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone { + if snapshot.statusMessage != nil { + return .warning + } + + switch snapshot.resultSource { + case .live: + return .success + case .cached: + return .secondary + case .mixed: + return .warning + case .snapshot: + return .warning + } + } + private static func securityGradeTone(_ grade: String) -> ResultTone { switch grade { case "A", "B": diff --git a/DomainDig/LookupRuntime.swift b/DomainDig/LookupRuntime.swift new file mode 100644 index 0000000..b70a134 --- /dev/null +++ b/DomainDig/LookupRuntime.swift @@ -0,0 +1,289 @@ +import Foundation + +struct CachedLookupResult<Value> { + let value: Value + let source: LookupResultSource +} + +actor LookupRuntime { + static let shared = LookupRuntime() + + private let ttl: TimeInterval = 300 + + private enum RequestKey: Hashable { + case domain(String, LookupSectionKind) + case subject(String, LookupSectionKind) + } + + private enum RateLimitBucket: Hashable { + case crtsh + case rdap + case ipGeolocation + + var minimumSpacing: TimeInterval { + switch self { + case .crtsh: + return 1.0 + case .rdap: + return 0.75 + case .ipGeolocation: + return 0.75 + } + } + } + + private enum CachedPayload { + case dns(ServiceResult<[DNSSection]>) + case availability(DomainAvailabilityResult) + case ssl(ServiceResult<SSLCertificateInfo>) + case hsts(Bool?) + case http(ServiceResult<HTTPHeadersResult>) + case reachability(ServiceResult<[PortReachability]>) + case ownership(ServiceResult<DomainOwnership>) + case redirect(ServiceResult<[RedirectHop]>) + case subdomains(ServiceResult<[DiscoveredSubdomain]>) + case portScan(ServiceResult<[PortScanResult]>) + case email(ServiceResult<EmailSecurityResult>) + case ptr(ServiceResult<String>) + case ipGeolocation(ServiceResult<IPGeolocation>) + case suggestions([DomainSuggestionResult]) + } + + private struct CacheEntry { + let payload: CachedPayload + let expiresAt: Date + } + + private var cache: [RequestKey: CacheEntry] = [:] + private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:] + private var nextAllowedAt: [RateLimitBucket: Date] = [:] + + func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> { + await execute( + key: .domain(domain, .dns), + extract: { payload in + guard case let .dns(result) = payload else { return nil } + return result + }, + operation: { + .dns(await DNSLookupService.lookupAll(domain: domain)) + } + ) + } + + func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> { + await execute( + key: .domain(domain, .availability), + extract: { payload in + guard case let .availability(result) = payload else { return nil } + return result + }, + operation: { + .availability(await DomainAvailabilityService.check(domain: domain)) + } + ) + } + + func ssl(domain: String) async -> CachedLookupResult<ServiceResult<SSLCertificateInfo>> { + await execute( + key: .domain(domain, .ssl), + extract: { payload in + guard case let .ssl(result) = payload else { return nil } + return result + }, + operation: { + .ssl(await SSLCheckService.check(domain: domain)) + } + ) + } + + func hsts(domain: String) async -> CachedLookupResult<Bool?> { + await execute( + key: .domain(domain, .hsts), + extract: { payload in + guard case let .hsts(result) = payload else { return nil } + return result + }, + operation: { + .hsts(await SSLCheckService.checkHSTSPreload(domain: domain)) + } + ) + } + + func http(domain: String) async -> CachedLookupResult<ServiceResult<HTTPHeadersResult>> { + await execute( + key: .domain(domain, .httpHeaders), + extract: { payload in + guard case let .http(result) = payload else { return nil } + return result + }, + operation: { + .http(await HTTPHeadersService.fetch(domain: domain)) + } + ) + } + + func reachability(domain: String) async -> CachedLookupResult<ServiceResult<[PortReachability]>> { + await execute( + key: .domain(domain, .reachability), + extract: { payload in + guard case let .reachability(result) = payload else { return nil } + return result + }, + operation: { + .reachability(await ReachabilityService.checkAll(domain: domain)) + } + ) + } + + func ownership(domain: String) async -> CachedLookupResult<ServiceResult<DomainOwnership>> { + await execute( + key: .domain(domain, .ownership), + rateLimitBucket: .rdap, + extract: { payload in + guard case let .ownership(result) = payload else { return nil } + return result + }, + operation: { + .ownership(await DomainOwnershipService.lookup(domain: domain)) + } + ) + } + + func redirectChain(domain: String) async -> CachedLookupResult<ServiceResult<[RedirectHop]>> { + await execute( + key: .domain(domain, .redirectChain), + extract: { payload in + guard case let .redirect(result) = payload else { return nil } + return result + }, + operation: { + .redirect(await RedirectChainService.trace(domain: domain)) + } + ) + } + + func subdomains(domain: String) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> { + await execute( + key: .domain(domain, .subdomains), + rateLimitBucket: .crtsh, + extract: { payload in + guard case let .subdomains(result) = payload else { return nil } + return result + }, + operation: { + .subdomains(await SubdomainDiscoveryService.discover(for: domain)) + } + ) + } + + func portScan(domain: String) async -> CachedLookupResult<ServiceResult<[PortScanResult]>> { + await execute( + key: .domain(domain, .portScan), + extract: { payload in + guard case let .portScan(result) = payload else { return nil } + return result + }, + operation: { + .portScan(await PortScanService.scanAll(domain: domain)) + } + ) + } + + func email(domain: String, txtRecords: [DNSRecord]) async -> CachedLookupResult<ServiceResult<EmailSecurityResult>> { + await execute( + key: .domain(domain, .emailSecurity), + extract: { payload in + guard case let .email(result) = payload else { return nil } + return result + }, + operation: { + .email(await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)) + } + ) + } + + func ptr(ip: String, resolverURLString: String) async -> CachedLookupResult<ServiceResult<String>> { + await execute( + key: .subject("\(resolverURLString)|\(ip)", .ptr), + extract: { payload in + guard case let .ptr(result) = payload else { return nil } + return result + }, + operation: { + .ptr(await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)) + } + ) + } + + func ipGeolocation(ip: String) async -> CachedLookupResult<ServiceResult<IPGeolocation>> { + await execute( + key: .subject(ip, .ipGeolocation), + rateLimitBucket: .ipGeolocation, + extract: { payload in + guard case let .ipGeolocation(result) = payload else { return nil } + return result + }, + operation: { + .ipGeolocation(await IPGeolocationService.lookup(ip: ip)) + } + ) + } + + func suggestions(domain: String) async -> CachedLookupResult<[DomainSuggestionResult]> { + await execute( + key: .domain(domain, .suggestions), + extract: { payload in + guard case let .suggestions(result) = payload else { return nil } + return result + }, + operation: { + .suggestions(await DomainAvailabilityService.suggestions(for: domain)) + } + ) + } + + private func execute<T>( + key: RequestKey, + rateLimitBucket: RateLimitBucket? = nil, + extract: @escaping (CachedPayload) -> T?, + operation: @escaping @Sendable () async -> CachedPayload + ) async -> CachedLookupResult<T> { + if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) { + return CachedLookupResult(value: value, source: .cached) + } + + if let task = inFlight[key], let value = extract(await task.value) { + return CachedLookupResult(value: value, source: .mixed) + } + + let task = Task<CachedPayload, Never> { + if let rateLimitBucket { + await self.enforceRateLimit(for: rateLimitBucket) + } + return await operation() + } + inFlight[key] = task + + let payload = await task.value + cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl)) + inFlight[key] = nil + + guard let value = extract(payload) else { + fatalError("LookupRuntime payload extraction mismatch") + } + + return CachedLookupResult(value: value, source: .live) + } + + private func enforceRateLimit(for bucket: RateLimitBucket) async { + let now = Date() + if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now { + let delay = nextAllowed.timeIntervalSince(now) + if delay > 0 { + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing) + } +} diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index b1e5432..85136d2 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -6,6 +6,43 @@ enum ServiceResult<Value> { case error(String) } +enum LookupResultSource: String, Codable { + case live + case cached + case mixed + case snapshot + + var label: String { + switch self { + case .live: + return "Live" + case .cached: + return "Cached" + case .mixed: + return "Mixed" + case .snapshot: + return "Snapshot" + } + } +} + +enum LookupSectionKind: String, Codable, CaseIterable { + case dns + case availability + case ssl + case hsts + case httpHeaders + case reachability + case ipGeolocation + case emailSecurity + case ownership + case ptr + case redirectChain + case subdomains + case portScan + case suggestions +} + enum DomainAvailabilityStatus: String, Codable { case available case registered @@ -134,6 +171,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { let id: UUID let domain: String let historyEntryID: UUID? + let resultSource: LookupResultSource let availability: DomainAvailabilityStatus? let primaryIP: String? let quickStatus: String @@ -148,6 +186,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { id: UUID = UUID(), domain: String, historyEntryID: UUID?, + resultSource: LookupResultSource = .live, availability: DomainAvailabilityStatus?, primaryIP: String?, quickStatus: String, @@ -161,6 +200,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable { self.id = id self.domain = domain self.historyEntryID = historyEntryID + self.resultSource = resultSource self.availability = availability self.primaryIP = primaryIP self.quickStatus = quickStatus diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift index c38a140..3556624 100644 --- a/DomainDig/RDAPService.swift +++ b/DomainDig/RDAPService.swift @@ -5,7 +5,7 @@ enum RDAPService { let normalizedDomain = normalize(domain) guard !normalizedDomain.isEmpty else { return nil } - switch await cache.response(for: normalizedDomain) { + switch await fetchRDAPResponse(for: normalizedDomain) { case let .success(response): return response.isDomainRecord ? .registered : nil case .empty: @@ -21,7 +21,7 @@ enum RDAPService { return .empty("Unavailable") } - switch await cache.response(for: normalizedDomain) { + switch await fetchRDAPResponse(for: normalizedDomain) { case let .success(response): let ownership = DomainOwnership( registrar: response.registrarName, @@ -39,8 +39,6 @@ enum RDAPService { } } - private static let cache = RDAPCache() - private static func normalize(_ domain: String) -> String { domain .trimmingCharacters(in: .whitespacesAndNewlines) @@ -48,31 +46,6 @@ enum RDAPService { } } -private actor RDAPCache { - private var cachedResponses: [String: ServiceResult<RDAPDomainResponse>] = [:] - private var inFlightTasks: [String: Task<ServiceResult<RDAPDomainResponse>, Never>] = [:] - - func response(for domain: String) async -> ServiceResult<RDAPDomainResponse> { - if let cachedResponse = cachedResponses[domain] { - return cachedResponse - } - - if let inFlightTask = inFlightTasks[domain] { - return await inFlightTask.value - } - - let task = Task<ServiceResult<RDAPDomainResponse>, Never> { - await fetchRDAPResponse(for: domain) - } - inFlightTasks[domain] = task - - let result = await task.value - cachedResponses[domain] = result - inFlightTasks[domain] = nil - return result - } -} - private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { return .error("Unavailable") diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift index 57db25d..4e25cf0 100644 --- a/DomainDig/SubdomainDiscoveryService.swift +++ b/DomainDig/SubdomainDiscoveryService.swift @@ -7,55 +7,16 @@ enum SubdomainDiscoveryService { return .empty("No passive subdomains found") } - return await cache.subdomains(for: normalizedDomain, limit: limit) + return await fetchSubdomains(for: normalizedDomain, limit: limit) } - private static let cache = SubdomainDiscoveryCache() - private static func normalize(_ domain: String) -> String { domain .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() } -} - -private actor SubdomainDiscoveryCache { - private var cachedResults: [String: ServiceResult<[DiscoveredSubdomain]>] = [:] - private var inFlightTasks: [String: Task<ServiceResult<[DiscoveredSubdomain]>, Never>] = [:] - private var lastRequestAt: Date? - - func subdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { - if let cachedResult = cachedResults[domain] { - return cachedResult - } - - if let inFlightTask = inFlightTasks[domain] { - return await inFlightTask.value - } - - let task = Task<ServiceResult<[DiscoveredSubdomain]>, Never> { - await enforceRateLimit() - return await fetchSubdomains(for: domain, limit: limit) - } - inFlightTasks[domain] = task - - let result = await task.value - cachedResults[domain] = result - inFlightTasks[domain] = nil - return result - } - - private func enforceRateLimit() async { - if let lastRequestAt { - let delay = max(0, 0.75 - Date().timeIntervalSince(lastRequestAt)) - if delay > 0 { - try? await Task.sleep(for: .seconds(delay)) - } - } - lastRequestAt = Date() - } - private func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { + private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> { var components = URLComponents(string: "https://crt.sh/")! components.queryItems = [ URLQueryItem(name: "q", value: "%.\(domain)"), @@ -81,7 +42,7 @@ private actor SubdomainDiscoveryCache { } } - private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] { + private static func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] { var seen = Set<String>() var results: [DiscoveredSubdomain] = [] diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift index 73f7028..d0a4d86 100644 --- a/DomainDigCLI.swift +++ b/DomainDigCLI.swift @@ -14,19 +14,39 @@ struct DomainDigCLI { let wantsJSON = arguments.contains("--json") || arguments.contains("-j") let domains = arguments.filter { !$0.hasPrefix("-") } - guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + let requestedDomains = domains + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + guard !requestedDomains.isEmpty else { fputs("usage: domaindig <domain> [--json]\n", stderr) Foundation.exit(1) } let inspectionService = DomainInspectionService() - let report = await inspectionService.inspect(domain: domain) + var reports: [DomainReport] = [] + var seen = Set<String>() + + for domain in requestedDomains { + let normalizedDomain = domain.lowercased() + guard seen.insert(normalizedDomain).inserted else { continue } + reports.append(await inspectionService.inspect(domain: domain)) + } do { - let data = try DomainReportExporter.data( - for: report, - format: wantsJSON ? .json : .text - ) + let data: Data + if reports.count == 1, let report = reports.first { + data = try DomainReportExporter.data( + for: report, + format: wantsJSON ? .json : .text + ) + } else { + data = try DomainReportExporter.data( + for: reports, + format: wantsJSON ? .json : .text, + title: "DomainDig Batch Report" + ) + } FileHandle.standardOutput.write(data) if data.last != 0x0A { FileHandle.standardOutput.write(Data([0x0A])) diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift index 3f11290..cb9350b 100644 --- a/DomainInspectionService.swift +++ b/DomainInspectionService.swift @@ -2,89 +2,155 @@ import Foundation struct DomainInspectionService { private let reportBuilder = DomainReportBuilder() + private let runtime: LookupRuntime - func inspect(domain: String) async -> DomainReport { - let snapshot = await inspectSnapshot(domain: domain) + init(runtime: LookupRuntime = .shared) { + self.runtime = runtime + } + + func inspect(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> DomainReport { + let snapshot = await inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot) return reportBuilder.build(from: snapshot) } - func inspectSnapshot(domain: String) async -> LookupSnapshot { + func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot { let normalizedDomain = normalize(domain) let startedAt = Date() let resolverDisplayName = DNSLookupService.currentResolverDisplayName() let resolverURLString = DNSLookupService.currentResolverURLString() + var cachedSections = Set<LookupSectionKind>() + var sectionSources: [LookupResultSource] = [] + + async let dnsFetch = runtime.dns(domain: normalizedDomain) + async let availabilityFetch = runtime.availability(domain: normalizedDomain) + async let sslFetch = runtime.ssl(domain: normalizedDomain) + async let hstsFetch = runtime.hsts(domain: normalizedDomain) + async let httpFetch = runtime.http(domain: normalizedDomain) + async let reachabilityFetch = runtime.reachability(domain: normalizedDomain) + async let ownershipFetch = runtime.ownership(domain: normalizedDomain) + async let redirectFetch = runtime.redirectChain(domain: normalizedDomain) + async let subdomainFetch = runtime.subdomains(domain: normalizedDomain) + async let portScanFetch = runtime.portScan(domain: normalizedDomain) + + let resolvedDNS = await dnsFetch + let dnsResult = normalizeErrors(in: resolvedDNS.value) + track(.dns, source: resolvedDNS.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let availability = await availabilityFetch + track(.availability, source: availability.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let resolvedSSL = await sslFetch + let sslResult = normalizeErrors(in: resolvedSSL.value) + track(.ssl, source: resolvedSSL.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let hsts = await hstsFetch + track(.hsts, source: hsts.source, cachedSections: &cachedSections, sectionSources: §ionSources) - async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain) - async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain) - async let sslResult = SSLCheckService.check(domain: normalizedDomain) - async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain) - async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain) - async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain) - async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain) - async let redirectResult = RedirectChainService.trace(domain: normalizedDomain) - async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain) - async let portScanResult = PortScanService.scanAll(domain: normalizedDomain) - - let resolvedDNS = await dnsResult - let availability = await availabilityResult - let resolvedSSL = await sslResult - let hsts = await hstsResult - let http = await httpResult - let reachability = await reachabilityResult - let resolvedOwnership = await ownershipResult - let redirects = await redirectResult - let resolvedSubdomains = await subdomainResult - let ports = await portScanResult - - let dnsSections = mapServiceResult(resolvedDNS, emptyValue: []) - let sslInfo = mapOptionalValueServiceResult(resolvedSSL) - let httpHeadersResult = mapHTTPResult(http) - let reachabilityResultValue = mapServiceResult(reachability, emptyValue: []) - let redirectChain = mapServiceResult(redirects, emptyValue: []) - let ownership = mapOptionalValueServiceResult(resolvedOwnership) - let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: []) - let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain) + let http = await httpFetch + let httpResult = normalizeErrors(in: http.value) + track(.httpHeaders, source: http.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let reachability = await reachabilityFetch + let reachabilityResult = normalizeErrors(in: reachability.value) + track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let resolvedOwnership = await ownershipFetch + let ownershipResult = normalizeErrors(in: resolvedOwnership.value) + track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let redirects = await redirectFetch + let redirectResult = normalizeErrors(in: redirects.value) + track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let resolvedSubdomains = await subdomainFetch + let subdomainResult = normalizeErrors(in: resolvedSubdomains.value) + track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let ports = await portScanFetch + let portScanResult = normalizeErrors(in: ports.value) + track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: §ionSources) + + let dnsSections = mapServiceResult(dnsResult, emptyValue: []) + let sslInfo = mapOptionalValueServiceResult(sslResult) + let httpHeadersResult = mapHTTPResult(httpResult) + let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: []) + let redirectChain = mapServiceResult(redirectResult, emptyValue: []) + let ownership = mapOptionalValueServiceResult(ownershipResult) + let subdomains = mapServiceResult(subdomainResult, emptyValue: []) + let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain) let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value + let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value) + let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP) - async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords) - async let suggestions = availability.status == .registered - ? DomainAvailabilityService.suggestions(for: normalizedDomain) - : [] + let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>> + if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity { + emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached) + } else if canReuseDNSDependents, let previousSnapshot, let error = previousSnapshot.emailSecurityError { + emailOutcome = CachedLookupResult(value: .error(error), source: .cached) + } else { + emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords) + } + track(.emailSecurity, source: emailOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) - let resolvedEmail = await emailResult - let resolvedSuggestions = await suggestions + let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]> + if availability.value.status == .registered { + suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain) + track(.suggestions, source: suggestionsOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) + } else { + suggestionsOutcome = CachedLookupResult(value: [], source: .live) + } - let ptrResult: ServiceResult<String>? - let geoResult: ServiceResult<IPGeolocation>? + let ptrOutcome: CachedLookupResult<ServiceResult<String>>? + let geoOutcome: CachedLookupResult<ServiceResult<IPGeolocation>>? if let primaryIP { - ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) - geoResult = await IPGeolocationService.lookup(ip: primaryIP) + if canReuseIPDependents, let previousSnapshot, let ptrRecord = previousSnapshot.ptrRecord { + ptrOutcome = CachedLookupResult(value: .success(ptrRecord), source: .cached) + } else if canReuseIPDependents, let previousSnapshot, let ptrError = previousSnapshot.ptrError { + ptrOutcome = CachedLookupResult(value: .error(ptrError), source: .cached) + } else { + ptrOutcome = await runtime.ptr(ip: primaryIP, resolverURLString: resolverURLString) + } + + if canReuseIPDependents, let previousSnapshot, let ipGeolocation = previousSnapshot.ipGeolocation { + geoOutcome = CachedLookupResult(value: .success(ipGeolocation), source: .cached) + } else if canReuseIPDependents, let previousSnapshot, let ipGeolocationError = previousSnapshot.ipGeolocationError { + geoOutcome = CachedLookupResult(value: .error(ipGeolocationError), source: .cached) + } else { + geoOutcome = await runtime.ipGeolocation(ip: primaryIP) + } + + if let ptrOutcome { + track(.ptr, source: ptrOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) + } + if let geoOutcome { + track(.ipGeolocation, source: geoOutcome.source, cachedSections: &cachedSections, sectionSources: §ionSources) + } } else { - ptrResult = nil - geoResult = nil + ptrOutcome = nil + geoOutcome = nil } - let emailSecurity = mapOptionalValueServiceResult(resolvedEmail) - let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available") - let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available") + let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value)) + let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") + let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available") return LookupSnapshot( historyEntryID: nil, - domain: availability.domain, + domain: availability.value.domain, timestamp: Date(), - trackedDomainID: nil, + trackedDomainID: previousSnapshot?.trackedDomainID, resolverDisplayName: resolverDisplayName, resolverURLString: resolverURLString, totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), dnsSections: dnsSections.value, dnsError: dnsSections.message, - availabilityResult: availability, - suggestions: resolvedSuggestions, + availabilityResult: availability.value, + suggestions: suggestionsOutcome.value, sslInfo: sslInfo.value, sslError: sslInfo.message, - hstsPreloaded: hsts, + hstsPreloaded: hsts.value, httpHeaders: httpHeadersResult.headers, httpSecurityGrade: httpHeadersResult.securityGrade, httpStatusCode: httpHeadersResult.statusCode, @@ -109,7 +175,9 @@ struct DomainInspectionService { portScanResults: portScanResults.value, portScanError: portScanResults.message, changeSummary: nil, - isLive: false + resultSource: aggregateSource(sectionSources), + cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue }, + statusMessage: nil ) } @@ -122,6 +190,115 @@ struct DomainInspectionService { .lowercased() ?? domain.lowercased() } + private func track( + _ section: LookupSectionKind, + source: LookupResultSource, + cachedSections: inout Set<LookupSectionKind>, + sectionSources: inout [LookupResultSource] + ) { + sectionSources.append(source) + if source != .live { + cachedSections.insert(section) + } + } + + private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource { + let normalizedSources = sectionSources.map { source -> LookupResultSource in + source == .mixed ? .cached : source + } + + let hasLive = normalizedSources.contains(.live) + let hasCached = normalizedSources.contains(.cached) + + switch (hasLive, hasCached) { + case (true, true): + return .mixed + case (false, true): + return .cached + default: + return .live + } + } + + private func canReuseDependentSections(from previousSnapshot: LookupSnapshot?, dnsSections: [DNSSection]) -> Bool { + guard let previousSnapshot else { return false } + return dnsSignature(for: previousSnapshot.dnsSections) == dnsSignature(for: dnsSections) + } + + private func canReuseIPBasedSections(from previousSnapshot: LookupSnapshot?, primaryIP: String?) -> Bool { + guard let previousSnapshot else { return false } + let previousIP = previousSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value + return primaryIP == previousIP + } + + private func dnsSignature(for sections: [DNSSection]) -> String { + sections + .sorted { $0.recordType.rawValue < $1.recordType.rawValue } + .map { section in + let records = section.records + .sorted { $0.value < $1.value } + .map { "\($0.value)|\($0.ttl)" } + .joined(separator: ",") + let wildcardRecords = section.wildcardRecords + .sorted { $0.value < $1.value } + .map { "\($0.value)|\($0.ttl)" } + .joined(separator: ",") + return [ + section.recordType.rawValue, + records, + wildcardRecords, + section.dnssecSigned.map { $0 ? "signed" : "unsigned" } ?? "unknown", + section.error ?? "" + ].joined(separator: "#") + } + .joined(separator: "||") + } + + private func normalizeErrors<Value>(in result: ServiceResult<Value>) -> ServiceResult<Value> { + switch result { + case let .success(value): + return .success(value) + case let .empty(message): + return .empty(message) + case let .error(message): + return .error(classifiedMessage(from: message)) + } + } + + private func classifiedMessage(from message: String) -> String { + let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedMessage = normalizedMessage.lowercased() + + if lowercasedMessage.hasPrefix("network error:") + || lowercasedMessage.hasPrefix("timeout:") + || lowercasedMessage.hasPrefix("rate limit:") + || lowercasedMessage.hasPrefix("parsing error:") { + return normalizedMessage + } + + if lowercasedMessage.contains("timed out") { + return "Timeout: Request timed out" + } + if lowercasedMessage.contains("429") + || lowercasedMessage.contains("too many requests") + || lowercasedMessage.contains("rate limit") { + return "Rate limit: Try again shortly" + } + if lowercasedMessage.contains("cannot parse") + || lowercasedMessage.contains("decoding") + || lowercasedMessage.contains("json") { + return "Parsing error: Invalid server response" + } + if lowercasedMessage.contains("offline") + || lowercasedMessage.contains("internet connection") + || lowercasedMessage.contains("not connected") + || lowercasedMessage.contains("network connection") { + return "Network error: Offline" + } + + return "Network error: \(normalizedMessage)" + } + private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { switch result { case let .success(value): diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift index 8cb5c82..7c5a7c0 100644 --- a/DomainReportBuilder.swift +++ b/DomainReportBuilder.swift @@ -3,6 +3,7 @@ import Foundation struct DomainReport: Codable { let domain: String let timestamp: Date + let resultSource: LookupResultSource let availability: DomainAvailabilityStatus let ownership: DomainOwnership? let dns: DNSResultSummary @@ -70,6 +71,7 @@ struct DomainReportBuilder { return DomainReport( domain: snapshot.domain, timestamp: snapshot.timestamp, + resultSource: snapshot.resultSource, availability: snapshot.availabilityResult?.status ?? .unknown, ownership: snapshot.ownership, dns: DNSResultSummary( diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift index 19271fa..ecfdbd4 100644 --- a/DomainReportExporter.swift +++ b/DomainReportExporter.swift @@ -36,6 +36,7 @@ enum DomainReportExporter { "DomainDig Report", "Domain: \(report.domain)", "Timestamp: \(textDateFormatter.string(from: report.timestamp))", + "Source: \(report.resultSource.label)", "Availability: \(availabilityLabel(report.availability))" ] @@ -212,6 +213,7 @@ enum DomainReportExporter { let headers = [ "domain", "timestamp", + "result_source", "availability", "registrar", "ownership_expires", @@ -247,6 +249,7 @@ enum DomainReportExporter { return [ report.domain, csvDateFormatter.string(from: report.timestamp), + report.resultSource.rawValue, availabilityLabel(report.availability), report.ownership?.registrar ?? "", expirationDate, diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift index 3a35eff..dd1e5f4 100644 --- a/LookupSnapshot.swift +++ b/LookupSnapshot.swift @@ -39,7 +39,13 @@ struct LookupSnapshot { let portScanResults: [PortScanResult] let portScanError: String? let changeSummary: DomainChangeSummary? - let isLive: Bool + let resultSource: LookupResultSource + let cachedSections: [LookupSectionKind] + let statusMessage: String? + + var isLive: Bool { + resultSource == .live + } } extension HistoryEntry { @@ -83,7 +89,9 @@ extension HistoryEntry { portScanResults: portScanResults, portScanError: portScanError, changeSummary: changeSummary, - isLive: false + resultSource: .snapshot, + cachedSections: [], + statusMessage: nil ) } } |
