summaryrefslogtreecommitdiff
path: root/DomainDig
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 /DomainDig
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 'DomainDig')
-rw-r--r--DomainDig/BatchResultsView.swift5
-rw-r--r--DomainDig/ContentView.swift50
-rw-r--r--DomainDig/DomainViewModel.swift267
-rw-r--r--DomainDig/LookupRuntime.swift289
-rw-r--r--DomainDig/Models.swift40
-rw-r--r--DomainDig/RDAPService.swift31
-rw-r--r--DomainDig/SubdomainDiscoveryService.swift45
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] = []