diff options
| author | Christian Cleberg <[email protected]> | 2026-04-21 23:21:19 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-21 23:21:19 -0500 |
| commit | 3846dc2f5dee69fc4ffbc052009a60e17d60e36b (patch) | |
| tree | 867dca9d45b87f318c1b368cd535c9c9fb53e29d /DomainDig | |
| parent | e73d58ee5dd43eaeaaa717f7781cc22256df30f0 (diff) | |
| download | domain-dig-3846dc2f5dee69fc4ffbc052009a60e17d60e36b.tar.gz domain-dig-3846dc2f5dee69fc4ffbc052009a60e17d60e36b.tar.bz2 domain-dig-3846dc2f5dee69fc4ffbc052009a60e17d60e36b.zip | |
feat(v2.5.0): add caching, request deduplication, and performance improvements
Diffstat (limited to 'DomainDig')
| -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 |
7 files changed, 605 insertions, 122 deletions
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] = [] |
