summaryrefslogtreecommitdiff
path: root/DomainInspectionService.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-21 23:21:19 -0500
committerChristian Cleberg <[email protected]>2026-04-21 23:21:19 -0500
commit3846dc2f5dee69fc4ffbc052009a60e17d60e36b (patch)
tree867dca9d45b87f318c1b368cd535c9c9fb53e29d /DomainInspectionService.swift
parente73d58ee5dd43eaeaaa717f7781cc22256df30f0 (diff)
downloaddomain-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 'DomainInspectionService.swift')
-rw-r--r--DomainInspectionService.swift285
1 files changed, 231 insertions, 54 deletions
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: &sectionSources)
+
+ let availability = await availabilityFetch
+ track(.availability, source: availability.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let resolvedSSL = await sslFetch
+ let sslResult = normalizeErrors(in: resolvedSSL.value)
+ track(.ssl, source: resolvedSSL.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let hsts = await hstsFetch
+ track(.hsts, source: hsts.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
- 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: &sectionSources)
+
+ let reachability = await reachabilityFetch
+ let reachabilityResult = normalizeErrors(in: reachability.value)
+ track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let resolvedOwnership = await ownershipFetch
+ let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
+ track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let redirects = await redirectFetch
+ let redirectResult = normalizeErrors(in: redirects.value)
+ track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let resolvedSubdomains = await subdomainFetch
+ let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
+ track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let ports = await portScanFetch
+ let portScanResult = normalizeErrors(in: ports.value)
+ track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ 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: &sectionSources)
- 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: &sectionSources)
+ } 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: &sectionSources)
+ }
+ if let geoOutcome {
+ track(.ipGeolocation, source: geoOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+ }
} 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):