From 3846dc2f5dee69fc4ffbc052009a60e17d60e36b Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Tue, 21 Apr 2026 23:21:19 -0500 Subject: feat(v2.5.0): add caching, request deduplication, and performance improvements --- DomainInspectionService.swift | 285 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 231 insertions(+), 54 deletions(-) (limited to 'DomainInspectionService.swift') 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() + 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> + 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? - let geoResult: ServiceResult? + let ptrOutcome: CachedLookupResult>? + let geoOutcome: CachedLookupResult>? 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, + 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(in result: ServiceResult) -> ServiceResult { + 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(_ result: ServiceResult, emptyValue: Value) -> (value: Value, message: String?) { switch result { case let .success(value): -- cgit v1.2.3